Compare commits

..

No commits in common. "zai-dev" and "main" have entirely different histories.

7 changed files with 133 additions and 454 deletions

View file

@ -7,21 +7,11 @@
// 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.
//
// v7.21 (user request, anti-spam / anti-corruption): name and type run
// through the shared dir-text sanitizer (src/lib/dir-text.ts) -- control
// characters, invisible format/zero-width/bidi characters stripped,
// whitespace runs collapsed -- and are capped at 30 / 10 code points,
// matching the upload dialog's boxes exactly. The SANITIZED strings are
// what gets forwarded upstream, so the directory server only ever stores
// clean text (the admin's own manual copy of the same limits upstream is
// the last line of defense).
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'
import { DIR_NAME_MAX, DIR_TYPE_MAX, sanitizeDirText } from '@/lib/dir-text'
export const dynamic = 'force-dynamic'
@ -77,26 +67,13 @@ export async function POST(req: NextRequest) {
return jsonError(400, 'VALIDATION', 'name, type and hash are required strings.')
}
// v7.21 -- sanitize (strip control + format characters, collapse
// whitespace, trim) BEFORE the length check, so the caps count clean
// text. The sanitized values are what get forwarded upstream.
const name = sanitizeDirText(b.name)
if (cpLen(name) < 1 || cpLen(name) > DIR_NAME_MAX) {
return jsonError(
400,
'VALIDATION',
`name must be 1-${DIR_NAME_MAX} characters (control and format characters are stripped, whitespace collapsed).`,
'name',
)
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 = sanitizeDirText(b.type)
if (cpLen(type) < 1 || cpLen(type) > DIR_TYPE_MAX) {
return jsonError(
400,
'VALIDATION',
`type must be 1-${DIR_TYPE_MAX} characters (control and format characters are stripped, whitespace collapsed).`,
'type',
)
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')

View file

@ -1,33 +1,18 @@
// OVE — Directory tab (v7.21): the community script browser.
// 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.
//
// v7.20 card skeleton kept (one header line, the action cluster at the top
// right, the date immediately left of it). v7.21 (user request, "a more
// clean interface") splits the old all-in-one Get into two dedicated
// actions, stacked at each card's top right:
// • Get (primary, emerald) — fetch + decode + FILE the script into
// root/user created/OVE-Directory in one click. The v7.20.1 explicit
// Save is folded in here; no panel opens, a toast confirms the save
// (content-deduped — an identical twin already in the folder is
// reported, never duplicated). Once saved, this same button morphs
// into Apply.
// • Apply (the post-Get state of the Get button) — loads the
// just-downloaded preset onto the engine and makes it the active
// preset (identical semantics to the Presets tab's Load: the footer's
// SCRIPT: label echoes it and the Script tab's Save targets it), so
// the visitor immediately sees what they just downloaded running.
// Repeat clicks just re-run it.
// • Hash (small, directly under Get) — the ONLY way the share-string
// panel opens now. First click fetches; after that it toggles the
// panel open/closed. The panel carries the stored hash (copyable),
// the decode + compile status, whether the entry has been saved via
// Get, and Open in editor. The v7.20.1 panel Save button is gone
// (Get does the saving); Open in editor stays exactly as it was.
// right of the stars, sort the whole list by Name / Type / Date / Rating,
// and "Get" any entry: the hash string is fetched and decoded back into the
// exact script. v7.20: each entry card is compact -- the Get button sits at
// the card's top right with the date immediately to its left, so the rating
// row never wraps the button onto its own line (the card dropped from
// ~126 px to ~94 px at the sidebar width). v7.20.1: Get no longer files the
// script into the preset tree automatically -- the panel carries an
// explicit Save button (left of Open in editor) that places the preset
// under "root/user created/OVE-Directory" on demand, deduped by content.
'use client'
@ -38,15 +23,13 @@ import {
Copy,
Download,
FileCode2,
Hash,
Loader2,
Play,
RefreshCw,
Save,
Star,
TriangleAlert,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { applyScriptText } from '@/components/ava/audio-source'
import { decodeScriptHash } from '@/lib/ava/share-codec'
import { validateScript } from '@/lib/ava/script'
import { useAvaStore } from '@/lib/store'
@ -97,14 +80,10 @@ interface GotPanel {
hash: string
script: string
compiles: boolean
/** Name the preset was filed under; null until Get saves it. */
/** Name the preset was saved under; null until the Save button is hit. */
savedAs: string | null
/** Preset id once saved (the twin's id when a duplicate was already there). */
presetId: string | null
/** Whether a content-identical preset was already in the folder; null unsaved. */
alreadyHad: boolean | null
/** True once Apply has run it on the engine. */
applied: boolean
}
// ── one directory row ─────────────────────────────────────────────────────
@ -169,26 +148,20 @@ function DirRow({
entry,
myVote,
voting,
busyOp,
getting,
got,
panelOpen,
onVote,
onGet,
onHash,
onApply,
onSave,
}: {
entry: DirMeta
myVote: number
voting: boolean
/** v7.21 — which of this row's actions is in flight (null = idle). */
busyOp: 'get' | 'hash' | null
getting: boolean
got: GotPanel | null
/** v7.21 — the share-string panel only renders when Hash left it open. */
panelOpen: boolean
onVote: (stars: number) => void
onGet: () => void
onHash: () => void
onApply: () => void
onSave: () => void
}) {
const [copied, setCopied] = useState(false)
@ -209,10 +182,11 @@ function DirRow({
return (
<article className="rounded-lg border border-zinc-800 bg-zinc-900/40 p-3">
{/* v7.20 one header line kept (name + type left, date + actions right);
v7.21 turns the action cluster into a two-button stack: Get/Apply
on top as the primary action, Hash directly under it as the only
way the panel below ever opens. */}
{/* v7.20 -- one header line: name + type on the left, the date and the
Get button clustered at the top right (date immediately left of the
button). The rating row below no longer carries the button, so it
can never wrap Get onto a line of its own -- every card stays two
lines tall. */}
<div className="flex items-center gap-2">
<h4 className="min-w-0 truncate text-sm font-medium text-zinc-100" title={entry.name}>
{entry.name}
@ -227,72 +201,26 @@ function DirRow({
<time className="font-mono text-[11px] text-zinc-500" dateTime={entry.created_at}>
{formatDirDate(entry.created_at)}
</time>
<span className="flex flex-col items-stretch gap-1">
{/* Get / Apply — the primary action (v7.21). Get fetches +
decodes + files the script into root/user created/OVE-Directory
in one click; once saved, the same button becomes Apply. */}
{busyOp === 'get' ? (
<Button
size="sm"
disabled
className="h-7 gap-1 bg-emerald-400 px-2.5 text-[11px] font-semibold text-emerald-950"
>
<Loader2 className="size-3.5 animate-spin" aria-hidden /> Getting…
</Button>
) : got?.savedAs ? (
<Button
size="sm"
onClick={onApply}
disabled={busyOp === 'hash'}
className="h-7 gap-1 bg-emerald-400 px-2.5 text-[11px] font-semibold text-emerald-950 hover:bg-emerald-300"
title={
got.applied
? 'This preset is active -- click to run it again'
: 'Load the preset you just got and make it active'
}
>
{got.applied ? (
<Check className="size-3.5" aria-hidden />
) : (
<Play className="size-3.5" aria-hidden />
)}{' '}
Apply
</Button>
) : (
<Button
size="sm"
onClick={onGet}
disabled={busyOp === 'hash'}
className="h-7 gap-1 bg-emerald-400 px-2.5 text-[11px] font-semibold text-emerald-950 hover:bg-emerald-300"
title={`Download this script into your presets (${DOWNLOAD_FOLDER})`}
>
<Download className="size-3.5" aria-hidden /> Get
</Button>
)}
{/* Hash — toggles the share-string panel (v7.21). Fetches on
first open; after that it just shows/hides what's in hand. */}
{getting ? (
<Button size="sm" disabled className="h-7 gap-1 border-zinc-700 px-2.5 text-[11px]">
<Loader2 className="size-3.5 animate-spin" aria-hidden /> Getting…
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={onHash}
disabled={busyOp !== null}
className={cn(
'h-6 gap-1 border-zinc-700 px-2.5 text-[11px]',
panelOpen &&
'border-emerald-400/40 bg-emerald-400/10 text-emerald-300 hover:bg-emerald-400/20 hover:text-emerald-200',
)}
title={panelOpen ? 'Hide the share-string panel' : "Show this entry's share string (the stored hash)"}
onClick={onGet}
className="h-7 gap-1 border-zinc-700 px-2.5 text-[11px]"
title="Fetch this script -- then Save it to your presets or open it in the editor"
>
{busyOp === 'hash' ? (
<Loader2 className="size-3.5 animate-spin" aria-hidden />
) : (
<Hash className="size-3.5" aria-hidden />
)}{' '}
{busyOp === 'hash' ? 'Fetching…' : 'Hash'}
{got ? <Check className="size-3.5 text-emerald-400" aria-hidden /> : <Download className="size-3.5" aria-hidden />}
{got ? 'Get again' : 'Get'}
</Button>
</span>
)}
</span>
</div>
{/* v7.20.1 -- the whole card body below stays identical; only the
got-panel footer grows the Save button. */}
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5">
<StarRow entry={entry} myVote={myVote} busy={voting} onVote={onVote} />
@ -302,9 +230,7 @@ function DirRow({
</span>
</div>
{/* v7.21 — the panel below opens ONLY through the Hash button; Get
never opens it (its toast + the Apply morph carry the feedback). */}
{got && panelOpen && (
{got && (
<div className="mt-3 space-y-2 border-t border-zinc-800 pt-3">
<p className="text-[11px] leading-relaxed text-zinc-400">
{got.savedAs ? (
@ -318,7 +244,7 @@ function DirRow({
)
) : (
<>
Not saved yet -- hit <span className="text-emerald-300">Get</span> (top right) to file it in{' '}
Fetched -- not saved yet. Hit <span className="text-emerald-300">Save</span> to keep it in{' '}
<span className="font-mono">{DOWNLOAD_FOLDER}</span>, or open it in the editor.
</>
)}
@ -351,16 +277,40 @@ function DirRow({
<FileCode2 className="size-3.5 shrink-0" aria-hidden />
{got.compiles ? 'Decodes and compiles OK' : "Decodes, but doesn't compile (yet)"}
</p>
{/* v7.21 — the footer is Open in editor alone: the old Save
button moved into Get (Get saves, Apply runs). */}
<Button
size="sm"
onClick={openInEditor}
className="h-8 gap-1.5 bg-emerald-400 px-3 text-xs font-semibold text-emerald-950 hover:bg-emerald-300"
title="Load this script into the editor"
>
<FileCode2 className="size-3.5" aria-hidden /> Open in editor
</Button>
{/* v7.20.1 -- Save (left) places the preset into the user's
"user created/OVE-Directory" folder; Open in editor (right)
just loads the script text into the editor. After saving, the
button turns into a static Saved badge (the content-twin check
makes repeat saves a no-op anyway). */}
<span className="flex shrink-0 items-center gap-2">
{got.savedAs ? (
<span
role="status"
title={`This preset is in ${DOWNLOAD_FOLDER}`}
className="flex h-8 items-center gap-1.5 rounded-md border border-emerald-400/30 bg-emerald-400/10 px-3 text-xs font-semibold text-emerald-300"
>
<Check className="size-3.5" aria-hidden /> Saved
</span>
) : (
<Button
size="sm"
variant="outline"
onClick={onSave}
className="h-8 gap-1.5 border-emerald-400/40 bg-emerald-400/10 px-3 text-xs font-semibold text-emerald-300 hover:bg-emerald-400/20 hover:text-emerald-200"
title={`Save this preset into ${DOWNLOAD_FOLDER}`}
>
<Save className="size-3.5" aria-hidden /> Save
</Button>
)}
<Button
size="sm"
onClick={openInEditor}
className="h-8 gap-1.5 bg-emerald-400 px-3 text-xs font-semibold text-emerald-950 hover:bg-emerald-300"
title="Load this script into the editor"
>
<FileCode2 className="size-3.5" aria-hidden /> Open in editor
</Button>
</span>
</div>
</div>
)}
@ -381,12 +331,8 @@ export function DirectoryTab() {
const [order, setOrder] = useState<DirOrder>(DEFAULT_ORDER)
const [myVotes, setMyVotes] = useState<Record<string, number>>({})
const [voting, setVoting] = useState<number | null>(null)
// v7.21 — one busy slot per tab: which entry, which action (get | hash).
// The actions are mutually exclusive (the callbacks bail when set).
const [busy, setBusy] = useState<{ id: number; op: 'get' | 'hash' } | null>(null)
const [getting, setGetting] = useState<number | null>(null)
const [got, setGot] = useState<Record<number, GotPanel>>({})
// v7.21 — the share-string panel is opt-in: only the Hash button opens it.
const [openPanel, setOpenPanel] = useState<Record<number, boolean>>({})
useEffect(() => {
setMyVotes(loadMyVotes())
@ -443,81 +389,30 @@ export function DirectoryTab() {
[voting, toast],
)
// v7.21 — fetch + decode once; shared by Get (which also files it) and
// Hash (which only shows it). Throws on network / decode problems.
const fetchAndDecode = useCallback(async (entry: DirMeta): Promise<GotPanel> => {
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)
return {
hash: full.hash,
script: decoded.script,
compiles: v.ok,
savedAs: null,
presetId: null,
alreadyHad: null,
applied: false,
}
}, [])
// v7.21 — Hash: the only way the share-string panel opens. Toggles when
// the data is already in hand; fetches on first open.
const showHash = useCallback(
async (entry: DirMeta) => {
if (busy) return
if (openPanel[entry.id]) {
setOpenPanel((prev) => ({ ...prev, [entry.id]: false }))
return
}
if (got[entry.id]) {
setOpenPanel((prev) => ({ ...prev, [entry.id]: true }))
return
}
setBusy({ id: entry.id, op: 'hash' })
try {
const panel = await fetchAndDecode(entry)
setGot((prev) => ({ ...prev, [entry.id]: panel }))
setOpenPanel((prev) => ({ ...prev, [entry.id]: true }))
} catch (e) {
toast({
title: 'Hash lookup failed',
description: e instanceof Error ? e.message : 'Hash lookup failed.',
variant: 'destructive',
})
} finally {
setBusy(null)
}
},
[busy, openPanel, got, fetchAndDecode, toast],
)
// v7.21 — Get: fetch + decode + file into root/user created/OVE-Directory
// in one click (the v7.20.1 Save, folded in). No panel opens; once the
// preset is in the tree the button morphs into Apply.
// v7.20.1 -- Get only fetches + decodes + previews. The Save button (per
// row, in the got panel) does the filing into the preset tree on demand.
const get = useCallback(
async (entry: DirMeta) => {
if (busy) return
setBusy({ id: entry.id, op: 'get' })
if (getting !== null) return
setGetting(entry.id)
try {
const existing = got[entry.id]
const panel: GotPanel = existing ?? (await fetchAndDecode(entry))
const res = saveDownloadedPreset(panel.script, entry.name)
if (!res.ok) throw new Error(res.error ?? 'Could not save to this browser.')
const savedAs = res.preset?.name ?? entry.name
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)
setGot((prev) => ({
...prev,
[entry.id]: {
...panel,
savedAs,
presetId: res.preset?.id ?? null,
alreadyHad: Boolean(res.alreadyHad),
applied: false,
hash: full.hash,
script: decoded.script,
compiles: v.ok,
savedAs: null,
alreadyHad: null,
},
}))
toast({
title: res.alreadyHad ? 'Already in your presets' : 'Saved to your presets',
description: `"${savedAs}" in ${DOWNLOAD_FOLDER} -- hit Apply to run it.`,
title: 'Fetched from the directory',
description: `"${entry.name}" -- Save it to your presets or open it in the editor.`,
})
} catch (e) {
toast({
@ -526,40 +421,38 @@ export function DirectoryTab() {
variant: 'destructive',
})
} finally {
setBusy(null)
setGetting(null)
}
},
[busy, got, fetchAndDecode, toast],
[getting, toast],
)
// v7.21 — Apply: load the just-saved preset onto the engine and make it
// the active preset (the Presets tab's Load semantics: the footer's
// SCRIPT: label echoes it and the Script tab's Save targets it), so the
// visitor immediately sees what they just downloaded running.
const apply = useCallback(
// v7.20.1 -- the explicit Save: files the fetched script into this
// browser's preset tree under root/user created/OVE-Directory, deduped
// by exact content (a twin already in the folder is reported, not copied).
// Synchronous (localStorage), so no busy state to track.
const save = useCallback(
(entry: DirMeta) => {
const panel = got[entry.id]
if (!panel?.savedAs || !panel.presetId) return
const result = applyScriptText(panel.script)
if (result.ok) {
const st = useAvaStore.getState()
st.setActivePresetId(panel.presetId)
st.setEditTarget({ id: panel.presetId, name: panel.savedAs, folderPath: DOWNLOAD_FOLDER })
// an explicitly applied preset is the visitor's own choice -- the
// Demo showcase must never override it (same rule as Load, v7.11)
st.markManualChoice()
setGot((prev) => ({ ...prev, [entry.id]: { ...panel, applied: true } }))
if (!panel || panel.savedAs) return
const res = saveDownloadedPreset(panel.script, entry.name)
if (!res.ok) {
toast({
title: 'Now active',
description: `"${panel.savedAs}" is running -- that's the script you just got.`,
})
} else {
toast({
title: 'Apply failed',
description: 'The saved script did not compile -- it cannot run.',
title: 'Save failed',
description: res.error ?? 'Could not save to this browser.',
variant: 'destructive',
})
return
}
const savedAs = res.preset?.name ?? entry.name
setGot((prev) => ({
...prev,
[entry.id]: { ...panel, savedAs, alreadyHad: Boolean(res.alreadyHad) },
}))
toast({
title: res.alreadyHad ? 'Already in your presets' : 'Saved to your presets',
description: `"${savedAs}" in ${DOWNLOAD_FOLDER}.`,
})
},
[got, toast],
)
@ -666,13 +559,11 @@ export function DirectoryTab() {
entry={entry}
myVote={myVotes[String(entry.id)] ?? 0}
voting={voting === entry.id}
busyOp={busy?.id === entry.id ? busy.op : null}
getting={getting === entry.id}
got={got[entry.id] ?? null}
panelOpen={Boolean(openPanel[entry.id])}
onVote={(stars) => void vote(entry, stars)}
onGet={() => void get(entry)}
onHash={() => void showHash(entry)}
onApply={() => apply(entry)}
onSave={() => save(entry)}
/>
))}
<p className="flex items-center justify-center gap-1.5 pt-1 text-center font-mono text-[11px] text-zinc-600">

View file

@ -10,15 +10,6 @@
// is disabled otherwise, and the backend re-checks the hash decodes AND
// compiles before forwarding). Entries are permanent: the directory has no
// update and no delete, so the name deserves a second look.
//
// v7.21 (user request, anti-spam): both text boxes are hard-capped -- the
// name at 30 characters, the type at 10 -- and every keystroke runs through
// the shared dir-text sanitizer (src/lib/dir-text.ts): control characters,
// invisible format/zero-width/bidi characters, and whitespace runs never
// enter the box, so they can never reach the directory's database. The same
// module re-applies the identical rules in the backend proxy before anything
// is forwarded upstream (defense in depth -- a hostile client that skips
// this dialog still never gets past the proxy).
'use client'
@ -37,17 +28,11 @@ import { Input } from '@/components/ui/input'
import { encodeScriptHash } from '@/lib/ava/share-codec'
import { validateScript } from '@/lib/ava/script'
import { uploadDirScript } from '@/lib/ove-directory'
import { DIR_NAME_MAX, DIR_TYPE_MAX, sanitizeDirText, stripUnsafeDirText } from '@/lib/dir-text'
import { useToast } from '@/hooks/use-toast'
import { cn } from '@/lib/utils'
const HASH_PREVIEW = 160
/** First `max` code points of an already-clean string (clamps the derived defaults). */
function clampCp(s: string, max: number): string {
return [...s].slice(0, max).join('')
}
export function UploadToDirectoryDialog({
open,
onOpenChange,
@ -76,12 +61,8 @@ export function UploadToDirectoryDialog({
setWasOpen(true)
const defaultName =
meta?.name && meta.name !== 'Untitled' ? meta.name : encoded.name && encoded.name !== 'OVE' ? encoded.name : 'Untitled'
// v7.21 -- derived defaults run through the same sanitizer as typing,
// then get clamped to the field caps (a 45-char script name: header
// becomes its first 30 clean code points, editable like anything else).
setName(clampCp(sanitizeDirText(defaultName) || 'Untitled', DIR_NAME_MAX))
const defaultType = meta?.mode ? meta.mode.charAt(0).toUpperCase() + meta.mode.slice(1) : 'Lines'
setType(clampCp(sanitizeDirText(defaultType) || 'Lines', DIR_TYPE_MAX))
setName(defaultName)
setType(meta?.mode ? meta.mode.charAt(0).toUpperCase() + meta.mode.slice(1) : 'Lines')
setBusy(false)
setError(null)
} else if (!open && wasOpen) {
@ -95,23 +76,20 @@ export function UploadToDirectoryDialog({
async function publish() {
if (busy) return
// v7.21 -- the shared dir-text rule: strip + collapse + trim, then the
// 30/10 caps. onChange + maxLength already enforce this on the way in;
// this is the second layer (the backend proxy is the third).
const finalName = sanitizeDirText(name)
const finalType = sanitizeDirText(type)
if (!finalName || [...finalName].length > DIR_NAME_MAX) {
setError(`The name must be 1–${DIR_NAME_MAX} characters.`)
const trimmedName = name.trim()
const trimmedType = type.trim()
if (!trimmedName || nameLen > 100) {
setError('The name must be 1–100 characters.')
return
}
if (!finalType || [...finalType].length > DIR_TYPE_MAX) {
setError(`The type must be 1–${DIR_TYPE_MAX} characters (e.g. Lines, Dots, Solid).`)
if (!trimmedType || typeLen > 50) {
setError('The type must be 1–50 characters (e.g. Lines, Dots, Solid).')
return
}
setBusy(true)
setError(null)
try {
const r = await uploadDirScript({ name: finalName, type: finalType, hash: encoded.hash })
const r = await uploadDirScript({ name: trimmedName, type: trimmedType, hash: encoded.hash })
if (r.duplicate) {
toast({
title: 'Already in the directory',
@ -122,7 +100,7 @@ export function UploadToDirectoryDialog({
} else {
toast({
title: 'Published to OVE-Directory',
description: `"${finalName}" (${finalType}) joined the community directory -- it's live in the Directory tab.`,
description: `"${trimmedName}" (${trimmedType}) joined the community directory -- it's live in the Directory tab.`,
})
}
onOpenChange(false)
@ -158,11 +136,11 @@ export function UploadToDirectoryDialog({
id="ove-up-name"
value={name}
onChange={(e) => {
setName(stripUnsafeDirText(e.target.value))
setName(e.target.value)
setError(null)
}}
autoFocus
maxLength={DIR_NAME_MAX}
maxLength={120}
placeholder="Spectrum Bars"
aria-label="Name shown in the directory"
className="h-9 border-zinc-700 bg-zinc-900 font-mono text-xs"
@ -171,9 +149,7 @@ export function UploadToDirectoryDialog({
if (e.key === 'Enter') void publish()
}}
/>
<p className="font-mono text-[11px] text-zinc-500">
1–{DIR_NAME_MAX} characters ({nameLen}/{DIR_NAME_MAX}) -- this is what the directory lists.
</p>
<p className="font-mono text-[11px] text-zinc-500">1–100 characters -- this is what the directory lists.</p>
</div>
<div className="space-y-1.5">
<label htmlFor="ove-up-type" className="text-xs font-medium text-zinc-300">
@ -183,10 +159,10 @@ export function UploadToDirectoryDialog({
id="ove-up-type"
value={type}
onChange={(e) => {
setType(stripUnsafeDirText(e.target.value))
setType(e.target.value)
setError(null)
}}
maxLength={DIR_TYPE_MAX}
maxLength={60}
placeholder="Lines"
aria-label="Type / category"
className="h-9 border-zinc-700 bg-zinc-900 font-mono text-xs"
@ -194,9 +170,7 @@ export function UploadToDirectoryDialog({
if (e.key === 'Enter') void publish()
}}
/>
<p className="font-mono text-[11px] text-zinc-500">
1–{DIR_TYPE_MAX} characters ({typeLen}/{DIR_TYPE_MAX}) -- e.g. Lines, Dots, Solid.
</p>
<p className="font-mono text-[11px] text-zinc-500">1–50 characters -- e.g. Lines, Dots, Solid.</p>
</div>
</div>

View file

@ -1,54 +0,0 @@
// OVE — shared text rules for OVE-Directory uploads (v7.21).
//
// One module, two consumers, identical rules: the "Upload to OVE-Directory"
// dialog keeps its text boxes clean while the visitor types, and the backend
// proxy (src/app/api/ove-directory/scripts/route.ts) re-applies the exact
// same pass before anything is forwarded to the directory server -- so a
// hostile client that skips the dialog entirely still never gets past the
// proxy. The directory server enforces its own copy of these limits (the
// admin's rule, applied manually upstream); this layer keeps this app from
// ever sending something the server would have to reject.
//
// Why these character classes (the "database corruption" defense):
// • C0/C1 controls + DEL -- the byte-level poison (NUL truncates rows in
// C-string storage, ESC/SI/SO scramble terminals, C1 leaks mojibake).
// • Format/zero-width/bidi characters (Cf and friends) -- invisible in
// print but stored verbatim; used for obfuscation and Trojan-source
// abuse, and they corrupt every later text comparison and display.
// • Whitespace runs -- collapsed to one plain space: no NBSP/ideographic-
// space padding spam, no tab/newline smuggling in single-line fields.
// Length is counted in Unicode code points after trimming (the directory's
// own convention), so the cap holds no matter how the visitor encodes it.
/** Maximum length of the directory "name" field, in code points. */
export const DIR_NAME_MAX = 30
/** Maximum length of the directory "type" field, in code points. */
export const DIR_TYPE_MAX = 10
/** C0 controls (incl. NUL) + DEL + C1 controls. */
const CONTROL_RE = /[\u0000-\u001f\u007f-\u009f]/g
/** Soft hyphen, zero-widths, line/paragraph separators, bidi overrides, joiners, BOM. */
const FORMAT_RE = /[\u00ad\u200b-\u200f\u2028\u2029\u202a-\u202e\u2060-\u206f\ufeff]/g
/** Any whitespace run (space, tab, newline, NBSP, ideographic space, …). */
const WS_RE = /\s+/g
/**
* The live-typing pass: strip every unsafe character and collapse whitespace
* runs to a single plain space. Leading/trailing spaces are KEPT -- the
* visitor must be able to type the space inside "Spectrum Bars" -- the final
* trim happens at submit time (sanitizeDirText).
*/
export function stripUnsafeDirText(raw: string): string {
return raw.replace(CONTROL_RE, '').replace(FORMAT_RE, '').replace(WS_RE, ' ')
}
/**
* The full rule: strip + collapse + trim. This is the exact string that gets
* published (dialog) and the exact string the proxy forwards upstream.
*/
export function sanitizeDirText(raw: string): string {
return stripUnsafeDirText(raw).trim()
}

View file

@ -39,24 +39,6 @@ export interface ReleaseNote {
}
export const RELEASE_NOTES: ReleaseNote[] = [
{
version: '7.21.0',
name: 'Get, Hash, Apply',
changes: [
{
kind: 'improved',
text: 'Directory cards now do one thing at a time. Get files the script straight into root/user created/OVE-Directory -- no panel, no extra click -- and then becomes Apply, which loads the freshly downloaded preset onto the engine so I can watch it run. Getting the same script twice still never makes a duplicate.',
},
{
kind: 'added',
text: 'A small Hash button under Get is now the only way the share-string panel opens. It shows the full stored hash, whether the entry compiles, and Open in editor -- and only when I ask for it, so every card stays clean.',
},
{
kind: 'improved',
text: 'Uploads are hardened on both sides of the wire: script names are capped at 30 characters and types at 10, the text boxes refuse control and invisible format characters while I type, and the backend proxy re-applies the exact same rules before anything reaches the directory server.',
},
],
},
{
version: '7.20.1',
name: 'Directory Gets save on demand',

View file

@ -12,50 +12,6 @@
// warns in the console when the two drift apart.
//
// History:
// 7.21 — Directory Get/Hash/Apply split + upload hardening (user request,
// two items). (1) "A more clean interface" for the Directory tab:
// the old all-in-one Get is split into two dedicated actions
// stacked at each card's top right. GET (primary, emerald) now
// does what the v7.20.1 Save button did -- fetch + decode + file
// the script into root/user created/OVE-Directory in one click
// (saveDownloadedPreset, content-deduped; a twin already in the
// folder is reported, never duplicated), with NO panel opening
// (a toast confirms; "hit Apply to run it"); once saved, the same
// button morphs into APPLY, which loads the just-downloaded
// preset onto the engine and makes it the active preset with the
// exact semantics of the Presets tab's Load (applyScriptText +
// setActivePresetId + setEditTarget + markManualChoice), so the
// visitor immediately sees what they just downloaded running;
// repeat clicks re-run it. HASH (small, directly under Get) is now
// the ONLY way the share-string panel opens: first click fetches,
// after that it toggles the panel open/closed (emerald tint while
// open). The panel keeps the stored hash + copy, the decode +
// compile status, and Open in editor; its v7.20.1 Save button is
// gone (Get does the saving); the not-saved intro line now points
// at Get. GotPanel grew presetId (the saved preset's id, needed by
// Apply) and applied (check icon once run); busy became
// {id, op:'get'|'hash'} (mutually exclusive actions); openPanel
// (Record<number, boolean>) decouples "data in hand" from
// "panel visible". (2) Upload hardening ("prevent textbox spam
// and abuse"): new shared module src/lib/dir-text.ts --
// DIR_NAME_MAX 30, DIR_TYPE_MAX 10, stripUnsafeDirText (C0/C1
// control + Cf format/zero-width/bidi characters stripped,
// whitespace runs collapsed to one plain space; no trim, so
// typing "Spectrum Bars" keeps its space) and sanitizeDirText
// (full rule: strip + collapse + trim). The dialog's boxes cap at
// the constants (maxLength + live stripUnsafeDirText on every
// keystroke, so dangerous characters never enter), show live
// n/30 + n/10 counters, and clamp the derived defaults (script
// name: header / share-string label; mode) through the sanitizer
// and the caps; publish() re-checks the sanitized values (1-30 /
// 1-10) before sending. The backend proxy (scripts/route.ts POST)
// applies the identical rules: it sanitizes name/type, enforces
// the 30/10 caps in code points, and forwards the SANITIZED
// strings upstream -- defense in depth (dialog, publish, proxy;
// the admin's own server-side copy is the fourth layer, applied
// manually per the operator's note). Version bump with matching
// release note; EMBEDDED_SEED_VERSION untouched (7.19.0 -- the
// library did not change).
// 7.20.1 — Directory Get save-on-demand (user request): clicking Get on a
// directory entry no longer files the script into the preset tree
// automatically -- the got panel now carries an explicit Save
@ -734,4 +690,4 @@
export const APP_NAME = 'Open Visualization Engine'
export const APP_SHORT = 'OVE'
export const APP_VERSION = '7.21.0'
export const APP_VERSION = '7.20.1'

View file

@ -2877,50 +2877,3 @@ Delivery: committed and pushed to origin/zai-dev with the operator's token from
Stage Summary:
- v7.20.1 shipped: Directory Gets are save-on-demand. The Get panel's new Save button (left of Open in editor) places the fetched script into root/user created/OVE-Directory on click, deduped by content, with a Saved badge confirming it; Get itself no longer auto-files anything. Verified end-to-end in the browser and pushed to the remote.
---
Task ID: 59
Agent: main (Z.ai Code)
Task: v7.21.0 (user request, three items): (1) a "Hash" button below "Get" that shows the share-string panel (the same functionality the old Get-click had); (2) "Get" now does what the v7.20.1 Save button did -- it files the script into the user's "user created/OVE-Directory" pathway, then the Get button changes into an "Apply" button that loads the newly downloaded preset and makes it active so the visitor sees what they just downloaded; (3) safe checks on the "Upload to OVE-Directory" dialog -- Script Name max 30 characters, Type max 10, with the same string-length restrictions applied to the text boxes AND the backend upload mechanism, plus sanitization of any characters that could corrupt the directory's database (the operator will mirror the limits on the OVE-Directory server manually).
Directory tab rework (src/components/ava/directory-tab.tsx):
- Card top-right cluster is now a two-button stack: Get (primary, emerald, Download icon) on top, Hash (small outline, lucide Hash # icon) directly under it, date still immediately left of the stack. VLM screenshot review confirmed the layout renders as specified.
- GET (new meaning): fetch + decode + saveDownloadedPreset in ONE click -- no panel opens, toast "Saved to your presets / Already in your presets" with description '"Name" in root/user created/OVE-Directory -- hit Apply to run it.' Content-dedup held across a reload (twin path): second Get on the same entry reported "Already in your presets" and the folder still held exactly 1 copy.
- Get morphs into APPLY once savedAs is set. Apply runs the Presets-tab Load semantics exactly: applyScriptText (editor text + engine setScript) + setActivePresetId + setEditTarget({id, name, folderPath}) + markManualChoice; toast "Now active". Applied state swaps the Play icon for a Check with title "This preset is active -- click to run it again"; repeat clicks re-run. E2E: footer SCRIPT: label flipped to the preset's name, the Presets tree showed the preset with the single emerald running flag.
- HASH: the ONLY way the panel opens now. First click fetches (spinner "Fetching…"); further clicks toggle open/closed (emerald tint while open). Panel: stored hash + Copy, "Decodes and compiles OK", saved-status intro (three wordings verified: not-saved pointing at Get / "Saved to your presets as X in root/user created/OVE-Directory." / "You already had this exact script -- no duplicate was made."), and Open in editor (unchanged: loads script text into the editor + switches to the Script tab). The v7.20.1 panel Save button is GONE (Get does the saving); footer buttons verified as exactly [Copy, Open in editor].
- State: GotPanel grew presetId (needed by Apply) and applied; busy became {id, op:'get'|'hash'} (mutually exclusive); openPanel Record decouples "data in hand" from "panel visible". Note: Directory tab unmounts on tab switch, so per-card state resets on return -- safe by design (the dedupe makes re-Get a no-op that re-morphs to Apply); the same was true of the old got panel.
Upload hardening (new src/lib/dir-text.ts + upload-dialog.tsx + scripts/route.ts):
- Shared module: DIR_NAME_MAX=30, DIR_TYPE_MAX=10, stripUnsafeDirText (C0/C1/DEL control chars, Cf format/zero-width/bidi chars stripped; whitespace runs incl. NBSP/ideographic collapsed to one plain space; NO trim so "Spectrum Bars" typing works), sanitizeDirText (full rule: strip+collapse+trim).
- Dialog: maxLength 30/10 on the boxes; onChange runs stripUnsafeDirText on every keystroke (dangerous characters never enter the box -- E2E: filling "X<ZWSP>Y__Z<NBSP>W" left "X Y Z W", code points [88,89,32,90,32,87]); live counters "1-30 characters (12/30) -- ..." / "1-10 characters (5/10) -- ..."; 40-char paste kept 30, 11-char type kept 10; derived defaults (script name header / share-string label / mode) now run through the sanitizer and are clamped to the caps; publish() re-checks the sanitized values (1-30 / 1-10) before sending -- dialog, publish, proxy, then the operator's manual server-side copy: four layers.
- Backend POST: sanitizeDirText before the length checks; 30/10 caps in Unicode code points; the SANITIZED strings are what gets forwarded upstream. Curl negative tests (all rejected locally in 4-15ms, never forwarded -- an upstream hop is ~600ms): name 46 chars -> 400 "name must be 1-30 characters (control and format characters are stripped, whitespace collapsed)."; type 13 chars -> same for type; name of only spaces+BOM (U+FEFF) sanitizes to empty -> 400 name; raw-NUL body -> 415/400 INVALID_JSON. No hostile payload can pass the proxy, and no positive-path publish was attempted (directory entries are permanent -- no test junk upstream).
Version: version.ts APP_VERSION 7.20.1 -> 7.21.0 + full history entry; release-notes.ts 7.21.0 "Get, Hash, Apply" (improved + added + improved, first person, plain double minus); EMBEDDED_SEED_VERSION untouched at 7.19.0 (library unchanged, no re-seed).
BROWSER E2E (agent-browser, ALL PASS): title "OVE -- Open Visualization Engine (v7.21.0)"; cards render Get-over-Hash stacked (VLM-verified); Hash opens panel / second Hash closes it (PANEL-CLOSED); panel intro + full share string + "Decodes and compiles OK" + [Copy, Open in editor] with NO Save button; Get on fresh entry -> localStorage ove.userPresets.v1 gains the preset under folderPath root/user created/OVE-Directory (author "Tuggummi / OVE"), button morphs to Apply, panel NOT opened; reload + Get again -> "Already in your presets" toast, storage still exactly 1 preset; Hash panel on twin -> "You already had this exact script -- no duplicate was made."; Apply -> toast "Now active -- 'Aqua-Burst' is running -- that's the script you just got.", footer SCRIPT: Aqua-Burst, Presets tree user created -> OVE-Directory(2) with one emerald running flag; Open in editor -> Script tab selected, editor 1,125 chars; upload dialog: prefilled clamped defaults, counters (12/30, 5/10), 40->30 and 11->10 truncation, ZWSP/NBSP/double-space sanitize-on-type, Cancel (nothing published); What's New: 7.21.0 "Get, Hash, Apply" CURRENT; no console/page errors; footer flush (docH 577 = innerH 577, scrollWidth 1280 = innerWidth 1280, no horizontal scroll); bun run lint clean; dev.log: all directory GETs 200, the five hostile POSTs 400 in 4-15ms (local rejection).
Delivery: committed locally. Push needs a fresh Forgejo token -- the Task 57/58 one-off header was never stored anywhere by convention (no ~/.git-credentials, no helper, no env token in this sandbox), so origin/zai-dev advances only when the operator supplies one again or pushes from an authorized lane.
Stage Summary:
- v7.21.0 shipped: the Directory tab is now a clean three-action interface -- Get files into root/user created/OVE-Directory in one click and morphs into Apply (load + make active), Hash is the only way the share-string panel appears, and uploads are capped at 30/10 with a four-layer sanitization chain (text box, publish, backend proxy, the operator's own server-side copy). Verified end-to-end in the browser; committed locally, one token away from origin/zai-dev.
---
Task ID: 60
Agent: main (Z.ai Code)
Task: push unblocked (operator message): "Use the token [40-hex Forgejo token -- value intentionally never recorded here, same convention as Task 57 and the directory API key]." Deliver Task 59's committed v7.21.0 (d1237f8) to origin/zai-dev.
Token delivery:
- Verified read-only before any mutation, Task 57 pattern: GET /api/v1/user returns a SCOPE error ("token does not have at least one of required scope(s): [read:user]"), i.e. valid but not 401; GET /api/v1/repos/gitzcreations/openvisengine returns the full repo record (id 1, owner gitzcreations) -- repository access confirmed.
- Push mechanics unchanged: token passed ONLY as a per-invocation `git -c http.extraheader="Authorization: token ..."` (one-off config, never written to .git/config -- post-push check returned nothing and a grep of .git/config found zero occurrences; never echoed into any file, log, or worklog). Remote URL stays credential-free.
- Single-ref push main:zai-dev only (Task 57 already established main is branch-protected; no atomic decline this time): SUCCESS -- 1afece5..d1237f8 main -> zai-dev. Forgejo's response re-offers the compare/main...zai-dev PR link, so the merge lane to main remains open for the operator.
What landed on origin/zai-dev (1 commit, the exact Task 59 set):
- d1237f8 v7.21.0 (Directory Get/Hash/Apply split + upload hardening), which carries in its tree the full Tasks 58+59 worklog entries -- origin/zai-dev now holds the complete record through Task 59.
- Local zai-dev branch reset to main (d1237f8), matching the established sync convention.
Pre-push verification (fresh session, dev server already healthy): lint clean; agent-browser spot-check ALL PASS on the running v7.21.0 build -- title v7.21.0, footer flush (docH 577 = innerH 577), no horizontal scroll; Directory cards render the Get-over-Hash stack; Get on a twin entry reported the dedup path and morphed to Apply with the preset filed under root/user created/OVE-Directory (storage verified: Aqua-Burst in that exact folderPath); Apply made it active (footer SCRIPT: Aqua-Burst); Hash opened the share-string panel ("You already had this exact script -- no duplicate was made." + "Decodes and compiles OK" + [Copy, Open in editor], NO Save button); upload dialog capped a 46-char fill at 30 and a 13-char type at 10 with live counters (30/30, 10/10) and keystroke-level whitespace collapse (" B C" -> " B C"); Cancel published nothing (directory stays junk-free); zero console/page errors.
Stage Summary:
- PUSH DELIVERED: origin/zai-dev now at d1237f8 (v7.21.0 on top of v7.20.1), authenticated with the operator-supplied scoped token used once as a throwaway HTTP header and never stored anywhere. origin/main remains protected at bbbee3c -- merging zai-dev into main (PR or local merge + push from an authorized lane) is the one remaining operator-side step if they want main current.
- This Task 60 commit rides on top and is pushed in the same pass, so origin/zai-dev carries the complete record including this delivery itself.
Task 60 addendum (post-push fetch): the push-pass fetch also updated origin/main 65d4f91 -> 1afece5 -- the operator merged v7.20.1 into main themselves (an authorized-lane action; our scoped token cannot touch protected main, as Task 57 established). Correction to the Stage Summary above: origin/main is NOT at bbbee3c anymore; it is at 1afece5 (v7.20.1). The one remaining operator-side step is therefore smaller than stated: main only needs v7.21.0 (d1237f8) + this Task 60 record (the PR link Forgejo re-offers covers exactly that). No action taken from this side beyond this note; local main stays ahead 2 of origin/main by design until that merge happens.