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

main
Last change on this file since e2c3d09 was e2c3d09, checked in by Robin <roboburr@…>, 6 weeks ago

Media-submappen volgen nu MEDIA_PATH

De submappen voor avatars, post-images, reply-media, hero en audio-covers hadden
elk hun eigen env-variabele met een fallback naar <app>/storage/media/<sub>.
Daardoor negeerden ze MEDIA_PATH: wie zijn data buiten de checkout zette kreeg
alsnog een storage/-map in de work-tree, en uploads landden naast de code. Een
opruimstap bij een volgende deploy kan die vervolgens weggooien.

Nu leiden ze allemaal af van MEDIA_PATH via een gedeelde helper. Een eigen
override per submap wint nog steeds, dus bestaande installaties merken niets.
Dit maakt de scheiding van gebruikersdata en programmadata mogelijk met drie
regels in .env in plaats van acht.

Changed files:
src/routes/account.js

  • AVATAR_DIR via mediaDir(); dode dirname en fileURLToPath-import weg

src/routes/posts.js

  • POST_IMAGES_DIR en REPLY_MEDIA_DIR via mediaDir(); dode declaraties weg

src/routes/admin-media.js

  • POST_IMAGES_DIR en REPLY_MEDIA_DIR via mediaDir(); dode declaraties weg

src/routes/admin-settings.js

  • HERO_DIR via mediaDir(); dode declaraties weg

src/routes/admin-playlists.js

  • COVER_DIR via mediaDir(); dode dirname weg

src/routes/admin-audio.js

  • COVER_DIR via mediaDir(); AUDIO_DIR ongewijzigd (eigen wortel)

src/routes/admin-sites.js

  • PHOTO_DIR via mediaDir(), deelt bewust de avatars-map

src/routes/activitypub.js

  • AP_MEDIA_DIR via mediaDir(); ongebruikte fileURLToPath-import weg

New file:
src/config/paths.js

  • MEDIA_ROOT afgeleid van MEDIA_PATH
  • mediaDir(envVar, sub) voor submappen, met behoud van per-map overrides

DATABASE_PATH en AUDIO_PATH zijn eigen wortels en bewust ongemoeid gelaten.
Geverifieerd: 356 tests groen, en een server met externe MEDIA_PATH maakt al
zijn mappen buiten de checkout aan zonder de work-tree te raken.

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

  • Property mode set to 100644
File size: 7.7 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 db from '../config/database.js';
13import { renderPage } from '../middleware/render.js';
14import { requireGod } from '../middleware/auth.js';
15import { audioEnabled } from '../config/features.js';
16import { mediaDir } from '../config/paths.js';
17
18const POST_IMAGES_DIR = mediaDir('POST_IMAGES_PATH', 'post-images');
19
20const router = express.Router();
21
22const IMG_EXT = /\.(jpe?g|png|webp|gif|avif)$/i;
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.
26const REPLY_MEDIA_DIR = mediaDir('REPLY_MEDIA_PATH', 'reply-media');
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
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);
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
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 }))
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
97function 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
120router.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.
134router.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
146router.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.
161router.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
174export default router;
Note: See TracBrowser for help on using the repository browser.