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

main
Last change on this file since f24b795 was f24b795, checked in by roboburr <roboburr@…>, 2 months ago

feat(media): Beheer -> Media — image library with usage + cleanup of orphan files

A new /admin/media page lists uploaded images (storage/media/post-images), shows how many posts use
each, lets you copy a URL, and deletes unused files — including the loop-MP4 + poster siblings of an
animated cover. The Beheer "Audio" nav link becomes "Media" (Audio stays a tab linking to
/admin/audio) so the nav doesn't grow. Mounted unconditionally (works in lite/no-audio mode).

  • src/routes/admin-media.js — list / delete / cleanup (path-traversal-safe, god-only)
  • src/views/pages/admin-media.ejs — grid + copy/delete/cleanup (CSP-safe nonce'd script)
  • server.js mount; admin.ejs nav Audio->Media; i18n nl/en/de

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

  • Property mode set to 100644
File size: 5.0 KB
Line 
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 { fileURLToPath } from 'url';
13import db from '../config/database.js';
14import { renderPage } from '../middleware/render.js';
15import { requireGod } from '../middleware/auth.js';
16import { audioEnabled } from '../config/features.js';
17
18const __dirname = path.dirname(fileURLToPath(import.meta.url));
19const POST_IMAGES_DIR = path.resolve(
20 process.env.POST_IMAGES_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'post-images')
21);
22
23const router = express.Router();
24
25const IMG_EXT = /\.(jpe?g|png|webp|gif|avif)$/i;
26const isSibling = (f) => /-v\.(mp4|jpg)$/i.test(f); // an animated cover's video/poster sibling
27
28// Basename of a /media/post-images/<file> URL (or null).
29function baseOf(url) {
30 const m = String(url || '').match(/\/media\/post-images\/([^/?#"'\s)]+)/);
31 return m ? m[1] : null;
32}
33
34// Map filename -> Set(postId) of posts that reference it (as cover or inline image).
35function usageMap(siteId) {
36 const posts = db.prepare('SELECT id, content, cover_image_url, cover_video_url FROM posts WHERE site_id = ?').all(siteId);
37 const map = new Map();
38 const add = (fn, id) => { if (!fn) return; if (!map.has(fn)) map.set(fn, new Set()); map.get(fn).add(id); };
39 for (const p of posts) {
40 add(baseOf(p.cover_image_url), p.id);
41 add(baseOf(p.cover_video_url), p.id);
42 for (const m of String(p.content || '').matchAll(/\/media\/post-images\/([^/?#"'\s)]+)/g)) add(m[1], p.id);
43 }
44 return map;
45}
46
47function statSize(name) { try { return fs.statSync(path.join(POST_IMAGES_DIR, name)).size; } catch { return 0; } }
48function statMtime(name) { try { return fs.statSync(path.join(POST_IMAGES_DIR, name)).mtimeMs; } catch { return 0; } }
49
50router.get('/', requireGod, (req, res) => {
51 const site = res.locals.site;
52 if (!site) return res.status(404).send('Site required');
53 const used = usageMap(site.id);
54 let all = [];
55 try { all = fs.readdirSync(POST_IMAGES_DIR).filter(f => !f.startsWith('.')); } catch { /* dir may not exist yet */ }
56 const present = new Set(all);
57 const items = all
58 .filter(f => IMG_EXT.test(f) && !isSibling(f))
59 .map(f => {
60 const stem = f.replace(/\.[^.]+$/, '');
61 const mp4 = `${stem}-v.mp4`;
62 const hasVideo = present.has(mp4);
63 const ids = new Set([...(used.get(f) || []), ...(hasVideo ? (used.get(mp4) || []) : [])]);
64 return {
65 file: f,
66 url: `/media/post-images/${f}`,
67 kb: Math.round((statSize(f) + (hasVideo ? statSize(mp4) : 0)) / 1024),
68 hasVideo,
69 usedCount: ids.size,
70 _mtime: statMtime(f),
71 };
72 })
73 .sort((a, b) => b._mtime - a._mtime); // newest first
74 renderPage(req, res, 'pages/admin-media', {
75 pageTitleKey: 'admin.t_media',
76 bodyClass: 'on-admin',
77 items,
78 unusedCount: items.filter(i => !i.usedCount).length,
79 audioOn: audioEnabled(),
80 success: req.query.success || null,
81 });
82});
83
84// Delete one image + its loop-MP4 / poster siblings. Basename-only + within-dir → no traversal.
85router.post('/delete', requireGod, (req, res) => {
86 if (!res.locals.site) return res.status(404).json({ ok: false, error: 'Site required' });
87 const f = String(req.body.file || '');
88 if (!f || path.basename(f) !== f || !IMG_EXT.test(f)) return res.status(400).json({ ok: false, error: 'Bad file' });
89 const stem = f.replace(/\.[^.]+$/, '');
90 let removed = 0;
91 for (const name of [f, `${stem}-v.mp4`, `${stem}-v.jpg`]) {
92 const full = path.join(POST_IMAGES_DIR, name);
93 if (path.dirname(full) !== POST_IMAGES_DIR) continue;
94 try { fs.unlinkSync(full); removed++; } catch { /* missing sibling */ }
95 }
96 res.json({ ok: true, removed });
97});
98
99// Delete every unused image (orphan) + its siblings.
100router.post('/cleanup', requireGod, (req, res) => {
101 const site = res.locals.site;
102 if (!site) return res.status(404).json({ ok: false, error: 'Site required' });
103 const used = usageMap(site.id);
104 let all = [];
105 try { all = fs.readdirSync(POST_IMAGES_DIR).filter(f => !f.startsWith('.')); } catch { /* */ }
106 const present = new Set(all);
107 let removed = 0;
108 for (const f of all.filter(x => IMG_EXT.test(x) && !isSibling(x))) {
109 const stem = f.replace(/\.[^.]+$/, '');
110 const mp4 = `${stem}-v.mp4`;
111 const ids = new Set([...(used.get(f) || []), ...(present.has(mp4) ? (used.get(mp4) || []) : [])]);
112 if (ids.size) continue; // still in use
113 for (const name of [f, mp4, `${stem}-v.jpg`]) {
114 try { fs.unlinkSync(path.join(POST_IMAGES_DIR, name)); removed++; } catch { /* */ }
115 }
116 }
117 res.json({ ok: true, removed });
118});
119
120export default router;
Note: See TracBrowser for help on using the repository browser.