Compare commits

..

No commits in common. "509839dba961a5c5336afca275dd417d12f95935" and "d524966f0033c1e6dbcdffe9a6bd8da45cbca27c" have entirely different histories.

96 changed files with 7350 additions and 8154 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. - **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".** - **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 a tool's HTML (local-only — no fetch, no channels/versions): To override at any level, either:
1. Drop a real `<app>.html` file at the path → static handler serves it (highest priority). 1. Drop a real `<app>.html` file at the path → static handler serves it (highest priority).
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`. 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.)
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. 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.
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). 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.
**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. **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,7 +350,6 @@ 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). - 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. - 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`. - 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. - 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) ## Form-data system (`form/` + zddc-server form handler)
@ -422,7 +421,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`): **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 (like `display:`/`tables:`) — 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 (mirror of `apps:`) — 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. - `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. - `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.
@ -539,7 +538,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`. 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:`, `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:`, `apps:`, `convert:`, `on_plan_review:`, `records:`, `available_tools:`, `default_tool:`, `dir_tool:`, etc.).
### Build ### Build
@ -635,13 +634,14 @@ 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_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_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_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. | | `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 ### 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. **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 (`.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 (`_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.
**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. **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` | | 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 | | 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 | | 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; 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` | | 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` |
| zddc-server operations: env vars, ACL syntax, `.archive` URLs, container vs binary | `zddc/README.md` | `AGENTS.md`, website intro | | 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") | | Build / release / channel commands | `AGENTS.md` | repo `README.md` ("see AGENTS.md") |
| Architecture & internal patterns | `ARCHITECTURE.md` (this file) | `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: Resolution order at a request to `<dir>/<app>.html` where the app is available:
1. **On-disk override** — real `.html` file at the path → static handler. 1. **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. 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.
3. **Embedded** — the build-time HTML compiled into the binary. 3. **Embedded** — the build-time HTML compiled into the binary.
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. 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.
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. The `X-ZDDC-Source` response header always reports what was served: `fetch:URL`, `cache:URL`, `path:/abs`, or `embedded:<app>@<build>`.
### Runtime mode detection ### Runtime mode detection

View file

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

View file

@ -324,68 +324,6 @@ body {
color: var(--text); 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 /* Per-row drop target highlight: applied while a file/folder drag is
hovering this row. The dashed outline reads as "drop here" without hovering this row. The dashed outline reads as "drop here" without
shifting layout. */ shifting layout. */

View file

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

View file

@ -1,203 +0,0 @@
// 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,35 +183,8 @@
} }
} }
// 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 = { window.app.modules.download = {
downloadFile: downloadFile, downloadFile: downloadFile,
downloadFolder: downloadFolder, downloadFolder: downloadFolder
exportFile: exportFile
}; };
})(); })();

View file

@ -88,21 +88,6 @@
refresh.classList.add('hidden'); 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 + // syncURLToSelection reflects the current scope + selected node +
@ -180,7 +165,6 @@
await tree.restoreState(snap); await tree.restoreState(snap);
if (!isCurrentNav(seq)) return; if (!isCurrentNav(seq)) return;
tree.render(); tree.render();
prefetchScopeAccess();
statusInfo('Refreshed (' + raw.length + ' item' statusInfo('Refreshed (' + raw.length + ' item'
+ (raw.length === 1 ? '' : 's') + ')'); + (raw.length === 1 ? '' : 's') + ')');
} else if (state.source === 'fs' && state.rootHandle) { } else if (state.source === 'fs' && state.rootHandle) {
@ -201,23 +185,6 @@
} }
function init() { 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 // Header buttons
var btn = document.getElementById('addDirectoryBtn'); var btn = document.getElementById('addDirectoryBtn');
if (btn) btn.addEventListener('click', pickLocalDir); if (btn) btn.addEventListener('click', pickLocalDir);
@ -225,37 +192,6 @@
var refresh = document.getElementById('refreshHeaderBtn'); var refresh = document.getElementById('refreshHeaderBtn');
if (refresh) refresh.addEventListener('click', refreshListing); 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 // Tree autofilter — parses input through zddc.filter.parse so
// the same query grammar that the archive app uses (terms, // the same query grammar that the archive app uses (terms,
// quotes, !negation, multi-word AND) works here. The AST is // quotes, !negation, multi-word AND) works here. The AST is
@ -350,16 +286,6 @@
treeBody.addEventListener('click', function (e) { treeBody.addEventListener('click', function (e) {
var row = e.target.closest('.tree-row'); var row = e.target.closest('.tree-row');
if (!row) return; 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 id = parseInt(row.dataset.id, 10);
var node = state.nodes.get(id); var node = state.nodes.get(id);
if (!node) return; if (!node) return;
@ -456,22 +382,6 @@
// if collapsed/leaf // if collapsed/leaf
// Enter / Space — preview file / toggle folder // Enter / Space — preview file / toggle folder
// Home / End — first / last visible row // 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) { document.addEventListener('keydown', function (e) {
// Skip editable contexts. // Skip editable contexts.
var tag = (e.target && e.target.tagName) || ''; var tag = (e.target && e.target.tagName) || '';
@ -573,8 +483,27 @@
treeBody.addEventListener('contextmenu', function (e) { treeBody.addEventListener('contextmenu', function (e) {
e.preventDefault(); e.preventDefault();
var row = e.target.closest('.tree-row'); var row = e.target.closest('.tree-row');
if (row) openRowMenuFor(row, e.clientX, e.clientY); if (row) {
else openPaneMenu(e.clientX, e.clientY); 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
});
}
}); });
// Per-row drag-drop. Any row is a drop target — folders // Per-row drag-drop. Any row is a drop target — folders
@ -945,6 +874,7 @@
} }
} }
function createInside(node, kind) { return createInDir(parentDirFor(node), kind); }
// Reload a directory's children in the tree so a create/delete/ // Reload a directory's children in the tree so a create/delete/
// rename is reflected. Works for both the current scope (root) // rename is reflected. Works for both the current scope (root)
@ -1057,6 +987,42 @@
} }
} }
// 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 // canCreateHere — whether New folder/file has a writable target: the
// server (ACL decides the rest) or a picked local folder (the // server (ACL decides the rest) or a picked local folder (the
// filesystem permission decides, escalated on first write). // filesystem permission decides, escalated on first write).
@ -1064,64 +1030,315 @@
return state.source === 'server' || (state.source === 'fs' && !!state.rootHandle); return state.source === 'server' || (state.source === 'fs' && !!state.rootHandle);
} }
// ── Menu opening (row / pane / kebab / keyboard) ────────────────────── function buildTreeRowMenu(ctx) {
// The menu CONTENTS come from the declarative menu-model; this layer just var serverMode = state.source === 'server';
// resolves the target, syncs selection, and positions the menu. All four var canMutate = function (c) {
// entry points (right-click row, right-click pane, kebab button, keyboard var up = window.app.modules.upload;
// menu key) funnel through here so they stay identical. 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 },
// The prefetched /.profile/access view for the current scope (set on every // ── Download (single item; label flips on type) ──
// listing load — see prefetchScopeAccess). Returned synchronously; the {
// menu never triggers a fetch at open time. null until prefetched / FS mode. label: function (c) { return c.node.isDir ? 'Download ZIP' : 'Download'; },
function prefetchedAccess() { return state.scopeAccess; } 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 },
function menuModel() { return window.app.modules.menuModel; } // ── 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 openRowMenuFor(row, x, y) { // ── Rename + Delete (the permission-gated pair) ──
var id = parseInt(row.dataset.id, 10); //
var node = state.nodes.get(id); // Two gates compose: canMutate() rules out un-writable
if (!node) return; // sources (offline FS-API without a handle, zip members,
// Select the row first so the highlight + menu target agree. // virtual placeholders) and — when the listing carries
state.selectedId = id; // server-cascade verbs — zddc.cap.has(node, verb) applies
tree.render(); // 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(); syncURLToSelection();
var mm = menuModel(); refreshListing();
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()); }
});
} }
function openPaneMenu(x, y) { // Right-click on empty space in the tree pane → directory-scope
var mm = menuModel(); // menu. Operations apply to the current scope (state.currentPath),
if (!mm) return; // not any specific row.
window.zddc.menu.open({ function buildPaneMenu() {
x: x, y: y, var serverMode = state.source === 'server';
context: { dir: state.currentPath || '/', surface: 'pane' }, return [
items: function () { return mm.buildPaneItems(prefetchedAccess()); } {
}); 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();
} }
},
// Prefetch (memoised) the scope access view so the menu's create-gate and { separator: true },
// admin/sub-admin tier items resolve without a fetch. Server-mode only; {
// cap.at returns null on file:// so FS mode leaves scopeAccess null. label: 'Refresh',
function prefetchScopeAccess() { accel: 'F5',
if (state.source !== 'server' || !window.zddc || !window.zddc.cap || !window.zddc.cap.at) { action: function () { refreshListing(); }
state.scopeAccess = null; },
return; { 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();
} }
];
} }
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 mode is URL-driven, not UI-driven.
// //
@ -1216,7 +1433,6 @@
// don't pushState/setRoot on top of it. // don't pushState/setRoot on top of it.
if (!isCurrentNav(seq)) return; if (!isCurrentNav(seq)) return;
state.currentPath = url; state.currentPath = url;
prefetchScopeAccess();
// Selection / preview belong to the old scope; clear them so // Selection / preview belong to the old scope; clear them so
// the new root doesn't carry stale highlight state. // the new root doesn't carry stale highlight state.
state.selectedId = null; state.selectedId = null;
@ -1273,11 +1489,6 @@
// can't race the in-tool navigations. beginNav() claims the latest // can't race the in-tool navigations. beginNav() claims the latest
// token; isCurrentNav(seq) reports whether it's still latest. // token; isCurrentNav(seq) reports whether it's still latest.
beginNav: beginNav, 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>'; + '<span class="tree-hovercard__val" id="hc-roles">…</span>';
} }
// URL last (longest, most likely to wrap) — rendered as a clickable // Path comes last (longest, most likely to wrap).
// link the user can open or right-click to copy. The on-disk path is var path = tree ? tree.pathFor(node) : '';
// intentionally omitted; the URL is the shareable reference. if (path) html += kv('Path', path, true);
if (node.url) html += kvLink('URL', node.url, node.url); if (node.url && node.url !== path) html += kv('URL', node.url, true);
return html; return html;
} }

View file

@ -79,14 +79,6 @@
scopeCanonicalFolder: '', scopeCanonicalFolder: '',
scopeOnPlanReview: false, 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 // Whether the listing includes dotfiles. Toggled by the
// "Show hidden files" menu item; URL-persisted via ?hidden=1. // "Show hidden files" menu item; URL-persisted via ?hidden=1.
showHidden: false, showHidden: false,

View file

@ -1,444 +0,0 @@
// 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 ──────────────────────────────────────────────────────────────── // ── Save ────────────────────────────────────────────────────────────────
function saveContent(node, content, opts) { function saveContent(node, content) {
return util.saveFile(node, content, 'text/markdown; charset=utf-8', opts); return util.saveFile(node, content, 'text/markdown; charset=utf-8');
} }
var isZipMemberNode = util.isZipMemberNode; var isZipMemberNode = util.isZipMemberNode;
@ -310,21 +310,11 @@
} }
dispose(); dispose();
// Read content + the server version token (etag/last-modified) so // Read content.
// the save can send an If-Match precondition and detect a concurrent var text;
// 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 { try {
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); var buf = await ctx.getArrayBuffer(node);
text = new TextDecoder('utf-8', { fatal: false }).decode(buf); text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
}
} catch (e) { } catch (e) {
container.innerHTML = container.innerHTML =
'<div class="preview-empty" style="color:var(--danger)">' '<div class="preview-empty" style="color:var(--danger)">'
@ -563,11 +553,7 @@
hash: initialHash, hash: initialHash,
tocEl: tocBody, tocEl: tocBody,
fmEl: fmTextarea, 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; currentInstance = instance;
@ -701,81 +687,21 @@
fmTextarea.addEventListener('input', onFmChange); fmTextarea.addEventListener('input', onFmChange);
// ── Save ─────────────────────────────────────────────────────────── // ── 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() { async function save() {
if (currentInstance !== instance) return; if (currentInstance !== instance) return;
if (!instance.dirty || !canSave(node)) return; if (!instance.dirty || !canSave(node)) return;
var content = assembleContent(fmTextarea.value, editor.getMarkdown()); var content = assembleContent(fmTextarea.value, editor.getMarkdown());
try { try {
statusEl.textContent = 'Saving…'; statusEl.textContent = 'Saving…';
var res = await saveContent(node, content, { await saveContent(node, content);
etag: instance.etag, lastModified: instance.lastModified if (currentInstance !== instance) return; // switched away mid-save
}); instance.hash = await hashContent(content);
await markSaved(content, res); markDirty(false);
} catch (e) { statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
if (e && e.status === 412) { if (window.zddc && window.zddc.toast) {
if (currentInstance !== instance) return; window.zddc.toast('Saved ' + node.name, 'success');
statusEl.textContent = 'Conflict — resolving…';
await resolveConflict(content);
return;
} }
} catch (e) {
statusEl.textContent = 'Save failed: ' + (e.message || e); statusEl.textContent = 'Save failed: ' + (e.message || e);
if (window.zddc && window.zddc.toast) { if (window.zddc && window.zddc.toast) {
window.zddc.toast('Save failed: ' + (e.message || e), 'error'); window.zddc.toast('Save failed: ' + (e.message || e), 'error');

View file

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

View file

@ -56,30 +56,6 @@
throw new Error('no source for file'); 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) { async function getBlobUrl(node) {
// Server-served files (including zip members at "<…>.zip/<member>" // Server-served files (including zip members at "<…>.zip/<member>"
// URLs) load straight from the server — preserves Content-Type // URLs) load straight from the server — preserves Content-Type
@ -204,7 +180,7 @@
window.app.modules.markdown && window.app.modules.markdown &&
typeof window.app.modules.markdown.render === 'function') { typeof window.app.modules.markdown.render === 'function') {
try { try {
await window.app.modules.markdown.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion }); await window.app.modules.markdown.render(node, container, { getArrayBuffer: getArrayBuffer });
} catch (e) { } catch (e) {
renderError(container, 'Markdown render failed: ' + (e.message || e)); renderError(container, 'Markdown render failed: ' + (e.message || e));
} }
@ -217,7 +193,7 @@
var yamlMod = window.app.modules.yamledit; var yamlMod = window.app.modules.yamledit;
if (yamlMod && yamlMod.handles(node)) { if (yamlMod && yamlMod.handles(node)) {
try { try {
await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion }); await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer });
} catch (e) { } catch (e) {
renderError(container, 'YAML render failed: ' + (e.message || e)); renderError(container, 'YAML render failed: ' + (e.message || e));
} }
@ -467,9 +443,6 @@
// Tear down any live editor + blank the pane (rescope / popstate). // Tear down any live editor + blank the pane (rescope / popstate).
clearPreview: clearPreview, clearPreview: clearPreview,
// Expose for the markdown plugin so it can read file bytes. // 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,14 +392,6 @@
+ '<span class="tree-name__icon">' + iconChar + extChip + '</span>' + '<span class="tree-name__icon">' + iconChar + extChip + '</span>'
+ labelHtml(node) + labelHtml(node)
+ virtualHint + 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>'; + '</div>';
} }

View file

@ -90,100 +90,33 @@
return false; return false;
} }
// Thrown by saveFile when the server rejects a write with 412 // Write content back to a file's source. Local (FS-Access) folders are
// 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 // picked read-only, so the first write escalates to readwrite via
// upload.ensureWritable (one permission prompt, then granted for the // upload.ensureWritable (one permission prompt, then granted for the
// session). contentType sets the PUT Content-Type for server files. // session). contentType sets the PUT Content-Type for server files.
// // Throws when the source has no write target.
// opts (server mode only): async function saveFile(node, content, contentType) {
// 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') { if (node.handle && typeof node.handle.createWritable === 'function') {
var up = window.app.modules.upload; var up = window.app.modules.upload;
if (up && up.ensureWritable) await up.ensureWritable(); if (up && up.ensureWritable) await up.ensureWritable();
var writable = await node.handle.createWritable(); var writable = await node.handle.createWritable();
await writable.write(content); await writable.write(content);
await writable.close(); await writable.close();
return { etag: null }; return;
} }
if (node.url && window.app.state.source === 'server') { 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, { var resp = await fetch(node.url, {
method: 'PUT', method: 'PUT',
headers: headers, headers: { 'Content-Type': contentType },
body: content, body: content,
credentials: 'same-origin' credentials: 'same-origin'
}); });
if (resp.status === 412) throw ConflictError();
if (!resp.ok) throw new Error('HTTP ' + resp.status); if (!resp.ok) throw new Error('HTTP ' + resp.status);
return { etag: resp.headers.get('ETag') || null }; return;
} }
throw new Error('No write target for this file (read-only source).'); 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 = { window.app.modules.util = {
escapeHtml: escapeHtml, escapeHtml: escapeHtml,
hashContent: hashContent, hashContent: hashContent,
@ -193,8 +126,6 @@
fetchAccessEmails: fetchAccessEmails, fetchAccessEmails: fetchAccessEmails,
fmtSize: fmtSize, fmtSize: fmtSize,
isZipMemberNode: isZipMemberNode, isZipMemberNode: isZipMemberNode,
saveFile: saveFile, saveFile: saveFile
saveCopy: saveCopy,
ConflictError: ConflictError
}; };
})(); })();

View file

@ -73,25 +73,6 @@
aria-label="Filter the tree by name, tracking number, status, revision, or title" aria-label="Filter the tree by name, tracking number, status, revision, or title"
autocomplete="off" autocomplete="off"
spellcheck="false"> 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>
<div class="tree-pane__body" id="treeBody" role="tree" aria-label="Files"></div> <div class="tree-pane__body" id="treeBody" role="tree" aria-label="Files"></div>
</div> </div>
@ -145,16 +126,10 @@
<dd>Recursive expand or collapse — the whole subtree.</dd> <dd>Recursive expand or collapse — the whole subtree.</dd>
<dt>Click a file</dt> <dt>Click a file</dt>
<dd>Preview it in the right pane.</dd> <dd>Preview it in the right pane.</dd>
<dt>Row actions — right-click, ⋯, or the menu key</dt> <dt>Right-click any row</dt>
<dd>Right-click a row, click the ⋯ button that appears on hover, or <dd>Opens a context menu with Open, Download, Copy path, Sort, and
press the menu key (or Shift+F10) on the selected row. The menu only folder-specific actions. Toggle items show a ✓ when active; submenus
lists actions that apply to that item; actions you can see but can't open on hover.</dd>
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> <dt>⤴ Pop out</dt>
<dd>Open the current preview in a separate window — useful for a second <dd>Open the current preview in a separate window — useful for a second
monitor.</dd> monitor.</dd>

6
build
View file

@ -218,12 +218,6 @@ fi
cp "$SCRIPT_DIR/tables/dist/tables.html" "$SCRIPT_DIR/zddc/internal/handler/tables.html" cp "$SCRIPT_DIR/tables/dist/tables.html" "$SCRIPT_DIR/zddc/internal/handler/tables.html"
echo "Populated zddc/internal/handler/tables.html for //go:embed" 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 if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
# Assemble the embedded versions manifest from the per-tool .label sidecars # Assemble the embedded versions manifest from the per-tool .label sidecars

View file

@ -4,52 +4,41 @@ A collection of tools for converting Markdown documents to HTML with a professio
## Server-side conversion (`zddc-server`) ## Server-side conversion (`zddc-server`)
> The shell scripts in this folder are standalone CLI/batch tools. `zddc-server` zddc-server can offer the same conversions on demand: a `.md` file in any
> implements its **own** on-demand conversion (Go package `zddc/internal/convert`) served directory becomes downloadable as `.docx`, `.html`, and `.pdf` via the
> and does **not** call these scripts. It does, however, reuse the same `?convert=` query parameter, surfaced as Download buttons in the browse app's
> `templates/` (embedded at build time). See AGENTS.md → "Server-side document markdown editor.
> conversion" for the authoritative reference.
zddc-server can render any served `.md` on demand: requesting the sibling URL The server shells out to two upstream container images, pulling each on
`<path>/foo.docx` (or `.html` / `.pdf`) returns the converted bytes — no query first use via `--pull=missing`. No custom image build is required —
string. A real on-disk file of that name always wins; the virtual conversion operators just install `podman` (preferred) or `docker`, and the first
only fires when the requested file doesn't exist but `foo.md` does. The browse conversion request pulls the image:
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).
**Architecture.** The Go code does the minimum — it `exec`s `pandoc` and - `docker.io/pandoc/latex:latest` — MD → DOCX and MD → HTML
`chromium-browser` directly. The sandbox and resource caps live in the runtime (override: `--convert-pandoc-image=` or `ZDDC_CONVERT_PANDOC_IMAGE`;
**image**, where `/usr/local/bin/{pandoc,chromium-browser}` are wrapper scripts switch to `docker.io/pandoc/core:latest` for a ~90% size reduction
that run the real binary inside a per-conversion bubblewrap sandbox if you don't need pandoc's native LaTeX-PDF path)
(`--unshare-all`, read-only binds, `--tmpfs /tmp`, `--clearenv`) under cgroup v2 - `docker.io/zenika/alpine-chrome:latest` — HTML → PDF
memory/PID caps. I/O is via stdin/stdout plus a per-call scratch dir. There is no (override: `--convert-chromium-image=` or `ZDDC_CONVERT_CHROMIUM_IMAGE`)
container runtime and no image pulling at request time.
The PDF flow is two-stage: pandoc renders the markdown through the selected The PDF flow is two-stage: pandoc renders the markdown through
`templates/<doctype>.html` to standalone HTML, then headless Chromium prints that `viewer-template.html` to standalone HTML, then headless Chromium
HTML to PDF — preserving the template's print-media CSS rather than going through prints that HTML to PDF. This preserves the existing print-media CSS
pandoc's LaTeX template. authored for the viewer template rather than going through pandoc's
LaTeX template.
Converted bytes are cached at `<dir>/.zddc.d/converted/<base>.<ext>` with mtime If neither podman nor docker is on PATH the endpoint serves 503 with
synced to the source, so a fresh cache hit is a stat-and-serve with no `exec`. a clear "no container runtime" message. Engine choice is overridable
A PUT/DELETE/MOVE on the source `.md` purges the sidecars. Per-project header via `--convert-engine=` or `ZDDC_CONVERT_ENGINE`.
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`.
Relevant flags (defaults in parens): Resource limits are per-container and configurable: `--convert-mem-mib`
(default 512), `--convert-cpus` (default "2"), `--convert-pids`
(default 100), `--convert-timeout` (default 30s).
- `--convert-pandoc-binary` (`pandoc`) / `--convert-chromium-binary` Each conversion runs in a throw-away container with
(`chromium-browser`; `chromium` on Debian) — PATH-resolved name or absolute path `--rm --network=none --read-only --tmpfs=/tmp --cap-drop=ALL
- `--convert-scratch-dir` (`$TMPDIR`) — host scratch root for template + intermediates --security-opt=no-new-privileges` plus a bind-mounted scratch dir
- `--convert-mem-mib` (`1024`) — per-conversion memory cap (cgroup `memory.max`) for I/O (read-only for the template; read-write for the PDF output).
- `--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 ## Features
@ -61,15 +50,7 @@ working. Running against raw pandoc/chromium with no wrapper gives a working but
- **Template integration**: Automatically applies the viewer template - **Template integration**: Automatically applies the viewer template
- **Progress tracking**: Real-time conversion status and summary - **Progress tracking**: Real-time conversion status and summary
### Professional templates (`templates/`) ### Professional Viewer Template (`viewer-template.html`)
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 - **Modern responsive design**: Works on desktop, tablet, and mobile
- **Table of Contents (TOC)**: Auto-generated sidebar navigation with smooth scrolling - **Table of Contents (TOC)**: Auto-generated sidebar navigation with smooth scrolling
- **Print optimization**: Professional formatting for PDF generation - **Print optimization**: Professional formatting for PDF generation
@ -99,18 +80,20 @@ resolve per-project/per-party overrides from `.zddc.d/templates/<name>.html`.
``` ```
### Configuration (`zddc.conf`) ### Configuration (`zddc.conf`)
Create a `zddc.conf` file in your project directory. It is **sourced as shell**, Create a `zddc.conf` file in your project directory:
so use `var="value"` syntax (no spaces around `=`). Only these four variables are ```ini
read; all are optional and feed the document header via pandoc `--variable`: # Project metadata
```sh title = "Project Documentation"
contractor="Contractor Name" # contracting organization (header) author = "Your Organization"
client="Client Name" # client org (header, paired with project) date = "2024"
project="Project Name" # full project name
project_number="AR 28088" # shown in parentheses after the project name # Template settings
template = "/path/to/viewer-template.html"
css = "custom-styles.css"
# Output settings
output_dir = "rendered"
``` ```
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 ### Directory Structure
``` ```
@ -142,12 +125,40 @@ your-project/
- **Tablet**: Collapsible sidebar with overlay - **Tablet**: Collapsible sidebar with overlay
- **Mobile**: Hamburger menu with full-screen TOC 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 ## File Types Supported
- **Input**: Markdown (`.md`), DOCX (`.docx`), and HTML (`.html`/`.htm`) files - **Input**: Markdown (`.md`) files with pandoc extensions
(auto-detected: DOCX→MD, MD→HTML, HTML→MD; override with `-t md|html|docx`). - **Output**: HTML files with embedded CSS and JavaScript
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 - **Images**: Supports embedded images and diagrams
- **Tables**: Full table support with print optimization - **Tables**: Full table support with print optimization
- **Code**: Syntax highlighting for code blocks - **Code**: Syntax highlighting for code blocks
@ -161,7 +172,29 @@ your-project/
## Troubleshooting ## Troubleshooting
### Common Issues ### Common Issues
1. **Template not found**: Keep the `templates/` directory beside the script (or input), or pass `-T /path/to/template.html` 1. **Template not found**: Ensure `zddc.conf` points to correct template path
2. **Permission errors**: Make sure `convert` script is executable (`chmod +x convert`) 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 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 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,8 +8,7 @@ show_help() {
echo " -f: Force overwrite existing output files" echo " -f: Force overwrite existing output files"
echo " -o: Output directory (default: same as input)" echo " -o: Output directory (default: same as input)"
echo " -t: Target format (md, html, docx) - overrides auto-detection" echo " -t: Target format (md, html, docx) - overrides auto-detection"
echo " -T: Template file path (default: templates/<template>.html, where <template>" echo " -T: Template file path (default: viewer-template.html)"
echo " comes from the doc's YAML front matter; falls back to templates/report.html)"
echo " --no-toc: Skip table of contents generation" echo " --no-toc: Skip table of contents generation"
} }
@ -125,23 +124,6 @@ SUCCESSFUL=0
FAILED=0 FAILED=0
SKIPPED=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 # Function to convert DOCX to Markdown
convert_docx_to_md() { convert_docx_to_md() {
local INPUT="$1" local INPUT="$1"
@ -152,14 +134,16 @@ convert_docx_to_md() {
local FILENAME_NO_EXT="$6" local FILENAME_NO_EXT="$6"
# Convert using pandoc with proper extension stripping to temp file first # 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 "$INPUT" -o "$TEMP_FILE"; then if pandoc -f docx -t gfm --markdown-headings=atx --extract-media="$MEDIA_DIR" --wrap=none --standalone "$INPUT" -o "$TEMP_FILE"; then
# Parse ZDDC filename pattern: trackingNumber_revision (status) - title.extension # Parse ZDDC filename pattern: trackingNumber_revision (status) - title.extension
if parse_zddc_filename "$FILENAME_NO_EXT"; then # Use sed to extract ZDDC components
TRACKING_NUMBER="$ZDDC_TRACKING" ZDDC_MATCH=$(echo "$FILENAME_NO_EXT" | sed -n 's/^\([^_]*\)_\([^ ]*\) *(\([^)]*\)) *- *\(.*\)$/\1|\2|\3|\4/p')
REVISION="$ZDDC_REVISION" if [ -n "$ZDDC_MATCH" ]; then
STATUS="$ZDDC_STATUS" TRACKING_NUMBER=$(echo "$ZDDC_MATCH" | cut -d'|' -f1)
TITLE="$ZDDC_TITLE" REVISION=$(echo "$ZDDC_MATCH" | cut -d'|' -f2)
STATUS=$(echo "$ZDDC_MATCH" | cut -d'|' -f3)
TITLE=$(echo "$ZDDC_MATCH" | cut -d'|' -f4)
echo " → ZDDC metadata detected:" echo " → ZDDC metadata detected:"
echo " • Tracking: $TRACKING_NUMBER" echo " • Tracking: $TRACKING_NUMBER"
@ -170,8 +154,8 @@ convert_docx_to_md() {
# Create YAML front matter and combine with content # Create YAML front matter and combine with content
{ {
echo "---" echo "---"
echo "client: \"${client:-}\"" echo "client: \"${CLIENT:-}\""
echo "project: \"${project:-}\"" echo "project: \"${PROJECT:-}\""
echo "tracking_number: \"$TRACKING_NUMBER\"" echo "tracking_number: \"$TRACKING_NUMBER\""
echo "revision: \"$REVISION\"" echo "revision: \"$REVISION\""
echo "status: \"$STATUS\"" echo "status: \"$STATUS\""
@ -275,10 +259,7 @@ convert_md_to_html() {
fi fi
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 if [ -z "$CUSTOM_TEMPLATE" ]; then
# Convert script directory to absolute path # Convert script directory to absolute path
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
@ -286,33 +267,25 @@ convert_md_to_html() {
# Check if script is a symlink and resolve target directory # Check if script is a symlink and resolve target directory
SCRIPT_TARGET_DIR="" SCRIPT_TARGET_DIR=""
if [ -L "$0" ]; then if [ -L "$0" ]; then
# Script is a symlink - resolve the target fully
# readlink -f is available on Linux with GNU coreutils # readlink -f is available on Linux with GNU coreutils
SCRIPT_TARGET=$(readlink -f "$0") SCRIPT_TARGET=$(readlink -f "$0")
SCRIPT_TARGET_DIR=$(dirname "$SCRIPT_TARGET") SCRIPT_TARGET_DIR=$(dirname "$SCRIPT_TARGET")
fi fi
# Template name from the doc's front matter (sanitized to a bare basename). # Template search order: input dir, script dir, symlink target dir
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) if [ -f "$INPUT_DIR/viewer-template.html" ]; then
[ -n "$TEMPLATE_NAME" ] || TEMPLATE_NAME="report" TEMPLATE_ABS="$INPUT_DIR/viewer-template.html"
echo " → Using template from input directory: $TEMPLATE_ABS"
# Search order: input dir, script dir, symlink target dir — each a templates/ elif [ -f "$SCRIPT_DIR/viewer-template.html" ]; then
# subdir. Use absolute paths since pandoc runs after a cd into the input dir. TEMPLATE_ABS="$SCRIPT_DIR/viewer-template.html"
INPUT_DIR_ABS=$(dirname "$INPUT_ABS") 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_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
fi fi
@ -320,8 +293,8 @@ convert_md_to_html() {
ORIGINAL_DIR=$(pwd) ORIGINAL_DIR=$(pwd)
cd "$INPUT_DIR" cd "$INPUT_DIR"
# Build pandoc command as an argument array (safe form, no eval — each value # Build pandoc command using positional arguments (安全方式,无 eval)
# is a separate array element so it can't be re-split or injected by the shell). # 以空格分隔的参数数组,避免 shell 注入
PANDOC_ARGS=() PANDOC_ARGS=()
PANDOC_ARGS+=("--from" "markdown+yaml_metadata_block") PANDOC_ARGS+=("--from" "markdown+yaml_metadata_block")
PANDOC_ARGS+=("--standalone") PANDOC_ARGS+=("--standalone")
@ -342,11 +315,12 @@ convert_md_to_html() {
# Extract ZDDC metadata from filename for template variables # Extract ZDDC metadata from filename for template variables
FILENAME_NO_EXT=$(basename "$INPUT" .md) FILENAME_NO_EXT=$(basename "$INPUT" .md)
if parse_zddc_filename "$FILENAME_NO_EXT"; then ZDDC_MATCH=$(echo "$FILENAME_NO_EXT" | sed -n 's/^\([^_]*\)_\([^ ]*\) *(\([^)]*\)) *- *\(.*\)$/\1|\2|\3|\4/p')
TRACKING_NUMBER="$ZDDC_TRACKING" if [ -n "$ZDDC_MATCH" ]; then
REVISION="$ZDDC_REVISION" TRACKING_NUMBER=$(echo "$ZDDC_MATCH" | cut -d'|' -f1)
STATUS="$ZDDC_STATUS" REVISION=$(echo "$ZDDC_MATCH" | cut -d'|' -f2)
TITLE="$ZDDC_TITLE" STATUS=$(echo "$ZDDC_MATCH" | cut -d'|' -f3)
TITLE=$(echo "$ZDDC_MATCH" | cut -d'|' -f4)
# Pass ZDDC variables to template (each as separate args to avoid injection) # Pass ZDDC variables to template (each as separate args to avoid injection)
PANDOC_ARGS+=("--variable" "tracking_number=$TRACKING_NUMBER") PANDOC_ARGS+=("--variable" "tracking_number=$TRACKING_NUMBER")
@ -383,10 +357,11 @@ convert_md_to_html() {
PANDOC_ARGS+=("--variable" "no-toc=true") PANDOC_ARGS+=("--variable" "no-toc=true")
fi fi
# (--section-divs already added above) PANDOC_ARGS+=("--section-divs")
PANDOC_ARGS+=("--id-prefix=")
PANDOC_ARGS+=("--html-q-tags") PANDOC_ARGS+=("--html-q-tags")
# Run pandoc with positional arguments (safe form, no eval) # Run pandoc with positional arguments (安全方式)
# All variables passed as separate arguments to avoid shell injection # All variables passed as separate arguments to avoid shell injection
if pandoc "$(basename "$INPUT_ABS")" -o "$OUTPUT_ABS" "${PANDOC_ARGS[@]}"; then if pandoc "$(basename "$INPUT_ABS")" -o "$OUTPUT_ABS" "${PANDOC_ARGS[@]}"; then

View file

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

163
pandoc/custom.css Normal file
View file

@ -0,0 +1,163 @@
/*
* 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,6 +13,11 @@
set -e set -e
cleanup() {
unset latest_files
}
trap cleanup EXIT
# Default output directory # Default output directory
OUTPUT_DIR=".archive" OUTPUT_DIR=".archive"
@ -54,21 +59,15 @@ done
mkdir -p "$OUTPUT_DIR" mkdir -p "$OUTPUT_DIR"
# Function to get relative path from $1 (base dir) to $2 (target path) # Function to get relative path from $1 (base dir) to $2 (target path)
# Prefers python3 for portability (works on both GNU and BSD systems). Paths are # Uses Python for portability (works on both GNU and BSD systems)
# 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() { relative_path() {
local base_dir="$1" local base_dir="$1"
local target_path="$2" local target_path="$2"
if command -v python3 >/dev/null 2>&1; then if command -v python3 >/dev/null 2>&1; then
python3 -c 'import os, sys; print(os.path.relpath(sys.argv[1], sys.argv[2]))' \ python3 -c "import os; print(os.path.relpath('$target_path', '$base_dir'))"
"$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 else
# Last resort: absolute path (still a valid symlink target, just not relative). # Fallback: use absolute paths if python3 not available
realpath "$target_path" realpath "$target_path"
fi fi
} }
@ -267,12 +266,8 @@ EOF
# Create truncated SHA256 for display # Create truncated SHA256 for display
sha256_short="${sha256:0:6}...${sha256: -6}" 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 # Add to markdown table
echo "| $row_counter | $tracking_link | $md_title | $revision_link | $md_status | <span class=\"sha256\" title=\"$sha256\">$sha256_short</span> |" >> "$index_md_file" echo "| $row_counter | $tracking_link | $doc_title | $revision_link | $status | <span class=\"sha256\" title=\"$sha256\">$sha256_short</span> |" >> "$index_md_file"
echo " $filename -> symlinks created" echo " $filename -> symlinks created"
done < <(find "$folder" -maxdepth 1 \( -type f -o -type l \) -print0) done < <(find "$folder" -maxdepth 1 \( -type f -o -type l \) -print0)

View file

@ -1,112 +0,0 @@
<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

@ -1,778 +0,0 @@
<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

@ -1,259 +0,0 @@
<!-- 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

@ -1,56 +0,0 @@
<!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

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

View file

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

1261
pandoc/viewer-template.html Normal file

File diff suppressed because it is too large Load diff

View file

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

View file

@ -14,11 +14,6 @@
# so it stays registered (else its workspaces can't be recreated) # so it stays registered (else its workspaces can't be recreated)
# - LEAVE archive/<party>/{received,issued} in place (the WORM record) # - 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 # Per-folder .zddc files travel with their directory (the whole slot dir
# is moved). Idempotent: already-migrated paths are skipped. Run with the # is moved). Idempotent: already-migrated paths are skipped. Run with the
# server stopped (or accept it's a plain filesystem move). # server stopped (or accept it's a plain filesystem move).
@ -117,28 +112,6 @@ synth_registry() {
synth=$((synth + 1)) 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 for projectdir in "$ROOT"/*/; do
[ -d "$projectdir/archive" ] || continue [ -d "$projectdir/archive" ] || continue
project=$(basename "$projectdir") project=$(basename "$projectdir")
@ -164,11 +137,6 @@ for projectdir in "$ROOT"/*/; do
done done
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 ""
say "summary: moved=$moved synthesized=$synth skipped=$skipped" say "summary: moved=$moved synthesized=$synth skipped=$skipped"
[ "$DRY" -eq 1 ] && say "(dry-run — nothing changed)" [ "$DRY" -eq 1 ] && say "(dry-run — nothing changed)"

View file

@ -83,38 +83,6 @@ concat_files() {
done 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 # ISO UTC build timestamp — set once when this file is sourced
build_timestamp=$(date -u +"%Y-%m-%d %H:%M:%S") build_timestamp=$(date -u +"%Y-%m-%d %H:%M:%S")

View file

@ -127,12 +127,6 @@
// one path instead of two. // one path instead of two.
+ '<symbol id="icon-chevron-right" viewBox="0 0 24 24">' + '<symbol id="icon-chevron-right" viewBox="0 0 24 24">'
+ '<path d="m9 18 6-6-6-6"/>' + '<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>'; + '</symbol>';
var injected = false; var injected = false;

View file

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

View file

@ -207,96 +207,3 @@ 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();
});
});

View file

@ -1,134 +0,0 @@
// 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,6 +61,7 @@ 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_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_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_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_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
| `ZDDC_INDEX_PATH` | `.archive` | URL path segment name for the virtual archive index | | `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) | | `ZDDC_EMAIL_HEADER` | `X-Auth-Request-Email` | HTTP request header containing the authenticated user's email (the oauth2-proxy / nginx auth-request convention) |
@ -671,15 +672,14 @@ naive intuition suggests.
at `zddc/internal/zddc/file.go:17-20` (and `IsAdmin`) only reads root. This is 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. the only upward-escalation gate; subtree write access never grants admin.
4. **Dropping a tool HTML on disk is a full UI mount, not just a file.** 4. **An `apps:` URL override is a full UI mount, not just a tool version pin.**
A real `<app>.html` at a path — or an `<app>.html` member of the site Any `.zddc` writer in a subtree can pin `archive: https://attacker.example/...`
`<ZDDC_ROOT>/.zddc.zip` bundle — is served verbatim to every viewer at or and serve arbitrary HTML to every viewer below that level. Subtree write
below that scope. So write access to a directory is effectively UI-mounting authority on `.zddc` should be treated as full UI-mounting authority. The
authority for it, and write access to `<root>/.zddc.zip` is a **site-wide** `_app/` cache is fetch-once-and-keep — operators clear it by deleting
UI mount (treat it like `admins:` — keep the root writable only by admins). `<ZDDC_ROOT>/_app/<scheme>/<host>[:<port>]/<path>`. (See "Apps: virtual tool HTMLs" below for
There is no remote fetch and nothing to sign: the bytes are whatever sits on the resolver order; SHA-256 pinning is on the federal-readiness list, not
the local filesystem, governed by the same ACL/WORM as any other file. (See currently implemented.)
"Apps: virtual tool HTMLs" below for the resolver order.)
5. **Relying on `/Archive/` being unbrowsable to "hide" sibling vendor folders' 5. **Relying on `/Archive/` being unbrowsable to "hide" sibling vendor folders'
existence.** Sibling-vendor names are hidden because directories the caller existence.** Sibling-vendor names are hidden because directories the caller
@ -742,12 +742,10 @@ guarantee these for the model above to hold:
logging, ship the JSON-line file to an external append-only sink (syslog, 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 SIEM) via a sidecar; do not treat the local rotation as the system of
record. record.
4. **Tool-HTML overrides are local files, not fetched/signed.** A tool's HTML 4. **`apps:` URL fetches have no integrity check.** Fetched once on first
comes from a real file at the path, an `<app>.html` member of the site miss, cached at `<ZDDC_ROOT>/_app/<scheme>/<host>[:<port>]/<path>` forever — no SHA-256 pin,
`<ZDDC_ROOT>/.zddc.zip`, or the embedded default — never the network. There no signature. Use only URLs you control, treat the apps cache as a trust
is nothing to verify; the trust boundary is filesystem write access. Audit boundary, and audit who has `.zddc` write authority where.
who can write tool HTML at each scope (especially `<root>/.zddc.zip`, a
site-wide UI mount).
### Debugging permissions ### Debugging permissions
@ -951,9 +949,9 @@ have to redo the gap analysis from scratch.
subcommand. See §"Policy export for change control" below. subcommand. See §"Policy export for change control" below.
- **Supply-chain integrity** (NIST SI-7) — vendored libs (jszip, - **Supply-chain integrity** (NIST SI-7) — vendored libs (jszip,
docx-preview, xlsx) need SBOM, CVE tracking, automated update pipeline. docx-preview, xlsx) need SBOM, CVE tracking, automated update pipeline.
Tool HTML is no longer fetched at runtime (overrides are local files / `apps:` URL fetches need code signing (operator trusts a published
the site `.zddc.zip` bundle, governed by filesystem ACL), so there is no public key once; no per-artifact hash management). See §"Code-signed
remote-artifact signing requirement here. apps: URL fetches" below.
- **Data-at-rest encryption** (NIST SC-28) — delegated to the deployment - **Data-at-rest encryption** (NIST SC-28) — delegated to the deployment
platform. Required: documented baseline (cloud KMS, LUKS, dm-crypt) with platform. Required: documented baseline (cloud KMS, LUKS, dm-crypt) with
key-rotation procedures. key-rotation procedures.
@ -964,10 +962,11 @@ have to redo the gap analysis from scratch.
A full SSP / control-by-control mapping consumes this list as input; it is A full SSP / control-by-control mapping consumes this list as input; it is
not a substitute for one. not a substitute for one.
The bullets most likely to need engineering depth — FIPS, the The four bullets most likely to need engineering depth — FIPS, the
authenticated proxy channel, and policy export — have their own authenticated proxy channel, policy export, and signed `apps:` URL
subsections below capturing the design considerations and effort fetches — have their own subsections below capturing the design
estimates so a future implementor doesn't restart from zero. considerations and effort estimates so a future implementor doesn't
restart from zero.
#### FIPS-validated cryptography (NIST SC-13) #### FIPS-validated cryptography (NIST SC-13)
@ -1165,6 +1164,77 @@ match — small graph problem), and the format renderers.
**Effort estimate:** ~250 lines of Go (CLI subcommand + equivalence- **Effort estimate:** ~250 lines of Go (CLI subcommand + equivalence-
class computation + JSON/Markdown/CSV renderers) + ~100 lines of tests. 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) ### External policy decider (OPA-compatible)
For deployments that need policy decisions made by an external, For deployments that need policy decisions made by an external,
@ -1451,8 +1521,8 @@ fsnotify watcher's debounce window (~2 s) — no service restart needed.
`zddc-server` virtually serves the tool HTMLs (archive, transmittal, `zddc-server` virtually serves the tool HTMLs (archive, transmittal,
classifier, landing, browse, form, tables) at the appropriate paths. classifier, landing, browse, form, tables) at the appropriate paths.
The current-stable build of each tool is **baked into the binary at The current-stable build of each tool is **baked into the binary at
compile time** via `//go:embed`; that's the default. Overrides are compile time** via `//go:embed`; that's the default. No fetch happens
**local only** — there is no network fetch, ever. out of the box.
### Where each tool is served ### Where each tool is served
@ -1466,40 +1536,40 @@ compile time** via `//go:embed`; that's the default. Overrides are
Outside these locations, the corresponding `<app>.html` URL returns 404. Outside these locations, the corresponding `<app>.html` URL returns 404.
### Override (local only) ### Override and version-pin
For any path, the resolution order is: For any path, the resolution order is:
1. **Real file at the path**drop a real `archive.html` (or `browse.html`, 1. **Real file at the path**operator drops `archive.html` (or any other)
or a brand-new `mytool.html`) into a directory; the static handler serves into a directory; the static handler serves it. Beats everything below.
it. Beats everything below. 2. **Closer-to-leaf `.zddc apps:` entry** — walks `.zddc` files leaf→root
2. **Site bundle `<ZDDC_ROOT>/.zddc.zip`** — a local zip whose `<app>.html` for an `apps.<app>` entry. The first match wins. Spec is one of:
members override the embedded default site-wide (and let you add new - `stable` (canonical upstream "current stable")
`<name>.html` tools). The server reads members from the filesystem via - `v0.0.4` (canonical upstream exact-version pin)
`internal/zipfs` — no fetch, no signature. The bundle is re-stat'd on each - `https://...` (full URL to a custom mirror)
request, so dropping in a new one takes effect immediately. - `./local.html` / `/abs/path.html` (local file)
3. **Embedded** — the build-time HTML compiled into the binary. 3. **Embedded** — the build-time HTML compiled into the binary.
There is no `apps:` `.zddc` key, no channels/versions, no URL fetching, and no URL sources are fetched once on first request and cached forever in
signature verification — all removed in favour of this local model. `.zddc.zip` `<ZDDC_ROOT>/_app/<scheme>/<host>[:<port>]/<path>`. There is no background refresh and no
is config, not content: a direct `GET /.zddc.zip` returns 404 for everyone, hash verification — to pull a new build, delete the cache file. Concurrent
while the server reads its members internally (so resolution works for any misses for the same URL share one outbound fetch (singleflight). Direct
user). The `X-ZDDC-Source` response header reports what was served: URL access to `/_app/...` is blocked at dispatch; cached HTMLs are served
`bundle:<app>.html` or `embedded:<app>@<build>` (an on-disk override is served only via the apps resolver.
by the static handler with its own headers).
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>`.
### Example ### Example
Override `browse` everywhere and add a custom `report` tool via the bundle: ```yaml
# <ZDDC_ROOT>/Project-A/.zddc
```sh apps:
cd <ZDDC_ROOT> && zip .zddc.zip browse.html report.html 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
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 ### Env vars

View file

@ -507,12 +507,52 @@ func newGzipWrapper() (func(http.Handler) http.HandlerFunc, error) {
return gzhttp.NewWrapper(gzhttp.MinSize(1024)) return gzhttp.NewWrapper(gzhttp.MinSize(1024))
} }
// setupApps builds the tool-HTML server. Resolution is LOCAL-ONLY: a real // setupApps creates the cache + fetcher + server. No seeding, no refresh,
// file on disk at the request path (handled upstream by dispatch) → a // no admin UI — the server fetches once on first request, caches forever
// "<app>.html" member of the site-root <ZDDC_ROOT>/.zddc.zip bundle → the // in <ZDDC_ROOT>/.zddc.d/apps/, and falls back to the embedded HTML on any failure.
// embedded default. No fetch, no cache, no signatures.
func setupApps(cfg config.Config) (*apps.Server, error) { func setupApps(cfg config.Config) (*apps.Server, error) {
return apps.NewServer(cfg.Root, version), nil 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
} }
// warnIfNoBootstrap fires a startup slog.Warn when the root .zddc grants // warnIfNoBootstrap fires a startup slog.Warn when the root .zddc grants
@ -623,14 +663,6 @@ func embeddedVersionsForLog(embedded map[string]string) string {
// authenticated user (may be empty). // authenticated user (may be empty).
func serveSpecializedNoSlash(cfg config.Config, appsSrv *apps.Server, w http.ResponseWriter, r *http.Request, dirAbs, urlPath, email string) bool { 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) 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 == "" { if app == "" {
return false return false
} }
@ -709,30 +741,6 @@ func splitZipPath(fsRoot, urlPath string) (zipAbs, member string, ok bool) {
return "", "", false 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. // 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) { 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 // URL paths are case-insensitive: resolve each segment against the
@ -830,29 +838,6 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return 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 // Raw .zddc YAML view: <dir>/.zddc is reachable at every depth
// and returns the on-disk file's bytes (Content-Type: application/yaml) // and returns the on-disk file's bytes (Content-Type: application/yaml)
// or — when no file exists — a synthetic placeholder body with a // or — when no file exists — a synthetic placeholder body with a
@ -1274,27 +1259,6 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return 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 // (MD→{docx,html,pdf} on-demand conversion now lives at
// `GET /<dir>/<file>.{docx,html,pdf}` (virtual file URL, // `GET /<dir>/<file>.{docx,html,pdf}` (virtual file URL,
// see RecognizeVirtualConvert). The .md source serves // see RecognizeVirtualConvert). The .md source serves

View file

@ -4,14 +4,18 @@ import (
"archive/zip" "archive/zip"
"bytes" "bytes"
"context" "context"
"crypto/ed25519"
"crypto/rand"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive" "codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler" "codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
@ -96,22 +100,60 @@ func TestDispatchReservesZddcD(t *testing.T) {
} }
} }
// TestDispatchAppsResolution drives local tool-HTML resolution through // TestDispatchAppsResolution drives the full apps fetch+cache flow through
// dispatch(): the site .zddc.zip member overrides the embedded default, the // dispatch() with a fake upstream. Confirms that:
// embedded default is served when no bundle member exists, GET / serves // - GET / serves the landing app from the apps subsystem
// landing, the bundle itself is 404 over HTTP, and folder-availability rules // - GET /archive.html serves the archive app via fetch+cache
// still gate which tools are served where. // - second GET /archive.html serves from cache (X-ZDDC-Source: cache:)
// - direct URL access to the reserved cache (/.zddc.d/apps/...) is rejected
func TestDispatchAppsResolution(t *testing.T) { func TestDispatchAppsResolution(t *testing.T) {
root := t.TempDir() root := t.TempDir()
// Allow-all ACL so the test doesn't need email headers. body := []byte("<!doctype html>archive content")
zf := zddc.ZddcFile{ACL: zddc.ACLRules{Permissions: map[string]string{"*": "rwcd"}}} 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",
},
}
if err := zddc.WriteFile(root, zf); err != nil { if err := zddc.WriteFile(root, zf); err != nil {
t.Fatalf("WriteFile: %v", err) t.Fatalf("WriteFile: %v", err)
} }
// Site config bundle overriding archive.html. // Create folder convention dirs so classifier/browse/transmittal
writeRootBundle(t, root, map[string]string{"archive.html": "<!doctype html>BUNDLE archive"}) // availability rules pass for the test paths used below.
// Folder-convention dir so classifier availability passes below.
mustMkdir(t, filepath.Join(root, "Project-A", "working", "Acme")) mustMkdir(t, filepath.Join(root, "Project-A", "working", "Acme"))
idx, err := archive.BuildIndex(root) idx, err := archive.BuildIndex(root)
@ -124,33 +166,47 @@ func TestDispatchAppsResolution(t *testing.T) {
EmailHeader: "X-Auth-Request-Email", EmailHeader: "X-Auth-Request-Email",
} }
ring := handler.NewLogRing(10) ring := handler.NewLogRing(10)
appsSrv, err := setupApps(cfg) appsSrv, err := setupApps(cfg)
if err != nil { if err != nil {
t.Fatalf("setupApps: %v", err) 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 → served from the bundle member (overrides embedded). // GET /archive.html → fetched from upstream (archive is available everywhere)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil)) req := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "BUNDLE archive") { dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
t.Fatalf("/archive.html: status=%d body=%s (want bundle override)", rec.Code, rec.Body.String()) if rec.Code != http.StatusOK {
t.Fatalf("first /archive.html: status=%d body=%s", rec.Code, rec.Body.String())
} }
if rec.Header().Get("X-ZDDC-Source") != "bundle:archive.html" { if rec.Body.String() != string(body) {
t.Errorf("X-ZDDC-Source=%q, want bundle:archive.html", rec.Header().Get("X-ZDDC-Source")) t.Errorf("first /archive.html: body mismatch")
} }
// GET / → landing (no bundle member → embedded). // 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
rec3 := httptest.NewRecorder() rec3 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec3, httptest.NewRequest(http.MethodGet, "/", nil)) dispatch(cfg, idx, ring, appsSrv, nil, rec3, httptest.NewRequest(http.MethodGet, "/", nil))
if rec3.Code != http.StatusOK { if rec3.Code != http.StatusOK {
t.Errorf("GET /: status=%d", rec3.Code) t.Errorf("GET /: status=%d", rec3.Code)
} }
// The site bundle is config, not content: a direct GET is 404 for everyone. // 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).
rec4 := httptest.NewRecorder() rec4 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec4, httptest.NewRequest(http.MethodGet, "/.zddc.zip", nil)) dispatch(cfg, idx, ring, appsSrv, nil, rec4, httptest.NewRequest(http.MethodGet, "/.zddc.d/apps/foo.html", nil))
if rec4.Code != http.StatusNotFound { if rec4.Code != http.StatusNotFound {
t.Errorf("GET /.zddc.zip: status=%d, want 404", rec4.Code) t.Errorf("/.zddc.d/apps/ direct: status=%d, want 404", rec4.Code)
} }
// Folder availability rules: classifier should NOT be served at root // Folder availability rules: classifier should NOT be served at root
@ -221,6 +277,10 @@ 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 // TestDispatchRoutesWritesToFileAPI verifies dispatch sends PUT/DELETE/POST
// to the file API rather than to the read pipeline. // to the file API rather than to the read pipeline.
func TestDispatchRoutesWritesToFileAPI(t *testing.T) { func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
@ -1034,162 +1094,3 @@ func TestGzhttpWrapper_CompressesLargeResponses(t *testing.T) {
// dot-prefix guard, like any bookkeeping, and surfaced only through the // dot-prefix guard, like any bookkeeping, and surfaced only through the
// history endpoints. Raw-block coverage is in TestDispatchHidesDotPrefixedSegments; // history endpoints. Raw-block coverage is in TestDispatchHidesDotPrefixedSegments;
// the viewer is covered in mdhistory_test.go.) // 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,22 +1,415 @@
// Package apps serves the ZDDC tool HTML files (archive, transmittal, // Package apps serves the ZDDC tool HTML files (archive, transmittal,
// classifier, landing, browse, form, tables) on virtual paths in the file // classifier, landing, browse, form, tables) on virtual paths in the
// tree. Each tool is "available" only at directories whose cascade selects // file tree. Each tool is "available" only at directories whose name
// it (default_tool / dir_tool / available_tools) — see availability.go and // matches a folder convention (Incoming/Working/Staging) — see
// the .zddc cascade. The markdown editor lives as a plugin inside browse. // availability.go. The markdown editor lives as a plugin inside browse.
// //
// Tool HTML resolution is LOCAL-ONLY — no network fetch, no signatures, no // Resolution priority for an enabled <dir>/<app>.html request:
// channels/versions. For an enabled <dir>/<app>.html request the bytes come
// from, in precedence:
// //
// 1. A real file on disk at the request path → static handler (operator // 1. Real file at the request path → static handler (operator override).
// override; handled by the dispatcher BEFORE Serve is ever reached, so // 2. Subdir cascade — walk .zddc files root→leaf, accumulating URL prefix
// by the time Serve runs no such file exists). // and channel/version components from the special `apps.default` key
// 2. A member of the site-root config bundle <ZDDC_ROOT>/.zddc.zip, named // and the per-app `apps.<name>` key. Either component can be set,
// "<app>.html", read server-side via internal/zipfs (see bundle.go). // overridden, or left to inherit at any level. Path or full-`.html`-URL
// 3. The embedded default baked into the binary at compile time via // entries are *terminal* — they short-circuit composition and a deeper
// //go:embed (see embed.go). // 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.
// //
// To change a tool's HTML, drop a file at the path, drop "<app>.html" into // Spec forms (each is a string value in `.zddc apps:`):
// .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. // :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).
package apps 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

@ -0,0 +1,438 @@
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

@ -1,115 +0,0 @@
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

@ -1,96 +0,0 @@
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")
}
}

187
zddc/internal/apps/cache.go Normal file
View file

@ -0,0 +1,187 @@
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

@ -0,0 +1,160 @@
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> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span> <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-05 12:41:17 · 382645b</span></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>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></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> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span> <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-05 12:41:17 · 382645b</span></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>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <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> <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> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC</span> <span class="app-header__title">ZDDC</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> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 18:26:16 · f723323</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -2635,7 +2635,7 @@ dialog.modal--narrow {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span> <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-05 12:41:16 · 382645b</span></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>
</div> </div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span> <span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action; <!-- 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. # Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b archive=v0.0.27-beta · 2026-06-03 18:26:16 · f723323
transmittal=v0.0.27-beta · 2026-06-05 12:41:16 · 382645b transmittal=v0.0.27-beta · 2026-06-03 18:26:16 · f723323
classifier=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b classifier=v0.0.27-beta · 2026-06-03 18:26:16 · f723323
landing=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b landing=v0.0.27-beta · 2026-06-03 18:26:16 · f723323
form=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b form=v0.0.27-beta · 2026-06-03 18:26:16 · f723323
tables=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b tables=v0.0.27-beta · 2026-06-03 18:26:16 · f723323
browse=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b browse=v0.0.27-beta · 2026-06-03 18:26:16 · f723323

193
zddc/internal/apps/fetch.go Normal file
View file

@ -0,0 +1,193 @@
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,36 +3,33 @@ package apps
import ( import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"log/slog" "errors"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
) )
// Server resolves tool HTML for a request: bundle member → embedded. The // Server orchestrates app HTML resolution: subdir cascade override → fetch
// on-disk-at-path tier (operator override) is handled UPSTREAM by the // or path read → embedded fallback. It does NOT check whether the app is
// dispatcher's stat-first static handler, so by the time Serve runs no real // available at the request directory — that's AppAvailableAt's job, called
// file exists at the path. Server does NOT decide whether the app is // from dispatch before invoking Serve.
// available at the directory — that's AppAvailableAt's job, called from
// dispatch before Serve.
type Server struct { type Server struct {
Root string Root string
Cache *Cache
Fetcher *Fetcher
BuildVer string // baked into X-ZDDC-Source for embedded responses BuildVer string // baked into X-ZDDC-Source for embedded responses
Bundle *Bundle
Logger *slog.Logger
} }
// NewServer constructs a Server bound to the site-root config bundle. // NewServer constructs a Server.
func NewServer(root, buildVer string) *Server { func NewServer(root string, cache *Cache, fetcher *Fetcher, buildVer string) *Server {
root = filepath.Clean(root)
logger := slog.Default()
return &Server{ return &Server{
Root: root, Root: filepath.Clean(root),
Cache: cache,
Fetcher: fetcher,
BuildVer: buildVer, BuildVer: buildVer,
Bundle: NewBundle(root, logger),
Logger: logger,
} }
} }
@ -41,8 +38,8 @@ func NewServer(root, buildVer string) *Server {
// directory (relative to root) the request is rooted at. The cmd/zddc- // 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 // 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.) // that happens to look like `<dir>/archive.html` (or browse.html, etc.)
// resolves to the embedded (or bundle) app HTML for that directory — // resolves to the embedded app HTML for that directory — operators
// operators don't have to copy app HTML into every project. // don't have to copy app HTML into every project.
// //
// Special case: GET / and GET /index.html both resolve to landing — the // Special case: GET / and GET /index.html both resolve to landing — the
// only entry point that scopes ACL per-project, and the conventional // only entry point that scopes ACL per-project, and the conventional
@ -75,51 +72,105 @@ func MatchAppHTML(requestPath string) (app string, requestDirRel string) {
return "", "" 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: // Serve resolves and writes the response. Caller has already verified:
// - no real file exists at the request path (so tier 1 didn't apply) // - no real file exists at the request path
// - AppAvailableAt(root, requestDir, app) is true // - AppAvailableAt(root, requestDir, app) is true
// - ACL passes for requestDir // - ACL passes for requestDir
// //
// chain and requestDir are retained in the signature for call-site stability // Honors a `?v=<spec>` query parameter as a per-request override on top of
// and future per-directory resolution; the current local model is path- // the cascade. With `?v=` set, the resolved URL must already exist in the
// independent (a bundle member or the embedded default). // cache — otherwise the response is 404. This prevents users from
func (s *Server) Serve(w http.ResponseWriter, r *http.Request, app string, _ zddc.PolicyChain, _ string) { // triggering arbitrary upstream fetches via URL-crafted requests; only
body, tag, embedded, ok := s.resolveBytes(app) // versions the operator's `.zddc apps:` entries have already pulled in
if !ok { // (or that the user has manually placed in `_app/`) are reachable.
w.Header().Set("Retry-After", "60") func (s *Server) Serve(w http.ResponseWriter, r *http.Request, app string, chain zddc.PolicyChain, requestDir string) {
http.Error(w, vSpec := strings.TrimSpace(r.URL.Query().Get("v"))
"503 Service Unavailable\n\n"+
"This zddc-server has no embedded fallback for "+app+" and no\n"+ src, hasOverride, err := ResolveWithOverride(chain, app, s.Root, requestDir, vSpec)
"\""+app+".html\" in the site .zddc.zip bundle.\n"+ if err != nil {
"Rebuild the binary against the latest tool HTMLs, or add the\n"+ // `?v=` parsing/validation errors are user input → 400.
"file to .zddc.zip.\n", if vSpec != "" {
http.StatusServiceUnavailable) http.Error(w, "400 Bad Request — invalid ?v= value: "+err.Error(), http.StatusBadRequest)
return return
} }
etag := bodyETag(body) // Malformed `.zddc` spec — operator's fault. Log and serve embedded.
if embedded { s.Fetcher.Logger.Warn("apps.Resolve failed; serving embedded",
etag = EmbeddedETag(app) "app", app, "request_dir", requestDir, "err", err)
s.serveEmbedded(w, r, app, err)
return
} }
writeWithETag(w, r, body, etag, "text/html; charset=utf-8", tag)
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
}
// 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 writes body with a strong ETag, cache-friendly headers, and // writeWithETag writes body with a strong ETag derived from `etag`, the
// short-circuits to 304 Not Modified when the client's If-None-Match matches. // 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).
func writeWithETag(w http.ResponseWriter, r *http.Request, body []byte, etag, contentType, sourceHeader string) { func writeWithETag(w http.ResponseWriter, r *http.Request, body []byte, etag, contentType, sourceHeader string) {
quotedTag := `"` + etag + `"` quotedTag := `"` + etag + `"`
w.Header().Set("ETag", quotedTag) w.Header().Set("ETag", quotedTag)
@ -134,8 +185,30 @@ func writeWithETag(w http.ResponseWriter, r *http.Request, body []byte, etag, co
_, _ = w.Write(body) _, _ = w.Write(body)
} }
// bodyETag computes a stable 32-hex-char ETag for an arbitrary 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).
func bodyETag(body []byte) string { func bodyETag(body []byte) string {
sum := sha256.Sum256(body) sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:])[:32] 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,14 +1,47 @@
package apps package apps
import ( import (
"crypto/ed25519"
"crypto/rand"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "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) { func TestMatchAppHTML(t *testing.T) {
cases := []struct { cases := []struct {
path, wantApp, wantDir string path, wantApp, wantDir string
@ -30,21 +63,34 @@ func TestMatchAppHTML(t *testing.T) {
} }
} }
// serve runs srv.Serve for app and returns the recorder. // Build a Server with a fake upstream serving body. The upstream
func serve(srv *Server, app string) *httptest.ResponseRecorder { // also publishes a valid Ed25519 signature alongside (.sig) and the
rec := httptest.NewRecorder() // fetcher's VerifyKey is overridden to the matching test pubkey so
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}} // fetched bytes pass the strict-signature gate.
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/"+app+".html", nil), app, chain, srv.Root) func newTestServer(t *testing.T, body []byte) (*Server, *httptest.Server, string) {
return rec 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
} }
func TestServer_NoBundle_ServesEmbedded(t *testing.T) { func TestServer_NoOverride_ServesEmbedded(t *testing.T) {
srv := NewServer(t.TempDir(), "test") srv, _, root := newTestServer(t, []byte("upstream body"))
saved := embeddedArchive saved := embeddedArchive
embeddedArchive = []byte("EMBEDDED archive") embeddedArchive = []byte("EMBEDDED archive")
defer func() { embeddedArchive = saved }() defer func() { embeddedArchive = saved }()
rec := serve(srv, "archive") chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code) t.Fatalf("status=%d", rec.Code)
} }
@ -56,60 +102,266 @@ func TestServer_NoBundle_ServesEmbedded(t *testing.T) {
} }
} }
func TestServer_BundleMemberOverridesEmbedded(t *testing.T) { func TestServer_OverrideURL_FetchesAndCaches(t *testing.T) {
root := t.TempDir() body := []byte("from upstream")
writeTestBundle(t, root, map[string]string{"archive.html": "BUNDLE archive override"}) srv, up, root := newTestServer(t, body)
srv := NewServer(root, "test") chain := zddc.PolicyChain{
saved := embeddedArchive Levels: []zddc.ZddcFile{{
embeddedArchive = []byte("EMBEDDED archive") Apps: map[string]string{"archive": up.URL + "/archive_stable.html"},
defer func() { embeddedArchive = saved }() }},
}
rec := serve(srv, "archive") rec := httptest.NewRecorder()
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code) t.Fatalf("status=%d", rec.Code)
} }
if !strings.Contains(rec.Body.String(), "BUNDLE archive override") { if rec.Body.String() != string(body) {
t.Errorf("expected bundle body, got %q", rec.Body.String()) t.Errorf("body mismatch")
} }
if rec.Header().Get("X-ZDDC-Source") != "bundle:archive.html" { // Cache should be populated.
t.Errorf("X-ZDDC-Source=%q, want bundle:archive.html", rec.Header().Get("X-ZDDC-Source")) if !srv.Cache.Has(up.URL + "/archive_stable.html") {
t.Errorf("cache miss after fetch")
} }
} }
func TestServer_BundlePresent_MemberAbsent_ServesEmbedded(t *testing.T) { 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()
root := t.TempDir() root := t.TempDir()
writeTestBundle(t, root, map[string]string{"browse.html": "BUNDLE browse"}) cache, _ := NewCache(filepath.Join(root, CacheDirName))
srv := NewServer(root, "test") 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"))
saved := embeddedArchive saved := embeddedArchive
embeddedArchive = []byte("EMBEDDED archive") embeddedArchive = []byte("EMBEDDED")
defer func() { embeddedArchive = saved }() defer func() { embeddedArchive = saved }()
rec := serve(srv, "archive") // bundle has browse, not archive 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)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d (want 200 from embedded)", rec.Code)
}
if !strings.Contains(rec.Body.String(), "EMBEDDED") { if !strings.Contains(rec.Body.String(), "EMBEDDED") {
t.Errorf("expected embedded fallback, got %q", rec.Body.String()) t.Errorf("body did not come from embedded fallback: %q", rec.Body.String())
} }
} }
func TestServer_UnknownTool_503WithoutBundle(t *testing.T) { // ── ?v= per-request override ─────────────────────────────────────────────
srv := NewServer(t.TempDir(), "test")
rec := serve(srv, "nope") // not embedded, no bundle func TestServer_VParam_CacheHitServesFromCache(t *testing.T) {
if rec.Code != http.StatusServiceUnavailable { srv, _, root := newTestServer(t, []byte("ignored"))
t.Errorf("status=%d, want 503", rec.Code) // 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_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) { func TestServer_Embedded_ConditionalGET(t *testing.T) {
srv := NewServer(t.TempDir(), "test") srv, _, root := newTestServer(t, []byte("upstream"))
saved := embeddedArchive saved := embeddedArchive
embeddedArchive = []byte("EMBEDDED archive bytes for ETag test") embeddedArchive = []byte("EMBEDDED archive bytes for ETag test")
defer func() { defer func() {
embeddedArchive = saved embeddedArchive = saved
etagCacheByApp.Delete("archive") etagCacheByApp.Delete("archive") // reset memoization for sibling tests
}() }()
etagCacheByApp.Delete("archive") etagCacheByApp.Delete("archive") // ensure clean state for THIS test
rec1 := serve(srv, "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)
if rec1.Code != http.StatusOK { if rec1.Code != http.StatusOK {
t.Fatalf("first GET: status=%d body=%s", rec1.Code, rec1.Body.String()) t.Fatalf("first GET: status=%d body=%s", rec1.Code, rec1.Body.String())
} }
@ -118,15 +370,17 @@ func TestServer_Embedded_ConditionalGET(t *testing.T) {
t.Fatalf("first GET: missing ETag header") 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") { 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", cc) 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())
} }
// Matching If-None-Match → 304, empty body. // Second request with matching If-None-Match: 304, empty body.
rec2 := httptest.NewRecorder() rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/archive.html", nil) req2 := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
req2.Header.Set("If-None-Match", etag) req2.Header.Set("If-None-Match", etag)
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}} srv.Serve(rec2, req2, "archive", chain, root)
srv.Serve(rec2, req2, "archive", chain, srv.Root)
if rec2.Code != http.StatusNotModified { if rec2.Code != http.StatusNotModified {
t.Fatalf("If-None-Match match: status=%d (want 304)", rec2.Code) t.Fatalf("If-None-Match match: status=%d (want 304)", rec2.Code)
} }
@ -134,34 +388,16 @@ func TestServer_Embedded_ConditionalGET(t *testing.T) {
t.Errorf("304 response should have empty body; got %d bytes", rec2.Body.Len()) t.Errorf("304 response should have empty body; got %d bytes", rec2.Body.Len())
} }
// Stale If-None-Match → 200, full body. // Third request with stale If-None-Match: 200, full body.
rec3 := httptest.NewRecorder() rec3 := httptest.NewRecorder()
req3 := httptest.NewRequest(http.MethodGet, "/archive.html", nil) req3 := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
req3.Header.Set("If-None-Match", `"deadbeef"`) req3.Header.Set("If-None-Match", `"deadbeef"`)
srv.Serve(rec3, req3, "archive", chain, srv.Root) srv.Serve(rec3, req3, "archive", chain, root)
if rec3.Code != http.StatusOK || rec3.Body.Len() == 0 { if rec3.Code != http.StatusOK {
t.Errorf("stale If-None-Match: status=%d bodyLen=%d (want 200, non-empty)", rec3.Code, rec3.Body.Len()) t.Errorf("stale If-None-Match: status=%d (want 200)", rec3.Code)
} }
} 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)
} }
} }
@ -186,6 +422,6 @@ func TestEmbeddedETag_Stable(t *testing.T) {
etagCacheByApp.Delete("archive") etagCacheByApp.Delete("archive")
b := EmbeddedETag("archive") b := EmbeddedETag("archive")
if b == a1 { if b == a1 {
t.Errorf("EmbeddedETag should change with bytes; both %q", b) t.Errorf("EmbeddedETag should differ for different bytes; both %q", b)
} }
} }

View file

@ -0,0 +1,43 @@
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

@ -0,0 +1,67 @@
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

@ -0,0 +1,73 @@
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

@ -0,0 +1,255 @@
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

@ -43,6 +43,7 @@ type Config struct {
OPAURL string // --opa-url / ZDDC_OPA_URL — policy decider endpoint: "internal" (default), "http(s)://..." (real OPA via HTTP), or "unix:///..." (OPA via Unix socket) 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) 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. 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. 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. 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.
@ -131,6 +132,8 @@ func Load(args []string) (Config, error) {
"External OPA only: on unreachable / non-2xx / malformed response, allow the request instead of denying. Default: fail closed.") "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), 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.") "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), 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.") "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), archiveRescanIntervalFlag := fs.Duration("archive-rescan-interval", parseDurationOrDefault(os.Getenv("ZDDC_ARCHIVE_RESCAN_INTERVAL"), 60*time.Second),
@ -215,6 +218,7 @@ func Load(args []string) (Config, error) {
OPAURL: *opaURLFlag, OPAURL: *opaURLFlag,
OPAFailOpen: *opaFailOpenFlag, OPAFailOpen: *opaFailOpenFlag,
OPACacheTTL: *opaCacheTTLFlag, OPACacheTTL: *opaCacheTTLFlag,
AppsPubKey: *appsPubKeyFlag,
MaxWriteBytes: *maxWriteBytesFlag, MaxWriteBytes: *maxWriteBytesFlag,
ArchiveRescanInterval: *archiveRescanIntervalFlag, ArchiveRescanInterval: *archiveRescanIntervalFlag,
ConvertPandocBinary: *convertPandocBinaryFlag, ConvertPandocBinary: *convertPandocBinaryFlag,
@ -412,6 +416,7 @@ func Usage(w io.Writer) {
fs.String("opa-url", "internal", "Policy decider: \"internal\", \"http(s)://...\", or \"unix:///...\".") 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.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.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.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.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.") fs.Bool("help", false, "Print this help and exit.")

View file

@ -14,8 +14,8 @@
// Public surface: // Public surface:
// //
// ToDocx(ctx, source, meta) → []byte (DOCX bytes) // ToDocx(ctx, source, meta) → []byte (DOCX bytes)
// ToHTML(ctx, source, meta, ts) → []byte (standalone HTML) // ToHTML(ctx, source, meta) → []byte (standalone HTML)
// ToPDF (ctx, source, meta, ts) → []byte (PDF, via HTML + chromium) // ToPDF (ctx, source, meta) → []byte (PDF, via HTML + chromium)
// //
// Probe(ctx) → Capabilities (call once at startup) // Probe(ctx) → Capabilities (call once at startup)
// Available() → (Capabilities, bool) // Available() → (Capabilities, bool)
@ -25,7 +25,7 @@
// All three converters are safe for concurrent use; each call gets a // All three converters are safe for concurrent use; each call gets a
// fresh scratch dir + (image-provided) sandbox. // fresh scratch dir + (image-provided) sandbox.
// //
// Metadata maps to the placeholders consumed by the doctype templates. // Metadata maps to the placeholders consumed by viewer-template.html.
// title/tracking_number/revision/status/is_draft typically come from // title/tracking_number/revision/status/is_draft typically come from
// the source filename (zddc.ParseFilename); client/project/contractor/ // the source filename (zddc.ParseFilename); client/project/contractor/
// project_number from the .zddc cascade `convert:` block. // 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` // Metadata is the variable bag passed to pandoc as `--variable k=v`
// pairs. Fields with zero values are omitted. The templates use // pairs. Fields with zero values are omitted. The viewer-template.html
// `$if(field)$ … $endif$` blocks so absent fields render cleanly. // uses `$if(field)$ … $endif$` blocks so absent fields render cleanly.
type Metadata struct { type Metadata struct {
Title string Title string
TrackingNumber string TrackingNumber string
@ -58,28 +58,6 @@ type Metadata struct {
NoTOC bool 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 // Default binary names. The runtime image installs WRAPPER scripts at
// /usr/local/bin/pandoc and /usr/local/bin/chromium-browser (shadowing // /usr/local/bin/pandoc and /usr/local/bin/chromium-browser (shadowing
// the real binaries in /usr/bin/) so these names resolve through the // the real binaries in /usr/bin/) so these names resolve through the
@ -154,20 +132,12 @@ func currentChromiumBinary() string {
// full file content (envelope + body); pandoc handles // full file content (envelope + body); pandoc handles
// `markdown+yaml_metadata_block` natively. // `markdown+yaml_metadata_block` natively.
func ToDocx(ctx context.Context, source []byte, m Metadata) ([]byte, error) { 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() r := currentRunner()
if r == nil { if r == nil {
return nil, ErrUnavailable return nil, ErrUnavailable
} }
cmd := []string{ cmd := []string{
"--from=" + fromFmt, "--from=markdown+yaml_metadata_block",
"--to=docx", "--to=docx",
"--output=-", "--output=-",
} }
@ -176,99 +146,25 @@ func convertToDocx(ctx context.Context, fromFmt string, source []byte, m Metadat
return r.Run(ctx, currentPandocBinary(), source, "", cmd) return r.Run(ctx, currentPandocBinary(), source, "", cmd)
} }
// convertToMarkdown renders source (DOCX or HTML, per fromFmt) to GitHub- // ToHTML renders source markdown to standalone HTML using
// flavored markdown. Embedded images are inlined as base64 data: URIs via the // viewer-template.html. Embeds CSS + images via --embed-resources.
// inline-media.lua filter so the output .md is self-contained; --wrap=none keeps // Template + custom.css live in a per-call scratch dir; the host
// paragraphs on one line (no hard line breaks). // path is passed via ZDDC_SCRATCH so the wrapper bind-mounts it
func convertToMarkdown(ctx context.Context, fromFmt string, source []byte) ([]byte, error) { // into the sandbox at the same path.
func ToHTML(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
r := currentRunner() r := currentRunner()
if r == nil { if r == nil {
return nil, ErrUnavailable return nil, ErrUnavailable
} }
scratch, err := writeScratchFiles(currentScratchDir(), map[string][]byte{"inline-media.lua": inlineMediaLua}) scratch, err := writeAssetsToScratch(currentScratchDir())
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 { if err != nil {
return nil, fmt.Errorf("scratch: %w", err) return nil, fmt.Errorf("scratch: %w", err)
} }
defer os.RemoveAll(scratch) defer os.RemoveAll(scratch)
tplPath := filepath.Join(scratch, ts.Name) tplPath := filepath.Join(scratch, "viewer-template.html")
cmd := []string{ cmd := []string{
"--from=" + fromFmt, "--from=markdown+yaml_metadata_block",
"--to=html5", "--to=html5",
"--standalone", "--standalone",
"--embed-resources", "--embed-resources",
@ -286,18 +182,18 @@ func convertToHTML(ctx context.Context, fromFmt string, source []byte, m Metadat
} }
// ToPDF renders source markdown to PDF in two stages: pandoc // ToPDF renders source markdown to PDF in two stages: pandoc
// produces HTML using the doctype template in ts (stage 1), then // produces HTML using viewer-template.html (stage 1), then headless
// headless chromium prints that HTML to PDF (stage 2). The two-stage // chromium prints that HTML to PDF (stage 2). The two-stage choice
// choice preserves the print-media CSS authored in the templates — // preserves the print-media CSS already authored in viewer-
// pandoc's native --pdf-engine path uses LaTeX which would bypass it // template.html — pandoc's native --pdf-engine path uses LaTeX
// entirely. // which would bypass it entirely.
// //
// Both stages share a single per-call scratch dir: pandoc writes // Both stages share a single per-call scratch dir: pandoc writes
// `in.html` and chromium reads it, then chromium writes `out.pdf` // `in.html` and chromium reads it, then chromium writes `out.pdf`
// which the host reads back. The wrapper bind-mounts the scratch // which the host reads back. The wrapper bind-mounts the scratch
// dir read-write into the sandbox at the same path. // dir read-write into the sandbox at the same path.
func ToPDF(ctx context.Context, source []byte, m Metadata, ts TemplateSet) ([]byte, error) { func ToPDF(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
html, err := ToHTML(ctx, source, m, ts) html, err := ToHTML(ctx, source, m)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -41,77 +41,6 @@ func (f *fakeRunner) lastCall() (string, []string) {
return f.binaries[len(f.binaries)-1], f.calls[len(f.calls)-1] 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) { func TestToDocx_UsesPandocBinary(t *testing.T) {
f := &fakeRunner{resp: []byte("FAKE-DOCX")} f := &fakeRunner{resp: []byte("FAKE-DOCX")}
InstallRunner(f) InstallRunner(f)
@ -157,7 +86,7 @@ func TestToHTML_UsesTemplateFromScratchDir(t *testing.T) {
t.Cleanup(func() { InstallRunner(nil) }) t.Cleanup(func() { InstallRunner(nil) })
SetBinaries("pandoc", "chromium-browser") SetBinaries("pandoc", "chromium-browser")
_, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{Title: "Hi"}, TemplateSet{}) _, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{Title: "Hi"})
if err != nil { if err != nil {
t.Fatalf("ToHTML: %v", err) t.Fatalf("ToHTML: %v", err)
} }
@ -172,7 +101,7 @@ func TestToHTML_UsesTemplateFromScratchDir(t *testing.T) {
if scratch == "" { if scratch == "" {
t.Fatalf("ToHTML must pass a scratch dir to the runner") t.Fatalf("ToHTML must pass a scratch dir to the runner")
} }
wantTpl := "--template=" + scratch + "/report.html" wantTpl := "--template=" + scratch + "/viewer-template.html"
if !contains(call, wantTpl) { if !contains(call, wantTpl) {
t.Errorf("template flag missing/wrong; want %q in %v", wantTpl, call) t.Errorf("template flag missing/wrong; want %q in %v", wantTpl, call)
} }
@ -186,7 +115,7 @@ func TestToHTML_NoTOCSuppressesTOC(t *testing.T) {
InstallRunner(f) InstallRunner(f)
t.Cleanup(func() { InstallRunner(nil) }) t.Cleanup(func() { InstallRunner(nil) })
_, _ = ToHTML(context.Background(), []byte("# Hi\n"), Metadata{NoTOC: true}, TemplateSet{}) _, _ = ToHTML(context.Background(), []byte("# Hi\n"), Metadata{NoTOC: true})
_, call := f.lastCall() _, call := f.lastCall()
if contains(call, "--toc") { if contains(call, "--toc") {
t.Errorf("TOC should be suppressed when NoTOC=true: %v", call) t.Errorf("TOC should be suppressed when NoTOC=true: %v", call)
@ -241,7 +170,7 @@ func TestScratchDir_UsedByToHTML(t *testing.T) {
scratchRoot := t.TempDir() scratchRoot := t.TempDir()
SetScratchDir(scratchRoot) SetScratchDir(scratchRoot)
_, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{}, TemplateSet{}) _, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{})
if err != nil { if err != nil {
t.Fatalf("ToHTML: %v", err) t.Fatalf("ToHTML: %v", err)
} }
@ -270,7 +199,7 @@ func TestToPDF_TwoStagePipeline(t *testing.T) {
t.Cleanup(func() { InstallRunner(nil) }) t.Cleanup(func() { InstallRunner(nil) })
SetBinaries("pandoc", "chromium-browser") SetBinaries("pandoc", "chromium-browser")
_, err := ToPDF(context.Background(), []byte("# Hi\n"), Metadata{}, TemplateSet{}) _, err := ToPDF(context.Background(), []byte("# Hi\n"), Metadata{})
// PDF read-back will fail (fake runner didn't write the file) — // PDF read-back will fail (fake runner didn't write the file) —
// that's expected for this test which only inspects the call shape. // that's expected for this test which only inspects the call shape.
if err == nil { if err == nil {

View file

@ -0,0 +1,163 @@
/*
* 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,88 +1,19 @@
package convert package convert
import ( import _ "embed"
"embed"
"io/fs"
"path"
"sort"
)
// Default pandoc HTML templates, mirrored verbatim from /pandoc/templates/ by // Pandoc HTML template and its companion stylesheet, copied verbatim from
// the top-level ./build (shared/build-lib.sh: sync_pandoc_templates). The runner // /pandoc/viewer-template.html and /pandoc/custom.css. The runner writes
// writes the chosen template + its partials to a host scratch dir on each HTML // these to a host scratch dir on each conversion and bind-mounts them
// conversion and bind-mounts them into the sandbox so pandoc can `--template` // read-only into the container so pandoc can `--template` against them.
// against them.
// //
// pandoc/templates/ is the single source of truth; this directory is a build // Refresh: when /pandoc/viewer-template.html changes, copy the new bytes
// artifact kept in sync and guarded by TestEmbeddedTemplatesMatchSource. There's // here. There's no symlink because go:embed paths must resolve under the
// no symlink because go:embed paths must resolve under the containing module, and // containing module — and we want the binary to ship the bytes verbatim,
// we want the binary to ship the bytes verbatim, not depend on the source tree at // not depend on the source tree at runtime.
// 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).
// `all:` is required so the `_`-prefixed partials (_head.html, _doc.html, //go:embed viewer-template.html
// _scripts.html) are embedded — a bare `//go:embed templates` excludes names var viewerTemplate []byte
// beginning with `_` or `.`.
//
//go:embed all:templates
var templatesFS embed.FS
// inlineMediaLua is the pandoc filter that base64-inlines images into markdown //go:embed custom.css
// output (docx→md / html→md), written to the per-call scratch dir alongside the var customCSS []byte
// 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

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

View file

@ -1,112 +0,0 @@
<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

@ -1,778 +0,0 @@
<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

@ -1,259 +0,0 @@
<!-- 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

@ -1,56 +0,0 @@
<!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

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

View file

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

View file

@ -1,71 +0,0 @@
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,15 +120,6 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
continue 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() info, err := entry.Info()
if err != nil { if err != nil {
continue continue

View file

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

View file

@ -1,65 +0,0 @@
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

@ -1,143 +0,0 @@
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

@ -1,95 +0,0 @@
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,8 +497,10 @@ func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request)
http.Error(w, msg, status) http.Error(w, msg, status)
return return
} }
// (Directory vs file is decided by stat below; the client sends a folder if strings.HasSuffix(cleanURL, "/") {
// DELETE with a trailing slash. A directory delete is admin-gated.) http.Error(w, "DELETE must target a file, not a directory", http.StatusBadRequest)
return
}
// Register rows are real files — a DELETE targets them directly with // Register rows are real files — a DELETE targets them directly with
// the normal ACL gate. (Deleting an ssr/<party>.yaml de-registers the // the normal ACL gate. (Deleting an ssr/<party>.yaml de-registers the
@ -515,32 +517,7 @@ func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request)
return return
} }
if info.IsDir() { if info.IsDir() {
// Directory delete is recursive (os.RemoveAll), which bypasses the http.Error(w, "Conflict — DELETE of directories is not supported", http.StatusConflict)
// 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 return
} }
@ -594,9 +571,10 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
http.Error(w, msg, status) http.Error(w, msg, status)
return return
} }
// (A trailing slash on src/dst signals a directory target; we no longer if strings.HasSuffix(srcURL, "/") {
// reject it here — file-vs-directory is decided by stat below, and a http.Error(w, "MOVE source must be a file path", http.StatusBadRequest)
// directory move is admin-gated.) return
}
dstHeader := r.Header.Get(headerDestination) dstHeader := r.Header.Get(headerDestination)
if dstHeader == "" { if dstHeader == "" {
@ -612,6 +590,10 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
http.Error(w, "destination: "+msg, status) http.Error(w, "destination: "+msg, status)
return 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 // A move whose destination introduces a new party folder under a
// party_source peer requires the party to be registered. // party_source peer requires the party to be registered.
if rejected, why, _ := partySourceGate(cfg.Root, dstAbs); rejected { if rejected, why, _ := partySourceGate(cfg.Root, dstAbs); rejected {
@ -637,28 +619,8 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
} }
return return
} }
isDir := srcInfo.IsDir() if srcInfo.IsDir() {
if isDir { http.Error(w, "Conflict — MOVE of directories is not supported", http.StatusConflict)
// 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 return
} }
@ -681,13 +643,9 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
if !authorizeAction(cfg, w, r, dstAbs, dstURL, policy.ActionCreate) { if !authorizeAction(cfg, w, r, dstAbs, dstURL, policy.ActionCreate) {
return 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) { if !checkIfMatch(w, r, srcAbs) {
return return
} }
}
// Ensure destination's canonical ancestors are created (with auto-own // Ensure destination's canonical ancestors are created (with auto-own
// .zddc seeding) before the rename. This lets a MOVE from working/foo // .zddc seeding) before the rename. This lets a MOVE from working/foo

View file

@ -284,34 +284,11 @@ func TestFileAPI_DeleteMissing404(t *testing.T) {
} }
} }
// Directory delete is admin-only and recursive. A non-admin (elevated but not func TestFileAPI_DeleteDirectoryConflict(t *testing.T) {
// named in admins:) is forbidden; an admin recursively removes the subtree.
func TestFileAPI_DeleteDirectoryNonAdminForbidden(t *testing.T) {
_, do, _ := fileAPITestSetup(t, []string{"Incoming/sub"}, nil) _, do, _ := fileAPITestSetup(t, []string{"Incoming/sub"}, nil)
rec := do(http.MethodDelete, "/Incoming/sub/", "alice@example.com", nil, nil) rec := do(http.MethodDelete, "/Incoming/sub", "alice@example.com", nil, nil)
if rec.Code != http.StatusForbidden { if rec.Code != http.StatusConflict {
t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String()) t.Fatalf("want 409, 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)
} }
} }
@ -342,63 +319,6 @@ 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) { func TestFileAPI_MoveDestinationExistsConflict(t *testing.T) {
_, do, _ := fileAPITestSetup(t, nil, map[string]string{ _, do, _ := fileAPITestSetup(t, nil, map[string]string{
"Incoming/a.txt": "a", "Incoming/a.txt": "a",

View file

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

View file

@ -46,25 +46,6 @@ const ElevatedKey contextKey = "elevated"
// named in admin lists. // named in admin lists.
const elevationCookieName = "zddc-elevate" 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 // ACLMiddleware extracts the user email and stores it (along with the
// policy decider) in the request context. It does NOT enforce ACL // policy decider) in the request context. It does NOT enforce ACL
// itself — each handler performs its own ACL check via // itself — each handler performs its own ACL check via
@ -117,20 +98,6 @@ func ACLMiddleware(cfg config.Config, decider policy.Decider, tokens *auth.Store
if c, err := r.Cookie(elevationCookieName); err == nil && c.Value == "1" { if c, err := r.Cookie(elevationCookieName); err == nil && c.Value == "1" {
elevated = true 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 // DEBUG-level header dump for diagnosing proxy / SSO header
// passthrough. Off by default (LogLevel info); enable with // passthrough. Off by default (LogLevel info); enable with

View file

@ -250,73 +250,3 @@ 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,15 +28,11 @@ package handler
import ( import (
_ "embed" _ "embed"
"encoding/json"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"gopkg.in/yaml.v3"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy" "codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
@ -165,17 +161,6 @@ func IsDefaultSpecAbs(fsRoot, absPath string) ([]byte, bool) {
// not name one of the recognized virtual fallback files. // not name one of the recognized virtual fallback files.
func classifyDefaultSpec(rel string) []byte { func classifyDefaultSpec(rel string) []byte {
parts := strings.Split(rel, "/") 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) { switch len(parts) {
case 4: case 4:
// <project>/<peer>/<party>/<file> — per-party register specs // <project>/<peer>/<party>/<file> — per-party register specs
@ -324,9 +309,8 @@ func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest {
specAbs := filepath.Join(dirAbs, "table.yaml") specAbs := filepath.Join(dirAbs, "table.yaml")
// Presence-based discovery: the spec in the supporting-files reserve // Presence-based discovery: <dir>/table.yaml on disk.
// (<dir>/.zddc.d/table.yaml) or, legacy, the directory root. if fileExists(specAbs) {
if fileExists(filepath.Join(dirAbs, ".zddc.d", "table.yaml")) || fileExists(specAbs) {
return &TableRequest{Name: name, SpecPath: specAbs, Dir: dirAbs} return &TableRequest{Name: name, SpecPath: specAbs, Dir: dirAbs}
} }
@ -378,77 +362,10 @@ func isNotExistError(err error) bool {
return err != nil && strings.Contains(err.Error(), "no such file or directory") return err != nil && strings.Contains(err.Error(), "no such file or directory")
} }
// LoadViewSpec resolves a config file's bytes for dir, preferring the // ServeTable serves the static tables.html bytes for a recognized
// supporting-files reserve <dir>/.zddc.d/<name>, then the legacy <dir>/<name>, // request. ACL gate is the read action at the request directory; on
// then the embedded default for this dir's shape. Returns nil when none // allow, the embedded HTML is written verbatim. The client takes over
// applies. This is the single seam that puts table/form specs under .zddc.d/ // from there — see tables/js/main.js.
// (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) { func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r *http.Request) {
p := PrincipalFromContext(r) p := PrincipalFromContext(r)
decider := DeciderFromContext(r) decider := DeciderFromContext(r)
@ -467,14 +384,7 @@ func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r *
return 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("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body) _, _ = w.Write(embeddedTablesHTML)
} }

View file

@ -184,9 +184,8 @@ func TestRecognizeTableRequest(t *testing.T) {
} }
// TestServeTable_ServesEmbeddedHTML — an ACL-passing GET returns the // TestServeTable_ServesEmbeddedHTML — an ACL-passing GET returns the
// embedded tables.html with the resolved table spec server-injected into // embedded tables.html bytes verbatim, with the empty inline context
// #table-context (the embedded default for this virtual MDL dir), so the // placeholder intact (so the client knows to walk the directory).
// client renders without a separate spec fetch.
func TestServeTable_ServesEmbeddedHTML(t *testing.T) { func TestServeTable_ServesEmbeddedHTML(t *testing.T) {
rows := map[string]string{ rows := map[string]string{
"D-001.yaml": "id: D-001\ntitle: One\nstatus: pending\n", "D-001.yaml": "id: D-001\ntitle: One\nstatus: pending\n",
@ -203,13 +202,8 @@ func TestServeTable_ServesEmbeddedHTML(t *testing.T) {
if !strings.Contains(body, `<table id="table-root"`) { if !strings.Contains(body, `<table id="table-root"`) {
t.Error("body missing #table-root markup; embedded HTML may be stale or empty") t.Error("body missing #table-root markup; embedded HTML may be stale or empty")
} }
// #table-context is no longer the empty placeholder — the resolved spec if !strings.Contains(body, `<script id="table-context" type="application/json">{}</script>`) {
// is injected (the client uses it instead of fetching table.yaml). t.Error("inline context placeholder not preserved verbatim — client expects {} so it knows to walk")
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> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <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-05 12:41:17 · 382645b</span></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>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -3691,19 +3691,13 @@ body.is-elevated::after {
// inline context (tests) or open the page through zddc-server. // inline context (tests) or open the page through zddc-server.
async function load() { async function load() {
const inline = readInlineContext(); const inline = readInlineContext();
// A fully pre-assembled context (columns + rows) is used as-is — the if (inline && Object.keys(inline).length > 0) {
// test seam, or any host that renders the whole table server-side.
if (inline && Array.isArray(inline.columns)) {
return inline; 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' && if (typeof location !== 'undefined' &&
(location.protocol === 'http:' || location.protocol === 'https:')) { (location.protocol === 'http:' || location.protocol === 'https:')) {
try { try {
const walked = await walkServer(inline || {}); const walked = await walkServer();
if (walked) { if (walked) {
return walked; return walked;
} }
@ -3735,8 +3729,7 @@ body.is-elevated::after {
el.hidden = false; el.hidden = false;
} }
async function walkServer(injected) { async function walkServer() {
injected = injected || {};
const source = window.zddc && window.zddc.source; const source = window.zddc && window.zddc.source;
if (!source) { if (!source) {
throw new Error('zddc.source not available'); throw new Error('zddc.source not available');
@ -3753,33 +3746,28 @@ body.is-elevated::after {
} }
const dir = probe.handle; const dir = probe.handle;
// Spec: prefer the server-injected #table-context.spec (sourced from // Spec lives at <currentdir>/table.yaml — the page URL is
// <dir>/.zddc.d/table.yaml). Falling back, read the spec from the // <currentdir>/table.html, so the spec is right next door.
// supporting-files reserve, then the legacy directory root — the const spec = await readYaml(dir, 'table.yaml');
// 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)) { if (!spec || !Array.isArray(spec.columns)) {
throw new Error('Spec table.yaml missing columns[]'); throw new Error('Spec table.yaml missing columns[]');
} }
// Row schema: prefer the injected #table-context.rowSchema, else read // Optional row schema from <dir>/form.yaml — same JSON Schema
// <dir>/.zddc.d/form.yaml (then legacy root). Best-effort — a table // the form-mode renderer uses. Phase 2 derives per-cell editor
// with no row schema still renders with plain-text cells. // widgets from it (text/number/date/select/checkbox).
let rowSchema = injected.rowSchema || null; // Best-effort: a directory with only table.yaml still renders
if (!rowSchema) { // as a sortable/filterable table; cells fall back to plain
// text inputs without per-property hints.
let rowSchema = null;
try { try {
const formSpec = await readYamlFirst(dir, ['.zddc.d/form.yaml', 'form.yaml']); const formSpec = await readYaml(dir, 'form.yaml');
if (formSpec && formSpec.schema) { if (formSpec && formSpec.schema) {
rowSchema = formSpec.schema; rowSchema = formSpec.schema;
} }
} catch (_) { } catch (_) {
// form.yaml missing or unreadable; carry on without it. // form.yaml missing or unreadable; carry on without it.
} }
}
// Rows are every *.yaml in <currentdir> EXCEPT the spec // Rows are every *.yaml in <currentdir> EXCEPT the spec
// (table.yaml) and the row-edit form (form.yaml). They live // (table.yaml) and the row-edit form (form.yaml). They live
@ -3837,22 +3825,6 @@ body.is-elevated::after {
return window.jsyaml.load(text); 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 // Walk a "/"-separated relative path under dir, returning the
// FileSystemFileHandle (or HttpFileHandle) at the leaf. // FileSystemFileHandle (or HttpFileHandle) at the leaf.
async function resolveFile(dir, relPath) { async function resolveFile(dir, relPath) {

View file

@ -31,19 +31,12 @@ func IsZddcFileRequest(urlPath string) bool {
// ServeZddcFile serves a directory's .zddc as a plain YAML view. // ServeZddcFile serves a directory's .zddc as a plain YAML view.
// //
// Method: GET / HEAD only — the dispatcher routes writes // 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 // 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 // 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 // Virtual: if it does not exist, the body is the cascade's
//
// leaf-level ZddcFile (what defaults.zddc.yaml's paths: // leaf-level ZddcFile (what defaults.zddc.yaml's paths:
// tree declares for THIS exact directory, plus any // tree declares for THIS exact directory, plus any
// virtual contributions threaded through by the walker) // virtual contributions threaded through by the walker)
@ -281,6 +274,7 @@ func levelURLsFor(_, dirURL string, n int) []string {
// surfacing. // surfacing.
func isZeroZddcFile(zf zddc.ZddcFile) bool { func isZeroZddcFile(zf zddc.ZddcFile) bool {
return zf.Title == "" && return zf.Title == "" &&
zf.AppsPubKey == "" &&
zf.CreatedBy == "" && zf.CreatedBy == "" &&
zf.DefaultTool == "" && zf.DefaultTool == "" &&
zf.DirTool == "" && zf.DirTool == "" &&
@ -295,8 +289,8 @@ func isZeroZddcFile(zf zddc.ZddcFile) bool {
zf.Convert == nil && zf.Convert == nil &&
len(zf.ACL.Permissions) == 0 && len(zf.ACL.Permissions) == 0 &&
len(zf.Admins) == 0 && len(zf.Admins) == 0 &&
len(zf.Apps) == 0 &&
len(zf.Tables) == 0 && len(zf.Tables) == 0 &&
len(zf.Views) == 0 &&
len(zf.Display) == 0 && len(zf.Display) == 0 &&
len(zf.Roles) == 0 && len(zf.Roles) == 0 &&
len(zf.FieldCodes) == 0 && len(zf.FieldCodes) == 0 &&

View file

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

View file

@ -92,15 +92,6 @@ type ConvertMetadata struct {
ProjectNumber string `yaml:"project_number,omitempty" json:"project_number,omitempty"` 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. // ZddcFile represents the parsed contents of a .zddc configuration file.
// //
// Admins is honored only in the root .zddc file (<ZDDC_ROOT>/.zddc); subdir // Admins is honored only in the root .zddc file (<ZDDC_ROOT>/.zddc); subdir
@ -113,13 +104,32 @@ type ViewSpec struct {
// for the project on the landing-page picker. Optional — projects without a // for the project on the landing-page picker. Optional — projects without a
// title fall back to displaying the directory name. // title fall back to displaying the directory name.
// //
// Tool HTML is resolved LOCALLY (no .zddc key): a real file on disk at the // Apps is a per-directory cascade override mapping app name → source spec.
// path → an "<app>.html" member of <ZDDC_ROOT>/.zddc.zip → the embedded // The spec is one of: "stable" / "beta" / "alpha" (channel on the canonical
// default. There is no `apps:` / `apps_pubkey:` key and no upstream fetch. // 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.
type ZddcFile struct { type ZddcFile struct {
ACL ACLRules `yaml:"acl,omitempty" json:"acl,omitempty"` ACL ACLRules `yaml:"acl,omitempty" json:"acl,omitempty"`
Admins []string `yaml:"admins,omitempty" json:"admins,omitempty"` Admins []string `yaml:"admins,omitempty" json:"admins,omitempty"`
Title string `yaml:"title,omitempty" json:"title,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"`
// Tables declares directory-of-YAML table views available at this // Tables declares directory-of-YAML table views available at this
// directory. The map key becomes the URL stem: tables[MDL] is served // directory. The map key becomes the URL stem: tables[MDL] is served
@ -206,21 +216,6 @@ type ZddcFile struct {
// Cascades leaf→root like DefaultTool. // Cascades leaf→root like DefaultTool.
DirTool string `yaml:"dir_tool,omitempty" json:"dir_tool,omitempty"` 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 // AutoOwn controls whether the file API's mkdir post-hook writes
// an auto-owned .zddc granting the creator rwcda at the new // an auto-owned .zddc granting the creator rwcda at the new
// directory. Useful for working/staging/incoming-style drafting // directory. Useful for working/staging/incoming-style drafting

View file

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

View file

@ -56,44 +56,6 @@ func DirToolAt(fsRoot, dirPath string) string {
return "browse" 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 // AutoOwnAt reports whether mkdir at THIS specific directory should
// write an auto-owned .zddc. Leaf-only lookup — auto-own does NOT // write an auto-owned .zddc. Leaf-only lookup — auto-own does NOT
// propagate to descendants (creating working/alice/notes/sub/ does // propagate to descendants (creating working/alice/notes/sub/ does
@ -413,7 +375,7 @@ func isZeroZddcFile(zf ZddcFile) bool {
if len(zf.AvailableTools) > 0 { if len(zf.AvailableTools) > 0 {
return false return false
} }
if zf.CreatedBy != "" { if zf.AppsPubKey != "" || zf.CreatedBy != "" {
return false return false
} }
if zf.Worm != nil { // non-nil even when empty — marks a WORM zone if zf.Worm != nil { // non-nil even when empty — marks a WORM zone
@ -428,7 +390,7 @@ func isZeroZddcFile(zf ZddcFile) bool {
if zf.ACL.Inherit != nil { if zf.ACL.Inherit != nil {
return false return false
} }
if len(zf.Tables) > 0 || len(zf.Views) > 0 || len(zf.Display) > 0 || len(zf.Paths) > 0 { if len(zf.Apps) > 0 || len(zf.Tables) > 0 || len(zf.Display) > 0 || len(zf.Paths) > 0 {
return false return false
} }
if len(zf.Roles) > 0 { if len(zf.Roles) > 0 {

View file

@ -45,48 +45,6 @@ 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 // TestHistoryAt_Defaults — edit-history defaults on for the live-editing
// peers working/mdl/rsk and the ssr registry (subtree-inheriting). The // peers working/mdl/rsk and the ssr registry (subtree-inheriting). The
// other peers and the WORM archive do not get history. // other peers and the WORM archive do not get history.

View file

@ -5,16 +5,24 @@ import (
"strings" "strings"
) )
// AppNames is the canonical set of app HTML files the server can serve // AppNames is the canonical set of app HTML files the server resolves
// (from disk, the site .zddc.zip bundle, or the embedded default). Order // via the apps fetch+cache subsystem. Order is stable for reproducible
// is stable for reproducible rendering. // admin-UI rendering.
// //
// All seven HTML tools belong here — including browse, form, and tables. // 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 // Markdown editing used to be a dedicated tool ("mdedit"); it now
// lives as a plugin inside browse (browse/js/preview-markdown.js). // lives as a plugin inside browse (browse/js/preview-markdown.js).
var AppNames = []string{"archive", "transmittal", "classifier", "landing", "browse", "form", "tables"} 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. // IsKnownApp reports whether name is one of the canonical apps.
func IsKnownApp(name string) bool { func IsKnownApp(name string) bool {
for _, n := range AppNames { for _, n := range AppNames {
@ -25,6 +33,12 @@ func IsKnownApp(name string) bool {
return false 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 // ValidatePattern returns an error if pattern is not a syntactically
// well-formed email-glob. The matcher in MatchesPattern is forgiving and // well-formed email-glob. The matcher in MatchesPattern is forgiving and
// will silently fail to match malformed patterns (e.g., "alice@@x" or // will silently fail to match malformed patterns (e.g., "alice@@x" or
@ -106,6 +120,101 @@ func ValidateProjectName(name string) error {
return nil 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 { func ValidateFile(zf ZddcFile) []FieldError {
var errs []FieldError var errs []FieldError
check := func(field string, vals []string) { check := func(field string, vals []string) {
@ -133,25 +242,21 @@ func ValidateFile(zf ZddcFile) []FieldError {
Message: "title exceeds 200 characters", Message: "title exceeds 200 characters",
}) })
} }
// views: each entry names a known tool and (optionally) a config file for app, spec := range zf.Apps {
// resolved under <dir>/.zddc.d/ — so it must be a safe relative filename if !IsValidAppsKey(app) {
// (no slashes, no traversal, no leading dot).
for shape, v := range zf.Views {
if v.Tool == "" || !IsKnownApp(v.Tool) {
errs = append(errs, FieldError{ errs = append(errs, FieldError{
Field: fmt.Sprintf("views.%s.tool", shape), Field: fmt.Sprintf("apps.%s", app),
Message: fmt.Sprintf("unknown tool %q (known: %s)", v.Tool, strings.Join(AppNames, ", ")), Message: fmt.Sprintf("unknown app %q (known: default, archive, transmittal, classifier, landing, browse, form, tables)", app),
}) })
continue
} }
if v.Config != "" { if err := ValidateAppSourceSpec(spec); err != nil {
if strings.ContainsAny(v.Config, "/\\") || v.Config == "." || v.Config == ".." || strings.HasPrefix(v.Config, ".") {
errs = append(errs, FieldError{ errs = append(errs, FieldError{
Field: fmt.Sprintf("views.%s.config", shape), Field: fmt.Sprintf("apps.%s", app),
Message: "config must be a plain filename (resolved under .zddc.d/); no slashes, traversal, or leading dot", Message: err.Error(),
}) })
} }
} }
}
// worm: is a list of principal patterns (email-globs, @role:name, // worm: is a list of principal patterns (email-globs, @role:name,
// or bare role names) that get write-once-create inside the WORM // or bare role names) that get write-once-create inside the WORM
// zone. Validate each as an email-glob unless it's a role // zone. Validate each as an email-glob unless it's a role

View file

@ -70,6 +70,125 @@ 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) { func TestValidateProjectName(t *testing.T) {
cases := []struct { cases := []struct {
name string name string

View file

@ -57,6 +57,9 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
if top.Title != "" { if top.Title != "" {
out.Title = top.Title out.Title = top.Title
} }
if top.AppsPubKey != "" {
out.AppsPubKey = top.AppsPubKey
}
if top.CreatedBy != "" { if top.CreatedBy != "" {
out.CreatedBy = top.CreatedBy out.CreatedBy = top.CreatedBy
} }
@ -121,22 +124,10 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
} }
out.ACL.Permissions = mergeStringMap(out.ACL.Permissions, top.ACL.Permissions) 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.Tables = mergeStringMap(out.Tables, top.Tables)
out.Display = mergeStringMap(out.Display, top.Display) 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 // Convert: per-key latest-wins. Pointer-to-struct so we can tell
// "absent" from "explicitly empty" — the latter is rare but valid // "absent" from "explicitly empty" — the latter is rare but valid
// (an operator who wants to suppress a deployment-default value). // (an operator who wants to suppress a deployment-default value).