Author SHA1 Message Date
adminandClaude Sonnet 5 aa75fe612b fix(stories): video picker not showing videos on story creation
The story upload input's accept attribute listed explicit image+video
MIME subtypes (image/jpeg,...,video/mp4,...). Several mobile
browsers'/OS's native 'Photos & Videos' pickers only reliably offer
both media kinds when given the broad image/*,video/* wildcard
categories - the explicit mixed list was silently narrowing the picker
to photos only, matching the user's report ('nur Zugriff auf Fotos,
Videos werden gar nicht angezeigt').

The actual accept/reject decision is unchanged (still enforced in
handleFile() against the specific ALLOWED_IMAGE_TYPES/
ALLOWED_VIDEO_TYPES lists) - this only relaxes what the OS offers in
the picker dialog.

Code-reviewed before commit (WORKFLOW.md step 6) - fixed the one
finding that became newly reachable by this fix: videos were
previously never selectable at all, so a parameterized/codec-suffixed
MIME string (e.g.  from some Android camera-roll
pickers) could never surface as a rejection; now strips codec
parameters before the ALLOWED_VIDEO_TYPES comparison. Documented two
other findings as accepted, not-fixed-here trade-offs in code comments
(iOS HEIC auto-transcode interaction, broader picker surfacing formats
that still get rejected post-selection) rather than silently ignoring
them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 19:08:07 +02:00
adminandClaude Sonnet 5 ea3cf1dcd4 fix(admin): music-track upload failing on valid audio files
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 <noreply@anthropic.com>
2026-09-19 18:49:51 +02:00
adminandClaude Sonnet 5 1e8e6bbd7d docs: document Clerk redirect-URL allowlist as the Google SSO gotcha
Google SSO broke live with 'redirect url ... does not match an
authorized redirect URI ... pawfeed://sso-callback', despite the SSO
code (99575dd) being unchanged. Root cause was outside the repo
entirely: Clerk's per-instance authorized-redirect-URL allowlist is
server-side config (CLERK_SECRET_KEY-authenticated Backend API), not
tracked in git, and only had a stale org.pawfeed.app2 entry - the
current pawfeed:// scheme's callback was never registered.

Fixed live via POST https://api.clerk.com/v1/redirect_urls (no app
deploy needed), verified through the Google consent screen on an
emulator and end-to-end by the user on a real device.

Documented in both mobile/found.md (full incident) and README.md's
Known Issues table (the reusable gotcha: check Clerk's redirect-URL
allowlist before debugging app code when SSO breaks unexpectedly).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 18:30:43 +02:00
adminandClaude Sonnet 5 31b3f7b81b docs(mobile): note merge/verification/OTA-publish in found.md
Adds the merge commit (30c57b6), emulator end-to-end verification, and
OTA update group reference to the Settings-header-icon found.md entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 18:21:04 +02:00
adminandClaude Sonnet 5 30c57b661c Merge branch 'feature/settings-header-icon' into main
Closes Gitea #36.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 18:15:19 +02:00
6 changed files with 115 additions and 15 deletions
+2
View File
@@ -625,3 +625,5 @@ npx vitest # watch mode
| A 401 (not 403) from an admin tRPC procedure usually means a stale Clerk session, not a permissions bug | `protectedProcedure` throws `UNAUTHORIZED` (401) when Clerk's session is missing/expired; `assertAdmin()` throws `FORBIDDEN` (403) when the session is valid but the role is too low. A browser tab left open and backgrounded for hours can let Clerk's silent token refresh lapse — reload the page before suspecting a code regression. |
| `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 <channel>` 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. |
+6 -1
View File
@@ -117,6 +117,7 @@ Neu 7:
→ Beides im Code bestätigt (`app/(auth)/sign-up.tsx`, gleiches Muster auch in `sign-in.tsx` gefunden und mitbehoben): (1) Der Screen nutzte nur ein einfaches `View` als äußeren Container, kein `ScrollView`/`KeyboardAvoidingView` — auf kleinen Screens schob sich die Tastatur einfach über die unteren Felder. Fix: äußerer Container jetzt `KeyboardAvoidingView` (iOS: `padding`) + `ScrollView` (`keyboardShouldPersistTaps="handled"`, `contentContainerStyle` mit `flexGrow` statt `flex`, damit weiterhin zentriert bleibt aber scrollbar wird). (2) Neue geteilte `PasswordField`-Komponente (`src/components/PasswordField.tsx`) mit Auge/Auge-durchgestrichen-Toggle (lucide `Eye`/`EyeOff`) ersetzt das nackte `secureTextEntry`-`TextField` in beiden Screens. Kein natives Modul, OTA-fähig. Typecheck sauber (Mobile).
- [x] Ein anderer User, meinte wäre es nicht schöner und aufgeräumter wenn die App Settings nicht unter MyPets stehen würden sondern einen eigenen Tab erhalten.
→ Umgesetzt (Plan: `~/.claude/plans/mobile-settings-tab-extraction.md`, Gitea-Issue #36, Branch `feature/settings-header-icon`). Statt eines 5. Bottom-Tabs (ursprünglich vorgeschlagene Variante, siehe Mockup `mobile-settings-tab-mockup.png` im Plan-Ordner) wurde ein **Zahnrad-Icon im Header** gewählt, neben der Mailbox — auf Wunsch des Users, da Settings realistisch nur 12× im Halbjahr geöffnet wird und ein permanenter 5. Tab dafür unnötig Platz kostet (Mockup: `mobile-settings-header-icon-mockup.png`). Neuer Screen `app/(app)/settings.tsx` (Stack-Push wie "Suche", kein Modal, keine Tab-Bar sichtbar währenddessen), erreichbar über `SettingsHeaderButton` in `(tabs)/_layout.tsx` — erscheint auf allen 4 Tabs neben `MessagesHeaderButton`. Die 5 Footer-Sektionen aus `pets.tsx` (Darstellung/Sprache/Tier-Vorschläge/Rechtliches/Logout) wurden nach `src/components/settings/*.tsx` extrahiert (DRY, je eigene Datei). `pets.tsx` ist jetzt reine Tierliste. Nebenbei behoben: `FooterErrorBoundary` schützt jetzt den **gesamten** Settings-Screen statt nur den Logout-Button (Lücke aus der Code-Review beim OTA-Indikator-Feature). Typecheck + Lint sauber, i18n-Parität geprüft.
**Gemerged nach `main` in Commit `30c57b6`** (Merge von `feature/settings-header-icon`, Issue #36 geschlossen), end-to-end auf einem Android-Emulator verifiziert (Zahnrad öffnet Einstellungen korrekt, Zurück-Navigation funktioniert, keine Tab-Bar sichtbar währenddessen, "Meine Tiere" zeigt nur noch die Tierliste) und per OTA live veröffentlicht (Update-Group `f31e3b54`).
- [x] Ein user hat die meldung erhalten, "you do not have premission to access this pet" - kannst du mir erklären woher das kommen könnte ?
→ Ursache im Code gefunden (Web-seitig, nicht Mobile): Der Fehlertext kommt exakt aus `assertPetOwnership` (`src/lib/assert-pet-ownership.ts:19`), dem gemeinsamen Ownership-Check, den praktisch jede pet-bezogene Mutation zuerst durchläuft. Web speicherte die "aktive Tier"-ID in `localStorage["active_pet_id"]` (`src/context/ActivePetContext.tsx`) — anders als Mobile (siehe TODO Neu6: `LogoutSection` ruft dort bewusst `clearActivePetId()` auf) wurde dieser Wert auf Web **nirgends geleert**, auch nicht beim Sign-out über Clerks `<UserButton />`. `ActivePetInitializer` setzt nur dann einen Wert, wenn `activePetId` leer ist — er prüfte nie, ob die gespeicherte ID überhaupt noch zum aktuell eingeloggten Owner gehört. Konkretes Szenario: Nutzer A meldet sich ab, Nutzer B meldet sich im selben Browser an (gemeinsamer Rechner, oder A erstellt ein zweites Konto) — B's Session lud weiterhin A's alte `active_pet_id`, und jede Aktion schickte diese fremde Pet-ID ans Backend, das sie zu Recht mit genau dieser Fehlermeldung ablehnte. Gleiche Bug-Klasse wie der bereits auf Mobile behobene Logout-Fund, hier aber nie für Web nachgezogen.
Fix: `clearActivePetId()` in `src/context/ActivePetContext.tsx` ergänzt (Pendant zu Mobile). Da `ActivePetProvider` im Root-Layout **außerhalb** von `ClerkThemeProvider` sitzt (Clerk-Hooks dort nicht verfügbar — bewusste Architektur laut `(app)/layout.tsx`-Kommentar, um den Clerk-Bundle aus öffentlichen Routen rauszuhalten), kein direkter `useAuth()`-Zugriff im Context selbst möglich. Stattdessen neue `SignOutCleanup`-Komponente (`src/components/layout/SignOutCleanup.tsx`), mounted in `(app)/layout.tsx` neben `ActivePetInitializer` — beobachtet `useAuth().isSignedIn` und ruft `clearActivePetId()` beim Wechsel von `true` auf `false`, statt einen Klick-Handler an Clerks `<UserButton/>` zu hängen (die bietet dafür keinen sauberen Hook-Punkt). Deckt damit jeden Sign-out ab, unabhängig vom Auslöser. Typecheck + voller Testlauf (209/210, 1 skip wie zuvor) sauber, keine neuen Lint-Fehler.
@@ -124,4 +125,8 @@ Neu 7:
→ Ursache gefunden: `app/(app)/post-new.tsx` gehörte noch zu den nicht auf Dark Mode migrierten Screens — es importierte die statischen, Light-only `colors`/`typography` aus `theme/tokens.ts` direkt, statt sie per `useTheme()` zu beziehen (gleiche Bug-Klasse wie die bereits behobenen Fälle aus dem Darkmode-Audit, siehe README/CLAUDE.md-Kommentar in `theme/tokens.ts`: "dark-mode support has to be added file-by-file"). Der Container-Hintergrund blieb dadurch immer hell (`colors.porchWhite`), während die geteilte `TextField`-Komponente **korrekt** theme-abhängige Textfarbe zieht (`colors.ink`, im Dark Mode hell) — macht bei aktivem Dark Mode aus hellem Text auf hellem Hintergrund den gemeldeten "weiß auf weiß"-Effekt in der Caption-Eingabe.
Fix: `post-new.tsx` auf `useTheme()` + `makeStyles(colors)`-Muster umgestellt (identisch zu `milestone-new.tsx`). Gleiches Muster/gleicher Fund auch in `story-new.tsx` entdeckt (dort ohne Texteingabe, macht sich als falscher heller Hintergrund statt unlesbarem Text bemerkbar) — gleich mitbehoben.
Typecheck + Lint sauber (0 neue Warnungen, 6 vorbestehende `exhaustive-deps`-Warnungen unverändert).
**Nachtrag:** `app/(app)/search.tsx` (gleicher Fehler im Suchfeld) auf Nachfrage ebenfalls behoben — dort bekommen die separaten Unterkomponenten (`TabButton`, `PetRow`, `Loading`, `EmptyState`) `styles`/`colors` jetzt als Props von der Hauptkomponente durchgereicht, statt selbst die statischen Tokens zu importieren. Typecheck + Lint sauber (1 vorbestehende, unveränderte `alt-text`-Warnung bei `expo-image`).
**Nachtrag:** `app/(app)/search.tsx` (gleicher Fehler im Suchfeld) auf Nachfrage ebenfalls behoben — dort bekommen die separaten Unterkomponenten (`TabButton`, `PetRow`, `Loading`, `EmptyState`) `styles`/`colors` jetzt als Props von der Hauptkomponente durchgereicht, statt selbst die statischen Tokens zu importieren. Typecheck + Lint sauber (1 vorbestehende, unveränderte `alt-text`-Warnung bei `expo-image`).
- [x] Google-SSO-Login ging nicht mehr, obwohl das schon mal gelöst war ("The current redirect url passed in the sign in or sign up request does not match an authorized redirect URI for this instance... pawfeed://sso-callback").
**Kein Code-Bug**`GoogleSignInButton.tsx`/`useWarmUpBrowser.ts` (Commit `99575dd`) waren unverändert korrekt. Ursache lag außerhalb des Repos: Clerk führt pro Instanz eine eigene, serverseitige Allowlist autorisierter Redirect-URLs (`GET/POST https://api.clerk.com/v1/redirect_urls`, Auth via `CLERK_SECRET_KEY`) — komplett getrennt vom App-Code, kann also unabhängig vom letzten "war doch schon gelöst"-Stand geändert/geleert worden sein. Registriert war nur ein falscher/veralteter Eintrag `clerk://org.pawfeed.app2.callback` (vermutlich Rest einer früheren Bundle-ID-Iteration mit einer "2" drin) — `pawfeed://sso-callback` (aus `app.json`s `"scheme": "pawfeed"`) fehlte komplett.
Fix: fehlenden Eintrag per Clerk Backend API ergänzt (`POST /v1/redirect_urls`, `{"url":"pawfeed://sso-callback"}`) — live sofort wirksam, kein App-Deploy/Rebuild nötig. Am Emulator bis zum Google-Consent-Screen verifiziert (öffnet korrekt, leitet zu pawfeed.org weiter), kompletten Rücksprung dann vom User auf echtem Gerät bestätigt.
**Merke für künftige Sessions:** wenn SSO trotz unverändertem Code plötzlich bricht, zuerst die Clerk-Redirect-URL-Allowlist prüfen, bevor im App-Code gesucht wird — die liegt nicht in git und kann von außerhalb dieser Session geändert worden sein.
+35 -8
View File
@@ -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 <audio> element. */
function readAudioDuration(file: File): Promise<number> {
@@ -116,8 +142,9 @@ export default function AdminMusicPage() {
toast.error("Audiodatei muss unter 5 MB sein.");
return;
}
if (!ALLOWED_TYPES.includes(file.type as AllowedType)) {
toast.error("Nur MP3, M4A oder AAC erlaubt.");
const contentType = resolveContentType(file);
if (!contentType) {
toast.error("Nur .mp3, .m4a oder .aac erlaubt.");
return;
}
@@ -125,7 +152,7 @@ export default function AdminMusicPage() {
try {
const durationSecs = await readAudioDuration(file);
const { presignedUrl, key } = await getUploadUrl.mutateAsync({
contentType: file.type as AllowedType,
contentType,
fileSize: file.size,
});
@@ -135,7 +162,7 @@ export default function AdminMusicPage() {
xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`Upload failed: ${xhr.status}`));
xhr.onerror = () => reject(new Error("Network error"));
xhr.open("PUT", presignedUrl);
xhr.setRequestHeader("Content-Type", file.type);
xhr.setRequestHeader("Content-Type", contentType);
xhr.send(file);
});
@@ -245,7 +272,7 @@ export default function AdminMusicPage() {
<input
ref={fileInputRef}
type="file"
accept="audio/mpeg,audio/mp4,audio/aac"
accept=".mp3,.m4a,.aac,audio/mpeg,audio/mp4,audio/aac"
className="hidden"
onChange={handleFileChange}
/>
+36 -3
View File
@@ -23,7 +23,33 @@ const MAX_IMAGE_SIZE = 10 * 1024 * 1024;
const ALLOWED_VIDEO_TYPES = ["video/mp4", "video/quicktime", "video/webm"] as const;
const MAX_VIDEO_SIZE = 500 * 1024 * 1024; // same as normal video posts (VideoUploadForm.tsx)
const ACCEPT_ATTR = [...ALLOWED_IMAGE_TYPES, ...ALLOWED_VIDEO_TYPES].join(",");
// Wildcard categories, not the explicit MIME list (`image/jpeg,...,video/mp4,...`)
// — several mobile browsers'/OS's native "Photos & Videos" pickers only
// reliably offer *both* media kinds when given the broad `image/*`/`video/*`
// categories; a long explicit list mixing image and video subtypes was
// silently narrowing the picker down to photos only on at least one such
// picker (user report: "beim Story erstellen nur Zugriff auf Fotos, Videos
// werden gar nicht angezeigt"). This only relaxes what the OS *offers* in
// the picker dialog — the actual accept/reject decision still happens in
// handleFile() below against the specific
// ALLOWED_IMAGE_TYPES/ALLOWED_VIDEO_TYPES lists, unchanged. The two are
// deliberately decoupled now (this is a fixed category string, not derived
// from those lists) — a wildcard category can't meaningfully express "just
// these three video subtypes" to the OS picker, so narrowing
// ALLOWED_VIDEO_TYPES later won't narrow what the picker offers; it'll
// still be enforced right here after selection, just with an extra
// pick-then-reject round trip for a now-disallowed type — an accepted
// trade-off for actually being able to pick a video at all. Two related,
// deliberately-not-fixed-here risks (flagged in code review): (1) the OS
// picker now also surfaces formats this form never accepted (GIF, MKV,
// 3GPP, ...), each ending in the same pick-then-reject round trip; (2) iOS
// Safari's HEIC->JPEG auto-transcode for gallery photos is reportedly tied
// to the accept list being specific — `image/*` may let a raw HEIC file
// through where `image/jpeg,image/png,image/webp` didn't, and
// ALLOWED_IMAGE_TYPES doesn't list `image/heic`/`image/heif`. Neither is
// confirmed against a real device in this session; revisit if iPhone users
// report photo uploads failing here specifically.
const ACCEPT_ATTR = "image/*,video/*";
interface StoryFormProps {
onClose: () => void;
@@ -181,8 +207,15 @@ export function StoryForm({ onClose }: StoryFormProps) {
}
setErrorMessage(null);
const isVideo = (ALLOWED_VIDEO_TYPES as readonly string[]).includes(file.type);
const isImage = (ALLOWED_IMAGE_TYPES as readonly string[]).includes(file.type);
// Strip codec parameters before comparing — some Android camera-roll
// pickers report e.g. `video/mp4;codecs=hvc1` for HEVC-recorded clips,
// which never matched the bare `ALLOWED_VIDEO_TYPES` strings. This was
// unreachable while the accept attribute only offered photos (see
// ACCEPT_ATTR above); now that videos are actually selectable, a real
// mp4 could otherwise still get rejected as "invalid type" here.
const bareMimeType = file.type.split(";")[0].trim().toLowerCase();
const isVideo = (ALLOWED_VIDEO_TYPES as readonly string[]).includes(bareMimeType);
const isImage = (ALLOWED_IMAGE_TYPES as readonly string[]).includes(bareMimeType);
if (!isVideo && !isImage) {
setErrorMessage(t("invalidType"));
+26
View File
@@ -0,0 +1,26 @@
/**
* Client-safe constants for Story music-track uploads. Extracted out of
* `supabase-storage.ts` (which imports `"server-only"` and therefore can't
* be imported from the admin music page, a `"use client"` component) so the
* upload form and the server-side storage/tRPC code share one source of
* truth instead of two independently maintained copies drifting apart —
* found during code review of the upload-validation fix (see
* README.md's Known Issues).
*/
export const MUSIC_ALLOWED_CONTENT_TYPES = ["audio/mpeg", "audio/mp4", "audio/aac"] as const;
export type MusicContentType = (typeof MUSIC_ALLOWED_CONTENT_TYPES)[number];
export const MAX_MUSIC_FILE_SIZE = 5 * 1024 * 1024; // 5 MB — short clips only
/**
* Extension -> canonical content-type. A `Map`, not a plain object literal —
* a plain-object lookup (`{ mp3: ... }[ext]`) resolves inherited
* `Object.prototype` members for an extension like "constructor", silently
* returning a function instead of `undefined` and bypassing the "unknown
* extension" rejection entirely.
*/
export const MUSIC_EXTENSION_CONTENT_TYPES: ReadonlyMap<string, MusicContentType> = new Map([
["mp3", "audio/mpeg"],
["m4a", "audio/mp4"],
["aac", "audio/aac"],
]);
+10 -3
View File
@@ -1,5 +1,8 @@
import "server-only";
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
import { MUSIC_ALLOWED_CONTENT_TYPES, MAX_MUSIC_FILE_SIZE } from "./music-content-types";
export { MUSIC_ALLOWED_CONTENT_TYPES, MAX_MUSIC_FILE_SIZE };
// Lazy singleton — not initialized at module eval so Next.js build does not
// throw when env vars are absent during the page-data collection phase.
@@ -22,10 +25,14 @@ export const ALLOWED_CONTENT_TYPES = ["image/jpeg", "image/png", "image/webp"] a
// Music-track catalog (admin-curated, see admin.ts's music-track procedures)
// — a separate bucket since it holds audio, not images. Must be created once
// in the Supabase dashboard; this code assumes it already exists.
// in the Supabase dashboard (name must match exactly — a "music-track" vs.
// "music-tracks" typo here once made every upload fail with a bucket-not-found
// error that surfaced as a generic upload failure, see README.md's Known
// Issues); this code assumes it already exists.
export const MUSIC_STORAGE_BUCKET = "music-tracks";
export const MAX_MUSIC_FILE_SIZE = 5 * 1024 * 1024; // 5 MB — short clips only
export const MUSIC_ALLOWED_CONTENT_TYPES = ["audio/mpeg", "audio/mp4", "audio/aac"] as const;
// MAX_MUSIC_FILE_SIZE / MUSIC_ALLOWED_CONTENT_TYPES live in ./music-content-types
// (a plain, non-"server-only" module) and are re-exported above so existing
// imports of this file keep working — see that file's doc comment for why.
/**
* Creates a signed upload URL for direct client-to-Supabase uploads.