small tweeks

This commit is contained in:
minster586
2026-11-02 05:46:56 -05:00
parent b9a2cc584b
commit 86ab23a814
8 changed files with 1076 additions and 54 deletions
+242 -19
View File
@@ -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 } });