983 lines
39 KiB
JavaScript
983 lines
39 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* rdj-art-app — Album artwork manager for RadioDJ MySQL databases.
|
|
*
|
|
* - Local accounts + RadioDJ connection profiles stored in embedded SQLite.
|
|
* - First registered user becomes 'admin' (user management + profile cloning).
|
|
* - Artwork lookup via 100% free APIs: iTunes Search, fallback MusicBrainz +
|
|
* Cover Art Archive. No API keys required.
|
|
* - Confirmed artwork is downloaded, renamed to the base audio filename
|
|
* ("01 Track.mp3" -> "01 Track.jpg") and streamed back as a ZIP, with an
|
|
* optional UPDATE of the RadioDJ `songs.image` column.
|
|
*/
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const crypto = require('crypto');
|
|
const http = require('http');
|
|
const https = require('https');
|
|
const dns = require('dns').promises;
|
|
const net = require('net');
|
|
const os = require('os');
|
|
const express = require('express');
|
|
const Database = require('better-sqlite3');
|
|
const mysql = require('mysql2/promise');
|
|
const archiver = require('archiver');
|
|
const multer = require('multer');
|
|
const sharp = require('sharp');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Configuration
|
|
// ---------------------------------------------------------------------------
|
|
const PORT = Number.parseInt(process.env.PORT || '3000', 10);
|
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
|
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
const USER_AGENT = 'rdj-art-app/1.0 (open-source; contact: local-admin)';
|
|
const FETCH_TIMEOUT_MS = 12000;
|
|
const MAX_IMAGE_BYTES = 15 * 1024 * 1024;
|
|
const MAX_UPLOAD_FILES = 20;
|
|
const LOG_LEVELS = ['error', 'warn', 'info', 'debug'];
|
|
const LOG_LEVEL = String(process.env.LOG_LEVEL || 'info').toLowerCase();
|
|
if (!LOG_LEVELS.includes(LOG_LEVEL)) {
|
|
throw new Error(`Invalid LOG_LEVEL "${LOG_LEVEL}". Use error, warn, info, or debug.`);
|
|
}
|
|
const LOG_THRESHOLD = LOG_LEVELS.indexOf(LOG_LEVEL);
|
|
const privateAddressRanges = { ipv4: new net.BlockList(), ipv6: new net.BlockList() };
|
|
for (const range of [
|
|
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
|
|
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
|
|
['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24],
|
|
['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
|
|
]) privateAddressRanges.ipv4.addSubnet(range[0], range[1], 'ipv4');
|
|
for (const range of [
|
|
['::', 128], ['::1', 128], ['::ffff:0:0', 96], ['64:ff9b::', 96],
|
|
['100::', 64], ['2001:db8::', 32], ['2001:10::', 28], ['2001:20::', 28],
|
|
['fc00::', 7], ['fe80::', 10], ['ff00::', 8],
|
|
]) privateAddressRanges.ipv6.addSubnet(range[0], range[1], 'ipv6');
|
|
|
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
|
|
function log(level, event, details = {}) {
|
|
if (LOG_LEVELS.indexOf(level) > LOG_THRESHOLD) return;
|
|
const write = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log;
|
|
write(`[${level}] ${event} ${JSON.stringify(details)}`);
|
|
}
|
|
|
|
/**
|
|
* APP_SECRET encrypts stored RadioDJ MySQL passwords. In production always set
|
|
* it via the environment. For convenience a random secret is generated and
|
|
* persisted in the data directory on first boot (never commit that file).
|
|
*/
|
|
function loadSecret() {
|
|
if (process.env.APP_SECRET && process.env.APP_SECRET.length >= 16) {
|
|
return process.env.APP_SECRET;
|
|
}
|
|
const secretFile = path.join(DATA_DIR, '.app-secret');
|
|
try {
|
|
const existing = fs.readFileSync(secretFile, 'utf8').trim();
|
|
if (existing) {
|
|
console.warn('[security] APP_SECRET not set (or too short) — reusing secret from data/.app-secret');
|
|
return existing;
|
|
}
|
|
} catch { /* first boot */ }
|
|
const generated = crypto.randomBytes(32).toString('hex');
|
|
fs.writeFileSync(secretFile, generated, { mode: 0o600 });
|
|
console.warn('[security] APP_SECRET not set — generated a random secret at data/.app-secret. Set APP_SECRET in production!');
|
|
return generated;
|
|
}
|
|
const APP_SECRET = loadSecret();
|
|
const ENC_KEY = crypto.scryptSync(APP_SECRET, 'rdj-art-app/enc/v1', 32);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Crypto helpers
|
|
// ---------------------------------------------------------------------------
|
|
function encryptSecret(plain) {
|
|
const iv = crypto.randomBytes(12);
|
|
const cipher = crypto.createCipheriv('aes-256-gcm', ENC_KEY, iv);
|
|
const data = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
|
|
return JSON.stringify({
|
|
iv: iv.toString('base64'),
|
|
tag: cipher.getAuthTag().toString('base64'),
|
|
data: data.toString('base64'),
|
|
});
|
|
}
|
|
|
|
function decryptSecret(payload) {
|
|
const { iv, tag, data } = JSON.parse(payload);
|
|
const decipher = crypto.createDecipheriv('aes-256-gcm', ENC_KEY, Buffer.from(iv, 'base64'));
|
|
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
|
return Buffer.concat([decipher.update(Buffer.from(data, 'base64')), decipher.final()]).toString('utf8');
|
|
}
|
|
|
|
function hashPassword(password) {
|
|
const salt = crypto.randomBytes(16);
|
|
const hash = crypto.scryptSync(password, salt, 64, { N: 16384, r: 8, p: 1 });
|
|
return ['scrypt', 16384, 8, 1, salt.toString('base64'), hash.toString('base64')].join('$');
|
|
}
|
|
|
|
function verifyPassword(password, stored) {
|
|
try {
|
|
const [algo, N, r, p, saltB64, hashB64] = String(stored).split('$');
|
|
if (algo !== 'scrypt') return false;
|
|
const expected = Buffer.from(hashB64, 'base64');
|
|
const actual = crypto.scryptSync(password, Buffer.from(saltB64, 'base64'), expected.length, {
|
|
N: Number(N), r: Number(r), p: Number(p),
|
|
});
|
|
return crypto.timingSafeEqual(expected, actual);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SQLite (users, profiles, sessions)
|
|
// ---------------------------------------------------------------------------
|
|
const db = new Database(path.join(DATA_DIR, 'app.db'));
|
|
db.pragma('journal_mode = WAL');
|
|
db.pragma('foreign_keys = ON');
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
password_hash TEXT NOT NULL,
|
|
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin','user')),
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
CREATE TABLE IF NOT EXISTS profiles (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
host TEXT NOT NULL,
|
|
port INTEGER NOT NULL DEFAULT 3306,
|
|
db_name TEXT NOT NULL,
|
|
db_user TEXT NOT NULL,
|
|
db_pass_enc TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
token TEXT PRIMARY KEY,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
created_at INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_profiles_user ON profiles(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
|
`);
|
|
|
|
// Purge expired sessions hourly.
|
|
setInterval(() => {
|
|
try { db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now()); } catch { /* ignore */ }
|
|
}, 60 * 60 * 1000).unref();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Small utilities
|
|
// ---------------------------------------------------------------------------
|
|
const asyncH = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
|
|
|
function parseCookies(req) {
|
|
const out = {};
|
|
for (const part of (req.headers.cookie || '').split(';')) {
|
|
const i = part.indexOf('=');
|
|
if (i > -1) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function publicUser(u) {
|
|
return { id: u.id, username: u.username, role: u.role, createdAt: u.created_at };
|
|
}
|
|
|
|
/** Base name of an audio file without extension; handles / and \ separators. */
|
|
function audioBaseName(p) {
|
|
if (!p) return '';
|
|
const norm = String(p).replace(/\\/g, '/');
|
|
const base = norm.slice(norm.lastIndexOf('/') + 1);
|
|
const dot = base.lastIndexOf('.');
|
|
return dot > 0 ? base.slice(0, dot) : base;
|
|
}
|
|
|
|
/** Strip characters that are illegal in file names on Windows/POSIX. */
|
|
function sanitizeFilename(name) {
|
|
const cleaned = String(name)
|
|
// eslint-disable-next-line no-control-regex
|
|
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.slice(0, 150);
|
|
return cleaned || 'track';
|
|
}
|
|
|
|
async function fetchWithTimeout(url, options = {}, timeoutMs = FETCH_TIMEOUT_MS) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
return await fetch(url, { ...options, signal: controller.signal, redirect: 'follow' });
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
async function fetchJson(url, headers = {}) {
|
|
const res = await fetchWithTimeout(url, { headers });
|
|
if (!res.ok) throw new Error(`HTTP ${res.status} from ${new URL(url).host}`);
|
|
return res.json();
|
|
}
|
|
|
|
function imageExtensionFromBuffer(buffer) {
|
|
if (buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return '.png';
|
|
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg';
|
|
if (
|
|
buffer.toString('ascii', 0, 4) === 'RIFF'
|
|
&& buffer.toString('ascii', 8, 12) === 'WEBP'
|
|
) return '.webp';
|
|
return null;
|
|
}
|
|
|
|
async function normalizeArtworkForRadioDj(buffer) {
|
|
const ext = imageExtensionFromBuffer(buffer);
|
|
if (ext === '.webp') {
|
|
const converted = await sharp(buffer).jpeg({ quality: 90 }).toBuffer();
|
|
if (converted.length > MAX_IMAGE_BYTES) {
|
|
throw new Error('Converted image exceeds 15 MB limit.');
|
|
}
|
|
return { buffer: converted, ext: '.jpg', convertedFrom: '.webp' };
|
|
}
|
|
if (ext !== '.jpg' && ext !== '.png') {
|
|
throw new Error('Artwork must be a PNG, JPEG, or WebP image.');
|
|
}
|
|
return { buffer, ext, convertedFrom: null };
|
|
}
|
|
|
|
async function getPublicImageAddresses(url) {
|
|
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
|
throw new Error('Image URL must be a public HTTP or HTTPS URL.');
|
|
}
|
|
const hostname = url.hostname.replace(/^\[|\]$/g, '');
|
|
const addresses = net.isIP(hostname)
|
|
? [{ address: hostname, family: net.isIP(hostname) }]
|
|
: await dns.lookup(hostname, { all: true, verbatim: true });
|
|
const publicAddresses = addresses.filter(({ address, family }) =>
|
|
!privateAddressRanges[family === 6 ? 'ipv6' : 'ipv4'].check(address));
|
|
if (!publicAddresses.length) {
|
|
throw new Error('Image URL must resolve only to public IP addresses.');
|
|
}
|
|
return publicAddresses;
|
|
}
|
|
|
|
function requestImageFromAddress(url, address) {
|
|
return new Promise((resolve, reject) => {
|
|
const client = url.protocol === 'https:' ? https : http;
|
|
const request = client.request(url, {
|
|
headers: { 'User-Agent': USER_AGENT, Accept: 'image/*' },
|
|
lookup: (hostname, options, callback) => {
|
|
if (options?.all) callback(null, [address]);
|
|
else callback(null, address.address, address.family);
|
|
},
|
|
}, resolve);
|
|
request.setTimeout(20000, () => request.destroy(new Error('Image download timed out.')));
|
|
request.once('error', reject);
|
|
request.end();
|
|
});
|
|
}
|
|
|
|
async function downloadImage(url) {
|
|
let currentUrl;
|
|
try {
|
|
currentUrl = new URL(url);
|
|
} catch {
|
|
throw new Error('Invalid image URL.');
|
|
}
|
|
|
|
for (let redirects = 0; redirects <= 5; redirects += 1) {
|
|
const addresses = await getPublicImageAddresses(currentUrl);
|
|
let res;
|
|
let lastError;
|
|
for (const address of addresses) {
|
|
try {
|
|
res = await requestImageFromAddress(currentUrl, address);
|
|
break;
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
}
|
|
if (!res) throw new Error(`Image download failed: ${lastError?.message || 'No public address was reachable.'}`);
|
|
try {
|
|
if ([301, 302, 303, 307, 308].includes(res.statusCode)) {
|
|
const location = res.headers.location;
|
|
if (!location) throw new Error('Image URL redirect did not include a destination.');
|
|
if (redirects === 5) throw new Error('Image URL redirected too many times.');
|
|
res.destroy();
|
|
currentUrl = new URL(location, currentUrl);
|
|
continue;
|
|
}
|
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
throw new Error(`Download failed (HTTP ${res.statusCode}).`);
|
|
}
|
|
const type = (res.headers['content-type'] || '').toLowerCase();
|
|
if (!type.startsWith('image/')) {
|
|
throw new Error(`URL did not return an image (${type || 'unknown content-type'}).`);
|
|
}
|
|
const chunks = [];
|
|
let size = 0;
|
|
for await (const chunk of res) {
|
|
size += chunk.length;
|
|
if (size > MAX_IMAGE_BYTES) {
|
|
res.destroy();
|
|
throw new Error('Image exceeds 15 MB limit.');
|
|
}
|
|
chunks.push(chunk);
|
|
}
|
|
const buffer = Buffer.concat(chunks);
|
|
if (!buffer.length) throw new Error('Empty image.');
|
|
const ext = imageExtensionFromBuffer(buffer);
|
|
if (!ext) throw new Error('URL did not return a supported PNG, JPEG, or WebP image.');
|
|
return { buffer, ext };
|
|
} finally {
|
|
if (!res.complete) res.destroy();
|
|
}
|
|
}
|
|
throw new Error('Image URL redirected too many times.');
|
|
}
|
|
|
|
/** Run async fn over items with bounded concurrency, preserving order. */
|
|
async function mapLimit(items, limit, fn) {
|
|
const out = new Array(items.length);
|
|
let i = 0;
|
|
await Promise.all(
|
|
Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
while (i < items.length) {
|
|
const idx = i++;
|
|
out[idx] = await fn(items[idx], idx);
|
|
}
|
|
}),
|
|
);
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sessions / auth middleware
|
|
// ---------------------------------------------------------------------------
|
|
function getSessionUser(req) {
|
|
const token = parseCookies(req).rdj_session;
|
|
if (!token) return null;
|
|
const row = db.prepare(`
|
|
SELECT u.id, u.username, u.role, u.created_at, s.expires_at AS exp
|
|
FROM sessions s JOIN users u ON u.id = s.user_id
|
|
WHERE s.token = ?`).get(token);
|
|
if (!row) return null;
|
|
if (row.exp < Date.now()) {
|
|
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
|
|
return null;
|
|
}
|
|
return { id: row.id, username: row.username, role: row.role, created_at: row.created_at };
|
|
}
|
|
|
|
function createSession(res, userId) {
|
|
const token = crypto.randomBytes(32).toString('base64url');
|
|
const now = Date.now();
|
|
db.prepare('INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?,?,?,?)')
|
|
.run(token, userId, now, now + SESSION_TTL_MS);
|
|
const secure = process.env.COOKIE_SECURE === '1' ? '; Secure' : '';
|
|
res.setHeader(
|
|
'Set-Cookie',
|
|
`rdj_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}${secure}`,
|
|
);
|
|
}
|
|
|
|
function requireAuth(req, res, next) {
|
|
const user = getSessionUser(req);
|
|
if (!user) return res.status(401).json({ error: 'Authentication required.' });
|
|
req.user = user;
|
|
next();
|
|
}
|
|
|
|
function requireAdmin(req, res, next) {
|
|
if (!req.user || req.user.role !== 'admin') return res.status(403).json({ error: 'Admin role required.' });
|
|
next();
|
|
}
|
|
|
|
// Naive in-memory rate limiting for auth endpoints (20 attempts / 5 min / IP).
|
|
const authAttempts = new Map();
|
|
function authRateLimit(req, res, next) {
|
|
const key = req.ip || 'unknown';
|
|
const now = Date.now();
|
|
const rec = authAttempts.get(key) || { count: 0, reset: now + 5 * 60 * 1000 };
|
|
if (now > rec.reset) { rec.count = 0; rec.reset = now + 5 * 60 * 1000; }
|
|
rec.count += 1;
|
|
authAttempts.set(key, rec);
|
|
if (rec.count > 20) return res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
|
next();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RadioDJ profiles / dynamic MySQL pools
|
|
// ---------------------------------------------------------------------------
|
|
function validateProfileInput(body) {
|
|
const errors = [];
|
|
const name = String(body.name || '').trim();
|
|
const host = String(body.host || '').trim();
|
|
const port = Number.parseInt(body.port ?? '3306', 10);
|
|
const dbName = String(body.database || '').trim();
|
|
const dbUser = String(body.dbUser || '').trim();
|
|
const dbPass = body.dbPassword != null ? String(body.dbPassword) : null;
|
|
if (!name || name.length > 64) errors.push('Profile name is required (max 64 chars).');
|
|
if (!host || host.length > 255) errors.push('Host is required.');
|
|
if (!Number.isInteger(port) || port < 1 || port > 65535) errors.push('Port must be between 1 and 65535.');
|
|
if (!dbName) errors.push('Database name is required.');
|
|
if (!dbUser) errors.push('Database user is required.');
|
|
return { errors, value: { name, host, port, dbName, dbUser, dbPass } };
|
|
}
|
|
|
|
function getOwnedProfile(userId, profileId) {
|
|
return db.prepare('SELECT * FROM profiles WHERE id = ? AND user_id = ?').get(profileId, userId);
|
|
}
|
|
|
|
function profileToJson(p) {
|
|
return { id: p.id, name: p.name, host: p.host, port: p.port, database: p.db_name, dbUser: p.db_user, createdAt: p.created_at };
|
|
}
|
|
|
|
/** Create a temporary MySQL pool for the selected profile and always close it. */
|
|
async function withProfilePool(profile, fn) {
|
|
const pool = mysql.createPool({
|
|
host: profile.host,
|
|
port: profile.port,
|
|
user: profile.db_user,
|
|
password: decryptSecret(profile.db_pass_enc),
|
|
database: profile.db_name,
|
|
waitForConnections: true,
|
|
connectionLimit: 4,
|
|
connectTimeout: 8000,
|
|
charset: 'utf8mb4',
|
|
});
|
|
try {
|
|
return await fn(pool);
|
|
} finally {
|
|
await pool.end().catch(() => {});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* RadioDJ tracks that need artwork. Besides NULL/empty values, RadioDJ assigns
|
|
* default placeholder file names (e.g. 'no_cover_image.jpg', 'no_image.jpg',
|
|
* 'no-cover.jpg') to songs imported without artwork — treat those as missing.
|
|
*/
|
|
const MISSING_ARTWORK_WHERE = `
|
|
song_type = 0
|
|
AND (
|
|
image IS NULL
|
|
OR image = ''
|
|
OR LOWER(image) LIKE '%no_cover%'
|
|
OR LOWER(image) LIKE '%no_image%'
|
|
OR LOWER(image) LIKE '%no-cover%'
|
|
)`;
|
|
|
|
/** True when a RadioDJ `image` value is a known default placeholder. */
|
|
function isPlaceholderImage(image) {
|
|
const v = String(image || '').toLowerCase();
|
|
return v.includes('no_cover') || v.includes('no_image') || v.includes('no-cover');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Express app
|
|
// ---------------------------------------------------------------------------
|
|
const app = express();
|
|
const receiveExportUploads = multer({
|
|
storage: multer.diskStorage({
|
|
destination: os.tmpdir(),
|
|
filename: (req, file, callback) => callback(null, `rdj-art-${crypto.randomBytes(16).toString('hex')}`),
|
|
}),
|
|
limits: { fileSize: MAX_IMAGE_BYTES, files: MAX_UPLOAD_FILES, fields: 1, fieldSize: 2 * 1024 * 1024 },
|
|
}).any();
|
|
app.disable('x-powered-by');
|
|
app.set('trust proxy', true);
|
|
app.use(express.json({ limit: '2mb' }));
|
|
app.use((req, res, next) => {
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
res.setHeader('X-Frame-Options', 'DENY');
|
|
res.setHeader('Referrer-Policy', 'same-origin');
|
|
next();
|
|
});
|
|
app.use(express.static(path.join(__dirname, 'public'), {
|
|
maxAge: '1h',
|
|
index: 'index.html',
|
|
setHeaders: (res, filePath) => {
|
|
// Never serve stale HTML — always revalidate (ETag keeps it cheap).
|
|
if (filePath.endsWith('.html')) res.setHeader('Cache-Control', 'no-cache');
|
|
},
|
|
}));
|
|
app.get('/Album-Art.png', (req, res, next) => {
|
|
res.sendFile(path.join(__dirname, 'Album-Art.png'), err => {
|
|
if (err) next(err);
|
|
});
|
|
});
|
|
|
|
// ------------------------------ Auth --------------------------------------
|
|
const USERNAME_RE = /^[A-Za-z0-9_.-]{3,32}$/;
|
|
|
|
app.post('/api/auth/register', authRateLimit, asyncH(async (req, res) => {
|
|
const { username, password } = req.body || {};
|
|
if (!USERNAME_RE.test(String(username || ''))) {
|
|
return res.status(400).json({ error: 'Username must be 3-32 chars (letters, numbers, _ . -).' });
|
|
}
|
|
if (typeof password !== 'string' || password.length < 8) {
|
|
return res.status(400).json({ error: 'Password must be at least 8 characters.' });
|
|
}
|
|
const userCount = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
|
|
const role = userCount === 0 ? 'admin' : 'user';
|
|
try {
|
|
const info = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?,?,?)')
|
|
.run(String(username).trim(), hashPassword(password), role);
|
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid);
|
|
createSession(res, user.id);
|
|
res.status(201).json({ user: publicUser(user) });
|
|
} catch (e) {
|
|
if (String(e.message).includes('UNIQUE')) return res.status(409).json({ error: 'Username already taken.' });
|
|
throw e;
|
|
}
|
|
}));
|
|
|
|
app.post('/api/auth/login', authRateLimit, asyncH(async (req, res) => {
|
|
const { username, password } = req.body || {};
|
|
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(String(username || '').trim());
|
|
if (!user || !verifyPassword(String(password || ''), user.password_hash)) {
|
|
return res.status(401).json({ error: 'Invalid username or password.' });
|
|
}
|
|
createSession(res, user.id);
|
|
res.json({ user: publicUser(user) });
|
|
}));
|
|
|
|
app.post('/api/auth/logout', (req, res) => {
|
|
const token = parseCookies(req).rdj_session;
|
|
if (token) db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
|
|
res.setHeader('Set-Cookie', 'rdj_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.get('/api/auth/me', (req, res) => {
|
|
const user = getSessionUser(req);
|
|
if (!user) return res.status(401).json({ error: 'Not signed in.' });
|
|
res.json({ user: publicUser(user) });
|
|
});
|
|
|
|
// ---------------------------- Profiles ------------------------------------
|
|
app.get('/api/profiles', requireAuth, (req, res) => {
|
|
const rows = db.prepare('SELECT * FROM profiles WHERE user_id = ? ORDER BY name').all(req.user.id);
|
|
res.json({ profiles: rows.map(profileToJson) });
|
|
});
|
|
|
|
app.post('/api/profiles', requireAuth, asyncH(async (req, res) => {
|
|
const { errors, value } = validateProfileInput(req.body || {});
|
|
if (!value.dbPass) errors.push('Database password is required.');
|
|
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
|
|
const info = db.prepare(`
|
|
INSERT INTO profiles (user_id, name, host, port, db_name, db_user, db_pass_enc)
|
|
VALUES (?,?,?,?,?,?,?)`)
|
|
.run(req.user.id, value.name, value.host, value.port, value.dbName, value.dbUser, encryptSecret(value.dbPass));
|
|
res.status(201).json({ profile: profileToJson(db.prepare('SELECT * FROM profiles WHERE id = ?').get(info.lastInsertRowid)) });
|
|
}));
|
|
|
|
app.put('/api/profiles/:id', requireAuth, asyncH(async (req, res) => {
|
|
const existing = getOwnedProfile(req.user.id, req.params.id);
|
|
if (!existing) return res.status(404).json({ error: 'Profile not found.' });
|
|
const { errors, value } = validateProfileInput(req.body || {});
|
|
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
|
|
// Blank password keeps the stored one.
|
|
const enc = value.dbPass ? encryptSecret(value.dbPass) : existing.db_pass_enc;
|
|
db.prepare('UPDATE profiles SET name=?, host=?, port=?, db_name=?, db_user=?, db_pass_enc=? WHERE id=?')
|
|
.run(value.name, value.host, value.port, value.dbName, value.dbUser, enc, existing.id);
|
|
res.json({ profile: profileToJson(db.prepare('SELECT * FROM profiles WHERE id = ?').get(existing.id)) });
|
|
}));
|
|
|
|
app.delete('/api/profiles/:id', requireAuth, (req, res) => {
|
|
const existing = getOwnedProfile(req.user.id, req.params.id);
|
|
if (!existing) return res.status(404).json({ error: 'Profile not found.' });
|
|
db.prepare('DELETE FROM profiles WHERE id = ?').run(existing.id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.post('/api/profiles/:id/test', requireAuth, asyncH(async (req, res) => {
|
|
const profile = getOwnedProfile(req.user.id, req.params.id);
|
|
if (!profile) return res.status(404).json({ error: 'Profile not found.' });
|
|
try {
|
|
const result = await withProfilePool(profile, async pool => {
|
|
const [[ver]] = await pool.query('SELECT VERSION() AS version');
|
|
const [[cnt]] = await pool.query(
|
|
`SELECT COUNT(*) AS c FROM songs WHERE ${MISSING_ARTWORK_WHERE}`,
|
|
);
|
|
return { version: ver.version, missingArtwork: Number(cnt.c) };
|
|
});
|
|
res.json({ ok: true, ...result });
|
|
} catch (e) {
|
|
res.status(502).json({ ok: false, error: `Connection failed: ${e.message}` });
|
|
}
|
|
}));
|
|
|
|
// ----------------------------- Tracks -------------------------------------
|
|
app.get('/api/tracks', requireAuth, asyncH(async (req, res) => {
|
|
const profile = getOwnedProfile(req.user.id, req.query.profileId);
|
|
if (!profile) return res.status(404).json({ error: 'Profile not found.' });
|
|
const rawLimit = String(req.query.limit || '25');
|
|
const fetchAll = rawLimit === 'all';
|
|
const limit = fetchAll ? null : Math.min(Math.max(Number.parseInt(rawLimit, 10) || 25, 1), 1000);
|
|
try {
|
|
const payload = await withProfilePool(profile, async pool => {
|
|
const [[cnt]] = await pool.query(`SELECT COUNT(*) AS c FROM songs WHERE ${MISSING_ARTWORK_WHERE}`);
|
|
const totalTracks = Number(cnt.c);
|
|
const totalPages = fetchAll ? 1 : Math.max(1, Math.ceil(totalTracks / limit));
|
|
const page = fetchAll ? 1 : Math.min(Math.max(Number.parseInt(req.query.page || '1', 10) || 1, 1), totalPages);
|
|
const offset = fetchAll ? 0 : (page - 1) * limit;
|
|
const sql = `SELECT id, artist, title, album, path, image
|
|
FROM songs
|
|
WHERE ${MISSING_ARTWORK_WHERE}
|
|
ORDER BY artist ASC, title ASC` + (fetchAll ? '' : '\n LIMIT ? OFFSET ?');
|
|
const rows = await pool.query(sql, fetchAll ? [] : [limit, offset]).then(([r]) => r);
|
|
return { rows, totalTracks, page, totalPages };
|
|
});
|
|
res.json({
|
|
tracks: payload.rows.map(r => ({
|
|
id: r.id,
|
|
artist: r.artist || '',
|
|
title: r.title || '',
|
|
album: r.album || '',
|
|
path: r.path || '',
|
|
audioBase: audioBaseName(r.path),
|
|
image: r.image || '',
|
|
placeholder: isPlaceholderImage(r.image),
|
|
})),
|
|
totalTracks: payload.totalTracks,
|
|
page: payload.page,
|
|
totalPages: payload.totalPages,
|
|
});
|
|
} catch (e) {
|
|
res.status(502).json({ error: `Database error: ${e.message}` });
|
|
}
|
|
}));
|
|
|
|
// ------------------------ Artwork search (free) ----------------------------
|
|
function dedupeResults(list) {
|
|
const seen = new Set();
|
|
const out = [];
|
|
for (const r of list) {
|
|
if (r.full && !seen.has(r.full)) { seen.add(r.full); out.push(r); }
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function escapeLucene(s) {
|
|
return String(s).replace(/[+\-!(){}[\]^"~*?:\\/]|&&|\|\|/g, ' ').replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
app.post('/api/artwork/search', requireAuth, asyncH(async (req, res) => {
|
|
const artist = String(req.body?.artist || '').trim();
|
|
const title = String(req.body?.title || '').trim();
|
|
const album = String(req.body?.album || '').trim();
|
|
const source = ['auto', 'itunes', 'deezer', 'musicbrainz'].includes(req.body?.source) ? req.body.source : 'auto';
|
|
if (!artist && !title && !album) return res.status(400).json({ error: 'Provide an artist and/or title.' });
|
|
const results = [];
|
|
|
|
// 1) iTunes Search API — free, no key.
|
|
if (source === 'auto' || source === 'itunes') {
|
|
try {
|
|
const term = [artist, album || title].filter(Boolean).join(' ');
|
|
const data = await fetchJson(
|
|
`https://itunes.apple.com/search?media=music&entity=album&limit=10&term=${encodeURIComponent(term)}`,
|
|
);
|
|
for (const item of data.results || []) {
|
|
if (!item.artworkUrl100) continue;
|
|
results.push({
|
|
source: 'itunes',
|
|
sourceLabel: 'iTunes API',
|
|
artist: item.artistName || artist,
|
|
album: item.collectionName || '',
|
|
thumb: item.artworkUrl100.replace('100x100bb', '200x200bb'),
|
|
full: item.artworkUrl100.replace('100x100bb', '1200x1200bb'),
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.warn('[artwork] iTunes search failed:', e.message);
|
|
}
|
|
}
|
|
|
|
// 2) Deezer API — free, no key.
|
|
if (source === 'auto' || source === 'deezer') {
|
|
if (source === 'deezer' || results.length < 3) {
|
|
try {
|
|
const q = [artist, album || title].filter(Boolean).join(' ');
|
|
const dz = await fetchJson(`https://api.deezer.com/search/album?limit=10&q=${encodeURIComponent(q)}`);
|
|
for (const d of dz.data || []) {
|
|
const cover = d.cover_xl || d.cover_big || d.cover_medium;
|
|
if (!cover) continue;
|
|
results.push({
|
|
source: 'deezer',
|
|
sourceLabel: 'Deezer',
|
|
artist: d.artist?.name || artist,
|
|
album: d.title || '',
|
|
thumb: d.cover_medium || cover,
|
|
full: cover,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.warn('[artwork] Deezer search failed:', e.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3) MusicBrainz + Cover Art Archive fallback — free, no key.
|
|
if (source === 'auto' || source === 'musicbrainz') {
|
|
if (source === 'musicbrainz' || results.length < 3) {
|
|
try {
|
|
const clauses = [];
|
|
if (album) clauses.push(`release:"${escapeLucene(album)}"`);
|
|
else if (title) clauses.push(`release:"${escapeLucene(title)}"`);
|
|
if (artist) clauses.push(`artist:"${escapeLucene(artist)}"`);
|
|
const q = clauses.join(' AND ') || escapeLucene(`${artist} ${title}`);
|
|
const mb = await fetchJson(
|
|
`https://musicbrainz.org/ws/2/release/?fmt=json&limit=5&query=${encodeURIComponent(q)}`,
|
|
{ 'User-Agent': USER_AGENT, Accept: 'application/json' },
|
|
);
|
|
for (const rel of (mb.releases || []).slice(0, 4)) {
|
|
try {
|
|
const caa = await fetchJson(
|
|
`https://coverartarchive.org/release/${rel.id}`,
|
|
{ 'User-Agent': USER_AGENT, Accept: 'application/json' },
|
|
);
|
|
const front = (caa.images || []).find(i => i.front) || (caa.images || [])[0];
|
|
if (front && front.image) {
|
|
results.push({
|
|
source: 'musicbrainz',
|
|
sourceLabel: 'Cover Art Archive / MusicBrainz',
|
|
artist: rel['artist-credit']?.[0]?.name || artist,
|
|
album: rel.title || '',
|
|
thumb: front.thumbnails?.small || front.thumbnails?.['250'] || front.image,
|
|
full: front.image,
|
|
});
|
|
}
|
|
} catch { /* release has no cover art */ }
|
|
}
|
|
} catch (e) {
|
|
console.warn('[artwork] MusicBrainz search failed:', e.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
res.json({ results: dedupeResults(results).slice(0, 12) });
|
|
}));
|
|
|
|
app.post('/api/artwork/selection', requireAuth, (req, res) => {
|
|
const trackId = Number(req.body?.trackId);
|
|
const source = String(req.body?.source || '');
|
|
const validSources = ['itunes', 'deezer', 'musicbrainz', 'manual_upload', 'url_upload', 'unknown'];
|
|
if (!Number.isSafeInteger(trackId) || trackId <= 0 || !validSources.includes(source)) {
|
|
return res.status(400).json({ error: 'Invalid artwork selection details.' });
|
|
}
|
|
log('info', 'artwork.selected', { trackId, source });
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// ------------------------ ZIP export + DB update ---------------------------
|
|
app.post('/api/export', requireAuth, (req, res, next) => {
|
|
receiveExportUploads(req, res, err => {
|
|
let cleaned = false;
|
|
const cleanup = () => {
|
|
if (cleaned) return;
|
|
cleaned = true;
|
|
Promise.all((req.files || []).map(file => fs.promises.unlink(file.path)))
|
|
.catch(error => console.error('[export] temporary upload cleanup failed:', error));
|
|
};
|
|
if (err) {
|
|
cleanup();
|
|
const tooLarge = err.code === 'LIMIT_FILE_SIZE';
|
|
return res.status(400).json({
|
|
error: tooLarge
|
|
? 'Uploaded images must be 15 MB or smaller.'
|
|
: `Upload failed: ${err.message}`,
|
|
});
|
|
}
|
|
res.once('finish', cleanup);
|
|
res.once('close', cleanup);
|
|
next();
|
|
});
|
|
}, asyncH(async (req, res) => {
|
|
let body = req.body || {};
|
|
if (req.is('multipart/form-data')) {
|
|
try {
|
|
body = JSON.parse(req.body?.payload || '{}');
|
|
} catch {
|
|
return res.status(400).json({ error: 'Invalid export payload.' });
|
|
}
|
|
}
|
|
const { profileId, updateDb = false, items = [] } = body;
|
|
if (!Array.isArray(items) || items.length === 0) return res.status(400).json({ error: 'No items provided.' });
|
|
if (items.length > 500) return res.status(400).json({ error: 'Too many items (max 500 per export).' });
|
|
if ((req.files || []).length > MAX_UPLOAD_FILES) {
|
|
return res.status(400).json({ error: `Too many uploaded images (max ${MAX_UPLOAD_FILES} per export).` });
|
|
}
|
|
|
|
const report = { generatedAt: new Date().toISOString(), downloaded: [], updated: [], errors: [] };
|
|
|
|
// Download artwork (bounded concurrency), rename to base audio filename.
|
|
const results = await mapLimit(items, 4, async item => {
|
|
const base = sanitizeFilename(item.audioBase || audioBaseName(item.path) || `track-${item.id ?? 'x'}`);
|
|
const trackId = Number.isSafeInteger(Number(item.id)) ? Number(item.id) : null;
|
|
const source = item.upload === true ? 'manual_upload' : /^https?:\/\//i.test(String(item.imageUrl || '')) ? 'url_upload' : 'provider';
|
|
log('debug', 'artwork.export_item_started', { trackId, source });
|
|
try {
|
|
let downloaded;
|
|
if (item.upload === true) {
|
|
const uploaded = (req.files || []).find(file => file.fieldname === `upload-${item.id}`);
|
|
if (!uploaded) throw new Error('Uploaded artwork is missing.');
|
|
const buffer = await fs.promises.readFile(uploaded.path);
|
|
const ext = imageExtensionFromBuffer(buffer);
|
|
if (!ext) throw new Error('Uploaded file must be a PNG, JPEG, or WebP image.');
|
|
downloaded = { buffer, ext };
|
|
} else {
|
|
downloaded = await downloadImage(String(item.imageUrl || ''));
|
|
}
|
|
const normalized = await normalizeArtworkForRadioDj(downloaded.buffer);
|
|
if (normalized.convertedFrom) {
|
|
log('debug', 'artwork.converted', {
|
|
trackId,
|
|
from: normalized.convertedFrom,
|
|
to: normalized.ext,
|
|
});
|
|
}
|
|
log('debug', 'artwork.export_item_downloaded', { trackId, format: normalized.ext });
|
|
return {
|
|
ok: true,
|
|
base,
|
|
buffer: normalized.buffer,
|
|
ext: normalized.ext,
|
|
songId: Number.isFinite(+item.id) ? +item.id : null,
|
|
};
|
|
} catch (e) {
|
|
log('warn', 'artwork.export_item_failed', { trackId, source, error: e.message });
|
|
return { ok: false, base, id: item.id ?? null, error: e.message };
|
|
}
|
|
});
|
|
|
|
const usedNames = new Set();
|
|
const files = [];
|
|
for (const r of results) {
|
|
if (!r.ok) {
|
|
report.errors.push({ file: r.base, id: r.id, error: r.error });
|
|
continue;
|
|
}
|
|
let name = `${r.base}${r.ext}`;
|
|
let n = 2;
|
|
while (usedNames.has(name.toLowerCase())) name = `${r.base} (${n++})${r.ext}`;
|
|
usedNames.add(name.toLowerCase());
|
|
files.push({ name, buffer: r.buffer, songId: r.songId });
|
|
report.downloaded.push({ name });
|
|
}
|
|
|
|
// Optional: UPDATE the RadioDJ `songs.image` column to the new file name.
|
|
if (updateDb) {
|
|
const profile = profileId ? getOwnedProfile(req.user.id, profileId) : null;
|
|
if (!profile) return res.status(400).json({ error: 'updateDb requires a valid profileId.' });
|
|
try {
|
|
await withProfilePool(profile, async pool => {
|
|
for (const f of files) {
|
|
if (f.songId == null) continue;
|
|
try {
|
|
await pool.query('UPDATE songs SET image = ? WHERE id = ?', [f.name, f.songId]);
|
|
report.updated.push({ id: f.songId, image: f.name });
|
|
} catch (e) {
|
|
report.errors.push({ id: f.songId, image: f.name, error: `DB update failed: ${e.message}` });
|
|
}
|
|
}
|
|
});
|
|
} catch (e) {
|
|
return res.status(502).json({ error: `Database error: ${e.message}` });
|
|
}
|
|
}
|
|
|
|
log('info', 'artwork.export_completed', {
|
|
downloaded: report.downloaded.length,
|
|
errors: report.errors.length,
|
|
databaseUpdated: report.updated.length,
|
|
});
|
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
res.setHeader('X-Export-Downloaded', String(report.downloaded.length));
|
|
res.setHeader('X-Export-Errors', String(report.errors.length));
|
|
res.setHeader('Content-Type', 'application/zip');
|
|
res.setHeader('Content-Disposition', `attachment; filename="rdj-artwork-${stamp}.zip"`);
|
|
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
archive.on('error', err => {
|
|
console.error('[export] archive error:', err);
|
|
res.destroy(err);
|
|
});
|
|
archive.pipe(res);
|
|
for (const f of files) archive.append(f.buffer, { name: f.name });
|
|
archive.append(JSON.stringify(report, null, 2), { name: 'export-report.json' });
|
|
await archive.finalize();
|
|
}));
|
|
|
|
// ------------------------------ Admin --------------------------------------
|
|
app.get('/api/admin/users', requireAuth, requireAdmin, (req, res) => {
|
|
const users = db.prepare(`
|
|
SELECT u.id, u.username, u.role, u.created_at,
|
|
(SELECT COUNT(*) FROM profiles p WHERE p.user_id = u.id) AS profileCount
|
|
FROM users u ORDER BY u.id`).all();
|
|
res.json({
|
|
users: users.map(u => ({
|
|
id: u.id, username: u.username, role: u.role, createdAt: u.created_at, profileCount: u.profileCount,
|
|
})),
|
|
});
|
|
});
|
|
|
|
app.put('/api/admin/users/:id/role', requireAuth, requireAdmin, (req, res) => {
|
|
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
|
if (!target) return res.status(404).json({ error: 'User not found.' });
|
|
const role = req.body?.role;
|
|
if (!['admin', 'user'].includes(role)) return res.status(400).json({ error: 'Role must be "admin" or "user".' });
|
|
if (target.id === req.user.id) return res.status(400).json({ error: 'You cannot change your own role.' });
|
|
if (target.role === 'admin' && role === 'user') {
|
|
const admins = db.prepare("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'").get().c;
|
|
if (admins <= 1) return res.status(400).json({ error: 'Cannot demote the last admin.' });
|
|
}
|
|
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, target.id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.post('/api/admin/clone-profiles', requireAuth, requireAdmin, (req, res) => {
|
|
const from = db.prepare('SELECT * FROM users WHERE id = ?').get(req.body?.fromUserId);
|
|
const to = db.prepare('SELECT * FROM users WHERE id = ?').get(req.body?.toUserId);
|
|
if (!from || !to) return res.status(404).json({ error: 'User not found.' });
|
|
if (from.id === to.id) return res.status(400).json({ error: 'Source and target user must differ.' });
|
|
const rows = db.prepare('SELECT * FROM profiles WHERE user_id = ?').all(from.id);
|
|
if (rows.length === 0) return res.status(400).json({ error: `User "${from.username}" has no profiles to copy.` });
|
|
const insert = db.prepare(`
|
|
INSERT INTO profiles (user_id, name, host, port, db_name, db_user, db_pass_enc)
|
|
VALUES (?,?,?,?,?,?,?)`);
|
|
const tx = db.transaction(() => {
|
|
for (const p of rows) insert.run(to.id, p.name, p.host, p.port, p.db_name, p.db_user, p.db_pass_enc);
|
|
});
|
|
tx();
|
|
res.json({ ok: true, cloned: rows.length });
|
|
});
|
|
|
|
// ------------------------------ Misc ---------------------------------------
|
|
app.get('/api/health', (req, res) => {
|
|
const users = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
|
|
res.json({ ok: true, users });
|
|
});
|
|
|
|
app.use('/api', (req, res) => res.status(404).json({ error: 'Not found.' }));
|
|
|
|
// SPA fallback.
|
|
app.get('*', (req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
|
|
|
|
// Central error handler.
|
|
// eslint-disable-next-line no-unused-vars
|
|
app.use((err, req, res, next) => {
|
|
console.error('[error]', err);
|
|
if (res.headersSent) return;
|
|
res.status(err.status || 500).json({ error: err.expose ? err.message : 'Internal server error.' });
|
|
});
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`rdj-art-app listening on http://0.0.0.0:${PORT}`);
|
|
console.log(`Data directory: ${DATA_DIR}`);
|
|
});
|