Compare commits

...

21 commits

Author SHA1 Message Date
509839dba9 chore(embedded): cut v0.0.27-beta
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 8s
2026-06-05 07:41:23 -05:00
382645b2d2 feat(browse): Export context-menu submenu (folder→.zip, file→other formats)
Add an "Export" item to the row context menu with a submenu:
- a folder offers ".zip" (reuses download.downloadFolder; works offline + server)
- an md/docx/html file offers the OTHER two formats, each triggering a
  server-side conversion download via the new download.exportFile (builds the
  sibling-extension URL and lets the browser pull the converted bytes). File
  conversion is server-only, so it's hidden in offline (FS) mode; a zip is
  already an archive and gets no Export.

menu-model's toMenuItem now passes a descriptor's `items` through as a submenu
(resolved against the captured browse ctx) instead of only emitting action rows.

Verified: 11/11 browse Playwright specs pass (incl. menu/context + Download ZIP);
a logic harness confirms the per-type submenu contents and that clicks route to
download.exportFile / downloadFolder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:05:51 -05:00
16d88010a6 feat(server): full md/docx/html conversion matrix + base64 image inlining
Generalize the conversion engine from markdown-source-only to a (from→to)
dispatcher, convert.Convert, supporting:

  md   → docx | html | pdf
  docx → md   | html
  html → md   | docx

- convertToMarkdown (docx→md, html→md): pandoc -t gfm --wrap=none with an
  embedded inline-media.lua filter that base64-inlines mediabag images as data:
  URIs, so the output .md is self-contained (markdown has no --embed-resources).
- convertToHTML now takes a source format: docx→html reuses the doctype template
  and --embed-resources base64-inlines the docx's images automatically.
- convertToDocx takes a source format: html→docx embeds images natively.
- ToDocx/ToHTML/ToPDF are kept as the md-source entry points, delegating to the
  shared internals. writeScratchFiles generalizes the old template-set writer.

Routing (converthandler.go):
- RecognizeVirtualConvert maps any target ext {md,docx,html,pdf} to the first
  existing real sibling source by precedence (md←docx,html; docx←md,html;
  html←md,docx; pdf←md). Real files still win (dispatcher stats first).
- ServeConverted accepts md; buildAndStore dispatches on (ext(src), format) via
  convert.Convert; purgeConverted clears all derived siblings on any write.

Tests: per-direction command-shape assertions (convert) + recognizer matrix and
precedence (handler). Verified end-to-end with real pandoc (docx→md/html,
html→md/docx, base64 images). Full ./... suite green.

PDF stays markdown-only for now (docx/html→pdf would need a two-stage hop).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:02:11 -05:00
894610d59e feat(server): admin folder move + recursive delete (file API)
Directory MOVE and DELETE were hard-rejected with 409 "not supported" for
everyone, so a folder could never be renamed, relocated, or removed — even
in admin mode. The browse menu offered Rename/Delete on folder rows, but
they failed at the server. This is exactly the restructuring admin mode
exists for (e.g. doing a layout migration by hand instead of a script).

serveFileMove: a directory source is now allowed when the principal is an
active admin (zddc.IsSubtreeAdmin) over BOTH the source subtree and the
destination's parent — a root admin covers all; a subtree admin within
scope. os.Rename relocates the whole subtree (bypassing the per-file
WORM/ACL gates on its contents, which is the point), and a move into the
directory's own descendant is refused (409). File moves are unchanged.

serveFileDelete: a directory target is now allowed for an active admin over
that subtree and removes it recursively (os.RemoveAll). Non-admins get 403.

Both relax the trailing-slash guard (the browse client sends folder ops with
a trailing slash) and decide file-vs-directory by stat. Directory ops skip
the If-Match precondition (a directory carries no ETag). Recursive deletes
are audited with a "(recursive)" marker. Non-admin directory ops now return
403 rather than the old blanket 409.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:01:51 -05:00
c7ab633653 docs(agents): document the named-template + numbering + .zddc.d cascade
Update the server-side conversion section to describe the doctype templates
(report/letter/specification + partials), the front-matter template:/numbering:
selection, the .zddc.d/templates/ override cascade, and the known cache-on-
template-change limitation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:19:54 -05:00
1d816ae43a feat(server): multi-template MD→HTML with .zddc.d/templates cascade
The convert engine renders markdown→HTML/PDF through named doctype templates
selected by the document's `template:` front matter, with per-project/per-party
overrides.

convert package:
- embed.go now embeds the whole templates/ dir (all: prefix so _-prefixed
  partials are included) as an embed.FS; drop the single viewer-template.html +
  custom.css embeds. New TemplateSet type + DefaultTemplateSet(name) returning the
  chosen doctype + its partials.
- ToHTML/ToPDF take a TemplateSet; writeTemplateSetToScratch materialises the
  template + partials flat into the per-call scratch dir (pandoc resolves
  $partial()$ from the template's own directory).

handler:
- converttemplate.go: templateNameFromFrontMatter (YAML front-matter scan,
  sanitized to a bare basename) + resolveTemplateSet, which overlays
  <level>/.zddc.d/templates/<name>.html overrides onto the embedded defaults,
  walking docDir→fsRoot so a party dir beats the project-global dir. An override
  may replace a doctype, a partial, or add a brand-new doctype.
- buildAndStore threads fsRoot + source into the html/pdf paths.

build: pandoc/templates/ is the single source of truth; shared/build-lib.sh
sync_pandoc_templates mirrors it into the embed dir on every build (cmp-guarded,
stale-pruning). convert.TestEmbeddedTemplatesMatchSource fails on drift.

Tests: drift + DefaultTemplateSet (convert); front-matter parse + cascade
override precedence (handler). Full ./... suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:18:40 -05:00
c765fe9183 feat(pandoc): named doctype templates + front-matter numbering toggle
Replace the single always-numbered viewer-template.html with a templates/
directory of named doctype templates that share partials:

- templates/_head.html  — <head> + all CSS (numbering CSS now scoped behind a
  body.numbered class instead of being applied unconditionally)
- templates/_doc.html   — shared TOC-sidebar body (report/specification)
- templates/_scripts.html — shared JS
- templates/{report,specification}.html — TOC-layout doctypes
- templates/letter.html — single-column letterhead, no TOC

A document selects its template with `template: <name>` in YAML front matter
(default report) and turns on legal numbering with `numbering: true` (default
off). Pandoc passes both fields straight from the front matter — the numbering
toggle needs no converter code. Retire custom.css (folded into _head.html,
gated) and the old viewer-template.html.

CLI: convert md→html resolves templates/<name>.html (name from front matter,
sanitized, default report); convert-diff uses templates/report.html and no
longer passes --css=custom.css. README updated.

Server (zddc/internal/convert) still uses its own embedded copy and is
unchanged here; it migrates to this templates/ dir in the next commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:07:36 -05:00
c59bea183e feat(server): honor ?admin=true|false elevation on every endpoint
shared/elevation.js toggles admin mode via the ?admin= URL param, but it's
client-side JS — it only runs on HTML tool pages, where it sets the sticky
zddc-elevate cookie. A raw endpoint (a directory's JSON listing, zip
browsing at /<…>.zip/, the file API) loads no JS, so ?admin=true was inert
there and such requests stayed un-elevated.

ACLMiddleware now reads the same ?admin= toggle directly: true|1|on|yes
elevates the request, false|0|off|no drops it (overriding the cookie for
that request). This is per-request only — the server doesn't set/clear the
cookie; elevation.js still owns sticky persistence on pages. Elevation
grants powers only to a caller who already holds admin authority (every
admin call site re-checks via IsActiveAdmin), so a non-admin's ?admin=true
sets the forensic flag but confers nothing.

Makes e.g. GET /.zddc.zip/?admin=true work for an admin without first
arming the cookie on a page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:13:30 -05:00
9513ea3a07 chore(pandoc): remove cruft from convert tools
- convert: drop --standalone from the DOCX→MD pandoc call. It emitted its
  own YAML title block, which collided with the frontmatter the script
  prepends (a ZDDC docx with title metadata produced two stacked --- blocks).
  Matches the HTML→MD path, which already omits it.
- index.sh: remove the no-op cleanup()/trap EXIT — unsetting a global as the
  process exits does nothing; the per-folder `unset latest_files` is the real
  reset.
- README: trim the generic Advanced Usage / Performance / "Perfect for"
  filler, and fix the Troubleshooting note that wrongly pointed at a
  zddc.conf template key (template is -T / auto-discovery).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:09:16 -05:00
d10cd23076 fix(pandoc): correctness, robustness & doc cleanup of convert tools
Audit-driven cleanup of the standalone pandoc/ CLI tools (no changes to
the server's own zddc/internal/convert engine).

convert:
- DOCX→MD now reads lowercase client/project from zddc.conf (was $CLIENT/
  $PROJECT, always empty)
- ZDDC filename parsing via a shared parse_zddc_filename helper that
  extracts each field with its own backref, so a '|' in the title no
  longer truncates it (was cut -d'|')
- drop duplicate --section-divs and no-op --id-prefix=

convert-diff:
- replace hardcoded "(AR 28088)" in the diff header with the configured
  $project_number (omitted when unset)
- only pass --template when one was found (empty --template= errors out)
- drop the false "Loading ZDDC configuration" log and the sed quote-escape
  that leaked backslashes into custom_header
- remove dead REV_A/REV_B and rev*_date extraction; fix usage typo;
  pin LC_TIME=C on date calls

index.sh:
- relative_path passes paths to python via argv (no -c interpolation) and
  uses realpath --relative-to as the fallback instead of an absolute path
- escape '|' in title/status before emitting the markdown table row

README:
- rewrite the stale server-side section to match the real binary+bubblewrap
  design and flags/defaults (was a non-existent podman/docker/image design)
- fix the invalid zddc.conf example (sourced shell, four real vars) and the
  understated input-format list

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:53:26 -05:00
613092b30e feat(server): elevated admins can browse the .zddc.zip config bundle
The site-root .zddc.zip bundle was existence-hidden (404) over HTTP for
everyone. Now an active (elevated) admin over its directory can browse it
in the file tree like any other zip: GET /.zddc.zip/ lists members, GET
/.zddc.zip/<member> extracts one, and a bare GET downloads it. Everyone
else — including the same admin un-elevated — still gets 404 for every URL
shape, which additionally closes a prior by-name member read (the old gate
only 404'd the bundle base, so /.zddc.zip/<member> leaked to any reader of
the root).

The dispatch gate now keys off the bundle segment anywhere in the path and
requires activeAdminForBundle (mirrors ActiveAdminForSidecar). The listing
(fs.ListDirectory) surfaces the .zddc.d reserve and .zddc.zip bundle only to
an active admin, so non-admins don't even see the names under ?hidden=1.

Client needs no change: splitExtension('.zddc.zip').extension == 'zip', so
browse already renders it as a navigable archive (tree.js isZip). Internal
apps.Bundle FS resolution never goes through dispatch, so it's unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:39:57 -05:00
ee371c5bb2 feat(server): views.file → form editor on browser navigation
A no-slash GET to a data file, in a directory whose cascade declares
views.file = {tool: form}, now serves the form editor bound to that file
(render-edit; POST goes to the canonical <file>.yaml.html update URL).

Gated on Accept: text/html so it only fires for browser NAVIGATIONS — the
tables client reads rows via fetch() (Accept: */*) and gets raw YAML
unchanged, and ?raw is an explicit bytes escape hatch. A directory without
views.file keeps serving raw bytes. Opt-in per subtree; presentation only
(ACL/WORM stay orthogonal and server-enforced).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:31:24 -05:00
3e7aa34e49 feat(scripts): migrate relocates table.yaml/form.yaml configs into .zddc.d/
Adds a tree-wide config-relocation pass to migrate-toplevel-peers.sh: after
the peer move, every <dir>/table.yaml and <dir>/form.yaml is moved into
<dir>/.zddc.d/ (where the server now resolves specs from; the legacy root
still works, so it's a declutter). find|while-read handles directory names
with spaces; skips files already under .zddc.d/ and existing destinations;
honored by --dry-run. Idempotent (verified: dry-run → real → re-run skips).

(No server code writes these configs — they're operator/test-created — so
there are no writers to repoint.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:24:03 -05:00
03fa366814 feat(server): table/form specs resolve from .zddc.d/ + server-inject the table spec
The supporting config files (table.yaml, form.yaml) can now live in the
admin-gated, hidden `<dir>/.zddc.d/` reserve instead of the directory root —
the `.zddc`-declares / `.zddc.d/`-carries split. Backward-compatible: the
legacy root location still resolves (preferred order: .zddc.d/ → root →
embedded default).

Because `.zddc.d/` is non-fetchable over HTTP for non-admins, the spec is
resolved server-side and INJECTED:
- handler: LoadViewSpec(dir, name) resolves .zddc.d/ → root → embedded
  (classifyDefaultSpec is now location-agnostic — strips a `.zddc.d` segment).
- ServeTable injects the parsed table spec + row schema into the existing
  #table-context as {spec, rowSchema}; RecognizeTableRequest also recognizes a
  spec under .zddc.d/.
- formhandler loadFormSpec + specEligible prefer .zddc.d/form.yaml (forms
  already inject #form-context, so server-only).
- client (tables/js/context.js): walkServer uses the injected spec/rowSchema
  when present (server mode) and still walks the directory for ROW files; FS-
  Access mode reads .zddc.d/<name> (then legacy root) via readYamlFirst. load()
  passes the injected context through. Regenerated the embedded tables.html.

go build/vet/test ./... green; all 40 tables Playwright specs pass; the
ServeTable test now asserts the injected spec.

Remaining (next): file→form URL shape, retiring the recognizers in favour of
ServeView/views:, defaults.zddc.yaml views declaration, writers→.zddc.d/, and
the migration script.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:20:55 -05:00
45af24b2b1 feat(server): route no-slash directory URLs through views.dir (cascade spine)
serveSpecializedNoSlash now consults zddc.ViewAt(dir, "dir"): an explicit
`views.dir` in the cascade overrides the default_tool-derived app for the
no-slash directory URL. default_tool stays the sugar fallback (ViewAt returns
it when no views.dir is declared), so existing deployments are unaffected —
purely additive.

Also fixes the mergeOverlay trap (per the .zddc-policy-key checklist): added
Views to walker.go's per-level merge so views: survives cascade resolution at
default-driven paths (without it the key silently no-ops). Verified by a
defaults-path unit test (TestViewAt): default_tool/dir_tool surface via ViewAt;
an explicit views: entry overrides default_tool and declares a file shape.

go build + go test ./... all green. (Next: ServeView config injection from
.zddc.d/, the file→form shape, recognizer retirement, client + ./build.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:01:31 -05:00
760cba96c4 feat(server): add declarative views: cascade key + ViewAt resolver (schema)
Foundation for the generalized view model: `.zddc` declares, per URL shape,
which tool renders and where its supporting config lives.

- ZddcFile.Views map[string]ViewSpec{Tool, Config}; shapes "dir" / "dir_slash"
  / "file". config is a filename resolved under <dir>/.zddc.d/. Pure data — no
  behaviour; presentation/routing only (ACL/WORM/admin stay server-enforced).
- lookups.ViewAt(root, dir, shape): cascade leaf→root first-match, with
  default_tool / dir_tool honored as sugar for dir / dir_slash (semantics
  unchanged). No merged map — resolved per-shape like DefaultToolAt.
- cascade summary, isZero/is-empty checks, and validation (tool ∈ AppNames;
  config a path-bounded plain filename). Client .zddc validator (preview-yaml.js)
  gains a `views` key + `viewmap` case.

Additive only — nothing consumes Views yet (the generic resolver + dispatch
wiring + recognizer retirement follow). go build + zddc/handler tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:53:53 -05:00
4e86b1533d docs(server): rewrite README apps section for the local-only override model
Brings zddc/README.md in line with the apps-fetch removal:
- "Apps: virtual tool HTMLs" override section now describes the 3-tier local
  resolution (on-disk file → <root>/.zddc.zip member → embedded); drops the
  URL/channel/version spec forms, the _app/ cache, signatures, and the apps:
  example.
- Remove the ZDDC_APPS_PUBKEY env-var row.
- Security invariants #4 (in two places) reframed: a tool HTML on disk or in
  the site .zddc.zip is a full UI mount (write access = UI-mounting authority;
  <root>/.zddc.zip = site-wide), with no fetch and nothing to sign.
- Federal gap analysis: SI-7 no longer cites apps URL fetches; delete the
  whole "Code-signed apps: URL fetches (NIST SI-7)" subsection (feature gone).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:06:49 -05:00
4eeb25c0ef feat(server): local-only tool-HTML override; remove apps URL/version fetching
Replaces the URL/channel/version-fetching tool-HTML system with a
local-only override model. No network fetch, no Ed25519 signatures, no
channels/versions, no `apps:` .zddc key.

Tool HTML resolves, in precedence:
1. a real file on disk at the path (operator drops browse.html / archive.html
   / a new mytool.html) — served by the existing static handler;
2. an `<app>.html` member of the site-root <ZDDC_ROOT>/.zddc.zip bundle, read
   server-side via internal/zipfs (local file, no fetch, no signature;
   re-stat'd each request for free hot-reload);
3. the embedded //go:embed default.

Remove (complete unwire):
- internal/apps/{fetch,verify,cache,singleflight}.go and their tests; the
  spec-parsing/cascade machinery in apps.go (ParseSpec/Resolve/PreviewLine/
  SpecComponents/appsState, DefaultUpstream*/DefaultChannel/CacheDirName).
- --apps-pubkey / ZDDC_APPS_PUBKEY flag+env+Config field; the setupApps
  cache/fetcher/pubkey wiring (now just apps.NewServer(root, version)).
- the `apps:` / `apps_pubkey:` .zddc keys: ZddcFile.Apps/AppsPubKey, the
  walker merges, cascade-summary adds, validate.go apps validation
  (ValidateAppSourceSpec/validateURLSpec/validateChannelOrVersion/
  AppsDefaultKey/IsValidAppsKey), and the isZero/is-empty refs. A stale
  apps:/apps_pubkey: in an existing .zddc is now silently ignored
  (back-compat), not a parse error. Client .zddc validator (preview-yaml.js)
  drops the apps/apps_pubkey keys + appsmap case.

Add:
- internal/apps/bundle.go — nil-safe Bundle over <root>/.zddc.zip with
  stat-based hot-reload, size caps, corrupt-zip tolerance.
- handler.go: Server{Bundle}, resolveBytes (bundle→embedded), simplified
  Serve; X-ZDDC-Source = bundle:<m> / embedded:<app>@<ver>.
- dispatch: GET /.zddc.zip is 404 for everyone (config, not content); the
  server reads members from the filesystem internally.

Tests: new bundle_test.go (member hit/absent/no-file/hot-reload/corrupt);
handler_test.go rewritten for bundle-overrides-embedded, absent-member→
embedded, unknown-tool 503, conditional-GET for both sources; dispatch test
covers bundle override + /.zddc.zip 404 + availability rules. go build/vet/
test ./... all green; gofmt clean. Docs (AGENTS.md, ARCHITECTURE.md) updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 08:59:28 -05:00
198d691518 refine(browse): leaner menu — fold Navigate-into into Open, hide unpermitted actions, URL link in info box
Menu refinements per review:
- "Open" now navigates into a folder (rescope); the separate "Navigate into"
  item is removed. Zip → expand inline (can't navigate in); file → preview.
  Inline expand stays on single-click / chevron / arrow keys.
- "New markdown file" → "New file".
- New folder / New file / Rename / Delete are now HIDDEN when the user lacks
  the create/write/delete capability (folded into appliesTo) instead of shown
  greyed — a guest gets a lean menu; users who can still see them. New
  folder/file also remain on the toolbar.
- "Edit access rules…" is shown only when the user can actually edit them
  (admin verb 'a' or subtree/site admin) — hidden otherwise, not greyed.
- Removed "Copy path" / "Copy name" — the info box (hovercard) carries the
  name and a clickable URL now.

Info box (hovercard): dropped the on-disk "Path" row; the "URL" is rendered as
a clickable hyperlink (via the existing kvLink helper) — the shareable
reference, openable or right-click-to-copy.

Tests updated: file row omits New folder/file + Copy + Navigate; permission-
gated Rename/Delete are HIDDEN for a read-only server node and PRESENT for a
read/write/delete node (pure menuModel unit). All browse+conflict+diff green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:59:21 -05:00
e2179d167b feat(browse): capability/role/tier-driven, context-correct menu system
Reworks the browse menu/tree interaction into a declarative, contextually
honest model and moves view settings onto a toolbar — the menu is the UI to
the system, so it should be familiar, inviting, and only ever offer what
applies.

New declarative menu model (browse/js/menu-model.js):
- Every action is one descriptor with a TYPE predicate (appliesTo) and a
  CAPABILITY predicate (enabled)+tooltip. Row/pane menus are projections over
  it; separators are derived from group changes. Designed data-shaped so a
  future server-sourced manifest (zddc.zip) can supply/extend it.
- Hybrid visibility: type-inapplicable actions are OMITTED (New folder on a
  file, Expand on a file); permission/role/tier-gated actions are SHOWN
  DISABLED with a reason — so a lower tier sees what a higher role unlocks.
- Roles are NOT hardcoded: ordinary actions gate on the verbs the server
  returns (node.verbs / path_verbs), so any operator-defined role works. Only
  the two intrinsically-special tiers are recognised by name — site admin
  (is_super_admin) and project/subtree admin (path_is_admin), surfaced as the
  "Edit access rules…" item; both come from the existing /.profile/access.
- The headline fix: New folder / New markdown file no longer appear on file
  rows (they target a folder or the current dir).

events.js: deletes the ~350-line inline buildTreeRowMenu/buildPaneMenu/
SORT_BY_ITEMS; opens menus via menuModel projections through one openRowMenuFor
/openPaneMenu path shared by right-click, the hover kebab, and the keyboard
menu key (ContextMenu / Shift+F10). Injects action impls via menuModel.configure
to avoid a circular dep. Prefetches the scope /.profile/access (memoised) on
load/rescope/refresh/popstate so menus never fetch at open time.

Discoverability + a11y: a per-row ⋯ kebab (tree.js + new icon-ellipsis sprite,
revealed on hover/selection/focus) opens the same menu; keyboard menu key
supported.

Toolbar: Sort + Show-hidden moved OUT of per-row right-click menus into the
tree-pane toolbar, plus New folder / New file buttons (act on the current dir,
greyed with a reason when create access is lacking). Help copy updated.

Icons: dropped the 3 stray emoji from menu items (consistent, VS Code/Finder
style); only new sprite is the kebab's icon-ellipsis.

Tests: +5 browse specs (file row omits New-folder; folder row shows it; a
read-only server node greys Rename with a "write access" tooltip via a pure
menuModel unit; toolbar Sort/Show-hidden drive state + New buttons present;
kebab and Shift+F10 both open the menu). All 23 browse+conflict+diff green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:21:02 -05:00
8edbb81958 feat(browse): lost-update protection for editors + shared conflict dialog
Two users editing the same file online could silently clobber each other:
the editor's save did a bare PUT with no precondition, even though the master
already enforces optimistic concurrency (fileapi.go checkIfMatch → 412). Now
the editor sends a precondition and surfaces a conflict UI instead of
overwriting.

- util.js: saveFile(node, content, contentType, opts) sends `If-Match: <etag>`
  (or `If-Unmodified-Since` fallback) unless opts.force; returns {etag} from
  the PUT response (so save→edit→save adopts the new version and doesn't
  false-conflict); throws ConflictError (.status===412) on a precondition
  failure so callers branch cleanly. New saveCopy() parks a conflicting edit
  as `<stem>-conflict-<ts>.<ext>` (collision-probed) without losing either side.
- preview.js: getContentWithVersion(node) → {buf, etag, lastModified} captured
  from the content GET (the listing JSON carries no per-file etag); threaded
  into the editor ctx and exported. getArrayBuffer left untouched.
- conflict.js (new): shared, callback-driven dialog — mine-vs-theirs diff
  (reuses zddc.diff + css/history.css) + Overwrite / Reload-theirs /
  Save-a-copy / Cancel. Never calls saveFile/showFilePreview itself, so the
  deferred Phase 5 cache-outbox conflict UI can reuse it with its own callbacks.
- preview-markdown.js / preview-yaml.js: capture + forward the version token,
  adopt the returned etag on success, and on 412 open the dialog (Overwrite
  re-fetches the current etag then re-saves — re-conflicts on a third writer
  rather than blind-forcing; Reload clears dirty first so the renderInline
  guard skips its confirm). FS-Access mode sends no precondition (no
  concurrency) and never conflicts.
- build.sh: concat conflict.js after util.js.
- tests/conflict.spec.js (+ playwright project): If-Match sent, ConflictError
  on 412, new-etag returned, force omits the precondition, dialog renders the
  diff and each action resolves via its callback. Drives the fresh dist build
  over file:// with a stubbed fetch (the test binary embeds the committed
  browse.html, not dist, so a server-mode E2E would run stale code).

All browse + diff + conflict specs pass (18).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:24:15 -05:00
96 changed files with 8159 additions and 7355 deletions

View file

@ -289,13 +289,13 @@ No install script. Two paths:
- **Local** — download a tool `.html` from `https://zddc.varasys.io/releases/` and open it. Done.
- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded: the baked-in baseline (`zddc/internal/zddc/defaults.zddc.yaml`, dumpable via `zddc-server show-defaults`) declares, via a recursive `paths:` tree, a `default_tool` (the no-slash form: `archive` at `archive/`, `transmittal` at `archive/<party>/staging/`, `browse` at `archive/<party>/{working,reviewing}/` (browse hosts the markdown editor), `classifier` at `archive/<party>/incoming/`, `tables` at `archive/<party>/{mdl,rsk}` and at the project-level `ssr/mdl/rsk` virtual rollups, `landing` at the deployment root) and `available_tools` (which tools may be auto-served / offered) per folder. **Project shape (May 2026 reshape):** `archive/` is the only physical project-root directory. Six top-level URLs are virtual aggregators: `ssr/mdl/rsk` (tables row-rollups across parties, with a synthesised `$party` source-party column the tables tool renders read-only and strips before write) and `working/staging/reviewing` (browse folder-nav listings of parties with non-empty content; per-party URLs 302-redirect to `archive/<party>/<slot>/`). Mkdir directly at the project root is restricted to `archive` and `_`/`.`-prefixed system names — virtual aggregator names and ad-hoc folders return 409. The trailing-slash form serves `dir_tool` (defaults to `browse`). See `internal/apps/availability.go` (`DefaultAppAt`, `AppAvailableAt`) and `internal/zddc/lookups.go` (`DefaultToolAt`, `DirToolAt`, `AvailableToolsAt`); the dispatcher chokepoint is `serveSpecializedNoSlash` in `cmd/zddc-server/main.go`. Where the cascade declares no tool, requesting `<app>.html` returns 404 like any other missing file. **The full canonical-folder convention (auto-own, WORM, virtual folders, standard roles) is documented in ARCHITECTURE.md § "Canonical folders, URL routing & the `.zddc` cascade".**
To override at any level, either:
To override a tool's HTML (local-only — no fetch, no channels/versions):
1. Drop a real `<app>.html` file at the path → static handler serves it (highest priority).
2. Write an `apps:` entry in any `.zddc` along the path. Spec is one of `stable` (canonical "latest stable"), `v0.0.4` (exact-version pin), full URL, or local path. Closer-to-leaf entries win. (Or change `default_tool` / `dir_tool` / `available_tools` to route a different tool entirely.)
2. Add an `<app>.html` member to the site bundle `<ZDDC_ROOT>/.zddc.zip` (a local zip read server-side via `internal/zipfs`; overrides that tool everywhere, and lets you add new `<name>.html` tools). To route a *different* tool at a path, change `default_tool` / `dir_tool` / `available_tools`.
URL sources fetch once and cache forever in `<ZDDC_ROOT>/_app/<host>/<path>`. To force a re-fetch, delete the cache file. No background refresh, no SHA-256 verification, no admin UI. If a configured URL fetch fails, the server falls back to the embedded copy and emits a one-time WARN log.
Otherwise the embedded build-time copy is served. There is no `apps:` `.zddc` key, no upstream fetch, and no signature verification (all removed). `.zddc.zip` is config, not content: a direct `GET /.zddc.zip` is 404 for everyone; the server reads its members from the filesystem internally.
Operators audit by reading the `X-ZDDC-Source` response header: `fetch:URL` / `cache:URL` / `path:/abs` / `embedded:<app>@<build>`. Direct URL access to `/_app/...` is blocked at the dispatch layer.
Operators audit by reading the `X-ZDDC-Source` response header: `bundle:<app>.html` / `embedded:<app>@<build>` (an on-disk override is served by the static handler with its own headers).
**Runtime mode detection** in archive is independent of install: it auto-detects multi-project / project-root / in-archive from `?projects=` plus folder shape. The other tools don't care where they live.
@ -350,6 +350,7 @@ Why this shape: swapping isolation strategies (firejail, systemd-nspawn, podman-
- I/O via stdin/stdout + scratch dir. Pandoc reads markdown from stdin, writes to stdout. Templates + intermediate HTML + output PDF live in a per-call subdir under the scratch root; the dir's host path is passed to the child via `ZDDC_SCRATCH` so the wrapper bind-mounts it into the sandbox at the same path (no path translation).
- Output cached at `<dir>/.converted/<base>.<ext>` (hidden by the `.` prefix). mtime synced to source so the fast path is a stat-and-serve with no exec. PUT/DELETE/MOVE on the source `.md` purges the sidecars.
- Per-project template variables (client/project/contractor/project_number) come from `.zddc` `convert:` cascade keys. Title/tracking_number/revision/status are derived from the filename via `zddc.ParseFilename`.
- **HTML/PDF templates** are named doctype files — `report`, `letter`, `specification` — plus shared partials (`_head.html`, `_doc.html`, `_scripts.html`), living in `pandoc/templates/` (single source of truth; `./build` mirrors them into `zddc/internal/convert/templates/` for `//go:embed`, guarded by `convert.TestEmbeddedTemplatesMatchSource`). A document picks one with `template: <name>` in its YAML front matter (default `report`) and turns on legal heading numbering with `numbering: true` (default off) — both flow straight from the front matter to the template, no converter code. The handler resolves overrides from the `.zddc.d/templates/<name>.html` cascade (`resolveTemplateSet` in `converttemplate.go`): a nearer level (`working/<party>/.zddc.d/templates/`) overrides a farther one (`working/.zddc.d/templates/`), which overrides the embedded default; an override may replace a doctype, a partial, or add a new doctype. NOTE: the per-doc converted cache keys on source mtime only, so editing a template override doesn't invalidate already-cached HTML — purge `.zddc.d/converted/` or touch the source to re-render.
- If pandoc/chromium aren't on PATH (operator running zddc-server outside the runtime image), the endpoint serves 503 with a Retry-After. The rest of the server keeps working. Operators who run zddc-server with raw pandoc/chromium (no wrapper) get a working but unsandboxed conversion endpoint — useful for dev iteration.
## Form-data system (`form/` + zddc-server form handler)
@ -421,7 +422,7 @@ The "records" subset of the tables system carries three guarantees the generic f
**Two new `.zddc` keys** carry the rules (see `zddc/internal/zddc/file.go` + `field_codes.go`):
- `field_codes:` — vocabulary for the components used in filename composition and constrained body fields. Each entry is a discriminated union over `kind: enum|pattern|free` (`{kind: enum, codes: {ACM: Acme Inc, …}}` / `{kind: pattern, pattern: "^[0-9]{4}$"}` / `{kind: free, description: "..."}`). Map-merge across the cascade (mirror of `apps:`) — a deeper level can narrow or replace a single code's vocabulary without dropping unrelated codes.
- `field_codes:` — vocabulary for the components used in filename composition and constrained body fields. Each entry is a discriminated union over `kind: enum|pattern|free` (`{kind: enum, codes: {ACM: Acme Inc, …}}` / `{kind: pattern, pattern: "^[0-9]{4}$"}` / `{kind: free, description: "..."}`). Map-merge across the cascade (like `display:`/`tables:`) — a deeper level can narrow or replace a single code's vocabulary without dropping unrelated codes.
- `records:` — per-pattern rules keyed by filename basename (literal `ssr.yaml` or glob `*.yaml`). Each entry carries `filename_format` (composition template with `{field}` and `{field?}` placeholders), `field_defaults`, `locked`, `folder_fields`, plus `row_field` + `row_scope_fields` for RSK-style tables-of-rows. Filename-pattern scoping is what lets the SSR rule live at the party-folder level without affecting `mdl/`, `rsk/`, `received/`, etc., siblings.
- `folder_fields:` — map of `field → parent-distance` that binds a body field to an ancestor folder name (the folder is the sole source of truth). The map value is how many directories ABOVE the record's own directory the source folder sits (`originator: 1` under `archive/<party>/mdl/` resolves to the `<party>` folder). The server overwrites the body field with the derived name before validation + composition (so a client value can never disagree; a mismatched URL still trips the `filename_format` check), and the form renderer marks the field read-only and pre-fills it.
@ -538,7 +539,7 @@ Pick a role per persona:
These are NOT interchangeable. A note about which one operators want lives in `cascade.go:13-21` (the `PolicyChain` doc) and the relevant struct fields in `file.go`.
Run `zddc-server show-defaults` to dump the embedded `defaults.zddc.yaml` with annotated comments — that's the full schema with all the cascade keys (`worm:`, `auto_own:`, `drop_target:`, `apps:`, `convert:`, `on_plan_review:`, `records:`, `available_tools:`, `default_tool:`, `dir_tool:`, etc.).
Run `zddc-server show-defaults` to dump the embedded `defaults.zddc.yaml` with annotated comments — that's the full schema with all the cascade keys (`worm:`, `auto_own:`, `drop_target:`, `convert:`, `on_plan_review:`, `records:`, `available_tools:`, `default_tool:`, `dir_tool:`, etc.).
### Build
@ -634,14 +635,13 @@ ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \
| `ZDDC_OPA_URL` | `internal` | Policy decider endpoint. `internal` (default) = in-process Go evaluator (same `.zddc` cascade we always had). `http(s)://...` or `unix:///...` = external OPA — every access decision becomes a `POST /v1/data/zddc/access/allow` to the configured endpoint. Federal customers with their own audited Rego use this; commercial deployments leave it `internal`. |
| `ZDDC_OPA_FAIL_OPEN` | *(empty)* | External OPA only. `1` = allow on transport error; default = fail closed (deny). |
| `ZDDC_OPA_CACHE_TTL` | `1s` | External OPA only. Per-decision cache TTL — amortizes round-trips on bursty patterns (e.g. `.archive` listings hit the same `(email, dir)` tuple many times). `0` disables. Format is Go `time.ParseDuration`. |
| `ZDDC_APPS_PUBKEY` | *(empty)* | Path to PEM Ed25519 pubkey for verifying signatures on URL-fetched `apps:` artifacts. Empty = URL apps refused. Download from `zddc.varasys.io/pubkey.pem` (canonical channels) or supply your own. No baked-in default — same posture as TLS certs. Alternative inline form: `apps_pubkey:` in root `.zddc` (root-only, env/flag wins). |
| `ZDDC_ACCESS_LOG` | `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log` | JSON-line audit log (lumberjack-rotated, 100 MB / 10 backups / 90 days, gzipped). Server auto-mkdirs the parent. Set explicitly to empty (`--access-log=`) to disable. Per-host filename + `host` field in every record so multi-replica deployments writing to the same `.zddc.d/` dir disambiguate cleanly. |
### URL handling
**URLs are case-insensitive.** The dispatcher canonicalizes `r.URL.Path` against on-disk casing before any handler runs (`zddc/internal/fs/resolve.go ResolveCanonical`). Per segment: lowercase variant wins if it exists on disk; otherwise exact-case wins; otherwise readdir+CI scan with the lowercase variant winning the tiebreak when multiple case variants are siblings on disk. Walk stops at the first segment that doesn't exist so virtual prefixes (`.archive`, `.profile`, `.tokens`, `.api`, `.auth`) and 404 paths flow through with their tail preserved verbatim.
**File and folder names preserve case on disk.** The canonicalization is purely a URL→filesystem-name mapping; nothing renames anything. Lowercase is the *project-wide canonical* convention, and auto-created folders in `internal/zddc/ensure.go` (the per-party `archive/<party>/{working,staging,reviewing,incoming}/`) and the server's own state dirs (`_app/`, `.zddc.d/tokens/`, `.zddc.d/outbox/`, `.zddc.d/logs/`) are all lowercase by string literal. Operators can drop a `Mixed-Case-Folder/` and it stays mixed-case.
**File and folder names preserve case on disk.** The canonicalization is purely a URL→filesystem-name mapping; nothing renames anything. Lowercase is the *project-wide canonical* convention, and auto-created folders in `internal/zddc/ensure.go` (the per-party `archive/<party>/{working,staging,reviewing,incoming}/`) and the server's own state dirs (`.zddc.d/tokens/`, `.zddc.d/outbox/`, `.zddc.d/logs/`) are all lowercase by string literal. Operators can drop a `Mixed-Case-Folder/` and it stays mixed-case.
**Audit log captures the as-typed path.** `AccessLogMiddleware` snapshots `r.URL.Path` before dispatch rewrites it; the audit record's `path` field is what the client sent. When canonicalization changed it, a `resolved_path` field is added.

View file

@ -83,7 +83,7 @@ Each topic has exactly one authoritative home; everything else links to it.
| What ZDDC is + tool channel links + dual-mode (local/server) overview + install snippets | `~/src/zddc-website/index.html` (hand-edited intro for `zddc.varasys.io/`, in the `ZDDC-website` repo) | repo `README.md`, `zddc/README.md` |
| File-naming convention spec (status codes, modifiers, folder format) | `~/src/zddc-website/reference.html` | repo `README.md`, in-tool help text |
| Versions + channel builds index of every tool | `dist/release-output/index.html` (regenerated by `./build`; deployed to `/srv/zddc/releases/index.html`) | website intro nav, "Browse all versions" link |
| Customer-deployment install (`zddc-server` binary embeds current-stable tools; `.zddc apps:` cascade overrides; cache at `<root>/_app/`) | `zddc/README.md` "Apps: virtual tool HTMLs" section | website intro, `AGENTS.md` |
| Customer-deployment install (`zddc-server` binary embeds current-stable tools; local override via an on-disk `<app>.html` or the site `<root>/.zddc.zip` bundle — no fetch) | `zddc/README.md` "Apps: virtual tool HTMLs" section | website intro, `AGENTS.md` |
| zddc-server operations: env vars, ACL syntax, `.archive` URLs, container vs binary | `zddc/README.md` | `AGENTS.md`, website intro |
| Build / release / channel commands | `AGENTS.md` | repo `README.md` ("see AGENTS.md") |
| Architecture & internal patterns | `ARCHITECTURE.md` (this file) | `AGENTS.md` |
@ -154,13 +154,13 @@ Two orthogonal axes: how the bytes get there (this section), and what runtime mo
Resolution order at a request to `<dir>/<app>.html` where the app is available:
1. **Override** — real `.html` file at the path → static handler.
2. **`.zddc apps:` cascade** — walk leaf→root for an `apps.<app>` entry. Spec is `stable` (canonical "current stable"), `v0.0.4` (exact-version pin), full URL (custom mirror), or local path. Closer-to-leaf wins.
1. **On-disk override** — real `.html` file at the path → static handler.
2. **Site bundle** — an `<app>.html` member of `<ZDDC_ROOT>/.zddc.zip`, read server-side via `internal/zipfs` (see `internal/apps/bundle.go`). Local file, no fetch, no signature; re-stat'd each request for free hot-reload.
3. **Embedded** — the build-time HTML compiled into the binary.
URL sources fetch once on first request and cache forever in `<ZDDC_ROOT>/_app/<host>/<path>`. There is no background refresh, no SHA-256 verification, no admin UI. To pull a new build, delete the cache file. Concurrent misses for the same URL share one outbound fetch (hand-rolled singleflight). Failed fetches fall through to embedded with a one-time WARN log per source URL. Direct URL access to `/_app/...` is blocked at dispatch.
Resolution is LOCAL-ONLY — no network fetch, no signatures, no channels/versions, and no `apps:` `.zddc` key (all removed in favour of this model). `.zddc.zip` is config, not content: a direct `GET /.zddc.zip` is 404 for everyone, while the server reads its members from the filesystem internally. To change a tool's HTML: drop a file at the path, add `<app>.html` to `.zddc.zip`, or rebuild the binary.
The `X-ZDDC-Source` response header always reports what was served: `fetch:URL`, `cache:URL`, `path:/abs`, or `embedded:<app>@<build>`.
The `X-ZDDC-Source` response header always reports what was served: `bundle:<app>.html`, `embedded:<app>@<build>`, or (for an on-disk override) the static handler's own headers.
### Runtime mode detection

View file

@ -64,6 +64,8 @@ concat_files \
"../shared/zddc-source.js" \
"js/init.js" \
"js/util.js" \
"js/conflict.js" \
"js/menu-model.js" \
"js/loader.js" \
"js/tree.js" \
"js/preview.js" \

View file

@ -324,6 +324,68 @@ body {
color: var(--text);
}
/* Per-row "⋯" actions button the visible affordance that a row has a
context menu. Hidden until the row is hovered/selected or the button
itself is keyboard-focused, so it stays out of the way during reading
but is discoverable without knowing to right-click. Pushed to the right
edge; never part of the tab order (rows use roving tabindex). */
.tree-row__kebab {
margin-left: auto;
align-self: flex-start;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.4rem;
height: 1.4rem;
padding: 0;
border: none;
background: transparent;
color: var(--text-muted, #888);
border-radius: var(--radius);
cursor: pointer;
opacity: 0;
transition: opacity 0.1s, background 0.1s, color 0.1s;
}
.tree-row__kebab svg { width: 1em; height: 1em; }
.tree-row:hover .tree-row__kebab,
.tree-row.is-selected .tree-row__kebab,
.tree-row__kebab:focus-visible {
opacity: 1;
}
.tree-row__kebab:hover,
.tree-row__kebab:focus-visible {
background: var(--bg-hover);
color: var(--text);
}
/* Tree-pane toolbar controls row (New folder/file, Sort, Show hidden),
sitting under the filter input. */
.tree-pane__controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
margin-top: 0.4rem;
}
.tree-pane__controls .tp-control {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem;
color: var(--text-muted, #888);
}
.tree-pane__controls .tp-control--check { cursor: pointer; }
.tree-pane__controls select {
font-family: var(--font);
font-size: 0.8rem;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.15rem 0.3rem;
}
/* Per-row drop target highlight: applied while a file/folder drag is
hovering this row. The dashed outline reads as "drop here" without
shifting layout. */

View file

@ -91,6 +91,7 @@
tree.setRoot(detected.entries);
events.showBrowseRoot();
tree.render();
if (events.prefetchScopeAccess) events.prefetchScopeAccess();
events.statusInfo('Loaded ' + detected.entries.length + ' item'
+ (detected.entries.length === 1 ? '' : 's')
+ ' from ' + detected.path);
@ -133,6 +134,7 @@
window.app.state.lastPreviewedNodeId = null;
tree.setRoot(es);
tree.render();
if (events.prefetchScopeAccess) events.prefetchScopeAccess();
// Route through clearPreview so a live editor is disposed
// (not leaked) when back/forward swaps scope.
var pmod = window.app.modules.preview;

203
browse/js/conflict.js Normal file
View file

@ -0,0 +1,203 @@
// conflict.js — shared conflict-resolution dialog for the browse tool.
//
// Surfaced when a save loses an optimistic-concurrency race: the file
// changed on the server since the user loaded it (the editor sends an
// If-Match precondition; the master replies 412). Rather than clobber the
// other writer, the editor opens this dialog showing a mine-vs-theirs diff
// and four choices.
//
// Deliberately CALLBACK-DRIVEN: it never calls saveFile / showFilePreview
// itself — the caller supplies onOverwrite / onReload / onSaveCopy. That
// keeps it reusable by a second consumer (the deferred Phase 5 cache-outbox
// conflict UI, which would resolve `.zddc-outbox/<id>.conflict-<ts>/` entries
// against new server endpoints rather than the live file).
//
// Reuses the modal shell + diff markup conventions from history.js and the
// shared css/history.css classes (md-history-*, md-diff-*) — no new CSS.
(function () {
'use strict';
if (!window.app || !window.app.modules) return;
function toast(msg, level) {
if (window.zddc && typeof window.zddc.toast === 'function') {
window.zddc.toast(msg, level || 'info');
}
}
// Render a line diff of base→mine into `pane` (theirs treated as the
// base, so additions are what this save would introduce). Mirrors the
// history.js diff view.
function renderDiff(pane, theirsText, mineText) {
pane.innerHTML = '';
var ops = (window.zddc && window.zddc.diff)
? window.zddc.diff.lines(theirsText, mineText)
: null;
var diff = document.createElement('div');
diff.className = 'md-diff';
if (!ops) {
diff.textContent = 'Diff unavailable (diff module not loaded).';
pane.appendChild(diff);
return;
}
var unchanged = true;
ops.forEach(function (op) {
if (op.type !== 'eq') unchanged = false;
var line = document.createElement('div');
line.className = 'md-diff-line md-diff-' + op.type;
var g = document.createElement('span');
g.className = 'md-diff-gutter';
g.textContent = op.type === 'add' ? '+' : (op.type === 'del' ? '-' : ' ');
var t = document.createElement('span');
t.className = 'md-diff-text';
t.textContent = op.text;
line.appendChild(g);
line.appendChild(t);
diff.appendChild(line);
});
if (unchanged) {
var same = document.createElement('div');
same.className = 'md-diff-line md-diff-eq';
same.textContent = '(no differences — your copy matches the server)';
diff.appendChild(same);
}
pane.appendChild(diff);
var s = window.zddc.diff.stats(ops);
var stat = document.createElement('p');
stat.className = 'md-history-hint';
stat.textContent = 'Your version vs. current server: +' + s.added + ' / ' + s.removed;
pane.appendChild(stat);
}
// open(opts) → Promise<'overwrite' | 'reload' | 'savecopy' | 'cancel'>
//
// opts:
// filename — display name (e.g. node.name)
// mineText — the user's current (unsaved) content, for the diff
// theirsText — current server content (string), OR…
// fetchTheirs — async () => string — lazy fetch of current server content
// onOverwrite — async () => void — re-save, forcing past the conflict
// onReload — async () => void — discard mine, reload from server
// onSaveCopy — async () => void — write mine to a sibling path (optional)
//
// The matching callback runs when its button is clicked; on success the
// dialog closes and resolves with the action name. On callback error the
// dialog stays open (a toast explains) so the user can pick another path.
// Cancel / Esc / backdrop resolve 'cancel' and leave the editor untouched.
function open(opts) {
opts = opts || {};
return new Promise(function (resolve) {
var overlay = document.createElement('div');
overlay.className = 'modal-overlay md-history-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;';
var box = document.createElement('div');
box.className = 'md-history-box';
var title = document.createElement('h2');
title.className = 'md-history-title';
title.textContent = 'Conflict — ' + (opts.filename || 'file');
var body = document.createElement('div');
body.className = 'md-history-body';
box.appendChild(title);
box.appendChild(body);
overlay.appendChild(box);
document.body.appendChild(overlay);
var settled = false;
function close() {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
document.removeEventListener('keydown', onKey);
}
function finish(result) {
if (settled) return;
settled = true;
close();
resolve(result);
}
function onKey(e) { if (e.key === 'Escape') finish('cancel'); }
document.addEventListener('keydown', onKey);
overlay.addEventListener('mousedown', function (e) {
if (e.target === overlay) finish('cancel');
});
var hint = document.createElement('p');
hint.className = 'md-history-hint';
hint.textContent = '"' + (opts.filename || 'This file')
+ '" was changed by someone else since you opened it. '
+ 'Pick how to resolve — nothing is saved until you choose.';
body.appendChild(hint);
var diffPane = document.createElement('div');
diffPane.textContent = 'Loading current server version…';
body.appendChild(diffPane);
var footer = document.createElement('div');
footer.className = 'md-history-footer';
body.appendChild(footer);
function makeBtn(label, primary) {
var b = document.createElement('button');
b.type = 'button';
b.textContent = label;
if (primary) b.className = 'btn-primary';
footer.appendChild(b);
return b;
}
var overwriteBtn = makeBtn('Overwrite (keep mine)');
var reloadBtn = makeBtn('Discard mine — reload theirs');
var copyBtn = opts.onSaveCopy ? makeBtn('Save a copy') : null;
var cancelBtn = makeBtn('Cancel', true);
function setBusy(busy) {
[overwriteBtn, reloadBtn, copyBtn, cancelBtn].forEach(function (b) {
if (b) b.disabled = busy;
});
}
// Each action runs its callback; on success close+resolve, on
// error toast and re-enable so the user can try another path.
function wire(btn, fn, result) {
if (!btn) return;
btn.addEventListener('click', function () {
setBusy(true);
Promise.resolve()
.then(function () { return fn ? fn() : undefined; })
.then(function () { finish(result); })
.catch(function (e) {
toast('Could not ' + result + ': ' + (e && e.message ? e.message : e), 'error');
setBusy(false);
});
});
}
wire(overwriteBtn, opts.onOverwrite, 'overwrite');
wire(reloadBtn, opts.onReload, 'reload');
wire(copyBtn, opts.onSaveCopy, 'savecopy');
cancelBtn.addEventListener('click', function () { finish('cancel'); });
// Resolve the "theirs" text (eagerly provided or lazily fetched)
// then render the diff. A fetch failure leaves the actions usable
// — the diff is an aid, not a gate.
Promise.resolve()
.then(function () {
if (typeof opts.theirsText === 'string') return opts.theirsText;
if (opts.fetchTheirs) return opts.fetchTheirs();
return null;
})
.then(function (theirs) {
if (settled) return;
if (theirs == null) {
diffPane.textContent = 'Could not load the current server version for comparison.';
return;
}
renderDiff(diffPane, theirs, opts.mineText || '');
})
.catch(function (e) {
if (settled) return;
diffPane.textContent = 'Could not load the current server version: '
+ (e && e.message ? e.message : e);
});
});
}
window.app.modules.conflict = { open: open };
})();

View file

@ -183,8 +183,35 @@
}
}
// Export a file converted to another format. Server-only: builds the
// sibling-extension URL (foo.docx → foo.md) and lets the browser pull it —
// zddc-server recognises the virtual path and converts on the fly, emitting
// Content-Disposition. fmt is a bare extension ("md" | "docx" | "html").
function exportFile(node, fmt) {
if (!node || node.isDir) {
events().statusError('Not a file: ' + (node && node.name));
return;
}
if (state.source !== 'server') {
events().statusError('Export to .' + fmt + ' needs a server connection');
return;
}
var tree = window.app.modules.tree;
var path = tree && tree.pathFor ? tree.pathFor(node) : node.url;
if (!path) {
events().statusError('No path for ' + node.name);
return;
}
var url = path.replace(/\.[^./]+$/, '') + '.' + fmt;
var name = node.name.replace(/\.[^./]+$/, '') + '.' + fmt;
events().statusInfo('Exporting ' + name + '…');
downloadUrl(name, url);
setTimeout(function () { events().statusClear(); }, 2500);
}
window.app.modules.download = {
downloadFile: downloadFile,
downloadFolder: downloadFolder
downloadFolder: downloadFolder,
exportFile: exportFile
};
})();

View file

@ -88,6 +88,21 @@
refresh.classList.add('hidden');
}
}
// Toolbar New buttons: enabled when there's a writable target, and in
// server mode greyed (with a why-tooltip) when the scope lacks the
// create verb. Mirrors the menu's create-gate.
var canCreate = canCreateHere();
var lacksCreateVerb = state.source === 'server'
&& state.scopeAccess && typeof state.scopeAccess.path_verbs === 'string'
&& state.scopeAccess.path_verbs.indexOf('c') === -1;
['newFolderBtn', 'newFileBtn'].forEach(function (id) {
var b = document.getElementById(id);
if (!b) return;
var off = !canCreate || lacksCreateVerb;
b.disabled = off;
b.title = lacksCreateVerb ? 'You dont have create access here.'
: (!canCreate ? 'Open a folder to create files here.' : '');
});
}
// syncURLToSelection reflects the current scope + selected node +
@ -165,6 +180,7 @@
await tree.restoreState(snap);
if (!isCurrentNav(seq)) return;
tree.render();
prefetchScopeAccess();
statusInfo('Refreshed (' + raw.length + ' item'
+ (raw.length === 1 ? '' : 's') + ')');
} else if (state.source === 'fs' && state.rootHandle) {
@ -185,6 +201,23 @@
}
function init() {
// Inject the action implementations the declarative menu-model
// delegates to (avoids an events ↔ menu-model circular dependency).
var mm = window.app.modules.menuModel;
if (mm && mm.configure) {
mm.configure({
createInDir: createInDir,
renameNode: renameNode,
deleteNode: deleteNode,
navigateIntoFolder: navigateIntoFolder,
refreshListing: refreshListing,
parentDirFor: parentDirFor,
canCreateHere: canCreateHere,
statusInfo: statusInfo,
statusError: statusError
});
}
// Header buttons
var btn = document.getElementById('addDirectoryBtn');
if (btn) btn.addEventListener('click', pickLocalDir);
@ -192,6 +225,37 @@
var refresh = document.getElementById('refreshHeaderBtn');
if (refresh) refresh.addEventListener('click', refreshListing);
// ── Tree-pane toolbar: New folder / New file, Sort, Show hidden ──
// View settings live on the toolbar (not in per-row right-click
// menus); create has a discoverable affordance here now that file
// rows no longer offer it.
var newFolderBtn = document.getElementById('newFolderBtn');
if (newFolderBtn) newFolderBtn.addEventListener('click', function () {
createInDir(state.currentPath || '/', 'folder');
});
var newFileBtn = document.getElementById('newFileBtn');
if (newFileBtn) newFileBtn.addEventListener('click', function () {
createInDir(state.currentPath || '/', 'markdown');
});
var sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
// Reflect current state, then drive setSortExplicit on change.
sortSelect.value = state.sort.key + ':' + state.sort.dir;
sortSelect.addEventListener('change', function () {
var parts = sortSelect.value.split(':');
tree.setSortExplicit(parts[0], parseInt(parts[1], 10) === -1 ? -1 : 1);
});
}
var showHiddenChk = document.getElementById('showHiddenChk');
if (showHiddenChk) {
showHiddenChk.checked = !!state.showHidden;
showHiddenChk.addEventListener('change', function () {
state.showHidden = showHiddenChk.checked;
syncURLToSelection();
refreshListing();
});
}
// Tree autofilter — parses input through zddc.filter.parse so
// the same query grammar that the archive app uses (terms,
// quotes, !negation, multi-word AND) works here. The AST is
@ -286,6 +350,16 @@
treeBody.addEventListener('click', function (e) {
var row = e.target.closest('.tree-row');
if (!row) return;
// Kebab (⋯) button → open the row menu at the button; must run
// BEFORE the toggle/preview logic so it doesn't also fire those.
var kebab = e.target.closest('.tree-row__kebab');
if (kebab) {
e.preventDefault();
e.stopPropagation();
var r = kebab.getBoundingClientRect();
openRowMenuFor(row, r.right, r.bottom);
return;
}
var id = parseInt(row.dataset.id, 10);
var node = state.nodes.get(id);
if (!node) return;
@ -382,6 +456,22 @@
// if collapsed/leaf
// Enter / Space — preview file / toggle folder
// Home / End — first / last visible row
// Keyboard menu key — ContextMenu key or Shift+F10 opens the row
// menu at the selected row (standard file-manager / a11y gesture).
document.addEventListener('keydown', function (e) {
var tag = (e.target && e.target.tagName) || '';
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (e.target && e.target.isContentEditable) return;
if (document.querySelector('.modal-overlay, .zddc-menu')) return;
var isMenuKey = e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10');
if (!isMenuKey || state.selectedId == null) return;
var selRow = treeBody.querySelector('.tree-row[data-id="' + state.selectedId + '"]');
if (!selRow) return;
e.preventDefault();
var rr = selRow.getBoundingClientRect();
openRowMenuFor(selRow, rr.left + 16, rr.bottom - 4);
});
document.addEventListener('keydown', function (e) {
// Skip editable contexts.
var tag = (e.target && e.target.tagName) || '';
@ -483,27 +573,8 @@
treeBody.addEventListener('contextmenu', function (e) {
e.preventDefault();
var row = e.target.closest('.tree-row');
if (row) {
var id = parseInt(row.dataset.id, 10);
var node = state.nodes.get(id);
if (!node) return;
state.selectedId = id;
tree.render();
syncURLToSelection();
window.zddc.menu.open({
x: e.clientX,
y: e.clientY,
context: { node: node, row: row },
items: buildTreeRowMenu
});
} else {
window.zddc.menu.open({
x: e.clientX,
y: e.clientY,
context: { dir: state.currentPath || '/' },
items: buildPaneMenu
});
}
if (row) openRowMenuFor(row, e.clientX, e.clientY);
else openPaneMenu(e.clientX, e.clientY);
});
// Per-row drag-drop. Any row is a drop target — folders
@ -874,7 +945,6 @@
}
}
function createInside(node, kind) { return createInDir(parentDirFor(node), kind); }
// Reload a directory's children in the tree so a create/delete/
// rename is reflected. Works for both the current scope (root)
@ -987,42 +1057,6 @@
}
}
// Shared submenu (used by both the row menu and the pane menu).
// Toggle items so the active sort is checked in both surfaces.
var SORT_BY_ITEMS = [
{ label: 'Name',
checked: function () { return state.sort.key === 'name'; },
action: function () { tree.setSortExplicit('name', 1); } },
{ label: 'Modified',
checked: function () { return state.sort.key === 'date'; },
action: function () { tree.setSortExplicit('date', -1); } },
{ label: 'Size',
checked: function () { return state.sort.key === 'size'; },
action: function () { tree.setSortExplicit('size', -1); } },
{ label: 'Type',
checked: function () { return state.sort.key === 'ext'; },
action: function () { tree.setSortExplicit('ext', 1); } }
];
// Row context menu — traditional file-manager layout:
// Open / Open in new tab / Pop out preview
// ─
// Download (label flips on type)
// ─
// New folder / New markdown file
// ─
// Rename / Delete (permission-gated, disabled
// when the row can't be mutated)
// ─
// Copy path / Copy name
// ─
// Expand / Collapse / Navigate into
// ─
// Sort by … / Show hidden files
//
// Items are kept VISIBLE but DISABLED when they don't apply, so
// every menu has the same shape regardless of what the user
// right-clicked. Predictable position = muscle memory.
// canCreateHere — whether New folder/file has a writable target: the
// server (ACL decides the rest) or a picked local folder (the
// filesystem permission decides, escalated on first write).
@ -1030,316 +1064,65 @@
return state.source === 'server' || (state.source === 'fs' && !!state.rootHandle);
}
function buildTreeRowMenu(ctx) {
var serverMode = state.source === 'server';
var canMutate = function (c) {
var up = window.app.modules.upload;
return !!(up && up.canMutate(c.node));
};
return [
// ── Open / preview cluster ──
{
label: function (c) {
if (c.node.isDir) return 'Open';
if (c.node.isZip) return 'Open archive';
return 'Preview';
},
disabled: function (c) { return !!c.node.virtual; },
action: function (c) {
if (c.node.isDir || c.node.isZip) {
tree.toggleFolder(c.node.id);
} else {
var p = previewMod();
if (p) p.showFilePreview(c.node);
}
}
},
{
label: 'Open in new tab',
accel: 'Ctrl+Click',
disabled: function (c) { return !c.node.url; },
action: function (c) {
if (c.node.url) window.open(c.node.url, '_blank', 'noopener');
}
},
{
label: 'Pop out preview',
disabled: function (c) { return c.node.isDir || c.node.isZip; },
action: function (c) {
var p = previewMod();
if (p) p.showFilePreview(c.node, { popup: true });
}
},
{ separator: true },
// ── Menu opening (row / pane / kebab / keyboard) ──────────────────────
// The menu CONTENTS come from the declarative menu-model; this layer just
// resolves the target, syncs selection, and positions the menu. All four
// entry points (right-click row, right-click pane, kebab button, keyboard
// menu key) funnel through here so they stay identical.
// ── Download (single item; label flips on type) ──
{
label: function (c) { return c.node.isDir ? 'Download ZIP' : 'Download'; },
icon: '⤓',
disabled: function (c) { return !!c.node.virtual; },
action: function (c) {
var d = window.app.modules.download;
if (!d) return;
if (c.node.isDir) d.downloadFolder(c.node);
else d.downloadFile(c.node);
}
},
{ separator: true },
// The prefetched /.profile/access view for the current scope (set on every
// listing load — see prefetchScopeAccess). Returned synchronously; the
// menu never triggers a fetch at open time. null until prefetched / FS mode.
function prefetchedAccess() { return state.scopeAccess; }
// ── Create new (in the row's parent folder) ──
{
label: 'New folder',
disabled: !canCreateHere(),
action: function (c) { createInside(c.node, 'folder'); }
},
{
label: 'New markdown file',
disabled: !canCreateHere(),
action: function (c) { createInside(c.node, 'markdown'); }
},
{ separator: true },
function menuModel() { return window.app.modules.menuModel; }
// ── Rename + Delete (the permission-gated pair) ──
//
// Two gates compose: canMutate() rules out un-writable
// sources (offline FS-API without a handle, zip members,
// virtual placeholders) and — when the listing carries
// server-cascade verbs — zddc.cap.has(node, verb) applies
// the per-entry ACL. The verbs gate is server-mode only;
// file:// FS-API and plain Caddy listings have no verbs
// field, so we fall back to canMutate alone (FS-API
// enforces locally; Caddy has no PUT/DELETE either way).
// Server-side ACL still has the final say on the actual
// PUT/DELETE if a stale client tries the action.
{
label: 'Rename…',
disabled: function (c) {
if (!canMutate(c)) return true;
if (!serverMode || !window.zddc.cap) return false;
// verbs===undefined → Caddy or other non-zddc
// server, no cascade signal to gate on. verbs===""
// is zddc-server's explicit zero grant; still
// gate (disable). verbs==="rw…" → check the bit.
if (typeof c.node.verbs !== 'string') return false;
return !window.zddc.cap.has(c.node, 'w');
},
tooltip: function (c) {
if (!serverMode || !canMutate(c)) return '';
if (!window.zddc.cap) return '';
if (typeof c.node.verbs !== 'string') return '';
if (window.zddc.cap.has(c.node, 'w')) return '';
return "You don't have write access to this item.";
},
action: function (c) { renameNode(c.node); }
},
{
label: 'Delete…',
icon: '🗑',
danger: true,
disabled: function (c) {
if (!canMutate(c)) return true;
if (!serverMode || !window.zddc.cap) return false;
if (typeof c.node.verbs !== 'string') return false;
return !window.zddc.cap.has(c.node, 'd');
},
tooltip: function (c) {
if (!serverMode || !canMutate(c)) return '';
if (!window.zddc.cap) return '';
if (typeof c.node.verbs !== 'string') return '';
if (window.zddc.cap.has(c.node, 'd')) return '';
return "You don't have delete access to this item.";
},
action: function (c) { deleteNode(c.node); }
},
{ separator: true },
// ── Clipboard / identifiers ──
{
label: 'Copy path',
action: function (c) {
var path = tree.pathFor(c.node);
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(path).then(
function () { statusInfo('Copied: ' + path); },
function () { statusError('Clipboard copy denied'); }
);
} else {
statusInfo(path);
}
}
},
{
label: 'Copy name',
action: function (c) {
// Always include the file extension. node.name
// already does for normal listings, but re-joining
// via zddc.joinExtension is defensive against any
// upstream that ever returns the basename split.
var n = c.node.name;
var ext = c.node.ext;
if (!c.node.isDir && ext
&& !n.toLowerCase().endsWith('.' + ext.toLowerCase())) {
n = window.zddc.joinExtension(n, ext);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(n);
}
statusInfo('Copied: ' + n);
}
},
{ separator: true },
// ── Tree-view ops (folder/zip rows only) ──
{
label: 'Expand subtree',
accel: 'Shift+Click',
disabled: function (c) { return !(c.node.isDir || c.node.isZip); },
action: function (c) { tree.expandSubtree(c.node.id); }
},
{
label: 'Collapse subtree',
disabled: function (c) { return !(c.node.isDir || c.node.isZip); },
action: function (c) { tree.collapseSubtree(c.node.id); }
},
{
label: 'Navigate into',
accel: 'Dbl-click',
disabled: function (c) { return !c.node.isDir; },
action: function (c) { navigateIntoFolder(c.node); }
},
{ separator: true },
// ── Plan Review (received/<tracking>/ only, cascade-gated) ──
{
label: 'Plan Review…',
visible: function (c) {
if (!serverMode) return false;
if (!state.scopeOnPlanReview) return false;
var pr = window.app.modules.planReview;
if (!pr) return false;
return pr.isReceivedTrackingFolder(c.node);
},
action: function (c) {
var pr = window.app.modules.planReview;
if (pr) pr.invoke(c.node);
}
},
// ── Accept Transmittal (transmittal folder under incoming/) ──
{
label: 'Accept Transmittal…',
visible: function (c) {
if (!serverMode) return false;
var at = window.app.modules.acceptTransmittal;
if (!at) return false;
return at.isAcceptableTransmittalFolder(c.node);
},
action: function (c) {
var at = window.app.modules.acceptTransmittal;
if (at) at.invoke(c.node);
}
},
// ── Stage / Unstage (files under working/ or staging/) ──
{
label: 'Stage to…',
visible: function (c) {
if (!serverMode) return false;
var s = window.app.modules.stage;
return !!(s && s.isStageableFile(c.node));
},
action: function (c) {
var s = window.app.modules.stage;
if (s) s.invokeStage(c.node);
}
},
{
label: 'Unstage to working/',
visible: function (c) {
if (!serverMode) return false;
var s = window.app.modules.stage;
return !!(s && s.isUnstageableFile(c.node));
},
action: function (c) {
var s = window.app.modules.stage;
if (s) s.invokeUnstage(c.node);
}
},
// ── Version history (history:true subtree, real files only) ──
// Server-mode only: the audit trail (who saved when) is
// server-stamped, so there's no offline equivalent. node.history
// is set by the listing when this file sits in a history-enabled
// cascade subtree (working/).
{
label: 'History…',
icon: '🕘',
visible: function (c) {
if (!serverMode) return false;
if (c.node.isDir || c.node.isZip || c.node.virtual) return false;
return !!c.node.history;
},
action: function (c) {
var h = window.app.modules.history;
if (h) h.open(c.node);
}
},
{ separator: true },
// ── View ──
{ label: 'Sort by', items: SORT_BY_ITEMS },
{ label: 'Show hidden files',
checked: function () { return !!state.showHidden; },
action: function () {
state.showHidden = !state.showHidden;
syncURLToSelection();
refreshListing();
} }
];
function openRowMenuFor(row, x, y) {
var id = parseInt(row.dataset.id, 10);
var node = state.nodes.get(id);
if (!node) return;
// Select the row first so the highlight + menu target agree.
state.selectedId = id;
tree.render();
syncURLToSelection();
var mm = menuModel();
if (!mm) return;
window.zddc.menu.open({
x: x, y: y,
context: { node: node, row: row, surface: 'row' },
items: function () { return mm.buildRowItems(node, row, prefetchedAccess()); }
});
}
// Right-click on empty space in the tree pane → directory-scope
// menu. Operations apply to the current scope (state.currentPath),
// not any specific row.
function buildPaneMenu() {
var serverMode = state.source === 'server';
return [
{
label: 'New folder',
disabled: !canCreateHere(),
action: function () { createInDir(state.currentPath || '/', 'folder'); }
},
{
label: 'New markdown file',
disabled: !canCreateHere(),
action: function () { createInDir(state.currentPath || '/', 'markdown'); }
},
// ── Create Transmittal folder (staging/ scope only) ──
{
label: 'Create Transmittal folder…',
visible: function () {
return serverMode && state.scopeCanonicalFolder === 'staging';
},
action: function () {
var ct = window.app.modules.createTransmittal;
if (ct) ct.invoke();
}
},
{ separator: true },
{
label: 'Refresh',
accel: 'F5',
action: function () { refreshListing(); }
},
{ separator: true },
{ label: 'Sort by', items: SORT_BY_ITEMS },
{ label: 'Show hidden files',
checked: function () { return !!state.showHidden; },
action: function () {
state.showHidden = !state.showHidden;
syncURLToSelection();
refreshListing();
} }
];
function openPaneMenu(x, y) {
var mm = menuModel();
if (!mm) return;
window.zddc.menu.open({
x: x, y: y,
context: { dir: state.currentPath || '/', surface: 'pane' },
items: function () { return mm.buildPaneItems(prefetchedAccess()); }
});
}
// Prefetch (memoised) the scope access view so the menu's create-gate and
// admin/sub-admin tier items resolve without a fetch. Server-mode only;
// cap.at returns null on file:// so FS mode leaves scopeAccess null.
function prefetchScopeAccess() {
if (state.source !== 'server' || !window.zddc || !window.zddc.cap || !window.zddc.cap.at) {
state.scopeAccess = null;
return;
}
var path = state.currentPath || '/';
window.zddc.cap.at(path).then(function (view) {
// Ignore a stale resolution if the scope moved on.
if ((state.currentPath || '/') === path) {
state.scopeAccess = view || null;
applySourceUI();
}
}, function () { /* best-effort; leave prior value */ });
}
// View mode is URL-driven, not UI-driven.
//
// ?view=grid → grid mode (only honored where classifier is
@ -1433,6 +1216,7 @@
// don't pushState/setRoot on top of it.
if (!isCurrentNav(seq)) return;
state.currentPath = url;
prefetchScopeAccess();
// Selection / preview belong to the old scope; clear them so
// the new root doesn't carry stale highlight state.
state.selectedId = null;
@ -1489,6 +1273,11 @@
// can't race the in-tool navigations. beginNav() claims the latest
// token; isCurrentNav(seq) reports whether it's still latest.
beginNav: beginNav,
isCurrentNav: isCurrentNav
isCurrentNav: isCurrentNav,
// Prefetch the current scope's /.profile/access view into
// state.scopeAccess (memoised) so the menu's create-gate + admin-tier
// items resolve without a fetch. Called by app.js on initial load +
// back/forward.
prefetchScopeAccess: prefetchScopeAccess
};
})();

View file

@ -165,10 +165,10 @@
+ '<span class="tree-hovercard__val" id="hc-roles">…</span>';
}
// Path comes last (longest, most likely to wrap).
var path = tree ? tree.pathFor(node) : '';
if (path) html += kv('Path', path, true);
if (node.url && node.url !== path) html += kv('URL', node.url, true);
// URL last (longest, most likely to wrap) — rendered as a clickable
// link the user can open or right-click to copy. The on-disk path is
// intentionally omitted; the URL is the shareable reference.
if (node.url) html += kvLink('URL', node.url, node.url);
return html;
}

View file

@ -79,6 +79,14 @@
scopeCanonicalFolder: '',
scopeOnPlanReview: false,
// Prefetched /.profile/access view for the CURRENT scope
// (state.currentPath), via cap.at() — memoised. Supplies
// path_verbs / path_is_admin / path_roles to the menu model for
// pane-scope create gating and the admin/sub-admin tier items, so
// the menu never fetches at open time. null until prefetched / in
// FS-Access (offline) mode.
scopeAccess: null,
// Whether the listing includes dotfiles. Toggled by the
// "Show hidden files" menu item; URL-persisted via ?hidden=1.
showHidden: false,

444
browse/js/menu-model.js Normal file
View file

@ -0,0 +1,444 @@
// menu-model.js — the declarative source of truth for the browse tool's
// action menus (right-click row menu, right-click pane menu, the keyboard
// menu key, and the hover kebab).
//
// Every action is declared ONCE as a descriptor. The row/pane menus are
// projections over that list, filtered by surface + an `appliesTo` TYPE
// predicate and annotated with an `enabled` CAPABILITY predicate:
//
// appliesTo(ctx) === false → the item is OMITTED (it doesn't make sense
// for this target — e.g. "New folder" on a
// file row, "Expand" on a file).
// appliesTo true, enabled
// (ctx) === false → the item is SHOWN DISABLED with a tooltip
// naming what's required (write access /
// create access / project-admin / site-admin).
//
// That hybrid realizes the cumulative guest ⊂ project-team ⊂ sub-admin ⊂
// admin menus: a lower tier SEES higher-tier actions greyed and learns they
// exist, while type-irrelevant noise is hidden.
//
// Roles are NOT hardcoded: ordinary actions gate on the verbs the server
// returns per entry (node.verbs) or per scope (cap.at → path_verbs), so any
// operator-defined role works. Only two intrinsically-special tiers are
// recognised by name — site admin (is_super_admin / IsAdmin) and project /
// subtree admin (path_is_admin / IsSubtreeAdmin) — because they govern
// administration itself and can't be expressed as a plain verb bundle.
//
// Deliberately data-shaped so a future server-sourced manifest (zddc.zip)
// can supply or extend the descriptors without touching the tool code.
(function () {
'use strict';
if (!window.app || !window.app.modules) return;
var state = window.app.state;
// Action implementations are injected by events.init() via configure()
// to avoid an events ↔ menu-model circular dependency. Everything else
// (tree, preview, download, workflow modules) is reached through
// window.app.modules at call time.
var act = {};
function configure(a) { act = a || {}; }
// ── Predicates ────────────────────────────────────────────────────────
function isServer() { return state.source === 'server'; }
function appliesToFolderLike(node) { return !!(node && (node.isDir || node.isZip)); }
function appliesToFile(node) { return !!(node && !node.isDir && !node.isZip); }
// Formats the Export submenu offers for a file (server-side conversion):
// a file of one of these extensions can be exported as the other two.
var EXPORT_FORMATS = ['md', 'docx', 'html'];
function cap() { return window.zddc && window.zddc.cap; }
function canVerb(node, verb) {
return !!(node && cap() && cap().has(node, verb));
}
function pathHasVerb(access, verb) {
return !!(access && typeof access.path_verbs === 'string'
&& access.path_verbs.indexOf(verb) !== -1);
}
function isSiteAdmin(access) { return !!(access && access.is_super_admin); }
function isSubtreeAdminHere(access) { return !!(access && access.path_is_admin); }
// Create / mutate / admin actions are HIDDEN when the user can't perform
// them (capability folded into appliesTo), so these gates only need the
// boolean — the `missing` field is retained for potential future tooltips.
// Rename/Delete gate — preserves today's compose exactly: canMutate rules
// out un-writable sources (offline FS without a handle, zip members,
// virtual placeholders) with no tooltip; when the server cascade reports
// verbs, the per-entry ACL bit gates with a tooltip. FS / Caddy (no verbs
// field) fall back to canMutate alone. Returns { enabled, missing }.
function verbGate(node, verb) {
var up = window.app.modules.upload;
if (!up || !up.canMutate(node)) return { enabled: false, missing: '' };
if (!isServer() || !cap()) return { enabled: true, missing: '' };
if (typeof node.verbs !== 'string') return { enabled: true, missing: '' };
if (cap().has(node, verb)) return { enabled: true, missing: '' };
return { enabled: false, missing: verb };
}
// Create gate (New folder / New file). canCreateHere() rules out the
// no-target case (offline FS without a picked handle) — no tooltip there.
// In server mode, gate on the 'c' verb: per-node for a folder row, per
// scope for the pane. Unknown verbs → optimistic (server is the final
// arbiter, surfacing 403 via cap.handleForbidden, exactly as today).
function createGate(ctx) {
if (!act.canCreateHere || !act.canCreateHere()) return { enabled: false, missing: '' };
if (!isServer()) return { enabled: true, missing: '' };
if (ctx.node) { // folder-row create → inside this folder
if (typeof ctx.node.verbs === 'string') {
return canVerb(ctx.node, 'c')
? { enabled: true, missing: '' }
: { enabled: false, missing: 'c' };
}
return { enabled: true, missing: '' };
}
// pane create → current scope
if (ctx.access && typeof ctx.access.path_verbs === 'string') {
return pathHasVerb(ctx.access, 'c')
? { enabled: true, missing: '' }
: { enabled: false, missing: 'c' };
}
return { enabled: true, missing: '' };
}
// "Edit access rules" (.zddc) — the sub-admin / site-admin tier item.
// Enabled per-node when the entry grants the admin verb 'a', else by the
// scope's subtree-admin / site-admin status (admin authority cascades
// down a subtree). Returns { enabled, missing }.
function manageAccessGate(ctx) {
if (ctx.node && canVerb(ctx.node, 'a')) return { enabled: true, missing: '' };
if (isSubtreeAdminHere(ctx.access) || isSiteAdmin(ctx.access)) return { enabled: true, missing: '' };
return { enabled: false, missing: 'subtree-admin' };
}
function insideZip(node) {
// Creating inside a zip member is impossible — the server can't PUT
// into an archive. Mirror tree.zipNestedInsideZip's URL heuristic.
if (!node) return false;
if (node.url && /\.zip\//i.test(node.url)) return true;
if (node.handle && node.handle.isZipEntry) return true;
return false;
}
// ── Descriptors ─────────────────────────────────────────────────────────
// group order = visual order; a separator is inserted on each group change
// among the items that actually render (context-menu.js collapses extras).
var DESCRIPTORS = [
// ── open ──
{
id: 'open', group: 'open', surfaces: ['row'],
label: function (ctx) {
if (ctx.node.isDir) return 'Open';
if (ctx.node.isZip) return 'Open archive';
return 'Preview';
},
appliesTo: function (ctx) { return !ctx.node.virtual; },
action: function (ctx) {
if (ctx.node.isDir) {
// Open = navigate into the folder (rescope). Inline
// expand stays on single-click / chevron / arrow keys.
if (act.navigateIntoFolder) act.navigateIntoFolder(ctx.node);
} else if (ctx.node.isZip) {
// A zip can't be navigated into — expand it inline.
var t = window.app.modules.tree;
if (t) t.toggleFolder(ctx.node.id);
} else {
var p = window.app.modules.preview;
if (p) p.showFilePreview(ctx.node);
}
}
},
{
id: 'open-new-tab', group: 'open', surfaces: ['row'],
label: 'Open in new tab', accel: 'Ctrl+Click',
appliesTo: function (ctx) { return !!ctx.node.url; },
action: function (ctx) { window.open(ctx.node.url, '_blank', 'noopener'); }
},
{
id: 'popout', group: 'open', surfaces: ['row'],
label: 'Pop out preview',
appliesTo: function (ctx) { return appliesToFile(ctx.node) && !ctx.node.virtual; },
action: function (ctx) {
var p = window.app.modules.preview;
if (p) p.showFilePreview(ctx.node, { popup: true });
}
},
// ── io ──
{
id: 'download', group: 'io', surfaces: ['row'],
label: function (ctx) { return ctx.node.isDir ? 'Download ZIP' : 'Download'; },
appliesTo: function (ctx) { return !ctx.node.virtual; },
action: function (ctx) {
var d = window.app.modules.download;
if (!d) return;
if (ctx.node.isDir) d.downloadFolder(ctx.node);
else d.downloadFile(ctx.node);
}
},
{
// Export submenu: a folder offers ".zip" (both modes); a md/docx/html
// file offers the OTHER two formats (server-side conversion, so
// server mode only). A zip is already an archive — no Export.
id: 'export', group: 'io', surfaces: ['row'],
label: 'Export',
appliesTo: function (ctx) {
var n = ctx.node;
if (!n || n.virtual) return false;
if (n.isDir) return true;
if (n.isZip) return false;
return isServer() && EXPORT_FORMATS.indexOf((n.ext || '').toLowerCase()) !== -1;
},
items: function (ctx) {
var n = ctx.node;
var d = window.app.modules.download;
if (!d) return [];
if (n.isDir) {
return [{ label: '.zip', action: function () { d.downloadFolder(n); } }];
}
var cur = (n.ext || '').toLowerCase();
return EXPORT_FORMATS.filter(function (f) { return f !== cur; }).map(function (fmt) {
return { label: '.' + fmt, action: function () { d.exportFile(n, fmt); } };
});
}
},
// ── create (folder rows + pane; NOT file rows) ──
// Create actions are HIDDEN unless the user can create here (the
// capability is folded into appliesTo, not greyed). On a row they
// apply to folders only (create inside); on the pane, to the scope.
{
id: 'new-folder', group: 'create', surfaces: ['row', 'pane'],
label: 'New folder',
appliesTo: function (ctx) {
var typeOk = ctx.surface === 'pane'
|| (appliesToFolderLike(ctx.node) && !insideZip(ctx.node));
return typeOk && createGate(ctx).enabled;
},
action: function (ctx) { if (act.createInDir) act.createInDir(ctx.dir, 'folder'); }
},
{
id: 'new-file', group: 'create', surfaces: ['row', 'pane'],
label: 'New file',
appliesTo: function (ctx) {
var typeOk = ctx.surface === 'pane'
|| (appliesToFolderLike(ctx.node) && !insideZip(ctx.node));
return typeOk && createGate(ctx).enabled;
},
action: function (ctx) { if (act.createInDir) act.createInDir(ctx.dir, 'markdown'); }
},
{
id: 'create-transmittal', group: 'create', surfaces: ['pane'],
label: 'Create Transmittal folder…',
appliesTo: function () { return isServer() && state.scopeCanonicalFolder === 'staging'; },
action: function () {
var ct = window.app.modules.createTransmittal;
if (ct) ct.invoke();
}
},
// ── mutate (HIDDEN unless permitted — capability folded into appliesTo) ──
{
id: 'rename', group: 'mutate', surfaces: ['row'],
label: 'Rename…',
appliesTo: function (ctx) { return !ctx.node.virtual && verbGate(ctx.node, 'w').enabled; },
action: function (ctx) { if (act.renameNode) act.renameNode(ctx.node); }
},
{
id: 'delete', group: 'mutate', surfaces: ['row'], danger: true,
label: 'Delete…',
appliesTo: function (ctx) { return !ctx.node.virtual && verbGate(ctx.node, 'd').enabled; },
action: function (ctx) { if (act.deleteNode) act.deleteNode(ctx.node); }
},
// ── treeops (folder/zip rows only) ──
{
id: 'expand-subtree', group: 'treeops', surfaces: ['row'],
label: 'Expand subtree', accel: 'Shift+Click',
appliesTo: function (ctx) { return appliesToFolderLike(ctx.node); },
action: function (ctx) {
var t = window.app.modules.tree;
if (t) t.expandSubtree(ctx.node.id);
}
},
{
id: 'collapse-subtree', group: 'treeops', surfaces: ['row'],
label: 'Collapse subtree',
appliesTo: function (ctx) { return appliesToFolderLike(ctx.node); },
action: function (ctx) {
var t = window.app.modules.tree;
if (t) t.collapseSubtree(ctx.node.id);
}
},
// ── workflow (already type+scope gated → omitted when N/A) ──
{
id: 'plan-review', group: 'workflow', surfaces: ['row'],
label: 'Plan Review…',
appliesTo: function (ctx) {
if (!isServer() || !state.scopeOnPlanReview) return false;
var pr = window.app.modules.planReview;
return !!(pr && pr.isReceivedTrackingFolder(ctx.node));
},
action: function (ctx) {
var pr = window.app.modules.planReview;
if (pr) pr.invoke(ctx.node);
}
},
{
id: 'accept-transmittal', group: 'workflow', surfaces: ['row'],
label: 'Accept Transmittal…',
appliesTo: function (ctx) {
if (!isServer()) return false;
var at = window.app.modules.acceptTransmittal;
return !!(at && at.isAcceptableTransmittalFolder(ctx.node));
},
action: function (ctx) {
var at = window.app.modules.acceptTransmittal;
if (at) at.invoke(ctx.node);
}
},
{
id: 'stage', group: 'workflow', surfaces: ['row'],
label: 'Stage to…',
appliesTo: function (ctx) {
if (!isServer()) return false;
var s = window.app.modules.stage;
return !!(s && s.isStageableFile(ctx.node));
},
action: function (ctx) {
var s = window.app.modules.stage;
if (s) s.invokeStage(ctx.node);
}
},
{
id: 'unstage', group: 'workflow', surfaces: ['row'],
label: 'Unstage to working/',
appliesTo: function (ctx) {
if (!isServer()) return false;
var s = window.app.modules.stage;
return !!(s && s.isUnstageableFile(ctx.node));
},
action: function (ctx) {
var s = window.app.modules.stage;
if (s) s.invokeUnstage(ctx.node);
}
},
{
id: 'history', group: 'workflow', surfaces: ['row'],
label: 'History…',
appliesTo: function (ctx) {
if (!isServer()) return false;
var n = ctx.node;
return appliesToFile(n) && !n.virtual && !!n.history;
},
action: function (ctx) {
var h = window.app.modules.history;
if (h) h.open(ctx.node);
}
},
// ── admin / sub-admin tier ──
{
// HIDDEN unless the user can actually edit access rules here
// (admin verb 'a', or subtree/site admin) — not shown greyed.
id: 'manage-access', group: 'admin', surfaces: ['row', 'pane'],
label: 'Edit access rules…',
appliesTo: function (ctx) {
if (!isServer()) return false; // server-only tier
var typeOk = ctx.surface === 'pane' || appliesToFolderLike(ctx.node);
return typeOk && manageAccessGate(ctx).enabled;
},
action: function (ctx) { openZddcEditor(ctx.dir); }
},
// ── view (pane) ──
{
id: 'refresh', group: 'view', surfaces: ['pane'],
label: 'Refresh', accel: 'F5',
action: function () { if (act.refreshListing) act.refreshListing(); }
}
];
// Open the `.zddc` for `dir` in the YAML editor. Prefer an existing tree
// node (carries verbs/virtual flags) else synthesize one; the yaml plugin
// recognises name === '.zddc' and gates the save on the admin verb 'a'.
function openZddcEditor(dir) {
var url = (dir || '/');
if (!url.endsWith('/')) url += '/';
url += '.zddc';
var found = null;
var t = window.app.modules.tree;
state.nodes.forEach(function (n) {
if (found || n.name !== '.zddc' || !t) return;
if (t.pathFor(n) === url) found = n;
});
var node = found || { url: url, name: '.zddc', ext: '' };
var p = window.app.modules.preview;
if (p) p.showFilePreview(node);
}
// ── Projection ────────────────────────────────────────────────────────
function resolve(v, ctx) { return typeof v === 'function' ? v(ctx) : v; }
function resolveBool(v, ctx, dflt) {
if (v === undefined) return dflt;
return !!(typeof v === 'function' ? v(ctx) : v);
}
function toMenuItem(d, ctx) {
var item = {
label: resolve(d.label, ctx),
accel: d.accel,
danger: d.danger,
// disabled / tooltip ignore the menu's own context arg — ctx is
// already captured here with the richer browse context.
disabled: function () { return !resolveBool(d.enabled, ctx, true); },
tooltip: function () {
return resolveBool(d.enabled, ctx, true) ? '' : (resolve(d.tooltip, ctx) || '');
}
};
// A descriptor with `items` becomes a submenu (resolved against the
// captured browse ctx); otherwise it's a normal action row.
if (d.items) {
item.items = function () { return resolve(d.items, ctx); };
} else {
item.action = function () { if (d.action) d.action(ctx); };
}
return item;
}
function project(surface, ctx) {
var out = [];
var lastGroup = null;
for (var i = 0; i < DESCRIPTORS.length; i++) {
var d = DESCRIPTORS[i];
if (d.surfaces.indexOf(surface) === -1) continue;
if (!resolveBool(d.appliesTo, ctx, true)) continue;
if (lastGroup !== null && d.group !== lastGroup) out.push({ separator: true });
lastGroup = d.group;
out.push(toMenuItem(d, ctx));
}
return out; // context-menu.js collapses leading/trailing/dup separators
}
function buildRowItems(node, row, access) {
var dir = act.parentDirFor ? act.parentDirFor(node) : (state.currentPath || '/');
return project('row', { node: node, row: row, surface: 'row', dir: dir, access: access });
}
function buildPaneItems(access) {
var dir = state.currentPath || '/';
return project('pane', { node: null, row: null, surface: 'pane', dir: dir, access: access });
}
window.app.modules.menuModel = {
configure: configure,
buildRowItems: buildRowItems,
buildPaneItems: buildPaneItems,
DESCRIPTORS: DESCRIPTORS // exposed for tests
};
})();

View file

@ -278,8 +278,8 @@
// ── Save ────────────────────────────────────────────────────────────────
function saveContent(node, content) {
return util.saveFile(node, content, 'text/markdown; charset=utf-8');
function saveContent(node, content, opts) {
return util.saveFile(node, content, 'text/markdown; charset=utf-8', opts);
}
var isZipMemberNode = util.isZipMemberNode;
@ -310,11 +310,21 @@
}
dispose();
// Read content.
var text;
// Read content + the server version token (etag/last-modified) so
// the save can send an If-Match precondition and detect a concurrent
// edit instead of clobbering it. Falls back to getArrayBuffer (and a
// null token → no precondition) for callers/sources without it.
var text, loadedEtag = null, loadedLastModified = null;
try {
var buf = await ctx.getArrayBuffer(node);
text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
if (ctx.getContentWithVersion) {
var loaded = await ctx.getContentWithVersion(node);
text = new TextDecoder('utf-8', { fatal: false }).decode(loaded.buf);
loadedEtag = loaded.etag;
loadedLastModified = loaded.lastModified;
} else {
var buf = await ctx.getArrayBuffer(node);
text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
}
} catch (e) {
container.innerHTML =
'<div class="preview-empty" style="color:var(--danger)">'
@ -553,7 +563,11 @@
hash: initialHash,
tocEl: tocBody,
fmEl: fmTextarea,
ac: ac
ac: ac,
// Server version token captured at load — sent as If-Match on
// save and refreshed from each successful PUT's response ETag.
etag: loadedEtag,
lastModified: loadedLastModified
};
currentInstance = instance;
@ -687,21 +701,81 @@
fmTextarea.addEventListener('input', onFmChange);
// ── Save ───────────────────────────────────────────────────────────
// Mark a successful write: adopt the new server ETag (so the next
// save's If-Match matches — no false conflict on save→edit→save),
// refresh the dirty baseline, clear dirty.
async function markSaved(content, res) {
if (currentInstance !== instance) return;
if (res && res.etag) instance.etag = res.etag;
instance.hash = await hashContent(content);
if (currentInstance !== instance) return;
markDirty(false);
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Saved ' + node.name, 'success');
}
}
// 412 → the file changed on the server since we loaded it. Open the
// shared conflict dialog rather than clobbering. Dirty stays set
// until the user resolves.
async function resolveConflict(content) {
var conflict = window.app.modules.conflict;
var prev = window.app.modules.preview;
if (!conflict || !prev) return; // no UI available — leave dirty
await conflict.open({
filename: node.name,
mineText: content,
fetchTheirs: function () {
return prev.getContentWithVersion(node).then(function (r) {
return new TextDecoder('utf-8', { fatal: false }).decode(r.buf);
});
},
// Overwrite: re-fetch the CURRENT version and save against it
// (still 412s on a third concurrent writer rather than blind-
// forcing).
onOverwrite: function () {
return prev.getContentWithVersion(node).then(function (cur) {
return saveContent(node, content, { etag: cur.etag, lastModified: cur.lastModified });
}).then(function (res) { return markSaved(content, res); });
},
// Reload theirs: discard local edits. Clear dirty first so the
// renderInline dirty-guard skips its confirm; the fresh render
// re-captures content + a new ETag.
onReload: function () {
markDirty(false);
instance.dirty = false;
return prev.showFilePreview(node);
},
onSaveCopy: function () {
return util.saveCopy(node, content, 'text/markdown; charset=utf-8')
.then(function (name) {
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Saved your version as ' + name, 'success');
}
});
}
});
if (currentInstance === instance) statusEl.textContent = '';
}
async function save() {
if (currentInstance !== instance) return;
if (!instance.dirty || !canSave(node)) return;
var content = assembleContent(fmTextarea.value, editor.getMarkdown());
try {
statusEl.textContent = 'Saving…';
await saveContent(node, content);
if (currentInstance !== instance) return; // switched away mid-save
instance.hash = await hashContent(content);
markDirty(false);
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Saved ' + node.name, 'success');
}
var res = await saveContent(node, content, {
etag: instance.etag, lastModified: instance.lastModified
});
await markSaved(content, res);
} catch (e) {
if (e && e.status === 412) {
if (currentInstance !== instance) return;
statusEl.textContent = 'Conflict — resolving…';
await resolveConflict(content);
return;
}
statusEl.textContent = 'Save failed: ' + (e.message || e);
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Save failed: ' + (e.message || e), 'error');

View file

@ -45,11 +45,11 @@
// ── Save (mirrors preview-markdown.js) ─────────────────────────────────
function saveContent(node, content) {
function saveContent(node, content, opts) {
// Via the shared saveFile so local (FS-Access) saves escalate to
// readwrite the same as the markdown editor — previously this path
// skipped ensureWritable and failed on read-only-picked folders.
return util.saveFile(node, content, 'application/x-yaml; charset=utf-8');
return util.saveFile(node, content, 'application/x-yaml; charset=utf-8', opts);
}
var isZipMemberNode = util.isZipMemberNode;
@ -106,9 +106,8 @@
worm: 'string[]',
paths: 'pathmap',
display: 'stringmap',
apps: 'appsmap',
apps_pubkey: 'string',
tables: 'stringmap',
views: 'viewmap',
convert: 'convert',
created_by: 'string',
inherit: 'bool'
@ -225,19 +224,29 @@
walkObject(v, TOP_KEYS, path.concat([seg]), issues);
}
return;
case 'appsmap':
case 'viewmap':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
for (var app in val) {
if (!Object.prototype.hasOwnProperty.call(val, app)) continue;
if (!ALLOWED_TOOLS[app]) {
issues.push({ keyPath: path.concat([app]), severity: 'warning',
message: 'Unknown tool "' + app + '" in apps:.' });
for (var shape in val) {
if (!Object.prototype.hasOwnProperty.call(val, shape)) continue;
if (['dir', 'dir_slash', 'file'].indexOf(shape) === -1) {
issues.push({ keyPath: path.concat([shape]), severity: 'warning',
message: 'Unknown view shape "' + shape + '" (known: dir, dir_slash, file).' });
}
if (typeOf(val[app]) !== 'string') {
issues.push({ keyPath: path.concat([app]), severity: 'error',
message: 'apps.' + app + ' must be a spec string '
+ '(channel | v<semver> | URL | path).' });
var vv = val[shape];
if (typeOf(vv) !== 'object') {
issues.push({ keyPath: path.concat([shape]), severity: 'error',
message: 'views.' + shape + ' must be a map ({tool, config}).' });
continue;
}
if (typeOf(vv.tool) !== 'string' || !ALLOWED_TOOLS[vv.tool]) {
issues.push({ keyPath: path.concat([shape, 'tool']), severity: 'warning',
message: 'views.' + shape + '.tool should be a known tool ('
+ Object.keys(ALLOWED_TOOLS).join(', ') + ').' });
}
if (vv.config !== undefined && typeOf(vv.config) !== 'string') {
issues.push({ keyPath: path.concat([shape, 'config']), severity: 'error',
message: 'views.' + shape + '.config must be a filename string.' });
}
}
return;
@ -350,6 +359,10 @@
var currentEditor = null;
var currentDirty = false;
var currentNodeRef = null;
// Server version token for the loaded file — sent as If-Match on save
// and refreshed from each successful PUT's response ETag.
var currentEtag = null;
var currentLastModified = null;
function dispose() {
// CM doesn't have an explicit destroy(); GC handles it once
@ -358,6 +371,8 @@
currentEditor = null;
currentDirty = false;
currentNodeRef = null;
currentEtag = null;
currentLastModified = null;
}
function isDirty() {
@ -377,10 +392,17 @@
}
dispose();
var text;
var text, loadedEtag = null, loadedLastModified = null;
try {
var buf = await ctx.getArrayBuffer(node);
text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
if (ctx.getContentWithVersion) {
var loaded = await ctx.getContentWithVersion(node);
text = new TextDecoder('utf-8', { fatal: false }).decode(loaded.buf);
loadedEtag = loaded.etag;
loadedLastModified = loaded.lastModified;
} else {
var buf = await ctx.getArrayBuffer(node);
text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
}
} catch (e) {
container.innerHTML =
'<div class="preview-empty" style="color:var(--danger)">'
@ -483,6 +505,8 @@
currentEditor = editor;
currentNodeRef = node;
currentDirty = false;
currentEtag = loadedEtag;
currentLastModified = loadedLastModified;
if (!writable) {
saveBtn.disabled = true;
@ -511,6 +535,56 @@
markDirty(h !== initialHash);
});
// Adopt the new server ETag + refresh the dirty baseline after a
// successful write so save→edit→save doesn't false-conflict.
async function markSaved(content, res) {
if (currentEditor !== editor) return;
if (res && res.etag) currentEtag = res.etag;
initialHash = await hashContent(content);
if (currentEditor !== editor) return;
markDirty(false);
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Saved ' + node.name, 'success');
}
}
// 412 → file changed on the server since load. Open the shared
// conflict dialog instead of clobbering.
async function resolveConflict(content) {
var conflict = window.app.modules.conflict;
var prev = window.app.modules.preview;
if (!conflict || !prev) return;
await conflict.open({
filename: node.name,
mineText: content,
fetchTheirs: function () {
return prev.getContentWithVersion(node).then(function (r) {
return new TextDecoder('utf-8', { fatal: false }).decode(r.buf);
});
},
onOverwrite: function () {
return prev.getContentWithVersion(node).then(function (cur) {
return saveContent(node, content, { etag: cur.etag, lastModified: cur.lastModified });
}).then(function (res) { return markSaved(content, res); });
},
onReload: function () {
markDirty(false);
currentDirty = false;
return prev.showFilePreview(node);
},
onSaveCopy: function () {
return util.saveCopy(node, content, 'application/x-yaml; charset=utf-8')
.then(function (name) {
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Saved your version as ' + name, 'success');
}
});
}
});
if (currentEditor === editor) statusEl.textContent = '';
}
async function save() {
if (saveBtn.disabled) return;
// Re-check authority at click time, not via the mount-time
@ -520,14 +594,17 @@
var content = editor.getValue();
try {
statusEl.textContent = 'Saving…';
await saveContent(node, content);
initialHash = await hashContent(content);
markDirty(false);
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Saved ' + node.name, 'success');
}
var res = await saveContent(node, content, {
etag: currentEtag, lastModified: currentLastModified
});
await markSaved(content, res);
} catch (e) {
if (e && e.status === 412) {
if (currentEditor !== editor) return;
statusEl.textContent = 'Conflict — resolving…';
await resolveConflict(content);
return;
}
statusEl.textContent = 'Save failed: ' + (e.message || e);
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Save failed: ' + (e.message || e), 'error');

View file

@ -56,6 +56,30 @@
throw new Error('no source for file');
}
// Like getArrayBuffer, but also returns the server version token
// ({etag, lastModified}) captured from the content GET. The editors use
// it to send an If-Match precondition on save so a concurrent edit is
// rejected (412) instead of silently clobbered. FS-Access mode has no
// server version — etag/lastModified are null and the precondition is a
// clean no-op (a single locally-picked file has no concurrency).
async function getContentWithVersion(node) {
if (state.source === 'server' && node.url) {
var resp = await fetch(node.url, { credentials: 'same-origin' });
if (!resp.ok) throw new Error('HTTP ' + resp.status);
var buf = await resp.arrayBuffer();
return {
buf: buf,
etag: resp.headers.get('ETag') || null,
lastModified: resp.headers.get('Last-Modified') || null
};
}
if (node.handle) {
var f = await node.handle.getFile();
return { buf: await f.arrayBuffer(), etag: null, lastModified: null };
}
throw new Error('no source for file');
}
async function getBlobUrl(node) {
// Server-served files (including zip members at "<…>.zip/<member>"
// URLs) load straight from the server — preserves Content-Type
@ -180,7 +204,7 @@
window.app.modules.markdown &&
typeof window.app.modules.markdown.render === 'function') {
try {
await window.app.modules.markdown.render(node, container, { getArrayBuffer: getArrayBuffer });
await window.app.modules.markdown.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
} catch (e) {
renderError(container, 'Markdown render failed: ' + (e.message || e));
}
@ -193,7 +217,7 @@
var yamlMod = window.app.modules.yamledit;
if (yamlMod && yamlMod.handles(node)) {
try {
await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer });
await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
} catch (e) {
renderError(container, 'YAML render failed: ' + (e.message || e));
}
@ -443,6 +467,9 @@
// Tear down any live editor + blank the pane (rescope / popstate).
clearPreview: clearPreview,
// Expose for the markdown plugin so it can read file bytes.
getArrayBuffer: getArrayBuffer
getArrayBuffer: getArrayBuffer,
// Like getArrayBuffer but also returns the {etag, lastModified}
// version token — the editors use it for optimistic-concurrency saves.
getContentWithVersion: getContentWithVersion
};
})();

View file

@ -392,6 +392,14 @@
+ '<span class="tree-name__icon">' + iconChar + extChip + '</span>'
+ labelHtml(node)
+ virtualHint
// Kebab (⋯) — visible affordance that the row has actions; opens
// the same context menu. Revealed on hover/selection/focus (CSS).
// tabindex -1 keeps it out of the tab order (roving tabindex on
// the rows); reachable via right-click / the keyboard menu key.
+ '<button type="button" class="tree-row__kebab" tabindex="-1"'
+ ' aria-label="Row actions">'
+ window.zddc.icons.html('icon-ellipsis')
+ '</button>'
+ '</div>';
}

View file

@ -90,33 +90,100 @@
return false;
}
// Write content back to a file's source. Local (FS-Access) folders are
// Thrown by saveFile when the server rejects a write with 412
// Precondition Failed — the file changed under us since we loaded it.
// Callers branch on `.status === 412` to open the conflict UI instead
// of treating it as a generic save failure.
function ConflictError(message) {
var e = new Error(message || 'Conflict: file changed on server');
e.name = 'ConflictError';
e.status = 412;
return e;
}
// Write content back to a file's source, returning { etag } (the new
// server ETag, or null in FS-Access mode). Local (FS-Access) folders are
// picked read-only, so the first write escalates to readwrite via
// upload.ensureWritable (one permission prompt, then granted for the
// session). contentType sets the PUT Content-Type for server files.
// Throws when the source has no write target.
async function saveFile(node, content, contentType) {
//
// opts (server mode only):
// etag — send as `If-Match` so the master 412s if the file
// changed since we observed this version (optimistic
// concurrency; preferred — exact).
// lastModified — fallback precondition sent as `If-Unmodified-Since`
// (raw HTTP-date string) when no etag is available.
// force — skip the precondition entirely (deliberate overwrite).
//
// Throws ConflictError (.status===412) on a precondition failure, a
// plain Error('HTTP <status>') on any other non-2xx, or "no write
// target" when the source is read-only.
async function saveFile(node, content, contentType, opts) {
opts = opts || {};
if (node.handle && typeof node.handle.createWritable === 'function') {
var up = window.app.modules.upload;
if (up && up.ensureWritable) await up.ensureWritable();
var writable = await node.handle.createWritable();
await writable.write(content);
await writable.close();
return;
return { etag: null };
}
if (node.url && window.app.state.source === 'server') {
var headers = { 'Content-Type': contentType };
if (!opts.force) {
if (opts.etag) headers['If-Match'] = opts.etag;
else if (opts.lastModified) headers['If-Unmodified-Since'] = opts.lastModified;
}
var resp = await fetch(node.url, {
method: 'PUT',
headers: { 'Content-Type': contentType },
headers: headers,
body: content,
credentials: 'same-origin'
});
if (resp.status === 412) throw ConflictError();
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return;
return { etag: resp.headers.get('ETag') || null };
}
throw new Error('No write target for this file (read-only source).');
}
// Write `content` to a NEW sibling of `node` named
// `<stem>-conflict-<YYYYMMDD-HHMMSS>.<ext>` (server mode only), so a
// conflicting edit can be parked without losing either version. Probes
// for a free name (numeric-suffix bump, capped) so a same-second retry
// doesn't clobber a prior copy. Returns the created filename. The PUT
// uses no precondition — it's a brand-new path.
async function saveCopy(node, content, contentType) {
if (!(node.url && window.app.state.source === 'server')) {
throw new Error('Save a copy is only available for server files.');
}
var split = window.zddc.splitExtension(node.name);
var stem = split.name || node.name;
var ext = split.extension;
var d = new Date();
var stamp = d.getFullYear() + pad2(d.getMonth() + 1) + pad2(d.getDate())
+ '-' + pad2(d.getHours()) + pad2(d.getMinutes()) + pad2(d.getSeconds());
var base = stem + '-conflict-' + stamp;
var slash = node.url.lastIndexOf('/');
var dirUrl = slash >= 0 ? node.url.slice(0, slash + 1) : '';
var name = '', candidateUrl = '';
for (var i = 0; i < 20; i++) {
name = window.zddc.joinExtension(base + (i ? '-' + (i + 1) : ''), ext);
candidateUrl = dirUrl + encodeURIComponent(name);
var head;
try {
head = await fetch(candidateUrl, { method: 'HEAD', credentials: 'same-origin' });
} catch (_e) {
break; // network unknown — attempt the write rather than spin
}
if (head.status === 404) break; // free slot
if (head.status !== 200) break; // HEAD unsupported / odd — attempt anyway
if (i === 19) throw new Error('Could not find a free filename for the copy.');
}
await saveFile({ url: candidateUrl, name: name, ext: ext }, content, contentType, { force: true });
return name;
}
window.app.modules.util = {
escapeHtml: escapeHtml,
hashContent: hashContent,
@ -126,6 +193,8 @@
fetchAccessEmails: fetchAccessEmails,
fmtSize: fmtSize,
isZipMemberNode: isZipMemberNode,
saveFile: saveFile
saveFile: saveFile,
saveCopy: saveCopy,
ConflictError: ConflictError
};
})();

View file

@ -73,6 +73,25 @@
aria-label="Filter the tree by name, tracking number, status, revision, or title"
autocomplete="off"
spellcheck="false">
<div class="tree-pane__controls">
<button type="button" id="newFolderBtn" class="btn btn-sm btn--subtle"
title="New folder in the current directory">New folder</button>
<button type="button" id="newFileBtn" class="btn btn-sm btn--subtle"
title="New markdown file in the current directory">New file</button>
<label class="tp-control" title="Sort order">
<span class="tp-control__label">Sort</span>
<select id="sortSelect" aria-label="Sort order">
<option value="name:1">Name</option>
<option value="date:-1">Modified</option>
<option value="size:-1">Size</option>
<option value="ext:1">Type</option>
</select>
</label>
<label class="tp-control tp-control--check" title="Show hidden files (dot/underscore names)">
<input type="checkbox" id="showHiddenChk">
<span class="tp-control__label">Hidden</span>
</label>
</div>
</div>
<div class="tree-pane__body" id="treeBody" role="tree" aria-label="Files"></div>
</div>
@ -126,10 +145,16 @@
<dd>Recursive expand or collapse — the whole subtree.</dd>
<dt>Click a file</dt>
<dd>Preview it in the right pane.</dd>
<dt>Right-click any row</dt>
<dd>Opens a context menu with Open, Download, Copy path, Sort, and
folder-specific actions. Toggle items show a ✓ when active; submenus
open on hover.</dd>
<dt>Row actions — right-click, ⋯, or the menu key</dt>
<dd>Right-click a row, click the ⋯ button that appears on hover, or
press the menu key (or Shift+F10) on the selected row. The menu only
lists actions that apply to that item; actions you can see but can't
use yet (you lack write/create access, or they're for project or site
administrators) appear greyed with a reason — so you can see what a
higher role unlocks.</dd>
<dt>Toolbar (above the tree)</dt>
<dd>Filter, New folder / New file (created in the current directory),
Sort order, and Show hidden files all live here.</dd>
<dt>⤴ Pop out</dt>
<dd>Open the current preview in a separate window — useful for a second
monitor.</dd>

6
build
View file

@ -218,6 +218,12 @@ fi
cp "$SCRIPT_DIR/tables/dist/tables.html" "$SCRIPT_DIR/zddc/internal/handler/tables.html"
echo "Populated zddc/internal/handler/tables.html for //go:embed"
# Mirror the canonical conversion templates (pandoc/templates/) into the convert
# package's embed dir so //go:embed picks up the current bytes. pandoc/templates/
# is the single source of truth; the embed copy is a build artifact guarded by
# convert.TestEmbeddedTemplatesMatchSource. Runs on every build (incl. plain dev).
sync_pandoc_templates "$SCRIPT_DIR/pandoc/templates" "$SCRIPT_DIR/zddc/internal/convert/templates"
if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
# Assemble the embedded versions manifest from the per-tool .label sidecars

View file

@ -4,41 +4,52 @@ A collection of tools for converting Markdown documents to HTML with a professio
## Server-side conversion (`zddc-server`)
zddc-server can offer the same conversions on demand: a `.md` file in any
served directory becomes downloadable as `.docx`, `.html`, and `.pdf` via the
`?convert=` query parameter, surfaced as Download buttons in the browse app's
markdown editor.
> The shell scripts in this folder are standalone CLI/batch tools. `zddc-server`
> implements its **own** on-demand conversion (Go package `zddc/internal/convert`)
> and does **not** call these scripts. It does, however, reuse the same
> `templates/` (embedded at build time). See AGENTS.md → "Server-side document
> conversion" for the authoritative reference.
The server shells out to two upstream container images, pulling each on
first use via `--pull=missing`. No custom image build is required —
operators just install `podman` (preferred) or `docker`, and the first
conversion request pulls the image:
zddc-server can render any served `.md` on demand: requesting the sibling URL
`<path>/foo.docx` (or `.html` / `.pdf`) returns the converted bytes — no query
string. A real on-disk file of that name always wins; the virtual conversion
only fires when the requested file doesn't exist but `foo.md` does. The browse
app's markdown editor surfaces these as DOCX/HTML/PDF download links (auto-saving
a dirty buffer first so the output matches what's on screen).
- `docker.io/pandoc/latex:latest` — MD → DOCX and MD → HTML
(override: `--convert-pandoc-image=` or `ZDDC_CONVERT_PANDOC_IMAGE`;
switch to `docker.io/pandoc/core:latest` for a ~90% size reduction
if you don't need pandoc's native LaTeX-PDF path)
- `docker.io/zenika/alpine-chrome:latest` — HTML → PDF
(override: `--convert-chromium-image=` or `ZDDC_CONVERT_CHROMIUM_IMAGE`)
**Architecture.** The Go code does the minimum — it `exec`s `pandoc` and
`chromium-browser` directly. The sandbox and resource caps live in the runtime
**image**, where `/usr/local/bin/{pandoc,chromium-browser}` are wrapper scripts
that run the real binary inside a per-conversion bubblewrap sandbox
(`--unshare-all`, read-only binds, `--tmpfs /tmp`, `--clearenv`) under cgroup v2
memory/PID caps. I/O is via stdin/stdout plus a per-call scratch dir. There is no
container runtime and no image pulling at request time.
The PDF flow is two-stage: pandoc renders the markdown through
`viewer-template.html` to standalone HTML, then headless Chromium
prints that HTML to PDF. This preserves the existing print-media CSS
authored for the viewer template rather than going through pandoc's
LaTeX template.
The PDF flow is two-stage: pandoc renders the markdown through the selected
`templates/<doctype>.html` to standalone HTML, then headless Chromium prints that
HTML to PDF — preserving the template's print-media CSS rather than going through
pandoc's LaTeX template.
If neither podman nor docker is on PATH the endpoint serves 503 with
a clear "no container runtime" message. Engine choice is overridable
via `--convert-engine=` or `ZDDC_CONVERT_ENGINE`.
Converted bytes are cached at `<dir>/.zddc.d/converted/<base>.<ext>` with mtime
synced to the source, so a fresh cache hit is a stat-and-serve with no `exec`.
A PUT/DELETE/MOVE on the source `.md` purges the sidecars. Per-project header
metadata (client/project/contractor/project_number) comes from the `.zddc`
`convert:` cascade; title/tracking_number/revision/status are derived from the
filename via `zddc.ParseFilename`.
Resource limits are per-container and configurable: `--convert-mem-mib`
(default 512), `--convert-cpus` (default "2"), `--convert-pids`
(default 100), `--convert-timeout` (default 30s).
Relevant flags (defaults in parens):
Each conversion runs in a throw-away container with
`--rm --network=none --read-only --tmpfs=/tmp --cap-drop=ALL
--security-opt=no-new-privileges` plus a bind-mounted scratch dir
for I/O (read-only for the template; read-write for the PDF output).
- `--convert-pandoc-binary` (`pandoc`) / `--convert-chromium-binary`
(`chromium-browser`; `chromium` on Debian) — PATH-resolved name or absolute path
- `--convert-scratch-dir` (`$TMPDIR`) — host scratch root for template + intermediates
- `--convert-mem-mib` (`1024`) — per-conversion memory cap (cgroup `memory.max`)
- `--convert-pids` (`256`) — per-conversion PID cap (cgroup `pids.max`)
- `--convert-timeout` (`60s`) — per-conversion wall clock (Go `context.WithTimeout`)
If `pandoc`/`chromium` aren't on PATH (e.g. running zddc-server outside the runtime
image) the endpoint serves 503 with a `Retry-After`; the rest of the server keeps
working. Running against raw pandoc/chromium with no wrapper gives a working but
**unsandboxed** endpoint — fine for dev iteration.
## Features
@ -50,7 +61,15 @@ for I/O (read-only for the template; read-write for the PDF output).
- **Template integration**: Automatically applies the viewer template
- **Progress tracking**: Real-time conversion status and summary
### Professional Viewer Template (`viewer-template.html`)
### Professional templates (`templates/`)
Named doctype templates — `report.html`, `letter.html`, `specification.html`
share `_head.html` / `_doc.html` / `_scripts.html` partials. A document selects one
with a `template:` field in its YAML front matter (default `report`), and turns on
legal-style heading numbering with `numbering: true` (default off). Both fields are
read by pandoc straight from the front matter. Server deployments additionally
resolve per-project/per-party overrides from `.zddc.d/templates/<name>.html`.
- **Modern responsive design**: Works on desktop, tablet, and mobile
- **Table of Contents (TOC)**: Auto-generated sidebar navigation with smooth scrolling
- **Print optimization**: Professional formatting for PDF generation
@ -80,20 +99,18 @@ for I/O (read-only for the template; read-write for the PDF output).
```
### Configuration (`zddc.conf`)
Create a `zddc.conf` file in your project directory:
```ini
# Project metadata
title = "Project Documentation"
author = "Your Organization"
date = "2024"
# Template settings
template = "/path/to/viewer-template.html"
css = "custom-styles.css"
# Output settings
output_dir = "rendered"
Create a `zddc.conf` file in your project directory. It is **sourced as shell**,
so use `var="value"` syntax (no spaces around `=`). Only these four variables are
read; all are optional and feed the document header via pandoc `--variable`:
```sh
contractor="Contractor Name" # contracting organization (header)
client="Client Name" # client org (header, paired with project)
project="Project Name" # full project name
project_number="AR 28088" # shown in parentheses after the project name
```
The template path is discovered automatically (input dir → script dir →
symlink target) or set per-run with `-T`; the output directory is set with `-o`.
They are **not** `zddc.conf` keys.
### Directory Structure
```
@ -125,40 +142,12 @@ your-project/
- **Tablet**: Collapsible sidebar with overlay
- **Mobile**: Hamburger menu with full-screen TOC overlay
## Advanced Usage
### Custom Templates
You can customize the viewer template by:
1. Copying `viewer-template.html` to your project
2. Modifying the CSS and HTML structure
3. Updating `zddc.conf` to point to your custom template
### Batch Processing
For large document sets:
```bash
# Process all markdown files recursively
find . -name "*.md" -exec ./convert -f -o rendered/ {} +
# Process specific document types
./convert -f -o rendered/ *-SOW-*.md *-DBD-*.md
```
### Integration with Build Systems
The convert tool returns proper exit codes and can be integrated into CI/CD pipelines:
```bash
# In a build script
if ./convert -f -o dist/ *.md; then
echo "Documentation built successfully"
else
echo "Documentation build failed"
exit 1
fi
```
## File Types Supported
- **Input**: Markdown (`.md`) files with pandoc extensions
- **Output**: HTML files with embedded CSS and JavaScript
- **Input**: Markdown (`.md`), DOCX (`.docx`), and HTML (`.html`/`.htm`) files
(auto-detected: DOCX→MD, MD→HTML, HTML→MD; override with `-t md|html|docx`).
Direct DOCX→HTML is not supported — convert to MD first.
- **Output**: HTML files with embedded CSS and JavaScript (plus MD and DOCX targets)
- **Images**: Supports embedded images and diagrams
- **Tables**: Full table support with print optimization
- **Code**: Syntax highlighting for code blocks
@ -172,29 +161,7 @@ fi
## Troubleshooting
### Common Issues
1. **Template not found**: Ensure `zddc.conf` points to correct template path
1. **Template not found**: Keep the `templates/` directory beside the script (or input), or pass `-T /path/to/template.html`
2. **Permission errors**: Make sure `convert` script is executable (`chmod +x convert`)
3. **Missing output**: Check that output directory exists or use `-o` to create it
4. **Print issues**: Use "Print to PDF" in browser for best results
### Performance
- Large documents (>1000 pages) may take longer to render
- Consider splitting very large documents into sections
- Use batch processing for multiple files
## Examples
### Engineering Documentation
Perfect for:
- Design basis documents
- Specifications and standards
- Project requirements
- Technical procedures
- Quality documentation
### Features Optimized For
- **Professional appearance**: Clean, corporate styling
- **Technical content**: Tables, diagrams, code blocks
- **Print output**: PDF generation with proper formatting
- **Navigation**: Easy browsing of long documents
- **Sharing**: URL fragments for referencing specific sections

View file

@ -8,7 +8,8 @@ show_help() {
echo " -f: Force overwrite existing output files"
echo " -o: Output directory (default: same as input)"
echo " -t: Target format (md, html, docx) - overrides auto-detection"
echo " -T: Template file path (default: viewer-template.html)"
echo " -T: Template file path (default: templates/<template>.html, where <template>"
echo " comes from the doc's YAML front matter; falls back to templates/report.html)"
echo " --no-toc: Skip table of contents generation"
}
@ -124,6 +125,23 @@ SUCCESSFUL=0
FAILED=0
SKIPPED=0
# Parse a ZDDC filename stem (no extension) into ZDDC_TRACKING / ZDDC_REVISION /
# ZDDC_STATUS / ZDDC_TITLE. Returns 0 on a full match, 1 otherwise.
# Each field is extracted with its own sed backref rather than a delimiter-joined
# string + cut, so a title containing the join character (e.g. '|') can't corrupt
# the split.
parse_zddc_filename() {
local stem="$1"
local sub='s/^\([^_]*\)_\([^ ]*\) *(\([^)]*\)) *- *\(.*\)$'
# Gate on a full match before extracting (empty fields are otherwise ambiguous).
printf '%s\n' "$stem" | grep -Eq '^[^_]+_[^ ]+ *\([^)]*\) *- *.+$' || return 1
ZDDC_TRACKING=$(printf '%s\n' "$stem" | sed -n "${sub}/\\1/p")
ZDDC_REVISION=$(printf '%s\n' "$stem" | sed -n "${sub}/\\2/p")
ZDDC_STATUS=$(printf '%s\n' "$stem" | sed -n "${sub}/\\3/p")
ZDDC_TITLE=$(printf '%s\n' "$stem" | sed -n "${sub}/\\4/p")
return 0
}
# Function to convert DOCX to Markdown
convert_docx_to_md() {
local INPUT="$1"
@ -134,17 +152,15 @@ convert_docx_to_md() {
local FILENAME_NO_EXT="$6"
# Convert using pandoc with proper extension stripping to temp file first
if pandoc -f docx -t gfm --markdown-headings=atx --extract-media="$MEDIA_DIR" --wrap=none --standalone "$INPUT" -o "$TEMP_FILE"; then
if pandoc -f docx -t gfm --markdown-headings=atx --extract-media="$MEDIA_DIR" --wrap=none "$INPUT" -o "$TEMP_FILE"; then
# Parse ZDDC filename pattern: trackingNumber_revision (status) - title.extension
# Use sed to extract ZDDC components
ZDDC_MATCH=$(echo "$FILENAME_NO_EXT" | sed -n 's/^\([^_]*\)_\([^ ]*\) *(\([^)]*\)) *- *\(.*\)$/\1|\2|\3|\4/p')
if [ -n "$ZDDC_MATCH" ]; then
TRACKING_NUMBER=$(echo "$ZDDC_MATCH" | cut -d'|' -f1)
REVISION=$(echo "$ZDDC_MATCH" | cut -d'|' -f2)
STATUS=$(echo "$ZDDC_MATCH" | cut -d'|' -f3)
TITLE=$(echo "$ZDDC_MATCH" | cut -d'|' -f4)
if parse_zddc_filename "$FILENAME_NO_EXT"; then
TRACKING_NUMBER="$ZDDC_TRACKING"
REVISION="$ZDDC_REVISION"
STATUS="$ZDDC_STATUS"
TITLE="$ZDDC_TITLE"
echo " → ZDDC metadata detected:"
echo " • Tracking: $TRACKING_NUMBER"
echo " • Revision: $REVISION"
@ -154,8 +170,8 @@ convert_docx_to_md() {
# Create YAML front matter and combine with content
{
echo "---"
echo "client: \"${CLIENT:-}\""
echo "project: \"${PROJECT:-}\""
echo "client: \"${client:-}\""
echo "project: \"${project:-}\""
echo "tracking_number: \"$TRACKING_NUMBER\""
echo "revision: \"$REVISION\""
echo "status: \"$STATUS\""
@ -259,33 +275,44 @@ convert_md_to_html() {
fi
fi
# Default template discovery if no custom template or custom template not found
# Default template discovery if no custom template or custom template not found.
# Named templates live in a templates/ dir (report.html, letter.html,
# specification.html, sharing _head/_doc/_scripts partials). The document
# selects one via a `template:` field in its YAML front matter; default report.
if [ -z "$CUSTOM_TEMPLATE" ]; then
# Convert script directory to absolute path
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
# Check if script is a symlink and resolve target directory
SCRIPT_TARGET_DIR=""
if [ -L "$0" ]; then
# Script is a symlink - resolve the target fully
# readlink -f is available on Linux with GNU coreutils
SCRIPT_TARGET=$(readlink -f "$0")
SCRIPT_TARGET_DIR=$(dirname "$SCRIPT_TARGET")
fi
# Template search order: input dir, script dir, symlink target dir
if [ -f "$INPUT_DIR/viewer-template.html" ]; then
TEMPLATE_ABS="$INPUT_DIR/viewer-template.html"
echo " → Using template from input directory: $TEMPLATE_ABS"
elif [ -f "$SCRIPT_DIR/viewer-template.html" ]; then
TEMPLATE_ABS="$SCRIPT_DIR/viewer-template.html"
echo " → Using template from script directory: $TEMPLATE_ABS"
elif [ -n "$SCRIPT_TARGET_DIR" ] && [ -f "$SCRIPT_TARGET_DIR/viewer-template.html" ]; then
TEMPLATE_ABS="$SCRIPT_TARGET_DIR/viewer-template.html"
echo " → Using template from symlink target directory: $TEMPLATE_ABS"
else
echo " ⚠ Warning: viewer-template.html not found, using pandoc default template"
TEMPLATE_ABS=""
# Template name from the doc's front matter (sanitized to a bare basename).
TEMPLATE_NAME=$(sed -n '/^---[[:space:]]*$/,/^---[[:space:]]*$/ s/^template:[[:space:]]*"\{0,1\}\([A-Za-z0-9_-]\{1,\}\)"\{0,1\}[[:space:]]*$/\1/p' "$INPUT_ABS" | head -1)
[ -n "$TEMPLATE_NAME" ] || TEMPLATE_NAME="report"
# Search order: input dir, script dir, symlink target dir — each a templates/
# subdir. Use absolute paths since pandoc runs after a cd into the input dir.
INPUT_DIR_ABS=$(dirname "$INPUT_ABS")
TEMPLATE_ABS=""
for _tdir in "$INPUT_DIR_ABS/templates" "$SCRIPT_DIR/templates" "$SCRIPT_TARGET_DIR/templates"; do
[ -n "$_tdir" ] || continue
if [ -f "$_tdir/$TEMPLATE_NAME.html" ]; then
TEMPLATE_ABS="$_tdir/$TEMPLATE_NAME.html"
echo " → Using template: $TEMPLATE_ABS"
break
elif [ -f "$_tdir/report.html" ]; then
TEMPLATE_ABS="$_tdir/report.html"
echo " ⚠ Template '$TEMPLATE_NAME' not found; using $TEMPLATE_ABS"
break
fi
done
if [ -z "$TEMPLATE_ABS" ]; then
echo " ⚠ Warning: templates/ not found, using pandoc default template"
fi
fi
@ -293,8 +320,8 @@ convert_md_to_html() {
ORIGINAL_DIR=$(pwd)
cd "$INPUT_DIR"
# Build pandoc command using positional arguments (安全方式,无 eval)
# 以空格分隔的参数数组,避免 shell 注入
# Build pandoc command as an argument array (safe form, no eval — each value
# is a separate array element so it can't be re-split or injected by the shell).
PANDOC_ARGS=()
PANDOC_ARGS+=("--from" "markdown+yaml_metadata_block")
PANDOC_ARGS+=("--standalone")
@ -315,13 +342,12 @@ convert_md_to_html() {
# Extract ZDDC metadata from filename for template variables
FILENAME_NO_EXT=$(basename "$INPUT" .md)
ZDDC_MATCH=$(echo "$FILENAME_NO_EXT" | sed -n 's/^\([^_]*\)_\([^ ]*\) *(\([^)]*\)) *- *\(.*\)$/\1|\2|\3|\4/p')
if [ -n "$ZDDC_MATCH" ]; then
TRACKING_NUMBER=$(echo "$ZDDC_MATCH" | cut -d'|' -f1)
REVISION=$(echo "$ZDDC_MATCH" | cut -d'|' -f2)
STATUS=$(echo "$ZDDC_MATCH" | cut -d'|' -f3)
TITLE=$(echo "$ZDDC_MATCH" | cut -d'|' -f4)
if parse_zddc_filename "$FILENAME_NO_EXT"; then
TRACKING_NUMBER="$ZDDC_TRACKING"
REVISION="$ZDDC_REVISION"
STATUS="$ZDDC_STATUS"
TITLE="$ZDDC_TITLE"
# Pass ZDDC variables to template (each as separate args to avoid injection)
PANDOC_ARGS+=("--variable" "tracking_number=$TRACKING_NUMBER")
PANDOC_ARGS+=("--variable" "revision=$REVISION")
@ -357,11 +383,10 @@ convert_md_to_html() {
PANDOC_ARGS+=("--variable" "no-toc=true")
fi
PANDOC_ARGS+=("--section-divs")
PANDOC_ARGS+=("--id-prefix=")
# (--section-divs already added above)
PANDOC_ARGS+=("--html-q-tags")
# Run pandoc with positional arguments (安全方式)
# Run pandoc with positional arguments (safe form, no eval)
# All variables passed as separate arguments to avoid shell injection
if pandoc "$(basename "$INPUT_ABS")" -o "$OUTPUT_ABS" "${PANDOC_ARGS[@]}"; then

View file

@ -11,10 +11,10 @@ NO_TOC=false
show_help() {
echo "Batch Markdown Diff Converter"
echo "Compares pairs of markdown files and outputs HTML diffs using the same template as convert script"
echo "Usage: $0 [-f] [-o outputdir] [-T template] [--no-toc] file1_rev_a.md file1_rev_b.md [file2_rev_a.md file1_rev_b.md ...]"
echo "Usage: $0 [-f] [-o outputdir] [-T template] [--no-toc] file1_rev_a.md file1_rev_b.md [file2_rev_a.md file2_rev_b.md ...]"
echo " -f: Force overwrite existing output files"
echo " -o: Output directory (default: same as first input file)"
echo " -T: Template file path (default: viewer-template.html)"
echo " -T: Template file path (default: templates/report.html)"
echo " --no-toc: Skip table of contents generation"
echo ""
echo "Arguments:"
@ -350,58 +350,29 @@ while [ $# -gt 0 ]; do
fi
# Load ZDDC configuration from first file's directory
# (load_zddc_config logs the path itself, but only when a config is found)
FILE1_DIR=$(dirname "$FILE1")
load_zddc_config "$FILE1_DIR"
echo " → Loading ZDDC configuration from: $FILE1_DIR/zddc.conf"
# Determine template to use
# Determine template to use. Diffs render with the report template (its
# _head/_doc/_scripts partials live alongside it in templates/, so pandoc
# resolves them from the template's own directory).
TEMPLATE_ABS=""
if [ -n "$CUSTOM_TEMPLATE" ]; then
if [ -f "$CUSTOM_TEMPLATE" ]; then
TEMPLATE_ABS="$CUSTOM_TEMPLATE"
echo " → Using custom template: $TEMPLATE_ABS"
else
echo " → Custom template not found: $CUSTOM_TEMPLATE"
echo " → Falling back to default template"
echo " → Custom template not found: $CUSTOM_TEMPLATE; falling back to default"
fi
fi
# Check for symlinked template in current directory
if [ -z "$TEMPLATE_ABS" ] && [ -L "viewer-template.html" ]; then
TEMPLATE_TARGET=$(readlink "viewer-template.html")
if [ -f "$TEMPLATE_TARGET" ]; then
TEMPLATE_ABS="$TEMPLATE_TARGET"
echo " → Using template from symlink target: $TEMPLATE_ABS"
fi
fi
# Check for template in current directory
if [ -z "$TEMPLATE_ABS" ] && [ -f "viewer-template.html" ]; then
TEMPLATE_ABS="$(pwd)/viewer-template.html"
echo " → Using template from current directory: $TEMPLATE_ABS"
fi
# Resolve template to absolute path if it's relative
if [ -n "$TEMPLATE_ABS" ] && [ "${TEMPLATE_ABS:0:1}" != "/" ]; then
if [ -f "$TEMPLATE_ABS" ]; then
TEMPLATE_ABS="$(pwd)/$TEMPLATE_ABS"
elif [ -f "$SCRIPT_DIR/$TEMPLATE_ABS" ]; then
TEMPLATE_ABS="$SCRIPT_DIR/$TEMPLATE_ABS"
elif [ -f "$SCRIPT_TARGET_DIR/$TEMPLATE_ABS" ]; then
TEMPLATE_ABS="$SCRIPT_TARGET_DIR/$TEMPLATE_ABS"
elif [ -f "$SCRIPT_DIR/viewer-template.html" ]; then
TEMPLATE_ABS="$SCRIPT_DIR/viewer-template.html"
elif [ -f "$SCRIPT_TARGET_DIR/viewer-template.html" ]; then
TEMPLATE_ABS="$SCRIPT_TARGET_DIR/viewer-template.html"
fi
elif [ -z "$TEMPLATE_ABS" ]; then
# Fallback to script-relative template discovery
if [ -f "$SCRIPT_DIR/viewer-template.html" ]; then
TEMPLATE_ABS="$SCRIPT_DIR/viewer-template.html"
elif [ -f "$SCRIPT_TARGET_DIR/viewer-template.html" ]; then
TEMPLATE_ABS="$SCRIPT_TARGET_DIR/viewer-template.html"
fi
if [ -z "$TEMPLATE_ABS" ]; then
for _tdir in "$SCRIPT_DIR/templates" "$SCRIPT_TARGET_DIR/templates"; do
if [ -f "$_tdir/report.html" ]; then
TEMPLATE_ABS="$_tdir/report.html"
break
fi
done
fi
# Create temp file for pandiff output
@ -423,11 +394,7 @@ while [ $# -gt 0 ]; do
echo " ✓ Diff generated successfully"
echo "Stage 2: Adding TOC and styling with pandoc..."
# Extract revision info from filenames for metadata
REV_A=$(basename "$FILE1" .md | sed 's/.*_\([^_]*\)$/\1/')
REV_B=$(basename "$FILE2" .md | sed 's/.*_\([^_]*\)$/\1/')
# Extract metadata from both files (safe - no eval, uses heredoc)
{
# Extract YAML frontmatter and parse fields safely
@ -437,7 +404,6 @@ while [ $# -gt 0 ]; do
rev1_revision=$(grep '^revision:' "$TEMP_METADATA_REV1" | sed 's/^revision: *"\(.*\)"$/\1/' | head -1)
rev1_status=$(grep '^status:' "$TEMP_METADATA_REV1" | sed 's/^status: *"\(.*\)"$/\1/' | head -1)
rev1_project=$(grep '^project:' "$TEMP_METADATA_REV1" | sed 's/^project: *"\(.*\)"$/\1/' | head -1)
rev1_date=$(grep '^date:' "$TEMP_METADATA_REV1" | sed 's/^date: *"\(.*\)"$/\1/' | head -1)
}
{
awk '/^---$/{if(NR==1){p=1}else{p=0}} p && !/^---$/{print}' "$FILE2" > "$TEMP_METADATA_REV2"
@ -446,7 +412,6 @@ while [ $# -gt 0 ]; do
rev2_revision=$(grep '^revision:' "$TEMP_METADATA_REV2" | sed 's/^revision: *"\(.*\)"$/\1/' | head -1)
rev2_status=$(grep '^status:' "$TEMP_METADATA_REV2" | sed 's/^status: *"\(.*\)"$/\1/' | head -1)
rev2_project=$(grep '^project:' "$TEMP_METADATA_REV2" | sed 's/^project: *"\(.*\)"$/\1/' | head -1)
rev2_date=$(grep '^date:' "$TEMP_METADATA_REV2" | sed 's/^date: *"\(.*\)"$/\1/' | head -1)
}
# Clean up metadata temp files
@ -456,8 +421,9 @@ while [ $# -gt 0 ]; do
generate_diff_header() {
local header_html=""
# Project title (should be same for both)
header_html="<div class=\"header-line client-project\">$rev2_project (AR 28088)</div>"
# Project title (should be same for both). Append the project number from
# zddc.conf when set, e.g. "Project Name (AR 28088)"; omit the parens otherwise.
header_html="<div class=\"header-line client-project\">${rev2_project}${project_number:+ ($project_number)}</div>"
# Document title with diff
if [ "$rev1_title" != "$rev2_title" ]; then
@ -490,7 +456,7 @@ while [ $# -gt 0 ]; do
# Add draft marker if revision contains ~
if echo "$rev2_revision" | grep -q "~"; then
header_html="$header_html<div class=\"header-line metadata-line draft-line\"><span class=\"draft-status\">[DRAFT Generated at $(date '+%B %d, %Y at %I:%M:%S %p %Z')]</span></div>"
header_html="$header_html<div class=\"header-line metadata-line draft-line\"><span class=\"draft-status\">[DRAFT Generated at $(LC_TIME=C date '+%B %d, %Y at %I:%M:%S %p %Z')]</span></div>"
fi
echo "$header_html"
@ -498,35 +464,40 @@ while [ $# -gt 0 ]; do
DIFF_HEADER_HTML=$(generate_diff_header)
# Generate timestamp for conversion
GENERATION_TIME=$(date '+%B %d, %Y at %I:%M:%S %p %Z')
# Generate timestamp for conversion (force English locale, matching convert)
GENERATION_TIME=$(LC_TIME=C date '+%B %d, %Y at %I:%M:%S %p %Z')
# Set resource path to second file directory for resource resolution
FILE2_DIR=$(dirname "$FILE2")
# Escape HTML for safe shell usage
ESCAPED_HEADER_HTML=$(printf '%s' "$DIFF_HEADER_HTML" | sed 's/"/\\"/g')
# Build pandoc command as array (not string with eval)
# Build pandoc command as array (not string with eval). Header HTML is passed
# as a single array element below, so no shell escaping is needed — escaping the
# quotes here would leak backslashes into the rendered output.
PANDOC_ARGS=(
"pandoc" "$TEMP_DIFF" "-o" "$OUTPUT_FILE"
"--from" "html"
"--standalone"
"--template=$TEMPLATE_ABS"
)
# Only pass --template when one was actually found; pandoc errors on an empty
# --template= value, so fall back to its default template otherwise.
if [ -n "$TEMPLATE_ABS" ]; then
PANDOC_ARGS+=("--template=$TEMPLATE_ABS")
else
echo " ⚠ Warning: templates/report.html not found, using pandoc default template"
fi
# Add TOC args if not disabled
if [ "$NO_TOC" != "true" ]; then
PANDOC_ARGS+=("--toc" "--toc-depth=3")
fi
PANDOC_ARGS+=(
"--css=$SCRIPT_DIR/custom.css"
"--resource-path=$FILE2_DIR"
"--metadata" "title=$rev2_title"
"--metadata" "generation_time=$GENERATION_TIME"
"--metadata" "diff_mode=true"
"--metadata" "custom_header=$ESCAPED_HEADER_HTML"
"--metadata" "custom_header=$DIFF_HEADER_HTML"
)
# Add ZDDC configuration variables from zddc.conf (only once)
@ -548,7 +519,7 @@ while [ $# -gt 0 ]; do
PANDOC_ARGS+=("--variable" "no-toc=true")
fi
PANDOC_ARGS+=("--section-divs" "--id-prefix=" "--html-q-tags")
PANDOC_ARGS+=("--section-divs" "--html-q-tags")
# Execute pandoc via array (no eval)
if "${PANDOC_ARGS[@]}"; then

View file

@ -1,163 +0,0 @@
/*
* Legal-style heading numbering for ZDDC documents
* Adds hierarchical numbering like 1, 1.1, 1.1.1, etc.
*/
/* Reset counters at document level */
.document-content {
counter-reset: h1-counter;
}
/* H1 counters */
h1 {
counter-reset: h2-counter h3-counter h4-counter h5-counter h6-counter;
counter-increment: h1-counter;
}
h1::before {
content: counter(h1-counter) ". ";
font-weight: bold;
color: var(--primary-color);
}
/* H2 counters */
h2 {
counter-reset: h3-counter h4-counter h5-counter h6-counter;
counter-increment: h2-counter;
}
h2::before {
content: counter(h1-counter) "." counter(h2-counter) " ";
font-weight: bold;
color: var(--primary-color);
}
/* H3 counters */
h3 {
counter-reset: h4-counter h5-counter h6-counter;
counter-increment: h3-counter;
}
h3::before {
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) " ";
font-weight: bold;
color: var(--primary-color);
}
/* H4 counters */
h4 {
counter-reset: h5-counter h6-counter;
counter-increment: h4-counter;
}
h4::before {
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) " ";
font-weight: bold;
color: var(--primary-color);
}
/* H5 counters */
h5 {
counter-reset: h6-counter;
counter-increment: h5-counter;
}
h5::before {
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) "." counter(h5-counter) " ";
font-weight: bold;
color: var(--primary-color);
}
/* H6 counters */
h6 {
counter-increment: h6-counter;
}
h6::before {
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) "." counter(h5-counter) "." counter(h6-counter) " ";
font-weight: bold;
color: var(--primary-color);
}
/* TOC numbering to match document headings */
.toc {
counter-reset: toc-h1;
}
.toc ul {
list-style: none;
}
.toc > ul > li {
counter-increment: toc-h1;
counter-reset: toc-h2 toc-h3 toc-h4 toc-h5 toc-h6;
}
.toc > ul > li > a::before {
content: counter(toc-h1) ". ";
font-weight: bold;
color: var(--primary-color);
margin-right: 0.25em;
}
.toc > ul > li > ul > li {
counter-increment: toc-h2;
counter-reset: toc-h3 toc-h4 toc-h5 toc-h6;
}
.toc > ul > li > ul > li > a::before {
content: counter(toc-h1) "." counter(toc-h2) " ";
font-weight: bold;
color: var(--primary-color);
margin-right: 0.25em;
}
.toc > ul > li > ul > li > ul > li {
counter-increment: toc-h3;
counter-reset: toc-h4 toc-h5 toc-h6;
}
.toc > ul > li > ul > li > ul > li > a::before {
content: counter(toc-h1) "." counter(toc-h2) "." counter(toc-h3) " ";
font-weight: bold;
color: var(--primary-color);
margin-right: 0.25em;
}
/* Optional: Add some spacing after the numbers */
h1::before, h2::before, h3::before, h4::before, h5::before, h6::before {
margin-right: 0.5em;
}
/* Print-specific adjustments */
@media print {
h1::before, h2::before, h3::before, h4::before, h5::before, h6::before {
color: #000 !important; /* Ensure numbers print in black */
}
}
/* Optional: Style adjustments for better visual hierarchy */
h1 {
border-bottom: 2px solid var(--primary-color);
padding-bottom: 0.3em;
margin-top: 1em;
}
/* Reduce margin for first heading */
h1:first-of-type {
margin-top: 0.5em;
}
h2 {
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.2em;
margin-top: 1.5em;
}
h3 {
margin-top: 1.2em;
}
h4, h5, h6 {
margin-top: 1em;
}

View file

@ -13,11 +13,6 @@
set -e
cleanup() {
unset latest_files
}
trap cleanup EXIT
# Default output directory
OUTPUT_DIR=".archive"
@ -59,15 +54,21 @@ done
mkdir -p "$OUTPUT_DIR"
# Function to get relative path from $1 (base dir) to $2 (target path)
# Uses Python for portability (works on both GNU and BSD systems)
# Prefers python3 for portability (works on both GNU and BSD systems). Paths are
# passed as argv, not interpolated into the -c source, so quotes/specials in a
# path can't break or inject into the Python snippet.
relative_path() {
local base_dir="$1"
local target_path="$2"
if command -v python3 >/dev/null 2>&1; then
python3 -c "import os; print(os.path.relpath('$target_path', '$base_dir'))"
python3 -c 'import os, sys; print(os.path.relpath(sys.argv[1], sys.argv[2]))' \
"$target_path" "$base_dir"
elif realpath --relative-to=/ / >/dev/null 2>&1; then
# GNU realpath supports --relative-to; keep symlink targets relative.
realpath --relative-to="$base_dir" "$target_path"
else
# Fallback: use absolute paths if python3 not available
# Last resort: absolute path (still a valid symlink target, just not relative).
realpath "$target_path"
fi
}
@ -265,9 +266,13 @@ EOF
# Create truncated SHA256 for display
sha256_short="${sha256:0:6}...${sha256: -6}"
# Escape pipe chars so a title/status containing '|' can't break the table row
md_title=$(printf '%s' "$doc_title" | sed 's/|/\\|/g')
md_status=$(printf '%s' "$status" | sed 's/|/\\|/g')
# Add to markdown table
echo "| $row_counter | $tracking_link | $doc_title | $revision_link | $status | <span class=\"sha256\" title=\"$sha256\">$sha256_short</span> |" >> "$index_md_file"
echo "| $row_counter | $tracking_link | $md_title | $revision_link | $md_status | <span class=\"sha256\" title=\"$sha256\">$sha256_short</span> |" >> "$index_md_file"
echo " $filename -> symlinks created"
done < <(find "$folder" -maxdepth 1 \( -type f -o -type l \) -print0)

112
pandoc/templates/_doc.html Normal file
View file

@ -0,0 +1,112 @@
<div class="app-container">
$if(toc)$
<!-- Sidebar Navigation -->
<aside id="sidebar" role="complementary" aria-label="Table of contents">
<header class="sidebar-header">
<div class="toc-header-row">
<div class="sidebar-title">Table Of Contents</div>
<div class="toc-level-selector">
<select id="toc-level" aria-label="Filter table of contents levels">
<option value="1">1</option>
<option value="2">2</option>
<option value="3" selected>3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
</select>
</div>
</div>
</header>
<div class="toc-container">
$if(toc)$
<nav class="toc" role="navigation" aria-label="Table of contents">
$toc$
</nav>
$endif$
</div>
</aside>
$endif$
<!-- Main Content Area -->
<main class="content-wrapper" role="main">
<div class="content-page">
<!-- Document Header -->
<header class="document-header">
$if(toc)$
<div class="mobile-menu-container">
<button class="mobile-menu-toggle" type="button" aria-label="Toggle navigation menu" aria-expanded="false">
<span aria-hidden="true"></span>
</button>
</div>
$endif$
<div class="header-content">
$if(client)$$if(project)$
<div class="header-line client-project">
$client$ - $project$$if(project_number)$ ($project_number$)$endif$
</div>
$endif$$endif$
$if(title)$
<div class="document-title">$title$</div>
$endif$
<div class="document-meta">
$if(tracking_number)$<span class="tracking-number">$tracking_number$</span>$endif$
$if(revision)$<span class="revision">Revision: $revision$</span>$endif$
$if(status)$<span class="status">Status: $status$</span>$endif$
$if(revision_comparison)$<span class="revision-comparison">$revision_comparison$</span>$endif$
</div>
$if(is_draft)$
$if(generation_time)$
<div class="draft-line">
<span class="draft-status">[DRAFT Generated at $generation_time$]</span>
</div>
$endif$
$endif$
</div>
</header>
<!-- Scroll Progress Bar -->
<div class="scroll-progress" role="progressbar" aria-label="Reading progress">
<div class="scroll-progress-bar"></div>
</div>
<!-- Print-only header -->
<div class="print-header">
$if(custom_header)$
$custom_header$
$else$
$if(client)$$if(project)$
<div class="header-line client-project">
$client$ - $project$$if(project_number)$ ($project_number$)$endif$
</div>
$endif$$endif$
$if(title)$
<div class="header-line document-title">$title$</div>
$endif$
$if(tracking_number)$<div class="header-line">$tracking_number$$if(revision)$ Revision: $revision$$endif$$if(status)$ Status: $status$$endif$</div>$endif$
$if(revision_comparison)$<div class="header-line revision-comparison">$revision_comparison$</div>$endif$
$endif$
$if(generation_time)$
<div class="header-line metadata-line draft-line">
<span class="draft-status">Generated: $generation_time$</span>
</div>
$endif$
</div>
<!-- Print-only footer -->
<div class="print-footer">
<div class="footer-left">
$if(tracking_number)$$tracking_number$$endif$$if(revision)$ Revision: $revision$$endif$$if(status)$ Status: $status$$endif$
</div>
<div class="footer-right">
Page <span class="page-number"></span>
</div>
</div>
<!-- Document Content -->
<article class="document-content">
$body$
</article>
</div>
</main>
</div>

778
pandoc/templates/_head.html Normal file
View file

@ -0,0 +1,778 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>$if(title)$$title$$else$Document$endif$</title>
<!-- Document metadata for JavaScript -->
$if(revision)$<meta name="revision" content="$revision$">$endif$
$if(generation_time)$<meta name="generation_time" content="$generation_time$">$endif$
<!-- Embedded CSS -->
<style>
/*
* ZDDC Document Viewer Template
* Enhanced responsive layout with TOC navigation
*/
/* CSS Variables for theming - Soft Light Theme */
:root {
--primary-color: #2563eb;
--primary-color-dark: #1d4ed8;
--text-color: #4b5563;
--text-secondary: #6b7280;
--text-primary: #1f2937;
--bg-primary: #f8fafc;
--bg-secondary: #f1f5f9;
--border-color: #d1d5db;
--hover-bg: #e2e8f0;
--active-bg: rgba(37, 99, 235, 0.1);
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--sidebar-width: 280px;
--header-height: 120px;
--content-max-width: 900px;
}
/* Dark mode variables - Standard Dark Theme */
@media (prefers-color-scheme: dark) {
:root {
--primary-color: #60a5fa;
--primary-color-dark: #3b82f6;
--text-color: #d1d5db;
--text-secondary: #9ca3af;
--text-primary: #f9fafb;
--bg-primary: #111827;
--bg-secondary: #1f2937;
--border-color: #374151;
--hover-bg: #374151;
--active-bg: rgba(96, 165, 250, 0.2);
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
}
}
/* Reset and base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: var(--text-color);
background: var(--bg-secondary);
height: 100vh;
overflow-x: hidden;
}
@media print {
body {
height: auto !important;
overflow: visible !important;
}
}
/* App Container - Modern CSS Grid Layout */
.app-container {
height: 100vh;
display: grid;
grid-template-columns: var(--sidebar-width) 1fr;
grid-template-areas: "sidebar main";
}
@media (max-width: 768px) {
.app-container {
grid-template-columns: 1fr;
grid-template-areas: "main";
}
}
/* Content wrapper - Grid area */
.content-wrapper {
grid-area: main;
display: flex;
flex-direction: column;
min-height: 0;
max-width: min(900px, 100%);
margin: 0;
container-type: inline-size;
}
/* Content page simplified */
.content-page {
flex: 1;
display: flex;
flex-direction: column;
}
.header-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
/* Sidebar Navigation - Grid area */
#sidebar {
grid-area: sidebar;
height: 100vh;
background: var(--bg-primary);
border-inline-end: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow: hidden;
}
@media (max-width: 768px) {
#sidebar {
position: fixed;
inset: 0;
z-index: 1000;
transform: translateX(-100%);
transition: transform 0.3s ease;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
background: var(--bg-primary);
}
#sidebar.mobile-open {
transform: translateX(0);
}
.content-wrapper {
max-width: none;
}
/* Ensure mobile TOC uses light theme colors */
#sidebar .sidebar-header {
background: var(--bg-secondary);
color: var(--text-primary);
}
#sidebar .toc a {
color: var(--text-primary);
}
#sidebar .toc a:hover {
background: var(--hover-bg);
}
}
/* Document Header - Flex Row Layout */
.document-header {
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
padding: 1rem;
margin-bottom: 0;
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
}
.mobile-menu-container {
display: none;
flex-shrink: 0;
}
@media (max-width: 768px) {
.mobile-menu-container {
display: flex;
align-items: center;
}
.mobile-menu-toggle {
background: var(--primary-color);
color: white;
border: none;
padding: 0.75rem;
border-radius: 0.5rem;
font-size: 1.2rem;
cursor: pointer;
transition: all 0.2s ease;
line-height: 1;
}
.mobile-menu-toggle:hover {
background: var(--primary-color-dark);
transform: scale(1.05);
margin: 0;
}
}
.header-content {
flex: 1;
min-width: 0;
}
.sidebar-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.sidebar-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.toc-header-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.toc-level-selector {
display: flex;
align-items: center;
gap: 0.5rem;
}
.toc-level-selector select {
padding: 0.25rem 0.5rem;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
background: var(--bg-primary);
color: var(--text-color);
font-size: 0.9rem;
}
/* TOC Container */
.toc-container {
flex: 1;
overflow-y: auto;
padding: 1rem 0;
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
position: relative;
}
.toc-container::-webkit-scrollbar {
width: 6px;
}
.toc-container::-webkit-scrollbar-track {
background: transparent;
}
.toc-container::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
/* Scroll Progress Indicator */
.scroll-progress {
width: 100%;
height: 3px;
background: var(--border-color);
margin-bottom: 20px;
}
.scroll-progress-bar {
height: 100%;
background: var(--primary-color);
width: 0%;
transition: width 0.1s ease;
}
/* TOC Styling */
.toc ul {
list-style: none;
padding: 0;
margin: 0;
}
.toc ul ul {
padding-left: 1.25rem;
margin-top: 0.25rem;
border-left: 2px solid var(--border-color);
margin-left: 0.5rem;
}
.toc li {
margin: 0;
}
.toc a {
display: block;
padding: 0.375rem 0.75rem;
color: var(--text-color);
text-decoration: none;
border-radius: 4px;
transition: all 0.2s ease;
font-size: 0.9rem;
line-height: 1.3;
}
.toc li li a {
border-left: none;
}
.toc a:hover {
background: var(--hover-bg);
color: var(--primary-color);
}
.toc a.active {
background: var(--active-bg);
color: var(--primary-color);
border-left-color: var(--primary-color);
font-weight: 500;
}
/* Content Page Container - Simplified */
.content-page {
flex: 1;
background: var(--bg-primary);
display: flex;
flex-direction: column;
min-height: 0;
}
/* Document Content */
.document-content {
flex: 1;
padding: 0.5rem 2rem 2rem 2rem;
max-width: var(--content-max-width);
margin: 0 auto;
overflow-y: auto;
position: relative;
}
/* Document Header */
.document-header {
border-bottom: 1px solid var(--border-color);
padding: 0.75rem 2rem;
background: var(--bg-primary);
}
.header-content {
max-width: var(--content-max-width);
margin: 0 auto;
}
.header-line {
margin: 0;
line-height: 1.3;
}
/* Header line hierarchy */
.client-project {
font-size: 1.2rem;
color: var(--text-color);
font-weight: 600;
margin-bottom: 0.5rem;
}
.document-title {
font-size: 2.2rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.metadata-line {
font-size: 0.9rem;
color: var(--text-secondary);
font-weight: 400;
}
.draft-status {
color: #dc3545;
font-weight: bold;
margin-left: 0.5rem;
}
/* Print-only elements - hidden on screen */
.print-header,
.print-footer {
display: none;
}
/* Mobile menu backdrop */
@media (max-width: 768px) {
.mobile-menu-container {
display: flex;
align-items: center;
}
#sidebar.mobile-open::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: -1;
}
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
color: var(--text-primary);
font-weight: 600;
line-height: 1.25;
margin-top: 1em;
margin-bottom: 0.5em;
}
/* Remove top margin from first heading in content */
.document-content h1:first-child,
.document-content h2:first-child,
.document-content h3:first-child,
.document-content h4:first-child,
.document-content h5:first-child,
.document-content h6:first-child {
margin-top: 0;
}
h1 { font-size: 2rem; }
h2 { font-size: 1.5rem; }
h3 { font-size: 1.25rem; }
h4 { font-size: 1.125rem; }
h5 { font-size: 1rem; }
h6 { font-size: 0.875rem; }
p {
margin: 1rem 0;
color: var(--text-color);
}
/* Lists */
ol, ul {
margin: 1rem 0;
padding-left: 2rem;
}
ol {
list-style-type: decimal;
}
ul {
list-style-type: disc;
}
li {
margin: 0.25rem 0;
color: var(--text-color);
}
/* Nested lists */
ol ol, ul ul, ol ul, ul ol {
margin: 0.25rem 0;
padding-left: 1.5rem;
}
ul ul {
list-style-type: circle;
}
ul ul ul {
list-style-type: square;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
font-size: 0.9rem;
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
/* Print styles */
@media print {
/* Hide online-only elements */
.sidebar,
.mobile-menu-toggle,
.scroll-progress,
.document-header {
display: none !important;
}
/* Show print-only elements */
.print-header {
display: block !important;
position: fixed;
top: 0;
left: 0;
right: 0;
background: white;
border-bottom: 1pt solid #000;
padding: 12pt 0.5in;
z-index: 1000;
margin: 0;
}
.print-footer {
display: flex !important;
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
border-top: 1pt solid #000;
padding: 8pt 0.5in;
z-index: 1000;
justify-content: space-between;
align-items: center;
margin: 0;
}
/* Print header styling */
.print-header .client-project {
font-size: 12pt;
color: #333;
font-weight: 600;
margin: 0 0 4pt 0;
line-height: 1.2;
}
.print-header .document-title {
font-size: 16pt;
color: #000;
font-weight: 700;
margin: 0;
line-height: 1.2;
}
/* Print footer styling */
.print-footer .footer-left,
.print-footer .footer-right {
font-size: 10pt;
color: #666;
margin: 0;
}
/* Page counter for print */
.print-footer .page-number::after {
content: counter(page);
}
@page {
margin: 1in;
size: letter;
counter-increment: page;
}
.draft-line {
margin-top: 4pt;
font-size: 10pt;
}
/* Layout adjustments */
html, body {
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
}
.app-container {
display: block !important;
width: 100% !important;
max-width: 100% !important;
}
.content-wrapper {
margin-left: 0 !important;
width: 100% !important;
/* The screen layout caps content-wrapper at 900px; in print, the
printable area is page-width minus @page margins (~6.5in =
~624px for letter at 96dpi), which is narrower than 900px BUT
chromium's --print-to-pdf renders at the full page width and
only clips at print time — so without max-width:none the
element extends past the right margin. */
max-width: none !important;
}
.content-page {
max-width: none !important;
width: 100% !important;
padding: 0 !important;
}
.document-content {
margin-top: 80pt !important;
margin-bottom: 50pt !important;
padding: 0 0.5in !important;
border-left: none !important;
min-height: calc(100vh - 130pt) !important;
max-width: 100% !important;
box-sizing: border-box !important;
}
/* Wide content that wouldn't otherwise wrap: tables, code blocks,
long URLs in inline code. Force them to stay within the
printable area instead of running off the right edge. */
pre, code, table, blockquote, img, video {
max-width: 100% !important;
overflow-wrap: break-word !important;
word-wrap: break-word !important;
}
pre {
white-space: pre-wrap !important;
word-break: break-word !important;
}
table {
table-layout: fixed !important;
width: 100% !important;
}
/* Fix list formatting in print */
ol, ul {
padding-left: 2rem !important;
}
li {
margin: 0.25rem 0 !important;
}
/* Typography for print */
body {
font-size: 12pt !important;
line-height: 1.4 !important;
color: #000 !important;
background: white !important;
}
/* Page breaks */
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
page-break-inside: avoid;
margin-top: 0.5em;
}
p, li {
orphans: 3;
widows: 3;
page-break-inside: avoid;
}
/* Prevent content cutoff */
* {
box-sizing: border-box;
}
/* Ensure proper spacing at page breaks */
h1:first-child, h2:first-child, h3:first-child {
margin-top: 0;
padding-top: 0.5em;
}
/* Table print formatting */
table {
page-break-inside: avoid;
}
thead {
display: table-header-group;
}
tbody {
display: table-row-group;
}
tr {
page-break-inside: avoid;
}
th, td {
padding: 8pt 6pt !important;
vertical-align: top;
}
a {
color: #000 !important;
text-decoration: underline !important;
}
}
/* Diff styling for pandiff output */
u {
background-color: #d4edda;
color: #155724;
text-decoration: none;
padding: 0.1em 0.2em;
border-radius: 0.2em;
}
/*
* Legal-style heading numbering for ZDDC documents.
* Gated by the `numbered` body class, which the per-doctype templates add when
* the document's YAML front matter sets `numbering: true` (default: off).
*/
body.numbered .document-content { counter-reset: h1-counter; }
body.numbered h1 { counter-reset: h2-counter h3-counter h4-counter h5-counter h6-counter; counter-increment: h1-counter; }
body.numbered h1::before { content: counter(h1-counter) ". "; font-weight: bold; color: var(--primary-color); }
body.numbered h2 { counter-reset: h3-counter h4-counter h5-counter h6-counter; counter-increment: h2-counter; }
body.numbered h2::before { content: counter(h1-counter) "." counter(h2-counter) " "; font-weight: bold; color: var(--primary-color); }
body.numbered h3 { counter-reset: h4-counter h5-counter h6-counter; counter-increment: h3-counter; }
body.numbered h3::before { content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) " "; font-weight: bold; color: var(--primary-color); }
body.numbered h4 { counter-reset: h5-counter h6-counter; counter-increment: h4-counter; }
body.numbered h4::before { content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) " "; font-weight: bold; color: var(--primary-color); }
body.numbered h5 { counter-reset: h6-counter; counter-increment: h5-counter; }
body.numbered h5::before { content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) "." counter(h5-counter) " "; font-weight: bold; color: var(--primary-color); }
body.numbered h6 { counter-increment: h6-counter; }
body.numbered h6::before { content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) "." counter(h5-counter) "." counter(h6-counter) " "; font-weight: bold; color: var(--primary-color); }
/* TOC numbering to match document headings */
body.numbered .toc { counter-reset: toc-h1; }
body.numbered .toc ul { list-style: none; }
body.numbered .toc > ul > li { counter-increment: toc-h1; counter-reset: toc-h2 toc-h3 toc-h4 toc-h5 toc-h6; }
body.numbered .toc > ul > li > a::before { content: counter(toc-h1) ". "; font-weight: bold; color: var(--primary-color); margin-right: 0.25em; }
body.numbered .toc > ul > li > ul > li { counter-increment: toc-h2; counter-reset: toc-h3 toc-h4 toc-h5 toc-h6; }
body.numbered .toc > ul > li > ul > li > a::before { content: counter(toc-h1) "." counter(toc-h2) " "; font-weight: bold; color: var(--primary-color); margin-right: 0.25em; }
body.numbered .toc > ul > li > ul > li > ul > li { counter-increment: toc-h3; counter-reset: toc-h4 toc-h5 toc-h6; }
body.numbered .toc > ul > li > ul > li > ul > li > a::before { content: counter(toc-h1) "." counter(toc-h2) "." counter(toc-h3) " "; font-weight: bold; color: var(--primary-color); margin-right: 0.25em; }
body.numbered h1::before, body.numbered h2::before, body.numbered h3::before,
body.numbered h4::before, body.numbered h5::before, body.numbered h6::before { margin-right: 0.5em; }
@media print {
body.numbered h1::before, body.numbered h2::before, body.numbered h3::before,
body.numbered h4::before, body.numbered h5::before, body.numbered h6::before { color: #000 !important; }
}
/* Visual heading hierarchy that accompanies the numbered/legal look. */
body.numbered h1 { border-bottom: 2px solid var(--primary-color); padding-bottom: 0.3em; margin-top: 1em; }
body.numbered h1:first-of-type { margin-top: 0.5em; }
body.numbered h2 { border-bottom: 1px solid var(--border-color); padding-bottom: 0.2em; margin-top: 1.5em; }
body.numbered h3 { margin-top: 1.2em; }
body.numbered h4, body.numbered h5, body.numbered h6 { margin-top: 1em; }
/*
* Doctype-specific layout. `doctype` comes from the document's YAML front matter
* (report | specification | letter); the per-doctype template sets `doc-<name>`.
* A letter has no TOC sidebar and flows as a normal single column.
*/
body.doc-letter { height: auto; overflow: visible; }
body.doc-letter .content-wrapper { margin: 0 auto; max-width: var(--content-max-width); }
</style>
$for(header-includes)$
$header-includes$
$endfor$
</head>

View file

@ -0,0 +1,259 @@
<!-- Embedded JavaScript -->
<script>
'use strict';
// Modern initialization with arrow functions
document.addEventListener('DOMContentLoaded', function() {
// View mode toggle functionality
const buttons = document.querySelectorAll('.view-mode-btn');
const body = document.body;
buttons.forEach(button => {
button.addEventListener('click', function() {
const mode = this.dataset.mode;
// Remove all view mode classes
body.classList.remove('view-original', 'view-final');
// Add the selected mode class (except for diff which is default)
if (mode === 'original') {
body.classList.add('view-original');
} else if (mode === 'final') {
body.classList.add('view-final');
}
// Update button states
buttons.forEach(btn => btn.classList.remove('active'));
this.classList.add('active');
});
});
const sidebar = document.getElementById('sidebar');
if (sidebar) {
initTocNavigation();
}
// Set default TOC level filtering
filterTocLevels('3');
// Setup event listeners with delegation
setupEventListeners();
// Initialize print functionality
initPrintSupport();
});
// Modern TOC Navigation with ES6+ patterns
function initTocNavigation() {
const tocLinks = document.querySelectorAll('.toc a');
const contentArea = document.querySelector('.document-content');
if (!tocLinks.length || !contentArea) return;
// Smooth scroll with event delegation (better performance)
function handleTocClick(e) {
if (!e.target.matches('.toc a')) return;
e.preventDefault();
const href = e.target.getAttribute('href');
const targetId = href ? href.slice(1) : null;
const targetElement = targetId ? document.getElementById(targetId) : null;
if (!targetElement) return;
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Update URL hash without adding to browser history
window.location.replace(window.location.pathname + window.location.search + href);
// Update active state
tocLinks.forEach(link => link.classList.remove('active'));
e.target.classList.add('active');
// Close mobile menu if open
const sidebar = document.getElementById('sidebar');
if (sidebar && sidebar.classList.contains('mobile-open')) toggleMobileMenu();
};
document.addEventListener('click', handleTocClick);
// TOC scroll tracking using Intersection Observer API
// NOTE: Intersection Observer is the industry-standard, recommended approach for scroll spy
// implementations as of 2024. It provides better performance (runs off main thread),
// cleaner code, and is supported by all modern browsers. Avoid scroll event listeners
// for this use case as they are performance-intensive and require complex calculations.
// Find all sections with IDs - much simpler approach
const sections = Array.from(contentArea.querySelectorAll('section[id]'));
if (sections.length === 0) {
return;
}
function updateActiveTocItem(activeSection) {
if (!activeSection || !activeSection.id) return;
// Clear all active states
tocLinks.forEach(link => link.classList.remove('active'));
// Find and activate the matching TOC link
const activeLink = document.querySelector('.toc a[href="#' + activeSection.id + '"]');
if (!activeLink) return;
activeLink.classList.add('active');
// Auto-scroll TOC to keep active item visible
activeLink.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest'
});
};
// Create Intersection Observer with industry-standard configuration
const observer = new IntersectionObserver(function(entries) {
// Find visible sections and update active TOC item
const visibleSections = entries.filter(function(entry) { return entry.isIntersecting; });
if (visibleSections.length > 0) {
// Sort by position in viewport (topmost first)
visibleSections.sort(function(a, b) { return a.boundingClientRect.top - b.boundingClientRect.top; });
const activeSection = visibleSections[0].target;
updateActiveTocItem(activeSection);
}
}, {
root: contentArea,
rootMargin: '-20% 0px -60% 0px', // Only consider sections in the middle 20% of viewport
threshold: 0.1
});
// Observe all sections
sections.forEach(function(section) { observer.observe(section); });
// Scroll progress bar with throttling for better performance
const progressBar = document.querySelector('.scroll-progress-bar');
if (progressBar) {
let ticking = false;
function updateScrollProgress() {
const scrollTop = contentArea.scrollTop;
const scrollHeight = contentArea.scrollHeight;
const clientHeight = contentArea.clientHeight;
const scrollPercent = scrollHeight > clientHeight
? (scrollTop / (scrollHeight - clientHeight)) * 100
: 0;
progressBar.style.width = Math.min(100, Math.max(0, scrollPercent)) + '%';
ticking = false;
};
function onScroll() {
if (!ticking) {
requestAnimationFrame(updateScrollProgress);
ticking = true;
}
};
contentArea.addEventListener('scroll', onScroll, { passive: true });
updateScrollProgress(); // Initial call
}
};
// Toggle mobile menu with ARIA support
function toggleMobileMenu() {
const sidebar = document.getElementById('sidebar');
const menuToggle = document.querySelector('.mobile-menu-toggle');
if (!sidebar || !menuToggle) return;
const isOpen = sidebar.classList.toggle('mobile-open');
menuToggle.setAttribute('aria-expanded', isOpen.toString());
};
// Filter TOC levels with modern patterns
function filterTocLevels(maxLevel) {
const toc = document.querySelector('.toc');
if (!toc) return;
const allItems = toc.querySelectorAll('li');
const maxLevelNum = parseInt(maxLevel);
const showAll = maxLevel === '6';
allItems.forEach(function(item) {
const link = item.querySelector('a');
if (!link) return;
if (showAll) {
item.style.display = '';
return;
}
// Calculate nesting level more efficiently
let level = 1;
let parent = item.parentElement;
while (parent && !parent.classList.contains('toc')) {
if (parent.tagName === 'LI') level++;
parent = parent.parentElement;
}
item.style.display = level <= maxLevelNum ? '' : 'none';
});
};
// Setup event listeners with delegation
function setupEventListeners() {
// TOC level selector
const tocLevelSelect = document.getElementById('toc-level');
if (tocLevelSelect) tocLevelSelect.addEventListener('change', function(e) {
filterTocLevels(e.target.value);
});
// Mobile menu toggle
const menuToggle = document.querySelector('.mobile-menu-toggle');
if (menuToggle) menuToggle.addEventListener('click', toggleMobileMenu);
// Close mobile menu on outside click
document.addEventListener('click', function(e) {
const sidebar = document.getElementById('sidebar');
const menuToggle = document.querySelector('.mobile-menu-toggle');
if (sidebar && sidebar.classList.contains('mobile-open') &&
!sidebar.contains(e.target) &&
(!menuToggle || !menuToggle.contains(e.target))) {
toggleMobileMenu();
}
});
// Handle escape key to close mobile menu
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const sidebar = document.getElementById('sidebar');
if (sidebar && sidebar.classList.contains('mobile-open')) {
toggleMobileMenu();
}
}
});
};
// Initialize print support and draft status
function initPrintSupport() {
// Handle draft status for revisions containing tilde (~)
const revision = document.querySelector('meta[name="revision"]');
const generationTime = document.querySelector('meta[name="generation_time"]');
if (revision && generationTime) {
const revisionValue = revision.getAttribute('content');
const timeValue = generationTime.getAttribute('content');
if (revisionValue && revisionValue.includes('~') && timeValue) {
const draftElements = document.querySelectorAll('.draft-status');
draftElements.forEach(function(element) {
element.textContent = ' [DRAFT Generated at ' + timeValue + ']';
});
}
}
}
// Export functions for global access (maintaining backward compatibility)
window.toggleMobileMenu = toggleMobileMenu;
window.filterTocLevels = filterTocLevels;
</script>

View file

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
$_head()$
<body class="doc-letter$if(numbering)$ numbered$endif$">
<!-- Letter layout: single column, no TOC sidebar -->
<main class="content-wrapper" role="main">
<div class="content-page">
<!-- Letterhead -->
<header class="document-header">
<div class="header-content">
$if(client)$$if(project)$
<div class="header-line client-project">
$client$ - $project$$if(project_number)$ ($project_number$)$endif$
</div>
$endif$$endif$
$if(title)$
<div class="document-title">$title$</div>
$endif$
<div class="document-meta">
$if(date)$<span class="date">$date$</span>$endif$
$if(tracking_number)$<span class="tracking-number">$tracking_number$</span>$endif$
$if(revision)$<span class="revision">Revision: $revision$</span>$endif$
$if(status)$<span class="status">Status: $status$</span>$endif$
</div>
</div>
</header>
<!-- Print-only header -->
<div class="print-header">
$if(custom_header)$
$custom_header$
$else$
$if(client)$$if(project)$
<div class="header-line client-project">$client$ - $project$$if(project_number)$ ($project_number$)$endif$</div>
$endif$$endif$
$if(title)$<div class="header-line document-title">$title$</div>$endif$
$endif$
</div>
<!-- Print-only footer -->
<div class="print-footer">
<div class="footer-left">
$if(tracking_number)$$tracking_number$$endif$$if(revision)$ Revision: $revision$$endif$$if(status)$ Status: $status$$endif$
</div>
<div class="footer-right">Page <span class="page-number"></span></div>
</div>
<article class="document-content">
$body$
</article>
</div>
</main>
$_scripts()$
</body>
</html>

View file

@ -0,0 +1,9 @@
<!DOCTYPE html>
<html lang="en">
$_head()$
<body class="doc-report$if(numbering)$ numbered$endif$">
$_doc()$
$_scripts()$
</body>
</html>

View file

@ -0,0 +1,9 @@
<!DOCTYPE html>
<html lang="en">
$_head()$
<body class="doc-specification$if(numbering)$ numbered$endif$">
$_doc()$
$_scripts()$
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -55,6 +55,10 @@ export default defineConfig({
name: 'browse',
testMatch: 'browse.spec.js',
},
{
name: 'conflict',
testMatch: 'conflict.spec.js',
},
{
name: 'zddc-source',
testMatch: 'zddc-source.spec.js',

View file

@ -14,6 +14,11 @@
# so it stays registered (else its workspaces can't be recreated)
# - LEAVE archive/<party>/{received,issued} in place (the WORM record)
#
# Then, tree-wide, relocate any table.yaml / form.yaml config out of a
# directory root and into that directory's .zddc.d/ reserve (where the
# server now resolves specs from; the legacy root location still works, so
# this is a declutter, not a hard requirement).
#
# Per-folder .zddc files travel with their directory (the whole slot dir
# is moved). Idempotent: already-migrated paths are skipped. Run with the
# server stopped (or accept it's a plain filesystem move).
@ -112,6 +117,28 @@ synth_registry() {
synth=$((synth + 1))
}
# relocate_configs — move every <dir>/table.yaml and <dir>/form.yaml into
# <dir>/.zddc.d/. Tree-wide; skips files already under a .zddc.d/ and any
# destination that already exists. Uses find | while-read so directory names
# with spaces are handled (counters live in the subshell, so this pass just
# logs its actions rather than feeding the summary).
relocate_configs() {
find "$ROOT" -type f \( -name table.yaml -o -name form.yaml \) 2>/dev/null | while IFS= read -r f; do
case "$f" in
*/.zddc.d/*) continue ;;
esac
d=$(dirname "$f")
base=$(basename "$f")
dst="$d/.zddc.d/$base"
if [ -e "$dst" ]; then
say " .. skip (dest exists): $dst"
continue
fi
act "mkdir -p $d/.zddc.d" mkdir -p "$d/.zddc.d"
act "mv $f -> $dst" mv "$f" "$dst"
done
}
for projectdir in "$ROOT"/*/; do
[ -d "$projectdir/archive" ] || continue
project=$(basename "$projectdir")
@ -137,6 +164,11 @@ for projectdir in "$ROOT"/*/; do
done
done
# Tree-wide config relocation (runs over the now-migrated layout).
say ""
say "relocating table.yaml/form.yaml configs into .zddc.d/ …"
relocate_configs
say ""
say "summary: moved=$moved synthesized=$synth skipped=$skipped"
[ "$DRY" -eq 1 ] && say "(dry-run — nothing changed)"

View file

@ -83,6 +83,38 @@ concat_files() {
done
}
# Mirror the conversion templates from a canonical source dir into a build embed
# dir — go:embed can't follow symlinks, so the bytes must be a real copy under the
# Go package. Copies every *.html, drops stale destination *.html the source no
# longer has, and verifies byte-identity. Guarded at test time by
# convert.TestEmbeddedTemplatesMatchSource. Usage: sync_pandoc_templates <src> <dst>
sync_pandoc_templates() {
_src="$1"
_dst="$2"
if [ ! -d "$_src" ]; then
echo "error: missing template source dir: $_src" >&2
exit 1
fi
mkdir -p "$_dst"
# Drop destination templates the source no longer provides.
for _f in "$_dst"/*.html; do
[ -e "$_f" ] || continue
if [ ! -f "$_src/$(basename "$_f")" ]; then
rm -f "$_f"
fi
done
# Copy + verify each source template.
for _f in "$_src"/*.html; do
[ -e "$_f" ] || continue
cp "$_f" "$_dst/$(basename "$_f")"
if ! cmp -s "$_f" "$_dst/$(basename "$_f")"; then
echo "error: template sync mismatch: $_f" >&2
exit 1
fi
done
echo "Synced templates: $_src -> $_dst"
}
# ISO UTC build timestamp — set once when this file is sourced
build_timestamp=$(date -u +"%Y-%m-%d %H:%M:%S")

View file

@ -127,6 +127,12 @@
// one path instead of two.
+ '<symbol id="icon-chevron-right" viewBox="0 0 24 24">'
+ '<path d="m9 18 6-6-6-6"/>'
+ '</symbol>'
// Horizontal three-dot "kebab" — the per-row actions affordance.
+ '<symbol id="icon-ellipsis" viewBox="0 0 24 24">'
+ '<circle cx="12" cy="12" r="1"/>'
+ '<circle cx="19" cy="12" r="1"/>'
+ '<circle cx="5" cy="12" r="1"/>'
+ '</symbol>';
var injected = false;

View file

@ -22,13 +22,19 @@
// inline context (tests) or open the page through zddc-server.
async function load() {
const inline = readInlineContext();
if (inline && Object.keys(inline).length > 0) {
// A fully pre-assembled context (columns + rows) is used as-is — the
// test seam, or any host that renders the whole table server-side.
if (inline && Array.isArray(inline.columns)) {
return inline;
}
// Otherwise the inline context may still carry the server-injected
// SPEC ({spec, rowSchema}) sourced from <dir>/.zddc.d/ — pass it to
// walkServer, which uses it instead of fetching the spec and still
// walks the directory for row files.
if (typeof location !== 'undefined' &&
(location.protocol === 'http:' || location.protocol === 'https:')) {
try {
const walked = await walkServer();
const walked = await walkServer(inline || {});
if (walked) {
return walked;
}
@ -60,7 +66,8 @@
el.hidden = false;
}
async function walkServer() {
async function walkServer(injected) {
injected = injected || {};
const source = window.zddc && window.zddc.source;
if (!source) {
throw new Error('zddc.source not available');
@ -77,27 +84,32 @@
}
const dir = probe.handle;
// Spec lives at <currentdir>/table.yaml — the page URL is
// <currentdir>/table.html, so the spec is right next door.
const spec = await readYaml(dir, 'table.yaml');
// Spec: prefer the server-injected #table-context.spec (sourced from
// <dir>/.zddc.d/table.yaml). Falling back, read the spec from the
// supporting-files reserve, then the legacy directory root — the
// FS-Access path, where there's no server to inject.
let spec = (injected.spec && Array.isArray(injected.spec.columns))
? injected.spec : null;
if (!spec) {
spec = await readYamlFirst(dir, ['.zddc.d/table.yaml', 'table.yaml']);
}
if (!spec || !Array.isArray(spec.columns)) {
throw new Error('Spec table.yaml missing columns[]');
}
// Optional row schema from <dir>/form.yaml — same JSON Schema
// the form-mode renderer uses. Phase 2 derives per-cell editor
// widgets from it (text/number/date/select/checkbox).
// Best-effort: a directory with only table.yaml still renders
// as a sortable/filterable table; cells fall back to plain
// text inputs without per-property hints.
let rowSchema = null;
try {
const formSpec = await readYaml(dir, 'form.yaml');
if (formSpec && formSpec.schema) {
rowSchema = formSpec.schema;
// Row schema: prefer the injected #table-context.rowSchema, else read
// <dir>/.zddc.d/form.yaml (then legacy root). Best-effort — a table
// with no row schema still renders with plain-text cells.
let rowSchema = injected.rowSchema || null;
if (!rowSchema) {
try {
const formSpec = await readYamlFirst(dir, ['.zddc.d/form.yaml', 'form.yaml']);
if (formSpec && formSpec.schema) {
rowSchema = formSpec.schema;
}
} catch (_) {
// form.yaml missing or unreadable; carry on without it.
}
} catch (_) {
// form.yaml missing or unreadable; carry on without it.
}
// Rows are every *.yaml in <currentdir> EXCEPT the spec
@ -156,6 +168,22 @@
return window.jsyaml.load(text);
}
// readYamlFirst tries each relPath in order, returning the first that
// resolves + parses. Used to read a spec from the supporting-files
// reserve (.zddc.d/<name>) with a fallback to the legacy directory root.
async function readYamlFirst(dir, relPaths) {
let lastErr = null;
for (var i = 0; i < relPaths.length; i++) {
try {
return await readYaml(dir, relPaths[i]);
} catch (err) {
lastErr = err;
}
}
if (lastErr) throw lastErr;
return null;
}
// Walk a "/"-separated relative path under dir, returning the
// FileSystemFileHandle (or HttpFileHandle) at the leaf.
async function resolveFile(dir, relPath) {

View file

@ -207,3 +207,96 @@ test.describe('Browse', () => {
]);
});
});
// ── Menu harmonization: context-correct, capability/tier-driven ──────────
test.describe('Browse menu — context & tiers', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(MOCK_FS_INIT_SCRIPT);
});
async function openWithTree(page) {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
await page.evaluate(() => {
window.__setMockDirectoryTree('mock-folder', {
'a.txt': 'AAA',
'sub': { 'b.txt': 'BBB' },
});
});
await page.locator('#addDirectoryBtn').click();
await page.waitForSelector('#browseRoot:not(.hidden)', { timeout: 10000 });
}
test('file row OMITS New folder / New file (context-correct)', async ({ page }) => {
await openWithTree(page);
const fileRow = page.locator('.tree-row', { has: page.locator('.tree-name__label', { hasText: /^a\.txt$/ }) });
await fileRow.click({ button: 'right' });
await page.waitForSelector('.zddc-menu', { timeout: 5000 });
await expect(page.locator('.zddc-menu__item', { hasText: 'New folder' })).toHaveCount(0);
await expect(page.locator('.zddc-menu__item', { hasText: 'New file' })).toHaveCount(0);
// Copy path/name removed; Navigate into folded into Open.
await expect(page.locator('.zddc-menu__item', { hasText: 'Copy path' })).toHaveCount(0);
await expect(page.locator('.zddc-menu__item', { hasText: 'Navigate into' })).toHaveCount(0);
});
test('folder row SHOWS New folder (FS mode → create permitted), enabled', async ({ page }) => {
await openWithTree(page);
const folderRow = page.locator('.tree-row', { has: page.locator('.tree-name__label', { hasText: /^sub$/ }) });
await folderRow.click({ button: 'right' });
await page.waitForSelector('.zddc-menu', { timeout: 5000 });
const item = page.locator('.zddc-menu__item', { hasText: 'New folder' }).first();
await expect(item).toBeVisible();
await expect(item).not.toHaveClass(/is-disabled/);
});
test('permission-gated items are HIDDEN when not permitted, shown when permitted', async ({ page }) => {
await openWithTree(page);
// Pure-DOM unit over the declarative model in server mode.
const res = await page.evaluate(() => {
window.app.state.source = 'server';
function labels(verbs) {
const node = { name: 'doc.md', ext: 'md', isDir: false, isZip: false,
virtual: false, url: '/doc.md', verbs: verbs };
return window.app.modules.menuModel
.buildRowItems(node, null, { path_verbs: verbs })
.filter((i) => i.label).map((i) => i.label);
}
return { ro: labels('r'), rwd: labels('rwd') };
});
// read-only → no Rename/Delete (hidden, not greyed)
expect(res.ro).not.toContain('Rename…');
expect(res.ro).not.toContain('Delete…');
// read+write+delete → both present
expect(res.rwd).toContain('Rename…');
expect(res.rwd).toContain('Delete…');
});
test('toolbar Sort and Show-hidden drive state; New buttons present', async ({ page }) => {
await openWithTree(page);
await expect(page.locator('#newFolderBtn')).toBeVisible();
await expect(page.locator('#newFileBtn')).toBeVisible();
await page.locator('#sortSelect').selectOption('date:-1');
expect(await page.evaluate(() => window.app.state.sort)).toEqual({ key: 'date', dir: -1 });
await page.locator('#showHiddenChk').check();
expect(await page.evaluate(() => window.app.state.showHidden)).toBe(true);
});
test('keyboard menu key and kebab both open the row menu', async ({ page }) => {
await openWithTree(page);
const fileRow = page.locator('.tree-row', { has: page.locator('.tree-name__label', { hasText: /^a\.txt$/ }) });
// Kebab click opens the menu (no preview/toggle side-effect needed here).
await fileRow.click(); // select first
await fileRow.locator('.tree-row__kebab').click();
await expect(page.locator('.zddc-menu')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('.zddc-menu')).toHaveCount(0);
// Keyboard menu key (Shift+F10) opens it on the selected row.
await fileRow.click();
await page.keyboard.press('Shift+F10');
await expect(page.locator('.zddc-menu')).toBeVisible();
});
});

134
tests/conflict.spec.js Normal file
View file

@ -0,0 +1,134 @@
// conflict.spec.js — optimistic-concurrency save (If-Match → 412) + the
// shared conflict-resolution dialog in the browse tool.
//
// These drive the client modules directly against a stubbed fetch rather
// than a real master: the test zddc-server embeds the COMMITTED
// internal/apps/embedded/browse.html, not browse/dist/browse.html, so a
// server-mode E2E would run stale code. Loading the fresh dist build over
// file:// and stubbing fetch exercises exactly the code under test. Full
// server-mode behavior (the master's checkIfMatch → 412) is covered
// manually / on the bitnest dev server.
import { test, expect } from '@playwright/test';
import * as path from 'path';
const HTML_PATH = path.resolve('browse/dist/browse.html');
test.describe('Conflict / optimistic concurrency', () => {
test.beforeEach(async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
// init.js + util.js + conflict.js run synchronously on load.
await page.waitForFunction(
() => window.app && window.app.modules
&& window.app.modules.util && window.app.modules.conflict);
});
test('saveFile sends If-Match and throws ConflictError on 412', async ({ page }) => {
const result = await page.evaluate(async () => {
const calls = [];
window.fetch = async (url, opts) => {
calls.push({ url, opts });
return { status: 412, ok: false, headers: { get: () => null } };
};
window.app.state.source = 'server';
const node = { url: '/doc.md', name: 'doc.md' };
let status = null, name = null;
try {
await window.app.modules.util.saveFile(
node, 'hi', 'text/markdown; charset=utf-8', { etag: '"v1"' });
} catch (e) {
status = e.status;
name = e.name;
}
return {
sentIfMatch: calls[0] && calls[0].opts.headers['If-Match'],
method: calls[0] && calls[0].opts.method,
status, name
};
});
expect(result.method).toBe('PUT');
expect(result.sentIfMatch).toBe('"v1"');
expect(result.status).toBe(412);
expect(result.name).toBe('ConflictError');
});
test('saveFile returns the new ETag on success (re-edit loop)', async ({ page }) => {
const result = await page.evaluate(async () => {
window.fetch = async () => ({
status: 200, ok: true,
headers: { get: (h) => (h === 'ETag' ? '"v2"' : null) }
});
window.app.state.source = 'server';
const node = { url: '/doc.md', name: 'doc.md' };
const res = await window.app.modules.util.saveFile(
node, 'hi', 'text/markdown; charset=utf-8', { etag: '"v1"' });
return res;
});
expect(result.etag).toBe('"v2"');
});
test('saveFile omits the precondition when force is set', async ({ page }) => {
const sent = await page.evaluate(async () => {
let headers = null;
window.fetch = async (url, opts) => {
headers = opts.headers;
return { status: 200, ok: true, headers: { get: () => null } };
};
window.app.state.source = 'server';
await window.app.modules.util.saveFile(
{ url: '/doc.md', name: 'doc.md' }, 'hi', 'text/markdown',
{ etag: '"v1"', force: true });
return { hasIfMatch: 'If-Match' in headers };
});
expect(sent.hasIfMatch).toBe(false);
});
test('conflict dialog renders a diff and Overwrite resolves via the callback', async ({ page }) => {
// Kick off the dialog; stash the resolution + a flag the callback sets.
await page.evaluate(() => {
window.__conflict = { resolved: null, overwrote: false };
window.app.modules.conflict.open({
filename: 'doc.md',
mineText: 'line one\nMINE EDIT\nline three\n',
theirsText: 'line one\nTHEIR EDIT\nline three\n',
onOverwrite: async () => { window.__conflict.overwrote = true; },
onReload: async () => {},
onSaveCopy: async () => {}
}).then((r) => { window.__conflict.resolved = r; });
});
// Modal + diff present.
const overlay = page.locator('.md-history-overlay');
await expect(overlay).toBeVisible();
await expect(overlay.locator('.md-diff-add').first()).toBeVisible();
await expect(overlay.locator('.md-diff-del').first()).toBeVisible();
// Click "Overwrite (keep mine)".
await overlay.getByRole('button', { name: 'Overwrite (keep mine)' }).click();
await page.waitForFunction(() => window.__conflict.resolved !== null);
const outcome = await page.evaluate(() => window.__conflict);
expect(outcome.resolved).toBe('overwrite');
expect(outcome.overwrote).toBe(true);
await expect(overlay).toBeHidden();
});
test('conflict dialog Cancel resolves "cancel" and runs no callback', async ({ page }) => {
await page.evaluate(() => {
window.__c2 = { resolved: null, ran: false };
window.app.modules.conflict.open({
filename: 'doc.md',
mineText: 'a\n',
theirsText: 'b\n',
onOverwrite: async () => { window.__c2.ran = true; },
onReload: async () => { window.__c2.ran = true; }
}).then((r) => { window.__c2.resolved = r; });
});
const overlay = page.locator('.md-history-overlay');
await expect(overlay).toBeVisible();
await overlay.getByRole('button', { name: 'Cancel' }).click();
await page.waitForFunction(() => window.__c2.resolved !== null);
const outcome = await page.evaluate(() => window.__c2);
expect(outcome.resolved).toBe('cancel');
expect(outcome.ran).toBe(false);
});
});

View file

@ -61,7 +61,6 @@ There is no Containerfile / Dockerfile / compose file in this repo. Two ways to
| `ZDDC_OPA_URL` | `internal` | Policy decider endpoint. `internal` = built-in Go evaluator (default). `http(s)://...` or `unix:///...` = external OPA-compatible server (federal deployments using their own audited Rego). See "External policy decider" below. |
| `ZDDC_OPA_FAIL_OPEN` | *(empty)* | External OPA only. `1` = on transport error, allow the request (availability over correctness). Default = fail closed (deny). Never set to `1` in federal contexts. |
| `ZDDC_OPA_CACHE_TTL` | `1s` | External OPA only. Per-decision cache TTL — bursts of identical queries (a single `.archive` listing can hit the same `(email, dir)` tuple many times) collapse to one OPA round-trip. Set `0` to disable. Format is Go's `time.ParseDuration` (`500ms`, `2s`, `1m`). |
| `ZDDC_APPS_PUBKEY` | *(empty)* | Path to a PEM-encoded Ed25519 public key used to verify signatures on URL-fetched `apps:` artifacts. Empty (default) = URL-fetched apps refused; only embedded + local-path apps work. Operators using zddc.varasys.io's canonical channels download `pubkey.pem` from there and pass the local path here. Operators with their own signing infrastructure pass their own public key. Same posture as `ZDDC_TLS_CERT` — zddc-server bakes nothing in. **Alternative inline form:** the same key can be pasted as `apps_pubkey:` in the root `<ZDDC_ROOT>/.zddc` (root-only, like `admins:`). The env/flag wins when both are set. |
| `ZDDC_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
| `ZDDC_INDEX_PATH` | `.archive` | URL path segment name for the virtual archive index |
| `ZDDC_EMAIL_HEADER` | `X-Auth-Request-Email` | HTTP request header containing the authenticated user's email (the oauth2-proxy / nginx auth-request convention) |
@ -672,14 +671,15 @@ naive intuition suggests.
at `zddc/internal/zddc/file.go:17-20` (and `IsAdmin`) only reads root. This is
the only upward-escalation gate; subtree write access never grants admin.
4. **An `apps:` URL override is a full UI mount, not just a tool version pin.**
Any `.zddc` writer in a subtree can pin `archive: https://attacker.example/...`
and serve arbitrary HTML to every viewer below that level. Subtree write
authority on `.zddc` should be treated as full UI-mounting authority. The
`_app/` cache is fetch-once-and-keep — operators clear it by deleting
`<ZDDC_ROOT>/_app/<scheme>/<host>[:<port>]/<path>`. (See "Apps: virtual tool HTMLs" below for
the resolver order; SHA-256 pinning is on the federal-readiness list, not
currently implemented.)
4. **Dropping a tool HTML on disk is a full UI mount, not just a file.**
A real `<app>.html` at a path — or an `<app>.html` member of the site
`<ZDDC_ROOT>/.zddc.zip` bundle — is served verbatim to every viewer at or
below that scope. So write access to a directory is effectively UI-mounting
authority for it, and write access to `<root>/.zddc.zip` is a **site-wide**
UI mount (treat it like `admins:` — keep the root writable only by admins).
There is no remote fetch and nothing to sign: the bytes are whatever sits on
the local filesystem, governed by the same ACL/WORM as any other file. (See
"Apps: virtual tool HTMLs" below for the resolver order.)
5. **Relying on `/Archive/` being unbrowsable to "hide" sibling vendor folders'
existence.** Sibling-vendor names are hidden because directories the caller
@ -742,10 +742,12 @@ guarantee these for the model above to hold:
logging, ship the JSON-line file to an external append-only sink (syslog,
SIEM) via a sidecar; do not treat the local rotation as the system of
record.
4. **`apps:` URL fetches have no integrity check.** Fetched once on first
miss, cached at `<ZDDC_ROOT>/_app/<scheme>/<host>[:<port>]/<path>` forever — no SHA-256 pin,
no signature. Use only URLs you control, treat the apps cache as a trust
boundary, and audit who has `.zddc` write authority where.
4. **Tool-HTML overrides are local files, not fetched/signed.** A tool's HTML
comes from a real file at the path, an `<app>.html` member of the site
`<ZDDC_ROOT>/.zddc.zip`, or the embedded default — never the network. There
is nothing to verify; the trust boundary is filesystem write access. Audit
who can write tool HTML at each scope (especially `<root>/.zddc.zip`, a
site-wide UI mount).
### Debugging permissions
@ -949,9 +951,9 @@ have to redo the gap analysis from scratch.
subcommand. See §"Policy export for change control" below.
- **Supply-chain integrity** (NIST SI-7) — vendored libs (jszip,
docx-preview, xlsx) need SBOM, CVE tracking, automated update pipeline.
`apps:` URL fetches need code signing (operator trusts a published
public key once; no per-artifact hash management). See §"Code-signed
apps: URL fetches" below.
Tool HTML is no longer fetched at runtime (overrides are local files /
the site `.zddc.zip` bundle, governed by filesystem ACL), so there is no
remote-artifact signing requirement here.
- **Data-at-rest encryption** (NIST SC-28) — delegated to the deployment
platform. Required: documented baseline (cloud KMS, LUKS, dm-crypt) with
key-rotation procedures.
@ -962,11 +964,10 @@ have to redo the gap analysis from scratch.
A full SSP / control-by-control mapping consumes this list as input; it is
not a substitute for one.
The four bullets most likely to need engineering depth — FIPS, the
authenticated proxy channel, policy export, and signed `apps:` URL
fetches — have their own subsections below capturing the design
considerations and effort estimates so a future implementor doesn't
restart from zero.
The bullets most likely to need engineering depth — FIPS, the
authenticated proxy channel, and policy export — have their own
subsections below capturing the design considerations and effort
estimates so a future implementor doesn't restart from zero.
#### FIPS-validated cryptography (NIST SC-13)
@ -1164,77 +1165,6 @@ match — small graph problem), and the format renderers.
**Effort estimate:** ~250 lines of Go (CLI subcommand + equivalence-
class computation + JSON/Markdown/CSV renderers) + ~100 lines of tests.
#### Code-signed `apps:` URL fetches (NIST SI-7)
**The supply-chain risk today.** The `apps:` mechanism fetches a URL
once on first request, caches the bytes forever, and serves them to
every viewer below that level. There's no integrity check. If the
fetched URL is ever compromised — DNS hijack, CDN account takeover,
malicious upstream commit, MITM during the one fetch window — every
customer caches the bad bytes. The blast radius is "every user who
visits an archive page in a subtree where this `.zddc` applies."
**Why code signing instead of SHA-256 pinning.** SHA-256 pinning would
require operators to track-and-update a hash in `.zddc` every time an
artifact changes. Wrong workflow for this product. Code signing
sidesteps the operator entirely:
- Release pipeline signs each artifact once at publish time with a
long-lived private key.
- Operator trusts the published public key once and never deals with
hashes.
- zddc-server fetches the URL, downloads the detached signature
(e.g. `<artifact>.sig`), verifies against the configured public key,
caches if valid.
**Implementation has three parts** that interlock:
1. **Signing in the build pipeline.** `./build release` runs
`sign_release_artifacts` (in `./build`) after promote: walks
`dist/release-output/` and produces a detached Ed25519 signature
(`<artifact>.sig`) alongside every real file. Private key path comes
from `ZDDC_SIGNING_KEY`; absent or unreadable → release fails.
Symlinks (the canonical `<tool>.html` and `zddc-server_<platform>`
URLs) skip — the .sig at the symlink target is what counts; a
companion `.sig` symlink chains the canonical URL to that target.
2. **Public key on the website.** `pubkey.pem` is a real file in
`~/src/zddc-website/`, deployed to `zddc.varasys.io/pubkey.pem`.
The releases-page index includes a "Verify your downloads"
section with a download link, the SHA-256 fingerprint shown in
plain text, and a `curl + openssl pkeyutl -verify` example.
3. **Verifier in zddc-server (`apps/fetch.go`).** When fetching a
URL-pinned `apps:` artifact, also fetch `<url>.sig`, then call
`VerifyEd25519` against `Fetcher.VerifyKey`. The key is loaded
at startup with this resolution order:
1. `--apps-pubkey` / `ZDDC_APPS_PUBKEY` (path to PEM file)
2. `apps_pubkey:` inline PEM in the root `.zddc` file (root-only,
same trust-anchor treatment as `admins:`)
3. nothing → URL fetches refused
Failure cases at fetch time — sig 404, transport error, wrong key,
tampered body — all reject; the body is dropped and the apps
resolver falls back to the embedded copy. No baked-in default
public key; same posture as TLS certificates.
**Trust model.** The operator decides which signing infrastructure to
trust by configuring `--apps-pubkey`. The website publishes the
canonical-channel public key; operators who use `apps: archive: stable`
download it once, save the file on their server, and configure the
path. Operators running their own signing point at their own pubkey
instead. zddc-server has no opinion.
**Future direction.** A `signed_by:` field per-`apps:` entry would let
a single deployment trust multiple signing keys (one for canonical
channels, one for an in-house mirror). Sigstore integration
(transparency-log-backed signing via `github.com/sigstore/sigstore`)
is the federally-acceptable evolution. Both are additive on top of
the current single-key-per-server model.
**What's currently in place.** All three parts. The scaffolding
matches the design above one-to-one; future enhancements are
extensions, not refactors.
### External policy decider (OPA-compatible)
For deployments that need policy decisions made by an external,
@ -1521,8 +1451,8 @@ fsnotify watcher's debounce window (~2 s) — no service restart needed.
`zddc-server` virtually serves the tool HTMLs (archive, transmittal,
classifier, landing, browse, form, tables) at the appropriate paths.
The current-stable build of each tool is **baked into the binary at
compile time** via `//go:embed`; that's the default. No fetch happens
out of the box.
compile time** via `//go:embed`; that's the default. Overrides are
**local only** — there is no network fetch, ever.
### Where each tool is served
@ -1536,40 +1466,40 @@ out of the box.
Outside these locations, the corresponding `<app>.html` URL returns 404.
### Override and version-pin
### Override (local only)
For any path, the resolution order is:
1. **Real file at the path**operator drops `archive.html` (or any other)
into a directory; the static handler serves it. Beats everything below.
2. **Closer-to-leaf `.zddc apps:` entry** — walks `.zddc` files leaf→root
for an `apps.<app>` entry. The first match wins. Spec is one of:
- `stable` (canonical upstream "current stable")
- `v0.0.4` (canonical upstream exact-version pin)
- `https://...` (full URL to a custom mirror)
- `./local.html` / `/abs/path.html` (local file)
1. **Real file at the path**drop a real `archive.html` (or `browse.html`,
or a brand-new `mytool.html`) into a directory; the static handler serves
it. Beats everything below.
2. **Site bundle `<ZDDC_ROOT>/.zddc.zip`** — a local zip whose `<app>.html`
members override the embedded default site-wide (and let you add new
`<name>.html` tools). The server reads members from the filesystem via
`internal/zipfs` — no fetch, no signature. The bundle is re-stat'd on each
request, so dropping in a new one takes effect immediately.
3. **Embedded** — the build-time HTML compiled into the binary.
URL sources are fetched once on first request and cached forever in
`<ZDDC_ROOT>/_app/<scheme>/<host>[:<port>]/<path>`. There is no background refresh and no
hash verification — to pull a new build, delete the cache file. Concurrent
misses for the same URL share one outbound fetch (singleflight). Direct
URL access to `/_app/...` is blocked at dispatch; cached HTMLs are served
only via the apps resolver.
If a configured URL fetch fails (network down, 5xx), the server falls back
to the embedded copy and emits a one-time WARN log per source. The
`X-ZDDC-Source` response header always reports what was served:
`fetch:URL`, `cache:URL`, `path:/abs`, or `embedded:<app>@<build>`.
There is no `apps:` `.zddc` key, no channels/versions, no URL fetching, and no
signature verification — all removed in favour of this local model. `.zddc.zip`
is config, not content: a direct `GET /.zddc.zip` returns 404 for everyone,
while the server reads its members internally (so resolution works for any
user). The `X-ZDDC-Source` response header reports what was served:
`bundle:<app>.html` or `embedded:<app>@<build>` (an on-disk override is served
by the static handler with its own headers).
### Example
```yaml
# <ZDDC_ROOT>/Project-A/.zddc
apps:
classifier: v0.0.4 # pin classifier to v0.0.4 for this project
archive: https://my-mirror.internal/zddc/archive_v0.0.4.html # custom mirror, pinned
browse: ./our-browse.html # local fork
Override `browse` everywhere and add a custom `report` tool via the bundle:
```sh
cd <ZDDC_ROOT> && zip .zddc.zip browse.html report.html
```
Or override a single tool in one project by dropping a file:
```sh
cp our-browse.html <ZDDC_ROOT>/Project-A/browse.html
```
### Env vars

View file

@ -507,52 +507,12 @@ func newGzipWrapper() (func(http.Handler) http.HandlerFunc, error) {
return gzhttp.NewWrapper(gzhttp.MinSize(1024))
}
// setupApps creates the cache + fetcher + server. No seeding, no refresh,
// no admin UI — the server fetches once on first request, caches forever
// in <ZDDC_ROOT>/.zddc.d/apps/, and falls back to the embedded HTML on any failure.
// setupApps builds the tool-HTML server. Resolution is LOCAL-ONLY: a real
// file on disk at the request path (handled upstream by dispatch) → a
// "<app>.html" member of the site-root <ZDDC_ROOT>/.zddc.zip bundle → the
// embedded default. No fetch, no cache, no signatures.
func setupApps(cfg config.Config) (*apps.Server, error) {
cache, err := apps.NewCache(filepath.Join(cfg.Root, handler.ReservedSidecar, apps.CacheDirName))
if err != nil {
return nil, fmt.Errorf("create cache: %w", err)
}
fetcher := apps.NewFetcher(cache, slog.Default())
// Apps signing pubkey. Resolution order, highest priority first:
// 1. --apps-pubkey / ZDDC_APPS_PUBKEY (path to PEM file)
// 2. apps_pubkey: inline PEM in the root <ZDDC_ROOT>/.zddc file
// (root-only — same trust-anchor treatment as admins:)
// 3. nothing → URL-fetched apps refuse-by-default; only embedded
// + local-path apps work
//
// Same posture as TLS certificates: zddc-server bakes nothing in.
// Operators using zddc.varasys.io's canonical channels download
// pubkey.pem from there and either configure the path via env/flag
// or paste the PEM contents inline into root .zddc.
switch {
case cfg.AppsPubKey != "":
pub, err := apps.LoadPubKey(cfg.AppsPubKey)
if err != nil {
return nil, fmt.Errorf("apps-pubkey: %w", err)
}
fetcher.VerifyKey = pub
slog.Info("apps signing pubkey loaded", "source", "env/flag", "path", cfg.AppsPubKey)
default:
// Fall back to apps_pubkey: in root .zddc.
rootZddc, err := zddc.ParseFile(filepath.Join(cfg.Root, ".zddc"))
if err == nil && rootZddc.AppsPubKey != "" {
pub, err := apps.ParsePubKeyPEM([]byte(rootZddc.AppsPubKey))
if err != nil {
return nil, fmt.Errorf("root .zddc apps_pubkey: %w", err)
}
fetcher.VerifyKey = pub
slog.Info("apps signing pubkey loaded", "source", "root .zddc apps_pubkey")
} else {
slog.Warn("apps-pubkey not configured; URL-fetched apps will be refused (only embedded + local-path apps will work). " +
"Set --apps-pubkey, ZDDC_APPS_PUBKEY, or apps_pubkey: in the root .zddc file to a PEM Ed25519 public key you trust.")
}
}
return apps.NewServer(cfg.Root, cache, fetcher, version), nil
return apps.NewServer(cfg.Root, version), nil
}
// warnIfNoBootstrap fires a startup slog.Warn when the root .zddc grants
@ -663,6 +623,14 @@ func embeddedVersionsForLog(embedded map[string]string) string {
// authenticated user (may be empty).
func serveSpecializedNoSlash(cfg config.Config, appsSrv *apps.Server, w http.ResponseWriter, r *http.Request, dirAbs, urlPath, email string) bool {
app := apps.DefaultAppAt(cfg.Root, dirAbs)
// An explicit `views.dir` in the cascade overrides the default_tool-
// derived app for the no-slash directory URL — the generalization's
// dir-shape routing. default_tool remains the sugar fallback (ViewAt
// returns it when no views.dir is declared), so existing deployments
// are unaffected.
if v, ok := zddc.ViewAt(cfg.Root, dirAbs, "dir"); ok && v.Tool != "" {
app = v.Tool
}
if app == "" {
return false
}
@ -741,6 +709,30 @@ func splitZipPath(fsRoot, urlPath string) (zipAbs, member string, ok bool) {
return "", "", false
}
// activeAdminForBundle reports whether the request principal is an active
// (elevated) admin over the directory that holds the .zddc.zip config bundle
// referenced by urlPath. Mirrors handler.ActiveAdminForSidecar: the bundle is
// existence-hidden config for everyone else, but an elevated admin over its
// directory may browse its members and download it. Works for every bundle URL
// shape (bare, trailing-slash listing, and <bundle>/<member>) since it keys off
// the path segment that precedes the bundle name.
func activeAdminForBundle(cfg config.Config, r *http.Request, urlPath string) bool {
p := handler.PrincipalFromContext(r)
if !p.Elevated || p.Email == "" {
return false
}
parent := make([]string, 0)
for _, seg := range strings.Split(strings.Trim(urlPath, "/"), "/") {
if strings.EqualFold(seg, apps.BundleName) {
break
}
parent = append(parent, seg)
}
dir := filepath.Join(cfg.Root, filepath.FromSlash(strings.Join(parent, "/")))
chain, _ := zddc.EffectivePolicy(cfg.Root, dir)
return zddc.IsAdminForChain(chain, p.Email)
}
// dispatch routes a request to the appropriate handler.
func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, appsSrv *apps.Server, tokens *auth.Store, w http.ResponseWriter, r *http.Request) {
// URL paths are case-insensitive: resolve each segment against the
@ -838,6 +830,29 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return
}
// The site-root config bundle <ZDDC_ROOT>/.zddc.zip is config, not
// ordinary content: existence-hidden over HTTP for everyone EXCEPT an
// active (elevated) admin over its directory, who may browse it in the
// file tree. For an admin every bundle URL falls through to normal
// handling — GET <bundle>/ lists its members (the zip-as-directory
// intercept below), GET <bundle>/member extracts one, and a bare
// GET <bundle> downloads it. Everyone else gets 404 for every form,
// which also keeps individual members from being fetched by name. The
// server reads members from the filesystem internally (apps.Bundle) to
// resolve tool HTML — that path never goes through dispatch, so this
// gate doesn't affect resolution.
bundlePath := false
for _, seg := range segments {
if strings.EqualFold(seg, apps.BundleName) {
bundlePath = true
break
}
}
if bundlePath && !activeAdminForBundle(cfg, r, urlPath) {
http.NotFound(w, r)
return
}
// Raw .zddc YAML view: <dir>/.zddc is reachable at every depth
// and returns the on-disk file's bytes (Content-Type: application/yaml)
// or — when no file exists — a synthetic placeholder body with a
@ -1259,6 +1274,27 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return
}
// views.file → form editor. A browser NAVIGATION (Accept: text/html) to a
// no-slash data file whose cascade declares views.file = {tool: form}
// serves the form editor bound to that file. Programmatic reads — the
// tables client fetches rows with Accept: */* — and an explicit ?raw fall
// through to the raw bytes (the injected-row / ServeFile path below), so
// this never breaks row fetching. The POST goes to the canonical
// <file>.yaml.html update URL (the existing form-update handler).
if r.Method == http.MethodGet && !r.URL.Query().Has("raw") &&
strings.Contains(r.Header.Get("Accept"), "text/html") {
if v, ok := zddc.ViewAt(cfg.Root, filepath.Dir(absPath), "file"); ok && v.Tool == "form" {
fr := &handler.FormRequest{
Kind: "render-edit",
SpecPath: filepath.Join(filepath.Dir(absPath), "form.yaml"),
DataPath: absPath,
SubmitURL: urlPath + ".html",
}
handler.ServeForm(cfg, fr, w, r)
return
}
}
// (MD→{docx,html,pdf} on-demand conversion now lives at
// `GET /<dir>/<file>.{docx,html,pdf}` (virtual file URL,
// see RecognizeVirtualConvert). The .md source serves

View file

@ -4,18 +4,14 @@ import (
"archive/zip"
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
@ -100,60 +96,22 @@ func TestDispatchReservesZddcD(t *testing.T) {
}
}
// TestDispatchAppsResolution drives the full apps fetch+cache flow through
// dispatch() with a fake upstream. Confirms that:
// - GET / serves the landing app from the apps subsystem
// - GET /archive.html serves the archive app via fetch+cache
// - second GET /archive.html serves from cache (X-ZDDC-Source: cache:)
// - direct URL access to the reserved cache (/.zddc.d/apps/...) is rejected
// TestDispatchAppsResolution drives local tool-HTML resolution through
// dispatch(): the site .zddc.zip member overrides the embedded default, the
// embedded default is served when no bundle member exists, GET / serves
// landing, the bundle itself is 404 over HTTP, and folder-availability rules
// still gate which tools are served where.
func TestDispatchAppsResolution(t *testing.T) {
root := t.TempDir()
body := []byte("<!doctype html>archive content")
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
sig := ed25519.Sign(priv, body)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Same body for every artifact; same signature for every .sig
// (since the body is identical across the five tools in this
// fixture). Real deployments publish a distinct .sig per
// artifact; the test only cares that the verify gate passes.
if strings.HasSuffix(r.URL.Path, ".sig") {
_, _ = w.Write(sig)
return
}
w.Header().Set("ETag", `"v1"`)
_, _ = w.Write(body)
}))
defer upstream.Close()
upstreamURL, _ := url.Parse(upstream.URL)
upstreamHost := upstreamURL.Host
if i := strings.Index(upstreamHost, ":"); i >= 0 {
upstreamHost = upstreamHost[:i]
}
_ = upstreamHost // referenced below
// Seed root .zddc with subdir-cascade Apps entries pointing at the
// fake upstream. Allow all email patterns (anonymous) so the test
// doesn't have to set up email headers.
zf := zddc.ZddcFile{
ACL: zddc.ACLRules{Permissions: map[string]string{"*": "rwcd"}},
Apps: map[string]string{
"archive": upstream.URL + "/archive_stable.html",
"transmittal": upstream.URL + "/transmittal_stable.html",
"classifier": upstream.URL + "/classifier_stable.html",
"landing": upstream.URL + "/landing_stable.html",
"browse": upstream.URL + "/browse_stable.html",
},
}
// Allow-all ACL so the test doesn't need email headers.
zf := zddc.ZddcFile{ACL: zddc.ACLRules{Permissions: map[string]string{"*": "rwcd"}}}
if err := zddc.WriteFile(root, zf); err != nil {
t.Fatalf("WriteFile: %v", err)
}
// Create folder convention dirs so classifier/browse/transmittal
// availability rules pass for the test paths used below.
// Site config bundle overriding archive.html.
writeRootBundle(t, root, map[string]string{"archive.html": "<!doctype html>BUNDLE archive"})
// Folder-convention dir so classifier availability passes below.
mustMkdir(t, filepath.Join(root, "Project-A", "working", "Acme"))
idx, err := archive.BuildIndex(root)
@ -166,47 +124,33 @@ func TestDispatchAppsResolution(t *testing.T) {
EmailHeader: "X-Auth-Request-Email",
}
ring := handler.NewLogRing(10)
appsSrv, err := setupApps(cfg)
if err != nil {
t.Fatalf("setupApps: %v", err)
}
// Override the production embedded public key with the test fixture's
// pubkey so signature verification of upstream.Sign'd bodies succeeds.
appsSrv.Fetcher.VerifyKey = pub
// GET /archive.html → fetched from upstream (archive is available everywhere)
// GET /archive.html → served from the bundle member (overrides embedded).
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("first /archive.html: status=%d body=%s", rec.Code, rec.Body.String())
dispatch(cfg, idx, ring, appsSrv, nil, rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil))
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "BUNDLE archive") {
t.Fatalf("/archive.html: status=%d body=%s (want bundle override)", rec.Code, rec.Body.String())
}
if rec.Body.String() != string(body) {
t.Errorf("first /archive.html: body mismatch")
if rec.Header().Get("X-ZDDC-Source") != "bundle:archive.html" {
t.Errorf("X-ZDDC-Source=%q, want bundle:archive.html", rec.Header().Get("X-ZDDC-Source"))
}
// GET /archive.html again → cache hit (no new upstream fetch)
rec2 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec2, httptest.NewRequest(http.MethodGet, "/archive.html", nil))
if rec2.Code != http.StatusOK {
t.Errorf("second /archive.html: status=%d", rec2.Code)
}
// GET / → landing
// GET / → landing (no bundle member → embedded).
rec3 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec3, httptest.NewRequest(http.MethodGet, "/", nil))
if rec3.Code != http.StatusOK {
t.Errorf("GET /: status=%d", rec3.Code)
}
// The apps cache lives under the reserved sidecar (.zddc.d/apps/); direct
// URL access by a non-admin is 404'd by the sidecar gate, so cached HTML
// can only ever be served through the apps resolver (proper headers/ACL).
// The site bundle is config, not content: a direct GET is 404 for everyone.
rec4 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec4, httptest.NewRequest(http.MethodGet, "/.zddc.d/apps/foo.html", nil))
dispatch(cfg, idx, ring, appsSrv, nil, rec4, httptest.NewRequest(http.MethodGet, "/.zddc.zip", nil))
if rec4.Code != http.StatusNotFound {
t.Errorf("/.zddc.d/apps/ direct: status=%d, want 404", rec4.Code)
t.Errorf("GET /.zddc.zip: status=%d, want 404", rec4.Code)
}
// Folder availability rules: classifier should NOT be served at root
@ -277,10 +221,6 @@ func TestDispatchRootAppShellPublicButDataGated(t *testing.T) {
}
}
// silence "imported and not used" if apps not referenced elsewhere — keep
// import even when we trim test cases later.
var _ = apps.DefaultUpstream
// TestDispatchRoutesWritesToFileAPI verifies dispatch sends PUT/DELETE/POST
// to the file API rather than to the read pipeline.
func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
@ -1094,3 +1034,162 @@ func TestGzhttpWrapper_CompressesLargeResponses(t *testing.T) {
// dot-prefix guard, like any bookkeeping, and surfaced only through the
// history endpoints. Raw-block coverage is in TestDispatchHidesDotPrefixedSegments;
// the viewer is covered in mdhistory_test.go.)
// writeRootBundle writes <root>/.zddc.zip containing the given members.
// Used by dispatch tests exercising the local tool-HTML bundle override.
func writeRootBundle(t *testing.T, root string, members map[string]string) {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for name, body := range members {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("zip create %s: %v", name, err)
}
if _, err := w.Write([]byte(body)); err != nil {
t.Fatalf("zip write %s: %v", name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
if err := os.WriteFile(filepath.Join(root, ".zddc.zip"), buf.Bytes(), 0o644); err != nil {
t.Fatalf("write bundle: %v", err)
}
}
// TestDispatchFileToFormView locks in the views.file → form shape: a browser
// NAVIGATION (Accept: text/html) to a no-slash data file, in a dir whose
// cascade declares views.file = {tool: form}, serves the form editor bound to
// that file — while a programmatic fetch (Accept: */*, the tables client) and
// an explicit ?raw still get raw bytes, so row fetching never breaks. A dir
// without views.file keeps serving raw bytes on navigation too.
func TestDispatchFileToFormView(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"*\": rwcd\n")
dir := filepath.Join(root, "Proj", "records")
mustMkdir(t, dir)
// views.file declared on the records dir → form editor for its files.
mustWrite(t, filepath.Join(dir, ".zddc"),
"views:\n file:\n tool: form\n")
// Form schema lives in the supporting-files reserve.
mustMkdir(t, filepath.Join(dir, ".zddc.d"))
mustWrite(t, filepath.Join(dir, ".zddc.d", "form.yaml"),
"schema:\n type: object\n properties:\n title:\n type: string\n")
mustWrite(t, filepath.Join(dir, "rec1.yaml"), "title: Hello\n")
// A sibling file in a dir WITHOUT views.file stays raw.
mustWrite(t, filepath.Join(root, "Proj", "plain.yaml"), "x: 1\n")
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
cfg := config.Config{Root: root, IndexPath: ".archive", EmailHeader: "X-Auth-Request-Email"}
ring := handler.NewLogRing(10)
appsSrv, err := setupApps(cfg)
if err != nil {
t.Fatalf("setupApps: %v", err)
}
do := func(path string, hdr map[string]string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
for k, v := range hdr {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
return rec
}
// Navigation → form editor HTML.
rec := do("/Proj/records/rec1.yaml", map[string]string{"Accept": "text/html"})
if rec.Code != http.StatusOK {
t.Fatalf("navigation: status=%d body=%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Errorf("navigation Content-Type=%q, want text/html (form)", ct)
}
if strings.Contains(rec.Body.String(), "title: Hello") {
t.Errorf("navigation served raw YAML, want the form editor")
}
// Programmatic fetch (Accept: */*) → raw YAML bytes.
rec2 := do("/Proj/records/rec1.yaml", map[string]string{"Accept": "*/*"})
if rec2.Code != http.StatusOK || !strings.Contains(rec2.Body.String(), "title: Hello") {
t.Errorf("fetch: status=%d body=%q, want raw YAML", rec2.Code, rec2.Body.String())
}
// ?raw escape hatch → raw bytes even for a browser.
rec3 := do("/Proj/records/rec1.yaml?raw=1", map[string]string{"Accept": "text/html"})
if !strings.Contains(rec3.Body.String(), "title: Hello") {
t.Errorf("?raw body=%q, want raw YAML", rec3.Body.String())
}
// No views.file declared → navigation still serves raw bytes.
rec4 := do("/Proj/plain.yaml", map[string]string{"Accept": "text/html"})
if !strings.Contains(rec4.Body.String(), "x: 1") {
t.Errorf("no-views file body=%q, want raw YAML", rec4.Body.String())
}
}
// TestDispatchBundleAdminView locks in admin-mode visibility of the site-root
// .zddc.zip config bundle: an active (elevated) admin may browse it as a zip
// directory (list members, extract a member) and download it, while everyone
// else — including the same admin un-elevated, and non-admins — gets 404 for
// every bundle URL shape (closing the previous by-name member read).
func TestDispatchBundleAdminView(t *testing.T) {
root := t.TempDir()
// alice is a root admin; bob is a plain reader.
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"alice@x\": rwcda\n \"bob@x\": r\nadmins:\n - alice@x\n")
writeRootBundle(t, root, map[string]string{
"archive.html": "<!doctype html>BUNDLE archive",
"sub/note.txt": "a member note",
})
idx, err := archive.BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
cfg := config.Config{Root: root, IndexPath: ".archive", EmailHeader: "X-Auth-Request-Email"}
ring := handler.NewLogRing(10)
appsSrv, err := setupApps(cfg)
if err != nil {
t.Fatalf("setupApps: %v", err)
}
do := func(path, email string, elevated bool) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
ctx := context.WithValue(req.Context(), handler.EmailKey, email)
ctx = context.WithValue(ctx, handler.ElevatedKey, elevated)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
return rec
}
// Elevated admin: member listing, member extract, and bare download all work.
if rec := do("/.zddc.zip/", "alice@x", true); rec.Code != http.StatusOK {
t.Errorf("admin GET /.zddc.zip/ : status=%d body=%s, want 200 listing", rec.Code, rec.Body.String())
}
if rec := do("/.zddc.zip/archive.html", "alice@x", true); rec.Code != http.StatusOK ||
!strings.Contains(rec.Body.String(), "BUNDLE archive") {
t.Errorf("admin GET member: status=%d body=%s, want 200 member bytes", rec.Code, rec.Body.String())
}
if rec := do("/.zddc.zip", "alice@x", true); rec.Code != http.StatusOK {
t.Errorf("admin GET bare /.zddc.zip : status=%d, want 200 download", rec.Code)
}
// Same admin un-elevated → 404 (sudo model: powers are per-request).
if rec := do("/.zddc.zip/", "alice@x", false); rec.Code != http.StatusNotFound {
t.Errorf("un-elevated admin GET /.zddc.zip/ : status=%d, want 404", rec.Code)
}
// Non-admin reader → 404 for listing AND by-name member (no leak).
if rec := do("/.zddc.zip/", "bob@x", true); rec.Code != http.StatusNotFound {
t.Errorf("non-admin GET /.zddc.zip/ : status=%d, want 404", rec.Code)
}
if rec := do("/.zddc.zip/archive.html", "bob@x", true); rec.Code != http.StatusNotFound {
t.Errorf("non-admin GET member: status=%d, want 404 (no by-name leak)", rec.Code)
}
}

View file

@ -1,415 +1,22 @@
// Package apps serves the ZDDC tool HTML files (archive, transmittal,
// classifier, landing, browse, form, tables) on virtual paths in the
// file tree. Each tool is "available" only at directories whose name
// matches a folder convention (Incoming/Working/Staging) — see
// availability.go. The markdown editor lives as a plugin inside browse.
// classifier, landing, browse, form, tables) on virtual paths in the file
// tree. Each tool is "available" only at directories whose cascade selects
// it (default_tool / dir_tool / available_tools) — see availability.go and
// the .zddc cascade. The markdown editor lives as a plugin inside browse.
//
// Resolution priority for an enabled <dir>/<app>.html request:
// Tool HTML resolution is LOCAL-ONLY — no network fetch, no signatures, no
// channels/versions. For an enabled <dir>/<app>.html request the bytes come
// from, in precedence:
//
// 1. Real file at the request path → static handler (operator override).
// 2. Subdir cascade — walk .zddc files root→leaf, accumulating URL prefix
// and channel/version components from the special `apps.default` key
// and the per-app `apps.<name>` key. Either component can be set,
// overridden, or left to inherit at any level. Path or full-`.html`-URL
// entries are *terminal* — they short-circuit composition and a deeper
// non-terminal entry overrides a parent terminal.
// 3. Embedded fallback — bytes baked into the binary at compile time via
// //go:embed. Used when no `apps:` entry was found anywhere up the chain.
// 1. A real file on disk at the request path → static handler (operator
// override; handled by the dispatcher BEFORE Serve is ever reached, so
// by the time Serve runs no such file exists).
// 2. A member of the site-root config bundle <ZDDC_ROOT>/.zddc.zip, named
// "<app>.html", read server-side via internal/zipfs (see bundle.go).
// 3. The embedded default baked into the binary at compile time via
// //go:embed (see embed.go).
//
// Spec forms (each is a string value in `.zddc apps:`):
//
// :stable / :v0.0.4 — channel-only
// stable / v0.0.4 / 0.0.4 — channel-only (no leading colon)
// https://host/path — URL-prefix only (combines with cascade channel)
// https://host/path:stable — URL-prefix + channel (composes)
// https://host/path/file.html — terminal full URL (used as-is)
// ./local.html / /abs/local.html — terminal local path
//
// No background refresh, no SHA-256 verification. To pick up new upstream
// bytes, delete the cache file (or the whole .zddc.d/apps/ tree).
// To change a tool's HTML, drop a file at the path, drop "<app>.html" into
// .zddc.zip, or rebuild the binary. There is no `apps:` .zddc key and no
// upstream fetch — both were removed in favour of this local model.
package apps
import (
"fmt"
"net/url"
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// DefaultUpstream is where channel and version shorthand specs resolve when
// no `apps.default` URL prefix is configured anywhere up the chain.
const DefaultUpstream = "https://zddc.varasys.io"
// DefaultUpstreamReleases is the prefix appended to DefaultUpstream when
// composing the canonical upstream URL.
const DefaultUpstreamReleases = DefaultUpstream + "/releases"
// DefaultChannel is the channel shorthand used when nothing in the chain
// specifies one.
const DefaultChannel = "stable"
// CacheDirName is the directory under <ZDDC_ROOT>/.zddc.d/ where fetched URL
// sources are cached. Living under the reserved .zddc.d/ sidecar means the
// cache is hidden from listings and admin-gated for direct URL access like all
// other server bookkeeping (see handler.ReservedSidecar); the resolver itself
// reads/writes it via the filesystem, not over HTTP.
const CacheDirName = "apps"
// DefaultAppsKey is the special key in `apps:` that provides the baseline
// URL prefix and channel for any app not overridden per-name. Cascades
// through .zddc files like everything else.
const DefaultAppsKey = "default"
// Source is a fully-resolved app source (output of Resolve).
type Source struct {
App string // canonical app name
URL string // upstream URL (mutually exclusive with Path)
Path string // resolved local file path
}
// IsURL reports whether the source is fetched (vs read from disk).
func (s Source) IsURL() bool { return s.URL != "" }
// SpecComponents is the parsed shape of a single `.zddc apps:` value.
// Terminal forms (Path or FullURL) are mutually exclusive with the
// composable URLPrefix/Channel forms. Resolve() turns one or more
// SpecComponents (one per applicable level in the cascade) into a final
// Source.
type SpecComponents struct {
// Terminal forms — exactly one set means the spec is terminal and
// short-circuits composition.
Path string // local file path (resolved + bounded to ZDDC_ROOT)
FullURL string // full URL ending in `.html` (used as-is)
// Composable forms — either or both may be set, both may be empty
// (caller should treat empty-everything as a no-op).
URLPrefix string // "https://host/path" (no trailing /)
Channel string // "stable" (latest), "v0.0.4" (exact version pin)
}
// IsTerminal reports whether this spec terminates composition.
func (c SpecComponents) IsTerminal() bool {
return c.Path != "" || c.FullURL != ""
}
// IsEmpty reports whether the spec contributes nothing to composition.
func (c SpecComponents) IsEmpty() bool {
return c.Path == "" && c.FullURL == "" && c.URLPrefix == "" && c.Channel == ""
}
// ParseSpec parses one `.zddc apps:` value into components.
// zddcDir anchors relative paths; root bounds path-traversal.
func ParseSpec(spec, zddcDir, root string) (SpecComponents, error) {
spec = strings.TrimSpace(spec)
if spec == "" {
return SpecComponents{}, fmt.Errorf("source spec is empty")
}
// Path forms — terminal.
if strings.HasPrefix(spec, "/") ||
strings.HasPrefix(spec, "./") ||
strings.HasPrefix(spec, "../") {
var abs string
if filepath.IsAbs(spec) {
abs = filepath.Clean(spec)
} else {
abs = filepath.Clean(filepath.Join(zddcDir, spec))
}
rootClean := filepath.Clean(root)
if abs != rootClean && !strings.HasPrefix(abs, rootClean+string(filepath.Separator)) {
return SpecComponents{}, fmt.Errorf("path %q escapes ZDDC_ROOT", spec)
}
return SpecComponents{Path: abs}, nil
}
// URL forms.
if strings.HasPrefix(spec, "https://") || strings.HasPrefix(spec, "http://") {
return parseURLSpec(spec)
}
// Channel-only forms: ":channel" or bare "channel".
chanPart := strings.TrimPrefix(spec, ":")
if chanPart == "" {
return SpecComponents{}, fmt.Errorf("empty channel after ':'")
}
if !isValidChannelOrVersion(chanPart) {
return SpecComponents{}, fmt.Errorf("unrecognized source spec %q (expected channel, version, URL, or path)", spec)
}
return SpecComponents{Channel: normalizeChannel(chanPart)}, nil
}
// parseURLSpec splits a URL spec into prefix vs full-URL based on the
// last `:` after the last `/`. Examples:
//
// https://host:8080/path:stable → URLPrefix=https://host:8080/path, Channel=stable
// https://host:8080/path → URLPrefix=https://host:8080/path
// https://host/path/file.html → FullURL=https://host/path/file.html (terminal)
// https://host/path/file.html:stable → error (terminal URL with extra suffix)
func parseURLSpec(spec string) (SpecComponents, error) {
// Locate the channel separator: last `:` that comes after the last `/`.
lastSlash := strings.LastIndex(spec, "/")
if lastSlash < 0 {
return SpecComponents{}, fmt.Errorf("invalid URL %q: missing path separator", spec)
}
afterSlash := spec[lastSlash+1:]
colonInTail := strings.LastIndex(afterSlash, ":")
urlPart, suffixPart := spec, ""
if colonInTail >= 0 {
urlPart = spec[:lastSlash+1+colonInTail]
suffixPart = afterSlash[colonInTail+1:]
}
// Validate the URL portion.
u, err := url.Parse(urlPart)
if err != nil {
return SpecComponents{}, fmt.Errorf("invalid URL %q: %w", urlPart, err)
}
if u.Host == "" {
return SpecComponents{}, fmt.Errorf("URL %q is missing host", urlPart)
}
// Terminal full URL: ends in `.html`. A `:suffix` on a `.html` URL is
// rejected to prevent silent misinterpretation.
if strings.HasSuffix(urlPart, ".html") {
if suffixPart != "" {
return SpecComponents{}, fmt.Errorf("URL ends in .html but has %q suffix", ":"+suffixPart)
}
return SpecComponents{FullURL: urlPart}, nil
}
// URL-prefix form. Strip trailing slash for normalization.
prefix := strings.TrimRight(urlPart, "/")
out := SpecComponents{URLPrefix: prefix}
if suffixPart != "" {
if !isValidChannelOrVersion(suffixPart) {
return SpecComponents{}, fmt.Errorf("invalid channel/version suffix %q", suffixPart)
}
out.Channel = normalizeChannel(suffixPart)
}
return out, nil
}
// isValidChannelOrVersion reports whether s is `stable` (the canonical
// "current stable" alias) or an exact-version pin like `v0.0.4` / `0.0.4`.
// Partial pins (`v0.0`, `v0`) and the legacy `beta`/`alpha` channels
// are no longer accepted — the upstream publishes only stable + exact.
func isValidChannelOrVersion(s string) bool {
if s == "stable" {
return true
}
rest := strings.TrimPrefix(s, "v")
if rest == "" {
return false
}
parts := strings.Split(rest, ".")
if len(parts) != 3 {
return false
}
for _, p := range parts {
if p == "" {
return false
}
for _, r := range p {
if r < '0' || r > '9' {
return false
}
}
}
return true
}
// normalizeChannel ensures versions carry the `v` prefix (so the resulting
// filename is `<app>_v<X.Y.Z>.html` per upstream convention).
func normalizeChannel(s string) string {
if s == "stable" {
return s
}
if !strings.HasPrefix(s, "v") {
return "v" + s
}
return s
}
// Resolve walks the .zddc chain root→leaf, applying `apps.default` and
// `apps.<app>` at each level. Returns the resolved Source and true if any
// entry contributed; (Source{}, false, nil) means no override (caller
// serves embedded). On malformed spec, returns an error.
func Resolve(chain zddc.PolicyChain, app, root, requestDir string) (Source, bool, error) {
return ResolveWithOverride(chain, app, root, requestDir, "")
}
// ResolveWithOverride is Resolve with an additional per-request override
// applied as one final cascade level after the .zddc chain. Used to honor
// the `?v=` query parameter on tool HTML requests.
//
// vSpec accepts the same syntax as `.zddc apps:` values (channel/version,
// `:channel`, URL prefix, `url:channel`, full `.html` URL). Path sources
// are rejected (security: `?v=` must resolve to a URL whose bytes the
// caller can fetch from cache only).
//
// Empty vSpec is equivalent to plain Resolve.
func ResolveWithOverride(chain zddc.PolicyChain, app, root, requestDir, vSpec string) (Source, bool, error) {
app = strings.ToLower(strings.TrimSpace(app))
if !zddc.IsKnownApp(app) {
return Source{}, false, fmt.Errorf("unknown app %q", app)
}
dirs := walkDirs(root, requestDir)
st := newAppsState(app)
// Walk root → leaf.
for i := 0; i < len(chain.Levels); i++ {
lvl := chain.Levels[i]
dir := root
if i < len(dirs) {
dir = dirs[i]
}
// `default` first, then per-app override at the same level.
if spec, ok := lvl.Apps[DefaultAppsKey]; ok && spec != "" {
if err := st.apply(spec, dir, root, "apps.default"); err != nil {
return Source{}, false, err
}
}
if spec, ok := lvl.Apps[app]; ok && spec != "" {
if err := st.apply(spec, dir, root, "apps."+app); err != nil {
return Source{}, false, err
}
}
}
// Per-request override (`?v=`): one final layer.
if vSpec = strings.TrimSpace(vSpec); vSpec != "" {
comp, err := ParseSpec(vSpec, requestDir, root)
if err != nil {
return Source{}, false, fmt.Errorf("?v=%s: %w", vSpec, err)
}
// Reject path sources from per-request override — security: we serve
// only what the cache (populated by .zddc-controlled fetches) holds.
if comp.Path != "" {
return Source{}, false, fmt.Errorf("?v= cannot specify a local path source")
}
if err := st.applyComponents(comp); err != nil {
return Source{}, false, fmt.Errorf("?v=%s: %w", vSpec, err)
}
}
return st.finalize()
}
// appsState accumulates URL-prefix and channel components across cascade
// levels, with terminal-source short-circuit semantics.
type appsState struct {
app string
haveAny bool
urlPrefix string
channel string
terminalSrc *Source
}
func newAppsState(app string) *appsState {
return &appsState{app: app}
}
func (s *appsState) apply(spec, zddcDir, root, label string) error {
comp, err := ParseSpec(spec, zddcDir, root)
if err != nil {
return fmt.Errorf("%s: %w", label, err)
}
return s.applyComponents(comp)
}
func (s *appsState) applyComponents(comp SpecComponents) error {
if comp.IsEmpty() {
return nil
}
s.haveAny = true
switch {
case comp.Path != "":
s.terminalSrc = &Source{App: s.app, Path: comp.Path}
s.urlPrefix, s.channel = "", ""
case comp.FullURL != "":
s.terminalSrc = &Source{App: s.app, URL: comp.FullURL}
s.urlPrefix, s.channel = "", ""
default:
// Non-terminal: deeper non-terminal entries override a parent terminal.
s.terminalSrc = nil
if comp.URLPrefix != "" {
s.urlPrefix = comp.URLPrefix
}
if comp.Channel != "" {
s.channel = comp.Channel
}
}
return nil
}
func (s *appsState) finalize() (Source, bool, error) {
if !s.haveAny {
return Source{}, false, nil
}
if s.terminalSrc != nil {
return *s.terminalSrc, true, nil
}
urlPrefix := s.urlPrefix
if urlPrefix == "" {
urlPrefix = DefaultUpstreamReleases
}
channel := s.channel
if channel == "" {
channel = DefaultChannel
}
// channel == "stable" → canonical URL <prefix>/<app>.html (a
// symlink that always follows the latest stable cut).
// channel == "v<X.Y.Z>" → immutable per-version URL.
var name string
if channel == "stable" {
name = s.app + ".html"
} else {
name = s.app + "_" + channel + ".html"
}
return Source{
App: s.app,
URL: urlPrefix + "/" + name,
}, true, nil
}
// PreviewLine returns a short human-readable description of how an app
// resolves at requestDir given the chain. Used by the .zddc editor to
// render a "Resolves to: ..." line beside each apps input.
func PreviewLine(chain zddc.PolicyChain, app, root, requestDir string) string {
src, has, err := Resolve(chain, app, root, requestDir)
if err != nil {
return "error: " + err.Error()
}
if !has {
return "embedded (build-time default)"
}
if src.Path != "" {
return "local file: " + src.Path
}
return src.URL
}
func walkDirs(root, requestDir string) []string {
root = filepath.Clean(root)
requestDir = filepath.Clean(requestDir)
if requestDir == root {
return []string{root}
}
rel, err := filepath.Rel(root, requestDir)
if err != nil {
return []string{root}
}
dirs := []string{root}
cur := root
for _, part := range strings.Split(rel, string(filepath.Separator)) {
cur = filepath.Join(cur, part)
dirs = append(dirs, cur)
}
return dirs
}

View file

@ -1,438 +0,0 @@
package apps
import (
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// ── ParseSpec ────────────────────────────────────────────────────────────
func TestParseSpec_Channels(t *testing.T) {
// "stable" is the only channel alias (latest stable). beta and alpha
// channels no longer exist as public concepts.
cases := []struct {
spec, wantChan string
}{
{"stable", "stable"},
{":stable", "stable"},
}
for _, tc := range cases {
t.Run(tc.spec, func(t *testing.T) {
c, err := ParseSpec(tc.spec, "/root", "/root")
if err != nil {
t.Fatalf("ParseSpec error: %v", err)
}
if c.Channel != tc.wantChan {
t.Errorf("got Channel=%q, want %q", c.Channel, tc.wantChan)
}
if c.URLPrefix != "" || c.Path != "" || c.FullURL != "" {
t.Errorf("expected channel-only, got %+v", c)
}
})
}
}
func TestParseSpec_Versions(t *testing.T) {
// Exact-version pins only. Partial pins (v0.0, v0) no longer exist
// — the upstream publishes <tool>.html (current stable) and
// <tool>_v<X.Y.Z>.html (exact-version immutable). Bare "0.0.4"
// (no v prefix) is normalized to "v0.0.4".
cases := []struct {
spec, wantChan string
}{
{"v0.0.4", "v0.0.4"},
{"0.0.4", "v0.0.4"},
{":v0.0.4", "v0.0.4"},
{":0.0.4", "v0.0.4"},
}
for _, tc := range cases {
t.Run(tc.spec, func(t *testing.T) {
c, err := ParseSpec(tc.spec, "/root", "/root")
if err != nil {
t.Fatalf("ParseSpec error: %v", err)
}
if c.Channel != tc.wantChan {
t.Errorf("got Channel=%q, want %q", c.Channel, tc.wantChan)
}
})
}
}
func TestParseSpec_RejectsLegacyChannelsAndPartialPins(t *testing.T) {
// alpha/beta channels and partial-version pins are no longer valid.
rejected := []string{"alpha", "beta", ":alpha", ":beta", "v0.0", "v0", "0.0", "0", ":v0.0"}
for _, spec := range rejected {
t.Run(spec, func(t *testing.T) {
_, err := ParseSpec(spec, "/root", "/root")
if err == nil {
t.Errorf("expected error for %q, got none", spec)
}
})
}
}
func TestParseSpec_URLPrefix(t *testing.T) {
cases := []struct {
spec, wantPrefix, wantChan string
}{
{"https://my-mirror.example/releases", "https://my-mirror.example/releases", ""},
{"https://my-mirror.example/releases/", "https://my-mirror.example/releases", ""}, // trailing slash stripped
{"https://my-mirror.example/releases:stable", "https://my-mirror.example/releases", "stable"},
{"https://my-mirror.example/releases:v0.0.4", "https://my-mirror.example/releases", "v0.0.4"},
// Port colon must NOT be confused with channel separator.
{"https://my-mirror.example:8080/releases", "https://my-mirror.example:8080/releases", ""},
{"https://my-mirror.example:8080/releases:stable", "https://my-mirror.example:8080/releases", "stable"},
// Colon embedded in path before final slash — treated as part of path.
{"https://host/some:thing/releases", "https://host/some:thing/releases", ""},
{"https://host/some:thing/releases:v0.0.4", "https://host/some:thing/releases", "v0.0.4"},
}
for _, tc := range cases {
t.Run(tc.spec, func(t *testing.T) {
c, err := ParseSpec(tc.spec, "/root", "/root")
if err != nil {
t.Fatalf("ParseSpec error: %v", err)
}
if c.URLPrefix != tc.wantPrefix {
t.Errorf("got URLPrefix=%q, want %q", c.URLPrefix, tc.wantPrefix)
}
if c.Channel != tc.wantChan {
t.Errorf("got Channel=%q, want %q", c.Channel, tc.wantChan)
}
if c.Path != "" || c.FullURL != "" {
t.Errorf("expected non-terminal, got %+v", c)
}
})
}
}
func TestParseSpec_FullURL(t *testing.T) {
c, err := ParseSpec("https://my-fork.example/archive.html", "/root", "/root")
if err != nil {
t.Fatalf("ParseSpec error: %v", err)
}
if c.FullURL != "https://my-fork.example/archive.html" {
t.Errorf("got FullURL=%q", c.FullURL)
}
if !c.IsTerminal() {
t.Errorf("expected terminal")
}
}
func TestParseSpec_FullURLWithChannelSuffixRejected(t *testing.T) {
_, err := ParseSpec("https://my-fork.example/archive.html:stable", "/root", "/root")
if err == nil {
t.Errorf("expected error for .html URL with :suffix")
}
}
func TestParseSpec_Paths(t *testing.T) {
root := t.TempDir()
zddcDir := filepath.Join(root, "Project-X")
cases := []struct {
spec string
wantOK bool
wantPath string
}{
{"./local.html", true, filepath.Join(zddcDir, "local.html")},
{"../sibling.html", true, filepath.Join(root, "sibling.html")},
{filepath.Join(root, "abs.html"), true, filepath.Join(root, "abs.html")},
{"/etc/passwd", false, ""},
{"../../../escape.html", false, ""},
}
for _, tc := range cases {
t.Run(tc.spec, func(t *testing.T) {
c, err := ParseSpec(tc.spec, zddcDir, root)
if tc.wantOK {
if err != nil {
t.Fatalf("want success, got error: %v", err)
}
if c.Path != tc.wantPath {
t.Errorf("got Path=%q, want %q", c.Path, tc.wantPath)
}
if !c.IsTerminal() {
t.Errorf("expected terminal")
}
} else {
if err == nil {
t.Errorf("want error, got %+v", c)
}
}
})
}
}
func TestParseSpec_Errors(t *testing.T) {
cases := []string{
"",
"weird-thing",
":",
":weird",
"v",
"v0.0.0.0",
"v0.a.0",
"https://", // missing host
}
for _, tc := range cases {
t.Run(tc, func(t *testing.T) {
_, err := ParseSpec(tc, "/root", "/root")
if err == nil {
t.Errorf("ParseSpec(%q) = nil, want error", tc)
}
})
}
}
// ── Resolve ──────────────────────────────────────────────────────────────
func TestResolve_NoEntries(t *testing.T) {
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
_, has, err := Resolve(chain, "archive", t.TempDir(), t.TempDir())
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if has {
t.Errorf("got override=true, want false")
}
}
func TestResolve_PerAppChannelOnly(t *testing.T) {
root := t.TempDir()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{"archive": "stable"},
}}}
src, has, err := Resolve(chain, "archive", root, root)
if err != nil || !has {
t.Fatalf("has=%v err=%v", has, err)
}
// stable channel → canonical URL (no _stable_ suffix); the upstream
// publishes a symlink at this URL pointing at the latest version.
want := DefaultUpstreamReleases + "/archive.html"
if src.URL != want {
t.Errorf("got URL=%q, want %q", src.URL, want)
}
}
func TestResolve_PerAppVersionOnly(t *testing.T) {
root := t.TempDir()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{"archive": "v0.0.4"},
}}}
src, _, err := Resolve(chain, "archive", root, root)
if err != nil {
t.Fatal(err)
}
want := DefaultUpstreamReleases + "/archive_v0.0.4.html"
if src.URL != want {
t.Errorf("got URL=%q, want %q", src.URL, want)
}
}
func TestResolve_DefaultProvidesURLAndChannel(t *testing.T) {
root := t.TempDir()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{
"default": "https://mirror.example/releases:v0.0.4",
},
}}}
src, has, err := Resolve(chain, "archive", root, root)
if err != nil || !has {
t.Fatalf("has=%v err=%v", has, err)
}
if src.URL != "https://mirror.example/releases/archive_v0.0.4.html" {
t.Errorf("got URL=%q", src.URL)
}
}
func TestResolve_DefaultPlusPerAppChannelOverride(t *testing.T) {
// default=https://zddc.varasys.io/releases:stable, classifier=:v0.0.4
// → classifier pinned to v0.0.4 on the same mirror.
root := t.TempDir()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{
"default": "https://zddc.varasys.io/releases:stable",
"classifier": ":v0.0.4",
},
}}}
src, _, err := Resolve(chain, "classifier", root, root)
if err != nil {
t.Fatal(err)
}
if src.URL != "https://zddc.varasys.io/releases/classifier_v0.0.4.html" {
t.Errorf("got URL=%q", src.URL)
}
}
func TestResolve_DefaultPlusPerAppURLPrefixOverride(t *testing.T) {
// default=...:stable, archive=https://my.local.stuff/releases
// → custom URL + default channel (stable, canonical filename).
root := t.TempDir()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{
"default": "https://zddc.varasys.io/releases:stable",
"archive": "https://my.local.stuff/releases",
},
}}}
src, _, err := Resolve(chain, "archive", root, root)
if err != nil {
t.Fatal(err)
}
if src.URL != "https://my.local.stuff/releases/archive.html" {
t.Errorf("got URL=%q", src.URL)
}
}
func TestResolve_DeeperLevelOverridesParentChannel(t *testing.T) {
root := t.TempDir()
requestDir := filepath.Join(root, "Project-A")
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{
{Apps: map[string]string{"default": ":stable"}},
{Apps: map[string]string{"default": ":v0.0.4"}},
}}
src, _, err := Resolve(chain, "archive", root, requestDir)
if err != nil {
t.Fatal(err)
}
want := DefaultUpstreamReleases + "/archive_v0.0.4.html"
if src.URL != want {
t.Errorf("got URL=%q, want %q", src.URL, want)
}
}
func TestResolve_DeeperLevelOverridesParentURL(t *testing.T) {
root := t.TempDir()
requestDir := filepath.Join(root, "Project-A")
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{
{Apps: map[string]string{"default": "https://a.example/releases:stable"}},
{Apps: map[string]string{"default": "https://b.example/releases"}},
}}
src, _, err := Resolve(chain, "archive", root, requestDir)
if err != nil {
t.Fatal(err)
}
// b.example URL prefix wins; channel inherited (stable → canonical
// filename, no _stable_ suffix).
want := "https://b.example/releases/archive.html"
if src.URL != want {
t.Errorf("got URL=%q, want %q", src.URL, want)
}
}
func TestResolve_TerminalAtLeafBeatsParentDefault(t *testing.T) {
root := t.TempDir()
requestDir := filepath.Join(root, "Project-A")
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{
{Apps: map[string]string{"default": "https://a.example/releases:stable"}},
{Apps: map[string]string{"archive": "https://my-fork.example/archive.html"}},
}}
src, _, err := Resolve(chain, "archive", root, requestDir)
if err != nil {
t.Fatal(err)
}
if src.URL != "https://my-fork.example/archive.html" {
t.Errorf("got URL=%q (want terminal full URL)", src.URL)
}
}
func TestResolve_DeeperNonTerminalOverridesParentTerminal(t *testing.T) {
root := t.TempDir()
requestDir := filepath.Join(root, "Project-A")
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{
{Apps: map[string]string{"archive": "https://a.example/archive.html"}}, // terminal
{Apps: map[string]string{"archive": "v0.0.4"}}, // non-terminal
}}
src, _, err := Resolve(chain, "archive", root, requestDir)
if err != nil {
t.Fatal(err)
}
want := DefaultUpstreamReleases + "/archive_v0.0.4.html"
if src.URL != want {
t.Errorf("got URL=%q, want %q", src.URL, want)
}
}
func TestResolve_PathSourceTerminal(t *testing.T) {
root := t.TempDir()
projDir := filepath.Join(root, "Project-X")
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{
{},
{Apps: map[string]string{"archive": "./our-archive.html"}},
}}
src, _, err := Resolve(chain, "archive", root, projDir)
if err != nil {
t.Fatal(err)
}
if src.URL != "" {
t.Errorf("got URL=%q, want empty", src.URL)
}
want := filepath.Join(projDir, "our-archive.html")
if src.Path != want {
t.Errorf("got Path=%q, want %q", src.Path, want)
}
}
func TestResolve_PerAppOverridesDefaultAtSameLevel(t *testing.T) {
root := t.TempDir()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{
"default": "https://a.example/releases:stable",
"archive": "https://b.example/archive.html", // terminal — wins for archive only
},
}}}
src, _, err := Resolve(chain, "archive", root, root)
if err != nil {
t.Fatal(err)
}
if src.URL != "https://b.example/archive.html" {
t.Errorf("got URL=%q (want b.example terminal)", src.URL)
}
// Other apps still use the default.
src2, _, err := Resolve(chain, "classifier", root, root)
if err != nil {
t.Fatal(err)
}
if src2.URL != "https://a.example/releases/classifier.html" {
t.Errorf("got classifier URL=%q (want a.example default)", src2.URL)
}
}
func TestResolve_BadSpecBubblesError(t *testing.T) {
root := t.TempDir()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{"archive": "this is garbage"},
}}}
_, _, err := Resolve(chain, "archive", root, root)
if err == nil {
t.Errorf("expected error")
}
}
func TestResolve_UnknownAppRejected(t *testing.T) {
root := t.TempDir()
_, _, err := Resolve(zddc.PolicyChain{}, "unknown", root, root)
if err == nil {
t.Errorf("expected error")
}
}
// ── PreviewLine ──────────────────────────────────────────────────────────
func TestPreviewLine(t *testing.T) {
root := t.TempDir()
t.Run("no entries → embedded", func(t *testing.T) {
got := PreviewLine(zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}, "archive", root, root)
if !strings.Contains(got, "embedded") {
t.Errorf("got %q", got)
}
})
t.Run("default channel → URL", func(t *testing.T) {
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{Apps: map[string]string{"default": ":v0.0.4"}}}}
got := PreviewLine(chain, "archive", root, root)
if !strings.Contains(got, "archive_v0.0.4.html") {
t.Errorf("got %q", got)
}
})
}

View file

@ -0,0 +1,115 @@
package apps
import (
"archive/zip"
"bytes"
"io"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zipfs"
)
// BundleName is the site-root config bundle that supplies local tool-HTML
// overrides (and, in future, templated config). It lives at
// <ZDDC_ROOT>/.zddc.zip. It is dot-hidden and 404-gated over HTTP (it's
// config, not browsable content); the server reads it from the filesystem
// internally, so members resolve for any user regardless of the HTTP gate.
const BundleName = ".zddc.zip"
// maxBundleBytes caps the whole .zddc.zip read into memory. The bundle is
// small config (a handful of HTML files), so a generous cap is fine.
const maxBundleBytes = 64 << 20 // 64 MiB
// maxBundleMemberBytes caps a single extracted member.
const maxBundleMemberBytes = 32 << 20 // 32 MiB
// Bundle is the cached parsed view of <ZDDC_ROOT>/.zddc.zip. A nil *Bundle
// is valid and behaves as "no bundle present" for all methods. Member()
// re-stats the file each call (cheap, and gives free hot-reload when an
// operator drops in a new bundle), reparsing only when mtime or size change.
type Bundle struct {
path string
logger *slog.Logger
mu sync.Mutex
data []byte
reader *zip.Reader
modTime time.Time
size int64
loaded bool // a valid zip is parsed into reader
}
// NewBundle returns a Bundle bound to <zddcRoot>/.zddc.zip. The file need
// not exist; Member returns (nil,false) until it does.
func NewBundle(zddcRoot string, logger *slog.Logger) *Bundle {
if logger == nil {
logger = slog.Default()
}
return &Bundle{path: filepath.Join(zddcRoot, BundleName), logger: logger}
}
// Member returns the bytes of the named member (e.g. "browse.html") from the
// bundle, or (nil,false) when the bundle is absent, unreadable, corrupt, or
// has no such member. Lookup is case-insensitive (via zipfs), matching the
// rest of the server's URL case-folding.
func (b *Bundle) Member(name string) ([]byte, bool) {
if b == nil {
return nil, false
}
b.mu.Lock()
defer b.mu.Unlock()
info, err := os.Stat(b.path)
if err != nil || info.IsDir() {
// Absent (or replaced by a dir) → no bundle. Drop any stale parse.
b.data, b.reader, b.loaded = nil, nil, false
return nil, false
}
if !b.loaded || info.ModTime() != b.modTime || info.Size() != b.size {
if !b.reparse(info) {
return nil, false
}
}
rc, _, _, _, ok := zipfs.OpenMember(b.reader, name)
if !ok {
return nil, false
}
defer rc.Close()
body, err := io.ReadAll(io.LimitReader(rc, maxBundleMemberBytes+1))
if err != nil || int64(len(body)) > maxBundleMemberBytes {
b.logger.Warn("zddc.zip member unreadable or too large", "member", name)
return nil, false
}
return body, true
}
// reparse re-reads + re-parses the bundle. Caller holds b.mu. On any error
// the bundle is treated as absent (loaded=false) and the server falls back
// to embedded. Returns true when a valid reader is in place.
func (b *Bundle) reparse(info os.FileInfo) bool {
b.data, b.reader, b.loaded = nil, nil, false
if info.Size() > maxBundleBytes {
b.logger.Warn("zddc.zip too large; ignoring", "size", info.Size(), "cap", maxBundleBytes)
return false
}
data, err := os.ReadFile(b.path)
if err != nil {
b.logger.Warn("zddc.zip unreadable; ignoring", "err", err)
return false
}
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
b.logger.Warn("zddc.zip is not a valid zip; ignoring", "err", err)
return false
}
b.data = data
b.reader = zr
b.modTime = info.ModTime()
b.size = info.Size()
b.loaded = true
return true
}

View file

@ -0,0 +1,96 @@
package apps
import (
"archive/zip"
"bytes"
"os"
"path/filepath"
"testing"
"time"
)
// writeTestBundle writes a <dir>/.zddc.zip containing the given members.
// Shared by bundle + handler precedence tests.
func writeTestBundle(t *testing.T, dir string, members map[string]string) string {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for name, body := range members {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("zip create %s: %v", name, err)
}
if _, err := w.Write([]byte(body)); err != nil {
t.Fatalf("zip write %s: %v", name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
p := filepath.Join(dir, BundleName)
if err := os.WriteFile(p, buf.Bytes(), 0o644); err != nil {
t.Fatalf("write bundle: %v", err)
}
return p
}
func TestBundle_Member_Hit(t *testing.T) {
root := t.TempDir()
writeTestBundle(t, root, map[string]string{"browse.html": "BUNDLE browse"})
b := NewBundle(root, nil)
got, ok := b.Member("browse.html")
if !ok || string(got) != "BUNDLE browse" {
t.Fatalf("Member = (%q,%v), want (BUNDLE browse,true)", got, ok)
}
// Case-insensitive lookup (matches URL folding).
if _, ok := b.Member("BROWSE.HTML"); !ok {
t.Errorf("case-insensitive member lookup failed")
}
}
func TestBundle_Member_Absent(t *testing.T) {
root := t.TempDir()
writeTestBundle(t, root, map[string]string{"browse.html": "x"})
b := NewBundle(root, nil)
if _, ok := b.Member("archive.html"); ok {
t.Errorf("absent member reported present")
}
}
func TestBundle_NoFile(t *testing.T) {
b := NewBundle(t.TempDir(), nil)
if _, ok := b.Member("browse.html"); ok {
t.Errorf("no bundle file but member reported present")
}
// nil bundle is safe.
var nb *Bundle
if _, ok := nb.Member("browse.html"); ok {
t.Errorf("nil bundle reported a member")
}
}
func TestBundle_HotReload(t *testing.T) {
root := t.TempDir()
p := writeTestBundle(t, root, map[string]string{"browse.html": "v1"})
b := NewBundle(root, nil)
if got, _ := b.Member("browse.html"); string(got) != "v1" {
t.Fatalf("first read = %q, want v1", got)
}
// Rewrite with new bytes + a bumped mtime so the stat-based cache reparses.
writeTestBundle(t, root, map[string]string{"browse.html": "v2"})
_ = os.Chtimes(p, time.Now().Add(2*time.Second), time.Now().Add(2*time.Second))
if got, _ := b.Member("browse.html"); string(got) != "v2" {
t.Errorf("after reload = %q, want v2", got)
}
}
func TestBundle_CorruptZip(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, BundleName), []byte("not a zip"), 0o644); err != nil {
t.Fatal(err)
}
b := NewBundle(root, nil)
if _, ok := b.Member("browse.html"); ok {
t.Errorf("corrupt zip should yield no members")
}
}

View file

@ -1,187 +0,0 @@
package apps
import (
"fmt"
"io/fs"
"net/url"
"os"
"path/filepath"
"strings"
)
// Cache stores fetched URL responses on disk under <ZDDC_ROOT>/_app/.
// Files are name-keyed by upstream host + path so operators can list
// and inspect them by hand. There is no metadata, no SHA-256, no
// expiration — fetch-once-and-keep-forever. To force a refetch,
// delete the cache file.
type Cache struct {
root string
}
// NewCache creates a Cache rooted at the given path. The directory is
// created if missing. Stale *.tmp files left over from interrupted
// writes are swept on construction.
func NewCache(root string) (*Cache, error) {
root = filepath.Clean(root)
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, fmt.Errorf("create cache root: %w", err)
}
c := &Cache{root: root}
if err := c.sweepTemps(); err != nil {
return nil, fmt.Errorf("sweep temps: %w", err)
}
return c, nil
}
// Root returns the cache directory absolute path.
func (c *Cache) Root() string { return c.root }
// keyForURL converts a URL into a relative filesystem path under the
// cache root.
//
// Layout: <scheme>/<host>[:<port>]/<path>. The full origin tuple is in
// the key so two URLs that resolve different content cannot collide:
//
// https://example.com/x.html → https/example.com/x.html
// http://example.com/x.html → http/example.com/x.html
// https://example.com:8443/x.html → https/example.com:8443/x.html
//
// No port stripping. The previous behavior — collapsing :443 onto bare
// host for https (and :80 for http) — was a defensible HTTP convention
// but conflated "the operator wrote a URL with the default port" with
// "the operator wrote a bare-host URL". With explicit port preserved,
// every URL maps to exactly one filesystem path; operators can still
// `ls _app/https/example.com/` to inspect what's cached. Scheme
// segregation prevents an http:// hit from masquerading as an https://
// hit when both are deliberately distinct (rare, but real on
// reverse-proxied stacks where http and https serve different bytes).
//
// Host is lowercased so the canonical-host normalization survives
// case-insensitive DNS. Port is preserved verbatim.
func keyForURL(rawURL string) (string, error) {
u, err := url.Parse(rawURL)
if err != nil {
return "", fmt.Errorf("parse URL: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", fmt.Errorf("unsupported scheme %q", u.Scheme)
}
if u.Host == "" {
return "", fmt.Errorf("URL is missing host")
}
if u.RawQuery != "" {
return "", fmt.Errorf("URL must not contain query string: %s", rawURL)
}
// Lowercase the host part but preserve the port verbatim. Without
// this we'd lowercase a numeric port unnecessarily, which is fine
// but pointless; with this the ASCII-cased host normalization
// works the same for both default and explicit-port URLs.
host := u.Host
if i := strings.Index(host, ":"); i >= 0 {
host = strings.ToLower(host[:i]) + host[i:]
} else {
host = strings.ToLower(host)
}
p := u.Path
for strings.Contains(p, "//") {
p = strings.ReplaceAll(p, "//", "/")
}
p = strings.TrimPrefix(p, "/")
if p == "" {
p = "index.html"
}
cleaned := filepath.Clean("/" + p)
if strings.Contains(cleaned, "..") {
return "", fmt.Errorf("URL path contains '..'")
}
return u.Scheme + "/" + host + cleaned, nil
}
func (c *Cache) pathFor(rawURL string) (string, error) {
key, err := keyForURL(rawURL)
if err != nil {
return "", err
}
return filepath.Join(c.root, filepath.FromSlash(key)), nil
}
// Has reports whether a cache entry exists for the URL.
func (c *Cache) Has(rawURL string) bool {
p, err := c.pathFor(rawURL)
if err != nil {
return false
}
_, err = os.Stat(p)
return err == nil
}
// Read returns the cached body or os.ErrNotExist.
func (c *Cache) Read(rawURL string) ([]byte, error) {
p, err := c.pathFor(rawURL)
if err != nil {
return nil, err
}
return os.ReadFile(p)
}
// Write atomically stores body for the URL. Parent directories are
// created as needed. Writes via tmp+rename so partial files are never
// observable.
func (c *Cache) Write(rawURL string, body []byte) error {
p, err := c.pathFor(rawURL)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
return writeAtomic(p, body)
}
func writeAtomic(path string, data []byte) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp.*")
if err != nil {
return err
}
tmpName := tmp.Name()
cleanup := func() { _ = os.Remove(tmpName) }
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
cleanup()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
cleanup()
return err
}
if err := tmp.Close(); err != nil {
cleanup()
return err
}
if err := os.Rename(tmpName, path); err != nil {
cleanup()
return err
}
return nil
}
func (c *Cache) sweepTemps() error {
err := filepath.WalkDir(c.root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if strings.Contains(d.Name(), ".tmp.") {
_ = os.Remove(p)
}
return nil
})
if err != nil && !os.IsNotExist(err) {
return err
}
return nil
}

View file

@ -1,160 +0,0 @@
package apps
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestKeyForURL(t *testing.T) {
cases := []struct {
raw, want string
}{
// Default ports are PRESERVED — no port-stripping (the previous
// behavior conflated "operator wrote :443" with "operator wrote
// bare host"; with the full origin in the key, every URL maps
// to exactly one path).
{"https://zddc.varasys.io/releases/archive_stable.html", "https/zddc.varasys.io/releases/archive_stable.html"},
{"https://ZDDC.Varasys.IO/releases/archive_stable.html", "https/zddc.varasys.io/releases/archive_stable.html"},
{"http://example.com/foo.html", "http/example.com/foo.html"},
{"http://example.com:80/foo.html", "http/example.com:80/foo.html"},
{"https://example.com/foo.html", "https/example.com/foo.html"},
{"https://example.com:443/foo.html", "https/example.com:443/foo.html"},
{"https://example.com:8443/foo.html", "https/example.com:8443/foo.html"},
// Scheme segregation: same host+path under http and https map
// to different cache entries (defensive against reverse-proxy
// stacks that legitimately serve different bytes per scheme).
{"http://example.com/x.html", "http/example.com/x.html"},
{"https://example.com/x.html", "https/example.com/x.html"},
// Path normalization preserved.
{"https://example.com/", "https/example.com/index.html"},
{"https://example.com", "https/example.com/index.html"},
{"https://example.com//foo//bar.html", "https/example.com/foo/bar.html"},
}
for _, tc := range cases {
t.Run(tc.raw, func(t *testing.T) {
got, err := keyForURL(tc.raw)
if err != nil {
t.Fatalf("keyForURL error: %v", err)
}
if got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
// TestKeyForURL_NoCollisions: explicit assertion that the dimensions
// previously collapsed (default-port ↔ bare-host, http ↔ https) are
// now distinct. Any future change that re-introduces collapsing will
// fail this test.
func TestKeyForURL_NoCollisions(t *testing.T) {
pairs := [][2]string{
// Different scheme, same host+path
{"http://example.com/x.html", "https://example.com/x.html"},
// https default port preserved (not collapsed onto bare host)
{"https://example.com/x.html", "https://example.com:443/x.html"},
// http default port preserved
{"http://example.com/x.html", "http://example.com:80/x.html"},
// Different non-default ports
{"https://example.com:8443/x.html", "https://example.com:9443/x.html"},
}
for _, p := range pairs {
t.Run(p[0]+" vs "+p[1], func(t *testing.T) {
a, err := keyForURL(p[0])
if err != nil {
t.Fatalf("keyForURL(%q): %v", p[0], err)
}
b, err := keyForURL(p[1])
if err != nil {
t.Fatalf("keyForURL(%q): %v", p[1], err)
}
if a == b {
t.Errorf("collision: %q and %q both → %q", p[0], p[1], a)
}
})
}
}
func TestKeyForURL_Errors(t *testing.T) {
cases := []string{
"",
"not-a-url",
"ftp://example.com/x.html",
"https:///x.html",
"https://example.com/x.html?v=1",
}
for _, tc := range cases {
t.Run(tc, func(t *testing.T) {
if _, err := keyForURL(tc); err == nil {
t.Errorf("keyForURL(%q) = nil, want error", tc)
}
})
}
}
func TestCacheRoundtrip(t *testing.T) {
c, err := NewCache(filepath.Join(t.TempDir(), "_app"))
if err != nil {
t.Fatalf("NewCache: %v", err)
}
urlStr := "https://zddc.varasys.io/releases/archive_stable.html"
body := []byte("<!DOCTYPE html>archive content")
if c.Has(urlStr) {
t.Fatalf("Has(empty cache) = true, want false")
}
if err := c.Write(urlStr, body); err != nil {
t.Fatalf("Write: %v", err)
}
if !c.Has(urlStr) {
t.Fatalf("Has(after write) = false, want true")
}
got, err := c.Read(urlStr)
if err != nil {
t.Fatalf("Read: %v", err)
}
if string(got) != string(body) {
t.Errorf("body mismatch")
}
}
func TestCacheAtomicWrite_LeavesNoTempOnSuccess(t *testing.T) {
root := filepath.Join(t.TempDir(), "_app")
c, _ := NewCache(root)
urlStr := "https://zddc.varasys.io/releases/archive_stable.html"
if err := c.Write(urlStr, []byte("hello")); err != nil {
t.Fatalf("Write: %v", err)
}
count := 0
_ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.Contains(info.Name(), ".tmp.") {
count++
}
return nil
})
if count != 0 {
t.Errorf("found %d .tmp.* leftovers, want 0", count)
}
}
func TestCacheSweepsTempsOnNew(t *testing.T) {
root := filepath.Join(t.TempDir(), "_app")
stale := filepath.Join(root, "example.com", "releases", "archive_stable.html.tmp.123")
if err := os.MkdirAll(filepath.Dir(stale), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(stale, []byte("partial"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := NewCache(root); err != nil {
t.Fatalf("NewCache: %v", err)
}
if _, err := os.Stat(stale); !os.IsNotExist(err) {
t.Errorf("stale tmp file not swept: %v", err)
}
}

View file

@ -2582,7 +2582,7 @@ td[data-field="trackingNumber"] {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 18:26:16 · f723323</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-05 12:41:17 · 382645b</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>

File diff suppressed because it is too large Load diff

View file

@ -1793,7 +1793,7 @@ body.is-elevated::after {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 18:26:16 · f723323</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-05 12:41:17 · 382645b</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>

View file

@ -1536,7 +1536,7 @@ body {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 18:26:16 · f723323</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-05 12:41:17 · 382645b</span></span>
</div>
</div>
<div class="header-right">

View file

@ -2635,7 +2635,7 @@ dialog.modal--narrow {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 18:26:16 · f723323</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-05 12:41:16 · 382645b</span></span>
</div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action;

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.27-beta · 2026-06-03 18:26:16 · f723323
transmittal=v0.0.27-beta · 2026-06-03 18:26:16 · f723323
classifier=v0.0.27-beta · 2026-06-03 18:26:16 · f723323
landing=v0.0.27-beta · 2026-06-03 18:26:16 · f723323
form=v0.0.27-beta · 2026-06-03 18:26:16 · f723323
tables=v0.0.27-beta · 2026-06-03 18:26:16 · f723323
browse=v0.0.27-beta · 2026-06-03 18:26:16 · f723323
archive=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
transmittal=v0.0.27-beta · 2026-06-05 12:41:16 · 382645b
classifier=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
landing=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
form=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
tables=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
browse=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b

View file

@ -1,193 +0,0 @@
package apps
import (
"context"
"crypto/ed25519"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"sync"
"time"
)
// Fetcher pulls URL sources once, caches the body forever, and serves
// from cache on subsequent calls. Path sources don't go through here —
// the handler reads the file directly.
//
// Concurrent calls for the same URL dedupe via singleflight. There is no
// background refresh, no conditional GET.
//
// Signature verification (Ed25519). Strict. On every fetch, also
// fetches <url>.sig (raw 64-byte Ed25519 signature). The fetched body
// is rejected unless the .sig is present, well-formed, and verifies
// against the trusted public key. Rejection causes the apps resolver
// to fall through to the embedded copy.
//
// There is no "accept unsigned with a warning" mode and no embedded
// default key. The operator configures VerifyKey explicitly via
// --apps-pubkey or ZDDC_APPS_PUBKEY (same posture as TLS certificates:
// zddc-server bakes nothing in). When VerifyKey is nil, every URL fetch
// is rejected with an error noting the missing config — the resolver
// falls back to embedded and operators get a clear signal that they
// need to opt in.
//
// Every URL the resolver might fetch is expected to have a
// corresponding .sig published by whoever signed the artifact.
// Operators using custom mirrors must sign their own artifacts and
// host the .sig alongside, then configure their public key here.
type Fetcher struct {
Cache *Cache
Client *http.Client
Logger *slog.Logger
// VerifyKey is the Ed25519 public key against which fetched
// artifacts are verified. Set at startup from the operator's
// configured --apps-pubkey path. nil = URL fetches refuse-by-
// default (caller falls back to embedded).
VerifyKey ed25519.PublicKey
sf singleflightGroup
embeddedFails sync.Map // url → struct{} (rate-limit "fell back to embedded" warnings)
}
// NewFetcher returns a Fetcher with sensible defaults: 10s timeout, no
// redirects (ops must point at the final URL).
func NewFetcher(cache *Cache, logger *slog.Logger) *Fetcher {
if logger == nil {
logger = slog.Default()
}
return &Fetcher{
Cache: cache,
Logger: logger,
// VerifyKey starts nil. Operator configures it via
// cfg.AppsPubKey at server startup; main.go sets it on the
// returned Fetcher before any request is served.
Client: &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
}
// Fetch returns the body for url. If the cache already has it, returns
// the cached bytes immediately. Otherwise fetches, caches, and returns.
// All concurrent requests for the same URL share one outbound fetch.
func (f *Fetcher) Fetch(ctx context.Context, urlStr string) ([]byte, error) {
if f.Cache != nil {
if body, err := f.Cache.Read(urlStr); err == nil {
return body, nil
}
}
val, err := f.sf.Do(urlStr, func() (any, error) {
return f.fetchOnce(ctx, urlStr)
})
if err != nil {
return nil, err
}
return val.([]byte), nil
}
func (f *Fetcher) fetchOnce(ctx context.Context, urlStr string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
if err != nil {
return nil, err
}
resp, err := f.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("upstream %s returned HTTP %d", urlStr, resp.StatusCode)
}
const maxBytes = 25 * 1024 * 1024
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
if err != nil {
return nil, err
}
if int64(len(body)) > maxBytes {
return nil, fmt.Errorf("response from %s exceeds %d bytes", urlStr, maxBytes)
}
// Signature verification gate. See Fetcher type docstring for the
// decision matrix. The transitional period accepts unsigned artifacts
// with a WARN log; flipping RequireSigs makes it strict-reject.
if err := f.verifyFetched(ctx, urlStr, body); err != nil {
return nil, fmt.Errorf("signature verification failed: %w", err)
}
if f.Cache != nil {
if err := f.Cache.Write(urlStr, body); err != nil {
f.Logger.Warn("cache write failed; serving from response anyway",
"url", urlStr, "err", err)
}
}
return body, nil
}
// verifyFetched fetches <urlStr>.sig and validates body against it.
// Returns nil only when the signature is present, well-formed, and
// verifies against f.VerifyKey. Any other outcome is a hard reject:
// the caller drops the body and the apps resolver falls through to
// the embedded copy.
//
// f.VerifyKey == nil means the operator hasn't configured an apps-
// pubkey. We reject every URL fetch in that state — the operator
// needs to opt in to a specific signing key explicitly. The reject
// error is informative so the WARN log line tells the operator
// exactly what to fix.
func (f *Fetcher) verifyFetched(ctx context.Context, urlStr string, body []byte) error {
if f.VerifyKey == nil {
return errors.New("ZDDC_APPS_PUBKEY is not configured; URL-fetched apps require an explicit signing key (see zddc.varasys.io/pubkey.pem for the canonical-channel key)")
}
sigURL := urlStr + ".sig"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sigURL, nil)
if err != nil {
return fmt.Errorf("build sig request for %s: %w", sigURL, err)
}
resp, err := f.Client.Do(req)
if err != nil {
return fmt.Errorf("fetch %s: %w", sigURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s returned HTTP %d", sigURL, resp.StatusCode)
}
// Raw Ed25519 sig is 64 bytes; cap at a small limit so a hostile
// upstream can't flood us with a garbage "signature."
const maxSigBytes = 256
sig, err := io.ReadAll(io.LimitReader(resp.Body, maxSigBytes+1))
if err != nil {
return fmt.Errorf("read %s: %w", sigURL, err)
}
if len(sig) > maxSigBytes {
return fmt.Errorf("%s exceeds %d bytes", sigURL, maxSigBytes)
}
if err := VerifyEd25519(f.VerifyKey, body, sig); err != nil {
// Verification failure is positive evidence of tampering or a
// build/key mismatch. Logged at WARN so operators see it; the
// resolver's existing embedded-fallback logging will note that
// the embedded copy is being served instead.
f.Logger.Warn("REJECTED: artifact signature does not verify",
"url", urlStr, "sig_url", sigURL, "err", err)
return err
}
f.Logger.Debug("artifact signature verified", "url", urlStr)
return nil
}
// LogEmbeddedFallback emits a one-time warning when the embedded fallback
// is used for a particular source URL. Rate-limited per URL.
func (f *Fetcher) LogEmbeddedFallback(app, urlStr string, reason error) {
if _, loaded := f.embeddedFails.LoadOrStore(urlStr, struct{}{}); loaded {
return
}
f.Logger.Warn("serving embedded fallback for app HTML",
"app", app, "url", urlStr, "reason", reason)
}

View file

@ -3,33 +3,36 @@ package apps
import (
"crypto/sha256"
"encoding/hex"
"errors"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// Server orchestrates app HTML resolution: subdir cascade override → fetch
// or path read → embedded fallback. It does NOT check whether the app is
// available at the request directory — that's AppAvailableAt's job, called
// from dispatch before invoking Serve.
// Server resolves tool HTML for a request: bundle member → embedded. The
// on-disk-at-path tier (operator override) is handled UPSTREAM by the
// dispatcher's stat-first static handler, so by the time Serve runs no real
// file exists at the path. Server does NOT decide whether the app is
// available at the directory — that's AppAvailableAt's job, called from
// dispatch before Serve.
type Server struct {
Root string
Cache *Cache
Fetcher *Fetcher
BuildVer string // baked into X-ZDDC-Source for embedded responses
Bundle *Bundle
Logger *slog.Logger
}
// NewServer constructs a Server.
func NewServer(root string, cache *Cache, fetcher *Fetcher, buildVer string) *Server {
// NewServer constructs a Server bound to the site-root config bundle.
func NewServer(root, buildVer string) *Server {
root = filepath.Clean(root)
logger := slog.Default()
return &Server{
Root: filepath.Clean(root),
Cache: cache,
Fetcher: fetcher,
Root: root,
BuildVer: buildVer,
Bundle: NewBundle(root, logger),
Logger: logger,
}
}
@ -38,8 +41,8 @@ func NewServer(root string, cache *Cache, fetcher *Fetcher, buildVer string) *Se
// directory (relative to root) the request is rooted at. The cmd/zddc-
// server dispatcher calls this when stat fails on a URL: a missing file
// that happens to look like `<dir>/archive.html` (or browse.html, etc.)
// resolves to the embedded app HTML for that directory — operators
// don't have to copy app HTML into every project.
// resolves to the embedded (or bundle) app HTML for that directory —
// operators don't have to copy app HTML into every project.
//
// Special case: GET / and GET /index.html both resolve to landing — the
// only entry point that scopes ACL per-project, and the conventional
@ -72,105 +75,51 @@ func MatchAppHTML(requestPath string) (app string, requestDirRel string) {
return "", ""
}
// resolveBytes applies the local override precedence (tiers 2 then 3; tier 1
// is handled upstream). Returns the HTML body, the X-ZDDC-Source tag, and
// whether to use the memoized embedded ETag (vs a body-hash ETag).
func (s *Server) resolveBytes(app string) (body []byte, sourceTag string, embedded, ok bool) {
if s.Bundle != nil {
if b, found := s.Bundle.Member(app + ".html"); found {
return b, "bundle:" + app + ".html", false, true
}
}
if b := EmbeddedBytes(app); len(b) > 0 {
return b, "embedded:" + app + "@" + s.BuildVer, true, true
}
return nil, "", false, false
}
// Serve resolves and writes the response. Caller has already verified:
// - no real file exists at the request path
// - no real file exists at the request path (so tier 1 didn't apply)
// - AppAvailableAt(root, requestDir, app) is true
// - ACL passes for requestDir
//
// Honors a `?v=<spec>` query parameter as a per-request override on top of
// the cascade. With `?v=` set, the resolved URL must already exist in the
// cache — otherwise the response is 404. This prevents users from
// triggering arbitrary upstream fetches via URL-crafted requests; only
// versions the operator's `.zddc apps:` entries have already pulled in
// (or that the user has manually placed in `_app/`) are reachable.
func (s *Server) Serve(w http.ResponseWriter, r *http.Request, app string, chain zddc.PolicyChain, requestDir string) {
vSpec := strings.TrimSpace(r.URL.Query().Get("v"))
src, hasOverride, err := ResolveWithOverride(chain, app, s.Root, requestDir, vSpec)
if err != nil {
// `?v=` parsing/validation errors are user input → 400.
if vSpec != "" {
http.Error(w, "400 Bad Request — invalid ?v= value: "+err.Error(), http.StatusBadRequest)
return
}
// Malformed `.zddc` spec — operator's fault. Log and serve embedded.
s.Fetcher.Logger.Warn("apps.Resolve failed; serving embedded",
"app", app, "request_dir", requestDir, "err", err)
s.serveEmbedded(w, r, app, err)
// chain and requestDir are retained in the signature for call-site stability
// and future per-directory resolution; the current local model is path-
// independent (a bundle member or the embedded default).
func (s *Server) Serve(w http.ResponseWriter, r *http.Request, app string, _ zddc.PolicyChain, _ string) {
body, tag, embedded, ok := s.resolveBytes(app)
if !ok {
w.Header().Set("Retry-After", "60")
http.Error(w,
"503 Service Unavailable\n\n"+
"This zddc-server has no embedded fallback for "+app+" and no\n"+
"\""+app+".html\" in the site .zddc.zip bundle.\n"+
"Rebuild the binary against the latest tool HTMLs, or add the\n"+
"file to .zddc.zip.\n",
http.StatusServiceUnavailable)
return
}
if !hasOverride {
// No `.zddc apps:` entry anywhere up the chain and no `?v=` either →
// embedded is the authoritative default.
s.serveEmbedded(w, r, app, nil)
return
etag := bodyETag(body)
if embedded {
etag = EmbeddedETag(app)
}
// Per-request `?v=` is restricted to cache-backed URL sources.
if vSpec != "" {
if !src.IsURL() {
http.Error(w, "400 Bad Request — ?v= requires a URL-form spec", http.StatusBadRequest)
return
}
if s.Cache == nil || !s.Cache.Has(src.URL) {
http.Error(w,
"404 Not Found — version requested via ?v= is not in the local cache.\n"+
"Only versions the deployment has already fetched (via .zddc apps: entries) are servable.\n"+
"Asked for: "+src.URL+"\n",
http.StatusNotFound)
return
}
body, err := s.Cache.Read(src.URL)
if err != nil {
s.Fetcher.Logger.Warn("?v= cache read failed", "url", src.URL, "err", err)
http.Error(w, "500 Internal Server Error", http.StatusInternalServerError)
return
}
s.serveBody(w, r, body, "cache:"+src.URL)
return
}
if !src.IsURL() {
// Path source: read directly, no cache.
body, err := os.ReadFile(src.Path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
s.Fetcher.Logger.Warn("path source missing; serving embedded",
"app", app, "path", src.Path)
} else {
s.Fetcher.Logger.Warn("path source unreadable; serving embedded",
"app", app, "path", src.Path, "err", err)
}
s.serveEmbedded(w, r, app, err)
return
}
s.serveBody(w, r, body, "path:"+src.Path)
return
}
// URL source: cache hit serves immediately; cache miss fetches once.
body, err := s.Fetcher.Fetch(r.Context(), src.URL)
if err != nil {
s.Fetcher.LogEmbeddedFallback(app, src.URL, err)
s.serveEmbedded(w, r, app, err)
return
}
sourceTag := "fetch:" + src.URL
if s.Cache != nil && s.Cache.Has(src.URL) {
// Likely served from cache (Has was true when the read started).
// Distinguishing cache-hit from just-fetched is best-effort here.
sourceTag = "cache:" + src.URL
}
s.serveBody(w, r, body, sourceTag)
writeWithETag(w, r, body, etag, "text/html; charset=utf-8", tag)
}
// writeWithETag writes body with a strong ETag derived from `etag`, the
// cache-friendly headers, and short-circuits to 304 Not Modified when the
// client's `If-None-Match` matches. `max-age=0, must-revalidate` means the
// browser revalidates on every load — and the matching ETag returns 304
// with empty body, so the steady-state cost of a reload is ~200 bytes
// instead of the full HTML payload (50920 KB depending on the tool).
// writeWithETag writes body with a strong ETag, cache-friendly headers, and
// short-circuits to 304 Not Modified when the client's If-None-Match matches.
func writeWithETag(w http.ResponseWriter, r *http.Request, body []byte, etag, contentType, sourceHeader string) {
quotedTag := `"` + etag + `"`
w.Header().Set("ETag", quotedTag)
@ -185,30 +134,8 @@ func writeWithETag(w http.ResponseWriter, r *http.Request, body []byte, etag, co
_, _ = w.Write(body)
}
// bodyETag computes a stable 32-hex-char ETag for an arbitrary body. Used
// for the URL/path-sourced response path (the bytes vary per cache-fetch
// or per file read, so memoizing per-app would be wrong).
// bodyETag computes a stable 32-hex-char ETag for an arbitrary body.
func bodyETag(body []byte) string {
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:])[:32]
}
func (s *Server) serveBody(w http.ResponseWriter, r *http.Request, body []byte, sourceHeader string) {
writeWithETag(w, r, body, bodyETag(body), "text/html; charset=utf-8", sourceHeader)
}
func (s *Server) serveEmbedded(w http.ResponseWriter, r *http.Request, app string, _ error) {
body := EmbeddedBytes(app)
if len(body) == 0 {
w.Header().Set("Retry-After", "60")
http.Error(w,
"503 Service Unavailable\n\n"+
"This zddc-server has no embedded fallback for "+app+".\n"+
"Rebuild the binary against the latest tool HTMLs.\n",
http.StatusServiceUnavailable)
return
}
writeWithETag(w, r, body, EmbeddedETag(app),
"text/html; charset=utf-8",
"embedded:"+app+"@"+s.BuildVer)
}

View file

@ -1,47 +1,14 @@
package apps
import (
"crypto/ed25519"
"crypto/rand"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// signedFixture returns a (publicKey, handler) pair where the handler
// serves `body` for any URL ending in `.html` and the corresponding
// Ed25519 signature for the same URL with `.sig` appended. Tests use
// this to stand up upstream stubs that exercise the apps fetcher's
// strict signature-verification path.
//
// All tests share one pattern: the fetcher's VerifyKey gets overridden
// to this fixture's publicKey so verification passes against the
// fixture's signature instead of the production embedded key.
func signedFixture(t *testing.T, body []byte) (ed25519.PublicKey, http.HandlerFunc) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
sig := ed25519.Sign(priv, body)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, ".sig"):
_, _ = w.Write(sig)
default:
_, _ = w.Write(body)
}
})
return pub, handler
}
func TestMatchAppHTML(t *testing.T) {
cases := []struct {
path, wantApp, wantDir string
@ -63,34 +30,21 @@ func TestMatchAppHTML(t *testing.T) {
}
}
// Build a Server with a fake upstream serving body. The upstream
// also publishes a valid Ed25519 signature alongside (.sig) and the
// fetcher's VerifyKey is overridden to the matching test pubkey so
// fetched bytes pass the strict-signature gate.
func newTestServer(t *testing.T, body []byte) (*Server, *httptest.Server, string) {
t.Helper()
pub, handler := signedFixture(t, body)
upstream := httptest.NewServer(handler)
t.Cleanup(upstream.Close)
root := t.TempDir()
cache, err := NewCache(filepath.Join(root, CacheDirName))
if err != nil {
t.Fatal(err)
}
f := NewFetcher(cache, nil)
f.VerifyKey = pub
return NewServer(root, cache, f, "test"), upstream, root
// serve runs srv.Serve for app and returns the recorder.
func serve(srv *Server, app string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/"+app+".html", nil), app, chain, srv.Root)
return rec
}
func TestServer_NoOverride_ServesEmbedded(t *testing.T) {
srv, _, root := newTestServer(t, []byte("upstream body"))
func TestServer_NoBundle_ServesEmbedded(t *testing.T) {
srv := NewServer(t.TempDir(), "test")
saved := embeddedArchive
embeddedArchive = []byte("EMBEDDED archive")
defer func() { embeddedArchive = saved }()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
rec := serve(srv, "archive")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
@ -102,266 +56,60 @@ func TestServer_NoOverride_ServesEmbedded(t *testing.T) {
}
}
func TestServer_OverrideURL_FetchesAndCaches(t *testing.T) {
body := []byte("from upstream")
srv, up, root := newTestServer(t, body)
chain := zddc.PolicyChain{
Levels: []zddc.ZddcFile{{
Apps: map[string]string{"archive": up.URL + "/archive_stable.html"},
}},
}
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
if rec.Body.String() != string(body) {
t.Errorf("body mismatch")
}
// Cache should be populated.
if !srv.Cache.Has(up.URL + "/archive_stable.html") {
t.Errorf("cache miss after fetch")
}
}
func TestServer_OverrideURL_CacheHitOnSecondCall(t *testing.T) {
var hits atomic.Int64
body := []byte("body")
pub, _, sig := func() (ed25519.PublicKey, ed25519.PrivateKey, []byte) {
p, k, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
return p, k, ed25519.Sign(k, body)
}()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Count only artifact fetches (not .sig fetches) so the assertion
// "1 hit means cache works" stays meaningful: cache stores the
// artifact body, signature verification re-runs each time the
// resolver hits the URL but only on the first miss does it fetch
// the artifact bytes itself. After that, cache.Read short-circuits.
if !strings.HasSuffix(r.URL.Path, ".sig") {
hits.Add(1)
_, _ = w.Write(body)
return
}
_, _ = w.Write(sig)
}))
defer upstream.Close()
func TestServer_BundleMemberOverridesEmbedded(t *testing.T) {
root := t.TempDir()
cache, _ := NewCache(filepath.Join(root, CacheDirName))
f := NewFetcher(cache, nil)
f.VerifyKey = pub
srv := NewServer(root, cache, f, "test")
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{"archive": upstream.URL + "/archive_stable.html"},
}}}
for i := 0; i < 3; i++ {
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
if rec.Code != http.StatusOK {
t.Fatalf("call %d status=%d", i, rec.Code)
}
}
if hits.Load() != 1 {
t.Errorf("upstream fetched %d times, want exactly 1 (cache forever)", hits.Load())
}
}
func TestServer_PathOverride_ServedDirectly(t *testing.T) {
root := t.TempDir()
pathFile := filepath.Join(root, "local.html")
body := []byte("local archive bytes")
if err := os.WriteFile(pathFile, body, 0o644); err != nil {
t.Fatal(err)
}
cache, _ := NewCache(filepath.Join(root, CacheDirName))
f := NewFetcher(cache, nil)
srv := NewServer(root, cache, f, "test")
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{
{Apps: map[string]string{"archive": "./local.html"}},
}}
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
if rec.Body.String() != string(body) {
t.Errorf("body mismatch")
}
if !strings.HasPrefix(rec.Header().Get("X-ZDDC-Source"), "path:") {
t.Errorf("X-ZDDC-Source=%q", rec.Header().Get("X-ZDDC-Source"))
}
}
func TestServer_FetchFailFallsBackToEmbedded(t *testing.T) {
srv, _, root := newTestServer(t, []byte("ok"))
writeTestBundle(t, root, map[string]string{"archive.html": "BUNDLE archive override"})
srv := NewServer(root, "test")
saved := embeddedArchive
embeddedArchive = []byte("EMBEDDED")
embeddedArchive = []byte("EMBEDDED archive")
defer func() { embeddedArchive = saved }()
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{"archive": "https://no-such.example/archive.html"},
}}}
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
rec := serve(srv, "archive")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d (want 200 from embedded)", rec.Code)
t.Fatalf("status=%d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "BUNDLE archive override") {
t.Errorf("expected bundle body, got %q", rec.Body.String())
}
if rec.Header().Get("X-ZDDC-Source") != "bundle:archive.html" {
t.Errorf("X-ZDDC-Source=%q, want bundle:archive.html", rec.Header().Get("X-ZDDC-Source"))
}
}
func TestServer_BundlePresent_MemberAbsent_ServesEmbedded(t *testing.T) {
root := t.TempDir()
writeTestBundle(t, root, map[string]string{"browse.html": "BUNDLE browse"})
srv := NewServer(root, "test")
saved := embeddedArchive
embeddedArchive = []byte("EMBEDDED archive")
defer func() { embeddedArchive = saved }()
rec := serve(srv, "archive") // bundle has browse, not archive
if !strings.Contains(rec.Body.String(), "EMBEDDED") {
t.Errorf("body did not come from embedded fallback: %q", rec.Body.String())
t.Errorf("expected embedded fallback, got %q", rec.Body.String())
}
}
// ── ?v= per-request override ─────────────────────────────────────────────
func TestServer_VParam_CacheHitServesFromCache(t *testing.T) {
srv, _, root := newTestServer(t, []byte("ignored"))
// Pre-populate the cache with a known URL.
cachedURL := "https://zddc.varasys.io/releases/archive_v0.0.4.html"
cachedBody := []byte("CACHED v0.0.4 archive")
if err := srv.Cache.Write(cachedURL, cachedBody); err != nil {
t.Fatal(err)
}
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=v0.0.4", nil), "archive", chain, root)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if rec.Body.String() != string(cachedBody) {
t.Errorf("body=%q, want CACHED bytes", rec.Body.String())
}
if got := rec.Header().Get("X-ZDDC-Source"); got != "cache:"+cachedURL {
t.Errorf("X-ZDDC-Source=%q", got)
func TestServer_UnknownTool_503WithoutBundle(t *testing.T) {
srv := NewServer(t.TempDir(), "test")
rec := serve(srv, "nope") // not embedded, no bundle
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("status=%d, want 503", rec.Code)
}
}
func TestServer_VParam_CacheMissReturns404(t *testing.T) {
srv, _, root := newTestServer(t, []byte("ignored"))
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=v0.0.4", nil), "archive", chain, root)
if rec.Code != http.StatusNotFound {
t.Fatalf("status=%d (want 404)", rec.Code)
}
if !strings.Contains(rec.Body.String(), "not in the local cache") {
t.Errorf("body should explain cache miss, got %q", rec.Body.String())
}
}
func TestServer_VParam_RejectsPathSource(t *testing.T) {
srv, _, root := newTestServer(t, []byte("ignored"))
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=./local.html", nil), "archive", chain, root)
if rec.Code != http.StatusBadRequest {
t.Errorf("status=%d (want 400 for path source via ?v=)", rec.Code)
}
}
func TestServer_VParam_BadSpecReturns400(t *testing.T) {
srv, _, root := newTestServer(t, []byte("ignored"))
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=not%20a%20spec", nil), "archive", chain, root)
if rec.Code != http.StatusBadRequest {
t.Errorf("status=%d (want 400)", rec.Code)
}
}
func TestServer_VParam_CombinesWithCascadeURLPrefix(t *testing.T) {
// Cascade has a default URL prefix; ?v=:beta should resolve against it.
srv, _, root := newTestServer(t, []byte("ignored"))
cachedURL := "https://my-mirror.example/releases/archive_v0.0.4.html"
if err := srv.Cache.Write(cachedURL, []byte("MIRROR v0.0.4")); err != nil {
t.Fatal(err)
}
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{"default": "https://my-mirror.example/releases:stable"},
}}}
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=:v0.0.4", nil), "archive", chain, root)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if rec.Body.String() != "MIRROR v0.0.4" {
t.Errorf("body=%q", rec.Body.String())
}
if got := rec.Header().Get("X-ZDDC-Source"); got != "cache:"+cachedURL {
t.Errorf("X-ZDDC-Source=%q (expected mirror URL)", got)
}
}
func TestServer_VParam_OverridesPathTerminalFromCascade(t *testing.T) {
// Operator's cascade specifies a path source. User passes ?v=stable.
// ?v= overrides → resolves to canonical/archive.html, then cache check.
srv, _, root := newTestServer(t, []byte("ignored"))
cachedURL := "https://zddc.varasys.io/releases/archive.html"
if err := srv.Cache.Write(cachedURL, []byte("CACHED stable")); err != nil {
t.Fatal(err)
}
pathFile := filepath.Join(root, "operator-version.html")
if err := os.WriteFile(pathFile, []byte("OPERATOR PATH"), 0o644); err != nil {
t.Fatal(err)
}
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
Apps: map[string]string{"archive": "./operator-version.html"},
}}}
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=stable", nil), "archive", chain, root)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if rec.Body.String() != "CACHED stable" {
t.Errorf("body=%q (expected ?v= override to win)", rec.Body.String())
}
}
func TestServer_VParam_FullURLForm(t *testing.T) {
// `?v=https://my-fork/archive.html` — terminal full URL, must be cached.
srv, _, root := newTestServer(t, []byte("ignored"))
cachedURL := "https://my-fork.example/custom.html"
if err := srv.Cache.Write(cachedURL, []byte("FORK custom")); err != nil {
t.Fatal(err)
}
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
target := "/archive.html?v=" + url.QueryEscape(cachedURL)
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, target, nil), "archive", chain, root)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if rec.Body.String() != "FORK custom" {
t.Errorf("body=%q", rec.Body.String())
}
}
// TestServer_Embedded_ConditionalGET verifies the ETag/If-None-Match dance
// for the embedded fallback path: a fresh GET returns 200 with an ETag,
// and a follow-up with a matching If-None-Match returns 304 + empty body.
// This is the cache-friendliness fix that lets a browser revalidate
// against zddc-server's embedded HTML without re-transferring the bytes.
func TestServer_Embedded_ConditionalGET(t *testing.T) {
srv, _, root := newTestServer(t, []byte("upstream"))
srv := NewServer(t.TempDir(), "test")
saved := embeddedArchive
embeddedArchive = []byte("EMBEDDED archive bytes for ETag test")
defer func() {
embeddedArchive = saved
etagCacheByApp.Delete("archive") // reset memoization for sibling tests
etagCacheByApp.Delete("archive")
}()
etagCacheByApp.Delete("archive") // ensure clean state for THIS test
etagCacheByApp.Delete("archive")
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
// First request: full body + ETag header.
rec1 := httptest.NewRecorder()
srv.Serve(rec1, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
rec1 := serve(srv, "archive")
if rec1.Code != http.StatusOK {
t.Fatalf("first GET: status=%d body=%s", rec1.Code, rec1.Body.String())
}
@ -370,17 +118,15 @@ func TestServer_Embedded_ConditionalGET(t *testing.T) {
t.Fatalf("first GET: missing ETag header")
}
if cc := rec1.Header().Get("Cache-Control"); !strings.Contains(cc, "max-age=0") || !strings.Contains(cc, "must-revalidate") {
t.Errorf("first GET: Cache-Control=%q (want max-age=0 + must-revalidate)", cc)
}
if !strings.Contains(rec1.Body.String(), "EMBEDDED archive bytes") {
t.Errorf("first GET: body=%q", rec1.Body.String())
t.Errorf("first GET: Cache-Control=%q", cc)
}
// Second request with matching If-None-Match: 304, empty body.
// Matching If-None-Match → 304, empty body.
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
req2.Header.Set("If-None-Match", etag)
srv.Serve(rec2, req2, "archive", chain, root)
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
srv.Serve(rec2, req2, "archive", chain, srv.Root)
if rec2.Code != http.StatusNotModified {
t.Fatalf("If-None-Match match: status=%d (want 304)", rec2.Code)
}
@ -388,16 +134,34 @@ func TestServer_Embedded_ConditionalGET(t *testing.T) {
t.Errorf("304 response should have empty body; got %d bytes", rec2.Body.Len())
}
// Third request with stale If-None-Match: 200, full body.
// Stale If-None-Match → 200, full body.
rec3 := httptest.NewRecorder()
req3 := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
req3.Header.Set("If-None-Match", `"deadbeef"`)
srv.Serve(rec3, req3, "archive", chain, root)
if rec3.Code != http.StatusOK {
t.Errorf("stale If-None-Match: status=%d (want 200)", rec3.Code)
srv.Serve(rec3, req3, "archive", chain, srv.Root)
if rec3.Code != http.StatusOK || rec3.Body.Len() == 0 {
t.Errorf("stale If-None-Match: status=%d bodyLen=%d (want 200, non-empty)", rec3.Code, rec3.Body.Len())
}
if rec3.Body.Len() == 0 {
t.Errorf("stale If-None-Match: empty body; want full")
}
// Bundle responses get a body-hash ETag and also short-circuit to 304.
func TestServer_Bundle_ConditionalGET(t *testing.T) {
root := t.TempDir()
writeTestBundle(t, root, map[string]string{"browse.html": "BUNDLE browse body"})
srv := NewServer(root, "test")
rec1 := serve(srv, "browse")
etag := rec1.Header().Get("ETag")
if rec1.Code != http.StatusOK || etag == "" {
t.Fatalf("first GET: status=%d etag=%q", rec1.Code, etag)
}
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/browse.html", nil)
req2.Header.Set("If-None-Match", etag)
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
srv.Serve(rec2, req2, "browse", chain, srv.Root)
if rec2.Code != http.StatusNotModified {
t.Errorf("bundle If-None-Match: status=%d (want 304)", rec2.Code)
}
}
@ -422,6 +186,6 @@ func TestEmbeddedETag_Stable(t *testing.T) {
etagCacheByApp.Delete("archive")
b := EmbeddedETag("archive")
if b == a1 {
t.Errorf("EmbeddedETag should differ for different bytes; both %q", b)
t.Errorf("EmbeddedETag should change with bytes; both %q", b)
}
}

View file

@ -1,43 +0,0 @@
package apps
import "sync"
// singleflightGroup deduplicates concurrent calls keyed by string. If N
// goroutines call Do(key, fn) before the first one returns, fn runs once
// and all callers receive the same (val, err).
//
// Hand-rolled to avoid pulling in golang.org/x/sync — we only need the
// 30-line core, not Forget/DoChan. Pattern is the standard one.
type singleflightGroup struct {
mu sync.Mutex
m map[string]*sfCall
}
type sfCall struct {
done chan struct{}
val any
err error
}
func (g *singleflightGroup) Do(key string, fn func() (any, error)) (any, error) {
g.mu.Lock()
if g.m == nil {
g.m = make(map[string]*sfCall)
}
if c, ok := g.m[key]; ok {
g.mu.Unlock()
<-c.done
return c.val, c.err
}
c := &sfCall{done: make(chan struct{})}
g.m[key] = c
g.mu.Unlock()
c.val, c.err = fn()
close(c.done)
g.mu.Lock()
delete(g.m, key)
g.mu.Unlock()
return c.val, c.err
}

View file

@ -1,67 +0,0 @@
package apps
import (
"sync"
"sync/atomic"
"testing"
"time"
)
func TestSingleflightDedupes(t *testing.T) {
var g singleflightGroup
var calls atomic.Int64
fn := func() (any, error) {
calls.Add(1)
time.Sleep(50 * time.Millisecond) // hold the lock long enough for races
return "result", nil
}
var wg sync.WaitGroup
const N = 50
for i := 0; i < N; i++ {
wg.Add(1)
go func() {
defer wg.Done()
val, err := g.Do("the-key", fn)
if err != nil {
t.Errorf("Do err: %v", err)
return
}
if val.(string) != "result" {
t.Errorf("got %v, want 'result'", val)
}
}()
}
wg.Wait()
if got := calls.Load(); got != 1 {
t.Errorf("fn called %d times, want exactly 1", got)
}
}
func TestSingleflightDifferentKeysParallel(t *testing.T) {
var g singleflightGroup
var calls atomic.Int64
fn := func() (any, error) {
calls.Add(1)
return "ok", nil
}
for _, k := range []string{"a", "b", "c"} {
_, _ = g.Do(k, fn)
}
if got := calls.Load(); got != 3 {
t.Errorf("fn called %d times, want 3", got)
}
}
func TestSingleflightSecondCallAfterFirstResolves(t *testing.T) {
var g singleflightGroup
var calls atomic.Int64
fn := func() (any, error) {
calls.Add(1)
return "x", nil
}
_, _ = g.Do("k", fn)
_, _ = g.Do("k", fn)
if got := calls.Load(); got != 2 {
t.Errorf("fn called %d times, want 2 (second call sees no in-flight entry)", got)
}
}

View file

@ -1,73 +0,0 @@
package apps
import (
"crypto/ed25519"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
)
// LoadPubKey reads a PEM-encoded SubjectPublicKeyInfo (the format
// `openssl pkey -pubout` emits) from path and returns the underlying
// Ed25519 public key.
//
// Operators distribute and configure this key explicitly — same posture
// as the TLS certificate: zddc-server bakes nothing in. Customers
// running against zddc.varasys.io's release channel download the
// canonical key from zddc.varasys.io/pubkey.pem and pass the local
// path via --apps-pubkey or ZDDC_APPS_PUBKEY. Customers running their
// own signing infrastructure pass their own public key instead.
//
// Returns a descriptive error for missing files, malformed PEM, wrong
// PEM type, or non-Ed25519 keys. Callers (cmd/zddc-server's startup
// path) treat any error as fatal — refusing to start with a misconfigured
// apps-pubkey is the right posture.
func LoadPubKey(path string) (ed25519.PublicKey, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read apps-pubkey from %s: %w", path, err)
}
return ParsePubKeyPEM(data)
}
// ParsePubKeyPEM is LoadPubKey's content-only variant. Useful when the
// PEM bytes come from somewhere other than disk (test fixtures, etc.).
func ParsePubKeyPEM(pemBytes []byte) (ed25519.PublicKey, error) {
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, errors.New("no PEM block found")
}
if block.Type != "PUBLIC KEY" {
return nil, fmt.Errorf("unexpected PEM type %q (want PUBLIC KEY)", block.Type)
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse PKIX: %w", err)
}
edPub, ok := pub.(ed25519.PublicKey)
if !ok {
return nil, fmt.Errorf("public key is not Ed25519 (got %T)", pub)
}
return edPub, nil
}
// VerifyEd25519 checks that sig is a valid Ed25519 signature of body
// produced with the private key matching pub. Returns nil on success
// or a descriptive error otherwise.
//
// sig must be exactly 64 bytes (the raw Ed25519 signature format
// produced by `openssl pkeyutl -sign -rawin`).
func VerifyEd25519(pub ed25519.PublicKey, body, sig []byte) error {
if pub == nil {
return errors.New("no public key configured")
}
if len(sig) != ed25519.SignatureSize {
return fmt.Errorf("signature has wrong length: %d (want %d)", len(sig), ed25519.SignatureSize)
}
if !ed25519.Verify(pub, body, sig) {
return errors.New("signature does not verify against trusted public key")
}
return nil
}

View file

@ -1,255 +0,0 @@
package apps
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
)
// genTestKey returns a fresh Ed25519 keypair for tests so the test
// suite never depends on the embedded production key.
func genTestKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
return pub, priv
}
func TestParseEd25519PublicKeyPEM_RoundTrip(t *testing.T) {
pub, _ := genTestKey(t)
derBytes, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
t.Fatalf("MarshalPKIXPublicKey: %v", err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: derBytes})
parsed, err := ParsePubKeyPEM(pemBytes)
if err != nil {
t.Fatalf("parse: %v", err)
}
if !pub.Equal(parsed) {
t.Errorf("round-trip pubkey mismatch")
}
}
func TestParseEd25519PublicKeyPEM_RejectsRSA(t *testing.T) {
// PEM containing a non-Ed25519 key should error rather than
// silently coerce. Use a hand-crafted bad PEM block.
bad := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: []byte("not a valid SubjectPublicKeyInfo")})
if _, err := ParsePubKeyPEM(bad); err == nil {
t.Error("ParsePubKeyPEM accepted malformed PEM, want error")
}
}
func TestParseEd25519PublicKeyPEM_RejectsWrongType(t *testing.T) {
pub, _ := genTestKey(t)
derBytes, _ := x509.MarshalPKIXPublicKey(pub)
wrongType := pem.EncodeToMemory(&pem.Block{Type: "RSA PUBLIC KEY", Bytes: derBytes})
if _, err := ParsePubKeyPEM(wrongType); err == nil {
t.Error("ParsePubKeyPEM accepted wrong PEM Type, want error")
}
}
func TestVerifyEd25519_ValidSignature(t *testing.T) {
pub, priv := genTestKey(t)
msg := []byte("the artifact bytes")
sig := ed25519.Sign(priv, msg)
if err := VerifyEd25519(pub, msg, sig); err != nil {
t.Errorf("VerifyEd25519 rejected a valid signature: %v", err)
}
}
func TestVerifyEd25519_TamperedMessage(t *testing.T) {
pub, priv := genTestKey(t)
original := []byte("the artifact bytes")
tampered := []byte("the artifact byteX")
sig := ed25519.Sign(priv, original)
if err := VerifyEd25519(pub, tampered, sig); err == nil {
t.Error("VerifyEd25519 accepted a tampered message, want error")
}
}
func TestVerifyEd25519_WrongKey(t *testing.T) {
_, priv := genTestKey(t)
otherPub, _ := genTestKey(t)
msg := []byte("the artifact bytes")
sig := ed25519.Sign(priv, msg)
if err := VerifyEd25519(otherPub, msg, sig); err == nil {
t.Error("VerifyEd25519 accepted a signature from the wrong key, want error")
}
}
func TestVerifyEd25519_MalformedSignature(t *testing.T) {
pub, _ := genTestKey(t)
msg := []byte("hello")
cases := [][]byte{
nil, // empty
make([]byte, 32), // too short
make([]byte, 100), // too long
make([]byte, 64), // right length, wrong contents
}
for i, sig := range cases {
if err := VerifyEd25519(pub, msg, sig); err == nil {
t.Errorf("case %d: VerifyEd25519 accepted malformed signature of length %d, want error", i, len(sig))
}
}
}
func TestVerifyEd25519_NilKey(t *testing.T) {
if err := VerifyEd25519(nil, []byte("x"), make([]byte, 64)); err == nil {
t.Error("VerifyEd25519(nil, ...) accepted, want error")
}
}
// TestFetcher_AcceptsValidSignature: end-to-end. Server publishes
// an artifact and a valid .sig; fetcher accepts and caches.
func TestFetcher_AcceptsValidSignature(t *testing.T) {
pub, priv := genTestKey(t)
body := []byte("<!doctype html><html><body>signed artifact</body></html>")
sig := ed25519.Sign(priv, body)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/archive.html":
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write(body)
case "/archive.html.sig":
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(sig)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
cache, err := NewCache(filepath.Join(t.TempDir(), "_app"))
if err != nil {
t.Fatalf("NewCache: %v", err)
}
f := NewFetcher(cache, nil)
f.VerifyKey = pub // override the embedded production key
got, err := f.Fetch(context.Background(), srv.URL+"/archive.html")
if err != nil {
t.Fatalf("Fetch failed: %v", err)
}
if string(got) != string(body) {
t.Errorf("body mismatch")
}
// Cache hit on second call.
if !cache.Has(srv.URL + "/archive.html") {
t.Error("expected cache to contain artifact after successful verification")
}
}
// TestFetcher_RejectsTamperedBody: the published .sig is valid but
// the body has been changed by a hypothetical mitm. Fetcher must
// reject and NOT cache the tampered bytes.
func TestFetcher_RejectsTamperedBody(t *testing.T) {
pub, priv := genTestKey(t)
original := []byte("<!doctype html>genuine")
sig := ed25519.Sign(priv, original)
tampered := []byte("<!doctype html>injected")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/archive.html":
_, _ = w.Write(tampered)
case "/archive.html.sig":
_, _ = w.Write(sig)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
cache, err := NewCache(filepath.Join(t.TempDir(), "_app"))
if err != nil {
t.Fatalf("NewCache: %v", err)
}
f := NewFetcher(cache, nil)
f.VerifyKey = pub
_, err = f.Fetch(context.Background(), srv.URL+"/archive.html")
if err == nil {
t.Fatal("Fetch accepted tampered body, want error")
}
if !strings.Contains(err.Error(), "signature") {
t.Errorf("error %q does not mention signature", err)
}
if cache.Has(srv.URL + "/archive.html") {
t.Error("tampered bytes were cached; verifier must not write to cache on rejection")
}
}
// TestFetcher_RejectsMissingSignature: artifact published but no .sig
// alongside (HTTP 404). Strict mode → reject.
func TestFetcher_RejectsMissingSignature(t *testing.T) {
pub, _ := genTestKey(t)
body := []byte("body without sig")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/archive.html":
_, _ = w.Write(body)
case "/archive.html.sig":
http.NotFound(w, r)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
cache, _ := NewCache(filepath.Join(t.TempDir(), "_app"))
f := NewFetcher(cache, nil)
f.VerifyKey = pub
_, err := f.Fetch(context.Background(), srv.URL+"/archive.html")
if err == nil {
t.Fatal("Fetch accepted unsigned artifact, want error")
}
if !strings.Contains(err.Error(), "404") && !strings.Contains(err.Error(), "signature") {
t.Errorf("error %q does not mention 404 or signature", err)
}
if cache.Has(srv.URL + "/archive.html") {
t.Error("unsigned bytes were cached; verifier must reject before caching")
}
}
// TestFetcher_RejectsWrongKeySignature: .sig present, well-formed,
// but signed by a different key than f.VerifyKey trusts.
func TestFetcher_RejectsWrongKeySignature(t *testing.T) {
trustedPub, _ := genTestKey(t)
_, attackerPriv := genTestKey(t)
body := []byte("body signed by an untrusted key")
sig := ed25519.Sign(attackerPriv, body)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/archive.html":
_, _ = w.Write(body)
case "/archive.html.sig":
_, _ = w.Write(sig)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
cache, _ := NewCache(filepath.Join(t.TempDir(), "_app"))
f := NewFetcher(cache, nil)
f.VerifyKey = trustedPub
_, err := f.Fetch(context.Background(), srv.URL+"/archive.html")
if err == nil {
t.Fatal("Fetch accepted wrong-key-signed artifact, want error")
}
if cache.Has(srv.URL + "/archive.html") {
t.Error("wrong-key-signed bytes were cached")
}
}

View file

@ -34,17 +34,16 @@ type Config struct {
// Root then becomes the cache directory rather than the served
// data root. Master-mode flags (apps, archive, opa, etc.) are
// ignored in client mode — see cmd/zddc-server/main.go.
Upstream string // --upstream / ZDDC_UPSTREAM — master URL (https://master.example.com); empty = run as master
Mode string // --mode / ZDDC_MODE — "proxy" (no disk persistence), "cache" (default; persist on access), "mirror" (cache + access-triggered subtree warmer)
BearerFile string // --bearer-file / ZDDC_BEARER_FILE — path to a 0600 file containing the master-issued token to forward upstream
SkipTLSVerify bool // --skip-tls-verify / ZDDC_SKIP_TLS_VERIFY=1 — accept self-signed / untrusted upstream certs. Distinct from --no-auth; intended for dev/internal CA scenarios only.
MirrorSubtree []string // --mirror-subtree / ZDDC_MIRROR_SUBTREE — comma-separated subtree URL paths the access-triggered walker keeps current. Default `/` when --mode=mirror and unset; ignored otherwise.
MirrorMinInterval time.Duration // --mirror-min-interval / ZDDC_MIRROR_MIN_INTERVAL — minimum gap between walks of the same subtree. Idle subtrees stay quiet; bumping this reduces upstream load on busy mirrors. Default 1h.
OPAURL string // --opa-url / ZDDC_OPA_URL — policy decider endpoint: "internal" (default), "http(s)://..." (real OPA via HTTP), or "unix:///..." (OPA via Unix socket)
OPAFailOpen bool // --opa-fail-open / ZDDC_OPA_FAIL_OPEN=1 — when external OPA is unreachable, allow instead of deny (default: fail closed)
OPACacheTTL time.Duration // --opa-cache-ttl / ZDDC_OPA_CACHE_TTL — external mode only: per-decision cache TTL. Default 1s. Set 0s to disable.
AppsPubKey string // --apps-pubkey / ZDDC_APPS_PUBKEY — path to the Ed25519 public key (PEM) used to verify Ed25519 signatures on URL-fetched apps: artifacts. Empty = URL apps disabled (only embedded + local-path apps work). Operators using zddc.varasys.io's canonical channels download pubkey.pem from there.
MaxWriteBytes int64 // --max-write-bytes / ZDDC_MAX_WRITE_BYTES — upper bound on PUT body size. Default 256 MiB. Per-request limit; rejected with 413.
Upstream string // --upstream / ZDDC_UPSTREAM — master URL (https://master.example.com); empty = run as master
Mode string // --mode / ZDDC_MODE — "proxy" (no disk persistence), "cache" (default; persist on access), "mirror" (cache + access-triggered subtree warmer)
BearerFile string // --bearer-file / ZDDC_BEARER_FILE — path to a 0600 file containing the master-issued token to forward upstream
SkipTLSVerify bool // --skip-tls-verify / ZDDC_SKIP_TLS_VERIFY=1 — accept self-signed / untrusted upstream certs. Distinct from --no-auth; intended for dev/internal CA scenarios only.
MirrorSubtree []string // --mirror-subtree / ZDDC_MIRROR_SUBTREE — comma-separated subtree URL paths the access-triggered walker keeps current. Default `/` when --mode=mirror and unset; ignored otherwise.
MirrorMinInterval time.Duration // --mirror-min-interval / ZDDC_MIRROR_MIN_INTERVAL — minimum gap between walks of the same subtree. Idle subtrees stay quiet; bumping this reduces upstream load on busy mirrors. Default 1h.
OPAURL string // --opa-url / ZDDC_OPA_URL — policy decider endpoint: "internal" (default), "http(s)://..." (real OPA via HTTP), or "unix:///..." (OPA via Unix socket)
OPAFailOpen bool // --opa-fail-open / ZDDC_OPA_FAIL_OPEN=1 — when external OPA is unreachable, allow instead of deny (default: fail closed)
OPACacheTTL time.Duration // --opa-cache-ttl / ZDDC_OPA_CACHE_TTL — external mode only: per-decision cache TTL. Default 1s. Set 0s to disable.
MaxWriteBytes int64 // --max-write-bytes / ZDDC_MAX_WRITE_BYTES — upper bound on PUT body size. Default 256 MiB. Per-request limit; rejected with 413.
ArchiveRescanInterval time.Duration // --archive-rescan-interval / ZDDC_ARCHIVE_RESCAN_INTERVAL — periodic full re-walk of the archive index. Covers SMB/CIFS where inotify misses cross-client writes. Default 60s; 0 to disable.
// MD→{docx,html,pdf} conversion endpoint (see internal/convert).
@ -132,8 +131,6 @@ func Load(args []string) (Config, error) {
"External OPA only: on unreachable / non-2xx / malformed response, allow the request instead of denying. Default: fail closed.")
opaCacheTTLFlag := fs.Duration("opa-cache-ttl", parseDurationOrDefault(os.Getenv("ZDDC_OPA_CACHE_TTL"), time.Second),
"External OPA only: per-decision cache TTL. Amortizes round-trips on bursts of identical queries (e.g. .archive listing). Default 1s; set 0 to disable.")
appsPubKeyFlag := fs.String("apps-pubkey", os.Getenv("ZDDC_APPS_PUBKEY"),
"Path to the Ed25519 public key (PEM) used to verify signatures on URL-fetched apps: artifacts. Empty (default) = URL-fetched apps refused; only embedded + local-path apps work. Download zddc.varasys.io/pubkey.pem if you use the canonical channels.")
maxWriteBytesFlag := fs.Int64("max-write-bytes", parseInt64OrDefault(os.Getenv("ZDDC_MAX_WRITE_BYTES"), 256*1024*1024),
"Maximum PUT body size in bytes for the file API. Default 256 MiB. Larger requests are rejected with 413.")
archiveRescanIntervalFlag := fs.Duration("archive-rescan-interval", parseDurationOrDefault(os.Getenv("ZDDC_ARCHIVE_RESCAN_INTERVAL"), 60*time.Second),
@ -198,28 +195,27 @@ func Load(args []string) (Config, error) {
addrExplicit := addrFlagSet || addrEnvSet
cfg := Config{
Root: *rootFlag,
Addr: *addrFlag,
TLSCert: *tlsCertFlag,
TLSKey: *tlsKeyFlag,
LogLevel: *logLevelFlag,
IndexPath: *indexPathFlag,
EmailHeader: *emailHeaderFlag,
CORSOrigins: resolveCORS(corsFlagSet, *corsOriginFlag),
AccessLog: *accessLogFlag,
Insecure: *insecureFlag,
NoAuth: *noAuthFlag,
Upstream: *upstreamFlag,
Mode: *modeFlag,
BearerFile: *bearerFileFlag,
SkipTLSVerify: *skipTLSVerifyFlag,
MirrorSubtree: parseCSV(*mirrorSubtreeFlag),
MirrorMinInterval: *mirrorMinIntervalFlag,
OPAURL: *opaURLFlag,
OPAFailOpen: *opaFailOpenFlag,
OPACacheTTL: *opaCacheTTLFlag,
AppsPubKey: *appsPubKeyFlag,
MaxWriteBytes: *maxWriteBytesFlag,
Root: *rootFlag,
Addr: *addrFlag,
TLSCert: *tlsCertFlag,
TLSKey: *tlsKeyFlag,
LogLevel: *logLevelFlag,
IndexPath: *indexPathFlag,
EmailHeader: *emailHeaderFlag,
CORSOrigins: resolveCORS(corsFlagSet, *corsOriginFlag),
AccessLog: *accessLogFlag,
Insecure: *insecureFlag,
NoAuth: *noAuthFlag,
Upstream: *upstreamFlag,
Mode: *modeFlag,
BearerFile: *bearerFileFlag,
SkipTLSVerify: *skipTLSVerifyFlag,
MirrorSubtree: parseCSV(*mirrorSubtreeFlag),
MirrorMinInterval: *mirrorMinIntervalFlag,
OPAURL: *opaURLFlag,
OPAFailOpen: *opaFailOpenFlag,
OPACacheTTL: *opaCacheTTLFlag,
MaxWriteBytes: *maxWriteBytesFlag,
ArchiveRescanInterval: *archiveRescanIntervalFlag,
ConvertPandocBinary: *convertPandocBinaryFlag,
ConvertChromiumBinary: *convertChromiumBinaryFlag,
@ -416,7 +412,6 @@ func Usage(w io.Writer) {
fs.String("opa-url", "internal", "Policy decider: \"internal\", \"http(s)://...\", or \"unix:///...\".")
fs.Bool("opa-fail-open", false, "External OPA: allow on transport error (default: deny / fail closed).")
fs.Duration("opa-cache-ttl", time.Second, "External OPA: per-decision cache TTL (default 1s; 0 disables).")
fs.String("apps-pubkey", "", "Path to PEM Ed25519 pubkey for verifying signed URL-fetched apps. Empty = URL apps refused.")
fs.String("access-log", "", "Tee structured access logs to this file (JSON, size-rotated). Default <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log; --access-log= disables.")
fs.Duration("archive-rescan-interval", 60*time.Second, "Periodic full re-walk of the archive index (covers SMB inotify gap). Default 60s; 0 disables.")
fs.Bool("help", false, "Print this help and exit.")

View file

@ -14,8 +14,8 @@
// Public surface:
//
// ToDocx(ctx, source, meta) → []byte (DOCX bytes)
// ToHTML(ctx, source, meta) → []byte (standalone HTML)
// ToPDF (ctx, source, meta) → []byte (PDF, via HTML + chromium)
// ToHTML(ctx, source, meta, ts) → []byte (standalone HTML)
// ToPDF (ctx, source, meta, ts) → []byte (PDF, via HTML + chromium)
//
// Probe(ctx) → Capabilities (call once at startup)
// Available() → (Capabilities, bool)
@ -25,7 +25,7 @@
// All three converters are safe for concurrent use; each call gets a
// fresh scratch dir + (image-provided) sandbox.
//
// Metadata maps to the placeholders consumed by viewer-template.html.
// Metadata maps to the placeholders consumed by the doctype templates.
// title/tracking_number/revision/status/is_draft typically come from
// the source filename (zddc.ParseFilename); client/project/contractor/
// project_number from the .zddc cascade `convert:` block.
@ -42,8 +42,8 @@ import (
)
// Metadata is the variable bag passed to pandoc as `--variable k=v`
// pairs. Fields with zero values are omitted. The viewer-template.html
// uses `$if(field)$ … $endif$` blocks so absent fields render cleanly.
// pairs. Fields with zero values are omitted. The templates use
// `$if(field)$ … $endif$` blocks so absent fields render cleanly.
type Metadata struct {
Title string
TrackingNumber string
@ -58,6 +58,28 @@ type Metadata struct {
NoTOC bool
}
// TemplateSet is the bundle of files written to the per-call scratch dir for an
// HTML render: the chosen doctype template (Name) plus every partial it may
// include. pandoc resolves `$partial()$` includes from the template's own
// directory, so Files must contain Name and all referenced partials.
type TemplateSet struct {
Name string // primary template filename, e.g. "report.html"
Files map[string][]byte // base filename -> bytes (must include Name)
}
// DefaultTemplateSet returns the baked-in template set for doctype `name`
// (e.g. "report"). An empty or unknown name falls back to DefaultTemplateName.
// The set includes every embedded partial so `$..()$` includes resolve; handlers
// may overlay .zddc.d/templates/ overrides onto the returned Files map.
func DefaultTemplateSet(name string) TemplateSet {
files := embeddedTemplateFiles()
primary := name + ".html"
if name == "" || files[primary] == nil {
primary = DefaultTemplateName + ".html"
}
return TemplateSet{Name: primary, Files: files}
}
// Default binary names. The runtime image installs WRAPPER scripts at
// /usr/local/bin/pandoc and /usr/local/bin/chromium-browser (shadowing
// the real binaries in /usr/bin/) so these names resolve through the
@ -132,12 +154,20 @@ func currentChromiumBinary() string {
// full file content (envelope + body); pandoc handles
// `markdown+yaml_metadata_block` natively.
func ToDocx(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
return convertToDocx(ctx, "markdown+yaml_metadata_block", source, m)
}
// convertToDocx renders source (in pandoc input format fromFmt) to DOCX bytes
// via a single pandoc exec (stdin → stdout; no scratch dir). Images in the
// source's mediabag — present when fromFmt is "html" — are embedded into the
// .docx natively by pandoc's docx writer.
func convertToDocx(ctx context.Context, fromFmt string, source []byte, m Metadata) ([]byte, error) {
r := currentRunner()
if r == nil {
return nil, ErrUnavailable
}
cmd := []string{
"--from=markdown+yaml_metadata_block",
"--from=" + fromFmt,
"--to=docx",
"--output=-",
}
@ -146,25 +176,99 @@ func ToDocx(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
return r.Run(ctx, currentPandocBinary(), source, "", cmd)
}
// ToHTML renders source markdown to standalone HTML using
// viewer-template.html. Embeds CSS + images via --embed-resources.
// Template + custom.css live in a per-call scratch dir; the host
// path is passed via ZDDC_SCRATCH so the wrapper bind-mounts it
// into the sandbox at the same path.
func ToHTML(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
// convertToMarkdown renders source (DOCX or HTML, per fromFmt) to GitHub-
// flavored markdown. Embedded images are inlined as base64 data: URIs via the
// inline-media.lua filter so the output .md is self-contained; --wrap=none keeps
// paragraphs on one line (no hard line breaks).
func convertToMarkdown(ctx context.Context, fromFmt string, source []byte) ([]byte, error) {
r := currentRunner()
if r == nil {
return nil, ErrUnavailable
}
scratch, err := writeAssetsToScratch(currentScratchDir())
scratch, err := writeScratchFiles(currentScratchDir(), map[string][]byte{"inline-media.lua": inlineMediaLua})
if err != nil {
return nil, fmt.Errorf("scratch: %w", err)
}
defer os.RemoveAll(scratch)
cmd := []string{
"--from=" + fromFmt,
"--to=gfm",
"--wrap=none",
"--lua-filter=" + filepath.Join(scratch, "inline-media.lua"),
"--output=-",
"-",
}
return r.Run(ctx, currentPandocBinary(), source, scratch, cmd)
}
// Convert renders source from one document format to another. Supported pairs:
//
// md → docx | html | pdf
// docx → md | html
// html → md | docx
//
// ts is the resolved HTML template set, used only for the *→html and md→pdf
// directions and ignored otherwise. Unsupported pairs return an error.
func Convert(ctx context.Context, from, to string, source []byte, m Metadata, ts TemplateSet) ([]byte, error) {
switch from {
case "md", "markdown":
switch to {
case "docx":
return ToDocx(ctx, source, m)
case "html":
return ToHTML(ctx, source, m, ts)
case "pdf":
return ToPDF(ctx, source, m, ts)
}
case "docx":
switch to {
case "md":
return convertToMarkdown(ctx, "docx", source)
case "html":
return convertToHTML(ctx, "docx", source, m, ts)
}
case "html", "htm":
switch to {
case "md":
return convertToMarkdown(ctx, "html", source)
case "docx":
return convertToDocx(ctx, "html", source, m)
}
}
return nil, fmt.Errorf("unsupported conversion %s→%s", from, to)
}
// ToHTML renders source markdown to standalone HTML using the doctype
// template in ts. Embeds CSS + images via --embed-resources. The
// template + its partials live in a per-call scratch dir; the host path
// is passed via ZDDC_SCRATCH so the wrapper bind-mounts it into the
// sandbox at the same path. A zero-value ts falls back to the embedded
// default template.
func ToHTML(ctx context.Context, source []byte, m Metadata, ts TemplateSet) ([]byte, error) {
return convertToHTML(ctx, "markdown+yaml_metadata_block", source, m, ts)
}
// convertToHTML renders source (in pandoc input format fromFmt) to standalone
// HTML through the doctype template in ts. --embed-resources base64-inlines CSS
// and any mediabag images (so DOCX images survive docx→html with no extra
// filter). The template + partials are written to a per-call scratch dir.
func convertToHTML(ctx context.Context, fromFmt string, source []byte, m Metadata, ts TemplateSet) ([]byte, error) {
r := currentRunner()
if r == nil {
return nil, ErrUnavailable
}
if ts.Name == "" || len(ts.Files) == 0 {
ts = DefaultTemplateSet(DefaultTemplateName)
}
scratch, err := writeScratchFiles(currentScratchDir(), ts.Files)
if err != nil {
return nil, fmt.Errorf("scratch: %w", err)
}
defer os.RemoveAll(scratch)
tplPath := filepath.Join(scratch, "viewer-template.html")
tplPath := filepath.Join(scratch, ts.Name)
cmd := []string{
"--from=markdown+yaml_metadata_block",
"--from=" + fromFmt,
"--to=html5",
"--standalone",
"--embed-resources",
@ -182,18 +286,18 @@ func ToHTML(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
}
// ToPDF renders source markdown to PDF in two stages: pandoc
// produces HTML using viewer-template.html (stage 1), then headless
// chromium prints that HTML to PDF (stage 2). The two-stage choice
// preserves the print-media CSS already authored in viewer-
// template.html — pandoc's native --pdf-engine path uses LaTeX
// which would bypass it entirely.
// produces HTML using the doctype template in ts (stage 1), then
// headless chromium prints that HTML to PDF (stage 2). The two-stage
// choice preserves the print-media CSS authored in the templates —
// pandoc's native --pdf-engine path uses LaTeX which would bypass it
// entirely.
//
// Both stages share a single per-call scratch dir: pandoc writes
// `in.html` and chromium reads it, then chromium writes `out.pdf`
// which the host reads back. The wrapper bind-mounts the scratch
// dir read-write into the sandbox at the same path.
func ToPDF(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
html, err := ToHTML(ctx, source, m)
func ToPDF(ctx context.Context, source []byte, m Metadata, ts TemplateSet) ([]byte, error) {
html, err := ToHTML(ctx, source, m, ts)
if err != nil {
return nil, err
}

View file

@ -41,6 +41,77 @@ func (f *fakeRunner) lastCall() (string, []string) {
return f.binaries[len(f.binaries)-1], f.calls[len(f.calls)-1]
}
func TestConvert_Directions(t *testing.T) {
cases := []struct {
from, to string
wantArgs []string // substrings that must appear in the pandoc command
wantErr bool
}{
{"docx", "md", []string{"--from=docx", "--to=gfm", "--wrap=none"}, false},
{"html", "md", []string{"--from=html", "--to=gfm", "--wrap=none"}, false},
{"docx", "html", []string{"--from=docx", "--to=html5", "--embed-resources"}, false},
{"html", "docx", []string{"--from=html", "--to=docx"}, false},
{"md", "docx", []string{"--from=markdown+yaml_metadata_block", "--to=docx"}, false},
{"md", "html", []string{"--from=markdown+yaml_metadata_block", "--to=html5"}, false},
{"docx", "pdf", nil, true}, // pdf is markdown-only
{"docx", "docx", nil, true}, // same-format is unsupported
{"html", "html", nil, true},
}
for _, c := range cases {
t.Run(c.from+"_to_"+c.to, func(t *testing.T) {
f := &fakeRunner{resp: []byte("OUT")}
InstallRunner(f)
t.Cleanup(func() { InstallRunner(nil) })
SetBinaries("pandoc", "chromium-browser")
_, err := Convert(context.Background(), c.from, c.to, []byte("x"), Metadata{}, TemplateSet{})
if c.wantErr {
if err == nil {
t.Fatalf("Convert(%s→%s): expected error, got nil", c.from, c.to)
}
return
}
if err != nil {
t.Fatalf("Convert(%s→%s): %v", c.from, c.to, err)
}
binary, call := f.lastCall()
if binary != "pandoc" {
t.Errorf("expected pandoc, got %q", binary)
}
for _, want := range c.wantArgs {
if !contains(call, want) {
t.Errorf("Convert(%s→%s) missing %q in %v", c.from, c.to, want, call)
}
}
// To-markdown directions inline images via the lua filter.
if c.to == "md" {
if !hasPrefArg(call, "--lua-filter=") || !hasSuffArg(call, "inline-media.lua") {
t.Errorf("Convert(%s→md) missing inline-media.lua filter: %v", c.from, call)
}
}
})
}
}
// hasPrefArg / hasSuffArg report whether any arg has the given prefix/suffix.
func hasPrefArg(args []string, prefix string) bool {
for _, a := range args {
if strings.HasPrefix(a, prefix) {
return true
}
}
return false
}
func hasSuffArg(args []string, suffix string) bool {
for _, a := range args {
if strings.HasSuffix(a, suffix) {
return true
}
}
return false
}
func TestToDocx_UsesPandocBinary(t *testing.T) {
f := &fakeRunner{resp: []byte("FAKE-DOCX")}
InstallRunner(f)
@ -86,7 +157,7 @@ func TestToHTML_UsesTemplateFromScratchDir(t *testing.T) {
t.Cleanup(func() { InstallRunner(nil) })
SetBinaries("pandoc", "chromium-browser")
_, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{Title: "Hi"})
_, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{Title: "Hi"}, TemplateSet{})
if err != nil {
t.Fatalf("ToHTML: %v", err)
}
@ -101,7 +172,7 @@ func TestToHTML_UsesTemplateFromScratchDir(t *testing.T) {
if scratch == "" {
t.Fatalf("ToHTML must pass a scratch dir to the runner")
}
wantTpl := "--template=" + scratch + "/viewer-template.html"
wantTpl := "--template=" + scratch + "/report.html"
if !contains(call, wantTpl) {
t.Errorf("template flag missing/wrong; want %q in %v", wantTpl, call)
}
@ -115,7 +186,7 @@ func TestToHTML_NoTOCSuppressesTOC(t *testing.T) {
InstallRunner(f)
t.Cleanup(func() { InstallRunner(nil) })
_, _ = ToHTML(context.Background(), []byte("# Hi\n"), Metadata{NoTOC: true})
_, _ = ToHTML(context.Background(), []byte("# Hi\n"), Metadata{NoTOC: true}, TemplateSet{})
_, call := f.lastCall()
if contains(call, "--toc") {
t.Errorf("TOC should be suppressed when NoTOC=true: %v", call)
@ -170,7 +241,7 @@ func TestScratchDir_UsedByToHTML(t *testing.T) {
scratchRoot := t.TempDir()
SetScratchDir(scratchRoot)
_, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{})
_, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{}, TemplateSet{})
if err != nil {
t.Fatalf("ToHTML: %v", err)
}
@ -199,7 +270,7 @@ func TestToPDF_TwoStagePipeline(t *testing.T) {
t.Cleanup(func() { InstallRunner(nil) })
SetBinaries("pandoc", "chromium-browser")
_, err := ToPDF(context.Background(), []byte("# Hi\n"), Metadata{})
_, err := ToPDF(context.Background(), []byte("# Hi\n"), Metadata{}, TemplateSet{})
// PDF read-back will fail (fake runner didn't write the file) —
// that's expected for this test which only inspects the call shape.
if err == nil {

View file

@ -1,163 +0,0 @@
/*
* Legal-style heading numbering for ZDDC documents
* Adds hierarchical numbering like 1, 1.1, 1.1.1, etc.
*/
/* Reset counters at document level */
.document-content {
counter-reset: h1-counter;
}
/* H1 counters */
h1 {
counter-reset: h2-counter h3-counter h4-counter h5-counter h6-counter;
counter-increment: h1-counter;
}
h1::before {
content: counter(h1-counter) ". ";
font-weight: bold;
color: var(--primary-color);
}
/* H2 counters */
h2 {
counter-reset: h3-counter h4-counter h5-counter h6-counter;
counter-increment: h2-counter;
}
h2::before {
content: counter(h1-counter) "." counter(h2-counter) " ";
font-weight: bold;
color: var(--primary-color);
}
/* H3 counters */
h3 {
counter-reset: h4-counter h5-counter h6-counter;
counter-increment: h3-counter;
}
h3::before {
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) " ";
font-weight: bold;
color: var(--primary-color);
}
/* H4 counters */
h4 {
counter-reset: h5-counter h6-counter;
counter-increment: h4-counter;
}
h4::before {
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) " ";
font-weight: bold;
color: var(--primary-color);
}
/* H5 counters */
h5 {
counter-reset: h6-counter;
counter-increment: h5-counter;
}
h5::before {
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) "." counter(h5-counter) " ";
font-weight: bold;
color: var(--primary-color);
}
/* H6 counters */
h6 {
counter-increment: h6-counter;
}
h6::before {
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) "." counter(h5-counter) "." counter(h6-counter) " ";
font-weight: bold;
color: var(--primary-color);
}
/* TOC numbering to match document headings */
.toc {
counter-reset: toc-h1;
}
.toc ul {
list-style: none;
}
.toc > ul > li {
counter-increment: toc-h1;
counter-reset: toc-h2 toc-h3 toc-h4 toc-h5 toc-h6;
}
.toc > ul > li > a::before {
content: counter(toc-h1) ". ";
font-weight: bold;
color: var(--primary-color);
margin-right: 0.25em;
}
.toc > ul > li > ul > li {
counter-increment: toc-h2;
counter-reset: toc-h3 toc-h4 toc-h5 toc-h6;
}
.toc > ul > li > ul > li > a::before {
content: counter(toc-h1) "." counter(toc-h2) " ";
font-weight: bold;
color: var(--primary-color);
margin-right: 0.25em;
}
.toc > ul > li > ul > li > ul > li {
counter-increment: toc-h3;
counter-reset: toc-h4 toc-h5 toc-h6;
}
.toc > ul > li > ul > li > ul > li > a::before {
content: counter(toc-h1) "." counter(toc-h2) "." counter(toc-h3) " ";
font-weight: bold;
color: var(--primary-color);
margin-right: 0.25em;
}
/* Optional: Add some spacing after the numbers */
h1::before, h2::before, h3::before, h4::before, h5::before, h6::before {
margin-right: 0.5em;
}
/* Print-specific adjustments */
@media print {
h1::before, h2::before, h3::before, h4::before, h5::before, h6::before {
color: #000 !important; /* Ensure numbers print in black */
}
}
/* Optional: Style adjustments for better visual hierarchy */
h1 {
border-bottom: 2px solid var(--primary-color);
padding-bottom: 0.3em;
margin-top: 1em;
}
/* Reduce margin for first heading */
h1:first-of-type {
margin-top: 0.5em;
}
h2 {
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.2em;
margin-top: 1.5em;
}
h3 {
margin-top: 1.2em;
}
h4, h5, h6 {
margin-top: 1em;
}

View file

@ -1,19 +1,88 @@
package convert
import _ "embed"
import (
"embed"
"io/fs"
"path"
"sort"
)
// Pandoc HTML template and its companion stylesheet, copied verbatim from
// /pandoc/viewer-template.html and /pandoc/custom.css. The runner writes
// these to a host scratch dir on each conversion and bind-mounts them
// read-only into the container so pandoc can `--template` against them.
// Default pandoc HTML templates, mirrored verbatim from /pandoc/templates/ by
// the top-level ./build (shared/build-lib.sh: sync_pandoc_templates). The runner
// writes the chosen template + its partials to a host scratch dir on each HTML
// conversion and bind-mounts them into the sandbox so pandoc can `--template`
// against them.
//
// Refresh: when /pandoc/viewer-template.html changes, copy the new bytes
// here. There's no symlink because go:embed paths must resolve under the
// containing module — and we want the binary to ship the bytes verbatim,
// not depend on the source tree at runtime.
// pandoc/templates/ is the single source of truth; this directory is a build
// artifact kept in sync and guarded by TestEmbeddedTemplatesMatchSource. There's
// no symlink because go:embed paths must resolve under the containing module, and
// we want the binary to ship the bytes verbatim, not depend on the source tree at
// runtime.
//
// The set holds named doctype templates (report.html, letter.html,
// specification.html) plus the shared partials they include (_head.html,
// _doc.html, _scripts.html). A document picks one via its `template:` front
// matter; operators override individual files through the .zddc.d/templates/
// cascade (see internal/handler).
//go:embed viewer-template.html
var viewerTemplate []byte
// `all:` is required so the `_`-prefixed partials (_head.html, _doc.html,
// _scripts.html) are embedded — a bare `//go:embed templates` excludes names
// beginning with `_` or `.`.
//
//go:embed all:templates
var templatesFS embed.FS
//go:embed custom.css
var customCSS []byte
// inlineMediaLua is the pandoc filter that base64-inlines images into markdown
// output (docx→md / html→md), written to the per-call scratch dir alongside the
// conversion. Server-only — the CLI convert script extracts media to a folder
// instead.
//
//go:embed inline-media.lua
var inlineMediaLua []byte
// DefaultTemplateName is used when a document declares no `template:` field or
// names one that doesn't resolve.
const DefaultTemplateName = "report"
// embeddedTemplate returns the bytes of a baked-in template/partial by base file
// name (e.g. "report.html", "_head.html"), or nil if there is no such default.
func embeddedTemplate(name string) []byte {
b, err := templatesFS.ReadFile(path.Join("templates", name))
if err != nil {
return nil
}
return b
}
// embeddedTemplateFiles returns all baked-in template/partial files keyed by
// base name. The returned map is a fresh copy the caller may mutate (e.g. to
// overlay .zddc.d/templates overrides).
func embeddedTemplateFiles() map[string][]byte {
out := make(map[string][]byte)
entries, _ := fs.ReadDir(templatesFS, "templates")
for _, e := range entries {
if e.IsDir() {
continue
}
if b := embeddedTemplate(e.Name()); b != nil {
out[e.Name()] = b
}
}
return out
}
// EmbeddedTemplateNames lists the baked-in doctype template names (no extension,
// partials excluded — i.e. the names a `template:` field may select), sorted.
func EmbeddedTemplateNames() []string {
var names []string
entries, _ := fs.ReadDir(templatesFS, "templates")
for _, e := range entries {
n := e.Name()
if e.IsDir() || n == "" || n[0] == '_' || path.Ext(n) != ".html" {
continue
}
names = append(names, n[:len(n)-len(".html")])
}
sort.Strings(names)
return names
}

View file

@ -0,0 +1,31 @@
-- inline-media.lua — pandoc filter that rewrites every image to a self-contained
-- base64 data: URI, pulling the bytes from pandoc's mediabag (populated when
-- reading DOCX, or fetched for HTML). Used by the docx→md / html→md conversions
-- so the resulting markdown carries its images inline (markdown output has no
-- native --embed-resources equivalent).
local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
local function base64(data)
return ((data:gsub('.', function(x)
local r, byte = '', x:byte()
for i = 8, 1, -1 do r = r .. (byte % 2 ^ i - byte % 2 ^ (i - 1) > 0 and '1' or '0') end
return r
end) .. '0000'):gsub('%d%d%d?%d?%d?%d?', function(x)
if #x < 6 then return '' end
local c = 0
for i = 1, 6 do c = c + (x:sub(i, i) == '1' and 2 ^ (6 - i) or 0) end
return b:sub(c + 1, c + 1)
end) .. ({ '', '==', '=' })[#data % 3 + 1])
end
function Image(img)
local mt, data = pandoc.mediabag.lookup(img.src)
if not data then
mt, data = pandoc.mediabag.fetch(img.src)
end
if data then
img.src = 'data:' .. (mt or 'application/octet-stream') .. ';base64,' .. base64(data)
end
return img
end

View file

@ -274,29 +274,27 @@ func (r *ringWriter) String() string {
return string(r.buf)
}
// writeAssetsToScratch materialises the embedded viewer-template.html
// and custom.css into a fresh scratch dir and returns the host path.
// Caller is responsible for os.RemoveAll(dir) when done. Used by
// ToHTML which needs the template visible inside the sandbox.
// writeScratchFiles materialises a set of named byte buffers (template +
// partials, or a lua filter) into a fresh scratch dir and returns the host
// path. Caller is responsible for os.RemoveAll(dir) when done. pandoc resolves
// `$partial()$` includes and --lua-filter paths from this dir, so everything
// lands flat alongside the entry file.
//
// scratchRoot controls where the temp dir lands. Empty means
// "use $TMPDIR".
// scratchRoot controls where the temp dir lands. Empty means "use $TMPDIR".
//
// Files are written world-readable so the binary's default user can
// read them through the wrapper's bind mount regardless of the
// host's umask.
func writeAssetsToScratch(scratchRoot string) (string, error) {
// Files are written world-readable so the binary's default user can read them
// through the wrapper's bind mount regardless of the host's umask. Keys are
// reduced to base names only (no path separators).
func writeScratchFiles(scratchRoot string, files map[string][]byte) (string, error) {
dir, err := os.MkdirTemp(scratchRoot, "zddc-convert-")
if err != nil {
return "", fmt.Errorf("scratch dir: %w", err)
}
if err := os.WriteFile(filepath.Join(dir, "viewer-template.html"), viewerTemplate, 0o644); err != nil {
os.RemoveAll(dir)
return "", fmt.Errorf("write template: %w", err)
}
if err := os.WriteFile(filepath.Join(dir, "custom.css"), customCSS, 0o644); err != nil {
os.RemoveAll(dir)
return "", fmt.Errorf("write css: %w", err)
for name, b := range files {
if err := os.WriteFile(filepath.Join(dir, filepath.Base(name)), b, 0o644); err != nil {
os.RemoveAll(dir)
return "", fmt.Errorf("write scratch file %q: %w", name, err)
}
}
if err := chmodTree(dir, 0o755, 0o644); err != nil {
os.RemoveAll(dir)

View file

@ -0,0 +1,112 @@
<div class="app-container">
$if(toc)$
<!-- Sidebar Navigation -->
<aside id="sidebar" role="complementary" aria-label="Table of contents">
<header class="sidebar-header">
<div class="toc-header-row">
<div class="sidebar-title">Table Of Contents</div>
<div class="toc-level-selector">
<select id="toc-level" aria-label="Filter table of contents levels">
<option value="1">1</option>
<option value="2">2</option>
<option value="3" selected>3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
</select>
</div>
</div>
</header>
<div class="toc-container">
$if(toc)$
<nav class="toc" role="navigation" aria-label="Table of contents">
$toc$
</nav>
$endif$
</div>
</aside>
$endif$
<!-- Main Content Area -->
<main class="content-wrapper" role="main">
<div class="content-page">
<!-- Document Header -->
<header class="document-header">
$if(toc)$
<div class="mobile-menu-container">
<button class="mobile-menu-toggle" type="button" aria-label="Toggle navigation menu" aria-expanded="false">
<span aria-hidden="true"></span>
</button>
</div>
$endif$
<div class="header-content">
$if(client)$$if(project)$
<div class="header-line client-project">
$client$ - $project$$if(project_number)$ ($project_number$)$endif$
</div>
$endif$$endif$
$if(title)$
<div class="document-title">$title$</div>
$endif$
<div class="document-meta">
$if(tracking_number)$<span class="tracking-number">$tracking_number$</span>$endif$
$if(revision)$<span class="revision">Revision: $revision$</span>$endif$
$if(status)$<span class="status">Status: $status$</span>$endif$
$if(revision_comparison)$<span class="revision-comparison">$revision_comparison$</span>$endif$
</div>
$if(is_draft)$
$if(generation_time)$
<div class="draft-line">
<span class="draft-status">[DRAFT Generated at $generation_time$]</span>
</div>
$endif$
$endif$
</div>
</header>
<!-- Scroll Progress Bar -->
<div class="scroll-progress" role="progressbar" aria-label="Reading progress">
<div class="scroll-progress-bar"></div>
</div>
<!-- Print-only header -->
<div class="print-header">
$if(custom_header)$
$custom_header$
$else$
$if(client)$$if(project)$
<div class="header-line client-project">
$client$ - $project$$if(project_number)$ ($project_number$)$endif$
</div>
$endif$$endif$
$if(title)$
<div class="header-line document-title">$title$</div>
$endif$
$if(tracking_number)$<div class="header-line">$tracking_number$$if(revision)$ Revision: $revision$$endif$$if(status)$ Status: $status$$endif$</div>$endif$
$if(revision_comparison)$<div class="header-line revision-comparison">$revision_comparison$</div>$endif$
$endif$
$if(generation_time)$
<div class="header-line metadata-line draft-line">
<span class="draft-status">Generated: $generation_time$</span>
</div>
$endif$
</div>
<!-- Print-only footer -->
<div class="print-footer">
<div class="footer-left">
$if(tracking_number)$$tracking_number$$endif$$if(revision)$ Revision: $revision$$endif$$if(status)$ Status: $status$$endif$
</div>
<div class="footer-right">
Page <span class="page-number"></span>
</div>
</div>
<!-- Document Content -->
<article class="document-content">
$body$
</article>
</div>
</main>
</div>

View file

@ -0,0 +1,778 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>$if(title)$$title$$else$Document$endif$</title>
<!-- Document metadata for JavaScript -->
$if(revision)$<meta name="revision" content="$revision$">$endif$
$if(generation_time)$<meta name="generation_time" content="$generation_time$">$endif$
<!-- Embedded CSS -->
<style>
/*
* ZDDC Document Viewer Template
* Enhanced responsive layout with TOC navigation
*/
/* CSS Variables for theming - Soft Light Theme */
:root {
--primary-color: #2563eb;
--primary-color-dark: #1d4ed8;
--text-color: #4b5563;
--text-secondary: #6b7280;
--text-primary: #1f2937;
--bg-primary: #f8fafc;
--bg-secondary: #f1f5f9;
--border-color: #d1d5db;
--hover-bg: #e2e8f0;
--active-bg: rgba(37, 99, 235, 0.1);
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--sidebar-width: 280px;
--header-height: 120px;
--content-max-width: 900px;
}
/* Dark mode variables - Standard Dark Theme */
@media (prefers-color-scheme: dark) {
:root {
--primary-color: #60a5fa;
--primary-color-dark: #3b82f6;
--text-color: #d1d5db;
--text-secondary: #9ca3af;
--text-primary: #f9fafb;
--bg-primary: #111827;
--bg-secondary: #1f2937;
--border-color: #374151;
--hover-bg: #374151;
--active-bg: rgba(96, 165, 250, 0.2);
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
}
}
/* Reset and base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: var(--text-color);
background: var(--bg-secondary);
height: 100vh;
overflow-x: hidden;
}
@media print {
body {
height: auto !important;
overflow: visible !important;
}
}
/* App Container - Modern CSS Grid Layout */
.app-container {
height: 100vh;
display: grid;
grid-template-columns: var(--sidebar-width) 1fr;
grid-template-areas: "sidebar main";
}
@media (max-width: 768px) {
.app-container {
grid-template-columns: 1fr;
grid-template-areas: "main";
}
}
/* Content wrapper - Grid area */
.content-wrapper {
grid-area: main;
display: flex;
flex-direction: column;
min-height: 0;
max-width: min(900px, 100%);
margin: 0;
container-type: inline-size;
}
/* Content page simplified */
.content-page {
flex: 1;
display: flex;
flex-direction: column;
}
.header-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
/* Sidebar Navigation - Grid area */
#sidebar {
grid-area: sidebar;
height: 100vh;
background: var(--bg-primary);
border-inline-end: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow: hidden;
}
@media (max-width: 768px) {
#sidebar {
position: fixed;
inset: 0;
z-index: 1000;
transform: translateX(-100%);
transition: transform 0.3s ease;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
background: var(--bg-primary);
}
#sidebar.mobile-open {
transform: translateX(0);
}
.content-wrapper {
max-width: none;
}
/* Ensure mobile TOC uses light theme colors */
#sidebar .sidebar-header {
background: var(--bg-secondary);
color: var(--text-primary);
}
#sidebar .toc a {
color: var(--text-primary);
}
#sidebar .toc a:hover {
background: var(--hover-bg);
}
}
/* Document Header - Flex Row Layout */
.document-header {
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
padding: 1rem;
margin-bottom: 0;
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
}
.mobile-menu-container {
display: none;
flex-shrink: 0;
}
@media (max-width: 768px) {
.mobile-menu-container {
display: flex;
align-items: center;
}
.mobile-menu-toggle {
background: var(--primary-color);
color: white;
border: none;
padding: 0.75rem;
border-radius: 0.5rem;
font-size: 1.2rem;
cursor: pointer;
transition: all 0.2s ease;
line-height: 1;
}
.mobile-menu-toggle:hover {
background: var(--primary-color-dark);
transform: scale(1.05);
margin: 0;
}
}
.header-content {
flex: 1;
min-width: 0;
}
.sidebar-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.sidebar-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.toc-header-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.toc-level-selector {
display: flex;
align-items: center;
gap: 0.5rem;
}
.toc-level-selector select {
padding: 0.25rem 0.5rem;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
background: var(--bg-primary);
color: var(--text-color);
font-size: 0.9rem;
}
/* TOC Container */
.toc-container {
flex: 1;
overflow-y: auto;
padding: 1rem 0;
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
position: relative;
}
.toc-container::-webkit-scrollbar {
width: 6px;
}
.toc-container::-webkit-scrollbar-track {
background: transparent;
}
.toc-container::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
/* Scroll Progress Indicator */
.scroll-progress {
width: 100%;
height: 3px;
background: var(--border-color);
margin-bottom: 20px;
}
.scroll-progress-bar {
height: 100%;
background: var(--primary-color);
width: 0%;
transition: width 0.1s ease;
}
/* TOC Styling */
.toc ul {
list-style: none;
padding: 0;
margin: 0;
}
.toc ul ul {
padding-left: 1.25rem;
margin-top: 0.25rem;
border-left: 2px solid var(--border-color);
margin-left: 0.5rem;
}
.toc li {
margin: 0;
}
.toc a {
display: block;
padding: 0.375rem 0.75rem;
color: var(--text-color);
text-decoration: none;
border-radius: 4px;
transition: all 0.2s ease;
font-size: 0.9rem;
line-height: 1.3;
}
.toc li li a {
border-left: none;
}
.toc a:hover {
background: var(--hover-bg);
color: var(--primary-color);
}
.toc a.active {
background: var(--active-bg);
color: var(--primary-color);
border-left-color: var(--primary-color);
font-weight: 500;
}
/* Content Page Container - Simplified */
.content-page {
flex: 1;
background: var(--bg-primary);
display: flex;
flex-direction: column;
min-height: 0;
}
/* Document Content */
.document-content {
flex: 1;
padding: 0.5rem 2rem 2rem 2rem;
max-width: var(--content-max-width);
margin: 0 auto;
overflow-y: auto;
position: relative;
}
/* Document Header */
.document-header {
border-bottom: 1px solid var(--border-color);
padding: 0.75rem 2rem;
background: var(--bg-primary);
}
.header-content {
max-width: var(--content-max-width);
margin: 0 auto;
}
.header-line {
margin: 0;
line-height: 1.3;
}
/* Header line hierarchy */
.client-project {
font-size: 1.2rem;
color: var(--text-color);
font-weight: 600;
margin-bottom: 0.5rem;
}
.document-title {
font-size: 2.2rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.metadata-line {
font-size: 0.9rem;
color: var(--text-secondary);
font-weight: 400;
}
.draft-status {
color: #dc3545;
font-weight: bold;
margin-left: 0.5rem;
}
/* Print-only elements - hidden on screen */
.print-header,
.print-footer {
display: none;
}
/* Mobile menu backdrop */
@media (max-width: 768px) {
.mobile-menu-container {
display: flex;
align-items: center;
}
#sidebar.mobile-open::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: -1;
}
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
color: var(--text-primary);
font-weight: 600;
line-height: 1.25;
margin-top: 1em;
margin-bottom: 0.5em;
}
/* Remove top margin from first heading in content */
.document-content h1:first-child,
.document-content h2:first-child,
.document-content h3:first-child,
.document-content h4:first-child,
.document-content h5:first-child,
.document-content h6:first-child {
margin-top: 0;
}
h1 { font-size: 2rem; }
h2 { font-size: 1.5rem; }
h3 { font-size: 1.25rem; }
h4 { font-size: 1.125rem; }
h5 { font-size: 1rem; }
h6 { font-size: 0.875rem; }
p {
margin: 1rem 0;
color: var(--text-color);
}
/* Lists */
ol, ul {
margin: 1rem 0;
padding-left: 2rem;
}
ol {
list-style-type: decimal;
}
ul {
list-style-type: disc;
}
li {
margin: 0.25rem 0;
color: var(--text-color);
}
/* Nested lists */
ol ol, ul ul, ol ul, ul ol {
margin: 0.25rem 0;
padding-left: 1.5rem;
}
ul ul {
list-style-type: circle;
}
ul ul ul {
list-style-type: square;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
font-size: 0.9rem;
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
/* Print styles */
@media print {
/* Hide online-only elements */
.sidebar,
.mobile-menu-toggle,
.scroll-progress,
.document-header {
display: none !important;
}
/* Show print-only elements */
.print-header {
display: block !important;
position: fixed;
top: 0;
left: 0;
right: 0;
background: white;
border-bottom: 1pt solid #000;
padding: 12pt 0.5in;
z-index: 1000;
margin: 0;
}
.print-footer {
display: flex !important;
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
border-top: 1pt solid #000;
padding: 8pt 0.5in;
z-index: 1000;
justify-content: space-between;
align-items: center;
margin: 0;
}
/* Print header styling */
.print-header .client-project {
font-size: 12pt;
color: #333;
font-weight: 600;
margin: 0 0 4pt 0;
line-height: 1.2;
}
.print-header .document-title {
font-size: 16pt;
color: #000;
font-weight: 700;
margin: 0;
line-height: 1.2;
}
/* Print footer styling */
.print-footer .footer-left,
.print-footer .footer-right {
font-size: 10pt;
color: #666;
margin: 0;
}
/* Page counter for print */
.print-footer .page-number::after {
content: counter(page);
}
@page {
margin: 1in;
size: letter;
counter-increment: page;
}
.draft-line {
margin-top: 4pt;
font-size: 10pt;
}
/* Layout adjustments */
html, body {
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
}
.app-container {
display: block !important;
width: 100% !important;
max-width: 100% !important;
}
.content-wrapper {
margin-left: 0 !important;
width: 100% !important;
/* The screen layout caps content-wrapper at 900px; in print, the
printable area is page-width minus @page margins (~6.5in =
~624px for letter at 96dpi), which is narrower than 900px BUT
chromium's --print-to-pdf renders at the full page width and
only clips at print time — so without max-width:none the
element extends past the right margin. */
max-width: none !important;
}
.content-page {
max-width: none !important;
width: 100% !important;
padding: 0 !important;
}
.document-content {
margin-top: 80pt !important;
margin-bottom: 50pt !important;
padding: 0 0.5in !important;
border-left: none !important;
min-height: calc(100vh - 130pt) !important;
max-width: 100% !important;
box-sizing: border-box !important;
}
/* Wide content that wouldn't otherwise wrap: tables, code blocks,
long URLs in inline code. Force them to stay within the
printable area instead of running off the right edge. */
pre, code, table, blockquote, img, video {
max-width: 100% !important;
overflow-wrap: break-word !important;
word-wrap: break-word !important;
}
pre {
white-space: pre-wrap !important;
word-break: break-word !important;
}
table {
table-layout: fixed !important;
width: 100% !important;
}
/* Fix list formatting in print */
ol, ul {
padding-left: 2rem !important;
}
li {
margin: 0.25rem 0 !important;
}
/* Typography for print */
body {
font-size: 12pt !important;
line-height: 1.4 !important;
color: #000 !important;
background: white !important;
}
/* Page breaks */
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
page-break-inside: avoid;
margin-top: 0.5em;
}
p, li {
orphans: 3;
widows: 3;
page-break-inside: avoid;
}
/* Prevent content cutoff */
* {
box-sizing: border-box;
}
/* Ensure proper spacing at page breaks */
h1:first-child, h2:first-child, h3:first-child {
margin-top: 0;
padding-top: 0.5em;
}
/* Table print formatting */
table {
page-break-inside: avoid;
}
thead {
display: table-header-group;
}
tbody {
display: table-row-group;
}
tr {
page-break-inside: avoid;
}
th, td {
padding: 8pt 6pt !important;
vertical-align: top;
}
a {
color: #000 !important;
text-decoration: underline !important;
}
}
/* Diff styling for pandiff output */
u {
background-color: #d4edda;
color: #155724;
text-decoration: none;
padding: 0.1em 0.2em;
border-radius: 0.2em;
}
/*
* Legal-style heading numbering for ZDDC documents.
* Gated by the `numbered` body class, which the per-doctype templates add when
* the document's YAML front matter sets `numbering: true` (default: off).
*/
body.numbered .document-content { counter-reset: h1-counter; }
body.numbered h1 { counter-reset: h2-counter h3-counter h4-counter h5-counter h6-counter; counter-increment: h1-counter; }
body.numbered h1::before { content: counter(h1-counter) ". "; font-weight: bold; color: var(--primary-color); }
body.numbered h2 { counter-reset: h3-counter h4-counter h5-counter h6-counter; counter-increment: h2-counter; }
body.numbered h2::before { content: counter(h1-counter) "." counter(h2-counter) " "; font-weight: bold; color: var(--primary-color); }
body.numbered h3 { counter-reset: h4-counter h5-counter h6-counter; counter-increment: h3-counter; }
body.numbered h3::before { content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) " "; font-weight: bold; color: var(--primary-color); }
body.numbered h4 { counter-reset: h5-counter h6-counter; counter-increment: h4-counter; }
body.numbered h4::before { content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) " "; font-weight: bold; color: var(--primary-color); }
body.numbered h5 { counter-reset: h6-counter; counter-increment: h5-counter; }
body.numbered h5::before { content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) "." counter(h5-counter) " "; font-weight: bold; color: var(--primary-color); }
body.numbered h6 { counter-increment: h6-counter; }
body.numbered h6::before { content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) "." counter(h5-counter) "." counter(h6-counter) " "; font-weight: bold; color: var(--primary-color); }
/* TOC numbering to match document headings */
body.numbered .toc { counter-reset: toc-h1; }
body.numbered .toc ul { list-style: none; }
body.numbered .toc > ul > li { counter-increment: toc-h1; counter-reset: toc-h2 toc-h3 toc-h4 toc-h5 toc-h6; }
body.numbered .toc > ul > li > a::before { content: counter(toc-h1) ". "; font-weight: bold; color: var(--primary-color); margin-right: 0.25em; }
body.numbered .toc > ul > li > ul > li { counter-increment: toc-h2; counter-reset: toc-h3 toc-h4 toc-h5 toc-h6; }
body.numbered .toc > ul > li > ul > li > a::before { content: counter(toc-h1) "." counter(toc-h2) " "; font-weight: bold; color: var(--primary-color); margin-right: 0.25em; }
body.numbered .toc > ul > li > ul > li > ul > li { counter-increment: toc-h3; counter-reset: toc-h4 toc-h5 toc-h6; }
body.numbered .toc > ul > li > ul > li > ul > li > a::before { content: counter(toc-h1) "." counter(toc-h2) "." counter(toc-h3) " "; font-weight: bold; color: var(--primary-color); margin-right: 0.25em; }
body.numbered h1::before, body.numbered h2::before, body.numbered h3::before,
body.numbered h4::before, body.numbered h5::before, body.numbered h6::before { margin-right: 0.5em; }
@media print {
body.numbered h1::before, body.numbered h2::before, body.numbered h3::before,
body.numbered h4::before, body.numbered h5::before, body.numbered h6::before { color: #000 !important; }
}
/* Visual heading hierarchy that accompanies the numbered/legal look. */
body.numbered h1 { border-bottom: 2px solid var(--primary-color); padding-bottom: 0.3em; margin-top: 1em; }
body.numbered h1:first-of-type { margin-top: 0.5em; }
body.numbered h2 { border-bottom: 1px solid var(--border-color); padding-bottom: 0.2em; margin-top: 1.5em; }
body.numbered h3 { margin-top: 1.2em; }
body.numbered h4, body.numbered h5, body.numbered h6 { margin-top: 1em; }
/*
* Doctype-specific layout. `doctype` comes from the document's YAML front matter
* (report | specification | letter); the per-doctype template sets `doc-<name>`.
* A letter has no TOC sidebar and flows as a normal single column.
*/
body.doc-letter { height: auto; overflow: visible; }
body.doc-letter .content-wrapper { margin: 0 auto; max-width: var(--content-max-width); }
</style>
$for(header-includes)$
$header-includes$
$endfor$
</head>

View file

@ -0,0 +1,259 @@
<!-- Embedded JavaScript -->
<script>
'use strict';
// Modern initialization with arrow functions
document.addEventListener('DOMContentLoaded', function() {
// View mode toggle functionality
const buttons = document.querySelectorAll('.view-mode-btn');
const body = document.body;
buttons.forEach(button => {
button.addEventListener('click', function() {
const mode = this.dataset.mode;
// Remove all view mode classes
body.classList.remove('view-original', 'view-final');
// Add the selected mode class (except for diff which is default)
if (mode === 'original') {
body.classList.add('view-original');
} else if (mode === 'final') {
body.classList.add('view-final');
}
// Update button states
buttons.forEach(btn => btn.classList.remove('active'));
this.classList.add('active');
});
});
const sidebar = document.getElementById('sidebar');
if (sidebar) {
initTocNavigation();
}
// Set default TOC level filtering
filterTocLevels('3');
// Setup event listeners with delegation
setupEventListeners();
// Initialize print functionality
initPrintSupport();
});
// Modern TOC Navigation with ES6+ patterns
function initTocNavigation() {
const tocLinks = document.querySelectorAll('.toc a');
const contentArea = document.querySelector('.document-content');
if (!tocLinks.length || !contentArea) return;
// Smooth scroll with event delegation (better performance)
function handleTocClick(e) {
if (!e.target.matches('.toc a')) return;
e.preventDefault();
const href = e.target.getAttribute('href');
const targetId = href ? href.slice(1) : null;
const targetElement = targetId ? document.getElementById(targetId) : null;
if (!targetElement) return;
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Update URL hash without adding to browser history
window.location.replace(window.location.pathname + window.location.search + href);
// Update active state
tocLinks.forEach(link => link.classList.remove('active'));
e.target.classList.add('active');
// Close mobile menu if open
const sidebar = document.getElementById('sidebar');
if (sidebar && sidebar.classList.contains('mobile-open')) toggleMobileMenu();
};
document.addEventListener('click', handleTocClick);
// TOC scroll tracking using Intersection Observer API
// NOTE: Intersection Observer is the industry-standard, recommended approach for scroll spy
// implementations as of 2024. It provides better performance (runs off main thread),
// cleaner code, and is supported by all modern browsers. Avoid scroll event listeners
// for this use case as they are performance-intensive and require complex calculations.
// Find all sections with IDs - much simpler approach
const sections = Array.from(contentArea.querySelectorAll('section[id]'));
if (sections.length === 0) {
return;
}
function updateActiveTocItem(activeSection) {
if (!activeSection || !activeSection.id) return;
// Clear all active states
tocLinks.forEach(link => link.classList.remove('active'));
// Find and activate the matching TOC link
const activeLink = document.querySelector('.toc a[href="#' + activeSection.id + '"]');
if (!activeLink) return;
activeLink.classList.add('active');
// Auto-scroll TOC to keep active item visible
activeLink.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest'
});
};
// Create Intersection Observer with industry-standard configuration
const observer = new IntersectionObserver(function(entries) {
// Find visible sections and update active TOC item
const visibleSections = entries.filter(function(entry) { return entry.isIntersecting; });
if (visibleSections.length > 0) {
// Sort by position in viewport (topmost first)
visibleSections.sort(function(a, b) { return a.boundingClientRect.top - b.boundingClientRect.top; });
const activeSection = visibleSections[0].target;
updateActiveTocItem(activeSection);
}
}, {
root: contentArea,
rootMargin: '-20% 0px -60% 0px', // Only consider sections in the middle 20% of viewport
threshold: 0.1
});
// Observe all sections
sections.forEach(function(section) { observer.observe(section); });
// Scroll progress bar with throttling for better performance
const progressBar = document.querySelector('.scroll-progress-bar');
if (progressBar) {
let ticking = false;
function updateScrollProgress() {
const scrollTop = contentArea.scrollTop;
const scrollHeight = contentArea.scrollHeight;
const clientHeight = contentArea.clientHeight;
const scrollPercent = scrollHeight > clientHeight
? (scrollTop / (scrollHeight - clientHeight)) * 100
: 0;
progressBar.style.width = Math.min(100, Math.max(0, scrollPercent)) + '%';
ticking = false;
};
function onScroll() {
if (!ticking) {
requestAnimationFrame(updateScrollProgress);
ticking = true;
}
};
contentArea.addEventListener('scroll', onScroll, { passive: true });
updateScrollProgress(); // Initial call
}
};
// Toggle mobile menu with ARIA support
function toggleMobileMenu() {
const sidebar = document.getElementById('sidebar');
const menuToggle = document.querySelector('.mobile-menu-toggle');
if (!sidebar || !menuToggle) return;
const isOpen = sidebar.classList.toggle('mobile-open');
menuToggle.setAttribute('aria-expanded', isOpen.toString());
};
// Filter TOC levels with modern patterns
function filterTocLevels(maxLevel) {
const toc = document.querySelector('.toc');
if (!toc) return;
const allItems = toc.querySelectorAll('li');
const maxLevelNum = parseInt(maxLevel);
const showAll = maxLevel === '6';
allItems.forEach(function(item) {
const link = item.querySelector('a');
if (!link) return;
if (showAll) {
item.style.display = '';
return;
}
// Calculate nesting level more efficiently
let level = 1;
let parent = item.parentElement;
while (parent && !parent.classList.contains('toc')) {
if (parent.tagName === 'LI') level++;
parent = parent.parentElement;
}
item.style.display = level <= maxLevelNum ? '' : 'none';
});
};
// Setup event listeners with delegation
function setupEventListeners() {
// TOC level selector
const tocLevelSelect = document.getElementById('toc-level');
if (tocLevelSelect) tocLevelSelect.addEventListener('change', function(e) {
filterTocLevels(e.target.value);
});
// Mobile menu toggle
const menuToggle = document.querySelector('.mobile-menu-toggle');
if (menuToggle) menuToggle.addEventListener('click', toggleMobileMenu);
// Close mobile menu on outside click
document.addEventListener('click', function(e) {
const sidebar = document.getElementById('sidebar');
const menuToggle = document.querySelector('.mobile-menu-toggle');
if (sidebar && sidebar.classList.contains('mobile-open') &&
!sidebar.contains(e.target) &&
(!menuToggle || !menuToggle.contains(e.target))) {
toggleMobileMenu();
}
});
// Handle escape key to close mobile menu
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const sidebar = document.getElementById('sidebar');
if (sidebar && sidebar.classList.contains('mobile-open')) {
toggleMobileMenu();
}
}
});
};
// Initialize print support and draft status
function initPrintSupport() {
// Handle draft status for revisions containing tilde (~)
const revision = document.querySelector('meta[name="revision"]');
const generationTime = document.querySelector('meta[name="generation_time"]');
if (revision && generationTime) {
const revisionValue = revision.getAttribute('content');
const timeValue = generationTime.getAttribute('content');
if (revisionValue && revisionValue.includes('~') && timeValue) {
const draftElements = document.querySelectorAll('.draft-status');
draftElements.forEach(function(element) {
element.textContent = ' [DRAFT Generated at ' + timeValue + ']';
});
}
}
}
// Export functions for global access (maintaining backward compatibility)
window.toggleMobileMenu = toggleMobileMenu;
window.filterTocLevels = filterTocLevels;
</script>

View file

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
$_head()$
<body class="doc-letter$if(numbering)$ numbered$endif$">
<!-- Letter layout: single column, no TOC sidebar -->
<main class="content-wrapper" role="main">
<div class="content-page">
<!-- Letterhead -->
<header class="document-header">
<div class="header-content">
$if(client)$$if(project)$
<div class="header-line client-project">
$client$ - $project$$if(project_number)$ ($project_number$)$endif$
</div>
$endif$$endif$
$if(title)$
<div class="document-title">$title$</div>
$endif$
<div class="document-meta">
$if(date)$<span class="date">$date$</span>$endif$
$if(tracking_number)$<span class="tracking-number">$tracking_number$</span>$endif$
$if(revision)$<span class="revision">Revision: $revision$</span>$endif$
$if(status)$<span class="status">Status: $status$</span>$endif$
</div>
</div>
</header>
<!-- Print-only header -->
<div class="print-header">
$if(custom_header)$
$custom_header$
$else$
$if(client)$$if(project)$
<div class="header-line client-project">$client$ - $project$$if(project_number)$ ($project_number$)$endif$</div>
$endif$$endif$
$if(title)$<div class="header-line document-title">$title$</div>$endif$
$endif$
</div>
<!-- Print-only footer -->
<div class="print-footer">
<div class="footer-left">
$if(tracking_number)$$tracking_number$$endif$$if(revision)$ Revision: $revision$$endif$$if(status)$ Status: $status$$endif$
</div>
<div class="footer-right">Page <span class="page-number"></span></div>
</div>
<article class="document-content">
$body$
</article>
</div>
</main>
$_scripts()$
</body>
</html>

View file

@ -0,0 +1,9 @@
<!DOCTYPE html>
<html lang="en">
$_head()$
<body class="doc-report$if(numbering)$ numbered$endif$">
$_doc()$
$_scripts()$
</body>
</html>

View file

@ -0,0 +1,9 @@
<!DOCTYPE html>
<html lang="en">
$_head()$
<body class="doc-specification$if(numbering)$ numbered$endif$">
$_doc()$
$_scripts()$
</body>
</html>

View file

@ -0,0 +1,71 @@
package convert
import (
"os"
"path/filepath"
"testing"
)
// canonicalTemplatesDir is the single source of truth for the conversion
// templates: /pandoc/templates/, relative to this package
// (zddc/internal/convert → ../../../pandoc/templates).
const canonicalTemplatesDir = "../../../pandoc/templates"
// TestEmbeddedTemplatesMatchSource guards against drift between the embedded
// templates/ (a build artifact, synced by shared/build-lib.sh:
// sync_pandoc_templates) and the canonical pandoc/templates/. If this fails,
// re-run ./build (or copy pandoc/templates/* into this package's templates/).
func TestEmbeddedTemplatesMatchSource(t *testing.T) {
srcEntries, err := os.ReadDir(canonicalTemplatesDir)
if err != nil {
t.Fatalf("read canonical templates dir %q: %v", canonicalTemplatesDir, err)
}
embedded := embeddedTemplateFiles()
srcCount := 0
for _, e := range srcEntries {
if e.IsDir() || filepath.Ext(e.Name()) != ".html" {
continue
}
srcCount++
want, err := os.ReadFile(filepath.Join(canonicalTemplatesDir, e.Name()))
if err != nil {
t.Fatalf("read %s: %v", e.Name(), err)
}
got, ok := embedded[e.Name()]
if !ok {
t.Errorf("embedded templates/ is missing %s (run ./build to sync)", e.Name())
continue
}
if string(got) != string(want) {
t.Errorf("embedded %s differs from pandoc/templates/%s (run ./build to sync)", e.Name(), e.Name())
}
}
if srcCount != len(embedded) {
t.Errorf("template count mismatch: canonical=%d embedded=%d (stale file in one tree?)", srcCount, len(embedded))
}
}
// TestDefaultTemplateSet checks the doctype fallback + that partials ride along.
func TestDefaultTemplateSet(t *testing.T) {
for _, name := range EmbeddedTemplateNames() {
ts := DefaultTemplateSet(name)
if ts.Name != name+".html" {
t.Errorf("DefaultTemplateSet(%q).Name = %q, want %q.html", name, ts.Name, name)
}
if ts.Files[ts.Name] == nil {
t.Errorf("DefaultTemplateSet(%q) Files missing primary %q", name, ts.Name)
}
if ts.Files["_head.html"] == nil {
t.Errorf("DefaultTemplateSet(%q) Files missing _head.html partial", name)
}
}
// Unknown / empty fall back to the default doctype.
if ts := DefaultTemplateSet("nope"); ts.Name != DefaultTemplateName+".html" {
t.Errorf("unknown doctype fell back to %q, want %q.html", ts.Name, DefaultTemplateName)
}
if ts := DefaultTemplateSet(""); ts.Name != DefaultTemplateName+".html" {
t.Errorf("empty doctype fell back to %q, want %q.html", ts.Name, DefaultTemplateName)
}
}

File diff suppressed because it is too large Load diff

View file

@ -120,6 +120,15 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
continue
}
// Reserved config — the .zddc.d sidecar reserve and the .zddc.zip
// config bundle — is surfaced only to an active (elevated) admin over
// this directory. Everyone else can't open it anyway (dispatch 404s
// the access), so listing the names would just advertise hidden
// config. The plain .zddc file stays visible/editable (handled below).
if (strings.EqualFold(name, ".zddc.d") || strings.EqualFold(name, ".zddc.zip")) && !parentActiveAdmin {
continue
}
info, err := entry.Info()
if err != nil {
continue

View file

@ -48,35 +48,50 @@ var convertSF singleflightGroup
// runner itself enforces a finer-grained timeout on the container.
const convertTimeout = 90 * time.Second
// convertSourceExts maps a requested target extension to the candidate source
// extensions in precedence order — the first existing real sibling wins. The
// matrix: md↔docx↔html all directions, plus md→pdf (PDF stays markdown-only).
var convertSourceExts = map[string][]string{
"md": {"docx", "html"},
"docx": {"md", "html"},
"html": {"md", "docx"},
"pdf": {"md"},
}
// RecognizeVirtualConvert reports whether urlPath names a virtual
// "<file>.<format>" — a rendered form of a sibling markdown source.
// Returns (mdAbsPath, format, true) when <file>.md exists on disk and
// the requested extension is one of docx / html / pdf. The caller
// (the dispatcher) only invokes this when a stat on the requested
// path itself fails — a real on-disk file always wins.
// "<file>.<format>" — a rendered form of a sibling source document in a
// different format. Returns (srcAbsPath, format, true) when the requested
// extension is convertible (md/docx/html/pdf) and a sibling source exists on
// disk, picked by convertSourceExts precedence. The caller (the dispatcher) only
// invokes this when a stat on the requested path itself fails — a real on-disk
// file always wins.
//
// A virtual file URL means `<a href="…/foo.docx">` works without any
// query-string handling, and a script's `curl -O …/foo.pdf` writes the
// expected filename.
func RecognizeVirtualConvert(fsRoot, urlPath string) (mdAbs, format string, ok bool) {
// query-string handling, and a script's `curl -O …/foo.md` writes the expected
// filename.
func RecognizeVirtualConvert(fsRoot, urlPath string) (srcAbs, format string, ok bool) {
lower := strings.ToLower(urlPath)
for _, ext := range []string{".docx", ".html", ".pdf"} {
for target, sources := range convertSourceExts {
ext := "." + target
if !strings.HasSuffix(lower, ext) {
continue
continue // distinct suffixes — at most one target matches
}
base := urlPath[:len(urlPath)-len(ext)]
if base == "" || strings.HasSuffix(base, "/") {
continue
return "", "", false
}
rel := strings.Trim(base, "/") + ".md"
abs := filepath.Join(fsRoot, filepath.FromSlash(rel))
// Path containment.
if abs != fsRoot && !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) {
continue
}
if info, err := os.Stat(abs); err == nil && !info.IsDir() {
return abs, ext[1:], true
stem := strings.Trim(base, "/")
for _, srcExt := range sources {
abs := filepath.Join(fsRoot, filepath.FromSlash(stem+"."+srcExt))
// Path containment.
if abs != fsRoot && !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) {
continue
}
if info, err := os.Stat(abs); err == nil && !info.IsDir() {
return abs, target, true
}
}
return "", "", false
}
return "", "", false
}
@ -87,9 +102,9 @@ func RecognizeVirtualConvert(fsRoot, urlPath string) (mdAbs, format string, ok b
func ServeConverted(cfg config.Config, w http.ResponseWriter, r *http.Request, srcAbs, format string, chain zddc.PolicyChain) {
format = strings.ToLower(strings.TrimSpace(format))
switch format {
case "docx", "html", "pdf":
case "md", "docx", "html", "pdf":
default:
http.Error(w, "Bad Request — convert must be docx, html, or pdf", http.StatusBadRequest)
http.Error(w, "Bad Request — convert must be md, docx, html, or pdf", http.StatusBadRequest)
return
}
@ -135,7 +150,7 @@ func ServeConverted(cfg config.Config, w http.ResponseWriter, r *http.Request, s
// Slow path: convert, cache, serve. Singleflight collapses
// concurrent requests for the same target.
_, err = convertSF.Do(cacheAbs, func() (any, error) {
return nil, buildAndStore(r.Context(), srcAbs, srcInfo, cacheDir, cacheAbs, format, base, chain)
return nil, buildAndStore(r.Context(), cfg.Root, srcAbs, srcInfo, cacheDir, cacheAbs, format, base, chain)
})
if err != nil {
mapConvertError(w, err, format)
@ -148,7 +163,7 @@ func ServeConverted(cfg config.Config, w http.ResponseWriter, r *http.Request, s
// buildAndStore reads the source, runs the conversion, atomically
// writes the result, and syncs the cached mtime to the source mtime.
// Returns the cached file's absolute path on success.
func buildAndStore(ctx context.Context, srcAbs string, srcInfo os.FileInfo, cacheDir, cacheAbs, format, base string, chain zddc.PolicyChain) error {
func buildAndStore(ctx context.Context, fsRoot, srcAbs string, srcInfo os.FileInfo, cacheDir, cacheAbs, format, base string, chain zddc.PolicyChain) error {
source, err := os.ReadFile(srcAbs)
if err != nil {
return fmt.Errorf("read source: %w", err)
@ -159,17 +174,13 @@ func buildAndStore(ctx context.Context, srcAbs string, srcInfo os.FileInfo, cach
ctx, cancel := context.WithTimeout(ctx, convertTimeout)
defer cancel()
var out []byte
switch format {
case "docx":
out, err = convert.ToDocx(ctx, source, meta)
case "html":
out, err = convert.ToHTML(ctx, source, meta)
case "pdf":
out, err = convert.ToPDF(ctx, source, meta)
default:
return fmt.Errorf("unsupported format %q", format)
// Source format is the on-disk extension; target is the requested format.
from := strings.TrimPrefix(strings.ToLower(filepath.Ext(srcAbs)), ".")
var ts convert.TemplateSet
if format == "html" || format == "pdf" {
ts = resolveTemplateSet(fsRoot, filepath.Dir(srcAbs), source)
}
out, err := convert.Convert(ctx, from, format, source, meta, ts)
if err != nil {
return err
}
@ -290,20 +301,21 @@ func contentDispositionFor(format, base string) string {
return fmt.Sprintf(`inline; filename="%s.%s"`, base, format)
}
// purgeConverted removes the cached .zddc.d/converted/<base>.{docx,html,pdf}
// sidecars for an .md source. Called from the file API after a
// successful PUT/DELETE/MOVE so the next GET ?convert= regenerates.
// Best-effort: errors (including "directory doesn't exist") are
// swallowed. Non-.md sources are a no-op so this is safe to call
// purgeConverted removes the cached .zddc.d/converted/<base>.{md,docx,html,pdf}
// sidecars for a convertible source. Called from the file API after a successful
// PUT/DELETE/MOVE so the next virtual-convert GET regenerates. Best-effort:
// errors (including "directory doesn't exist") are swallowed. Sources whose
// extension isn't convertible are a no-op, so this is safe to call
// unconditionally after any write.
func purgeConverted(srcAbs string) {
if !strings.HasSuffix(strings.ToLower(srcAbs), ".md") {
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(srcAbs)), ".")
if _, ok := convertSourceExts[ext]; !ok {
return
}
dir := filepath.Dir(srcAbs)
base := strings.TrimSuffix(filepath.Base(srcAbs), filepath.Ext(srcAbs))
for _, ext := range []string{".docx", ".html", ".pdf"} {
_ = os.Remove(filepath.Join(dir, ReservedSidecar, "converted", base+ext))
for target := range convertSourceExts {
_ = os.Remove(filepath.Join(dir, ReservedSidecar, "converted", base+"."+target))
}
}

View file

@ -0,0 +1,65 @@
package handler
import (
"os"
"path/filepath"
"testing"
)
func TestRecognizeVirtualConvert_MatrixAndPrecedence(t *testing.T) {
root := t.TempDir()
write := func(rel string) {
p := filepath.Join(root, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
// Sources on disk: doc.md, only.docx, both.md + both.docx, page.html.
write("doc.md")
write("only.docx")
write("both.md")
write("both.docx")
write("page.html")
cases := []struct {
name string
url string
wantOK bool
wantSrcExt string
wantFormat string
}{
{"md→docx", "/doc.docx", true, ".md", "docx"},
{"md→html", "/doc.html", true, ".md", "html"},
{"md→pdf", "/doc.pdf", true, ".md", "pdf"},
{"docx→md (only docx present)", "/only.md", true, ".docx", "md"},
{"docx→html (only docx present)", "/only.html", true, ".docx", "html"},
{"docx has no pdf source", "/only.pdf", false, "", ""},
{"both present, html prefers md source", "/both.html", true, ".md", "html"},
{"html→md", "/page.md", true, ".html", "md"},
{"html→docx", "/page.docx", true, ".html", "docx"},
{"no source at all", "/missing.html", false, "", ""},
{"directory url ignored", "/doc/", false, "", ""},
{"non-convertible target", "/doc.txt", false, "", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
src, format, ok := RecognizeVirtualConvert(root, c.url)
if ok != c.wantOK {
t.Fatalf("ok=%v want %v (src=%q format=%q)", ok, c.wantOK, src, format)
}
if !ok {
return
}
if format != c.wantFormat {
t.Errorf("format=%q want %q", format, c.wantFormat)
}
if filepath.Ext(src) != c.wantSrcExt {
t.Errorf("source ext=%q want %q (src=%q)", filepath.Ext(src), c.wantSrcExt, src)
}
})
}
}

View file

@ -0,0 +1,143 @@
package handler
import (
"bytes"
"os"
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/convert"
"gopkg.in/yaml.v3"
)
// resolveTemplateSet builds the convert.TemplateSet for an HTML/PDF render of a
// markdown source. It starts from the baked-in defaults for the doctype named in
// the document's `template:` front matter (default "report"), then overlays any
// per-project / per-party overrides found in the `.zddc.d/templates/` cascade.
//
// The cascade walks from the document's directory up to fsRoot; a nearer level
// (e.g. working/<party>/.zddc.d/templates/) overrides a farther one (e.g.
// working/.zddc.d/templates/), which overrides the embedded default. Overrides
// may replace the named doctype template, any shared partial (_head.html, …), or
// introduce an entirely new doctype the front matter names.
func resolveTemplateSet(fsRoot, docDir string, source []byte) convert.TemplateSet {
name := templateNameFromFrontMatter(source) // "" when absent/invalid
ts := convert.DefaultTemplateSet(name) // primary falls back to report
dirs := templateCascadeDirs(fsRoot, docDir)
// If the named doctype isn't a baked-in default but an override provides it,
// adopt the override as the primary template.
if name != "" {
primary := name + ".html"
if b := firstTemplateOverride(dirs, primary); b != nil {
ts.Name = primary
ts.Files[primary] = b
}
}
// Overlay overrides for every file in the set (primary + partials).
for fname := range ts.Files {
if b := firstTemplateOverride(dirs, fname); b != nil {
ts.Files[fname] = b
}
}
return ts
}
// templateCascadeDirs returns the `<level>/.zddc.d/templates` directories from
// docDir up to fsRoot, nearest (most specific) first. Levels outside fsRoot are
// skipped (path-containment guard).
func templateCascadeDirs(fsRoot, docDir string) []string {
root := filepath.Clean(fsRoot)
d := filepath.Clean(docDir)
var dirs []string
for {
if d == root || strings.HasPrefix(d, root+string(filepath.Separator)) {
dirs = append(dirs, filepath.Join(d, ReservedSidecar, "templates"))
}
if d == root {
break
}
parent := filepath.Dir(d)
if parent == d {
break
}
d = parent
}
return dirs
}
// firstTemplateOverride returns the bytes of the first existing `<dir>/<name>`
// across dirs (nearest first), or nil. name is reduced to a base name so it can
// never escape the templates dir.
func firstTemplateOverride(dirs []string, name string) []byte {
base := filepath.Base(name)
if base == "" || base == "." || base == ".." {
return nil
}
for _, dir := range dirs {
if b, err := os.ReadFile(filepath.Join(dir, base)); err == nil {
return b
}
}
return nil
}
// templateNameFromFrontMatter extracts a sanitized `template:` doctype name from
// a markdown document's leading YAML front matter. Returns "" when there is no
// front matter, no `template:` field, or the value isn't a safe bare name.
func templateNameFromFrontMatter(source []byte) string {
fm := leadingFrontMatter(source)
if fm == nil {
return ""
}
var doc struct {
Template string `yaml:"template"`
}
if err := yaml.Unmarshal(fm, &doc); err != nil {
return ""
}
return sanitizeTemplateName(doc.Template)
}
// leadingFrontMatter returns the YAML between an opening `---` line (which must
// be the very first line) and the next `---` or `...` line, or nil if absent.
func leadingFrontMatter(src []byte) []byte {
s := bytes.TrimPrefix(src, []byte{0xEF, 0xBB, 0xBF}) // strip a UTF-8 BOM
if !bytes.HasPrefix(s, []byte("---\n")) && !bytes.HasPrefix(s, []byte("---\r\n")) {
return nil
}
lines := bytes.Split(s, []byte("\n"))
var buf bytes.Buffer
for i := 1; i < len(lines); i++ {
ln := bytes.TrimRight(lines[i], "\r")
if bytes.Equal(ln, []byte("---")) || bytes.Equal(ln, []byte("...")) {
return buf.Bytes()
}
buf.Write(lines[i])
buf.WriteByte('\n')
}
return nil // unterminated block
}
// sanitizeTemplateName allows only a bare basename of [A-Za-z0-9_-] so a
// `template:` value can't traverse paths or name a partial. Returns "" if the
// value is empty or contains any other character.
func sanitizeTemplateName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
for _, r := range name {
switch {
case r == '-' || r == '_':
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
default:
return ""
}
}
return name
}

View file

@ -0,0 +1,95 @@
package handler
import (
"os"
"path/filepath"
"testing"
)
func TestTemplateNameFromFrontMatter(t *testing.T) {
cases := []struct {
name string
src string
want string
}{
{"plain", "---\ntemplate: specification\n---\n\n# H\n", "specification"},
{"quoted", "---\ntemplate: \"letter\"\n---\n", "letter"},
{"absent", "---\ntitle: X\n---\n", ""},
{"no-frontmatter", "# Just a heading\n", ""},
{"traversal-rejected", "---\ntemplate: ../../etc/passwd\n---\n", ""},
{"slash-rejected", "---\ntemplate: a/b\n---\n", ""},
{"crlf", "---\r\ntemplate: report\r\n---\r\n", "report"},
{"dots-terminator", "---\ntemplate: letter\n...\nbody", "letter"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := templateNameFromFrontMatter([]byte(c.src)); got != c.want {
t.Errorf("templateNameFromFrontMatter(%q) = %q, want %q", c.src, got, c.want)
}
})
}
}
func TestResolveTemplateSet_DefaultsAndCascade(t *testing.T) {
root := t.TempDir()
party := filepath.Join(root, "working", "AcmeCo")
if err := os.MkdirAll(party, 0o755); err != nil {
t.Fatal(err)
}
// No overrides, no front matter → embedded report, partials present.
ts := resolveTemplateSet(root, party, []byte("# Hi\n"))
if ts.Name != "report.html" {
t.Fatalf("default doctype: got %q, want report.html", ts.Name)
}
if ts.Files["_head.html"] == nil {
t.Errorf("partial _head.html missing from default set")
}
embeddedReport := string(ts.Files["report.html"])
// Front matter selects a doctype.
if ts := resolveTemplateSet(root, party, []byte("---\ntemplate: letter\n---\n")); ts.Name != "letter.html" {
t.Errorf("front-matter doctype: got %q, want letter.html", ts.Name)
}
// Project-global override at <root>/.zddc.d/templates/report.html.
projDir := filepath.Join(root, ".zddc.d", "templates")
if err := os.MkdirAll(projDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(projDir, "report.html"), []byte("PROJECT-REPORT"), 0o644); err != nil {
t.Fatal(err)
}
ts = resolveTemplateSet(root, party, []byte("# Hi\n"))
if string(ts.Files["report.html"]) != "PROJECT-REPORT" {
t.Errorf("project override not applied: %q", ts.Files["report.html"])
}
if string(ts.Files["report.html"]) == embeddedReport {
t.Errorf("override identical to embedded — overlay didn't happen")
}
// Party override wins over project-global.
partyDir := filepath.Join(party, ".zddc.d", "templates")
if err := os.MkdirAll(partyDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(partyDir, "report.html"), []byte("PARTY-REPORT"), 0o644); err != nil {
t.Fatal(err)
}
ts = resolveTemplateSet(root, party, []byte("# Hi\n"))
if string(ts.Files["report.html"]) != "PARTY-REPORT" {
t.Errorf("party override should win: got %q", ts.Files["report.html"])
}
// A brand-new doctype provided only as an override is adopted as primary.
if err := os.WriteFile(filepath.Join(partyDir, "memo.html"), []byte("MEMO-TEMPLATE"), 0o644); err != nil {
t.Fatal(err)
}
ts = resolveTemplateSet(root, party, []byte("---\ntemplate: memo\n---\n"))
if ts.Name != "memo.html" || string(ts.Files["memo.html"]) != "MEMO-TEMPLATE" {
t.Errorf("custom doctype override: name=%q bytes=%q", ts.Name, ts.Files["memo.html"])
}
if ts.Files["_head.html"] == nil {
t.Errorf("partials should still ride along with a custom doctype override")
}
}

View file

@ -497,10 +497,8 @@ func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request)
http.Error(w, msg, status)
return
}
if strings.HasSuffix(cleanURL, "/") {
http.Error(w, "DELETE must target a file, not a directory", http.StatusBadRequest)
return
}
// (Directory vs file is decided by stat below; the client sends a folder
// DELETE with a trailing slash. A directory delete is admin-gated.)
// Register rows are real files — a DELETE targets them directly with
// the normal ACL gate. (Deleting an ssr/<party>.yaml de-registers the
@ -517,7 +515,32 @@ func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request)
return
}
if info.IsDir() {
http.Error(w, "Conflict — DELETE of directories is not supported", http.StatusConflict)
// Directory delete is recursive (os.RemoveAll), which bypasses the
// per-file WORM/delete gates protecting the contents — so it's
// admin-only: an active admin over this subtree (a root admin, or a
// subtree admin within scope). This is the "admin mode exists for
// restructuring" capability.
p := PrincipalFromContext(r)
if !zddc.IsSubtreeAdmin(cfg.Root, abs, p) {
http.Error(w, "Forbidden — deleting a directory requires admin authority over it", http.StatusForbidden)
return
}
if err := os.RemoveAll(abs); err != nil {
auditFile(r, "delete", cleanURL+" (recursive)", http.StatusInternalServerError, 0, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
etagCacheM.Delete(abs)
purgeConverted(abs)
w.Header().Set("X-ZDDC-Source", "fileapi:delete-dir")
w.WriteHeader(http.StatusNoContent)
auditFile(r, "delete", cleanURL+" (recursive)", http.StatusNoContent, 0, nil)
return
}
// File delete: a trailing slash is a directory URL — reject the mismatch.
if strings.HasSuffix(cleanURL, "/") {
http.Error(w, "DELETE must target a file, not a directory", http.StatusBadRequest)
return
}
@ -571,10 +594,9 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
http.Error(w, msg, status)
return
}
if strings.HasSuffix(srcURL, "/") {
http.Error(w, "MOVE source must be a file path", http.StatusBadRequest)
return
}
// (A trailing slash on src/dst signals a directory target; we no longer
// reject it here — file-vs-directory is decided by stat below, and a
// directory move is admin-gated.)
dstHeader := r.Header.Get(headerDestination)
if dstHeader == "" {
@ -590,10 +612,6 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
http.Error(w, "destination: "+msg, status)
return
}
if strings.HasSuffix(dstURL, "/") {
http.Error(w, "MOVE destination must be a file path", http.StatusBadRequest)
return
}
// A move whose destination introduces a new party folder under a
// party_source peer requires the party to be registered.
if rejected, why, _ := partySourceGate(cfg.Root, dstAbs); rejected {
@ -619,8 +637,28 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
}
return
}
if srcInfo.IsDir() {
http.Error(w, "Conflict — MOVE of directories is not supported", http.StatusConflict)
isDir := srcInfo.IsDir()
if isDir {
// Directory moves relocate the whole subtree with one os.Rename,
// which sidesteps the per-file WORM/ACL gates protecting the
// descendants — so they're admin-only: an active admin over BOTH the
// source subtree and the destination's parent (a root admin covers
// all; a subtree admin within their own scope). This is the "admin
// mode exists for restructuring" capability.
p := PrincipalFromContext(r)
if !zddc.IsSubtreeAdmin(cfg.Root, srcAbs, p) ||
!zddc.IsSubtreeAdmin(cfg.Root, filepath.Dir(dstAbs), p) {
http.Error(w, "Forbidden — moving a directory requires admin authority over the source and destination", http.StatusForbidden)
return
}
// Refuse moving a directory into itself or one of its descendants.
if dstAbs == srcAbs || strings.HasPrefix(dstAbs, srcAbs+string(filepath.Separator)) {
http.Error(w, "Conflict — cannot move a directory into itself", http.StatusConflict)
return
}
} else if strings.HasSuffix(dstURL, "/") {
// A file move must target a file path, not a directory URL.
http.Error(w, "destination: MOVE of a file must target a file path", http.StatusBadRequest)
return
}
@ -643,8 +681,12 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
if !authorizeAction(cfg, w, r, dstAbs, dstURL, policy.ActionCreate) {
return
}
if !checkIfMatch(w, r, srcAbs) {
return
// If-Match concurrency applies to the source bytes — only meaningful for
// a file. A directory carries no ETag, so skip the precondition.
if !isDir {
if !checkIfMatch(w, r, srcAbs) {
return
}
}
// Ensure destination's canonical ancestors are created (with auto-own

View file

@ -284,11 +284,34 @@ func TestFileAPI_DeleteMissing404(t *testing.T) {
}
}
func TestFileAPI_DeleteDirectoryConflict(t *testing.T) {
// Directory delete is admin-only and recursive. A non-admin (elevated but not
// named in admins:) is forbidden; an admin recursively removes the subtree.
func TestFileAPI_DeleteDirectoryNonAdminForbidden(t *testing.T) {
_, do, _ := fileAPITestSetup(t, []string{"Incoming/sub"}, nil)
rec := do(http.MethodDelete, "/Incoming/sub", "alice@example.com", nil, nil)
if rec.Code != http.StatusConflict {
t.Fatalf("want 409, got %d: %s", rec.Code, rec.Body.String())
rec := do(http.MethodDelete, "/Incoming/sub/", "alice@example.com", nil, nil)
if rec.Code != http.StatusForbidden {
t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestFileAPI_DeleteDirectoryAdminRecursive(t *testing.T) {
_, do, root := fileAPITestSetup(t, []string{"Incoming/sub"}, map[string]string{
"Incoming/sub/a.txt": "one",
"Incoming/sub/deep/b.txt": "two",
})
// Promote alice to root admin.
if err := os.WriteFile(filepath.Join(root, ".zddc"),
[]byte("acl:\n permissions:\n \"*@example.com\": rwcd\nadmins:\n - alice@example.com\n"), 0o644); err != nil {
t.Fatalf("rewrite root .zddc: %v", err)
}
zddc.InvalidateCache(root)
rec := do(http.MethodDelete, "/Incoming/sub/", "alice@example.com", nil, nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("want 204, got %d: %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(root, "Incoming/sub")); !os.IsNotExist(err) {
t.Fatalf("dir should be gone recursively, err=%v", err)
}
}
@ -319,6 +342,63 @@ func TestFileAPI_MoveRenames(t *testing.T) {
}
}
// Directory move is admin-only and relocates the whole subtree. A non-admin
// (elevated but not in admins:) is forbidden; an admin renames/relocates it.
func TestFileAPI_MoveDirectoryNonAdminForbidden(t *testing.T) {
_, do, _ := fileAPITestSetup(t, []string{"Docs/sub"}, nil)
rec := do(http.MethodPost, "/Docs/sub/", "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "move",
"X-ZDDC-Destination": "/Docs/renamed/",
})
if rec.Code != http.StatusForbidden {
t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestFileAPI_MoveDirectoryAdmin(t *testing.T) {
_, do, root := fileAPITestSetup(t, []string{"Docs/sub"}, map[string]string{
"Docs/sub/a.txt": "x",
"Docs/sub/deep/b.txt": "y",
})
if err := os.WriteFile(filepath.Join(root, ".zddc"),
[]byte("acl:\n permissions:\n \"*@example.com\": rwcd\nadmins:\n - alice@example.com\n"), 0o644); err != nil {
t.Fatalf("rewrite root .zddc: %v", err)
}
zddc.InvalidateCache(root)
rec := do(http.MethodPost, "/Docs/sub/", "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "move",
"X-ZDDC-Destination": "/Docs/renamed/",
})
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(root, "Docs/sub")); !os.IsNotExist(err) {
t.Fatalf("source dir should be gone, err=%v", err)
}
if b, err := os.ReadFile(filepath.Join(root, "Docs/renamed/deep/b.txt")); err != nil || string(b) != "y" {
t.Fatalf("moved subtree content missing: b=%q err=%v", b, err)
}
}
// Refuse moving a directory into itself or a descendant (would orphan the tree).
func TestFileAPI_MoveDirectoryIntoItself(t *testing.T) {
_, do, root := fileAPITestSetup(t, []string{"Docs/sub"}, nil)
if err := os.WriteFile(filepath.Join(root, ".zddc"),
[]byte("acl:\n permissions:\n \"*@example.com\": rwcd\nadmins:\n - alice@example.com\n"), 0o644); err != nil {
t.Fatalf("rewrite root .zddc: %v", err)
}
zddc.InvalidateCache(root)
rec := do(http.MethodPost, "/Docs/sub/", "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "move",
"X-ZDDC-Destination": "/Docs/sub/inner/",
})
if rec.Code != http.StatusConflict {
t.Fatalf("want 409, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestFileAPI_MoveDestinationExistsConflict(t *testing.T) {
_, do, _ := fileAPITestSetup(t, nil, map[string]string{
"Incoming/a.txt": "a",

View file

@ -159,6 +159,10 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
// any of the default-spec virtual-fallback shapes (per-party
// mdl/rsk, per-party SSR schema, project-level virtual specs).
specEligible := func(specAbs string) bool {
dir, base := filepath.Split(specAbs)
if fileExists(filepath.Join(filepath.Clean(dir), ".zddc.d", base)) {
return true
}
if fileExists(specAbs) {
return true
}
@ -542,13 +546,19 @@ func serveFormUpdate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
// --- Helpers -----------------------------------------------------------------
func loadFormSpec(fsRoot, path string) (*FormSpec, error) {
data, err := os.ReadFile(path)
// Prefer the supporting-files reserve: a spec at <dir>/.zddc.d/form.yaml
// takes precedence over the legacy <dir>/form.yaml. `path` is the legacy
// <dir>/form.yaml location the callers build.
dir, base := filepath.Split(path)
data, err := os.ReadFile(filepath.Join(filepath.Clean(dir), ".zddc.d", base))
if err != nil {
// Default-spec virtual fallback: when no operator file exists at
// path, serve the embedded default if path matches one of the
// recognized virtual fallback shapes (per-party mdl/rsk, per-
// party SSR schema, project-level virtual specs). Mirrors the
// static-handler fallback for direct YAML fetches.
data, err = os.ReadFile(path)
}
if err != nil {
// Default-spec virtual fallback: when no operator file exists in
// either location, serve the embedded default if path matches one of
// the recognized virtual fallback shapes (per-party mdl/rsk, per-
// party SSR schema, project-level virtual specs).
if os.IsNotExist(err) {
if bytes, ok := IsDefaultSpecAbs(fsRoot, path); ok {
data = bytes

View file

@ -46,6 +46,25 @@ const ElevatedKey contextKey = "elevated"
// named in admin lists.
const elevationCookieName = "zddc-elevate"
// adminQueryParam reads the ?admin= elevation toggle, returning a pointer to
// the requested state (true = elevate, false = drop) or nil when the param is
// absent or unrecognised. Recognised values mirror shared/elevation.js so the
// URL toggle behaves identically whether elevation.js sets the cookie or the
// server honors the bare param: true/1/on/yes and false/0/off/no
// (case-insensitive).
func adminQueryParam(r *http.Request) *bool {
v := strings.ToLower(r.URL.Query().Get("admin"))
switch v {
case "true", "1", "on", "yes":
t := true
return &t
case "false", "0", "off", "no":
f := false
return &f
}
return nil
}
// ACLMiddleware extracts the user email and stores it (along with the
// policy decider) in the request context. It does NOT enforce ACL
// itself — each handler performs its own ACL check via
@ -98,6 +117,20 @@ func ACLMiddleware(cfg config.Config, decider policy.Decider, tokens *auth.Store
if c, err := r.Cookie(elevationCookieName); err == nil && c.Value == "1" {
elevated = true
}
// ?admin=true|1|on|yes elevates this request directly, and
// ?admin=false|0|off|no drops it — mirroring the URL toggle in
// shared/elevation.js, but honored at the server so the param
// works on EVERY endpoint (raw directory listings, zip browsing,
// the file API), not just HTML pages where elevation.js runs to
// set the cookie. elevation.js still sets the cookie for sticky
// persistence across navigation; this just makes the bare param
// effective on a single direct request too. Elevation only grants
// powers to a caller who already holds admin authority (every
// admin call site re-checks the cascade via IsActiveAdmin), so
// honoring the param for a non-admin is a harmless no-op.
if v := adminQueryParam(r); v != nil {
elevated = *v
}
}
// DEBUG-level header dump for diagnosing proxy / SSO header
// passthrough. Off by default (LogLevel info); enable with

View file

@ -194,12 +194,12 @@ func TestAccessLog_ChainAdminLevelAttribution(t *testing.T) {
}
cases := []struct {
name string
email string
elevate bool
path string
wantLevel int
wantActive bool
name string
email string
elevate bool
path string
wantLevel int
wantActive bool
}{
{"root admin elevated probing root → level 0", "root@example.com", true, "/", 0, true},
{"root admin elevated probing project → level 0 (walks down chain)", "root@example.com", true, "/Project-1/", 0, true},
@ -250,3 +250,73 @@ func TestAccessLog_ChainAdminLevelAttribution(t *testing.T) {
})
}
}
// TestACLMiddleware_AdminQueryParamElevation verifies the server honors the
// ?admin= URL toggle directly (mirroring shared/elevation.js), so the param
// elevates ANY endpoint — not just HTML pages where elevation.js runs to set
// the cookie. ?admin=true elevates with no cookie; ?admin=false drops even
// when the cookie is present; a non-admin's ?admin=true sets the flag but
// confers no authority.
func TestACLMiddleware_AdminQueryParamElevation(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"),
[]byte("admins:\n - root@example.com\n"), 0o644); err != nil {
t.Fatalf("write root .zddc: %v", err)
}
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
noop := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
type record struct {
Elevated bool `json:"elevated"`
ActiveAdmin bool `json:"active_admin"`
}
run := func(t *testing.T, path, email string, cookie bool) record {
t.Helper()
var buf bytes.Buffer
auditLogger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(cfg, auditLogger, noop))
req := httptest.NewRequest(http.MethodGet, path, nil)
if email != "" {
req.Header.Set("X-Auth-Request-Email", email)
}
if cookie {
req.AddCookie(&http.Cookie{Name: "zddc-elevate", Value: "1"})
}
chain.ServeHTTP(httptest.NewRecorder(), req)
var rec record
if err := json.Unmarshal(buf.Bytes(), &rec); err != nil {
t.Fatalf("audit log not JSON: %v; raw=%s", err, buf.String())
}
return rec
}
t.Run("?admin=true elevates root admin with no cookie", func(t *testing.T) {
rec := run(t, "/?admin=true", "root@example.com", false)
if !rec.Elevated || !rec.ActiveAdmin {
t.Errorf("elevated=%v active=%v, want both true", rec.Elevated, rec.ActiveAdmin)
}
})
t.Run("?admin=false drops despite cookie", func(t *testing.T) {
rec := run(t, "/?admin=false", "root@example.com", true)
if rec.Elevated || rec.ActiveAdmin {
t.Errorf("elevated=%v active=%v, want both false", rec.Elevated, rec.ActiveAdmin)
}
})
t.Run("non-admin ?admin=true sets flag but confers no authority", func(t *testing.T) {
rec := run(t, "/?admin=true", "stranger@example.com", false)
if !rec.Elevated {
t.Errorf("elevated=%v, want true (flag set)", rec.Elevated)
}
if rec.ActiveAdmin {
t.Errorf("active_admin=%v, want false (no admin authority)", rec.ActiveAdmin)
}
})
t.Run("no param, no cookie → not elevated", func(t *testing.T) {
rec := run(t, "/", "root@example.com", false)
if rec.Elevated || rec.ActiveAdmin {
t.Errorf("elevated=%v active=%v, want both false", rec.Elevated, rec.ActiveAdmin)
}
})
}

View file

@ -28,11 +28,15 @@ package handler
import (
_ "embed"
"encoding/json"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
@ -161,6 +165,17 @@ func IsDefaultSpecAbs(fsRoot, absPath string) ([]byte, bool) {
// not name one of the recognized virtual fallback files.
func classifyDefaultSpec(rel string) []byte {
parts := strings.Split(rel, "/")
// A spec may live either in the directory root (<dir>/table.yaml) or in
// the supporting-files reserve (<dir>/.zddc.d/table.yaml). Strip a
// ".zddc.d" segment so both classify by the same dir shape.
clean := parts[:0:0]
for _, p := range parts {
if strings.EqualFold(p, ".zddc.d") {
continue
}
clean = append(clean, p)
}
parts = clean
switch len(parts) {
case 4:
// <project>/<peer>/<party>/<file> — per-party register specs
@ -309,8 +324,9 @@ func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest {
specAbs := filepath.Join(dirAbs, "table.yaml")
// Presence-based discovery: <dir>/table.yaml on disk.
if fileExists(specAbs) {
// Presence-based discovery: the spec in the supporting-files reserve
// (<dir>/.zddc.d/table.yaml) or, legacy, the directory root.
if fileExists(filepath.Join(dirAbs, ".zddc.d", "table.yaml")) || fileExists(specAbs) {
return &TableRequest{Name: name, SpecPath: specAbs, Dir: dirAbs}
}
@ -362,10 +378,77 @@ func isNotExistError(err error) bool {
return err != nil && strings.Contains(err.Error(), "no such file or directory")
}
// ServeTable serves the static tables.html bytes for a recognized
// request. ACL gate is the read action at the request directory; on
// allow, the embedded HTML is written verbatim. The client takes over
// from there — see tables/js/main.js.
// LoadViewSpec resolves a config file's bytes for dir, preferring the
// supporting-files reserve <dir>/.zddc.d/<name>, then the legacy <dir>/<name>,
// then the embedded default for this dir's shape. Returns nil when none
// applies. This is the single seam that puts table/form specs under .zddc.d/
// (where they're admin-gated + hidden) while staying back-compatible.
func LoadViewSpec(fsRoot, dir, name string) []byte {
if b, err := os.ReadFile(filepath.Join(dir, ".zddc.d", name)); err == nil {
return b
}
if b, err := os.ReadFile(filepath.Join(dir, name)); err == nil {
return b
}
if rel, err := filepath.Rel(fsRoot, filepath.Join(dir, name)); err == nil {
if b := classifyDefaultSpec(filepath.ToSlash(rel)); b != nil {
return b
}
}
return nil
}
// injectTableContext writes the resolved table spec + row-form schema into the
// `#table-context` placeholder so the client reads them instead of fetching
// <dir>/table.yaml and <dir>/form.yaml over HTTP (impossible once the specs
// live under the admin-gated .zddc.d/). The client still walks the directory
// for ROW files — only the SPEC is injected. Shape:
//
// { "spec": <parsed table.yaml>, "rowSchema": <parsed form.yaml .schema> }
//
// Empty {} when neither resolves (the client then walks for the spec too,
// preserving legacy behavior). Returns an error only if the placeholder is
// absent from the template.
func injectTableContext(template, tableYAML, formYAML []byte) ([]byte, error) {
ctx := map[string]interface{}{}
if len(tableYAML) > 0 {
var spec interface{}
if err := yaml.Unmarshal(tableYAML, &spec); err == nil && spec != nil {
ctx["spec"] = spec
}
}
if len(formYAML) > 0 {
var fs map[string]interface{}
if err := yaml.Unmarshal(formYAML, &fs); err == nil {
if sch, ok := fs["schema"]; ok {
ctx["rowSchema"] = sch
}
}
}
js, err := json.Marshal(ctx)
if err != nil {
return nil, err
}
js = []byte(strings.ReplaceAll(string(js), "</", "<\\/"))
needle := []byte(`<script id="table-context" type="application/json">{}</script>`)
if !bytesContains(template, needle) {
return nil, errBundle("#table-context placeholder not found in template")
}
replacement := append([]byte(`<script id="table-context" type="application/json">`), js...)
replacement = append(replacement, []byte(`</script>`)...)
return bytesReplace(template, needle, replacement), nil
}
type errBundle string
func (e errBundle) Error() string { return string(e) }
// ServeTable serves the tables HTML for a recognized request, ACL-gated on
// read at the request directory. The resolved table.yaml + form.yaml (from
// .zddc.d/, legacy root, or the embedded default) are injected as
// #table-context so the client never fetches the spec over HTTP. If the
// template predates the placeholder, the bare HTML is served (the client
// falls back to fetching) — keeps this non-breaking before ./build.
func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r *http.Request) {
p := PrincipalFromContext(r)
decider := DeciderFromContext(r)
@ -384,7 +467,14 @@ func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r *
return
}
body := embeddedTablesHTML
tableYAML := LoadViewSpec(cfg.Root, req.Dir, "table.yaml")
formYAML := LoadViewSpec(cfg.Root, req.Dir, "form.yaml")
if injected, ierr := injectTableContext(embeddedTablesHTML, tableYAML, formYAML); ierr == nil {
body = injected
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(embeddedTablesHTML)
_, _ = w.Write(body)
}

View file

@ -184,8 +184,9 @@ func TestRecognizeTableRequest(t *testing.T) {
}
// TestServeTable_ServesEmbeddedHTML — an ACL-passing GET returns the
// embedded tables.html bytes verbatim, with the empty inline context
// placeholder intact (so the client knows to walk the directory).
// embedded tables.html with the resolved table spec server-injected into
// #table-context (the embedded default for this virtual MDL dir), so the
// client renders without a separate spec fetch.
func TestServeTable_ServesEmbeddedHTML(t *testing.T) {
rows := map[string]string{
"D-001.yaml": "id: D-001\ntitle: One\nstatus: pending\n",
@ -202,8 +203,13 @@ func TestServeTable_ServesEmbeddedHTML(t *testing.T) {
if !strings.Contains(body, `<table id="table-root"`) {
t.Error("body missing #table-root markup; embedded HTML may be stale or empty")
}
if !strings.Contains(body, `<script id="table-context" type="application/json">{}</script>`) {
t.Error("inline context placeholder not preserved verbatim — client expects {} so it knows to walk")
// #table-context is no longer the empty placeholder — the resolved spec
// is injected (the client uses it instead of fetching table.yaml).
if strings.Contains(body, `<script id="table-context" type="application/json">{}</script>`) {
t.Error("#table-context still empty; expected the resolved spec to be injected")
}
if !strings.Contains(body, `id="table-context"`) || !strings.Contains(body, `"spec"`) {
t.Error("expected the resolved table spec injected into #table-context")
}
}

View file

@ -1534,7 +1534,7 @@ body.is-elevated::after {
</svg>
<div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 18:26:16 · f723323</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-05 12:41:17 · 382645b</span></span>
</div>
</div>
<div class="header-right">
@ -3691,13 +3691,19 @@ body.is-elevated::after {
// inline context (tests) or open the page through zddc-server.
async function load() {
const inline = readInlineContext();
if (inline && Object.keys(inline).length > 0) {
// A fully pre-assembled context (columns + rows) is used as-is — the
// test seam, or any host that renders the whole table server-side.
if (inline && Array.isArray(inline.columns)) {
return inline;
}
// Otherwise the inline context may still carry the server-injected
// SPEC ({spec, rowSchema}) sourced from <dir>/.zddc.d/ — pass it to
// walkServer, which uses it instead of fetching the spec and still
// walks the directory for row files.
if (typeof location !== 'undefined' &&
(location.protocol === 'http:' || location.protocol === 'https:')) {
try {
const walked = await walkServer();
const walked = await walkServer(inline || {});
if (walked) {
return walked;
}
@ -3729,7 +3735,8 @@ body.is-elevated::after {
el.hidden = false;
}
async function walkServer() {
async function walkServer(injected) {
injected = injected || {};
const source = window.zddc && window.zddc.source;
if (!source) {
throw new Error('zddc.source not available');
@ -3746,27 +3753,32 @@ body.is-elevated::after {
}
const dir = probe.handle;
// Spec lives at <currentdir>/table.yaml — the page URL is
// <currentdir>/table.html, so the spec is right next door.
const spec = await readYaml(dir, 'table.yaml');
// Spec: prefer the server-injected #table-context.spec (sourced from
// <dir>/.zddc.d/table.yaml). Falling back, read the spec from the
// supporting-files reserve, then the legacy directory root — the
// FS-Access path, where there's no server to inject.
let spec = (injected.spec && Array.isArray(injected.spec.columns))
? injected.spec : null;
if (!spec) {
spec = await readYamlFirst(dir, ['.zddc.d/table.yaml', 'table.yaml']);
}
if (!spec || !Array.isArray(spec.columns)) {
throw new Error('Spec table.yaml missing columns[]');
}
// Optional row schema from <dir>/form.yaml — same JSON Schema
// the form-mode renderer uses. Phase 2 derives per-cell editor
// widgets from it (text/number/date/select/checkbox).
// Best-effort: a directory with only table.yaml still renders
// as a sortable/filterable table; cells fall back to plain
// text inputs without per-property hints.
let rowSchema = null;
try {
const formSpec = await readYaml(dir, 'form.yaml');
if (formSpec && formSpec.schema) {
rowSchema = formSpec.schema;
// Row schema: prefer the injected #table-context.rowSchema, else read
// <dir>/.zddc.d/form.yaml (then legacy root). Best-effort — a table
// with no row schema still renders with plain-text cells.
let rowSchema = injected.rowSchema || null;
if (!rowSchema) {
try {
const formSpec = await readYamlFirst(dir, ['.zddc.d/form.yaml', 'form.yaml']);
if (formSpec && formSpec.schema) {
rowSchema = formSpec.schema;
}
} catch (_) {
// form.yaml missing or unreadable; carry on without it.
}
} catch (_) {
// form.yaml missing or unreadable; carry on without it.
}
// Rows are every *.yaml in <currentdir> EXCEPT the spec
@ -3825,6 +3837,22 @@ body.is-elevated::after {
return window.jsyaml.load(text);
}
// readYamlFirst tries each relPath in order, returning the first that
// resolves + parses. Used to read a spec from the supporting-files
// reserve (.zddc.d/<name>) with a fallback to the legacy directory root.
async function readYamlFirst(dir, relPaths) {
let lastErr = null;
for (var i = 0; i < relPaths.length; i++) {
try {
return await readYaml(dir, relPaths[i]);
} catch (err) {
lastErr = err;
}
}
if (lastErr) throw lastErr;
return null;
}
// Walk a "/"-separated relative path under dir, returning the
// FileSystemFileHandle (or HttpFileHandle) at the leaf.
async function resolveFile(dir, relPath) {

View file

@ -31,22 +31,29 @@ func IsZddcFileRequest(urlPath string) bool {
// ServeZddcFile serves a directory's .zddc as a plain YAML view.
//
// Method: GET / HEAD only — the dispatcher routes writes
// (PUT/DELETE/POST) directly to ServeFileAPI.
//
// (PUT/DELETE/POST) directly to ServeFileAPI.
//
// ACL: the parent directory's read permission gates access. A
// user who can read the directory can read its .zddc.
//
// user who can read the directory can read its .zddc.
//
// On-disk: if <dir>/.zddc exists, its bytes are returned verbatim
// with Content-Type: application/yaml.
//
// with Content-Type: application/yaml.
//
// Virtual: if it does not exist, the body is the cascade's
// leaf-level ZddcFile (what defaults.zddc.yaml's paths:
// tree declares for THIS exact directory, plus any
// virtual contributions threaded through by the walker)
// marshalled as YAML. A header comment names the source
// and points at ?effective=1 for the composed view. The
// virtual body is itself valid YAML — PUT-saving it back
// (with or without edits) through the file API
// materialises a real on-disk override carrying exactly
// the bytes the user saved. The response sets
// X-ZDDC-Source: virtual:zddc so clients can distinguish.
//
// leaf-level ZddcFile (what defaults.zddc.yaml's paths:
// tree declares for THIS exact directory, plus any
// virtual contributions threaded through by the walker)
// marshalled as YAML. A header comment names the source
// and points at ?effective=1 for the composed view. The
// virtual body is itself valid YAML — PUT-saving it back
// (with or without edits) through the file API
// materialises a real on-disk override carrying exactly
// the bytes the user saved. The response sets
// X-ZDDC-Source: virtual:zddc so clients can distinguish.
func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
decider := DeciderFromContext(r)
@ -274,7 +281,6 @@ func levelURLsFor(_, dirURL string, n int) []string {
// surfacing.
func isZeroZddcFile(zf zddc.ZddcFile) bool {
return zf.Title == "" &&
zf.AppsPubKey == "" &&
zf.CreatedBy == "" &&
zf.DefaultTool == "" &&
zf.DirTool == "" &&
@ -289,8 +295,8 @@ func isZeroZddcFile(zf zddc.ZddcFile) bool {
zf.Convert == nil &&
len(zf.ACL.Permissions) == 0 &&
len(zf.Admins) == 0 &&
len(zf.Apps) == 0 &&
len(zf.Tables) == 0 &&
len(zf.Views) == 0 &&
len(zf.Display) == 0 &&
len(zf.Roles) == 0 &&
len(zf.FieldCodes) == 0 &&

View file

@ -393,9 +393,8 @@ func nonZeroZddcFields(zf ZddcFile) []string {
add("title", zf.Title != "")
add("acl", len(zf.ACL.Permissions) > 0 || zf.ACL.Inherit != nil)
add("admins", len(zf.Admins) > 0)
add("apps", len(zf.Apps) > 0)
add("apps_pubkey", zf.AppsPubKey != "")
add("tables", len(zf.Tables) > 0)
add("views", len(zf.Views) > 0)
add("display", len(zf.Display) > 0)
add("convert", zf.Convert != nil)
add("roles", len(zf.Roles) > 0)

View file

@ -92,6 +92,15 @@ type ConvertMetadata struct {
ProjectNumber string `yaml:"project_number,omitempty" json:"project_number,omitempty"`
}
// ViewSpec is one entry in ZddcFile.Views: which tool renders a given URL
// shape, and the filename (under <dir>/.zddc.d/) of its supporting config.
// Config is optional (e.g. browse needs none). Both are plain data — no
// behaviour. See ZddcFile.Views.
type ViewSpec struct {
Tool string `yaml:"tool,omitempty" json:"tool,omitempty"`
Config string `yaml:"config,omitempty" json:"config,omitempty"`
}
// ZddcFile represents the parsed contents of a .zddc configuration file.
//
// Admins is honored only in the root .zddc file (<ZDDC_ROOT>/.zddc); subdir
@ -104,32 +113,13 @@ type ConvertMetadata struct {
// for the project on the landing-page picker. Optional — projects without a
// title fall back to displaying the directory name.
//
// Apps is a per-directory cascade override mapping app name → source spec.
// The spec is one of: "stable" / "beta" / "alpha" (channel on the canonical
// upstream), "v0.0.4" / "v0.0" / "v0" (version pin on the canonical
// upstream), an absolute "https://..." URL (custom mirror), or a relative
// or absolute filesystem path (./local.html, /opt/zddc/foo.html).
//
// On a request for a tool HTML, zddc-server walks .zddc files leaf→root
// looking for an Apps entry; first match wins. With no entry anywhere, the
// server serves the version baked into the binary at compile time (//go:embed).
// Fetched URL sources are cached in <ZDDC_ROOT>/_app/; the cache is fetch-once
// and never re-validates — operators delete the file to force a refetch.
//
// AppsPubKey is the inline PEM of the Ed25519 public key used to verify
// signatures on URL-fetched apps artifacts. Honored only at the root
// .zddc file (same root-only treatment as Admins, for the same reason:
// it's a trust anchor; subtree write authority must not be able to
// re-anchor it). Lower priority than --apps-pubkey / ZDDC_APPS_PUBKEY:
// when both are set, the env/flag (file path) wins. Empty in either
// place = URL-fetched apps refused (only embedded + local-path apps
// work). See zddc-server's setupApps.
// Tool HTML is resolved LOCALLY (no .zddc key): a real file on disk at the
// path → an "<app>.html" member of <ZDDC_ROOT>/.zddc.zip → the embedded
// default. There is no `apps:` / `apps_pubkey:` key and no upstream fetch.
type ZddcFile struct {
ACL ACLRules `yaml:"acl,omitempty" json:"acl,omitempty"`
Admins []string `yaml:"admins,omitempty" json:"admins,omitempty"`
Title string `yaml:"title,omitempty" json:"title,omitempty"`
Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"`
AppsPubKey string `yaml:"apps_pubkey,omitempty" json:"apps_pubkey,omitempty"`
ACL ACLRules `yaml:"acl,omitempty" json:"acl,omitempty"`
Admins []string `yaml:"admins,omitempty" json:"admins,omitempty"`
Title string `yaml:"title,omitempty" json:"title,omitempty"`
// Tables declares directory-of-YAML table views available at this
// directory. The map key becomes the URL stem: tables[MDL] is served
@ -216,6 +206,21 @@ type ZddcFile struct {
// Cascades leaf→root like DefaultTool.
DirTool string `yaml:"dir_tool,omitempty" json:"dir_tool,omitempty"`
// Views declares, per URL shape, which tool renders and where its
// supporting config lives — the generalization of default_tool/dir_tool
// plus the form/table recognizers. Keys are URL shapes:
// "dir" — GET <dir> (no slash) e.g. {tool: tables, config: table.yaml}
// "dir_slash" — GET <dir>/ e.g. {tool: browse}
// "file" — GET <dir>/<file> (no slash) e.g. {tool: form, config: form.yaml}
// config is a filename resolved under <dir>/.zddc.d/ (the supporting-files
// reserve), server-resolved and injected (#view-context) since .zddc.d/ is
// not client-fetchable. A view is presentation/routing ONLY — it never
// grants access; ACL/WORM/admin stay server-enforced. default_tool /
// dir_tool are normalized into views.dir / views.dir_slash (kept as sugar).
// Cascades leaf→root like DefaultTool. No arbitrary code: tool ∈ the known
// app set, config is a path-bounded relative name.
Views map[string]ViewSpec `yaml:"views,omitempty" json:"views,omitempty"`
// AutoOwn controls whether the file API's mkdir post-hook writes
// an auto-owned .zddc granting the creator rwcda at the new
// directory. Useful for working/staging/incoming-style drafting

View file

@ -42,9 +42,8 @@ roles:
if zf.Title != "Demo" {
t.Errorf("Title = %q want %q", zf.Title, "Demo")
}
if got := zf.Apps["archive"]; got != "stable" {
t.Errorf("Apps[archive] = %q want %q", got, "stable")
}
// A stale `apps:` key in the fixture is ignored (the key was removed),
// not a parse error — back-compat for existing .zddc files.
if r, ok := zf.Roles["reviewers"]; !ok || len(r.Members) != 1 {
t.Errorf("Roles[reviewers] = %+v want one member", r)
}

View file

@ -56,6 +56,44 @@ func DirToolAt(fsRoot, dirPath string) string {
return "browse"
}
// ViewAt resolves the view for a URL shape ("dir", "dir_slash", "file") at
// dirPath. Walks the cascade leaf→root (then the embedded defaults): the first
// level whose Views declares the shape wins; default_tool / dir_tool are
// honored as sugar for the "dir" / "dir_slash" shapes. Returns
// (ViewSpec{}, false) when nothing declares the shape (the caller decides any
// floor, e.g. dir_slash → browse). Mirrors DefaultToolAt's first-match-wins
// cascade so default_tool/dir_tool semantics are unchanged.
func ViewAt(fsRoot, dirPath, shape string) (ViewSpec, bool) {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return ViewSpec{}, false
}
atLevel := func(lvl ZddcFile) (ViewSpec, bool) {
if lvl.Views != nil {
if v, ok := lvl.Views[shape]; ok && v.Tool != "" {
return v, true
}
}
switch shape {
case "dir":
if lvl.DefaultTool != "" {
return ViewSpec{Tool: lvl.DefaultTool}, true
}
case "dir_slash":
if lvl.DirTool != "" {
return ViewSpec{Tool: lvl.DirTool}, true
}
}
return ViewSpec{}, false
}
for i := len(chain.Levels) - 1; i >= 0; i-- {
if v, ok := atLevel(chain.Levels[i]); ok {
return v, true
}
}
return atLevel(chain.Embedded)
}
// AutoOwnAt reports whether mkdir at THIS specific directory should
// write an auto-owned .zddc. Leaf-only lookup — auto-own does NOT
// propagate to descendants (creating working/alice/notes/sub/ does
@ -375,7 +413,7 @@ func isZeroZddcFile(zf ZddcFile) bool {
if len(zf.AvailableTools) > 0 {
return false
}
if zf.AppsPubKey != "" || zf.CreatedBy != "" {
if zf.CreatedBy != "" {
return false
}
if zf.Worm != nil { // non-nil even when empty — marks a WORM zone
@ -390,7 +428,7 @@ func isZeroZddcFile(zf ZddcFile) bool {
if zf.ACL.Inherit != nil {
return false
}
if len(zf.Apps) > 0 || len(zf.Tables) > 0 || len(zf.Display) > 0 || len(zf.Paths) > 0 {
if len(zf.Tables) > 0 || len(zf.Views) > 0 || len(zf.Display) > 0 || len(zf.Paths) > 0 {
return false
}
if len(zf.Roles) > 0 {

View file

@ -45,6 +45,48 @@ func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) {
}
}
// TestViewAt — default_tool/dir_tool act as sugar for the dir/dir_slash
// shapes, and an explicit views: entry overrides them.
func TestViewAt(t *testing.T) {
resetCache()
root := t.TempDir()
j := func(p ...string) string { return filepath.Join(append([]string{root, "Project-X"}, p...)...) }
// Sugar: the embedded default_tool/dir_tool surface via ViewAt.
if v, ok := ViewAt(root, j("mdl", "Acme"), "dir"); !ok || v.Tool != "tables" {
t.Errorf("ViewAt(mdl/Acme, dir) = (%+v,%v), want tables", v, ok)
}
if v, ok := ViewAt(root, j("working", "Acme"), "dir"); !ok || v.Tool != "browse" {
t.Errorf("ViewAt(working/Acme, dir) = (%+v,%v), want browse", v, ok)
}
// No file-shape declared by defaults.
if _, ok := ViewAt(root, j("working", "Acme"), "file"); ok {
t.Errorf("ViewAt(working/Acme, file) should be unset by default")
}
// Explicit views: overrides default_tool and declares a file shape.
resetCache()
dir := j("custom")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := WriteFile(dir, ZddcFile{
DefaultTool: "browse",
Views: map[string]ViewSpec{
"dir": {Tool: "tables", Config: "table.yaml"},
"file": {Tool: "form", Config: "form.yaml"},
},
}); err != nil {
t.Fatal(err)
}
if v, ok := ViewAt(root, dir, "dir"); !ok || v.Tool != "tables" || v.Config != "table.yaml" {
t.Errorf("ViewAt(custom, dir) = (%+v,%v), want {tables,table.yaml}", v, ok)
}
if v, ok := ViewAt(root, dir, "file"); !ok || v.Tool != "form" || v.Config != "form.yaml" {
t.Errorf("ViewAt(custom, file) = (%+v,%v), want {form,form.yaml}", v, ok)
}
}
// TestHistoryAt_Defaults — edit-history defaults on for the live-editing
// peers working/mdl/rsk and the ssr registry (subtree-inheriting). The
// other peers and the WORM archive do not get history.

View file

@ -5,24 +5,16 @@ import (
"strings"
)
// AppNames is the canonical set of app HTML files the server resolves
// via the apps fetch+cache subsystem. Order is stable for reproducible
// admin-UI rendering.
// AppNames is the canonical set of app HTML files the server can serve
// (from disk, the site .zddc.zip bundle, or the embedded default). Order
// is stable for reproducible rendering.
//
// All seven HTML tools belong here — including browse, form, and tables.
// Omitting any of them means the apps cascade (.zddc apps:) silently
// short-circuits to embedded for that name, defeating live-dev
// path-source overrides.
//
// Markdown editing used to be a dedicated tool ("mdedit"); it now
// lives as a plugin inside browse (browse/js/preview-markdown.js).
var AppNames = []string{"archive", "transmittal", "classifier", "landing", "browse", "form", "tables"}
// AppsDefaultKey is the special apps-map key that provides the baseline
// URL prefix and channel for any app not overridden per-name. Cascades
// through .zddc files like a per-app entry.
const AppsDefaultKey = "default"
// IsKnownApp reports whether name is one of the canonical apps.
func IsKnownApp(name string) bool {
for _, n := range AppNames {
@ -33,12 +25,6 @@ func IsKnownApp(name string) bool {
return false
}
// IsValidAppsKey reports whether name is acceptable as a key in the
// `apps:` map — either a canonical app or the special "default" key.
func IsValidAppsKey(name string) bool {
return name == AppsDefaultKey || IsKnownApp(name)
}
// ValidatePattern returns an error if pattern is not a syntactically
// well-formed email-glob. The matcher in MatchesPattern is forgiving and
// will silently fail to match malformed patterns (e.g., "alice@@x" or
@ -120,101 +106,6 @@ func ValidateProjectName(name string) error {
return nil
}
// ValidateAppSourceSpec returns nil if spec is a syntactically well-formed
// source spec accepted by apps.ParseSpec. It checks the string shape only —
// it does not verify URLs are reachable or paths exist.
//
// Accepted forms:
// - "stable" / "beta" / "alpha" / ":stable" / ":beta" / ":alpha" (channel)
// - "v0.0.4" / "0.0.4" / "v0.0" / "0.0" / "v0" / "0" / ":v0.0.4" (version)
// - "https://host/path" (URL prefix)
// - "https://host/path:stable" (URL prefix + channel)
// - "https://host/path/file.html" (terminal full URL)
// - "/abs/path.html" / "./rel/path.html" / "../sibling.html" (path)
func ValidateAppSourceSpec(spec string) error {
if spec == "" {
return fmt.Errorf("source spec is empty")
}
if strings.ContainsAny(spec, " \t\n\r") {
return fmt.Errorf("source spec contains whitespace")
}
// Path forms.
if strings.HasPrefix(spec, "/") ||
strings.HasPrefix(spec, "./") ||
strings.HasPrefix(spec, "../") {
return nil
}
// URL forms.
if strings.HasPrefix(spec, "https://") || strings.HasPrefix(spec, "http://") {
return validateURLSpec(spec)
}
// Channel-or-version (with optional leading colon).
chanPart := strings.TrimPrefix(spec, ":")
if chanPart == "" {
return fmt.Errorf("empty channel after ':'")
}
return validateChannelOrVersion(chanPart)
}
// validateURLSpec checks the URL-prefix or full-URL form. Splits on the
// last `:` after the last `/` (matching apps.parseURLSpec behavior).
func validateURLSpec(spec string) error {
// Minimal sanity check on URL shape.
if len(spec) <= len("https://") {
return fmt.Errorf("URL is missing host")
}
lastSlash := strings.LastIndex(spec, "/")
if lastSlash < 0 {
return fmt.Errorf("invalid URL %q: missing path separator", spec)
}
afterSlash := spec[lastSlash+1:]
colonInTail := strings.LastIndex(afterSlash, ":")
urlPart, suffixPart := spec, ""
if colonInTail >= 0 {
urlPart = spec[:lastSlash+1+colonInTail]
suffixPart = afterSlash[colonInTail+1:]
}
if strings.HasSuffix(urlPart, ".html") {
if suffixPart != "" {
return fmt.Errorf("URL ends in .html but has %q suffix", ":"+suffixPart)
}
return nil // terminal full URL
}
if suffixPart != "" {
return validateChannelOrVersion(suffixPart)
}
return nil // URL-prefix only
}
// validateChannelOrVersion enforces the channel/version shape.
func validateChannelOrVersion(s string) error {
if s == "stable" || s == "beta" || s == "alpha" {
return nil
}
rest := strings.TrimPrefix(s, "v")
if rest == "" {
return fmt.Errorf("unrecognized source spec %q", s)
}
parts := strings.Split(rest, ".")
if len(parts) > 3 {
return fmt.Errorf("version has too many dots: %q", s)
}
for _, p := range parts {
if p == "" {
return fmt.Errorf("version has empty component: %q", s)
}
for _, r := range p {
if r < '0' || r > '9' {
return fmt.Errorf("unrecognized source spec %q", s)
}
}
}
return nil
}
func ValidateFile(zf ZddcFile) []FieldError {
var errs []FieldError
check := func(field string, vals []string) {
@ -242,19 +133,23 @@ func ValidateFile(zf ZddcFile) []FieldError {
Message: "title exceeds 200 characters",
})
}
for app, spec := range zf.Apps {
if !IsValidAppsKey(app) {
// views: each entry names a known tool and (optionally) a config file
// resolved under <dir>/.zddc.d/ — so it must be a safe relative filename
// (no slashes, no traversal, no leading dot).
for shape, v := range zf.Views {
if v.Tool == "" || !IsKnownApp(v.Tool) {
errs = append(errs, FieldError{
Field: fmt.Sprintf("apps.%s", app),
Message: fmt.Sprintf("unknown app %q (known: default, archive, transmittal, classifier, landing, browse, form, tables)", app),
Field: fmt.Sprintf("views.%s.tool", shape),
Message: fmt.Sprintf("unknown tool %q (known: %s)", v.Tool, strings.Join(AppNames, ", ")),
})
continue
}
if err := ValidateAppSourceSpec(spec); err != nil {
errs = append(errs, FieldError{
Field: fmt.Sprintf("apps.%s", app),
Message: err.Error(),
})
if v.Config != "" {
if strings.ContainsAny(v.Config, "/\\") || v.Config == "." || v.Config == ".." || strings.HasPrefix(v.Config, ".") {
errs = append(errs, FieldError{
Field: fmt.Sprintf("views.%s.config", shape),
Message: "config must be a plain filename (resolved under .zddc.d/); no slashes, traversal, or leading dot",
})
}
}
}
// worm: is a list of principal patterns (email-globs, @role:name,

View file

@ -70,125 +70,6 @@ func TestValidateFile(t *testing.T) {
}
}
func TestValidateAppSourceSpec(t *testing.T) {
cases := []struct {
spec string
ok bool
}{
// Channel shorthand (with and without leading colon)
{"stable", true},
{"beta", true},
{"alpha", true},
{":stable", true},
{":beta", true},
{":alpha", true},
// Version pin shorthand (full, partial, with/without leading 'v')
{"v0.0.4", true},
{"0.0.4", true},
{"v0.0", true},
{"0.0", true},
{"v0", true},
{"0", true},
{"v1.2.3", true},
{":v0.0.4", true},
{":0.0.4", true},
// URLs
{"https://zddc.varasys.io/releases/archive_stable.html", true},
{"http://my-fork.example.com/archive.html", true},
{"https://my-mirror.example/releases", true}, // URL-prefix only
{"https://my-mirror.example/releases:stable", true}, // URL-prefix + channel
{"https://my-mirror.example/releases:v0.0.4", true}, // URL-prefix + version
{"https://my-mirror.example:8080/releases", true}, // URL with port
{"https://my-mirror.example:8080/releases:stable", true}, // URL with port + channel
// Paths
{"/abs/path.html", true},
{"./local.html", true},
{"../sibling.html", true},
// Errors
{"", false},
{" stable", false},
{"stable ", false},
{"with space", false},
{"https://", false},
{"https://host/path/file.html:stable", false}, // .html URL with suffix
{"random-thing", false},
{":", false},
{":random", false},
{"v", false},
{"v0.", false},
{".0.0", false},
{"v0.0.0.0", false},
{"v0.a.0", false},
{"https://my-mirror.example/releases:bogus", false}, // bad channel suffix
}
for _, tc := range cases {
t.Run(tc.spec, func(t *testing.T) {
err := ValidateAppSourceSpec(tc.spec)
if tc.ok && err != nil {
t.Errorf("ValidateAppSourceSpec(%q) = %v, want nil", tc.spec, err)
}
if !tc.ok && err == nil {
t.Errorf("ValidateAppSourceSpec(%q) = nil, want error", tc.spec)
}
})
}
}
func TestIsValidAppsKey(t *testing.T) {
cases := []struct {
key string
ok bool
}{
{"default", true},
{"archive", true},
{"transmittal", true},
{"classifier", true},
{"browse", true},
{"landing", true},
{"unknown", false},
{"", false},
{"DEFAULT", false}, // case-sensitive
}
for _, tc := range cases {
t.Run(tc.key, func(t *testing.T) {
if got := IsValidAppsKey(tc.key); got != tc.ok {
t.Errorf("IsValidAppsKey(%q) = %v, want %v", tc.key, got, tc.ok)
}
})
}
}
func TestValidateFile_Apps(t *testing.T) {
zf := ZddcFile{
Apps: map[string]string{
"archive": "stable", // ok
"classifier": "v0.0.4", // ok
"default": "https://zddc.varasys.io/releases:stable", // ok (default key + URL+channel)
"transmittal": ":beta", // ok (channel-only)
"browse": "https://my-mirror.example/releases", // ok (URL-prefix only)
"unknown": "stable", // unknown app
"landing": "what is this", // bad spec
},
}
errs := ValidateFile(zf)
want := map[string]bool{
"apps.unknown": false,
"apps.landing": false,
}
for _, e := range errs {
if _, ok := want[e.Field]; ok {
want[e.Field] = true
} else {
t.Errorf("unexpected error field: %q (%s)", e.Field, e.Message)
}
}
for f, seen := range want {
if !seen {
t.Errorf("missing error for field %q (got: %+v)", f, errs)
}
}
}
func TestValidateProjectName(t *testing.T) {
cases := []struct {
name string

View file

@ -57,9 +57,6 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
if top.Title != "" {
out.Title = top.Title
}
if top.AppsPubKey != "" {
out.AppsPubKey = top.AppsPubKey
}
if top.CreatedBy != "" {
out.CreatedBy = top.CreatedBy
}
@ -124,10 +121,22 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
}
out.ACL.Permissions = mergeStringMap(out.ACL.Permissions, top.ACL.Permissions)
out.Apps = mergeStringMap(out.Apps, top.Apps)
out.Tables = mergeStringMap(out.Tables, top.Tables)
out.Display = mergeStringMap(out.Display, top.Display)
// Views: per-shape latest-wins (a deeper level overrides a shape, others
// inherit). Mirror of the Roles/Records map merge.
if len(top.Views) > 0 {
merged := make(map[string]ViewSpec, len(out.Views)+len(top.Views))
for k, v := range out.Views {
merged[k] = v
}
for k, v := range top.Views {
merged[k] = v
}
out.Views = merged
}
// Convert: per-key latest-wins. Pointer-to-struct so we can tell
// "absent" from "explicitly empty" — the latter is rare but valid
// (an operator who wants to suppress a deployment-default value).