source: Klonkt/src/routes/admin-media.js@ b2646cc

main
Last change on this file since b2646cc was 156baa3, checked in by roboburr <roboburr@…>, 5 weeks ago

De chrome en vier schermen met servergegevens (shaer-bqr, stap 3e)

topnav en profile-sheet naar mod/chrome.js; admin-media, admin-videos, paid-gate,
paid-passkey en admin-playlists naar eigen modules.

TWEE MANIEREN om servergegevens bij een module te krijgen, en de keuze is niet
willekeurig:

OP HET ELEMENT waar de tekst bij EEN ding hoort. De zoekteksten staan nu op

#search-overlay, de twee themalabels op het label zelf. Dat is
ook robuuster: bij een htmx-navigatie wordt de chrome
vervangen, en dan komt de tekst gewoon mee.

VIA pageData() waar het om paginabrede gegevens gaat (csrf, slug, een blob,

een setje meldingen).

De chrome kon niet via pageData: die leeft langer dan een pagina.

DRIE FOUTEN ONDERWEG, alle drie gevangen voordat er iets stuk ging:

  1. Ik zocht de eerste </script> in het HELE bestand in plaats van die na het openende tag. paid-gate en paid-passkey hebben een <script src> erboven, dus het "blok" werd leeg en er werd een lege module geschreven. Teruggezet uit git en de zoekfunctie kreeg een offset plus een controle dat het blok niet leeg is.
  2. Een zoekterm sloot te vroeg af: de tekst van delete_failed loopt door in dezelfde JS-string (": " + err.message), dus het patroon met de sluitquote matchte niet.
  3. Een eerdere poging verving nul regels en schreef toch. Nu telt de helper de vervangingen en weigert als het er niet exact zoveel zijn als opgegeven.

Die controle op "geen enkele module bevat nog EJS" draait mee, en er is er nu een
bij die een verdacht lege module afvangt -- precies wat fout 1 opleverde.

Templates compileren, suite 565/565.

  • Property mode set to 100644
File size: 7.7 KB
RevLine 
[f24b795]1/**
2 * admin-media.js — Beheer → Media (image library + cleanup).
3 *
4 * Lists the uploaded images under storage/media/post-images, shows where each is used, and lets the
5 * owner copy a URL or delete unused files. An animated cover's WebP, its loop MP4 (<base>-v.mp4) and
6 * poster (<base>-v.jpg) are treated as one item; deleting removes the trio. The Audio half of "Media"
7 * stays at /admin/audio (linked as a tab) — this page is the new image side.
8 */
9import express from 'express';
10import path from 'path';
11import fs from 'fs';
12import db from '../config/database.js';
13import { renderPage } from '../middleware/render.js';
14import { requireGod } from '../middleware/auth.js';
15import { audioEnabled } from '../config/features.js';
[e2c3d09]16import { mediaDir } from '../config/paths.js';
[f24b795]17
[e2c3d09]18const POST_IMAGES_DIR = mediaDir('POST_IMAGES_PATH', 'post-images');
[f24b795]19
20const router = express.Router();
21
22const IMG_EXT = /\.(jpe?g|png|webp|gif|avif)$/i;
[97bcf7e]23const VIDEO_EXT = /\.(mp4|webm|m4v|mov)$/i;
24// C2S uploads (Shaer's composer and the help buoy) land here; the videos among
25// them are what the Video tab shows.
[e2c3d09]26const REPLY_MEDIA_DIR = mediaDir('REPLY_MEDIA_PATH', 'reply-media');
[f24b795]27const isSibling = (f) => /-v\.(mp4|jpg)$/i.test(f); // an animated cover's video/poster sibling
28
29// Basename of a /media/post-images/<file> URL (or null).
30function baseOf(url) {
31 const m = String(url || '').match(/\/media\/post-images\/([^/?#"'\s)]+)/);
32 return m ? m[1] : null;
33}
34
35// Map filename -> Set(postId) of posts that reference it (as cover or inline image).
36function usageMap(siteId) {
37 const posts = db.prepare('SELECT id, content, cover_image_url, cover_video_url FROM posts WHERE site_id = ?').all(siteId);
38 const map = new Map();
39 const add = (fn, id) => { if (!fn) return; if (!map.has(fn)) map.set(fn, new Set()); map.get(fn).add(id); };
40 for (const p of posts) {
41 add(baseOf(p.cover_image_url), p.id);
42 add(baseOf(p.cover_video_url), p.id);
43 for (const m of String(p.content || '').matchAll(/\/media\/post-images\/([^/?#"'\s)]+)/g)) add(m[1], p.id);
44 }
45 return map;
46}
47
48function statSize(name) { try { return fs.statSync(path.join(POST_IMAGES_DIR, name)).size; } catch { return 0; } }
49function statMtime(name) { try { return fs.statSync(path.join(POST_IMAGES_DIR, name)).mtimeMs; } catch { return 0; } }
50
[f2943c1]51// All non-sibling images, each with its loop-MP4 sibling + how many posts use it. Shared by the
52// list view and the cleanup route so the readdir/filter/usage logic lives in one place.
53function imageEntries(siteId) {
54 const used = usageMap(siteId);
[f24b795]55 let all = [];
56 try { all = fs.readdirSync(POST_IMAGES_DIR).filter(f => !f.startsWith('.')); } catch { /* dir may not exist yet */ }
57 const present = new Set(all);
[f2943c1]58 return all
[f24b795]59 .filter(f => IMG_EXT.test(f) && !isSibling(f))
60 .map(f => {
61 const stem = f.replace(/\.[^.]+$/, '');
62 const mp4 = `${stem}-v.mp4`;
63 const hasVideo = present.has(mp4);
64 const ids = new Set([...(used.get(f) || []), ...(hasVideo ? (used.get(mp4) || []) : [])]);
[f2943c1]65 return { file: f, stem, mp4, hasVideo, usedCount: ids.size };
66 });
67}
68
69router.get('/', requireGod, (req, res) => {
70 const site = res.locals.site;
71 if (!site) return res.status(404).send('Site required');
72 const items = imageEntries(site.id)
73 .map(e => ({
74 file: e.file,
75 url: `/media/post-images/${e.file}`,
76 kb: Math.round((statSize(e.file) + (e.hasVideo ? statSize(e.mp4) : 0)) / 1024),
77 hasVideo: e.hasVideo,
78 usedCount: e.usedCount,
79 _mtime: statMtime(e.file),
80 }))
[f24b795]81 .sort((a, b) => b._mtime - a._mtime); // newest first
82 renderPage(req, res, 'pages/admin-media', {
[156baa3]83 pageJs: 'admin-media',
[f24b795]84 pageTitleKey: 'admin.t_media',
85 bodyClass: 'on-admin',
86 items,
87 unusedCount: items.filter(i => !i.usedCount).length,
88 audioOn: audioEnabled(),
89 success: req.query.success || null,
90 });
91});
92
93// Delete one image + its loop-MP4 / poster siblings. Basename-only + within-dir → no traversal.
[97bcf7e]94// ── The Video tab (Robins opdracht, 30-7) ─────────────────────────────────
95// Videos live in reply-media (C2S uploads: Shaer's composer, the help buoy).
96// Usage is a content/attachment reference from a post, exactly like images.
97
98function videoEntries(siteId) {
99 const posts = db.prepare('SELECT id, content, c2s_attachments FROM posts WHERE site_id = ?').all(siteId);
100 const used = new Map();
101 const add = (fn, id) => { if (!fn) return; if (!used.has(fn)) used.set(fn, new Set()); used.get(fn).add(id); };
102 for (const p of posts) {
103 for (const m of String(p.content || '').matchAll(/\/media\/reply-media\/([^/?#"'\s)]+)/g)) add(m[1], p.id);
104 try { for (const a of JSON.parse(p.c2s_attachments || '[]')) { const m = String(a.url || '').match(/\/media\/reply-media\/([^/?#"'\s)]+)/); if (m) add(m[1], p.id); } } catch { /* malformed never blocks the list */ }
105 }
106 let all = [];
107 try { all = fs.readdirSync(REPLY_MEDIA_DIR).filter(f => !f.startsWith('.')); } catch { /* dir may not exist yet */ }
108 const vstat = (name, key) => { try { const st = fs.statSync(path.join(REPLY_MEDIA_DIR, name)); return key === 'size' ? st.size : st.mtimeMs; } catch { return 0; } };
109 return all
110 .filter(f => VIDEO_EXT.test(f))
111 .map(f => ({
112 file: f,
113 url: `/media/reply-media/${f}`,
114 kb: Math.round(vstat(f, 'size') / 1024),
115 usedCount: (used.get(f) || new Set()).size,
116 _mtime: vstat(f, 'mtime'),
117 }))
118 .sort((a, b) => b._mtime - a._mtime);
119}
120
121router.get('/videos', requireGod, (req, res) => {
122 const site = res.locals.site;
123 if (!site) return res.status(404).send('Site required');
124 renderPage(req, res, 'pages/admin-videos', {
[156baa3]125 pageJs: 'admin-videos',
[97bcf7e]126 pageTitleKey: 'admin.t_media',
127 bodyClass: 'on-admin',
128 items: videoEntries(site.id),
129 audioOn: audioEnabled(),
130 success: req.query.success || null,
131 });
132});
133
134// Delete one video. Basename-only + within-dir, and only when no post uses it:
135// the same guardrails the image delete has.
136router.post('/videos/delete', requireGod, (req, res) => {
137 const site = res.locals.site;
138 if (!site) return res.status(404).json({ error: 'site' });
139 const file = path.basename(String(req.body?.file || ''));
140 if (!file || !VIDEO_EXT.test(file)) return res.status(400).json({ error: 'bad_file' });
141 const entry = videoEntries(site.id).find(e => e.file === file);
142 if (!entry) return res.status(404).json({ error: 'not_found' });
143 if (entry.usedCount) return res.status(409).json({ error: 'in_use' });
144 try { fs.unlinkSync(path.join(REPLY_MEDIA_DIR, file)); } catch { /* already gone is gone */ }
145 res.json({ ok: true });
146});
147
[f24b795]148router.post('/delete', requireGod, (req, res) => {
149 if (!res.locals.site) return res.status(404).json({ ok: false, error: 'Site required' });
150 const f = String(req.body.file || '');
151 if (!f || path.basename(f) !== f || !IMG_EXT.test(f)) return res.status(400).json({ ok: false, error: 'Bad file' });
152 const stem = f.replace(/\.[^.]+$/, '');
153 let removed = 0;
154 for (const name of [f, `${stem}-v.mp4`, `${stem}-v.jpg`]) {
155 const full = path.join(POST_IMAGES_DIR, name);
156 if (path.dirname(full) !== POST_IMAGES_DIR) continue;
157 try { fs.unlinkSync(full); removed++; } catch { /* missing sibling */ }
158 }
159 res.json({ ok: true, removed });
160});
161
162// Delete every unused image (orphan) + its siblings.
163router.post('/cleanup', requireGod, (req, res) => {
164 const site = res.locals.site;
165 if (!site) return res.status(404).json({ ok: false, error: 'Site required' });
166 let removed = 0;
[f2943c1]167 for (const e of imageEntries(site.id)) {
168 if (e.usedCount) continue; // still in use
169 for (const name of [e.file, e.mp4, `${e.stem}-v.jpg`]) {
[f24b795]170 try { fs.unlinkSync(path.join(POST_IMAGES_DIR, name)); removed++; } catch { /* */ }
171 }
172 }
173 res.json({ ok: true, removed });
174});
175
176export default router;
Note: See TracBrowser for help on using the repository browser.