From 40c6d2887ee5914ddb9a0e977ecf37f65772a59b Mon Sep 17 00:00:00 2001 From: Z User Date: Thu, 5 Nov 2026 06:53:07 +0000 Subject: [PATCH] b8fd4912-690d-432c-9235-71b5108014df --- .env | 2 + .../scripts/[id]/ratings/route.ts | 71 +++ .../api/ove-directory/scripts/[id]/route.ts | 23 + src/app/api/ove-directory/scripts/route.ts | 98 ++++ src/app/api/ove-directory/status/route.ts | 19 + src/components/ava/control-panel.tsx | 19 +- src/components/ava/directory-tab.tsx | 496 ++++++++++++++++++ src/components/ava/script-tab.tsx | 35 +- src/components/ava/upload-dialog.tsx | 216 ++++++++ src/lib/ove-directory.ts | 274 ++++++++++ src/lib/release-notes.ts | 22 + src/lib/server/ove-directory.ts | 145 +++++ src/lib/version.ts | 22 +- 13 files changed, 1434 insertions(+), 8 deletions(-) create mode 100644 src/app/api/ove-directory/scripts/[id]/ratings/route.ts create mode 100644 src/app/api/ove-directory/scripts/[id]/route.ts create mode 100644 src/app/api/ove-directory/scripts/route.ts create mode 100644 src/app/api/ove-directory/status/route.ts create mode 100644 src/components/ava/directory-tab.tsx create mode 100644 src/components/ava/upload-dialog.tsx create mode 100644 src/lib/ove-directory.ts create mode 100644 src/lib/server/ove-directory.ts diff --git a/.env b/.env index 769ebf2..b532a31 100755 --- a/.env +++ b/.env @@ -1 +1,3 @@ DATABASE_URL=file:/home/z/my-project/db/custom.db +OVE_DIRECTORY_URL=https://s3374dir.openvisengine.com +OVE_DIRECTORY_API_KEY=+7MkRB+hkV0UBhPqM1haTQ0f8JGKqkuXwoBTdTSAouU= diff --git a/src/app/api/ove-directory/scripts/[id]/ratings/route.ts b/src/app/api/ove-directory/scripts/[id]/ratings/route.ts new file mode 100644 index 0000000..e2bba10 --- /dev/null +++ b/src/app/api/ove-directory/scripts/[id]/ratings/route.ts @@ -0,0 +1,71 @@ +// OVE — /api/ove-directory/scripts/[id]/ratings: cast a 1–5 rating. +// +// The visitor's anonymous voter id is minted HERE (cookie "ove_voter", +// HttpOnly, one year) and forwarded as the opaque `voter` string the +// directory dedupes on — one vote per script per visitor, enforced upstream. +// 200 and 409 DUPLICATE_VOTE both pass through with the live aggregates. + +import { NextRequest, NextResponse } from 'next/server' +import { dirConfig, dirFetch, jsonError, oveRateCheck, visitorIp } from '@/lib/server/ove-directory' + +export const dynamic = 'force-dynamic' + +const VOTER_COOKIE = 'ove_voter' +const VOTER_MAX_AGE = 365 * 24 * 60 * 60 + +function mintVoter(): string { + return crypto.randomUUID() +} + +/** Read the existing voter id, or mint one (the response then sets it). */ +function voterFor(req: NextRequest): { voter: string; minted: boolean } { + const existing = req.cookies.get(VOTER_COOKIE)?.value + if (existing && existing.trim().length >= 1 && existing.trim().length <= 64) { + return { voter: existing.trim(), minted: false } + } + return { voter: mintVoter(), minted: true } +} + +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const wait = oveRateCheck(visitorIp(req)) + if (wait !== null) { + return jsonError(429, 'RATE_LIMITED', 'Too many directory requests. Try again shortly.') + } + if (!dirConfig()) { + return jsonError(500, 'NOT_CONFIGURED', 'OVE-Directory is not configured on this server.') + } + const { id } = await params + if (!/^\d+$/.test(id) || Number.parseInt(id, 10) < 1) { + return jsonError(400, 'VALIDATION', 'id must be a positive integer.', 'id') + } + const ct = req.headers.get('content-type') ?? '' + if (!ct.toLowerCase().startsWith('application/json')) { + return jsonError(415, 'UNSUPPORTED_MEDIA_TYPE', 'Content-Type must be application/json.') + } + let body: unknown + try { + body = await req.json() + } catch { + return jsonError(400, 'INVALID_JSON', 'The request body is not valid JSON.') + } + const rating = (body as { rating?: unknown } ?? {}).rating + if (typeof rating !== 'number' || !Number.isInteger(rating) || rating < 1 || rating > 5) { + return jsonError(400, 'VALIDATION', 'rating must be an integer from 1 to 5.', 'rating') + } + + const { voter, minted } = voterFor(req) + const r = await dirFetch(`/v1/scripts/${id}/ratings`, { + method: 'POST', + body: { rating, voter }, + }) + const res = NextResponse.json(r.data, { status: r.status }) + if (minted && (r.status === 200 || r.status === 409)) { + res.cookies.set(VOTER_COOKIE, voter, { + httpOnly: true, + sameSite: 'lax', + maxAge: VOTER_MAX_AGE, + path: '/', + }) + } + return res +} diff --git a/src/app/api/ove-directory/scripts/[id]/route.ts b/src/app/api/ove-directory/scripts/[id]/route.ts new file mode 100644 index 0000000..1236d6e --- /dev/null +++ b/src/app/api/ove-directory/scripts/[id]/route.ts @@ -0,0 +1,23 @@ +// OVE — /api/ove-directory/scripts/[id]: fetch one full record (including +// the hash string) by numeric id — the "Get" flow's first hop. + +import { NextRequest, NextResponse } from 'next/server' +import { dirConfig, dirFetch, jsonError, oveRateCheck, visitorIp } from '@/lib/server/ove-directory' + +export const dynamic = 'force-dynamic' + +export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const wait = oveRateCheck(visitorIp(req)) + if (wait !== null) { + return jsonError(429, 'RATE_LIMITED', 'Too many directory requests. Try again shortly.') + } + if (!dirConfig()) { + return jsonError(500, 'NOT_CONFIGURED', 'OVE-Directory is not configured on this server.') + } + const { id } = await params + if (!/^\d+$/.test(id) || Number.parseInt(id, 10) < 1) { + return jsonError(400, 'VALIDATION', 'id must be a positive integer.', 'id') + } + const r = await dirFetch(`/v1/scripts/${id}`) + return NextResponse.json(r.data, { status: r.status }) +} diff --git a/src/app/api/ove-directory/scripts/route.ts b/src/app/api/ove-directory/scripts/route.ts new file mode 100644 index 0000000..0746124 --- /dev/null +++ b/src/app/api/ove-directory/scripts/route.ts @@ -0,0 +1,98 @@ +// OVE — /api/ove-directory/scripts: the directory's list + submit endpoints, +// proxied server-to-server (the API key never leaves this process). +// +// GET ?sort=name|type|date|rating&order=asc|desc → meta records (no hash) +// POST { name, type, hash } → 201 full record, or +// 409 DUPLICATE_HASH with the existing row (treated as success by the +// UI). Before forwarding, the proxy re-checks what it is about to +// publish: the share string must decode, and the decoded script must +// compile — only working scripts join the community directory. + +import { NextRequest, NextResponse } from 'next/server' +import { decodeScriptHash } from '@/lib/ava/share-codec' +import { validateScript } from '@/lib/ava/script' +import { dirConfig, dirFetch, jsonError, oveRateCheck, visitorIp } from '@/lib/server/ove-directory' + +export const dynamic = 'force-dynamic' + +const SORTS = new Set(['name', 'type', 'date', 'rating']) +const ORDERS = new Set(['asc', 'desc']) + +/** Unicode code points after trimming (how the upstream counts name/type). */ +function cpLen(s: string): number { + return [...s].length +} + +export async function GET(req: NextRequest) { + const wait = oveRateCheck(visitorIp(req)) + if (wait !== null) { + return jsonError(429, 'RATE_LIMITED', 'Too many directory requests. Try again shortly.') + } + if (!dirConfig()) { + return jsonError(500, 'NOT_CONFIGURED', 'OVE-Directory is not configured on this server.') + } + const url = new URL(req.url) + const sort = url.searchParams.get('sort') ?? 'date' + const order = url.searchParams.get('order') ?? 'desc' + if (!SORTS.has(sort)) { + return jsonError(400, 'VALIDATION', 'sort must be one of: name, type, date, rating.', 'sort') + } + if (!ORDERS.has(order)) { + return jsonError(400, 'VALIDATION', 'order must be asc or desc.', 'order') + } + const r = await dirFetch(`/v1/scripts?sort=${encodeURIComponent(sort)}&order=${encodeURIComponent(order)}`) + return NextResponse.json(r.data, { status: r.status }) +} + +export async function POST(req: NextRequest) { + const wait = oveRateCheck(visitorIp(req)) + if (wait !== null) { + return jsonError(429, 'RATE_LIMITED', 'Too many directory requests. Try again shortly.') + } + if (!dirConfig()) { + return jsonError(500, 'NOT_CONFIGURED', 'OVE-Directory is not configured on this server.') + } + const ct = req.headers.get('content-type') ?? '' + if (!ct.toLowerCase().startsWith('application/json')) { + return jsonError(415, 'UNSUPPORTED_MEDIA_TYPE', 'Content-Type must be application/json.') + } + let body: unknown + try { + body = await req.json() + } catch { + return jsonError(400, 'INVALID_JSON', 'The request body is not valid JSON.') + } + const b = (body ?? {}) as Record + if (typeof b.name !== 'string' || typeof b.type !== 'string' || typeof b.hash !== 'string') { + return jsonError(400, 'VALIDATION', 'name, type and hash are required strings.') + } + + const name = b.name.trim() + if (cpLen(name) < 1 || cpLen(name) > 100) { + return jsonError(400, 'VALIDATION', 'name must be 1–100 characters (after trimming).', 'name') + } + const type = b.type.trim() + if (cpLen(type) < 1 || cpLen(type) > 50) { + return jsonError(400, 'VALIDATION', 'type must be 1–50 characters (after trimming).', 'type') + } + const hash = b.hash + const hashBytes = Buffer.byteLength(hash, 'utf8') + if (hashBytes < 1 || hashBytes > 100_000) { + return jsonError(400, 'VALIDATION', 'hash must be 1–100,000 bytes.', 'hash') + } + + // Integrity gate: the share string must decode back into a script… + const decoded = decodeScriptHash(hash) + if (!decoded.ok) { + return jsonError(400, 'INVALID_HASH', `The share string did not decode: ${decoded.error}`) + } + // …and that script must compile before it joins the directory. + const v = validateScript(decoded.script) + if (!v.ok) { + const where = v.error ? ` [${v.error.section}] line ${v.error.line}: ${v.error.message}` : '' + return jsonError(400, 'VALIDATION', `Only scripts that compile can be published -- fix it first.${where}`, 'script') + } + + const r = await dirFetch('/v1/scripts', { method: 'POST', body: { name, type, hash } }) + return NextResponse.json(r.data, { status: r.status }) +} diff --git a/src/app/api/ove-directory/status/route.ts b/src/app/api/ove-directory/status/route.ts new file mode 100644 index 0000000..92455a7 --- /dev/null +++ b/src/app/api/ove-directory/status/route.ts @@ -0,0 +1,19 @@ +// OVE — /api/ove-directory/status: forward the directory's public health +// probe (no API key needed upstream; harmless to send one). Used by the +// Directory tab's status line. + +import { NextResponse } from 'next/server' +import { dirConfig, dirFetch } from '@/lib/server/ove-directory' + +export const dynamic = 'force-dynamic' + +export async function GET() { + if (!dirConfig()) { + return NextResponse.json( + { error: { code: 'NOT_CONFIGURED', message: 'OVE-Directory is not configured on this server.' } }, + { status: 500 }, + ) + } + const r = await dirFetch('/status') + return NextResponse.json(r.data, { status: r.status }) +} diff --git a/src/components/ava/control-panel.tsx b/src/components/ava/control-panel.tsx index ea65474..c7a88ba 100755 --- a/src/components/ava/control-panel.tsx +++ b/src/components/ava/control-panel.tsx @@ -1,4 +1,4 @@ -// OVE — right-hand control panel: the five tabs. +// OVE — right-hand control panel: the six tabs. // // Desktop: fixed width, content scrolls inside the panel. Mobile: natural // height (the page scrolls and the footer is pushed down naturally). @@ -9,6 +9,7 @@ import { motion } from 'framer-motion' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { PresetsTab } from '@/components/ava/presets-tab' import { ScriptTab } from '@/components/ava/script-tab' +import { DirectoryTab } from '@/components/ava/directory-tab' import { DetectTab } from '@/components/ava/detect-tab' import { BeatTab } from '@/components/ava/beat-tab' import { NativeTab } from '@/components/ava/native-tab' @@ -19,8 +20,12 @@ import { cn } from '@/lib/utils' // v6.5 — the Native tab is PARKED (hidden) so the Settings tab can take its // slot; it returns unchanged when the project nears completion. Flip this -// flag to bring it back — the tab row widens to 6 columns automatically and +// flag to bring it back — the tab row widens to 7 columns automatically and // the component + /api/rust-scaffold endpoint are untouched. +// +// v7.11 — the Directory tab (community script browser) joined between Script +// and Detect; the row is 3 columns on phones (two stacked rows) and one row +// from sm up. const NATIVE_TAB_ENABLED = false function TabBody({ children }: { children: React.ReactNode }) { @@ -45,7 +50,7 @@ export function ControlPanel() { @@ -54,6 +59,9 @@ export function ControlPanel() { Script + + Directory + Detect @@ -81,6 +89,11 @@ export function ControlPanel() { + + + + + diff --git a/src/components/ava/directory-tab.tsx b/src/components/ava/directory-tab.tsx new file mode 100644 index 0000000..3caaa36 --- /dev/null +++ b/src/components/ava/directory-tab.tsx @@ -0,0 +1,496 @@ +// OVE — Directory tab (v7.11): the community script browser. +// +// Lists every script published to the OVE-Directory (its own VPS, reached +// through this server's backend proxy) with the name its author chose, its +// type, its date, and its star rating. Visitors rate entries 1–5 (one vote +// per script per visitor, enforced upstream), see the live average to the +// right of the stars, sort the whole list by Name / Type / Date / Rating, +// and "Get" any entry: the hash string is fetched, decoded back into the +// exact script, and filed in this browser's preset tree under +// "root/user created/OVE-Directory" -- from where it opens in the editor. + +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { + Check, + CloudUpload, + Copy, + Download, + FileCode2, + Loader2, + RefreshCw, + Star, + TriangleAlert, +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { decodeScriptHash } from '@/lib/ava/share-codec' +import { validateScript } from '@/lib/ava/script' +import { useAvaStore } from '@/lib/store' +import { useToast } from '@/hooks/use-toast' +import { + DEFAULT_ORDER, + DEFAULT_SORT, + DIR_SORTS, + DOWNLOAD_FOLDER, + fetchDirList, + fetchDirScript, + fetchDirStatus, + formatDirDate, + loadMyVotes, + rateDirScript, + rememberMyVote, + saveDownloadedPreset, + type DirMeta, + type DirOrder, + type DirSortKey, + type DirStatus, +} from '@/lib/ove-directory' +import { cn } from '@/lib/utils' + +/** Copy text to the clipboard with a legacy fallback (same trick as the share dialogs). */ +async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + try { + const ta = document.createElement('textarea') + ta.value = text + ta.style.position = 'fixed' + ta.style.opacity = '0' + document.body.appendChild(ta) + ta.select() + const ok = document.execCommand('copy') + ta.remove() + return ok + } catch { + return false + } + } +} + +interface GotPanel { + hash: string + script: string + compiles: boolean + savedAs: string + alreadyHad: boolean +} + +// ── one directory row ───────────────────────────────────────────────────── + +function StarRow({ + entry, + myVote, + busy, + onVote, +}: { + entry: DirMeta + myVote: number + busy: boolean + onVote: (stars: number) => void +}) { + const [hover, setHover] = useState(0) + const active = hover || myVote + const locked = myVote > 0 + + return ( + setHover(0)} + role="group" + aria-label={`Rating for ${entry.name}`} + > + {[1, 2, 3, 4, 5].map((n) => { + const filled = n <= active + return ( + + ) + })} + + ) +} + +function DirRow({ + entry, + myVote, + voting, + getting, + got, + onVote, + onGet, +}: { + entry: DirMeta + myVote: number + voting: boolean + getting: boolean + got: GotPanel | null + onVote: (stars: number) => void + onGet: () => void +}) { + const [copied, setCopied] = useState(false) + + async function copyHash() { + if (!got) return + const ok = await copyText(got.hash) + if (ok) { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + } + + function openInEditor() { + if (!got) return + useAvaStore.getState().setScriptText(got.script) + useAvaStore.getState().setTab('script') + } + + return ( +
+
+

+ {entry.name} +

+ + {entry.type} + + +
+ +
+ + + Average: {entry.average.toFixed(1)} + · {entry.votes} {entry.votes === 1 ? 'vote' : 'votes'} + + + {getting ? ( + + ) : ( + + )} + +
+ + {got && ( +
+

+ {got.alreadyHad ? ( + <>You already had this exact script -- no duplicate was made. + ) : ( + <> + Saved to your presets as {got.savedAs} in{' '} + {DOWNLOAD_FOLDER}. + + )} +

+
+
+ Share string (the stored hash): + +
+

+ {got.hash} +

+
+
+

+ + {got.compiles ? 'Decodes and compiles OK' : "Decodes, but doesn't compile (yet)"} +

+ +
+
+ )} +
+ ) +} + +// ── the tab ─────────────────────────────────────────────────────────────── + +export function DirectoryTab() { + const { toast } = useToast() + + const [status, setStatus] = useState(null) + const [scripts, setScripts] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [sort, setSort] = useState(DEFAULT_SORT) + const [order, setOrder] = useState(DEFAULT_ORDER) + const [myVotes, setMyVotes] = useState>({}) + const [voting, setVoting] = useState(null) + const [getting, setGetting] = useState(null) + const [got, setGot] = useState>({}) + + useEffect(() => { + setMyVotes(loadMyVotes()) + void (async () => { + const s = await fetchDirStatus() + setStatus(s) + })() + }, []) + + const reload = useCallback(async () => { + setLoading(true) + setError(null) + try { + const list = await fetchDirList(sort, order) + setScripts(list) + } catch (e) { + setError(e instanceof Error ? e.message : 'The directory could not be listed.') + } finally { + setLoading(false) + } + }, [sort, order]) + + useEffect(() => { + void reload() + }, [reload]) + + const vote = useCallback( + async (entry: DirMeta, stars: number) => { + if (voting !== null) return + setVoting(entry.id) + try { + const r = await rateDirScript(entry.id, stars) + rememberMyVote(entry.id, stars) + setMyVotes(loadMyVotes()) + setScripts((prev) => + prev.map((s) => (s.id === entry.id ? { ...s, average: r.average, votes: r.votes } : s)), + ) + if (r.duplicate) { + toast({ + title: 'Already rated', + description: 'One vote per script per visitor -- the directory kept the first one.', + }) + } + } catch (e) { + const msg = e instanceof Error ? e.message : 'Rating failed.' + if (msg.includes('no longer in the directory')) { + setScripts((prev) => prev.filter((s) => s.id !== entry.id)) + } + toast({ title: 'Rating failed', description: msg, variant: 'destructive' }) + } finally { + setVoting(null) + } + }, + [voting, toast], + ) + + const get = useCallback( + async (entry: DirMeta) => { + if (getting !== null) return + setGetting(entry.id) + try { + const full = await fetchDirScript(entry.id) + const decoded = decodeScriptHash(full.hash) + if (!decoded.ok) throw new Error('The stored share string did not decode -- it may be corrupted.') + const v = validateScript(decoded.script) + const save = saveDownloadedPreset(decoded.script, entry.name) + if (!save.ok) throw new Error(save.error ?? 'Could not save to this browser.') + setGot((prev) => ({ + ...prev, + [entry.id]: { + hash: full.hash, + script: decoded.script, + compiles: v.ok, + savedAs: save.preset?.name ?? entry.name, + alreadyHad: Boolean(save.alreadyHad), + }, + })) + toast({ + title: save.alreadyHad ? 'Already in your presets' : 'Saved to your presets', + description: `"${save.preset?.name ?? entry.name}" -- open it in the editor from the panel below, or hit Apply after switching to the Script tab.`, + }) + } catch (e) { + toast({ + title: 'Get failed', + description: e instanceof Error ? e.message : 'Get failed.', + variant: 'destructive', + }) + } finally { + setGetting(null) + } + }, + [getting, toast], + ) + + const online = status?.status === 'ok' + + return ( +
+ {/* header: service status + refresh */} +
+ +

OVE-Directory

+ + {status === null + ? 'connecting…' + : online + ? `${status.scripts_total} ${status.scripts_total === 1 ? 'script' : 'scripts'} · v${status.version}` + : 'unreachable'} + + +
+ + {/* sort controls: Name / Type / Date / Rating + direction */} +
+ {DIR_SORTS.map((s) => ( + + ))} + + + {sort}/{order} + +
+ + {/* list / states */} + {error ? ( +
+

+ + {error} +

+ +
+ ) : loading ? ( +
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+ ) : scripts.length === 0 ? ( +
+

Nothing published yet.

+

+ Build a script in the Script tab, make sure it shows{' '} + ✔ Compiled OK, then hit{' '} + Upload to OVE-Directory -- published scripts appear + here for everyone to browse, rate, and Get. +

+
+ ) : ( +
+ {scripts.map((entry) => ( + void vote(entry, stars)} + onGet={() => void get(entry)} + /> + ))} +

+ + {scripts.length} {scripts.length === 1 ? 'entry' : 'entries'} -- publish yours from the Script tab +

+
+ )} +
+ ) +} diff --git a/src/components/ava/script-tab.tsx b/src/components/ava/script-tab.tsx index 8a9ca58..f79874b 100755 --- a/src/components/ava/script-tab.tsx +++ b/src/components/ava/script-tab.tsx @@ -3,10 +3,11 @@ // Toolbar: Apply, Save (to the preset picked in the Presets tab, when a save // target is set), Save As…, Save as Default, Save to File, Load from File, // Script⇢Hash / Hash⇢Script (v7.7 — share the script as one short Unicode -// string and paste one back), New, Help. Editor: line-number gutter + -// monospace textarea (synced scroll, Tab inserts two spaces), error lines -// flagged in the gutter. Status line under the editor shows compile status -// or runtime errors. +// string and paste one back), Upload to OVE-Directory (v7.11 — publish the +// compiled script to the community directory), New, Help. Editor: line-number +// gutter + monospace textarea (synced scroll, Tab inserts two spaces), error +// lines flagged in the gutter. Status line under the editor shows compile +// status or runtime errors. 'use client' @@ -15,6 +16,7 @@ import { Check, CircleHelp, ClipboardPaste, + CloudUpload, Copy, Download, FilePlus2, @@ -44,6 +46,7 @@ import { import { applyScriptText } from '@/components/ava/audio-source' import { HelpPanel } from '@/components/ava/help-panel' import { HashToScriptDialog, ScriptToHashDialog } from '@/components/ava/share-dialogs' +import { UploadToDirectoryDialog } from '@/components/ava/upload-dialog' import { sanitizeFileName } from '@/components/ava/shared' import { useToast } from '@/hooks/use-toast' import { cn } from '@/lib/utils' @@ -80,6 +83,12 @@ export function ScriptTab() { // v7.7 — share-string dialogs const [shareOutOpen, setShareOutOpen] = useState(false) const [shareInOpen, setShareInOpen] = useState(false) + // v7.11 — publish to the community OVE-Directory. Gated on the same line + // the status line shows: the editor script must pass ✔ Compiled OK and the + // engine must not be sitting on a runtime error (a stale crash from an + // earlier Apply keeps this off until Apply runs clean). + const [uploadOpen, setUploadOpen] = useState(false) + const canUpload = validation?.ok === true && !runtimeError // debounced live validation (compile is cheap, scripts are small) useEffect(() => { @@ -368,6 +377,21 @@ export function ScriptTab() { > Hash⇢Script + + + + + + ) +} diff --git a/src/lib/ove-directory.ts b/src/lib/ove-directory.ts new file mode 100644 index 0000000..7366b6e --- /dev/null +++ b/src/lib/ove-directory.ts @@ -0,0 +1,274 @@ +// OVE — OVE-Directory client library (v7.11). +// +// The community script directory lives on its own server +// (https://s3374dir.openvisengine.com) behind an API key. Browsers NEVER +// talk to it directly: every call goes through this app's backend proxy at +// /api/ove-directory/* (src/app/api/ove-directory/), which holds the key in +// server env and forwards the request. This module is the browser-side +// twin: typed wrappers for each proxied endpoint, the per-visitor vote +// bookkeeping (localStorage), and the "Get" flow that decodes a fetched +// hash string back into a script and files it in this browser's preset +// tree under "root/user created/OVE-Directory". +// +// Wire shapes mirror the upstream directory 1:1 (the proxy forwards bodies +// verbatim), so the reference that matters is the OVE-Directory API card: +// meta record { id, name, type, created_at, average, votes } (lists) +// full record meta + { hash } (fetch) +// errors { error: { code, message, field? }, hint?, … } + +import type { PresetItem } from '@/lib/store' +import { applyUserPresets } from '@/lib/store' +import { createUserPreset, listUserPresets } from '@/lib/ava/user-presets' +import { validateScript } from '@/lib/ava/script' + +// ── types & constants ───────────────────────────────────────────────────── + +export type DirSortKey = 'name' | 'type' | 'date' | 'rating' +export type DirOrder = 'asc' | 'desc' + +/** Meta record — one row of a directory listing (no hash). */ +export interface DirMeta { + id: number + name: string + type: string + created_at: string + average: number + votes: number +} + +/** Full record — a fetched script (meta + the hash string). */ +export interface DirScript extends DirMeta { + hash: string +} + +export interface DirStatus { + status: string + service: string + version: string + uptime_seconds: number + scripts_total: number + timestamp: string +} + +export const DIR_SORTS: ReadonlyArray<{ key: DirSortKey; label: string }> = [ + { key: 'rating', label: 'Rating' }, + { key: 'name', label: 'Name' }, + { key: 'type', label: 'Type' }, + { key: 'date', label: 'Date' }, +] + +/** Default listing: top rated first; unrated sink to the bottom (upstream rule). */ +export const DEFAULT_SORT: DirSortKey = 'rating' +export const DEFAULT_ORDER: DirOrder = 'desc' + +/** Where "Get" files downloaded scripts in this browser's preset tree. */ +export const DOWNLOAD_FOLDER = 'root/user created/OVE-Directory' + +// ── low-level proxy call ────────────────────────────────────────────────── + +interface ProxyErrorBody { + error?: { code?: string; message?: string; field?: string } | string + hint?: string +} + +/** Pull a human-readable message out of a proxy/upstream error body. */ +function errorText(body: unknown, fallback: string): string { + if (!body || typeof body !== 'object') return fallback + const e = (body as ProxyErrorBody).error + if (typeof e === 'string') return e + if (e && typeof e === 'object') { + if (typeof e.message === 'string' && e.message) return e.message + } + return fallback +} + +async function proxyJson(path: string, init?: RequestInit): Promise<{ status: number; body: T | null; message: string | null }> { + let res: Response + try { + res = await fetch(path, { ...init, headers: { Accept: 'application/json', ...(init?.headers ?? {}) } }) + } catch { + return { status: 0, body: null, message: 'Could not reach this server (network error).' } + } + let body: T | null = null + try { + body = (await res.json()) as T + } catch { + /* non-JSON body — keep null */ + } + if (!res.ok) { + const message = body ? errorText(body, `Request failed (${res.status}).`) : `Request failed (${res.status}).` + return { status: res.status, body, message } + } + return { status: res.status, body, message: null } +} + +// ── endpoint wrappers ───────────────────────────────────────────────────── + +/** Health probe. Null when the proxy itself is unreachable. */ +export async function fetchDirStatus(): Promise { + const r = await proxyJson('/api/ove-directory/status') + return r.body ?? null +} + +/** List scripts, sorted upstream. Throws Error(message) on failure. */ +export async function fetchDirList(sort: DirSortKey, order: DirOrder): Promise { + const qs = `?sort=${encodeURIComponent(sort)}&order=${encodeURIComponent(order)}` + const r = await proxyJson<{ count: number; scripts: DirMeta[] }>(`/api/ove-directory/scripts${qs}`) + if (!r.body || !Array.isArray(r.body.scripts)) throw new Error(r.message ?? 'The directory response was malformed.') + return r.body.scripts +} + +/** Fetch one full record (with its hash string). Throws Error(message). */ +export async function fetchDirScript(id: number): Promise { + const r = await proxyJson(`/api/ove-directory/scripts/${id}`) + if (!r.body || typeof r.body.hash !== 'string') throw new Error(r.message ?? 'The directory response was malformed.') + return r.body +} + +export interface UploadResult { + id: number + duplicate: boolean + record: DirMeta +} + +/** + * Publish {name, type, hash} to the directory. Both the 201 (new) and the + * 409 DUPLICATE_HASH (already there — nothing changed) paths resolve as + * success; everything else throws Error(message). + */ +export async function uploadDirScript(input: { name: string; type: string; hash: string }): Promise { + const r = await proxyJson('/api/ove-directory/scripts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: input.name, type: input.type, hash: input.hash }), + }) + if (r.status === 201 && r.body) { + return { id: r.body.id, duplicate: false, record: r.body as DirMeta } + } + if (r.status === 409 && r.body) { + // duplicate-hash bodies carry the row as `existing` + const body = r.body as DirMeta & { existing?: DirMeta } + const existing = body.existing ?? body + return { id: existing.id, duplicate: true, record: existing } + } + throw new Error(r.message ?? 'Upload failed.') +} + +export interface RateResult { + average: number + votes: number + duplicate: boolean +} + +/** + * Cast a 1–5 rating for script `id` as this visitor (the backend mints the + * anonymous voter id). 200 and 409 DUPLICATE_VOTE both resolve as success — + * on duplicates the directory returns the unchanged aggregates. + */ +export async function rateDirScript(id: number, rating: number): Promise { + const r = await proxyJson<{ average: number; votes: number }>(`/api/ove-directory/scripts/${id}/ratings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rating }), + }) + if ((r.status === 200 || r.status === 409) && r.body && typeof r.body.average === 'number') { + return { average: r.body.average, votes: r.body.votes ?? 0, duplicate: r.status === 409 } + } + if (r.status === 404) throw new Error('That script is no longer in the directory.') + throw new Error(r.message ?? 'Rating failed.') +} + +// ── per-visitor vote bookkeeping (this browser) ─────────────────────────── + +const VOTES_KEY = 'ove.directory.votes.v1' + +/** { scriptId: stars } as remembered from this browser. */ +export function loadMyVotes(): Record { + try { + const raw = localStorage.getItem(VOTES_KEY) + if (!raw) return {} + const parsed = JSON.parse(raw) as Record + if (!parsed || typeof parsed !== 'object') return {} + const out: Record = {} + for (const [k, v] of Object.entries(parsed)) { + const n = typeof v === 'number' ? v : Number(v) + if (Number.isInteger(n) && n >= 1 && n <= 5 && /^\d{1,10}$/.test(k)) out[k] = n + } + return out + } catch { + return {} + } +} + +/** Remember that this browser rated `id` with `stars` (1–5). */ +export function rememberMyVote(id: number, stars: number): void { + try { + const votes = loadMyVotes() + votes[String(id)] = stars + localStorage.setItem(VOTES_KEY, JSON.stringify(votes)) + } catch { + /* private mode — voting still worked, we just can't remember it */ + } +} + +// ── "Get" flow: decode a fetched hash and file it in this browser ───────── + +export interface SaveDownloadResult { + ok: boolean + error?: string + /** The preset as stored (newly created or the already-present twin). */ + preset?: PresetItem + /** True when a preset with identical content was already there (no copy made). */ + alreadyHad?: boolean +} + +/** + * Pick a name for a downloaded script inside DOWNLOAD_FOLDER: the exact + * name first, then "Name (1)", "Name (2)", … mirroring the directory's own + * duplicate-numbering style. + */ +function uniqueDownloadName(base: string, taken: Set): string { + if (!taken.has(base)) return base + for (let i = 1; i < 100; i++) { + const candidate = `${base} (${i})` + if (!taken.has(candidate)) return candidate + } + return `${base} (${Date.now() % 1000})` +} + +/** + * Save a script fetched from the directory into this browser's preset tree + * (localStorage), deduped by exact content. Does NOT touch the engine — + * the caller decides whether to also drop it into the editor. + */ +export function saveDownloadedPreset(script: string, fallbackName: string): SaveDownloadResult { + const v = validateScript(script) + const meta = v.ok ? v.meta : null + const base = (meta?.name && meta.name !== 'Untitled' ? meta.name : fallbackName).trim() || 'Untitled' + + const existing = listUserPresets().filter((p) => p.folderPath === DOWNLOAD_FOLDER) + const twin = existing.find((p) => p.content === script) + if (twin) return { ok: true, preset: twin, alreadyHad: true } + + const taken = new Set(existing.map((p) => p.name)) + const name = uniqueDownloadName(base, taken) + const res = createUserPreset({ + name, + content: script, + folderPath: DOWNLOAD_FOLDER, + author: meta?.author || 'OVE-Directory', + description: `Downloaded from the community directory as "${fallbackName}".`, + }) + if (!res.ok) return { ok: false, error: res.error } + applyUserPresets() + return { ok: true, preset: res.preset, alreadyHad: false } +} + +// ── display helpers ─────────────────────────────────────────────────────── + +/** "2026-11-05T18:40:12Z" → "Nov 5, 2026" (falls back to the raw date). */ +export function formatDirDate(iso: string): string { + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return iso.slice(0, 10) + return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) +} diff --git a/src/lib/release-notes.ts b/src/lib/release-notes.ts index 8c30c9d..5cf7d45 100755 --- a/src/lib/release-notes.ts +++ b/src/lib/release-notes.ts @@ -39,6 +39,28 @@ export interface ReleaseNote { } export const RELEASE_NOTES: ReleaseNote[] = [ + { + version: '7.11', + name: 'The community OVE-Directory', + changes: [ + { + kind: 'added', + text: 'A community script directory on its own server -- a sixth tab, Directory, lists every published script with its name, type and date, a 1–5 star rater with the live "Average: #.#" beside the stars, and sorting by Name, Type, Date or Rating. I reached it through this site\u2019s own backend, so the two servers stay fully independent: either can go down and the other keeps running.', + }, + { + kind: 'added', + text: '"Upload to OVE-Directory" in the Script tab (enabled while the script shows \u2714 Compiled OK with no runtime error). The script travels as its compact Script\u21e2Hash string -- the directory stores a fraction of the source -- and every upload is re-checked server-side: the string must decode back into a script that still compiles.', + }, + { + kind: 'added', + text: '"Get" on any directory entry fetches the exact script back, files it in this browser\u2019s preset tree under "user created/OVE-Directory" (identical copies are never duplicated; name collisions number themselves "(1) (2) (3)\u2026"), and offers a one-click "Open in editor".', + }, + { + kind: 'added', + text: 'Ratings are one vote per script per visitor, enforced by the directory itself; a second attempt just shows the stored average. The anonymous voter id is a cookie minted by this site\u2019s backend -- no accounts, no identity stored.', + }, + ], + }, { version: '7.10', name: 'Ten demo tracks', diff --git a/src/lib/server/ove-directory.ts b/src/lib/server/ove-directory.ts new file mode 100644 index 0000000..1af92bd --- /dev/null +++ b/src/lib/server/ove-directory.ts @@ -0,0 +1,145 @@ +// OVE — OVE-Directory backend proxy helpers (v7.11). +// +// The community script directory runs on its own VPS +// (https://s3374dir.openvisengine.com) behind an API key. Browsers never see +// that key: every /api/ove-directory/* route on THIS server forwards the +// request server-to-server, so the directory's address and credentials stay +// in env config here. The two VPSs stay fully independent -- either can go +// down without touching the other. +// +// What lives here: +// dirConfig() — env: OVE_DIRECTORY_URL + OVE_DIRECTORY_API_KEY +// dirFetch() — authenticated upstream call (X-API-Key, 10 s timeout), +// returns { status, data } with upstream bodies verbatim; +// network failures become 502 BAD_GATEWAY envelopes +// oveRateCheck()— a small per-visitor-IP throttle for the proxy itself +// (the upstream's 240/min bucket is shared by every visitor +// behind this server's IP, so local policing matters) +// jsonError() — the shared error envelope for local rejections + +import { NextRequest, NextResponse } from 'next/server' + +const UPSTREAM_TIMEOUT_MS = 10_000 + +// ── configuration ───────────────────────────────────────────────────────── + +export interface DirConfig { + url: string + key: string +} + +/** Env config; null when the proxy is not set up (500 to callers). */ +export function dirConfig(): DirConfig | null { + const url = (process.env.OVE_DIRECTORY_URL ?? '').trim().replace(/\/+$/, '') + const key = (process.env.OVE_DIRECTORY_API_KEY ?? '').trim() + if (!url || !key) return null + return { url, key } +} + +// ── upstream calls ──────────────────────────────────────────────────────── + +export interface DirFetchResult { + status: number + /** Parsed upstream JSON body (envelope, record, list — verbatim). */ + data: unknown +} + +/** + * Call the directory. `path` is the upstream path (with query), `body` is + * JSON-encoded when present. Adds the X-API-Key header on every call; the + * upstream's one public route (/status) just ignores it. + */ +export async function dirFetch(path: string, init?: { method?: string; body?: unknown }): Promise { + const cfg = dirConfig() + if (!cfg) { + return { + status: 500, + data: { error: { code: 'NOT_CONFIGURED', message: 'OVE-Directory is not configured on this server.' } }, + } + } + let res: Response + try { + res = await fetch(`${cfg.url}${path}`, { + method: init?.method ?? 'GET', + headers: { + 'X-API-Key': cfg.key, + Accept: 'application/json', + ...(init?.body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + body: init?.body !== undefined ? JSON.stringify(init.body) : undefined, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + cache: 'no-store', + }) + } catch { + return { + status: 502, + data: { + error: { + code: 'BAD_GATEWAY', + message: 'Could not reach the OVE-Directory server. Try again in a moment.', + }, + }, + } + } + let data: unknown = null + try { + data = await res.json() + } catch { + data = null + } + if (data === null) { + return { + status: 502, + data: { + error: { + code: 'BAD_UPSTREAM', + message: 'The OVE-Directory server sent a malformed response.', + }, + }, + } + } + return { status: res.status, data } +} + +// ── local error envelope (matches the upstream shape) ───────────────────── + +export function jsonError(status: number, code: string, message: string, field?: string): NextResponse { + return NextResponse.json( + { error: { code, message, ...(field !== undefined ? { field } : {}) } }, + { status }, + ) +} + +// ── per-visitor throttle for the proxy ──────────────────────────────────── + +const RL_WINDOW_MS = 60_000 +const RL_LIMIT = 30 +const rlBuckets = new Map() + +/** + * Count this visitor IP against the proxy's own limit (30 requests/min). + * Returns null when allowed, or the seconds to wait when throttled. + */ +export function oveRateCheck(ip: string): number | null { + const now = Date.now() + let b = rlBuckets.get(ip) + if (!b || now - b.start >= RL_WINDOW_MS) { + b = { start: now, count: 0 } + rlBuckets.set(ip, b) + } + b.count++ + if (rlBuckets.size > 512) { + for (const [k, v] of rlBuckets) { + if (now - v.start >= RL_WINDOW_MS) rlBuckets.delete(k) + } + } + if (b.count > RL_LIMIT) return Math.max(1, Math.ceil((b.start + RL_WINDOW_MS - now) / 1000)) + return null +} + +/** Best-effort visitor IP for the throttle (the gateway sets X-Forwarded-For). */ +export function visitorIp(req: NextRequest): string { + const fwd = req.headers.get('x-forwarded-for') + const first = fwd?.split(',')[0]?.trim() + return first && first.length > 0 ? first : 'local' +} diff --git a/src/lib/version.ts b/src/lib/version.ts index 0384e80..8e95cb3 100755 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -12,6 +12,26 @@ // warns in the console when the two drift apart. // // History: +// 7.11 — the community OVE-Directory. A sixth tab (Directory) lists every +// script published to the community directory — a separate VPS at +// s3374dir.openvisengine.com, reached ONLY through this server's +// backend proxy (/api/ove-directory/*), which holds the API key in +// env and re-validates every upload (the share string must decode +// AND compile). Publishing: the Script tab gains "Upload to +// OVE-Directory" (enabled only while the editor shows ✔ Compiled OK +// with no runtime error); the script travels as its Script⇢Hash +// string, so the directory stores a fraction of the source. The +// listing carries each entry's name, type and date, a 1–5 star +// widget (the voter id is an HttpOnly cookie minted by the proxy; +// one vote per script per visitor, enforced upstream), the live +// "Average: #.#" to the right of the stars, and sorting by Name / +// Type / Date / Rating (done upstream, both directions). "Get" +// fetches the full record, decodes the hash back into the exact +// script, files it in this browser's preset tree under +// root/user created/OVE-Directory (content-deduped, names numbered +// "(1) (2) (3)…" on collision) and offers "Open in editor". +// Duplicate uploads resolve as success (409 DUPLICATE_HASH carries +// the existing row); duplicate votes show the stored aggregates. // 7.10 — ten demo tracks + first-person changelog + declutter. The demo // source is now a COLLECTION: ten mastered tracks in // public/demo-tracks/ (demo-01.mp3 … demo-10.mp3, filename order @@ -299,4 +319,4 @@ export const APP_NAME = 'Open Visualization Engine' export const APP_SHORT = 'OVE' -export const APP_VERSION = '7.10' +export const APP_VERSION = '7.11'