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
18 changes: 15 additions & 3 deletions app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,15 @@ interface UsageState {

interface MarkerHit { ruleId: string; category: string; severity: 'critical' | 'high' | 'medium'; matchedTag: string; matchedValue: string; }
interface ResidualTag { tag: string; markerCategory: string; severity: string; }
interface QualityFinding { code: string; field?: string; message: string; }
interface QualityVerification { passed: boolean; failures: QualityFinding[]; warnings: QualityFinding[]; expected?: Record<string, string>; }
interface ForensicReport {
removedCount: number; removedTags: string[]; timestamp: string;
removedCount: number; removedTags: string[]; timestamp: string; exportTimestamp?: string;
status?: 'clean' | 'clean_with_notes' | 'review_required'; summary?: string;
wipeVerificationPassed?: boolean; finalVerificationPassed?: boolean;
detectedMarkersBefore?: MarkerHit[]; detectedMarkersFinal?: MarkerHit[];
suspiciousResidual?: ResidualTag[]; unexpectedDescriptive?: string[];
qualityVerification?: QualityVerification; verificationFindings?: QualityFinding[];
allowedInjectedTags?: string[]; rewrittenTags?: string[];
}

Expand All @@ -53,7 +56,7 @@ interface QueueItem {
downloadName: string | null;
report: ForensicReport | null;
error: string | null;
analysis: { format: string; title: string; artist: string; genre: string; provenanceRisk: RiskLevel; detectedMarkers: string[]; parseError?: string | null } | null;
analysis: { format: string; title: string; artist: string; producer?: string; copyright?: string; genre: string; lyrics?: string; provenanceRisk: RiskLevel; detectedMarkers: string[]; parseError?: string | null } | null;
logs: string[];
}

Expand Down Expand Up @@ -714,8 +717,13 @@ export default function App() {
const formData = new FormData();
formData.append('file', item.file);
formData.append('title', currentSeo.title);
formData.append('artist', item.analysis?.artist || '');
formData.append('producer', item.analysis?.producer || '');
formData.append('copyright', item.analysis?.copyright || '');
formData.append('genre', item.analysis?.genre || '');
formData.append('description', currentSeo.description);
formData.append('tags', currentSeo.tags);
formData.append('lyrics', item.analysis?.lyrics || '');
formData.append('platform', platform);

const res = await fetch(`${API_BASE_URL}/api/process`, {
Expand Down Expand Up @@ -760,11 +768,15 @@ export default function App() {
const newLimit = limitHeader === 'unlimited' ? null : parseInt(limitHeader || '3', 10);
setUsage({ thisMonth: usedNow, limit: newLimit });

const reportHeader = res.headers.get('X-Forensic-Report');
let report: ForensicReport = { removedCount, removedTags, timestamp: new Date().toLocaleTimeString() };
try { if (reportHeader) report = JSON.parse(reportHeader); } catch {}

updateItem(item.id, {
status: 'done',
downloadUrl,
downloadName,
report: { removedCount, removedTags, timestamp: new Date().toLocaleTimeString() },
report,
});

} catch (err: any) {
Expand Down
11 changes: 6 additions & 5 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ app.use(cors({
methods: ['GET', 'POST'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: [
'X-Forensic-Removed', 'X-Forensic-Tags', 'X-Forensic-Status',
'X-Forensic-Removed', 'X-Forensic-Tags', 'X-Forensic-Status', 'X-Forensic-Report',
'X-Usage-This-Month', 'X-Usage-Limit',
],
}));
Expand Down Expand Up @@ -509,16 +509,17 @@ app.post('/api/process', requireAuth, upload.single('file'), async (req, res) =>
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;
const { title, description, tags, artist, producer, copyright, genre, lyrics, platform = 'General' } = req.body;
const outputPath = path.join('uploads', `out_${Date.now()}${ext}`);
try { await fs.copy(inputPath, outputPath); } catch { await fs.remove(inputPath).catch(() => {}); return res.status(500).json({ error: 'File copy failed' }); }
try {
const { report } = await processMediaFile({ outputPath, originalName: req.file.originalname, platform, metadata: { title, description, tags, artist, genre, lyrics } });
const { report } = await processMediaFile({ outputPath, originalName: req.file.originalname, platform, metadata: { title, description, tags, artist, producer, copyright, genre, lyrics } });
try { db.prepare('INSERT INTO jobs (user_id, filename, platform) VALUES (?, ?, ?)').run(userId, req.file.originalname, platform); } catch (dbErr) { console.error('Job record failed (non-fatal):', dbErr); }
const usedNow = getMonthlyJobCount(userId);
res.setHeader('X-Forensic-Removed', report.removedCount);
res.setHeader('X-Forensic-Tags', JSON.stringify(report.removedTags.slice(0, 50)));
res.setHeader('X-Forensic-Status', report.status || 'Sanitized');
res.setHeader('X-Forensic-Report', JSON.stringify(report));
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid putting JSON report with user text in response header

Setting X-Forensic-Report to JSON.stringify(report) can throw ERR_INVALID_CHAR when metadata contains non-Latin1 characters (for example Japanese, emoji, or many non-Western artist/title values), because Node validates header bytes and rejects those characters. In that case the /api/process request falls into the error path and returns a server failure instead of the cleansed file. This should be moved to the response body (or safely encoded) rather than a raw custom header.

Useful? React with 👍 / 👎.

res.setHeader('X-Usage-This-Month', usedNow);
res.setHeader('X-Usage-Limit', userPlan === 'free' ? FREE_MONTHLY_LIMIT : 'unlimited');
cleanup.registerForCleanup([outputPath]);
Expand All @@ -542,14 +543,14 @@ app.post('/api/process-batch', requireAuth, upload.array('files', 20), async (re
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 { title, description, tags, artist, producer, copyright, genre, lyrics, platform = 'General' } = req.body;
const results = [];
for (const file of files) {
const ext = normalizeExt(file.originalname || '');
const mime = (file.mimetype || '').toLowerCase();
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(() => {}); }
try { await fs.copy(file.path, outputPath); const { report } = await processMediaFile({ outputPath, originalName: file.originalname, platform, metadata: { title, description, tags, artist, producer, copyright, 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(() => {}); }
}
const usedNow = getMonthlyJobCount(userId);
res.setHeader('X-Usage-This-Month', usedNow);
Expand Down
9 changes: 6 additions & 3 deletions server/metadataRules.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

const MARKER_RULES = [
{ id: 'c2pa-jumbf', category: 'AI Provenance', severity: 'critical', patterns: [/jumbf/i, /c2pa/i, /manifest/i, /assertion/i] },
{ id: 'xmp-creator-tool', category: 'XMP Origin', severity: 'high', patterns: [/CreatorTool/i, /DerivedFrom/i, /MetadataDate/i, /HistoryAction/i] },
{ id: 'xmp-creator-tool', category: 'XMP Origin', severity: 'high', patterns: [/CreatorTool/i, /DerivedFrom/i, /MetadataDate/i, /HistoryAction/i, /XMPToolkit/i, /Image::ExifTool/i] },
{ id: 'iptc-synthetic', category: 'Synthetic Media Flag', severity: 'high', patterns: [/DigitalSourceType/i, /trainedAlgorithmicMedia/i] },
{ id: 'ai-brand', category: 'AI Brand Residue', severity: 'high', valueOnly: true, patterns: [/\bSuno\b/i, /\bUdio\b/i, /\bRunway\b/i, /\bLuma\b/i, /\bPika\b/i, /\bSora\b/i, /\bMidjourney\b/i, /\bDALL-E\b/i, /\bOpenAI\b/i, /\bChatGPT\b/i, /\bElevenLabs\b/i, /\bStable Diffusion\b/i, /\bAIVA\b/i, /\bMubert\b/i] },
{ id: 'encoder-software', category: 'Encoder / Software Residue', severity: 'medium', patterns: [/WrittenBy/i, /EncoderSettings/i] },
Expand All @@ -20,16 +20,19 @@ const BENIGN_TAG_PATTERNS = [
/^CreateDate$/i, /^ModifyDate$/i, /^TrackCreateDate$/i, /^TrackModifyDate$/i, /^MediaCreateDate$/i,
/^MediaModifyDate$/i, /^TrackDuration$/i, /^MediaDuration$/i, /^HandlerType$/i, /^HandlerDescription$/i,
/^CompressorID$/i, /^MatrixStructure$/i, /^XResolution$/i, /^YResolution$/i,
/^AVCConfiguration$/i, /^SampleSizes$/i, /^ChunkOffset$/i, /^MediaData/i, /^TimeToSampleTable$/i,
/^SyncSampleTable$/i, /^SampleToChunk$/i, /^HandlerVendorID$/i, /^MediaTimeScale$/i, /^TrackID$/i,
];

const ALLOWED_INJECTED_TAGS = new Set(['Title', 'Artist', 'Copyright', 'Keywords', 'Genre', 'Description', 'Comment', 'Album', 'Year', 'Lyrics-eng']);
const ALLOWED_INJECTED_TAGS = new Set(['Title', 'Artist', 'Producer', 'Copyright', 'Keyword', 'Keywords', 'Genre', 'Description', 'Comment', 'Album', 'ContentCreateDate', 'Year', 'Lyrics', 'Lyrics-eng']);

function isBenign(tagName) {
return BENIGN_TAG_PATTERNS.some((p) => p.test(String(tagName || '')));
}

function isAllowedInjected(tagName) {
return ALLOWED_INJECTED_TAGS.has(String(tagName || ''));
const cleanTagName = String(tagName || '').replace(/^.*:/, '');
return ALLOWED_INJECTED_TAGS.has(cleanTagName);
}

module.exports = { MARKER_RULES, ALLOWED_INJECTED_TAGS, isBenign, isAllowedInjected };
Loading
Loading