audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(upload): pre-flight auth check before destructive upload step (#1350)

an expired session during upload was producing a misleading "lost
connection to server" error from the SSE pipeline (uploader.svelte.ts
:240) after the user had filled out the entire form. worst possible
moment for an ambiguous failure — the user just spent time entering
metadata, picked files, attested rights, and gets thrown back to a
form with no clear path forward.

now: before kicking off the destructive XHR step, ping /auth/me and
distinguish three outcomes:
- ok: proceed with upload
- expired (401/403): for the track form, stash the metadata to
sessionStorage; redirect to /login with a return URL of /upload.
on return, rehydrate the form and prompt the user to reattach
the audio file (file objects can't be serialized).
- unverified (network error / 5xx): don't redirect (session may be
fine), don't proceed (upload would fail anyway). user retries.

a `preflightAuth` helper is used instead of `auth.refresh()` because
refresh treats network errors as session loss, which would bounce
healthy users to /login on any transient blip.

album-mode form preservation is deferred — the auth check still
fires for album uploads and the user gets a clear redirect with
return URL, but the album form metadata is lost on redirect.
will be addressed as a follow-up.

closes #1346

Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>

authored by

nate nowack
Claude Opus 4 (1M context)
and committed by
GitHub
06e62d83 1f0804f6

+175 -2
+22
frontend/src/lib/components/AlbumUploadForm.svelte
··· 1 1 <script lang="ts"> 2 + import { goto } from '$app/navigation'; 2 3 import type { AlbumSummary, Artist } from '$lib/types'; 3 4 import type { TrackEntry } from '$lib/components/TrackEntryCard.svelte'; 4 5 import TrackEntryCard from '$lib/components/TrackEntryCard.svelte'; 5 6 import PdsTooltip from '$lib/components/PdsTooltip.svelte'; 6 7 import { uploader, type UploadResult } from '$lib/uploader.svelte'; 7 8 import { toast } from '$lib/toast.svelte'; 9 + import { setReturnUrl } from '$lib/utils/return-url'; 10 + import { preflightAuth } from '$lib/upload-form-stash'; 8 11 import { getServerConfig, API_URL } from '$lib/config'; 9 12 10 13 const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.m4a', '.aiff', '.aif', '.flac']; ··· 231 234 e.preventDefault(); 232 235 233 236 if (!canSubmit) return; 237 + 238 + // pre-flight auth revalidation. without this, an expired session 239 + // produces a misleading "lost connection" error after the user has 240 + // filled out the entire album form (cover art, tracks, metadata). 241 + // catch it before the first destructive call so the user gets a 242 + // clear, recoverable error instead of an ambiguous failure. 243 + // (album-mode draft preservation is a follow-up — for now the album 244 + // form is lost on redirect, but at least the error is honest.) 245 + const authStatus = await preflightAuth(); 246 + if (authStatus === 'expired') { 247 + setReturnUrl('/upload?mode=album'); 248 + toast.error('your session expired — sign in to continue your upload'); 249 + goto('/login'); 250 + return; 251 + } 252 + if (authStatus === 'unverified') { 253 + toast.error("couldn't verify your session — check your connection and try again"); 254 + return; 255 + } 234 256 235 257 uploading = true; 236 258 let completed = 0;
+87
frontend/src/lib/upload-form-stash.ts
··· 1 + // sessionStorage stash for the track upload form, used when an auth check 2 + // before submit detects an expired session. the page redirects to /login and 3 + // rehydrates the form on return so the user doesn't lose what they typed. 4 + // 5 + // File objects can't be serialized — audio + cover art are deliberately 6 + // excluded. the user has to reattach them after sign-in; the upload page 7 + // surfaces this with a toast. 8 + 9 + import { API_URL } from '$lib/config'; 10 + import type { FeaturedArtist } from '$lib/types'; 11 + 12 + const STASH_KEY = 'plyr_upload_track_form_stash'; 13 + 14 + export interface TrackFormStash { 15 + title: string; 16 + albumTitle: string; 17 + description: string; 18 + featuredArtists: FeaturedArtist[]; 19 + uploadTags: string[]; 20 + attestedRights: boolean; 21 + supportGated: boolean; 22 + autoTag: boolean; 23 + trackUnlisted: boolean; 24 + } 25 + 26 + export function stashTrackForm(state: TrackFormStash): void { 27 + if (typeof sessionStorage === 'undefined') return; 28 + try { 29 + sessionStorage.setItem(STASH_KEY, JSON.stringify(state)); 30 + } catch { 31 + // sessionStorage disabled / quota exceeded — fail silently; 32 + // worst case we lose the draft, which matches today's behavior. 33 + } 34 + } 35 + 36 + export function restoreTrackForm(): TrackFormStash | null { 37 + if (typeof sessionStorage === 'undefined') return null; 38 + try { 39 + const raw = sessionStorage.getItem(STASH_KEY); 40 + if (!raw) return null; 41 + const parsed = JSON.parse(raw); 42 + // minimal shape check — if a future schema bump renders the stash 43 + // unreadable, drop it rather than crash the page. 44 + if (typeof parsed !== 'object' || parsed === null) return null; 45 + return parsed as TrackFormStash; 46 + } catch { 47 + return null; 48 + } 49 + } 50 + 51 + export function clearTrackFormStash(): void { 52 + if (typeof sessionStorage === 'undefined') return; 53 + try { 54 + sessionStorage.removeItem(STASH_KEY); 55 + } catch { 56 + // ignore 57 + } 58 + } 59 + 60 + /** 61 + * pre-flight auth status used before destructive upload actions. 62 + * 63 + * - `ok`: session is still valid; proceed with upload 64 + * - `expired`: server says we're not authenticated (401/403); stash the form 65 + * and redirect to login 66 + * - `unverified`: we couldn't reach the auth endpoint (network error / 5xx); 67 + * don't redirect (the session may still be fine), but also don't proceed 68 + * (the upload would fail anyway). caller surfaces a "try again" toast. 69 + * 70 + * intentionally distinct from `auth.refresh()` — refresh treats network 71 + * failures as session loss, which would bounce healthy users to /login on 72 + * any transient blip. 73 + */ 74 + export type PreflightAuthResult = 'ok' | 'expired' | 'unverified'; 75 + 76 + export async function preflightAuth(): Promise<PreflightAuthResult> { 77 + try { 78 + const response = await fetch(`${API_URL}/auth/me`, { 79 + credentials: 'include', 80 + }); 81 + if (response.ok) return 'ok'; 82 + if (response.status === 401 || response.status === 403) return 'expired'; 83 + return 'unverified'; 84 + } catch { 85 + return 'unverified'; 86 + } 87 + }
+65 -1
frontend/src/routes/upload/+page.svelte
··· 14 14 import { uploader } from "$lib/uploader.svelte"; 15 15 import { toast } from "$lib/toast.svelte"; 16 16 import { auth } from "$lib/auth.svelte"; 17 + import { setReturnUrl } from "$lib/utils/return-url"; 18 + import { 19 + stashTrackForm, 20 + restoreTrackForm, 21 + clearTrackFormStash, 22 + preflightAuth, 23 + } from "$lib/upload-form-stash"; 17 24 18 25 const AUDIO_EXTENSIONS = [".mp3", ".wav", ".m4a", ".aiff", ".aif", ".flac"]; 19 26 const AUDIO_MIME_TYPES = [ ··· 80 87 return; 81 88 } 82 89 90 + // restore a draft stashed when the user was bounced to /login by an 91 + // expired-session pre-flight. files can't be serialized, so the user 92 + // re-attaches the audio (and cover art) — everything else is restored. 93 + const stashed = restoreTrackForm(); 94 + if (stashed) { 95 + title = stashed.title; 96 + albumTitle = stashed.albumTitle; 97 + description = stashed.description; 98 + featuredArtists = stashed.featuredArtists; 99 + uploadTags = stashed.uploadTags; 100 + attestedRights = stashed.attestedRights; 101 + supportGated = stashed.supportGated; 102 + autoTag = stashed.autoTag; 103 + trackUnlisted = stashed.trackUnlisted; 104 + clearTrackFormStash(); 105 + toast.info( 106 + "your draft was restored — please reattach your audio file", 107 + 7000, 108 + ); 109 + } 110 + 83 111 await Promise.all([loadMyAlbums(), loadArtistProfile()]); 84 112 loading = false; 85 113 }); ··· 113 141 } 114 142 } 115 143 116 - function handleUpload(e: SubmitEvent) { 144 + async function handleUpload(e: SubmitEvent) { 117 145 e.preventDefault(); 118 146 if (!file) return; 147 + 148 + // pre-flight auth revalidation. the destructive XHR/SSE upload pipeline 149 + // surfaces an expired session as a generic "lost connection" error 150 + // (see uploader.svelte.ts — eventSource.onerror), which is misleading 151 + // at the worst possible moment. catch the auth state here, stash the 152 + // draft, and redirect to /login so the user can recover without losing 153 + // what they typed. 154 + const authStatus = await preflightAuth(); 155 + if (authStatus === "expired") { 156 + stashTrackForm({ 157 + title, 158 + albumTitle, 159 + description, 160 + featuredArtists: [...featuredArtists], 161 + uploadTags: [...uploadTags], 162 + attestedRights, 163 + supportGated, 164 + autoTag, 165 + trackUnlisted, 166 + }); 167 + setReturnUrl("/upload"); 168 + toast.error( 169 + "your session expired — sign in to continue your upload", 170 + ); 171 + goto("/login"); 172 + return; 173 + } 174 + if (authStatus === "unverified") { 175 + // couldn't reach /auth/me — session may be fine, but we can't tell. 176 + // don't redirect (might be a transient network blip), don't proceed 177 + // (the upload would fail anyway). user retries when they're back online. 178 + toast.error( 179 + "couldn't verify your session — check your connection and try again", 180 + ); 181 + return; 182 + } 119 183 120 184 const uploadFile = file; 121 185 const uploadTitle = title;
+1 -1
loq.toml
··· 176 176 177 177 [[rules]] 178 178 path = "frontend/src/routes/upload/+page.svelte" 179 - max_lines = 819 179 + max_lines = 883 180 180 181 181 [[rules]] 182 182 path = "services/moderation/src/admin.rs"