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/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/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/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..7299f99 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'; @@ -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); + }; + 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.', + 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.', + }, + ]; + const current = steps[onboardingStep - 1]; + return ( +
+
+
+ +
+

Step {onboardingStep} of {steps.length}

+

{current.title}

+

{current.body}

+ {current.visual} + {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..fa2b3c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,17 +20,19 @@ "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", + "express-rate-limit": "^8.5.2", "fs-extra": "^11.2.0", "jsonwebtoken": "^9.0.2", "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", + "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", @@ -1735,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": { @@ -2428,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.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", @@ -2524,6 +3011,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 +3435,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", @@ -3476,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.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" @@ -4151,6 +4665,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 +5201,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..f529ed5 100644 --- a/package.json +++ b/package.json @@ -27,17 +27,19 @@ "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", + "express-rate-limit": "^8.5.2", "fs-extra": "^11.2.0", "jsonwebtoken": "^9.0.2", "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", + "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 0000000..dde3db9 Binary files /dev/null and b/public/assets/og-image.png differ 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/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/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..e44df0b 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) => { @@ -764,7 +1004,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); }); 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'); + }); +});