source: Klonkt/src/routes/download.js@ 59e522f

main
Last change on this file since 59e522f was 91094a4, checked in by roboburr <roboburr@…>, 3 months ago

Download-for-email (premium feature #2)

Fan leaves their email -> receives the file; address is added to the mailing list
(source 'download', single opt-in so the download is not behind a confirm barrier).
Reuses SubscriberService (#1).

  • DB: audio_tracks.downloadable (default 0).
  • admin-audio: per-track no-JS toggle (POST /admin/audio/:id/downloadable) + ⬇ button in the admin list (premium-gated); downloadable also in the /api/:id whitelist.
  • routes/download.js (premium-gated): GET /downloads (list), GET /download/:id (capture page), POST /download/:id (save email + session grant), GET /download/:id/bestand (serves attachment from storage/audio, 15-min session window, path-traversal guards).
  • views: pages/downloads.ejs + pages/download.ejs (form/ready, auto-start download).
  • admin dashboard: ⬇ Downloads link (premium, non-hub).

node --check passed. Nothing else reuses this; #8 notify reuses subscribers.

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

  • Property mode set to 100644
File size: 5.2 KB
RevLine 
[91094a4]1/**
2 * Download-voor-email (premium feature #2).
3 *
4 * GET /downloads -> lijst van downloadbare tracks (premium; anders 404)
5 * GET /download/:id -> e-mail-capture-pagina voor één track
6 * POST /download/:id -> e-mail opslaan (-> mailinglijst) + download vrijgeven
7 * GET /download/:id/bestand -> serveert het bestand (sessie-gated na capture)
8 *
9 * De fan laat z'n e-mail achter en krijgt het bestand; het adres komt in de
10 * subscribers-lijst (source 'download', single opt-in — geen confirm-drempel vóór de
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';
22
23const router = express.Router();
24const __dirname = path.dirname(fileURLToPath(import.meta.url));
25const AUDIO_DIR = path.resolve(process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio'));
26
27const MIME = { '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.flac': 'audio/flac', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg' };
28const GRACE_MS = 15 * 60 * 1000; // download-venster na capture
29
30function dlTrack(siteId, id) {
31 return db.prepare(
32 `SELECT t.id, t.title, t.artist, t.cover_url, m.storage_path
33 FROM audio_tracks t JOIN media m ON m.id = t.media_id
34 WHERE t.id = ? AND t.site_id = ? AND t.downloadable = 1`
35 ).get(id, siteId);
36}
37function safeName(title, storagePath) {
38 const ext = path.extname(storagePath || '').toLowerCase() || '.mp3';
39 const base = String(title || 'track').replace(/[^a-zA-Z0-9 _.-]/g, '').trim().slice(0, 80) || 'track';
40 return base + ext;
41}
42
43// Lijst van downloadbare tracks.
44router.get('/downloads', (req, res, next) => {
45 if (!premiumUnlocked()) return next();
46 const site = res.locals.site;
47 if (!site) return next();
48 const tracks = db.prepare(
49 `SELECT id, title, artist, cover_url FROM audio_tracks
50 WHERE site_id = ? AND downloadable = 1 ORDER BY position ASC, created_at ASC`
51 ).all(site.id);
52 renderPage(req, res, 'pages/downloads', {
53 pageTitle: 'Downloads — ' + (site.title || ''),
54 bodyClass: 'on-downloads',
55 dlTracks: tracks,
56 });
57});
58
59// Capture-pagina voor één track.
60router.get('/download/:id', (req, res, next) => {
61 if (!premiumUnlocked()) return next();
62 const site = res.locals.site;
63 if (!site) return next();
64 const track = dlTrack(site.id, req.params.id);
65 if (!track) return next();
66 const fan = req.session && req.session.user;
67 renderPage(req, res, 'pages/download', {
68 pageTitle: track.title + ' — download',
69 bodyClass: 'on-download',
70 dlState: 'form',
71 dlTrack: track,
72 dlPrefill: (fan && fan.email && fan.email.includes('@')) ? fan.email : '',
73 });
74});
75
76// E-mail opslaan + download vrijgeven.
77router.post('/download/:id', (req, res, next) => {
78 if (!premiumUnlocked()) return next();
79 const site = res.locals.site;
80 if (!site) return next();
81 const track = dlTrack(site.id, req.params.id);
82 if (!track) return next();
83 const email = (req.body.email || '').trim();
84 const r = addSubscriber(site.id, email, 'download', { doubleOptin: false });
85 if (!r.ok) {
86 return renderPage(req, res, 'pages/download', {
87 pageTitle: track.title + ' — download', bodyClass: 'on-download',
88 dlState: 'form', dlTrack: track, dlPrefill: email,
89 dlError: r.error === 'invalid_email' ? 'Controleer je e-mailadres.' : 'Er ging iets mis.',
90 });
91 }
92 // Download vrijgeven in de sessie (kort venster).
93 if (!req.session.dl) req.session.dl = {};
94 req.session.dl[track.id] = Date.now();
95 renderPage(req, res, 'pages/download', {
96 pageTitle: track.title + ' — download', bodyClass: 'on-download',
97 dlState: 'ready', dlTrack: track,
98 });
99});
100
101// Het bestand serveren — alleen als er net een e-mail is achtergelaten (sessie).
102router.get('/download/:id/bestand', (req, res, next) => {
103 if (!premiumUnlocked()) return next();
104 const site = res.locals.site;
105 if (!site) return next();
106 const track = dlTrack(site.id, req.params.id);
107 if (!track) return next();
108 const ts = req.session && req.session.dl && req.session.dl[track.id];
109 if (!ts || (Date.now() - ts) > GRACE_MS) {
110 return res.status(403).send('Laat eerst je e-mailadres achter om te downloaden.');
111 }
112 const sp = track.storage_path;
113 if (!sp || sp.includes('/') || sp.includes('\\') || sp.includes('..')) return res.status(400).send('Bad path');
114 const filePath = path.join(AUDIO_DIR, sp);
115 if (!filePath.startsWith(AUDIO_DIR + path.sep)) return res.status(400).send('Bad path');
116 let stat;
117 try { stat = fs.statSync(filePath); } catch { return res.status(404).send('Bestand niet gevonden'); }
118 if (!stat.isFile()) return res.status(404).send('Bestand niet gevonden');
119 const ext = path.extname(sp).toLowerCase();
120 res.setHeader('Content-Type', MIME[ext] || 'application/octet-stream');
121 res.setHeader('Content-Length', stat.size);
122 res.setHeader('Content-Disposition', 'attachment; filename="' + safeName(track.title, sp) + '"');
123 fs.createReadStream(filePath).pipe(res);
124});
125
126export default router;
Note: See TracBrowser for help on using the repository browser.