3 Plan
Giorgio Caddeo edited this page 2026-06-29 14:58:37 +02:00

Plan: Promoter Page

Context

The Dancaestral website needs a password-protected promoter page (/promo) serving unreleased demo tracks privately. The demoCollection, demoTrack, and appSetting tables already exist in the schema (PostgreSQL, Drizzle). Audio files are stored in S3-compatible object storage via Bun's native S3Client. The admin dashboard already has Events and Albums CRUD — the demos and settings admin pages need to be added.

Progress

Completed

  • Section 1 — Promo Auth Utilities (src/lib/server/auth.ts)
  • Section 2app.d.ts (promoAuthed: boolean added to App.Locals)
  • Section 3 — Hooks (handlePromo added to sequence(...))
  • Section 6 (partial) — Promo routes: layout, login page, login server, auth guard all created. +page.svelte has dummy text only (collections + players not yet built)
  • Section 7 (partial) — Empty placeholder page at /admin/demos (no CRUD yet)
  • Section 8 — Admin Settings page (full: password field, copy current password, warning, upsert)
  • Section 9 — Admin sidebar (Demo + Impostazioni nav items added)

🔲 Remaining

  • Section 4 — Zod types: only promoLoginSchema and promoPasswordSchema exist. Still need: demoCollectionSchema, updateDemoCollectionSchema, demoTrackSchema, updateDemoTrackSchema
  • Section 5 — Audio Cache Module (src/lib/server/audio-cache.ts)
  • Section 6 — Promo +page.svelte (replace dummy text with collections + audio players)
  • Section 6 — Promo +page.server.ts (load collections + tracks)
  • Section 6track/[trackId]/+server.ts (cached audio proxy with Range support)
  • Section 7 — Admin demos full CRUD (+page.server.ts, +page.svelte, collection/track forms, S3 upload, cache invalidation)
  • Section 10 — S3 Helper Additions (getS3ClientInstance, deleteS3Object)
  • Section 11 — Env var AUDIO_CACHE_DIR

1. Promo Auth Utilities (src/lib/server/auth.ts)

Add alongside existing cookie helpers (uses @oslojs/crypto/sha2 + @oslojs/encoding already imported):

export const promoCookieName = 'promo-session';

export function getPromoPasswordHash(password: string): string {
    return encodeHexLowerCase(sha256(new TextEncoder().encode(password)));
}

export function setPromoCookie(event: RequestEvent, hash: string): void {
    event.cookies.set(promoCookieName, hash, {
        httpOnly: true,
        secure: true,
        path: '/promo',
        sameSite: 'strict',
        maxAge: 60 * 60 * 24 * 7
    });
}

export function deletePromoCookie(event: RequestEvent): void {
    event.cookies.delete(promoCookieName, { path: '/promo' });
}

Password change in admin immediately invalidates all promo cookies (hash changes → stored cookie no longer matches).


2. app.d.ts

Add promoAuthed: boolean; to App.Locals:

interface Locals {
    user: import('$lib/server/auth').SessionValidationResult['user'];
    session: import('$lib/server/auth').SessionValidationResult['session'];
    promoAuthed: boolean;
}

3. Hooks (src/hooks.server.ts)

Add handlePromo handle and include it in sequence(handleParaglide, handleAuth, handlePromo):

const handlePromo: Handle = async ({ event, resolve }) => {
    event.locals.promoAuthed = false;
    if (!event.url.pathname.startsWith('/promo')) return resolve(event);

    const cookie = event.cookies.get(auth.promoCookieName);
    if (!cookie) return resolve(event);

    const [setting] = await db
        .select()
        .from(table.appSettingTable)
        .where(eq(table.appSettingTable.key, 'promo_password'));

    if (setting) {
        event.locals.promoAuthed =
            cookie === auth.getPromoPasswordHash(setting.value);
    }

    return resolve(event);
};

The hook reads the promo password from appSettingTable, hashes it, and compares against the cookie value.


4. Zod Types 🔲

src/lib/types/demo.ts — partial (only promoLoginSchema + promoPasswordSchema). Still need collection and track schemas:

// TODO: Add these
export const demoCollectionSchema = z.object({
    title: z.string().min(1),
    description_it: z.string().optional(),
    description_en: z.string().optional(),
    order: z.coerce.number().int().default(0)
});

export const updateDemoCollectionSchema = demoCollectionSchema.extend({
    id: z.coerce.number().int().positive()
});

export const demoTrackSchema = z.object({
    collectionId: z.coerce.number().int().positive(),
    title: z.string().min(1),
    description_it: z.string().optional(),
    description_en: z.string().optional(),
    order: z.coerce.number().int().default(0)
});

export const updateDemoTrackSchema = demoTrackSchema.extend({
    id: z.coerce.number().int().positive()
});

5. Audio Cache Module (src/lib/server/audio-cache.ts) 🔲

Browsers send Range requests for every audio seek, so streaming every chunk from S3 means many read requests per track play. Instead, we download the full file from S3 once on first request, cache it to the local filesystem, and serve all subsequent requests (including Range) directly from disk.

src/lib/server/audio-cache.ts

import { existsSync, mkdirSync, statSync, unlinkSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { createWriteStream } from 'node:fs';
import { open } from 'node:fs/promises';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { getS3ClientInstance } from './s3.js';

const CACHE_DIR = process.env.AUDIO_CACHE_DIR ?? '/var/cache/dancaestral/audio';

function ensureCacheDir(): void {
    if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true });
}

/** Maps an S3 key to a local filesystem path inside the cache dir. */
export function cachedFilePath(storagePath: string): string {
    return join(CACHE_DIR, storagePath);
}

export function isCached(storagePath: string): boolean {
    return existsSync(cachedFilePath(storagePath));
}

export function getCachedSize(storagePath: string): number | null {
    if (!isCached(storagePath)) return null;
    return statSync(cachedFilePath(storagePath)).size;
}

/** Download full file from S3 and write to cache dir. Returns file size. */
export async function downloadToCache(storagePath: string): Promise<number> {
    const s3 = getS3ClientInstance();
    if (!s3) throw new Error('S3 not configured');

    const file = s3.file(storagePath);
    if (!(await file.exists())) throw new Error(`S3 object not found: ${storagePath}`);

    ensureCacheDir();
    const dest = cachedFilePath(storagePath);
    mkdirSync(dirname(dest), { recursive: true });

    const blob = await file.blob();
    const nodeStream = Readable.fromWeb(blob.stream() as any);
    await pipeline(nodeStream, createWriteStream(dest));

    return statSync(dest).size;
}

/** Open a cached file and return a ReadableStream for a byte range (or full file). */
export async function openCachedRange(
    storagePath: string,
    start?: number,
    end?: number
): Promise<{ stream: ReadableStream; size: number }> {
    const filePath = cachedFilePath(storagePath);
    const handle = await open(filePath, 'r');
    const { size } = await handle.stat();

    const rangeStart = start ?? 0;
    const rangeEnd = end ?? size - 1;

    const nodeStream = handle.createReadStream({ start: rangeStart, end: rangeEnd });
    nodeStream.on('close', () => handle.close());

    return {
        stream: Readable.toWeb(nodeStream) as ReadableStream,
        size
    };
}

/** Remove a cached file (called on track deletion or re-upload). */
export function invalidateCache(storagePath: string): void {
    const path = cachedFilePath(storagePath);
    if (existsSync(path)) unlinkSync(path);
}

How it works

  1. First request for a track → isCached() returns false → downloadToCache() fetches the full file from S3 and writes it to /var/cache/dancaestral/audio/{storagePath}.
  2. All requests (first included, after download) serve from disk via openCachedRange(), which uses fs.createReadStream({ start, end }) for efficient Range responses — zero S3 calls.
  3. Subsequent Range requests from browser seeks → served directly from the local file descriptor, near-instant.
  4. Track deletion / re-uploadinvalidateCache(storagePath) removes the local file so the next request re-downloads from S3.

6. Feature: Promoter Page (/promo) 🔲

Route structure

src/routes/promo/
    +layout.server.ts       ← ✅ redirect to /promo/login if !promoAuthed
    +layout.svelte          ← ✅ minimal layout (no main Navbar/footer)
    +page.svelte            ← 🔲 DUMMY TEXT — needs collections + audio players
    +page.server.ts         ← 🔲 load collections + tracks
    login/
        +page.svelte        ← ✅ password form
        +page.server.ts     ← ✅ validate → set cookie → redirect to /promo
    track/
        [trackId]/
            +server.ts      ← 🔲 S3 audio proxy (validates promoAuthed, streams with Range support)

+page.server.ts — load collections + tracks (TODO)

import { db } from '$lib/server/db/index.js';
import { demoCollectionTable, demoTrackTable } from '$lib/server/db/schema.js';
import { asc } from 'drizzle-orm';

export async function load() {
    const collections = await db
        .select()
        .from(demoCollectionTable)
        .orderBy(asc(demoCollectionTable.order));

    const tracks = await db
        .select()
        .from(demoTrackTable)
        .orderBy(asc(demoTrackTable.order));

    const grouped = collections.map((collection) => ({
        ...collection,
        tracks: tracks.filter((t) => t.collectionId === collection.id)
    }));

    return { collections: grouped };
}

track/[trackId]/+server.ts — cached audio proxy with Range support (TODO)

Serves audio from the local filesystem cache, downloading from S3 on first access. All Range requests are served from disk — one S3 read per track lifetime (plus re-uploads).

import type { RequestHandler } from './$types';
import { db } from '$lib/server/db/index.js';
import { demoTrackTable } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import {
    isCached,
    downloadToCache,
    getCachedSize,
    openCachedRange
} from '$lib/server/audio-cache.js';

export const GET: RequestHandler = async (event) => {
    if (!event.locals.promoAuthed) {
        return new Response('Unauthorized', { status: 401 });
    }

    const trackId = parseInt(event.params.trackId);
    const [track] = await db
        .select()
        .from(demoTrackTable)
        .where(eq(demoTrackTable.id, trackId));

    if (!track) {
        return new Response('Not Found', { status: 404 });
    }

    // Ensure the file is cached locally (download from S3 if not)
    try {
        if (!isCached(track.storagePath)) {
            await downloadToCache(track.storagePath);
        }
    } catch {
        return new Response('File Not Found', { status: 404 });
    }

    const totalSize = getCachedSize(track.storagePath)!;
    const rangeHeader = event.request.headers.get('Range');

    if (rangeHeader) {
        const match = rangeHeader.match(/bytes=(\d+)-(\d*)/);
        if (!match) return new Response('Invalid Range', { status: 416 });

        const start = parseInt(match[1]);
        const end = match[2] ? parseInt(match[2]) : totalSize - 1;
        const chunkSize = end - start + 1;

        const { stream } = await openCachedRange(track.storagePath, start, end);

        return new Response(stream, {
            status: 206,
            headers: {
                'Content-Type': 'audio/mpeg',
                'Content-Length': String(chunkSize),
                'Content-Range': `bytes ${start}-${end}/${totalSize}`,
                'Accept-Ranges': 'bytes',
                'Cache-Control': 'no-store'
            }
        });
    }

    const { stream } = await openCachedRange(track.storagePath);

    return new Response(stream, {
        headers: {
            'Content-Type': 'audio/mpeg',
            'Content-Length': String(totalSize),
            'Accept-Ranges': 'bytes',
            'Cache-Control': 'no-store'
        }
    });
};

Range support is required — browsers send Range: headers for audio seeks and will stall without 206 responses.


7. Admin: Demo Collections & Tracks (/admin/demos) 🔲

src/routes/admin/(dashboard)/demos/+page.server.ts

  • load: all collections (ordered by order) + all tracks. Superforms: createCollection, editCollection, deleteCollection, createTrack, deleteTrack.
  • Action createTrack: handles file upload via request.formData(). File is uploaded to S3 at key demos/{collectionId}/{timestamp}-{sanitized-filename} using the presigned URL flow from src/lib/server/s3.ts + src/routes/admin/(dashboard)/api/upload/+server.ts. The S3 key is stored as storagePath.
  • Action deleteTrack: deletes DB row, deletes the S3 object via deleteS3Object() in src/lib/server/s3.ts, and calls invalidateCache(storagePath) to remove the local cached copy.

src/routes/admin/(dashboard)/demos/+page.svelte

  • Two-column or accordion layout: collections on left, tracks for selected collection on right.
  • Dialogs for collection CRUD.
  • Track form includes <input type="file"> for audio upload.
  • Show a preview audio player per track (disabled until password is set in settings).

src/lib/components/forms/demo-collection-form.svelte

Shared form with: title, description (it/en), order.

src/lib/components/forms/demo-track-form.svelte

Shared form with: title, description (it/en), order, file input.


8. Admin: Settings (/admin/settings)

src/routes/admin/(dashboard)/settings/+page.server.ts

  • load: returns form + current password value (if set).
  • Action default: upsert into appSettingTable using .onConflictDoUpdate({ target: appSettingTable.key, set: { value: sqlexcluded.value } }).

src/routes/admin/(dashboard)/settings/+page.svelte

  • Single card showing current password (with copy button) above the warning.
  • Password field (min 8 chars) + save button.
  • Shows a warning that changing the password will invalidate all existing promo sessions.
  • Success message after save.

9. Admin Sidebar (src/lib/components/routes/admin/admin-sidebar.svelte)

Added to items array:

{ title: 'Demo',          url: '/admin/demos',    icon: MicVocalIcon },
{ title: 'Impostazioni',  url: '/admin/settings', icon: SettingsIcon }

10. S3 Helper Additions (src/lib/server/s3.ts) 🔲

Add getS3ClientInstance (internal, used by audio-cache) and deleteS3Object for track file cleanup:

export function getS3ClientInstance() {
    const config = getS3Client();
    if (!config) return null;
    return config.s3;
}

export async function deleteS3Object(key: string): Promise<boolean> {
    const s3 = getS3ClientInstance();
    if (!s3) return false;
    try {
        await s3.delete(key);
        return true;
    } catch {
        return false;
    }
}

11. Env Var 🔲

Add AUDIO_CACHE_DIR to the server environment (e.g. in .env):

AUDIO_CACHE_DIR=/var/cache/dancaestral/audio

Defaults to /var/cache/dancaestral/audio if not set. The directory is created automatically on first download.


File Manifest

Done

File Change
src/app.d.ts Add promoAuthed: boolean to App.Locals
src/hooks.server.ts Add handlePromo to sequence(...) + db/table imports
src/lib/server/auth.ts Add promoCookieName, getPromoPasswordHash, setPromoCookie, deletePromoCookie
src/lib/types/demo.ts Zod schemas for promo login + promo password
src/lib/components/routes/admin/admin-sidebar.svelte Add Demo + Impostazioni nav items
src/routes/admin/(dashboard)/settings/+page.server.ts New — promo password upsert
src/routes/admin/(dashboard)/settings/+page.svelte New — settings UI (current password + copy + form + warning)
src/routes/admin/(dashboard)/demos/+page.svelte New — empty placeholder page
src/routes/promo/+layout.server.ts New — promo auth guard
src/routes/promo/+layout.svelte New — minimal promo layout
src/routes/promo/+page.svelte New — dummy text placeholder
src/routes/promo/login/+page.svelte New — password form
src/routes/promo/login/+page.server.ts New — validate password, set cookie

🔲 Still to create

File Purpose
src/lib/server/audio-cache.ts Filesystem cache layer for S3 audio files
src/lib/components/forms/demo-collection-form.svelte Collection form
src/lib/components/forms/demo-track-form.svelte Track form with file input
src/routes/admin/(dashboard)/demos/+page.server.ts Demos CRUD + S3 upload server
src/routes/promo/+page.svelte Replace dummy text with collections + audio players
src/routes/promo/+page.server.ts Load collections + tracks
src/routes/promo/track/[trackId]/+server.ts Cached audio proxy with Range support

Verification (for remaining items)

  1. Promo login: set password in /admin/settings. Hit /promo → confirm redirect to /promo/login. Wrong password → error message. Correct password → redirect to /promo.
  2. Cookie invalidation: log into /promo, change password in admin, refresh → confirm redirect to login.
  3. Demo upload: upload audio track via /admin/demos. Verify file appears in S3 at demos/{collectionId}/....
  4. Audio cache: play track on /promo — first request downloads from S3 to /var/cache/dancaestral/audio/.... Subsequent requests (including seeks) served from disk. Verify with ls on cache dir + DevTools Network showing 206 with Content-Range.
  5. Unauthorized proxy: unauthenticated GET /promo/track/1 → 401.
  6. Track deletion + cache invalidation: delete a track in admin → verify DB row removed, S3 object deleted, and local cache file removed.

Lessons Learned

  • superforms: Always add name attribute to form <input> elements when using superForm — without it the browser doesn't send the field in POST data, causing 400 errors.
  • superforms: Use () => data.form (getter function) instead of data.form directly in superForm() to avoid Svelte 5's "captures only initial value" warning.
  • superforms: The correct property from superForm() for loading state is submitting (not enhancing).
  • superforms: Add use:enhance directive on <form> for proper client-side form handling with superforms.