small tweeks
This commit is contained in:
@@ -15,10 +15,17 @@
|
||||
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
|
||||
@@ -29,9 +36,34 @@ 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
|
||||
@@ -192,21 +224,120 @@ async function fetchJson(url, headers = {}) {
|
||||
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) {
|
||||
if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) throw new Error('Invalid image URL.');
|
||||
const res = await fetchWithTimeout(
|
||||
url,
|
||||
{ headers: { 'User-Agent': USER_AGENT, Accept: 'image/*' } },
|
||||
20000,
|
||||
);
|
||||
if (!res.ok) throw new Error(`Download failed (HTTP ${res.status}).`);
|
||||
const type = (res.headers.get('content-type') || '').toLowerCase();
|
||||
if (!type.startsWith('image/')) throw new Error(`URL did not return an image (${type || 'unknown content-type'}).`);
|
||||
const ext = type.includes('png') ? '.png' : type.includes('webp') ? '.webp' : '.jpg';
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
if (buffer.length === 0) throw new Error('Empty image.');
|
||||
if (buffer.length > MAX_IMAGE_BYTES) throw new Error('Image exceeds 15 MB limit.');
|
||||
return { buffer, ext };
|
||||
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. */
|
||||
@@ -351,6 +482,13 @@ function isPlaceholderImage(image) {
|
||||
// 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' }));
|
||||
@@ -368,6 +506,11 @@ app.use(express.static(path.join(__dirname, 'public'), {
|
||||
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}$/;
|
||||
@@ -621,21 +764,94 @@ app.post('/api/artwork/search', requireAuth, asyncH(async (req, res) => {
|
||||
res.json({ results: dedupeResults(results).slice(0, 12) });
|
||||
}));
|
||||
|
||||
// ------------------------ ZIP export + DB update ---------------------------
|
||||
app.post('/api/export', requireAuth, asyncH(async (req, res) => {
|
||||
const { profileId, updateDb = false, items = [] } = req.body || {};
|
||||
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 {
|
||||
const { buffer, ext } = await downloadImage(String(item.imageUrl || ''));
|
||||
return { ok: true, base, buffer, ext, songId: Number.isFinite(+item.id) ? +item.id : null };
|
||||
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 };
|
||||
}
|
||||
});
|
||||
@@ -676,7 +892,14 @@ app.post('/api/export', requireAuth, asyncH(async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
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 } });
|
||||
|
||||
Reference in New Issue
Block a user