Changeset 72ec6a4 in Klonkt


Ignore:
Timestamp:
07/31/2026 06:55:59 PM (6 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
17546af
Parents:
bfa6fa1
Message:

Hub-modus en guardian-lite eruit

Robins besluit (31-7): een instance is een eigenaar. Twee dingen weg.

HUB-MODUS was al dood: getTenancy() gaf sinds 24-6 hardcoded 'solo'
terug, dus elke tenancy === 'hub'-tak was onbereikbaar. Nu ook echt
verwijderd: getTenancy/setTenancy zelf, de /user/:slug-routing in
resolveSite, de hub-takken in admin, zoeken, audio, posts (neighbours
en related over alle sites), download, en de push-prefix. In de views
verdwijnen de hub-tagline, de hub-navigatie, de sites- en
users-tabellen (die kwamen alleen in hub-modus gevuld en verwezen nu
naar locals die niemand meer meegeeft), de eigenaar-toewijzing bij een
site, de /user/-slugprefix, het hub-brandblok en de hub-thuisknop.

GUARDIAN-LITE was de laatste multi-user-rest: /guardian/invite gaf een
link waarmee iemand via /guardian/join een echte user plus een site met
guardian_only=1 aanmaakte. Dat zette andermans wachtwoordhash, sessie
en PRIVATE actor-sleutel in jouw database, waardoor een verhuizing of
export nooit netjes kon (shaer-qw6q). Routes, formulier, kolom en
uitnodigingstabel zijn weg. Het guardian-DASHBOARD blijft: dat is
FEP-633c en werkt voor guardians met een eigen Klonkt. Bestaande
installaties houden kolom en tabel ongebruikt; nieuwe krijgen ze niet.

Changed files:
src/services/SettingsService.js

  • getTenancy/setTenancy verwijderd; kop herschreven

src/middleware/site.js, src/middleware/render.js

  • /user/:slug-routing weg; tenancy en hubTitle uit de locals

src/routes/admin.js, admin-settings.js, audio.js, download.js,
src/routes/posts.js, search.js

  • hub-takken en hub-queries weg; postNeighbors zonder isHub

src/services/ActivityPubService.js

  • pushPrefix is nu gewoon ; getTenancy-import weg

src/routes/guardian.js

  • /invite en /join verwijderd (dashboard blijft), imports opgeschoond

src/config/database.js

  • guardian_only-kolom en ap_guardian_invites-tabel niet meer aangemaakt

src/views/pages/admin.ejs, admin-users.ejs, admin-site-edit.ejs,
src/views/pages/guardian.ejs, partials/topnav.ejs, chrome.ejs, bottom-tab.ejs

  • alle hub-takken en de uitnodigingsknop weg

remarks: 320 regels weg, 63 erbij. Suite 372 groen; alle 85 templates
compileren; en met een wegwerp-kopie van de database daadwerkelijk
gedraaid en ingelogd: /, /admin, /admin/users, /admin/sites,
/admin/settings, /admin/sites/demo/edit, /admin/media en /guardian
geven alle 200 zonder fouten in het log, en /guardian/invite en
/guardian/join geven nu 404.

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

Location:
src
Files:
19 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    rbfa6fa1 r72ec6a4  
    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   )`);
     40  // (Verwijderd 31-7-2026: sites.guardian_only en ap_guardian_invites hoorden
     41  // bij de guardian-lite accounts. Bestaande installaties houden kolom en tabel
     42  // ongebruikt; nieuwe krijgen ze niet meer.)
    5043  // FEP-633c §5.3: follows targeting a ward are held pending until its
    5144  // guardians approve (Guardian 2). Gating applies only to ward-actors.
  • src/middleware/render.js

    rbfa6fa1 r72ec6a4  
    200200    canManageFedi,
    201201    apEnabled: apEnabled(),
    202     // Cirkel = the artists you feature (auto-boost). Shown when AP is on and you
    203     // auto-boost ≥1 account, or (legacy) on a circle-tenancy site.
     202    // Cirkel = the artists you feature (auto-boost): shown when AP is on and
     203    // you auto-boost at least one account.
    204204    hasCirkel: !!(_site && apEnabled() && (ActivityPubService.autoBoostCount(_site.slug) > 0 || ActivityPubService.boostedCount(_site.slug) > 0)),
    205205    isViewer: _isViewer,
     
    213213    audioTracks: data.audioTracks || res.locals.audioTracks || [],
    214214    siteUrlBase: res.locals.siteUrlBase || '',
    215     tenancy: res.locals.tenancy || 'solo',
    216     hubTitle: getSetting('hub_title') || '',
    217215    footerNewsletter: getSetting('footer_newsletter') === '1', // newsletter sign-up in footer (premium)
    218216    agendaEnabled: getSetting('agenda_enabled') === '1', // show agenda/events in the pill (premium, opt-in)
  • src/middleware/site.js

    rbfa6fa1 r72ec6a4  
    1010
    1111import db from '../config/database.js';
    12 import { getTenancy } from '../services/SettingsService.js';
    1312import { audioUrl } from '../services/AudioStreamService.js';
    1413import { audioEnabled } from '../config/features.js';
     
    2726
    2827export function resolveSite(req, res, next) {
    29   const tenancy = getTenancy();
    30   res.locals.tenancy = tenancy; // also available in views
    31 
    32   // In HUB mode /user/:slug maps to a specific site. In SOLO mode there is only
    33   // one site: we skip that routing and pin to the primary site.
    34   if (tenancy === 'hub') {
    35     // A Klonkt site is canonically reachable via /user/:slug. /sites/:slug is a
    36     // legacy alias → 301 to the canonical form so one URL scheme remains
    37     // (preserves path + query string; does NOT touch /admin/sites, which starts with /admin/).
    38     const m = req.path.match(/^\/(sites|user)\/([a-zA-Z0-9_-]+)(\/.*)?$/);
    39     if (m) {
    40       if (m[1] === 'sites') {
    41         return res.redirect(301, req.originalUrl.replace(/^\/sites\//, '/user/'));
    42       }
    43       const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(m[2]);
    44       if (site) {
    45         res.locals.site = site;
    46         req.url = (m[3] || '/'); // strip /user/:slug zodat downstream de rest ziet
    47         res.locals.siteUrlBase = `/user/${m[2]}`;
    48         return next();
    49       }
    50     }
    51     // (Removed: a dead "slug == hostname" subdomain hack. Slugs may not contain
    52     // dots, so it could never match. Real subdomain routing would match the
    53     // subdomain LABEL against the slug — a separate feature, not this.)
    54   }
    55 
    56   // Solo (or hub without a match): pin to the primary/main site.
     28  // One instance is one owner (Robins besluit, 31-7): there is one site tree,
     29  // pinned to the primary site. The /user/:slug routing that hub mode needed
     30  // is gone with it.
    5731  const defaultSite = getPrimarySite();
    5832  if (defaultSite) {
  • src/routes/admin-settings.js

    rbfa6fa1 r72ec6a4  
    11/**
    22 * Admin: global settings.
    3  *  - tenancy mode (Solo/Hub)
    43 *  - hub branding (name/tagline/intro/hero of the generic hub home page)
    54 *
     
    2019import { renderPage } from '../middleware/render.js';
    2120import { requireGod } from '../middleware/auth.js';
    22 import { getTenancy, setTenancy, getSetting, setSetting } from '../services/SettingsService.js';
     21import { getSetting, setSetting } from '../services/SettingsService.js';
    2322import { SUPPORTED } from '../services/i18n.js';
    2423import { mailerStatus, sendMail } from '../config/mailer.js';
     
    6968    pageTitleKey: 'admin.t_settings',
    7069    bodyClass: 'on-admin',
    71     tenancy: getTenancy(),
    72     hubTitle: getSetting('hub_title') || '',
    7370    hubTagline: getSetting('hub_tagline') || '',
    7471    hubIntro: getSetting('hub_intro') || '',
     
    8784router.post('/', requireGod, (req, res) => {
    8885  // multer.single processes multipart (hub branding form). For a plain
    89   // urlencoded POST (tenancy form) multer does nothing and req.body stays intact.
     86  // urlencoded POST: multer does nothing and req.body stays intact.
    9087  heroUpload.single('hub_hero_file')(req, res, (err) => {
    9188    if (err) {
     
    9390    }
    9491
    95     if (typeof req.body.tenancy !== 'undefined') {
    96       // Hub mode is removed → setTenancy only accepts solo | circle (coerces the rest).
    97       setTenancy(req.body.tenancy);
    98     }
    9992    if (typeof req.body.default_lang !== 'undefined') {
    10093      // Default language for visitors (empty = follow env/browser). Validated against NL/EN/DE.
     
    109102      if (tz) { try { Intl.DateTimeFormat('en-US', { timeZone: tz }); valid = tz; } catch { valid = ''; } }
    110103      setSetting('timezone', valid);
    111     }
    112     if (typeof req.body.hub_title !== 'undefined') {
    113       setSetting('hub_title', (req.body.hub_title || '').toString().slice(0, 80).trim());
    114104    }
    115105    if (typeof req.body.hub_tagline !== 'undefined') {
  • src/routes/admin.js

    rbfa6fa1 r72ec6a4  
    99import { renderPage } from '../middleware/render.js';
    1010import { requireAuth } from '../middleware/auth.js';
    11 import { getTenancy, apEnabled } from '../services/SettingsService.js';
     11import { apEnabled } from '../services/SettingsService.js';
    1212import { getPrimarySite } from '../middleware/site.js';
    1313
     
    1717// Solves the problem that drafts (status != published) were not findable anywhere:
    1818// the timeline shows only published posts.
    19 function sitePosts(siteId, siteSlug, tenancy, limit = 60) {
    20   const base = tenancy === 'hub' ? `/user/${siteSlug}` : '';
     19function sitePosts(siteId, siteSlug, limit = 60) {
     20  const base = '';
    2121  return db.prepare(`
    2222    SELECT slug, title, status, published_at, created_at, updated_at
     
    5353      mySite,
    5454      mine,
    55       posts: sitePosts(mySite.id, mySite.slug, 'hub'), // my-site is hub-only
     55      posts: sitePosts(mySite.id, mySite.slug),
    5656    });
    5757  }
    5858
    59   const tenancy = getTenancy();
    60 
    61   // The primary/main site — in solo THE site, in hub the main site. Provides the
    62   // "Appearance" tile with its edit link + the posts/drafts list.
     59  // THE site: it provides the "Appearance" tile with its edit link and the
     60  // posts/drafts list.
    6361  const primarySite = getPrimarySite();
    6462
     
    7270  };
    7371
    74   // Sites/users tables are only relevant in hub mode; in solo we skip the query.
    75   const sites = tenancy === 'hub' ? db.prepare(`
    76     SELECT s.slug, s.title, s.created_at, u.username AS owner_username
    77     FROM sites s
    78     LEFT JOIN users u ON u.id = s.owner_id
    79     ORDER BY s.created_at DESC
    80     LIMIT 50
    81   `).all() : [];
    82 
    83   const users = tenancy === 'hub' ? db.prepare(`
    84     SELECT username, email, role, created_at
    85     FROM users
    86     ORDER BY created_at DESC
    87     LIMIT 50
    88   `).all() : [];
    89 
    90   // Posts/drafts of the primary site (in solo = the site; in hub = the admin's
    91   // main site). Drafts are listed first so they are easy to find.
    92   const posts = primarySite ? sitePosts(primarySite.id, primarySite.slug, tenancy) : [];
     72  // Posts/drafts of the site. Drafts are listed first so they are easy to find.
     73  const posts = primarySite ? sitePosts(primarySite.id, primarySite.slug) : [];
    9374
    9475  renderPage(req, res, 'pages/admin', {
    9576    pageTitleKey: 'admin.t_admin',
    9677    bodyClass: 'on-admin',
    97     tenancy,
    98     circlesOn: apEnabled(),   // solo vs cirkels tagline (federatie aan/uit)
     78    circlesOn: apEnabled(),   // federatie aan/uit in de tagline
    9979    primarySite,
    10080    stats,
    101     sites,
    10281    posts,
    103     users,
    10482  });
    10583});
     
    11189    pageTitleKey: 'admin.t_manual',
    11290    bodyClass: 'on-admin',
    113     tenancy: getTenancy(),
    11491  });
    11592});
  • src/routes/audio.js

    rbfa6fa1 r72ec6a4  
    156156  const id = String(req.params.id || '');
    157157  if (!/^[A-Za-z0-9_-]+$/.test(id)) return res.status(400).json({ error: 'bad id' });
    158   const isHub = res.locals.tenancy === 'hub';
    159158  const row = db.prepare(`
    160159    SELECT p.slug, s.slug AS site_slug
     
    164163  `).get('%[[track:' + id + ']]%');
    165164  if (!row) return res.status(404).json({ error: 'not found' });
    166   const url = isHub ? `/user/${row.site_slug}/${row.slug}` : `/${row.slug}`;
     165  const url = `/${row.slug}`;
    167166  res.json({ url });
    168167});
  • src/routes/download.js

    rbfa6fa1 r72ec6a4  
    3434  ).get(site.id);
    3535  if (!post) return {};
    36   try { return postNeighbors(site, post, res.locals.tenancy === 'hub'); } catch (e) { return {}; }
     36  try { return postNeighbors(site, post); } catch (e) { return {}; }
    3737}
    3838const __dirname = path.dirname(fileURLToPath(import.meta.url));
  • src/routes/guardian.js

    rbfa6fa1 r72ec6a4  
    1010 */
    1111import express from 'express';
    12 import crypto from 'crypto';
    13 import bcrypt from 'bcryptjs';
    1412import path from 'path';
    1513import { fileURLToPath } from 'url';
     
    595593});
    596594
    597 // ── Losse guardians (Guardian 2): uitnodigen en aansluiten ───────────────
    598 // De familie nodigt oma uit; zij kiest naam + wachtwoord en heeft daarmee een
    599 // guardian-only account: user + minimale site (guardian_only=1). Alles wat al
    600 // per slug werkt (actor, inbox, offers, push, deze PWA) werkt dan meteen.
    601 
    602 router.post('/invite', requireAuth, (req, res) => {
    603   const token = crypto.randomBytes(16).toString('base64url');
    604   db.prepare('INSERT INTO ap_guardian_invites (token, created_by) VALUES (?,?)')
    605     .run(token, req.session.user.id);
    606   const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
    607   const url = `${base}/guardian/join/${token}`;
    608   res.send(`<!doctype html><meta charset="utf-8"><body style="font-family:sans-serif;max-width:480px;margin:40px auto">
    609     <h2>Invite a guardian</h2>
    610     <p>Share this link. It lets one person create a guardian account here:</p>
    611     <p><a href="${url}">${url}</a></p>
    612     <p><a href="/guardian">Back</a></p></body>`);
    613 });
    614 
    615 function joinForm(token, error) {
    616   return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
    617   <body style="font-family:sans-serif;max-width:420px;margin:40px auto">
    618   <h2>Become a guardian</h2>
    619   <p>Watch over someone you care about. Pick a name and a password; that is all.</p>
    620   ${error ? `<p style="color:#b00">${error}</p>` : ''}
    621   <form method="post" action="/guardian/join/${token}">
    622     <p><input name="name" placeholder="your name (grandma)" required pattern="[a-z0-9_-]{1,32}"
    623        style="width:100%;padding:10px" autocapitalize="none"></p>
    624     <p><input name="password" type="password" placeholder="password" required minlength="8"
    625        style="width:100%;padding:10px"></p>
    626     <p><button style="width:100%;padding:12px">Create my guardian account</button></p>
    627   </form></body>`;
    628 }
    629 
    630 router.get('/join/:token', (req, res) => {
    631   const inv = db.prepare('SELECT * FROM ap_guardian_invites WHERE token = ? AND used_at IS NULL')
    632     .get(req.params.token);
    633   if (!inv) return res.status(404).send('This invite is no longer valid.');
    634   res.send(joinForm(req.params.token));
    635 });
    636 
    637 router.post('/join/:token', express.urlencoded({ extended: false }), (req, res) => {
    638   const inv = db.prepare('SELECT * FROM ap_guardian_invites WHERE token = ? AND used_at IS NULL')
    639     .get(req.params.token);
    640   if (!inv) return res.status(404).send('This invite is no longer valid.');
    641   const name = String(req.body.name || '').trim().toLowerCase();
    642   const password = String(req.body.password || '');
    643   if (!/^[a-z0-9_-]{1,32}$/.test(name)) return res.status(400).send(joinForm(req.params.token, 'Only lowercase letters, digits, - and _.'));
    644   if (password.length < 8) return res.status(400).send(joinForm(req.params.token, 'Password: at least 8 characters.'));
    645   if (db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(name) || db.prepare('SELECT 1 FROM users WHERE username = ?').get(name)) {
    646     return res.status(409).send(joinForm(req.params.token, 'That name is taken, pick another.'));
    647   }
    648   const userId = crypto.randomUUID();
    649   db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
    650     .run(userId, name, `${name}@guardian.invalid`, bcrypt.hashSync(password, 10), 'member');
    651   db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, guardian_only) VALUES (?,?,?,?,0,1)')
    652     .run(crypto.randomUUID(), name, name, userId);
    653   db.prepare('UPDATE ap_guardian_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE token = ?')
    654     .run(userId, req.params.token);
    655   req.session.user = { id: userId, username: name, role: 'member' };
    656   res.redirect('/guardian');
    657 });
     595// Losse guardian-accounts (guardian-lite: /invite + /join, user + site met
     596// guardian_only=1) zijn verwijderd op 31-7-2026. Een instance is een eigenaar;
     597// zo'n account was de laatste multi-user-rest en zette bovendien andermans
     598// wachtwoordhash, sessie en PRIVATE actor-sleutel in jouw database, wat een
     599// verhuizing (shaer-qw6q) onmogelijk netjes maakte. Een guardian hoort een
     600// eigen Klonkt te hebben; de adoptie loopt dan gewoon over de federatie.
    658601
    659602export default router;
  • src/routes/posts.js

    rbfa6fa1 r72ec6a4  
    763763}
    764764
    765 function postNeighbors(site, post, isHub) {
    766   const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : '';
    767   const ordered = isHub
    768     ? db.prepare(`
    769         SELECT p.id, p.slug, p.title, p.pinned, s.slug AS site_slug
    770         FROM posts p JOIN sites s ON s.id = p.site_id
    771         WHERE p.status = 'published'
    772         ORDER BY p.published_at DESC
    773       `).all()
    774     : db.prepare(`
    775         SELECT id, slug, title, pinned FROM posts
    776         WHERE site_id = ? AND status = 'published'
    777         ORDER BY (pinned = 0) ASC, pinned ASC, published_at DESC
    778       `).all(site.id);
     765function postNeighbors(site, post) {
     766  const ordered = db.prepare(`
     767    SELECT id, slug, title, pinned FROM posts
     768    WHERE site_id = ? AND status = 'published'
     769    ORDER BY (pinned = 0) ASC, pinned ASC, published_at DESC
     770  `).all(site.id);
    779771  const idx = ordered.findIndex((p) => p.id === post.id);
    780772  const newerPost = idx > 0 ? ordered[idx - 1] : null;
    781773  const olderPost = (idx >= 0 && idx < ordered.length - 1) ? ordered[idx + 1] : null;
    782   if (newerPost) newerPost._urlBase = urlBaseFor(newerPost);
    783   if (olderPost) olderPost._urlBase = urlBaseFor(olderPost);
     774  if (newerPost) newerPost._urlBase = '';
     775  if (olderPost) olderPost._urlBase = '';
    784776  return { newerPost, olderPost };
    785777}
     
    13441336  const _unlocked = _u && _u.purpose === 'unlocked' && _u.siteId === site.id && String(_u.post) === String(post.slug);
    13451337  if (post.paid && !canEditThis && !_unlocked) {
    1346     const { newerPost, olderPost } = postNeighbors(site, post, res.locals.tenancy === 'hub');
     1338    const { newerPost, olderPost } = postNeighbors(site, post);
    13471339    return renderPage(req, res, 'pages/paid-gate', {
    13481340      pageTitle: post.title || 'Voor supporters',
     
    13641356    // Same Newer/Older navigation as on a normal post, so the visitor doesn't get
    13651357    // stuck on the fan gate but can keep browsing.
    1366     const { newerPost, olderPost } = postNeighbors(site, post, res.locals.tenancy === 'hub');
     1358    const { newerPost, olderPost } = postNeighbors(site, post);
    13671359    return renderPage(req, res, 'pages/fan-gate', {
    13681360      pageTitle: post.title || 'Alleen voor fans',
     
    13961388  // Prev / next chronological (kept for back-compat — "post-nav" feature
    13971389  // below the article still uses these as a simple linear navigation).
    1398   // Hub mode: Related posts + Newer/Older pull from ALL users (all sites),
    1399   // newest first. Solo mode: within the current site (old behaviour).
    1400   const isHub = res.locals.tenancy === 'hub';
    1401   // Per-post URL base: in hub a link points to /user/<site-slug>/<post-slug>.
    1402   const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : '';
     1390  const urlBaseFor = () => '';
    14031391
    14041392  // Newer/Older across ALL posts (shared helper — also used by the fan gate).
    1405   const { newerPost, olderPost } = postNeighbors(site, post, isHub);
     1393  const { newerPost, olderPost } = postNeighbors(site, post);
    14061394
    14071395  // ── Related posts: same-tag matching with recency fallback ─────
    14081396  // Fetch ~50 candidates, score by tag overlap, take top 3.
    14091397  // Excluding self via `id != ?`.
    1410   const candidates = isHub
    1411     ? db.prepare(`
    1412         SELECT p.id, p.slug, p.title, p.cover_image_url, p.cover_video_url, p.published_at, p.tags, p.nsfw, p.content_warning, s.slug AS site_slug
    1413         FROM posts p JOIN sites s ON s.id = p.site_id
    1414         WHERE p.status = 'published' AND p.id != ?
    1415         ORDER BY p.published_at DESC LIMIT 50
    1416       `).all(post.id)
    1417     : db.prepare(`
    1418         SELECT id, slug, title, cover_image_url, cover_video_url, published_at, tags, nsfw, content_warning
    1419         FROM posts
    1420         WHERE site_id = ? AND status = 'published' AND id != ?
    1421         ORDER BY published_at DESC LIMIT 50
    1422       `).all(site.id, post.id);
     1398  const candidates = db.prepare(`
     1399    SELECT id, slug, title, cover_image_url, cover_video_url, published_at, tags, nsfw, content_warning
     1400    FROM posts
     1401    WHERE site_id = ? AND status = 'published' AND id != ?
     1402    ORDER BY published_at DESC LIMIT 50
     1403  `).all(site.id, post.id);
    14231404
    14241405  // Parse tags JSON safely; missing/malformed → empty array.
  • src/routes/search.js

    rbfa6fa1 r72ec6a4  
    5151function searchSite(req, res, rawQ, lim) {
    5252  const site = res.locals.site;
    53   const isHub = res.locals.tenancy === 'hub';
    5453  const base = res.locals.siteUrlBase || '';
    55   const urlFor = (slug) => (isHub ? `/user/${site.slug}/${slug}` : `/${slug}`);
     54  const urlFor = (slug) => `/${slug}`;
    5655  // Eigen vertaler (werkt ook in de JSON-route, waar res.locals.t niet bestaat).
    5756  const lang = resolveLang(req);
     
    172171  if (rawQ.length < 2) return res.json({ posts: [], tracks: [], events: [], pages: [] });
    173172
    174   const isHub = res.locals.tenancy === 'hub';
    175   const urlFor = (slug) => (isHub ? `/user/${site.slug}/${slug}` : `/${slug}`);
     173  const urlFor = (slug) => `/${slug}`;
    176174  const r = searchSite(req, res, rawQ, { posts: 5, tracks: 4, events: 3, pages: 4 });
    177175  res.json({
  • src/services/ActivityPubService.js

    rbfa6fa1 r72ec6a4  
    2626import EmbedResolver from './EmbedResolver.js';
    2727import Push from './PushService.js';
    28 import { getTenancy } from './SettingsService.js';
    2928import { t as i18nT } from './i18n.js';
    3029import Blocklist from './BlocklistService.js';
     
    14181417  for (const cb of cbs) { try { cb(); } catch { /* a waiter must never break the rest */ } }
    14191418}
    1420 // Hub-aware path prefix for a site's pages ('' in solo).
    1421 function pushPrefix(slug) {
    1422   try { return getTenancy() === 'hub' ? `/user/${slug}` : ''; } catch { return ''; }
    1423 }
     1419// Path prefix for a site's pages. One instance is one owner, so the site
     1420// lives at the root; kept as a function because the push URLs read like
     1421// `${pushPrefix(slug)}/messages` all over this file.
     1422function pushPrefix() { return ''; }
    14241423// Notification language: the site's content language (fallback: instance default).
    14251424function pushLang(slug) {
  • src/services/SettingsService.js

    rbfa6fa1 r72ec6a4  
    1 // Global app settings (key/value, cached). Primarily used for the tenancy mode.
     1// Global app settings (key/value, cached).
    22//
    3 //   tenancy = 'solo'   -> exactly one site (the primary/owner site)
    4 //   tenancy = 'circle' -> solo site that federates with other solo Klonkt sites
    5 //
    6 // HUB MODE IS REMOVED (2026-06-24): multi-artist-per-domain was dropped in favour
    7 // of solo + Cirkels. getTenancy() coerces any legacy 'hub' value to 'solo' so all
    8 // the old `tenancy === 'hub'` branches are unreachable; the hub code is being
    9 // deleted incrementally.
     3// One instance is one owner (Robins besluit, 31-7-2026). The old tenancy modes
     4// (hub = many artists on one domain, circle) are gone, code and all: the
     5// branches were already unreachable and have now been deleted.
    106//
    117// The cache is updated immediately on setSetting, so a toggle in admin
     
    3935}
    4036
    41 export function getTenancy() {
    42   // Tenancy is retired: 'hub' and 'circle' were both removed. Every site is
    43   // 'solo'. Cirkels are now an ActivityPub feature (auto-boost), not a mode.
    44   return 'solo';
    45 }
    46 
    47 export function setTenancy() {
    48   setSetting('tenancy', 'solo');
    49 }
    50 
    5137// ActivityPub / fediverse federation. ON by default. '0' = off: the site does
    5238// not federate, /ap/* is gone, and the "from the fediverse" reactions disappear
  • src/views/pages/admin-site-edit.ejs

    rbfa6fa1 r72ec6a4  
    1414        <span class="slug-url">
    1515          <span class="slug-url-host" id="slug-host"
    16                 data-prefix="<%= (typeof tenancy !== 'undefined' && tenancy === 'hub') ? '/user/' : '/' %>">website.com/</span><input
     16                data-prefix="/">website.com/</span><input
    1717                type="text" name="slug" id="slug-input" class="slug-url-input" value="<%= site.slug %>" required <% if (!isNew) { %>readonly<% } %>
    1818                pattern="[a-z0-9_-]{2,40}" placeholder="<%= t('asite.slug_placeholder') %>"
     
    2424        <input type="text" name="title" value="<%= site.title || '' %>" required maxlength="200">
    2525      </label>
    26       <%# Assign (or reassign) owner — ONLY god AND only in hub mode. In hub this
    27           gives each user their own self-managed Klonkt. In solo (one site,
    28           one owner) this is pointless/confusing, so it's hidden. %>
    29       <% if (user && user.role === 'god' && (typeof tenancy !== 'undefined' && tenancy === 'hub')) { %>
    30       <label>
    31         <span><%= t('asite.owner') %> <small class="form-hint-inline"><%= t('asite.owner_hint') %></small></span>
    32         <% var _godSuffix = t('asite.owner_god_suffix'); %>
    33         <select name="owner_id">
    34           <% (typeof users !== 'undefined' ? users : []).forEach(function(u){ %>
    35             <option value="<%= u.id %>" <%= (site.owner_id === u.id) ? 'selected' : '' %>><%= u.username %><%= u.role === 'god' ? _godSuffix : '' %></option>
    36           <% }); %>
    37         </select>
    38       </label>
    39       <% } %>
    4026      <label>
    4127        <span><%= t('asite.tagline') %> <small class="form-hint-inline"><%= t('asite.tagline_hint') %></small></span>
  • src/views/pages/admin-users.ejs

    rbfa6fa1 r72ec6a4  
    7171
    7272          <div class="ax-user-controls">
    73             <%# Hub: give this user their own self-managed Klonkt — opens the
    74                 new-site form with this user already pre-selected as owner. %>
    75             <% if (typeof tenancy !== 'undefined' && tenancy === 'hub' && canMutate) { %>
    76               <a href="/admin/sites/new?owner=<%= u.id %>" class="ax-icon-btn"
    77                  aria-label="<%= t('ausr.new_klonkt_for', { name: u.username }) %>" title="<%= _u_newklonkt %>">+🌐</a>
    78             <% } %>
    7973            <form method="post" action="/admin/users/<%= u.id %>/role" class="ax-role-form">
    8074              <label class="ax-role">
  • src/views/pages/admin.ejs

    rbfa6fa1 r72ec6a4  
    1717    .prem-badge:hover{filter:brightness(1.08)}
    1818  </style>
    19   <% if (tenancy === 'hub') { %>
    20     <p class="admin-tagline"><%= t('admin.tagline_hub') %></p>
    21   <% } else if (typeof circlesOn !== 'undefined' && circlesOn) { %>
     19  <% if (typeof circlesOn !== 'undefined' && circlesOn) { %>
    2220    <p class="admin-tagline"><%= t('admin.tagline_cirkels') %></p>
    2321  <% } else { %>
     
    2624
    2725  <nav class="admin-quick-links" aria-label="<%= t('nav.admin') %>">
    28     <% if (tenancy === 'hub') { %>
    29       <a href="/admin/sites" class="btn"><%= t('admin.b_sites') %></a>
    30       <a href="/admin/users" class="btn"><%= t('admin.b_users') %></a>
    31       <a href="/admin/media" class="btn"><%= t('admin.b_media') %></a>
    32       <% if (typeof audioEnabled === 'undefined' || audioEnabled) { %>
    33       <a href="/admin/playlists" class="btn"><%= t('admin.b_playlists') %></a>
    34       <% } %>
    35       <% if (primarySite) { %><a href="/admin/seo" class="btn"><%= t('admin.b_seo') %></a><% } %>
    36       <a href="/admin/settings" class="btn"><%= t('admin.b_settings') %></a>
    37     <% } else { %>
    3826      <a href="/posts/new" class="btn"><%= t('admin.b_newpost') %></a>
    3927      <a href="/admin/media" class="btn"><%= t('admin.b_media') %></a>
     
    4533      <% if (primarySite) { %><a href="/admin/seo" class="btn"><%= t('admin.b_seo') %></a><% } %>
    4634      <a href="/admin/settings" class="btn"><%= t('admin.b_settings') %></a>
    47     <% } %>
    4835    <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
    4936      <a href="/admin/stats" class="btn"><%= t('admin.b_stats') %></a>
    5037      <a href="/admin/paid" class="btn"><%= t('admin.b_paid') %></a>
    5138      <a href="/admin/newsletter" class="btn"><%= t('admin.b_newsletter') %></a>
    52       <% if (tenancy !== 'hub' && primarySite) { %><a href="/pers" class="btn" target="_blank"><%= t('admin.b_perskit') %></a><% } %>
    53       <% if (tenancy !== 'hub' && primarySite) { %><a href="/downloads" class="btn" target="_blank"><%= t('admin.b_downloads') %></a><% } %>
    54       <% if (tenancy !== 'hub' && primarySite) { %><a href="/links" class="btn" target="_blank"><%= t('admin.b_linkbio') %></a><% } %>
     39      <% if (primarySite) { %><a href="/pers" class="btn" target="_blank"><%= t('admin.b_perskit') %></a><% } %>
     40      <% if (primarySite) { %><a href="/downloads" class="btn" target="_blank"><%= t('admin.b_downloads') %></a><% } %>
     41      <% if (primarySite) { %><a href="/links" class="btn" target="_blank"><%= t('admin.b_linkbio') %></a><% } %>
    5542      <a href="/admin/shows" class="btn"><%= t('admin.b_agenda') %></a>
    5643    <% } %>
     
    6350
    6451  <div class="admin-stats">
    65     <% if (tenancy === 'hub') { %>
    66       <div class="stat-card"><div class="stat-num"><%= stats.users %></div><div class="stat-label"><%= t('admin.st_users') %></div></div>
    67       <div class="stat-card"><div class="stat-num"><%= stats.sites %></div><div class="stat-label"><%= t('admin.st_sites') %></div></div>
    68     <% } %>
    6952    <div class="stat-card"><div class="stat-num"><%= stats.posts %></div><div class="stat-label"><%= t('admin.st_posts') %></div></div>
    7053    <div class="stat-card"><div class="stat-num"><%= stats.published %></div><div class="stat-label"><%= t('admin.st_published') %></div></div>
     
    9073  <% } %>
    9174
    92   <% if (sites && sites.length) { %>
    93     <h2><%= t('admin.sec_sites') %></h2>
    94     <table class="admin-table">
    95       <thead>
    96         <tr><th><%= t('admin.th_slug') %></th><th><%= t('admin.th_title') %></th><th><%= t('admin.th_owner') %></th><th><%= t('admin.th_created') %></th></tr>
    97       </thead>
    98       <tbody>
    99         <% sites.forEach(function(s) { %>
    100           <tr>
    101             <td><code><%= s.slug %></code></td>
    102             <td><%= s.title %></td>
    103             <td><%= s.owner_username || '—' %></td>
    104             <td><%= formatDate(s.created_at) %></td>
    105           </tr>
    106         <% }); %>
    107       </tbody>
    108     </table>
    109   <% } %>
    110 
    111   <% if (users && users.length) { %>
    112     <h2><%= t('admin.sec_users') %></h2>
    113     <table class="admin-table">
    114       <thead>
    115         <tr><th><%= t('admin.th_username') %></th><th><%= t('admin.th_email') %></th><th><%= t('admin.th_role') %></th><th><%= t('admin.th_joined') %></th></tr>
    116       </thead>
    117       <tbody>
    118         <% users.forEach(function(u) { %>
    119           <tr>
    120             <td><%= u.username %></td>
    121             <td><%= u.email %></td>
    122             <td><span class="role-pill role-<%= u.role %>"><%= u.role %></span></td>
    123             <td><%= formatDate(u.created_at) %></td>
    124           </tr>
    125         <% }); %>
    126       </tbody>
    127     </table>
    128   <% } %>
    129 
    13075</div>
    13176
  • src/views/pages/guardian.ejs

    rbfa6fa1 r72ec6a4  
    2626    <span class="g-me" title="<%= t('guardian.acting_as') %>">@<%= state.site %></span>
    2727    <% } %>
    28     <form method="post" action="/guardian/invite" style="display:inline">
    29     <button class="quiet small" type="submit">Invite a guardian</button>
    30   </form>
    3128</header>
    3229
  • src/views/partials/bottom-tab.ejs

    rbfa6fa1 r72ec6a4  
    1313                        && permissions && permissions.canCreatePost && permissions.canCreatePost(user, site));
    1414const _ownProfile  = user ? ('/users/' + user.username) : null;
    15 const _isHub       = (typeof tenancy !== 'undefined' && tenancy === 'hub');
    16 // Home in hub mode ALWAYS goes to the hub root (/), not to an artist's sub-home.
    17 // In solo/circle _siteUrlBase is empty, so it stays '/'.
    18 const _homeHref    = (_isHub ? '' : _siteUrlBase) + '/';
     15const _homeHref    = _siteUrlBase + '/';
    1916
    2017// Strip site prefix for cleaner matching
     
    2623
    2724let _active = null;
    28 // In hub, Home is only active on the real hub root, not on an artist sub-home.
    29 if (_isHub ? (_path === '/') : (_p === '/' || _p === '')) _active = 'home';
     25if (_p === '/' || _p === '') _active = 'home';
    3026else if (_p.indexOf('/search') === 0 || _p.indexOf('/tag/') === 0 || _p.indexOf('/type/') === 0) _active = 'search';
    3127else if (_p === '/posts/new' || /^\/posts\/[^/]+\/edit$/.test(_p)) _active = 'create';
  • src/views/partials/chrome.ejs

    rbfa6fa1 r72ec6a4  
    1717var _isAuth    = typeof bodyClass === 'string' && bodyClass.indexOf('on-auth') >= 0;
    1818var _isChat    = typeof bodyClass === 'string' && bodyClass.indexOf('on-chat') >= 0;
    19 var hubLanding = (typeof bodyClass === 'string' && bodyClass.indexOf('on-hub') >= 0)
    20    || ((typeof tenancy !== 'undefined' && tenancy === 'hub') && !siteUrlBase && !_isAdmin);
     19var hubLanding = (typeof bodyClass === 'string' && bodyClass.indexOf('on-hub') >= 0);
    2120// Zelfstandige premium-feature-pagina's hebben hun eigen kop → geen profielkop/
    2221// switcher, wél de topnav (Robin 2026-06-18: "bovenste weg, behoud de nav").
  • src/views/partials/topnav.ejs

    rbfa6fa1 r72ec6a4  
    2828      <% if (_isAdmin || _isSpecial) { %>
    2929        <a href="<%= _siteUrlBase %>/" class="nav-link nav-link-admin"><%= t('nav.back_to_site') %></a>
    30       <% } else if (typeof tenancy !== 'undefined' && tenancy === 'hub') { %>
    31         <%# Hub: the brand navigates to the hub home via HTMX. The chrome is always
    32             rendered in hub mode and hidden on the landing via body.on-hub (CSS), so htmx
    33             can switch without a full reload → the audio player stays alive. %>
    34         <a class="nav-hub-home" href="/" aria-label="Home" title="Home"
    35            hx-get="/?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="/" hx-indicator="#pcms-loading">
    36           <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
    37         </a>
    38       <% } else if (site) { %>
    39         <a class="site-title" href="<%= _siteUrlBase %>/"
    40            hx-get="<%= _siteUrlBase %>/?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
    41            hx-push-url="<%= _siteUrlBase %>/" hx-indicator="#pcms-loading"><%= site.title %></a>
    4230      <% } else { %>
    4331        <a class="site-title" href="/">Klonkt</a>
Note: See TracChangeset for help on using the changeset viewer.