Changeset 8cb1dc7 in Klonkt


Ignore:
Timestamp:
06/14/2026 04:17:14 PM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
640b39c
Parents:
ccbd6a6
Message:

hub: artist self-management — owner manages their own subsite (posts/appearance/comments)

A site owner (member) now gets a "My PrutFolio" dashboard and can manage their
OWN site; cross-site access is restricted.

  • auth.js: requireSiteManager (owner of res.locals.site or god) + requireSiteManagerBySlug (owner of :slug or god).
  • admin.js: /admin role-aware -> god=hub management, owner=my-site dashboard, member without site=403.
  • admin-sites.js: /:slug/edit + /save owner-or-god (was god-only); upload-photo requireAuth. list/new/create/delete remain god-only.
  • admin-comments.js: requireSiteManager + redirects/form actions via siteUrlBase so the artist context (/user/<slug>/admin/comments) is correct.
  • render.js: userOwnsSite flag; topnav + overview show "Admin" for owners; topnav "New post" uses siteUrlBase.
  • pages/my-site.ejs: artist dashboard.

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

Location:
src
Files:
1 added
8 edited

Legend:

Unmodified
Added
Removed
  • src/middleware/auth.js

    rccbd6a6 r8cb1dc7  
    22 * Auth middleware
    33 */
     4
     5import db from '../config/database.js';
    46
    57/**
     
    4244  next();
    4345}
     46
     47// Mag de ingelogde user de HUIDIGE site (res.locals.site) beheren? god altijd;
     48// anders alleen de owner van die site. Gebruikt voor site-gescopete beheerroutes
     49// die een artiest via /user/<eigen-slug>/admin/... bereikt (res.locals.site is dan
     50// z'n eigen site; een vreemde slug levert een andere site -> 403).
     51export function requireSiteManager(req, res, next) {
     52  if (!req.session?.user) return loginRedirect(req, res);
     53  const u = req.session.user;
     54  if (u.role === 'god') return next();
     55  const site = res.locals.site;
     56  if (site && site.owner_id === u.id) return next();
     57  return res.status(403).send('Geen toegang tot deze site.');
     58}
     59
     60// Idem, maar de site wordt bepaald door de :slug-parameter (bv. site-edit).
     61export function requireSiteManagerBySlug(req, res, next) {
     62  if (!req.session?.user) return loginRedirect(req, res);
     63  const u = req.session.user;
     64  if (u.role === 'god') return next();
     65  const site = db.prepare('SELECT owner_id FROM sites WHERE slug = ?').get(req.params.slug);
     66  if (site && site.owner_id === u.id) return next();
     67  return res.status(403).send('Geen toegang tot deze site.');
     68}
  • src/middleware/render.js

    rccbd6a6 r8cb1dc7  
    1313import { fileURLToPath } from 'url';
    1414import ejs from 'ejs';
     15import db from '../config/database.js';
    1516import PermissionsService from '../services/PermissionsService.js';
    1617import { PLATFORMS as PLATFORMS_CATALOG } from '../services/PlatformIcons.js';
     
    3637  const isPartial = req.headers['hx-request'] === 'true' || req.query.partial === '1';
    3738
     39  // Bezit deze (niet-god) user een eigen site? Bepaalt of 'ie een "Beheer"-
     40  // ingang ziet (artiest-zelfbeheer). god ziet beheer sowieso (op rol).
     41  const _u = req.session?.user || null;
     42  const userOwnsSite = !!(_u && _u.role !== 'god' &&
     43    db.prepare('SELECT 1 FROM sites WHERE owner_id = ? LIMIT 1').get(_u.id));
     44
    3845  // Common locals
    3946  const locals = {
    40     user: req.session?.user || null,
     47    user: _u,
     48    userOwnsSite,
    4149    site: data.site || res.locals.site || null,
    4250    audioTracks: data.audioTracks || res.locals.audioTracks || [],
  • src/routes/admin-comments.js

    rccbd6a6 r8cb1dc7  
    1515import db from '../config/database.js';
    1616import { renderPage } from '../middleware/render.js';
    17 import { requireGod } from '../middleware/auth.js';
     17import { requireSiteManager } from '../middleware/auth.js';
    1818
    1919const router = express.Router();
    2020
    21 router.get('/', requireGod, (req, res) => {
     21router.get('/', requireSiteManager, (req, res) => {
    2222  const site = res.locals.site;
    2323  if (!site) return res.status(404).send('No site');
     
    6161  const site = res.locals.site;
    6262  if (!site) return res.status(404).send('No site');
     63  const base = res.locals.siteUrlBase || ''; // /user/<slug> in hub-artiestcontext, anders ''
    6364
    6465  const row = db.prepare(`
     
    6768  `).get(req.params.id, site.id);
    6869
    69   if (!row) return res.redirect('/admin/comments?error=Not+found');
     70  if (!row) return res.redirect(base + '/admin/comments?error=Not+found');
    7071
    7172  db.prepare(
    7273    'UPDATE comments SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
    7374  ).run(status, req.params.id);
    74   res.redirect('/admin/comments?success=' + encodeURIComponent('Comment ' + status));
     75  res.redirect(base + '/admin/comments?success=' + encodeURIComponent('Comment ' + status));
    7576}
    7677
    77 router.post('/:id/approve', requireGod, (req, res) => setStatus(req, res, 'approved'));
    78 router.post('/:id/reject',  requireGod, (req, res) => setStatus(req, res, 'rejected'));
     78router.post('/:id/approve', requireSiteManager, (req, res) => setStatus(req, res, 'approved'));
     79router.post('/:id/reject',  requireSiteManager, (req, res) => setStatus(req, res, 'rejected'));
    7980
    8081export default router;
  • src/routes/admin-sites.js

    rccbd6a6 r8cb1dc7  
    2222import db from '../config/database.js';
    2323import { renderPage } from '../middleware/render.js';
    24 import { requireGod } from '../middleware/auth.js';
     24import { requireGod, requireAuth, requireSiteManagerBySlug } from '../middleware/auth.js';
    2525import ThemeService from '../services/ThemeService.js';
    2626import { listPlatforms, PLATFORMS } from '../services/PlatformIcons.js';
     
    8282// holds the URL string in `profile_photo` — this endpoint just stores the
    8383// file and hands back a URL that the form can paste into the input field.
    84 router.post('/upload-photo', requireGod, (req, res) => {
     84router.post('/upload-photo', requireAuth, (req, res) => {
    8585  photoUpload.single('photo')(req, res, (err) => {
    8686    if (err) return res.status(400).json({ ok: false, error: err.message });
     
    218218
    219219// ==================== EDIT (form) ====================
    220 router.get('/:slug/edit', requireGod, (req, res) => {
     220router.get('/:slug/edit', requireSiteManagerBySlug, (req, res) => {
    221221  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(req.params.slug);
    222222  if (!site) return res.redirect('/admin/sites?error=Not+found');
     
    242242
    243243// ==================== SAVE ====================
    244 router.post('/:slug/save', requireGod, (req, res) => {
     244router.post('/:slug/save', requireSiteManagerBySlug, (req, res) => {
    245245  const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
    246246  if (!site) return res.redirect('/admin/sites?error=Not+found');
  • src/routes/admin.js

    rccbd6a6 r8cb1dc7  
    88import db from '../config/database.js';
    99import { renderPage } from '../middleware/render.js';
    10 import { requireGod } from '../middleware/auth.js';
     10import { requireAuth } from '../middleware/auth.js';
    1111import { getTenancy } from '../services/SettingsService.js';
    1212
    1313const router = express.Router();
    1414
    15 router.get('/', requireGod, (req, res) => {
     15router.get('/', requireAuth, (req, res) => {
     16  const user = req.session.user;
     17
     18  // Niet-god: een artiest die een eigen site bezit krijgt een "Mijn PrutFolio"-
     19  // dashboard, gescopet op z'n eigen site. Bezit 'ie geen site -> geen beheer.
     20  if (user.role !== 'god') {
     21    const mySite = db.prepare(
     22      'SELECT * FROM sites WHERE owner_id = ? ORDER BY created_at ASC LIMIT 1'
     23    ).get(user.id);
     24    if (!mySite) return res.status(403).send('Geen beheer beschikbaar voor dit account.');
     25
     26    const mine = {
     27      posts: db.prepare("SELECT COUNT(*) AS c FROM posts WHERE site_id = ?").get(mySite.id).c,
     28      published: db.prepare("SELECT COUNT(*) AS c FROM posts WHERE site_id = ? AND status = 'published'").get(mySite.id).c,
     29    };
     30    return renderPage(req, res, 'pages/my-site', {
     31      pageTitle: 'Mijn PrutFolio',
     32      bodyClass: 'on-admin',
     33      mySite,
     34      mine,
     35    });
     36  }
     37
    1638  const tenancy = getTenancy();
    1739
  • src/views/pages/admin-comments.ejs

    rccbd6a6 r8cb1dc7  
    2626            <strong><%= c.author_username %></strong>
    2727            <% if (c.parent_comment_id) { %><span class="muted">(reply)</span><% } %>
    28             on <a href="/<%= c.post_slug %>"><%= c.post_title || c.post_slug %></a>
     28            on <a href="<%= siteUrlBase %>/<%= c.post_slug %>"><%= c.post_title || c.post_slug %></a>
    2929            <span class="muted">&middot; <%= formatDateTime(c.created_at) %></span>
    3030          </div>
    3131          <blockquote class="moderation-content"><%= c.content %></blockquote>
    3232          <div class="moderation-actions">
    33             <form method="post" action="/admin/comments/<%= c.id %>/approve" style="display:inline">
     33            <form method="post" action="<%= siteUrlBase %>/admin/comments/<%= c.id %>/approve" style="display:inline">
    3434              <button type="submit" class="btn btn-primary">Approve</button>
    3535            </form>
    36             <form method="post" action="/admin/comments/<%= c.id %>/reject" style="display:inline">
     36            <form method="post" action="<%= siteUrlBase %>/admin/comments/<%= c.id %>/reject" style="display:inline">
    3737              <button type="submit" class="btn btn-danger">Reject</button>
    3838            </form>
     
    5555          <tr class="status-<%= c.status %>">
    5656            <td><%= c.author_username %></td>
    57             <td><a href="/<%= c.post_slug %>"><%= c.post_title || c.post_slug %></a></td>
     57            <td><a href="<%= siteUrlBase %>/<%= c.post_slug %>"><%= c.post_title || c.post_slug %></a></td>
    5858            <td>
    5959              <span class="status-pill status-pill--<%= c.status %>"><%= c.status %></span>
  • src/views/pages/hub-home.ejs

    rccbd6a6 r8cb1dc7  
    55    <div class="hub-nav-inner container">
    66      <% if (user) { %>
    7         <% if (user.role === 'god' || user.role === 'admin') { %>
     7        <% if (user.role === 'god' || user.role === 'admin' || (typeof userOwnsSite !== 'undefined' && userOwnsSite)) { %>
    88          <a class="hub-nav-link" href="/admin">Beheer</a>
    99        <% } %>
  • src/views/partials/topnav.ejs

    rccbd6a6 r8cb1dc7  
    109109                <span>Account</span>
    110110              </a>
    111               <% if (user.role === 'god' || user.role === 'admin') { %>
     111              <% if (user.role === 'god' || user.role === 'admin' || (typeof userOwnsSite !== 'undefined' && userOwnsSite)) { %>
    112112                <a href="/admin" role="menuitem" class="udi">
    113113                  <svg class="udi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3h7v7H3z"/><path d="M14 3h7v7h-7z"/><path d="M14 14h7v7h-7z"/><path d="M3 14h7v7H3z"/></svg>
     
    116116              <% } %>
    117117              <% if (_canPost) { %>
    118                 <a href="/posts/new" role="menuitem" class="udi"
    119                    hx-get="/posts/new?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
    120                    hx-push-url="/posts/new" hx-indicator="#pcms-loading">
     118                <a href="<%= _siteUrlBase %>/posts/new" role="menuitem" class="udi"
     119                   hx-get="<%= _siteUrlBase %>/posts/new?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
     120                   hx-push-url="<%= _siteUrlBase %>/posts/new" hx-indicator="#pcms-loading">
    121121                  <svg class="udi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
    122122                  <span>Nieuwe post</span>
Note: See TracChangeset for help on using the changeset viewer.