From 87b5a7fe348558eba6e1524a8dd4731a20b18b0e Mon Sep 17 00:00:00 2001 From: Z User Date: Tue, 27 Oct 2026 07:44:06 +0000 Subject: [PATCH] =?UTF-8?q?v6.2=20=E2=80=94=20read-only=20embedded=20libra?= =?UTF-8?q?ry=20+=20per-visitor=20browser=20presets=20(anti-spam/anti-vand?= =?UTF-8?q?alism=20hardening)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/custom.db | Bin 307200 -> 307200 bytes src/app/api/folders/route.ts | 141 +-- src/app/api/presets/[id]/route.ts | 65 +- src/app/api/presets/import/route.ts | 129 +- src/app/api/presets/route.ts | 162 ++- src/app/api/settings/route.ts | 48 - src/components/ava/detect-tab.tsx | 4 +- src/components/ava/presets-tab.tsx | 409 +++--- src/components/ava/script-tab.tsx | 77 +- src/lib/version.ts | 4 +- tool-results/e2e-v62/01-desktop-presets.png | Bin 0 -> 102835 bytes .../e2e-v62/02-embedded-folder-selected.png | Bin 0 -> 103645 bytes .../e2e-v62/03-embedded-preset-selected.png | Bin 0 -> 99913 bytes .../e2e-v62/04-script-tab-created.png | Bin 0 -> 110711 bytes .../e2e-v62/05-save-blocked-toast.png | Bin 0 -> 115230 bytes .../e2e-v62/06-user-library-empty.png | Bin 0 -> 107860 bytes tool-results/e2e-v62/07-mobile-firstpaint.png | Bin 0 -> 51185 bytes tool-results/e2e-v62/08-mobile-bottom.png | Bin 0 -> 63323 bytes .../e2e-v62/09-mobile-embedded-selected.png | Bin 0 -> 51604 bytes .../read_1793086254569_65b3b8ecd40e.txt | 834 +++++++++++++ .../read_1793086294893_e6288764dc88.txt | 1099 +++++++++++++++++ worklog.md | 199 +++ 22 files changed, 2568 insertions(+), 603 deletions(-) delete mode 100755 src/app/api/settings/route.ts create mode 100644 tool-results/e2e-v62/01-desktop-presets.png create mode 100644 tool-results/e2e-v62/02-embedded-folder-selected.png create mode 100644 tool-results/e2e-v62/03-embedded-preset-selected.png create mode 100644 tool-results/e2e-v62/04-script-tab-created.png create mode 100644 tool-results/e2e-v62/05-save-blocked-toast.png create mode 100644 tool-results/e2e-v62/06-user-library-empty.png create mode 100644 tool-results/e2e-v62/07-mobile-firstpaint.png create mode 100644 tool-results/e2e-v62/08-mobile-bottom.png create mode 100644 tool-results/e2e-v62/09-mobile-embedded-selected.png create mode 100644 tool-results/read_1793086254569_65b3b8ecd40e.txt create mode 100644 tool-results/read_1793086294893_e6288764dc88.txt diff --git a/db/custom.db b/db/custom.db index ef73d910ef517ec163acc0ba0cf1d8984d390450..aed7f07af2a30e2d6b60da6aa1b4e28df601d8df 100755 GIT binary patch delta 9668 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 delta 9467 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 diff --git a/src/app/api/folders/route.ts b/src/app/api/folders/route.ts index c183556..8bfd2b1 100755 --- a/src/app/api/folders/route.ts +++ b/src/app/api/folders/route.ts @@ -1,131 +1,28 @@ import { NextRequest, NextResponse } from 'next/server' -import { db } from '@/lib/db' -import { - PROTECTED_FOLDERS, - isValidName, - normalizeFolderPath, - parentOf, - subtreeFilter, - subtreeFolderFilter, -} from '@/lib/server/preset-tree' export const dynamic = 'force-dynamic' -/** Create a subfolder inside an existing folder. Body: { path: "root/a/b" } */ -export async function POST(req: NextRequest) { - try { - const body = await req.json() - const folder = normalizeFolderPath(body?.path) - if (!folder) { - return NextResponse.json({ error: 'Invalid folder path' }, { status: 400 }) - } - if (folder.path === 'root') { - return NextResponse.json({ error: '"root" already exists' }, { status: 409 }) - } - if (PROTECTED_FOLDERS.has(folder.path)) { - return NextResponse.json({ error: 'That folder already exists' }, { status: 409 }) - } - const existing = await db.folder.findUnique({ where: { path: folder.path } }) - if (existing) { - return NextResponse.json({ error: `"${folder.path}" already exists` }, { status: 409 }) - } - const parent = parentOf(folder.segments) - const parentRow = await db.folder.findUnique({ where: { path: parent } }) - if (!parentRow) { - return NextResponse.json( - { error: `Parent folder "${parent}" does not exist` }, - { status: 400 }, - ) - } - await db.folder.create({ data: { path: folder.path } }) - return NextResponse.json({ folder: { path: folder.path } }, { status: 201 }) - } catch (err) { - console.error('POST /api/folders failed:', err) - return NextResponse.json({ error: 'Failed to create folder' }, { status: 500 }) - } +const READ_ONLY_MSG = + 'The server folder tree is read-only. Your folders live in your browser under "user created".' + +/** + * READ-ONLY tree (v6.2): no folder on the shared server may be created, + * renamed, or deleted through the public API — otherwise one visitor could + * restructure (or wipe) the embedded library for everybody. Each visitor's + * own folders are managed entirely inside their browser. + */ +export async function POST(_req: NextRequest) { + return NextResponse.json({ error: READ_ONLY_MSG }, { status: 403 }) } -/** Rename a folder (and everything below it). Body: { path, newName } */ -export async function PATCH(req: NextRequest) { - try { - const body = await req.json() - const folder = normalizeFolderPath(body?.path) - const newName = isValidName(body?.newName, 80) - if (!folder || !newName) { - return NextResponse.json({ error: 'Invalid folder or name' }, { status: 400 }) - } - if (PROTECTED_FOLDERS.has(folder.path)) { - return NextResponse.json( - { error: `"${folder.segments[folder.segments.length - 1]}" is a protected folder and cannot be renamed` }, - { status: 400 }, - ) - } - const row = await db.folder.findUnique({ where: { path: folder.path } }) - if (!row) { - return NextResponse.json({ error: 'Folder not found' }, { status: 404 }) - } - const newPath = `${parentOf(folder.segments)}/${newName}` - const clash = await db.folder.findUnique({ where: { path: newPath } }) - if (clash) { - return NextResponse.json({ error: `"${newName}" already exists there` }, { status: 409 }) - } - - const result = await db.$transaction(async (tx) => { - const folders = await tx.folder.findMany({ where: subtreeFolderFilter(folder.path) }) - const presets = await tx.preset.findMany({ - where: subtreeFilter(folder.path), - select: { id: true, folderPath: true }, - }) - for (const f of folders) { - const target = f.path === folder.path ? newPath : newPath + f.path.slice(folder.path.length) - await tx.folder.update({ where: { path: f.path }, data: { path: target } }) - } - for (const p of presets) { - await tx.preset.update({ - where: { id: p.id }, - data: { folderPath: newPath + p.folderPath.slice(folder.path.length) }, - }) - } - return { folders: folders.length, presets: presets.length } - }) - - return NextResponse.json({ renamed: folder.path, to: newPath, ...result }) - } catch (err) { - if ((err as { code?: string })?.code === 'P2002') { - return NextResponse.json({ error: 'A folder with that name already exists' }, { status: 409 }) - } - console.error('PATCH /api/folders failed:', err) - return NextResponse.json({ error: 'Failed to rename folder' }, { status: 500 }) - } +export async function PUT(_req: NextRequest) { + return NextResponse.json({ error: READ_ONLY_MSG }, { status: 403 }) } -/** Delete a folder subtree. Query: ?path=root/a/b */ -export async function DELETE(req: NextRequest) { - try { - const raw = new URL(req.url).searchParams.get('path') - const folder = normalizeFolderPath(raw) - if (!folder) { - return NextResponse.json({ error: 'Invalid folder path' }, { status: 400 }) - } - if (PROTECTED_FOLDERS.has(folder.path)) { - return NextResponse.json( - { error: `"${folder.segments[folder.segments.length - 1]}" is a protected folder and cannot be deleted` }, - { status: 400 }, - ) - } - const row = await db.folder.findUnique({ where: { path: folder.path } }) - if (!row) { - return NextResponse.json({ error: 'Folder not found' }, { status: 404 }) - } - const deletedPresets = await db.preset.deleteMany({ where: subtreeFilter(folder.path) }) - const deletedFolders = await db.folder.deleteMany({ where: subtreeFolderFilter(folder.path) }) - return NextResponse.json({ - deleted: true, - folders: deletedFolders.count, - presets: deletedPresets.count, - }) - } catch (err) { - console.error('DELETE /api/folders failed:', err) - return NextResponse.json({ error: 'Failed to delete folder' }, { status: 500 }) - } +export async function PATCH(_req: NextRequest) { + return NextResponse.json({ error: READ_ONLY_MSG }, { status: 403 }) +} + +export async function DELETE(_req: NextRequest) { + return NextResponse.json({ error: READ_ONLY_MSG }, { status: 403 }) } diff --git a/src/app/api/presets/[id]/route.ts b/src/app/api/presets/[id]/route.ts index 80a8fd3..2b3f499 100755 --- a/src/app/api/presets/[id]/route.ts +++ b/src/app/api/presets/[id]/route.ts @@ -1,61 +1,22 @@ import { NextRequest, NextResponse } from 'next/server' -import { db } from '@/lib/db' -import { isValidName } from '@/lib/server/preset-tree' export const dynamic = 'force-dynamic' -/** Update a preset (content, author, description, or rename within its folder). */ -export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const { id } = await params - const body = await req.json() - const preset = await db.preset.findUnique({ where: { id } }) - if (!preset) { - return NextResponse.json({ error: 'Preset not found' }, { status: 404 }) - } +const READ_ONLY_MSG = + 'The server preset library is read-only. Your changes are saved in your browser under "user created".' - const data: Record = {} - if (typeof body?.name === 'string' && body.name.trim() && body.name.trim() !== preset.name) { - const name = isValidName(body.name) - if (!name) { - return NextResponse.json({ error: 'Invalid preset name' }, { status: 400 }) - } - const clash = await db.preset.findFirst({ - where: { folderPath: preset.folderPath, name }, - select: { id: true }, - }) - if (clash && clash.id !== id) { - return NextResponse.json( - { error: `A preset named "${name}" already exists in ${preset.folderPath}` }, - { status: 409 }, - ) - } - data.name = name - } - if (typeof body?.content === 'string') data.content = body.content - if (typeof body?.author === 'string') data.author = body.author.trim().slice(0, 120) - if (typeof body?.description === 'string') data.description = body.description.trim().slice(0, 500) - - const updated = await db.preset.update({ where: { id }, data }) - return NextResponse.json({ preset: updated }) - } catch (err) { - console.error('PUT /api/presets/[id] failed:', err) - return NextResponse.json({ error: 'Failed to update preset' }, { status: 500 }) - } +/** + * READ-ONLY library (v6.2): the embedded presets are shared by every visitor + * and can never be modified through the API. Editing happens on a visitor's + * own copy in their browser (localStorage, merged into the tree client-side). + */ +export async function PUT(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + void params + return NextResponse.json({ error: READ_ONLY_MSG }, { status: 403 }) } -/** Delete a preset (any preset — the tree is fully user-manageable). */ +/** Same policy — embedded presets cannot be deleted through the API. */ export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const { id } = await params - const preset = await db.preset.findUnique({ where: { id } }) - if (!preset) { - return NextResponse.json({ error: 'Preset not found' }, { status: 404 }) - } - await db.preset.delete({ where: { id } }) - return NextResponse.json({ deleted: true }) - } catch (err) { - console.error('DELETE /api/presets/[id] failed:', err) - return NextResponse.json({ error: 'Failed to delete preset' }, { status: 500 }) - } + void params + return NextResponse.json({ error: READ_ONLY_MSG }, { status: 403 }) } diff --git a/src/app/api/presets/import/route.ts b/src/app/api/presets/import/route.ts index b8e05a9..973f534 100755 --- a/src/app/api/presets/import/route.ts +++ b/src/app/api/presets/import/route.ts @@ -1,125 +1,18 @@ import { NextRequest, NextResponse } from 'next/server' -import JSZip from 'jszip' -import { db } from '@/lib/db' -import { ensureFolderRows, isValidName, normalizeFolderPath } from '@/lib/server/preset-tree' export const dynamic = 'force-dynamic' -const MAX_ZIP_BYTES = 50 * 1024 * 1024 - -interface ImportEntry { - folderPath: string - name: string - content: string -} - /** - * Import a .zip of presets, preserving its directory tree. Entries stored - * under "root/…" land at exactly that path; anything else is placed inside - * the target folder selected in the UI. Only .ava (and .txt) script files - * are imported — other files are counted and skipped. - * - * POST /api/presets/import (multipart/form-data: file, targetPath) + * READ-ONLY library (v6.2): zips are unpacked IN THE VISITOR'S BROWSER and + * land only in that visitor's "user created" area (localStorage). Nothing is + * ever uploaded to the server, so an import can never affect other visitors. */ -export async function POST(req: NextRequest) { - try { - const form = await req.formData() - const file = form.get('file') - if (!(file instanceof File)) { - return NextResponse.json({ error: 'Attach a .zip file to import' }, { status: 400 }) - } - if (file.size > MAX_ZIP_BYTES) { - return NextResponse.json({ error: 'That zip is too large (50 MB limit)' }, { status: 413 }) - } - const target = normalizeFolderPath(form.get('targetPath') ?? 'root/user created') - if (!target) { - return NextResponse.json({ error: 'Invalid target folder' }, { status: 400 }) - } - - const zip = await JSZip.loadAsync(await file.arrayBuffer()) - - const entries: ImportEntry[] = [] - let skippedOther = 0 - for (const entry of Object.values(zip.files)) { - if (entry.dir) continue - const rawPath = entry.name.replace(/\\/g, '/') - if (rawPath.includes('__MACOSX') || rawPath.split('/').some((s) => s.startsWith('.'))) continue - if (!/\.(ava|txt)$/i.test(rawPath)) { - skippedOther++ - continue - } - const segs = rawPath - .split('/') - .map((s) => s.trim()) - .filter((s) => s.length > 0 && s !== '.' && s !== '..') - if (segs.length === 0) continue - const fileName = segs[segs.length - 1] - const name = isValidName(fileName.replace(/\.(ava|txt)$/i, '')) - if (!name) { - skippedOther++ - continue - } - const dirSegs = segs.slice(0, -1) - const joined = dirSegs.length > 0 ? dirSegs.join('/') : null - const folderPath = joined - ? joined === 'root' || joined.startsWith('root/') - ? joined - : `${target.path}/${joined}` - : target.path - const norm = normalizeFolderPath(folderPath) - if (!norm) { - skippedOther++ - continue - } - const content = await entry.async('string') - entries.push({ folderPath: norm.path, name, content }) - } - - if (entries.length === 0) { - return NextResponse.json( - { error: 'No .ava preset files found in that zip' }, - { status: 400 }, - ) - } - - const foldersCreated = await ensureFolderRows([...new Set(entries.map((e) => e.folderPath))]) - - let imported = 0 - let skipped = 0 - for (const e of entries) { - const clash = await db.preset.findFirst({ - where: { folderPath: e.folderPath, name: e.name }, - select: { id: true }, - }) - if (clash) { - skipped++ - continue - } - const author = - /^author:\s*(.+)$/m - .exec(e.content)?.[1] - ?.replace(/^["']|["']$/g, '') - .trim() - .slice(0, 120) ?? '' - await db.preset.create({ - data: { - name: e.name, - content: e.content, - author, - folderPath: e.folderPath, - isBuiltIn: false, - }, - }) - imported++ - } - - return NextResponse.json({ imported, skipped, skippedOther, foldersCreated }) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - if (message.includes('corrupted') || message.includes('zip')) { - return NextResponse.json({ error: 'That file is not a valid zip archive' }, { status: 400 }) - } - console.error('POST /api/presets/import failed:', err) - return NextResponse.json({ error: 'Failed to import presets' }, { status: 500 }) - } +export async function POST(_req: NextRequest) { + return NextResponse.json( + { + error: + 'Server-side import is disabled. Zips are imported in your browser into "user created" — nothing is uploaded.', + }, + { status: 403 }, + ) } diff --git a/src/app/api/presets/route.ts b/src/app/api/presets/route.ts index 9087309..eeae10e 100755 --- a/src/app/api/presets/route.ts +++ b/src/app/api/presets/route.ts @@ -2,15 +2,16 @@ import { NextRequest, NextResponse } from 'next/server' import { db } from '@/lib/db' import { BUILTIN_PRESETS } from '@/lib/ava/presets' import { EMBEDDED_LIBRARY, EMBEDDED_SEED_VERSION } from '@/lib/ava/embedded-presets' -import { ensureFolderRows, isValidName, normalizeFolderPath } from '@/lib/server/preset-tree' +import { ensureFolderRows } from '@/lib/server/preset-tree' +import { EMBEDDED_ROOT, ROOT, USER_ROOT } from '@/lib/preset-paths' export const dynamic = 'force-dynamic' /** The structural backbone of the tree, created on first run. */ const BASE_FOLDERS = [ - 'root', - 'root/embedded', - 'root/user created', + ROOT, + EMBEDDED_ROOT, + USER_ROOT, 'root/embedded/Synth-C', 'root/embedded/Amp Picks', 'root/embedded/Dynamics', @@ -18,27 +19,53 @@ const BASE_FOLDERS = [ ] /** - * Version-gated seed: folder backbone + the full embedded library - * (5 hand-ported classics + 90 Winamp AVS presets from the user's Google - * Drive collection, organized as Synth-C / Amp Picks / Dynamics / Envision). + * Version-gated seed + INTEGRITY SELF-HEAL for the embedded library. * - * The embedded subtree (root/embedded/**) refreshes automatically whenever - * EMBEDDED_SEED_VERSION changes (i.e. the library was regenerated) — user - * presets outside root/embedded are never touched. + * The server library is the single source of truth for what every visitor + * sees, so GET verifies it on every request: + * 1. version gate — EMBEDDED_SEED_VERSION changes trigger a full re-seed + * (library was regenerated); + * 2. fingerprint — the embedded subtree must exactly match + * BUILTIN + EMBEDDED (folder + name). Any drift — a preset renamed or + * deleted through pre-hardening endpoints, a row injected straight into + * the DB — re-seeds the subtree automatically. */ +function libraryFingerprint(): string { + const items = [ + ...BUILTIN_PRESETS.map((p) => `root/embedded/Synth-C/${p.name}`), + ...EMBEDDED_LIBRARY.map( + (p) => `${p.folder.replace('root/embedded/root/embedded/', 'root/embedded/')}/${p.name}`, + ), + ] + return items.sort().join('|') +} + +async function currentFingerprint(): Promise { + const rows = await db.preset.findMany({ + where: { OR: [{ folderPath: EMBEDDED_ROOT }, { folderPath: { startsWith: 'root/embedded/' } }] }, + select: { folderPath: true, name: true }, + }) + return rows.map((r) => `${r.folderPath}/${r.name}`).sort().join('|') +} + async function ensureSeeded(): Promise { const settings = await db.settings.findUnique({ where: { id: 'main' }, select: { embeddedSeed: true }, }) - if (settings?.embeddedSeed === EMBEDDED_SEED_VERSION) return + if (settings?.embeddedSeed === EMBEDDED_SEED_VERSION) { + // version marker matches — still verify the subtree is intact (heals any + // pre-hardening rename / delete / injection even without a version bump) + if ((await currentFingerprint()) === libraryFingerprint()) return + console.warn('[presets] embedded library drift detected — re-seeding from source') + } try { await ensureFolderRows(BASE_FOLDERS) // refresh the embedded library subtree (+ legacy stray built-ins) await db.preset.deleteMany({ where: { OR: [ - { folderPath: 'root/embedded' }, + { folderPath: EMBEDDED_ROOT }, { folderPath: { startsWith: 'root/embedded/' } }, { isBuiltIn: true }, ], @@ -80,12 +107,52 @@ async function ensureSeeded(): Promise { } } +/** + * SELF-HEAL (v6.2 security): the server library is READ-ONLY for visitors. + * Anything a visitor managed to write before this hardening — presets outside + * the embedded library, folders outside the structural backbone — is purged + * here so no visitor can ever alter what other visitors see. Runs on every + * GET; it's two indexed deleteMany calls on a small SQLite table. + */ +async function purgeVisitorJunk(): Promise { + await db.preset.deleteMany({ + where: { + OR: [ + { isBuiltIn: false }, + { folderPath: USER_ROOT }, + { folderPath: { startsWith: 'root/user created/' } }, + ], + }, + }) + await db.folder.deleteMany({ + where: { + NOT: [ + { path: ROOT }, + { path: EMBEDDED_ROOT }, + { path: USER_ROOT }, + { path: { startsWith: 'root/embedded/' } }, + ], + }, + }) +} + +/** + * The embedded library, read-only. Visitors' own presets live in their + * browser (localStorage) and are merged client-side — nothing per-visitor + * is stored on, or served from, this endpoint. + */ export async function GET() { try { await ensureSeeded() - const [presets, folders] = await Promise.all([ + await purgeVisitorJunk() + const [presets, backbone, embeddedSubfolders] = await Promise.all([ db.preset.findMany({ orderBy: [{ folderPath: 'asc' }, { name: 'asc' }], + where: { + // defense in depth: only ever serve the embedded library + isBuiltIn: true, + OR: [{ folderPath: EMBEDDED_ROOT }, { folderPath: { startsWith: 'root/embedded/' } }], + }, select: { id: true, name: true, @@ -99,50 +166,41 @@ export async function GET() { updatedAt: true, }, }), - db.folder.findMany({ orderBy: { path: 'asc' }, select: { path: true } }), + db.folder.findMany({ + orderBy: { path: 'asc' }, + where: { + path: { + in: [ROOT, EMBEDDED_ROOT, USER_ROOT], + }, + }, + select: { path: true }, + }), + // embedded subfolders (folder rows under root/embedded/) + db.folder.findMany({ + orderBy: { path: 'asc' }, + where: { path: { startsWith: 'root/embedded/' } }, + select: { path: true }, + }), ]) - return NextResponse.json({ presets, folders: folders.map((f) => f.path) }) + const folderPaths = [...new Set([...backbone.map((f) => f.path), ...embeddedSubfolders.map((f) => f.path)])] + return NextResponse.json({ presets, folders: folderPaths }) } catch (err) { console.error('GET /api/presets failed:', err) return NextResponse.json({ error: 'Failed to load presets' }, { status: 500 }) } } -export async function POST(req: NextRequest) { - try { - const body = await req.json() - const name = isValidName(body?.name) - const content = typeof body?.content === 'string' ? body.content : '' - const author = typeof body?.author === 'string' ? body.author.trim().slice(0, 120) : '' - const description = typeof body?.description === 'string' ? body.description.trim().slice(0, 500) : '' - const folder = normalizeFolderPath(body?.folderPath ?? 'root/user created') - if (!name || !content) { - return NextResponse.json({ error: 'name and content are required' }, { status: 400 }) - } - if (!folder) { - return NextResponse.json({ error: 'Invalid folder path' }, { status: 400 }) - } - const folderRow = await db.folder.findUnique({ where: { path: folder.path } }) - if (!folderRow) { - // tolerate missing ancestor rows (e.g. very old data) by creating them - await ensureFolderRows([folder.path]) - } - const clash = await db.preset.findFirst({ - where: { folderPath: folder.path, name }, - select: { id: true }, - }) - if (clash) { - return NextResponse.json( - { error: `A preset named "${name}" already exists in ${folder.path}` }, - { status: 409 }, - ) - } - const preset = await db.preset.create({ - data: { name, content, author, description, folderPath: folder.path, isBuiltIn: false }, - }) - return NextResponse.json({ preset, created: true }, { status: 201 }) - } catch (err) { - console.error('POST /api/presets failed:', err) - return NextResponse.json({ error: 'Failed to save preset' }, { status: 500 }) - } +/** + * READ-ONLY library (v6.2): preset creation happens exclusively in the + * visitor's browser ("user created" area, localStorage). The server accepts + * no writes, so nobody can save anything other visitors would see. + */ +export async function POST(_req: NextRequest) { + return NextResponse.json( + { + error: + 'The server preset library is read-only. Your presets are saved in your browser under "user created".', + }, + { status: 403 }, + ) } diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts deleted file mode 100755 index 60608b9..0000000 --- a/src/app/api/settings/route.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { db } from '@/lib/db' - -export const dynamic = 'force-dynamic' - -export async function GET() { - try { - let settings = await db.settings.findUnique({ where: { id: 'main' } }) - if (!settings) { - settings = await db.settings.create({ data: { id: 'main' } }) - } - return NextResponse.json({ - defaultScript: settings.defaultScript ?? null, - bandConfig: settings.bandConfig ? JSON.parse(settings.bandConfig) : null, - beatConfig: settings.beatConfig ? JSON.parse(settings.beatConfig) : null, - }) - } catch (err) { - console.error('GET /api/settings failed:', err) - return NextResponse.json({ error: 'Failed to load settings' }, { status: 500 }) - } -} - -export async function PUT(req: NextRequest) { - try { - const body = await req.json() - const data: Record = {} - if (typeof body.defaultScript === 'string') data.defaultScript = body.defaultScript - if (body.bandConfig && typeof body.bandConfig === 'object') { - data.bandConfig = JSON.stringify(body.bandConfig) - } - if (body.beatConfig && typeof body.beatConfig === 'object') { - data.beatConfig = JSON.stringify(body.beatConfig) - } - const settings = await db.settings.upsert({ - where: { id: 'main' }, - update: data, - create: { id: 'main', ...data }, - }) - return NextResponse.json({ - defaultScript: settings.defaultScript ?? null, - bandConfig: settings.bandConfig ? JSON.parse(settings.bandConfig) : null, - beatConfig: settings.beatConfig ? JSON.parse(settings.beatConfig) : null, - }) - } catch (err) { - console.error('PUT /api/settings failed:', err) - return NextResponse.json({ error: 'Failed to save settings' }, { status: 500 }) - } -} diff --git a/src/components/ava/detect-tab.tsx b/src/components/ava/detect-tab.tsx index b5ac940..352d4f3 100755 --- a/src/components/ava/detect-tab.tsx +++ b/src/components/ava/detect-tab.tsx @@ -1,6 +1,6 @@ -// AVA — Detect tab: fine-tune the three analysis bands (Hz ranges + gains) +// OVE — Detect tab: fine-tune the three analysis bands (Hz ranges + gains) // with live VU meters. Changes apply to the engine immediately and persist -// (localStorage instantly, /api/settings debounced). +// to this browser's localStorage. 'use client' diff --git a/src/components/ava/presets-tab.tsx b/src/components/ava/presets-tab.tsx index 281af91..2f7e036 100755 --- a/src/components/ava/presets-tab.tsx +++ b/src/components/ava/presets-tab.tsx @@ -1,27 +1,36 @@ -// AVA — Presets tab: folder/file tree manager (Task 10 redesign). +// OVE — Presets tab: folder/file tree manager (v6.2 security model). // -// Left column: the preset library tree — folders expand/collapse (chevron -// only), rows select, double-clicking a preset runs it, the running preset -// is flagged emerald, and originals download straight from the row. -// Right column: context-sensitive management (load / create / rename / -// delete / export .zip / import .zip) plus the club-mode randomizer — the -// timer itself lives in the store, this tab only wires the controls. +// The tree merges two worlds: +// • root/embedded — the shipped library, READ-ONLY for every visitor (lock +// badge in the tree; the server refuses all writes). Selecting an embedded +// preset offers "Copy to my presets" instead of rename/delete. +// • root/user created — this visitor's OWN presets, stored in their browser +// (localStorage). Create / rename / delete / import all happen locally — +// nothing is sent to the server, so nobody can spam or vandalize what +// other visitors see. +// +// Left column: the tree (chevron expands, row selects, double-click runs, +// running preset flagged emerald, originals download from the row). +// Right column: context-sensitive management + the club-mode randomizer. 'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ChevronRight, + CopyPlus, Download, FilePlus2, FileText, Folder, FolderOpen, FolderPlus, + Lock, Pencil, Play, Trash2, Upload, + User, } from 'lucide-react' import { AlertDialog, @@ -47,14 +56,22 @@ import { import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { STARTER_TEMPLATE } from '@/lib/ava/presets' -import { refreshPresets, useAvaStore, type PresetItem } from '@/lib/store' +import { applyUserPresets, refreshPresets, useAvaStore, type PresetItem } from '@/lib/store' import { applyScriptText } from '@/components/ava/audio-source' import { thinScrollbar } from '@/components/ava/shared' import { useToast } from '@/hooks/use-toast' import { cn } from '@/lib/utils' - -/** The tree backbone — the backend refuses to rename or delete these. */ -const PROTECTED_FOLDERS = new Set(['root', 'root/embedded', 'root/user created']) +import { ROOT, USER_ROOT, isEmbeddedPath, isUserAreaPath } from '@/lib/preset-paths' +import { + createUserFolder, + createUserPreset, + deleteUserFolder, + deleteUserPreset, + presetIsLocal, + renameUserFolder, + renameUserPreset, +} from '@/lib/ava/user-presets' +import { exportPresetsZip, importPresetsZip } from '@/lib/ava/preset-io' /** Same default-expansion rule as the store: paths with ≤ 2 segments open. */ function defaultOpen(path: string): boolean { @@ -171,6 +188,7 @@ function useLoadPreset() { function TreeFolder({ node }: { node: FolderNode }) { const isOpen = useAvaStore((s) => s.expandedFolders[node.path] ?? defaultOpen(node.path)) const selected = useAvaStore((s) => s.selectedFolder === node.path) + const readOnly = node.path === ROOT || isEmbeddedPath(node.path) return (
@@ -199,7 +217,7 @@ function TreeFolder({ node }: { node: FolderNode }) {
@@ -247,6 +279,7 @@ function TreePreset({ preset, depth }: { preset: PresetItem; depth: number }) { const loadPreset = useLoadPreset() const selected = useAvaStore((s) => s.selectedPresetId === preset.id) const running = useAvaStore((s) => s.activePresetId === preset.id) + const own = presetIsLocal(preset.id) return (
useAvaStore.getState().selectPreset(preset.id)} onDoubleClick={() => loadPreset(preset)} - title={`${preset.name} (double-click to run)`} + title={`${preset.name} (double-click to run)${own ? ' — yours (this browser)' : ''}`} className="flex h-9 min-w-0 flex-1 items-center gap-1.5 py-1 pr-1 text-left" > {running ? ( @@ -286,6 +319,15 @@ function TreePreset({ preset, depth }: { preset: PresetItem; depth: number }) { ≈ )} + {own && ( + + + + )} {preset.originalFile && ( void submitLabel: string busy: boolean - /** Return true to close the dialog (server errors keep it open). */ + /** Return true to close the dialog (errors keep it open). */ onSubmit: (name: string) => Promise }) { const submit = async () => { @@ -424,7 +466,7 @@ export function PresetsTab() { const [delayError, setDelayError] = useState(false) const importInputRef = useRef(null) - // freshness refresh whenever the tab mounts (initial seed happens on app load) + // freshness refresh whenever the tab mounts (initial merge happens on app load) useEffect(() => { void refreshPresets() }, []) @@ -439,7 +481,12 @@ export function PresetsTab() { () => presets.find((p) => p.id === selectedPresetId) ?? null, [presets, selectedPresetId], ) - const folderProtected = PROTECTED_FOLDERS.has(selectedFolder) + + // ---- read-only vs. browser-owned areas -------------------------------- + const folderReadOnly = selectedFolder === ROOT || isEmbeddedPath(selectedFolder) + const folderProtected = selectedFolder === USER_ROOT // the user area root itself + const presetOwn = selectedPreset ? presetIsLocal(selectedPreset.id) : false + const folderName = selectedFolder.split('/').pop() ?? selectedFolder const folderPrefix = `${selectedFolder}/` const folderPresetCount = presets.filter( @@ -454,46 +501,35 @@ export function PresetsTab() { const scopeLabel = randomScope === 'root' ? 'all presets' : (randomScope.split('/').pop() ?? randomScope) // ----------------------------------------------------------------- - // actions + // actions — all user-area CRUD happens in THIS browser (localStorage) // ----------------------------------------------------------------- const submitCreatePreset = async (name: string): Promise => { setBusy(true) try { - const res = await fetch('/api/presets', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, content: STARTER_TEMPLATE, folderPath: selectedFolder }), + const res = createUserPreset({ + name, + content: STARTER_TEMPLATE, + folderPath: selectedFolder, + author: 'you', }) - const data = (await res.json().catch(() => null)) as { preset?: PresetItem; error?: string } | null - if (!res.ok || !data?.preset) { - toast({ - title: 'Preset not created', - description: data?.error ?? `Server responded ${res.status}.`, - variant: 'destructive', - }) + if (!res.ok) { + toast({ title: 'Preset not created', description: res.error, variant: 'destructive' }) return false } - await refreshPresets() + applyUserPresets() const st = useAvaStore.getState() - st.setEditTarget({ id: data.preset.id, name: data.preset.name, folderPath: data.preset.folderPath }) + st.setEditTarget({ id: res.preset.id, name: res.preset.name, folderPath: res.preset.folderPath }) st.setScriptText(STARTER_TEMPLATE) applyScriptText(STARTER_TEMPLATE) - st.setActivePresetId(data.preset.id) - st.selectPreset(data.preset.id) + st.setActivePresetId(res.preset.id) + st.selectPreset(res.preset.id) st.setTab('script') toast({ title: 'Preset created', - description: `"${data.preset.name}" created — now edit and Save.`, + description: `"${res.preset.name}" created in this browser — now edit and Save.`, }) return true - } catch { - toast({ - title: 'Preset not created', - description: 'Network error while creating the preset.', - variant: 'destructive', - }) - return false } finally { setBusy(false) } @@ -503,33 +539,17 @@ export function PresetsTab() { const path = `${selectedFolder}/${name}` setBusy(true) try { - const res = await fetch('/api/folders', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path }), - }) - const data = (await res.json().catch(() => null)) as { folder?: { path: string }; error?: string } | null - if (!res.ok || !data?.folder) { - toast({ - title: 'Folder not created', - description: data?.error ?? `Server responded ${res.status}.`, - variant: 'destructive', - }) + const res = createUserFolder(path) + if (!res.ok) { + toast({ title: 'Folder not created', description: res.error, variant: 'destructive' }) return false } - await refreshPresets() + applyUserPresets() const st = useAvaStore.getState() st.toggleFolder(selectedFolder, true) - st.selectFolder(data.folder.path) - toast({ title: 'Folder created', description: `"${name}" added under ${selectedFolder}.` }) + st.selectFolder(res.path) + toast({ title: 'Folder created', description: `"${name}" added under ${selectedFolder} (this browser).` }) return true - } catch { - toast({ - title: 'Folder not created', - description: 'Network error while creating the folder.', - variant: 'destructive', - }) - return false } finally { setBusy(false) } @@ -539,71 +559,46 @@ export function PresetsTab() { setBusy(true) try { if (selectedPreset) { - const res = await fetch(`/api/presets/${selectedPreset.id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name }), - }) - const data = (await res.json().catch(() => null)) as { preset?: PresetItem; error?: string } | null - if (!res.ok || !data?.preset) { - toast({ - title: 'Rename failed', - description: data?.error ?? `Server responded ${res.status}.`, - variant: 'destructive', - }) + const res = renameUserPreset(selectedPreset.id, name) + if (!res.ok) { + toast({ title: 'Rename failed', description: res.error, variant: 'destructive' }) return false } - await refreshPresets() + applyUserPresets() const st = useAvaStore.getState() if (st.editTarget?.id === selectedPreset.id) { st.setEditTarget({ - id: data.preset.id, - name: data.preset.name, - folderPath: data.preset.folderPath, + id: res.preset.id, + name: res.preset.name, + folderPath: res.preset.folderPath, }) } toast({ title: 'Preset renamed', - description: `"${selectedPreset.name}" is now "${data.preset.name}".`, + description: `"${selectedPreset.name}" is now "${res.preset.name}".`, }) return true } - // folder rename — everything below moves with it - const res = await fetch('/api/folders', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path: selectedFolder, newName: name }), - }) - const data = (await res.json().catch(() => null)) as { to?: string; error?: string } | null - if (!res.ok || !data?.to) { - toast({ - title: 'Rename failed', - description: data?.error ?? `Server responded ${res.status}.`, - variant: 'destructive', - }) + // folder rename — everything below moves with it (browser-side) + const res = renameUserFolder(selectedFolder, name) + if (!res.ok) { + toast({ title: 'Rename failed', description: res.error, variant: 'destructive' }) return false } - await refreshPresets() + applyUserPresets() const st = useAvaStore.getState() - st.selectFolder(data.to) + st.selectFolder(res.to) if ( st.editTarget && (st.editTarget.folderPath === selectedFolder || st.editTarget.folderPath.startsWith(folderPrefix)) ) { st.setEditTarget({ ...st.editTarget, - folderPath: data.to + st.editTarget.folderPath.slice(selectedFolder.length), + folderPath: res.to + st.editTarget.folderPath.slice(selectedFolder.length), }) } - toast({ title: 'Folder renamed', description: `Now at ${data.to}.` }) + toast({ title: 'Folder renamed', description: `Now at ${res.to}.` }) return true - } catch { - toast({ - title: 'Rename failed', - description: 'Network error while renaming.', - variant: 'destructive', - }) - return false } finally { setBusy(false) } @@ -639,31 +634,17 @@ export function PresetsTab() { setBusy(true) try { const isFolder = deleteTarget.kind === 'folder' - const res = await fetch( - isFolder ? `/api/folders?path=${encodeURIComponent(deleteTarget.path)}` : `/api/presets/${deleteTarget.path}`, - { method: 'DELETE' }, - ) - const data = (await res.json().catch(() => null)) as { deleted?: boolean; error?: string } | null + const res = isFolder ? deleteUserFolder(deleteTarget.path) : deleteUserPreset(deleteTarget.path) if (!res.ok) { - toast({ - title: 'Delete failed', - description: data?.error ?? `Server responded ${res.status}.`, - variant: 'destructive', - }) + toast({ title: 'Delete failed', description: res.error, variant: 'destructive' }) return } - await refreshPresets() + applyUserPresets() toast({ title: isFolder ? 'Folder deleted' : 'Preset deleted', description: isFolder - ? `"${deleteTarget.name}" and everything inside it removed.` - : `"${deleteTarget.name}" removed.`, - }) - } catch { - toast({ - title: 'Delete failed', - description: 'Network error while deleting.', - variant: 'destructive', + ? `"${deleteTarget.name}" and everything inside it removed from this browser.` + : `"${deleteTarget.name}" removed from this browser.`, }) } finally { setBusy(false) @@ -671,36 +652,71 @@ export function PresetsTab() { } } - const importZip = async (file: File) => { - setBusy(true) - try { - const form = new FormData() - form.append('file', file) - form.append('targetPath', selectedFolder) - const res = await fetch('/api/presets/import', { method: 'POST', body: form }) - const data = (await res.json().catch(() => null)) as - | { imported?: number; skipped?: number; foldersCreated?: number; error?: string } - | null - if (!res.ok || !data) { + /** Copy an embedded preset into this visitor's browser library. */ + const copyToMyPresets = (preset: PresetItem) => { + const candidates = [preset.name, `${preset.name} (copy)`, `${preset.name} (copy 2)`, `${preset.name} (copy 3)`] + for (const name of candidates) { + const res = createUserPreset({ + name, + content: preset.content, + folderPath: USER_ROOT, + author: preset.author, + description: preset.description, + }) + if (res.ok) { + applyUserPresets() + const st = useAvaStore.getState() + st.toggleFolder(USER_ROOT, true) + st.selectPreset(res.preset.id) toast({ - title: 'Import failed', - description: data?.error ?? `Server responded ${res.status}.`, - variant: 'destructive', + title: 'Copied to your presets', + description: `"${res.preset.name}" is now yours to edit — stored in this browser only.`, }) return } - await refreshPresets() - useAvaStore.getState().toggleFolder(selectedFolder, true) - let summary = `Imported ${data.imported ?? 0} presets` - if ((data.skipped ?? 0) > 0) summary += `, ${data.skipped} duplicates skipped` - if ((data.foldersCreated ?? 0) > 0) summary += ` — ${data.foldersCreated} folders created` + if (!res.error.includes('already exists')) { + toast({ title: 'Copy failed', description: res.error, variant: 'destructive' }) + return + } + } + toast({ + title: 'Copy failed', + description: 'A preset with that name already exists — rename it in "user created" first.', + variant: 'destructive', + }) + } + + const exportZip = async () => { + if (busy) return + setBusy(true) + try { + const res = await exportPresetsZip(selectedFolder, presets, folders) + if (res.ok) { + toast({ title: 'Export ready', description: `${plural(res.presets, 'preset')} zipped and downloading.` }) + } else { + toast({ title: 'Export failed', description: res.error, variant: 'destructive' }) + } + } finally { + setBusy(false) + } + } + + const importZip = async (file: File) => { + setBusy(true) + try { + const res = await importPresetsZip(file, selectedFolder) + if (!res.ok) { + toast({ title: 'Import failed', description: res.error, variant: 'destructive' }) + return + } + applyUserPresets() + useAvaStore.getState().toggleFolder(USER_ROOT, true) + let summary = `Imported ${res.imported} presets into your browser library` + if ((res.skipped ?? 0) > 0) summary += `, ${res.skipped} duplicates skipped` + if ((res.foldersCreated ?? 0) > 0) summary += ` — ${res.foldersCreated} folders created` toast({ title: 'Import complete', description: `${summary}.` }) } catch { - toast({ - title: 'Import failed', - description: 'Network error while importing.', - variant: 'destructive', - }) + toast({ title: 'Import failed', description: 'Could not read that zip.', variant: 'destructive' }) } finally { setBusy(false) } @@ -733,7 +749,7 @@ export function PresetsTab() { {presets.length} presets ·{' '} {folders.length} folders

-

double-click a preset to run it

+

double-click a preset to run it

@@ -780,6 +796,14 @@ export function PresetsTab() {

)}
+ {(folderReadOnly || (selectedPreset && !presetOwn)) && ( + + read-only + + )}
@@ -793,24 +817,72 @@ export function PresetsTab() { > Load Preset + {presetOwn ? ( +
+ + +
+ ) : ( + <> + +

+ Embedded presets are read-only — identical for every visitor. Copy it to make it yours + (stored in this browser only). +

+ + )} + + ) : folderReadOnly ? ( + <> +

+ The embedded library is read-only — the same for every + visitor, safe from edits, renames and deletion. Your own presets live under{' '} + user created, stored in this browser. +

@@ -820,7 +892,7 @@ export function PresetsTab() { size="sm" variant="outline" onClick={() => openDialog('createPreset', '')} - title={`Create a new preset from the starter template inside ${selectedFolder}`} + title={`Create a new preset from the starter template inside ${selectedFolder} (this browser)`} className="h-9 w-full gap-1.5 border-zinc-700" > Create Preset @@ -829,7 +901,7 @@ export function PresetsTab() { size="sm" variant="outline" onClick={() => openDialog('newFolder', '')} - title={`Create a subfolder inside ${selectedFolder}`} + title={`Create a subfolder inside ${selectedFolder} (this browser)`} className="h-9 w-full gap-1.5 border-zinc-700" > New Subfolder @@ -840,7 +912,7 @@ export function PresetsTab() { variant="outline" disabled={folderProtected} onClick={() => openDialog('rename', folderName)} - title={folderProtected ? 'Protected folder' : `Rename ${folderName} — everything inside moves with it`} + title={folderProtected ? '"user created" is the root of your library' : `Rename ${folderName} — everything inside moves with it`} className="h-9 gap-1.5 border-zinc-700" > Rename @@ -850,7 +922,7 @@ export function PresetsTab() { variant="outline" disabled={folderProtected} onClick={openFolderDelete} - title={folderProtected ? 'Protected folder' : `Delete ${folderName} and everything inside it`} + title={folderProtected ? '"user created" is the root of your library' : `Delete ${folderName} and everything inside it (this browser)`} className="h-9 gap-1.5 border-zinc-700 hover:border-rose-500/40 hover:text-rose-300" > Delete @@ -860,13 +932,12 @@ export function PresetsTab() {