Compare commits
21 commits
d524966f00
...
509839dba9
| Author | SHA1 | Date | |
|---|---|---|---|
| 509839dba9 | |||
| 382645b2d2 | |||
| 16d88010a6 | |||
| 894610d59e | |||
| c7ab633653 | |||
| 1d816ae43a | |||
| c765fe9183 | |||
| c59bea183e | |||
| 9513ea3a07 | |||
| d10cd23076 | |||
| 613092b30e | |||
| ee371c5bb2 | |||
| 3e7aa34e49 | |||
| 03fa366814 | |||
| 45af24b2b1 | |||
| 760cba96c4 | |||
| 4e86b1533d | |||
| 4eeb25c0ef | |||
| 198d691518 | |||
| e2179d167b | |||
| 8edbb81958 |
96 changed files with 8159 additions and 7355 deletions
16
AGENTS.md
16
AGENTS.md
|
|
@ -289,13 +289,13 @@ No install script. Two paths:
|
|||
- **Local** — download a tool `.html` from `https://zddc.varasys.io/releases/` and open it. Done.
|
||||
- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded: the baked-in baseline (`zddc/internal/zddc/defaults.zddc.yaml`, dumpable via `zddc-server show-defaults`) declares, via a recursive `paths:` tree, a `default_tool` (the no-slash form: `archive` at `archive/`, `transmittal` at `archive/<party>/staging/`, `browse` at `archive/<party>/{working,reviewing}/` (browse hosts the markdown editor), `classifier` at `archive/<party>/incoming/`, `tables` at `archive/<party>/{mdl,rsk}` and at the project-level `ssr/mdl/rsk` virtual rollups, `landing` at the deployment root) and `available_tools` (which tools may be auto-served / offered) per folder. **Project shape (May 2026 reshape):** `archive/` is the only physical project-root directory. Six top-level URLs are virtual aggregators: `ssr/mdl/rsk` (tables row-rollups across parties, with a synthesised `$party` source-party column the tables tool renders read-only and strips before write) and `working/staging/reviewing` (browse folder-nav listings of parties with non-empty content; per-party URLs 302-redirect to `archive/<party>/<slot>/`). Mkdir directly at the project root is restricted to `archive` and `_`/`.`-prefixed system names — virtual aggregator names and ad-hoc folders return 409. The trailing-slash form serves `dir_tool` (defaults to `browse`). See `internal/apps/availability.go` (`DefaultAppAt`, `AppAvailableAt`) and `internal/zddc/lookups.go` (`DefaultToolAt`, `DirToolAt`, `AvailableToolsAt`); the dispatcher chokepoint is `serveSpecializedNoSlash` in `cmd/zddc-server/main.go`. Where the cascade declares no tool, requesting `<app>.html` returns 404 like any other missing file. **The full canonical-folder convention (auto-own, WORM, virtual folders, standard roles) is documented in ARCHITECTURE.md § "Canonical folders, URL routing & the `.zddc` cascade".**
|
||||
|
||||
To override at any level, either:
|
||||
To override a tool's HTML (local-only — no fetch, no channels/versions):
|
||||
1. Drop a real `<app>.html` file at the path → static handler serves it (highest priority).
|
||||
2. Write an `apps:` entry in any `.zddc` along the path. Spec is one of `stable` (canonical "latest stable"), `v0.0.4` (exact-version pin), full URL, or local path. Closer-to-leaf entries win. (Or change `default_tool` / `dir_tool` / `available_tools` to route a different tool entirely.)
|
||||
2. Add an `<app>.html` member to the site bundle `<ZDDC_ROOT>/.zddc.zip` (a local zip read server-side via `internal/zipfs`; overrides that tool everywhere, and lets you add new `<name>.html` tools). To route a *different* tool at a path, change `default_tool` / `dir_tool` / `available_tools`.
|
||||
|
||||
URL sources fetch once and cache forever in `<ZDDC_ROOT>/_app/<host>/<path>`. To force a re-fetch, delete the cache file. No background refresh, no SHA-256 verification, no admin UI. If a configured URL fetch fails, the server falls back to the embedded copy and emits a one-time WARN log.
|
||||
Otherwise the embedded build-time copy is served. There is no `apps:` `.zddc` key, no upstream fetch, and no signature verification (all removed). `.zddc.zip` is config, not content: a direct `GET /.zddc.zip` is 404 for everyone; the server reads its members from the filesystem internally.
|
||||
|
||||
Operators audit by reading the `X-ZDDC-Source` response header: `fetch:URL` / `cache:URL` / `path:/abs` / `embedded:<app>@<build>`. Direct URL access to `/_app/...` is blocked at the dispatch layer.
|
||||
Operators audit by reading the `X-ZDDC-Source` response header: `bundle:<app>.html` / `embedded:<app>@<build>` (an on-disk override is served by the static handler with its own headers).
|
||||
|
||||
**Runtime mode detection** in archive is independent of install: it auto-detects multi-project / project-root / in-archive from `?projects=` plus folder shape. The other tools don't care where they live.
|
||||
|
||||
|
|
@ -350,6 +350,7 @@ Why this shape: swapping isolation strategies (firejail, systemd-nspawn, podman-
|
|||
- I/O via stdin/stdout + scratch dir. Pandoc reads markdown from stdin, writes to stdout. Templates + intermediate HTML + output PDF live in a per-call subdir under the scratch root; the dir's host path is passed to the child via `ZDDC_SCRATCH` so the wrapper bind-mounts it into the sandbox at the same path (no path translation).
|
||||
- Output cached at `<dir>/.converted/<base>.<ext>` (hidden by the `.` prefix). mtime synced to source so the fast path is a stat-and-serve with no exec. PUT/DELETE/MOVE on the source `.md` purges the sidecars.
|
||||
- Per-project template variables (client/project/contractor/project_number) come from `.zddc` `convert:` cascade keys. Title/tracking_number/revision/status are derived from the filename via `zddc.ParseFilename`.
|
||||
- **HTML/PDF templates** are named doctype files — `report`, `letter`, `specification` — plus shared partials (`_head.html`, `_doc.html`, `_scripts.html`), living in `pandoc/templates/` (single source of truth; `./build` mirrors them into `zddc/internal/convert/templates/` for `//go:embed`, guarded by `convert.TestEmbeddedTemplatesMatchSource`). A document picks one with `template: <name>` in its YAML front matter (default `report`) and turns on legal heading numbering with `numbering: true` (default off) — both flow straight from the front matter to the template, no converter code. The handler resolves overrides from the `.zddc.d/templates/<name>.html` cascade (`resolveTemplateSet` in `converttemplate.go`): a nearer level (`working/<party>/.zddc.d/templates/`) overrides a farther one (`working/.zddc.d/templates/`), which overrides the embedded default; an override may replace a doctype, a partial, or add a new doctype. NOTE: the per-doc converted cache keys on source mtime only, so editing a template override doesn't invalidate already-cached HTML — purge `.zddc.d/converted/` or touch the source to re-render.
|
||||
- If pandoc/chromium aren't on PATH (operator running zddc-server outside the runtime image), the endpoint serves 503 with a Retry-After. The rest of the server keeps working. Operators who run zddc-server with raw pandoc/chromium (no wrapper) get a working but unsandboxed conversion endpoint — useful for dev iteration.
|
||||
|
||||
## Form-data system (`form/` + zddc-server form handler)
|
||||
|
|
@ -421,7 +422,7 @@ The "records" subset of the tables system carries three guarantees the generic f
|
|||
|
||||
**Two new `.zddc` keys** carry the rules (see `zddc/internal/zddc/file.go` + `field_codes.go`):
|
||||
|
||||
- `field_codes:` — vocabulary for the components used in filename composition and constrained body fields. Each entry is a discriminated union over `kind: enum|pattern|free` (`{kind: enum, codes: {ACM: Acme Inc, …}}` / `{kind: pattern, pattern: "^[0-9]{4}$"}` / `{kind: free, description: "..."}`). Map-merge across the cascade (mirror of `apps:`) — a deeper level can narrow or replace a single code's vocabulary without dropping unrelated codes.
|
||||
- `field_codes:` — vocabulary for the components used in filename composition and constrained body fields. Each entry is a discriminated union over `kind: enum|pattern|free` (`{kind: enum, codes: {ACM: Acme Inc, …}}` / `{kind: pattern, pattern: "^[0-9]{4}$"}` / `{kind: free, description: "..."}`). Map-merge across the cascade (like `display:`/`tables:`) — a deeper level can narrow or replace a single code's vocabulary without dropping unrelated codes.
|
||||
- `records:` — per-pattern rules keyed by filename basename (literal `ssr.yaml` or glob `*.yaml`). Each entry carries `filename_format` (composition template with `{field}` and `{field?}` placeholders), `field_defaults`, `locked`, `folder_fields`, plus `row_field` + `row_scope_fields` for RSK-style tables-of-rows. Filename-pattern scoping is what lets the SSR rule live at the party-folder level without affecting `mdl/`, `rsk/`, `received/`, etc., siblings.
|
||||
- `folder_fields:` — map of `field → parent-distance` that binds a body field to an ancestor folder name (the folder is the sole source of truth). The map value is how many directories ABOVE the record's own directory the source folder sits (`originator: 1` under `archive/<party>/mdl/` resolves to the `<party>` folder). The server overwrites the body field with the derived name before validation + composition (so a client value can never disagree; a mismatched URL still trips the `filename_format` check), and the form renderer marks the field read-only and pre-fills it.
|
||||
|
||||
|
|
@ -538,7 +539,7 @@ Pick a role per persona:
|
|||
|
||||
These are NOT interchangeable. A note about which one operators want lives in `cascade.go:13-21` (the `PolicyChain` doc) and the relevant struct fields in `file.go`.
|
||||
|
||||
Run `zddc-server show-defaults` to dump the embedded `defaults.zddc.yaml` with annotated comments — that's the full schema with all the cascade keys (`worm:`, `auto_own:`, `drop_target:`, `apps:`, `convert:`, `on_plan_review:`, `records:`, `available_tools:`, `default_tool:`, `dir_tool:`, etc.).
|
||||
Run `zddc-server show-defaults` to dump the embedded `defaults.zddc.yaml` with annotated comments — that's the full schema with all the cascade keys (`worm:`, `auto_own:`, `drop_target:`, `convert:`, `on_plan_review:`, `records:`, `available_tools:`, `default_tool:`, `dir_tool:`, etc.).
|
||||
|
||||
### Build
|
||||
|
||||
|
|
@ -634,14 +635,13 @@ ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \
|
|||
| `ZDDC_OPA_URL` | `internal` | Policy decider endpoint. `internal` (default) = in-process Go evaluator (same `.zddc` cascade we always had). `http(s)://...` or `unix:///...` = external OPA — every access decision becomes a `POST /v1/data/zddc/access/allow` to the configured endpoint. Federal customers with their own audited Rego use this; commercial deployments leave it `internal`. |
|
||||
| `ZDDC_OPA_FAIL_OPEN` | *(empty)* | External OPA only. `1` = allow on transport error; default = fail closed (deny). |
|
||||
| `ZDDC_OPA_CACHE_TTL` | `1s` | External OPA only. Per-decision cache TTL — amortizes round-trips on bursty patterns (e.g. `.archive` listings hit the same `(email, dir)` tuple many times). `0` disables. Format is Go `time.ParseDuration`. |
|
||||
| `ZDDC_APPS_PUBKEY` | *(empty)* | Path to PEM Ed25519 pubkey for verifying signatures on URL-fetched `apps:` artifacts. Empty = URL apps refused. Download from `zddc.varasys.io/pubkey.pem` (canonical channels) or supply your own. No baked-in default — same posture as TLS certs. Alternative inline form: `apps_pubkey:` in root `.zddc` (root-only, env/flag wins). |
|
||||
| `ZDDC_ACCESS_LOG` | `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log` | JSON-line audit log (lumberjack-rotated, 100 MB / 10 backups / 90 days, gzipped). Server auto-mkdirs the parent. Set explicitly to empty (`--access-log=`) to disable. Per-host filename + `host` field in every record so multi-replica deployments writing to the same `.zddc.d/` dir disambiguate cleanly. |
|
||||
|
||||
### URL handling
|
||||
|
||||
**URLs are case-insensitive.** The dispatcher canonicalizes `r.URL.Path` against on-disk casing before any handler runs (`zddc/internal/fs/resolve.go ResolveCanonical`). Per segment: lowercase variant wins if it exists on disk; otherwise exact-case wins; otherwise readdir+CI scan with the lowercase variant winning the tiebreak when multiple case variants are siblings on disk. Walk stops at the first segment that doesn't exist so virtual prefixes (`.archive`, `.profile`, `.tokens`, `.api`, `.auth`) and 404 paths flow through with their tail preserved verbatim.
|
||||
|
||||
**File and folder names preserve case on disk.** The canonicalization is purely a URL→filesystem-name mapping; nothing renames anything. Lowercase is the *project-wide canonical* convention, and auto-created folders in `internal/zddc/ensure.go` (the per-party `archive/<party>/{working,staging,reviewing,incoming}/`) and the server's own state dirs (`_app/`, `.zddc.d/tokens/`, `.zddc.d/outbox/`, `.zddc.d/logs/`) are all lowercase by string literal. Operators can drop a `Mixed-Case-Folder/` and it stays mixed-case.
|
||||
**File and folder names preserve case on disk.** The canonicalization is purely a URL→filesystem-name mapping; nothing renames anything. Lowercase is the *project-wide canonical* convention, and auto-created folders in `internal/zddc/ensure.go` (the per-party `archive/<party>/{working,staging,reviewing,incoming}/`) and the server's own state dirs (`.zddc.d/tokens/`, `.zddc.d/outbox/`, `.zddc.d/logs/`) are all lowercase by string literal. Operators can drop a `Mixed-Case-Folder/` and it stays mixed-case.
|
||||
|
||||
**Audit log captures the as-typed path.** `AccessLogMiddleware` snapshots `r.URL.Path` before dispatch rewrites it; the audit record's `path` field is what the client sent. When canonicalization changed it, a `resolved_path` field is added.
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ Each topic has exactly one authoritative home; everything else links to it.
|
|||
| What ZDDC is + tool channel links + dual-mode (local/server) overview + install snippets | `~/src/zddc-website/index.html` (hand-edited intro for `zddc.varasys.io/`, in the `ZDDC-website` repo) | repo `README.md`, `zddc/README.md` |
|
||||
| File-naming convention spec (status codes, modifiers, folder format) | `~/src/zddc-website/reference.html` | repo `README.md`, in-tool help text |
|
||||
| Versions + channel builds index of every tool | `dist/release-output/index.html` (regenerated by `./build`; deployed to `/srv/zddc/releases/index.html`) | website intro nav, "Browse all versions" link |
|
||||
| Customer-deployment install (`zddc-server` binary embeds current-stable tools; `.zddc apps:` cascade overrides; cache at `<root>/_app/`) | `zddc/README.md` "Apps: virtual tool HTMLs" section | website intro, `AGENTS.md` |
|
||||
| Customer-deployment install (`zddc-server` binary embeds current-stable tools; local override via an on-disk `<app>.html` or the site `<root>/.zddc.zip` bundle — no fetch) | `zddc/README.md` "Apps: virtual tool HTMLs" section | website intro, `AGENTS.md` |
|
||||
| zddc-server operations: env vars, ACL syntax, `.archive` URLs, container vs binary | `zddc/README.md` | `AGENTS.md`, website intro |
|
||||
| Build / release / channel commands | `AGENTS.md` | repo `README.md` ("see AGENTS.md") |
|
||||
| Architecture & internal patterns | `ARCHITECTURE.md` (this file) | `AGENTS.md` |
|
||||
|
|
@ -154,13 +154,13 @@ Two orthogonal axes: how the bytes get there (this section), and what runtime mo
|
|||
|
||||
Resolution order at a request to `<dir>/<app>.html` where the app is available:
|
||||
|
||||
1. **Override** — real `.html` file at the path → static handler.
|
||||
2. **`.zddc apps:` cascade** — walk leaf→root for an `apps.<app>` entry. Spec is `stable` (canonical "current stable"), `v0.0.4` (exact-version pin), full URL (custom mirror), or local path. Closer-to-leaf wins.
|
||||
1. **On-disk override** — real `.html` file at the path → static handler.
|
||||
2. **Site bundle** — an `<app>.html` member of `<ZDDC_ROOT>/.zddc.zip`, read server-side via `internal/zipfs` (see `internal/apps/bundle.go`). Local file, no fetch, no signature; re-stat'd each request for free hot-reload.
|
||||
3. **Embedded** — the build-time HTML compiled into the binary.
|
||||
|
||||
URL sources fetch once on first request and cache forever in `<ZDDC_ROOT>/_app/<host>/<path>`. There is no background refresh, no SHA-256 verification, no admin UI. To pull a new build, delete the cache file. Concurrent misses for the same URL share one outbound fetch (hand-rolled singleflight). Failed fetches fall through to embedded with a one-time WARN log per source URL. Direct URL access to `/_app/...` is blocked at dispatch.
|
||||
Resolution is LOCAL-ONLY — no network fetch, no signatures, no channels/versions, and no `apps:` `.zddc` key (all removed in favour of this model). `.zddc.zip` is config, not content: a direct `GET /.zddc.zip` is 404 for everyone, while the server reads its members from the filesystem internally. To change a tool's HTML: drop a file at the path, add `<app>.html` to `.zddc.zip`, or rebuild the binary.
|
||||
|
||||
The `X-ZDDC-Source` response header always reports what was served: `fetch:URL`, `cache:URL`, `path:/abs`, or `embedded:<app>@<build>`.
|
||||
The `X-ZDDC-Source` response header always reports what was served: `bundle:<app>.html`, `embedded:<app>@<build>`, or (for an on-disk override) the static handler's own headers.
|
||||
|
||||
### Runtime mode detection
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,8 @@ concat_files \
|
|||
"../shared/zddc-source.js" \
|
||||
"js/init.js" \
|
||||
"js/util.js" \
|
||||
"js/conflict.js" \
|
||||
"js/menu-model.js" \
|
||||
"js/loader.js" \
|
||||
"js/tree.js" \
|
||||
"js/preview.js" \
|
||||
|
|
|
|||
|
|
@ -324,6 +324,68 @@ body {
|
|||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Per-row "⋯" actions button — the visible affordance that a row has a
|
||||
context menu. Hidden until the row is hovered/selected or the button
|
||||
itself is keyboard-focused, so it stays out of the way during reading
|
||||
but is discoverable without knowing to right-click. Pushed to the right
|
||||
edge; never part of the tab order (rows use roving tabindex). */
|
||||
.tree-row__kebab {
|
||||
margin-left: auto;
|
||||
align-self: flex-start;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted, #888);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s, background 0.1s, color 0.1s;
|
||||
}
|
||||
.tree-row__kebab svg { width: 1em; height: 1em; }
|
||||
.tree-row:hover .tree-row__kebab,
|
||||
.tree-row.is-selected .tree-row__kebab,
|
||||
.tree-row__kebab:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.tree-row__kebab:hover,
|
||||
.tree-row__kebab:focus-visible {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Tree-pane toolbar controls row (New folder/file, Sort, Show hidden),
|
||||
sitting under the filter input. */
|
||||
.tree-pane__controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.tree-pane__controls .tp-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
.tree-pane__controls .tp-control--check { cursor: pointer; }
|
||||
.tree-pane__controls select {
|
||||
font-family: var(--font);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.15rem 0.3rem;
|
||||
}
|
||||
|
||||
/* Per-row drop target highlight: applied while a file/folder drag is
|
||||
hovering this row. The dashed outline reads as "drop here" without
|
||||
shifting layout. */
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@
|
|||
tree.setRoot(detected.entries);
|
||||
events.showBrowseRoot();
|
||||
tree.render();
|
||||
if (events.prefetchScopeAccess) events.prefetchScopeAccess();
|
||||
events.statusInfo('Loaded ' + detected.entries.length + ' item'
|
||||
+ (detected.entries.length === 1 ? '' : 's')
|
||||
+ ' from ' + detected.path);
|
||||
|
|
@ -133,6 +134,7 @@
|
|||
window.app.state.lastPreviewedNodeId = null;
|
||||
tree.setRoot(es);
|
||||
tree.render();
|
||||
if (events.prefetchScopeAccess) events.prefetchScopeAccess();
|
||||
// Route through clearPreview so a live editor is disposed
|
||||
// (not leaked) when back/forward swaps scope.
|
||||
var pmod = window.app.modules.preview;
|
||||
|
|
|
|||
203
browse/js/conflict.js
Normal file
203
browse/js/conflict.js
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
// conflict.js — shared conflict-resolution dialog for the browse tool.
|
||||
//
|
||||
// Surfaced when a save loses an optimistic-concurrency race: the file
|
||||
// changed on the server since the user loaded it (the editor sends an
|
||||
// If-Match precondition; the master replies 412). Rather than clobber the
|
||||
// other writer, the editor opens this dialog showing a mine-vs-theirs diff
|
||||
// and four choices.
|
||||
//
|
||||
// Deliberately CALLBACK-DRIVEN: it never calls saveFile / showFilePreview
|
||||
// itself — the caller supplies onOverwrite / onReload / onSaveCopy. That
|
||||
// keeps it reusable by a second consumer (the deferred Phase 5 cache-outbox
|
||||
// conflict UI, which would resolve `.zddc-outbox/<id>.conflict-<ts>/` entries
|
||||
// against new server endpoints rather than the live file).
|
||||
//
|
||||
// Reuses the modal shell + diff markup conventions from history.js and the
|
||||
// shared css/history.css classes (md-history-*, md-diff-*) — no new CSS.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (!window.app || !window.app.modules) return;
|
||||
|
||||
function toast(msg, level) {
|
||||
if (window.zddc && typeof window.zddc.toast === 'function') {
|
||||
window.zddc.toast(msg, level || 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// Render a line diff of base→mine into `pane` (theirs treated as the
|
||||
// base, so additions are what this save would introduce). Mirrors the
|
||||
// history.js diff view.
|
||||
function renderDiff(pane, theirsText, mineText) {
|
||||
pane.innerHTML = '';
|
||||
var ops = (window.zddc && window.zddc.diff)
|
||||
? window.zddc.diff.lines(theirsText, mineText)
|
||||
: null;
|
||||
var diff = document.createElement('div');
|
||||
diff.className = 'md-diff';
|
||||
if (!ops) {
|
||||
diff.textContent = 'Diff unavailable (diff module not loaded).';
|
||||
pane.appendChild(diff);
|
||||
return;
|
||||
}
|
||||
var unchanged = true;
|
||||
ops.forEach(function (op) {
|
||||
if (op.type !== 'eq') unchanged = false;
|
||||
var line = document.createElement('div');
|
||||
line.className = 'md-diff-line md-diff-' + op.type;
|
||||
var g = document.createElement('span');
|
||||
g.className = 'md-diff-gutter';
|
||||
g.textContent = op.type === 'add' ? '+' : (op.type === 'del' ? '-' : ' ');
|
||||
var t = document.createElement('span');
|
||||
t.className = 'md-diff-text';
|
||||
t.textContent = op.text;
|
||||
line.appendChild(g);
|
||||
line.appendChild(t);
|
||||
diff.appendChild(line);
|
||||
});
|
||||
if (unchanged) {
|
||||
var same = document.createElement('div');
|
||||
same.className = 'md-diff-line md-diff-eq';
|
||||
same.textContent = '(no differences — your copy matches the server)';
|
||||
diff.appendChild(same);
|
||||
}
|
||||
pane.appendChild(diff);
|
||||
var s = window.zddc.diff.stats(ops);
|
||||
var stat = document.createElement('p');
|
||||
stat.className = 'md-history-hint';
|
||||
stat.textContent = 'Your version vs. current server: +' + s.added + ' / −' + s.removed;
|
||||
pane.appendChild(stat);
|
||||
}
|
||||
|
||||
// open(opts) → Promise<'overwrite' | 'reload' | 'savecopy' | 'cancel'>
|
||||
//
|
||||
// opts:
|
||||
// filename — display name (e.g. node.name)
|
||||
// mineText — the user's current (unsaved) content, for the diff
|
||||
// theirsText — current server content (string), OR…
|
||||
// fetchTheirs — async () => string — lazy fetch of current server content
|
||||
// onOverwrite — async () => void — re-save, forcing past the conflict
|
||||
// onReload — async () => void — discard mine, reload from server
|
||||
// onSaveCopy — async () => void — write mine to a sibling path (optional)
|
||||
//
|
||||
// The matching callback runs when its button is clicked; on success the
|
||||
// dialog closes and resolves with the action name. On callback error the
|
||||
// dialog stays open (a toast explains) so the user can pick another path.
|
||||
// Cancel / Esc / backdrop resolve 'cancel' and leave the editor untouched.
|
||||
function open(opts) {
|
||||
opts = opts || {};
|
||||
return new Promise(function (resolve) {
|
||||
var overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay md-history-overlay';
|
||||
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;';
|
||||
|
||||
var box = document.createElement('div');
|
||||
box.className = 'md-history-box';
|
||||
var title = document.createElement('h2');
|
||||
title.className = 'md-history-title';
|
||||
title.textContent = 'Conflict — ' + (opts.filename || 'file');
|
||||
var body = document.createElement('div');
|
||||
body.className = 'md-history-body';
|
||||
box.appendChild(title);
|
||||
box.appendChild(body);
|
||||
overlay.appendChild(box);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
var settled = false;
|
||||
function close() {
|
||||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
}
|
||||
function finish(result) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
close();
|
||||
resolve(result);
|
||||
}
|
||||
function onKey(e) { if (e.key === 'Escape') finish('cancel'); }
|
||||
document.addEventListener('keydown', onKey);
|
||||
overlay.addEventListener('mousedown', function (e) {
|
||||
if (e.target === overlay) finish('cancel');
|
||||
});
|
||||
|
||||
var hint = document.createElement('p');
|
||||
hint.className = 'md-history-hint';
|
||||
hint.textContent = '"' + (opts.filename || 'This file')
|
||||
+ '" was changed by someone else since you opened it. '
|
||||
+ 'Pick how to resolve — nothing is saved until you choose.';
|
||||
body.appendChild(hint);
|
||||
|
||||
var diffPane = document.createElement('div');
|
||||
diffPane.textContent = 'Loading current server version…';
|
||||
body.appendChild(diffPane);
|
||||
|
||||
var footer = document.createElement('div');
|
||||
footer.className = 'md-history-footer';
|
||||
body.appendChild(footer);
|
||||
|
||||
function makeBtn(label, primary) {
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.textContent = label;
|
||||
if (primary) b.className = 'btn-primary';
|
||||
footer.appendChild(b);
|
||||
return b;
|
||||
}
|
||||
var overwriteBtn = makeBtn('Overwrite (keep mine)');
|
||||
var reloadBtn = makeBtn('Discard mine — reload theirs');
|
||||
var copyBtn = opts.onSaveCopy ? makeBtn('Save a copy') : null;
|
||||
var cancelBtn = makeBtn('Cancel', true);
|
||||
|
||||
function setBusy(busy) {
|
||||
[overwriteBtn, reloadBtn, copyBtn, cancelBtn].forEach(function (b) {
|
||||
if (b) b.disabled = busy;
|
||||
});
|
||||
}
|
||||
|
||||
// Each action runs its callback; on success close+resolve, on
|
||||
// error toast and re-enable so the user can try another path.
|
||||
function wire(btn, fn, result) {
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function () {
|
||||
setBusy(true);
|
||||
Promise.resolve()
|
||||
.then(function () { return fn ? fn() : undefined; })
|
||||
.then(function () { finish(result); })
|
||||
.catch(function (e) {
|
||||
toast('Could not ' + result + ': ' + (e && e.message ? e.message : e), 'error');
|
||||
setBusy(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
wire(overwriteBtn, opts.onOverwrite, 'overwrite');
|
||||
wire(reloadBtn, opts.onReload, 'reload');
|
||||
wire(copyBtn, opts.onSaveCopy, 'savecopy');
|
||||
cancelBtn.addEventListener('click', function () { finish('cancel'); });
|
||||
|
||||
// Resolve the "theirs" text (eagerly provided or lazily fetched)
|
||||
// then render the diff. A fetch failure leaves the actions usable
|
||||
// — the diff is an aid, not a gate.
|
||||
Promise.resolve()
|
||||
.then(function () {
|
||||
if (typeof opts.theirsText === 'string') return opts.theirsText;
|
||||
if (opts.fetchTheirs) return opts.fetchTheirs();
|
||||
return null;
|
||||
})
|
||||
.then(function (theirs) {
|
||||
if (settled) return;
|
||||
if (theirs == null) {
|
||||
diffPane.textContent = 'Could not load the current server version for comparison.';
|
||||
return;
|
||||
}
|
||||
renderDiff(diffPane, theirs, opts.mineText || '');
|
||||
})
|
||||
.catch(function (e) {
|
||||
if (settled) return;
|
||||
diffPane.textContent = 'Could not load the current server version: '
|
||||
+ (e && e.message ? e.message : e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.app.modules.conflict = { open: open };
|
||||
})();
|
||||
|
|
@ -183,8 +183,35 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Export a file converted to another format. Server-only: builds the
|
||||
// sibling-extension URL (foo.docx → foo.md) and lets the browser pull it —
|
||||
// zddc-server recognises the virtual path and converts on the fly, emitting
|
||||
// Content-Disposition. fmt is a bare extension ("md" | "docx" | "html").
|
||||
function exportFile(node, fmt) {
|
||||
if (!node || node.isDir) {
|
||||
events().statusError('Not a file: ' + (node && node.name));
|
||||
return;
|
||||
}
|
||||
if (state.source !== 'server') {
|
||||
events().statusError('Export to .' + fmt + ' needs a server connection');
|
||||
return;
|
||||
}
|
||||
var tree = window.app.modules.tree;
|
||||
var path = tree && tree.pathFor ? tree.pathFor(node) : node.url;
|
||||
if (!path) {
|
||||
events().statusError('No path for ' + node.name);
|
||||
return;
|
||||
}
|
||||
var url = path.replace(/\.[^./]+$/, '') + '.' + fmt;
|
||||
var name = node.name.replace(/\.[^./]+$/, '') + '.' + fmt;
|
||||
events().statusInfo('Exporting ' + name + '…');
|
||||
downloadUrl(name, url);
|
||||
setTimeout(function () { events().statusClear(); }, 2500);
|
||||
}
|
||||
|
||||
window.app.modules.download = {
|
||||
downloadFile: downloadFile,
|
||||
downloadFolder: downloadFolder
|
||||
downloadFolder: downloadFolder,
|
||||
exportFile: exportFile
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -88,6 +88,21 @@
|
|||
refresh.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
// Toolbar New buttons: enabled when there's a writable target, and in
|
||||
// server mode greyed (with a why-tooltip) when the scope lacks the
|
||||
// create verb. Mirrors the menu's create-gate.
|
||||
var canCreate = canCreateHere();
|
||||
var lacksCreateVerb = state.source === 'server'
|
||||
&& state.scopeAccess && typeof state.scopeAccess.path_verbs === 'string'
|
||||
&& state.scopeAccess.path_verbs.indexOf('c') === -1;
|
||||
['newFolderBtn', 'newFileBtn'].forEach(function (id) {
|
||||
var b = document.getElementById(id);
|
||||
if (!b) return;
|
||||
var off = !canCreate || lacksCreateVerb;
|
||||
b.disabled = off;
|
||||
b.title = lacksCreateVerb ? 'You don’t have create access here.'
|
||||
: (!canCreate ? 'Open a folder to create files here.' : '');
|
||||
});
|
||||
}
|
||||
|
||||
// syncURLToSelection reflects the current scope + selected node +
|
||||
|
|
@ -165,6 +180,7 @@
|
|||
await tree.restoreState(snap);
|
||||
if (!isCurrentNav(seq)) return;
|
||||
tree.render();
|
||||
prefetchScopeAccess();
|
||||
statusInfo('Refreshed (' + raw.length + ' item'
|
||||
+ (raw.length === 1 ? '' : 's') + ')');
|
||||
} else if (state.source === 'fs' && state.rootHandle) {
|
||||
|
|
@ -185,6 +201,23 @@
|
|||
}
|
||||
|
||||
function init() {
|
||||
// Inject the action implementations the declarative menu-model
|
||||
// delegates to (avoids an events ↔ menu-model circular dependency).
|
||||
var mm = window.app.modules.menuModel;
|
||||
if (mm && mm.configure) {
|
||||
mm.configure({
|
||||
createInDir: createInDir,
|
||||
renameNode: renameNode,
|
||||
deleteNode: deleteNode,
|
||||
navigateIntoFolder: navigateIntoFolder,
|
||||
refreshListing: refreshListing,
|
||||
parentDirFor: parentDirFor,
|
||||
canCreateHere: canCreateHere,
|
||||
statusInfo: statusInfo,
|
||||
statusError: statusError
|
||||
});
|
||||
}
|
||||
|
||||
// Header buttons
|
||||
var btn = document.getElementById('addDirectoryBtn');
|
||||
if (btn) btn.addEventListener('click', pickLocalDir);
|
||||
|
|
@ -192,6 +225,37 @@
|
|||
var refresh = document.getElementById('refreshHeaderBtn');
|
||||
if (refresh) refresh.addEventListener('click', refreshListing);
|
||||
|
||||
// ── Tree-pane toolbar: New folder / New file, Sort, Show hidden ──
|
||||
// View settings live on the toolbar (not in per-row right-click
|
||||
// menus); create has a discoverable affordance here now that file
|
||||
// rows no longer offer it.
|
||||
var newFolderBtn = document.getElementById('newFolderBtn');
|
||||
if (newFolderBtn) newFolderBtn.addEventListener('click', function () {
|
||||
createInDir(state.currentPath || '/', 'folder');
|
||||
});
|
||||
var newFileBtn = document.getElementById('newFileBtn');
|
||||
if (newFileBtn) newFileBtn.addEventListener('click', function () {
|
||||
createInDir(state.currentPath || '/', 'markdown');
|
||||
});
|
||||
var sortSelect = document.getElementById('sortSelect');
|
||||
if (sortSelect) {
|
||||
// Reflect current state, then drive setSortExplicit on change.
|
||||
sortSelect.value = state.sort.key + ':' + state.sort.dir;
|
||||
sortSelect.addEventListener('change', function () {
|
||||
var parts = sortSelect.value.split(':');
|
||||
tree.setSortExplicit(parts[0], parseInt(parts[1], 10) === -1 ? -1 : 1);
|
||||
});
|
||||
}
|
||||
var showHiddenChk = document.getElementById('showHiddenChk');
|
||||
if (showHiddenChk) {
|
||||
showHiddenChk.checked = !!state.showHidden;
|
||||
showHiddenChk.addEventListener('change', function () {
|
||||
state.showHidden = showHiddenChk.checked;
|
||||
syncURLToSelection();
|
||||
refreshListing();
|
||||
});
|
||||
}
|
||||
|
||||
// Tree autofilter — parses input through zddc.filter.parse so
|
||||
// the same query grammar that the archive app uses (terms,
|
||||
// quotes, !negation, multi-word AND) works here. The AST is
|
||||
|
|
@ -286,6 +350,16 @@
|
|||
treeBody.addEventListener('click', function (e) {
|
||||
var row = e.target.closest('.tree-row');
|
||||
if (!row) return;
|
||||
// Kebab (⋯) button → open the row menu at the button; must run
|
||||
// BEFORE the toggle/preview logic so it doesn't also fire those.
|
||||
var kebab = e.target.closest('.tree-row__kebab');
|
||||
if (kebab) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var r = kebab.getBoundingClientRect();
|
||||
openRowMenuFor(row, r.right, r.bottom);
|
||||
return;
|
||||
}
|
||||
var id = parseInt(row.dataset.id, 10);
|
||||
var node = state.nodes.get(id);
|
||||
if (!node) return;
|
||||
|
|
@ -382,6 +456,22 @@
|
|||
// if collapsed/leaf
|
||||
// Enter / Space — preview file / toggle folder
|
||||
// Home / End — first / last visible row
|
||||
// Keyboard menu key — ContextMenu key or Shift+F10 opens the row
|
||||
// menu at the selected row (standard file-manager / a11y gesture).
|
||||
document.addEventListener('keydown', function (e) {
|
||||
var tag = (e.target && e.target.tagName) || '';
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
if (e.target && e.target.isContentEditable) return;
|
||||
if (document.querySelector('.modal-overlay, .zddc-menu')) return;
|
||||
var isMenuKey = e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10');
|
||||
if (!isMenuKey || state.selectedId == null) return;
|
||||
var selRow = treeBody.querySelector('.tree-row[data-id="' + state.selectedId + '"]');
|
||||
if (!selRow) return;
|
||||
e.preventDefault();
|
||||
var rr = selRow.getBoundingClientRect();
|
||||
openRowMenuFor(selRow, rr.left + 16, rr.bottom - 4);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
// Skip editable contexts.
|
||||
var tag = (e.target && e.target.tagName) || '';
|
||||
|
|
@ -483,27 +573,8 @@
|
|||
treeBody.addEventListener('contextmenu', function (e) {
|
||||
e.preventDefault();
|
||||
var row = e.target.closest('.tree-row');
|
||||
if (row) {
|
||||
var id = parseInt(row.dataset.id, 10);
|
||||
var node = state.nodes.get(id);
|
||||
if (!node) return;
|
||||
state.selectedId = id;
|
||||
tree.render();
|
||||
syncURLToSelection();
|
||||
window.zddc.menu.open({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
context: { node: node, row: row },
|
||||
items: buildTreeRowMenu
|
||||
});
|
||||
} else {
|
||||
window.zddc.menu.open({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
context: { dir: state.currentPath || '/' },
|
||||
items: buildPaneMenu
|
||||
});
|
||||
}
|
||||
if (row) openRowMenuFor(row, e.clientX, e.clientY);
|
||||
else openPaneMenu(e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
// Per-row drag-drop. Any row is a drop target — folders
|
||||
|
|
@ -874,7 +945,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
function createInside(node, kind) { return createInDir(parentDirFor(node), kind); }
|
||||
|
||||
// Reload a directory's children in the tree so a create/delete/
|
||||
// rename is reflected. Works for both the current scope (root)
|
||||
|
|
@ -987,42 +1057,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Shared submenu (used by both the row menu and the pane menu).
|
||||
// Toggle items so the active sort is checked in both surfaces.
|
||||
var SORT_BY_ITEMS = [
|
||||
{ label: 'Name',
|
||||
checked: function () { return state.sort.key === 'name'; },
|
||||
action: function () { tree.setSortExplicit('name', 1); } },
|
||||
{ label: 'Modified',
|
||||
checked: function () { return state.sort.key === 'date'; },
|
||||
action: function () { tree.setSortExplicit('date', -1); } },
|
||||
{ label: 'Size',
|
||||
checked: function () { return state.sort.key === 'size'; },
|
||||
action: function () { tree.setSortExplicit('size', -1); } },
|
||||
{ label: 'Type',
|
||||
checked: function () { return state.sort.key === 'ext'; },
|
||||
action: function () { tree.setSortExplicit('ext', 1); } }
|
||||
];
|
||||
|
||||
// Row context menu — traditional file-manager layout:
|
||||
// Open / Open in new tab / Pop out preview
|
||||
// ─
|
||||
// Download (label flips on type)
|
||||
// ─
|
||||
// New folder / New markdown file
|
||||
// ─
|
||||
// Rename / Delete (permission-gated, disabled
|
||||
// when the row can't be mutated)
|
||||
// ─
|
||||
// Copy path / Copy name
|
||||
// ─
|
||||
// Expand / Collapse / Navigate into
|
||||
// ─
|
||||
// Sort by … / Show hidden files
|
||||
//
|
||||
// Items are kept VISIBLE but DISABLED when they don't apply, so
|
||||
// every menu has the same shape regardless of what the user
|
||||
// right-clicked. Predictable position = muscle memory.
|
||||
// canCreateHere — whether New folder/file has a writable target: the
|
||||
// server (ACL decides the rest) or a picked local folder (the
|
||||
// filesystem permission decides, escalated on first write).
|
||||
|
|
@ -1030,316 +1064,65 @@
|
|||
return state.source === 'server' || (state.source === 'fs' && !!state.rootHandle);
|
||||
}
|
||||
|
||||
function buildTreeRowMenu(ctx) {
|
||||
var serverMode = state.source === 'server';
|
||||
var canMutate = function (c) {
|
||||
var up = window.app.modules.upload;
|
||||
return !!(up && up.canMutate(c.node));
|
||||
};
|
||||
return [
|
||||
// ── Open / preview cluster ──
|
||||
{
|
||||
label: function (c) {
|
||||
if (c.node.isDir) return 'Open';
|
||||
if (c.node.isZip) return 'Open archive';
|
||||
return 'Preview';
|
||||
},
|
||||
disabled: function (c) { return !!c.node.virtual; },
|
||||
action: function (c) {
|
||||
if (c.node.isDir || c.node.isZip) {
|
||||
tree.toggleFolder(c.node.id);
|
||||
} else {
|
||||
var p = previewMod();
|
||||
if (p) p.showFilePreview(c.node);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Open in new tab',
|
||||
accel: 'Ctrl+Click',
|
||||
disabled: function (c) { return !c.node.url; },
|
||||
action: function (c) {
|
||||
if (c.node.url) window.open(c.node.url, '_blank', 'noopener');
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Pop out preview',
|
||||
disabled: function (c) { return c.node.isDir || c.node.isZip; },
|
||||
action: function (c) {
|
||||
var p = previewMod();
|
||||
if (p) p.showFilePreview(c.node, { popup: true });
|
||||
}
|
||||
},
|
||||
{ separator: true },
|
||||
// ── Menu opening (row / pane / kebab / keyboard) ──────────────────────
|
||||
// The menu CONTENTS come from the declarative menu-model; this layer just
|
||||
// resolves the target, syncs selection, and positions the menu. All four
|
||||
// entry points (right-click row, right-click pane, kebab button, keyboard
|
||||
// menu key) funnel through here so they stay identical.
|
||||
|
||||
// ── Download (single item; label flips on type) ──
|
||||
{
|
||||
label: function (c) { return c.node.isDir ? 'Download ZIP' : 'Download'; },
|
||||
icon: '⤓',
|
||||
disabled: function (c) { return !!c.node.virtual; },
|
||||
action: function (c) {
|
||||
var d = window.app.modules.download;
|
||||
if (!d) return;
|
||||
if (c.node.isDir) d.downloadFolder(c.node);
|
||||
else d.downloadFile(c.node);
|
||||
}
|
||||
},
|
||||
{ separator: true },
|
||||
// The prefetched /.profile/access view for the current scope (set on every
|
||||
// listing load — see prefetchScopeAccess). Returned synchronously; the
|
||||
// menu never triggers a fetch at open time. null until prefetched / FS mode.
|
||||
function prefetchedAccess() { return state.scopeAccess; }
|
||||
|
||||
// ── Create new (in the row's parent folder) ──
|
||||
{
|
||||
label: 'New folder',
|
||||
disabled: !canCreateHere(),
|
||||
action: function (c) { createInside(c.node, 'folder'); }
|
||||
},
|
||||
{
|
||||
label: 'New markdown file',
|
||||
disabled: !canCreateHere(),
|
||||
action: function (c) { createInside(c.node, 'markdown'); }
|
||||
},
|
||||
{ separator: true },
|
||||
function menuModel() { return window.app.modules.menuModel; }
|
||||
|
||||
// ── Rename + Delete (the permission-gated pair) ──
|
||||
//
|
||||
// Two gates compose: canMutate() rules out un-writable
|
||||
// sources (offline FS-API without a handle, zip members,
|
||||
// virtual placeholders) and — when the listing carries
|
||||
// server-cascade verbs — zddc.cap.has(node, verb) applies
|
||||
// the per-entry ACL. The verbs gate is server-mode only;
|
||||
// file:// FS-API and plain Caddy listings have no verbs
|
||||
// field, so we fall back to canMutate alone (FS-API
|
||||
// enforces locally; Caddy has no PUT/DELETE either way).
|
||||
// Server-side ACL still has the final say on the actual
|
||||
// PUT/DELETE if a stale client tries the action.
|
||||
{
|
||||
label: 'Rename…',
|
||||
disabled: function (c) {
|
||||
if (!canMutate(c)) return true;
|
||||
if (!serverMode || !window.zddc.cap) return false;
|
||||
// verbs===undefined → Caddy or other non-zddc
|
||||
// server, no cascade signal to gate on. verbs===""
|
||||
// is zddc-server's explicit zero grant; still
|
||||
// gate (disable). verbs==="rw…" → check the bit.
|
||||
if (typeof c.node.verbs !== 'string') return false;
|
||||
return !window.zddc.cap.has(c.node, 'w');
|
||||
},
|
||||
tooltip: function (c) {
|
||||
if (!serverMode || !canMutate(c)) return '';
|
||||
if (!window.zddc.cap) return '';
|
||||
if (typeof c.node.verbs !== 'string') return '';
|
||||
if (window.zddc.cap.has(c.node, 'w')) return '';
|
||||
return "You don't have write access to this item.";
|
||||
},
|
||||
action: function (c) { renameNode(c.node); }
|
||||
},
|
||||
{
|
||||
label: 'Delete…',
|
||||
icon: '🗑',
|
||||
danger: true,
|
||||
disabled: function (c) {
|
||||
if (!canMutate(c)) return true;
|
||||
if (!serverMode || !window.zddc.cap) return false;
|
||||
if (typeof c.node.verbs !== 'string') return false;
|
||||
return !window.zddc.cap.has(c.node, 'd');
|
||||
},
|
||||
tooltip: function (c) {
|
||||
if (!serverMode || !canMutate(c)) return '';
|
||||
if (!window.zddc.cap) return '';
|
||||
if (typeof c.node.verbs !== 'string') return '';
|
||||
if (window.zddc.cap.has(c.node, 'd')) return '';
|
||||
return "You don't have delete access to this item.";
|
||||
},
|
||||
action: function (c) { deleteNode(c.node); }
|
||||
},
|
||||
{ separator: true },
|
||||
|
||||
// ── Clipboard / identifiers ──
|
||||
{
|
||||
label: 'Copy path',
|
||||
action: function (c) {
|
||||
var path = tree.pathFor(c.node);
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(path).then(
|
||||
function () { statusInfo('Copied: ' + path); },
|
||||
function () { statusError('Clipboard copy denied'); }
|
||||
);
|
||||
} else {
|
||||
statusInfo(path);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Copy name',
|
||||
action: function (c) {
|
||||
// Always include the file extension. node.name
|
||||
// already does for normal listings, but re-joining
|
||||
// via zddc.joinExtension is defensive against any
|
||||
// upstream that ever returns the basename split.
|
||||
var n = c.node.name;
|
||||
var ext = c.node.ext;
|
||||
if (!c.node.isDir && ext
|
||||
&& !n.toLowerCase().endsWith('.' + ext.toLowerCase())) {
|
||||
n = window.zddc.joinExtension(n, ext);
|
||||
}
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(n);
|
||||
}
|
||||
statusInfo('Copied: ' + n);
|
||||
}
|
||||
},
|
||||
{ separator: true },
|
||||
|
||||
// ── Tree-view ops (folder/zip rows only) ──
|
||||
{
|
||||
label: 'Expand subtree',
|
||||
accel: 'Shift+Click',
|
||||
disabled: function (c) { return !(c.node.isDir || c.node.isZip); },
|
||||
action: function (c) { tree.expandSubtree(c.node.id); }
|
||||
},
|
||||
{
|
||||
label: 'Collapse subtree',
|
||||
disabled: function (c) { return !(c.node.isDir || c.node.isZip); },
|
||||
action: function (c) { tree.collapseSubtree(c.node.id); }
|
||||
},
|
||||
{
|
||||
label: 'Navigate into',
|
||||
accel: 'Dbl-click',
|
||||
disabled: function (c) { return !c.node.isDir; },
|
||||
action: function (c) { navigateIntoFolder(c.node); }
|
||||
},
|
||||
{ separator: true },
|
||||
|
||||
// ── Plan Review (received/<tracking>/ only, cascade-gated) ──
|
||||
{
|
||||
label: 'Plan Review…',
|
||||
visible: function (c) {
|
||||
if (!serverMode) return false;
|
||||
if (!state.scopeOnPlanReview) return false;
|
||||
var pr = window.app.modules.planReview;
|
||||
if (!pr) return false;
|
||||
return pr.isReceivedTrackingFolder(c.node);
|
||||
},
|
||||
action: function (c) {
|
||||
var pr = window.app.modules.planReview;
|
||||
if (pr) pr.invoke(c.node);
|
||||
}
|
||||
},
|
||||
// ── Accept Transmittal (transmittal folder under incoming/) ──
|
||||
{
|
||||
label: 'Accept Transmittal…',
|
||||
visible: function (c) {
|
||||
if (!serverMode) return false;
|
||||
var at = window.app.modules.acceptTransmittal;
|
||||
if (!at) return false;
|
||||
return at.isAcceptableTransmittalFolder(c.node);
|
||||
},
|
||||
action: function (c) {
|
||||
var at = window.app.modules.acceptTransmittal;
|
||||
if (at) at.invoke(c.node);
|
||||
}
|
||||
},
|
||||
// ── Stage / Unstage (files under working/ or staging/) ──
|
||||
{
|
||||
label: 'Stage to…',
|
||||
visible: function (c) {
|
||||
if (!serverMode) return false;
|
||||
var s = window.app.modules.stage;
|
||||
return !!(s && s.isStageableFile(c.node));
|
||||
},
|
||||
action: function (c) {
|
||||
var s = window.app.modules.stage;
|
||||
if (s) s.invokeStage(c.node);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Unstage to working/',
|
||||
visible: function (c) {
|
||||
if (!serverMode) return false;
|
||||
var s = window.app.modules.stage;
|
||||
return !!(s && s.isUnstageableFile(c.node));
|
||||
},
|
||||
action: function (c) {
|
||||
var s = window.app.modules.stage;
|
||||
if (s) s.invokeUnstage(c.node);
|
||||
}
|
||||
},
|
||||
// ── Version history (history:true subtree, real files only) ──
|
||||
// Server-mode only: the audit trail (who saved when) is
|
||||
// server-stamped, so there's no offline equivalent. node.history
|
||||
// is set by the listing when this file sits in a history-enabled
|
||||
// cascade subtree (working/).
|
||||
{
|
||||
label: 'History…',
|
||||
icon: '🕘',
|
||||
visible: function (c) {
|
||||
if (!serverMode) return false;
|
||||
if (c.node.isDir || c.node.isZip || c.node.virtual) return false;
|
||||
return !!c.node.history;
|
||||
},
|
||||
action: function (c) {
|
||||
var h = window.app.modules.history;
|
||||
if (h) h.open(c.node);
|
||||
}
|
||||
},
|
||||
{ separator: true },
|
||||
|
||||
// ── View ──
|
||||
{ label: 'Sort by', items: SORT_BY_ITEMS },
|
||||
{ label: 'Show hidden files',
|
||||
checked: function () { return !!state.showHidden; },
|
||||
action: function () {
|
||||
state.showHidden = !state.showHidden;
|
||||
syncURLToSelection();
|
||||
refreshListing();
|
||||
} }
|
||||
];
|
||||
function openRowMenuFor(row, x, y) {
|
||||
var id = parseInt(row.dataset.id, 10);
|
||||
var node = state.nodes.get(id);
|
||||
if (!node) return;
|
||||
// Select the row first so the highlight + menu target agree.
|
||||
state.selectedId = id;
|
||||
tree.render();
|
||||
syncURLToSelection();
|
||||
var mm = menuModel();
|
||||
if (!mm) return;
|
||||
window.zddc.menu.open({
|
||||
x: x, y: y,
|
||||
context: { node: node, row: row, surface: 'row' },
|
||||
items: function () { return mm.buildRowItems(node, row, prefetchedAccess()); }
|
||||
});
|
||||
}
|
||||
|
||||
// Right-click on empty space in the tree pane → directory-scope
|
||||
// menu. Operations apply to the current scope (state.currentPath),
|
||||
// not any specific row.
|
||||
function buildPaneMenu() {
|
||||
var serverMode = state.source === 'server';
|
||||
return [
|
||||
{
|
||||
label: 'New folder',
|
||||
disabled: !canCreateHere(),
|
||||
action: function () { createInDir(state.currentPath || '/', 'folder'); }
|
||||
},
|
||||
{
|
||||
label: 'New markdown file',
|
||||
disabled: !canCreateHere(),
|
||||
action: function () { createInDir(state.currentPath || '/', 'markdown'); }
|
||||
},
|
||||
// ── Create Transmittal folder (staging/ scope only) ──
|
||||
{
|
||||
label: 'Create Transmittal folder…',
|
||||
visible: function () {
|
||||
return serverMode && state.scopeCanonicalFolder === 'staging';
|
||||
},
|
||||
action: function () {
|
||||
var ct = window.app.modules.createTransmittal;
|
||||
if (ct) ct.invoke();
|
||||
}
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: 'Refresh',
|
||||
accel: 'F5',
|
||||
action: function () { refreshListing(); }
|
||||
},
|
||||
{ separator: true },
|
||||
{ label: 'Sort by', items: SORT_BY_ITEMS },
|
||||
{ label: 'Show hidden files',
|
||||
checked: function () { return !!state.showHidden; },
|
||||
action: function () {
|
||||
state.showHidden = !state.showHidden;
|
||||
syncURLToSelection();
|
||||
refreshListing();
|
||||
} }
|
||||
];
|
||||
function openPaneMenu(x, y) {
|
||||
var mm = menuModel();
|
||||
if (!mm) return;
|
||||
window.zddc.menu.open({
|
||||
x: x, y: y,
|
||||
context: { dir: state.currentPath || '/', surface: 'pane' },
|
||||
items: function () { return mm.buildPaneItems(prefetchedAccess()); }
|
||||
});
|
||||
}
|
||||
|
||||
// Prefetch (memoised) the scope access view so the menu's create-gate and
|
||||
// admin/sub-admin tier items resolve without a fetch. Server-mode only;
|
||||
// cap.at returns null on file:// so FS mode leaves scopeAccess null.
|
||||
function prefetchScopeAccess() {
|
||||
if (state.source !== 'server' || !window.zddc || !window.zddc.cap || !window.zddc.cap.at) {
|
||||
state.scopeAccess = null;
|
||||
return;
|
||||
}
|
||||
var path = state.currentPath || '/';
|
||||
window.zddc.cap.at(path).then(function (view) {
|
||||
// Ignore a stale resolution if the scope moved on.
|
||||
if ((state.currentPath || '/') === path) {
|
||||
state.scopeAccess = view || null;
|
||||
applySourceUI();
|
||||
}
|
||||
}, function () { /* best-effort; leave prior value */ });
|
||||
}
|
||||
|
||||
|
||||
// View mode is URL-driven, not UI-driven.
|
||||
//
|
||||
// ?view=grid → grid mode (only honored where classifier is
|
||||
|
|
@ -1433,6 +1216,7 @@
|
|||
// don't pushState/setRoot on top of it.
|
||||
if (!isCurrentNav(seq)) return;
|
||||
state.currentPath = url;
|
||||
prefetchScopeAccess();
|
||||
// Selection / preview belong to the old scope; clear them so
|
||||
// the new root doesn't carry stale highlight state.
|
||||
state.selectedId = null;
|
||||
|
|
@ -1489,6 +1273,11 @@
|
|||
// can't race the in-tool navigations. beginNav() claims the latest
|
||||
// token; isCurrentNav(seq) reports whether it's still latest.
|
||||
beginNav: beginNav,
|
||||
isCurrentNav: isCurrentNav
|
||||
isCurrentNav: isCurrentNav,
|
||||
// Prefetch the current scope's /.profile/access view into
|
||||
// state.scopeAccess (memoised) so the menu's create-gate + admin-tier
|
||||
// items resolve without a fetch. Called by app.js on initial load +
|
||||
// back/forward.
|
||||
prefetchScopeAccess: prefetchScopeAccess
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -165,10 +165,10 @@
|
|||
+ '<span class="tree-hovercard__val" id="hc-roles">…</span>';
|
||||
}
|
||||
|
||||
// Path comes last (longest, most likely to wrap).
|
||||
var path = tree ? tree.pathFor(node) : '';
|
||||
if (path) html += kv('Path', path, true);
|
||||
if (node.url && node.url !== path) html += kv('URL', node.url, true);
|
||||
// URL last (longest, most likely to wrap) — rendered as a clickable
|
||||
// link the user can open or right-click to copy. The on-disk path is
|
||||
// intentionally omitted; the URL is the shareable reference.
|
||||
if (node.url) html += kvLink('URL', node.url, node.url);
|
||||
|
||||
return html;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,6 +79,14 @@
|
|||
scopeCanonicalFolder: '',
|
||||
scopeOnPlanReview: false,
|
||||
|
||||
// Prefetched /.profile/access view for the CURRENT scope
|
||||
// (state.currentPath), via cap.at() — memoised. Supplies
|
||||
// path_verbs / path_is_admin / path_roles to the menu model for
|
||||
// pane-scope create gating and the admin/sub-admin tier items, so
|
||||
// the menu never fetches at open time. null until prefetched / in
|
||||
// FS-Access (offline) mode.
|
||||
scopeAccess: null,
|
||||
|
||||
// Whether the listing includes dotfiles. Toggled by the
|
||||
// "Show hidden files" menu item; URL-persisted via ?hidden=1.
|
||||
showHidden: false,
|
||||
|
|
|
|||
444
browse/js/menu-model.js
Normal file
444
browse/js/menu-model.js
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
// menu-model.js — the declarative source of truth for the browse tool's
|
||||
// action menus (right-click row menu, right-click pane menu, the keyboard
|
||||
// menu key, and the hover kebab).
|
||||
//
|
||||
// Every action is declared ONCE as a descriptor. The row/pane menus are
|
||||
// projections over that list, filtered by surface + an `appliesTo` TYPE
|
||||
// predicate and annotated with an `enabled` CAPABILITY predicate:
|
||||
//
|
||||
// appliesTo(ctx) === false → the item is OMITTED (it doesn't make sense
|
||||
// for this target — e.g. "New folder" on a
|
||||
// file row, "Expand" on a file).
|
||||
// appliesTo true, enabled
|
||||
// (ctx) === false → the item is SHOWN DISABLED with a tooltip
|
||||
// naming what's required (write access /
|
||||
// create access / project-admin / site-admin).
|
||||
//
|
||||
// That hybrid realizes the cumulative guest ⊂ project-team ⊂ sub-admin ⊂
|
||||
// admin menus: a lower tier SEES higher-tier actions greyed and learns they
|
||||
// exist, while type-irrelevant noise is hidden.
|
||||
//
|
||||
// Roles are NOT hardcoded: ordinary actions gate on the verbs the server
|
||||
// returns per entry (node.verbs) or per scope (cap.at → path_verbs), so any
|
||||
// operator-defined role works. Only two intrinsically-special tiers are
|
||||
// recognised by name — site admin (is_super_admin / IsAdmin) and project /
|
||||
// subtree admin (path_is_admin / IsSubtreeAdmin) — because they govern
|
||||
// administration itself and can't be expressed as a plain verb bundle.
|
||||
//
|
||||
// Deliberately data-shaped so a future server-sourced manifest (zddc.zip)
|
||||
// can supply or extend the descriptors without touching the tool code.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (!window.app || !window.app.modules) return;
|
||||
|
||||
var state = window.app.state;
|
||||
|
||||
// Action implementations are injected by events.init() via configure()
|
||||
// to avoid an events ↔ menu-model circular dependency. Everything else
|
||||
// (tree, preview, download, workflow modules) is reached through
|
||||
// window.app.modules at call time.
|
||||
var act = {};
|
||||
function configure(a) { act = a || {}; }
|
||||
|
||||
// ── Predicates ────────────────────────────────────────────────────────
|
||||
|
||||
function isServer() { return state.source === 'server'; }
|
||||
function appliesToFolderLike(node) { return !!(node && (node.isDir || node.isZip)); }
|
||||
function appliesToFile(node) { return !!(node && !node.isDir && !node.isZip); }
|
||||
|
||||
// Formats the Export submenu offers for a file (server-side conversion):
|
||||
// a file of one of these extensions can be exported as the other two.
|
||||
var EXPORT_FORMATS = ['md', 'docx', 'html'];
|
||||
function cap() { return window.zddc && window.zddc.cap; }
|
||||
|
||||
function canVerb(node, verb) {
|
||||
return !!(node && cap() && cap().has(node, verb));
|
||||
}
|
||||
function pathHasVerb(access, verb) {
|
||||
return !!(access && typeof access.path_verbs === 'string'
|
||||
&& access.path_verbs.indexOf(verb) !== -1);
|
||||
}
|
||||
function isSiteAdmin(access) { return !!(access && access.is_super_admin); }
|
||||
function isSubtreeAdminHere(access) { return !!(access && access.path_is_admin); }
|
||||
|
||||
// Create / mutate / admin actions are HIDDEN when the user can't perform
|
||||
// them (capability folded into appliesTo), so these gates only need the
|
||||
// boolean — the `missing` field is retained for potential future tooltips.
|
||||
|
||||
// Rename/Delete gate — preserves today's compose exactly: canMutate rules
|
||||
// out un-writable sources (offline FS without a handle, zip members,
|
||||
// virtual placeholders) with no tooltip; when the server cascade reports
|
||||
// verbs, the per-entry ACL bit gates with a tooltip. FS / Caddy (no verbs
|
||||
// field) fall back to canMutate alone. Returns { enabled, missing }.
|
||||
function verbGate(node, verb) {
|
||||
var up = window.app.modules.upload;
|
||||
if (!up || !up.canMutate(node)) return { enabled: false, missing: '' };
|
||||
if (!isServer() || !cap()) return { enabled: true, missing: '' };
|
||||
if (typeof node.verbs !== 'string') return { enabled: true, missing: '' };
|
||||
if (cap().has(node, verb)) return { enabled: true, missing: '' };
|
||||
return { enabled: false, missing: verb };
|
||||
}
|
||||
|
||||
// Create gate (New folder / New file). canCreateHere() rules out the
|
||||
// no-target case (offline FS without a picked handle) — no tooltip there.
|
||||
// In server mode, gate on the 'c' verb: per-node for a folder row, per
|
||||
// scope for the pane. Unknown verbs → optimistic (server is the final
|
||||
// arbiter, surfacing 403 via cap.handleForbidden, exactly as today).
|
||||
function createGate(ctx) {
|
||||
if (!act.canCreateHere || !act.canCreateHere()) return { enabled: false, missing: '' };
|
||||
if (!isServer()) return { enabled: true, missing: '' };
|
||||
if (ctx.node) { // folder-row create → inside this folder
|
||||
if (typeof ctx.node.verbs === 'string') {
|
||||
return canVerb(ctx.node, 'c')
|
||||
? { enabled: true, missing: '' }
|
||||
: { enabled: false, missing: 'c' };
|
||||
}
|
||||
return { enabled: true, missing: '' };
|
||||
}
|
||||
// pane create → current scope
|
||||
if (ctx.access && typeof ctx.access.path_verbs === 'string') {
|
||||
return pathHasVerb(ctx.access, 'c')
|
||||
? { enabled: true, missing: '' }
|
||||
: { enabled: false, missing: 'c' };
|
||||
}
|
||||
return { enabled: true, missing: '' };
|
||||
}
|
||||
|
||||
// "Edit access rules" (.zddc) — the sub-admin / site-admin tier item.
|
||||
// Enabled per-node when the entry grants the admin verb 'a', else by the
|
||||
// scope's subtree-admin / site-admin status (admin authority cascades
|
||||
// down a subtree). Returns { enabled, missing }.
|
||||
function manageAccessGate(ctx) {
|
||||
if (ctx.node && canVerb(ctx.node, 'a')) return { enabled: true, missing: '' };
|
||||
if (isSubtreeAdminHere(ctx.access) || isSiteAdmin(ctx.access)) return { enabled: true, missing: '' };
|
||||
return { enabled: false, missing: 'subtree-admin' };
|
||||
}
|
||||
|
||||
function insideZip(node) {
|
||||
// Creating inside a zip member is impossible — the server can't PUT
|
||||
// into an archive. Mirror tree.zipNestedInsideZip's URL heuristic.
|
||||
if (!node) return false;
|
||||
if (node.url && /\.zip\//i.test(node.url)) return true;
|
||||
if (node.handle && node.handle.isZipEntry) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Descriptors ─────────────────────────────────────────────────────────
|
||||
// group order = visual order; a separator is inserted on each group change
|
||||
// among the items that actually render (context-menu.js collapses extras).
|
||||
var DESCRIPTORS = [
|
||||
// ── open ──
|
||||
{
|
||||
id: 'open', group: 'open', surfaces: ['row'],
|
||||
label: function (ctx) {
|
||||
if (ctx.node.isDir) return 'Open';
|
||||
if (ctx.node.isZip) return 'Open archive';
|
||||
return 'Preview';
|
||||
},
|
||||
appliesTo: function (ctx) { return !ctx.node.virtual; },
|
||||
action: function (ctx) {
|
||||
if (ctx.node.isDir) {
|
||||
// Open = navigate into the folder (rescope). Inline
|
||||
// expand stays on single-click / chevron / arrow keys.
|
||||
if (act.navigateIntoFolder) act.navigateIntoFolder(ctx.node);
|
||||
} else if (ctx.node.isZip) {
|
||||
// A zip can't be navigated into — expand it inline.
|
||||
var t = window.app.modules.tree;
|
||||
if (t) t.toggleFolder(ctx.node.id);
|
||||
} else {
|
||||
var p = window.app.modules.preview;
|
||||
if (p) p.showFilePreview(ctx.node);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'open-new-tab', group: 'open', surfaces: ['row'],
|
||||
label: 'Open in new tab', accel: 'Ctrl+Click',
|
||||
appliesTo: function (ctx) { return !!ctx.node.url; },
|
||||
action: function (ctx) { window.open(ctx.node.url, '_blank', 'noopener'); }
|
||||
},
|
||||
{
|
||||
id: 'popout', group: 'open', surfaces: ['row'],
|
||||
label: 'Pop out preview',
|
||||
appliesTo: function (ctx) { return appliesToFile(ctx.node) && !ctx.node.virtual; },
|
||||
action: function (ctx) {
|
||||
var p = window.app.modules.preview;
|
||||
if (p) p.showFilePreview(ctx.node, { popup: true });
|
||||
}
|
||||
},
|
||||
|
||||
// ── io ──
|
||||
{
|
||||
id: 'download', group: 'io', surfaces: ['row'],
|
||||
label: function (ctx) { return ctx.node.isDir ? 'Download ZIP' : 'Download'; },
|
||||
appliesTo: function (ctx) { return !ctx.node.virtual; },
|
||||
action: function (ctx) {
|
||||
var d = window.app.modules.download;
|
||||
if (!d) return;
|
||||
if (ctx.node.isDir) d.downloadFolder(ctx.node);
|
||||
else d.downloadFile(ctx.node);
|
||||
}
|
||||
},
|
||||
{
|
||||
// Export submenu: a folder offers ".zip" (both modes); a md/docx/html
|
||||
// file offers the OTHER two formats (server-side conversion, so
|
||||
// server mode only). A zip is already an archive — no Export.
|
||||
id: 'export', group: 'io', surfaces: ['row'],
|
||||
label: 'Export',
|
||||
appliesTo: function (ctx) {
|
||||
var n = ctx.node;
|
||||
if (!n || n.virtual) return false;
|
||||
if (n.isDir) return true;
|
||||
if (n.isZip) return false;
|
||||
return isServer() && EXPORT_FORMATS.indexOf((n.ext || '').toLowerCase()) !== -1;
|
||||
},
|
||||
items: function (ctx) {
|
||||
var n = ctx.node;
|
||||
var d = window.app.modules.download;
|
||||
if (!d) return [];
|
||||
if (n.isDir) {
|
||||
return [{ label: '.zip', action: function () { d.downloadFolder(n); } }];
|
||||
}
|
||||
var cur = (n.ext || '').toLowerCase();
|
||||
return EXPORT_FORMATS.filter(function (f) { return f !== cur; }).map(function (fmt) {
|
||||
return { label: '.' + fmt, action: function () { d.exportFile(n, fmt); } };
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// ── create (folder rows + pane; NOT file rows) ──
|
||||
// Create actions are HIDDEN unless the user can create here (the
|
||||
// capability is folded into appliesTo, not greyed). On a row they
|
||||
// apply to folders only (create inside); on the pane, to the scope.
|
||||
{
|
||||
id: 'new-folder', group: 'create', surfaces: ['row', 'pane'],
|
||||
label: 'New folder',
|
||||
appliesTo: function (ctx) {
|
||||
var typeOk = ctx.surface === 'pane'
|
||||
|| (appliesToFolderLike(ctx.node) && !insideZip(ctx.node));
|
||||
return typeOk && createGate(ctx).enabled;
|
||||
},
|
||||
action: function (ctx) { if (act.createInDir) act.createInDir(ctx.dir, 'folder'); }
|
||||
},
|
||||
{
|
||||
id: 'new-file', group: 'create', surfaces: ['row', 'pane'],
|
||||
label: 'New file',
|
||||
appliesTo: function (ctx) {
|
||||
var typeOk = ctx.surface === 'pane'
|
||||
|| (appliesToFolderLike(ctx.node) && !insideZip(ctx.node));
|
||||
return typeOk && createGate(ctx).enabled;
|
||||
},
|
||||
action: function (ctx) { if (act.createInDir) act.createInDir(ctx.dir, 'markdown'); }
|
||||
},
|
||||
{
|
||||
id: 'create-transmittal', group: 'create', surfaces: ['pane'],
|
||||
label: 'Create Transmittal folder…',
|
||||
appliesTo: function () { return isServer() && state.scopeCanonicalFolder === 'staging'; },
|
||||
action: function () {
|
||||
var ct = window.app.modules.createTransmittal;
|
||||
if (ct) ct.invoke();
|
||||
}
|
||||
},
|
||||
|
||||
// ── mutate (HIDDEN unless permitted — capability folded into appliesTo) ──
|
||||
{
|
||||
id: 'rename', group: 'mutate', surfaces: ['row'],
|
||||
label: 'Rename…',
|
||||
appliesTo: function (ctx) { return !ctx.node.virtual && verbGate(ctx.node, 'w').enabled; },
|
||||
action: function (ctx) { if (act.renameNode) act.renameNode(ctx.node); }
|
||||
},
|
||||
{
|
||||
id: 'delete', group: 'mutate', surfaces: ['row'], danger: true,
|
||||
label: 'Delete…',
|
||||
appliesTo: function (ctx) { return !ctx.node.virtual && verbGate(ctx.node, 'd').enabled; },
|
||||
action: function (ctx) { if (act.deleteNode) act.deleteNode(ctx.node); }
|
||||
},
|
||||
|
||||
// ── treeops (folder/zip rows only) ──
|
||||
{
|
||||
id: 'expand-subtree', group: 'treeops', surfaces: ['row'],
|
||||
label: 'Expand subtree', accel: 'Shift+Click',
|
||||
appliesTo: function (ctx) { return appliesToFolderLike(ctx.node); },
|
||||
action: function (ctx) {
|
||||
var t = window.app.modules.tree;
|
||||
if (t) t.expandSubtree(ctx.node.id);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'collapse-subtree', group: 'treeops', surfaces: ['row'],
|
||||
label: 'Collapse subtree',
|
||||
appliesTo: function (ctx) { return appliesToFolderLike(ctx.node); },
|
||||
action: function (ctx) {
|
||||
var t = window.app.modules.tree;
|
||||
if (t) t.collapseSubtree(ctx.node.id);
|
||||
}
|
||||
},
|
||||
|
||||
// ── workflow (already type+scope gated → omitted when N/A) ──
|
||||
{
|
||||
id: 'plan-review', group: 'workflow', surfaces: ['row'],
|
||||
label: 'Plan Review…',
|
||||
appliesTo: function (ctx) {
|
||||
if (!isServer() || !state.scopeOnPlanReview) return false;
|
||||
var pr = window.app.modules.planReview;
|
||||
return !!(pr && pr.isReceivedTrackingFolder(ctx.node));
|
||||
},
|
||||
action: function (ctx) {
|
||||
var pr = window.app.modules.planReview;
|
||||
if (pr) pr.invoke(ctx.node);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'accept-transmittal', group: 'workflow', surfaces: ['row'],
|
||||
label: 'Accept Transmittal…',
|
||||
appliesTo: function (ctx) {
|
||||
if (!isServer()) return false;
|
||||
var at = window.app.modules.acceptTransmittal;
|
||||
return !!(at && at.isAcceptableTransmittalFolder(ctx.node));
|
||||
},
|
||||
action: function (ctx) {
|
||||
var at = window.app.modules.acceptTransmittal;
|
||||
if (at) at.invoke(ctx.node);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'stage', group: 'workflow', surfaces: ['row'],
|
||||
label: 'Stage to…',
|
||||
appliesTo: function (ctx) {
|
||||
if (!isServer()) return false;
|
||||
var s = window.app.modules.stage;
|
||||
return !!(s && s.isStageableFile(ctx.node));
|
||||
},
|
||||
action: function (ctx) {
|
||||
var s = window.app.modules.stage;
|
||||
if (s) s.invokeStage(ctx.node);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'unstage', group: 'workflow', surfaces: ['row'],
|
||||
label: 'Unstage to working/',
|
||||
appliesTo: function (ctx) {
|
||||
if (!isServer()) return false;
|
||||
var s = window.app.modules.stage;
|
||||
return !!(s && s.isUnstageableFile(ctx.node));
|
||||
},
|
||||
action: function (ctx) {
|
||||
var s = window.app.modules.stage;
|
||||
if (s) s.invokeUnstage(ctx.node);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'history', group: 'workflow', surfaces: ['row'],
|
||||
label: 'History…',
|
||||
appliesTo: function (ctx) {
|
||||
if (!isServer()) return false;
|
||||
var n = ctx.node;
|
||||
return appliesToFile(n) && !n.virtual && !!n.history;
|
||||
},
|
||||
action: function (ctx) {
|
||||
var h = window.app.modules.history;
|
||||
if (h) h.open(ctx.node);
|
||||
}
|
||||
},
|
||||
|
||||
// ── admin / sub-admin tier ──
|
||||
{
|
||||
// HIDDEN unless the user can actually edit access rules here
|
||||
// (admin verb 'a', or subtree/site admin) — not shown greyed.
|
||||
id: 'manage-access', group: 'admin', surfaces: ['row', 'pane'],
|
||||
label: 'Edit access rules…',
|
||||
appliesTo: function (ctx) {
|
||||
if (!isServer()) return false; // server-only tier
|
||||
var typeOk = ctx.surface === 'pane' || appliesToFolderLike(ctx.node);
|
||||
return typeOk && manageAccessGate(ctx).enabled;
|
||||
},
|
||||
action: function (ctx) { openZddcEditor(ctx.dir); }
|
||||
},
|
||||
|
||||
// ── view (pane) ──
|
||||
{
|
||||
id: 'refresh', group: 'view', surfaces: ['pane'],
|
||||
label: 'Refresh', accel: 'F5',
|
||||
action: function () { if (act.refreshListing) act.refreshListing(); }
|
||||
}
|
||||
];
|
||||
|
||||
// Open the `.zddc` for `dir` in the YAML editor. Prefer an existing tree
|
||||
// node (carries verbs/virtual flags) else synthesize one; the yaml plugin
|
||||
// recognises name === '.zddc' and gates the save on the admin verb 'a'.
|
||||
function openZddcEditor(dir) {
|
||||
var url = (dir || '/');
|
||||
if (!url.endsWith('/')) url += '/';
|
||||
url += '.zddc';
|
||||
var found = null;
|
||||
var t = window.app.modules.tree;
|
||||
state.nodes.forEach(function (n) {
|
||||
if (found || n.name !== '.zddc' || !t) return;
|
||||
if (t.pathFor(n) === url) found = n;
|
||||
});
|
||||
var node = found || { url: url, name: '.zddc', ext: '' };
|
||||
var p = window.app.modules.preview;
|
||||
if (p) p.showFilePreview(node);
|
||||
}
|
||||
|
||||
// ── Projection ────────────────────────────────────────────────────────
|
||||
|
||||
function resolve(v, ctx) { return typeof v === 'function' ? v(ctx) : v; }
|
||||
function resolveBool(v, ctx, dflt) {
|
||||
if (v === undefined) return dflt;
|
||||
return !!(typeof v === 'function' ? v(ctx) : v);
|
||||
}
|
||||
|
||||
function toMenuItem(d, ctx) {
|
||||
var item = {
|
||||
label: resolve(d.label, ctx),
|
||||
accel: d.accel,
|
||||
danger: d.danger,
|
||||
// disabled / tooltip ignore the menu's own context arg — ctx is
|
||||
// already captured here with the richer browse context.
|
||||
disabled: function () { return !resolveBool(d.enabled, ctx, true); },
|
||||
tooltip: function () {
|
||||
return resolveBool(d.enabled, ctx, true) ? '' : (resolve(d.tooltip, ctx) || '');
|
||||
}
|
||||
};
|
||||
// A descriptor with `items` becomes a submenu (resolved against the
|
||||
// captured browse ctx); otherwise it's a normal action row.
|
||||
if (d.items) {
|
||||
item.items = function () { return resolve(d.items, ctx); };
|
||||
} else {
|
||||
item.action = function () { if (d.action) d.action(ctx); };
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
function project(surface, ctx) {
|
||||
var out = [];
|
||||
var lastGroup = null;
|
||||
for (var i = 0; i < DESCRIPTORS.length; i++) {
|
||||
var d = DESCRIPTORS[i];
|
||||
if (d.surfaces.indexOf(surface) === -1) continue;
|
||||
if (!resolveBool(d.appliesTo, ctx, true)) continue;
|
||||
if (lastGroup !== null && d.group !== lastGroup) out.push({ separator: true });
|
||||
lastGroup = d.group;
|
||||
out.push(toMenuItem(d, ctx));
|
||||
}
|
||||
return out; // context-menu.js collapses leading/trailing/dup separators
|
||||
}
|
||||
|
||||
function buildRowItems(node, row, access) {
|
||||
var dir = act.parentDirFor ? act.parentDirFor(node) : (state.currentPath || '/');
|
||||
return project('row', { node: node, row: row, surface: 'row', dir: dir, access: access });
|
||||
}
|
||||
function buildPaneItems(access) {
|
||||
var dir = state.currentPath || '/';
|
||||
return project('pane', { node: null, row: null, surface: 'pane', dir: dir, access: access });
|
||||
}
|
||||
|
||||
window.app.modules.menuModel = {
|
||||
configure: configure,
|
||||
buildRowItems: buildRowItems,
|
||||
buildPaneItems: buildPaneItems,
|
||||
DESCRIPTORS: DESCRIPTORS // exposed for tests
|
||||
};
|
||||
})();
|
||||
|
|
@ -278,8 +278,8 @@
|
|||
|
||||
// ── Save ────────────────────────────────────────────────────────────────
|
||||
|
||||
function saveContent(node, content) {
|
||||
return util.saveFile(node, content, 'text/markdown; charset=utf-8');
|
||||
function saveContent(node, content, opts) {
|
||||
return util.saveFile(node, content, 'text/markdown; charset=utf-8', opts);
|
||||
}
|
||||
|
||||
var isZipMemberNode = util.isZipMemberNode;
|
||||
|
|
@ -310,11 +310,21 @@
|
|||
}
|
||||
dispose();
|
||||
|
||||
// Read content.
|
||||
var text;
|
||||
// Read content + the server version token (etag/last-modified) so
|
||||
// the save can send an If-Match precondition and detect a concurrent
|
||||
// edit instead of clobbering it. Falls back to getArrayBuffer (and a
|
||||
// null token → no precondition) for callers/sources without it.
|
||||
var text, loadedEtag = null, loadedLastModified = null;
|
||||
try {
|
||||
var buf = await ctx.getArrayBuffer(node);
|
||||
text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
|
||||
if (ctx.getContentWithVersion) {
|
||||
var loaded = await ctx.getContentWithVersion(node);
|
||||
text = new TextDecoder('utf-8', { fatal: false }).decode(loaded.buf);
|
||||
loadedEtag = loaded.etag;
|
||||
loadedLastModified = loaded.lastModified;
|
||||
} else {
|
||||
var buf = await ctx.getArrayBuffer(node);
|
||||
text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
|
||||
}
|
||||
} catch (e) {
|
||||
container.innerHTML =
|
||||
'<div class="preview-empty" style="color:var(--danger)">'
|
||||
|
|
@ -553,7 +563,11 @@
|
|||
hash: initialHash,
|
||||
tocEl: tocBody,
|
||||
fmEl: fmTextarea,
|
||||
ac: ac
|
||||
ac: ac,
|
||||
// Server version token captured at load — sent as If-Match on
|
||||
// save and refreshed from each successful PUT's response ETag.
|
||||
etag: loadedEtag,
|
||||
lastModified: loadedLastModified
|
||||
};
|
||||
currentInstance = instance;
|
||||
|
||||
|
|
@ -687,21 +701,81 @@
|
|||
fmTextarea.addEventListener('input', onFmChange);
|
||||
|
||||
// ── Save ───────────────────────────────────────────────────────────
|
||||
// Mark a successful write: adopt the new server ETag (so the next
|
||||
// save's If-Match matches — no false conflict on save→edit→save),
|
||||
// refresh the dirty baseline, clear dirty.
|
||||
async function markSaved(content, res) {
|
||||
if (currentInstance !== instance) return;
|
||||
if (res && res.etag) instance.etag = res.etag;
|
||||
instance.hash = await hashContent(content);
|
||||
if (currentInstance !== instance) return;
|
||||
markDirty(false);
|
||||
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
|
||||
if (window.zddc && window.zddc.toast) {
|
||||
window.zddc.toast('Saved ' + node.name, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
// 412 → the file changed on the server since we loaded it. Open the
|
||||
// shared conflict dialog rather than clobbering. Dirty stays set
|
||||
// until the user resolves.
|
||||
async function resolveConflict(content) {
|
||||
var conflict = window.app.modules.conflict;
|
||||
var prev = window.app.modules.preview;
|
||||
if (!conflict || !prev) return; // no UI available — leave dirty
|
||||
await conflict.open({
|
||||
filename: node.name,
|
||||
mineText: content,
|
||||
fetchTheirs: function () {
|
||||
return prev.getContentWithVersion(node).then(function (r) {
|
||||
return new TextDecoder('utf-8', { fatal: false }).decode(r.buf);
|
||||
});
|
||||
},
|
||||
// Overwrite: re-fetch the CURRENT version and save against it
|
||||
// (still 412s on a third concurrent writer rather than blind-
|
||||
// forcing).
|
||||
onOverwrite: function () {
|
||||
return prev.getContentWithVersion(node).then(function (cur) {
|
||||
return saveContent(node, content, { etag: cur.etag, lastModified: cur.lastModified });
|
||||
}).then(function (res) { return markSaved(content, res); });
|
||||
},
|
||||
// Reload theirs: discard local edits. Clear dirty first so the
|
||||
// renderInline dirty-guard skips its confirm; the fresh render
|
||||
// re-captures content + a new ETag.
|
||||
onReload: function () {
|
||||
markDirty(false);
|
||||
instance.dirty = false;
|
||||
return prev.showFilePreview(node);
|
||||
},
|
||||
onSaveCopy: function () {
|
||||
return util.saveCopy(node, content, 'text/markdown; charset=utf-8')
|
||||
.then(function (name) {
|
||||
if (window.zddc && window.zddc.toast) {
|
||||
window.zddc.toast('Saved your version as ' + name, 'success');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
if (currentInstance === instance) statusEl.textContent = '';
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (currentInstance !== instance) return;
|
||||
if (!instance.dirty || !canSave(node)) return;
|
||||
var content = assembleContent(fmTextarea.value, editor.getMarkdown());
|
||||
try {
|
||||
statusEl.textContent = 'Saving…';
|
||||
await saveContent(node, content);
|
||||
if (currentInstance !== instance) return; // switched away mid-save
|
||||
instance.hash = await hashContent(content);
|
||||
markDirty(false);
|
||||
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
|
||||
if (window.zddc && window.zddc.toast) {
|
||||
window.zddc.toast('Saved ' + node.name, 'success');
|
||||
}
|
||||
var res = await saveContent(node, content, {
|
||||
etag: instance.etag, lastModified: instance.lastModified
|
||||
});
|
||||
await markSaved(content, res);
|
||||
} catch (e) {
|
||||
if (e && e.status === 412) {
|
||||
if (currentInstance !== instance) return;
|
||||
statusEl.textContent = 'Conflict — resolving…';
|
||||
await resolveConflict(content);
|
||||
return;
|
||||
}
|
||||
statusEl.textContent = 'Save failed: ' + (e.message || e);
|
||||
if (window.zddc && window.zddc.toast) {
|
||||
window.zddc.toast('Save failed: ' + (e.message || e), 'error');
|
||||
|
|
|
|||
|
|
@ -45,11 +45,11 @@
|
|||
|
||||
// ── Save (mirrors preview-markdown.js) ─────────────────────────────────
|
||||
|
||||
function saveContent(node, content) {
|
||||
function saveContent(node, content, opts) {
|
||||
// Via the shared saveFile so local (FS-Access) saves escalate to
|
||||
// readwrite the same as the markdown editor — previously this path
|
||||
// skipped ensureWritable and failed on read-only-picked folders.
|
||||
return util.saveFile(node, content, 'application/x-yaml; charset=utf-8');
|
||||
return util.saveFile(node, content, 'application/x-yaml; charset=utf-8', opts);
|
||||
}
|
||||
|
||||
var isZipMemberNode = util.isZipMemberNode;
|
||||
|
|
@ -106,9 +106,8 @@
|
|||
worm: 'string[]',
|
||||
paths: 'pathmap',
|
||||
display: 'stringmap',
|
||||
apps: 'appsmap',
|
||||
apps_pubkey: 'string',
|
||||
tables: 'stringmap',
|
||||
views: 'viewmap',
|
||||
convert: 'convert',
|
||||
created_by: 'string',
|
||||
inherit: 'bool'
|
||||
|
|
@ -225,19 +224,29 @@
|
|||
walkObject(v, TOP_KEYS, path.concat([seg]), issues);
|
||||
}
|
||||
return;
|
||||
case 'appsmap':
|
||||
case 'viewmap':
|
||||
if (t === 'null') return;
|
||||
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
|
||||
for (var app in val) {
|
||||
if (!Object.prototype.hasOwnProperty.call(val, app)) continue;
|
||||
if (!ALLOWED_TOOLS[app]) {
|
||||
issues.push({ keyPath: path.concat([app]), severity: 'warning',
|
||||
message: 'Unknown tool "' + app + '" in apps:.' });
|
||||
for (var shape in val) {
|
||||
if (!Object.prototype.hasOwnProperty.call(val, shape)) continue;
|
||||
if (['dir', 'dir_slash', 'file'].indexOf(shape) === -1) {
|
||||
issues.push({ keyPath: path.concat([shape]), severity: 'warning',
|
||||
message: 'Unknown view shape "' + shape + '" (known: dir, dir_slash, file).' });
|
||||
}
|
||||
if (typeOf(val[app]) !== 'string') {
|
||||
issues.push({ keyPath: path.concat([app]), severity: 'error',
|
||||
message: 'apps.' + app + ' must be a spec string '
|
||||
+ '(channel | v<semver> | URL | path).' });
|
||||
var vv = val[shape];
|
||||
if (typeOf(vv) !== 'object') {
|
||||
issues.push({ keyPath: path.concat([shape]), severity: 'error',
|
||||
message: 'views.' + shape + ' must be a map ({tool, config}).' });
|
||||
continue;
|
||||
}
|
||||
if (typeOf(vv.tool) !== 'string' || !ALLOWED_TOOLS[vv.tool]) {
|
||||
issues.push({ keyPath: path.concat([shape, 'tool']), severity: 'warning',
|
||||
message: 'views.' + shape + '.tool should be a known tool ('
|
||||
+ Object.keys(ALLOWED_TOOLS).join(', ') + ').' });
|
||||
}
|
||||
if (vv.config !== undefined && typeOf(vv.config) !== 'string') {
|
||||
issues.push({ keyPath: path.concat([shape, 'config']), severity: 'error',
|
||||
message: 'views.' + shape + '.config must be a filename string.' });
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
|
@ -350,6 +359,10 @@
|
|||
var currentEditor = null;
|
||||
var currentDirty = false;
|
||||
var currentNodeRef = null;
|
||||
// Server version token for the loaded file — sent as If-Match on save
|
||||
// and refreshed from each successful PUT's response ETag.
|
||||
var currentEtag = null;
|
||||
var currentLastModified = null;
|
||||
|
||||
function dispose() {
|
||||
// CM doesn't have an explicit destroy(); GC handles it once
|
||||
|
|
@ -358,6 +371,8 @@
|
|||
currentEditor = null;
|
||||
currentDirty = false;
|
||||
currentNodeRef = null;
|
||||
currentEtag = null;
|
||||
currentLastModified = null;
|
||||
}
|
||||
|
||||
function isDirty() {
|
||||
|
|
@ -377,10 +392,17 @@
|
|||
}
|
||||
dispose();
|
||||
|
||||
var text;
|
||||
var text, loadedEtag = null, loadedLastModified = null;
|
||||
try {
|
||||
var buf = await ctx.getArrayBuffer(node);
|
||||
text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
|
||||
if (ctx.getContentWithVersion) {
|
||||
var loaded = await ctx.getContentWithVersion(node);
|
||||
text = new TextDecoder('utf-8', { fatal: false }).decode(loaded.buf);
|
||||
loadedEtag = loaded.etag;
|
||||
loadedLastModified = loaded.lastModified;
|
||||
} else {
|
||||
var buf = await ctx.getArrayBuffer(node);
|
||||
text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
|
||||
}
|
||||
} catch (e) {
|
||||
container.innerHTML =
|
||||
'<div class="preview-empty" style="color:var(--danger)">'
|
||||
|
|
@ -483,6 +505,8 @@
|
|||
currentEditor = editor;
|
||||
currentNodeRef = node;
|
||||
currentDirty = false;
|
||||
currentEtag = loadedEtag;
|
||||
currentLastModified = loadedLastModified;
|
||||
|
||||
if (!writable) {
|
||||
saveBtn.disabled = true;
|
||||
|
|
@ -511,6 +535,56 @@
|
|||
markDirty(h !== initialHash);
|
||||
});
|
||||
|
||||
// Adopt the new server ETag + refresh the dirty baseline after a
|
||||
// successful write so save→edit→save doesn't false-conflict.
|
||||
async function markSaved(content, res) {
|
||||
if (currentEditor !== editor) return;
|
||||
if (res && res.etag) currentEtag = res.etag;
|
||||
initialHash = await hashContent(content);
|
||||
if (currentEditor !== editor) return;
|
||||
markDirty(false);
|
||||
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
|
||||
if (window.zddc && window.zddc.toast) {
|
||||
window.zddc.toast('Saved ' + node.name, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
// 412 → file changed on the server since load. Open the shared
|
||||
// conflict dialog instead of clobbering.
|
||||
async function resolveConflict(content) {
|
||||
var conflict = window.app.modules.conflict;
|
||||
var prev = window.app.modules.preview;
|
||||
if (!conflict || !prev) return;
|
||||
await conflict.open({
|
||||
filename: node.name,
|
||||
mineText: content,
|
||||
fetchTheirs: function () {
|
||||
return prev.getContentWithVersion(node).then(function (r) {
|
||||
return new TextDecoder('utf-8', { fatal: false }).decode(r.buf);
|
||||
});
|
||||
},
|
||||
onOverwrite: function () {
|
||||
return prev.getContentWithVersion(node).then(function (cur) {
|
||||
return saveContent(node, content, { etag: cur.etag, lastModified: cur.lastModified });
|
||||
}).then(function (res) { return markSaved(content, res); });
|
||||
},
|
||||
onReload: function () {
|
||||
markDirty(false);
|
||||
currentDirty = false;
|
||||
return prev.showFilePreview(node);
|
||||
},
|
||||
onSaveCopy: function () {
|
||||
return util.saveCopy(node, content, 'application/x-yaml; charset=utf-8')
|
||||
.then(function (name) {
|
||||
if (window.zddc && window.zddc.toast) {
|
||||
window.zddc.toast('Saved your version as ' + name, 'success');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
if (currentEditor === editor) statusEl.textContent = '';
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (saveBtn.disabled) return;
|
||||
// Re-check authority at click time, not via the mount-time
|
||||
|
|
@ -520,14 +594,17 @@
|
|||
var content = editor.getValue();
|
||||
try {
|
||||
statusEl.textContent = 'Saving…';
|
||||
await saveContent(node, content);
|
||||
initialHash = await hashContent(content);
|
||||
markDirty(false);
|
||||
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
|
||||
if (window.zddc && window.zddc.toast) {
|
||||
window.zddc.toast('Saved ' + node.name, 'success');
|
||||
}
|
||||
var res = await saveContent(node, content, {
|
||||
etag: currentEtag, lastModified: currentLastModified
|
||||
});
|
||||
await markSaved(content, res);
|
||||
} catch (e) {
|
||||
if (e && e.status === 412) {
|
||||
if (currentEditor !== editor) return;
|
||||
statusEl.textContent = 'Conflict — resolving…';
|
||||
await resolveConflict(content);
|
||||
return;
|
||||
}
|
||||
statusEl.textContent = 'Save failed: ' + (e.message || e);
|
||||
if (window.zddc && window.zddc.toast) {
|
||||
window.zddc.toast('Save failed: ' + (e.message || e), 'error');
|
||||
|
|
|
|||
|
|
@ -56,6 +56,30 @@
|
|||
throw new Error('no source for file');
|
||||
}
|
||||
|
||||
// Like getArrayBuffer, but also returns the server version token
|
||||
// ({etag, lastModified}) captured from the content GET. The editors use
|
||||
// it to send an If-Match precondition on save so a concurrent edit is
|
||||
// rejected (412) instead of silently clobbered. FS-Access mode has no
|
||||
// server version — etag/lastModified are null and the precondition is a
|
||||
// clean no-op (a single locally-picked file has no concurrency).
|
||||
async function getContentWithVersion(node) {
|
||||
if (state.source === 'server' && node.url) {
|
||||
var resp = await fetch(node.url, { credentials: 'same-origin' });
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
var buf = await resp.arrayBuffer();
|
||||
return {
|
||||
buf: buf,
|
||||
etag: resp.headers.get('ETag') || null,
|
||||
lastModified: resp.headers.get('Last-Modified') || null
|
||||
};
|
||||
}
|
||||
if (node.handle) {
|
||||
var f = await node.handle.getFile();
|
||||
return { buf: await f.arrayBuffer(), etag: null, lastModified: null };
|
||||
}
|
||||
throw new Error('no source for file');
|
||||
}
|
||||
|
||||
async function getBlobUrl(node) {
|
||||
// Server-served files (including zip members at "<…>.zip/<member>"
|
||||
// URLs) load straight from the server — preserves Content-Type
|
||||
|
|
@ -180,7 +204,7 @@
|
|||
window.app.modules.markdown &&
|
||||
typeof window.app.modules.markdown.render === 'function') {
|
||||
try {
|
||||
await window.app.modules.markdown.render(node, container, { getArrayBuffer: getArrayBuffer });
|
||||
await window.app.modules.markdown.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
|
||||
} catch (e) {
|
||||
renderError(container, 'Markdown render failed: ' + (e.message || e));
|
||||
}
|
||||
|
|
@ -193,7 +217,7 @@
|
|||
var yamlMod = window.app.modules.yamledit;
|
||||
if (yamlMod && yamlMod.handles(node)) {
|
||||
try {
|
||||
await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer });
|
||||
await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
|
||||
} catch (e) {
|
||||
renderError(container, 'YAML render failed: ' + (e.message || e));
|
||||
}
|
||||
|
|
@ -443,6 +467,9 @@
|
|||
// Tear down any live editor + blank the pane (rescope / popstate).
|
||||
clearPreview: clearPreview,
|
||||
// Expose for the markdown plugin so it can read file bytes.
|
||||
getArrayBuffer: getArrayBuffer
|
||||
getArrayBuffer: getArrayBuffer,
|
||||
// Like getArrayBuffer but also returns the {etag, lastModified}
|
||||
// version token — the editors use it for optimistic-concurrency saves.
|
||||
getContentWithVersion: getContentWithVersion
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -392,6 +392,14 @@
|
|||
+ '<span class="tree-name__icon">' + iconChar + extChip + '</span>'
|
||||
+ labelHtml(node)
|
||||
+ virtualHint
|
||||
// Kebab (⋯) — visible affordance that the row has actions; opens
|
||||
// the same context menu. Revealed on hover/selection/focus (CSS).
|
||||
// tabindex -1 keeps it out of the tab order (roving tabindex on
|
||||
// the rows); reachable via right-click / the keyboard menu key.
|
||||
+ '<button type="button" class="tree-row__kebab" tabindex="-1"'
|
||||
+ ' aria-label="Row actions">'
|
||||
+ window.zddc.icons.html('icon-ellipsis')
|
||||
+ '</button>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -90,33 +90,100 @@
|
|||
return false;
|
||||
}
|
||||
|
||||
// Write content back to a file's source. Local (FS-Access) folders are
|
||||
// Thrown by saveFile when the server rejects a write with 412
|
||||
// Precondition Failed — the file changed under us since we loaded it.
|
||||
// Callers branch on `.status === 412` to open the conflict UI instead
|
||||
// of treating it as a generic save failure.
|
||||
function ConflictError(message) {
|
||||
var e = new Error(message || 'Conflict: file changed on server');
|
||||
e.name = 'ConflictError';
|
||||
e.status = 412;
|
||||
return e;
|
||||
}
|
||||
|
||||
// Write content back to a file's source, returning { etag } (the new
|
||||
// server ETag, or null in FS-Access mode). Local (FS-Access) folders are
|
||||
// picked read-only, so the first write escalates to readwrite via
|
||||
// upload.ensureWritable (one permission prompt, then granted for the
|
||||
// session). contentType sets the PUT Content-Type for server files.
|
||||
// Throws when the source has no write target.
|
||||
async function saveFile(node, content, contentType) {
|
||||
//
|
||||
// opts (server mode only):
|
||||
// etag — send as `If-Match` so the master 412s if the file
|
||||
// changed since we observed this version (optimistic
|
||||
// concurrency; preferred — exact).
|
||||
// lastModified — fallback precondition sent as `If-Unmodified-Since`
|
||||
// (raw HTTP-date string) when no etag is available.
|
||||
// force — skip the precondition entirely (deliberate overwrite).
|
||||
//
|
||||
// Throws ConflictError (.status===412) on a precondition failure, a
|
||||
// plain Error('HTTP <status>') on any other non-2xx, or "no write
|
||||
// target" when the source is read-only.
|
||||
async function saveFile(node, content, contentType, opts) {
|
||||
opts = opts || {};
|
||||
if (node.handle && typeof node.handle.createWritable === 'function') {
|
||||
var up = window.app.modules.upload;
|
||||
if (up && up.ensureWritable) await up.ensureWritable();
|
||||
var writable = await node.handle.createWritable();
|
||||
await writable.write(content);
|
||||
await writable.close();
|
||||
return;
|
||||
return { etag: null };
|
||||
}
|
||||
if (node.url && window.app.state.source === 'server') {
|
||||
var headers = { 'Content-Type': contentType };
|
||||
if (!opts.force) {
|
||||
if (opts.etag) headers['If-Match'] = opts.etag;
|
||||
else if (opts.lastModified) headers['If-Unmodified-Since'] = opts.lastModified;
|
||||
}
|
||||
var resp = await fetch(node.url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': contentType },
|
||||
headers: headers,
|
||||
body: content,
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (resp.status === 412) throw ConflictError();
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
return;
|
||||
return { etag: resp.headers.get('ETag') || null };
|
||||
}
|
||||
throw new Error('No write target for this file (read-only source).');
|
||||
}
|
||||
|
||||
// Write `content` to a NEW sibling of `node` named
|
||||
// `<stem>-conflict-<YYYYMMDD-HHMMSS>.<ext>` (server mode only), so a
|
||||
// conflicting edit can be parked without losing either version. Probes
|
||||
// for a free name (numeric-suffix bump, capped) so a same-second retry
|
||||
// doesn't clobber a prior copy. Returns the created filename. The PUT
|
||||
// uses no precondition — it's a brand-new path.
|
||||
async function saveCopy(node, content, contentType) {
|
||||
if (!(node.url && window.app.state.source === 'server')) {
|
||||
throw new Error('Save a copy is only available for server files.');
|
||||
}
|
||||
var split = window.zddc.splitExtension(node.name);
|
||||
var stem = split.name || node.name;
|
||||
var ext = split.extension;
|
||||
var d = new Date();
|
||||
var stamp = d.getFullYear() + pad2(d.getMonth() + 1) + pad2(d.getDate())
|
||||
+ '-' + pad2(d.getHours()) + pad2(d.getMinutes()) + pad2(d.getSeconds());
|
||||
var base = stem + '-conflict-' + stamp;
|
||||
var slash = node.url.lastIndexOf('/');
|
||||
var dirUrl = slash >= 0 ? node.url.slice(0, slash + 1) : '';
|
||||
var name = '', candidateUrl = '';
|
||||
for (var i = 0; i < 20; i++) {
|
||||
name = window.zddc.joinExtension(base + (i ? '-' + (i + 1) : ''), ext);
|
||||
candidateUrl = dirUrl + encodeURIComponent(name);
|
||||
var head;
|
||||
try {
|
||||
head = await fetch(candidateUrl, { method: 'HEAD', credentials: 'same-origin' });
|
||||
} catch (_e) {
|
||||
break; // network unknown — attempt the write rather than spin
|
||||
}
|
||||
if (head.status === 404) break; // free slot
|
||||
if (head.status !== 200) break; // HEAD unsupported / odd — attempt anyway
|
||||
if (i === 19) throw new Error('Could not find a free filename for the copy.');
|
||||
}
|
||||
await saveFile({ url: candidateUrl, name: name, ext: ext }, content, contentType, { force: true });
|
||||
return name;
|
||||
}
|
||||
|
||||
window.app.modules.util = {
|
||||
escapeHtml: escapeHtml,
|
||||
hashContent: hashContent,
|
||||
|
|
@ -126,6 +193,8 @@
|
|||
fetchAccessEmails: fetchAccessEmails,
|
||||
fmtSize: fmtSize,
|
||||
isZipMemberNode: isZipMemberNode,
|
||||
saveFile: saveFile
|
||||
saveFile: saveFile,
|
||||
saveCopy: saveCopy,
|
||||
ConflictError: ConflictError
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -73,6 +73,25 @@
|
|||
aria-label="Filter the tree by name, tracking number, status, revision, or title"
|
||||
autocomplete="off"
|
||||
spellcheck="false">
|
||||
<div class="tree-pane__controls">
|
||||
<button type="button" id="newFolderBtn" class="btn btn-sm btn--subtle"
|
||||
title="New folder in the current directory">New folder</button>
|
||||
<button type="button" id="newFileBtn" class="btn btn-sm btn--subtle"
|
||||
title="New markdown file in the current directory">New file</button>
|
||||
<label class="tp-control" title="Sort order">
|
||||
<span class="tp-control__label">Sort</span>
|
||||
<select id="sortSelect" aria-label="Sort order">
|
||||
<option value="name:1">Name</option>
|
||||
<option value="date:-1">Modified</option>
|
||||
<option value="size:-1">Size</option>
|
||||
<option value="ext:1">Type</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="tp-control tp-control--check" title="Show hidden files (dot/underscore names)">
|
||||
<input type="checkbox" id="showHiddenChk">
|
||||
<span class="tp-control__label">Hidden</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tree-pane__body" id="treeBody" role="tree" aria-label="Files"></div>
|
||||
</div>
|
||||
|
|
@ -126,10 +145,16 @@
|
|||
<dd>Recursive expand or collapse — the whole subtree.</dd>
|
||||
<dt>Click a file</dt>
|
||||
<dd>Preview it in the right pane.</dd>
|
||||
<dt>Right-click any row</dt>
|
||||
<dd>Opens a context menu with Open, Download, Copy path, Sort, and
|
||||
folder-specific actions. Toggle items show a ✓ when active; submenus
|
||||
open on hover.</dd>
|
||||
<dt>Row actions — right-click, ⋯, or the menu key</dt>
|
||||
<dd>Right-click a row, click the ⋯ button that appears on hover, or
|
||||
press the menu key (or Shift+F10) on the selected row. The menu only
|
||||
lists actions that apply to that item; actions you can see but can't
|
||||
use yet (you lack write/create access, or they're for project or site
|
||||
administrators) appear greyed with a reason — so you can see what a
|
||||
higher role unlocks.</dd>
|
||||
<dt>Toolbar (above the tree)</dt>
|
||||
<dd>Filter, New folder / New file (created in the current directory),
|
||||
Sort order, and Show hidden files all live here.</dd>
|
||||
<dt>⤴ Pop out</dt>
|
||||
<dd>Open the current preview in a separate window — useful for a second
|
||||
monitor.</dd>
|
||||
|
|
|
|||
6
build
6
build
|
|
@ -218,6 +218,12 @@ fi
|
|||
cp "$SCRIPT_DIR/tables/dist/tables.html" "$SCRIPT_DIR/zddc/internal/handler/tables.html"
|
||||
echo "Populated zddc/internal/handler/tables.html for //go:embed"
|
||||
|
||||
# Mirror the canonical conversion templates (pandoc/templates/) into the convert
|
||||
# package's embed dir so //go:embed picks up the current bytes. pandoc/templates/
|
||||
# is the single source of truth; the embed copy is a build artifact guarded by
|
||||
# convert.TestEmbeddedTemplatesMatchSource. Runs on every build (incl. plain dev).
|
||||
sync_pandoc_templates "$SCRIPT_DIR/pandoc/templates" "$SCRIPT_DIR/zddc/internal/convert/templates"
|
||||
|
||||
if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
|
||||
|
||||
# Assemble the embedded versions manifest from the per-tool .label sidecars
|
||||
|
|
|
|||
163
pandoc/README.md
163
pandoc/README.md
|
|
@ -4,41 +4,52 @@ A collection of tools for converting Markdown documents to HTML with a professio
|
|||
|
||||
## Server-side conversion (`zddc-server`)
|
||||
|
||||
zddc-server can offer the same conversions on demand: a `.md` file in any
|
||||
served directory becomes downloadable as `.docx`, `.html`, and `.pdf` via the
|
||||
`?convert=` query parameter, surfaced as Download buttons in the browse app's
|
||||
markdown editor.
|
||||
> The shell scripts in this folder are standalone CLI/batch tools. `zddc-server`
|
||||
> implements its **own** on-demand conversion (Go package `zddc/internal/convert`)
|
||||
> and does **not** call these scripts. It does, however, reuse the same
|
||||
> `templates/` (embedded at build time). See AGENTS.md → "Server-side document
|
||||
> conversion" for the authoritative reference.
|
||||
|
||||
The server shells out to two upstream container images, pulling each on
|
||||
first use via `--pull=missing`. No custom image build is required —
|
||||
operators just install `podman` (preferred) or `docker`, and the first
|
||||
conversion request pulls the image:
|
||||
zddc-server can render any served `.md` on demand: requesting the sibling URL
|
||||
`<path>/foo.docx` (or `.html` / `.pdf`) returns the converted bytes — no query
|
||||
string. A real on-disk file of that name always wins; the virtual conversion
|
||||
only fires when the requested file doesn't exist but `foo.md` does. The browse
|
||||
app's markdown editor surfaces these as DOCX/HTML/PDF download links (auto-saving
|
||||
a dirty buffer first so the output matches what's on screen).
|
||||
|
||||
- `docker.io/pandoc/latex:latest` — MD → DOCX and MD → HTML
|
||||
(override: `--convert-pandoc-image=` or `ZDDC_CONVERT_PANDOC_IMAGE`;
|
||||
switch to `docker.io/pandoc/core:latest` for a ~90% size reduction
|
||||
if you don't need pandoc's native LaTeX-PDF path)
|
||||
- `docker.io/zenika/alpine-chrome:latest` — HTML → PDF
|
||||
(override: `--convert-chromium-image=` or `ZDDC_CONVERT_CHROMIUM_IMAGE`)
|
||||
**Architecture.** The Go code does the minimum — it `exec`s `pandoc` and
|
||||
`chromium-browser` directly. The sandbox and resource caps live in the runtime
|
||||
**image**, where `/usr/local/bin/{pandoc,chromium-browser}` are wrapper scripts
|
||||
that run the real binary inside a per-conversion bubblewrap sandbox
|
||||
(`--unshare-all`, read-only binds, `--tmpfs /tmp`, `--clearenv`) under cgroup v2
|
||||
memory/PID caps. I/O is via stdin/stdout plus a per-call scratch dir. There is no
|
||||
container runtime and no image pulling at request time.
|
||||
|
||||
The PDF flow is two-stage: pandoc renders the markdown through
|
||||
`viewer-template.html` to standalone HTML, then headless Chromium
|
||||
prints that HTML to PDF. This preserves the existing print-media CSS
|
||||
authored for the viewer template rather than going through pandoc's
|
||||
LaTeX template.
|
||||
The PDF flow is two-stage: pandoc renders the markdown through the selected
|
||||
`templates/<doctype>.html` to standalone HTML, then headless Chromium prints that
|
||||
HTML to PDF — preserving the template's print-media CSS rather than going through
|
||||
pandoc's LaTeX template.
|
||||
|
||||
If neither podman nor docker is on PATH the endpoint serves 503 with
|
||||
a clear "no container runtime" message. Engine choice is overridable
|
||||
via `--convert-engine=` or `ZDDC_CONVERT_ENGINE`.
|
||||
Converted bytes are cached at `<dir>/.zddc.d/converted/<base>.<ext>` with mtime
|
||||
synced to the source, so a fresh cache hit is a stat-and-serve with no `exec`.
|
||||
A PUT/DELETE/MOVE on the source `.md` purges the sidecars. Per-project header
|
||||
metadata (client/project/contractor/project_number) comes from the `.zddc`
|
||||
`convert:` cascade; title/tracking_number/revision/status are derived from the
|
||||
filename via `zddc.ParseFilename`.
|
||||
|
||||
Resource limits are per-container and configurable: `--convert-mem-mib`
|
||||
(default 512), `--convert-cpus` (default "2"), `--convert-pids`
|
||||
(default 100), `--convert-timeout` (default 30s).
|
||||
Relevant flags (defaults in parens):
|
||||
|
||||
Each conversion runs in a throw-away container with
|
||||
`--rm --network=none --read-only --tmpfs=/tmp --cap-drop=ALL
|
||||
--security-opt=no-new-privileges` plus a bind-mounted scratch dir
|
||||
for I/O (read-only for the template; read-write for the PDF output).
|
||||
- `--convert-pandoc-binary` (`pandoc`) / `--convert-chromium-binary`
|
||||
(`chromium-browser`; `chromium` on Debian) — PATH-resolved name or absolute path
|
||||
- `--convert-scratch-dir` (`$TMPDIR`) — host scratch root for template + intermediates
|
||||
- `--convert-mem-mib` (`1024`) — per-conversion memory cap (cgroup `memory.max`)
|
||||
- `--convert-pids` (`256`) — per-conversion PID cap (cgroup `pids.max`)
|
||||
- `--convert-timeout` (`60s`) — per-conversion wall clock (Go `context.WithTimeout`)
|
||||
|
||||
If `pandoc`/`chromium` aren't on PATH (e.g. running zddc-server outside the runtime
|
||||
image) the endpoint serves 503 with a `Retry-After`; the rest of the server keeps
|
||||
working. Running against raw pandoc/chromium with no wrapper gives a working but
|
||||
**unsandboxed** endpoint — fine for dev iteration.
|
||||
|
||||
## Features
|
||||
|
||||
|
|
@ -50,7 +61,15 @@ for I/O (read-only for the template; read-write for the PDF output).
|
|||
- **Template integration**: Automatically applies the viewer template
|
||||
- **Progress tracking**: Real-time conversion status and summary
|
||||
|
||||
### Professional Viewer Template (`viewer-template.html`)
|
||||
### Professional templates (`templates/`)
|
||||
|
||||
Named doctype templates — `report.html`, `letter.html`, `specification.html` —
|
||||
share `_head.html` / `_doc.html` / `_scripts.html` partials. A document selects one
|
||||
with a `template:` field in its YAML front matter (default `report`), and turns on
|
||||
legal-style heading numbering with `numbering: true` (default off). Both fields are
|
||||
read by pandoc straight from the front matter. Server deployments additionally
|
||||
resolve per-project/per-party overrides from `.zddc.d/templates/<name>.html`.
|
||||
|
||||
- **Modern responsive design**: Works on desktop, tablet, and mobile
|
||||
- **Table of Contents (TOC)**: Auto-generated sidebar navigation with smooth scrolling
|
||||
- **Print optimization**: Professional formatting for PDF generation
|
||||
|
|
@ -80,20 +99,18 @@ for I/O (read-only for the template; read-write for the PDF output).
|
|||
```
|
||||
|
||||
### Configuration (`zddc.conf`)
|
||||
Create a `zddc.conf` file in your project directory:
|
||||
```ini
|
||||
# Project metadata
|
||||
title = "Project Documentation"
|
||||
author = "Your Organization"
|
||||
date = "2024"
|
||||
|
||||
# Template settings
|
||||
template = "/path/to/viewer-template.html"
|
||||
css = "custom-styles.css"
|
||||
|
||||
# Output settings
|
||||
output_dir = "rendered"
|
||||
Create a `zddc.conf` file in your project directory. It is **sourced as shell**,
|
||||
so use `var="value"` syntax (no spaces around `=`). Only these four variables are
|
||||
read; all are optional and feed the document header via pandoc `--variable`:
|
||||
```sh
|
||||
contractor="Contractor Name" # contracting organization (header)
|
||||
client="Client Name" # client org (header, paired with project)
|
||||
project="Project Name" # full project name
|
||||
project_number="AR 28088" # shown in parentheses after the project name
|
||||
```
|
||||
The template path is discovered automatically (input dir → script dir →
|
||||
symlink target) or set per-run with `-T`; the output directory is set with `-o`.
|
||||
They are **not** `zddc.conf` keys.
|
||||
|
||||
### Directory Structure
|
||||
```
|
||||
|
|
@ -125,40 +142,12 @@ your-project/
|
|||
- **Tablet**: Collapsible sidebar with overlay
|
||||
- **Mobile**: Hamburger menu with full-screen TOC overlay
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Custom Templates
|
||||
You can customize the viewer template by:
|
||||
1. Copying `viewer-template.html` to your project
|
||||
2. Modifying the CSS and HTML structure
|
||||
3. Updating `zddc.conf` to point to your custom template
|
||||
|
||||
### Batch Processing
|
||||
For large document sets:
|
||||
```bash
|
||||
# Process all markdown files recursively
|
||||
find . -name "*.md" -exec ./convert -f -o rendered/ {} +
|
||||
|
||||
# Process specific document types
|
||||
./convert -f -o rendered/ *-SOW-*.md *-DBD-*.md
|
||||
```
|
||||
|
||||
### Integration with Build Systems
|
||||
The convert tool returns proper exit codes and can be integrated into CI/CD pipelines:
|
||||
```bash
|
||||
# In a build script
|
||||
if ./convert -f -o dist/ *.md; then
|
||||
echo "Documentation built successfully"
|
||||
else
|
||||
echo "Documentation build failed"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## File Types Supported
|
||||
|
||||
- **Input**: Markdown (`.md`) files with pandoc extensions
|
||||
- **Output**: HTML files with embedded CSS and JavaScript
|
||||
- **Input**: Markdown (`.md`), DOCX (`.docx`), and HTML (`.html`/`.htm`) files
|
||||
(auto-detected: DOCX→MD, MD→HTML, HTML→MD; override with `-t md|html|docx`).
|
||||
Direct DOCX→HTML is not supported — convert to MD first.
|
||||
- **Output**: HTML files with embedded CSS and JavaScript (plus MD and DOCX targets)
|
||||
- **Images**: Supports embedded images and diagrams
|
||||
- **Tables**: Full table support with print optimization
|
||||
- **Code**: Syntax highlighting for code blocks
|
||||
|
|
@ -172,29 +161,7 @@ fi
|
|||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
1. **Template not found**: Ensure `zddc.conf` points to correct template path
|
||||
1. **Template not found**: Keep the `templates/` directory beside the script (or input), or pass `-T /path/to/template.html`
|
||||
2. **Permission errors**: Make sure `convert` script is executable (`chmod +x convert`)
|
||||
3. **Missing output**: Check that output directory exists or use `-o` to create it
|
||||
4. **Print issues**: Use "Print to PDF" in browser for best results
|
||||
|
||||
### Performance
|
||||
- Large documents (>1000 pages) may take longer to render
|
||||
- Consider splitting very large documents into sections
|
||||
- Use batch processing for multiple files
|
||||
|
||||
## Examples
|
||||
|
||||
### Engineering Documentation
|
||||
Perfect for:
|
||||
- Design basis documents
|
||||
- Specifications and standards
|
||||
- Project requirements
|
||||
- Technical procedures
|
||||
- Quality documentation
|
||||
|
||||
### Features Optimized For
|
||||
- **Professional appearance**: Clean, corporate styling
|
||||
- **Technical content**: Tables, diagrams, code blocks
|
||||
- **Print output**: PDF generation with proper formatting
|
||||
- **Navigation**: Easy browsing of long documents
|
||||
- **Sharing**: URL fragments for referencing specific sections
|
||||
|
|
|
|||
109
pandoc/convert
109
pandoc/convert
|
|
@ -8,7 +8,8 @@ show_help() {
|
|||
echo " -f: Force overwrite existing output files"
|
||||
echo " -o: Output directory (default: same as input)"
|
||||
echo " -t: Target format (md, html, docx) - overrides auto-detection"
|
||||
echo " -T: Template file path (default: viewer-template.html)"
|
||||
echo " -T: Template file path (default: templates/<template>.html, where <template>"
|
||||
echo " comes from the doc's YAML front matter; falls back to templates/report.html)"
|
||||
echo " --no-toc: Skip table of contents generation"
|
||||
}
|
||||
|
||||
|
|
@ -124,6 +125,23 @@ SUCCESSFUL=0
|
|||
FAILED=0
|
||||
SKIPPED=0
|
||||
|
||||
# Parse a ZDDC filename stem (no extension) into ZDDC_TRACKING / ZDDC_REVISION /
|
||||
# ZDDC_STATUS / ZDDC_TITLE. Returns 0 on a full match, 1 otherwise.
|
||||
# Each field is extracted with its own sed backref rather than a delimiter-joined
|
||||
# string + cut, so a title containing the join character (e.g. '|') can't corrupt
|
||||
# the split.
|
||||
parse_zddc_filename() {
|
||||
local stem="$1"
|
||||
local sub='s/^\([^_]*\)_\([^ ]*\) *(\([^)]*\)) *- *\(.*\)$'
|
||||
# Gate on a full match before extracting (empty fields are otherwise ambiguous).
|
||||
printf '%s\n' "$stem" | grep -Eq '^[^_]+_[^ ]+ *\([^)]*\) *- *.+$' || return 1
|
||||
ZDDC_TRACKING=$(printf '%s\n' "$stem" | sed -n "${sub}/\\1/p")
|
||||
ZDDC_REVISION=$(printf '%s\n' "$stem" | sed -n "${sub}/\\2/p")
|
||||
ZDDC_STATUS=$(printf '%s\n' "$stem" | sed -n "${sub}/\\3/p")
|
||||
ZDDC_TITLE=$(printf '%s\n' "$stem" | sed -n "${sub}/\\4/p")
|
||||
return 0
|
||||
}
|
||||
|
||||
# Function to convert DOCX to Markdown
|
||||
convert_docx_to_md() {
|
||||
local INPUT="$1"
|
||||
|
|
@ -134,17 +152,15 @@ convert_docx_to_md() {
|
|||
local FILENAME_NO_EXT="$6"
|
||||
|
||||
# Convert using pandoc with proper extension stripping to temp file first
|
||||
if pandoc -f docx -t gfm --markdown-headings=atx --extract-media="$MEDIA_DIR" --wrap=none --standalone "$INPUT" -o "$TEMP_FILE"; then
|
||||
if pandoc -f docx -t gfm --markdown-headings=atx --extract-media="$MEDIA_DIR" --wrap=none "$INPUT" -o "$TEMP_FILE"; then
|
||||
|
||||
# Parse ZDDC filename pattern: trackingNumber_revision (status) - title.extension
|
||||
# Use sed to extract ZDDC components
|
||||
ZDDC_MATCH=$(echo "$FILENAME_NO_EXT" | sed -n 's/^\([^_]*\)_\([^ ]*\) *(\([^)]*\)) *- *\(.*\)$/\1|\2|\3|\4/p')
|
||||
if [ -n "$ZDDC_MATCH" ]; then
|
||||
TRACKING_NUMBER=$(echo "$ZDDC_MATCH" | cut -d'|' -f1)
|
||||
REVISION=$(echo "$ZDDC_MATCH" | cut -d'|' -f2)
|
||||
STATUS=$(echo "$ZDDC_MATCH" | cut -d'|' -f3)
|
||||
TITLE=$(echo "$ZDDC_MATCH" | cut -d'|' -f4)
|
||||
|
||||
if parse_zddc_filename "$FILENAME_NO_EXT"; then
|
||||
TRACKING_NUMBER="$ZDDC_TRACKING"
|
||||
REVISION="$ZDDC_REVISION"
|
||||
STATUS="$ZDDC_STATUS"
|
||||
TITLE="$ZDDC_TITLE"
|
||||
|
||||
echo " → ZDDC metadata detected:"
|
||||
echo " • Tracking: $TRACKING_NUMBER"
|
||||
echo " • Revision: $REVISION"
|
||||
|
|
@ -154,8 +170,8 @@ convert_docx_to_md() {
|
|||
# Create YAML front matter and combine with content
|
||||
{
|
||||
echo "---"
|
||||
echo "client: \"${CLIENT:-}\""
|
||||
echo "project: \"${PROJECT:-}\""
|
||||
echo "client: \"${client:-}\""
|
||||
echo "project: \"${project:-}\""
|
||||
echo "tracking_number: \"$TRACKING_NUMBER\""
|
||||
echo "revision: \"$REVISION\""
|
||||
echo "status: \"$STATUS\""
|
||||
|
|
@ -259,33 +275,44 @@ convert_md_to_html() {
|
|||
fi
|
||||
fi
|
||||
|
||||
# Default template discovery if no custom template or custom template not found
|
||||
# Default template discovery if no custom template or custom template not found.
|
||||
# Named templates live in a templates/ dir (report.html, letter.html,
|
||||
# specification.html, sharing _head/_doc/_scripts partials). The document
|
||||
# selects one via a `template:` field in its YAML front matter; default report.
|
||||
if [ -z "$CUSTOM_TEMPLATE" ]; then
|
||||
# Convert script directory to absolute path
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
|
||||
|
||||
# Check if script is a symlink and resolve target directory
|
||||
SCRIPT_TARGET_DIR=""
|
||||
if [ -L "$0" ]; then
|
||||
# Script is a symlink - resolve the target fully
|
||||
# readlink -f is available on Linux with GNU coreutils
|
||||
SCRIPT_TARGET=$(readlink -f "$0")
|
||||
SCRIPT_TARGET_DIR=$(dirname "$SCRIPT_TARGET")
|
||||
fi
|
||||
|
||||
# Template search order: input dir, script dir, symlink target dir
|
||||
if [ -f "$INPUT_DIR/viewer-template.html" ]; then
|
||||
TEMPLATE_ABS="$INPUT_DIR/viewer-template.html"
|
||||
echo " → Using template from input directory: $TEMPLATE_ABS"
|
||||
elif [ -f "$SCRIPT_DIR/viewer-template.html" ]; then
|
||||
TEMPLATE_ABS="$SCRIPT_DIR/viewer-template.html"
|
||||
echo " → Using template from script directory: $TEMPLATE_ABS"
|
||||
elif [ -n "$SCRIPT_TARGET_DIR" ] && [ -f "$SCRIPT_TARGET_DIR/viewer-template.html" ]; then
|
||||
TEMPLATE_ABS="$SCRIPT_TARGET_DIR/viewer-template.html"
|
||||
echo " → Using template from symlink target directory: $TEMPLATE_ABS"
|
||||
else
|
||||
echo " ⚠ Warning: viewer-template.html not found, using pandoc default template"
|
||||
TEMPLATE_ABS=""
|
||||
|
||||
# Template name from the doc's front matter (sanitized to a bare basename).
|
||||
TEMPLATE_NAME=$(sed -n '/^---[[:space:]]*$/,/^---[[:space:]]*$/ s/^template:[[:space:]]*"\{0,1\}\([A-Za-z0-9_-]\{1,\}\)"\{0,1\}[[:space:]]*$/\1/p' "$INPUT_ABS" | head -1)
|
||||
[ -n "$TEMPLATE_NAME" ] || TEMPLATE_NAME="report"
|
||||
|
||||
# Search order: input dir, script dir, symlink target dir — each a templates/
|
||||
# subdir. Use absolute paths since pandoc runs after a cd into the input dir.
|
||||
INPUT_DIR_ABS=$(dirname "$INPUT_ABS")
|
||||
TEMPLATE_ABS=""
|
||||
for _tdir in "$INPUT_DIR_ABS/templates" "$SCRIPT_DIR/templates" "$SCRIPT_TARGET_DIR/templates"; do
|
||||
[ -n "$_tdir" ] || continue
|
||||
if [ -f "$_tdir/$TEMPLATE_NAME.html" ]; then
|
||||
TEMPLATE_ABS="$_tdir/$TEMPLATE_NAME.html"
|
||||
echo " → Using template: $TEMPLATE_ABS"
|
||||
break
|
||||
elif [ -f "$_tdir/report.html" ]; then
|
||||
TEMPLATE_ABS="$_tdir/report.html"
|
||||
echo " ⚠ Template '$TEMPLATE_NAME' not found; using $TEMPLATE_ABS"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$TEMPLATE_ABS" ]; then
|
||||
echo " ⚠ Warning: templates/ not found, using pandoc default template"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
|
@ -293,8 +320,8 @@ convert_md_to_html() {
|
|||
ORIGINAL_DIR=$(pwd)
|
||||
cd "$INPUT_DIR"
|
||||
|
||||
# Build pandoc command using positional arguments (安全方式,无 eval)
|
||||
# 以空格分隔的参数数组,避免 shell 注入
|
||||
# Build pandoc command as an argument array (safe form, no eval — each value
|
||||
# is a separate array element so it can't be re-split or injected by the shell).
|
||||
PANDOC_ARGS=()
|
||||
PANDOC_ARGS+=("--from" "markdown+yaml_metadata_block")
|
||||
PANDOC_ARGS+=("--standalone")
|
||||
|
|
@ -315,13 +342,12 @@ convert_md_to_html() {
|
|||
|
||||
# Extract ZDDC metadata from filename for template variables
|
||||
FILENAME_NO_EXT=$(basename "$INPUT" .md)
|
||||
ZDDC_MATCH=$(echo "$FILENAME_NO_EXT" | sed -n 's/^\([^_]*\)_\([^ ]*\) *(\([^)]*\)) *- *\(.*\)$/\1|\2|\3|\4/p')
|
||||
if [ -n "$ZDDC_MATCH" ]; then
|
||||
TRACKING_NUMBER=$(echo "$ZDDC_MATCH" | cut -d'|' -f1)
|
||||
REVISION=$(echo "$ZDDC_MATCH" | cut -d'|' -f2)
|
||||
STATUS=$(echo "$ZDDC_MATCH" | cut -d'|' -f3)
|
||||
TITLE=$(echo "$ZDDC_MATCH" | cut -d'|' -f4)
|
||||
|
||||
if parse_zddc_filename "$FILENAME_NO_EXT"; then
|
||||
TRACKING_NUMBER="$ZDDC_TRACKING"
|
||||
REVISION="$ZDDC_REVISION"
|
||||
STATUS="$ZDDC_STATUS"
|
||||
TITLE="$ZDDC_TITLE"
|
||||
|
||||
# Pass ZDDC variables to template (each as separate args to avoid injection)
|
||||
PANDOC_ARGS+=("--variable" "tracking_number=$TRACKING_NUMBER")
|
||||
PANDOC_ARGS+=("--variable" "revision=$REVISION")
|
||||
|
|
@ -357,11 +383,10 @@ convert_md_to_html() {
|
|||
PANDOC_ARGS+=("--variable" "no-toc=true")
|
||||
fi
|
||||
|
||||
PANDOC_ARGS+=("--section-divs")
|
||||
PANDOC_ARGS+=("--id-prefix=")
|
||||
# (--section-divs already added above)
|
||||
PANDOC_ARGS+=("--html-q-tags")
|
||||
|
||||
# Run pandoc with positional arguments (安全方式)
|
||||
|
||||
# Run pandoc with positional arguments (safe form, no eval)
|
||||
# All variables passed as separate arguments to avoid shell injection
|
||||
if pandoc "$(basename "$INPUT_ABS")" -o "$OUTPUT_ABS" "${PANDOC_ARGS[@]}"; then
|
||||
|
||||
|
|
|
|||
|
|
@ -11,10 +11,10 @@ NO_TOC=false
|
|||
show_help() {
|
||||
echo "Batch Markdown Diff Converter"
|
||||
echo "Compares pairs of markdown files and outputs HTML diffs using the same template as convert script"
|
||||
echo "Usage: $0 [-f] [-o outputdir] [-T template] [--no-toc] file1_rev_a.md file1_rev_b.md [file2_rev_a.md file1_rev_b.md ...]"
|
||||
echo "Usage: $0 [-f] [-o outputdir] [-T template] [--no-toc] file1_rev_a.md file1_rev_b.md [file2_rev_a.md file2_rev_b.md ...]"
|
||||
echo " -f: Force overwrite existing output files"
|
||||
echo " -o: Output directory (default: same as first input file)"
|
||||
echo " -T: Template file path (default: viewer-template.html)"
|
||||
echo " -T: Template file path (default: templates/report.html)"
|
||||
echo " --no-toc: Skip table of contents generation"
|
||||
echo ""
|
||||
echo "Arguments:"
|
||||
|
|
@ -350,58 +350,29 @@ while [ $# -gt 0 ]; do
|
|||
fi
|
||||
|
||||
# Load ZDDC configuration from first file's directory
|
||||
# (load_zddc_config logs the path itself, but only when a config is found)
|
||||
FILE1_DIR=$(dirname "$FILE1")
|
||||
load_zddc_config "$FILE1_DIR"
|
||||
|
||||
echo " → Loading ZDDC configuration from: $FILE1_DIR/zddc.conf"
|
||||
|
||||
# Determine template to use
|
||||
|
||||
# Determine template to use. Diffs render with the report template (its
|
||||
# _head/_doc/_scripts partials live alongside it in templates/, so pandoc
|
||||
# resolves them from the template's own directory).
|
||||
TEMPLATE_ABS=""
|
||||
if [ -n "$CUSTOM_TEMPLATE" ]; then
|
||||
if [ -f "$CUSTOM_TEMPLATE" ]; then
|
||||
TEMPLATE_ABS="$CUSTOM_TEMPLATE"
|
||||
echo " → Using custom template: $TEMPLATE_ABS"
|
||||
else
|
||||
echo " → Custom template not found: $CUSTOM_TEMPLATE"
|
||||
echo " → Falling back to default template"
|
||||
echo " → Custom template not found: $CUSTOM_TEMPLATE; falling back to default"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for symlinked template in current directory
|
||||
if [ -z "$TEMPLATE_ABS" ] && [ -L "viewer-template.html" ]; then
|
||||
TEMPLATE_TARGET=$(readlink "viewer-template.html")
|
||||
if [ -f "$TEMPLATE_TARGET" ]; then
|
||||
TEMPLATE_ABS="$TEMPLATE_TARGET"
|
||||
echo " → Using template from symlink target: $TEMPLATE_ABS"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for template in current directory
|
||||
if [ -z "$TEMPLATE_ABS" ] && [ -f "viewer-template.html" ]; then
|
||||
TEMPLATE_ABS="$(pwd)/viewer-template.html"
|
||||
echo " → Using template from current directory: $TEMPLATE_ABS"
|
||||
fi
|
||||
|
||||
# Resolve template to absolute path if it's relative
|
||||
if [ -n "$TEMPLATE_ABS" ] && [ "${TEMPLATE_ABS:0:1}" != "/" ]; then
|
||||
if [ -f "$TEMPLATE_ABS" ]; then
|
||||
TEMPLATE_ABS="$(pwd)/$TEMPLATE_ABS"
|
||||
elif [ -f "$SCRIPT_DIR/$TEMPLATE_ABS" ]; then
|
||||
TEMPLATE_ABS="$SCRIPT_DIR/$TEMPLATE_ABS"
|
||||
elif [ -f "$SCRIPT_TARGET_DIR/$TEMPLATE_ABS" ]; then
|
||||
TEMPLATE_ABS="$SCRIPT_TARGET_DIR/$TEMPLATE_ABS"
|
||||
elif [ -f "$SCRIPT_DIR/viewer-template.html" ]; then
|
||||
TEMPLATE_ABS="$SCRIPT_DIR/viewer-template.html"
|
||||
elif [ -f "$SCRIPT_TARGET_DIR/viewer-template.html" ]; then
|
||||
TEMPLATE_ABS="$SCRIPT_TARGET_DIR/viewer-template.html"
|
||||
fi
|
||||
elif [ -z "$TEMPLATE_ABS" ]; then
|
||||
# Fallback to script-relative template discovery
|
||||
if [ -f "$SCRIPT_DIR/viewer-template.html" ]; then
|
||||
TEMPLATE_ABS="$SCRIPT_DIR/viewer-template.html"
|
||||
elif [ -f "$SCRIPT_TARGET_DIR/viewer-template.html" ]; then
|
||||
TEMPLATE_ABS="$SCRIPT_TARGET_DIR/viewer-template.html"
|
||||
fi
|
||||
if [ -z "$TEMPLATE_ABS" ]; then
|
||||
for _tdir in "$SCRIPT_DIR/templates" "$SCRIPT_TARGET_DIR/templates"; do
|
||||
if [ -f "$_tdir/report.html" ]; then
|
||||
TEMPLATE_ABS="$_tdir/report.html"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Create temp file for pandiff output
|
||||
|
|
@ -423,11 +394,7 @@ while [ $# -gt 0 ]; do
|
|||
|
||||
echo " ✓ Diff generated successfully"
|
||||
echo "Stage 2: Adding TOC and styling with pandoc..."
|
||||
|
||||
# Extract revision info from filenames for metadata
|
||||
REV_A=$(basename "$FILE1" .md | sed 's/.*_\([^_]*\)$/\1/')
|
||||
REV_B=$(basename "$FILE2" .md | sed 's/.*_\([^_]*\)$/\1/')
|
||||
|
||||
|
||||
# Extract metadata from both files (safe - no eval, uses heredoc)
|
||||
{
|
||||
# Extract YAML frontmatter and parse fields safely
|
||||
|
|
@ -437,7 +404,6 @@ while [ $# -gt 0 ]; do
|
|||
rev1_revision=$(grep '^revision:' "$TEMP_METADATA_REV1" | sed 's/^revision: *"\(.*\)"$/\1/' | head -1)
|
||||
rev1_status=$(grep '^status:' "$TEMP_METADATA_REV1" | sed 's/^status: *"\(.*\)"$/\1/' | head -1)
|
||||
rev1_project=$(grep '^project:' "$TEMP_METADATA_REV1" | sed 's/^project: *"\(.*\)"$/\1/' | head -1)
|
||||
rev1_date=$(grep '^date:' "$TEMP_METADATA_REV1" | sed 's/^date: *"\(.*\)"$/\1/' | head -1)
|
||||
}
|
||||
{
|
||||
awk '/^---$/{if(NR==1){p=1}else{p=0}} p && !/^---$/{print}' "$FILE2" > "$TEMP_METADATA_REV2"
|
||||
|
|
@ -446,7 +412,6 @@ while [ $# -gt 0 ]; do
|
|||
rev2_revision=$(grep '^revision:' "$TEMP_METADATA_REV2" | sed 's/^revision: *"\(.*\)"$/\1/' | head -1)
|
||||
rev2_status=$(grep '^status:' "$TEMP_METADATA_REV2" | sed 's/^status: *"\(.*\)"$/\1/' | head -1)
|
||||
rev2_project=$(grep '^project:' "$TEMP_METADATA_REV2" | sed 's/^project: *"\(.*\)"$/\1/' | head -1)
|
||||
rev2_date=$(grep '^date:' "$TEMP_METADATA_REV2" | sed 's/^date: *"\(.*\)"$/\1/' | head -1)
|
||||
}
|
||||
|
||||
# Clean up metadata temp files
|
||||
|
|
@ -456,8 +421,9 @@ while [ $# -gt 0 ]; do
|
|||
generate_diff_header() {
|
||||
local header_html=""
|
||||
|
||||
# Project title (should be same for both)
|
||||
header_html="<div class=\"header-line client-project\">$rev2_project (AR 28088)</div>"
|
||||
# Project title (should be same for both). Append the project number from
|
||||
# zddc.conf when set, e.g. "Project Name (AR 28088)"; omit the parens otherwise.
|
||||
header_html="<div class=\"header-line client-project\">${rev2_project}${project_number:+ ($project_number)}</div>"
|
||||
|
||||
# Document title with diff
|
||||
if [ "$rev1_title" != "$rev2_title" ]; then
|
||||
|
|
@ -490,7 +456,7 @@ while [ $# -gt 0 ]; do
|
|||
|
||||
# Add draft marker if revision contains ~
|
||||
if echo "$rev2_revision" | grep -q "~"; then
|
||||
header_html="$header_html<div class=\"header-line metadata-line draft-line\"><span class=\"draft-status\">[DRAFT Generated at $(date '+%B %d, %Y at %I:%M:%S %p %Z')]</span></div>"
|
||||
header_html="$header_html<div class=\"header-line metadata-line draft-line\"><span class=\"draft-status\">[DRAFT Generated at $(LC_TIME=C date '+%B %d, %Y at %I:%M:%S %p %Z')]</span></div>"
|
||||
fi
|
||||
|
||||
echo "$header_html"
|
||||
|
|
@ -498,35 +464,40 @@ while [ $# -gt 0 ]; do
|
|||
|
||||
DIFF_HEADER_HTML=$(generate_diff_header)
|
||||
|
||||
# Generate timestamp for conversion
|
||||
GENERATION_TIME=$(date '+%B %d, %Y at %I:%M:%S %p %Z')
|
||||
|
||||
# Generate timestamp for conversion (force English locale, matching convert)
|
||||
GENERATION_TIME=$(LC_TIME=C date '+%B %d, %Y at %I:%M:%S %p %Z')
|
||||
|
||||
# Set resource path to second file directory for resource resolution
|
||||
FILE2_DIR=$(dirname "$FILE2")
|
||||
|
||||
# Escape HTML for safe shell usage
|
||||
ESCAPED_HEADER_HTML=$(printf '%s' "$DIFF_HEADER_HTML" | sed 's/"/\\"/g')
|
||||
|
||||
# Build pandoc command as array (not string with eval)
|
||||
|
||||
# Build pandoc command as array (not string with eval). Header HTML is passed
|
||||
# as a single array element below, so no shell escaping is needed — escaping the
|
||||
# quotes here would leak backslashes into the rendered output.
|
||||
PANDOC_ARGS=(
|
||||
"pandoc" "$TEMP_DIFF" "-o" "$OUTPUT_FILE"
|
||||
"--from" "html"
|
||||
"--standalone"
|
||||
"--template=$TEMPLATE_ABS"
|
||||
)
|
||||
|
||||
|
||||
# Only pass --template when one was actually found; pandoc errors on an empty
|
||||
# --template= value, so fall back to its default template otherwise.
|
||||
if [ -n "$TEMPLATE_ABS" ]; then
|
||||
PANDOC_ARGS+=("--template=$TEMPLATE_ABS")
|
||||
else
|
||||
echo " ⚠ Warning: templates/report.html not found, using pandoc default template"
|
||||
fi
|
||||
|
||||
# Add TOC args if not disabled
|
||||
if [ "$NO_TOC" != "true" ]; then
|
||||
PANDOC_ARGS+=("--toc" "--toc-depth=3")
|
||||
fi
|
||||
|
||||
PANDOC_ARGS+=(
|
||||
"--css=$SCRIPT_DIR/custom.css"
|
||||
"--resource-path=$FILE2_DIR"
|
||||
"--metadata" "title=$rev2_title"
|
||||
"--metadata" "generation_time=$GENERATION_TIME"
|
||||
"--metadata" "diff_mode=true"
|
||||
"--metadata" "custom_header=$ESCAPED_HEADER_HTML"
|
||||
"--metadata" "custom_header=$DIFF_HEADER_HTML"
|
||||
)
|
||||
|
||||
# Add ZDDC configuration variables from zddc.conf (only once)
|
||||
|
|
@ -548,7 +519,7 @@ while [ $# -gt 0 ]; do
|
|||
PANDOC_ARGS+=("--variable" "no-toc=true")
|
||||
fi
|
||||
|
||||
PANDOC_ARGS+=("--section-divs" "--id-prefix=" "--html-q-tags")
|
||||
PANDOC_ARGS+=("--section-divs" "--html-q-tags")
|
||||
|
||||
# Execute pandoc via array (no eval)
|
||||
if "${PANDOC_ARGS[@]}"; then
|
||||
|
|
|
|||
|
|
@ -1,163 +0,0 @@
|
|||
/*
|
||||
* Legal-style heading numbering for ZDDC documents
|
||||
* Adds hierarchical numbering like 1, 1.1, 1.1.1, etc.
|
||||
*/
|
||||
|
||||
/* Reset counters at document level */
|
||||
.document-content {
|
||||
counter-reset: h1-counter;
|
||||
}
|
||||
|
||||
/* H1 counters */
|
||||
h1 {
|
||||
counter-reset: h2-counter h3-counter h4-counter h5-counter h6-counter;
|
||||
counter-increment: h1-counter;
|
||||
}
|
||||
|
||||
h1::before {
|
||||
content: counter(h1-counter) ". ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* H2 counters */
|
||||
h2 {
|
||||
counter-reset: h3-counter h4-counter h5-counter h6-counter;
|
||||
counter-increment: h2-counter;
|
||||
}
|
||||
|
||||
h2::before {
|
||||
content: counter(h1-counter) "." counter(h2-counter) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* H3 counters */
|
||||
h3 {
|
||||
counter-reset: h4-counter h5-counter h6-counter;
|
||||
counter-increment: h3-counter;
|
||||
}
|
||||
|
||||
h3::before {
|
||||
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* H4 counters */
|
||||
h4 {
|
||||
counter-reset: h5-counter h6-counter;
|
||||
counter-increment: h4-counter;
|
||||
}
|
||||
|
||||
h4::before {
|
||||
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* H5 counters */
|
||||
h5 {
|
||||
counter-reset: h6-counter;
|
||||
counter-increment: h5-counter;
|
||||
}
|
||||
|
||||
h5::before {
|
||||
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) "." counter(h5-counter) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* H6 counters */
|
||||
h6 {
|
||||
counter-increment: h6-counter;
|
||||
}
|
||||
|
||||
h6::before {
|
||||
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) "." counter(h5-counter) "." counter(h6-counter) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* TOC numbering to match document headings */
|
||||
.toc {
|
||||
counter-reset: toc-h1;
|
||||
}
|
||||
|
||||
.toc ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.toc > ul > li {
|
||||
counter-increment: toc-h1;
|
||||
counter-reset: toc-h2 toc-h3 toc-h4 toc-h5 toc-h6;
|
||||
}
|
||||
|
||||
.toc > ul > li > a::before {
|
||||
content: counter(toc-h1) ". ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
.toc > ul > li > ul > li {
|
||||
counter-increment: toc-h2;
|
||||
counter-reset: toc-h3 toc-h4 toc-h5 toc-h6;
|
||||
}
|
||||
|
||||
.toc > ul > li > ul > li > a::before {
|
||||
content: counter(toc-h1) "." counter(toc-h2) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
.toc > ul > li > ul > li > ul > li {
|
||||
counter-increment: toc-h3;
|
||||
counter-reset: toc-h4 toc-h5 toc-h6;
|
||||
}
|
||||
|
||||
.toc > ul > li > ul > li > ul > li > a::before {
|
||||
content: counter(toc-h1) "." counter(toc-h2) "." counter(toc-h3) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
/* Optional: Add some spacing after the numbers */
|
||||
h1::before, h2::before, h3::before, h4::before, h5::before, h6::before {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
/* Print-specific adjustments */
|
||||
@media print {
|
||||
h1::before, h2::before, h3::before, h4::before, h5::before, h6::before {
|
||||
color: #000 !important; /* Ensure numbers print in black */
|
||||
}
|
||||
}
|
||||
|
||||
/* Optional: Style adjustments for better visual hierarchy */
|
||||
h1 {
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
padding-bottom: 0.3em;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
/* Reduce margin for first heading */
|
||||
h1:first-of-type {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 0.2em;
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 1.2em;
|
||||
}
|
||||
|
||||
h4, h5, h6 {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
|
@ -13,11 +13,6 @@
|
|||
|
||||
set -e
|
||||
|
||||
cleanup() {
|
||||
unset latest_files
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Default output directory
|
||||
OUTPUT_DIR=".archive"
|
||||
|
||||
|
|
@ -59,15 +54,21 @@ done
|
|||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
# Function to get relative path from $1 (base dir) to $2 (target path)
|
||||
# Uses Python for portability (works on both GNU and BSD systems)
|
||||
# Prefers python3 for portability (works on both GNU and BSD systems). Paths are
|
||||
# passed as argv, not interpolated into the -c source, so quotes/specials in a
|
||||
# path can't break or inject into the Python snippet.
|
||||
relative_path() {
|
||||
local base_dir="$1"
|
||||
local target_path="$2"
|
||||
|
||||
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 -c "import os; print(os.path.relpath('$target_path', '$base_dir'))"
|
||||
python3 -c 'import os, sys; print(os.path.relpath(sys.argv[1], sys.argv[2]))' \
|
||||
"$target_path" "$base_dir"
|
||||
elif realpath --relative-to=/ / >/dev/null 2>&1; then
|
||||
# GNU realpath supports --relative-to; keep symlink targets relative.
|
||||
realpath --relative-to="$base_dir" "$target_path"
|
||||
else
|
||||
# Fallback: use absolute paths if python3 not available
|
||||
# Last resort: absolute path (still a valid symlink target, just not relative).
|
||||
realpath "$target_path"
|
||||
fi
|
||||
}
|
||||
|
|
@ -265,9 +266,13 @@ EOF
|
|||
|
||||
# Create truncated SHA256 for display
|
||||
sha256_short="${sha256:0:6}...${sha256: -6}"
|
||||
|
||||
|
||||
# Escape pipe chars so a title/status containing '|' can't break the table row
|
||||
md_title=$(printf '%s' "$doc_title" | sed 's/|/\\|/g')
|
||||
md_status=$(printf '%s' "$status" | sed 's/|/\\|/g')
|
||||
|
||||
# Add to markdown table
|
||||
echo "| $row_counter | $tracking_link | $doc_title | $revision_link | $status | <span class=\"sha256\" title=\"$sha256\">$sha256_short</span> |" >> "$index_md_file"
|
||||
echo "| $row_counter | $tracking_link | $md_title | $revision_link | $md_status | <span class=\"sha256\" title=\"$sha256\">$sha256_short</span> |" >> "$index_md_file"
|
||||
|
||||
echo " $filename -> symlinks created"
|
||||
done < <(find "$folder" -maxdepth 1 \( -type f -o -type l \) -print0)
|
||||
|
|
|
|||
112
pandoc/templates/_doc.html
Normal file
112
pandoc/templates/_doc.html
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<div class="app-container">
|
||||
$if(toc)$
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside id="sidebar" role="complementary" aria-label="Table of contents">
|
||||
<header class="sidebar-header">
|
||||
<div class="toc-header-row">
|
||||
<div class="sidebar-title">Table Of Contents</div>
|
||||
<div class="toc-level-selector">
|
||||
<select id="toc-level" aria-label="Filter table of contents levels">
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3" selected>3</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5">5</option>
|
||||
<option value="6">6</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="toc-container">
|
||||
$if(toc)$
|
||||
<nav class="toc" role="navigation" aria-label="Table of contents">
|
||||
$toc$
|
||||
</nav>
|
||||
$endif$
|
||||
</div>
|
||||
</aside>
|
||||
$endif$
|
||||
<!-- Main Content Area -->
|
||||
<main class="content-wrapper" role="main">
|
||||
<div class="content-page">
|
||||
<!-- Document Header -->
|
||||
<header class="document-header">
|
||||
$if(toc)$
|
||||
<div class="mobile-menu-container">
|
||||
<button class="mobile-menu-toggle" type="button" aria-label="Toggle navigation menu" aria-expanded="false">
|
||||
<span aria-hidden="true">☰</span>
|
||||
</button>
|
||||
</div>
|
||||
$endif$
|
||||
<div class="header-content">
|
||||
$if(client)$$if(project)$
|
||||
<div class="header-line client-project">
|
||||
$client$ - $project$$if(project_number)$ ($project_number$)$endif$
|
||||
</div>
|
||||
$endif$$endif$
|
||||
$if(title)$
|
||||
<div class="document-title">$title$</div>
|
||||
$endif$
|
||||
<div class="document-meta">
|
||||
$if(tracking_number)$<span class="tracking-number">$tracking_number$</span>$endif$
|
||||
$if(revision)$<span class="revision">Revision: $revision$</span>$endif$
|
||||
$if(status)$<span class="status">Status: $status$</span>$endif$
|
||||
$if(revision_comparison)$<span class="revision-comparison">$revision_comparison$</span>$endif$
|
||||
</div>
|
||||
$if(is_draft)$
|
||||
$if(generation_time)$
|
||||
<div class="draft-line">
|
||||
<span class="draft-status">[DRAFT Generated at $generation_time$]</span>
|
||||
</div>
|
||||
$endif$
|
||||
$endif$
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Scroll Progress Bar -->
|
||||
<div class="scroll-progress" role="progressbar" aria-label="Reading progress">
|
||||
<div class="scroll-progress-bar"></div>
|
||||
</div>
|
||||
|
||||
<!-- Print-only header -->
|
||||
<div class="print-header">
|
||||
$if(custom_header)$
|
||||
$custom_header$
|
||||
$else$
|
||||
$if(client)$$if(project)$
|
||||
<div class="header-line client-project">
|
||||
$client$ - $project$$if(project_number)$ ($project_number$)$endif$
|
||||
</div>
|
||||
$endif$$endif$
|
||||
$if(title)$
|
||||
<div class="header-line document-title">$title$</div>
|
||||
$endif$
|
||||
$if(tracking_number)$<div class="header-line">$tracking_number$$if(revision)$ Revision: $revision$$endif$$if(status)$ Status: $status$$endif$</div>$endif$
|
||||
$if(revision_comparison)$<div class="header-line revision-comparison">$revision_comparison$</div>$endif$
|
||||
$endif$
|
||||
$if(generation_time)$
|
||||
<div class="header-line metadata-line draft-line">
|
||||
<span class="draft-status">Generated: $generation_time$</span>
|
||||
</div>
|
||||
$endif$
|
||||
</div>
|
||||
|
||||
<!-- Print-only footer -->
|
||||
<div class="print-footer">
|
||||
<div class="footer-left">
|
||||
$if(tracking_number)$$tracking_number$$endif$$if(revision)$ Revision: $revision$$endif$$if(status)$ Status: $status$$endif$
|
||||
</div>
|
||||
<div class="footer-right">
|
||||
Page <span class="page-number"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Document Content -->
|
||||
<article class="document-content">
|
||||
$body$
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
778
pandoc/templates/_head.html
Normal file
778
pandoc/templates/_head.html
Normal file
|
|
@ -0,0 +1,778 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>$if(title)$$title$$else$Document$endif$</title>
|
||||
|
||||
<!-- Document metadata for JavaScript -->
|
||||
$if(revision)$<meta name="revision" content="$revision$">$endif$
|
||||
$if(generation_time)$<meta name="generation_time" content="$generation_time$">$endif$
|
||||
|
||||
<!-- Embedded CSS -->
|
||||
<style>
|
||||
/*
|
||||
* ZDDC Document Viewer Template
|
||||
* Enhanced responsive layout with TOC navigation
|
||||
*/
|
||||
|
||||
/* CSS Variables for theming - Soft Light Theme */
|
||||
:root {
|
||||
--primary-color: #2563eb;
|
||||
--primary-color-dark: #1d4ed8;
|
||||
--text-color: #4b5563;
|
||||
--text-secondary: #6b7280;
|
||||
--text-primary: #1f2937;
|
||||
--bg-primary: #f8fafc;
|
||||
--bg-secondary: #f1f5f9;
|
||||
--border-color: #d1d5db;
|
||||
--hover-bg: #e2e8f0;
|
||||
--active-bg: rgba(37, 99, 235, 0.1);
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--sidebar-width: 280px;
|
||||
--header-height: 120px;
|
||||
--content-max-width: 900px;
|
||||
}
|
||||
|
||||
/* Dark mode variables - Standard Dark Theme */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--primary-color: #60a5fa;
|
||||
--primary-color-dark: #3b82f6;
|
||||
--text-color: #d1d5db;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-primary: #f9fafb;
|
||||
--bg-primary: #111827;
|
||||
--bg-secondary: #1f2937;
|
||||
--border-color: #374151;
|
||||
--hover-bg: #374151;
|
||||
--active-bg: rgba(96, 165, 250, 0.2);
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* Reset and base styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color);
|
||||
background: var(--bg-secondary);
|
||||
height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* App Container - Modern CSS Grid Layout */
|
||||
.app-container {
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) 1fr;
|
||||
grid-template-areas: "sidebar main";
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas: "main";
|
||||
}
|
||||
}
|
||||
|
||||
/* Content wrapper - Grid area */
|
||||
.content-wrapper {
|
||||
grid-area: main;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
max-width: min(900px, 100%);
|
||||
margin: 0;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* Content page simplified */
|
||||
.content-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* Sidebar Navigation - Grid area */
|
||||
#sidebar {
|
||||
grid-area: sidebar;
|
||||
height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
border-inline-end: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#sidebar {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
#sidebar.mobile-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Ensure mobile TOC uses light theme colors */
|
||||
#sidebar .sidebar-header {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
#sidebar .toc a {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
#sidebar .toc a:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Document Header - Flex Row Layout */
|
||||
.document-header {
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 1rem;
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-menu-container {
|
||||
display: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-menu-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle:hover {
|
||||
background: var(--primary-color-dark);
|
||||
transform: scale(1.05);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.toc-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toc-level-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.toc-level-selector select {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.25rem;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-color);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* TOC Container */
|
||||
.toc-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 0;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toc-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.toc-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.toc-container::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Scroll Progress Indicator */
|
||||
.scroll-progress {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: var(--border-color);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.scroll-progress-bar {
|
||||
height: 100%;
|
||||
background: var(--primary-color);
|
||||
width: 0%;
|
||||
transition: width 0.1s ease;
|
||||
}
|
||||
|
||||
/* TOC Styling */
|
||||
.toc ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.toc ul ul {
|
||||
padding-left: 1.25rem;
|
||||
margin-top: 0.25rem;
|
||||
border-left: 2px solid var(--border-color);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.toc li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.toc a {
|
||||
display: block;
|
||||
padding: 0.375rem 0.75rem;
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.toc li li a {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.toc a:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.toc a.active {
|
||||
background: var(--active-bg);
|
||||
color: var(--primary-color);
|
||||
border-left-color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Content Page Container - Simplified */
|
||||
.content-page {
|
||||
flex: 1;
|
||||
background: var(--bg-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Document Content */
|
||||
.document-content {
|
||||
flex: 1;
|
||||
padding: 0.5rem 2rem 2rem 2rem;
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Document Header */
|
||||
.document-header {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0.75rem 2rem;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header-line {
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Header line hierarchy */
|
||||
.client-project {
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.document-title {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.metadata-line {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.draft-status {
|
||||
color: #dc3545;
|
||||
font-weight: bold;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Print-only elements - hidden on screen */
|
||||
.print-header,
|
||||
.print-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile menu backdrop */
|
||||
@media (max-width: 768px) {
|
||||
.mobile-menu-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#sidebar.mobile-open::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
/* Remove top margin from first heading in content */
|
||||
.document-content h1:first-child,
|
||||
.document-content h2:first-child,
|
||||
.document-content h3:first-child,
|
||||
.document-content h4:first-child,
|
||||
.document-content h5:first-child,
|
||||
.document-content h6:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 { font-size: 2rem; }
|
||||
h2 { font-size: 1.5rem; }
|
||||
h3 { font-size: 1.25rem; }
|
||||
h4 { font-size: 1.125rem; }
|
||||
h5 { font-size: 1rem; }
|
||||
h6 { font-size: 0.875rem; }
|
||||
|
||||
p {
|
||||
margin: 1rem 0;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
ol, ul {
|
||||
margin: 1rem 0;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.25rem 0;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Nested lists */
|
||||
ol ol, ul ul, ol ul, ul ol {
|
||||
margin: 0.25rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
ul ul {
|
||||
list-style-type: circle;
|
||||
}
|
||||
|
||||
ul ul ul {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
/* Hide online-only elements */
|
||||
.sidebar,
|
||||
.mobile-menu-toggle,
|
||||
.scroll-progress,
|
||||
.document-header {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Show print-only elements */
|
||||
.print-header {
|
||||
display: block !important;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
border-bottom: 1pt solid #000;
|
||||
padding: 12pt 0.5in;
|
||||
z-index: 1000;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.print-footer {
|
||||
display: flex !important;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
border-top: 1pt solid #000;
|
||||
padding: 8pt 0.5in;
|
||||
z-index: 1000;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Print header styling */
|
||||
.print-header .client-project {
|
||||
font-size: 12pt;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4pt 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.print-header .document-title {
|
||||
font-size: 16pt;
|
||||
color: #000;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Print footer styling */
|
||||
.print-footer .footer-left,
|
||||
.print-footer .footer-right {
|
||||
font-size: 10pt;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Page counter for print */
|
||||
.print-footer .page-number::after {
|
||||
content: counter(page);
|
||||
}
|
||||
|
||||
@page {
|
||||
margin: 1in;
|
||||
size: letter;
|
||||
counter-increment: page;
|
||||
}
|
||||
|
||||
.draft-line {
|
||||
margin-top: 4pt;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
/* Layout adjustments */
|
||||
html, body {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
margin-left: 0 !important;
|
||||
width: 100% !important;
|
||||
/* The screen layout caps content-wrapper at 900px; in print, the
|
||||
printable area is page-width minus @page margins (~6.5in =
|
||||
~624px for letter at 96dpi), which is narrower than 900px BUT
|
||||
chromium's --print-to-pdf renders at the full page width and
|
||||
only clips at print time — so without max-width:none the
|
||||
element extends past the right margin. */
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
.content-page {
|
||||
max-width: none !important;
|
||||
width: 100% !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.document-content {
|
||||
margin-top: 80pt !important;
|
||||
margin-bottom: 50pt !important;
|
||||
padding: 0 0.5in !important;
|
||||
border-left: none !important;
|
||||
min-height: calc(100vh - 130pt) !important;
|
||||
max-width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
/* Wide content that wouldn't otherwise wrap: tables, code blocks,
|
||||
long URLs in inline code. Force them to stay within the
|
||||
printable area instead of running off the right edge. */
|
||||
pre, code, table, blockquote, img, video {
|
||||
max-width: 100% !important;
|
||||
overflow-wrap: break-word !important;
|
||||
word-wrap: break-word !important;
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap !important;
|
||||
word-break: break-word !important;
|
||||
}
|
||||
table {
|
||||
table-layout: fixed !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Fix list formatting in print */
|
||||
ol, ul {
|
||||
padding-left: 2rem !important;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.25rem 0 !important;
|
||||
}
|
||||
|
||||
/* Typography for print */
|
||||
body {
|
||||
font-size: 12pt !important;
|
||||
line-height: 1.4 !important;
|
||||
color: #000 !important;
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
/* Page breaks */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
page-break-after: avoid;
|
||||
page-break-inside: avoid;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
p, li {
|
||||
orphans: 3;
|
||||
widows: 3;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
/* Prevent content cutoff */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Ensure proper spacing at page breaks */
|
||||
h1:first-child, h2:first-child, h3:first-child {
|
||||
margin-top: 0;
|
||||
padding-top: 0.5em;
|
||||
}
|
||||
|
||||
/* Table print formatting */
|
||||
table {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
|
||||
tbody {
|
||||
display: table-row-group;
|
||||
}
|
||||
|
||||
tr {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 8pt 6pt !important;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #000 !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Diff styling for pandiff output */
|
||||
u {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
text-decoration: none;
|
||||
padding: 0.1em 0.2em;
|
||||
border-radius: 0.2em;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Legal-style heading numbering for ZDDC documents.
|
||||
* Gated by the `numbered` body class, which the per-doctype templates add when
|
||||
* the document's YAML front matter sets `numbering: true` (default: off).
|
||||
*/
|
||||
body.numbered .document-content { counter-reset: h1-counter; }
|
||||
|
||||
body.numbered h1 { counter-reset: h2-counter h3-counter h4-counter h5-counter h6-counter; counter-increment: h1-counter; }
|
||||
body.numbered h1::before { content: counter(h1-counter) ". "; font-weight: bold; color: var(--primary-color); }
|
||||
|
||||
body.numbered h2 { counter-reset: h3-counter h4-counter h5-counter h6-counter; counter-increment: h2-counter; }
|
||||
body.numbered h2::before { content: counter(h1-counter) "." counter(h2-counter) " "; font-weight: bold; color: var(--primary-color); }
|
||||
|
||||
body.numbered h3 { counter-reset: h4-counter h5-counter h6-counter; counter-increment: h3-counter; }
|
||||
body.numbered h3::before { content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) " "; font-weight: bold; color: var(--primary-color); }
|
||||
|
||||
body.numbered h4 { counter-reset: h5-counter h6-counter; counter-increment: h4-counter; }
|
||||
body.numbered h4::before { content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) " "; font-weight: bold; color: var(--primary-color); }
|
||||
|
||||
body.numbered h5 { counter-reset: h6-counter; counter-increment: h5-counter; }
|
||||
body.numbered h5::before { content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) "." counter(h5-counter) " "; font-weight: bold; color: var(--primary-color); }
|
||||
|
||||
body.numbered h6 { counter-increment: h6-counter; }
|
||||
body.numbered h6::before { content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) "." counter(h5-counter) "." counter(h6-counter) " "; font-weight: bold; color: var(--primary-color); }
|
||||
|
||||
/* TOC numbering to match document headings */
|
||||
body.numbered .toc { counter-reset: toc-h1; }
|
||||
body.numbered .toc ul { list-style: none; }
|
||||
body.numbered .toc > ul > li { counter-increment: toc-h1; counter-reset: toc-h2 toc-h3 toc-h4 toc-h5 toc-h6; }
|
||||
body.numbered .toc > ul > li > a::before { content: counter(toc-h1) ". "; font-weight: bold; color: var(--primary-color); margin-right: 0.25em; }
|
||||
body.numbered .toc > ul > li > ul > li { counter-increment: toc-h2; counter-reset: toc-h3 toc-h4 toc-h5 toc-h6; }
|
||||
body.numbered .toc > ul > li > ul > li > a::before { content: counter(toc-h1) "." counter(toc-h2) " "; font-weight: bold; color: var(--primary-color); margin-right: 0.25em; }
|
||||
body.numbered .toc > ul > li > ul > li > ul > li { counter-increment: toc-h3; counter-reset: toc-h4 toc-h5 toc-h6; }
|
||||
body.numbered .toc > ul > li > ul > li > ul > li > a::before { content: counter(toc-h1) "." counter(toc-h2) "." counter(toc-h3) " "; font-weight: bold; color: var(--primary-color); margin-right: 0.25em; }
|
||||
|
||||
body.numbered h1::before, body.numbered h2::before, body.numbered h3::before,
|
||||
body.numbered h4::before, body.numbered h5::before, body.numbered h6::before { margin-right: 0.5em; }
|
||||
|
||||
@media print {
|
||||
body.numbered h1::before, body.numbered h2::before, body.numbered h3::before,
|
||||
body.numbered h4::before, body.numbered h5::before, body.numbered h6::before { color: #000 !important; }
|
||||
}
|
||||
|
||||
/* Visual heading hierarchy that accompanies the numbered/legal look. */
|
||||
body.numbered h1 { border-bottom: 2px solid var(--primary-color); padding-bottom: 0.3em; margin-top: 1em; }
|
||||
body.numbered h1:first-of-type { margin-top: 0.5em; }
|
||||
body.numbered h2 { border-bottom: 1px solid var(--border-color); padding-bottom: 0.2em; margin-top: 1.5em; }
|
||||
body.numbered h3 { margin-top: 1.2em; }
|
||||
body.numbered h4, body.numbered h5, body.numbered h6 { margin-top: 1em; }
|
||||
|
||||
/*
|
||||
* Doctype-specific layout. `doctype` comes from the document's YAML front matter
|
||||
* (report | specification | letter); the per-doctype template sets `doc-<name>`.
|
||||
* A letter has no TOC sidebar and flows as a normal single column.
|
||||
*/
|
||||
body.doc-letter { height: auto; overflow: visible; }
|
||||
body.doc-letter .content-wrapper { margin: 0 auto; max-width: var(--content-max-width); }
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
$for(header-includes)$
|
||||
$header-includes$
|
||||
$endfor$
|
||||
</head>
|
||||
259
pandoc/templates/_scripts.html
Normal file
259
pandoc/templates/_scripts.html
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
<!-- Embedded JavaScript -->
|
||||
<script>
|
||||
'use strict';
|
||||
|
||||
// Modern initialization with arrow functions
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// View mode toggle functionality
|
||||
const buttons = document.querySelectorAll('.view-mode-btn');
|
||||
const body = document.body;
|
||||
|
||||
buttons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const mode = this.dataset.mode;
|
||||
|
||||
// Remove all view mode classes
|
||||
body.classList.remove('view-original', 'view-final');
|
||||
|
||||
// Add the selected mode class (except for diff which is default)
|
||||
if (mode === 'original') {
|
||||
body.classList.add('view-original');
|
||||
} else if (mode === 'final') {
|
||||
body.classList.add('view-final');
|
||||
}
|
||||
|
||||
// Update button states
|
||||
buttons.forEach(btn => btn.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (sidebar) {
|
||||
initTocNavigation();
|
||||
}
|
||||
|
||||
// Set default TOC level filtering
|
||||
filterTocLevels('3');
|
||||
|
||||
// Setup event listeners with delegation
|
||||
setupEventListeners();
|
||||
|
||||
// Initialize print functionality
|
||||
initPrintSupport();
|
||||
});
|
||||
|
||||
// Modern TOC Navigation with ES6+ patterns
|
||||
function initTocNavigation() {
|
||||
const tocLinks = document.querySelectorAll('.toc a');
|
||||
const contentArea = document.querySelector('.document-content');
|
||||
|
||||
if (!tocLinks.length || !contentArea) return;
|
||||
|
||||
// Smooth scroll with event delegation (better performance)
|
||||
function handleTocClick(e) {
|
||||
if (!e.target.matches('.toc a')) return;
|
||||
|
||||
e.preventDefault();
|
||||
const href = e.target.getAttribute('href');
|
||||
const targetId = href ? href.slice(1) : null;
|
||||
const targetElement = targetId ? document.getElementById(targetId) : null;
|
||||
|
||||
if (!targetElement) return;
|
||||
|
||||
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
|
||||
// Update URL hash without adding to browser history
|
||||
window.location.replace(window.location.pathname + window.location.search + href);
|
||||
|
||||
// Update active state
|
||||
tocLinks.forEach(link => link.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
|
||||
// Close mobile menu if open
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (sidebar && sidebar.classList.contains('mobile-open')) toggleMobileMenu();
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleTocClick);
|
||||
|
||||
// TOC scroll tracking using Intersection Observer API
|
||||
// NOTE: Intersection Observer is the industry-standard, recommended approach for scroll spy
|
||||
// implementations as of 2024. It provides better performance (runs off main thread),
|
||||
// cleaner code, and is supported by all modern browsers. Avoid scroll event listeners
|
||||
// for this use case as they are performance-intensive and require complex calculations.
|
||||
// Find all sections with IDs - much simpler approach
|
||||
const sections = Array.from(contentArea.querySelectorAll('section[id]'));
|
||||
|
||||
|
||||
if (sections.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
function updateActiveTocItem(activeSection) {
|
||||
if (!activeSection || !activeSection.id) return;
|
||||
|
||||
// Clear all active states
|
||||
tocLinks.forEach(link => link.classList.remove('active'));
|
||||
|
||||
// Find and activate the matching TOC link
|
||||
const activeLink = document.querySelector('.toc a[href="#' + activeSection.id + '"]');
|
||||
if (!activeLink) return;
|
||||
|
||||
activeLink.classList.add('active');
|
||||
|
||||
// Auto-scroll TOC to keep active item visible
|
||||
activeLink.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'nearest'
|
||||
});
|
||||
};
|
||||
|
||||
// Create Intersection Observer with industry-standard configuration
|
||||
const observer = new IntersectionObserver(function(entries) {
|
||||
// Find visible sections and update active TOC item
|
||||
const visibleSections = entries.filter(function(entry) { return entry.isIntersecting; });
|
||||
if (visibleSections.length > 0) {
|
||||
// Sort by position in viewport (topmost first)
|
||||
visibleSections.sort(function(a, b) { return a.boundingClientRect.top - b.boundingClientRect.top; });
|
||||
const activeSection = visibleSections[0].target;
|
||||
updateActiveTocItem(activeSection);
|
||||
}
|
||||
}, {
|
||||
root: contentArea,
|
||||
rootMargin: '-20% 0px -60% 0px', // Only consider sections in the middle 20% of viewport
|
||||
threshold: 0.1
|
||||
});
|
||||
|
||||
// Observe all sections
|
||||
sections.forEach(function(section) { observer.observe(section); });
|
||||
|
||||
// Scroll progress bar with throttling for better performance
|
||||
const progressBar = document.querySelector('.scroll-progress-bar');
|
||||
if (progressBar) {
|
||||
let ticking = false;
|
||||
|
||||
function updateScrollProgress() {
|
||||
const scrollTop = contentArea.scrollTop;
|
||||
const scrollHeight = contentArea.scrollHeight;
|
||||
const clientHeight = contentArea.clientHeight;
|
||||
const scrollPercent = scrollHeight > clientHeight
|
||||
? (scrollTop / (scrollHeight - clientHeight)) * 100
|
||||
: 0;
|
||||
progressBar.style.width = Math.min(100, Math.max(0, scrollPercent)) + '%';
|
||||
ticking = false;
|
||||
};
|
||||
|
||||
function onScroll() {
|
||||
if (!ticking) {
|
||||
requestAnimationFrame(updateScrollProgress);
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
|
||||
contentArea.addEventListener('scroll', onScroll, { passive: true });
|
||||
updateScrollProgress(); // Initial call
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle mobile menu with ARIA support
|
||||
function toggleMobileMenu() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const menuToggle = document.querySelector('.mobile-menu-toggle');
|
||||
|
||||
if (!sidebar || !menuToggle) return;
|
||||
|
||||
const isOpen = sidebar.classList.toggle('mobile-open');
|
||||
menuToggle.setAttribute('aria-expanded', isOpen.toString());
|
||||
};
|
||||
|
||||
// Filter TOC levels with modern patterns
|
||||
function filterTocLevels(maxLevel) {
|
||||
const toc = document.querySelector('.toc');
|
||||
if (!toc) return;
|
||||
|
||||
const allItems = toc.querySelectorAll('li');
|
||||
const maxLevelNum = parseInt(maxLevel);
|
||||
const showAll = maxLevel === '6';
|
||||
|
||||
allItems.forEach(function(item) {
|
||||
const link = item.querySelector('a');
|
||||
if (!link) return;
|
||||
|
||||
if (showAll) {
|
||||
item.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate nesting level more efficiently
|
||||
let level = 1;
|
||||
let parent = item.parentElement;
|
||||
while (parent && !parent.classList.contains('toc')) {
|
||||
if (parent.tagName === 'LI') level++;
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
|
||||
item.style.display = level <= maxLevelNum ? '' : 'none';
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// Setup event listeners with delegation
|
||||
function setupEventListeners() {
|
||||
// TOC level selector
|
||||
const tocLevelSelect = document.getElementById('toc-level');
|
||||
if (tocLevelSelect) tocLevelSelect.addEventListener('change', function(e) {
|
||||
filterTocLevels(e.target.value);
|
||||
});
|
||||
|
||||
// Mobile menu toggle
|
||||
const menuToggle = document.querySelector('.mobile-menu-toggle');
|
||||
if (menuToggle) menuToggle.addEventListener('click', toggleMobileMenu);
|
||||
|
||||
// Close mobile menu on outside click
|
||||
document.addEventListener('click', function(e) {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const menuToggle = document.querySelector('.mobile-menu-toggle');
|
||||
|
||||
if (sidebar && sidebar.classList.contains('mobile-open') &&
|
||||
!sidebar.contains(e.target) &&
|
||||
(!menuToggle || !menuToggle.contains(e.target))) {
|
||||
toggleMobileMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle escape key to close mobile menu
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (sidebar && sidebar.classList.contains('mobile-open')) {
|
||||
toggleMobileMenu();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Initialize print support and draft status
|
||||
function initPrintSupport() {
|
||||
// Handle draft status for revisions containing tilde (~)
|
||||
const revision = document.querySelector('meta[name="revision"]');
|
||||
const generationTime = document.querySelector('meta[name="generation_time"]');
|
||||
|
||||
if (revision && generationTime) {
|
||||
const revisionValue = revision.getAttribute('content');
|
||||
const timeValue = generationTime.getAttribute('content');
|
||||
|
||||
if (revisionValue && revisionValue.includes('~') && timeValue) {
|
||||
const draftElements = document.querySelectorAll('.draft-status');
|
||||
draftElements.forEach(function(element) {
|
||||
element.textContent = ' [DRAFT Generated at ' + timeValue + ']';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export functions for global access (maintaining backward compatibility)
|
||||
window.toggleMobileMenu = toggleMobileMenu;
|
||||
window.filterTocLevels = filterTocLevels;
|
||||
</script>
|
||||
56
pandoc/templates/letter.html
Normal file
56
pandoc/templates/letter.html
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
$_head()$
|
||||
|
||||
<body class="doc-letter$if(numbering)$ numbered$endif$">
|
||||
<!-- Letter layout: single column, no TOC sidebar -->
|
||||
<main class="content-wrapper" role="main">
|
||||
<div class="content-page">
|
||||
<!-- Letterhead -->
|
||||
<header class="document-header">
|
||||
<div class="header-content">
|
||||
$if(client)$$if(project)$
|
||||
<div class="header-line client-project">
|
||||
$client$ - $project$$if(project_number)$ ($project_number$)$endif$
|
||||
</div>
|
||||
$endif$$endif$
|
||||
$if(title)$
|
||||
<div class="document-title">$title$</div>
|
||||
$endif$
|
||||
<div class="document-meta">
|
||||
$if(date)$<span class="date">$date$</span>$endif$
|
||||
$if(tracking_number)$<span class="tracking-number">$tracking_number$</span>$endif$
|
||||
$if(revision)$<span class="revision">Revision: $revision$</span>$endif$
|
||||
$if(status)$<span class="status">Status: $status$</span>$endif$
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Print-only header -->
|
||||
<div class="print-header">
|
||||
$if(custom_header)$
|
||||
$custom_header$
|
||||
$else$
|
||||
$if(client)$$if(project)$
|
||||
<div class="header-line client-project">$client$ - $project$$if(project_number)$ ($project_number$)$endif$</div>
|
||||
$endif$$endif$
|
||||
$if(title)$<div class="header-line document-title">$title$</div>$endif$
|
||||
$endif$
|
||||
</div>
|
||||
|
||||
<!-- Print-only footer -->
|
||||
<div class="print-footer">
|
||||
<div class="footer-left">
|
||||
$if(tracking_number)$$tracking_number$$endif$$if(revision)$ Revision: $revision$$endif$$if(status)$ Status: $status$$endif$
|
||||
</div>
|
||||
<div class="footer-right">Page <span class="page-number"></span></div>
|
||||
</div>
|
||||
|
||||
<article class="document-content">
|
||||
$body$
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
$_scripts()$
|
||||
</body>
|
||||
</html>
|
||||
9
pandoc/templates/report.html
Normal file
9
pandoc/templates/report.html
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
$_head()$
|
||||
|
||||
<body class="doc-report$if(numbering)$ numbered$endif$">
|
||||
$_doc()$
|
||||
$_scripts()$
|
||||
</body>
|
||||
</html>
|
||||
9
pandoc/templates/specification.html
Normal file
9
pandoc/templates/specification.html
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
$_head()$
|
||||
|
||||
<body class="doc-specification$if(numbering)$ numbered$endif$">
|
||||
$_doc()$
|
||||
$_scripts()$
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -55,6 +55,10 @@ export default defineConfig({
|
|||
name: 'browse',
|
||||
testMatch: 'browse.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'conflict',
|
||||
testMatch: 'conflict.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'zddc-source',
|
||||
testMatch: 'zddc-source.spec.js',
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@
|
|||
# so it stays registered (else its workspaces can't be recreated)
|
||||
# - LEAVE archive/<party>/{received,issued} in place (the WORM record)
|
||||
#
|
||||
# Then, tree-wide, relocate any table.yaml / form.yaml config out of a
|
||||
# directory root and into that directory's .zddc.d/ reserve (where the
|
||||
# server now resolves specs from; the legacy root location still works, so
|
||||
# this is a declutter, not a hard requirement).
|
||||
#
|
||||
# Per-folder .zddc files travel with their directory (the whole slot dir
|
||||
# is moved). Idempotent: already-migrated paths are skipped. Run with the
|
||||
# server stopped (or accept it's a plain filesystem move).
|
||||
|
|
@ -112,6 +117,28 @@ synth_registry() {
|
|||
synth=$((synth + 1))
|
||||
}
|
||||
|
||||
# relocate_configs — move every <dir>/table.yaml and <dir>/form.yaml into
|
||||
# <dir>/.zddc.d/. Tree-wide; skips files already under a .zddc.d/ and any
|
||||
# destination that already exists. Uses find | while-read so directory names
|
||||
# with spaces are handled (counters live in the subshell, so this pass just
|
||||
# logs its actions rather than feeding the summary).
|
||||
relocate_configs() {
|
||||
find "$ROOT" -type f \( -name table.yaml -o -name form.yaml \) 2>/dev/null | while IFS= read -r f; do
|
||||
case "$f" in
|
||||
*/.zddc.d/*) continue ;;
|
||||
esac
|
||||
d=$(dirname "$f")
|
||||
base=$(basename "$f")
|
||||
dst="$d/.zddc.d/$base"
|
||||
if [ -e "$dst" ]; then
|
||||
say " .. skip (dest exists): $dst"
|
||||
continue
|
||||
fi
|
||||
act "mkdir -p $d/.zddc.d" mkdir -p "$d/.zddc.d"
|
||||
act "mv $f -> $dst" mv "$f" "$dst"
|
||||
done
|
||||
}
|
||||
|
||||
for projectdir in "$ROOT"/*/; do
|
||||
[ -d "$projectdir/archive" ] || continue
|
||||
project=$(basename "$projectdir")
|
||||
|
|
@ -137,6 +164,11 @@ for projectdir in "$ROOT"/*/; do
|
|||
done
|
||||
done
|
||||
|
||||
# Tree-wide config relocation (runs over the now-migrated layout).
|
||||
say ""
|
||||
say "relocating table.yaml/form.yaml configs into .zddc.d/ …"
|
||||
relocate_configs
|
||||
|
||||
say ""
|
||||
say "summary: moved=$moved synthesized=$synth skipped=$skipped"
|
||||
[ "$DRY" -eq 1 ] && say "(dry-run — nothing changed)"
|
||||
|
|
|
|||
|
|
@ -83,6 +83,38 @@ concat_files() {
|
|||
done
|
||||
}
|
||||
|
||||
# Mirror the conversion templates from a canonical source dir into a build embed
|
||||
# dir — go:embed can't follow symlinks, so the bytes must be a real copy under the
|
||||
# Go package. Copies every *.html, drops stale destination *.html the source no
|
||||
# longer has, and verifies byte-identity. Guarded at test time by
|
||||
# convert.TestEmbeddedTemplatesMatchSource. Usage: sync_pandoc_templates <src> <dst>
|
||||
sync_pandoc_templates() {
|
||||
_src="$1"
|
||||
_dst="$2"
|
||||
if [ ! -d "$_src" ]; then
|
||||
echo "error: missing template source dir: $_src" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$_dst"
|
||||
# Drop destination templates the source no longer provides.
|
||||
for _f in "$_dst"/*.html; do
|
||||
[ -e "$_f" ] || continue
|
||||
if [ ! -f "$_src/$(basename "$_f")" ]; then
|
||||
rm -f "$_f"
|
||||
fi
|
||||
done
|
||||
# Copy + verify each source template.
|
||||
for _f in "$_src"/*.html; do
|
||||
[ -e "$_f" ] || continue
|
||||
cp "$_f" "$_dst/$(basename "$_f")"
|
||||
if ! cmp -s "$_f" "$_dst/$(basename "$_f")"; then
|
||||
echo "error: template sync mismatch: $_f" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "Synced templates: $_src -> $_dst"
|
||||
}
|
||||
|
||||
# ISO UTC build timestamp — set once when this file is sourced
|
||||
build_timestamp=$(date -u +"%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
|
|
|||
|
|
@ -127,6 +127,12 @@
|
|||
// one path instead of two.
|
||||
+ '<symbol id="icon-chevron-right" viewBox="0 0 24 24">'
|
||||
+ '<path d="m9 18 6-6-6-6"/>'
|
||||
+ '</symbol>'
|
||||
// Horizontal three-dot "kebab" — the per-row actions affordance.
|
||||
+ '<symbol id="icon-ellipsis" viewBox="0 0 24 24">'
|
||||
+ '<circle cx="12" cy="12" r="1"/>'
|
||||
+ '<circle cx="19" cy="12" r="1"/>'
|
||||
+ '<circle cx="5" cy="12" r="1"/>'
|
||||
+ '</symbol>';
|
||||
|
||||
var injected = false;
|
||||
|
|
|
|||
|
|
@ -22,13 +22,19 @@
|
|||
// inline context (tests) or open the page through zddc-server.
|
||||
async function load() {
|
||||
const inline = readInlineContext();
|
||||
if (inline && Object.keys(inline).length > 0) {
|
||||
// A fully pre-assembled context (columns + rows) is used as-is — the
|
||||
// test seam, or any host that renders the whole table server-side.
|
||||
if (inline && Array.isArray(inline.columns)) {
|
||||
return inline;
|
||||
}
|
||||
// Otherwise the inline context may still carry the server-injected
|
||||
// SPEC ({spec, rowSchema}) sourced from <dir>/.zddc.d/ — pass it to
|
||||
// walkServer, which uses it instead of fetching the spec and still
|
||||
// walks the directory for row files.
|
||||
if (typeof location !== 'undefined' &&
|
||||
(location.protocol === 'http:' || location.protocol === 'https:')) {
|
||||
try {
|
||||
const walked = await walkServer();
|
||||
const walked = await walkServer(inline || {});
|
||||
if (walked) {
|
||||
return walked;
|
||||
}
|
||||
|
|
@ -60,7 +66,8 @@
|
|||
el.hidden = false;
|
||||
}
|
||||
|
||||
async function walkServer() {
|
||||
async function walkServer(injected) {
|
||||
injected = injected || {};
|
||||
const source = window.zddc && window.zddc.source;
|
||||
if (!source) {
|
||||
throw new Error('zddc.source not available');
|
||||
|
|
@ -77,27 +84,32 @@
|
|||
}
|
||||
const dir = probe.handle;
|
||||
|
||||
// Spec lives at <currentdir>/table.yaml — the page URL is
|
||||
// <currentdir>/table.html, so the spec is right next door.
|
||||
const spec = await readYaml(dir, 'table.yaml');
|
||||
// Spec: prefer the server-injected #table-context.spec (sourced from
|
||||
// <dir>/.zddc.d/table.yaml). Falling back, read the spec from the
|
||||
// supporting-files reserve, then the legacy directory root — the
|
||||
// FS-Access path, where there's no server to inject.
|
||||
let spec = (injected.spec && Array.isArray(injected.spec.columns))
|
||||
? injected.spec : null;
|
||||
if (!spec) {
|
||||
spec = await readYamlFirst(dir, ['.zddc.d/table.yaml', 'table.yaml']);
|
||||
}
|
||||
if (!spec || !Array.isArray(spec.columns)) {
|
||||
throw new Error('Spec table.yaml missing columns[]');
|
||||
}
|
||||
|
||||
// Optional row schema from <dir>/form.yaml — same JSON Schema
|
||||
// the form-mode renderer uses. Phase 2 derives per-cell editor
|
||||
// widgets from it (text/number/date/select/checkbox).
|
||||
// Best-effort: a directory with only table.yaml still renders
|
||||
// as a sortable/filterable table; cells fall back to plain
|
||||
// text inputs without per-property hints.
|
||||
let rowSchema = null;
|
||||
try {
|
||||
const formSpec = await readYaml(dir, 'form.yaml');
|
||||
if (formSpec && formSpec.schema) {
|
||||
rowSchema = formSpec.schema;
|
||||
// Row schema: prefer the injected #table-context.rowSchema, else read
|
||||
// <dir>/.zddc.d/form.yaml (then legacy root). Best-effort — a table
|
||||
// with no row schema still renders with plain-text cells.
|
||||
let rowSchema = injected.rowSchema || null;
|
||||
if (!rowSchema) {
|
||||
try {
|
||||
const formSpec = await readYamlFirst(dir, ['.zddc.d/form.yaml', 'form.yaml']);
|
||||
if (formSpec && formSpec.schema) {
|
||||
rowSchema = formSpec.schema;
|
||||
}
|
||||
} catch (_) {
|
||||
// form.yaml missing or unreadable; carry on without it.
|
||||
}
|
||||
} catch (_) {
|
||||
// form.yaml missing or unreadable; carry on without it.
|
||||
}
|
||||
|
||||
// Rows are every *.yaml in <currentdir> EXCEPT the spec
|
||||
|
|
@ -156,6 +168,22 @@
|
|||
return window.jsyaml.load(text);
|
||||
}
|
||||
|
||||
// readYamlFirst tries each relPath in order, returning the first that
|
||||
// resolves + parses. Used to read a spec from the supporting-files
|
||||
// reserve (.zddc.d/<name>) with a fallback to the legacy directory root.
|
||||
async function readYamlFirst(dir, relPaths) {
|
||||
let lastErr = null;
|
||||
for (var i = 0; i < relPaths.length; i++) {
|
||||
try {
|
||||
return await readYaml(dir, relPaths[i]);
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
}
|
||||
}
|
||||
if (lastErr) throw lastErr;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Walk a "/"-separated relative path under dir, returning the
|
||||
// FileSystemFileHandle (or HttpFileHandle) at the leaf.
|
||||
async function resolveFile(dir, relPath) {
|
||||
|
|
|
|||
|
|
@ -207,3 +207,96 @@ test.describe('Browse', () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Menu harmonization: context-correct, capability/tier-driven ──────────
|
||||
test.describe('Browse menu — context & tiers', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(MOCK_FS_INIT_SCRIPT);
|
||||
});
|
||||
|
||||
async function openWithTree(page) {
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
|
||||
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||
await page.evaluate(() => {
|
||||
window.__setMockDirectoryTree('mock-folder', {
|
||||
'a.txt': 'AAA',
|
||||
'sub': { 'b.txt': 'BBB' },
|
||||
});
|
||||
});
|
||||
await page.locator('#addDirectoryBtn').click();
|
||||
await page.waitForSelector('#browseRoot:not(.hidden)', { timeout: 10000 });
|
||||
}
|
||||
|
||||
test('file row OMITS New folder / New file (context-correct)', async ({ page }) => {
|
||||
await openWithTree(page);
|
||||
const fileRow = page.locator('.tree-row', { has: page.locator('.tree-name__label', { hasText: /^a\.txt$/ }) });
|
||||
await fileRow.click({ button: 'right' });
|
||||
await page.waitForSelector('.zddc-menu', { timeout: 5000 });
|
||||
await expect(page.locator('.zddc-menu__item', { hasText: 'New folder' })).toHaveCount(0);
|
||||
await expect(page.locator('.zddc-menu__item', { hasText: 'New file' })).toHaveCount(0);
|
||||
// Copy path/name removed; Navigate into folded into Open.
|
||||
await expect(page.locator('.zddc-menu__item', { hasText: 'Copy path' })).toHaveCount(0);
|
||||
await expect(page.locator('.zddc-menu__item', { hasText: 'Navigate into' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('folder row SHOWS New folder (FS mode → create permitted), enabled', async ({ page }) => {
|
||||
await openWithTree(page);
|
||||
const folderRow = page.locator('.tree-row', { has: page.locator('.tree-name__label', { hasText: /^sub$/ }) });
|
||||
await folderRow.click({ button: 'right' });
|
||||
await page.waitForSelector('.zddc-menu', { timeout: 5000 });
|
||||
const item = page.locator('.zddc-menu__item', { hasText: 'New folder' }).first();
|
||||
await expect(item).toBeVisible();
|
||||
await expect(item).not.toHaveClass(/is-disabled/);
|
||||
});
|
||||
|
||||
test('permission-gated items are HIDDEN when not permitted, shown when permitted', async ({ page }) => {
|
||||
await openWithTree(page);
|
||||
// Pure-DOM unit over the declarative model in server mode.
|
||||
const res = await page.evaluate(() => {
|
||||
window.app.state.source = 'server';
|
||||
function labels(verbs) {
|
||||
const node = { name: 'doc.md', ext: 'md', isDir: false, isZip: false,
|
||||
virtual: false, url: '/doc.md', verbs: verbs };
|
||||
return window.app.modules.menuModel
|
||||
.buildRowItems(node, null, { path_verbs: verbs })
|
||||
.filter((i) => i.label).map((i) => i.label);
|
||||
}
|
||||
return { ro: labels('r'), rwd: labels('rwd') };
|
||||
});
|
||||
// read-only → no Rename/Delete (hidden, not greyed)
|
||||
expect(res.ro).not.toContain('Rename…');
|
||||
expect(res.ro).not.toContain('Delete…');
|
||||
// read+write+delete → both present
|
||||
expect(res.rwd).toContain('Rename…');
|
||||
expect(res.rwd).toContain('Delete…');
|
||||
});
|
||||
|
||||
test('toolbar Sort and Show-hidden drive state; New buttons present', async ({ page }) => {
|
||||
await openWithTree(page);
|
||||
await expect(page.locator('#newFolderBtn')).toBeVisible();
|
||||
await expect(page.locator('#newFileBtn')).toBeVisible();
|
||||
|
||||
await page.locator('#sortSelect').selectOption('date:-1');
|
||||
expect(await page.evaluate(() => window.app.state.sort)).toEqual({ key: 'date', dir: -1 });
|
||||
|
||||
await page.locator('#showHiddenChk').check();
|
||||
expect(await page.evaluate(() => window.app.state.showHidden)).toBe(true);
|
||||
});
|
||||
|
||||
test('keyboard menu key and kebab both open the row menu', async ({ page }) => {
|
||||
await openWithTree(page);
|
||||
const fileRow = page.locator('.tree-row', { has: page.locator('.tree-name__label', { hasText: /^a\.txt$/ }) });
|
||||
|
||||
// Kebab click opens the menu (no preview/toggle side-effect needed here).
|
||||
await fileRow.click(); // select first
|
||||
await fileRow.locator('.tree-row__kebab').click();
|
||||
await expect(page.locator('.zddc-menu')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('.zddc-menu')).toHaveCount(0);
|
||||
|
||||
// Keyboard menu key (Shift+F10) opens it on the selected row.
|
||||
await fileRow.click();
|
||||
await page.keyboard.press('Shift+F10');
|
||||
await expect(page.locator('.zddc-menu')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
134
tests/conflict.spec.js
Normal file
134
tests/conflict.spec.js
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// conflict.spec.js — optimistic-concurrency save (If-Match → 412) + the
|
||||
// shared conflict-resolution dialog in the browse tool.
|
||||
//
|
||||
// These drive the client modules directly against a stubbed fetch rather
|
||||
// than a real master: the test zddc-server embeds the COMMITTED
|
||||
// internal/apps/embedded/browse.html, not browse/dist/browse.html, so a
|
||||
// server-mode E2E would run stale code. Loading the fresh dist build over
|
||||
// file:// and stubbing fetch exercises exactly the code under test. Full
|
||||
// server-mode behavior (the master's checkIfMatch → 412) is covered
|
||||
// manually / on the bitnest dev server.
|
||||
import { test, expect } from '@playwright/test';
|
||||
import * as path from 'path';
|
||||
|
||||
const HTML_PATH = path.resolve('browse/dist/browse.html');
|
||||
|
||||
test.describe('Conflict / optimistic concurrency', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
|
||||
// init.js + util.js + conflict.js run synchronously on load.
|
||||
await page.waitForFunction(
|
||||
() => window.app && window.app.modules
|
||||
&& window.app.modules.util && window.app.modules.conflict);
|
||||
});
|
||||
|
||||
test('saveFile sends If-Match and throws ConflictError on 412', async ({ page }) => {
|
||||
const result = await page.evaluate(async () => {
|
||||
const calls = [];
|
||||
window.fetch = async (url, opts) => {
|
||||
calls.push({ url, opts });
|
||||
return { status: 412, ok: false, headers: { get: () => null } };
|
||||
};
|
||||
window.app.state.source = 'server';
|
||||
const node = { url: '/doc.md', name: 'doc.md' };
|
||||
let status = null, name = null;
|
||||
try {
|
||||
await window.app.modules.util.saveFile(
|
||||
node, 'hi', 'text/markdown; charset=utf-8', { etag: '"v1"' });
|
||||
} catch (e) {
|
||||
status = e.status;
|
||||
name = e.name;
|
||||
}
|
||||
return {
|
||||
sentIfMatch: calls[0] && calls[0].opts.headers['If-Match'],
|
||||
method: calls[0] && calls[0].opts.method,
|
||||
status, name
|
||||
};
|
||||
});
|
||||
expect(result.method).toBe('PUT');
|
||||
expect(result.sentIfMatch).toBe('"v1"');
|
||||
expect(result.status).toBe(412);
|
||||
expect(result.name).toBe('ConflictError');
|
||||
});
|
||||
|
||||
test('saveFile returns the new ETag on success (re-edit loop)', async ({ page }) => {
|
||||
const result = await page.evaluate(async () => {
|
||||
window.fetch = async () => ({
|
||||
status: 200, ok: true,
|
||||
headers: { get: (h) => (h === 'ETag' ? '"v2"' : null) }
|
||||
});
|
||||
window.app.state.source = 'server';
|
||||
const node = { url: '/doc.md', name: 'doc.md' };
|
||||
const res = await window.app.modules.util.saveFile(
|
||||
node, 'hi', 'text/markdown; charset=utf-8', { etag: '"v1"' });
|
||||
return res;
|
||||
});
|
||||
expect(result.etag).toBe('"v2"');
|
||||
});
|
||||
|
||||
test('saveFile omits the precondition when force is set', async ({ page }) => {
|
||||
const sent = await page.evaluate(async () => {
|
||||
let headers = null;
|
||||
window.fetch = async (url, opts) => {
|
||||
headers = opts.headers;
|
||||
return { status: 200, ok: true, headers: { get: () => null } };
|
||||
};
|
||||
window.app.state.source = 'server';
|
||||
await window.app.modules.util.saveFile(
|
||||
{ url: '/doc.md', name: 'doc.md' }, 'hi', 'text/markdown',
|
||||
{ etag: '"v1"', force: true });
|
||||
return { hasIfMatch: 'If-Match' in headers };
|
||||
});
|
||||
expect(sent.hasIfMatch).toBe(false);
|
||||
});
|
||||
|
||||
test('conflict dialog renders a diff and Overwrite resolves via the callback', async ({ page }) => {
|
||||
// Kick off the dialog; stash the resolution + a flag the callback sets.
|
||||
await page.evaluate(() => {
|
||||
window.__conflict = { resolved: null, overwrote: false };
|
||||
window.app.modules.conflict.open({
|
||||
filename: 'doc.md',
|
||||
mineText: 'line one\nMINE EDIT\nline three\n',
|
||||
theirsText: 'line one\nTHEIR EDIT\nline three\n',
|
||||
onOverwrite: async () => { window.__conflict.overwrote = true; },
|
||||
onReload: async () => {},
|
||||
onSaveCopy: async () => {}
|
||||
}).then((r) => { window.__conflict.resolved = r; });
|
||||
});
|
||||
|
||||
// Modal + diff present.
|
||||
const overlay = page.locator('.md-history-overlay');
|
||||
await expect(overlay).toBeVisible();
|
||||
await expect(overlay.locator('.md-diff-add').first()).toBeVisible();
|
||||
await expect(overlay.locator('.md-diff-del').first()).toBeVisible();
|
||||
|
||||
// Click "Overwrite (keep mine)".
|
||||
await overlay.getByRole('button', { name: 'Overwrite (keep mine)' }).click();
|
||||
|
||||
await page.waitForFunction(() => window.__conflict.resolved !== null);
|
||||
const outcome = await page.evaluate(() => window.__conflict);
|
||||
expect(outcome.resolved).toBe('overwrite');
|
||||
expect(outcome.overwrote).toBe(true);
|
||||
await expect(overlay).toBeHidden();
|
||||
});
|
||||
|
||||
test('conflict dialog Cancel resolves "cancel" and runs no callback', async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
window.__c2 = { resolved: null, ran: false };
|
||||
window.app.modules.conflict.open({
|
||||
filename: 'doc.md',
|
||||
mineText: 'a\n',
|
||||
theirsText: 'b\n',
|
||||
onOverwrite: async () => { window.__c2.ran = true; },
|
||||
onReload: async () => { window.__c2.ran = true; }
|
||||
}).then((r) => { window.__c2.resolved = r; });
|
||||
});
|
||||
const overlay = page.locator('.md-history-overlay');
|
||||
await expect(overlay).toBeVisible();
|
||||
await overlay.getByRole('button', { name: 'Cancel' }).click();
|
||||
await page.waitForFunction(() => window.__c2.resolved !== null);
|
||||
const outcome = await page.evaluate(() => window.__c2);
|
||||
expect(outcome.resolved).toBe('cancel');
|
||||
expect(outcome.ran).toBe(false);
|
||||
});
|
||||
});
|
||||
170
zddc/README.md
170
zddc/README.md
|
|
@ -61,7 +61,6 @@ There is no Containerfile / Dockerfile / compose file in this repo. Two ways to
|
|||
| `ZDDC_OPA_URL` | `internal` | Policy decider endpoint. `internal` = built-in Go evaluator (default). `http(s)://...` or `unix:///...` = external OPA-compatible server (federal deployments using their own audited Rego). See "External policy decider" below. |
|
||||
| `ZDDC_OPA_FAIL_OPEN` | *(empty)* | External OPA only. `1` = on transport error, allow the request (availability over correctness). Default = fail closed (deny). Never set to `1` in federal contexts. |
|
||||
| `ZDDC_OPA_CACHE_TTL` | `1s` | External OPA only. Per-decision cache TTL — bursts of identical queries (a single `.archive` listing can hit the same `(email, dir)` tuple many times) collapse to one OPA round-trip. Set `0` to disable. Format is Go's `time.ParseDuration` (`500ms`, `2s`, `1m`). |
|
||||
| `ZDDC_APPS_PUBKEY` | *(empty)* | Path to a PEM-encoded Ed25519 public key used to verify signatures on URL-fetched `apps:` artifacts. Empty (default) = URL-fetched apps refused; only embedded + local-path apps work. Operators using zddc.varasys.io's canonical channels download `pubkey.pem` from there and pass the local path here. Operators with their own signing infrastructure pass their own public key. Same posture as `ZDDC_TLS_CERT` — zddc-server bakes nothing in. **Alternative inline form:** the same key can be pasted as `apps_pubkey:` in the root `<ZDDC_ROOT>/.zddc` (root-only, like `admins:`). The env/flag wins when both are set. |
|
||||
| `ZDDC_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
|
||||
| `ZDDC_INDEX_PATH` | `.archive` | URL path segment name for the virtual archive index |
|
||||
| `ZDDC_EMAIL_HEADER` | `X-Auth-Request-Email` | HTTP request header containing the authenticated user's email (the oauth2-proxy / nginx auth-request convention) |
|
||||
|
|
@ -672,14 +671,15 @@ naive intuition suggests.
|
|||
at `zddc/internal/zddc/file.go:17-20` (and `IsAdmin`) only reads root. This is
|
||||
the only upward-escalation gate; subtree write access never grants admin.
|
||||
|
||||
4. **An `apps:` URL override is a full UI mount, not just a tool version pin.**
|
||||
Any `.zddc` writer in a subtree can pin `archive: https://attacker.example/...`
|
||||
and serve arbitrary HTML to every viewer below that level. Subtree write
|
||||
authority on `.zddc` should be treated as full UI-mounting authority. The
|
||||
`_app/` cache is fetch-once-and-keep — operators clear it by deleting
|
||||
`<ZDDC_ROOT>/_app/<scheme>/<host>[:<port>]/<path>`. (See "Apps: virtual tool HTMLs" below for
|
||||
the resolver order; SHA-256 pinning is on the federal-readiness list, not
|
||||
currently implemented.)
|
||||
4. **Dropping a tool HTML on disk is a full UI mount, not just a file.**
|
||||
A real `<app>.html` at a path — or an `<app>.html` member of the site
|
||||
`<ZDDC_ROOT>/.zddc.zip` bundle — is served verbatim to every viewer at or
|
||||
below that scope. So write access to a directory is effectively UI-mounting
|
||||
authority for it, and write access to `<root>/.zddc.zip` is a **site-wide**
|
||||
UI mount (treat it like `admins:` — keep the root writable only by admins).
|
||||
There is no remote fetch and nothing to sign: the bytes are whatever sits on
|
||||
the local filesystem, governed by the same ACL/WORM as any other file. (See
|
||||
"Apps: virtual tool HTMLs" below for the resolver order.)
|
||||
|
||||
5. **Relying on `/Archive/` being unbrowsable to "hide" sibling vendor folders'
|
||||
existence.** Sibling-vendor names are hidden because directories the caller
|
||||
|
|
@ -742,10 +742,12 @@ guarantee these for the model above to hold:
|
|||
logging, ship the JSON-line file to an external append-only sink (syslog,
|
||||
SIEM) via a sidecar; do not treat the local rotation as the system of
|
||||
record.
|
||||
4. **`apps:` URL fetches have no integrity check.** Fetched once on first
|
||||
miss, cached at `<ZDDC_ROOT>/_app/<scheme>/<host>[:<port>]/<path>` forever — no SHA-256 pin,
|
||||
no signature. Use only URLs you control, treat the apps cache as a trust
|
||||
boundary, and audit who has `.zddc` write authority where.
|
||||
4. **Tool-HTML overrides are local files, not fetched/signed.** A tool's HTML
|
||||
comes from a real file at the path, an `<app>.html` member of the site
|
||||
`<ZDDC_ROOT>/.zddc.zip`, or the embedded default — never the network. There
|
||||
is nothing to verify; the trust boundary is filesystem write access. Audit
|
||||
who can write tool HTML at each scope (especially `<root>/.zddc.zip`, a
|
||||
site-wide UI mount).
|
||||
|
||||
### Debugging permissions
|
||||
|
||||
|
|
@ -949,9 +951,9 @@ have to redo the gap analysis from scratch.
|
|||
subcommand. See §"Policy export for change control" below.
|
||||
- **Supply-chain integrity** (NIST SI-7) — vendored libs (jszip,
|
||||
docx-preview, xlsx) need SBOM, CVE tracking, automated update pipeline.
|
||||
`apps:` URL fetches need code signing (operator trusts a published
|
||||
public key once; no per-artifact hash management). See §"Code-signed
|
||||
apps: URL fetches" below.
|
||||
Tool HTML is no longer fetched at runtime (overrides are local files /
|
||||
the site `.zddc.zip` bundle, governed by filesystem ACL), so there is no
|
||||
remote-artifact signing requirement here.
|
||||
- **Data-at-rest encryption** (NIST SC-28) — delegated to the deployment
|
||||
platform. Required: documented baseline (cloud KMS, LUKS, dm-crypt) with
|
||||
key-rotation procedures.
|
||||
|
|
@ -962,11 +964,10 @@ have to redo the gap analysis from scratch.
|
|||
A full SSP / control-by-control mapping consumes this list as input; it is
|
||||
not a substitute for one.
|
||||
|
||||
The four bullets most likely to need engineering depth — FIPS, the
|
||||
authenticated proxy channel, policy export, and signed `apps:` URL
|
||||
fetches — have their own subsections below capturing the design
|
||||
considerations and effort estimates so a future implementor doesn't
|
||||
restart from zero.
|
||||
The bullets most likely to need engineering depth — FIPS, the
|
||||
authenticated proxy channel, and policy export — have their own
|
||||
subsections below capturing the design considerations and effort
|
||||
estimates so a future implementor doesn't restart from zero.
|
||||
|
||||
#### FIPS-validated cryptography (NIST SC-13)
|
||||
|
||||
|
|
@ -1164,77 +1165,6 @@ match — small graph problem), and the format renderers.
|
|||
**Effort estimate:** ~250 lines of Go (CLI subcommand + equivalence-
|
||||
class computation + JSON/Markdown/CSV renderers) + ~100 lines of tests.
|
||||
|
||||
#### Code-signed `apps:` URL fetches (NIST SI-7)
|
||||
|
||||
**The supply-chain risk today.** The `apps:` mechanism fetches a URL
|
||||
once on first request, caches the bytes forever, and serves them to
|
||||
every viewer below that level. There's no integrity check. If the
|
||||
fetched URL is ever compromised — DNS hijack, CDN account takeover,
|
||||
malicious upstream commit, MITM during the one fetch window — every
|
||||
customer caches the bad bytes. The blast radius is "every user who
|
||||
visits an archive page in a subtree where this `.zddc` applies."
|
||||
|
||||
**Why code signing instead of SHA-256 pinning.** SHA-256 pinning would
|
||||
require operators to track-and-update a hash in `.zddc` every time an
|
||||
artifact changes. Wrong workflow for this product. Code signing
|
||||
sidesteps the operator entirely:
|
||||
|
||||
- Release pipeline signs each artifact once at publish time with a
|
||||
long-lived private key.
|
||||
- Operator trusts the published public key once and never deals with
|
||||
hashes.
|
||||
- zddc-server fetches the URL, downloads the detached signature
|
||||
(e.g. `<artifact>.sig`), verifies against the configured public key,
|
||||
caches if valid.
|
||||
|
||||
**Implementation has three parts** that interlock:
|
||||
|
||||
1. **Signing in the build pipeline.** `./build release` runs
|
||||
`sign_release_artifacts` (in `./build`) after promote: walks
|
||||
`dist/release-output/` and produces a detached Ed25519 signature
|
||||
(`<artifact>.sig`) alongside every real file. Private key path comes
|
||||
from `ZDDC_SIGNING_KEY`; absent or unreadable → release fails.
|
||||
Symlinks (the canonical `<tool>.html` and `zddc-server_<platform>`
|
||||
URLs) skip — the .sig at the symlink target is what counts; a
|
||||
companion `.sig` symlink chains the canonical URL to that target.
|
||||
|
||||
2. **Public key on the website.** `pubkey.pem` is a real file in
|
||||
`~/src/zddc-website/`, deployed to `zddc.varasys.io/pubkey.pem`.
|
||||
The releases-page index includes a "Verify your downloads"
|
||||
section with a download link, the SHA-256 fingerprint shown in
|
||||
plain text, and a `curl + openssl pkeyutl -verify` example.
|
||||
|
||||
3. **Verifier in zddc-server (`apps/fetch.go`).** When fetching a
|
||||
URL-pinned `apps:` artifact, also fetch `<url>.sig`, then call
|
||||
`VerifyEd25519` against `Fetcher.VerifyKey`. The key is loaded
|
||||
at startup with this resolution order:
|
||||
1. `--apps-pubkey` / `ZDDC_APPS_PUBKEY` (path to PEM file)
|
||||
2. `apps_pubkey:` inline PEM in the root `.zddc` file (root-only,
|
||||
same trust-anchor treatment as `admins:`)
|
||||
3. nothing → URL fetches refused
|
||||
Failure cases at fetch time — sig 404, transport error, wrong key,
|
||||
tampered body — all reject; the body is dropped and the apps
|
||||
resolver falls back to the embedded copy. No baked-in default
|
||||
public key; same posture as TLS certificates.
|
||||
|
||||
**Trust model.** The operator decides which signing infrastructure to
|
||||
trust by configuring `--apps-pubkey`. The website publishes the
|
||||
canonical-channel public key; operators who use `apps: archive: stable`
|
||||
download it once, save the file on their server, and configure the
|
||||
path. Operators running their own signing point at their own pubkey
|
||||
instead. zddc-server has no opinion.
|
||||
|
||||
**Future direction.** A `signed_by:` field per-`apps:` entry would let
|
||||
a single deployment trust multiple signing keys (one for canonical
|
||||
channels, one for an in-house mirror). Sigstore integration
|
||||
(transparency-log-backed signing via `github.com/sigstore/sigstore`)
|
||||
is the federally-acceptable evolution. Both are additive on top of
|
||||
the current single-key-per-server model.
|
||||
|
||||
**What's currently in place.** All three parts. The scaffolding
|
||||
matches the design above one-to-one; future enhancements are
|
||||
extensions, not refactors.
|
||||
|
||||
### External policy decider (OPA-compatible)
|
||||
|
||||
For deployments that need policy decisions made by an external,
|
||||
|
|
@ -1521,8 +1451,8 @@ fsnotify watcher's debounce window (~2 s) — no service restart needed.
|
|||
`zddc-server` virtually serves the tool HTMLs (archive, transmittal,
|
||||
classifier, landing, browse, form, tables) at the appropriate paths.
|
||||
The current-stable build of each tool is **baked into the binary at
|
||||
compile time** via `//go:embed`; that's the default. No fetch happens
|
||||
out of the box.
|
||||
compile time** via `//go:embed`; that's the default. Overrides are
|
||||
**local only** — there is no network fetch, ever.
|
||||
|
||||
### Where each tool is served
|
||||
|
||||
|
|
@ -1536,40 +1466,40 @@ out of the box.
|
|||
|
||||
Outside these locations, the corresponding `<app>.html` URL returns 404.
|
||||
|
||||
### Override and version-pin
|
||||
### Override (local only)
|
||||
|
||||
For any path, the resolution order is:
|
||||
|
||||
1. **Real file at the path** — operator drops `archive.html` (or any other)
|
||||
into a directory; the static handler serves it. Beats everything below.
|
||||
2. **Closer-to-leaf `.zddc apps:` entry** — walks `.zddc` files leaf→root
|
||||
for an `apps.<app>` entry. The first match wins. Spec is one of:
|
||||
- `stable` (canonical upstream "current stable")
|
||||
- `v0.0.4` (canonical upstream exact-version pin)
|
||||
- `https://...` (full URL to a custom mirror)
|
||||
- `./local.html` / `/abs/path.html` (local file)
|
||||
1. **Real file at the path** — drop a real `archive.html` (or `browse.html`,
|
||||
or a brand-new `mytool.html`) into a directory; the static handler serves
|
||||
it. Beats everything below.
|
||||
2. **Site bundle `<ZDDC_ROOT>/.zddc.zip`** — a local zip whose `<app>.html`
|
||||
members override the embedded default site-wide (and let you add new
|
||||
`<name>.html` tools). The server reads members from the filesystem via
|
||||
`internal/zipfs` — no fetch, no signature. The bundle is re-stat'd on each
|
||||
request, so dropping in a new one takes effect immediately.
|
||||
3. **Embedded** — the build-time HTML compiled into the binary.
|
||||
|
||||
URL sources are fetched once on first request and cached forever in
|
||||
`<ZDDC_ROOT>/_app/<scheme>/<host>[:<port>]/<path>`. There is no background refresh and no
|
||||
hash verification — to pull a new build, delete the cache file. Concurrent
|
||||
misses for the same URL share one outbound fetch (singleflight). Direct
|
||||
URL access to `/_app/...` is blocked at dispatch; cached HTMLs are served
|
||||
only via the apps resolver.
|
||||
|
||||
If a configured URL fetch fails (network down, 5xx), the server falls back
|
||||
to the embedded copy and emits a one-time WARN log per source. The
|
||||
`X-ZDDC-Source` response header always reports what was served:
|
||||
`fetch:URL`, `cache:URL`, `path:/abs`, or `embedded:<app>@<build>`.
|
||||
There is no `apps:` `.zddc` key, no channels/versions, no URL fetching, and no
|
||||
signature verification — all removed in favour of this local model. `.zddc.zip`
|
||||
is config, not content: a direct `GET /.zddc.zip` returns 404 for everyone,
|
||||
while the server reads its members internally (so resolution works for any
|
||||
user). The `X-ZDDC-Source` response header reports what was served:
|
||||
`bundle:<app>.html` or `embedded:<app>@<build>` (an on-disk override is served
|
||||
by the static handler with its own headers).
|
||||
|
||||
### Example
|
||||
|
||||
```yaml
|
||||
# <ZDDC_ROOT>/Project-A/.zddc
|
||||
apps:
|
||||
classifier: v0.0.4 # pin classifier to v0.0.4 for this project
|
||||
archive: https://my-mirror.internal/zddc/archive_v0.0.4.html # custom mirror, pinned
|
||||
browse: ./our-browse.html # local fork
|
||||
Override `browse` everywhere and add a custom `report` tool via the bundle:
|
||||
|
||||
```sh
|
||||
cd <ZDDC_ROOT> && zip .zddc.zip browse.html report.html
|
||||
```
|
||||
|
||||
Or override a single tool in one project by dropping a file:
|
||||
|
||||
```sh
|
||||
cp our-browse.html <ZDDC_ROOT>/Project-A/browse.html
|
||||
```
|
||||
|
||||
### Env vars
|
||||
|
|
|
|||
|
|
@ -507,52 +507,12 @@ func newGzipWrapper() (func(http.Handler) http.HandlerFunc, error) {
|
|||
return gzhttp.NewWrapper(gzhttp.MinSize(1024))
|
||||
}
|
||||
|
||||
// setupApps creates the cache + fetcher + server. No seeding, no refresh,
|
||||
// no admin UI — the server fetches once on first request, caches forever
|
||||
// in <ZDDC_ROOT>/.zddc.d/apps/, and falls back to the embedded HTML on any failure.
|
||||
// setupApps builds the tool-HTML server. Resolution is LOCAL-ONLY: a real
|
||||
// file on disk at the request path (handled upstream by dispatch) → a
|
||||
// "<app>.html" member of the site-root <ZDDC_ROOT>/.zddc.zip bundle → the
|
||||
// embedded default. No fetch, no cache, no signatures.
|
||||
func setupApps(cfg config.Config) (*apps.Server, error) {
|
||||
cache, err := apps.NewCache(filepath.Join(cfg.Root, handler.ReservedSidecar, apps.CacheDirName))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create cache: %w", err)
|
||||
}
|
||||
fetcher := apps.NewFetcher(cache, slog.Default())
|
||||
|
||||
// Apps signing pubkey. Resolution order, highest priority first:
|
||||
// 1. --apps-pubkey / ZDDC_APPS_PUBKEY (path to PEM file)
|
||||
// 2. apps_pubkey: inline PEM in the root <ZDDC_ROOT>/.zddc file
|
||||
// (root-only — same trust-anchor treatment as admins:)
|
||||
// 3. nothing → URL-fetched apps refuse-by-default; only embedded
|
||||
// + local-path apps work
|
||||
//
|
||||
// Same posture as TLS certificates: zddc-server bakes nothing in.
|
||||
// Operators using zddc.varasys.io's canonical channels download
|
||||
// pubkey.pem from there and either configure the path via env/flag
|
||||
// or paste the PEM contents inline into root .zddc.
|
||||
switch {
|
||||
case cfg.AppsPubKey != "":
|
||||
pub, err := apps.LoadPubKey(cfg.AppsPubKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("apps-pubkey: %w", err)
|
||||
}
|
||||
fetcher.VerifyKey = pub
|
||||
slog.Info("apps signing pubkey loaded", "source", "env/flag", "path", cfg.AppsPubKey)
|
||||
default:
|
||||
// Fall back to apps_pubkey: in root .zddc.
|
||||
rootZddc, err := zddc.ParseFile(filepath.Join(cfg.Root, ".zddc"))
|
||||
if err == nil && rootZddc.AppsPubKey != "" {
|
||||
pub, err := apps.ParsePubKeyPEM([]byte(rootZddc.AppsPubKey))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("root .zddc apps_pubkey: %w", err)
|
||||
}
|
||||
fetcher.VerifyKey = pub
|
||||
slog.Info("apps signing pubkey loaded", "source", "root .zddc apps_pubkey")
|
||||
} else {
|
||||
slog.Warn("apps-pubkey not configured; URL-fetched apps will be refused (only embedded + local-path apps will work). " +
|
||||
"Set --apps-pubkey, ZDDC_APPS_PUBKEY, or apps_pubkey: in the root .zddc file to a PEM Ed25519 public key you trust.")
|
||||
}
|
||||
}
|
||||
|
||||
return apps.NewServer(cfg.Root, cache, fetcher, version), nil
|
||||
return apps.NewServer(cfg.Root, version), nil
|
||||
}
|
||||
|
||||
// warnIfNoBootstrap fires a startup slog.Warn when the root .zddc grants
|
||||
|
|
@ -663,6 +623,14 @@ func embeddedVersionsForLog(embedded map[string]string) string {
|
|||
// authenticated user (may be empty).
|
||||
func serveSpecializedNoSlash(cfg config.Config, appsSrv *apps.Server, w http.ResponseWriter, r *http.Request, dirAbs, urlPath, email string) bool {
|
||||
app := apps.DefaultAppAt(cfg.Root, dirAbs)
|
||||
// An explicit `views.dir` in the cascade overrides the default_tool-
|
||||
// derived app for the no-slash directory URL — the generalization's
|
||||
// dir-shape routing. default_tool remains the sugar fallback (ViewAt
|
||||
// returns it when no views.dir is declared), so existing deployments
|
||||
// are unaffected.
|
||||
if v, ok := zddc.ViewAt(cfg.Root, dirAbs, "dir"); ok && v.Tool != "" {
|
||||
app = v.Tool
|
||||
}
|
||||
if app == "" {
|
||||
return false
|
||||
}
|
||||
|
|
@ -741,6 +709,30 @@ func splitZipPath(fsRoot, urlPath string) (zipAbs, member string, ok bool) {
|
|||
return "", "", false
|
||||
}
|
||||
|
||||
// activeAdminForBundle reports whether the request principal is an active
|
||||
// (elevated) admin over the directory that holds the .zddc.zip config bundle
|
||||
// referenced by urlPath. Mirrors handler.ActiveAdminForSidecar: the bundle is
|
||||
// existence-hidden config for everyone else, but an elevated admin over its
|
||||
// directory may browse its members and download it. Works for every bundle URL
|
||||
// shape (bare, trailing-slash listing, and <bundle>/<member>) since it keys off
|
||||
// the path segment that precedes the bundle name.
|
||||
func activeAdminForBundle(cfg config.Config, r *http.Request, urlPath string) bool {
|
||||
p := handler.PrincipalFromContext(r)
|
||||
if !p.Elevated || p.Email == "" {
|
||||
return false
|
||||
}
|
||||
parent := make([]string, 0)
|
||||
for _, seg := range strings.Split(strings.Trim(urlPath, "/"), "/") {
|
||||
if strings.EqualFold(seg, apps.BundleName) {
|
||||
break
|
||||
}
|
||||
parent = append(parent, seg)
|
||||
}
|
||||
dir := filepath.Join(cfg.Root, filepath.FromSlash(strings.Join(parent, "/")))
|
||||
chain, _ := zddc.EffectivePolicy(cfg.Root, dir)
|
||||
return zddc.IsAdminForChain(chain, p.Email)
|
||||
}
|
||||
|
||||
// dispatch routes a request to the appropriate handler.
|
||||
func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, appsSrv *apps.Server, tokens *auth.Store, w http.ResponseWriter, r *http.Request) {
|
||||
// URL paths are case-insensitive: resolve each segment against the
|
||||
|
|
@ -838,6 +830,29 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
return
|
||||
}
|
||||
|
||||
// The site-root config bundle <ZDDC_ROOT>/.zddc.zip is config, not
|
||||
// ordinary content: existence-hidden over HTTP for everyone EXCEPT an
|
||||
// active (elevated) admin over its directory, who may browse it in the
|
||||
// file tree. For an admin every bundle URL falls through to normal
|
||||
// handling — GET <bundle>/ lists its members (the zip-as-directory
|
||||
// intercept below), GET <bundle>/member extracts one, and a bare
|
||||
// GET <bundle> downloads it. Everyone else gets 404 for every form,
|
||||
// which also keeps individual members from being fetched by name. The
|
||||
// server reads members from the filesystem internally (apps.Bundle) to
|
||||
// resolve tool HTML — that path never goes through dispatch, so this
|
||||
// gate doesn't affect resolution.
|
||||
bundlePath := false
|
||||
for _, seg := range segments {
|
||||
if strings.EqualFold(seg, apps.BundleName) {
|
||||
bundlePath = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if bundlePath && !activeAdminForBundle(cfg, r, urlPath) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Raw .zddc YAML view: <dir>/.zddc is reachable at every depth
|
||||
// and returns the on-disk file's bytes (Content-Type: application/yaml)
|
||||
// or — when no file exists — a synthetic placeholder body with a
|
||||
|
|
@ -1259,6 +1274,27 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
return
|
||||
}
|
||||
|
||||
// views.file → form editor. A browser NAVIGATION (Accept: text/html) to a
|
||||
// no-slash data file whose cascade declares views.file = {tool: form}
|
||||
// serves the form editor bound to that file. Programmatic reads — the
|
||||
// tables client fetches rows with Accept: */* — and an explicit ?raw fall
|
||||
// through to the raw bytes (the injected-row / ServeFile path below), so
|
||||
// this never breaks row fetching. The POST goes to the canonical
|
||||
// <file>.yaml.html update URL (the existing form-update handler).
|
||||
if r.Method == http.MethodGet && !r.URL.Query().Has("raw") &&
|
||||
strings.Contains(r.Header.Get("Accept"), "text/html") {
|
||||
if v, ok := zddc.ViewAt(cfg.Root, filepath.Dir(absPath), "file"); ok && v.Tool == "form" {
|
||||
fr := &handler.FormRequest{
|
||||
Kind: "render-edit",
|
||||
SpecPath: filepath.Join(filepath.Dir(absPath), "form.yaml"),
|
||||
DataPath: absPath,
|
||||
SubmitURL: urlPath + ".html",
|
||||
}
|
||||
handler.ServeForm(cfg, fr, w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// (MD→{docx,html,pdf} on-demand conversion now lives at
|
||||
// `GET /<dir>/<file>.{docx,html,pdf}` (virtual file URL,
|
||||
// see RecognizeVirtualConvert). The .md source serves
|
||||
|
|
|
|||
|
|
@ -4,18 +4,14 @@ import (
|
|||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
|
||||
|
|
@ -100,60 +96,22 @@ func TestDispatchReservesZddcD(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestDispatchAppsResolution drives the full apps fetch+cache flow through
|
||||
// dispatch() with a fake upstream. Confirms that:
|
||||
// - GET / serves the landing app from the apps subsystem
|
||||
// - GET /archive.html serves the archive app via fetch+cache
|
||||
// - second GET /archive.html serves from cache (X-ZDDC-Source: cache:)
|
||||
// - direct URL access to the reserved cache (/.zddc.d/apps/...) is rejected
|
||||
// TestDispatchAppsResolution drives local tool-HTML resolution through
|
||||
// dispatch(): the site .zddc.zip member overrides the embedded default, the
|
||||
// embedded default is served when no bundle member exists, GET / serves
|
||||
// landing, the bundle itself is 404 over HTTP, and folder-availability rules
|
||||
// still gate which tools are served where.
|
||||
func TestDispatchAppsResolution(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
body := []byte("<!doctype html>archive content")
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
sig := ed25519.Sign(priv, body)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Same body for every artifact; same signature for every .sig
|
||||
// (since the body is identical across the five tools in this
|
||||
// fixture). Real deployments publish a distinct .sig per
|
||||
// artifact; the test only cares that the verify gate passes.
|
||||
if strings.HasSuffix(r.URL.Path, ".sig") {
|
||||
_, _ = w.Write(sig)
|
||||
return
|
||||
}
|
||||
w.Header().Set("ETag", `"v1"`)
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
upstreamURL, _ := url.Parse(upstream.URL)
|
||||
upstreamHost := upstreamURL.Host
|
||||
if i := strings.Index(upstreamHost, ":"); i >= 0 {
|
||||
upstreamHost = upstreamHost[:i]
|
||||
}
|
||||
|
||||
_ = upstreamHost // referenced below
|
||||
|
||||
// Seed root .zddc with subdir-cascade Apps entries pointing at the
|
||||
// fake upstream. Allow all email patterns (anonymous) so the test
|
||||
// doesn't have to set up email headers.
|
||||
zf := zddc.ZddcFile{
|
||||
ACL: zddc.ACLRules{Permissions: map[string]string{"*": "rwcd"}},
|
||||
Apps: map[string]string{
|
||||
"archive": upstream.URL + "/archive_stable.html",
|
||||
"transmittal": upstream.URL + "/transmittal_stable.html",
|
||||
"classifier": upstream.URL + "/classifier_stable.html",
|
||||
"landing": upstream.URL + "/landing_stable.html",
|
||||
"browse": upstream.URL + "/browse_stable.html",
|
||||
},
|
||||
}
|
||||
// Allow-all ACL so the test doesn't need email headers.
|
||||
zf := zddc.ZddcFile{ACL: zddc.ACLRules{Permissions: map[string]string{"*": "rwcd"}}}
|
||||
if err := zddc.WriteFile(root, zf); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
// Create folder convention dirs so classifier/browse/transmittal
|
||||
// availability rules pass for the test paths used below.
|
||||
// Site config bundle overriding archive.html.
|
||||
writeRootBundle(t, root, map[string]string{"archive.html": "<!doctype html>BUNDLE archive"})
|
||||
// Folder-convention dir so classifier availability passes below.
|
||||
mustMkdir(t, filepath.Join(root, "Project-A", "working", "Acme"))
|
||||
|
||||
idx, err := archive.BuildIndex(root)
|
||||
|
|
@ -166,47 +124,33 @@ func TestDispatchAppsResolution(t *testing.T) {
|
|||
EmailHeader: "X-Auth-Request-Email",
|
||||
}
|
||||
ring := handler.NewLogRing(10)
|
||||
|
||||
appsSrv, err := setupApps(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("setupApps: %v", err)
|
||||
}
|
||||
// Override the production embedded public key with the test fixture's
|
||||
// pubkey so signature verification of upstream.Sign'd bodies succeeds.
|
||||
appsSrv.Fetcher.VerifyKey = pub
|
||||
|
||||
// GET /archive.html → fetched from upstream (archive is available everywhere)
|
||||
// GET /archive.html → served from the bundle member (overrides embedded).
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
|
||||
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("first /archive.html: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
dispatch(cfg, idx, ring, appsSrv, nil, rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil))
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "BUNDLE archive") {
|
||||
t.Fatalf("/archive.html: status=%d body=%s (want bundle override)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Body.String() != string(body) {
|
||||
t.Errorf("first /archive.html: body mismatch")
|
||||
if rec.Header().Get("X-ZDDC-Source") != "bundle:archive.html" {
|
||||
t.Errorf("X-ZDDC-Source=%q, want bundle:archive.html", rec.Header().Get("X-ZDDC-Source"))
|
||||
}
|
||||
|
||||
// GET /archive.html again → cache hit (no new upstream fetch)
|
||||
rec2 := httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, appsSrv, nil, rec2, httptest.NewRequest(http.MethodGet, "/archive.html", nil))
|
||||
if rec2.Code != http.StatusOK {
|
||||
t.Errorf("second /archive.html: status=%d", rec2.Code)
|
||||
}
|
||||
|
||||
// GET / → landing
|
||||
// GET / → landing (no bundle member → embedded).
|
||||
rec3 := httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, appsSrv, nil, rec3, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if rec3.Code != http.StatusOK {
|
||||
t.Errorf("GET /: status=%d", rec3.Code)
|
||||
}
|
||||
|
||||
// The apps cache lives under the reserved sidecar (.zddc.d/apps/); direct
|
||||
// URL access by a non-admin is 404'd by the sidecar gate, so cached HTML
|
||||
// can only ever be served through the apps resolver (proper headers/ACL).
|
||||
// The site bundle is config, not content: a direct GET is 404 for everyone.
|
||||
rec4 := httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, appsSrv, nil, rec4, httptest.NewRequest(http.MethodGet, "/.zddc.d/apps/foo.html", nil))
|
||||
dispatch(cfg, idx, ring, appsSrv, nil, rec4, httptest.NewRequest(http.MethodGet, "/.zddc.zip", nil))
|
||||
if rec4.Code != http.StatusNotFound {
|
||||
t.Errorf("/.zddc.d/apps/ direct: status=%d, want 404", rec4.Code)
|
||||
t.Errorf("GET /.zddc.zip: status=%d, want 404", rec4.Code)
|
||||
}
|
||||
|
||||
// Folder availability rules: classifier should NOT be served at root
|
||||
|
|
@ -277,10 +221,6 @@ func TestDispatchRootAppShellPublicButDataGated(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// silence "imported and not used" if apps not referenced elsewhere — keep
|
||||
// import even when we trim test cases later.
|
||||
var _ = apps.DefaultUpstream
|
||||
|
||||
// TestDispatchRoutesWritesToFileAPI verifies dispatch sends PUT/DELETE/POST
|
||||
// to the file API rather than to the read pipeline.
|
||||
func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
||||
|
|
@ -1094,3 +1034,162 @@ func TestGzhttpWrapper_CompressesLargeResponses(t *testing.T) {
|
|||
// dot-prefix guard, like any bookkeeping, and surfaced only through the
|
||||
// history endpoints. Raw-block coverage is in TestDispatchHidesDotPrefixedSegments;
|
||||
// the viewer is covered in mdhistory_test.go.)
|
||||
|
||||
// writeRootBundle writes <root>/.zddc.zip containing the given members.
|
||||
// Used by dispatch tests exercising the local tool-HTML bundle override.
|
||||
func writeRootBundle(t *testing.T, root string, members map[string]string) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for name, body := range members {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("zip create %s: %v", name, err)
|
||||
}
|
||||
if _, err := w.Write([]byte(body)); err != nil {
|
||||
t.Fatalf("zip write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("zip close: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, ".zddc.zip"), buf.Bytes(), 0o644); err != nil {
|
||||
t.Fatalf("write bundle: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatchFileToFormView locks in the views.file → form shape: a browser
|
||||
// NAVIGATION (Accept: text/html) to a no-slash data file, in a dir whose
|
||||
// cascade declares views.file = {tool: form}, serves the form editor bound to
|
||||
// that file — while a programmatic fetch (Accept: */*, the tables client) and
|
||||
// an explicit ?raw still get raw bytes, so row fetching never breaks. A dir
|
||||
// without views.file keeps serving raw bytes on navigation too.
|
||||
func TestDispatchFileToFormView(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||
"acl:\n permissions:\n \"*\": rwcd\n")
|
||||
dir := filepath.Join(root, "Proj", "records")
|
||||
mustMkdir(t, dir)
|
||||
// views.file declared on the records dir → form editor for its files.
|
||||
mustWrite(t, filepath.Join(dir, ".zddc"),
|
||||
"views:\n file:\n tool: form\n")
|
||||
// Form schema lives in the supporting-files reserve.
|
||||
mustMkdir(t, filepath.Join(dir, ".zddc.d"))
|
||||
mustWrite(t, filepath.Join(dir, ".zddc.d", "form.yaml"),
|
||||
"schema:\n type: object\n properties:\n title:\n type: string\n")
|
||||
mustWrite(t, filepath.Join(dir, "rec1.yaml"), "title: Hello\n")
|
||||
// A sibling file in a dir WITHOUT views.file stays raw.
|
||||
mustWrite(t, filepath.Join(root, "Proj", "plain.yaml"), "x: 1\n")
|
||||
|
||||
idx, err := archive.BuildIndex(root)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildIndex: %v", err)
|
||||
}
|
||||
cfg := config.Config{Root: root, IndexPath: ".archive", EmailHeader: "X-Auth-Request-Email"}
|
||||
ring := handler.NewLogRing(10)
|
||||
appsSrv, err := setupApps(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("setupApps: %v", err)
|
||||
}
|
||||
|
||||
do := func(path string, hdr map[string]string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// Navigation → form editor HTML.
|
||||
rec := do("/Proj/records/rec1.yaml", map[string]string{"Accept": "text/html"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("navigation: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
|
||||
t.Errorf("navigation Content-Type=%q, want text/html (form)", ct)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "title: Hello") {
|
||||
t.Errorf("navigation served raw YAML, want the form editor")
|
||||
}
|
||||
|
||||
// Programmatic fetch (Accept: */*) → raw YAML bytes.
|
||||
rec2 := do("/Proj/records/rec1.yaml", map[string]string{"Accept": "*/*"})
|
||||
if rec2.Code != http.StatusOK || !strings.Contains(rec2.Body.String(), "title: Hello") {
|
||||
t.Errorf("fetch: status=%d body=%q, want raw YAML", rec2.Code, rec2.Body.String())
|
||||
}
|
||||
|
||||
// ?raw escape hatch → raw bytes even for a browser.
|
||||
rec3 := do("/Proj/records/rec1.yaml?raw=1", map[string]string{"Accept": "text/html"})
|
||||
if !strings.Contains(rec3.Body.String(), "title: Hello") {
|
||||
t.Errorf("?raw body=%q, want raw YAML", rec3.Body.String())
|
||||
}
|
||||
|
||||
// No views.file declared → navigation still serves raw bytes.
|
||||
rec4 := do("/Proj/plain.yaml", map[string]string{"Accept": "text/html"})
|
||||
if !strings.Contains(rec4.Body.String(), "x: 1") {
|
||||
t.Errorf("no-views file body=%q, want raw YAML", rec4.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatchBundleAdminView locks in admin-mode visibility of the site-root
|
||||
// .zddc.zip config bundle: an active (elevated) admin may browse it as a zip
|
||||
// directory (list members, extract a member) and download it, while everyone
|
||||
// else — including the same admin un-elevated, and non-admins — gets 404 for
|
||||
// every bundle URL shape (closing the previous by-name member read).
|
||||
func TestDispatchBundleAdminView(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// alice is a root admin; bob is a plain reader.
|
||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||
"acl:\n permissions:\n \"alice@x\": rwcda\n \"bob@x\": r\nadmins:\n - alice@x\n")
|
||||
writeRootBundle(t, root, map[string]string{
|
||||
"archive.html": "<!doctype html>BUNDLE archive",
|
||||
"sub/note.txt": "a member note",
|
||||
})
|
||||
|
||||
idx, err := archive.BuildIndex(root)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildIndex: %v", err)
|
||||
}
|
||||
cfg := config.Config{Root: root, IndexPath: ".archive", EmailHeader: "X-Auth-Request-Email"}
|
||||
ring := handler.NewLogRing(10)
|
||||
appsSrv, err := setupApps(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("setupApps: %v", err)
|
||||
}
|
||||
|
||||
do := func(path, email string, elevated bool) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
ctx := context.WithValue(req.Context(), handler.EmailKey, email)
|
||||
ctx = context.WithValue(ctx, handler.ElevatedKey, elevated)
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// Elevated admin: member listing, member extract, and bare download all work.
|
||||
if rec := do("/.zddc.zip/", "alice@x", true); rec.Code != http.StatusOK {
|
||||
t.Errorf("admin GET /.zddc.zip/ : status=%d body=%s, want 200 listing", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := do("/.zddc.zip/archive.html", "alice@x", true); rec.Code != http.StatusOK ||
|
||||
!strings.Contains(rec.Body.String(), "BUNDLE archive") {
|
||||
t.Errorf("admin GET member: status=%d body=%s, want 200 member bytes", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := do("/.zddc.zip", "alice@x", true); rec.Code != http.StatusOK {
|
||||
t.Errorf("admin GET bare /.zddc.zip : status=%d, want 200 download", rec.Code)
|
||||
}
|
||||
|
||||
// Same admin un-elevated → 404 (sudo model: powers are per-request).
|
||||
if rec := do("/.zddc.zip/", "alice@x", false); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("un-elevated admin GET /.zddc.zip/ : status=%d, want 404", rec.Code)
|
||||
}
|
||||
// Non-admin reader → 404 for listing AND by-name member (no leak).
|
||||
if rec := do("/.zddc.zip/", "bob@x", true); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("non-admin GET /.zddc.zip/ : status=%d, want 404", rec.Code)
|
||||
}
|
||||
if rec := do("/.zddc.zip/archive.html", "bob@x", true); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("non-admin GET member: status=%d, want 404 (no by-name leak)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,415 +1,22 @@
|
|||
// Package apps serves the ZDDC tool HTML files (archive, transmittal,
|
||||
// classifier, landing, browse, form, tables) on virtual paths in the
|
||||
// file tree. Each tool is "available" only at directories whose name
|
||||
// matches a folder convention (Incoming/Working/Staging) — see
|
||||
// availability.go. The markdown editor lives as a plugin inside browse.
|
||||
// classifier, landing, browse, form, tables) on virtual paths in the file
|
||||
// tree. Each tool is "available" only at directories whose cascade selects
|
||||
// it (default_tool / dir_tool / available_tools) — see availability.go and
|
||||
// the .zddc cascade. The markdown editor lives as a plugin inside browse.
|
||||
//
|
||||
// Resolution priority for an enabled <dir>/<app>.html request:
|
||||
// Tool HTML resolution is LOCAL-ONLY — no network fetch, no signatures, no
|
||||
// channels/versions. For an enabled <dir>/<app>.html request the bytes come
|
||||
// from, in precedence:
|
||||
//
|
||||
// 1. Real file at the request path → static handler (operator override).
|
||||
// 2. Subdir cascade — walk .zddc files root→leaf, accumulating URL prefix
|
||||
// and channel/version components from the special `apps.default` key
|
||||
// and the per-app `apps.<name>` key. Either component can be set,
|
||||
// overridden, or left to inherit at any level. Path or full-`.html`-URL
|
||||
// entries are *terminal* — they short-circuit composition and a deeper
|
||||
// non-terminal entry overrides a parent terminal.
|
||||
// 3. Embedded fallback — bytes baked into the binary at compile time via
|
||||
// //go:embed. Used when no `apps:` entry was found anywhere up the chain.
|
||||
// 1. A real file on disk at the request path → static handler (operator
|
||||
// override; handled by the dispatcher BEFORE Serve is ever reached, so
|
||||
// by the time Serve runs no such file exists).
|
||||
// 2. A member of the site-root config bundle <ZDDC_ROOT>/.zddc.zip, named
|
||||
// "<app>.html", read server-side via internal/zipfs (see bundle.go).
|
||||
// 3. The embedded default baked into the binary at compile time via
|
||||
// //go:embed (see embed.go).
|
||||
//
|
||||
// Spec forms (each is a string value in `.zddc apps:`):
|
||||
//
|
||||
// :stable / :v0.0.4 — channel-only
|
||||
// stable / v0.0.4 / 0.0.4 — channel-only (no leading colon)
|
||||
// https://host/path — URL-prefix only (combines with cascade channel)
|
||||
// https://host/path:stable — URL-prefix + channel (composes)
|
||||
// https://host/path/file.html — terminal full URL (used as-is)
|
||||
// ./local.html / /abs/local.html — terminal local path
|
||||
//
|
||||
// No background refresh, no SHA-256 verification. To pick up new upstream
|
||||
// bytes, delete the cache file (or the whole .zddc.d/apps/ tree).
|
||||
// To change a tool's HTML, drop a file at the path, drop "<app>.html" into
|
||||
// .zddc.zip, or rebuild the binary. There is no `apps:` .zddc key and no
|
||||
// upstream fetch — both were removed in favour of this local model.
|
||||
package apps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// DefaultUpstream is where channel and version shorthand specs resolve when
|
||||
// no `apps.default` URL prefix is configured anywhere up the chain.
|
||||
const DefaultUpstream = "https://zddc.varasys.io"
|
||||
|
||||
// DefaultUpstreamReleases is the prefix appended to DefaultUpstream when
|
||||
// composing the canonical upstream URL.
|
||||
const DefaultUpstreamReleases = DefaultUpstream + "/releases"
|
||||
|
||||
// DefaultChannel is the channel shorthand used when nothing in the chain
|
||||
// specifies one.
|
||||
const DefaultChannel = "stable"
|
||||
|
||||
// CacheDirName is the directory under <ZDDC_ROOT>/.zddc.d/ where fetched URL
|
||||
// sources are cached. Living under the reserved .zddc.d/ sidecar means the
|
||||
// cache is hidden from listings and admin-gated for direct URL access like all
|
||||
// other server bookkeeping (see handler.ReservedSidecar); the resolver itself
|
||||
// reads/writes it via the filesystem, not over HTTP.
|
||||
const CacheDirName = "apps"
|
||||
|
||||
// DefaultAppsKey is the special key in `apps:` that provides the baseline
|
||||
// URL prefix and channel for any app not overridden per-name. Cascades
|
||||
// through .zddc files like everything else.
|
||||
const DefaultAppsKey = "default"
|
||||
|
||||
// Source is a fully-resolved app source (output of Resolve).
|
||||
type Source struct {
|
||||
App string // canonical app name
|
||||
URL string // upstream URL (mutually exclusive with Path)
|
||||
Path string // resolved local file path
|
||||
}
|
||||
|
||||
// IsURL reports whether the source is fetched (vs read from disk).
|
||||
func (s Source) IsURL() bool { return s.URL != "" }
|
||||
|
||||
// SpecComponents is the parsed shape of a single `.zddc apps:` value.
|
||||
// Terminal forms (Path or FullURL) are mutually exclusive with the
|
||||
// composable URLPrefix/Channel forms. Resolve() turns one or more
|
||||
// SpecComponents (one per applicable level in the cascade) into a final
|
||||
// Source.
|
||||
type SpecComponents struct {
|
||||
// Terminal forms — exactly one set means the spec is terminal and
|
||||
// short-circuits composition.
|
||||
Path string // local file path (resolved + bounded to ZDDC_ROOT)
|
||||
FullURL string // full URL ending in `.html` (used as-is)
|
||||
|
||||
// Composable forms — either or both may be set, both may be empty
|
||||
// (caller should treat empty-everything as a no-op).
|
||||
URLPrefix string // "https://host/path" (no trailing /)
|
||||
Channel string // "stable" (latest), "v0.0.4" (exact version pin)
|
||||
}
|
||||
|
||||
// IsTerminal reports whether this spec terminates composition.
|
||||
func (c SpecComponents) IsTerminal() bool {
|
||||
return c.Path != "" || c.FullURL != ""
|
||||
}
|
||||
|
||||
// IsEmpty reports whether the spec contributes nothing to composition.
|
||||
func (c SpecComponents) IsEmpty() bool {
|
||||
return c.Path == "" && c.FullURL == "" && c.URLPrefix == "" && c.Channel == ""
|
||||
}
|
||||
|
||||
// ParseSpec parses one `.zddc apps:` value into components.
|
||||
// zddcDir anchors relative paths; root bounds path-traversal.
|
||||
func ParseSpec(spec, zddcDir, root string) (SpecComponents, error) {
|
||||
spec = strings.TrimSpace(spec)
|
||||
if spec == "" {
|
||||
return SpecComponents{}, fmt.Errorf("source spec is empty")
|
||||
}
|
||||
|
||||
// Path forms — terminal.
|
||||
if strings.HasPrefix(spec, "/") ||
|
||||
strings.HasPrefix(spec, "./") ||
|
||||
strings.HasPrefix(spec, "../") {
|
||||
var abs string
|
||||
if filepath.IsAbs(spec) {
|
||||
abs = filepath.Clean(spec)
|
||||
} else {
|
||||
abs = filepath.Clean(filepath.Join(zddcDir, spec))
|
||||
}
|
||||
rootClean := filepath.Clean(root)
|
||||
if abs != rootClean && !strings.HasPrefix(abs, rootClean+string(filepath.Separator)) {
|
||||
return SpecComponents{}, fmt.Errorf("path %q escapes ZDDC_ROOT", spec)
|
||||
}
|
||||
return SpecComponents{Path: abs}, nil
|
||||
}
|
||||
|
||||
// URL forms.
|
||||
if strings.HasPrefix(spec, "https://") || strings.HasPrefix(spec, "http://") {
|
||||
return parseURLSpec(spec)
|
||||
}
|
||||
|
||||
// Channel-only forms: ":channel" or bare "channel".
|
||||
chanPart := strings.TrimPrefix(spec, ":")
|
||||
if chanPart == "" {
|
||||
return SpecComponents{}, fmt.Errorf("empty channel after ':'")
|
||||
}
|
||||
if !isValidChannelOrVersion(chanPart) {
|
||||
return SpecComponents{}, fmt.Errorf("unrecognized source spec %q (expected channel, version, URL, or path)", spec)
|
||||
}
|
||||
return SpecComponents{Channel: normalizeChannel(chanPart)}, nil
|
||||
}
|
||||
|
||||
// parseURLSpec splits a URL spec into prefix vs full-URL based on the
|
||||
// last `:` after the last `/`. Examples:
|
||||
//
|
||||
// https://host:8080/path:stable → URLPrefix=https://host:8080/path, Channel=stable
|
||||
// https://host:8080/path → URLPrefix=https://host:8080/path
|
||||
// https://host/path/file.html → FullURL=https://host/path/file.html (terminal)
|
||||
// https://host/path/file.html:stable → error (terminal URL with extra suffix)
|
||||
func parseURLSpec(spec string) (SpecComponents, error) {
|
||||
// Locate the channel separator: last `:` that comes after the last `/`.
|
||||
lastSlash := strings.LastIndex(spec, "/")
|
||||
if lastSlash < 0 {
|
||||
return SpecComponents{}, fmt.Errorf("invalid URL %q: missing path separator", spec)
|
||||
}
|
||||
afterSlash := spec[lastSlash+1:]
|
||||
colonInTail := strings.LastIndex(afterSlash, ":")
|
||||
|
||||
urlPart, suffixPart := spec, ""
|
||||
if colonInTail >= 0 {
|
||||
urlPart = spec[:lastSlash+1+colonInTail]
|
||||
suffixPart = afterSlash[colonInTail+1:]
|
||||
}
|
||||
|
||||
// Validate the URL portion.
|
||||
u, err := url.Parse(urlPart)
|
||||
if err != nil {
|
||||
return SpecComponents{}, fmt.Errorf("invalid URL %q: %w", urlPart, err)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return SpecComponents{}, fmt.Errorf("URL %q is missing host", urlPart)
|
||||
}
|
||||
|
||||
// Terminal full URL: ends in `.html`. A `:suffix` on a `.html` URL is
|
||||
// rejected to prevent silent misinterpretation.
|
||||
if strings.HasSuffix(urlPart, ".html") {
|
||||
if suffixPart != "" {
|
||||
return SpecComponents{}, fmt.Errorf("URL ends in .html but has %q suffix", ":"+suffixPart)
|
||||
}
|
||||
return SpecComponents{FullURL: urlPart}, nil
|
||||
}
|
||||
|
||||
// URL-prefix form. Strip trailing slash for normalization.
|
||||
prefix := strings.TrimRight(urlPart, "/")
|
||||
|
||||
out := SpecComponents{URLPrefix: prefix}
|
||||
if suffixPart != "" {
|
||||
if !isValidChannelOrVersion(suffixPart) {
|
||||
return SpecComponents{}, fmt.Errorf("invalid channel/version suffix %q", suffixPart)
|
||||
}
|
||||
out.Channel = normalizeChannel(suffixPart)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// isValidChannelOrVersion reports whether s is `stable` (the canonical
|
||||
// "current stable" alias) or an exact-version pin like `v0.0.4` / `0.0.4`.
|
||||
// Partial pins (`v0.0`, `v0`) and the legacy `beta`/`alpha` channels
|
||||
// are no longer accepted — the upstream publishes only stable + exact.
|
||||
func isValidChannelOrVersion(s string) bool {
|
||||
if s == "stable" {
|
||||
return true
|
||||
}
|
||||
rest := strings.TrimPrefix(s, "v")
|
||||
if rest == "" {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(rest, ".")
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
}
|
||||
for _, p := range parts {
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range p {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// normalizeChannel ensures versions carry the `v` prefix (so the resulting
|
||||
// filename is `<app>_v<X.Y.Z>.html` per upstream convention).
|
||||
func normalizeChannel(s string) string {
|
||||
if s == "stable" {
|
||||
return s
|
||||
}
|
||||
if !strings.HasPrefix(s, "v") {
|
||||
return "v" + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Resolve walks the .zddc chain root→leaf, applying `apps.default` and
|
||||
// `apps.<app>` at each level. Returns the resolved Source and true if any
|
||||
// entry contributed; (Source{}, false, nil) means no override (caller
|
||||
// serves embedded). On malformed spec, returns an error.
|
||||
func Resolve(chain zddc.PolicyChain, app, root, requestDir string) (Source, bool, error) {
|
||||
return ResolveWithOverride(chain, app, root, requestDir, "")
|
||||
}
|
||||
|
||||
// ResolveWithOverride is Resolve with an additional per-request override
|
||||
// applied as one final cascade level after the .zddc chain. Used to honor
|
||||
// the `?v=` query parameter on tool HTML requests.
|
||||
//
|
||||
// vSpec accepts the same syntax as `.zddc apps:` values (channel/version,
|
||||
// `:channel`, URL prefix, `url:channel`, full `.html` URL). Path sources
|
||||
// are rejected (security: `?v=` must resolve to a URL whose bytes the
|
||||
// caller can fetch from cache only).
|
||||
//
|
||||
// Empty vSpec is equivalent to plain Resolve.
|
||||
func ResolveWithOverride(chain zddc.PolicyChain, app, root, requestDir, vSpec string) (Source, bool, error) {
|
||||
app = strings.ToLower(strings.TrimSpace(app))
|
||||
if !zddc.IsKnownApp(app) {
|
||||
return Source{}, false, fmt.Errorf("unknown app %q", app)
|
||||
}
|
||||
|
||||
dirs := walkDirs(root, requestDir)
|
||||
|
||||
st := newAppsState(app)
|
||||
|
||||
// Walk root → leaf.
|
||||
for i := 0; i < len(chain.Levels); i++ {
|
||||
lvl := chain.Levels[i]
|
||||
dir := root
|
||||
if i < len(dirs) {
|
||||
dir = dirs[i]
|
||||
}
|
||||
// `default` first, then per-app override at the same level.
|
||||
if spec, ok := lvl.Apps[DefaultAppsKey]; ok && spec != "" {
|
||||
if err := st.apply(spec, dir, root, "apps.default"); err != nil {
|
||||
return Source{}, false, err
|
||||
}
|
||||
}
|
||||
if spec, ok := lvl.Apps[app]; ok && spec != "" {
|
||||
if err := st.apply(spec, dir, root, "apps."+app); err != nil {
|
||||
return Source{}, false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-request override (`?v=`): one final layer.
|
||||
if vSpec = strings.TrimSpace(vSpec); vSpec != "" {
|
||||
comp, err := ParseSpec(vSpec, requestDir, root)
|
||||
if err != nil {
|
||||
return Source{}, false, fmt.Errorf("?v=%s: %w", vSpec, err)
|
||||
}
|
||||
// Reject path sources from per-request override — security: we serve
|
||||
// only what the cache (populated by .zddc-controlled fetches) holds.
|
||||
if comp.Path != "" {
|
||||
return Source{}, false, fmt.Errorf("?v= cannot specify a local path source")
|
||||
}
|
||||
if err := st.applyComponents(comp); err != nil {
|
||||
return Source{}, false, fmt.Errorf("?v=%s: %w", vSpec, err)
|
||||
}
|
||||
}
|
||||
|
||||
return st.finalize()
|
||||
}
|
||||
|
||||
// appsState accumulates URL-prefix and channel components across cascade
|
||||
// levels, with terminal-source short-circuit semantics.
|
||||
type appsState struct {
|
||||
app string
|
||||
haveAny bool
|
||||
urlPrefix string
|
||||
channel string
|
||||
terminalSrc *Source
|
||||
}
|
||||
|
||||
func newAppsState(app string) *appsState {
|
||||
return &appsState{app: app}
|
||||
}
|
||||
|
||||
func (s *appsState) apply(spec, zddcDir, root, label string) error {
|
||||
comp, err := ParseSpec(spec, zddcDir, root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
return s.applyComponents(comp)
|
||||
}
|
||||
|
||||
func (s *appsState) applyComponents(comp SpecComponents) error {
|
||||
if comp.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
s.haveAny = true
|
||||
switch {
|
||||
case comp.Path != "":
|
||||
s.terminalSrc = &Source{App: s.app, Path: comp.Path}
|
||||
s.urlPrefix, s.channel = "", ""
|
||||
case comp.FullURL != "":
|
||||
s.terminalSrc = &Source{App: s.app, URL: comp.FullURL}
|
||||
s.urlPrefix, s.channel = "", ""
|
||||
default:
|
||||
// Non-terminal: deeper non-terminal entries override a parent terminal.
|
||||
s.terminalSrc = nil
|
||||
if comp.URLPrefix != "" {
|
||||
s.urlPrefix = comp.URLPrefix
|
||||
}
|
||||
if comp.Channel != "" {
|
||||
s.channel = comp.Channel
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *appsState) finalize() (Source, bool, error) {
|
||||
if !s.haveAny {
|
||||
return Source{}, false, nil
|
||||
}
|
||||
if s.terminalSrc != nil {
|
||||
return *s.terminalSrc, true, nil
|
||||
}
|
||||
urlPrefix := s.urlPrefix
|
||||
if urlPrefix == "" {
|
||||
urlPrefix = DefaultUpstreamReleases
|
||||
}
|
||||
channel := s.channel
|
||||
if channel == "" {
|
||||
channel = DefaultChannel
|
||||
}
|
||||
// channel == "stable" → canonical URL <prefix>/<app>.html (a
|
||||
// symlink that always follows the latest stable cut).
|
||||
// channel == "v<X.Y.Z>" → immutable per-version URL.
|
||||
var name string
|
||||
if channel == "stable" {
|
||||
name = s.app + ".html"
|
||||
} else {
|
||||
name = s.app + "_" + channel + ".html"
|
||||
}
|
||||
return Source{
|
||||
App: s.app,
|
||||
URL: urlPrefix + "/" + name,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
// PreviewLine returns a short human-readable description of how an app
|
||||
// resolves at requestDir given the chain. Used by the .zddc editor to
|
||||
// render a "Resolves to: ..." line beside each apps input.
|
||||
func PreviewLine(chain zddc.PolicyChain, app, root, requestDir string) string {
|
||||
src, has, err := Resolve(chain, app, root, requestDir)
|
||||
if err != nil {
|
||||
return "error: " + err.Error()
|
||||
}
|
||||
if !has {
|
||||
return "embedded (build-time default)"
|
||||
}
|
||||
if src.Path != "" {
|
||||
return "local file: " + src.Path
|
||||
}
|
||||
return src.URL
|
||||
}
|
||||
|
||||
func walkDirs(root, requestDir string) []string {
|
||||
root = filepath.Clean(root)
|
||||
requestDir = filepath.Clean(requestDir)
|
||||
if requestDir == root {
|
||||
return []string{root}
|
||||
}
|
||||
rel, err := filepath.Rel(root, requestDir)
|
||||
if err != nil {
|
||||
return []string{root}
|
||||
}
|
||||
dirs := []string{root}
|
||||
cur := root
|
||||
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
||||
cur = filepath.Join(cur, part)
|
||||
dirs = append(dirs, cur)
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,438 +0,0 @@
|
|||
package apps
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// ── ParseSpec ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestParseSpec_Channels(t *testing.T) {
|
||||
// "stable" is the only channel alias (latest stable). beta and alpha
|
||||
// channels no longer exist as public concepts.
|
||||
cases := []struct {
|
||||
spec, wantChan string
|
||||
}{
|
||||
{"stable", "stable"},
|
||||
{":stable", "stable"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.spec, func(t *testing.T) {
|
||||
c, err := ParseSpec(tc.spec, "/root", "/root")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSpec error: %v", err)
|
||||
}
|
||||
if c.Channel != tc.wantChan {
|
||||
t.Errorf("got Channel=%q, want %q", c.Channel, tc.wantChan)
|
||||
}
|
||||
if c.URLPrefix != "" || c.Path != "" || c.FullURL != "" {
|
||||
t.Errorf("expected channel-only, got %+v", c)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSpec_Versions(t *testing.T) {
|
||||
// Exact-version pins only. Partial pins (v0.0, v0) no longer exist
|
||||
// — the upstream publishes <tool>.html (current stable) and
|
||||
// <tool>_v<X.Y.Z>.html (exact-version immutable). Bare "0.0.4"
|
||||
// (no v prefix) is normalized to "v0.0.4".
|
||||
cases := []struct {
|
||||
spec, wantChan string
|
||||
}{
|
||||
{"v0.0.4", "v0.0.4"},
|
||||
{"0.0.4", "v0.0.4"},
|
||||
{":v0.0.4", "v0.0.4"},
|
||||
{":0.0.4", "v0.0.4"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.spec, func(t *testing.T) {
|
||||
c, err := ParseSpec(tc.spec, "/root", "/root")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSpec error: %v", err)
|
||||
}
|
||||
if c.Channel != tc.wantChan {
|
||||
t.Errorf("got Channel=%q, want %q", c.Channel, tc.wantChan)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSpec_RejectsLegacyChannelsAndPartialPins(t *testing.T) {
|
||||
// alpha/beta channels and partial-version pins are no longer valid.
|
||||
rejected := []string{"alpha", "beta", ":alpha", ":beta", "v0.0", "v0", "0.0", "0", ":v0.0"}
|
||||
for _, spec := range rejected {
|
||||
t.Run(spec, func(t *testing.T) {
|
||||
_, err := ParseSpec(spec, "/root", "/root")
|
||||
if err == nil {
|
||||
t.Errorf("expected error for %q, got none", spec)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSpec_URLPrefix(t *testing.T) {
|
||||
cases := []struct {
|
||||
spec, wantPrefix, wantChan string
|
||||
}{
|
||||
{"https://my-mirror.example/releases", "https://my-mirror.example/releases", ""},
|
||||
{"https://my-mirror.example/releases/", "https://my-mirror.example/releases", ""}, // trailing slash stripped
|
||||
{"https://my-mirror.example/releases:stable", "https://my-mirror.example/releases", "stable"},
|
||||
{"https://my-mirror.example/releases:v0.0.4", "https://my-mirror.example/releases", "v0.0.4"},
|
||||
// Port colon must NOT be confused with channel separator.
|
||||
{"https://my-mirror.example:8080/releases", "https://my-mirror.example:8080/releases", ""},
|
||||
{"https://my-mirror.example:8080/releases:stable", "https://my-mirror.example:8080/releases", "stable"},
|
||||
// Colon embedded in path before final slash — treated as part of path.
|
||||
{"https://host/some:thing/releases", "https://host/some:thing/releases", ""},
|
||||
{"https://host/some:thing/releases:v0.0.4", "https://host/some:thing/releases", "v0.0.4"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.spec, func(t *testing.T) {
|
||||
c, err := ParseSpec(tc.spec, "/root", "/root")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSpec error: %v", err)
|
||||
}
|
||||
if c.URLPrefix != tc.wantPrefix {
|
||||
t.Errorf("got URLPrefix=%q, want %q", c.URLPrefix, tc.wantPrefix)
|
||||
}
|
||||
if c.Channel != tc.wantChan {
|
||||
t.Errorf("got Channel=%q, want %q", c.Channel, tc.wantChan)
|
||||
}
|
||||
if c.Path != "" || c.FullURL != "" {
|
||||
t.Errorf("expected non-terminal, got %+v", c)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSpec_FullURL(t *testing.T) {
|
||||
c, err := ParseSpec("https://my-fork.example/archive.html", "/root", "/root")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSpec error: %v", err)
|
||||
}
|
||||
if c.FullURL != "https://my-fork.example/archive.html" {
|
||||
t.Errorf("got FullURL=%q", c.FullURL)
|
||||
}
|
||||
if !c.IsTerminal() {
|
||||
t.Errorf("expected terminal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSpec_FullURLWithChannelSuffixRejected(t *testing.T) {
|
||||
_, err := ParseSpec("https://my-fork.example/archive.html:stable", "/root", "/root")
|
||||
if err == nil {
|
||||
t.Errorf("expected error for .html URL with :suffix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSpec_Paths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
zddcDir := filepath.Join(root, "Project-X")
|
||||
cases := []struct {
|
||||
spec string
|
||||
wantOK bool
|
||||
wantPath string
|
||||
}{
|
||||
{"./local.html", true, filepath.Join(zddcDir, "local.html")},
|
||||
{"../sibling.html", true, filepath.Join(root, "sibling.html")},
|
||||
{filepath.Join(root, "abs.html"), true, filepath.Join(root, "abs.html")},
|
||||
{"/etc/passwd", false, ""},
|
||||
{"../../../escape.html", false, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.spec, func(t *testing.T) {
|
||||
c, err := ParseSpec(tc.spec, zddcDir, root)
|
||||
if tc.wantOK {
|
||||
if err != nil {
|
||||
t.Fatalf("want success, got error: %v", err)
|
||||
}
|
||||
if c.Path != tc.wantPath {
|
||||
t.Errorf("got Path=%q, want %q", c.Path, tc.wantPath)
|
||||
}
|
||||
if !c.IsTerminal() {
|
||||
t.Errorf("expected terminal")
|
||||
}
|
||||
} else {
|
||||
if err == nil {
|
||||
t.Errorf("want error, got %+v", c)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSpec_Errors(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
"weird-thing",
|
||||
":",
|
||||
":weird",
|
||||
"v",
|
||||
"v0.0.0.0",
|
||||
"v0.a.0",
|
||||
"https://", // missing host
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc, func(t *testing.T) {
|
||||
_, err := ParseSpec(tc, "/root", "/root")
|
||||
if err == nil {
|
||||
t.Errorf("ParseSpec(%q) = nil, want error", tc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolve ──────────────────────────────────────────────────────────────
|
||||
|
||||
func TestResolve_NoEntries(t *testing.T) {
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
||||
_, has, err := Resolve(chain, "archive", t.TempDir(), t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if has {
|
||||
t.Errorf("got override=true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_PerAppChannelOnly(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
|
||||
Apps: map[string]string{"archive": "stable"},
|
||||
}}}
|
||||
src, has, err := Resolve(chain, "archive", root, root)
|
||||
if err != nil || !has {
|
||||
t.Fatalf("has=%v err=%v", has, err)
|
||||
}
|
||||
// stable channel → canonical URL (no _stable_ suffix); the upstream
|
||||
// publishes a symlink at this URL pointing at the latest version.
|
||||
want := DefaultUpstreamReleases + "/archive.html"
|
||||
if src.URL != want {
|
||||
t.Errorf("got URL=%q, want %q", src.URL, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_PerAppVersionOnly(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
|
||||
Apps: map[string]string{"archive": "v0.0.4"},
|
||||
}}}
|
||||
src, _, err := Resolve(chain, "archive", root, root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := DefaultUpstreamReleases + "/archive_v0.0.4.html"
|
||||
if src.URL != want {
|
||||
t.Errorf("got URL=%q, want %q", src.URL, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_DefaultProvidesURLAndChannel(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
|
||||
Apps: map[string]string{
|
||||
"default": "https://mirror.example/releases:v0.0.4",
|
||||
},
|
||||
}}}
|
||||
src, has, err := Resolve(chain, "archive", root, root)
|
||||
if err != nil || !has {
|
||||
t.Fatalf("has=%v err=%v", has, err)
|
||||
}
|
||||
if src.URL != "https://mirror.example/releases/archive_v0.0.4.html" {
|
||||
t.Errorf("got URL=%q", src.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_DefaultPlusPerAppChannelOverride(t *testing.T) {
|
||||
// default=https://zddc.varasys.io/releases:stable, classifier=:v0.0.4
|
||||
// → classifier pinned to v0.0.4 on the same mirror.
|
||||
root := t.TempDir()
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
|
||||
Apps: map[string]string{
|
||||
"default": "https://zddc.varasys.io/releases:stable",
|
||||
"classifier": ":v0.0.4",
|
||||
},
|
||||
}}}
|
||||
src, _, err := Resolve(chain, "classifier", root, root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if src.URL != "https://zddc.varasys.io/releases/classifier_v0.0.4.html" {
|
||||
t.Errorf("got URL=%q", src.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_DefaultPlusPerAppURLPrefixOverride(t *testing.T) {
|
||||
// default=...:stable, archive=https://my.local.stuff/releases
|
||||
// → custom URL + default channel (stable, canonical filename).
|
||||
root := t.TempDir()
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
|
||||
Apps: map[string]string{
|
||||
"default": "https://zddc.varasys.io/releases:stable",
|
||||
"archive": "https://my.local.stuff/releases",
|
||||
},
|
||||
}}}
|
||||
src, _, err := Resolve(chain, "archive", root, root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if src.URL != "https://my.local.stuff/releases/archive.html" {
|
||||
t.Errorf("got URL=%q", src.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_DeeperLevelOverridesParentChannel(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
requestDir := filepath.Join(root, "Project-A")
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
||||
{Apps: map[string]string{"default": ":stable"}},
|
||||
{Apps: map[string]string{"default": ":v0.0.4"}},
|
||||
}}
|
||||
src, _, err := Resolve(chain, "archive", root, requestDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := DefaultUpstreamReleases + "/archive_v0.0.4.html"
|
||||
if src.URL != want {
|
||||
t.Errorf("got URL=%q, want %q", src.URL, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_DeeperLevelOverridesParentURL(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
requestDir := filepath.Join(root, "Project-A")
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
||||
{Apps: map[string]string{"default": "https://a.example/releases:stable"}},
|
||||
{Apps: map[string]string{"default": "https://b.example/releases"}},
|
||||
}}
|
||||
src, _, err := Resolve(chain, "archive", root, requestDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// b.example URL prefix wins; channel inherited (stable → canonical
|
||||
// filename, no _stable_ suffix).
|
||||
want := "https://b.example/releases/archive.html"
|
||||
if src.URL != want {
|
||||
t.Errorf("got URL=%q, want %q", src.URL, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_TerminalAtLeafBeatsParentDefault(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
requestDir := filepath.Join(root, "Project-A")
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
||||
{Apps: map[string]string{"default": "https://a.example/releases:stable"}},
|
||||
{Apps: map[string]string{"archive": "https://my-fork.example/archive.html"}},
|
||||
}}
|
||||
src, _, err := Resolve(chain, "archive", root, requestDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if src.URL != "https://my-fork.example/archive.html" {
|
||||
t.Errorf("got URL=%q (want terminal full URL)", src.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_DeeperNonTerminalOverridesParentTerminal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
requestDir := filepath.Join(root, "Project-A")
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
||||
{Apps: map[string]string{"archive": "https://a.example/archive.html"}}, // terminal
|
||||
{Apps: map[string]string{"archive": "v0.0.4"}}, // non-terminal
|
||||
}}
|
||||
src, _, err := Resolve(chain, "archive", root, requestDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := DefaultUpstreamReleases + "/archive_v0.0.4.html"
|
||||
if src.URL != want {
|
||||
t.Errorf("got URL=%q, want %q", src.URL, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_PathSourceTerminal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
projDir := filepath.Join(root, "Project-X")
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
||||
{},
|
||||
{Apps: map[string]string{"archive": "./our-archive.html"}},
|
||||
}}
|
||||
src, _, err := Resolve(chain, "archive", root, projDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if src.URL != "" {
|
||||
t.Errorf("got URL=%q, want empty", src.URL)
|
||||
}
|
||||
want := filepath.Join(projDir, "our-archive.html")
|
||||
if src.Path != want {
|
||||
t.Errorf("got Path=%q, want %q", src.Path, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_PerAppOverridesDefaultAtSameLevel(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
|
||||
Apps: map[string]string{
|
||||
"default": "https://a.example/releases:stable",
|
||||
"archive": "https://b.example/archive.html", // terminal — wins for archive only
|
||||
},
|
||||
}}}
|
||||
src, _, err := Resolve(chain, "archive", root, root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if src.URL != "https://b.example/archive.html" {
|
||||
t.Errorf("got URL=%q (want b.example terminal)", src.URL)
|
||||
}
|
||||
|
||||
// Other apps still use the default.
|
||||
src2, _, err := Resolve(chain, "classifier", root, root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if src2.URL != "https://a.example/releases/classifier.html" {
|
||||
t.Errorf("got classifier URL=%q (want a.example default)", src2.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_BadSpecBubblesError(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
|
||||
Apps: map[string]string{"archive": "this is garbage"},
|
||||
}}}
|
||||
_, _, err := Resolve(chain, "archive", root, root)
|
||||
if err == nil {
|
||||
t.Errorf("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_UnknownAppRejected(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
_, _, err := Resolve(zddc.PolicyChain{}, "unknown", root, root)
|
||||
if err == nil {
|
||||
t.Errorf("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── PreviewLine ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestPreviewLine(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Run("no entries → embedded", func(t *testing.T) {
|
||||
got := PreviewLine(zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}, "archive", root, root)
|
||||
if !strings.Contains(got, "embedded") {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
})
|
||||
t.Run("default channel → URL", func(t *testing.T) {
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{Apps: map[string]string{"default": ":v0.0.4"}}}}
|
||||
got := PreviewLine(chain, "archive", root, root)
|
||||
if !strings.Contains(got, "archive_v0.0.4.html") {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
115
zddc/internal/apps/bundle.go
Normal file
115
zddc/internal/apps/bundle.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package apps
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zipfs"
|
||||
)
|
||||
|
||||
// BundleName is the site-root config bundle that supplies local tool-HTML
|
||||
// overrides (and, in future, templated config). It lives at
|
||||
// <ZDDC_ROOT>/.zddc.zip. It is dot-hidden and 404-gated over HTTP (it's
|
||||
// config, not browsable content); the server reads it from the filesystem
|
||||
// internally, so members resolve for any user regardless of the HTTP gate.
|
||||
const BundleName = ".zddc.zip"
|
||||
|
||||
// maxBundleBytes caps the whole .zddc.zip read into memory. The bundle is
|
||||
// small config (a handful of HTML files), so a generous cap is fine.
|
||||
const maxBundleBytes = 64 << 20 // 64 MiB
|
||||
|
||||
// maxBundleMemberBytes caps a single extracted member.
|
||||
const maxBundleMemberBytes = 32 << 20 // 32 MiB
|
||||
|
||||
// Bundle is the cached parsed view of <ZDDC_ROOT>/.zddc.zip. A nil *Bundle
|
||||
// is valid and behaves as "no bundle present" for all methods. Member()
|
||||
// re-stats the file each call (cheap, and gives free hot-reload when an
|
||||
// operator drops in a new bundle), reparsing only when mtime or size change.
|
||||
type Bundle struct {
|
||||
path string
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
data []byte
|
||||
reader *zip.Reader
|
||||
modTime time.Time
|
||||
size int64
|
||||
loaded bool // a valid zip is parsed into reader
|
||||
}
|
||||
|
||||
// NewBundle returns a Bundle bound to <zddcRoot>/.zddc.zip. The file need
|
||||
// not exist; Member returns (nil,false) until it does.
|
||||
func NewBundle(zddcRoot string, logger *slog.Logger) *Bundle {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Bundle{path: filepath.Join(zddcRoot, BundleName), logger: logger}
|
||||
}
|
||||
|
||||
// Member returns the bytes of the named member (e.g. "browse.html") from the
|
||||
// bundle, or (nil,false) when the bundle is absent, unreadable, corrupt, or
|
||||
// has no such member. Lookup is case-insensitive (via zipfs), matching the
|
||||
// rest of the server's URL case-folding.
|
||||
func (b *Bundle) Member(name string) ([]byte, bool) {
|
||||
if b == nil {
|
||||
return nil, false
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
info, err := os.Stat(b.path)
|
||||
if err != nil || info.IsDir() {
|
||||
// Absent (or replaced by a dir) → no bundle. Drop any stale parse.
|
||||
b.data, b.reader, b.loaded = nil, nil, false
|
||||
return nil, false
|
||||
}
|
||||
if !b.loaded || info.ModTime() != b.modTime || info.Size() != b.size {
|
||||
if !b.reparse(info) {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
rc, _, _, _, ok := zipfs.OpenMember(b.reader, name)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
defer rc.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(rc, maxBundleMemberBytes+1))
|
||||
if err != nil || int64(len(body)) > maxBundleMemberBytes {
|
||||
b.logger.Warn("zddc.zip member unreadable or too large", "member", name)
|
||||
return nil, false
|
||||
}
|
||||
return body, true
|
||||
}
|
||||
|
||||
// reparse re-reads + re-parses the bundle. Caller holds b.mu. On any error
|
||||
// the bundle is treated as absent (loaded=false) and the server falls back
|
||||
// to embedded. Returns true when a valid reader is in place.
|
||||
func (b *Bundle) reparse(info os.FileInfo) bool {
|
||||
b.data, b.reader, b.loaded = nil, nil, false
|
||||
if info.Size() > maxBundleBytes {
|
||||
b.logger.Warn("zddc.zip too large; ignoring", "size", info.Size(), "cap", maxBundleBytes)
|
||||
return false
|
||||
}
|
||||
data, err := os.ReadFile(b.path)
|
||||
if err != nil {
|
||||
b.logger.Warn("zddc.zip unreadable; ignoring", "err", err)
|
||||
return false
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
b.logger.Warn("zddc.zip is not a valid zip; ignoring", "err", err)
|
||||
return false
|
||||
}
|
||||
b.data = data
|
||||
b.reader = zr
|
||||
b.modTime = info.ModTime()
|
||||
b.size = info.Size()
|
||||
b.loaded = true
|
||||
return true
|
||||
}
|
||||
96
zddc/internal/apps/bundle_test.go
Normal file
96
zddc/internal/apps/bundle_test.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package apps
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// writeTestBundle writes a <dir>/.zddc.zip containing the given members.
|
||||
// Shared by bundle + handler precedence tests.
|
||||
func writeTestBundle(t *testing.T, dir string, members map[string]string) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for name, body := range members {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("zip create %s: %v", name, err)
|
||||
}
|
||||
if _, err := w.Write([]byte(body)); err != nil {
|
||||
t.Fatalf("zip write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("zip close: %v", err)
|
||||
}
|
||||
p := filepath.Join(dir, BundleName)
|
||||
if err := os.WriteFile(p, buf.Bytes(), 0o644); err != nil {
|
||||
t.Fatalf("write bundle: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestBundle_Member_Hit(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestBundle(t, root, map[string]string{"browse.html": "BUNDLE browse"})
|
||||
b := NewBundle(root, nil)
|
||||
got, ok := b.Member("browse.html")
|
||||
if !ok || string(got) != "BUNDLE browse" {
|
||||
t.Fatalf("Member = (%q,%v), want (BUNDLE browse,true)", got, ok)
|
||||
}
|
||||
// Case-insensitive lookup (matches URL folding).
|
||||
if _, ok := b.Member("BROWSE.HTML"); !ok {
|
||||
t.Errorf("case-insensitive member lookup failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundle_Member_Absent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestBundle(t, root, map[string]string{"browse.html": "x"})
|
||||
b := NewBundle(root, nil)
|
||||
if _, ok := b.Member("archive.html"); ok {
|
||||
t.Errorf("absent member reported present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundle_NoFile(t *testing.T) {
|
||||
b := NewBundle(t.TempDir(), nil)
|
||||
if _, ok := b.Member("browse.html"); ok {
|
||||
t.Errorf("no bundle file but member reported present")
|
||||
}
|
||||
// nil bundle is safe.
|
||||
var nb *Bundle
|
||||
if _, ok := nb.Member("browse.html"); ok {
|
||||
t.Errorf("nil bundle reported a member")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundle_HotReload(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
p := writeTestBundle(t, root, map[string]string{"browse.html": "v1"})
|
||||
b := NewBundle(root, nil)
|
||||
if got, _ := b.Member("browse.html"); string(got) != "v1" {
|
||||
t.Fatalf("first read = %q, want v1", got)
|
||||
}
|
||||
// Rewrite with new bytes + a bumped mtime so the stat-based cache reparses.
|
||||
writeTestBundle(t, root, map[string]string{"browse.html": "v2"})
|
||||
_ = os.Chtimes(p, time.Now().Add(2*time.Second), time.Now().Add(2*time.Second))
|
||||
if got, _ := b.Member("browse.html"); string(got) != "v2" {
|
||||
t.Errorf("after reload = %q, want v2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundle_CorruptZip(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, BundleName), []byte("not a zip"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := NewBundle(root, nil)
|
||||
if _, ok := b.Member("browse.html"); ok {
|
||||
t.Errorf("corrupt zip should yield no members")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
package apps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Cache stores fetched URL responses on disk under <ZDDC_ROOT>/_app/.
|
||||
// Files are name-keyed by upstream host + path so operators can list
|
||||
// and inspect them by hand. There is no metadata, no SHA-256, no
|
||||
// expiration — fetch-once-and-keep-forever. To force a refetch,
|
||||
// delete the cache file.
|
||||
type Cache struct {
|
||||
root string
|
||||
}
|
||||
|
||||
// NewCache creates a Cache rooted at the given path. The directory is
|
||||
// created if missing. Stale *.tmp files left over from interrupted
|
||||
// writes are swept on construction.
|
||||
func NewCache(root string) (*Cache, error) {
|
||||
root = filepath.Clean(root)
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create cache root: %w", err)
|
||||
}
|
||||
c := &Cache{root: root}
|
||||
if err := c.sweepTemps(); err != nil {
|
||||
return nil, fmt.Errorf("sweep temps: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Root returns the cache directory absolute path.
|
||||
func (c *Cache) Root() string { return c.root }
|
||||
|
||||
// keyForURL converts a URL into a relative filesystem path under the
|
||||
// cache root.
|
||||
//
|
||||
// Layout: <scheme>/<host>[:<port>]/<path>. The full origin tuple is in
|
||||
// the key so two URLs that resolve different content cannot collide:
|
||||
//
|
||||
// https://example.com/x.html → https/example.com/x.html
|
||||
// http://example.com/x.html → http/example.com/x.html
|
||||
// https://example.com:8443/x.html → https/example.com:8443/x.html
|
||||
//
|
||||
// No port stripping. The previous behavior — collapsing :443 onto bare
|
||||
// host for https (and :80 for http) — was a defensible HTTP convention
|
||||
// but conflated "the operator wrote a URL with the default port" with
|
||||
// "the operator wrote a bare-host URL". With explicit port preserved,
|
||||
// every URL maps to exactly one filesystem path; operators can still
|
||||
// `ls _app/https/example.com/` to inspect what's cached. Scheme
|
||||
// segregation prevents an http:// hit from masquerading as an https://
|
||||
// hit when both are deliberately distinct (rare, but real on
|
||||
// reverse-proxied stacks where http and https serve different bytes).
|
||||
//
|
||||
// Host is lowercased so the canonical-host normalization survives
|
||||
// case-insensitive DNS. Port is preserved verbatim.
|
||||
func keyForURL(rawURL string) (string, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse URL: %w", err)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return "", fmt.Errorf("unsupported scheme %q", u.Scheme)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return "", fmt.Errorf("URL is missing host")
|
||||
}
|
||||
if u.RawQuery != "" {
|
||||
return "", fmt.Errorf("URL must not contain query string: %s", rawURL)
|
||||
}
|
||||
// Lowercase the host part but preserve the port verbatim. Without
|
||||
// this we'd lowercase a numeric port unnecessarily, which is fine
|
||||
// but pointless; with this the ASCII-cased host normalization
|
||||
// works the same for both default and explicit-port URLs.
|
||||
host := u.Host
|
||||
if i := strings.Index(host, ":"); i >= 0 {
|
||||
host = strings.ToLower(host[:i]) + host[i:]
|
||||
} else {
|
||||
host = strings.ToLower(host)
|
||||
}
|
||||
p := u.Path
|
||||
for strings.Contains(p, "//") {
|
||||
p = strings.ReplaceAll(p, "//", "/")
|
||||
}
|
||||
p = strings.TrimPrefix(p, "/")
|
||||
if p == "" {
|
||||
p = "index.html"
|
||||
}
|
||||
cleaned := filepath.Clean("/" + p)
|
||||
if strings.Contains(cleaned, "..") {
|
||||
return "", fmt.Errorf("URL path contains '..'")
|
||||
}
|
||||
return u.Scheme + "/" + host + cleaned, nil
|
||||
}
|
||||
|
||||
func (c *Cache) pathFor(rawURL string) (string, error) {
|
||||
key, err := keyForURL(rawURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(c.root, filepath.FromSlash(key)), nil
|
||||
}
|
||||
|
||||
// Has reports whether a cache entry exists for the URL.
|
||||
func (c *Cache) Has(rawURL string) bool {
|
||||
p, err := c.pathFor(rawURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, err = os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Read returns the cached body or os.ErrNotExist.
|
||||
func (c *Cache) Read(rawURL string) ([]byte, error) {
|
||||
p, err := c.pathFor(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.ReadFile(p)
|
||||
}
|
||||
|
||||
// Write atomically stores body for the URL. Parent directories are
|
||||
// created as needed. Writes via tmp+rename so partial files are never
|
||||
// observable.
|
||||
func (c *Cache) Write(rawURL string, body []byte) error {
|
||||
p, err := c.pathFor(rawURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAtomic(p, body)
|
||||
}
|
||||
|
||||
func writeAtomic(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp.*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
cleanup := func() { _ = os.Remove(tmpName) }
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) sweepTemps() error {
|
||||
err := filepath.WalkDir(c.root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(d.Name(), ".tmp.") {
|
||||
_ = os.Remove(p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
package apps
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestKeyForURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw, want string
|
||||
}{
|
||||
// Default ports are PRESERVED — no port-stripping (the previous
|
||||
// behavior conflated "operator wrote :443" with "operator wrote
|
||||
// bare host"; with the full origin in the key, every URL maps
|
||||
// to exactly one path).
|
||||
{"https://zddc.varasys.io/releases/archive_stable.html", "https/zddc.varasys.io/releases/archive_stable.html"},
|
||||
{"https://ZDDC.Varasys.IO/releases/archive_stable.html", "https/zddc.varasys.io/releases/archive_stable.html"},
|
||||
{"http://example.com/foo.html", "http/example.com/foo.html"},
|
||||
{"http://example.com:80/foo.html", "http/example.com:80/foo.html"},
|
||||
{"https://example.com/foo.html", "https/example.com/foo.html"},
|
||||
{"https://example.com:443/foo.html", "https/example.com:443/foo.html"},
|
||||
{"https://example.com:8443/foo.html", "https/example.com:8443/foo.html"},
|
||||
// Scheme segregation: same host+path under http and https map
|
||||
// to different cache entries (defensive against reverse-proxy
|
||||
// stacks that legitimately serve different bytes per scheme).
|
||||
{"http://example.com/x.html", "http/example.com/x.html"},
|
||||
{"https://example.com/x.html", "https/example.com/x.html"},
|
||||
// Path normalization preserved.
|
||||
{"https://example.com/", "https/example.com/index.html"},
|
||||
{"https://example.com", "https/example.com/index.html"},
|
||||
{"https://example.com//foo//bar.html", "https/example.com/foo/bar.html"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.raw, func(t *testing.T) {
|
||||
got, err := keyForURL(tc.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("keyForURL error: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeyForURL_NoCollisions: explicit assertion that the dimensions
|
||||
// previously collapsed (default-port ↔ bare-host, http ↔ https) are
|
||||
// now distinct. Any future change that re-introduces collapsing will
|
||||
// fail this test.
|
||||
func TestKeyForURL_NoCollisions(t *testing.T) {
|
||||
pairs := [][2]string{
|
||||
// Different scheme, same host+path
|
||||
{"http://example.com/x.html", "https://example.com/x.html"},
|
||||
// https default port preserved (not collapsed onto bare host)
|
||||
{"https://example.com/x.html", "https://example.com:443/x.html"},
|
||||
// http default port preserved
|
||||
{"http://example.com/x.html", "http://example.com:80/x.html"},
|
||||
// Different non-default ports
|
||||
{"https://example.com:8443/x.html", "https://example.com:9443/x.html"},
|
||||
}
|
||||
for _, p := range pairs {
|
||||
t.Run(p[0]+" vs "+p[1], func(t *testing.T) {
|
||||
a, err := keyForURL(p[0])
|
||||
if err != nil {
|
||||
t.Fatalf("keyForURL(%q): %v", p[0], err)
|
||||
}
|
||||
b, err := keyForURL(p[1])
|
||||
if err != nil {
|
||||
t.Fatalf("keyForURL(%q): %v", p[1], err)
|
||||
}
|
||||
if a == b {
|
||||
t.Errorf("collision: %q and %q both → %q", p[0], p[1], a)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyForURL_Errors(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
"not-a-url",
|
||||
"ftp://example.com/x.html",
|
||||
"https:///x.html",
|
||||
"https://example.com/x.html?v=1",
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc, func(t *testing.T) {
|
||||
if _, err := keyForURL(tc); err == nil {
|
||||
t.Errorf("keyForURL(%q) = nil, want error", tc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheRoundtrip(t *testing.T) {
|
||||
c, err := NewCache(filepath.Join(t.TempDir(), "_app"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewCache: %v", err)
|
||||
}
|
||||
urlStr := "https://zddc.varasys.io/releases/archive_stable.html"
|
||||
body := []byte("<!DOCTYPE html>archive content")
|
||||
|
||||
if c.Has(urlStr) {
|
||||
t.Fatalf("Has(empty cache) = true, want false")
|
||||
}
|
||||
if err := c.Write(urlStr, body); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
if !c.Has(urlStr) {
|
||||
t.Fatalf("Has(after write) = false, want true")
|
||||
}
|
||||
got, err := c.Read(urlStr)
|
||||
if err != nil {
|
||||
t.Fatalf("Read: %v", err)
|
||||
}
|
||||
if string(got) != string(body) {
|
||||
t.Errorf("body mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheAtomicWrite_LeavesNoTempOnSuccess(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "_app")
|
||||
c, _ := NewCache(root)
|
||||
urlStr := "https://zddc.varasys.io/releases/archive_stable.html"
|
||||
if err := c.Write(urlStr, []byte("hello")); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
count := 0
|
||||
_ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() && strings.Contains(info.Name(), ".tmp.") {
|
||||
count++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if count != 0 {
|
||||
t.Errorf("found %d .tmp.* leftovers, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheSweepsTempsOnNew(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "_app")
|
||||
stale := filepath.Join(root, "example.com", "releases", "archive_stable.html.tmp.123")
|
||||
if err := os.MkdirAll(filepath.Dir(stale), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(stale, []byte("partial"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := NewCache(root); err != nil {
|
||||
t.Fatalf("NewCache: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(stale); !os.IsNotExist(err) {
|
||||
t.Errorf("stale tmp file not swept: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -2582,7 +2582,7 @@ td[data-field="trackingNumber"] {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Archive</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 18:26:16 · f723323</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-05 12:41:17 · 382645b</span></span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1793,7 +1793,7 @@ body.is-elevated::after {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Classifier</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 18:26:16 · f723323</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-05 12:41:17 · 382645b</span></span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||
|
|
|
|||
|
|
@ -1536,7 +1536,7 @@ body {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 18:26:16 · f723323</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-05 12:41:17 · 382645b</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
|
|||
|
|
@ -2635,7 +2635,7 @@ dialog.modal--narrow {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Transmittal</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 18:26:16 · f723323</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-05 12:41:16 · 382645b</span></span>
|
||||
</div>
|
||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||
<!-- Publish split-button (Transmittal-specific primary action;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||
archive=v0.0.27-beta · 2026-06-03 18:26:16 · f723323
|
||||
transmittal=v0.0.27-beta · 2026-06-03 18:26:16 · f723323
|
||||
classifier=v0.0.27-beta · 2026-06-03 18:26:16 · f723323
|
||||
landing=v0.0.27-beta · 2026-06-03 18:26:16 · f723323
|
||||
form=v0.0.27-beta · 2026-06-03 18:26:16 · f723323
|
||||
tables=v0.0.27-beta · 2026-06-03 18:26:16 · f723323
|
||||
browse=v0.0.27-beta · 2026-06-03 18:26:16 · f723323
|
||||
archive=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
|
||||
transmittal=v0.0.27-beta · 2026-06-05 12:41:16 · 382645b
|
||||
classifier=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
|
||||
landing=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
|
||||
form=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
|
||||
tables=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
|
||||
browse=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
|
||||
|
|
|
|||
|
|
@ -1,193 +0,0 @@
|
|||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Fetcher pulls URL sources once, caches the body forever, and serves
|
||||
// from cache on subsequent calls. Path sources don't go through here —
|
||||
// the handler reads the file directly.
|
||||
//
|
||||
// Concurrent calls for the same URL dedupe via singleflight. There is no
|
||||
// background refresh, no conditional GET.
|
||||
//
|
||||
// Signature verification (Ed25519). Strict. On every fetch, also
|
||||
// fetches <url>.sig (raw 64-byte Ed25519 signature). The fetched body
|
||||
// is rejected unless the .sig is present, well-formed, and verifies
|
||||
// against the trusted public key. Rejection causes the apps resolver
|
||||
// to fall through to the embedded copy.
|
||||
//
|
||||
// There is no "accept unsigned with a warning" mode and no embedded
|
||||
// default key. The operator configures VerifyKey explicitly via
|
||||
// --apps-pubkey or ZDDC_APPS_PUBKEY (same posture as TLS certificates:
|
||||
// zddc-server bakes nothing in). When VerifyKey is nil, every URL fetch
|
||||
// is rejected with an error noting the missing config — the resolver
|
||||
// falls back to embedded and operators get a clear signal that they
|
||||
// need to opt in.
|
||||
//
|
||||
// Every URL the resolver might fetch is expected to have a
|
||||
// corresponding .sig published by whoever signed the artifact.
|
||||
// Operators using custom mirrors must sign their own artifacts and
|
||||
// host the .sig alongside, then configure their public key here.
|
||||
type Fetcher struct {
|
||||
Cache *Cache
|
||||
Client *http.Client
|
||||
Logger *slog.Logger
|
||||
|
||||
// VerifyKey is the Ed25519 public key against which fetched
|
||||
// artifacts are verified. Set at startup from the operator's
|
||||
// configured --apps-pubkey path. nil = URL fetches refuse-by-
|
||||
// default (caller falls back to embedded).
|
||||
VerifyKey ed25519.PublicKey
|
||||
|
||||
sf singleflightGroup
|
||||
embeddedFails sync.Map // url → struct{} (rate-limit "fell back to embedded" warnings)
|
||||
}
|
||||
|
||||
// NewFetcher returns a Fetcher with sensible defaults: 10s timeout, no
|
||||
// redirects (ops must point at the final URL).
|
||||
func NewFetcher(cache *Cache, logger *slog.Logger) *Fetcher {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Fetcher{
|
||||
Cache: cache,
|
||||
Logger: logger,
|
||||
// VerifyKey starts nil. Operator configures it via
|
||||
// cfg.AppsPubKey at server startup; main.go sets it on the
|
||||
// returned Fetcher before any request is served.
|
||||
Client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch returns the body for url. If the cache already has it, returns
|
||||
// the cached bytes immediately. Otherwise fetches, caches, and returns.
|
||||
// All concurrent requests for the same URL share one outbound fetch.
|
||||
func (f *Fetcher) Fetch(ctx context.Context, urlStr string) ([]byte, error) {
|
||||
if f.Cache != nil {
|
||||
if body, err := f.Cache.Read(urlStr); err == nil {
|
||||
return body, nil
|
||||
}
|
||||
}
|
||||
val, err := f.sf.Do(urlStr, func() (any, error) {
|
||||
return f.fetchOnce(ctx, urlStr)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return val.([]byte), nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) fetchOnce(ctx context.Context, urlStr string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := f.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("upstream %s returned HTTP %d", urlStr, resp.StatusCode)
|
||||
}
|
||||
const maxBytes = 25 * 1024 * 1024
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(body)) > maxBytes {
|
||||
return nil, fmt.Errorf("response from %s exceeds %d bytes", urlStr, maxBytes)
|
||||
}
|
||||
|
||||
// Signature verification gate. See Fetcher type docstring for the
|
||||
// decision matrix. The transitional period accepts unsigned artifacts
|
||||
// with a WARN log; flipping RequireSigs makes it strict-reject.
|
||||
if err := f.verifyFetched(ctx, urlStr, body); err != nil {
|
||||
return nil, fmt.Errorf("signature verification failed: %w", err)
|
||||
}
|
||||
|
||||
if f.Cache != nil {
|
||||
if err := f.Cache.Write(urlStr, body); err != nil {
|
||||
f.Logger.Warn("cache write failed; serving from response anyway",
|
||||
"url", urlStr, "err", err)
|
||||
}
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// verifyFetched fetches <urlStr>.sig and validates body against it.
|
||||
// Returns nil only when the signature is present, well-formed, and
|
||||
// verifies against f.VerifyKey. Any other outcome is a hard reject:
|
||||
// the caller drops the body and the apps resolver falls through to
|
||||
// the embedded copy.
|
||||
//
|
||||
// f.VerifyKey == nil means the operator hasn't configured an apps-
|
||||
// pubkey. We reject every URL fetch in that state — the operator
|
||||
// needs to opt in to a specific signing key explicitly. The reject
|
||||
// error is informative so the WARN log line tells the operator
|
||||
// exactly what to fix.
|
||||
func (f *Fetcher) verifyFetched(ctx context.Context, urlStr string, body []byte) error {
|
||||
if f.VerifyKey == nil {
|
||||
return errors.New("ZDDC_APPS_PUBKEY is not configured; URL-fetched apps require an explicit signing key (see zddc.varasys.io/pubkey.pem for the canonical-channel key)")
|
||||
}
|
||||
sigURL := urlStr + ".sig"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sigURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build sig request for %s: %w", sigURL, err)
|
||||
}
|
||||
resp, err := f.Client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch %s: %w", sigURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%s returned HTTP %d", sigURL, resp.StatusCode)
|
||||
}
|
||||
|
||||
// Raw Ed25519 sig is 64 bytes; cap at a small limit so a hostile
|
||||
// upstream can't flood us with a garbage "signature."
|
||||
const maxSigBytes = 256
|
||||
sig, err := io.ReadAll(io.LimitReader(resp.Body, maxSigBytes+1))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", sigURL, err)
|
||||
}
|
||||
if len(sig) > maxSigBytes {
|
||||
return fmt.Errorf("%s exceeds %d bytes", sigURL, maxSigBytes)
|
||||
}
|
||||
|
||||
if err := VerifyEd25519(f.VerifyKey, body, sig); err != nil {
|
||||
// Verification failure is positive evidence of tampering or a
|
||||
// build/key mismatch. Logged at WARN so operators see it; the
|
||||
// resolver's existing embedded-fallback logging will note that
|
||||
// the embedded copy is being served instead.
|
||||
f.Logger.Warn("REJECTED: artifact signature does not verify",
|
||||
"url", urlStr, "sig_url", sigURL, "err", err)
|
||||
return err
|
||||
}
|
||||
f.Logger.Debug("artifact signature verified", "url", urlStr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogEmbeddedFallback emits a one-time warning when the embedded fallback
|
||||
// is used for a particular source URL. Rate-limited per URL.
|
||||
func (f *Fetcher) LogEmbeddedFallback(app, urlStr string, reason error) {
|
||||
if _, loaded := f.embeddedFails.LoadOrStore(urlStr, struct{}{}); loaded {
|
||||
return
|
||||
}
|
||||
f.Logger.Warn("serving embedded fallback for app HTML",
|
||||
"app", app, "url", urlStr, "reason", reason)
|
||||
}
|
||||
|
|
@ -3,33 +3,36 @@ package apps
|
|||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// Server orchestrates app HTML resolution: subdir cascade override → fetch
|
||||
// or path read → embedded fallback. It does NOT check whether the app is
|
||||
// available at the request directory — that's AppAvailableAt's job, called
|
||||
// from dispatch before invoking Serve.
|
||||
// Server resolves tool HTML for a request: bundle member → embedded. The
|
||||
// on-disk-at-path tier (operator override) is handled UPSTREAM by the
|
||||
// dispatcher's stat-first static handler, so by the time Serve runs no real
|
||||
// file exists at the path. Server does NOT decide whether the app is
|
||||
// available at the directory — that's AppAvailableAt's job, called from
|
||||
// dispatch before Serve.
|
||||
type Server struct {
|
||||
Root string
|
||||
Cache *Cache
|
||||
Fetcher *Fetcher
|
||||
BuildVer string // baked into X-ZDDC-Source for embedded responses
|
||||
Bundle *Bundle
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewServer constructs a Server.
|
||||
func NewServer(root string, cache *Cache, fetcher *Fetcher, buildVer string) *Server {
|
||||
// NewServer constructs a Server bound to the site-root config bundle.
|
||||
func NewServer(root, buildVer string) *Server {
|
||||
root = filepath.Clean(root)
|
||||
logger := slog.Default()
|
||||
return &Server{
|
||||
Root: filepath.Clean(root),
|
||||
Cache: cache,
|
||||
Fetcher: fetcher,
|
||||
Root: root,
|
||||
BuildVer: buildVer,
|
||||
Bundle: NewBundle(root, logger),
|
||||
Logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -38,8 +41,8 @@ func NewServer(root string, cache *Cache, fetcher *Fetcher, buildVer string) *Se
|
|||
// directory (relative to root) the request is rooted at. The cmd/zddc-
|
||||
// server dispatcher calls this when stat fails on a URL: a missing file
|
||||
// that happens to look like `<dir>/archive.html` (or browse.html, etc.)
|
||||
// resolves to the embedded app HTML for that directory — operators
|
||||
// don't have to copy app HTML into every project.
|
||||
// resolves to the embedded (or bundle) app HTML for that directory —
|
||||
// operators don't have to copy app HTML into every project.
|
||||
//
|
||||
// Special case: GET / and GET /index.html both resolve to landing — the
|
||||
// only entry point that scopes ACL per-project, and the conventional
|
||||
|
|
@ -72,105 +75,51 @@ func MatchAppHTML(requestPath string) (app string, requestDirRel string) {
|
|||
return "", ""
|
||||
}
|
||||
|
||||
// resolveBytes applies the local override precedence (tiers 2 then 3; tier 1
|
||||
// is handled upstream). Returns the HTML body, the X-ZDDC-Source tag, and
|
||||
// whether to use the memoized embedded ETag (vs a body-hash ETag).
|
||||
func (s *Server) resolveBytes(app string) (body []byte, sourceTag string, embedded, ok bool) {
|
||||
if s.Bundle != nil {
|
||||
if b, found := s.Bundle.Member(app + ".html"); found {
|
||||
return b, "bundle:" + app + ".html", false, true
|
||||
}
|
||||
}
|
||||
if b := EmbeddedBytes(app); len(b) > 0 {
|
||||
return b, "embedded:" + app + "@" + s.BuildVer, true, true
|
||||
}
|
||||
return nil, "", false, false
|
||||
}
|
||||
|
||||
// Serve resolves and writes the response. Caller has already verified:
|
||||
// - no real file exists at the request path
|
||||
// - no real file exists at the request path (so tier 1 didn't apply)
|
||||
// - AppAvailableAt(root, requestDir, app) is true
|
||||
// - ACL passes for requestDir
|
||||
//
|
||||
// Honors a `?v=<spec>` query parameter as a per-request override on top of
|
||||
// the cascade. With `?v=` set, the resolved URL must already exist in the
|
||||
// cache — otherwise the response is 404. This prevents users from
|
||||
// triggering arbitrary upstream fetches via URL-crafted requests; only
|
||||
// versions the operator's `.zddc apps:` entries have already pulled in
|
||||
// (or that the user has manually placed in `_app/`) are reachable.
|
||||
func (s *Server) Serve(w http.ResponseWriter, r *http.Request, app string, chain zddc.PolicyChain, requestDir string) {
|
||||
vSpec := strings.TrimSpace(r.URL.Query().Get("v"))
|
||||
|
||||
src, hasOverride, err := ResolveWithOverride(chain, app, s.Root, requestDir, vSpec)
|
||||
if err != nil {
|
||||
// `?v=` parsing/validation errors are user input → 400.
|
||||
if vSpec != "" {
|
||||
http.Error(w, "400 Bad Request — invalid ?v= value: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Malformed `.zddc` spec — operator's fault. Log and serve embedded.
|
||||
s.Fetcher.Logger.Warn("apps.Resolve failed; serving embedded",
|
||||
"app", app, "request_dir", requestDir, "err", err)
|
||||
s.serveEmbedded(w, r, app, err)
|
||||
// chain and requestDir are retained in the signature for call-site stability
|
||||
// and future per-directory resolution; the current local model is path-
|
||||
// independent (a bundle member or the embedded default).
|
||||
func (s *Server) Serve(w http.ResponseWriter, r *http.Request, app string, _ zddc.PolicyChain, _ string) {
|
||||
body, tag, embedded, ok := s.resolveBytes(app)
|
||||
if !ok {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
http.Error(w,
|
||||
"503 Service Unavailable\n\n"+
|
||||
"This zddc-server has no embedded fallback for "+app+" and no\n"+
|
||||
"\""+app+".html\" in the site .zddc.zip bundle.\n"+
|
||||
"Rebuild the binary against the latest tool HTMLs, or add the\n"+
|
||||
"file to .zddc.zip.\n",
|
||||
http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
if !hasOverride {
|
||||
// No `.zddc apps:` entry anywhere up the chain and no `?v=` either →
|
||||
// embedded is the authoritative default.
|
||||
s.serveEmbedded(w, r, app, nil)
|
||||
return
|
||||
etag := bodyETag(body)
|
||||
if embedded {
|
||||
etag = EmbeddedETag(app)
|
||||
}
|
||||
|
||||
// Per-request `?v=` is restricted to cache-backed URL sources.
|
||||
if vSpec != "" {
|
||||
if !src.IsURL() {
|
||||
http.Error(w, "400 Bad Request — ?v= requires a URL-form spec", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if s.Cache == nil || !s.Cache.Has(src.URL) {
|
||||
http.Error(w,
|
||||
"404 Not Found — version requested via ?v= is not in the local cache.\n"+
|
||||
"Only versions the deployment has already fetched (via .zddc apps: entries) are servable.\n"+
|
||||
"Asked for: "+src.URL+"\n",
|
||||
http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
body, err := s.Cache.Read(src.URL)
|
||||
if err != nil {
|
||||
s.Fetcher.Logger.Warn("?v= cache read failed", "url", src.URL, "err", err)
|
||||
http.Error(w, "500 Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.serveBody(w, r, body, "cache:"+src.URL)
|
||||
return
|
||||
}
|
||||
|
||||
if !src.IsURL() {
|
||||
// Path source: read directly, no cache.
|
||||
body, err := os.ReadFile(src.Path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
s.Fetcher.Logger.Warn("path source missing; serving embedded",
|
||||
"app", app, "path", src.Path)
|
||||
} else {
|
||||
s.Fetcher.Logger.Warn("path source unreadable; serving embedded",
|
||||
"app", app, "path", src.Path, "err", err)
|
||||
}
|
||||
s.serveEmbedded(w, r, app, err)
|
||||
return
|
||||
}
|
||||
s.serveBody(w, r, body, "path:"+src.Path)
|
||||
return
|
||||
}
|
||||
|
||||
// URL source: cache hit serves immediately; cache miss fetches once.
|
||||
body, err := s.Fetcher.Fetch(r.Context(), src.URL)
|
||||
if err != nil {
|
||||
s.Fetcher.LogEmbeddedFallback(app, src.URL, err)
|
||||
s.serveEmbedded(w, r, app, err)
|
||||
return
|
||||
}
|
||||
sourceTag := "fetch:" + src.URL
|
||||
if s.Cache != nil && s.Cache.Has(src.URL) {
|
||||
// Likely served from cache (Has was true when the read started).
|
||||
// Distinguishing cache-hit from just-fetched is best-effort here.
|
||||
sourceTag = "cache:" + src.URL
|
||||
}
|
||||
s.serveBody(w, r, body, sourceTag)
|
||||
writeWithETag(w, r, body, etag, "text/html; charset=utf-8", tag)
|
||||
}
|
||||
|
||||
// writeWithETag writes body with a strong ETag derived from `etag`, the
|
||||
// cache-friendly headers, and short-circuits to 304 Not Modified when the
|
||||
// client's `If-None-Match` matches. `max-age=0, must-revalidate` means the
|
||||
// browser revalidates on every load — and the matching ETag returns 304
|
||||
// with empty body, so the steady-state cost of a reload is ~200 bytes
|
||||
// instead of the full HTML payload (50–920 KB depending on the tool).
|
||||
// writeWithETag writes body with a strong ETag, cache-friendly headers, and
|
||||
// short-circuits to 304 Not Modified when the client's If-None-Match matches.
|
||||
func writeWithETag(w http.ResponseWriter, r *http.Request, body []byte, etag, contentType, sourceHeader string) {
|
||||
quotedTag := `"` + etag + `"`
|
||||
w.Header().Set("ETag", quotedTag)
|
||||
|
|
@ -185,30 +134,8 @@ func writeWithETag(w http.ResponseWriter, r *http.Request, body []byte, etag, co
|
|||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// bodyETag computes a stable 32-hex-char ETag for an arbitrary body. Used
|
||||
// for the URL/path-sourced response path (the bytes vary per cache-fetch
|
||||
// or per file read, so memoizing per-app would be wrong).
|
||||
// bodyETag computes a stable 32-hex-char ETag for an arbitrary body.
|
||||
func bodyETag(body []byte) string {
|
||||
sum := sha256.Sum256(body)
|
||||
return hex.EncodeToString(sum[:])[:32]
|
||||
}
|
||||
|
||||
func (s *Server) serveBody(w http.ResponseWriter, r *http.Request, body []byte, sourceHeader string) {
|
||||
writeWithETag(w, r, body, bodyETag(body), "text/html; charset=utf-8", sourceHeader)
|
||||
}
|
||||
|
||||
func (s *Server) serveEmbedded(w http.ResponseWriter, r *http.Request, app string, _ error) {
|
||||
body := EmbeddedBytes(app)
|
||||
if len(body) == 0 {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
http.Error(w,
|
||||
"503 Service Unavailable\n\n"+
|
||||
"This zddc-server has no embedded fallback for "+app+".\n"+
|
||||
"Rebuild the binary against the latest tool HTMLs.\n",
|
||||
http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeWithETag(w, r, body, EmbeddedETag(app),
|
||||
"text/html; charset=utf-8",
|
||||
"embedded:"+app+"@"+s.BuildVer)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,47 +1,14 @@
|
|||
package apps
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// signedFixture returns a (publicKey, handler) pair where the handler
|
||||
// serves `body` for any URL ending in `.html` and the corresponding
|
||||
// Ed25519 signature for the same URL with `.sig` appended. Tests use
|
||||
// this to stand up upstream stubs that exercise the apps fetcher's
|
||||
// strict signature-verification path.
|
||||
//
|
||||
// All tests share one pattern: the fetcher's VerifyKey gets overridden
|
||||
// to this fixture's publicKey so verification passes against the
|
||||
// fixture's signature instead of the production embedded key.
|
||||
func signedFixture(t *testing.T, body []byte) (ed25519.PublicKey, http.HandlerFunc) {
|
||||
t.Helper()
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
sig := ed25519.Sign(priv, body)
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, ".sig"):
|
||||
_, _ = w.Write(sig)
|
||||
default:
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
})
|
||||
return pub, handler
|
||||
}
|
||||
|
||||
func TestMatchAppHTML(t *testing.T) {
|
||||
cases := []struct {
|
||||
path, wantApp, wantDir string
|
||||
|
|
@ -63,34 +30,21 @@ func TestMatchAppHTML(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// Build a Server with a fake upstream serving body. The upstream
|
||||
// also publishes a valid Ed25519 signature alongside (.sig) and the
|
||||
// fetcher's VerifyKey is overridden to the matching test pubkey so
|
||||
// fetched bytes pass the strict-signature gate.
|
||||
func newTestServer(t *testing.T, body []byte) (*Server, *httptest.Server, string) {
|
||||
t.Helper()
|
||||
pub, handler := signedFixture(t, body)
|
||||
upstream := httptest.NewServer(handler)
|
||||
t.Cleanup(upstream.Close)
|
||||
root := t.TempDir()
|
||||
cache, err := NewCache(filepath.Join(root, CacheDirName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := NewFetcher(cache, nil)
|
||||
f.VerifyKey = pub
|
||||
return NewServer(root, cache, f, "test"), upstream, root
|
||||
// serve runs srv.Serve for app and returns the recorder.
|
||||
func serve(srv *Server, app string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
||||
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/"+app+".html", nil), app, chain, srv.Root)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestServer_NoOverride_ServesEmbedded(t *testing.T) {
|
||||
srv, _, root := newTestServer(t, []byte("upstream body"))
|
||||
func TestServer_NoBundle_ServesEmbedded(t *testing.T) {
|
||||
srv := NewServer(t.TempDir(), "test")
|
||||
saved := embeddedArchive
|
||||
embeddedArchive = []byte("EMBEDDED archive")
|
||||
defer func() { embeddedArchive = saved }()
|
||||
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
|
||||
rec := serve(srv, "archive")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d", rec.Code)
|
||||
}
|
||||
|
|
@ -102,266 +56,60 @@ func TestServer_NoOverride_ServesEmbedded(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestServer_OverrideURL_FetchesAndCaches(t *testing.T) {
|
||||
body := []byte("from upstream")
|
||||
srv, up, root := newTestServer(t, body)
|
||||
chain := zddc.PolicyChain{
|
||||
Levels: []zddc.ZddcFile{{
|
||||
Apps: map[string]string{"archive": up.URL + "/archive_stable.html"},
|
||||
}},
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d", rec.Code)
|
||||
}
|
||||
if rec.Body.String() != string(body) {
|
||||
t.Errorf("body mismatch")
|
||||
}
|
||||
// Cache should be populated.
|
||||
if !srv.Cache.Has(up.URL + "/archive_stable.html") {
|
||||
t.Errorf("cache miss after fetch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_OverrideURL_CacheHitOnSecondCall(t *testing.T) {
|
||||
var hits atomic.Int64
|
||||
body := []byte("body")
|
||||
pub, _, sig := func() (ed25519.PublicKey, ed25519.PrivateKey, []byte) {
|
||||
p, k, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
return p, k, ed25519.Sign(k, body)
|
||||
}()
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Count only artifact fetches (not .sig fetches) so the assertion
|
||||
// "1 hit means cache works" stays meaningful: cache stores the
|
||||
// artifact body, signature verification re-runs each time the
|
||||
// resolver hits the URL but only on the first miss does it fetch
|
||||
// the artifact bytes itself. After that, cache.Read short-circuits.
|
||||
if !strings.HasSuffix(r.URL.Path, ".sig") {
|
||||
hits.Add(1)
|
||||
_, _ = w.Write(body)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(sig)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
func TestServer_BundleMemberOverridesEmbedded(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cache, _ := NewCache(filepath.Join(root, CacheDirName))
|
||||
f := NewFetcher(cache, nil)
|
||||
f.VerifyKey = pub
|
||||
srv := NewServer(root, cache, f, "test")
|
||||
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
|
||||
Apps: map[string]string{"archive": upstream.URL + "/archive_stable.html"},
|
||||
}}}
|
||||
for i := 0; i < 3; i++ {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("call %d status=%d", i, rec.Code)
|
||||
}
|
||||
}
|
||||
if hits.Load() != 1 {
|
||||
t.Errorf("upstream fetched %d times, want exactly 1 (cache forever)", hits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_PathOverride_ServedDirectly(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
pathFile := filepath.Join(root, "local.html")
|
||||
body := []byte("local archive bytes")
|
||||
if err := os.WriteFile(pathFile, body, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cache, _ := NewCache(filepath.Join(root, CacheDirName))
|
||||
f := NewFetcher(cache, nil)
|
||||
srv := NewServer(root, cache, f, "test")
|
||||
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
||||
{Apps: map[string]string{"archive": "./local.html"}},
|
||||
}}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d", rec.Code)
|
||||
}
|
||||
if rec.Body.String() != string(body) {
|
||||
t.Errorf("body mismatch")
|
||||
}
|
||||
if !strings.HasPrefix(rec.Header().Get("X-ZDDC-Source"), "path:") {
|
||||
t.Errorf("X-ZDDC-Source=%q", rec.Header().Get("X-ZDDC-Source"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_FetchFailFallsBackToEmbedded(t *testing.T) {
|
||||
srv, _, root := newTestServer(t, []byte("ok"))
|
||||
writeTestBundle(t, root, map[string]string{"archive.html": "BUNDLE archive override"})
|
||||
srv := NewServer(root, "test")
|
||||
saved := embeddedArchive
|
||||
embeddedArchive = []byte("EMBEDDED")
|
||||
embeddedArchive = []byte("EMBEDDED archive")
|
||||
defer func() { embeddedArchive = saved }()
|
||||
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
|
||||
Apps: map[string]string{"archive": "https://no-such.example/archive.html"},
|
||||
}}}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
|
||||
rec := serve(srv, "archive")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d (want 200 from embedded)", rec.Code)
|
||||
t.Fatalf("status=%d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "BUNDLE archive override") {
|
||||
t.Errorf("expected bundle body, got %q", rec.Body.String())
|
||||
}
|
||||
if rec.Header().Get("X-ZDDC-Source") != "bundle:archive.html" {
|
||||
t.Errorf("X-ZDDC-Source=%q, want bundle:archive.html", rec.Header().Get("X-ZDDC-Source"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_BundlePresent_MemberAbsent_ServesEmbedded(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestBundle(t, root, map[string]string{"browse.html": "BUNDLE browse"})
|
||||
srv := NewServer(root, "test")
|
||||
saved := embeddedArchive
|
||||
embeddedArchive = []byte("EMBEDDED archive")
|
||||
defer func() { embeddedArchive = saved }()
|
||||
|
||||
rec := serve(srv, "archive") // bundle has browse, not archive
|
||||
if !strings.Contains(rec.Body.String(), "EMBEDDED") {
|
||||
t.Errorf("body did not come from embedded fallback: %q", rec.Body.String())
|
||||
t.Errorf("expected embedded fallback, got %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── ?v= per-request override ─────────────────────────────────────────────
|
||||
|
||||
func TestServer_VParam_CacheHitServesFromCache(t *testing.T) {
|
||||
srv, _, root := newTestServer(t, []byte("ignored"))
|
||||
// Pre-populate the cache with a known URL.
|
||||
cachedURL := "https://zddc.varasys.io/releases/archive_v0.0.4.html"
|
||||
cachedBody := []byte("CACHED v0.0.4 archive")
|
||||
if err := srv.Cache.Write(cachedURL, cachedBody); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=v0.0.4", nil), "archive", chain, root)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Body.String() != string(cachedBody) {
|
||||
t.Errorf("body=%q, want CACHED bytes", rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("X-ZDDC-Source"); got != "cache:"+cachedURL {
|
||||
t.Errorf("X-ZDDC-Source=%q", got)
|
||||
func TestServer_UnknownTool_503WithoutBundle(t *testing.T) {
|
||||
srv := NewServer(t.TempDir(), "test")
|
||||
rec := serve(srv, "nope") // not embedded, no bundle
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("status=%d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_VParam_CacheMissReturns404(t *testing.T) {
|
||||
srv, _, root := newTestServer(t, []byte("ignored"))
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=v0.0.4", nil), "archive", chain, root)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status=%d (want 404)", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "not in the local cache") {
|
||||
t.Errorf("body should explain cache miss, got %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_VParam_RejectsPathSource(t *testing.T) {
|
||||
srv, _, root := newTestServer(t, []byte("ignored"))
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=./local.html", nil), "archive", chain, root)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status=%d (want 400 for path source via ?v=)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_VParam_BadSpecReturns400(t *testing.T) {
|
||||
srv, _, root := newTestServer(t, []byte("ignored"))
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=not%20a%20spec", nil), "archive", chain, root)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status=%d (want 400)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_VParam_CombinesWithCascadeURLPrefix(t *testing.T) {
|
||||
// Cascade has a default URL prefix; ?v=:beta should resolve against it.
|
||||
srv, _, root := newTestServer(t, []byte("ignored"))
|
||||
cachedURL := "https://my-mirror.example/releases/archive_v0.0.4.html"
|
||||
if err := srv.Cache.Write(cachedURL, []byte("MIRROR v0.0.4")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
|
||||
Apps: map[string]string{"default": "https://my-mirror.example/releases:stable"},
|
||||
}}}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=:v0.0.4", nil), "archive", chain, root)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Body.String() != "MIRROR v0.0.4" {
|
||||
t.Errorf("body=%q", rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("X-ZDDC-Source"); got != "cache:"+cachedURL {
|
||||
t.Errorf("X-ZDDC-Source=%q (expected mirror URL)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_VParam_OverridesPathTerminalFromCascade(t *testing.T) {
|
||||
// Operator's cascade specifies a path source. User passes ?v=stable.
|
||||
// ?v= overrides → resolves to canonical/archive.html, then cache check.
|
||||
srv, _, root := newTestServer(t, []byte("ignored"))
|
||||
cachedURL := "https://zddc.varasys.io/releases/archive.html"
|
||||
if err := srv.Cache.Write(cachedURL, []byte("CACHED stable")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pathFile := filepath.Join(root, "operator-version.html")
|
||||
if err := os.WriteFile(pathFile, []byte("OPERATOR PATH"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{
|
||||
Apps: map[string]string{"archive": "./operator-version.html"},
|
||||
}}}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Serve(rec, httptest.NewRequest(http.MethodGet, "/archive.html?v=stable", nil), "archive", chain, root)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Body.String() != "CACHED stable" {
|
||||
t.Errorf("body=%q (expected ?v= override to win)", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_VParam_FullURLForm(t *testing.T) {
|
||||
// `?v=https://my-fork/archive.html` — terminal full URL, must be cached.
|
||||
srv, _, root := newTestServer(t, []byte("ignored"))
|
||||
cachedURL := "https://my-fork.example/custom.html"
|
||||
if err := srv.Cache.Write(cachedURL, []byte("FORK custom")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
||||
target := "/archive.html?v=" + url.QueryEscape(cachedURL)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Serve(rec, httptest.NewRequest(http.MethodGet, target, nil), "archive", chain, root)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Body.String() != "FORK custom" {
|
||||
t.Errorf("body=%q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestServer_Embedded_ConditionalGET verifies the ETag/If-None-Match dance
|
||||
// for the embedded fallback path: a fresh GET returns 200 with an ETag,
|
||||
// and a follow-up with a matching If-None-Match returns 304 + empty body.
|
||||
// This is the cache-friendliness fix that lets a browser revalidate
|
||||
// against zddc-server's embedded HTML without re-transferring the bytes.
|
||||
func TestServer_Embedded_ConditionalGET(t *testing.T) {
|
||||
srv, _, root := newTestServer(t, []byte("upstream"))
|
||||
srv := NewServer(t.TempDir(), "test")
|
||||
|
||||
saved := embeddedArchive
|
||||
embeddedArchive = []byte("EMBEDDED archive bytes for ETag test")
|
||||
defer func() {
|
||||
embeddedArchive = saved
|
||||
etagCacheByApp.Delete("archive") // reset memoization for sibling tests
|
||||
etagCacheByApp.Delete("archive")
|
||||
}()
|
||||
etagCacheByApp.Delete("archive") // ensure clean state for THIS test
|
||||
etagCacheByApp.Delete("archive")
|
||||
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
||||
|
||||
// First request: full body + ETag header.
|
||||
rec1 := httptest.NewRecorder()
|
||||
srv.Serve(rec1, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
|
||||
rec1 := serve(srv, "archive")
|
||||
if rec1.Code != http.StatusOK {
|
||||
t.Fatalf("first GET: status=%d body=%s", rec1.Code, rec1.Body.String())
|
||||
}
|
||||
|
|
@ -370,17 +118,15 @@ func TestServer_Embedded_ConditionalGET(t *testing.T) {
|
|||
t.Fatalf("first GET: missing ETag header")
|
||||
}
|
||||
if cc := rec1.Header().Get("Cache-Control"); !strings.Contains(cc, "max-age=0") || !strings.Contains(cc, "must-revalidate") {
|
||||
t.Errorf("first GET: Cache-Control=%q (want max-age=0 + must-revalidate)", cc)
|
||||
}
|
||||
if !strings.Contains(rec1.Body.String(), "EMBEDDED archive bytes") {
|
||||
t.Errorf("first GET: body=%q", rec1.Body.String())
|
||||
t.Errorf("first GET: Cache-Control=%q", cc)
|
||||
}
|
||||
|
||||
// Second request with matching If-None-Match: 304, empty body.
|
||||
// Matching If-None-Match → 304, empty body.
|
||||
rec2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
|
||||
req2.Header.Set("If-None-Match", etag)
|
||||
srv.Serve(rec2, req2, "archive", chain, root)
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
||||
srv.Serve(rec2, req2, "archive", chain, srv.Root)
|
||||
if rec2.Code != http.StatusNotModified {
|
||||
t.Fatalf("If-None-Match match: status=%d (want 304)", rec2.Code)
|
||||
}
|
||||
|
|
@ -388,16 +134,34 @@ func TestServer_Embedded_ConditionalGET(t *testing.T) {
|
|||
t.Errorf("304 response should have empty body; got %d bytes", rec2.Body.Len())
|
||||
}
|
||||
|
||||
// Third request with stale If-None-Match: 200, full body.
|
||||
// Stale If-None-Match → 200, full body.
|
||||
rec3 := httptest.NewRecorder()
|
||||
req3 := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
|
||||
req3.Header.Set("If-None-Match", `"deadbeef"`)
|
||||
srv.Serve(rec3, req3, "archive", chain, root)
|
||||
if rec3.Code != http.StatusOK {
|
||||
t.Errorf("stale If-None-Match: status=%d (want 200)", rec3.Code)
|
||||
srv.Serve(rec3, req3, "archive", chain, srv.Root)
|
||||
if rec3.Code != http.StatusOK || rec3.Body.Len() == 0 {
|
||||
t.Errorf("stale If-None-Match: status=%d bodyLen=%d (want 200, non-empty)", rec3.Code, rec3.Body.Len())
|
||||
}
|
||||
if rec3.Body.Len() == 0 {
|
||||
t.Errorf("stale If-None-Match: empty body; want full")
|
||||
}
|
||||
|
||||
// Bundle responses get a body-hash ETag and also short-circuit to 304.
|
||||
func TestServer_Bundle_ConditionalGET(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestBundle(t, root, map[string]string{"browse.html": "BUNDLE browse body"})
|
||||
srv := NewServer(root, "test")
|
||||
|
||||
rec1 := serve(srv, "browse")
|
||||
etag := rec1.Header().Get("ETag")
|
||||
if rec1.Code != http.StatusOK || etag == "" {
|
||||
t.Fatalf("first GET: status=%d etag=%q", rec1.Code, etag)
|
||||
}
|
||||
rec2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/browse.html", nil)
|
||||
req2.Header.Set("If-None-Match", etag)
|
||||
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
|
||||
srv.Serve(rec2, req2, "browse", chain, srv.Root)
|
||||
if rec2.Code != http.StatusNotModified {
|
||||
t.Errorf("bundle If-None-Match: status=%d (want 304)", rec2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -422,6 +186,6 @@ func TestEmbeddedETag_Stable(t *testing.T) {
|
|||
etagCacheByApp.Delete("archive")
|
||||
b := EmbeddedETag("archive")
|
||||
if b == a1 {
|
||||
t.Errorf("EmbeddedETag should differ for different bytes; both %q", b)
|
||||
t.Errorf("EmbeddedETag should change with bytes; both %q", b)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
package apps
|
||||
|
||||
import "sync"
|
||||
|
||||
// singleflightGroup deduplicates concurrent calls keyed by string. If N
|
||||
// goroutines call Do(key, fn) before the first one returns, fn runs once
|
||||
// and all callers receive the same (val, err).
|
||||
//
|
||||
// Hand-rolled to avoid pulling in golang.org/x/sync — we only need the
|
||||
// 30-line core, not Forget/DoChan. Pattern is the standard one.
|
||||
type singleflightGroup struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*sfCall
|
||||
}
|
||||
|
||||
type sfCall struct {
|
||||
done chan struct{}
|
||||
val any
|
||||
err error
|
||||
}
|
||||
|
||||
func (g *singleflightGroup) Do(key string, fn func() (any, error)) (any, error) {
|
||||
g.mu.Lock()
|
||||
if g.m == nil {
|
||||
g.m = make(map[string]*sfCall)
|
||||
}
|
||||
if c, ok := g.m[key]; ok {
|
||||
g.mu.Unlock()
|
||||
<-c.done
|
||||
return c.val, c.err
|
||||
}
|
||||
c := &sfCall{done: make(chan struct{})}
|
||||
g.m[key] = c
|
||||
g.mu.Unlock()
|
||||
|
||||
c.val, c.err = fn()
|
||||
close(c.done)
|
||||
|
||||
g.mu.Lock()
|
||||
delete(g.m, key)
|
||||
g.mu.Unlock()
|
||||
return c.val, c.err
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
package apps
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSingleflightDedupes(t *testing.T) {
|
||||
var g singleflightGroup
|
||||
var calls atomic.Int64
|
||||
fn := func() (any, error) {
|
||||
calls.Add(1)
|
||||
time.Sleep(50 * time.Millisecond) // hold the lock long enough for races
|
||||
return "result", nil
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
const N = 50
|
||||
for i := 0; i < N; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
val, err := g.Do("the-key", fn)
|
||||
if err != nil {
|
||||
t.Errorf("Do err: %v", err)
|
||||
return
|
||||
}
|
||||
if val.(string) != "result" {
|
||||
t.Errorf("got %v, want 'result'", val)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Errorf("fn called %d times, want exactly 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleflightDifferentKeysParallel(t *testing.T) {
|
||||
var g singleflightGroup
|
||||
var calls atomic.Int64
|
||||
fn := func() (any, error) {
|
||||
calls.Add(1)
|
||||
return "ok", nil
|
||||
}
|
||||
for _, k := range []string{"a", "b", "c"} {
|
||||
_, _ = g.Do(k, fn)
|
||||
}
|
||||
if got := calls.Load(); got != 3 {
|
||||
t.Errorf("fn called %d times, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleflightSecondCallAfterFirstResolves(t *testing.T) {
|
||||
var g singleflightGroup
|
||||
var calls atomic.Int64
|
||||
fn := func() (any, error) {
|
||||
calls.Add(1)
|
||||
return "x", nil
|
||||
}
|
||||
_, _ = g.Do("k", fn)
|
||||
_, _ = g.Do("k", fn)
|
||||
if got := calls.Load(); got != 2 {
|
||||
t.Errorf("fn called %d times, want 2 (second call sees no in-flight entry)", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
package apps
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// LoadPubKey reads a PEM-encoded SubjectPublicKeyInfo (the format
|
||||
// `openssl pkey -pubout` emits) from path and returns the underlying
|
||||
// Ed25519 public key.
|
||||
//
|
||||
// Operators distribute and configure this key explicitly — same posture
|
||||
// as the TLS certificate: zddc-server bakes nothing in. Customers
|
||||
// running against zddc.varasys.io's release channel download the
|
||||
// canonical key from zddc.varasys.io/pubkey.pem and pass the local
|
||||
// path via --apps-pubkey or ZDDC_APPS_PUBKEY. Customers running their
|
||||
// own signing infrastructure pass their own public key instead.
|
||||
//
|
||||
// Returns a descriptive error for missing files, malformed PEM, wrong
|
||||
// PEM type, or non-Ed25519 keys. Callers (cmd/zddc-server's startup
|
||||
// path) treat any error as fatal — refusing to start with a misconfigured
|
||||
// apps-pubkey is the right posture.
|
||||
func LoadPubKey(path string) (ed25519.PublicKey, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read apps-pubkey from %s: %w", path, err)
|
||||
}
|
||||
return ParsePubKeyPEM(data)
|
||||
}
|
||||
|
||||
// ParsePubKeyPEM is LoadPubKey's content-only variant. Useful when the
|
||||
// PEM bytes come from somewhere other than disk (test fixtures, etc.).
|
||||
func ParsePubKeyPEM(pemBytes []byte) (ed25519.PublicKey, error) {
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
if block == nil {
|
||||
return nil, errors.New("no PEM block found")
|
||||
}
|
||||
if block.Type != "PUBLIC KEY" {
|
||||
return nil, fmt.Errorf("unexpected PEM type %q (want PUBLIC KEY)", block.Type)
|
||||
}
|
||||
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse PKIX: %w", err)
|
||||
}
|
||||
edPub, ok := pub.(ed25519.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("public key is not Ed25519 (got %T)", pub)
|
||||
}
|
||||
return edPub, nil
|
||||
}
|
||||
|
||||
// VerifyEd25519 checks that sig is a valid Ed25519 signature of body
|
||||
// produced with the private key matching pub. Returns nil on success
|
||||
// or a descriptive error otherwise.
|
||||
//
|
||||
// sig must be exactly 64 bytes (the raw Ed25519 signature format
|
||||
// produced by `openssl pkeyutl -sign -rawin`).
|
||||
func VerifyEd25519(pub ed25519.PublicKey, body, sig []byte) error {
|
||||
if pub == nil {
|
||||
return errors.New("no public key configured")
|
||||
}
|
||||
if len(sig) != ed25519.SignatureSize {
|
||||
return fmt.Errorf("signature has wrong length: %d (want %d)", len(sig), ed25519.SignatureSize)
|
||||
}
|
||||
if !ed25519.Verify(pub, body, sig) {
|
||||
return errors.New("signature does not verify against trusted public key")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,255 +0,0 @@
|
|||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// genTestKey returns a fresh Ed25519 keypair for tests so the test
|
||||
// suite never depends on the embedded production key.
|
||||
func genTestKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
return pub, priv
|
||||
}
|
||||
|
||||
func TestParseEd25519PublicKeyPEM_RoundTrip(t *testing.T) {
|
||||
pub, _ := genTestKey(t)
|
||||
derBytes, err := x509.MarshalPKIXPublicKey(pub)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPKIXPublicKey: %v", err)
|
||||
}
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: derBytes})
|
||||
|
||||
parsed, err := ParsePubKeyPEM(pemBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if !pub.Equal(parsed) {
|
||||
t.Errorf("round-trip pubkey mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEd25519PublicKeyPEM_RejectsRSA(t *testing.T) {
|
||||
// PEM containing a non-Ed25519 key should error rather than
|
||||
// silently coerce. Use a hand-crafted bad PEM block.
|
||||
bad := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: []byte("not a valid SubjectPublicKeyInfo")})
|
||||
if _, err := ParsePubKeyPEM(bad); err == nil {
|
||||
t.Error("ParsePubKeyPEM accepted malformed PEM, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEd25519PublicKeyPEM_RejectsWrongType(t *testing.T) {
|
||||
pub, _ := genTestKey(t)
|
||||
derBytes, _ := x509.MarshalPKIXPublicKey(pub)
|
||||
wrongType := pem.EncodeToMemory(&pem.Block{Type: "RSA PUBLIC KEY", Bytes: derBytes})
|
||||
if _, err := ParsePubKeyPEM(wrongType); err == nil {
|
||||
t.Error("ParsePubKeyPEM accepted wrong PEM Type, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEd25519_ValidSignature(t *testing.T) {
|
||||
pub, priv := genTestKey(t)
|
||||
msg := []byte("the artifact bytes")
|
||||
sig := ed25519.Sign(priv, msg)
|
||||
if err := VerifyEd25519(pub, msg, sig); err != nil {
|
||||
t.Errorf("VerifyEd25519 rejected a valid signature: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEd25519_TamperedMessage(t *testing.T) {
|
||||
pub, priv := genTestKey(t)
|
||||
original := []byte("the artifact bytes")
|
||||
tampered := []byte("the artifact byteX")
|
||||
sig := ed25519.Sign(priv, original)
|
||||
if err := VerifyEd25519(pub, tampered, sig); err == nil {
|
||||
t.Error("VerifyEd25519 accepted a tampered message, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEd25519_WrongKey(t *testing.T) {
|
||||
_, priv := genTestKey(t)
|
||||
otherPub, _ := genTestKey(t)
|
||||
msg := []byte("the artifact bytes")
|
||||
sig := ed25519.Sign(priv, msg)
|
||||
if err := VerifyEd25519(otherPub, msg, sig); err == nil {
|
||||
t.Error("VerifyEd25519 accepted a signature from the wrong key, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEd25519_MalformedSignature(t *testing.T) {
|
||||
pub, _ := genTestKey(t)
|
||||
msg := []byte("hello")
|
||||
cases := [][]byte{
|
||||
nil, // empty
|
||||
make([]byte, 32), // too short
|
||||
make([]byte, 100), // too long
|
||||
make([]byte, 64), // right length, wrong contents
|
||||
}
|
||||
for i, sig := range cases {
|
||||
if err := VerifyEd25519(pub, msg, sig); err == nil {
|
||||
t.Errorf("case %d: VerifyEd25519 accepted malformed signature of length %d, want error", i, len(sig))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEd25519_NilKey(t *testing.T) {
|
||||
if err := VerifyEd25519(nil, []byte("x"), make([]byte, 64)); err == nil {
|
||||
t.Error("VerifyEd25519(nil, ...) accepted, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetcher_AcceptsValidSignature: end-to-end. Server publishes
|
||||
// an artifact and a valid .sig; fetcher accepts and caches.
|
||||
func TestFetcher_AcceptsValidSignature(t *testing.T) {
|
||||
pub, priv := genTestKey(t)
|
||||
body := []byte("<!doctype html><html><body>signed artifact</body></html>")
|
||||
sig := ed25519.Sign(priv, body)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/archive.html":
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write(body)
|
||||
case "/archive.html.sig":
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write(sig)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cache, err := NewCache(filepath.Join(t.TempDir(), "_app"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewCache: %v", err)
|
||||
}
|
||||
f := NewFetcher(cache, nil)
|
||||
f.VerifyKey = pub // override the embedded production key
|
||||
got, err := f.Fetch(context.Background(), srv.URL+"/archive.html")
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch failed: %v", err)
|
||||
}
|
||||
if string(got) != string(body) {
|
||||
t.Errorf("body mismatch")
|
||||
}
|
||||
// Cache hit on second call.
|
||||
if !cache.Has(srv.URL + "/archive.html") {
|
||||
t.Error("expected cache to contain artifact after successful verification")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetcher_RejectsTamperedBody: the published .sig is valid but
|
||||
// the body has been changed by a hypothetical mitm. Fetcher must
|
||||
// reject and NOT cache the tampered bytes.
|
||||
func TestFetcher_RejectsTamperedBody(t *testing.T) {
|
||||
pub, priv := genTestKey(t)
|
||||
original := []byte("<!doctype html>genuine")
|
||||
sig := ed25519.Sign(priv, original)
|
||||
tampered := []byte("<!doctype html>injected")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/archive.html":
|
||||
_, _ = w.Write(tampered)
|
||||
case "/archive.html.sig":
|
||||
_, _ = w.Write(sig)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cache, err := NewCache(filepath.Join(t.TempDir(), "_app"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewCache: %v", err)
|
||||
}
|
||||
f := NewFetcher(cache, nil)
|
||||
f.VerifyKey = pub
|
||||
_, err = f.Fetch(context.Background(), srv.URL+"/archive.html")
|
||||
if err == nil {
|
||||
t.Fatal("Fetch accepted tampered body, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "signature") {
|
||||
t.Errorf("error %q does not mention signature", err)
|
||||
}
|
||||
if cache.Has(srv.URL + "/archive.html") {
|
||||
t.Error("tampered bytes were cached; verifier must not write to cache on rejection")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetcher_RejectsMissingSignature: artifact published but no .sig
|
||||
// alongside (HTTP 404). Strict mode → reject.
|
||||
func TestFetcher_RejectsMissingSignature(t *testing.T) {
|
||||
pub, _ := genTestKey(t)
|
||||
body := []byte("body without sig")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/archive.html":
|
||||
_, _ = w.Write(body)
|
||||
case "/archive.html.sig":
|
||||
http.NotFound(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cache, _ := NewCache(filepath.Join(t.TempDir(), "_app"))
|
||||
f := NewFetcher(cache, nil)
|
||||
f.VerifyKey = pub
|
||||
_, err := f.Fetch(context.Background(), srv.URL+"/archive.html")
|
||||
if err == nil {
|
||||
t.Fatal("Fetch accepted unsigned artifact, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "404") && !strings.Contains(err.Error(), "signature") {
|
||||
t.Errorf("error %q does not mention 404 or signature", err)
|
||||
}
|
||||
if cache.Has(srv.URL + "/archive.html") {
|
||||
t.Error("unsigned bytes were cached; verifier must reject before caching")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetcher_RejectsWrongKeySignature: .sig present, well-formed,
|
||||
// but signed by a different key than f.VerifyKey trusts.
|
||||
func TestFetcher_RejectsWrongKeySignature(t *testing.T) {
|
||||
trustedPub, _ := genTestKey(t)
|
||||
_, attackerPriv := genTestKey(t)
|
||||
body := []byte("body signed by an untrusted key")
|
||||
sig := ed25519.Sign(attackerPriv, body)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/archive.html":
|
||||
_, _ = w.Write(body)
|
||||
case "/archive.html.sig":
|
||||
_, _ = w.Write(sig)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cache, _ := NewCache(filepath.Join(t.TempDir(), "_app"))
|
||||
f := NewFetcher(cache, nil)
|
||||
f.VerifyKey = trustedPub
|
||||
_, err := f.Fetch(context.Background(), srv.URL+"/archive.html")
|
||||
if err == nil {
|
||||
t.Fatal("Fetch accepted wrong-key-signed artifact, want error")
|
||||
}
|
||||
if cache.Has(srv.URL + "/archive.html") {
|
||||
t.Error("wrong-key-signed bytes were cached")
|
||||
}
|
||||
}
|
||||
|
|
@ -34,17 +34,16 @@ type Config struct {
|
|||
// Root then becomes the cache directory rather than the served
|
||||
// data root. Master-mode flags (apps, archive, opa, etc.) are
|
||||
// ignored in client mode — see cmd/zddc-server/main.go.
|
||||
Upstream string // --upstream / ZDDC_UPSTREAM — master URL (https://master.example.com); empty = run as master
|
||||
Mode string // --mode / ZDDC_MODE — "proxy" (no disk persistence), "cache" (default; persist on access), "mirror" (cache + access-triggered subtree warmer)
|
||||
BearerFile string // --bearer-file / ZDDC_BEARER_FILE — path to a 0600 file containing the master-issued token to forward upstream
|
||||
SkipTLSVerify bool // --skip-tls-verify / ZDDC_SKIP_TLS_VERIFY=1 — accept self-signed / untrusted upstream certs. Distinct from --no-auth; intended for dev/internal CA scenarios only.
|
||||
MirrorSubtree []string // --mirror-subtree / ZDDC_MIRROR_SUBTREE — comma-separated subtree URL paths the access-triggered walker keeps current. Default `/` when --mode=mirror and unset; ignored otherwise.
|
||||
MirrorMinInterval time.Duration // --mirror-min-interval / ZDDC_MIRROR_MIN_INTERVAL — minimum gap between walks of the same subtree. Idle subtrees stay quiet; bumping this reduces upstream load on busy mirrors. Default 1h.
|
||||
OPAURL string // --opa-url / ZDDC_OPA_URL — policy decider endpoint: "internal" (default), "http(s)://..." (real OPA via HTTP), or "unix:///..." (OPA via Unix socket)
|
||||
OPAFailOpen bool // --opa-fail-open / ZDDC_OPA_FAIL_OPEN=1 — when external OPA is unreachable, allow instead of deny (default: fail closed)
|
||||
OPACacheTTL time.Duration // --opa-cache-ttl / ZDDC_OPA_CACHE_TTL — external mode only: per-decision cache TTL. Default 1s. Set 0s to disable.
|
||||
AppsPubKey string // --apps-pubkey / ZDDC_APPS_PUBKEY — path to the Ed25519 public key (PEM) used to verify Ed25519 signatures on URL-fetched apps: artifacts. Empty = URL apps disabled (only embedded + local-path apps work). Operators using zddc.varasys.io's canonical channels download pubkey.pem from there.
|
||||
MaxWriteBytes int64 // --max-write-bytes / ZDDC_MAX_WRITE_BYTES — upper bound on PUT body size. Default 256 MiB. Per-request limit; rejected with 413.
|
||||
Upstream string // --upstream / ZDDC_UPSTREAM — master URL (https://master.example.com); empty = run as master
|
||||
Mode string // --mode / ZDDC_MODE — "proxy" (no disk persistence), "cache" (default; persist on access), "mirror" (cache + access-triggered subtree warmer)
|
||||
BearerFile string // --bearer-file / ZDDC_BEARER_FILE — path to a 0600 file containing the master-issued token to forward upstream
|
||||
SkipTLSVerify bool // --skip-tls-verify / ZDDC_SKIP_TLS_VERIFY=1 — accept self-signed / untrusted upstream certs. Distinct from --no-auth; intended for dev/internal CA scenarios only.
|
||||
MirrorSubtree []string // --mirror-subtree / ZDDC_MIRROR_SUBTREE — comma-separated subtree URL paths the access-triggered walker keeps current. Default `/` when --mode=mirror and unset; ignored otherwise.
|
||||
MirrorMinInterval time.Duration // --mirror-min-interval / ZDDC_MIRROR_MIN_INTERVAL — minimum gap between walks of the same subtree. Idle subtrees stay quiet; bumping this reduces upstream load on busy mirrors. Default 1h.
|
||||
OPAURL string // --opa-url / ZDDC_OPA_URL — policy decider endpoint: "internal" (default), "http(s)://..." (real OPA via HTTP), or "unix:///..." (OPA via Unix socket)
|
||||
OPAFailOpen bool // --opa-fail-open / ZDDC_OPA_FAIL_OPEN=1 — when external OPA is unreachable, allow instead of deny (default: fail closed)
|
||||
OPACacheTTL time.Duration // --opa-cache-ttl / ZDDC_OPA_CACHE_TTL — external mode only: per-decision cache TTL. Default 1s. Set 0s to disable.
|
||||
MaxWriteBytes int64 // --max-write-bytes / ZDDC_MAX_WRITE_BYTES — upper bound on PUT body size. Default 256 MiB. Per-request limit; rejected with 413.
|
||||
ArchiveRescanInterval time.Duration // --archive-rescan-interval / ZDDC_ARCHIVE_RESCAN_INTERVAL — periodic full re-walk of the archive index. Covers SMB/CIFS where inotify misses cross-client writes. Default 60s; 0 to disable.
|
||||
|
||||
// MD→{docx,html,pdf} conversion endpoint (see internal/convert).
|
||||
|
|
@ -132,8 +131,6 @@ func Load(args []string) (Config, error) {
|
|||
"External OPA only: on unreachable / non-2xx / malformed response, allow the request instead of denying. Default: fail closed.")
|
||||
opaCacheTTLFlag := fs.Duration("opa-cache-ttl", parseDurationOrDefault(os.Getenv("ZDDC_OPA_CACHE_TTL"), time.Second),
|
||||
"External OPA only: per-decision cache TTL. Amortizes round-trips on bursts of identical queries (e.g. .archive listing). Default 1s; set 0 to disable.")
|
||||
appsPubKeyFlag := fs.String("apps-pubkey", os.Getenv("ZDDC_APPS_PUBKEY"),
|
||||
"Path to the Ed25519 public key (PEM) used to verify signatures on URL-fetched apps: artifacts. Empty (default) = URL-fetched apps refused; only embedded + local-path apps work. Download zddc.varasys.io/pubkey.pem if you use the canonical channels.")
|
||||
maxWriteBytesFlag := fs.Int64("max-write-bytes", parseInt64OrDefault(os.Getenv("ZDDC_MAX_WRITE_BYTES"), 256*1024*1024),
|
||||
"Maximum PUT body size in bytes for the file API. Default 256 MiB. Larger requests are rejected with 413.")
|
||||
archiveRescanIntervalFlag := fs.Duration("archive-rescan-interval", parseDurationOrDefault(os.Getenv("ZDDC_ARCHIVE_RESCAN_INTERVAL"), 60*time.Second),
|
||||
|
|
@ -198,28 +195,27 @@ func Load(args []string) (Config, error) {
|
|||
addrExplicit := addrFlagSet || addrEnvSet
|
||||
|
||||
cfg := Config{
|
||||
Root: *rootFlag,
|
||||
Addr: *addrFlag,
|
||||
TLSCert: *tlsCertFlag,
|
||||
TLSKey: *tlsKeyFlag,
|
||||
LogLevel: *logLevelFlag,
|
||||
IndexPath: *indexPathFlag,
|
||||
EmailHeader: *emailHeaderFlag,
|
||||
CORSOrigins: resolveCORS(corsFlagSet, *corsOriginFlag),
|
||||
AccessLog: *accessLogFlag,
|
||||
Insecure: *insecureFlag,
|
||||
NoAuth: *noAuthFlag,
|
||||
Upstream: *upstreamFlag,
|
||||
Mode: *modeFlag,
|
||||
BearerFile: *bearerFileFlag,
|
||||
SkipTLSVerify: *skipTLSVerifyFlag,
|
||||
MirrorSubtree: parseCSV(*mirrorSubtreeFlag),
|
||||
MirrorMinInterval: *mirrorMinIntervalFlag,
|
||||
OPAURL: *opaURLFlag,
|
||||
OPAFailOpen: *opaFailOpenFlag,
|
||||
OPACacheTTL: *opaCacheTTLFlag,
|
||||
AppsPubKey: *appsPubKeyFlag,
|
||||
MaxWriteBytes: *maxWriteBytesFlag,
|
||||
Root: *rootFlag,
|
||||
Addr: *addrFlag,
|
||||
TLSCert: *tlsCertFlag,
|
||||
TLSKey: *tlsKeyFlag,
|
||||
LogLevel: *logLevelFlag,
|
||||
IndexPath: *indexPathFlag,
|
||||
EmailHeader: *emailHeaderFlag,
|
||||
CORSOrigins: resolveCORS(corsFlagSet, *corsOriginFlag),
|
||||
AccessLog: *accessLogFlag,
|
||||
Insecure: *insecureFlag,
|
||||
NoAuth: *noAuthFlag,
|
||||
Upstream: *upstreamFlag,
|
||||
Mode: *modeFlag,
|
||||
BearerFile: *bearerFileFlag,
|
||||
SkipTLSVerify: *skipTLSVerifyFlag,
|
||||
MirrorSubtree: parseCSV(*mirrorSubtreeFlag),
|
||||
MirrorMinInterval: *mirrorMinIntervalFlag,
|
||||
OPAURL: *opaURLFlag,
|
||||
OPAFailOpen: *opaFailOpenFlag,
|
||||
OPACacheTTL: *opaCacheTTLFlag,
|
||||
MaxWriteBytes: *maxWriteBytesFlag,
|
||||
ArchiveRescanInterval: *archiveRescanIntervalFlag,
|
||||
ConvertPandocBinary: *convertPandocBinaryFlag,
|
||||
ConvertChromiumBinary: *convertChromiumBinaryFlag,
|
||||
|
|
@ -416,7 +412,6 @@ func Usage(w io.Writer) {
|
|||
fs.String("opa-url", "internal", "Policy decider: \"internal\", \"http(s)://...\", or \"unix:///...\".")
|
||||
fs.Bool("opa-fail-open", false, "External OPA: allow on transport error (default: deny / fail closed).")
|
||||
fs.Duration("opa-cache-ttl", time.Second, "External OPA: per-decision cache TTL (default 1s; 0 disables).")
|
||||
fs.String("apps-pubkey", "", "Path to PEM Ed25519 pubkey for verifying signed URL-fetched apps. Empty = URL apps refused.")
|
||||
fs.String("access-log", "", "Tee structured access logs to this file (JSON, size-rotated). Default <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log; --access-log= disables.")
|
||||
fs.Duration("archive-rescan-interval", 60*time.Second, "Periodic full re-walk of the archive index (covers SMB inotify gap). Default 60s; 0 disables.")
|
||||
fs.Bool("help", false, "Print this help and exit.")
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@
|
|||
// Public surface:
|
||||
//
|
||||
// ToDocx(ctx, source, meta) → []byte (DOCX bytes)
|
||||
// ToHTML(ctx, source, meta) → []byte (standalone HTML)
|
||||
// ToPDF (ctx, source, meta) → []byte (PDF, via HTML + chromium)
|
||||
// ToHTML(ctx, source, meta, ts) → []byte (standalone HTML)
|
||||
// ToPDF (ctx, source, meta, ts) → []byte (PDF, via HTML + chromium)
|
||||
//
|
||||
// Probe(ctx) → Capabilities (call once at startup)
|
||||
// Available() → (Capabilities, bool)
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
// All three converters are safe for concurrent use; each call gets a
|
||||
// fresh scratch dir + (image-provided) sandbox.
|
||||
//
|
||||
// Metadata maps to the placeholders consumed by viewer-template.html.
|
||||
// Metadata maps to the placeholders consumed by the doctype templates.
|
||||
// title/tracking_number/revision/status/is_draft typically come from
|
||||
// the source filename (zddc.ParseFilename); client/project/contractor/
|
||||
// project_number from the .zddc cascade `convert:` block.
|
||||
|
|
@ -42,8 +42,8 @@ import (
|
|||
)
|
||||
|
||||
// Metadata is the variable bag passed to pandoc as `--variable k=v`
|
||||
// pairs. Fields with zero values are omitted. The viewer-template.html
|
||||
// uses `$if(field)$ … $endif$` blocks so absent fields render cleanly.
|
||||
// pairs. Fields with zero values are omitted. The templates use
|
||||
// `$if(field)$ … $endif$` blocks so absent fields render cleanly.
|
||||
type Metadata struct {
|
||||
Title string
|
||||
TrackingNumber string
|
||||
|
|
@ -58,6 +58,28 @@ type Metadata struct {
|
|||
NoTOC bool
|
||||
}
|
||||
|
||||
// TemplateSet is the bundle of files written to the per-call scratch dir for an
|
||||
// HTML render: the chosen doctype template (Name) plus every partial it may
|
||||
// include. pandoc resolves `$partial()$` includes from the template's own
|
||||
// directory, so Files must contain Name and all referenced partials.
|
||||
type TemplateSet struct {
|
||||
Name string // primary template filename, e.g. "report.html"
|
||||
Files map[string][]byte // base filename -> bytes (must include Name)
|
||||
}
|
||||
|
||||
// DefaultTemplateSet returns the baked-in template set for doctype `name`
|
||||
// (e.g. "report"). An empty or unknown name falls back to DefaultTemplateName.
|
||||
// The set includes every embedded partial so `$..()$` includes resolve; handlers
|
||||
// may overlay .zddc.d/templates/ overrides onto the returned Files map.
|
||||
func DefaultTemplateSet(name string) TemplateSet {
|
||||
files := embeddedTemplateFiles()
|
||||
primary := name + ".html"
|
||||
if name == "" || files[primary] == nil {
|
||||
primary = DefaultTemplateName + ".html"
|
||||
}
|
||||
return TemplateSet{Name: primary, Files: files}
|
||||
}
|
||||
|
||||
// Default binary names. The runtime image installs WRAPPER scripts at
|
||||
// /usr/local/bin/pandoc and /usr/local/bin/chromium-browser (shadowing
|
||||
// the real binaries in /usr/bin/) so these names resolve through the
|
||||
|
|
@ -132,12 +154,20 @@ func currentChromiumBinary() string {
|
|||
// full file content (envelope + body); pandoc handles
|
||||
// `markdown+yaml_metadata_block` natively.
|
||||
func ToDocx(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
|
||||
return convertToDocx(ctx, "markdown+yaml_metadata_block", source, m)
|
||||
}
|
||||
|
||||
// convertToDocx renders source (in pandoc input format fromFmt) to DOCX bytes
|
||||
// via a single pandoc exec (stdin → stdout; no scratch dir). Images in the
|
||||
// source's mediabag — present when fromFmt is "html" — are embedded into the
|
||||
// .docx natively by pandoc's docx writer.
|
||||
func convertToDocx(ctx context.Context, fromFmt string, source []byte, m Metadata) ([]byte, error) {
|
||||
r := currentRunner()
|
||||
if r == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
cmd := []string{
|
||||
"--from=markdown+yaml_metadata_block",
|
||||
"--from=" + fromFmt,
|
||||
"--to=docx",
|
||||
"--output=-",
|
||||
}
|
||||
|
|
@ -146,25 +176,99 @@ func ToDocx(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
|
|||
return r.Run(ctx, currentPandocBinary(), source, "", cmd)
|
||||
}
|
||||
|
||||
// ToHTML renders source markdown to standalone HTML using
|
||||
// viewer-template.html. Embeds CSS + images via --embed-resources.
|
||||
// Template + custom.css live in a per-call scratch dir; the host
|
||||
// path is passed via ZDDC_SCRATCH so the wrapper bind-mounts it
|
||||
// into the sandbox at the same path.
|
||||
func ToHTML(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
|
||||
// convertToMarkdown renders source (DOCX or HTML, per fromFmt) to GitHub-
|
||||
// flavored markdown. Embedded images are inlined as base64 data: URIs via the
|
||||
// inline-media.lua filter so the output .md is self-contained; --wrap=none keeps
|
||||
// paragraphs on one line (no hard line breaks).
|
||||
func convertToMarkdown(ctx context.Context, fromFmt string, source []byte) ([]byte, error) {
|
||||
r := currentRunner()
|
||||
if r == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
scratch, err := writeAssetsToScratch(currentScratchDir())
|
||||
scratch, err := writeScratchFiles(currentScratchDir(), map[string][]byte{"inline-media.lua": inlineMediaLua})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scratch: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(scratch)
|
||||
cmd := []string{
|
||||
"--from=" + fromFmt,
|
||||
"--to=gfm",
|
||||
"--wrap=none",
|
||||
"--lua-filter=" + filepath.Join(scratch, "inline-media.lua"),
|
||||
"--output=-",
|
||||
"-",
|
||||
}
|
||||
return r.Run(ctx, currentPandocBinary(), source, scratch, cmd)
|
||||
}
|
||||
|
||||
// Convert renders source from one document format to another. Supported pairs:
|
||||
//
|
||||
// md → docx | html | pdf
|
||||
// docx → md | html
|
||||
// html → md | docx
|
||||
//
|
||||
// ts is the resolved HTML template set, used only for the *→html and md→pdf
|
||||
// directions and ignored otherwise. Unsupported pairs return an error.
|
||||
func Convert(ctx context.Context, from, to string, source []byte, m Metadata, ts TemplateSet) ([]byte, error) {
|
||||
switch from {
|
||||
case "md", "markdown":
|
||||
switch to {
|
||||
case "docx":
|
||||
return ToDocx(ctx, source, m)
|
||||
case "html":
|
||||
return ToHTML(ctx, source, m, ts)
|
||||
case "pdf":
|
||||
return ToPDF(ctx, source, m, ts)
|
||||
}
|
||||
case "docx":
|
||||
switch to {
|
||||
case "md":
|
||||
return convertToMarkdown(ctx, "docx", source)
|
||||
case "html":
|
||||
return convertToHTML(ctx, "docx", source, m, ts)
|
||||
}
|
||||
case "html", "htm":
|
||||
switch to {
|
||||
case "md":
|
||||
return convertToMarkdown(ctx, "html", source)
|
||||
case "docx":
|
||||
return convertToDocx(ctx, "html", source, m)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported conversion %s→%s", from, to)
|
||||
}
|
||||
|
||||
// ToHTML renders source markdown to standalone HTML using the doctype
|
||||
// template in ts. Embeds CSS + images via --embed-resources. The
|
||||
// template + its partials live in a per-call scratch dir; the host path
|
||||
// is passed via ZDDC_SCRATCH so the wrapper bind-mounts it into the
|
||||
// sandbox at the same path. A zero-value ts falls back to the embedded
|
||||
// default template.
|
||||
func ToHTML(ctx context.Context, source []byte, m Metadata, ts TemplateSet) ([]byte, error) {
|
||||
return convertToHTML(ctx, "markdown+yaml_metadata_block", source, m, ts)
|
||||
}
|
||||
|
||||
// convertToHTML renders source (in pandoc input format fromFmt) to standalone
|
||||
// HTML through the doctype template in ts. --embed-resources base64-inlines CSS
|
||||
// and any mediabag images (so DOCX images survive docx→html with no extra
|
||||
// filter). The template + partials are written to a per-call scratch dir.
|
||||
func convertToHTML(ctx context.Context, fromFmt string, source []byte, m Metadata, ts TemplateSet) ([]byte, error) {
|
||||
r := currentRunner()
|
||||
if r == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if ts.Name == "" || len(ts.Files) == 0 {
|
||||
ts = DefaultTemplateSet(DefaultTemplateName)
|
||||
}
|
||||
scratch, err := writeScratchFiles(currentScratchDir(), ts.Files)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scratch: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(scratch)
|
||||
|
||||
tplPath := filepath.Join(scratch, "viewer-template.html")
|
||||
tplPath := filepath.Join(scratch, ts.Name)
|
||||
cmd := []string{
|
||||
"--from=markdown+yaml_metadata_block",
|
||||
"--from=" + fromFmt,
|
||||
"--to=html5",
|
||||
"--standalone",
|
||||
"--embed-resources",
|
||||
|
|
@ -182,18 +286,18 @@ func ToHTML(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
|
|||
}
|
||||
|
||||
// ToPDF renders source markdown to PDF in two stages: pandoc
|
||||
// produces HTML using viewer-template.html (stage 1), then headless
|
||||
// chromium prints that HTML to PDF (stage 2). The two-stage choice
|
||||
// preserves the print-media CSS already authored in viewer-
|
||||
// template.html — pandoc's native --pdf-engine path uses LaTeX
|
||||
// which would bypass it entirely.
|
||||
// produces HTML using the doctype template in ts (stage 1), then
|
||||
// headless chromium prints that HTML to PDF (stage 2). The two-stage
|
||||
// choice preserves the print-media CSS authored in the templates —
|
||||
// pandoc's native --pdf-engine path uses LaTeX which would bypass it
|
||||
// entirely.
|
||||
//
|
||||
// Both stages share a single per-call scratch dir: pandoc writes
|
||||
// `in.html` and chromium reads it, then chromium writes `out.pdf`
|
||||
// which the host reads back. The wrapper bind-mounts the scratch
|
||||
// dir read-write into the sandbox at the same path.
|
||||
func ToPDF(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
|
||||
html, err := ToHTML(ctx, source, m)
|
||||
func ToPDF(ctx context.Context, source []byte, m Metadata, ts TemplateSet) ([]byte, error) {
|
||||
html, err := ToHTML(ctx, source, m, ts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,77 @@ func (f *fakeRunner) lastCall() (string, []string) {
|
|||
return f.binaries[len(f.binaries)-1], f.calls[len(f.calls)-1]
|
||||
}
|
||||
|
||||
func TestConvert_Directions(t *testing.T) {
|
||||
cases := []struct {
|
||||
from, to string
|
||||
wantArgs []string // substrings that must appear in the pandoc command
|
||||
wantErr bool
|
||||
}{
|
||||
{"docx", "md", []string{"--from=docx", "--to=gfm", "--wrap=none"}, false},
|
||||
{"html", "md", []string{"--from=html", "--to=gfm", "--wrap=none"}, false},
|
||||
{"docx", "html", []string{"--from=docx", "--to=html5", "--embed-resources"}, false},
|
||||
{"html", "docx", []string{"--from=html", "--to=docx"}, false},
|
||||
{"md", "docx", []string{"--from=markdown+yaml_metadata_block", "--to=docx"}, false},
|
||||
{"md", "html", []string{"--from=markdown+yaml_metadata_block", "--to=html5"}, false},
|
||||
{"docx", "pdf", nil, true}, // pdf is markdown-only
|
||||
{"docx", "docx", nil, true}, // same-format is unsupported
|
||||
{"html", "html", nil, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.from+"_to_"+c.to, func(t *testing.T) {
|
||||
f := &fakeRunner{resp: []byte("OUT")}
|
||||
InstallRunner(f)
|
||||
t.Cleanup(func() { InstallRunner(nil) })
|
||||
SetBinaries("pandoc", "chromium-browser")
|
||||
|
||||
_, err := Convert(context.Background(), c.from, c.to, []byte("x"), Metadata{}, TemplateSet{})
|
||||
if c.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("Convert(%s→%s): expected error, got nil", c.from, c.to)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Convert(%s→%s): %v", c.from, c.to, err)
|
||||
}
|
||||
binary, call := f.lastCall()
|
||||
if binary != "pandoc" {
|
||||
t.Errorf("expected pandoc, got %q", binary)
|
||||
}
|
||||
for _, want := range c.wantArgs {
|
||||
if !contains(call, want) {
|
||||
t.Errorf("Convert(%s→%s) missing %q in %v", c.from, c.to, want, call)
|
||||
}
|
||||
}
|
||||
// To-markdown directions inline images via the lua filter.
|
||||
if c.to == "md" {
|
||||
if !hasPrefArg(call, "--lua-filter=") || !hasSuffArg(call, "inline-media.lua") {
|
||||
t.Errorf("Convert(%s→md) missing inline-media.lua filter: %v", c.from, call)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// hasPrefArg / hasSuffArg report whether any arg has the given prefix/suffix.
|
||||
func hasPrefArg(args []string, prefix string) bool {
|
||||
for _, a := range args {
|
||||
if strings.HasPrefix(a, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasSuffArg(args []string, suffix string) bool {
|
||||
for _, a := range args {
|
||||
if strings.HasSuffix(a, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestToDocx_UsesPandocBinary(t *testing.T) {
|
||||
f := &fakeRunner{resp: []byte("FAKE-DOCX")}
|
||||
InstallRunner(f)
|
||||
|
|
@ -86,7 +157,7 @@ func TestToHTML_UsesTemplateFromScratchDir(t *testing.T) {
|
|||
t.Cleanup(func() { InstallRunner(nil) })
|
||||
SetBinaries("pandoc", "chromium-browser")
|
||||
|
||||
_, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{Title: "Hi"})
|
||||
_, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{Title: "Hi"}, TemplateSet{})
|
||||
if err != nil {
|
||||
t.Fatalf("ToHTML: %v", err)
|
||||
}
|
||||
|
|
@ -101,7 +172,7 @@ func TestToHTML_UsesTemplateFromScratchDir(t *testing.T) {
|
|||
if scratch == "" {
|
||||
t.Fatalf("ToHTML must pass a scratch dir to the runner")
|
||||
}
|
||||
wantTpl := "--template=" + scratch + "/viewer-template.html"
|
||||
wantTpl := "--template=" + scratch + "/report.html"
|
||||
if !contains(call, wantTpl) {
|
||||
t.Errorf("template flag missing/wrong; want %q in %v", wantTpl, call)
|
||||
}
|
||||
|
|
@ -115,7 +186,7 @@ func TestToHTML_NoTOCSuppressesTOC(t *testing.T) {
|
|||
InstallRunner(f)
|
||||
t.Cleanup(func() { InstallRunner(nil) })
|
||||
|
||||
_, _ = ToHTML(context.Background(), []byte("# Hi\n"), Metadata{NoTOC: true})
|
||||
_, _ = ToHTML(context.Background(), []byte("# Hi\n"), Metadata{NoTOC: true}, TemplateSet{})
|
||||
_, call := f.lastCall()
|
||||
if contains(call, "--toc") {
|
||||
t.Errorf("TOC should be suppressed when NoTOC=true: %v", call)
|
||||
|
|
@ -170,7 +241,7 @@ func TestScratchDir_UsedByToHTML(t *testing.T) {
|
|||
scratchRoot := t.TempDir()
|
||||
SetScratchDir(scratchRoot)
|
||||
|
||||
_, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{})
|
||||
_, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{}, TemplateSet{})
|
||||
if err != nil {
|
||||
t.Fatalf("ToHTML: %v", err)
|
||||
}
|
||||
|
|
@ -199,7 +270,7 @@ func TestToPDF_TwoStagePipeline(t *testing.T) {
|
|||
t.Cleanup(func() { InstallRunner(nil) })
|
||||
SetBinaries("pandoc", "chromium-browser")
|
||||
|
||||
_, err := ToPDF(context.Background(), []byte("# Hi\n"), Metadata{})
|
||||
_, err := ToPDF(context.Background(), []byte("# Hi\n"), Metadata{}, TemplateSet{})
|
||||
// PDF read-back will fail (fake runner didn't write the file) —
|
||||
// that's expected for this test which only inspects the call shape.
|
||||
if err == nil {
|
||||
|
|
|
|||
|
|
@ -1,163 +0,0 @@
|
|||
/*
|
||||
* Legal-style heading numbering for ZDDC documents
|
||||
* Adds hierarchical numbering like 1, 1.1, 1.1.1, etc.
|
||||
*/
|
||||
|
||||
/* Reset counters at document level */
|
||||
.document-content {
|
||||
counter-reset: h1-counter;
|
||||
}
|
||||
|
||||
/* H1 counters */
|
||||
h1 {
|
||||
counter-reset: h2-counter h3-counter h4-counter h5-counter h6-counter;
|
||||
counter-increment: h1-counter;
|
||||
}
|
||||
|
||||
h1::before {
|
||||
content: counter(h1-counter) ". ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* H2 counters */
|
||||
h2 {
|
||||
counter-reset: h3-counter h4-counter h5-counter h6-counter;
|
||||
counter-increment: h2-counter;
|
||||
}
|
||||
|
||||
h2::before {
|
||||
content: counter(h1-counter) "." counter(h2-counter) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* H3 counters */
|
||||
h3 {
|
||||
counter-reset: h4-counter h5-counter h6-counter;
|
||||
counter-increment: h3-counter;
|
||||
}
|
||||
|
||||
h3::before {
|
||||
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* H4 counters */
|
||||
h4 {
|
||||
counter-reset: h5-counter h6-counter;
|
||||
counter-increment: h4-counter;
|
||||
}
|
||||
|
||||
h4::before {
|
||||
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* H5 counters */
|
||||
h5 {
|
||||
counter-reset: h6-counter;
|
||||
counter-increment: h5-counter;
|
||||
}
|
||||
|
||||
h5::before {
|
||||
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) "." counter(h5-counter) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* H6 counters */
|
||||
h6 {
|
||||
counter-increment: h6-counter;
|
||||
}
|
||||
|
||||
h6::before {
|
||||
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) "." counter(h5-counter) "." counter(h6-counter) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* TOC numbering to match document headings */
|
||||
.toc {
|
||||
counter-reset: toc-h1;
|
||||
}
|
||||
|
||||
.toc ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.toc > ul > li {
|
||||
counter-increment: toc-h1;
|
||||
counter-reset: toc-h2 toc-h3 toc-h4 toc-h5 toc-h6;
|
||||
}
|
||||
|
||||
.toc > ul > li > a::before {
|
||||
content: counter(toc-h1) ". ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
.toc > ul > li > ul > li {
|
||||
counter-increment: toc-h2;
|
||||
counter-reset: toc-h3 toc-h4 toc-h5 toc-h6;
|
||||
}
|
||||
|
||||
.toc > ul > li > ul > li > a::before {
|
||||
content: counter(toc-h1) "." counter(toc-h2) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
.toc > ul > li > ul > li > ul > li {
|
||||
counter-increment: toc-h3;
|
||||
counter-reset: toc-h4 toc-h5 toc-h6;
|
||||
}
|
||||
|
||||
.toc > ul > li > ul > li > ul > li > a::before {
|
||||
content: counter(toc-h1) "." counter(toc-h2) "." counter(toc-h3) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
/* Optional: Add some spacing after the numbers */
|
||||
h1::before, h2::before, h3::before, h4::before, h5::before, h6::before {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
/* Print-specific adjustments */
|
||||
@media print {
|
||||
h1::before, h2::before, h3::before, h4::before, h5::before, h6::before {
|
||||
color: #000 !important; /* Ensure numbers print in black */
|
||||
}
|
||||
}
|
||||
|
||||
/* Optional: Style adjustments for better visual hierarchy */
|
||||
h1 {
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
padding-bottom: 0.3em;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
/* Reduce margin for first heading */
|
||||
h1:first-of-type {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 0.2em;
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 1.2em;
|
||||
}
|
||||
|
||||
h4, h5, h6 {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
|
@ -1,19 +1,88 @@
|
|||
package convert
|
||||
|
||||
import _ "embed"
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Pandoc HTML template and its companion stylesheet, copied verbatim from
|
||||
// /pandoc/viewer-template.html and /pandoc/custom.css. The runner writes
|
||||
// these to a host scratch dir on each conversion and bind-mounts them
|
||||
// read-only into the container so pandoc can `--template` against them.
|
||||
// Default pandoc HTML templates, mirrored verbatim from /pandoc/templates/ by
|
||||
// the top-level ./build (shared/build-lib.sh: sync_pandoc_templates). The runner
|
||||
// writes the chosen template + its partials to a host scratch dir on each HTML
|
||||
// conversion and bind-mounts them into the sandbox so pandoc can `--template`
|
||||
// against them.
|
||||
//
|
||||
// Refresh: when /pandoc/viewer-template.html changes, copy the new bytes
|
||||
// here. There's no symlink because go:embed paths must resolve under the
|
||||
// containing module — and we want the binary to ship the bytes verbatim,
|
||||
// not depend on the source tree at runtime.
|
||||
// pandoc/templates/ is the single source of truth; this directory is a build
|
||||
// artifact kept in sync and guarded by TestEmbeddedTemplatesMatchSource. There's
|
||||
// no symlink because go:embed paths must resolve under the containing module, and
|
||||
// we want the binary to ship the bytes verbatim, not depend on the source tree at
|
||||
// runtime.
|
||||
//
|
||||
// The set holds named doctype templates (report.html, letter.html,
|
||||
// specification.html) plus the shared partials they include (_head.html,
|
||||
// _doc.html, _scripts.html). A document picks one via its `template:` front
|
||||
// matter; operators override individual files through the .zddc.d/templates/
|
||||
// cascade (see internal/handler).
|
||||
|
||||
//go:embed viewer-template.html
|
||||
var viewerTemplate []byte
|
||||
// `all:` is required so the `_`-prefixed partials (_head.html, _doc.html,
|
||||
// _scripts.html) are embedded — a bare `//go:embed templates` excludes names
|
||||
// beginning with `_` or `.`.
|
||||
//
|
||||
//go:embed all:templates
|
||||
var templatesFS embed.FS
|
||||
|
||||
//go:embed custom.css
|
||||
var customCSS []byte
|
||||
// inlineMediaLua is the pandoc filter that base64-inlines images into markdown
|
||||
// output (docx→md / html→md), written to the per-call scratch dir alongside the
|
||||
// conversion. Server-only — the CLI convert script extracts media to a folder
|
||||
// instead.
|
||||
//
|
||||
//go:embed inline-media.lua
|
||||
var inlineMediaLua []byte
|
||||
|
||||
// DefaultTemplateName is used when a document declares no `template:` field or
|
||||
// names one that doesn't resolve.
|
||||
const DefaultTemplateName = "report"
|
||||
|
||||
// embeddedTemplate returns the bytes of a baked-in template/partial by base file
|
||||
// name (e.g. "report.html", "_head.html"), or nil if there is no such default.
|
||||
func embeddedTemplate(name string) []byte {
|
||||
b, err := templatesFS.ReadFile(path.Join("templates", name))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// embeddedTemplateFiles returns all baked-in template/partial files keyed by
|
||||
// base name. The returned map is a fresh copy the caller may mutate (e.g. to
|
||||
// overlay .zddc.d/templates overrides).
|
||||
func embeddedTemplateFiles() map[string][]byte {
|
||||
out := make(map[string][]byte)
|
||||
entries, _ := fs.ReadDir(templatesFS, "templates")
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if b := embeddedTemplate(e.Name()); b != nil {
|
||||
out[e.Name()] = b
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EmbeddedTemplateNames lists the baked-in doctype template names (no extension,
|
||||
// partials excluded — i.e. the names a `template:` field may select), sorted.
|
||||
func EmbeddedTemplateNames() []string {
|
||||
var names []string
|
||||
entries, _ := fs.ReadDir(templatesFS, "templates")
|
||||
for _, e := range entries {
|
||||
n := e.Name()
|
||||
if e.IsDir() || n == "" || n[0] == '_' || path.Ext(n) != ".html" {
|
||||
continue
|
||||
}
|
||||
names = append(names, n[:len(n)-len(".html")])
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
|
|
|||
31
zddc/internal/convert/inline-media.lua
Normal file
31
zddc/internal/convert/inline-media.lua
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
-- inline-media.lua — pandoc filter that rewrites every image to a self-contained
|
||||
-- base64 data: URI, pulling the bytes from pandoc's mediabag (populated when
|
||||
-- reading DOCX, or fetched for HTML). Used by the docx→md / html→md conversions
|
||||
-- so the resulting markdown carries its images inline (markdown output has no
|
||||
-- native --embed-resources equivalent).
|
||||
|
||||
local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
|
||||
local function base64(data)
|
||||
return ((data:gsub('.', function(x)
|
||||
local r, byte = '', x:byte()
|
||||
for i = 8, 1, -1 do r = r .. (byte % 2 ^ i - byte % 2 ^ (i - 1) > 0 and '1' or '0') end
|
||||
return r
|
||||
end) .. '0000'):gsub('%d%d%d?%d?%d?%d?', function(x)
|
||||
if #x < 6 then return '' end
|
||||
local c = 0
|
||||
for i = 1, 6 do c = c + (x:sub(i, i) == '1' and 2 ^ (6 - i) or 0) end
|
||||
return b:sub(c + 1, c + 1)
|
||||
end) .. ({ '', '==', '=' })[#data % 3 + 1])
|
||||
end
|
||||
|
||||
function Image(img)
|
||||
local mt, data = pandoc.mediabag.lookup(img.src)
|
||||
if not data then
|
||||
mt, data = pandoc.mediabag.fetch(img.src)
|
||||
end
|
||||
if data then
|
||||
img.src = 'data:' .. (mt or 'application/octet-stream') .. ';base64,' .. base64(data)
|
||||
end
|
||||
return img
|
||||
end
|
||||
|
|
@ -274,29 +274,27 @@ func (r *ringWriter) String() string {
|
|||
return string(r.buf)
|
||||
}
|
||||
|
||||
// writeAssetsToScratch materialises the embedded viewer-template.html
|
||||
// and custom.css into a fresh scratch dir and returns the host path.
|
||||
// Caller is responsible for os.RemoveAll(dir) when done. Used by
|
||||
// ToHTML which needs the template visible inside the sandbox.
|
||||
// writeScratchFiles materialises a set of named byte buffers (template +
|
||||
// partials, or a lua filter) into a fresh scratch dir and returns the host
|
||||
// path. Caller is responsible for os.RemoveAll(dir) when done. pandoc resolves
|
||||
// `$partial()$` includes and --lua-filter paths from this dir, so everything
|
||||
// lands flat alongside the entry file.
|
||||
//
|
||||
// scratchRoot controls where the temp dir lands. Empty means
|
||||
// "use $TMPDIR".
|
||||
// scratchRoot controls where the temp dir lands. Empty means "use $TMPDIR".
|
||||
//
|
||||
// Files are written world-readable so the binary's default user can
|
||||
// read them through the wrapper's bind mount regardless of the
|
||||
// host's umask.
|
||||
func writeAssetsToScratch(scratchRoot string) (string, error) {
|
||||
// Files are written world-readable so the binary's default user can read them
|
||||
// through the wrapper's bind mount regardless of the host's umask. Keys are
|
||||
// reduced to base names only (no path separators).
|
||||
func writeScratchFiles(scratchRoot string, files map[string][]byte) (string, error) {
|
||||
dir, err := os.MkdirTemp(scratchRoot, "zddc-convert-")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("scratch dir: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "viewer-template.html"), viewerTemplate, 0o644); err != nil {
|
||||
os.RemoveAll(dir)
|
||||
return "", fmt.Errorf("write template: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "custom.css"), customCSS, 0o644); err != nil {
|
||||
os.RemoveAll(dir)
|
||||
return "", fmt.Errorf("write css: %w", err)
|
||||
for name, b := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, filepath.Base(name)), b, 0o644); err != nil {
|
||||
os.RemoveAll(dir)
|
||||
return "", fmt.Errorf("write scratch file %q: %w", name, err)
|
||||
}
|
||||
}
|
||||
if err := chmodTree(dir, 0o755, 0o644); err != nil {
|
||||
os.RemoveAll(dir)
|
||||
|
|
|
|||
112
zddc/internal/convert/templates/_doc.html
Normal file
112
zddc/internal/convert/templates/_doc.html
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<div class="app-container">
|
||||
$if(toc)$
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside id="sidebar" role="complementary" aria-label="Table of contents">
|
||||
<header class="sidebar-header">
|
||||
<div class="toc-header-row">
|
||||
<div class="sidebar-title">Table Of Contents</div>
|
||||
<div class="toc-level-selector">
|
||||
<select id="toc-level" aria-label="Filter table of contents levels">
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3" selected>3</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5">5</option>
|
||||
<option value="6">6</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="toc-container">
|
||||
$if(toc)$
|
||||
<nav class="toc" role="navigation" aria-label="Table of contents">
|
||||
$toc$
|
||||
</nav>
|
||||
$endif$
|
||||
</div>
|
||||
</aside>
|
||||
$endif$
|
||||
<!-- Main Content Area -->
|
||||
<main class="content-wrapper" role="main">
|
||||
<div class="content-page">
|
||||
<!-- Document Header -->
|
||||
<header class="document-header">
|
||||
$if(toc)$
|
||||
<div class="mobile-menu-container">
|
||||
<button class="mobile-menu-toggle" type="button" aria-label="Toggle navigation menu" aria-expanded="false">
|
||||
<span aria-hidden="true">☰</span>
|
||||
</button>
|
||||
</div>
|
||||
$endif$
|
||||
<div class="header-content">
|
||||
$if(client)$$if(project)$
|
||||
<div class="header-line client-project">
|
||||
$client$ - $project$$if(project_number)$ ($project_number$)$endif$
|
||||
</div>
|
||||
$endif$$endif$
|
||||
$if(title)$
|
||||
<div class="document-title">$title$</div>
|
||||
$endif$
|
||||
<div class="document-meta">
|
||||
$if(tracking_number)$<span class="tracking-number">$tracking_number$</span>$endif$
|
||||
$if(revision)$<span class="revision">Revision: $revision$</span>$endif$
|
||||
$if(status)$<span class="status">Status: $status$</span>$endif$
|
||||
$if(revision_comparison)$<span class="revision-comparison">$revision_comparison$</span>$endif$
|
||||
</div>
|
||||
$if(is_draft)$
|
||||
$if(generation_time)$
|
||||
<div class="draft-line">
|
||||
<span class="draft-status">[DRAFT Generated at $generation_time$]</span>
|
||||
</div>
|
||||
$endif$
|
||||
$endif$
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Scroll Progress Bar -->
|
||||
<div class="scroll-progress" role="progressbar" aria-label="Reading progress">
|
||||
<div class="scroll-progress-bar"></div>
|
||||
</div>
|
||||
|
||||
<!-- Print-only header -->
|
||||
<div class="print-header">
|
||||
$if(custom_header)$
|
||||
$custom_header$
|
||||
$else$
|
||||
$if(client)$$if(project)$
|
||||
<div class="header-line client-project">
|
||||
$client$ - $project$$if(project_number)$ ($project_number$)$endif$
|
||||
</div>
|
||||
$endif$$endif$
|
||||
$if(title)$
|
||||
<div class="header-line document-title">$title$</div>
|
||||
$endif$
|
||||
$if(tracking_number)$<div class="header-line">$tracking_number$$if(revision)$ Revision: $revision$$endif$$if(status)$ Status: $status$$endif$</div>$endif$
|
||||
$if(revision_comparison)$<div class="header-line revision-comparison">$revision_comparison$</div>$endif$
|
||||
$endif$
|
||||
$if(generation_time)$
|
||||
<div class="header-line metadata-line draft-line">
|
||||
<span class="draft-status">Generated: $generation_time$</span>
|
||||
</div>
|
||||
$endif$
|
||||
</div>
|
||||
|
||||
<!-- Print-only footer -->
|
||||
<div class="print-footer">
|
||||
<div class="footer-left">
|
||||
$if(tracking_number)$$tracking_number$$endif$$if(revision)$ Revision: $revision$$endif$$if(status)$ Status: $status$$endif$
|
||||
</div>
|
||||
<div class="footer-right">
|
||||
Page <span class="page-number"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Document Content -->
|
||||
<article class="document-content">
|
||||
$body$
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
778
zddc/internal/convert/templates/_head.html
Normal file
778
zddc/internal/convert/templates/_head.html
Normal file
|
|
@ -0,0 +1,778 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>$if(title)$$title$$else$Document$endif$</title>
|
||||
|
||||
<!-- Document metadata for JavaScript -->
|
||||
$if(revision)$<meta name="revision" content="$revision$">$endif$
|
||||
$if(generation_time)$<meta name="generation_time" content="$generation_time$">$endif$
|
||||
|
||||
<!-- Embedded CSS -->
|
||||
<style>
|
||||
/*
|
||||
* ZDDC Document Viewer Template
|
||||
* Enhanced responsive layout with TOC navigation
|
||||
*/
|
||||
|
||||
/* CSS Variables for theming - Soft Light Theme */
|
||||
:root {
|
||||
--primary-color: #2563eb;
|
||||
--primary-color-dark: #1d4ed8;
|
||||
--text-color: #4b5563;
|
||||
--text-secondary: #6b7280;
|
||||
--text-primary: #1f2937;
|
||||
--bg-primary: #f8fafc;
|
||||
--bg-secondary: #f1f5f9;
|
||||
--border-color: #d1d5db;
|
||||
--hover-bg: #e2e8f0;
|
||||
--active-bg: rgba(37, 99, 235, 0.1);
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--sidebar-width: 280px;
|
||||
--header-height: 120px;
|
||||
--content-max-width: 900px;
|
||||
}
|
||||
|
||||
/* Dark mode variables - Standard Dark Theme */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--primary-color: #60a5fa;
|
||||
--primary-color-dark: #3b82f6;
|
||||
--text-color: #d1d5db;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-primary: #f9fafb;
|
||||
--bg-primary: #111827;
|
||||
--bg-secondary: #1f2937;
|
||||
--border-color: #374151;
|
||||
--hover-bg: #374151;
|
||||
--active-bg: rgba(96, 165, 250, 0.2);
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* Reset and base styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color);
|
||||
background: var(--bg-secondary);
|
||||
height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* App Container - Modern CSS Grid Layout */
|
||||
.app-container {
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) 1fr;
|
||||
grid-template-areas: "sidebar main";
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas: "main";
|
||||
}
|
||||
}
|
||||
|
||||
/* Content wrapper - Grid area */
|
||||
.content-wrapper {
|
||||
grid-area: main;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
max-width: min(900px, 100%);
|
||||
margin: 0;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* Content page simplified */
|
||||
.content-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* Sidebar Navigation - Grid area */
|
||||
#sidebar {
|
||||
grid-area: sidebar;
|
||||
height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
border-inline-end: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#sidebar {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
#sidebar.mobile-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Ensure mobile TOC uses light theme colors */
|
||||
#sidebar .sidebar-header {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
#sidebar .toc a {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
#sidebar .toc a:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Document Header - Flex Row Layout */
|
||||
.document-header {
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 1rem;
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-menu-container {
|
||||
display: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-menu-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle:hover {
|
||||
background: var(--primary-color-dark);
|
||||
transform: scale(1.05);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.toc-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toc-level-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.toc-level-selector select {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.25rem;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-color);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* TOC Container */
|
||||
.toc-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 0;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toc-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.toc-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.toc-container::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Scroll Progress Indicator */
|
||||
.scroll-progress {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: var(--border-color);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.scroll-progress-bar {
|
||||
height: 100%;
|
||||
background: var(--primary-color);
|
||||
width: 0%;
|
||||
transition: width 0.1s ease;
|
||||
}
|
||||
|
||||
/* TOC Styling */
|
||||
.toc ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.toc ul ul {
|
||||
padding-left: 1.25rem;
|
||||
margin-top: 0.25rem;
|
||||
border-left: 2px solid var(--border-color);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.toc li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.toc a {
|
||||
display: block;
|
||||
padding: 0.375rem 0.75rem;
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.toc li li a {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.toc a:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.toc a.active {
|
||||
background: var(--active-bg);
|
||||
color: var(--primary-color);
|
||||
border-left-color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Content Page Container - Simplified */
|
||||
.content-page {
|
||||
flex: 1;
|
||||
background: var(--bg-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Document Content */
|
||||
.document-content {
|
||||
flex: 1;
|
||||
padding: 0.5rem 2rem 2rem 2rem;
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Document Header */
|
||||
.document-header {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0.75rem 2rem;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header-line {
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Header line hierarchy */
|
||||
.client-project {
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.document-title {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.metadata-line {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.draft-status {
|
||||
color: #dc3545;
|
||||
font-weight: bold;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Print-only elements - hidden on screen */
|
||||
.print-header,
|
||||
.print-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile menu backdrop */
|
||||
@media (max-width: 768px) {
|
||||
.mobile-menu-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#sidebar.mobile-open::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
/* Remove top margin from first heading in content */
|
||||
.document-content h1:first-child,
|
||||
.document-content h2:first-child,
|
||||
.document-content h3:first-child,
|
||||
.document-content h4:first-child,
|
||||
.document-content h5:first-child,
|
||||
.document-content h6:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 { font-size: 2rem; }
|
||||
h2 { font-size: 1.5rem; }
|
||||
h3 { font-size: 1.25rem; }
|
||||
h4 { font-size: 1.125rem; }
|
||||
h5 { font-size: 1rem; }
|
||||
h6 { font-size: 0.875rem; }
|
||||
|
||||
p {
|
||||
margin: 1rem 0;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
ol, ul {
|
||||
margin: 1rem 0;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.25rem 0;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Nested lists */
|
||||
ol ol, ul ul, ol ul, ul ol {
|
||||
margin: 0.25rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
ul ul {
|
||||
list-style-type: circle;
|
||||
}
|
||||
|
||||
ul ul ul {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
/* Hide online-only elements */
|
||||
.sidebar,
|
||||
.mobile-menu-toggle,
|
||||
.scroll-progress,
|
||||
.document-header {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Show print-only elements */
|
||||
.print-header {
|
||||
display: block !important;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
border-bottom: 1pt solid #000;
|
||||
padding: 12pt 0.5in;
|
||||
z-index: 1000;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.print-footer {
|
||||
display: flex !important;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
border-top: 1pt solid #000;
|
||||
padding: 8pt 0.5in;
|
||||
z-index: 1000;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Print header styling */
|
||||
.print-header .client-project {
|
||||
font-size: 12pt;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4pt 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.print-header .document-title {
|
||||
font-size: 16pt;
|
||||
color: #000;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Print footer styling */
|
||||
.print-footer .footer-left,
|
||||
.print-footer .footer-right {
|
||||
font-size: 10pt;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Page counter for print */
|
||||
.print-footer .page-number::after {
|
||||
content: counter(page);
|
||||
}
|
||||
|
||||
@page {
|
||||
margin: 1in;
|
||||
size: letter;
|
||||
counter-increment: page;
|
||||
}
|
||||
|
||||
.draft-line {
|
||||
margin-top: 4pt;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
/* Layout adjustments */
|
||||
html, body {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
margin-left: 0 !important;
|
||||
width: 100% !important;
|
||||
/* The screen layout caps content-wrapper at 900px; in print, the
|
||||
printable area is page-width minus @page margins (~6.5in =
|
||||
~624px for letter at 96dpi), which is narrower than 900px BUT
|
||||
chromium's --print-to-pdf renders at the full page width and
|
||||
only clips at print time — so without max-width:none the
|
||||
element extends past the right margin. */
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
.content-page {
|
||||
max-width: none !important;
|
||||
width: 100% !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.document-content {
|
||||
margin-top: 80pt !important;
|
||||
margin-bottom: 50pt !important;
|
||||
padding: 0 0.5in !important;
|
||||
border-left: none !important;
|
||||
min-height: calc(100vh - 130pt) !important;
|
||||
max-width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
/* Wide content that wouldn't otherwise wrap: tables, code blocks,
|
||||
long URLs in inline code. Force them to stay within the
|
||||
printable area instead of running off the right edge. */
|
||||
pre, code, table, blockquote, img, video {
|
||||
max-width: 100% !important;
|
||||
overflow-wrap: break-word !important;
|
||||
word-wrap: break-word !important;
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap !important;
|
||||
word-break: break-word !important;
|
||||
}
|
||||
table {
|
||||
table-layout: fixed !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Fix list formatting in print */
|
||||
ol, ul {
|
||||
padding-left: 2rem !important;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.25rem 0 !important;
|
||||
}
|
||||
|
||||
/* Typography for print */
|
||||
body {
|
||||
font-size: 12pt !important;
|
||||
line-height: 1.4 !important;
|
||||
color: #000 !important;
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
/* Page breaks */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
page-break-after: avoid;
|
||||
page-break-inside: avoid;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
p, li {
|
||||
orphans: 3;
|
||||
widows: 3;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
/* Prevent content cutoff */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Ensure proper spacing at page breaks */
|
||||
h1:first-child, h2:first-child, h3:first-child {
|
||||
margin-top: 0;
|
||||
padding-top: 0.5em;
|
||||
}
|
||||
|
||||
/* Table print formatting */
|
||||
table {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
|
||||
tbody {
|
||||
display: table-row-group;
|
||||
}
|
||||
|
||||
tr {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 8pt 6pt !important;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #000 !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Diff styling for pandiff output */
|
||||
u {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
text-decoration: none;
|
||||
padding: 0.1em 0.2em;
|
||||
border-radius: 0.2em;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Legal-style heading numbering for ZDDC documents.
|
||||
* Gated by the `numbered` body class, which the per-doctype templates add when
|
||||
* the document's YAML front matter sets `numbering: true` (default: off).
|
||||
*/
|
||||
body.numbered .document-content { counter-reset: h1-counter; }
|
||||
|
||||
body.numbered h1 { counter-reset: h2-counter h3-counter h4-counter h5-counter h6-counter; counter-increment: h1-counter; }
|
||||
body.numbered h1::before { content: counter(h1-counter) ". "; font-weight: bold; color: var(--primary-color); }
|
||||
|
||||
body.numbered h2 { counter-reset: h3-counter h4-counter h5-counter h6-counter; counter-increment: h2-counter; }
|
||||
body.numbered h2::before { content: counter(h1-counter) "." counter(h2-counter) " "; font-weight: bold; color: var(--primary-color); }
|
||||
|
||||
body.numbered h3 { counter-reset: h4-counter h5-counter h6-counter; counter-increment: h3-counter; }
|
||||
body.numbered h3::before { content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) " "; font-weight: bold; color: var(--primary-color); }
|
||||
|
||||
body.numbered h4 { counter-reset: h5-counter h6-counter; counter-increment: h4-counter; }
|
||||
body.numbered h4::before { content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) " "; font-weight: bold; color: var(--primary-color); }
|
||||
|
||||
body.numbered h5 { counter-reset: h6-counter; counter-increment: h5-counter; }
|
||||
body.numbered h5::before { content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) "." counter(h5-counter) " "; font-weight: bold; color: var(--primary-color); }
|
||||
|
||||
body.numbered h6 { counter-increment: h6-counter; }
|
||||
body.numbered h6::before { content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) "." counter(h5-counter) "." counter(h6-counter) " "; font-weight: bold; color: var(--primary-color); }
|
||||
|
||||
/* TOC numbering to match document headings */
|
||||
body.numbered .toc { counter-reset: toc-h1; }
|
||||
body.numbered .toc ul { list-style: none; }
|
||||
body.numbered .toc > ul > li { counter-increment: toc-h1; counter-reset: toc-h2 toc-h3 toc-h4 toc-h5 toc-h6; }
|
||||
body.numbered .toc > ul > li > a::before { content: counter(toc-h1) ". "; font-weight: bold; color: var(--primary-color); margin-right: 0.25em; }
|
||||
body.numbered .toc > ul > li > ul > li { counter-increment: toc-h2; counter-reset: toc-h3 toc-h4 toc-h5 toc-h6; }
|
||||
body.numbered .toc > ul > li > ul > li > a::before { content: counter(toc-h1) "." counter(toc-h2) " "; font-weight: bold; color: var(--primary-color); margin-right: 0.25em; }
|
||||
body.numbered .toc > ul > li > ul > li > ul > li { counter-increment: toc-h3; counter-reset: toc-h4 toc-h5 toc-h6; }
|
||||
body.numbered .toc > ul > li > ul > li > ul > li > a::before { content: counter(toc-h1) "." counter(toc-h2) "." counter(toc-h3) " "; font-weight: bold; color: var(--primary-color); margin-right: 0.25em; }
|
||||
|
||||
body.numbered h1::before, body.numbered h2::before, body.numbered h3::before,
|
||||
body.numbered h4::before, body.numbered h5::before, body.numbered h6::before { margin-right: 0.5em; }
|
||||
|
||||
@media print {
|
||||
body.numbered h1::before, body.numbered h2::before, body.numbered h3::before,
|
||||
body.numbered h4::before, body.numbered h5::before, body.numbered h6::before { color: #000 !important; }
|
||||
}
|
||||
|
||||
/* Visual heading hierarchy that accompanies the numbered/legal look. */
|
||||
body.numbered h1 { border-bottom: 2px solid var(--primary-color); padding-bottom: 0.3em; margin-top: 1em; }
|
||||
body.numbered h1:first-of-type { margin-top: 0.5em; }
|
||||
body.numbered h2 { border-bottom: 1px solid var(--border-color); padding-bottom: 0.2em; margin-top: 1.5em; }
|
||||
body.numbered h3 { margin-top: 1.2em; }
|
||||
body.numbered h4, body.numbered h5, body.numbered h6 { margin-top: 1em; }
|
||||
|
||||
/*
|
||||
* Doctype-specific layout. `doctype` comes from the document's YAML front matter
|
||||
* (report | specification | letter); the per-doctype template sets `doc-<name>`.
|
||||
* A letter has no TOC sidebar and flows as a normal single column.
|
||||
*/
|
||||
body.doc-letter { height: auto; overflow: visible; }
|
||||
body.doc-letter .content-wrapper { margin: 0 auto; max-width: var(--content-max-width); }
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
$for(header-includes)$
|
||||
$header-includes$
|
||||
$endfor$
|
||||
</head>
|
||||
259
zddc/internal/convert/templates/_scripts.html
Normal file
259
zddc/internal/convert/templates/_scripts.html
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
<!-- Embedded JavaScript -->
|
||||
<script>
|
||||
'use strict';
|
||||
|
||||
// Modern initialization with arrow functions
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// View mode toggle functionality
|
||||
const buttons = document.querySelectorAll('.view-mode-btn');
|
||||
const body = document.body;
|
||||
|
||||
buttons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const mode = this.dataset.mode;
|
||||
|
||||
// Remove all view mode classes
|
||||
body.classList.remove('view-original', 'view-final');
|
||||
|
||||
// Add the selected mode class (except for diff which is default)
|
||||
if (mode === 'original') {
|
||||
body.classList.add('view-original');
|
||||
} else if (mode === 'final') {
|
||||
body.classList.add('view-final');
|
||||
}
|
||||
|
||||
// Update button states
|
||||
buttons.forEach(btn => btn.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (sidebar) {
|
||||
initTocNavigation();
|
||||
}
|
||||
|
||||
// Set default TOC level filtering
|
||||
filterTocLevels('3');
|
||||
|
||||
// Setup event listeners with delegation
|
||||
setupEventListeners();
|
||||
|
||||
// Initialize print functionality
|
||||
initPrintSupport();
|
||||
});
|
||||
|
||||
// Modern TOC Navigation with ES6+ patterns
|
||||
function initTocNavigation() {
|
||||
const tocLinks = document.querySelectorAll('.toc a');
|
||||
const contentArea = document.querySelector('.document-content');
|
||||
|
||||
if (!tocLinks.length || !contentArea) return;
|
||||
|
||||
// Smooth scroll with event delegation (better performance)
|
||||
function handleTocClick(e) {
|
||||
if (!e.target.matches('.toc a')) return;
|
||||
|
||||
e.preventDefault();
|
||||
const href = e.target.getAttribute('href');
|
||||
const targetId = href ? href.slice(1) : null;
|
||||
const targetElement = targetId ? document.getElementById(targetId) : null;
|
||||
|
||||
if (!targetElement) return;
|
||||
|
||||
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
|
||||
// Update URL hash without adding to browser history
|
||||
window.location.replace(window.location.pathname + window.location.search + href);
|
||||
|
||||
// Update active state
|
||||
tocLinks.forEach(link => link.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
|
||||
// Close mobile menu if open
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (sidebar && sidebar.classList.contains('mobile-open')) toggleMobileMenu();
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleTocClick);
|
||||
|
||||
// TOC scroll tracking using Intersection Observer API
|
||||
// NOTE: Intersection Observer is the industry-standard, recommended approach for scroll spy
|
||||
// implementations as of 2024. It provides better performance (runs off main thread),
|
||||
// cleaner code, and is supported by all modern browsers. Avoid scroll event listeners
|
||||
// for this use case as they are performance-intensive and require complex calculations.
|
||||
// Find all sections with IDs - much simpler approach
|
||||
const sections = Array.from(contentArea.querySelectorAll('section[id]'));
|
||||
|
||||
|
||||
if (sections.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
function updateActiveTocItem(activeSection) {
|
||||
if (!activeSection || !activeSection.id) return;
|
||||
|
||||
// Clear all active states
|
||||
tocLinks.forEach(link => link.classList.remove('active'));
|
||||
|
||||
// Find and activate the matching TOC link
|
||||
const activeLink = document.querySelector('.toc a[href="#' + activeSection.id + '"]');
|
||||
if (!activeLink) return;
|
||||
|
||||
activeLink.classList.add('active');
|
||||
|
||||
// Auto-scroll TOC to keep active item visible
|
||||
activeLink.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'nearest'
|
||||
});
|
||||
};
|
||||
|
||||
// Create Intersection Observer with industry-standard configuration
|
||||
const observer = new IntersectionObserver(function(entries) {
|
||||
// Find visible sections and update active TOC item
|
||||
const visibleSections = entries.filter(function(entry) { return entry.isIntersecting; });
|
||||
if (visibleSections.length > 0) {
|
||||
// Sort by position in viewport (topmost first)
|
||||
visibleSections.sort(function(a, b) { return a.boundingClientRect.top - b.boundingClientRect.top; });
|
||||
const activeSection = visibleSections[0].target;
|
||||
updateActiveTocItem(activeSection);
|
||||
}
|
||||
}, {
|
||||
root: contentArea,
|
||||
rootMargin: '-20% 0px -60% 0px', // Only consider sections in the middle 20% of viewport
|
||||
threshold: 0.1
|
||||
});
|
||||
|
||||
// Observe all sections
|
||||
sections.forEach(function(section) { observer.observe(section); });
|
||||
|
||||
// Scroll progress bar with throttling for better performance
|
||||
const progressBar = document.querySelector('.scroll-progress-bar');
|
||||
if (progressBar) {
|
||||
let ticking = false;
|
||||
|
||||
function updateScrollProgress() {
|
||||
const scrollTop = contentArea.scrollTop;
|
||||
const scrollHeight = contentArea.scrollHeight;
|
||||
const clientHeight = contentArea.clientHeight;
|
||||
const scrollPercent = scrollHeight > clientHeight
|
||||
? (scrollTop / (scrollHeight - clientHeight)) * 100
|
||||
: 0;
|
||||
progressBar.style.width = Math.min(100, Math.max(0, scrollPercent)) + '%';
|
||||
ticking = false;
|
||||
};
|
||||
|
||||
function onScroll() {
|
||||
if (!ticking) {
|
||||
requestAnimationFrame(updateScrollProgress);
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
|
||||
contentArea.addEventListener('scroll', onScroll, { passive: true });
|
||||
updateScrollProgress(); // Initial call
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle mobile menu with ARIA support
|
||||
function toggleMobileMenu() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const menuToggle = document.querySelector('.mobile-menu-toggle');
|
||||
|
||||
if (!sidebar || !menuToggle) return;
|
||||
|
||||
const isOpen = sidebar.classList.toggle('mobile-open');
|
||||
menuToggle.setAttribute('aria-expanded', isOpen.toString());
|
||||
};
|
||||
|
||||
// Filter TOC levels with modern patterns
|
||||
function filterTocLevels(maxLevel) {
|
||||
const toc = document.querySelector('.toc');
|
||||
if (!toc) return;
|
||||
|
||||
const allItems = toc.querySelectorAll('li');
|
||||
const maxLevelNum = parseInt(maxLevel);
|
||||
const showAll = maxLevel === '6';
|
||||
|
||||
allItems.forEach(function(item) {
|
||||
const link = item.querySelector('a');
|
||||
if (!link) return;
|
||||
|
||||
if (showAll) {
|
||||
item.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate nesting level more efficiently
|
||||
let level = 1;
|
||||
let parent = item.parentElement;
|
||||
while (parent && !parent.classList.contains('toc')) {
|
||||
if (parent.tagName === 'LI') level++;
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
|
||||
item.style.display = level <= maxLevelNum ? '' : 'none';
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// Setup event listeners with delegation
|
||||
function setupEventListeners() {
|
||||
// TOC level selector
|
||||
const tocLevelSelect = document.getElementById('toc-level');
|
||||
if (tocLevelSelect) tocLevelSelect.addEventListener('change', function(e) {
|
||||
filterTocLevels(e.target.value);
|
||||
});
|
||||
|
||||
// Mobile menu toggle
|
||||
const menuToggle = document.querySelector('.mobile-menu-toggle');
|
||||
if (menuToggle) menuToggle.addEventListener('click', toggleMobileMenu);
|
||||
|
||||
// Close mobile menu on outside click
|
||||
document.addEventListener('click', function(e) {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const menuToggle = document.querySelector('.mobile-menu-toggle');
|
||||
|
||||
if (sidebar && sidebar.classList.contains('mobile-open') &&
|
||||
!sidebar.contains(e.target) &&
|
||||
(!menuToggle || !menuToggle.contains(e.target))) {
|
||||
toggleMobileMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle escape key to close mobile menu
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (sidebar && sidebar.classList.contains('mobile-open')) {
|
||||
toggleMobileMenu();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Initialize print support and draft status
|
||||
function initPrintSupport() {
|
||||
// Handle draft status for revisions containing tilde (~)
|
||||
const revision = document.querySelector('meta[name="revision"]');
|
||||
const generationTime = document.querySelector('meta[name="generation_time"]');
|
||||
|
||||
if (revision && generationTime) {
|
||||
const revisionValue = revision.getAttribute('content');
|
||||
const timeValue = generationTime.getAttribute('content');
|
||||
|
||||
if (revisionValue && revisionValue.includes('~') && timeValue) {
|
||||
const draftElements = document.querySelectorAll('.draft-status');
|
||||
draftElements.forEach(function(element) {
|
||||
element.textContent = ' [DRAFT Generated at ' + timeValue + ']';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export functions for global access (maintaining backward compatibility)
|
||||
window.toggleMobileMenu = toggleMobileMenu;
|
||||
window.filterTocLevels = filterTocLevels;
|
||||
</script>
|
||||
56
zddc/internal/convert/templates/letter.html
Normal file
56
zddc/internal/convert/templates/letter.html
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
$_head()$
|
||||
|
||||
<body class="doc-letter$if(numbering)$ numbered$endif$">
|
||||
<!-- Letter layout: single column, no TOC sidebar -->
|
||||
<main class="content-wrapper" role="main">
|
||||
<div class="content-page">
|
||||
<!-- Letterhead -->
|
||||
<header class="document-header">
|
||||
<div class="header-content">
|
||||
$if(client)$$if(project)$
|
||||
<div class="header-line client-project">
|
||||
$client$ - $project$$if(project_number)$ ($project_number$)$endif$
|
||||
</div>
|
||||
$endif$$endif$
|
||||
$if(title)$
|
||||
<div class="document-title">$title$</div>
|
||||
$endif$
|
||||
<div class="document-meta">
|
||||
$if(date)$<span class="date">$date$</span>$endif$
|
||||
$if(tracking_number)$<span class="tracking-number">$tracking_number$</span>$endif$
|
||||
$if(revision)$<span class="revision">Revision: $revision$</span>$endif$
|
||||
$if(status)$<span class="status">Status: $status$</span>$endif$
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Print-only header -->
|
||||
<div class="print-header">
|
||||
$if(custom_header)$
|
||||
$custom_header$
|
||||
$else$
|
||||
$if(client)$$if(project)$
|
||||
<div class="header-line client-project">$client$ - $project$$if(project_number)$ ($project_number$)$endif$</div>
|
||||
$endif$$endif$
|
||||
$if(title)$<div class="header-line document-title">$title$</div>$endif$
|
||||
$endif$
|
||||
</div>
|
||||
|
||||
<!-- Print-only footer -->
|
||||
<div class="print-footer">
|
||||
<div class="footer-left">
|
||||
$if(tracking_number)$$tracking_number$$endif$$if(revision)$ Revision: $revision$$endif$$if(status)$ Status: $status$$endif$
|
||||
</div>
|
||||
<div class="footer-right">Page <span class="page-number"></span></div>
|
||||
</div>
|
||||
|
||||
<article class="document-content">
|
||||
$body$
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
$_scripts()$
|
||||
</body>
|
||||
</html>
|
||||
9
zddc/internal/convert/templates/report.html
Normal file
9
zddc/internal/convert/templates/report.html
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
$_head()$
|
||||
|
||||
<body class="doc-report$if(numbering)$ numbered$endif$">
|
||||
$_doc()$
|
||||
$_scripts()$
|
||||
</body>
|
||||
</html>
|
||||
9
zddc/internal/convert/templates/specification.html
Normal file
9
zddc/internal/convert/templates/specification.html
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
$_head()$
|
||||
|
||||
<body class="doc-specification$if(numbering)$ numbered$endif$">
|
||||
$_doc()$
|
||||
$_scripts()$
|
||||
</body>
|
||||
</html>
|
||||
71
zddc/internal/convert/templatesync_test.go
Normal file
71
zddc/internal/convert/templatesync_test.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package convert
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// canonicalTemplatesDir is the single source of truth for the conversion
|
||||
// templates: /pandoc/templates/, relative to this package
|
||||
// (zddc/internal/convert → ../../../pandoc/templates).
|
||||
const canonicalTemplatesDir = "../../../pandoc/templates"
|
||||
|
||||
// TestEmbeddedTemplatesMatchSource guards against drift between the embedded
|
||||
// templates/ (a build artifact, synced by shared/build-lib.sh:
|
||||
// sync_pandoc_templates) and the canonical pandoc/templates/. If this fails,
|
||||
// re-run ./build (or copy pandoc/templates/* into this package's templates/).
|
||||
func TestEmbeddedTemplatesMatchSource(t *testing.T) {
|
||||
srcEntries, err := os.ReadDir(canonicalTemplatesDir)
|
||||
if err != nil {
|
||||
t.Fatalf("read canonical templates dir %q: %v", canonicalTemplatesDir, err)
|
||||
}
|
||||
|
||||
embedded := embeddedTemplateFiles()
|
||||
srcCount := 0
|
||||
for _, e := range srcEntries {
|
||||
if e.IsDir() || filepath.Ext(e.Name()) != ".html" {
|
||||
continue
|
||||
}
|
||||
srcCount++
|
||||
want, err := os.ReadFile(filepath.Join(canonicalTemplatesDir, e.Name()))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", e.Name(), err)
|
||||
}
|
||||
got, ok := embedded[e.Name()]
|
||||
if !ok {
|
||||
t.Errorf("embedded templates/ is missing %s (run ./build to sync)", e.Name())
|
||||
continue
|
||||
}
|
||||
if string(got) != string(want) {
|
||||
t.Errorf("embedded %s differs from pandoc/templates/%s (run ./build to sync)", e.Name(), e.Name())
|
||||
}
|
||||
}
|
||||
|
||||
if srcCount != len(embedded) {
|
||||
t.Errorf("template count mismatch: canonical=%d embedded=%d (stale file in one tree?)", srcCount, len(embedded))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultTemplateSet checks the doctype fallback + that partials ride along.
|
||||
func TestDefaultTemplateSet(t *testing.T) {
|
||||
for _, name := range EmbeddedTemplateNames() {
|
||||
ts := DefaultTemplateSet(name)
|
||||
if ts.Name != name+".html" {
|
||||
t.Errorf("DefaultTemplateSet(%q).Name = %q, want %q.html", name, ts.Name, name)
|
||||
}
|
||||
if ts.Files[ts.Name] == nil {
|
||||
t.Errorf("DefaultTemplateSet(%q) Files missing primary %q", name, ts.Name)
|
||||
}
|
||||
if ts.Files["_head.html"] == nil {
|
||||
t.Errorf("DefaultTemplateSet(%q) Files missing _head.html partial", name)
|
||||
}
|
||||
}
|
||||
// Unknown / empty fall back to the default doctype.
|
||||
if ts := DefaultTemplateSet("nope"); ts.Name != DefaultTemplateName+".html" {
|
||||
t.Errorf("unknown doctype fell back to %q, want %q.html", ts.Name, DefaultTemplateName)
|
||||
}
|
||||
if ts := DefaultTemplateSet(""); ts.Name != DefaultTemplateName+".html" {
|
||||
t.Errorf("empty doctype fell back to %q, want %q.html", ts.Name, DefaultTemplateName)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -120,6 +120,15 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
|||
continue
|
||||
}
|
||||
|
||||
// Reserved config — the .zddc.d sidecar reserve and the .zddc.zip
|
||||
// config bundle — is surfaced only to an active (elevated) admin over
|
||||
// this directory. Everyone else can't open it anyway (dispatch 404s
|
||||
// the access), so listing the names would just advertise hidden
|
||||
// config. The plain .zddc file stays visible/editable (handled below).
|
||||
if (strings.EqualFold(name, ".zddc.d") || strings.EqualFold(name, ".zddc.zip")) && !parentActiveAdmin {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -48,35 +48,50 @@ var convertSF singleflightGroup
|
|||
// runner itself enforces a finer-grained timeout on the container.
|
||||
const convertTimeout = 90 * time.Second
|
||||
|
||||
// convertSourceExts maps a requested target extension to the candidate source
|
||||
// extensions in precedence order — the first existing real sibling wins. The
|
||||
// matrix: md↔docx↔html all directions, plus md→pdf (PDF stays markdown-only).
|
||||
var convertSourceExts = map[string][]string{
|
||||
"md": {"docx", "html"},
|
||||
"docx": {"md", "html"},
|
||||
"html": {"md", "docx"},
|
||||
"pdf": {"md"},
|
||||
}
|
||||
|
||||
// RecognizeVirtualConvert reports whether urlPath names a virtual
|
||||
// "<file>.<format>" — a rendered form of a sibling markdown source.
|
||||
// Returns (mdAbsPath, format, true) when <file>.md exists on disk and
|
||||
// the requested extension is one of docx / html / pdf. The caller
|
||||
// (the dispatcher) only invokes this when a stat on the requested
|
||||
// path itself fails — a real on-disk file always wins.
|
||||
// "<file>.<format>" — a rendered form of a sibling source document in a
|
||||
// different format. Returns (srcAbsPath, format, true) when the requested
|
||||
// extension is convertible (md/docx/html/pdf) and a sibling source exists on
|
||||
// disk, picked by convertSourceExts precedence. The caller (the dispatcher) only
|
||||
// invokes this when a stat on the requested path itself fails — a real on-disk
|
||||
// file always wins.
|
||||
//
|
||||
// A virtual file URL means `<a href="…/foo.docx">` works without any
|
||||
// query-string handling, and a script's `curl -O …/foo.pdf` writes the
|
||||
// expected filename.
|
||||
func RecognizeVirtualConvert(fsRoot, urlPath string) (mdAbs, format string, ok bool) {
|
||||
// query-string handling, and a script's `curl -O …/foo.md` writes the expected
|
||||
// filename.
|
||||
func RecognizeVirtualConvert(fsRoot, urlPath string) (srcAbs, format string, ok bool) {
|
||||
lower := strings.ToLower(urlPath)
|
||||
for _, ext := range []string{".docx", ".html", ".pdf"} {
|
||||
for target, sources := range convertSourceExts {
|
||||
ext := "." + target
|
||||
if !strings.HasSuffix(lower, ext) {
|
||||
continue
|
||||
continue // distinct suffixes — at most one target matches
|
||||
}
|
||||
base := urlPath[:len(urlPath)-len(ext)]
|
||||
if base == "" || strings.HasSuffix(base, "/") {
|
||||
continue
|
||||
return "", "", false
|
||||
}
|
||||
rel := strings.Trim(base, "/") + ".md"
|
||||
abs := filepath.Join(fsRoot, filepath.FromSlash(rel))
|
||||
// Path containment.
|
||||
if abs != fsRoot && !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) {
|
||||
continue
|
||||
}
|
||||
if info, err := os.Stat(abs); err == nil && !info.IsDir() {
|
||||
return abs, ext[1:], true
|
||||
stem := strings.Trim(base, "/")
|
||||
for _, srcExt := range sources {
|
||||
abs := filepath.Join(fsRoot, filepath.FromSlash(stem+"."+srcExt))
|
||||
// Path containment.
|
||||
if abs != fsRoot && !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) {
|
||||
continue
|
||||
}
|
||||
if info, err := os.Stat(abs); err == nil && !info.IsDir() {
|
||||
return abs, target, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
|
@ -87,9 +102,9 @@ func RecognizeVirtualConvert(fsRoot, urlPath string) (mdAbs, format string, ok b
|
|||
func ServeConverted(cfg config.Config, w http.ResponseWriter, r *http.Request, srcAbs, format string, chain zddc.PolicyChain) {
|
||||
format = strings.ToLower(strings.TrimSpace(format))
|
||||
switch format {
|
||||
case "docx", "html", "pdf":
|
||||
case "md", "docx", "html", "pdf":
|
||||
default:
|
||||
http.Error(w, "Bad Request — convert must be docx, html, or pdf", http.StatusBadRequest)
|
||||
http.Error(w, "Bad Request — convert must be md, docx, html, or pdf", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -135,7 +150,7 @@ func ServeConverted(cfg config.Config, w http.ResponseWriter, r *http.Request, s
|
|||
// Slow path: convert, cache, serve. Singleflight collapses
|
||||
// concurrent requests for the same target.
|
||||
_, err = convertSF.Do(cacheAbs, func() (any, error) {
|
||||
return nil, buildAndStore(r.Context(), srcAbs, srcInfo, cacheDir, cacheAbs, format, base, chain)
|
||||
return nil, buildAndStore(r.Context(), cfg.Root, srcAbs, srcInfo, cacheDir, cacheAbs, format, base, chain)
|
||||
})
|
||||
if err != nil {
|
||||
mapConvertError(w, err, format)
|
||||
|
|
@ -148,7 +163,7 @@ func ServeConverted(cfg config.Config, w http.ResponseWriter, r *http.Request, s
|
|||
// buildAndStore reads the source, runs the conversion, atomically
|
||||
// writes the result, and syncs the cached mtime to the source mtime.
|
||||
// Returns the cached file's absolute path on success.
|
||||
func buildAndStore(ctx context.Context, srcAbs string, srcInfo os.FileInfo, cacheDir, cacheAbs, format, base string, chain zddc.PolicyChain) error {
|
||||
func buildAndStore(ctx context.Context, fsRoot, srcAbs string, srcInfo os.FileInfo, cacheDir, cacheAbs, format, base string, chain zddc.PolicyChain) error {
|
||||
source, err := os.ReadFile(srcAbs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read source: %w", err)
|
||||
|
|
@ -159,17 +174,13 @@ func buildAndStore(ctx context.Context, srcAbs string, srcInfo os.FileInfo, cach
|
|||
ctx, cancel := context.WithTimeout(ctx, convertTimeout)
|
||||
defer cancel()
|
||||
|
||||
var out []byte
|
||||
switch format {
|
||||
case "docx":
|
||||
out, err = convert.ToDocx(ctx, source, meta)
|
||||
case "html":
|
||||
out, err = convert.ToHTML(ctx, source, meta)
|
||||
case "pdf":
|
||||
out, err = convert.ToPDF(ctx, source, meta)
|
||||
default:
|
||||
return fmt.Errorf("unsupported format %q", format)
|
||||
// Source format is the on-disk extension; target is the requested format.
|
||||
from := strings.TrimPrefix(strings.ToLower(filepath.Ext(srcAbs)), ".")
|
||||
var ts convert.TemplateSet
|
||||
if format == "html" || format == "pdf" {
|
||||
ts = resolveTemplateSet(fsRoot, filepath.Dir(srcAbs), source)
|
||||
}
|
||||
out, err := convert.Convert(ctx, from, format, source, meta, ts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -290,20 +301,21 @@ func contentDispositionFor(format, base string) string {
|
|||
return fmt.Sprintf(`inline; filename="%s.%s"`, base, format)
|
||||
}
|
||||
|
||||
// purgeConverted removes the cached .zddc.d/converted/<base>.{docx,html,pdf}
|
||||
// sidecars for an .md source. Called from the file API after a
|
||||
// successful PUT/DELETE/MOVE so the next GET ?convert= regenerates.
|
||||
// Best-effort: errors (including "directory doesn't exist") are
|
||||
// swallowed. Non-.md sources are a no-op so this is safe to call
|
||||
// purgeConverted removes the cached .zddc.d/converted/<base>.{md,docx,html,pdf}
|
||||
// sidecars for a convertible source. Called from the file API after a successful
|
||||
// PUT/DELETE/MOVE so the next virtual-convert GET regenerates. Best-effort:
|
||||
// errors (including "directory doesn't exist") are swallowed. Sources whose
|
||||
// extension isn't convertible are a no-op, so this is safe to call
|
||||
// unconditionally after any write.
|
||||
func purgeConverted(srcAbs string) {
|
||||
if !strings.HasSuffix(strings.ToLower(srcAbs), ".md") {
|
||||
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(srcAbs)), ".")
|
||||
if _, ok := convertSourceExts[ext]; !ok {
|
||||
return
|
||||
}
|
||||
dir := filepath.Dir(srcAbs)
|
||||
base := strings.TrimSuffix(filepath.Base(srcAbs), filepath.Ext(srcAbs))
|
||||
for _, ext := range []string{".docx", ".html", ".pdf"} {
|
||||
_ = os.Remove(filepath.Join(dir, ReservedSidecar, "converted", base+ext))
|
||||
for target := range convertSourceExts {
|
||||
_ = os.Remove(filepath.Join(dir, ReservedSidecar, "converted", base+"."+target))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
65
zddc/internal/handler/converthandler_test.go
Normal file
65
zddc/internal/handler/converthandler_test.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRecognizeVirtualConvert_MatrixAndPrecedence(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
write := func(rel string) {
|
||||
p := filepath.Join(root, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sources on disk: doc.md, only.docx, both.md + both.docx, page.html.
|
||||
write("doc.md")
|
||||
write("only.docx")
|
||||
write("both.md")
|
||||
write("both.docx")
|
||||
write("page.html")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
wantOK bool
|
||||
wantSrcExt string
|
||||
wantFormat string
|
||||
}{
|
||||
{"md→docx", "/doc.docx", true, ".md", "docx"},
|
||||
{"md→html", "/doc.html", true, ".md", "html"},
|
||||
{"md→pdf", "/doc.pdf", true, ".md", "pdf"},
|
||||
{"docx→md (only docx present)", "/only.md", true, ".docx", "md"},
|
||||
{"docx→html (only docx present)", "/only.html", true, ".docx", "html"},
|
||||
{"docx has no pdf source", "/only.pdf", false, "", ""},
|
||||
{"both present, html prefers md source", "/both.html", true, ".md", "html"},
|
||||
{"html→md", "/page.md", true, ".html", "md"},
|
||||
{"html→docx", "/page.docx", true, ".html", "docx"},
|
||||
{"no source at all", "/missing.html", false, "", ""},
|
||||
{"directory url ignored", "/doc/", false, "", ""},
|
||||
{"non-convertible target", "/doc.txt", false, "", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
src, format, ok := RecognizeVirtualConvert(root, c.url)
|
||||
if ok != c.wantOK {
|
||||
t.Fatalf("ok=%v want %v (src=%q format=%q)", ok, c.wantOK, src, format)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if format != c.wantFormat {
|
||||
t.Errorf("format=%q want %q", format, c.wantFormat)
|
||||
}
|
||||
if filepath.Ext(src) != c.wantSrcExt {
|
||||
t.Errorf("source ext=%q want %q (src=%q)", filepath.Ext(src), c.wantSrcExt, src)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
143
zddc/internal/handler/converttemplate.go
Normal file
143
zddc/internal/handler/converttemplate.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/convert"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// resolveTemplateSet builds the convert.TemplateSet for an HTML/PDF render of a
|
||||
// markdown source. It starts from the baked-in defaults for the doctype named in
|
||||
// the document's `template:` front matter (default "report"), then overlays any
|
||||
// per-project / per-party overrides found in the `.zddc.d/templates/` cascade.
|
||||
//
|
||||
// The cascade walks from the document's directory up to fsRoot; a nearer level
|
||||
// (e.g. working/<party>/.zddc.d/templates/) overrides a farther one (e.g.
|
||||
// working/.zddc.d/templates/), which overrides the embedded default. Overrides
|
||||
// may replace the named doctype template, any shared partial (_head.html, …), or
|
||||
// introduce an entirely new doctype the front matter names.
|
||||
func resolveTemplateSet(fsRoot, docDir string, source []byte) convert.TemplateSet {
|
||||
name := templateNameFromFrontMatter(source) // "" when absent/invalid
|
||||
ts := convert.DefaultTemplateSet(name) // primary falls back to report
|
||||
|
||||
dirs := templateCascadeDirs(fsRoot, docDir)
|
||||
|
||||
// If the named doctype isn't a baked-in default but an override provides it,
|
||||
// adopt the override as the primary template.
|
||||
if name != "" {
|
||||
primary := name + ".html"
|
||||
if b := firstTemplateOverride(dirs, primary); b != nil {
|
||||
ts.Name = primary
|
||||
ts.Files[primary] = b
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay overrides for every file in the set (primary + partials).
|
||||
for fname := range ts.Files {
|
||||
if b := firstTemplateOverride(dirs, fname); b != nil {
|
||||
ts.Files[fname] = b
|
||||
}
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
// templateCascadeDirs returns the `<level>/.zddc.d/templates` directories from
|
||||
// docDir up to fsRoot, nearest (most specific) first. Levels outside fsRoot are
|
||||
// skipped (path-containment guard).
|
||||
func templateCascadeDirs(fsRoot, docDir string) []string {
|
||||
root := filepath.Clean(fsRoot)
|
||||
d := filepath.Clean(docDir)
|
||||
var dirs []string
|
||||
for {
|
||||
if d == root || strings.HasPrefix(d, root+string(filepath.Separator)) {
|
||||
dirs = append(dirs, filepath.Join(d, ReservedSidecar, "templates"))
|
||||
}
|
||||
if d == root {
|
||||
break
|
||||
}
|
||||
parent := filepath.Dir(d)
|
||||
if parent == d {
|
||||
break
|
||||
}
|
||||
d = parent
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
// firstTemplateOverride returns the bytes of the first existing `<dir>/<name>`
|
||||
// across dirs (nearest first), or nil. name is reduced to a base name so it can
|
||||
// never escape the templates dir.
|
||||
func firstTemplateOverride(dirs []string, name string) []byte {
|
||||
base := filepath.Base(name)
|
||||
if base == "" || base == "." || base == ".." {
|
||||
return nil
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
if b, err := os.ReadFile(filepath.Join(dir, base)); err == nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// templateNameFromFrontMatter extracts a sanitized `template:` doctype name from
|
||||
// a markdown document's leading YAML front matter. Returns "" when there is no
|
||||
// front matter, no `template:` field, or the value isn't a safe bare name.
|
||||
func templateNameFromFrontMatter(source []byte) string {
|
||||
fm := leadingFrontMatter(source)
|
||||
if fm == nil {
|
||||
return ""
|
||||
}
|
||||
var doc struct {
|
||||
Template string `yaml:"template"`
|
||||
}
|
||||
if err := yaml.Unmarshal(fm, &doc); err != nil {
|
||||
return ""
|
||||
}
|
||||
return sanitizeTemplateName(doc.Template)
|
||||
}
|
||||
|
||||
// leadingFrontMatter returns the YAML between an opening `---` line (which must
|
||||
// be the very first line) and the next `---` or `...` line, or nil if absent.
|
||||
func leadingFrontMatter(src []byte) []byte {
|
||||
s := bytes.TrimPrefix(src, []byte{0xEF, 0xBB, 0xBF}) // strip a UTF-8 BOM
|
||||
if !bytes.HasPrefix(s, []byte("---\n")) && !bytes.HasPrefix(s, []byte("---\r\n")) {
|
||||
return nil
|
||||
}
|
||||
lines := bytes.Split(s, []byte("\n"))
|
||||
var buf bytes.Buffer
|
||||
for i := 1; i < len(lines); i++ {
|
||||
ln := bytes.TrimRight(lines[i], "\r")
|
||||
if bytes.Equal(ln, []byte("---")) || bytes.Equal(ln, []byte("...")) {
|
||||
return buf.Bytes()
|
||||
}
|
||||
buf.Write(lines[i])
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
return nil // unterminated block
|
||||
}
|
||||
|
||||
// sanitizeTemplateName allows only a bare basename of [A-Za-z0-9_-] so a
|
||||
// `template:` value can't traverse paths or name a partial. Returns "" if the
|
||||
// value is empty or contains any other character.
|
||||
func sanitizeTemplateName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r == '-' || r == '_':
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r >= '0' && r <= '9':
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
95
zddc/internal/handler/converttemplate_test.go
Normal file
95
zddc/internal/handler/converttemplate_test.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTemplateNameFromFrontMatter(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
src string
|
||||
want string
|
||||
}{
|
||||
{"plain", "---\ntemplate: specification\n---\n\n# H\n", "specification"},
|
||||
{"quoted", "---\ntemplate: \"letter\"\n---\n", "letter"},
|
||||
{"absent", "---\ntitle: X\n---\n", ""},
|
||||
{"no-frontmatter", "# Just a heading\n", ""},
|
||||
{"traversal-rejected", "---\ntemplate: ../../etc/passwd\n---\n", ""},
|
||||
{"slash-rejected", "---\ntemplate: a/b\n---\n", ""},
|
||||
{"crlf", "---\r\ntemplate: report\r\n---\r\n", "report"},
|
||||
{"dots-terminator", "---\ntemplate: letter\n...\nbody", "letter"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := templateNameFromFrontMatter([]byte(c.src)); got != c.want {
|
||||
t.Errorf("templateNameFromFrontMatter(%q) = %q, want %q", c.src, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTemplateSet_DefaultsAndCascade(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
party := filepath.Join(root, "working", "AcmeCo")
|
||||
if err := os.MkdirAll(party, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// No overrides, no front matter → embedded report, partials present.
|
||||
ts := resolveTemplateSet(root, party, []byte("# Hi\n"))
|
||||
if ts.Name != "report.html" {
|
||||
t.Fatalf("default doctype: got %q, want report.html", ts.Name)
|
||||
}
|
||||
if ts.Files["_head.html"] == nil {
|
||||
t.Errorf("partial _head.html missing from default set")
|
||||
}
|
||||
embeddedReport := string(ts.Files["report.html"])
|
||||
|
||||
// Front matter selects a doctype.
|
||||
if ts := resolveTemplateSet(root, party, []byte("---\ntemplate: letter\n---\n")); ts.Name != "letter.html" {
|
||||
t.Errorf("front-matter doctype: got %q, want letter.html", ts.Name)
|
||||
}
|
||||
|
||||
// Project-global override at <root>/.zddc.d/templates/report.html.
|
||||
projDir := filepath.Join(root, ".zddc.d", "templates")
|
||||
if err := os.MkdirAll(projDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(projDir, "report.html"), []byte("PROJECT-REPORT"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts = resolveTemplateSet(root, party, []byte("# Hi\n"))
|
||||
if string(ts.Files["report.html"]) != "PROJECT-REPORT" {
|
||||
t.Errorf("project override not applied: %q", ts.Files["report.html"])
|
||||
}
|
||||
if string(ts.Files["report.html"]) == embeddedReport {
|
||||
t.Errorf("override identical to embedded — overlay didn't happen")
|
||||
}
|
||||
|
||||
// Party override wins over project-global.
|
||||
partyDir := filepath.Join(party, ".zddc.d", "templates")
|
||||
if err := os.MkdirAll(partyDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(partyDir, "report.html"), []byte("PARTY-REPORT"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts = resolveTemplateSet(root, party, []byte("# Hi\n"))
|
||||
if string(ts.Files["report.html"]) != "PARTY-REPORT" {
|
||||
t.Errorf("party override should win: got %q", ts.Files["report.html"])
|
||||
}
|
||||
|
||||
// A brand-new doctype provided only as an override is adopted as primary.
|
||||
if err := os.WriteFile(filepath.Join(partyDir, "memo.html"), []byte("MEMO-TEMPLATE"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts = resolveTemplateSet(root, party, []byte("---\ntemplate: memo\n---\n"))
|
||||
if ts.Name != "memo.html" || string(ts.Files["memo.html"]) != "MEMO-TEMPLATE" {
|
||||
t.Errorf("custom doctype override: name=%q bytes=%q", ts.Name, ts.Files["memo.html"])
|
||||
}
|
||||
if ts.Files["_head.html"] == nil {
|
||||
t.Errorf("partials should still ride along with a custom doctype override")
|
||||
}
|
||||
}
|
||||
|
|
@ -497,10 +497,8 @@ func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request)
|
|||
http.Error(w, msg, status)
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(cleanURL, "/") {
|
||||
http.Error(w, "DELETE must target a file, not a directory", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// (Directory vs file is decided by stat below; the client sends a folder
|
||||
// DELETE with a trailing slash. A directory delete is admin-gated.)
|
||||
|
||||
// Register rows are real files — a DELETE targets them directly with
|
||||
// the normal ACL gate. (Deleting an ssr/<party>.yaml de-registers the
|
||||
|
|
@ -517,7 +515,32 @@ func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request)
|
|||
return
|
||||
}
|
||||
if info.IsDir() {
|
||||
http.Error(w, "Conflict — DELETE of directories is not supported", http.StatusConflict)
|
||||
// Directory delete is recursive (os.RemoveAll), which bypasses the
|
||||
// per-file WORM/delete gates protecting the contents — so it's
|
||||
// admin-only: an active admin over this subtree (a root admin, or a
|
||||
// subtree admin within scope). This is the "admin mode exists for
|
||||
// restructuring" capability.
|
||||
p := PrincipalFromContext(r)
|
||||
if !zddc.IsSubtreeAdmin(cfg.Root, abs, p) {
|
||||
http.Error(w, "Forbidden — deleting a directory requires admin authority over it", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := os.RemoveAll(abs); err != nil {
|
||||
auditFile(r, "delete", cleanURL+" (recursive)", http.StatusInternalServerError, 0, err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
etagCacheM.Delete(abs)
|
||||
purgeConverted(abs)
|
||||
w.Header().Set("X-ZDDC-Source", "fileapi:delete-dir")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
auditFile(r, "delete", cleanURL+" (recursive)", http.StatusNoContent, 0, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// File delete: a trailing slash is a directory URL — reject the mismatch.
|
||||
if strings.HasSuffix(cleanURL, "/") {
|
||||
http.Error(w, "DELETE must target a file, not a directory", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -571,10 +594,9 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
http.Error(w, msg, status)
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(srcURL, "/") {
|
||||
http.Error(w, "MOVE source must be a file path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// (A trailing slash on src/dst signals a directory target; we no longer
|
||||
// reject it here — file-vs-directory is decided by stat below, and a
|
||||
// directory move is admin-gated.)
|
||||
|
||||
dstHeader := r.Header.Get(headerDestination)
|
||||
if dstHeader == "" {
|
||||
|
|
@ -590,10 +612,6 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
http.Error(w, "destination: "+msg, status)
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(dstURL, "/") {
|
||||
http.Error(w, "MOVE destination must be a file path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// A move whose destination introduces a new party folder under a
|
||||
// party_source peer requires the party to be registered.
|
||||
if rejected, why, _ := partySourceGate(cfg.Root, dstAbs); rejected {
|
||||
|
|
@ -619,8 +637,28 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
return
|
||||
}
|
||||
if srcInfo.IsDir() {
|
||||
http.Error(w, "Conflict — MOVE of directories is not supported", http.StatusConflict)
|
||||
isDir := srcInfo.IsDir()
|
||||
if isDir {
|
||||
// Directory moves relocate the whole subtree with one os.Rename,
|
||||
// which sidesteps the per-file WORM/ACL gates protecting the
|
||||
// descendants — so they're admin-only: an active admin over BOTH the
|
||||
// source subtree and the destination's parent (a root admin covers
|
||||
// all; a subtree admin within their own scope). This is the "admin
|
||||
// mode exists for restructuring" capability.
|
||||
p := PrincipalFromContext(r)
|
||||
if !zddc.IsSubtreeAdmin(cfg.Root, srcAbs, p) ||
|
||||
!zddc.IsSubtreeAdmin(cfg.Root, filepath.Dir(dstAbs), p) {
|
||||
http.Error(w, "Forbidden — moving a directory requires admin authority over the source and destination", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
// Refuse moving a directory into itself or one of its descendants.
|
||||
if dstAbs == srcAbs || strings.HasPrefix(dstAbs, srcAbs+string(filepath.Separator)) {
|
||||
http.Error(w, "Conflict — cannot move a directory into itself", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
} else if strings.HasSuffix(dstURL, "/") {
|
||||
// A file move must target a file path, not a directory URL.
|
||||
http.Error(w, "destination: MOVE of a file must target a file path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -643,8 +681,12 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
if !authorizeAction(cfg, w, r, dstAbs, dstURL, policy.ActionCreate) {
|
||||
return
|
||||
}
|
||||
if !checkIfMatch(w, r, srcAbs) {
|
||||
return
|
||||
// If-Match concurrency applies to the source bytes — only meaningful for
|
||||
// a file. A directory carries no ETag, so skip the precondition.
|
||||
if !isDir {
|
||||
if !checkIfMatch(w, r, srcAbs) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure destination's canonical ancestors are created (with auto-own
|
||||
|
|
|
|||
|
|
@ -284,11 +284,34 @@ func TestFileAPI_DeleteMissing404(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestFileAPI_DeleteDirectoryConflict(t *testing.T) {
|
||||
// Directory delete is admin-only and recursive. A non-admin (elevated but not
|
||||
// named in admins:) is forbidden; an admin recursively removes the subtree.
|
||||
func TestFileAPI_DeleteDirectoryNonAdminForbidden(t *testing.T) {
|
||||
_, do, _ := fileAPITestSetup(t, []string{"Incoming/sub"}, nil)
|
||||
rec := do(http.MethodDelete, "/Incoming/sub", "alice@example.com", nil, nil)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("want 409, got %d: %s", rec.Code, rec.Body.String())
|
||||
rec := do(http.MethodDelete, "/Incoming/sub/", "alice@example.com", nil, nil)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileAPI_DeleteDirectoryAdminRecursive(t *testing.T) {
|
||||
_, do, root := fileAPITestSetup(t, []string{"Incoming/sub"}, map[string]string{
|
||||
"Incoming/sub/a.txt": "one",
|
||||
"Incoming/sub/deep/b.txt": "two",
|
||||
})
|
||||
// Promote alice to root admin.
|
||||
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
||||
[]byte("acl:\n permissions:\n \"*@example.com\": rwcd\nadmins:\n - alice@example.com\n"), 0o644); err != nil {
|
||||
t.Fatalf("rewrite root .zddc: %v", err)
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
rec := do(http.MethodDelete, "/Incoming/sub/", "alice@example.com", nil, nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("want 204, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "Incoming/sub")); !os.IsNotExist(err) {
|
||||
t.Fatalf("dir should be gone recursively, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -319,6 +342,63 @@ func TestFileAPI_MoveRenames(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// Directory move is admin-only and relocates the whole subtree. A non-admin
|
||||
// (elevated but not in admins:) is forbidden; an admin renames/relocates it.
|
||||
func TestFileAPI_MoveDirectoryNonAdminForbidden(t *testing.T) {
|
||||
_, do, _ := fileAPITestSetup(t, []string{"Docs/sub"}, nil)
|
||||
rec := do(http.MethodPost, "/Docs/sub/", "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "move",
|
||||
"X-ZDDC-Destination": "/Docs/renamed/",
|
||||
})
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileAPI_MoveDirectoryAdmin(t *testing.T) {
|
||||
_, do, root := fileAPITestSetup(t, []string{"Docs/sub"}, map[string]string{
|
||||
"Docs/sub/a.txt": "x",
|
||||
"Docs/sub/deep/b.txt": "y",
|
||||
})
|
||||
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
||||
[]byte("acl:\n permissions:\n \"*@example.com\": rwcd\nadmins:\n - alice@example.com\n"), 0o644); err != nil {
|
||||
t.Fatalf("rewrite root .zddc: %v", err)
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
rec := do(http.MethodPost, "/Docs/sub/", "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "move",
|
||||
"X-ZDDC-Destination": "/Docs/renamed/",
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "Docs/sub")); !os.IsNotExist(err) {
|
||||
t.Fatalf("source dir should be gone, err=%v", err)
|
||||
}
|
||||
if b, err := os.ReadFile(filepath.Join(root, "Docs/renamed/deep/b.txt")); err != nil || string(b) != "y" {
|
||||
t.Fatalf("moved subtree content missing: b=%q err=%v", b, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Refuse moving a directory into itself or a descendant (would orphan the tree).
|
||||
func TestFileAPI_MoveDirectoryIntoItself(t *testing.T) {
|
||||
_, do, root := fileAPITestSetup(t, []string{"Docs/sub"}, nil)
|
||||
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
||||
[]byte("acl:\n permissions:\n \"*@example.com\": rwcd\nadmins:\n - alice@example.com\n"), 0o644); err != nil {
|
||||
t.Fatalf("rewrite root .zddc: %v", err)
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
rec := do(http.MethodPost, "/Docs/sub/", "alice@example.com", nil, map[string]string{
|
||||
"X-ZDDC-Op": "move",
|
||||
"X-ZDDC-Destination": "/Docs/sub/inner/",
|
||||
})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("want 409, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileAPI_MoveDestinationExistsConflict(t *testing.T) {
|
||||
_, do, _ := fileAPITestSetup(t, nil, map[string]string{
|
||||
"Incoming/a.txt": "a",
|
||||
|
|
|
|||
|
|
@ -159,6 +159,10 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
|
|||
// any of the default-spec virtual-fallback shapes (per-party
|
||||
// mdl/rsk, per-party SSR schema, project-level virtual specs).
|
||||
specEligible := func(specAbs string) bool {
|
||||
dir, base := filepath.Split(specAbs)
|
||||
if fileExists(filepath.Join(filepath.Clean(dir), ".zddc.d", base)) {
|
||||
return true
|
||||
}
|
||||
if fileExists(specAbs) {
|
||||
return true
|
||||
}
|
||||
|
|
@ -542,13 +546,19 @@ func serveFormUpdate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
|
|||
// --- Helpers -----------------------------------------------------------------
|
||||
|
||||
func loadFormSpec(fsRoot, path string) (*FormSpec, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
// Prefer the supporting-files reserve: a spec at <dir>/.zddc.d/form.yaml
|
||||
// takes precedence over the legacy <dir>/form.yaml. `path` is the legacy
|
||||
// <dir>/form.yaml location the callers build.
|
||||
dir, base := filepath.Split(path)
|
||||
data, err := os.ReadFile(filepath.Join(filepath.Clean(dir), ".zddc.d", base))
|
||||
if err != nil {
|
||||
// Default-spec virtual fallback: when no operator file exists at
|
||||
// path, serve the embedded default if path matches one of the
|
||||
// recognized virtual fallback shapes (per-party mdl/rsk, per-
|
||||
// party SSR schema, project-level virtual specs). Mirrors the
|
||||
// static-handler fallback for direct YAML fetches.
|
||||
data, err = os.ReadFile(path)
|
||||
}
|
||||
if err != nil {
|
||||
// Default-spec virtual fallback: when no operator file exists in
|
||||
// either location, serve the embedded default if path matches one of
|
||||
// the recognized virtual fallback shapes (per-party mdl/rsk, per-
|
||||
// party SSR schema, project-level virtual specs).
|
||||
if os.IsNotExist(err) {
|
||||
if bytes, ok := IsDefaultSpecAbs(fsRoot, path); ok {
|
||||
data = bytes
|
||||
|
|
|
|||
|
|
@ -46,6 +46,25 @@ const ElevatedKey contextKey = "elevated"
|
|||
// named in admin lists.
|
||||
const elevationCookieName = "zddc-elevate"
|
||||
|
||||
// adminQueryParam reads the ?admin= elevation toggle, returning a pointer to
|
||||
// the requested state (true = elevate, false = drop) or nil when the param is
|
||||
// absent or unrecognised. Recognised values mirror shared/elevation.js so the
|
||||
// URL toggle behaves identically whether elevation.js sets the cookie or the
|
||||
// server honors the bare param: true/1/on/yes and false/0/off/no
|
||||
// (case-insensitive).
|
||||
func adminQueryParam(r *http.Request) *bool {
|
||||
v := strings.ToLower(r.URL.Query().Get("admin"))
|
||||
switch v {
|
||||
case "true", "1", "on", "yes":
|
||||
t := true
|
||||
return &t
|
||||
case "false", "0", "off", "no":
|
||||
f := false
|
||||
return &f
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ACLMiddleware extracts the user email and stores it (along with the
|
||||
// policy decider) in the request context. It does NOT enforce ACL
|
||||
// itself — each handler performs its own ACL check via
|
||||
|
|
@ -98,6 +117,20 @@ func ACLMiddleware(cfg config.Config, decider policy.Decider, tokens *auth.Store
|
|||
if c, err := r.Cookie(elevationCookieName); err == nil && c.Value == "1" {
|
||||
elevated = true
|
||||
}
|
||||
// ?admin=true|1|on|yes elevates this request directly, and
|
||||
// ?admin=false|0|off|no drops it — mirroring the URL toggle in
|
||||
// shared/elevation.js, but honored at the server so the param
|
||||
// works on EVERY endpoint (raw directory listings, zip browsing,
|
||||
// the file API), not just HTML pages where elevation.js runs to
|
||||
// set the cookie. elevation.js still sets the cookie for sticky
|
||||
// persistence across navigation; this just makes the bare param
|
||||
// effective on a single direct request too. Elevation only grants
|
||||
// powers to a caller who already holds admin authority (every
|
||||
// admin call site re-checks the cascade via IsActiveAdmin), so
|
||||
// honoring the param for a non-admin is a harmless no-op.
|
||||
if v := adminQueryParam(r); v != nil {
|
||||
elevated = *v
|
||||
}
|
||||
}
|
||||
// DEBUG-level header dump for diagnosing proxy / SSO header
|
||||
// passthrough. Off by default (LogLevel info); enable with
|
||||
|
|
|
|||
|
|
@ -194,12 +194,12 @@ func TestAccessLog_ChainAdminLevelAttribution(t *testing.T) {
|
|||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
email string
|
||||
elevate bool
|
||||
path string
|
||||
wantLevel int
|
||||
wantActive bool
|
||||
name string
|
||||
email string
|
||||
elevate bool
|
||||
path string
|
||||
wantLevel int
|
||||
wantActive bool
|
||||
}{
|
||||
{"root admin elevated probing root → level 0", "root@example.com", true, "/", 0, true},
|
||||
{"root admin elevated probing project → level 0 (walks down chain)", "root@example.com", true, "/Project-1/", 0, true},
|
||||
|
|
@ -250,3 +250,73 @@ func TestAccessLog_ChainAdminLevelAttribution(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestACLMiddleware_AdminQueryParamElevation verifies the server honors the
|
||||
// ?admin= URL toggle directly (mirroring shared/elevation.js), so the param
|
||||
// elevates ANY endpoint — not just HTML pages where elevation.js runs to set
|
||||
// the cookie. ?admin=true elevates with no cookie; ?admin=false drops even
|
||||
// when the cookie is present; a non-admin's ?admin=true sets the flag but
|
||||
// confers no authority.
|
||||
func TestACLMiddleware_AdminQueryParamElevation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
||||
[]byte("admins:\n - root@example.com\n"), 0o644); err != nil {
|
||||
t.Fatalf("write root .zddc: %v", err)
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
||||
noop := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
|
||||
type record struct {
|
||||
Elevated bool `json:"elevated"`
|
||||
ActiveAdmin bool `json:"active_admin"`
|
||||
}
|
||||
run := func(t *testing.T, path, email string, cookie bool) record {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
auditLogger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(cfg, auditLogger, noop))
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
if email != "" {
|
||||
req.Header.Set("X-Auth-Request-Email", email)
|
||||
}
|
||||
if cookie {
|
||||
req.AddCookie(&http.Cookie{Name: "zddc-elevate", Value: "1"})
|
||||
}
|
||||
chain.ServeHTTP(httptest.NewRecorder(), req)
|
||||
var rec record
|
||||
if err := json.Unmarshal(buf.Bytes(), &rec); err != nil {
|
||||
t.Fatalf("audit log not JSON: %v; raw=%s", err, buf.String())
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
t.Run("?admin=true elevates root admin with no cookie", func(t *testing.T) {
|
||||
rec := run(t, "/?admin=true", "root@example.com", false)
|
||||
if !rec.Elevated || !rec.ActiveAdmin {
|
||||
t.Errorf("elevated=%v active=%v, want both true", rec.Elevated, rec.ActiveAdmin)
|
||||
}
|
||||
})
|
||||
t.Run("?admin=false drops despite cookie", func(t *testing.T) {
|
||||
rec := run(t, "/?admin=false", "root@example.com", true)
|
||||
if rec.Elevated || rec.ActiveAdmin {
|
||||
t.Errorf("elevated=%v active=%v, want both false", rec.Elevated, rec.ActiveAdmin)
|
||||
}
|
||||
})
|
||||
t.Run("non-admin ?admin=true sets flag but confers no authority", func(t *testing.T) {
|
||||
rec := run(t, "/?admin=true", "stranger@example.com", false)
|
||||
if !rec.Elevated {
|
||||
t.Errorf("elevated=%v, want true (flag set)", rec.Elevated)
|
||||
}
|
||||
if rec.ActiveAdmin {
|
||||
t.Errorf("active_admin=%v, want false (no admin authority)", rec.ActiveAdmin)
|
||||
}
|
||||
})
|
||||
t.Run("no param, no cookie → not elevated", func(t *testing.T) {
|
||||
rec := run(t, "/", "root@example.com", false)
|
||||
if rec.Elevated || rec.ActiveAdmin {
|
||||
t.Errorf("elevated=%v active=%v, want both false", rec.Elevated, rec.ActiveAdmin)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,15 @@ package handler
|
|||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
|
|
@ -161,6 +165,17 @@ func IsDefaultSpecAbs(fsRoot, absPath string) ([]byte, bool) {
|
|||
// not name one of the recognized virtual fallback files.
|
||||
func classifyDefaultSpec(rel string) []byte {
|
||||
parts := strings.Split(rel, "/")
|
||||
// A spec may live either in the directory root (<dir>/table.yaml) or in
|
||||
// the supporting-files reserve (<dir>/.zddc.d/table.yaml). Strip a
|
||||
// ".zddc.d" segment so both classify by the same dir shape.
|
||||
clean := parts[:0:0]
|
||||
for _, p := range parts {
|
||||
if strings.EqualFold(p, ".zddc.d") {
|
||||
continue
|
||||
}
|
||||
clean = append(clean, p)
|
||||
}
|
||||
parts = clean
|
||||
switch len(parts) {
|
||||
case 4:
|
||||
// <project>/<peer>/<party>/<file> — per-party register specs
|
||||
|
|
@ -309,8 +324,9 @@ func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest {
|
|||
|
||||
specAbs := filepath.Join(dirAbs, "table.yaml")
|
||||
|
||||
// Presence-based discovery: <dir>/table.yaml on disk.
|
||||
if fileExists(specAbs) {
|
||||
// Presence-based discovery: the spec in the supporting-files reserve
|
||||
// (<dir>/.zddc.d/table.yaml) or, legacy, the directory root.
|
||||
if fileExists(filepath.Join(dirAbs, ".zddc.d", "table.yaml")) || fileExists(specAbs) {
|
||||
return &TableRequest{Name: name, SpecPath: specAbs, Dir: dirAbs}
|
||||
}
|
||||
|
||||
|
|
@ -362,10 +378,77 @@ func isNotExistError(err error) bool {
|
|||
return err != nil && strings.Contains(err.Error(), "no such file or directory")
|
||||
}
|
||||
|
||||
// ServeTable serves the static tables.html bytes for a recognized
|
||||
// request. ACL gate is the read action at the request directory; on
|
||||
// allow, the embedded HTML is written verbatim. The client takes over
|
||||
// from there — see tables/js/main.js.
|
||||
// LoadViewSpec resolves a config file's bytes for dir, preferring the
|
||||
// supporting-files reserve <dir>/.zddc.d/<name>, then the legacy <dir>/<name>,
|
||||
// then the embedded default for this dir's shape. Returns nil when none
|
||||
// applies. This is the single seam that puts table/form specs under .zddc.d/
|
||||
// (where they're admin-gated + hidden) while staying back-compatible.
|
||||
func LoadViewSpec(fsRoot, dir, name string) []byte {
|
||||
if b, err := os.ReadFile(filepath.Join(dir, ".zddc.d", name)); err == nil {
|
||||
return b
|
||||
}
|
||||
if b, err := os.ReadFile(filepath.Join(dir, name)); err == nil {
|
||||
return b
|
||||
}
|
||||
if rel, err := filepath.Rel(fsRoot, filepath.Join(dir, name)); err == nil {
|
||||
if b := classifyDefaultSpec(filepath.ToSlash(rel)); b != nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// injectTableContext writes the resolved table spec + row-form schema into the
|
||||
// `#table-context` placeholder so the client reads them instead of fetching
|
||||
// <dir>/table.yaml and <dir>/form.yaml over HTTP (impossible once the specs
|
||||
// live under the admin-gated .zddc.d/). The client still walks the directory
|
||||
// for ROW files — only the SPEC is injected. Shape:
|
||||
//
|
||||
// { "spec": <parsed table.yaml>, "rowSchema": <parsed form.yaml .schema> }
|
||||
//
|
||||
// Empty {} when neither resolves (the client then walks for the spec too,
|
||||
// preserving legacy behavior). Returns an error only if the placeholder is
|
||||
// absent from the template.
|
||||
func injectTableContext(template, tableYAML, formYAML []byte) ([]byte, error) {
|
||||
ctx := map[string]interface{}{}
|
||||
if len(tableYAML) > 0 {
|
||||
var spec interface{}
|
||||
if err := yaml.Unmarshal(tableYAML, &spec); err == nil && spec != nil {
|
||||
ctx["spec"] = spec
|
||||
}
|
||||
}
|
||||
if len(formYAML) > 0 {
|
||||
var fs map[string]interface{}
|
||||
if err := yaml.Unmarshal(formYAML, &fs); err == nil {
|
||||
if sch, ok := fs["schema"]; ok {
|
||||
ctx["rowSchema"] = sch
|
||||
}
|
||||
}
|
||||
}
|
||||
js, err := json.Marshal(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
js = []byte(strings.ReplaceAll(string(js), "</", "<\\/"))
|
||||
needle := []byte(`<script id="table-context" type="application/json">{}</script>`)
|
||||
if !bytesContains(template, needle) {
|
||||
return nil, errBundle("#table-context placeholder not found in template")
|
||||
}
|
||||
replacement := append([]byte(`<script id="table-context" type="application/json">`), js...)
|
||||
replacement = append(replacement, []byte(`</script>`)...)
|
||||
return bytesReplace(template, needle, replacement), nil
|
||||
}
|
||||
|
||||
type errBundle string
|
||||
|
||||
func (e errBundle) Error() string { return string(e) }
|
||||
|
||||
// ServeTable serves the tables HTML for a recognized request, ACL-gated on
|
||||
// read at the request directory. The resolved table.yaml + form.yaml (from
|
||||
// .zddc.d/, legacy root, or the embedded default) are injected as
|
||||
// #table-context so the client never fetches the spec over HTTP. If the
|
||||
// template predates the placeholder, the bare HTML is served (the client
|
||||
// falls back to fetching) — keeps this non-breaking before ./build.
|
||||
func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFromContext(r)
|
||||
decider := DeciderFromContext(r)
|
||||
|
|
@ -384,7 +467,14 @@ func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r *
|
|||
return
|
||||
}
|
||||
|
||||
body := embeddedTablesHTML
|
||||
tableYAML := LoadViewSpec(cfg.Root, req.Dir, "table.yaml")
|
||||
formYAML := LoadViewSpec(cfg.Root, req.Dir, "form.yaml")
|
||||
if injected, ierr := injectTableContext(embeddedTablesHTML, tableYAML, formYAML); ierr == nil {
|
||||
body = injected
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(embeddedTablesHTML)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,8 +184,9 @@ func TestRecognizeTableRequest(t *testing.T) {
|
|||
}
|
||||
|
||||
// TestServeTable_ServesEmbeddedHTML — an ACL-passing GET returns the
|
||||
// embedded tables.html bytes verbatim, with the empty inline context
|
||||
// placeholder intact (so the client knows to walk the directory).
|
||||
// embedded tables.html with the resolved table spec server-injected into
|
||||
// #table-context (the embedded default for this virtual MDL dir), so the
|
||||
// client renders without a separate spec fetch.
|
||||
func TestServeTable_ServesEmbeddedHTML(t *testing.T) {
|
||||
rows := map[string]string{
|
||||
"D-001.yaml": "id: D-001\ntitle: One\nstatus: pending\n",
|
||||
|
|
@ -202,8 +203,13 @@ func TestServeTable_ServesEmbeddedHTML(t *testing.T) {
|
|||
if !strings.Contains(body, `<table id="table-root"`) {
|
||||
t.Error("body missing #table-root markup; embedded HTML may be stale or empty")
|
||||
}
|
||||
if !strings.Contains(body, `<script id="table-context" type="application/json">{}</script>`) {
|
||||
t.Error("inline context placeholder not preserved verbatim — client expects {} so it knows to walk")
|
||||
// #table-context is no longer the empty placeholder — the resolved spec
|
||||
// is injected (the client uses it instead of fetching table.yaml).
|
||||
if strings.Contains(body, `<script id="table-context" type="application/json">{}</script>`) {
|
||||
t.Error("#table-context still empty; expected the resolved spec to be injected")
|
||||
}
|
||||
if !strings.Contains(body, `id="table-context"`) || !strings.Contains(body, `"spec"`) {
|
||||
t.Error("expected the resolved table spec injected into #table-context")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1534,7 +1534,7 @@ body.is-elevated::after {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 18:26:16 · f723323</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-05 12:41:17 · 382645b</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
@ -3691,13 +3691,19 @@ body.is-elevated::after {
|
|||
// inline context (tests) or open the page through zddc-server.
|
||||
async function load() {
|
||||
const inline = readInlineContext();
|
||||
if (inline && Object.keys(inline).length > 0) {
|
||||
// A fully pre-assembled context (columns + rows) is used as-is — the
|
||||
// test seam, or any host that renders the whole table server-side.
|
||||
if (inline && Array.isArray(inline.columns)) {
|
||||
return inline;
|
||||
}
|
||||
// Otherwise the inline context may still carry the server-injected
|
||||
// SPEC ({spec, rowSchema}) sourced from <dir>/.zddc.d/ — pass it to
|
||||
// walkServer, which uses it instead of fetching the spec and still
|
||||
// walks the directory for row files.
|
||||
if (typeof location !== 'undefined' &&
|
||||
(location.protocol === 'http:' || location.protocol === 'https:')) {
|
||||
try {
|
||||
const walked = await walkServer();
|
||||
const walked = await walkServer(inline || {});
|
||||
if (walked) {
|
||||
return walked;
|
||||
}
|
||||
|
|
@ -3729,7 +3735,8 @@ body.is-elevated::after {
|
|||
el.hidden = false;
|
||||
}
|
||||
|
||||
async function walkServer() {
|
||||
async function walkServer(injected) {
|
||||
injected = injected || {};
|
||||
const source = window.zddc && window.zddc.source;
|
||||
if (!source) {
|
||||
throw new Error('zddc.source not available');
|
||||
|
|
@ -3746,27 +3753,32 @@ body.is-elevated::after {
|
|||
}
|
||||
const dir = probe.handle;
|
||||
|
||||
// Spec lives at <currentdir>/table.yaml — the page URL is
|
||||
// <currentdir>/table.html, so the spec is right next door.
|
||||
const spec = await readYaml(dir, 'table.yaml');
|
||||
// Spec: prefer the server-injected #table-context.spec (sourced from
|
||||
// <dir>/.zddc.d/table.yaml). Falling back, read the spec from the
|
||||
// supporting-files reserve, then the legacy directory root — the
|
||||
// FS-Access path, where there's no server to inject.
|
||||
let spec = (injected.spec && Array.isArray(injected.spec.columns))
|
||||
? injected.spec : null;
|
||||
if (!spec) {
|
||||
spec = await readYamlFirst(dir, ['.zddc.d/table.yaml', 'table.yaml']);
|
||||
}
|
||||
if (!spec || !Array.isArray(spec.columns)) {
|
||||
throw new Error('Spec table.yaml missing columns[]');
|
||||
}
|
||||
|
||||
// Optional row schema from <dir>/form.yaml — same JSON Schema
|
||||
// the form-mode renderer uses. Phase 2 derives per-cell editor
|
||||
// widgets from it (text/number/date/select/checkbox).
|
||||
// Best-effort: a directory with only table.yaml still renders
|
||||
// as a sortable/filterable table; cells fall back to plain
|
||||
// text inputs without per-property hints.
|
||||
let rowSchema = null;
|
||||
try {
|
||||
const formSpec = await readYaml(dir, 'form.yaml');
|
||||
if (formSpec && formSpec.schema) {
|
||||
rowSchema = formSpec.schema;
|
||||
// Row schema: prefer the injected #table-context.rowSchema, else read
|
||||
// <dir>/.zddc.d/form.yaml (then legacy root). Best-effort — a table
|
||||
// with no row schema still renders with plain-text cells.
|
||||
let rowSchema = injected.rowSchema || null;
|
||||
if (!rowSchema) {
|
||||
try {
|
||||
const formSpec = await readYamlFirst(dir, ['.zddc.d/form.yaml', 'form.yaml']);
|
||||
if (formSpec && formSpec.schema) {
|
||||
rowSchema = formSpec.schema;
|
||||
}
|
||||
} catch (_) {
|
||||
// form.yaml missing or unreadable; carry on without it.
|
||||
}
|
||||
} catch (_) {
|
||||
// form.yaml missing or unreadable; carry on without it.
|
||||
}
|
||||
|
||||
// Rows are every *.yaml in <currentdir> EXCEPT the spec
|
||||
|
|
@ -3825,6 +3837,22 @@ body.is-elevated::after {
|
|||
return window.jsyaml.load(text);
|
||||
}
|
||||
|
||||
// readYamlFirst tries each relPath in order, returning the first that
|
||||
// resolves + parses. Used to read a spec from the supporting-files
|
||||
// reserve (.zddc.d/<name>) with a fallback to the legacy directory root.
|
||||
async function readYamlFirst(dir, relPaths) {
|
||||
let lastErr = null;
|
||||
for (var i = 0; i < relPaths.length; i++) {
|
||||
try {
|
||||
return await readYaml(dir, relPaths[i]);
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
}
|
||||
}
|
||||
if (lastErr) throw lastErr;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Walk a "/"-separated relative path under dir, returning the
|
||||
// FileSystemFileHandle (or HttpFileHandle) at the leaf.
|
||||
async function resolveFile(dir, relPath) {
|
||||
|
|
|
|||
|
|
@ -31,22 +31,29 @@ func IsZddcFileRequest(urlPath string) bool {
|
|||
// ServeZddcFile serves a directory's .zddc as a plain YAML view.
|
||||
//
|
||||
// Method: GET / HEAD only — the dispatcher routes writes
|
||||
// (PUT/DELETE/POST) directly to ServeFileAPI.
|
||||
//
|
||||
// (PUT/DELETE/POST) directly to ServeFileAPI.
|
||||
//
|
||||
// ACL: the parent directory's read permission gates access. A
|
||||
// user who can read the directory can read its .zddc.
|
||||
//
|
||||
// user who can read the directory can read its .zddc.
|
||||
//
|
||||
// On-disk: if <dir>/.zddc exists, its bytes are returned verbatim
|
||||
// with Content-Type: application/yaml.
|
||||
//
|
||||
// with Content-Type: application/yaml.
|
||||
//
|
||||
// Virtual: if it does not exist, the body is the cascade's
|
||||
// leaf-level ZddcFile (what defaults.zddc.yaml's paths:
|
||||
// tree declares for THIS exact directory, plus any
|
||||
// virtual contributions threaded through by the walker)
|
||||
// marshalled as YAML. A header comment names the source
|
||||
// and points at ?effective=1 for the composed view. The
|
||||
// virtual body is itself valid YAML — PUT-saving it back
|
||||
// (with or without edits) through the file API
|
||||
// materialises a real on-disk override carrying exactly
|
||||
// the bytes the user saved. The response sets
|
||||
// X-ZDDC-Source: virtual:zddc so clients can distinguish.
|
||||
//
|
||||
// leaf-level ZddcFile (what defaults.zddc.yaml's paths:
|
||||
// tree declares for THIS exact directory, plus any
|
||||
// virtual contributions threaded through by the walker)
|
||||
// marshalled as YAML. A header comment names the source
|
||||
// and points at ?effective=1 for the composed view. The
|
||||
// virtual body is itself valid YAML — PUT-saving it back
|
||||
// (with or without edits) through the file API
|
||||
// materialises a real on-disk override carrying exactly
|
||||
// the bytes the user saved. The response sets
|
||||
// X-ZDDC-Source: virtual:zddc so clients can distinguish.
|
||||
func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||
decider := DeciderFromContext(r)
|
||||
|
||||
|
|
@ -274,7 +281,6 @@ func levelURLsFor(_, dirURL string, n int) []string {
|
|||
// surfacing.
|
||||
func isZeroZddcFile(zf zddc.ZddcFile) bool {
|
||||
return zf.Title == "" &&
|
||||
zf.AppsPubKey == "" &&
|
||||
zf.CreatedBy == "" &&
|
||||
zf.DefaultTool == "" &&
|
||||
zf.DirTool == "" &&
|
||||
|
|
@ -289,8 +295,8 @@ func isZeroZddcFile(zf zddc.ZddcFile) bool {
|
|||
zf.Convert == nil &&
|
||||
len(zf.ACL.Permissions) == 0 &&
|
||||
len(zf.Admins) == 0 &&
|
||||
len(zf.Apps) == 0 &&
|
||||
len(zf.Tables) == 0 &&
|
||||
len(zf.Views) == 0 &&
|
||||
len(zf.Display) == 0 &&
|
||||
len(zf.Roles) == 0 &&
|
||||
len(zf.FieldCodes) == 0 &&
|
||||
|
|
|
|||
|
|
@ -393,9 +393,8 @@ func nonZeroZddcFields(zf ZddcFile) []string {
|
|||
add("title", zf.Title != "")
|
||||
add("acl", len(zf.ACL.Permissions) > 0 || zf.ACL.Inherit != nil)
|
||||
add("admins", len(zf.Admins) > 0)
|
||||
add("apps", len(zf.Apps) > 0)
|
||||
add("apps_pubkey", zf.AppsPubKey != "")
|
||||
add("tables", len(zf.Tables) > 0)
|
||||
add("views", len(zf.Views) > 0)
|
||||
add("display", len(zf.Display) > 0)
|
||||
add("convert", zf.Convert != nil)
|
||||
add("roles", len(zf.Roles) > 0)
|
||||
|
|
|
|||
|
|
@ -92,6 +92,15 @@ type ConvertMetadata struct {
|
|||
ProjectNumber string `yaml:"project_number,omitempty" json:"project_number,omitempty"`
|
||||
}
|
||||
|
||||
// ViewSpec is one entry in ZddcFile.Views: which tool renders a given URL
|
||||
// shape, and the filename (under <dir>/.zddc.d/) of its supporting config.
|
||||
// Config is optional (e.g. browse needs none). Both are plain data — no
|
||||
// behaviour. See ZddcFile.Views.
|
||||
type ViewSpec struct {
|
||||
Tool string `yaml:"tool,omitempty" json:"tool,omitempty"`
|
||||
Config string `yaml:"config,omitempty" json:"config,omitempty"`
|
||||
}
|
||||
|
||||
// ZddcFile represents the parsed contents of a .zddc configuration file.
|
||||
//
|
||||
// Admins is honored only in the root .zddc file (<ZDDC_ROOT>/.zddc); subdir
|
||||
|
|
@ -104,32 +113,13 @@ type ConvertMetadata struct {
|
|||
// for the project on the landing-page picker. Optional — projects without a
|
||||
// title fall back to displaying the directory name.
|
||||
//
|
||||
// Apps is a per-directory cascade override mapping app name → source spec.
|
||||
// The spec is one of: "stable" / "beta" / "alpha" (channel on the canonical
|
||||
// upstream), "v0.0.4" / "v0.0" / "v0" (version pin on the canonical
|
||||
// upstream), an absolute "https://..." URL (custom mirror), or a relative
|
||||
// or absolute filesystem path (./local.html, /opt/zddc/foo.html).
|
||||
//
|
||||
// On a request for a tool HTML, zddc-server walks .zddc files leaf→root
|
||||
// looking for an Apps entry; first match wins. With no entry anywhere, the
|
||||
// server serves the version baked into the binary at compile time (//go:embed).
|
||||
// Fetched URL sources are cached in <ZDDC_ROOT>/_app/; the cache is fetch-once
|
||||
// and never re-validates — operators delete the file to force a refetch.
|
||||
//
|
||||
// AppsPubKey is the inline PEM of the Ed25519 public key used to verify
|
||||
// signatures on URL-fetched apps artifacts. Honored only at the root
|
||||
// .zddc file (same root-only treatment as Admins, for the same reason:
|
||||
// it's a trust anchor; subtree write authority must not be able to
|
||||
// re-anchor it). Lower priority than --apps-pubkey / ZDDC_APPS_PUBKEY:
|
||||
// when both are set, the env/flag (file path) wins. Empty in either
|
||||
// place = URL-fetched apps refused (only embedded + local-path apps
|
||||
// work). See zddc-server's setupApps.
|
||||
// Tool HTML is resolved LOCALLY (no .zddc key): a real file on disk at the
|
||||
// path → an "<app>.html" member of <ZDDC_ROOT>/.zddc.zip → the embedded
|
||||
// default. There is no `apps:` / `apps_pubkey:` key and no upstream fetch.
|
||||
type ZddcFile struct {
|
||||
ACL ACLRules `yaml:"acl,omitempty" json:"acl,omitempty"`
|
||||
Admins []string `yaml:"admins,omitempty" json:"admins,omitempty"`
|
||||
Title string `yaml:"title,omitempty" json:"title,omitempty"`
|
||||
Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"`
|
||||
AppsPubKey string `yaml:"apps_pubkey,omitempty" json:"apps_pubkey,omitempty"`
|
||||
ACL ACLRules `yaml:"acl,omitempty" json:"acl,omitempty"`
|
||||
Admins []string `yaml:"admins,omitempty" json:"admins,omitempty"`
|
||||
Title string `yaml:"title,omitempty" json:"title,omitempty"`
|
||||
|
||||
// Tables declares directory-of-YAML table views available at this
|
||||
// directory. The map key becomes the URL stem: tables[MDL] is served
|
||||
|
|
@ -216,6 +206,21 @@ type ZddcFile struct {
|
|||
// Cascades leaf→root like DefaultTool.
|
||||
DirTool string `yaml:"dir_tool,omitempty" json:"dir_tool,omitempty"`
|
||||
|
||||
// Views declares, per URL shape, which tool renders and where its
|
||||
// supporting config lives — the generalization of default_tool/dir_tool
|
||||
// plus the form/table recognizers. Keys are URL shapes:
|
||||
// "dir" — GET <dir> (no slash) e.g. {tool: tables, config: table.yaml}
|
||||
// "dir_slash" — GET <dir>/ e.g. {tool: browse}
|
||||
// "file" — GET <dir>/<file> (no slash) e.g. {tool: form, config: form.yaml}
|
||||
// config is a filename resolved under <dir>/.zddc.d/ (the supporting-files
|
||||
// reserve), server-resolved and injected (#view-context) since .zddc.d/ is
|
||||
// not client-fetchable. A view is presentation/routing ONLY — it never
|
||||
// grants access; ACL/WORM/admin stay server-enforced. default_tool /
|
||||
// dir_tool are normalized into views.dir / views.dir_slash (kept as sugar).
|
||||
// Cascades leaf→root like DefaultTool. No arbitrary code: tool ∈ the known
|
||||
// app set, config is a path-bounded relative name.
|
||||
Views map[string]ViewSpec `yaml:"views,omitempty" json:"views,omitempty"`
|
||||
|
||||
// AutoOwn controls whether the file API's mkdir post-hook writes
|
||||
// an auto-owned .zddc granting the creator rwcda at the new
|
||||
// directory. Useful for working/staging/incoming-style drafting
|
||||
|
|
|
|||
|
|
@ -42,9 +42,8 @@ roles:
|
|||
if zf.Title != "Demo" {
|
||||
t.Errorf("Title = %q want %q", zf.Title, "Demo")
|
||||
}
|
||||
if got := zf.Apps["archive"]; got != "stable" {
|
||||
t.Errorf("Apps[archive] = %q want %q", got, "stable")
|
||||
}
|
||||
// A stale `apps:` key in the fixture is ignored (the key was removed),
|
||||
// not a parse error — back-compat for existing .zddc files.
|
||||
if r, ok := zf.Roles["reviewers"]; !ok || len(r.Members) != 1 {
|
||||
t.Errorf("Roles[reviewers] = %+v want one member", r)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,44 @@ func DirToolAt(fsRoot, dirPath string) string {
|
|||
return "browse"
|
||||
}
|
||||
|
||||
// ViewAt resolves the view for a URL shape ("dir", "dir_slash", "file") at
|
||||
// dirPath. Walks the cascade leaf→root (then the embedded defaults): the first
|
||||
// level whose Views declares the shape wins; default_tool / dir_tool are
|
||||
// honored as sugar for the "dir" / "dir_slash" shapes. Returns
|
||||
// (ViewSpec{}, false) when nothing declares the shape (the caller decides any
|
||||
// floor, e.g. dir_slash → browse). Mirrors DefaultToolAt's first-match-wins
|
||||
// cascade so default_tool/dir_tool semantics are unchanged.
|
||||
func ViewAt(fsRoot, dirPath, shape string) (ViewSpec, bool) {
|
||||
chain, err := EffectivePolicy(fsRoot, dirPath)
|
||||
if err != nil {
|
||||
return ViewSpec{}, false
|
||||
}
|
||||
atLevel := func(lvl ZddcFile) (ViewSpec, bool) {
|
||||
if lvl.Views != nil {
|
||||
if v, ok := lvl.Views[shape]; ok && v.Tool != "" {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
switch shape {
|
||||
case "dir":
|
||||
if lvl.DefaultTool != "" {
|
||||
return ViewSpec{Tool: lvl.DefaultTool}, true
|
||||
}
|
||||
case "dir_slash":
|
||||
if lvl.DirTool != "" {
|
||||
return ViewSpec{Tool: lvl.DirTool}, true
|
||||
}
|
||||
}
|
||||
return ViewSpec{}, false
|
||||
}
|
||||
for i := len(chain.Levels) - 1; i >= 0; i-- {
|
||||
if v, ok := atLevel(chain.Levels[i]); ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return atLevel(chain.Embedded)
|
||||
}
|
||||
|
||||
// AutoOwnAt reports whether mkdir at THIS specific directory should
|
||||
// write an auto-owned .zddc. Leaf-only lookup — auto-own does NOT
|
||||
// propagate to descendants (creating working/alice/notes/sub/ does
|
||||
|
|
@ -375,7 +413,7 @@ func isZeroZddcFile(zf ZddcFile) bool {
|
|||
if len(zf.AvailableTools) > 0 {
|
||||
return false
|
||||
}
|
||||
if zf.AppsPubKey != "" || zf.CreatedBy != "" {
|
||||
if zf.CreatedBy != "" {
|
||||
return false
|
||||
}
|
||||
if zf.Worm != nil { // non-nil even when empty — marks a WORM zone
|
||||
|
|
@ -390,7 +428,7 @@ func isZeroZddcFile(zf ZddcFile) bool {
|
|||
if zf.ACL.Inherit != nil {
|
||||
return false
|
||||
}
|
||||
if len(zf.Apps) > 0 || len(zf.Tables) > 0 || len(zf.Display) > 0 || len(zf.Paths) > 0 {
|
||||
if len(zf.Tables) > 0 || len(zf.Views) > 0 || len(zf.Display) > 0 || len(zf.Paths) > 0 {
|
||||
return false
|
||||
}
|
||||
if len(zf.Roles) > 0 {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,48 @@ func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestViewAt — default_tool/dir_tool act as sugar for the dir/dir_slash
|
||||
// shapes, and an explicit views: entry overrides them.
|
||||
func TestViewAt(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
j := func(p ...string) string { return filepath.Join(append([]string{root, "Project-X"}, p...)...) }
|
||||
|
||||
// Sugar: the embedded default_tool/dir_tool surface via ViewAt.
|
||||
if v, ok := ViewAt(root, j("mdl", "Acme"), "dir"); !ok || v.Tool != "tables" {
|
||||
t.Errorf("ViewAt(mdl/Acme, dir) = (%+v,%v), want tables", v, ok)
|
||||
}
|
||||
if v, ok := ViewAt(root, j("working", "Acme"), "dir"); !ok || v.Tool != "browse" {
|
||||
t.Errorf("ViewAt(working/Acme, dir) = (%+v,%v), want browse", v, ok)
|
||||
}
|
||||
// No file-shape declared by defaults.
|
||||
if _, ok := ViewAt(root, j("working", "Acme"), "file"); ok {
|
||||
t.Errorf("ViewAt(working/Acme, file) should be unset by default")
|
||||
}
|
||||
|
||||
// Explicit views: overrides default_tool and declares a file shape.
|
||||
resetCache()
|
||||
dir := j("custom")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := WriteFile(dir, ZddcFile{
|
||||
DefaultTool: "browse",
|
||||
Views: map[string]ViewSpec{
|
||||
"dir": {Tool: "tables", Config: "table.yaml"},
|
||||
"file": {Tool: "form", Config: "form.yaml"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, ok := ViewAt(root, dir, "dir"); !ok || v.Tool != "tables" || v.Config != "table.yaml" {
|
||||
t.Errorf("ViewAt(custom, dir) = (%+v,%v), want {tables,table.yaml}", v, ok)
|
||||
}
|
||||
if v, ok := ViewAt(root, dir, "file"); !ok || v.Tool != "form" || v.Config != "form.yaml" {
|
||||
t.Errorf("ViewAt(custom, file) = (%+v,%v), want {form,form.yaml}", v, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryAt_Defaults — edit-history defaults on for the live-editing
|
||||
// peers working/mdl/rsk and the ssr registry (subtree-inheriting). The
|
||||
// other peers and the WORM archive do not get history.
|
||||
|
|
|
|||
|
|
@ -5,24 +5,16 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
// AppNames is the canonical set of app HTML files the server resolves
|
||||
// via the apps fetch+cache subsystem. Order is stable for reproducible
|
||||
// admin-UI rendering.
|
||||
// AppNames is the canonical set of app HTML files the server can serve
|
||||
// (from disk, the site .zddc.zip bundle, or the embedded default). Order
|
||||
// is stable for reproducible rendering.
|
||||
//
|
||||
// All seven HTML tools belong here — including browse, form, and tables.
|
||||
// Omitting any of them means the apps cascade (.zddc apps:) silently
|
||||
// short-circuits to embedded for that name, defeating live-dev
|
||||
// path-source overrides.
|
||||
//
|
||||
// Markdown editing used to be a dedicated tool ("mdedit"); it now
|
||||
// lives as a plugin inside browse (browse/js/preview-markdown.js).
|
||||
var AppNames = []string{"archive", "transmittal", "classifier", "landing", "browse", "form", "tables"}
|
||||
|
||||
// AppsDefaultKey is the special apps-map key that provides the baseline
|
||||
// URL prefix and channel for any app not overridden per-name. Cascades
|
||||
// through .zddc files like a per-app entry.
|
||||
const AppsDefaultKey = "default"
|
||||
|
||||
// IsKnownApp reports whether name is one of the canonical apps.
|
||||
func IsKnownApp(name string) bool {
|
||||
for _, n := range AppNames {
|
||||
|
|
@ -33,12 +25,6 @@ func IsKnownApp(name string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// IsValidAppsKey reports whether name is acceptable as a key in the
|
||||
// `apps:` map — either a canonical app or the special "default" key.
|
||||
func IsValidAppsKey(name string) bool {
|
||||
return name == AppsDefaultKey || IsKnownApp(name)
|
||||
}
|
||||
|
||||
// ValidatePattern returns an error if pattern is not a syntactically
|
||||
// well-formed email-glob. The matcher in MatchesPattern is forgiving and
|
||||
// will silently fail to match malformed patterns (e.g., "alice@@x" or
|
||||
|
|
@ -120,101 +106,6 @@ func ValidateProjectName(name string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// ValidateAppSourceSpec returns nil if spec is a syntactically well-formed
|
||||
// source spec accepted by apps.ParseSpec. It checks the string shape only —
|
||||
// it does not verify URLs are reachable or paths exist.
|
||||
//
|
||||
// Accepted forms:
|
||||
// - "stable" / "beta" / "alpha" / ":stable" / ":beta" / ":alpha" (channel)
|
||||
// - "v0.0.4" / "0.0.4" / "v0.0" / "0.0" / "v0" / "0" / ":v0.0.4" (version)
|
||||
// - "https://host/path" (URL prefix)
|
||||
// - "https://host/path:stable" (URL prefix + channel)
|
||||
// - "https://host/path/file.html" (terminal full URL)
|
||||
// - "/abs/path.html" / "./rel/path.html" / "../sibling.html" (path)
|
||||
func ValidateAppSourceSpec(spec string) error {
|
||||
if spec == "" {
|
||||
return fmt.Errorf("source spec is empty")
|
||||
}
|
||||
if strings.ContainsAny(spec, " \t\n\r") {
|
||||
return fmt.Errorf("source spec contains whitespace")
|
||||
}
|
||||
|
||||
// Path forms.
|
||||
if strings.HasPrefix(spec, "/") ||
|
||||
strings.HasPrefix(spec, "./") ||
|
||||
strings.HasPrefix(spec, "../") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// URL forms.
|
||||
if strings.HasPrefix(spec, "https://") || strings.HasPrefix(spec, "http://") {
|
||||
return validateURLSpec(spec)
|
||||
}
|
||||
|
||||
// Channel-or-version (with optional leading colon).
|
||||
chanPart := strings.TrimPrefix(spec, ":")
|
||||
if chanPart == "" {
|
||||
return fmt.Errorf("empty channel after ':'")
|
||||
}
|
||||
return validateChannelOrVersion(chanPart)
|
||||
}
|
||||
|
||||
// validateURLSpec checks the URL-prefix or full-URL form. Splits on the
|
||||
// last `:` after the last `/` (matching apps.parseURLSpec behavior).
|
||||
func validateURLSpec(spec string) error {
|
||||
// Minimal sanity check on URL shape.
|
||||
if len(spec) <= len("https://") {
|
||||
return fmt.Errorf("URL is missing host")
|
||||
}
|
||||
lastSlash := strings.LastIndex(spec, "/")
|
||||
if lastSlash < 0 {
|
||||
return fmt.Errorf("invalid URL %q: missing path separator", spec)
|
||||
}
|
||||
afterSlash := spec[lastSlash+1:]
|
||||
colonInTail := strings.LastIndex(afterSlash, ":")
|
||||
urlPart, suffixPart := spec, ""
|
||||
if colonInTail >= 0 {
|
||||
urlPart = spec[:lastSlash+1+colonInTail]
|
||||
suffixPart = afterSlash[colonInTail+1:]
|
||||
}
|
||||
if strings.HasSuffix(urlPart, ".html") {
|
||||
if suffixPart != "" {
|
||||
return fmt.Errorf("URL ends in .html but has %q suffix", ":"+suffixPart)
|
||||
}
|
||||
return nil // terminal full URL
|
||||
}
|
||||
if suffixPart != "" {
|
||||
return validateChannelOrVersion(suffixPart)
|
||||
}
|
||||
return nil // URL-prefix only
|
||||
}
|
||||
|
||||
// validateChannelOrVersion enforces the channel/version shape.
|
||||
func validateChannelOrVersion(s string) error {
|
||||
if s == "stable" || s == "beta" || s == "alpha" {
|
||||
return nil
|
||||
}
|
||||
rest := strings.TrimPrefix(s, "v")
|
||||
if rest == "" {
|
||||
return fmt.Errorf("unrecognized source spec %q", s)
|
||||
}
|
||||
parts := strings.Split(rest, ".")
|
||||
if len(parts) > 3 {
|
||||
return fmt.Errorf("version has too many dots: %q", s)
|
||||
}
|
||||
for _, p := range parts {
|
||||
if p == "" {
|
||||
return fmt.Errorf("version has empty component: %q", s)
|
||||
}
|
||||
for _, r := range p {
|
||||
if r < '0' || r > '9' {
|
||||
return fmt.Errorf("unrecognized source spec %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateFile(zf ZddcFile) []FieldError {
|
||||
var errs []FieldError
|
||||
check := func(field string, vals []string) {
|
||||
|
|
@ -242,19 +133,23 @@ func ValidateFile(zf ZddcFile) []FieldError {
|
|||
Message: "title exceeds 200 characters",
|
||||
})
|
||||
}
|
||||
for app, spec := range zf.Apps {
|
||||
if !IsValidAppsKey(app) {
|
||||
// views: each entry names a known tool and (optionally) a config file
|
||||
// resolved under <dir>/.zddc.d/ — so it must be a safe relative filename
|
||||
// (no slashes, no traversal, no leading dot).
|
||||
for shape, v := range zf.Views {
|
||||
if v.Tool == "" || !IsKnownApp(v.Tool) {
|
||||
errs = append(errs, FieldError{
|
||||
Field: fmt.Sprintf("apps.%s", app),
|
||||
Message: fmt.Sprintf("unknown app %q (known: default, archive, transmittal, classifier, landing, browse, form, tables)", app),
|
||||
Field: fmt.Sprintf("views.%s.tool", shape),
|
||||
Message: fmt.Sprintf("unknown tool %q (known: %s)", v.Tool, strings.Join(AppNames, ", ")),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if err := ValidateAppSourceSpec(spec); err != nil {
|
||||
errs = append(errs, FieldError{
|
||||
Field: fmt.Sprintf("apps.%s", app),
|
||||
Message: err.Error(),
|
||||
})
|
||||
if v.Config != "" {
|
||||
if strings.ContainsAny(v.Config, "/\\") || v.Config == "." || v.Config == ".." || strings.HasPrefix(v.Config, ".") {
|
||||
errs = append(errs, FieldError{
|
||||
Field: fmt.Sprintf("views.%s.config", shape),
|
||||
Message: "config must be a plain filename (resolved under .zddc.d/); no slashes, traversal, or leading dot",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// worm: is a list of principal patterns (email-globs, @role:name,
|
||||
|
|
|
|||
|
|
@ -70,125 +70,6 @@ func TestValidateFile(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestValidateAppSourceSpec(t *testing.T) {
|
||||
cases := []struct {
|
||||
spec string
|
||||
ok bool
|
||||
}{
|
||||
// Channel shorthand (with and without leading colon)
|
||||
{"stable", true},
|
||||
{"beta", true},
|
||||
{"alpha", true},
|
||||
{":stable", true},
|
||||
{":beta", true},
|
||||
{":alpha", true},
|
||||
// Version pin shorthand (full, partial, with/without leading 'v')
|
||||
{"v0.0.4", true},
|
||||
{"0.0.4", true},
|
||||
{"v0.0", true},
|
||||
{"0.0", true},
|
||||
{"v0", true},
|
||||
{"0", true},
|
||||
{"v1.2.3", true},
|
||||
{":v0.0.4", true},
|
||||
{":0.0.4", true},
|
||||
// URLs
|
||||
{"https://zddc.varasys.io/releases/archive_stable.html", true},
|
||||
{"http://my-fork.example.com/archive.html", true},
|
||||
{"https://my-mirror.example/releases", true}, // URL-prefix only
|
||||
{"https://my-mirror.example/releases:stable", true}, // URL-prefix + channel
|
||||
{"https://my-mirror.example/releases:v0.0.4", true}, // URL-prefix + version
|
||||
{"https://my-mirror.example:8080/releases", true}, // URL with port
|
||||
{"https://my-mirror.example:8080/releases:stable", true}, // URL with port + channel
|
||||
// Paths
|
||||
{"/abs/path.html", true},
|
||||
{"./local.html", true},
|
||||
{"../sibling.html", true},
|
||||
// Errors
|
||||
{"", false},
|
||||
{" stable", false},
|
||||
{"stable ", false},
|
||||
{"with space", false},
|
||||
{"https://", false},
|
||||
{"https://host/path/file.html:stable", false}, // .html URL with suffix
|
||||
{"random-thing", false},
|
||||
{":", false},
|
||||
{":random", false},
|
||||
{"v", false},
|
||||
{"v0.", false},
|
||||
{".0.0", false},
|
||||
{"v0.0.0.0", false},
|
||||
{"v0.a.0", false},
|
||||
{"https://my-mirror.example/releases:bogus", false}, // bad channel suffix
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.spec, func(t *testing.T) {
|
||||
err := ValidateAppSourceSpec(tc.spec)
|
||||
if tc.ok && err != nil {
|
||||
t.Errorf("ValidateAppSourceSpec(%q) = %v, want nil", tc.spec, err)
|
||||
}
|
||||
if !tc.ok && err == nil {
|
||||
t.Errorf("ValidateAppSourceSpec(%q) = nil, want error", tc.spec)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidAppsKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
key string
|
||||
ok bool
|
||||
}{
|
||||
{"default", true},
|
||||
{"archive", true},
|
||||
{"transmittal", true},
|
||||
{"classifier", true},
|
||||
{"browse", true},
|
||||
{"landing", true},
|
||||
{"unknown", false},
|
||||
{"", false},
|
||||
{"DEFAULT", false}, // case-sensitive
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.key, func(t *testing.T) {
|
||||
if got := IsValidAppsKey(tc.key); got != tc.ok {
|
||||
t.Errorf("IsValidAppsKey(%q) = %v, want %v", tc.key, got, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFile_Apps(t *testing.T) {
|
||||
zf := ZddcFile{
|
||||
Apps: map[string]string{
|
||||
"archive": "stable", // ok
|
||||
"classifier": "v0.0.4", // ok
|
||||
"default": "https://zddc.varasys.io/releases:stable", // ok (default key + URL+channel)
|
||||
"transmittal": ":beta", // ok (channel-only)
|
||||
"browse": "https://my-mirror.example/releases", // ok (URL-prefix only)
|
||||
"unknown": "stable", // unknown app
|
||||
"landing": "what is this", // bad spec
|
||||
},
|
||||
}
|
||||
errs := ValidateFile(zf)
|
||||
want := map[string]bool{
|
||||
"apps.unknown": false,
|
||||
"apps.landing": false,
|
||||
}
|
||||
for _, e := range errs {
|
||||
if _, ok := want[e.Field]; ok {
|
||||
want[e.Field] = true
|
||||
} else {
|
||||
t.Errorf("unexpected error field: %q (%s)", e.Field, e.Message)
|
||||
}
|
||||
}
|
||||
for f, seen := range want {
|
||||
if !seen {
|
||||
t.Errorf("missing error for field %q (got: %+v)", f, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProjectName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -57,9 +57,6 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
|
|||
if top.Title != "" {
|
||||
out.Title = top.Title
|
||||
}
|
||||
if top.AppsPubKey != "" {
|
||||
out.AppsPubKey = top.AppsPubKey
|
||||
}
|
||||
if top.CreatedBy != "" {
|
||||
out.CreatedBy = top.CreatedBy
|
||||
}
|
||||
|
|
@ -124,10 +121,22 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
|
|||
}
|
||||
|
||||
out.ACL.Permissions = mergeStringMap(out.ACL.Permissions, top.ACL.Permissions)
|
||||
out.Apps = mergeStringMap(out.Apps, top.Apps)
|
||||
out.Tables = mergeStringMap(out.Tables, top.Tables)
|
||||
out.Display = mergeStringMap(out.Display, top.Display)
|
||||
|
||||
// Views: per-shape latest-wins (a deeper level overrides a shape, others
|
||||
// inherit). Mirror of the Roles/Records map merge.
|
||||
if len(top.Views) > 0 {
|
||||
merged := make(map[string]ViewSpec, len(out.Views)+len(top.Views))
|
||||
for k, v := range out.Views {
|
||||
merged[k] = v
|
||||
}
|
||||
for k, v := range top.Views {
|
||||
merged[k] = v
|
||||
}
|
||||
out.Views = merged
|
||||
}
|
||||
|
||||
// Convert: per-key latest-wins. Pointer-to-struct so we can tell
|
||||
// "absent" from "explicitly empty" — the latter is rare but valid
|
||||
// (an operator who wants to suppress a deployment-default value).
|
||||
|
|
|
|||
Loading…
Reference in a new issue