From 67adf5e67b784ab831ef2d776319cbe6ce02e149 Mon Sep 17 00:00:00 2001 From: ChrisAdamsdevelopment Date: Wed, 27 May 2026 20:44:26 -0700 Subject: [PATCH 1/5] Roadmap buildout: rate limiting, history, drag-drop, onboarding, admin Adds the next chunk of the SpectraCleanse roadmap across three phases. Infrastructure - Add express-rate-limit: 60/min/IP global on /api/* (excluding /api/health and the Stripe webhook), 10/min on /api/login, /api/register, /api/auth/*. 429 responses return JSON { error: "Too many requests" }. - Expand /api/health to return { status, uptime, version, time } and document it (plus rate limiting) in the README. - Serve /sitemap.xml and /robots.txt from Express so they work in dev and prod without depending on Vite's static pipeline; both reference the canonical https://spectracleanse.com domain. - Generate public/assets/og-image.png (1200x630) via a sharp-backed script (scripts/generate-og-image.js) so social previews finally have an image. - Add unit tests for cleansePolicy and processor (buildMetaToWrite, detectMarkers, verifyFinalState, buildQualityVerification, formatQuickTimeTimestamp). 28/28 tests pass. Core feature / UX - Reject WAV/FLAC at the Multer fileFilter stage with a 415 and a clear message ("WAV and FLAC are not yet supported. Supported formats: MP3, MP4, M4A.") instead of wasting bandwidth and 422-ing post-upload. Keep ALLOWED_MIME in sync with CLEANSE_POLICY. Tighten the frontend accept= attribute and validExt regex to match. - Add GET /api/jobs (requireAuth, last 50) and a history modal in app.tsx showing file, date, status pill, markers. New jobs columns forensic_status + markers_removed (additive migration) are populated by both /api/process and /api/process-batch. - Add drag-and-drop on the main panel with a cyan ring/background highlight; drops go through the same addFiles validation path. - Replace the generic "Upgrade to continue" message (both the 402 detail and the client-side fallback) with the spec'd copy referencing the Creator plan price. - Rework the results card with a big green "X AI markers removed - your file is clean." headline, a status pill driven by X-Forensic-Status (Clean / Clean with Notes / Review Required), Copy-Link / Tweet-This share buttons, and a free-plan-only Creator upgrade nudge. - Fix a latent bug: server.js called crypto.randomUUID() in /api/process-batch without importing crypto. Onboarding + admin - Add a three-step first-run onboarding modal (Strip AI Fingerprints -> How It Works -> You're Ready) gated by localStorage.onboarding_seen with a Sparkles button in the navbar to re-show it. - Add an admin health dashboard behind a requireAdmin middleware backed by ADMIN_SECRET (bearer token, never logged): /admin/health, /admin/recent-failures, /admin/usage-stats, and an inline HTML /admin page (no JS framework) with uptime, DB counts, today's processing volume, plan distribution bars, and a recent-failures table. None of the admin routes expose user PII. Constraints preserved - express.raw() for the Stripe webhook still comes before express.json(). - exiftool.end() exit handler unchanged. - requireAuth re-reads plan from DB; no caching of JWT plan field. - SQLite WAL mode pragma untouched; jobs schema changes are additive. - ALLOWED_MIME and CLEANSE_POLICY are kept in sync. - No JWT_SECRET / STRIPE_WEBHOOK_SECRET / GEMINI_API_KEY / ADMIN_SECRET in logs or responses. Verification - npm run test:run: 28/28 passing - npx tsc --noEmit: clean - node --check server.js: ok - npm run build: production bundle builds, og-image.png ships in dist/ Co-Authored-By: Claude Opus 4.7 --- .env.example | 8 + README.md | 11 + app.tsx | 287 +++++++++++++++++- package-lock.json | 555 ++++++++++++++++++++++++++++++++++ package.json | 2 + public/assets/og-image.png | Bin 0 -> 70674 bytes public/sitemap.xml | 2 +- scripts/generate-og-image.js | 58 ++++ server.js | 262 +++++++++++++++- tests/cleanse-policy.test.ts | 39 +++ tests/processor-extra.test.ts | 69 +++++ 11 files changed, 1272 insertions(+), 21 deletions(-) create mode 100644 public/assets/og-image.png create mode 100644 scripts/generate-og-image.js create mode 100644 tests/cleanse-policy.test.ts create mode 100644 tests/processor-extra.test.ts diff --git a/.env.example b/.env.example index a46ae89..9c60f23 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,14 @@ SMTP_USER= SMTP_PASS= SMTP_FROM= +# ───────────────────────────────────────────────────────────────────────────── +# Admin dashboard +# Static bearer token for /admin/* routes. Must be a strong random string +# (e.g. `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`). +# Never share this value or commit a real one. +# ───────────────────────────────────────────────────────────────────────────── +ADMIN_SECRET= + # ───────────────────────────────────────────────────────────────────────────── # Frontend build # ───────────────────────────────────────────────────────────────────────────── diff --git a/README.md b/README.md index d190158..7242def 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,17 @@ docker run --rm -p 3001:3001 \ Never commit real secrets to source control. +## Health & observability + +- `GET /api/health` (public, no auth) returns `{ status, uptime, version, time }`. Useful for uptime monitors and Render health checks. +- `GET /sitemap.xml` and `GET /robots.txt` are served directly by the backend so they work without the SPA static build (e.g. during dev) and stay in sync with the canonical domain `https://spectracleanse.com`. + +## Rate limiting + +- All `/api/*` routes (except `/api/health` and the Stripe webhook) are gated by a 60-requests-per-minute-per-IP limiter. +- `/api/login`, `/api/register`, and the `/api/auth/*` routes get a tighter 10/min limiter on top to make brute-force harder. +- A throttled request returns `429` with `{ "error": "Too many requests" }`. + ## Batch processing API - `POST /api/process-batch` (authenticated): processes up to 20 uploaded files sequentially for paid plans (Creator/Studio). Free plan returns `403`. diff --git a/app.tsx b/app.tsx index fecf825..d14be0b 100644 --- a/app.tsx +++ b/app.tsx @@ -625,6 +625,12 @@ export default function App() { const [isBatching, setIsBatching] = useState(false); const [cancelRef] = useState({ cancelled: false }); const fileInputRef = useRef(null); + const [onboardingStep, setOnboardingStep] = useState(null); + const [isDragActive, setIsDragActive] = useState(false); + const dragDepthRef = useRef(0); + const [historyOpen, setHistoryOpen] = useState(false); + const [history, setHistory] = useState>([]); + const [historyLoading, setHistoryLoading] = useState(false); const activeItem = queue.find(f => f.id === activeId) ?? null; const params = new URLSearchParams(window.location.search); @@ -681,6 +687,12 @@ export default function App() { // Normal session restore – fetch fresh usage count fetchUsage(session.token); } + + // First-run onboarding (one-shot, persisted in localStorage) + if (!localStorage.getItem('onboarding_seen')) { + const t = window.setTimeout(() => setOnboardingStep(1), 500); + return () => window.clearTimeout(t); + } }, []); // Cleanup object URLs on unmount @@ -757,7 +769,7 @@ export default function App() { }; const addFiles = (files: FileList | File[]) => { - const validExt = /\.(mp3|wav|flac|m4a|mp4)$/i; + const validExt = /\.(mp3|m4a|mp4)$/i; const savedDefaults = getSavedReleaseDefaults(); const newItems: QueueItem[] = Array.from(files) .filter(f => validExt.test(f.name)) @@ -774,6 +786,40 @@ export default function App() { setActiveId(prev => prev ?? newItems[0].id); }; + const onDragOver = (e: React.DragEvent) => { + e.preventDefault(); + if (!isDragActive) setIsDragActive(true); + }; + const onDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + dragDepthRef.current += 1; + setIsDragActive(true); + }; + const onDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setIsDragActive(false); + }; + const onDrop = (e: React.DragEvent) => { + e.preventDefault(); + dragDepthRef.current = 0; + setIsDragActive(false); + const dropped = e.dataTransfer?.files; + if (dropped && dropped.length > 0) addFiles(dropped); + }; + + const loadHistory = async () => { + if (!authToken) return; + setHistoryLoading(true); + try { + const res = await fetch(`${API_BASE_URL}/api/jobs`, { headers: { Authorization: `Bearer ${authToken}` } }); + if (res.ok) { + const data = await res.json(); + setHistory(data.jobs || []); + } + } catch {} finally { setHistoryLoading(false); } + }; + const removeItem = (id: string) => { setQueue(prev => { const item = prev.find(i => i.id === id); @@ -941,7 +987,7 @@ export default function App() { const body = await res.json().catch(() => ({})); updateItem(item.id, { status: 'error', - error: body.detail || 'Monthly limit reached. Upgrade to continue.', + error: body.detail || `You've used your 3 free cleanses this month. Upgrade to Creator for unlimited cleanses and batch processing — starting at $9.99/month.`, }); setShowUpgrade(true); // Abort remaining items in this batch @@ -1049,6 +1095,155 @@ export default function App() { /> )} + {/* First-run onboarding */} + {onboardingStep !== null && (() => { + const dismiss = () => { + localStorage.setItem('onboarding_seen', '1'); + setOnboardingStep(null); + // Focus the upload zone on close so the user can immediately upload. + setTimeout(() => fileInputRef.current?.focus(), 100); + }; + const steps = [ + { + title: 'Strip AI Fingerprints. Own Your Release.', + body: 'AI music tools like Suno, Udio, and ElevenLabs embed metadata markers in every file they export — C2PA content credentials, synthetic content flags, and AI brand tags. These markers can get your tracks flagged on streaming platforms. SpectraCleanse removes them and injects real, platform-optimized metadata.', + visual: ( +
+
+

Before

+

14 markers

+
+
+

After

+

0 markers

+
+
+ ), + }, + { + title: 'How It Works', + body: 'Three steps, every time.', + visual: ( +
+
+ +

Upload your MP3, MP4, or M4A

+
+
+ +

We strip AI markers and inject real metadata

+
+
+ +

Download your clean, attribution-ready file

+
+
+ ), + footnote: 'Your audio is never stored. Files are processed in memory and immediately deleted.', + }, + { + title: "You're Ready", + body: 'You have 3 free cleanses this month. No credit card required.', + visual: null as any, + }, + ]; + const current = steps[onboardingStep - 1]; + return ( +
+
+
+ +
+

Step {onboardingStep} of {steps.length}

+

{current.title}

+

{current.body}

+ {current.visual} + {('footnote' in current) && current.footnote && ( +

{current.footnote}

+ )} + +
+ + {onboardingStep < steps.length ? ( + + ) : ( + + )} +
+
+
+
+ ); + })()} + + {/* History modal */} + {historyOpen && ( +
+
+
+

Processing history

+ +
+
+ {historyLoading ? ( +
Loading…
+ ) : history.length === 0 ? ( +
+ +

No files processed yet.

+

Upload your first file to start your history.

+
+ ) : ( + + + + + + + + + + + {history.map(row => { + const status = row.forensic_status || '—'; + const pill = status === 'clean' + ? 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30' + : status === 'clean_with_notes' + ? 'bg-amber-500/15 text-amber-300 border-amber-500/30' + : status === 'review_required' + ? 'bg-red-500/15 text-red-300 border-red-500/30' + : 'bg-slate-700/30 text-slate-400 border-slate-700/40'; + return ( + + + + + + + ); + })} + +
FileDateStatusMarkers
{row.filename}{new Date(row.created_at).toLocaleString()}{status}{row.markers_removed ?? '—'}
+ )} +
+
+ Showing the most recent 50 cleanses. + +
+
+
+ )} + {/* Checkout return banner */} {checkoutBanner && ( setCheckoutBanner(null)} /> @@ -1092,6 +1287,20 @@ export default function App() { )} + +
@@ -1225,15 +1434,23 @@ export default function App() { {/* Main panel */} -
+
{!activeItem ? (
fileInputRef.current?.click()} > -

Add files to get started

-

MP3 · WAV · FLAC · M4A · MP4 · up to 20 files

+

Drag files here, or click to browse

+

MP3 · M4A · MP4 · up to 20 files

) : (
@@ -1539,7 +1756,21 @@ export default function App() {

System Log

{activeItem.logs.map((l, i) => { const isErr = /failed|error/i.test(l); const isSuccess = /complete|generated|starting server cleanse/i.test(l); const m = l.match(/^\[(.*?)\]\s*(.*)$/); return
{m ? m[1] : '--:--:--'}{m ? m[2] : l}
; })}
{/* Forensic report */} - {activeItem.report && ( + {activeItem.report && (() => { + const removed = activeItem.report.removedCount ?? 0; + const headline = removed > 0 + ? `${removed} AI marker${removed === 1 ? '' : 's'} removed — your file is clean.` + : 'No AI markers found — your file was already clean.'; + const status = activeItem.report.status || 'clean'; + const statusPill = status === 'review_required' + ? { label: '⚑ Review Required', cls: 'bg-red-500/15 text-red-300 border-red-500/30' } + : status === 'clean_with_notes' + ? { label: '⚠ Clean with Notes', cls: 'bg-amber-500/15 text-amber-300 border-amber-500/30' } + : { label: '✓ Clean', cls: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30' }; + const tweetUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent( + 'Just stripped AI metadata from my track using SpectraCleanse — clean metadata, real attribution. Try it free: https://spectracleanse.com #IndependentArtist #AIMusic' + )}`; + return (

@@ -1547,10 +1778,14 @@ export default function App() {

{activeItem.report.timestamp}
+ +

{headline}

+ {statusPill.label} +

Tags Removed

-

{activeItem.report.removedCount}

+

{removed}

Platform Preset

@@ -1575,8 +1810,42 @@ export default function App() { > Download Cleansed File + +
+

Cleaned with SpectraCleanse. Share your release with confidence.

+
+ + Tweet This +
+
+ + {currentUser.plan === 'free' && ( +
+

Process unlimited files + batch upload with Creator plan. $9.99/month.

+ +
+ )}
- )} + ); + })()}
)}
diff --git a/package-lock.json b/package-lock.json index 87c3de5..7382b08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "dotenv": "^16.4.5", "exiftool-vendored": "^28.3.1", "express": "^4.19.2", + "express-rate-limit": "^8.5.2", "fs-extra": "^11.2.0", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.390.0", @@ -31,6 +32,7 @@ "postcss": "^8.4.38", "react": "^18.3.1", "react-dom": "^18.3.1", + "sharp": "^0.34.5", "stripe": "^16.2.0", "tailwindcss": "^3.4.4", "typescript": "^5.5.2", @@ -502,6 +504,16 @@ "node": ">=18" } }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -849,6 +861,471 @@ "node": ">=12" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2524,6 +3001,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -2930,6 +3425,15 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -4151,6 +4655,50 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -4643,6 +5191,13 @@ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", diff --git a/package.json b/package.json index 1f8e3c7..9a38583 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "dotenv": "^16.4.5", "exiftool-vendored": "^28.3.1", "express": "^4.19.2", + "express-rate-limit": "^8.5.2", "fs-extra": "^11.2.0", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.390.0", @@ -38,6 +39,7 @@ "postcss": "^8.4.38", "react": "^18.3.1", "react-dom": "^18.3.1", + "sharp": "^0.34.5", "stripe": "^16.2.0", "tailwindcss": "^3.4.4", "typescript": "^5.5.2", diff --git a/public/assets/og-image.png b/public/assets/og-image.png new file mode 100644 index 0000000000000000000000000000000000000000..dde3db90332b3aee9ba27ece87915f05c7655dc5 GIT binary patch literal 70674 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 B&0qik literal 0 HcmV?d00001 diff --git a/public/sitemap.xml b/public/sitemap.xml index 11b0063..bf180b2 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -2,7 +2,7 @@ https://spectracleanse.com/ - 2026-05-24 + 2026-05-27 weekly 1.0 diff --git a/scripts/generate-og-image.js b/scripts/generate-og-image.js new file mode 100644 index 0000000..7350844 --- /dev/null +++ b/scripts/generate-og-image.js @@ -0,0 +1,58 @@ +#!/usr/bin/env node +// Render public/assets/og-image.png (1200x630) for social previews. +// Runs at install time / dev — output is committed to the repo. + +const path = require('path'); +const fs = require('fs'); +const sharp = require('sharp'); + +const OUTPUT = path.join(__dirname, '..', 'public', 'assets', 'og-image.png'); +const WIDTH = 1200; +const HEIGHT = 630; + +const svg = ` + + + + + + + + + + + + + + + + SpectraCleanse + Strip AI Markers. Inject Real Metadata. + Beat algorithmic suppression on Spotify, YouTube, Apple Music, TikTok. + + + + + + + + + + + + + + spectracleanse.com + + +`; + +(async () => { + fs.mkdirSync(path.dirname(OUTPUT), { recursive: true }); + await sharp(Buffer.from(svg)).png().toFile(OUTPUT); + const stat = fs.statSync(OUTPUT); + console.log(`og-image: ${OUTPUT} (${stat.size} bytes)`); +})().catch((err) => { + console.error('og-image generation failed:', err); + process.exit(1); +}); diff --git a/server.js b/server.js index b3d29d7..67c61f9 100644 --- a/server.js +++ b/server.js @@ -16,6 +16,12 @@ const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const Database = require('better-sqlite3'); const Stripe = require('stripe'); +const crypto = require('crypto'); +const rateLimit = require('express-rate-limit'); + +// package.json is used for the /api/health version field; require kept inline to avoid +// pulling unrelated fields into module scope. +const pkgVersion = (() => { try { return require('./package.json').version; } catch { return 'unknown'; } })(); // ───────────────────────────────────────────────────────────────────────────── // Environment validation – strict in production, developer-friendly locally @@ -91,6 +97,12 @@ ensureUserColumn('password_reset_token_hash', 'TEXT'); ensureUserColumn('password_reset_expires_at', 'TEXT'); ensureUserColumn('password_reset_sent_at', 'TEXT'); +const jobColumns = db.prepare(`PRAGMA table_info(jobs)`).all().map((col) => col.name); +const ensureJobColumn = (name, definition) => { + if (!jobColumns.includes(name)) db.exec(`ALTER TABLE jobs ADD COLUMN ${name} ${definition}`); +}; +ensureJobColumn('forensic_status', 'TEXT'); +ensureJobColumn('markers_removed', 'INTEGER'); cleanup.init(db); downloadTokens.init(db); @@ -250,6 +262,37 @@ app.post( app.use(express.json({ limit: '10mb' })); +// ───────────────────────────────────────────────────────────────────────────── +// Rate limiting +// Webhook route is registered ABOVE these limiters and is never throttled. +// Generic 429 JSON keeps frontend handling consistent. +// ───────────────────────────────────────────────────────────────────────────── +const rateLimitMessage = { error: 'Too many requests' }; +const apiLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 60, + standardHeaders: true, + legacyHeaders: false, + message: rateLimitMessage, +}); +const authLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: rateLimitMessage, +}); + +// Auth routes get the tighter limiter mounted first; the global API limiter then +// applies to the rest of /api/*. Health and Stripe webhook are intentionally not gated. +app.use('/api/auth/', authLimiter); +app.use('/api/login', authLimiter); +app.use('/api/register', authLimiter); +app.use('/api/', (req, res, next) => { + if (req.path === '/health' || req.path === '/stripe-webhook') return next(); + return apiLimiter(req, res, next); +}); + // ───────────────────────────────────────────────────────────────────────────── // Auth endpoints // ───────────────────────────────────────────────────────────────────────────── @@ -501,9 +544,16 @@ app.post('/api/create-checkout-session', requireAuth, async (req, res) => { // ───────────────────────────────────────────────────────────────────────────── // Multer // ───────────────────────────────────────────────────────────────────────────── +// ALLOWED_MIME mirrors CLEANSE_POLICY (quick=mp3, server=mp4/m4a). WAV/FLAC are +// intentionally excluded so Multer rejects them at upload time instead of +// wasting bandwidth on uploads that the processor would 422 anyway. const ALLOWED_MIME = new Set([ - 'audio/mpeg', 'audio/wav', 'audio/x-wav', 'audio/flac', 'audio/x-flac', - 'audio/mp4', 'audio/m4a', 'video/mp4', + 'audio/mpeg', // .mp3 — passed through so the helpful "use Quick Cleanse" 422 fires + 'audio/mp4', 'audio/m4a', 'audio/x-m4a', 'video/mp4', // .mp4 / .m4a server cleanse +]); +const REJECTED_LEGACY_MIME = new Set([ + 'audio/wav', 'audio/x-wav', 'audio/wave', 'audio/vnd.wave', + 'audio/flac', 'audio/x-flac', ]); const MAX_FILE_SIZE = 500 * 1024 * 1024; @@ -511,9 +561,17 @@ const upload = multer({ dest: 'uploads/', limits: { fileSize: MAX_FILE_SIZE }, fileFilter: (req, file, cb) => { - ALLOWED_MIME.has(file.mimetype) - ? cb(null, true) - : cb(new Error(`Unsupported file type: ${file.mimetype}`)); + const mime = (file.mimetype || '').toLowerCase(); + const ext = (file.originalname || '').toLowerCase().match(/\.[a-z0-9]+$/)?.[0] || ''; + if (REJECTED_LEGACY_MIME.has(mime) || ext === '.wav' || ext === '.flac') { + const err = new Error('WAV_FLAC_NOT_SUPPORTED'); + err.code = 'WAV_FLAC_NOT_SUPPORTED'; + return cb(err); + } + if (ALLOWED_MIME.has(mime)) return cb(null, true); + const unsupported = new Error(`Unsupported file type: ${file.mimetype}`); + unsupported.code = 'UNSUPPORTED_FILE_TYPE'; + return cb(unsupported); }, }); @@ -640,7 +698,14 @@ app.post('/api/process', requireAuth, upload.single('file'), async (req, res) => if (usedThisMonth >= FREE_MONTHLY_LIMIT) { await fs.remove(req.file.path).catch(() => {}); 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 }); + return res.status(402).json({ + error: 'Monthly limit reached', + detail: `You've used your ${FREE_MONTHLY_LIMIT} free cleanses this month. Upgrade to Creator for unlimited cleanses and batch processing — starting at $9.99/month.`, + reason: 'usage_limit', + usedThisMonth, + limit: FREE_MONTHLY_LIMIT, + upgradeRequired: true, + }); } } const { title, description, tags, artist, producer, copyright, genre, lyrics, platform = 'General' } = req.body; @@ -659,7 +724,7 @@ app.post('/api/process', requireAuth, upload.single('file'), async (req, res) => 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, 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); } + try { db.prepare('INSERT INTO jobs (user_id, filename, platform, forensic_status, markers_removed) VALUES (?, ?, ?, ?, ?)').run(userId, req.file.originalname, platform, report.status || null, Number(report.removedCount) || 0); } catch (dbErr) { console.error('Job record failed (non-fatal):', dbErr); } const usedNow = getMonthlyJobCount(userId); const downloadName = `cleansed_${req.file.originalname}`; res.setHeader('X-Forensic-Removed', report.removedCount); @@ -710,7 +775,7 @@ app.post('/api/process-batch', requireAuth, upload.array('files', 20), async (re 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, 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(() => {}); } + 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, forensic_status, markers_removed) VALUES (?, ?, ?, ?, ?)').run(userId, file.originalname, platform, report.status || null, Number(report.removedCount) || 0); 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); @@ -718,6 +783,17 @@ app.post('/api/process-batch', requireAuth, upload.array('files', 20), async (re return res.json({ results, usage: { thisMonth: usedNow, limit: null } }); }); +app.get('/api/jobs', requireAuth, (req, res) => { + const userId = req.user.sub; + const rows = db.prepare( + `SELECT id, filename, platform, forensic_status, markers_removed, created_at + FROM jobs WHERE user_id = ? + ORDER BY created_at DESC, id DESC + LIMIT 50` + ).all(userId); + return res.json({ jobs: rows }); +}); + app.get('/api/download/:token', requireAuth, async (req, res) => { const userId = req.user.sub; const consumed = downloadTokens.consumeToken(req.params.token, userId); @@ -733,16 +809,180 @@ app.get('/api/download/:token', requireAuth, async (req, res) => { app.use((err, req, res, _next) => { if (err.code === 'LIMIT_FILE_SIZE') return res.status(413).json({ error: 'File too large (max 500MB)' }); - if (err.message?.startsWith('Unsupported file type')) - return res.status(415).json({ error: err.message }); + if (err.code === 'WAV_FLAC_NOT_SUPPORTED') + return res.status(415).json({ + error: 'WAV and FLAC are not yet supported. Supported formats: MP3, MP4, M4A.', + code: 'WAV_FLAC_NOT_SUPPORTED', + }); + if (err.code === 'UNSUPPORTED_FILE_TYPE' || err.message?.startsWith('Unsupported file type')) + return res.status(415).json({ error: err.message, code: 'UNSUPPORTED_FILE_TYPE' }); console.error('Unhandled error:', err); res.status(500).json({ error: 'Internal server error' }); }); app.get('/api/health', (_req, res) => - res.json({ status: 'ok', time: new Date().toISOString() }) + res.json({ + status: 'ok', + uptime: process.uptime(), + version: pkgVersion, + time: new Date().toISOString(), + }) ); +// ───────────────────────────────────────────────────────────────────────────── +// Public SEO endpoints — served from server so they work in dev and prod +// without depending on Vite's static asset pipeline. +// ───────────────────────────────────────────────────────────────────────────── +const CANONICAL_HOST = 'https://spectracleanse.com'; +// ───────────────────────────────────────────────────────────────────────────── +// Admin endpoints (Phase 1 admin health dashboard) +// Auth: static bearer token via ADMIN_SECRET env. Routes never return user PII. +// ───────────────────────────────────────────────────────────────────────────── +function requireAdmin(req, res, next) { + const token = (req.headers.authorization || '').replace(/^Bearer\s+/i, ''); + if (!token || !process.env.ADMIN_SECRET || token !== process.env.ADMIN_SECRET) { + return res.status(401).json({ error: 'Unauthorized' }); + } + return next(); +} + +function bytesToMB(n) { return Math.round((n / 1024 / 1024) * 100) / 100; } + +function buildAdminHealth() { + const dbJobCount = db.prepare('SELECT COUNT(*) AS n FROM jobs').get().n; + const dbUserCount = db.prepare('SELECT COUNT(*) AS n FROM users').get().n; + const processedToday = db.prepare( + `SELECT COUNT(*) AS n FROM jobs WHERE date(created_at) = date('now')` + ).get().n; + const errorsToday = db.prepare( + `SELECT COUNT(*) AS n FROM jobs WHERE date(created_at) = date('now') AND forensic_status = 'review_required'` + ).get().n; + const mem = process.memoryUsage(); + return { + status: 'ok', + uptime: process.uptime(), + nodeVersion: process.version, + dbJobCount, + dbUserCount, + processedToday, + errorRateToday: processedToday > 0 ? Math.round((errorsToday / processedToday) * 10000) / 10000 : 0, + memoryMB: { rss: bytesToMB(mem.rss), heapUsed: bytesToMB(mem.heapUsed), heapTotal: bytesToMB(mem.heapTotal) }, + }; +} + +function buildAdminUsageStats() { + const planRows = db.prepare('SELECT plan, COUNT(*) AS n FROM users GROUP BY plan').all(); + const plans = { free: 0, creator: 0, studio: 0 }; + let totalUsers = 0; + for (const row of planRows) { + totalUsers += row.n; + if (plans[row.plan] !== undefined) plans[row.plan] = row.n; + } + const activeThisMonth = db.prepare( + `SELECT COUNT(DISTINCT user_id) AS n FROM jobs + WHERE strftime('%Y-%m', created_at) = strftime('%Y-%m', 'now')` + ).get().n; + return { ...plans, totalUsers, activeThisMonth }; +} + +app.get('/admin/health', requireAdmin, (_req, res) => res.json(buildAdminHealth())); + +app.get('/admin/recent-failures', requireAdmin, (req, res) => { + const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100); + const rows = db.prepare( + `SELECT id, user_id, filename, forensic_status, created_at FROM jobs + WHERE forensic_status = 'review_required' + ORDER BY created_at DESC, id DESC LIMIT ?` + ).all(limit); + return res.json({ + jobs: rows.map((row) => ({ + id: row.id, + user_id: row.user_id, + filename: row.filename, + error_message: row.forensic_status, + created_at: row.created_at, + })), + }); +}); + +app.get('/admin/usage-stats', requireAdmin, (_req, res) => res.json(buildAdminUsageStats())); + +app.get('/admin', requireAdmin, (_req, res) => { + const health = buildAdminHealth(); + const usage = buildAdminUsageStats(); + const failures = db.prepare( + `SELECT id, user_id, filename, forensic_status, created_at FROM jobs + WHERE forensic_status = 'review_required' + ORDER BY created_at DESC, id DESC LIMIT 20` + ).all(); + const escape = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); + const planBar = (label, count, color) => { + const pct = usage.totalUsers > 0 ? Math.round((count / usage.totalUsers) * 100) : 0; + return `
${escape(label)}${count} (${pct}%)
`; + }; + const html = ` +SpectraCleanse — Admin + + +

SpectraCleanse Admin Health

+
+
Status
${escape(health.status)}
+
Uptime (s)
${Math.round(health.uptime)}
+
Node
${escape(health.nodeVersion)}
+
Users
${health.dbUserCount}
+
Jobs (all-time)
${health.dbJobCount}
+
Processed today
${health.processedToday}
+
Error rate today
${(health.errorRateToday * 100).toFixed(2)}%
+
RSS / Heap MB
${health.memoryMB.rss} / ${health.memoryMB.heapUsed}
+
+ +
+
Plan distribution (active this month: ${usage.activeThisMonth})
+ ${planBar('Free', usage.free, '#64748b')} + ${planBar('Creator', usage.creator, '#06b6d4')} + ${planBar('Studio', usage.studio, '#8b5cf6')} +
+ +
+
Recent failures (review_required)
+ ${failures.length === 0 ? '
No recent failures.
' : ` + + + + ${failures.map((row) => ``).join('')} + +
IDUserFileStatusWhen
${row.id}${row.user_id}${escape(row.filename)}${escape(row.forensic_status || '')}${escape(row.created_at)}
`} +
+`; + res.set('Content-Type', 'text/html; charset=utf-8'); + res.send(html); +}); + +app.get('/sitemap.xml', (_req, res) => { + const today = new Date().toISOString().slice(0, 10); + const urls = [ + { loc: `${CANONICAL_HOST}/`, changefreq: 'weekly', priority: '1.0' }, + ]; + const body = `\n\n${urls.map((u) => ` \n ${u.loc}\n ${today}\n ${u.changefreq}\n ${u.priority}\n `).join('\n')}\n\n`; + res.set('Content-Type', 'application/xml; charset=utf-8'); + res.send(body); +}); + +app.get('/robots.txt', (_req, res) => { + res.set('Content-Type', 'text/plain; charset=utf-8'); + res.send(`User-agent: *\nAllow: /\nDisallow: /api/\n\nSitemap: ${CANONICAL_HOST}/sitemap.xml\n`); +}); + // Unknown API routes should return JSON (never HTML) app.use('/api', (req, res) => { diff --git a/tests/cleanse-policy.test.ts b/tests/cleanse-policy.test.ts new file mode 100644 index 0000000..76377a9 --- /dev/null +++ b/tests/cleanse-policy.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { CLEANSE_POLICY, normalizeExt, isServerSupportedFormat } = require('../server/cleansePolicy'); + +describe('cleansePolicy', () => { + it('exposes MP3 only for Quick Cleanse and MP4/M4A only for Full Server Cleanse', () => { + expect(CLEANSE_POLICY.quick.supportedExtensions).toEqual(['.mp3']); + expect(CLEANSE_POLICY.server.supportedExtensions).toEqual(['.mp4', '.m4a']); + }); + + it('normalizeExt lowercases and returns leading dot', () => { + expect(normalizeExt('Song.MP4')).toBe('.mp4'); + expect(normalizeExt('song.m4a')).toBe('.m4a'); + expect(normalizeExt('song')).toBe(''); + expect(normalizeExt('')).toBe(''); + }); + + it('isServerSupportedFormat accepts MP4/M4A by extension', () => { + expect(isServerSupportedFormat('a.mp4', 'video/mp4')).toBe(true); + expect(isServerSupportedFormat('a.m4a', 'audio/m4a')).toBe(true); + expect(isServerSupportedFormat('a.M4A', 'audio/x-m4a')).toBe(true); + }); + + it('isServerSupportedFormat accepts common MIME aliases even with missing extension', () => { + expect(isServerSupportedFormat('blob', 'video/mp4')).toBe(true); + expect(isServerSupportedFormat('blob', 'audio/mp4')).toBe(true); + expect(isServerSupportedFormat('blob', 'audio/x-m4a')).toBe(true); + }); + + it('isServerSupportedFormat rejects MP3 (it goes to Quick Cleanse)', () => { + expect(isServerSupportedFormat('song.mp3', 'audio/mpeg')).toBe(false); + }); + + it('isServerSupportedFormat rejects WAV/FLAC/EXE', () => { + expect(isServerSupportedFormat('song.wav', 'audio/wav')).toBe(false); + expect(isServerSupportedFormat('song.flac', 'audio/flac')).toBe(false); + expect(isServerSupportedFormat('payload.exe', 'application/octet-stream')).toBe(false); + }); +}); diff --git a/tests/processor-extra.test.ts b/tests/processor-extra.test.ts new file mode 100644 index 0000000..415315e --- /dev/null +++ b/tests/processor-extra.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { buildMetaToWrite, detectMarkers, verifyFinalState, buildQualityVerification, formatQuickTimeTimestamp } = require('../server/processor'); + +describe('processor: buildMetaToWrite', () => { + it('omits Artist/Producer/Copyright tags when those fields are blank', () => { + const meta = buildMetaToWrite('General', { title: 'Just A Title' }); + expect(meta['ItemList:Title']).toBe('Just A Title'); + expect(meta['ItemList:Artist']).toBeUndefined(); + expect(meta['ItemList:Producer']).toBeUndefined(); + expect(meta['ItemList:Copyright']).toBeUndefined(); + }); + + it('synthesizes copyright from artist + current UTC year when copyright omitted', () => { + const year = new Date().getUTCFullYear(); + const meta = buildMetaToWrite('General', { artist: 'Solo Artist' }); + expect(meta['ItemList:Copyright']).toBe(`© ${year} Solo Artist`); + expect(meta['QuickTime:Copyright']).toBe(`© ${year} Solo Artist`); + }); + + it('TikTok platform produces a hashtagged comment from title + tags', () => { + const meta = buildMetaToWrite('TikTok', { title: 'Vibes', tags: 'trap, hip hop' }); + expect(typeof meta['ItemList:Comment']).toBe('string'); + expect(meta['ItemList:Comment']).toContain('Vibes'); + expect(meta['ItemList:Comment']).toContain('#trap'); + expect(meta['ItemList:Comment']).toContain('#hiphop'); + }); + + it('Spotify/Apple Music platform copies title to album and writes lyrics when provided', () => { + const meta = buildMetaToWrite('Spotify', { title: 'Song', lyrics: 'la la la' }); + expect(meta['ItemList:Album']).toBe('Song'); + expect(meta['ItemList:Lyrics']).toBe('la la la'); + }); +}); + +describe('processor: detectMarkers + verifyFinalState', () => { + it('detectMarkers returns hits for known AI provenance markers', () => { + const hits = detectMarkers({ XMPToolkit: 'Adobe XMP', 'Image::ExifTool': 'Image::ExifTool 13.0', SunoTag: 'suno-track-id-xyz' }); + expect(hits.length).toBeGreaterThan(0); + }); + + it('detectMarkers returns no hits for empty/benign tag bag', () => { + expect(detectMarkers({})).toEqual([]); + expect(detectMarkers({ Title: 'Hello', Artist: 'Joe' })).toEqual([]); + }); + + it('verifyFinalState reports passed=true for benign-only tags', () => { + const result = verifyFinalState({ Title: 'Hello', Artist: 'Joe' }); + expect(result.passed).toBe(true); + expect(result.suspiciousResidual).toEqual([]); + }); +}); + +describe('processor: buildQualityVerification format rejection cues', () => { + it('flags missing artist/producer/copyright when expected values are supplied', () => { + const result = buildQualityVerification({}, { title: 'T', artist: 'A', producer: 'P', copyright: '©' }); + expect(result.passed).toBe(false); + expect(result.failures.map((f: any) => f.code)).toEqual( + expect.arrayContaining(['expected_artist_missing', 'expected_copyright_missing', 'expected_producer_missing']) + ); + }); +}); + +describe('processor: formatQuickTimeTimestamp', () => { + it('formats date as YYYY:MM:DD HH:MM:SS in UTC', () => { + const stamp = formatQuickTimeTimestamp(new Date('2026-05-27T01:02:03Z')); + expect(stamp).toBe('2026:05:27 01:02:03'); + }); +}); From 2f12ef2d6abf924d95b123f0171ca9c8190b13e1 Mon Sep 17 00:00:00 2001 From: ChrisAdamsdevelopment Date: Wed, 27 May 2026 20:53:14 -0700 Subject: [PATCH 2/5] Fix CI audit and Sourcery review note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI: bump exiftool-vendored ^28.3.1 -> ^35.20.0 and nodemailer ^6.10.1 -> ^8.0.9 to clear two high-severity advisories (GHSA-cw26-7653-2rp5, GHSA-mm7p-fcc7-pg87 / -rcmh-qjqh-p98v / -c7w3-x93f-qmm8 / -vvjj-xcjg-gr5g). Our usage is limited to exiftool.{read,write,end,version,readRaw} and nodemailer.{createTransport,sendMail} — both stable across the bump and verified by smoke-importing each module. `npm audit --audit-level=high` now exits 0 (7 moderate vulns remain in dev-only tooling, below the CI threshold). Code review: type the onboarding step array as `{ title; body; visual?; footnote? }[]` and drop the `null as any` cast + `'footnote' in current` guard. Co-Authored-By: Claude Opus 4.7 --- app.tsx | 6 ++--- package-lock.json | 60 +++++++++++++++++++++++++++-------------------- package.json | 4 ++-- 3 files changed, 40 insertions(+), 30 deletions(-) diff --git a/app.tsx b/app.tsx index d14be0b..97d5eb7 100644 --- a/app.tsx +++ b/app.tsx @@ -1103,7 +1103,8 @@ export default function App() { // Focus the upload zone on close so the user can immediately upload. setTimeout(() => fileInputRef.current?.focus(), 100); }; - const steps = [ + type OnboardingStep = { title: string; body: string; visual?: React.ReactNode; footnote?: string }; + const steps: OnboardingStep[] = [ { title: 'Strip AI Fingerprints. Own Your Release.', body: 'AI music tools like Suno, Udio, and ElevenLabs embed metadata markers in every file they export — C2PA content credentials, synthetic content flags, and AI brand tags. These markers can get your tracks flagged on streaming platforms. SpectraCleanse removes them and injects real, platform-optimized metadata.', @@ -1144,7 +1145,6 @@ export default function App() { { title: "You're Ready", body: 'You have 3 free cleanses this month. No credit card required.', - visual: null as any, }, ]; const current = steps[onboardingStep - 1]; @@ -1158,7 +1158,7 @@ export default function App() {

{current.title}

{current.body}

{current.visual} - {('footnote' in current) && current.footnote && ( + {current.footnote && (

{current.footnote}

)} diff --git a/package-lock.json b/package-lock.json index 7382b08..ba57d7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "browser-id3-writer": "4.4.0", "cors": "^2.8.5", "dotenv": "^16.4.5", - "exiftool-vendored": "^28.3.1", + "exiftool-vendored": "^35.20.0", "express": "^4.19.2", "express-rate-limit": "^8.5.2", "fs-extra": "^11.2.0", @@ -28,7 +28,7 @@ "lucide-react": "^0.390.0", "multer": "^2.0.0", "music-metadata": "^11.12.3", - "nodemailer": "^6.10.1", + "nodemailer": "^8.0.9", "postcss": "^8.4.38", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -2212,11 +2212,12 @@ } }, "node_modules/batch-cluster": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-13.0.0.tgz", - "integrity": "sha512-EreW0Vi8TwovhYUHBXXRA5tthuU2ynGsZFlboyMJHCCUXYa2AjgwnE3ubBOJs2xJLcuXFJbi6c/8pH5+FVj8Og==", + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-17.3.1.tgz", + "integrity": "sha512-/aWEgZKXgvEseV3WEIRyjDoFka9FTrpt5+FYCxn+giUgveGBKxWjz3cl26V3aD+1kvOBP3nmANZZfcXDmKzcAA==", + "license": "MIT", "engines": { - "node": ">=14" + "node": ">=20" } }, "node_modules/bcryptjs": { @@ -2905,38 +2906,47 @@ } }, "node_modules/exiftool-vendored": { - "version": "28.8.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.8.0.tgz", - "integrity": "sha512-R7tirJLr9fWuH9JS/KFFLB+O7jNGKuPXGxREc6YybYangEudGb+X8ERsYXk9AifMiAWh/2agNfbgkbcQcF+MxA==", + "version": "35.20.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-35.20.0.tgz", + "integrity": "sha512-Yn66dSBaWGcUaSbm5Nl4G28rxtceLlWf4PstqJMbLix9sN7w0okWHPEvdudiP56Q5Cjl7v3TLyKKwowUFlbD8g==", + "license": "MIT", "dependencies": { - "@photostructure/tz-lookup": "^11.0.0", - "@types/luxon": "^3.4.2", - "batch-cluster": "^13.0.0", + "@photostructure/tz-lookup": "^11.5.0", + "@types/luxon": "^3.7.1", + "batch-cluster": "^17.3.1", "he": "^1.2.0", - "luxon": "^3.5.0" + "luxon": "^3.7.2" + }, + "engines": { + "node": ">=20.0.0" }, "optionalDependencies": { - "exiftool-vendored.exe": "13.0.0", - "exiftool-vendored.pl": "13.0.1" + "exiftool-vendored.exe": "13.58.0", + "exiftool-vendored.pl": "13.58.0" } }, "node_modules/exiftool-vendored.exe": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-13.0.0.tgz", - "integrity": "sha512-4zAMuFGgxZkOoyQIzZMHv1HlvgyJK3AkNqjAgm8A8V0UmOZO7yv3pH49cDV1OduzFJqgs6yQ6eG4OGydhKtxlg==", + "version": "13.58.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-13.58.0.tgz", + "integrity": "sha512-pV7SjQeOu4Q77DWuyF+hlRYWVlRcSAqfqTTujBZeGUy/Q9+RPAy877YgSZIxKOYW1TxmmL8KyBGxaG0JKYG8BQ==", + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/exiftool-vendored.pl": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-13.0.1.tgz", - "integrity": "sha512-+BRRzjselpWudKR0ltAW5SUt9T82D+gzQN8DdOQUgnSVWWp7oLCeTGBRptbQz+436Ihn/mPzmo/xnf0cv/Qw1A==", + "version": "13.58.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-13.58.0.tgz", + "integrity": "sha512-+Z2xhZrYLMu/anO/s14AaS/K5HMJ5Cw9C3KefIeYNpkZRN4RRBJHm7R34yjj9Pv+elqYRZrQV9NcqvkBLn/68w==", + "license": "MIT", "optional": true, "os": [ "!win32" - ] + ], + "bin": { + "exiftool": "bin/exiftool" + } }, "node_modules/expand-template": { "version": "2.0.3", @@ -3980,9 +3990,9 @@ "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==" }, "node_modules/nodemailer": { - "version": "6.10.1", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", - "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "version": "8.0.9", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.9.tgz", + "integrity": "sha512-5ofa7BUN8+C+Hckh5V2GjeeOGRQBx0CJQA6KxrvuZfC8iU4/q7sLn8XrtEEhJkjV6HdyIiQs7Bba6bTao8JhkA==", "license": "MIT-0", "engines": { "node": ">=6.0.0" diff --git a/package.json b/package.json index 9a38583..c61a4d0 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "browser-id3-writer": "4.4.0", "cors": "^2.8.5", "dotenv": "^16.4.5", - "exiftool-vendored": "^28.3.1", + "exiftool-vendored": "^35.20.0", "express": "^4.19.2", "express-rate-limit": "^8.5.2", "fs-extra": "^11.2.0", @@ -35,7 +35,7 @@ "lucide-react": "^0.390.0", "multer": "^2.0.0", "music-metadata": "^11.12.3", - "nodemailer": "^6.10.1", + "nodemailer": "^8.0.9", "postcss": "^8.4.38", "react": "^18.3.1", "react-dom": "^18.3.1", From cd2979dfaa66aa7c96ae84f201ddc433ef379129 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 04:25:21 +0000 Subject: [PATCH 3/5] Fix Render deployment: Docker server/ copy, same-origin frontend, config diagnostics - Dockerfile runtime stage was missing the server/ directory, causing a module-not-found crash on boot (server.js requires ./server/*). - Frontend no longer hard-throws when VITE_API_URL is unset; empty base URL now correctly means same-origin requests for single-service deployments. - Add startup [Config] log summary so Stripe/email live-vs-mock state is visible in logs. - Add render.yaml blueprint and RENDER.md with the full env-var reference. --- Dockerfile | 1 + RENDER.md | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++++ app.tsx | 8 ++--- render.yaml | 58 +++++++++++++++++++++++++++++++ server.js | 23 ++++++++++++- 5 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 RENDER.md create mode 100644 render.yaml diff --git a/Dockerfile b/Dockerfile index 50a634a..a24319c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,7 @@ RUN npm ci --omit=dev \ && npm cache clean --force COPY server.js ./ +COPY server ./server COPY --from=builder /app/dist ./dist # Runtime directories (uploads ephemeral; /data intended for SQLite volume mounts) diff --git a/RENDER.md b/RENDER.md new file mode 100644 index 0000000..411f384 --- /dev/null +++ b/RENDER.md @@ -0,0 +1,98 @@ +# Deploying SpectraCleanse AI on Render + +This is the complete environment-variable reference and setup guide for running +SpectraCleanse AI on [Render](https://render.com). It explains why Stripe and +email verification may appear "not working" and exactly how to fix it. + +## Why Stripe / email verification aren't working + +The server changes behavior based on `NODE_ENV` and which secrets are present: + +- **If `NODE_ENV` is NOT `production`** the server enables **mock checkout** + (Stripe is bypassed and returns a fake success URL — no real charge) and + **dev-fallback email** (verification/reset emails are only logged, never + sent). This is almost always the cause of "Stripe and email aren't working." +- **If `NODE_ENV` is `production` but Stripe vars are missing**, the server + exits on boot with `FATAL: Stripe is not fully configured in production.` +- **If SMTP vars are missing in production**, account creation still works but + verification/reset emails fail to send. + +After deploying, open your Render service **Logs** and look for the +`[Config]` summary printed at startup — it tells you whether Stripe and Email +are actually live or running in mock/fallback mode. + +## Required environment variables + +Set these in **Render → your service → Environment**. (If you deploy via the +included `render.yaml` blueprint, the keys are pre-created and you just fill in +the secret values.) + +### Core + +| Variable | Required | Value / Notes | +|---|---|---| +| `NODE_ENV` | ✅ | `production` — **this is the single most important one.** Turns off mock checkout and dev-email fallback. | +| `JWT_SECRET` | ✅ | A long random string. Generate: `node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"` | +| `FRONTEND_URL` | ✅ | Your public URL, e.g. `https://spectracleanseai.onrender.com`. Used for CORS, Stripe redirect URLs, and email links. | +| `DB_PATH` | ✅ | `/data/spectra.db` — must point at a **persistent disk** or data is wiped on every deploy. | +| `PORT` | Auto | Render injects this automatically; the server reads it. Do not hard-code. | +| `APP_BASE_URL` | Optional | Base URL for email links. Falls back to `FRONTEND_URL`, so usually unnecessary. | +| `ALLOWED_ORIGINS` | Optional | Comma-separated extra CORS origins. Only needed if the frontend is on a different domain than the API. | + +### Stripe — all four required for live checkout + +| Variable | Where to find it | +|---|---| +| `STRIPE_SECRET_KEY` | Stripe Dashboard → Developers → API keys → Secret key (`sk_live_…`) | +| `STRIPE_WEBHOOK_SECRET` | Stripe Dashboard → Developers → Webhooks → your endpoint → Signing secret (`whsec_…`) | +| `STRIPE_CREATOR_PRICE_ID` | Stripe → Products → Creator plan → Price ID (`price_…`) | +| `STRIPE_STUDIO_PRICE_ID` | Stripe → Products → Studio plan → Price ID (`price_…`) | + +If **any** of these four is missing, the server treats Stripe as unconfigured. + +### Email / SMTP — all five required to send mail + +| Variable | Notes | +|---|---| +| `SMTP_HOST` | e.g. `smtp.sendgrid.net`, `smtp.resend.com`, `smtp.gmail.com` | +| `SMTP_PORT` | `587` (STARTTLS) or `465` (implicit TLS) | +| `SMTP_USER` | SMTP username (for SendGrid the literal string `apikey`) | +| `SMTP_PASS` | SMTP password / API key | +| `SMTP_FROM` | From address, e.g. `SpectraCleanse ` | + +If **any** of these five is missing, verification/reset emails won't send in production. + +### Optional + +| Variable | Notes | +|---|---| +| `GEMINI_API_KEY` | Only needed for the AI SEO-generation feature. | +| `VITE_API_URL` | Build-time only. Leave **unset** for the default same-origin deployment. Set it only if you host the frontend separately from the API. | + +## Setup steps + +1. **Create the service.** Use the included `render.yaml` (New → Blueprint) or + create a Web Service with **Runtime: Docker** pointing at this repo's + `Dockerfile`. +2. **Add a persistent disk** mounted at `/data` (≥1 GB) so the SQLite database + survives deploys. Set `DB_PATH=/data/spectra.db`. +3. **Fill in all environment variables** from the tables above. +4. **Deploy**, then check `https://.onrender.com/api/health` → + `{"status":"ok"}`. +5. **Configure the Stripe webhook.** In Stripe → Developers → Webhooks, add an + endpoint at `https://.onrender.com/api/stripe-webhook` and + subscribe to `checkout.session.completed` and `customer.subscription.deleted`. + Copy its signing secret into `STRIPE_WEBHOOK_SECRET` and redeploy. +6. **Verify the logs.** The startup `[Config]` lines should show Stripe and + Email as `configured`. + +## Quick checklist + +- [ ] `NODE_ENV=production` +- [ ] `JWT_SECRET` set to a strong random value +- [ ] `FRONTEND_URL` = your Render URL +- [ ] Persistent disk at `/data` + `DB_PATH=/data/spectra.db` +- [ ] All 4 `STRIPE_*` vars set +- [ ] Stripe webhook endpoint created and `STRIPE_WEBHOOK_SECRET` set +- [ ] All 5 `SMTP_*` vars set +- [ ] `/api/health` returns ok and logs show Stripe + Email `configured` diff --git a/app.tsx b/app.tsx index fecf825..f99d07f 100644 --- a/app.tsx +++ b/app.tsx @@ -19,13 +19,13 @@ import { type SavedReleaseDefaults, } from './src/utils/releaseDefaults'; +// When the frontend and API are served from the same origin (the default +// single-service Render/Docker deployment), an empty base URL means requests +// are made relative to the current origin. Only set VITE_API_URL when the API +// is hosted on a different origin than the frontend. const API_BASE_URL = import.meta.env.VITE_API_URL || (import.meta.env.DEV ? 'http://localhost:3001' : ''); - -if (!API_BASE_URL) { - throw new Error('Missing VITE_API_URL in production build'); -} const PLATFORMS = ['General', 'YouTube', 'Spotify', 'Apple Music', 'TikTok'] as const; type Platform = typeof PLATFORMS[number]; type ItemStatus = 'pending' | 'analyzing' | 'processing' | 'done' | 'error'; diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..c1741ed --- /dev/null +++ b/render.yaml @@ -0,0 +1,58 @@ +# Render Blueprint for SpectraCleanse AI +# Deploy: Render Dashboard → New → Blueprint → connect this repo. +# Secrets (sync: false) must be filled in the Render dashboard after the first deploy. +services: + - type: web + name: spectracleanseai + runtime: docker + dockerfilePath: ./Dockerfile + plan: starter + healthCheckPath: /api/health + # Persistent disk for the SQLite database. Without this, ALL user accounts + # and data are wiped on every deploy/restart. + disk: + name: spectra-data + mountPath: /data + sizeGB: 1 + envVars: + # ── Core ──────────────────────────────────────────────────────────────── + - key: NODE_ENV + value: production + - key: DB_PATH + value: /data/spectra.db + - key: JWT_SECRET + generateValue: true # Render generates a strong random value + # Your public Render URL, e.g. https://spectracleanseai.onrender.com + # Used for CORS, Stripe success/cancel URLs, and email verification links. + - key: FRONTEND_URL + sync: false + - key: APP_BASE_URL + sync: false # Optional; falls back to FRONTEND_URL + - key: ALLOWED_ORIGINS + sync: false # Optional; only if a separate frontend domain + + # ── Stripe (ALL FOUR required for live checkout) ──────────────────────── + - key: STRIPE_SECRET_KEY + sync: false + - key: STRIPE_WEBHOOK_SECRET + sync: false + - key: STRIPE_CREATOR_PRICE_ID + sync: false + - key: STRIPE_STUDIO_PRICE_ID + sync: false + + # ── Email / SMTP (ALL FIVE required to send verification/reset mail) ───── + - key: SMTP_HOST + sync: false + - key: SMTP_PORT + value: "587" + - key: SMTP_USER + sync: false + - key: SMTP_PASS + sync: false + - key: SMTP_FROM + sync: false + + # ── Optional ──────────────────────────────────────────────────────────── + - key: GEMINI_API_KEY + sync: false # Only needed for AI SEO generation diff --git a/server.js b/server.js index b3d29d7..f79be43 100644 --- a/server.js +++ b/server.js @@ -764,7 +764,28 @@ if (fs.existsSync(distPath)) { } const PORT = process.env.PORT || 3001; -app.listen(PORT, () => console.log(`SpectraCleanse backend on :${PORT}`)); +app.listen(PORT, () => { + console.log(`SpectraCleanse backend on :${PORT}`); + // Startup config summary – check these lines in your host's logs to confirm + // Stripe and email are actually live (not running in mock / dev-fallback mode). + console.log(`[Config] NODE_ENV=${process.env.NODE_ENV || '(unset → non-production defaults)'}`); + console.log( + `[Config] Stripe: ${ + STRIPE_CONFIGURED + ? 'configured (live checkout)' + : `NOT configured${ENABLE_MOCK_CHECKOUT ? ' (mock checkout enabled – no real charges)' : ''}` + }` + ); + console.log( + `[Config] Email/SMTP: ${ + isEmailDeliveryConfigured() + ? 'configured (verification & reset emails will send)' + : 'NOT configured (verification/reset emails will NOT send)' + }` + ); + console.log(`[Config] Gemini SEO: ${process.env.GEMINI_API_KEY ? 'configured' : 'NOT configured'}`); + console.log(`[Config] CORS origins: ${[...allowedOrigins].join(', ') || '(none)'}`); +}); process.on('exit', () => { exiftool.end(); db.close(); }); process.on('SIGTERM', () => { exiftool.end(); db.close(); process.exit(0); }); From f7161170db7440099db16a9443c9fecb3a8b4e2f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 04:27:34 +0000 Subject: [PATCH 4/5] Upgrade nodemailer 6 -> 8 to fix high-severity SMTP CVEs Resolves GHSA-c7w3-x93f-qmm8 (SMTP command injection) and related high-severity advisories flagged by 'npm audit --audit-level=high' in CI. The createTransport/sendMail API used by server/emailService.js is unchanged; server smoke test still boots and /api/health responds. --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 87c3de5..404ddeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "lucide-react": "^0.390.0", "multer": "^2.0.0", "music-metadata": "^11.12.3", - "nodemailer": "^6.10.1", + "nodemailer": "^8.0.10", "postcss": "^8.4.38", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -3476,9 +3476,9 @@ "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==" }, "node_modules/nodemailer": { - "version": "6.10.1", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", - "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz", + "integrity": "sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==", "license": "MIT-0", "engines": { "node": ">=6.0.0" diff --git a/package.json b/package.json index 1f8e3c7..2aa8431 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "lucide-react": "^0.390.0", "multer": "^2.0.0", "music-metadata": "^11.12.3", - "nodemailer": "^6.10.1", + "nodemailer": "^8.0.10", "postcss": "^8.4.38", "react": "^18.3.1", "react-dom": "^18.3.1", From e13e0ce6a1e35b71d0c87a64398fa71117db496a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 04:59:25 +0000 Subject: [PATCH 5/5] Upgrade exiftool-vendored 28 -> 35 to fix argument-injection CVE Resolves GHSA-cw26-7653-2rp5 (argument injection via newline in tag names), the last high-severity advisory failing 'npm audit --audit-level=high' in CI. The read/write/version/readRaw/end API used by server/processor.js is unchanged; verified end-to-end: write+read+full -all= wipe cycle works on MP4 (bundled ExifTool 13.59), all 13 unit tests pass, and the server smoke test boots. --- package-lock.json | 52 ++++++++++++++++++++++++++++------------------- package.json | 2 +- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 404ddeb..9b8e174 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "browser-id3-writer": "4.4.0", "cors": "^2.8.5", "dotenv": "^16.4.5", - "exiftool-vendored": "^28.3.1", + "exiftool-vendored": "^35.21.0", "express": "^4.19.2", "fs-extra": "^11.2.0", "jsonwebtoken": "^9.0.2", @@ -1735,11 +1735,12 @@ } }, "node_modules/batch-cluster": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-13.0.0.tgz", - "integrity": "sha512-EreW0Vi8TwovhYUHBXXRA5tthuU2ynGsZFlboyMJHCCUXYa2AjgwnE3ubBOJs2xJLcuXFJbi6c/8pH5+FVj8Og==", + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-17.3.1.tgz", + "integrity": "sha512-/aWEgZKXgvEseV3WEIRyjDoFka9FTrpt5+FYCxn+giUgveGBKxWjz3cl26V3aD+1kvOBP3nmANZZfcXDmKzcAA==", + "license": "MIT", "engines": { - "node": ">=14" + "node": ">=20" } }, "node_modules/bcryptjs": { @@ -2428,38 +2429,47 @@ } }, "node_modules/exiftool-vendored": { - "version": "28.8.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.8.0.tgz", - "integrity": "sha512-R7tirJLr9fWuH9JS/KFFLB+O7jNGKuPXGxREc6YybYangEudGb+X8ERsYXk9AifMiAWh/2agNfbgkbcQcF+MxA==", + "version": "35.21.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-35.21.0.tgz", + "integrity": "sha512-yZE8E8ZrRwAiD0z+MaAH5n6FdGsc2dbXbGHTJOaMWsQFsNOuKOkHgpgFzS9neB4MB8MirNlKDfEOEmg1KzdeTw==", + "license": "MIT", "dependencies": { - "@photostructure/tz-lookup": "^11.0.0", - "@types/luxon": "^3.4.2", - "batch-cluster": "^13.0.0", + "@photostructure/tz-lookup": "^11.5.0", + "@types/luxon": "^3.7.1", + "batch-cluster": "^17.3.1", "he": "^1.2.0", - "luxon": "^3.5.0" + "luxon": "^3.7.2" + }, + "engines": { + "node": ">=20.0.0" }, "optionalDependencies": { - "exiftool-vendored.exe": "13.0.0", - "exiftool-vendored.pl": "13.0.1" + "exiftool-vendored.exe": "13.59.0", + "exiftool-vendored.pl": "13.59.0" } }, "node_modules/exiftool-vendored.exe": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-13.0.0.tgz", - "integrity": "sha512-4zAMuFGgxZkOoyQIzZMHv1HlvgyJK3AkNqjAgm8A8V0UmOZO7yv3pH49cDV1OduzFJqgs6yQ6eG4OGydhKtxlg==", + "version": "13.59.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-13.59.0.tgz", + "integrity": "sha512-d+Glvl77sKAt3ybMyE2o9eLMtublnZIlii7QGFej5CwcPwvq9v440CmKnLvufzdUV3RGXU1q1/rZepONUNXJIQ==", + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/exiftool-vendored.pl": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-13.0.1.tgz", - "integrity": "sha512-+BRRzjselpWudKR0ltAW5SUt9T82D+gzQN8DdOQUgnSVWWp7oLCeTGBRptbQz+436Ihn/mPzmo/xnf0cv/Qw1A==", + "version": "13.59.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-13.59.0.tgz", + "integrity": "sha512-6YlBpi4pcfSmIYcsied6g4+XlDe7KSKMf5WPsLBpUCiD5PyO2fd1v/Ob37VZDj36+9XCRnXY98fqVfCfNIq8ug==", + "license": "MIT", "optional": true, "os": [ "!win32" - ] + ], + "bin": { + "exiftool": "bin/exiftool" + } }, "node_modules/expand-template": { "version": "2.0.3", diff --git a/package.json b/package.json index 2aa8431..e8793bd 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "browser-id3-writer": "4.4.0", "cors": "^2.8.5", "dotenv": "^16.4.5", - "exiftool-vendored": "^28.3.1", + "exiftool-vendored": "^35.21.0", "express": "^4.19.2", "fs-extra": "^11.2.0", "jsonwebtoken": "^9.0.2",