From a7929c0ac99fc3ba4f2942a30a27c21ae89b7288 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Wed, 23 Sep 2026 18:33:21 +0200 Subject: [PATCH] Enhance upload functionality with genre selection and validation; refactor session cookie handling --- deploy/compose.yml | 4 ++ web-player/src/hooks.server.ts | 15 ++--- web-player/src/lib/api/schema.d.ts | 2 + .../lib/components/ui/primitives/Chip.svelte | 7 ++- web-player/src/lib/data/trackGenres.ts | 56 +++++++++++++++++++ web-player/src/lib/server/api.ts | 22 +++++++- web-player/src/lib/server/auth.ts | 39 +++++++++++++ .../src/routes/(app)/upload/+page.server.ts | 19 ++++++- .../src/routes/(app)/upload/+page.svelte | 43 +++++++++++++- .../src/routes/auth/callback/+server.ts | 5 +- web-player/src/routes/auth/logout/+server.ts | 11 +++- 11 files changed, 200 insertions(+), 23 deletions(-) create mode 100644 web-player/src/lib/data/trackGenres.ts diff --git a/deploy/compose.yml b/deploy/compose.yml index c36778f..c3b5318 100644 --- a/deploy/compose.yml +++ b/deploy/compose.yml @@ -381,6 +381,10 @@ services: environment: NODE_ENV: production PORT: 3000 + # adapter-node rejects request bodies over 512K by default; audio + # uploads are posted to /upload. Keep in line with the edge's + # client_max_body_size. + BODY_SIZE_LIMIT: 1G ORIGIN: ${PUBLIC_WEB_URL} ZITADEL_ISSUER: ${PUBLIC_AUTH_URL} ZITADEL_CLIENT_ID: ${WEB_CLIENT_ID} diff --git a/web-player/src/hooks.server.ts b/web-player/src/hooks.server.ts index bb5d1f2..8123144 100644 --- a/web-player/src/hooks.server.ts +++ b/web-player/src/hooks.server.ts @@ -1,15 +1,16 @@ import type { Handle } from '@sveltejs/kit'; import { + clearSessionCookie, decodeSession, encodeSession, + readSessionCookie, refreshSession, - sessionCookieOptions, - SESSION_COOKIE + writeSessionCookie } from '$lib/server/auth'; import { authConfig } from '$lib/server/config'; export const handle: Handle = async ({ event, resolve }) => { - let session = await decodeSession(event.cookies.get(SESSION_COOKIE)); + let session = await decodeSession(readSessionCookie(event.cookies)); if ( session && @@ -17,13 +18,9 @@ export const handle: Handle = async ({ event, resolve }) => { ) { session = await refreshSession(session); if (session) { - event.cookies.set( - SESSION_COOKIE, - await encodeSession(session), - sessionCookieOptions(event.url) - ); + writeSessionCookie(event.cookies, await encodeSession(session), event.url); } else { - event.cookies.delete(SESSION_COOKIE, { path: '/' }); + clearSessionCookie(event.cookies); } } diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index ce584cd..0854e6d 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -714,7 +714,9 @@ export interface components { pictureIntentId: string; audioIntentId: string; + tags: components["schemas"]["Genre"][]; }; + Genre: "Pop" | "Rock" | "HipHop" | "RnB" | "Jazz" | "Blues" | "Classical" | "Electronic" | "House" | "Techno" | "Trance" | "Dubstep" | "DrumAndBass" | "Metal" | "Punk" | "Reggae" | "Reggaeton" | "Country" | "Folk" | "Indie" | "KPop" | "Latin" | "Soul" | "Funk" | "Ambient" | "Lofi"; CreateUserRequest: { id: number | string; diff --git a/web-player/src/lib/components/ui/primitives/Chip.svelte b/web-player/src/lib/components/ui/primitives/Chip.svelte index e5155b5..4b5f6fa 100644 --- a/web-player/src/lib/components/ui/primitives/Chip.svelte +++ b/web-player/src/lib/components/ui/primitives/Chip.svelte @@ -3,20 +3,21 @@ interface Props { selected?: boolean; + disabled?: boolean; count?: number; href?: string; onclick?: () => void; children: Snippet; } - let { selected = false, count, href, onclick, children }: Props = $props(); + let { selected = false, disabled = false, count, href, onclick, children }: Props = $props(); const classes = $derived( href ? 'rounded-full border border-line-strong px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg' : `flex items-center gap-2 rounded-full px-3.5 py-2 text-xs font-medium transition ${ selected ? 'bg-cta-strong text-ink' : 'bg-btn text-fg-2 hover:bg-btn-hover hover:text-fg' - }` + } ${disabled ? 'cursor-not-allowed opacity-40' : ''}` ); @@ -30,5 +31,5 @@ {#if href} {@render content()} {:else} - + {/if} diff --git a/web-player/src/lib/data/trackGenres.ts b/web-player/src/lib/data/trackGenres.ts new file mode 100644 index 0000000..9e64458 --- /dev/null +++ b/web-player/src/lib/data/trackGenres.ts @@ -0,0 +1,56 @@ +export const TRACK_GENRES = [ + { value: 'Pop', label: 'Pop' }, + { value: 'Rock', label: 'Rock' }, + { value: 'HipHop', label: 'Hip-hop' }, + { value: 'RnB', label: 'R&B' }, + { value: 'Jazz', label: 'Jazz' }, + { value: 'Blues', label: 'Blues' }, + { value: 'Classical', label: 'Clásica' }, + { value: 'Electronic', label: 'Electrónica' }, + { value: 'House', label: 'House' }, + { value: 'Techno', label: 'Techno' }, + { value: 'Trance', label: 'Trance' }, + { value: 'Dubstep', label: 'Dubstep' }, + { value: 'DrumAndBass', label: 'Drum and bass' }, + { value: 'Metal', label: 'Metal' }, + { value: 'Punk', label: 'Punk' }, + { value: 'Reggae', label: 'Reggae' }, + { value: 'Reggaeton', label: 'Reggaetón' }, + { value: 'Country', label: 'Country' }, + { value: 'Folk', label: 'Folk' }, + { value: 'Indie', label: 'Indie' }, + { value: 'KPop', label: 'K-pop' }, + { value: 'Latin', label: 'Latina' }, + { value: 'Soul', label: 'Soul' }, + { value: 'Funk', label: 'Funk' }, + { value: 'Ambient', label: 'Ambient' }, + { value: 'Lofi', label: 'Lo-fi' } +] as const; + +export type TrackGenre = (typeof TRACK_GENRES)[number]['value']; + +const GENRE_VALUES: ReadonlySet = new Set(TRACK_GENRES.map((genre) => genre.value)); + +const INTENSE: ReadonlySet = new Set(['Metal', 'Punk', 'Dubstep', 'DrumAndBass']); +const MELLOW: ReadonlySet = new Set(['Classical', 'Ambient', 'Lofi']); + +export function isTrackGenre(value: string): value is TrackGenre { + return GENRE_VALUES.has(value); +} + +export function areCompatible(a: TrackGenre, b: TrackGenre): boolean { + return !((INTENSE.has(a) && MELLOW.has(b)) || (INTENSE.has(b) && MELLOW.has(a))); +} + +export function genreLabel(value: TrackGenre): string { + return TRACK_GENRES.find((genre) => genre.value === value)?.label ?? value; +} + +export function findConflict(tags: readonly TrackGenre[]): [TrackGenre, TrackGenre] | undefined { + for (let i = 0; i < tags.length; i++) { + for (let j = i + 1; j < tags.length; j++) { + if (!areCompatible(tags[i], tags[j])) return [tags[i], tags[j]]; + } + } + return undefined; +} diff --git a/web-player/src/lib/server/api.ts b/web-player/src/lib/server/api.ts index 8b79eda..722a5c7 100644 --- a/web-player/src/lib/server/api.ts +++ b/web-player/src/lib/server/api.ts @@ -52,9 +52,27 @@ export function unwrapOrError( return result.data; } +export function apiErrorDetail(error: unknown): string | undefined { + if (typeof error !== 'object' || error === null) return undefined; + const { errors, detail, title } = error as { + errors?: Record; + detail?: unknown; + title?: unknown; + }; + if (errors && typeof errors === 'object') { + const messages = Object.values(errors) + .flat() + .filter((entry): entry is string => typeof entry === 'string'); + if (messages.length > 0) return messages.join(' '); + } + if (typeof detail === 'string' && detail !== '') return detail; + if (typeof title === 'string' && title !== '') return title; + return undefined; +} + export function unwrapOrFail( result: { error?: unknown }, message: string -): ActionFailure<{ message: string }> | undefined { - return result.error ? fail(502, { message }) : undefined; +): ActionFailure<{ message: string; detail?: string }> | undefined { + return result.error ? fail(502, { message, detail: apiErrorDetail(result.error) }) : undefined; } diff --git a/web-player/src/lib/server/auth.ts b/web-player/src/lib/server/auth.ts index ac045e2..58f4a7a 100644 --- a/web-player/src/lib/server/auth.ts +++ b/web-player/src/lib/server/auth.ts @@ -1,3 +1,4 @@ +import type { Cookies } from '@sveltejs/kit'; import * as client from 'openid-client'; import { EncryptJWT, jwtDecrypt } from 'jose'; import { dev } from '$app/environment'; @@ -53,6 +54,44 @@ export function sessionCookieOptions(url: URL) { }; } +const SESSION_CHUNK_SIZE = 3600; + +function sessionChunkName(index: number): string { + return index === 0 ? SESSION_COOKIE : `${SESSION_COOKIE}.${index}`; +} + +export function readSessionCookie(cookies: Cookies): string | undefined { + const parts: string[] = []; + for (let index = 0; ; index++) { + const part = cookies.get(sessionChunkName(index)); + if (part === undefined) break; + parts.push(part); + } + return parts.length > 0 ? parts.join('') : undefined; +} + +export function clearSessionCookie(cookies: Cookies, keep = 0): void { + for (const { name } of cookies.getAll()) { + const match = + name === SESSION_COOKIE + ? 0 + : name.startsWith(`${SESSION_COOKIE}.`) + ? Number(name.slice(SESSION_COOKIE.length + 1)) + : -1; + if (Number.isInteger(match) && match >= keep) cookies.delete(name, { path: '/' }); + } +} + +export function writeSessionCookie(cookies: Cookies, token: string, url: URL): void { + const options = sessionCookieOptions(url); + const count = Math.ceil(token.length / SESSION_CHUNK_SIZE); + for (let index = 0; index < count; index++) { + const chunk = token.slice(index * SESSION_CHUNK_SIZE, (index + 1) * SESSION_CHUNK_SIZE); + cookies.set(sessionChunkName(index), chunk, options); + } + clearSessionCookie(cookies, count); +} + export async function encodeSession(session: Session): Promise { const now = Math.floor(Date.now() / 1000); return new EncryptJWT({ ...session }) diff --git a/web-player/src/routes/(app)/upload/+page.server.ts b/web-player/src/routes/(app)/upload/+page.server.ts index bb0e6ba..53a7bdd 100644 --- a/web-player/src/routes/(app)/upload/+page.server.ts +++ b/web-player/src/routes/(app)/upload/+page.server.ts @@ -7,6 +7,7 @@ import { unwrapOrFail } from '$lib/server/api'; import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload'; +import { findConflict, genreLabel, isTrackGenre, type TrackGenre } from '$lib/data/trackGenres'; const MAX_TITLE = 100; @@ -23,6 +24,7 @@ export const actions: Actions = { const title = String(form.get('title') ?? '').trim(); const audio = form.get('audio'); const cover = form.get('cover'); + const rawTags = form.getAll('tags').map(String); if (title === '' || title.length > MAX_TITLE) { return fail(400, { message: 'El título es obligatorio (máx. 100 caracteres).' }); @@ -34,6 +36,20 @@ export const actions: Actions = { return fail(400, { message: 'Selecciona una portada.' }); } + if (rawTags.length === 0) { + return fail(400, { message: 'Elige al menos un género.' }); + } + if (!rawTags.every(isTrackGenre)) { + return fail(400, { message: 'Alguno de los géneros no es válido.' }); + } + const tags: TrackGenre[] = [...new Set(rawTags)]; + const conflict = findConflict(tags); + if (conflict) { + return fail(400, { + message: `Los géneros ${genreLabel(conflict[0])} y ${genreLabel(conflict[1])} no se pueden combinar.` + }); + } + const audioContentType = contentTypeOf(audio, AUDIO_TYPES); const pictureContentType = contentTypeOf(cover, IMAGE_TYPES); const api = createApiClient({ fetch, accessToken }); @@ -73,7 +89,8 @@ export const actions: Actions = { body: { title, pictureIntentId: urls.pictureIntentId, - audioIntentId: urls.audioIntentId + audioIntentId: urls.audioIntentId, + tags } }); diff --git a/web-player/src/routes/(app)/upload/+page.svelte b/web-player/src/routes/(app)/upload/+page.svelte index ac12ca8..28e907d 100644 --- a/web-player/src/routes/(app)/upload/+page.svelte +++ b/web-player/src/routes/(app)/upload/+page.svelte @@ -13,6 +13,8 @@ import Input from '$lib/components/ui/primitives/Input.svelte'; import Checkbox from '$lib/components/ui/primitives/Checkbox.svelte'; import Button from '$lib/components/ui/primitives/Button.svelte'; + import Chip from '$lib/components/ui/primitives/Chip.svelte'; + import { TRACK_GENRES, areCompatible, type TrackGenre } from '$lib/data/trackGenres'; type Status = 'idle' | 'uploading' | 'done' | 'error'; type StepState = 'idle' | 'active' | 'done'; @@ -28,6 +30,8 @@ let status = $state('idle'); let acceptedTerms = $state(false); let errorMsg = $state(''); + let errorDetail = $state(''); + let tags = $state([]); let publishedTitle = $state(''); let audioInput = $state(); @@ -40,7 +44,7 @@ const busy = $derived(status === 'uploading'); const canPublish = $derived( - title.trim() !== '' && audioName !== '' && hasCover && acceptedTerms && !busy + title.trim() !== '' && audioName !== '' && hasCover && tags.length > 0 && acceptedTerms && !busy ); const stepStates = $derived.by(() => { @@ -81,6 +85,14 @@ pickAudio(file); } + function toggleTag(tag: TrackGenre) { + tags = tags.includes(tag) ? tags.filter((current) => current !== tag) : [...tags, tag]; + } + + function tagBlocked(tag: TrackGenre) { + return !tags.includes(tag) && tags.some((current) => !areCompatible(current, tag)); + } + function reset() { title = ''; audioName = ''; @@ -90,6 +102,8 @@ status = 'idle'; acceptedTerms = false; errorMsg = ''; + errorDetail = ''; + tags = []; } @@ -153,6 +167,7 @@ use:enhance={() => { status = 'uploading'; errorMsg = ''; + errorDetail = ''; return async ({ result }) => { if (result.type === 'success') { publishedTitle = title; @@ -160,6 +175,7 @@ } else if (result.type === 'failure') { status = 'error'; errorMsg = (result.data?.message as string) ?? 'No se pudo subir la canción.'; + errorDetail = (result.data?.detail as string | undefined) ?? ''; } else { status = 'error'; errorMsg = 'Error inesperado durante la subida.'; @@ -236,6 +252,26 @@ + +
+ {#each TRACK_GENRES as genre (genre.value)} + toggleTag(genre.value)} + > + {genre.label} + + {/each} +
+ {#each tags as tag (tag)} + + {/each} + {#snippet hint()} +

Elige al menos uno. Algunos géneros no se pueden combinar, como Metal con Ambient.

+ {/snippet} +
+ Acepto los {errorMsg} + + {errorMsg} + {#if errorDetail}{errorDetail}{/if} + {/if}
diff --git a/web-player/src/routes/auth/callback/+server.ts b/web-player/src/routes/auth/callback/+server.ts index cb6d7f3..ea7c909 100644 --- a/web-player/src/routes/auth/callback/+server.ts +++ b/web-player/src/routes/auth/callback/+server.ts @@ -4,10 +4,9 @@ import type { RequestHandler } from './$types'; import { getOidcConfig, encodeSession, - sessionCookieOptions, toSessionUser, + writeSessionCookie, type Session, - SESSION_COOKIE, STATE_COOKIE, VERIFIER_COOKIE, NONCE_COOKIE, @@ -55,7 +54,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => { expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600) }; - cookies.set(SESSION_COOKIE, await encodeSession(session), sessionCookieOptions(url)); + writeSessionCookie(cookies, await encodeSession(session), url); for (const name of [STATE_COOKIE, VERIFIER_COOKIE, NONCE_COOKIE, RETURN_COOKIE]) { cookies.delete(name, { path: '/' }); diff --git a/web-player/src/routes/auth/logout/+server.ts b/web-player/src/routes/auth/logout/+server.ts index 4ebf70c..b51153a 100644 --- a/web-player/src/routes/auth/logout/+server.ts +++ b/web-player/src/routes/auth/logout/+server.ts @@ -1,12 +1,17 @@ import { redirect } from '@sveltejs/kit'; import * as client from 'openid-client'; import type { RequestHandler } from './$types'; -import { getOidcConfig, decodeSession, SESSION_COOKIE } from '$lib/server/auth'; +import { + clearSessionCookie, + decodeSession, + getOidcConfig, + readSessionCookie +} from '$lib/server/auth'; import { authConfig } from '$lib/server/config'; const handleLogout: RequestHandler = async ({ cookies }) => { - const session = await decodeSession(cookies.get(SESSION_COOKIE)); - cookies.delete(SESSION_COOKIE, { path: '/' }); + const session = await decodeSession(readSessionCookie(cookies)); + clearSessionCookie(cookies); const config = await getOidcConfig(); const endSessionUrl = client.buildEndSessionUrl(config, {