source: Klonkt/src/routes/download.js@ 52fc278

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

De Krant en de downloadpagina, met het data-kanaal in gebruik (shaer-bqr, stap 3d)

De eerste twee die servergegevens nodig hadden. news gebruikte twee vertalingen,
download een berekende URL.

EEN ONTWERPCORRECTIE die het bouwen opleverde: de gegevens horen gedeclareerd te
worden waar ze BEKEND zijn, niet door de route geregen. fileUrl wordt in de
template zelf berekend, en t() bestaat in een route helemaal niet. Dus geeft de
pagina zijn eigen gegevens mee aan de include:

include('../partials/page-data', { pageData: { fileUrl: fileUrl } })

De route noemt alleen nog de module. Dat scheelt in elke volgende pagina een
omweg langs de route, en het houdt de gegevens naast de plek waar ze vandaan
komen.

Gecontroleerd dat er in GEEN enkele module nog een EJS-tag staat -- dat is de
enige manier om zeker te weten dat er niets stilzwijgend is blijven hangen dat
straks als letterlijke tekst in een statisch bestand belandt.

Templates compileren, suite 565/565.

  • Property mode set to 100644
File size: 6.2 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 pageJs: 'download',
88 pageTitle: track.title + ' — download',
89 bodyClass: 'on-download',
90 dlState: 'form',
91 dlTrack: track,
92 dlPrefill: (fan && fan.email && fan.email.includes('@')) ? fan.email : '',
93 });
94});
95
96// Save email + unlock download.
97router.post('/download/:id', (req, res, next) => {
98 if (!premiumUnlocked()) return next();
99 const site = res.locals.site;
100 if (!site) return next();
101 const track = dlTrack(site.id, req.params.id);
102 if (!track) return next();
103 const email = (req.body.email || '').trim();
104 const r = addSubscriber(site.id, email, 'download', { doubleOptin: false });
105 if (!r.ok) {
106 return renderPage(req, res, 'pages/download', {
107 pageTitle: track.title + ' — download', bodyClass: 'on-download',
108 dlState: 'form', dlTrack: track, dlPrefill: email,
109 dlError: r.error === 'invalid_email' ? 'Controleer je e-mailadres.' : 'Er ging iets mis.',
110 });
111 }
112 // Unlock download in the session (short window).
113 if (!req.session.dl) req.session.dl = {};
114 req.session.dl[track.id] = Date.now();
115 renderPage(req, res, 'pages/download', {
116 pageTitle: track.title + ' — download', bodyClass: 'on-download',
117 dlState: 'ready', dlTrack: track,
118 });
119});
120
121// Serve the file — only if an email was just submitted (session-gated).
122router.get('/download/:id/bestand', (req, res, next) => {
123 if (!premiumUnlocked()) return next();
124 const site = res.locals.site;
125 if (!site) return next();
126 const track = dlTrack(site.id, req.params.id);
127 if (!track) return next();
128 const ts = req.session && req.session.dl && req.session.dl[track.id];
129 if (!ts || (Date.now() - ts) > GRACE_MS) {
130 return res.status(403).send('Laat eerst je e-mailadres achter om te downloaden.');
131 }
132 // The playable/downloadable file = the BARE filename (storage_path is an
133 // absolute path → fails the slash-guard). Same approach as /audio/stream.
134 const sp = track.filename;
135 if (!sp || sp.includes('/') || sp.includes('\\') || sp.includes('..')) return res.status(400).send('Bad path');
136 const filePath = path.join(AUDIO_DIR, sp);
137 if (!filePath.startsWith(AUDIO_DIR + path.sep)) return res.status(400).send('Bad path');
138 let stat;
139 try { stat = fs.statSync(filePath); } catch { return res.status(404).send('Bestand niet gevonden'); }
140 if (!stat.isFile()) return res.status(404).send('Bestand niet gevonden');
141 const ext = path.extname(sp).toLowerCase();
142 res.setHeader('Content-Type', MIME[ext] || 'application/octet-stream');
143 res.setHeader('Content-Length', stat.size);
144 res.setHeader('Content-Disposition', 'attachment; filename="' + safeName(track.title, sp) + '"');
145 fs.createReadStream(filePath).pipe(res);
146});
147
148export default router;
Note: See TracBrowser for help on using the repository browser.