diff --git a/app.tsx b/app.tsx index 85fa2a6..28b64ca 100644 --- a/app.tsx +++ b/app.tsx @@ -725,10 +725,26 @@ export default function App() { const doneCount = queue.filter(i => i.status === 'done').length; const progress = queue.length > 0 ? Math.round((doneCount / queue.length) * 100) : 0; - const isMp3 = activeItem ? activeItem.file.name.toLowerCase().endsWith('.mp3') : false; + const getExt = (name: string) => { + const i = name.lastIndexOf('.'); + return i >= 0 ? name.slice(i).toLowerCase() : ''; + }; + const activeExt = activeItem ? getExt(activeItem.file.name) : ''; + const isMp3 = activeExt === '.mp3'; + const isServerSupportedFormat = activeExt === '.mp4' || activeExt === '.m4a'; const quickDisabledReason = !activeItem ? 'Select a file first.' : !isMp3 ? 'Quick Cleanse supports MP3 files only.' : ''; const seoDisabledReason = !activeItem ? 'Select a file to provide context first.' : ''; - const serverDisabledReason = isBatching ? 'Server cleanse already running.' : queue.length === 0 ? 'Add at least one file first.' : queue.every(i => i.status === 'done') ? 'All files are already completed.' : ''; + const serverDisabledReason = isBatching + ? 'Server cleanse already running.' + : queue.length === 0 + ? 'Add at least one file first.' + : queue.every(i => i.status === 'done') + ? 'All files are already completed.' + : !activeItem + ? 'Select a file first.' + : !isServerSupportedFormat + ? 'Full Server Cleanse currently supports MP4/M4A only.' + : ''; const resultSource = activeItem?.downloadName?.startsWith('quick_cleansed_') ? 'Browser Quick Cleanse' : 'Full Server Cleanse'; // ── Render ─────────────────────────────────────────────────────────────────── @@ -1042,10 +1058,15 @@ export default function App() {
-

Deeper forensic server pipeline • all supported formats • usage-counted.

+

Recommended for MP4/M4A • usage-counted • free plan allowed up to monthly limit.

{serverDisabledReason &&

{serverDisabledReason}

}
+ {activeItem && (activeExt === '.wav' || activeExt === '.flac') && ( +

+ WAV/FLAC server cleanse is currently not enabled. Use Quick Cleanse if available, or convert to M4A/MP4 for Full Server Cleanse. +

+ )} {activeItem.downloadUrl && (

Result Source: {resultSource}

diff --git a/server.js b/server.js index b1cf986..819fd54 100644 --- a/server.js +++ b/server.js @@ -6,6 +6,7 @@ const path = require('path'); const fs = require('fs-extra'); const { exiftool } = require('exiftool-vendored'); const { processMediaFile } = require('./server/processor'); +const { CLEANSE_POLICY, normalizeExt, isServerSupportedFormat } = require('./server/cleansePolicy'); const cleanup = require('./server/cleanup'); const downloadTokens = require('./server/downloadTokens'); const crypto = require('crypto'); @@ -485,20 +486,27 @@ app.post('/api/process', requireAuth, upload.single('file'), async (req, res) => const userId = req.user.sub; const inputPath = req.file.path; const originalName = req.file.originalname || ''; - const ext = path.extname(originalName).toLowerCase() || '.mp3'; + const ext = normalizeExt(originalName); const mime = (req.file.mimetype || '').toLowerCase(); - const isMp3 = ext === '.mp3' || mime === 'audio/mpeg'; - if (isMp3) { - await fs.remove(inputPath).catch(() => {}); - return res.status(422).json({ error: 'MP3 server cleanse is not supported', detail: 'Use Quick Cleanse (Browser) for MP3. Full Server Cleanse is best supported for MP4/M4A; WAV/FLAC may be rejected if ExifTool cannot safely rewrite them.' }); - } const dbUser = db.prepare('SELECT plan FROM users WHERE id = ?').get(userId); const userPlan = dbUser?.plan ?? 'free'; + console.info('[process] request', { fileName: originalName, mime, extension: ext || '(none)', mode: 'server', userPlan }); + if (!isServerSupportedFormat(originalName, mime)) { + await fs.remove(inputPath).catch(() => {}); + console.info('[process] rejected', { reason: 'unsupported_file_type', extension: ext || '(none)', mime, userPlan }); + return res.status(422).json({ + error: 'Unsupported file type for Full Server Cleanse', + detail: 'Full Server Cleanse currently supports MP4 and M4A only. Use Quick Cleanse (Browser) for MP3, or convert WAV/FLAC to M4A/MP4.', + reason: 'unsupported_file_type', + supportedServerFormats: CLEANSE_POLICY.server.supportedExtensions, + }); + } if (userPlan === 'free') { const usedThisMonth = getMonthlyJobCount(userId); if (usedThisMonth >= FREE_MONTHLY_LIMIT) { await fs.remove(req.file.path).catch(() => {}); - return res.status(402).json({ error: 'Monthly limit reached', detail: `Free accounts are limited to ${FREE_MONTHLY_LIMIT} files per month. Upgrade to continue processing.`, usedThisMonth, limit: FREE_MONTHLY_LIMIT, upgradeRequired: true }); + console.info('[process] rejected', { reason: 'usage_limit', userPlan, usedThisMonth, limit: FREE_MONTHLY_LIMIT }); + return res.status(402).json({ error: 'Monthly limit reached', detail: `Free accounts are limited to ${FREE_MONTHLY_LIMIT} files per month. Upgrade to continue processing.`, reason: 'usage_limit', usedThisMonth, limit: FREE_MONTHLY_LIMIT, upgradeRequired: true }); } } const { title, description, tags, artist, genre, lyrics, platform = 'General' } = req.body; @@ -518,7 +526,8 @@ app.post('/api/process', requireAuth, upload.single('file'), async (req, res) => } catch (err) { console.error('Processing error:', err); const status = err.statusCode || 500; - res.status(status).json({ error: status === 422 ? err.message : 'Processing failed', detail: err.publicDetail || err.message }); + const reason = err.reason || (status === 422 ? 'unsupported_file_type' : 'server_processing_failure'); + res.status(status).json({ error: status === 422 ? err.message : 'Processing failed', detail: err.publicDetail || err.message, reason }); await fs.remove(inputPath).catch(() => {}); await fs.remove(outputPath).catch(() => {}); } @@ -529,17 +538,16 @@ app.post('/api/process-batch', requireAuth, upload.array('files', 20), async (re const files = req.files || []; const dbUser = db.prepare('SELECT plan FROM users WHERE id = ?').get(userId); const userPlan = dbUser?.plan ?? 'free'; - if (userPlan === 'free') { await Promise.all(files.map((f) => fs.remove(f.path).catch(() => {}))); return res.status(403).json({ error: 'Batch processing requires Creator or Studio plan.' }); } + if (userPlan === 'free') { await Promise.all(files.map((f) => fs.remove(f.path).catch(() => {}))); return res.status(403).json({ error: 'Batch processing requires Creator or Studio plan.', reason: 'plan_restriction' }); } const totalBytes = files.reduce((n, f) => n + (f.size || 0), 0); // 2GB is a post-Multer soft guard; deployment/proxy/body-size limits are still required. if (totalBytes > 2 * 1024 * 1024 * 1024) { await Promise.all(files.map((f) => fs.remove(f.path).catch(() => {}))); return res.status(400).json({ error: 'Batch total exceeds 2GB limit.' }); } const { title, description, tags, artist, genre, lyrics, platform = 'General' } = req.body; const results = []; for (const file of files) { - const ext = path.extname(file.originalname || '').toLowerCase() || '.mp4'; + const ext = normalizeExt(file.originalname || ''); const mime = (file.mimetype || '').toLowerCase(); - const isMp3 = ext === '.mp3' || mime === 'audio/mpeg'; - if (isMp3) { await fs.remove(file.path).catch(() => {}); results.push({ originalName: file.originalname, error: 'MP3 server cleanse is not supported. Use Quick Cleanse (Browser) for MP3.' }); continue; } + if (!isServerSupportedFormat(file.originalname || '', mime)) { await fs.remove(file.path).catch(() => {}); results.push({ originalName: file.originalname, error: 'Full Server Cleanse currently supports MP4 and M4A only. Use Quick Cleanse (Browser) for MP3, or convert WAV/FLAC to M4A/MP4.', reason: 'unsupported_file_type' }); continue; } const outputPath = path.join('uploads', `out_batch_${Date.now()}_${crypto.randomUUID()}${ext}`); try { await fs.copy(file.path, outputPath); const { report } = await processMediaFile({ outputPath, originalName: file.originalname, platform, metadata: { title, description, tags, artist, genre, lyrics } }); db.prepare('INSERT INTO jobs (user_id, filename, platform) VALUES (?, ?, ?)').run(userId, file.originalname, platform); cleanup.registerForCleanup([outputPath]); const token = downloadTokens.createToken({ userId, filePath: outputPath, downloadName: `cleansed_${file.originalname}` }); results.push({ originalName: file.originalname, report, downloadToken: token }); } catch (err) { await fs.remove(outputPath).catch(() => {}); results.push({ originalName: file.originalname, error: err.publicDetail || err.message }); } finally { await fs.remove(file.path).catch(() => {}); } } diff --git a/server/cleansePolicy.js b/server/cleansePolicy.js new file mode 100644 index 0000000..bcc0ccf --- /dev/null +++ b/server/cleansePolicy.js @@ -0,0 +1,32 @@ +"use strict"; + +/** + * Shared cleanse policy source of truth for backend validation and frontend messaging. + * Keep this aligned with actual implementation safety guarantees. + */ +const CLEANSE_POLICY = { + quick: { + supportedExtensions: ['.mp3'], + recommendedExtensions: ['.mp3'], + }, + server: { + // Server wipe/rewrite is intentionally restricted to formats with stable behavior. + supportedExtensions: ['.mp4', '.m4a'], + recommendedExtensions: ['.mp4', '.m4a'], + }, +}; + +function normalizeExt(filename = '') { + const dot = filename.lastIndexOf('.'); + return dot >= 0 ? filename.slice(dot).toLowerCase() : ''; +} + +function isServerSupportedFormat(filename = '', mime = '') { + const ext = normalizeExt(filename); + const safeMime = String(mime || '').toLowerCase(); + if (CLEANSE_POLICY.server.supportedExtensions.includes(ext)) return true; + // Common upload MIME aliases for supported server formats. + return safeMime === 'video/mp4' || safeMime === 'audio/mp4' || safeMime === 'audio/x-m4a'; +} + +module.exports = { CLEANSE_POLICY, normalizeExt, isServerSupportedFormat }; diff --git a/server/processor.js b/server/processor.js index 41a481a..a06f8dc 100644 --- a/server/processor.js +++ b/server/processor.js @@ -6,6 +6,15 @@ function unsupportedCleanseError(message, detail) { const err = new Error(message); err.statusCode = 422; err.publicDetail = detail; + err.reason = 'unsupported_file_type'; + return err; +} + +function exiftoolFailureError(detail) { + const err = new Error('Server metadata processing failed'); + err.statusCode = 500; + err.publicDetail = detail; + err.reason = 'exiftool_failure'; return err; } @@ -46,7 +55,11 @@ async function processMediaFile({ outputPath, platform = 'General', metadata = { const wipeMarkers = detectMarkers(wipeTags); const wipeVerificationPassed = wipeMarkers.length === 0; const metaToWrite = buildMetaToWrite(platform, metadata); - await exiftool.write(outputPath, metaToWrite, ['-overwrite_original']); + try { + await exiftool.write(outputPath, metaToWrite, ['-overwrite_original']); + } catch { + throw exiftoolFailureError('Server metadata rewrite failed while applying sanitized fields.'); + } const finalTags = await exiftool.read(outputPath); const finalMarkers = detectMarkers(finalTags); const verification = verifyFinalState(finalTags);