source: Klonkt/src/routes/download.js@ 3ca7bdf

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

De editpagina laadt zijn editor weer, en opslaan zonder module wist niets meer

Robins test at een post op: /posts/:slug/edit rendert dezelfde template als
/posts/new, maar alleen de nieuw-route kreeg bij shaer-bqr zijn pageJs. De
editpagina had dus data-js="chrome" -- geen editor, geen mediaprompts, en
een verborgen contentveld dat leeg rendert. Opslaan schreef dat lege veld
naar de database: strike-homerun verloor zo om 18:54 zijn inhoud (testpost
van 18:12, niets ouds verloren).

Drie lagen, van symptoom naar vangrail:

  1. de edit-route declareert pageJs 'post-edit playlist-editor'
  2. het verborgen veld rendert de BESTAANDE inhoud: laadt de module niet (oude cache, js uit), dan is opslaan een no-op in plaats van een wisser
  3. test/page-modules.test.js scheurt luid als een render van de editor zijn modules vergeet -- een brontekst-test, bewust, want het gedrag leeft in de browser en dit is de fout die twee keer beet

En dezelfde audit ving download: drie renders, en pageJs stond op het
FORMULIER in plaats van op ready -- de auto-start hoorde v66r shaer-bqr bij
de ready-tak en omzeilde nu de e-mailvraag. Verplaatst, en de module weigert
voortaan een lege fileUrl.

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

  • Property mode set to 100644
File size: 6.4 KB
Line 
1/**
2 * Download-for-email (premium feature #2).
3 *
4 * GET /downloads -> list of downloadable tracks (premium; 404 otherwise)
5 * GET /download/:id -> email capture page for a single track
6 * POST /download/:id -> save email (-> mailing list) + unlock download
7 * GET /download/:id/bestand -> serves the file (session-gated after capture)
8 *
9 * The fan leaves their email and receives the file; the address is added to the
10 * subscribers list (source 'download', single opt-in — no confirm step before the
11 * download). Hub: via /user/:slug/... (resolveSite + siteUrlBase).
12 */
13
14import express from 'express';
15import path from 'path';
16import fs from 'fs';
17import { fileURLToPath } from 'url';
18import db from '../config/database.js';
19import { renderPage } from '../middleware/render.js';
20import { premiumUnlocked } from '../services/PatreonService.js';
21import { addSubscriber } from '../services/SubscriberService.js';
22import { postNeighbors } from './posts.js';
23
24const router = express.Router();
25
26// If a real (pinned) post with slug 'downloads' exists, the downloads list is
27// effectively attached to that post. We then also show the Newer/Older post nav
28// so the visitor can browse just like on a regular post.
29function downloadsPostNav(req, res) {
30 const site = res.locals.site;
31 if (!site) return {};
32 const post = db.prepare(
33 "SELECT id, slug, pinned FROM posts WHERE site_id = ? AND slug = 'downloads' AND status = 'published'"
34 ).get(site.id);
35 if (!post) return {};
36 try { return postNeighbors(site, post); } catch (e) { return {}; }
37}
38const __dirname = path.dirname(fileURLToPath(import.meta.url));
39const AUDIO_DIR = path.resolve(process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio'));
40
41const MIME = { '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.flac': 'audio/flac', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg' };
42const GRACE_MS = 15 * 60 * 1000; // download window after capture
43
44function dlTrack(siteId, id) {
45 return db.prepare(
46 `SELECT t.id, t.title, t.artist, t.cover_url, m.storage_path, m.filename
47 FROM audio_tracks t JOIN media m ON m.id = t.media_id
48 WHERE t.id = ? AND t.site_id = ? AND t.downloadable = 1`
49 ).get(id, siteId);
50}
51function safeName(title, storagePath) {
52 const ext = path.extname(storagePath || '').toLowerCase() || '.mp3';
53 const base = String(title || 'track').replace(/[^a-zA-Z0-9 _.-]/g, '').trim().slice(0, 80) || 'track';
54 return base + ext;
55}
56
57// List of downloadable tracks.
58router.get('/downloads', (req, res, next) => {
59 if (!premiumUnlocked()) return next();
60 const site = res.locals.site;
61 if (!site) return next();
62 const tracks = db.prepare(
63 `SELECT id, title, artist, cover_url FROM audio_tracks
64 WHERE site_id = ? AND downloadable = 1 ORDER BY position ASC, created_at ASC`
65 ).all(site.id);
66 const nav = downloadsPostNav(req, res);
67 renderPage(req, res, 'pages/downloads', {
68 pageTitle: 'Downloads — ' + (site.title || ''),
69 // on-special = compact profile header (like on a post); on-downloads = grey pill
70 // + feature-route behaviour. Together → downloads looks just like a post.
71 bodyClass: 'on-downloads on-special',
72 dlTracks: tracks,
73 newerPost: nav.newerPost || null,
74 olderPost: nav.olderPost || null,
75 });
76});
77
78// Capture page for a single track.
79router.get('/download/:id', (req, res, next) => {
80 if (!premiumUnlocked()) return next();
81 const site = res.locals.site;
82 if (!site) return next();
83 const track = dlTrack(site.id, req.params.id);
84 if (!track) return next();
85 const fan = req.session && req.session.user;
86 renderPage(req, res, 'pages/download', {
87 pageTitle: track.title + ' — download',
88 bodyClass: 'on-download',
89 dlState: 'form',
90 dlTrack: track,
91 dlPrefill: (fan && fan.email && fan.email.includes('@')) ? fan.email : '',
92 });
93});
94
95// Save email + unlock download.
96router.post('/download/:id', (req, res, next) => {
97 if (!premiumUnlocked()) return next();
98 const site = res.locals.site;
99 if (!site) return next();
100 const track = dlTrack(site.id, req.params.id);
101 if (!track) return next();
102 const email = (req.body.email || '').trim();
103 const r = addSubscriber(site.id, email, 'download', { doubleOptin: false });
104 if (!r.ok) {
105 return renderPage(req, res, 'pages/download', {
106 pageTitle: track.title + ' — download', bodyClass: 'on-download',
107 dlState: 'form', dlTrack: track, dlPrefill: email,
108 dlError: r.error === 'invalid_email' ? 'Controleer je e-mailadres.' : 'Er ging iets mis.',
109 });
110 }
111 // Unlock download in the session (short window).
112 if (!req.session.dl) req.session.dl = {};
113 req.session.dl[track.id] = Date.now();
114 renderPage(req, res, 'pages/download', {
115 // De auto-start hoort ALLEEN bij ready: op het formulier zou hij de
116 // e-mailvraag omzeilen. Het script stond v66r shaer-bqr dan ook binnen
117 // de ready-tak van de template.
118 pageJs: 'download',
119 pageTitle: track.title + ' — download', bodyClass: 'on-download',
120 dlState: 'ready', dlTrack: track,
121 });
122});
123
124// Serve the file — only if an email was just submitted (session-gated).
125router.get('/download/:id/bestand', (req, res, next) => {
126 if (!premiumUnlocked()) return next();
127 const site = res.locals.site;
128 if (!site) return next();
129 const track = dlTrack(site.id, req.params.id);
130 if (!track) return next();
131 const ts = req.session && req.session.dl && req.session.dl[track.id];
132 if (!ts || (Date.now() - ts) > GRACE_MS) {
133 return res.status(403).send('Laat eerst je e-mailadres achter om te downloaden.');
134 }
135 // The playable/downloadable file = the BARE filename (storage_path is an
136 // absolute path → fails the slash-guard). Same approach as /audio/stream.
137 const sp = track.filename;
138 if (!sp || sp.includes('/') || sp.includes('\\') || sp.includes('..')) return res.status(400).send('Bad path');
139 const filePath = path.join(AUDIO_DIR, sp);
140 if (!filePath.startsWith(AUDIO_DIR + path.sep)) return res.status(400).send('Bad path');
141 let stat;
142 try { stat = fs.statSync(filePath); } catch { return res.status(404).send('Bestand niet gevonden'); }
143 if (!stat.isFile()) return res.status(404).send('Bestand niet gevonden');
144 const ext = path.extname(sp).toLowerCase();
145 res.setHeader('Content-Type', MIME[ext] || 'application/octet-stream');
146 res.setHeader('Content-Length', stat.size);
147 res.setHeader('Content-Disposition', 'attachment; filename="' + safeName(track.title, sp) + '"');
148 fs.createReadStream(filePath).pipe(res);
149});
150
151export default router;
Note: See TracBrowser for help on using the repository browser.