Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1042,10 +1058,15 @@ export default function App() {
</div>
<div className="rounded-xl border border-cyan-500/30 bg-cyan-500/5 p-3">
<button onClick={runBatch} disabled={!!serverDisabledReason} className="w-full px-4 py-2 bg-cyan-600 hover:bg-cyan-500 disabled:bg-slate-700 disabled:text-slate-400 disabled:cursor-not-allowed rounded-lg text-sm font-bold">Full Server Cleanse</button>
<p className="mt-2 text-xs text-cyan-200/80">Deeper forensic server pipeline • all supported formats • usage-counted.</p>
<p className="mt-2 text-xs text-cyan-200/80">Recommended for MP4/M4A • usage-counted • free plan allowed up to monthly limit.</p>
{serverDisabledReason && <p className="mt-1 text-[11px] text-amber-300">{serverDisabledReason}</p>}
</div>
</div>
{activeItem && (activeExt === '.wav' || activeExt === '.flac') && (
<p className="text-[11px] text-amber-300">
WAV/FLAC server cleanse is currently not enabled. Use Quick Cleanse if available, or convert to M4A/MP4 for Full Server Cleanse.
</p>
)}
{activeItem.downloadUrl && (
<div className="rounded-xl border border-cyan-500/30 bg-cyan-500/10 p-3">
<p className="text-[11px] uppercase tracking-wider text-cyan-300 font-bold mb-1">Result Source: {resultSource}</p>
Expand Down
32 changes: 20 additions & 12 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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',
Comment on lines +489 to +498
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Normalizing the extension to empty string can lead to extension-less server output files in some cases.

With normalizeExt now returning '', a file uploaded without an extension but with a supported MIME type will still pass isServerSupportedFormat, but outputPath will append an empty ext. That yields output files with no extension, which is problematic for users and some tools. Please add a fallback to a safe default extension (e.g. .mp4) when normalizeExt returns an empty string but the MIME type is supported.

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;
Expand All @@ -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(() => {});
}
Expand All @@ -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; }
Comment on lines +548 to +550
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Batch output files may lose their default extension, unlike the previous behavior.

Previously, when path.extname returned an empty string in the batch path, ext defaulted to .mp4. With normalizeExt, ext is now '' in that case, so extension-less uploads will produce output paths with no file extension, changing the prior behavior and affecting download names. Please consider a fallback extension here (as in the single-file route) when ext is empty but mime is a supported format.

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(() => {}); }
}
Expand Down
32 changes: 32 additions & 0 deletions server/cleansePolicy.js
Original file line number Diff line number Diff line change
@@ -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';
}
Comment on lines +24 to +30
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): The server MIME checks for M4A may miss common variants such as audio/m4a.

The guard only allows video/mp4, audio/mp4, and audio/x-m4a, but some clients send M4A as audio/m4a. To prevent valid M4A uploads from being rejected when MIME/ext don’t line up, please add audio/m4a (and any other expected aliases) to this list.

Suggested change
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';
}
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' ||
safeMime === 'audio/m4a'
);
}


module.exports = { CLEANSE_POLICY, normalizeExt, isServerSupportedFormat };
15 changes: 14 additions & 1 deletion server/processor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
Expand Down
Loading