Changeset 05665bc in Klonkt


Ignore:
Timestamp:
07/25/2026 06:25:15 AM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
28267519
Parents:
c1f5c23
Message:

Guardian 2: losse guardians via uitnodiging (guardian-only accounts)

Stap 1 van het uitbouwplan op /guardian2. Een familie nodigt oma uit met een
link; zij kiest naam + wachtwoord en heeft daarmee een guardian-only account:
user + minimale site met guardian_only=1. Omdat alles in Klonkt al per slug
werkt (actor, WebFinger, inbox, offers, push, deze PWA) is zij daarmee meteen
een volwaardige guardian-actor, zonder CMS eromheen.

Changed files:
src/config/database.js

  • sites.guardian_only vlag; tabel ap_guardian_invites (token, eenmalig)

src/routes/guardian2.js

  • POST /invite (link minten, ingelogd), GET/POST /join/:token (naam+wachtwoord, user+site aanmaken, sessie, redirect naar de PWA)

src/views/pages/guardian2.ejs

  • Invite a guardian-knop in de header

remarks: npm test 164/164; join met ongeldig token geeft 404. Nog te doen op v2:
guardian-only sites uit listings houden, push per account, meekijken/follow-
goedkeuring.

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

Location:
src
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    rc1f5c23 r05665bc  
    3838  // SQLite throws if the column already exists; we swallow that.
    3939  ensureColumn('sites', 'enable_audio_player', 'INTEGER DEFAULT 1');
     40  // Guardian 2: losse guardians. Een guardian-only account is user + minimale
     41  // site (alleen de actor telt); de vlag houdt CMS/listings erbuiten.
     42  ensureColumn('sites', 'guardian_only', 'INTEGER DEFAULT 0');
     43  db.exec(`CREATE TABLE IF NOT EXISTS ap_guardian_invites (
     44    token TEXT PRIMARY KEY,
     45    created_by TEXT NOT NULL,
     46    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
     47    used_by TEXT,
     48    used_at TEXT
     49  )`);
    4050  ensureColumn('sites', 'profile_photo', 'TEXT');
    4151  ensureColumn('audio_tracks', 'cover_url', 'TEXT');
  • src/routes/guardian2.js

    rc1f5c23 r05665bc  
    1010 */
    1111import express from 'express';
     12import crypto from 'crypto';
     13import bcrypt from 'bcryptjs';
    1214import path from 'path';
    1315import { fileURLToPath } from 'url';
     
    181183});
    182184
     185// ── Losse guardians (Guardian 2): uitnodigen en aansluiten ───────────────
     186// De familie nodigt oma uit; zij kiest naam + wachtwoord en heeft daarmee een
     187// guardian-only account: user + minimale site (guardian_only=1). Alles wat al
     188// per slug werkt (actor, inbox, offers, push, deze PWA) werkt dan meteen.
     189
     190router.post('/invite', requireAuth, (req, res) => {
     191  const token = crypto.randomBytes(16).toString('base64url');
     192  db.prepare('INSERT INTO ap_guardian_invites (token, created_by) VALUES (?,?)')
     193    .run(token, req.session.user.id);
     194  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     195  const url = `${base}/guardian2/join/${token}`;
     196  res.send(`<!doctype html><meta charset="utf-8"><body style="font-family:sans-serif;max-width:480px;margin:40px auto">
     197    <h2>Invite a guardian</h2>
     198    <p>Share this link. It lets one person create a guardian account here:</p>
     199    <p><a href="${url}">${url}</a></p>
     200    <p><a href="/guardian2">Back</a></p></body>`);
     201});
     202
     203function joinForm(token, error) {
     204  return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
     205  <body style="font-family:sans-serif;max-width:420px;margin:40px auto">
     206  <h2>Become a guardian</h2>
     207  <p>Watch over someone you care about. Pick a name and a password; that is all.</p>
     208  ${error ? `<p style="color:#b00">${error}</p>` : ''}
     209  <form method="post" action="/guardian2/join/${token}">
     210    <p><input name="name" placeholder="your name (grandma)" required pattern="[a-z0-9_-]{1,32}"
     211       style="width:100%;padding:10px" autocapitalize="none"></p>
     212    <p><input name="password" type="password" placeholder="password" required minlength="8"
     213       style="width:100%;padding:10px"></p>
     214    <p><button style="width:100%;padding:12px">Create my guardian account</button></p>
     215  </form></body>`;
     216}
     217
     218router.get('/join/:token', (req, res) => {
     219  const inv = db.prepare('SELECT * FROM ap_guardian_invites WHERE token = ? AND used_at IS NULL')
     220    .get(req.params.token);
     221  if (!inv) return res.status(404).send('This invite is no longer valid.');
     222  res.send(joinForm(req.params.token));
     223});
     224
     225router.post('/join/:token', express.urlencoded({ extended: false }), (req, res) => {
     226  const inv = db.prepare('SELECT * FROM ap_guardian_invites WHERE token = ? AND used_at IS NULL')
     227    .get(req.params.token);
     228  if (!inv) return res.status(404).send('This invite is no longer valid.');
     229  const name = String(req.body.name || '').trim().toLowerCase();
     230  const password = String(req.body.password || '');
     231  if (!/^[a-z0-9_-]{1,32}$/.test(name)) return res.status(400).send(joinForm(req.params.token, 'Only lowercase letters, digits, - and _.'));
     232  if (password.length < 8) return res.status(400).send(joinForm(req.params.token, 'Password: at least 8 characters.'));
     233  if (db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(name) || db.prepare('SELECT 1 FROM users WHERE username = ?').get(name)) {
     234    return res.status(409).send(joinForm(req.params.token, 'That name is taken, pick another.'));
     235  }
     236  const userId = crypto.randomUUID();
     237  db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
     238    .run(userId, name, `${name}@guardian.invalid`, bcrypt.hashSync(password, 10), 'member');
     239  db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, guardian_only) VALUES (?,?,?,?,0,1)')
     240    .run(crypto.randomUUID(), name, name, userId);
     241  db.prepare('UPDATE ap_guardian_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE token = ?')
     242    .run(userId, req.params.token);
     243  req.session.user = { id: userId, username: name, role: 'member' };
     244  res.redirect('/guardian2');
     245});
     246
    183247export default router;
  • src/views/pages/guardian2.ejs

    rc1f5c23 r05665bc  
    2626    <span class="g-me" title="<%= t('guardian.acting_as') %>">@<%= state.site %></span>
    2727    <% } %>
    28   </header>
     28    <form method="post" action="/guardian2/invite" style="display:inline">
     29    <button class="quiet small" type="submit">Invite a guardian</button>
     30  </form>
     31</header>
    2932
    3033  <main>
Note: See TracChangeset for help on using the changeset viewer.