| 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 |
|
|---|
| 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 |
|
|---|
| 23 | const router = express.Router();
|
|---|
| 24 | const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|---|
| 25 | const AUDIO_DIR = path.resolve(process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio'));
|
|---|
| 26 |
|
|---|
| 27 | const MIME = { '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.flac': 'audio/flac', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg' };
|
|---|
| 28 | const GRACE_MS = 15 * 60 * 1000; // download-venster na capture
|
|---|
| 29 |
|
|---|
| 30 | function 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 | }
|
|---|
| 37 | function 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.
|
|---|
| 44 | router.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.
|
|---|
| 60 | router.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.
|
|---|
| 77 | router.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).
|
|---|
| 102 | router.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 |
|
|---|
| 126 | export default router;
|
|---|