From ea3cf1dcd43c58f453342fc3de379f9fd87c9e20 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sat, 19 Sep 2026 18:49:51 +0200 Subject: [PATCH] fix(admin): music-track upload failing on valid audio files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent root causes behind 'admin music upload fails': 1. Supabase bucket name mismatch — code expects 'music-tracks' (plural, MUSIC_STORAGE_BUCKET), but the bucket was created as 'music-track' (singular). Every upload failed with a bucket-not-found error from Supabase Storage, surfaced only as a generic toast. Fixed by creating the correctly-named bucket (public, 5MB limit, audio/mpeg|mp4|aac allowed_mime_types matching the app's own constants) via the Supabase Storage API; verified end-to-end with a signed-upload-url + PUT probe. The old empty 'music-track' bucket is unused but still exists - deleting it needs the user's own action (blocked here by the auto-mode classifier's cloud-storage-delete guard). 2. Client-side validation trusted the browser-reported File.type, which is sniffed from the OS's MIME registry and is inconsistent for .m4a/.aac (e.g. Windows Chrome reports .m4a as audio/x-m4a, not audio/mp4) - a perfectly valid file could get rejected as 'wrong type'. Now derives the content-type from the file extension instead. Code-reviewed before commit (WORKFLOW.md step 6) - fixed two of four findings: the extension lookup now uses a Map instead of a plain object (prototype-shadowing via a file named e.g. 'x.constructor' previously bypassed the rejection), and requires an actual '.' before the extension (a bare filename like 'mp3' no longer matches). Extracted the allowed-types/size constants into a new client-safe src/lib/music- content-types.ts (supabase-storage.ts re-exports them) so the client form and server-side Zod schema share one source of truth instead of two independently maintained copies. Left the trust-the-extension trade-off as-is (documented in code): this panel sits behind ADMIN_SECRET + a role check, not the public upload surface, and the old File.type check was never real content-based validation either. Full test suite green (one unrelated legal.test.ts timeout in the full-suite run was confirmed flaky - passes cleanly in isolation). Co-Authored-By: Claude Sonnet 5 --- README.md | 1 + src/app/p/[secret]/music/page.tsx | 43 +++++++++++++++++++++++++------ src/lib/music-content-types.ts | 26 +++++++++++++++++++ src/lib/supabase-storage.ts | 13 +++++++--- 4 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 src/lib/music-content-types.ts diff --git a/README.md b/README.md index 0f3159d..a2107cd 100644 --- a/README.md +++ b/README.md @@ -626,3 +626,4 @@ npx vitest # watch mode | `eas update` must be run with `--platform android` (`mobile/`), not `--platform all` | This app has no web target — `react-native-web` isn't installed — so the default `--platform all` fails bundling with `Unable to resolve module react-native-web/dist/index`. Previously published updates were all Android-only too (check `eas channel:view ` to confirm before assuming this changed). | | `mobile/node_modules` can end up silently corrupted (missing nested files) after an interrupted/partial install, without `npm install` re-detecting or fixing it | Symptom: Metro bundling fails with `Unable to resolve module ./some/nested/path.js` inside an already-installed package (seen with `@expo/cli`'s `utils/telemetry`, then `@sentry/browser` and `@sentry-internal/browser-utils` in the same session) — `npm install` reports "up to date" and does nothing. Fix: `rm -rf node_modules && npm install` (a full reinstall), not a targeted one; deleting just the one broken package's folder can just surface the *next* corrupted package one bundle-failure at a time. | | Mobile Google SSO can break with `redirect url ... does not match an authorized redirect URI` even though `GoogleSignInButton.tsx`/`app.json`'s scheme are unchanged | Clerk keeps its authorized-redirect-URL allowlist server-side per instance (`GET`/`POST https://api.clerk.com/v1/redirect_urls`, auth via `CLERK_SECRET_KEY`), entirely outside this repo — it can be missing or reset independent of any code change. Check that list before debugging app code; add the missing entry (`{"url":"pawfeed://sso-callback"}` for this app's `"scheme": "pawfeed"`) via `POST /v1/redirect_urls` — takes effect immediately, no app deploy/rebuild needed. | +| Admin music-track upload can fail even with a genuinely valid MP3/M4A/AAC file | Two independent causes, both hit in the same incident: (1) the Supabase bucket must be named **exactly** `music-tracks` (plural) — it was once created as `music-track` (singular), so every upload failed with a bucket-not-found error surfaced only as a generic "Upload fehlgeschlagen" toast; verify via `GET {SUPABASE_URL}/storage/v1/bucket` with the service-role key. (2) The upload form used to validate against the browser-reported `File.type`, which is sniffed from the OS's own MIME registry and is inconsistent for `.m4a`/`.aac` (e.g. Windows Chrome reports `.m4a` as `audio/x-m4a`, not `audio/mp4`) — it now derives the content-type from the file extension instead (`src/lib/music-content-types.ts`), matching what `MUSIC_ALLOWED_CONTENT_TYPES` (server-side, `supabase-storage.ts`) actually expects. | diff --git a/src/app/p/[secret]/music/page.tsx b/src/app/p/[secret]/music/page.tsx index 36df3cd..e85f46f 100644 --- a/src/app/p/[secret]/music/page.tsx +++ b/src/app/p/[secret]/music/page.tsx @@ -4,6 +4,7 @@ import { useState, useRef, useCallback } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; import { getMusicUrl } from "@/lib/music-url"; +import { MAX_MUSIC_FILE_SIZE, MUSIC_EXTENSION_CONTENT_TYPES, type MusicContentType } from "@/lib/music-content-types"; import { Plus, Pencil, Trash2, Loader2, Check, X, Music, Play, Pause } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -23,9 +24,34 @@ type TrackForm = { const emptyForm: TrackForm = { title: "", artist: "", licenseNote: "", storageKey: "", durationSecs: 0 }; -const ALLOWED_TYPES = ["audio/mpeg", "audio/mp4", "audio/aac"] as const; -type AllowedType = (typeof ALLOWED_TYPES)[number]; -const MAX_SIZE = 5 * 1024 * 1024; +const MAX_SIZE = MAX_MUSIC_FILE_SIZE; + +/** + * Extension -> canonical content-type, used instead of the browser-reported + * `file.type` for both validation and the actual upload's Content-Type + * header. `file.type` is sniffed by the browser/OS from its own MIME + * registry and is inconsistent for exactly these formats — Windows Chrome + * commonly reports `.m4a` as `audio/x-m4a` (not `audio/mp4`), and an + * unrecognized extension can come back as an empty string. Either would + * fail the old `ALLOWED_TYPES.includes(file.type)` check even for a + * perfectly valid MP3/M4A/AAC file — the upload looked like a rejected file + * type when it was really just an unrecognized *reported* MIME type. + * + * This does mean a file can be uploaded by simply renaming it with an + * allowed extension (e.g. `notes.txt` -> `notes.mp3`) rather than the + * browser's MIME-sniffed type having to agree first — a deliberate + * trade-off, not an oversight (flagged in code review): `file.type` was + * never a real content-based check either (browsers derive it from the same + * OS extension registry, not from inspecting bytes), this panel sits behind + * `ADMIN_SECRET` + a role check, not the public upload surface, and + * `readAudioDuration()` below still fails for genuinely non-audio bytes. + */ +function resolveContentType(file: File): MusicContentType | null { + const dotIndex = file.name.lastIndexOf("."); + if (dotIndex <= 0) return null; // no extension (or a bare ".ext"-only name) + const ext = file.name.slice(dotIndex + 1).toLowerCase(); + return MUSIC_EXTENSION_CONTENT_TYPES.get(ext) ?? null; +} /** Reads an audio file's duration client-side via a detached