| 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 | */
|
|---|
| 9 | import express from 'express';
|
|---|
| 10 | import path from 'path';
|
|---|
| 11 | import fs from 'fs';
|
|---|
| 12 | import db from '../config/database.js';
|
|---|
| 13 | import { renderPage } from '../middleware/render.js';
|
|---|
| 14 | import { requireGod } from '../middleware/auth.js';
|
|---|
| 15 | import { audioEnabled } from '../config/features.js';
|
|---|
| 16 | import { mediaDir } from '../config/paths.js';
|
|---|
| 17 |
|
|---|
| 18 | const POST_IMAGES_DIR = mediaDir('POST_IMAGES_PATH', 'post-images');
|
|---|
| 19 |
|
|---|
| 20 | const router = express.Router();
|
|---|
| 21 |
|
|---|
| 22 | const IMG_EXT = /\.(jpe?g|png|webp|gif|avif)$/i;
|
|---|
| 23 | const 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.
|
|---|
| 26 | const REPLY_MEDIA_DIR = mediaDir('REPLY_MEDIA_PATH', 'reply-media');
|
|---|
| 27 | const 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).
|
|---|
| 30 | function 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).
|
|---|
| 36 | function 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 |
|
|---|
| 48 | function statSize(name) { try { return fs.statSync(path.join(POST_IMAGES_DIR, name)).size; } catch { return 0; } }
|
|---|
| 49 | function statMtime(name) { try { return fs.statSync(path.join(POST_IMAGES_DIR, name)).mtimeMs; } catch { return 0; } }
|
|---|
| 50 |
|
|---|
| 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.
|
|---|
| 53 | function imageEntries(siteId) {
|
|---|
| 54 | const used = usageMap(siteId);
|
|---|
| 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);
|
|---|
| 58 | return all
|
|---|
| 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) || []) : [])]);
|
|---|
| 65 | return { file: f, stem, mp4, hasVideo, usedCount: ids.size };
|
|---|
| 66 | });
|
|---|
| 67 | }
|
|---|
| 68 |
|
|---|
| 69 | router.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 | }))
|
|---|
| 81 | .sort((a, b) => b._mtime - a._mtime); // newest first
|
|---|
| 82 | renderPage(req, res, 'pages/admin-media', {
|
|---|
| 83 | pageTitleKey: 'admin.t_media',
|
|---|
| 84 | bodyClass: 'on-admin',
|
|---|
| 85 | items,
|
|---|
| 86 | unusedCount: items.filter(i => !i.usedCount).length,
|
|---|
| 87 | audioOn: audioEnabled(),
|
|---|
| 88 | success: req.query.success || null,
|
|---|
| 89 | });
|
|---|
| 90 | });
|
|---|
| 91 |
|
|---|
| 92 | // Delete one image + its loop-MP4 / poster siblings. Basename-only + within-dir → no traversal.
|
|---|
| 93 | // ── The Video tab (Robins opdracht, 30-7) ─────────────────────────────────
|
|---|
| 94 | // Videos live in reply-media (C2S uploads: Shaer's composer, the help buoy).
|
|---|
| 95 | // Usage is a content/attachment reference from a post, exactly like images.
|
|---|
| 96 |
|
|---|
| 97 | function videoEntries(siteId) {
|
|---|
| 98 | const posts = db.prepare('SELECT id, content, c2s_attachments FROM posts WHERE site_id = ?').all(siteId);
|
|---|
| 99 | const used = new Map();
|
|---|
| 100 | const add = (fn, id) => { if (!fn) return; if (!used.has(fn)) used.set(fn, new Set()); used.get(fn).add(id); };
|
|---|
| 101 | for (const p of posts) {
|
|---|
| 102 | for (const m of String(p.content || '').matchAll(/\/media\/reply-media\/([^/?#"'\s)]+)/g)) add(m[1], p.id);
|
|---|
| 103 | 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 */ }
|
|---|
| 104 | }
|
|---|
| 105 | let all = [];
|
|---|
| 106 | try { all = fs.readdirSync(REPLY_MEDIA_DIR).filter(f => !f.startsWith('.')); } catch { /* dir may not exist yet */ }
|
|---|
| 107 | 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; } };
|
|---|
| 108 | return all
|
|---|
| 109 | .filter(f => VIDEO_EXT.test(f))
|
|---|
| 110 | .map(f => ({
|
|---|
| 111 | file: f,
|
|---|
| 112 | url: `/media/reply-media/${f}`,
|
|---|
| 113 | kb: Math.round(vstat(f, 'size') / 1024),
|
|---|
| 114 | usedCount: (used.get(f) || new Set()).size,
|
|---|
| 115 | _mtime: vstat(f, 'mtime'),
|
|---|
| 116 | }))
|
|---|
| 117 | .sort((a, b) => b._mtime - a._mtime);
|
|---|
| 118 | }
|
|---|
| 119 |
|
|---|
| 120 | router.get('/videos', requireGod, (req, res) => {
|
|---|
| 121 | const site = res.locals.site;
|
|---|
| 122 | if (!site) return res.status(404).send('Site required');
|
|---|
| 123 | renderPage(req, res, 'pages/admin-videos', {
|
|---|
| 124 | pageTitleKey: 'admin.t_media',
|
|---|
| 125 | bodyClass: 'on-admin',
|
|---|
| 126 | items: videoEntries(site.id),
|
|---|
| 127 | audioOn: audioEnabled(),
|
|---|
| 128 | success: req.query.success || null,
|
|---|
| 129 | });
|
|---|
| 130 | });
|
|---|
| 131 |
|
|---|
| 132 | // Delete one video. Basename-only + within-dir, and only when no post uses it:
|
|---|
| 133 | // the same guardrails the image delete has.
|
|---|
| 134 | router.post('/videos/delete', requireGod, (req, res) => {
|
|---|
| 135 | const site = res.locals.site;
|
|---|
| 136 | if (!site) return res.status(404).json({ error: 'site' });
|
|---|
| 137 | const file = path.basename(String(req.body?.file || ''));
|
|---|
| 138 | if (!file || !VIDEO_EXT.test(file)) return res.status(400).json({ error: 'bad_file' });
|
|---|
| 139 | const entry = videoEntries(site.id).find(e => e.file === file);
|
|---|
| 140 | if (!entry) return res.status(404).json({ error: 'not_found' });
|
|---|
| 141 | if (entry.usedCount) return res.status(409).json({ error: 'in_use' });
|
|---|
| 142 | try { fs.unlinkSync(path.join(REPLY_MEDIA_DIR, file)); } catch { /* already gone is gone */ }
|
|---|
| 143 | res.json({ ok: true });
|
|---|
| 144 | });
|
|---|
| 145 |
|
|---|
| 146 | router.post('/delete', requireGod, (req, res) => {
|
|---|
| 147 | if (!res.locals.site) return res.status(404).json({ ok: false, error: 'Site required' });
|
|---|
| 148 | const f = String(req.body.file || '');
|
|---|
| 149 | if (!f || path.basename(f) !== f || !IMG_EXT.test(f)) return res.status(400).json({ ok: false, error: 'Bad file' });
|
|---|
| 150 | const stem = f.replace(/\.[^.]+$/, '');
|
|---|
| 151 | let removed = 0;
|
|---|
| 152 | for (const name of [f, `${stem}-v.mp4`, `${stem}-v.jpg`]) {
|
|---|
| 153 | const full = path.join(POST_IMAGES_DIR, name);
|
|---|
| 154 | if (path.dirname(full) !== POST_IMAGES_DIR) continue;
|
|---|
| 155 | try { fs.unlinkSync(full); removed++; } catch { /* missing sibling */ }
|
|---|
| 156 | }
|
|---|
| 157 | res.json({ ok: true, removed });
|
|---|
| 158 | });
|
|---|
| 159 |
|
|---|
| 160 | // Delete every unused image (orphan) + its siblings.
|
|---|
| 161 | router.post('/cleanup', requireGod, (req, res) => {
|
|---|
| 162 | const site = res.locals.site;
|
|---|
| 163 | if (!site) return res.status(404).json({ ok: false, error: 'Site required' });
|
|---|
| 164 | let removed = 0;
|
|---|
| 165 | for (const e of imageEntries(site.id)) {
|
|---|
| 166 | if (e.usedCount) continue; // still in use
|
|---|
| 167 | for (const name of [e.file, e.mp4, `${e.stem}-v.jpg`]) {
|
|---|
| 168 | try { fs.unlinkSync(path.join(POST_IMAGES_DIR, name)); removed++; } catch { /* */ }
|
|---|
| 169 | }
|
|---|
| 170 | }
|
|---|
| 171 | res.json({ ok: true, removed });
|
|---|
| 172 | });
|
|---|
| 173 |
|
|---|
| 174 | export default router;
|
|---|