| 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 |
|
|---|
| 14 | import express from 'express';
|
|---|
| 15 | import path from 'path';
|
|---|
| 16 | import fs from 'fs';
|
|---|
| 17 | import { fileURLToPath } from 'url';
|
|---|
| 18 | import db from '../config/database.js';
|
|---|
| 19 | import { renderPage } from '../middleware/render.js';
|
|---|
| 20 | import { premiumUnlocked } from '../services/PatreonService.js';
|
|---|
| 21 | import { addSubscriber } from '../services/SubscriberService.js';
|
|---|
| 22 | import { postNeighbors } from './posts.js';
|
|---|
| 23 |
|
|---|
| 24 | const 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.
|
|---|
| 29 | function 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 | }
|
|---|
| 38 | const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|---|
| 39 | const AUDIO_DIR = path.resolve(process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio'));
|
|---|
| 40 |
|
|---|
| 41 | const MIME = { '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.flac': 'audio/flac', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg' };
|
|---|
| 42 | const GRACE_MS = 15 * 60 * 1000; // download window after capture
|
|---|
| 43 |
|
|---|
| 44 | function 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 | }
|
|---|
| 51 | function 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.
|
|---|
| 58 | router.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.
|
|---|
| 79 | router.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.
|
|---|
| 96 | router.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).
|
|---|
| 125 | router.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 |
|
|---|
| 151 | export default router;
|
|---|