Compare commits
34 commits
a62960b712
...
cef7188a77
| Author | SHA1 | Date | |
|---|---|---|---|
| cef7188a77 | |||
| 847e082e6e | |||
| 73e34bed5e | |||
| 351dc63cb4 | |||
| da4754b6ef | |||
| 85e6eb152c | |||
| c240bf30a5 | |||
| 8aebb0c346 | |||
| 19566360a6 | |||
| cff840e225 | |||
| f196205622 | |||
| ae105fde1c | |||
| df19a63853 | |||
| b80b11c99f | |||
| fd4f03afc3 | |||
| 63fc4338b6 | |||
| 55328c8c28 | |||
| ded9ff7883 | |||
| a85b25ce08 | |||
| 6c818648ca | |||
| 465d2f605c | |||
| 1c0777a847 | |||
| cfa7732183 | |||
| 1d758780fe | |||
| 03d008ff0a | |||
| 4497ebdf99 | |||
| c2423f8873 | |||
| 690d185dc2 | |||
| b4c0327f63 | |||
| 167a56dc07 | |||
| 050902fa9e | |||
| 2d114fcb96 | |||
| 94b2e29448 | |||
| e5ba2b6168 |
159 changed files with 14851 additions and 5443 deletions
62
AGENTS.md
62
AGENTS.md
|
|
@ -165,6 +165,27 @@ All JS is vanilla, no bundlers. Files are IIFEs, registered on `window.app.modul
|
|||
|
||||
**Exception:** archive uses plain globals (`APP_STATE`, top-level functions) — not the IIFE/modules pattern.
|
||||
|
||||
**State values used inside event handlers must be read fresh from the source of truth, never captured at mount.** No bundler, no reactivity layer — closures don't get refreshed when the underlying state mutates. Cache the *node*, re-read the *bit* at click time:
|
||||
|
||||
```javascript
|
||||
// Wrong — `writable` is whatever canSave returned at mount, even if
|
||||
// the tree node's bit later flips to true (e.g. admin toggle reload
|
||||
// re-fetched the listing).
|
||||
var writable = canSave(node);
|
||||
saveBtn.addEventListener('click', function () {
|
||||
if (!writable) return; // STALE
|
||||
});
|
||||
|
||||
// Right — re-read at click time.
|
||||
saveBtn.addEventListener('click', function () {
|
||||
if (!canSave(node)) return; // current
|
||||
});
|
||||
```
|
||||
|
||||
It's fine to use mount-time captures for *initial UI shape* (read-only banner, CodeMirror `readOnly:'nocursor'`, etc.) — those decisions are correct at the moment they're applied. The rule is specifically about gating logic in handlers that fire *after* mount.
|
||||
|
||||
This pattern bit twice in the markdown + YAML editors before we caught it: the typo `writable` (undefined) vs `writableMode` (captured) made every save click a no-op. Re-reading the source of truth would have surfaced the bug at click time instead of silently disabling save.
|
||||
|
||||
## ZDDC filename parsers
|
||||
|
||||
All parsing/formatting goes through `shared/zddc.js`, exposed as `window.zddc`. Tools call it directly — no per-tool wrappers.
|
||||
|
|
@ -324,17 +345,30 @@ The markdown editor lives at `browse/js/preview-markdown.js` and is mounted as t
|
|||
|
||||
## Server-side document conversion (`zddc/internal/convert`)
|
||||
|
||||
zddc-server can convert `.md` → DOCX/HTML/PDF on demand at `GET /<path>/foo.md?convert=docx|html|pdf`. Implementation:
|
||||
zddc-server can convert `.md` → DOCX/HTML/PDF on demand at `GET /<path>/foo.md?convert=docx|html|pdf`.
|
||||
|
||||
- **Two upstream images, pulled on first use.** No custom image build. Operator just needs `podman` or `docker` installed; the runner passes `--pull=missing` so the first request pulls each image and subsequent requests use the local cache.
|
||||
- `docker.io/pandoc/latex:latest` — pandoc's official image, entrypoint `pandoc`. Used for MD → DOCX and MD → HTML. Override via `--convert-pandoc-image=` / `ZDDC_CONVERT_PANDOC_IMAGE` (e.g. switch to `docker.io/pandoc/core:latest` for a ~90% size reduction).
|
||||
- `docker.io/zenika/alpine-chrome:latest` — Zenika's Alpine + Chromium image, entrypoint `chromium-browser`. Used for HTML → PDF (the PDF flow is two-stage: pandoc image emits HTML using viewer-template.html, chromium image prints it). Override via `--convert-chromium-image=` / `ZDDC_CONVERT_CHROMIUM_IMAGE`.
|
||||
- Engine is podman preferred, docker fallback (`--convert-engine=` / `ZDDC_CONVERT_ENGINE` to override). No host pandoc or chromium needed.
|
||||
- Each conversion runs in a throw-away container with `--rm --pull=missing --network=none --read-only --tmpfs=/tmp:size=128m,exec --memory --cpus --pids-limit --cap-drop=ALL --security-opt=no-new-privileges --env=HOME=/tmp`. Resource caps via `--convert-mem-mib` (default 512), `--convert-cpus` (default "2"), `--convert-pids` (default 100), `--convert-timeout` (default 30s). `--user` is intentionally not set so each image uses its default (root for pandoc/latex, uid 1000 for alpine-chrome) — the other flags already provide strong isolation and overriding the user would break alpine-chrome's user-data-dir layout.
|
||||
- I/O via bind mount + stdin/stdout. Pandoc reads markdown from stdin, writes to stdout. The viewer template is bind-mounted read-only at `/tpl`. Chromium reads HTML from a read-write bind mount at `/pdf` and writes the PDF to the same mount; the host reads it back.
|
||||
**Architecture.** zddc-server's Go code does the bare minimum: it `exec.Command("pandoc", args...)` or `exec.Command("chromium-browser", args...)`. **The sandbox + resource caps live in the IMAGE**, not in Go. In the production runtime image (`zddc/runtime.Containerfile`), `/usr/local/bin/pandoc` and `/usr/local/bin/chromium-browser` are symlinks to `zddc-sandbox-exec` — a shell wrapper that:
|
||||
|
||||
1. Creates a transient cgroup v2 (memory + pids cap from `ZDDC_CONV_MEM_MAX` / `ZDDC_CONV_PIDS_MAX` env), moves itself in.
|
||||
2. Wraps the real binary at `/usr/bin/<name>` in a bubblewrap sandbox (`--unshare-all --unshare-user-try --die-with-parent --ro-bind /usr /usr ... --proc /proc --dev /dev --tmpfs /tmp --clearenv`).
|
||||
3. exec's `/usr/bin/<name>` with the original argv.
|
||||
|
||||
Why this shape: swapping isolation strategies (firejail, systemd-nspawn, podman-run, raw exec for dev) is purely an image concern. The Go code never changed. A separate `zddc-cgroup-init` script runs at container start to delegate cgroup v2 `subtree_control` (the "no internal processes" constraint), then exec's zddc-server. Both scripts live in `zddc/runtime/`.
|
||||
|
||||
**Outer-container privileges.** Nested bwrap needs the outer container to permit user + mount namespace creation. Pod Security Standards defaults block this. The helm chart sets `securityContext: capabilities.add: [SYS_ADMIN]`, `seccompProfile.type: Unconfined`, `appArmorProfile.type: Unconfined`. Trade-off: a zddc-server RCE has near-root power within the container's namespace, but the bind-mount layout (overlay fs, no host /home or /usr visible) still bounds the blast radius. The per-conversion bwrap sandbox is the real isolation boundary between zddc-server and untrusted pandoc/chromium.
|
||||
|
||||
**Config knobs** (all in `cmd/zddc-server`):
|
||||
- `--convert-pandoc-binary` (default `pandoc`) / `--convert-chromium-binary` (default `chromium-browser`; `chromium` on debian)
|
||||
- `--convert-scratch-dir` (default `$TMPDIR`) — host scratch root; the wrapper bind-mounts the per-call subdir
|
||||
- `--convert-mem-mib` (default 1024) → wrapper's `memory.max`
|
||||
- `--convert-pids` (default 256) → wrapper's `pids.max`
|
||||
- `--convert-timeout` (default 60s) → enforced in Go via `context.WithTimeout`
|
||||
|
||||
**Other plumbing.**
|
||||
- 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`.
|
||||
- If neither podman nor docker is present, the endpoint serves 503 with a Retry-After. The rest of the server keeps working.
|
||||
- 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)
|
||||
|
||||
|
|
@ -569,6 +603,18 @@ The tokens directory inherits the existing `.zddc.d/` exclusion: dot-prefix segm
|
|||
|
||||
Implementation: `zddc/internal/auth/` (storage), `zddc/internal/handler/tokenhandler.go` (HTTP layer), middleware extension in `zddc/internal/handler/middleware.go`.
|
||||
|
||||
### Admin elevation (sudo-style)
|
||||
|
||||
Admins are treated as normal users by default; admin escape hatches (WORM bypass, auto-own takeover, `.zddc` edit authority, profile admin scaffolds) require an explicit per-request opt-in. The toggle lives in every tool's header (left of the theme button) and writes a `zddc-elevate=1` cookie (Max-Age=1800, SameSite=Lax) — 30-minute sudo window before it auto-expires.
|
||||
|
||||
Server-side the model is `zddc.Principal{Email, Elevated}`. `ACLMiddleware` builds it once per request and stashes it in context; `IsAdmin` / `IsSubtreeAdmin` / `CanEditZddc` take a `Principal` parameter rather than a bare email. That signature change is the enforcement mechanism — the compiler tells you when an admin call site doesn't thread elevation, so a "forgot to gate this" mistake doesn't compile. `PrincipalFromContext(r)` is the one-call-per-site bundling helper.
|
||||
|
||||
Bearer tokens are **implicitly elevated** — CLI clients and the mirror process can't toggle a cookie, and their authority is the bearer's full grant by design. Browser sessions elevate only when the user opts in.
|
||||
|
||||
`/.profile/access` exposes `can_elevate` (elevation-independent "does this email have any admin grant anywhere?") so the header toggle can render itself for an un-elevated admin who hasn't opted in yet. The access log captures `elevated=<true|false>` per request for forensics.
|
||||
|
||||
Implementation: `zddc/internal/zddc/admin.go` (Principal struct + gated functions), `zddc/internal/handler/middleware.go` (cookie/bearer → ElevatedKey context value), `shared/elevation.{js,css}` (header toggle UI, concat'd into every tool's bundle).
|
||||
|
||||
### Release tagging
|
||||
|
||||
zddc-server has no separate release script. The top-level `./build alpha|beta|release [version]` is the canonical path: it cross-compiles the binaries inside the containerized Go toolchain, copies them into `dist/release-output/` with the lockstep symlink chain (one set of symlinks per platform), regenerates the per-version + per-channel stub pages, refreshes the index, and (on stable) tags `zddc-server-v<X.Y.Z>` alongside the eight HTML-tool tags.
|
||||
|
|
|
|||
|
|
@ -403,7 +403,7 @@ Files at the root level are ignored. The grouping folder list and transmittal fo
|
|||
|
||||
**Dependencies:** Toast UI Editor v3.2.2 (vendored at `shared/vendor/toastui-editor-all.min.js`, concatenated into `browse/dist/browse.html` at build time). No runtime CDN, no Tailwind.
|
||||
|
||||
**Server-mode features:** When the file handle is an `HttpFileHandle` (so `node.url` is set and `state.source === 'server'`), three Download buttons appear in the file header — DOCX/HTML/PDF — fetching `?convert=<fmt>` via `window.zddc.source.downloadConverted()`. Clicks auto-save first if the buffer is dirty so converted bytes reflect what's on screen. See `zddc/internal/convert` for the server-side engine.
|
||||
**Server-mode features:** When the file handle is an `HttpFileHandle` (so `node.url` is set and `state.source === 'server'`), three Download buttons appear in the file header — DOCX/HTML/PDF — fetching `?convert=<fmt>` via `window.zddc.source.downloadConverted()`. Clicks auto-save first if the buffer is dirty so converted bytes reflect what's on screen. The server-side engine is in `zddc/internal/convert`: zddc-server `exec.Command`s `pandoc` and `chromium-browser` directly, and the runtime image's wrapper at `/usr/local/bin/<name>` (see `zddc/runtime.Containerfile` + `zddc/runtime/zddc-sandbox-exec`) handles the per-call cgroup v2 + bubblewrap sandbox between that exec and the real binary at `/usr/bin/<name>`. Isolation strategy lives entirely in the image; swap the wrapper for firejail / nspawn / podman-run and Go doesn't change.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -498,7 +498,7 @@ none of them is load-bearing alone.
|
|||
|---|---|---|
|
||||
| Authentication | Establish caller identity (email) | Two paths: `Authorization: Bearer <token>` validated against `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` (CLI / scripted callers); or `X-Auth-Request-Email` injected by an upstream auth proxy (browser users). Token system is built-in and self-issuing — no external IDP required |
|
||||
| Policy decider | Yield an allow/deny verdict for (identity, path, chain) | Pluggable via `ZDDC_OPA_URL`: in-process Go evaluator (default) or external OPA-compatible HTTP/socket endpoint. `zddc/internal/policy/` |
|
||||
| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, plus a baked-in `defaults.zddc.yaml` bottom layer (`zddc-server show-defaults`) that uses a recursive `paths:` tree to describe subfolder rules even before those folders exist. Walked deepest-first first-match-wins under `--cascade-mode=delegated`, or with absolute ancestor denies under `--cascade-mode=strict` (`zddc/internal/zddc/cascade.go`, `walker.go`, `acl.go`). External OPA can replace this rule set with arbitrary Rego while keeping the same `.zddc` files as input data |
|
||||
| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, plus a baked-in `defaults.zddc.yaml` bottom layer (`zddc-server show-defaults`) that uses a recursive `paths:` tree to describe subfolder rules even before those folders exist. Walked deepest-first first-match-wins (`zddc/internal/zddc/cascade.go`, `walker.go`, `acl.go`). External OPA can replace this rule set with arbitrary Rego (the bundled `access_federal.rego` is the parent-deny-is-absolute / NIST AC-6 variant) while keeping the same `.zddc` files as input data |
|
||||
| Canonical-folder behaviour | Codify the bilateral exchange-record archetype | All driven by `.zddc` keys (baked into `defaults.zddc.yaml`): `auto_own:` / `auto_own_fenced:` — mkdir here writes a creator-owned `.zddc` (`<email>: rwcda`; fenced adds `acl.inherit:false`); `worm: [principal…]` — write-once-read-many (`w`/`d`/`a` stripped for everyone non-admin, `c` survives only for the listed principals; admins exempt); `virtual:` — never materialise on disk; `drop_target:` — browse shows a drag-drop upload overlay. The defaults put `auto_own` on `working`/`staging`/`archive-party`/`incoming` and `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical convention is unchanged — but an operator can reshape it (rename `received`/`issued`, mark any path WORM, …) without a code change. `zddc/internal/zddc/lookups.go`, `worm.go`, `roles.go`; `defaults.zddc.yaml` |
|
||||
| Tool-rooted view | Make the caller's accessible subtree feel like their entire world (UX containment) | Archive auto-served at every directory; the URL it's served at *is* its root. No breadcrumb leads above |
|
||||
| URL canonicalization | Resolve URL paths to on-disk casing before any layer below sees them | `zddc/internal/fs/resolve.go ResolveCanonical` — case-insensitive lookup with lowercase-wins tiebreak when sibling case variants exist on disk. File and folder names preserve case on disk; the canonicalization is purely URL→FS-name mapping. Virtual prefixes (`.archive`, `.profile`, `.tokens`) flow through verbatim |
|
||||
|
|
@ -654,7 +654,7 @@ whether to deploy the system should know which column they're in.
|
|||
| Cryptography | Go stdlib defaults | FIPS 140-3 validated module (microsoft/go or RHEL FIPS) |
|
||||
| TLS | Go stdlib defaults | Explicit MinVersion ≥ TLS 1.2, DoD-approved cipher allowlist, OCSP stapling, HSTS |
|
||||
| Access model | Per-verb (`r`/`w`/`c`/`d`/`a`) with first-class roles and an admin escape hatch — closes NIST AC-3(7) | (closed by default; external Rego still available for org-specific policy via `ZDDC_OPA_URL`) |
|
||||
| Subtree authority | Operator-toggled cascade mode: `delegated` (default — leaf grants override ancestor denies) or `strict` (`--cascade-mode=strict` — ancestor explicit-denies are absolute, NIST AC-6) | (closed; `strict` is the federal posture) |
|
||||
| Subtree authority | In-process decider: leaf grants override ancestor denies (delegation primitive). Federal posture: deploy OPA with `access_federal.rego` for ancestor-deny-absolute / NIST AC-6 | (closed; federal posture is the OPA path) |
|
||||
| Audit log integrity | Local lumberjack rotation, filesystem-trusted | Tamper-evident (signed chain or external append-only sink), 1y online + 3y archive |
|
||||
| Information disclosure | Anonymous reaches `/` and `/.profile` (project picker, public-projects names) | All endpoints behind authenticated proxy; no anonymous discovery |
|
||||
| Apps URL fetches | Fetch-once-cached, no integrity check | SHA-256 pin + signature verification |
|
||||
|
|
@ -675,12 +675,9 @@ Five permission verbs gate every read and write:
|
|||
| `d` | delete a file |
|
||||
| `a` | modify the ACL of this subtree (write `.zddc`) |
|
||||
|
||||
`.zddc` files express grants under `acl.permissions: { principal → verb-set }`. A principal containing `@` is an email pattern matched by `MatchesPattern` (existing glob); a bare name is a role looked up against `roles:` definitions, walking the cascade for the closest definition. Empty verb set is an explicit deny. Legacy `acl.allow` / `acl.deny` lists fold into `permissions` at parse time (`allow` → `rwcd`, `deny` → `""`), so existing deployments behave identically.
|
||||
`.zddc` files express grants under `acl.permissions: { principal → verb-set }`. A principal containing `@` is an email pattern matched by `MatchesPattern` (existing glob); a bare name is a role looked up against `roles:` definitions, walking the cascade for the closest definition. Empty verb set is an explicit deny.
|
||||
|
||||
Cascade evaluation walks leaf→root for the first level whose entries match the user; the union of matching verb sets at that level wins. Operators select the precedence model for ancestor denies via `--cascade-mode`:
|
||||
|
||||
- `delegated` (default) — historical commercial behavior; a leaf allow overrides an ancestor explicit-deny.
|
||||
- `strict` — NIST AC-6 posture; an ancestor explicit-deny is absolute and cannot be overridden by any leaf grant.
|
||||
Cascade evaluation walks leaf→root for the first level whose entries match the user; the union of matching verb sets at that level wins. A leaf allow overrides an ancestor explicit-deny — that's the load-bearing delegation primitive that lets a subtree owner grant access without root-admin involvement. Operators who need the opposite rule (ancestor-deny-absolute, NIST AC-6) deploy OPA with the bundled `access_federal.rego`.
|
||||
|
||||
The `admins:` field in the root `.zddc` and any subtree `.zddc` remains the bypass: root admins (`IsAdmin`) and subtree admins (`IsSubtreeAdmin`) get unconditional `rwcda` and skip both the cascade and the WORM mask.
|
||||
|
||||
|
|
|
|||
|
|
@ -82,5 +82,6 @@ No lint/typecheck/format commands exist for the HTML tools — vanilla JS + POSI
|
|||
- **`</` in JS string/template literals breaks inline `<script>`** embedding. `shared/build-lib.sh` provides `escape_js_close_tags`; every tool's `build.sh` runs JS through it before inlining.
|
||||
- **All ZDDC parsing/formatting/hashing goes through `window.zddc`** (from `shared/zddc.js` + `shared/hash.js` + `shared/zddc-filter.js`). API: `parseFilename`, `parseFolder`, `parseRevision`, `formatFilename`, `formatFolder`, `compareRevisions`, `isValidStatus`, `splitExtension`, `joinExtension`, `crypto.{sha256Hex, sha256String, sha256File, bytesToHex}`, `filter.{parse, matches}`. File objects across tools use `trackingNumber` (string) and `extension` (string, **no leading dot** — use `zddc.joinExtension(name, ext)` to build a filename). Add edge cases to `tests/zddc.spec.js`, not per-tool tests.
|
||||
- **Two globals only**: `window.app` (per-tool app state + modules) and `window.zddc` (shared library). No others — anything that crosses tool boundaries goes through one of these.
|
||||
- **Admin elevation is sudo-style.** Admins behave as normal users by default; opting into admin powers is per-request and gated by the `zddc-elevate=1` cookie (Max-Age=1800, set by the header toggle in every tool). Server-side: `zddc.Principal{Email, Elevated}` is built once per request by `handler.ACLMiddleware` and threaded into `IsAdmin`/`IsSubtreeAdmin`/`CanEditZddc` — the compiler enforces the gate at every admin call site (no easy "forgot to check elevation" mistake). Bearer-token requests are implicitly elevated since CLI clients can't toggle a cookie; browser sessions elevate only when the user clicks the header checkbox. `/.profile/access` exposes `can_elevate` (elevation-independent "does this email have admin authority anywhere?") so the header toggle can decide whether to render itself for an un-elevated admin. The access-log captures the `elevated` flag per request for forensics.
|
||||
- **Worktrees live at `~/src/zddc-<branch>`.** Check `git worktree list` before starting a feature branch; never `git checkout`/`switch` inside a worktree another agent might be using.
|
||||
- **Build scripts are POSIX `sh` with `set -eu`**, not bash. `concat_files` takes positional args only.
|
||||
|
|
|
|||
4
archive/build.sh
Normal file → Executable file
4
archive/build.sh
Normal file → Executable file
|
|
@ -22,7 +22,7 @@ concat_files \
|
|||
"../shared/fonts.css" \
|
||||
"../shared/base.css" \
|
||||
"../shared/toast.css" \
|
||||
"../shared/nav.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/logo.css" \
|
||||
"css/base.css" \
|
||||
"css/layout.css" \
|
||||
|
|
@ -46,7 +46,6 @@ concat_files \
|
|||
"../shared/zip-source.js" \
|
||||
"../shared/theme.js" \
|
||||
"../shared/toast.js" \
|
||||
"../shared/nav.js" \
|
||||
"../shared/logo.js" \
|
||||
"../shared/preview-lib.js" \
|
||||
"js/init.js" \
|
||||
|
|
@ -64,6 +63,7 @@ concat_files \
|
|||
"js/events.js" \
|
||||
"js/app.js" \
|
||||
"../shared/help.js" \
|
||||
"../shared/elevation.js" \
|
||||
> "$js_raw"
|
||||
|
||||
# Escape '</' in inlined JS so the HTML parser cannot mistake string contents
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@
|
|||
|
||||
// Apply UI differences based on source mode
|
||||
function applySourceModeUI() {
|
||||
// "Add Local Directory" button is always visible in both modes —
|
||||
// "Use Local Directory" button is always visible in both modes —
|
||||
// in HTTP mode the user can augment the online archive with local directories.
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,11 +32,17 @@
|
|||
<span class="app-header__title">ZDDC Archive</span>
|
||||
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
<!-- Elevation toggle slot. shared/elevation.js fills it
|
||||
when /.profile/access reports the user has admin
|
||||
authority; stays empty + hidden for non-admins so
|
||||
the chrome is quiet for the common case. -->
|
||||
<span id="elevation-toggle" class="elevation-toggle hidden"
|
||||
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
<button id="help-btn" class="btn btn-secondary" title="Help">?</button>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -240,7 +246,7 @@
|
|||
<div id="noDirectoryMessage" class="empty-state empty-state--overlay">
|
||||
<div class="empty-state__inner empty-state__inner--centered">
|
||||
<h2>Welcome to ZDDC Archive</h2>
|
||||
<p>Click <strong>Add Local Directory</strong> to select an archive folder to browse.</p>
|
||||
<p>Click <strong>Use Local Directory</strong> to select an archive folder to browse.</p>
|
||||
<p>This browser provides a convenient interface for searching and retrieving files from ZDDC-compliant archives.</p>
|
||||
<p><strong>How to navigate:</strong></p>
|
||||
<ul class="welcome-list">
|
||||
|
|
@ -285,7 +291,7 @@
|
|||
<h3>Getting Started</h3>
|
||||
<ol>
|
||||
<li>When opened from a web server, the archive loads automatically from that server.</li>
|
||||
<li>Click <strong>Add Local Directory</strong> to open a local archive folder — works in both offline and online modes, and local files are merged with any server files already loaded.</li>
|
||||
<li>Click <strong>Use Local Directory</strong> to open a local archive folder — works in both offline and online modes, and local files are merged with any server files already loaded.</li>
|
||||
<li>The browser scans for grouping folders and transmittal folders automatically.</li>
|
||||
<li>Select folders in the left panel to see their files in the main table.</li>
|
||||
</ol>
|
||||
|
|
|
|||
|
|
@ -24,11 +24,14 @@ concat_files \
|
|||
"../shared/fonts.css" \
|
||||
"../shared/base.css" \
|
||||
"../shared/toast.css" \
|
||||
"../shared/nav.css" \
|
||||
"../shared/logo.css" \
|
||||
"../shared/vendor/toastui-editor.min.css" \
|
||||
"../shared/vendor/codemirror-yaml.min.css" \
|
||||
"../shared/context-menu.css" \
|
||||
"../shared/elevation.css" \
|
||||
"css/base.css" \
|
||||
"css/tree.css" \
|
||||
"css/preview-yaml.css" \
|
||||
> "$css_temp"
|
||||
|
||||
# JS files: shared canonical helpers, then browse modules.
|
||||
|
|
@ -39,25 +42,35 @@ concat_files \
|
|||
concat_files \
|
||||
"../shared/vendor/jszip.min.js" \
|
||||
"../shared/vendor/utif.min.js" \
|
||||
"../shared/vendor/js-yaml.min.js" \
|
||||
"../shared/vendor/codemirror-yaml.min.js" \
|
||||
"../shared/vendor/toastui-editor-all.min.js" \
|
||||
"../shared/zddc.js" \
|
||||
"../shared/zddc-filter.js" \
|
||||
"../shared/zip-source.js" \
|
||||
"../shared/theme.js" \
|
||||
"../shared/toast.js" \
|
||||
"../shared/nav.js" \
|
||||
"../shared/logo.js" \
|
||||
"../shared/help.js" \
|
||||
"../shared/preview-lib.js" \
|
||||
"../shared/context-menu.js" \
|
||||
"../shared/elevation.js" \
|
||||
"../shared/icons.js" \
|
||||
"../shared/zddc-source.js" \
|
||||
"js/init.js" \
|
||||
"js/loader.js" \
|
||||
"js/tree.js" \
|
||||
"js/preview.js" \
|
||||
"js/preview-markdown.js" \
|
||||
"js/preview-yaml.js" \
|
||||
"js/hovercard.js" \
|
||||
"js/grid.js" \
|
||||
"js/upload.js" \
|
||||
"js/download.js" \
|
||||
"js/plan-review.js" \
|
||||
"js/accept-transmittal.js" \
|
||||
"js/stage.js" \
|
||||
"js/create-transmittal.js" \
|
||||
"js/events.js" \
|
||||
"js/app.js" \
|
||||
> "$js_raw"
|
||||
|
|
|
|||
|
|
@ -40,3 +40,20 @@ body {
|
|||
|
||||
.status-bar--error { color: #b00020; }
|
||||
.status-bar--info { color: var(--primary); }
|
||||
|
||||
/* Read-only banner for the YAML editor — surfaced by preview-yaml.js
|
||||
when the listing's `writable` bit was false. CodeMirror's readOnly
|
||||
mode has no built-in visual signal beyond the disabled caret, so a
|
||||
banner here is the explicit cue. The markdown editor doesn't need
|
||||
one because its read-only mount uses Toast UI's Viewer (no edit
|
||||
toolbar at all). */
|
||||
.yaml-readonly-banner {
|
||||
background: rgba(220, 53, 69, 0.10);
|
||||
color: var(--text);
|
||||
border-bottom: 1px solid rgba(220, 53, 69, 0.35);
|
||||
padding: 0.4rem 0.7rem;
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
|
|
|||
110
browse/css/preview-yaml.css
Normal file
110
browse/css/preview-yaml.css
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/* preview-yaml.css — YAML editor pane styling. Mirrors the
|
||||
.md-shell info-header geometry; everything below is a CodeMirror 5
|
||||
host with dark-mode overrides so the editor blends into the theme
|
||||
instead of fighting it. */
|
||||
|
||||
.yaml-shell {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.yaml-shell__editor {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Schema-label badge — extends .md-shell__source so it sits next to
|
||||
"local"/"server"/"read-only (zip)" with the same chip styling. The
|
||||
primary-colored variant distinguishes ".zddc schema" from the
|
||||
plain "YAML" label. */
|
||||
.yaml-shell__schema {
|
||||
font-style: normal;
|
||||
}
|
||||
.yaml-shell__schema:not(:empty) {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* CodeMirror has to fill the grid cell. The vendored CSS sets
|
||||
`height: 300px` by default — we override to 100% so it grows with
|
||||
the preview pane. */
|
||||
.yaml-shell__editor .CodeMirror {
|
||||
height: 100%;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.yaml-shell__editor .CodeMirror-gutters {
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.yaml-shell__editor .CodeMirror-linenumber {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.yaml-shell__editor .CodeMirror-cursor {
|
||||
border-left-color: var(--text);
|
||||
}
|
||||
|
||||
.yaml-shell__editor .CodeMirror-selected {
|
||||
background: var(--bg-selected);
|
||||
}
|
||||
|
||||
.yaml-shell__editor .CodeMirror-focused .CodeMirror-selected {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
/* YAML token tints. CM5 emits semantic class names from the yaml
|
||||
mode; map them onto our palette so themes flip with the OS / data
|
||||
attribute. */
|
||||
.yaml-shell__editor .cm-keyword,
|
||||
.yaml-shell__editor .cm-atom { color: var(--primary); font-weight: 600; }
|
||||
.yaml-shell__editor .cm-string { color: #2e8b57; }
|
||||
.yaml-shell__editor .cm-comment { color: var(--text-muted); font-style: italic; }
|
||||
.yaml-shell__editor .cm-number { color: #b06000; }
|
||||
.yaml-shell__editor .cm-meta { color: #6f42c1; }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html:not([data-theme="light"]) .yaml-shell__editor .cm-string { color: #98c379; }
|
||||
html:not([data-theme="light"]) .yaml-shell__editor .cm-number { color: #e5c07b; }
|
||||
html:not([data-theme="light"]) .yaml-shell__editor .cm-meta { color: #c678dd; }
|
||||
}
|
||||
[data-theme="dark"] .yaml-shell__editor .cm-string { color: #98c379; }
|
||||
[data-theme="dark"] .yaml-shell__editor .cm-number { color: #e5c07b; }
|
||||
[data-theme="dark"] .yaml-shell__editor .cm-meta { color: #c678dd; }
|
||||
|
||||
/* Lint markers: keep CM's defaults for the gutter dots but make the
|
||||
inline underline play nicely with our background. Errors stay red,
|
||||
warnings amber. */
|
||||
.yaml-shell__editor .CodeMirror-lint-mark-error {
|
||||
background-image: none;
|
||||
border-bottom: 2px wavy var(--danger);
|
||||
}
|
||||
.yaml-shell__editor .CodeMirror-lint-mark-warning {
|
||||
background-image: none;
|
||||
border-bottom: 2px wavy var(--warning);
|
||||
}
|
||||
|
||||
/* Tooltip popping out of a lint marker — uses the shared menu shadow
|
||||
so it doesn't look like a separate component. */
|
||||
.CodeMirror-lint-tooltip {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18),
|
||||
0 2px 6px rgba(0, 0, 0, 0.10);
|
||||
font-family: var(--font);
|
||||
font-size: 0.82rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
|
@ -4,15 +4,33 @@ html, body {
|
|||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: var(--font);
|
||||
color: var(--text);
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
/* Body is a flex column so the header (which may wrap to a second
|
||||
row at narrow viewports), #appMain, and the status bar each get
|
||||
their natural height — no more fixed-pixel calc() that breaks
|
||||
when the header reflows. Horizontal overflow scrolls on the body
|
||||
as a final fallback when content can't shrink any further. */
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
/* Hard floor for the body. Below this, the html-level scrollbar
|
||||
picks up and the user can pan horizontally rather than seeing
|
||||
the right edge clipped. */
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
#appMain {
|
||||
position: relative;
|
||||
height: calc(100vh - 2.65rem); /* clear .app-header */
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: auto; /* override the old calc(100vh - 2.65rem) */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
|
@ -109,12 +127,6 @@ html, body {
|
|||
vertical-align: -0.15em;
|
||||
}
|
||||
|
||||
.toolbar__count {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Two-pane browse view ────────────────────────────────────────────────── */
|
||||
|
||||
.browse-view {
|
||||
|
|
@ -139,6 +151,42 @@ html, body {
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-pane__toolbar {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Single-input autofilter — same grammar as the archive app's column
|
||||
filters (terms, quotes, !negation, multi-word AND). type=search so
|
||||
the browser ships the native clear-X for free; the .filter-active
|
||||
class amber-highlights the input while a query is set, matching
|
||||
the archive `.column-filter.filter-active` cue. */
|
||||
.tree-filter {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-family: var(--font);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
outline: none;
|
||||
transition: border-color 0.12s, background 0.12s;
|
||||
}
|
||||
|
||||
.tree-filter:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px var(--primary-light);
|
||||
}
|
||||
|
||||
.tree-filter.filter-active {
|
||||
background: rgba(234, 179, 8, 0.18);
|
||||
border-color: rgba(234, 179, 8, 0.7);
|
||||
}
|
||||
|
||||
.tree-pane__body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
|
|
@ -250,9 +298,12 @@ html, body {
|
|||
|
||||
.tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* Top-aligned so the chevron + icon anchor to the title line on
|
||||
two-line ZDDC rows. Single-line rows are unaffected because the
|
||||
icon, chevron, and label all share a top edge. */
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: 0;
|
||||
|
|
@ -268,37 +319,91 @@ html, body {
|
|||
color: var(--text);
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
.tree-row.is-droptarget {
|
||||
background: var(--primary-light);
|
||||
outline: 2px dashed var(--primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.tree-row.is-selected .tree-name__label {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tree-name__chevron {
|
||||
display: inline-block;
|
||||
/* Fixed-width slot so leaf rows (empty chevron) still align with
|
||||
expandable rows. The SVG inside is sized via the rule below.
|
||||
Top-anchored to the title-line baseline by the row's flex-start
|
||||
alignment + this small top offset. */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
height: 1.2em;
|
||||
flex-shrink: 0;
|
||||
font-family: monospace;
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tree-row[data-isdir="true"] .tree-name__chevron::before,
|
||||
.tree-row[data-iszip="true"] .tree-name__chevron::before {
|
||||
content: "▸";
|
||||
.tree-name__chevron svg {
|
||||
width: 0.85em;
|
||||
height: 0.85em;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
|
||||
.tree-row[data-isdir="true"].expanded .tree-name__chevron::before,
|
||||
.tree-row[data-iszip="true"].expanded .tree-name__chevron::before {
|
||||
content: "▾";
|
||||
}
|
||||
|
||||
.tree-name__chevron--leaf::before {
|
||||
content: "";
|
||||
/* Expanded state — rotate the same chevron 90° rather than swapping
|
||||
to a second glyph. Smooth, single-sprite, and consistent with the
|
||||
way most modern file trees indicate expand state. */
|
||||
.tree-row.expanded .tree-name__chevron svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.tree-name__icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.95rem;
|
||||
/* Stacked column — glyph on top, extension chip below for files.
|
||||
Wider min-width than the 1em glyph itself so common extensions
|
||||
(pdf/docx/xlsx/json) don't push the label sideways. Height
|
||||
grows with content; flex-start anchors to the title-line. */
|
||||
min-width: 2.2em;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
color: var(--text-muted);
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.tree-name__icon svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tree-name__ext {
|
||||
font-size: 0.58rem;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 600;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Folder rows get the primary accent so directories stand out from
|
||||
files at a glance — same convention as macOS Finder / GNOME Files. */
|
||||
.tree-row[data-isdir="true"] .tree-name__icon,
|
||||
.tree-row[data-iszip="true"] .tree-name__icon {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Selected rows tint icon to match the label color (the bg-selected
|
||||
token already differentiates the row background). */
|
||||
.tree-row.is-selected .tree-name__icon {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tree-name__label {
|
||||
|
|
@ -306,6 +411,48 @@ html, body {
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Two-line ZDDC variant. Top line is monospace + small + muted so the
|
||||
trackingNumber / revision / status fields line up vertically across
|
||||
adjacent rows (every field has a fixed width by convention). Bottom
|
||||
line is the human-readable title at normal weight. */
|
||||
.tree-name__label--zddc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.15;
|
||||
/* Tight gap between meta and title; tweak by 1-2 px if the rows
|
||||
feel crowded on dense lists. */
|
||||
gap: 0.05rem;
|
||||
}
|
||||
|
||||
.tree-name__meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
/* Explicit weight: the folder-row rule below bolds .tree-name__label,
|
||||
which would otherwise inherit through to the meta span. We want
|
||||
the meta to stay light + muted on every row. */
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
/* Belt-and-braces: monospace already gives column-alignment, but
|
||||
tabular-nums hardens it on the rare proportional fallback. */
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.01em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tree-name__title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tree-row.is-selected .tree-name__title {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tree-row[data-isdir="true"] .tree-name__label,
|
||||
|
|
@ -364,19 +511,52 @@ html, body {
|
|||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Virtual rows: synthesized client-side for folders that aren't on
|
||||
disk yet (canonical project folders). Rendered muted so the user
|
||||
reads them as "available but empty" rather than ordinary entries.
|
||||
Hover/select states still apply; the hint sits to the right of the
|
||||
label. */
|
||||
.tree-row--virtual .tree-name__icon,
|
||||
.tree-row--virtual .tree-name__label {
|
||||
opacity: 0.65;
|
||||
/* Virtual rows: synthesized for folders/files declared by the
|
||||
cascade but absent from disk. The visual language reads as
|
||||
"expected, not yet materialized" — italic label, muted accent
|
||||
color, dashed left rail, and an outlined icon. Hover/select
|
||||
chrome still applies on top; the dashed rail sits inside the row
|
||||
so it doesn't fight padding-left indentation. */
|
||||
.tree-row--virtual {
|
||||
box-shadow: inset 2px 0 0 0 transparent;
|
||||
position: relative;
|
||||
}
|
||||
.tree-row--virtual::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
left: 2px;
|
||||
border-left: 2px dashed var(--accent-muted, #8aa4cc);
|
||||
pointer-events: none;
|
||||
}
|
||||
.tree-row--virtual .tree-name__label {
|
||||
font-style: italic;
|
||||
color: var(--text-muted, #6b7280);
|
||||
}
|
||||
.tree-row--virtual .tree-name__icon {
|
||||
/* Hollow out the filled Lucide glyph: reduce fill opacity so
|
||||
the icon reads as an outline-only sketch — the conventional
|
||||
"placeholder, not actual" cue across UI systems. */
|
||||
opacity: 0.5;
|
||||
}
|
||||
.tree-row--virtual .tree-name__icon svg {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.tree-row--virtual.is-selected::before {
|
||||
/* Selected virtual row: rail brightens to selection accent so the
|
||||
row reads as both selected and placeholder. */
|
||||
border-left-color: var(--accent, #2868c8);
|
||||
}
|
||||
|
||||
.tree-name__hint {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
color: var(--accent-muted, #8aa4cc);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
|
|
@ -427,12 +607,15 @@ html, body {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar (col 1): two stacked sections — Front matter (top, fixed
|
||||
default 180 px, drag-resizable) and TOC (bottom, takes the rest). */
|
||||
/* Sidebar (col 1): three stacked items — Front matter (fixed height,
|
||||
drag-resizable), the horizontal resizer (between FM and TOC), then
|
||||
the TOC section taking the remaining height. Flexbox keeps the
|
||||
resizer position unambiguous; the previous grid-overlay approach
|
||||
was hard to read and prone to misplacement. */
|
||||
.md-shell__sidebar {
|
||||
grid-area: sidebar;
|
||||
display: grid;
|
||||
grid-template-rows: 180px 1fr; /* JS overrides on resize */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--border);
|
||||
|
|
@ -460,20 +643,17 @@ html, body {
|
|||
outline: none;
|
||||
}
|
||||
|
||||
/* Horizontal resizer between front-matter and TOC inside the sidebar.
|
||||
Spans both rows by placement, then absolutely positioned to overlay
|
||||
the grid-row boundary. */
|
||||
/* Horizontal resizer — a real flex item between FM and TOC. Drag
|
||||
it up/down to change the front-matter pane's height; the JS
|
||||
handler updates fmSection.style.height directly. */
|
||||
.md-shell__fmresizer {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
align-self: end;
|
||||
justify-self: stretch;
|
||||
flex: 0 0 6px;
|
||||
height: 6px;
|
||||
margin-bottom: -3px;
|
||||
cursor: row-resize;
|
||||
background: transparent;
|
||||
z-index: 2;
|
||||
background: var(--border);
|
||||
transition: background 0.12s;
|
||||
/* Subtle "grab" affordance — a slightly darker bar appears on
|
||||
hover so users see this is the drag handle. */
|
||||
}
|
||||
.md-shell__fmresizer:hover,
|
||||
.md-shell__fmresizer.is-dragging,
|
||||
|
|
@ -558,15 +738,30 @@ html, body {
|
|||
}
|
||||
|
||||
.md-side {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.md-side--toc {
|
||||
border-top: 1px solid var(--border);
|
||||
|
||||
/* Front-matter section: fixed (resizable) height, set inline by the
|
||||
markdown plugin's mount + drag-handler. flex:0 0 auto so the
|
||||
explicit height wins over the parent flex layout. */
|
||||
.md-side--fm {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* TOC section: takes everything that's left. min-height:0 so the
|
||||
inner body's overflow:auto kicks in instead of pushing the
|
||||
resizer off-screen. */
|
||||
.md-side--toc {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.md-side__header {
|
||||
/* Header is its own flex item so the body can stretch to fill. */
|
||||
flex: 0 0 auto;
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
|
@ -576,8 +771,13 @@ html, body {
|
|||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.md-side__body {
|
||||
overflow-y: auto;
|
||||
/* Both axes — the textarea uses white-space:pre so long YAML
|
||||
lines need horizontal scroll, and the TOC entries below now
|
||||
extend their full width so deep headings need it too. */
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
padding: 0.3rem 0;
|
||||
font-size: 0.85rem;
|
||||
|
|
@ -604,10 +804,11 @@ html, body {
|
|||
cursor: pointer;
|
||||
border-left: 2px solid transparent;
|
||||
transition: background 0.1s, border-color 0.1s, color 0.1s;
|
||||
/* Truncate long headings rather than wrap; the title attribute
|
||||
carries the full text. */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
/* Single-line items but no ellipsis — long headings extend the
|
||||
item's intrinsic width, and the parent .md-side__body has
|
||||
overflow:auto, so they create a horizontal scrollbar instead
|
||||
of getting clipped. The title attribute still carries the
|
||||
full text for SR users. */
|
||||
white-space: nowrap;
|
||||
}
|
||||
.md-toc__item:hover {
|
||||
|
|
@ -670,44 +871,116 @@ html, body {
|
|||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Sort control ────────────────────────────────────────────────────────── */
|
||||
.sort-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sort-control__label {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sort-control__select {
|
||||
font-family: var(--font);
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sort-control__select:focus {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.sort-control__checkbox {
|
||||
/* Pair with the "Show hidden" label as a unified control. The
|
||||
parent .sort-control already does horizontal flex + gap, so the
|
||||
checkbox just needs sensible vertical alignment + a clickable
|
||||
hit target. */
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced
|
||||
by the .md-shell BEM block above. */
|
||||
|
||||
/* ── Hover info card ────────────────────────────────────────────────────── */
|
||||
/* Singleton element appended to <body> by browse/js/hovercard.js.
|
||||
Replaces the native title="…" tooltip on tree rows with a rich
|
||||
metadata view (ZDDC parse fields + filesystem info). */
|
||||
.tree-hovercard {
|
||||
position: fixed;
|
||||
z-index: 9000;
|
||||
max-width: 28rem;
|
||||
min-width: 17rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18),
|
||||
0 2px 6px rgba(0, 0, 0, 0.10);
|
||||
padding: 0.5rem 0.7rem 0.45rem;
|
||||
font-family: var(--font);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.35;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
/* pointer-events:auto so the user can mouse into the card to
|
||||
select text. The hide is delayed (HIDE_DELAY_MS in hovercard.js)
|
||||
so the cursor has time to traverse the gap between row and card
|
||||
before the card dismisses. */
|
||||
pointer-events: auto;
|
||||
/* The tree rows set user-select:none — explicitly allow it here
|
||||
so dragging across the card builds a real selection that can be
|
||||
Ctrl/Cmd-C'd or right-click-Copied via the browser's native menu. */
|
||||
user-select: text;
|
||||
cursor: default;
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
|
||||
.tree-hovercard.is-visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Highlight selected text inside the card with the primary accent so
|
||||
it reads as "yes, you can copy this" rather than the default browser
|
||||
selection color. */
|
||||
.tree-hovercard ::selection {
|
||||
background: var(--primary-light);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tree-hovercard__header {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.tree-hovercard__title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.2;
|
||||
color: var(--text);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tree-hovercard__sub {
|
||||
margin-top: 0.15rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.tree-hovercard__list {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.12rem 0.7rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.tree-hovercard__key {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.74rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.tree-hovercard__val {
|
||||
color: var(--text);
|
||||
font-size: 0.82rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tree-hovercard__val--mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* Archive-reference links inside the hovercard pick up the primary
|
||||
accent so they read as clickable, and stay inline with the mono
|
||||
font when they sit inside a mono cell. */
|
||||
.tree-hovercard__val a {
|
||||
color: var(--primary, #2868c8);
|
||||
text-decoration: none;
|
||||
}
|
||||
.tree-hovercard__val a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Separator stretches across both grid columns. Bleed into the
|
||||
card's padding so it visually reads as a divider, not a hairline. */
|
||||
.tree-hovercard__sep {
|
||||
grid-column: 1 / -1;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 0.25rem -0.7rem;
|
||||
}
|
||||
|
|
|
|||
312
browse/js/accept-transmittal.js
Normal file
312
browse/js/accept-transmittal.js
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
// accept-transmittal.js — the doc-controller "Accept Transmittal"
|
||||
// workflow modal.
|
||||
//
|
||||
// Surfaced by events.js as a right-click item on a transmittal folder
|
||||
// inside archive/<their-party>/incoming/. The folder name must conform
|
||||
// to the ZDDC transmittal grammar (date_tracking (status) - title);
|
||||
// every file inside must conform to ZDDC filename grammar with the
|
||||
// same tracking. Non-conformance is flagged in the modal and the user
|
||||
// cancels to ask the sender to fix.
|
||||
//
|
||||
// On submit, the form assembles a YAML body (received_date plus an
|
||||
// optional plan-review chain block) and POSTs it with
|
||||
// X-ZDDC-Op: accept-transmittal to the transmittal-folder URL. The
|
||||
// server validates everything, moves the folder into received/,
|
||||
// renames it to tracking-only, and optionally chains Plan Review.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var REVIEW_OFFSET_DAYS = 7;
|
||||
var RESPONSE_OFFSET_DAYS = 14;
|
||||
|
||||
function status(msg, level) {
|
||||
var t = window.zddc && window.zddc.toast;
|
||||
if (t) t(msg, level || 'info');
|
||||
}
|
||||
|
||||
function isoDateToday() {
|
||||
var d = new Date();
|
||||
return d.getFullYear()
|
||||
+ '-' + ('0' + (d.getMonth() + 1)).slice(-2)
|
||||
+ '-' + ('0' + d.getDate()).slice(-2);
|
||||
}
|
||||
function isoDatePlus(days) {
|
||||
var d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
return d.getFullYear()
|
||||
+ '-' + ('0' + (d.getMonth() + 1)).slice(-2)
|
||||
+ '-' + ('0' + d.getDate()).slice(-2);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return ({
|
||||
'&': '&', '<': '<', '>': '>',
|
||||
'"': '"', "'": '''
|
||||
})[c];
|
||||
});
|
||||
}
|
||||
|
||||
// Is this node a direct child of an incoming/ canonical folder
|
||||
// AND a well-formed transmittal folder? The first half is the
|
||||
// cascade-driven scope check (X-ZDDC-Canonical-Folder == 'incoming'
|
||||
// on the current listing's parent context); the second is a
|
||||
// structural folder-name parse against the ZDDC grammar.
|
||||
function isAcceptableTransmittalFolder(node) {
|
||||
if (!node || !node.isDir) return false;
|
||||
if (node.virtual) return false;
|
||||
// The cascade signal is on the PARENT directory's listing, which
|
||||
// is the directory whose contents are currently shown — i.e.
|
||||
// state.currentPath. When the listing's scope is incoming/,
|
||||
// every direct child folder is a candidate (validated by name
|
||||
// here and by the server again on POST).
|
||||
if (window.app.state.scopeCanonicalFolder !== 'incoming') return false;
|
||||
var parsed = window.zddc.parseFolder(node.name);
|
||||
return !!(parsed && parsed.valid);
|
||||
}
|
||||
|
||||
// Scan the listing's tree node for files inside the transmittal
|
||||
// folder and classify each as conforming (tracking matches the
|
||||
// folder) or violating. Returns { ok: [...], violations: [...] }.
|
||||
// Best-effort — operates only on already-loaded children. The
|
||||
// server is authoritative; this is a UX hint.
|
||||
function classifyChildren(node, folderTracking) {
|
||||
var out = { ok: [], violations: [] };
|
||||
var children = (node && node.children) ? node.children : [];
|
||||
children.forEach(function (c) {
|
||||
if (c.virtual) return;
|
||||
if (c.isDir) {
|
||||
out.violations.push(c.name + ': nested directories are not permitted');
|
||||
return;
|
||||
}
|
||||
if (c.name.charAt(0) === '.') return; // dotfiles ignored
|
||||
var parsed = window.zddc.parseFilename(c.name);
|
||||
if (!parsed || !parsed.valid) {
|
||||
out.violations.push(c.name + ': does not conform to ZDDC filename grammar');
|
||||
return;
|
||||
}
|
||||
if (parsed.trackingNumber !== folderTracking) {
|
||||
out.violations.push(c.name + ': tracking "' + parsed.trackingNumber
|
||||
+ '" does not match folder tracking "' + folderTracking + '"');
|
||||
return;
|
||||
}
|
||||
out.ok.push(c.name);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function fetchPeopleSuggestions() {
|
||||
return fetch('/.profile/access', {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
credentials: 'same-origin'
|
||||
}).then(function (r) {
|
||||
if (!r.ok) return [];
|
||||
return r.json().then(function (data) {
|
||||
var out = [];
|
||||
if (data && data.email) out.push(data.email);
|
||||
return out;
|
||||
});
|
||||
}).catch(function () { return []; });
|
||||
}
|
||||
|
||||
function openForm(initial) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var overlay = document.createElement('div');
|
||||
overlay.className = 'modal-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.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:28rem;max-width:36rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);font-family:inherit;';
|
||||
|
||||
var violationsHtml = '';
|
||||
if (initial.violations && initial.violations.length) {
|
||||
violationsHtml = '<div style="margin:0.5rem 0;padding:0.5rem 0.75rem;background:#fff3cd;border-left:3px solid #d39e00;font-size:0.85rem;">'
|
||||
+ '<strong>Non-conforming files detected:</strong><ul style="margin:0.25rem 0 0 1rem;padding:0;">'
|
||||
+ initial.violations.map(function (v) { return '<li>' + escapeHtml(v) + '</li>'; }).join('')
|
||||
+ '</ul><p style="margin:0.4rem 0 0 0;">Cancel and contact the sender to correct these before re-uploading.</p></div>';
|
||||
}
|
||||
|
||||
var planReviewFieldsHtml =
|
||||
'<div id="acc-pr-fields" style="display:none;margin-top:0.6rem;padding:0.5rem 0.75rem;background:rgba(0,0,0,0.03);border-radius:4px;">' +
|
||||
'<div style="display:grid;grid-template-columns:max-content 1fr;gap:0.4rem 0.75rem;align-items:center;font-size:0.9rem;">' +
|
||||
'<label for="acc-review-lead">Review lead</label>' +
|
||||
'<input id="acc-review-lead" type="email" list="acc-people" style="padding:0.25rem 0.4rem;" placeholder="email — becomes sub-admin of reviewing/<…>">' +
|
||||
'<label for="acc-review-date">Plan review complete date</label>' +
|
||||
'<input id="acc-review-date" type="date">' +
|
||||
'<label for="acc-approver">Approver</label>' +
|
||||
'<input id="acc-approver" type="email" list="acc-people" style="padding:0.25rem 0.4rem;" placeholder="email — becomes sub-admin of staging/<…>">' +
|
||||
'<label for="acc-response-date">Plan response date</label>' +
|
||||
'<input id="acc-response-date" type="date">' +
|
||||
'<datalist id="acc-people"></datalist>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
box.innerHTML =
|
||||
'<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">Accept Transmittal — ' + escapeHtml(initial.tracking) + '</h2>' +
|
||||
'<p style="margin:0 0 0.5rem 0;font-size:0.85rem;color:#666;">' +
|
||||
'This will file <strong>' + initial.fileCount + ' file' + (initial.fileCount === 1 ? '' : 's') + '</strong> from ' +
|
||||
'<code>' + escapeHtml(initial.folder) + '</code> into the immutable received archive at ' +
|
||||
'<code>archive/' + escapeHtml(initial.party) + '/received/' + escapeHtml(initial.tracking) + '/</code>. ' +
|
||||
'Once filed, only document-control can add new files there; nothing can be edited or deleted.' +
|
||||
'</p>' +
|
||||
violationsHtml +
|
||||
'<div style="display:grid;grid-template-columns:max-content 1fr;gap:0.5rem 0.75rem;align-items:center;font-size:0.9rem;">' +
|
||||
'<label for="acc-received-date">Received date</label>' +
|
||||
'<input id="acc-received-date" type="date" required>' +
|
||||
'</div>' +
|
||||
'<label style="display:flex;align-items:center;gap:0.4rem;margin-top:0.8rem;font-size:0.9rem;">' +
|
||||
'<input type="checkbox" id="acc-setup-pr">' +
|
||||
'<span>Set up Plan Review now — scaffold the reviewing/ and staging/ folders for the response</span>' +
|
||||
'</label>' +
|
||||
planReviewFieldsHtml +
|
||||
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
|
||||
'<button type="button" id="acc-cancel">Cancel</button>' +
|
||||
'<button type="button" id="acc-submit" class="btn-primary"' +
|
||||
(initial.violations && initial.violations.length ? ' disabled' : '') + '>Accept</button>' +
|
||||
'</div>';
|
||||
|
||||
overlay.appendChild(box);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
box.querySelector('#acc-received-date').value = isoDateToday();
|
||||
box.querySelector('#acc-review-date').value = isoDatePlus(REVIEW_OFFSET_DAYS);
|
||||
box.querySelector('#acc-response-date').value = isoDatePlus(RESPONSE_OFFSET_DAYS);
|
||||
|
||||
var prCheckbox = box.querySelector('#acc-setup-pr');
|
||||
var prFields = box.querySelector('#acc-pr-fields');
|
||||
prCheckbox.addEventListener('change', function () {
|
||||
prFields.style.display = prCheckbox.checked ? '' : 'none';
|
||||
});
|
||||
|
||||
fetchPeopleSuggestions().then(function (emails) {
|
||||
var dl = box.querySelector('#acc-people');
|
||||
if (!dl) return;
|
||||
emails.forEach(function (e) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = e;
|
||||
dl.appendChild(opt);
|
||||
});
|
||||
});
|
||||
|
||||
function close() {
|
||||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
}
|
||||
box.querySelector('#acc-cancel').addEventListener('click', function () {
|
||||
close(); reject(new Error('cancelled'));
|
||||
});
|
||||
overlay.addEventListener('click', function (e) {
|
||||
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
|
||||
});
|
||||
document.addEventListener('keydown', function escHandler(e) {
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
close(); reject(new Error('cancelled'));
|
||||
}
|
||||
});
|
||||
|
||||
box.querySelector('#acc-submit').addEventListener('click', function () {
|
||||
var values = {
|
||||
receivedDate: box.querySelector('#acc-received-date').value,
|
||||
setupPlanReview: prCheckbox.checked,
|
||||
reviewLead: box.querySelector('#acc-review-lead').value.trim(),
|
||||
approver: box.querySelector('#acc-approver').value.trim(),
|
||||
planReviewDate: box.querySelector('#acc-review-date').value,
|
||||
planResponseDate: box.querySelector('#acc-response-date').value
|
||||
};
|
||||
if (!values.receivedDate) { status('Received date is required.', 'error'); return; }
|
||||
if (values.setupPlanReview) {
|
||||
if (!values.reviewLead || !values.approver
|
||||
|| !values.planReviewDate || !values.planResponseDate) {
|
||||
status('Plan Review fields are required when the checkbox is on.', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
close(); resolve(values);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function quote(s) {
|
||||
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
||||
}
|
||||
function buildBody(values) {
|
||||
var lines = ['received_date: ' + values.receivedDate];
|
||||
if (values.setupPlanReview) {
|
||||
lines.push('setup_plan_review: true');
|
||||
lines.push('review_lead: ' + quote(values.reviewLead));
|
||||
lines.push('approver: ' + quote(values.approver));
|
||||
lines.push('plan_review_complete_date: ' + values.planReviewDate);
|
||||
lines.push('plan_response_date: ' + values.planResponseDate);
|
||||
}
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function invoke(node) {
|
||||
var tree = window.app.modules.tree;
|
||||
if (!tree) return;
|
||||
var url = tree.pathFor(node);
|
||||
if (!url.endsWith('/')) url += '/';
|
||||
|
||||
var parsedFolder = window.zddc.parseFolder(node.name);
|
||||
if (!parsedFolder || !parsedFolder.valid) {
|
||||
status('Folder name does not conform to ZDDC transmittal grammar.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Derive the party from the path: archive/<party>/incoming/<folder>/.
|
||||
var parts = url.replace(/^\/+|\/+$/g, '').split('/');
|
||||
var partyIdx = parts.indexOf('archive');
|
||||
var party = (partyIdx >= 0 && parts[partyIdx + 1]) ? parts[partyIdx + 1] : '';
|
||||
|
||||
var classification = classifyChildren(node, parsedFolder.trackingNumber);
|
||||
|
||||
var values;
|
||||
try {
|
||||
values = await openForm({
|
||||
tracking: parsedFolder.trackingNumber,
|
||||
folder: node.name,
|
||||
party: party,
|
||||
fileCount: classification.ok.length,
|
||||
violations: classification.violations
|
||||
});
|
||||
} catch (_e) {
|
||||
return;
|
||||
}
|
||||
|
||||
status('Accept Transmittal — submitting…');
|
||||
var resp;
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-ZDDC-Op': 'accept-transmittal',
|
||||
'Content-Type': 'application/yaml'
|
||||
},
|
||||
body: buildBody(values),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
} catch (e) {
|
||||
status('Accept failed: ' + (e && e.message ? e.message : e), 'error');
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
var text = '';
|
||||
try { text = await resp.text(); } catch (_e) { /* ignore */ }
|
||||
status('Accept failed (' + resp.status + '): ' + text, 'error');
|
||||
return;
|
||||
}
|
||||
var data; try { data = await resp.json(); } catch (_e) { data = null; }
|
||||
var msg = 'Accepted ' + (data && data.moved_files ? data.moved_files : '?') + ' file(s) into '
|
||||
+ (data && data.received_path ? data.received_path : 'received/');
|
||||
if (data && data.merged) msg += ' (merged with existing tracking)';
|
||||
if (data && data.plan_review) msg += ' · Plan Review scaffolded';
|
||||
status(msg + ' — reload to see the move.', 'success');
|
||||
}
|
||||
|
||||
window.app.modules.acceptTransmittal = {
|
||||
isAcceptableTransmittalFolder: isAcceptableTransmittalFolder,
|
||||
invoke: invoke
|
||||
};
|
||||
})();
|
||||
|
|
@ -19,9 +19,80 @@
|
|||
// Expose for events.js's client-side rescope on dblclick.
|
||||
window.app.modules.augmentRoot = passThroughEntries;
|
||||
|
||||
// Walk a `?file=` path segment-by-segment from the current root.
|
||||
// Each non-leaf segment is matched against the parent's children
|
||||
// by name; if found and it's a folder, expand+load it (so its
|
||||
// children populate state.nodes) and recurse into them. The leaf
|
||||
// segment becomes the selected/previewed entry. Silently no-ops
|
||||
// when any segment doesn't resolve — deep links aren't a hard
|
||||
// contract, just an affordance.
|
||||
async function openDeepLink(path) {
|
||||
var segs = path.split('/').filter(Boolean);
|
||||
if (segs.length === 0) return;
|
||||
var tree = window.app.modules.tree;
|
||||
var prev = window.app.modules.preview;
|
||||
|
||||
// Lookup helper: find a node by name within a given parent's
|
||||
// immediate children. Top-level walk uses state.rootIds.
|
||||
function findChild(parentIds, name) {
|
||||
for (var i = 0; i < parentIds.length; i++) {
|
||||
var n = window.app.state.nodes.get(parentIds[i]);
|
||||
if (n && n.name === name) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var ids = window.app.state.rootIds;
|
||||
for (var i = 0; i < segs.length; i++) {
|
||||
var node = findChild(ids, segs[i]);
|
||||
if (!node) return; // segment not present in this listing
|
||||
if (i === segs.length - 1) {
|
||||
// Leaf — select + preview.
|
||||
window.app.state.selectedId = node.id;
|
||||
window.app.state.lastPreviewedNodeId = node.id;
|
||||
tree.render();
|
||||
if (prev && !node.isDir) prev.showFilePreview(node);
|
||||
return;
|
||||
}
|
||||
// Intermediate — must be a folder we can expand into.
|
||||
if (!(node.isDir || node.isZip)) return;
|
||||
if (!node.loaded) {
|
||||
await tree.toggleFolder(node.id); // loads + sets expanded
|
||||
} else if (!node.expanded) {
|
||||
node.expanded = true;
|
||||
}
|
||||
ids = node.childIds;
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
events.init();
|
||||
|
||||
// Honor ?file=<path> deep links: external clients (the profile
|
||||
// page's "edit your .zddc files" list, future bookmarks, etc.)
|
||||
// can link directly to "open browse at <dir>, with this entry
|
||||
// selected and previewed". Single-segment names (?file=foo.md)
|
||||
// match in the current directory; multi-segment paths
|
||||
// (?file=a/b/foo.md) walk into a/ then b/ then open foo.md,
|
||||
// loading intermediate directories on the way.
|
||||
//
|
||||
// When the LEAF (or any intermediate segment) is hidden
|
||||
// (.zddc, .form.yaml, …), flip showHidden ON BEFORE the
|
||||
// initial listing fetch so dotfiles appear in the tree.
|
||||
var qs = new URLSearchParams(location.search);
|
||||
var deepFile = qs.get('file');
|
||||
// Explicit ?hidden=1 in the URL: restore the show-hidden toggle
|
||||
// on reload (the URL is the persistence layer for this flag —
|
||||
// see events.js syncURLToSelection).
|
||||
if (qs.get('hidden') === '1') state.showHidden = true;
|
||||
if (deepFile) {
|
||||
var segs = deepFile.split('/').filter(Boolean);
|
||||
for (var si = 0; si < segs.length; si++) {
|
||||
var c = segs[si].charAt(0);
|
||||
if (c === '.' || c === '_') { state.showHidden = true; break; }
|
||||
}
|
||||
}
|
||||
|
||||
// Try server auto-detect. If this page is served by zddc-server
|
||||
// (or any server with a Caddy-shaped JSON listing), load the
|
||||
// current directory automatically. Otherwise show the empty
|
||||
|
|
@ -40,6 +111,14 @@
|
|||
// response, re-resolve so an /incoming URL auto-activates
|
||||
// grid mode.
|
||||
if (events.applyResolvedViewMode) events.applyResolvedViewMode();
|
||||
|
||||
// Final step of the deep link: walk the path segment by
|
||||
// segment, expanding + loading intermediate directories
|
||||
// before opening the leaf. Single-segment names use the
|
||||
// same code path with one iteration.
|
||||
if (deepFile) {
|
||||
await openDeepLink(deepFile);
|
||||
}
|
||||
}
|
||||
// Else: empty state stays visible; user can click Select Directory.
|
||||
|
||||
|
|
@ -50,6 +129,9 @@
|
|||
if (window.app.state.source !== 'server') return;
|
||||
var path = location.pathname;
|
||||
if (!path.endsWith('/')) path += '/';
|
||||
var popQS = new URLSearchParams(location.search);
|
||||
if (popQS.get('hidden') === '1') window.app.state.showHidden = true;
|
||||
else window.app.state.showHidden = false;
|
||||
try {
|
||||
var es = await loader.fetchServerChildren(path);
|
||||
window.app.state.currentPath = path;
|
||||
|
|
@ -63,6 +145,10 @@
|
|||
if (previewTitle) previewTitle.textContent = 'No file selected';
|
||||
// Reapply view mode for the new URL (incoming/ → grid, etc).
|
||||
if (events.applyResolvedViewMode) events.applyResolvedViewMode();
|
||||
// Re-walk ?file= so back/forward restores selection +
|
||||
// expansion, not just scope.
|
||||
var popFile = popQS.get('file');
|
||||
if (popFile) await openDeepLink(popFile);
|
||||
} catch (_e) { /* swallow — leave the tree as-is */ }
|
||||
});
|
||||
}
|
||||
|
|
|
|||
146
browse/js/create-transmittal.js
Normal file
146
browse/js/create-transmittal.js
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// create-transmittal.js — folder-creation plumbing for outgoing
|
||||
// transmittals.
|
||||
//
|
||||
// Surfaced by events.js as a pane-menu item (right-click empty space)
|
||||
// when state.scopeCanonicalFolder == 'staging'. The modal prompts for
|
||||
// a ZDDC-conforming folder name (date_tracking (purpose) - subject)
|
||||
// with live validation via zddc.parseFolder, then POSTs X-ZDDC-Op:
|
||||
// mkdir. On success the client navigates to the new folder URL — the
|
||||
// staging/ cascade serves the transmittal tool there, where the user
|
||||
// builds the manifest, adds files, and publishes.
|
||||
//
|
||||
// No manifest assembly happens here. This is plumbing.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function status(msg, level) {
|
||||
var t = window.zddc && window.zddc.toast;
|
||||
if (t) t(msg, level || 'info');
|
||||
}
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[c];
|
||||
});
|
||||
}
|
||||
function isoDateToday() {
|
||||
var d = new Date();
|
||||
return d.getFullYear()
|
||||
+ '-' + ('0' + (d.getMonth() + 1)).slice(-2)
|
||||
+ '-' + ('0' + d.getDate()).slice(-2);
|
||||
}
|
||||
|
||||
function openForm() {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;inset: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.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:28rem;max-width:36rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);';
|
||||
box.innerHTML =
|
||||
'<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">Create Transmittal folder</h2>' +
|
||||
'<p style="margin:0 0 0.6rem 0;font-size:0.85rem;color:#666;">' +
|
||||
"After it's created, the transmittal tool opens here so you can build the manifest — " +
|
||||
'add rows from the MDL, choose revisions, and associate files.' +
|
||||
'</p>' +
|
||||
'<label for="ct-name" style="font-size:0.9rem;">Folder name (ZDDC convention)</label>' +
|
||||
'<input id="ct-name" type="text" style="width:100%;padding:0.3rem;font-family:var(--code,monospace);" ' +
|
||||
'placeholder="YYYY-MM-DD_TRACKING (PURPOSE) - SUBJECT" value="' + escapeHtml(isoDateToday() + '_') + '">' +
|
||||
'<div id="ct-feedback" style="font-size:0.8rem;color:#888;margin-top:0.2rem;min-height:1.1em;"></div>' +
|
||||
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
|
||||
'<button type="button" id="ct-cancel">Cancel</button>' +
|
||||
'<button type="button" id="ct-submit" class="btn-primary" disabled>Create</button>' +
|
||||
'</div>';
|
||||
overlay.appendChild(box);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
var input = box.querySelector('#ct-name');
|
||||
var submit = box.querySelector('#ct-submit');
|
||||
var feedback = box.querySelector('#ct-feedback');
|
||||
function revalidate() {
|
||||
var v = input.value.trim();
|
||||
if (!v) {
|
||||
feedback.textContent = '';
|
||||
submit.disabled = true;
|
||||
return;
|
||||
}
|
||||
var parsed = window.zddc.parseFolder(v);
|
||||
if (parsed && parsed.valid) {
|
||||
feedback.style.color = '#2a8';
|
||||
feedback.textContent = '✓ tracking=' + parsed.trackingNumber +
|
||||
', status=' + parsed.status + ', title=' + parsed.title;
|
||||
submit.disabled = false;
|
||||
} else {
|
||||
feedback.style.color = '#c33';
|
||||
feedback.textContent = '✗ does not match YYYY-MM-DD_TRACKING (PURPOSE) - SUBJECT';
|
||||
submit.disabled = true;
|
||||
}
|
||||
}
|
||||
input.addEventListener('input', revalidate);
|
||||
revalidate();
|
||||
|
||||
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
|
||||
box.querySelector('#ct-cancel').addEventListener('click', function () {
|
||||
close(); reject(new Error('cancelled'));
|
||||
});
|
||||
overlay.addEventListener('click', function (e) {
|
||||
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
|
||||
});
|
||||
document.addEventListener('keydown', function escHandler(e) {
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
close(); reject(new Error('cancelled'));
|
||||
}
|
||||
});
|
||||
submit.addEventListener('click', function () {
|
||||
var v = input.value.trim();
|
||||
var parsed = window.zddc.parseFolder(v);
|
||||
if (!parsed || !parsed.valid) {
|
||||
status('Folder name must conform to ZDDC convention.', 'error');
|
||||
return;
|
||||
}
|
||||
close(); resolve({ folderName: v });
|
||||
});
|
||||
|
||||
// Position cursor after the date prefix.
|
||||
setTimeout(function () {
|
||||
input.focus();
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
async function invoke() {
|
||||
if (window.app.state.scopeCanonicalFolder !== 'staging') {
|
||||
status('Create Transmittal folder is only available inside staging/.', 'error');
|
||||
return;
|
||||
}
|
||||
var stagingUrl = window.app.state.currentPath || '/';
|
||||
if (!stagingUrl.endsWith('/')) stagingUrl += '/';
|
||||
|
||||
var choice;
|
||||
try { choice = await openForm(); } catch (_e) { return; }
|
||||
var newUrl = stagingUrl + encodeURIComponent(choice.folderName) + '/';
|
||||
|
||||
var resp;
|
||||
try {
|
||||
resp = await fetch(newUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'X-ZDDC-Op': 'mkdir' },
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
} catch (e) {
|
||||
status('Create failed: ' + (e && e.message ? e.message : e), 'error');
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
var text = ''; try { text = await resp.text(); } catch (_e) {}
|
||||
status('Create failed (' + resp.status + '): ' + text, 'error');
|
||||
return;
|
||||
}
|
||||
status('Created ' + choice.folderName + ' — opening transmittal tool…', 'success');
|
||||
// Navigate to the new folder (no-slash form → default_tool: transmittal).
|
||||
window.location.href = stagingUrl + encodeURIComponent(choice.folderName);
|
||||
}
|
||||
|
||||
window.app.modules.createTransmittal = { invoke: invoke };
|
||||
})();
|
||||
|
|
@ -1,13 +1,17 @@
|
|||
// download.js — "Download (zip)" for the currently-viewed directory.
|
||||
// download.js — per-node downloads, surfaced through the tree's
|
||||
// right-click menu (downloadFile / downloadFolder).
|
||||
//
|
||||
// Server mode: just point an <a download> at "<currentPath>?zip=1" —
|
||||
// zddc-server streams an ACL-filtered .zip of the subtree, so nothing
|
||||
// is held in the browser.
|
||||
// downloadFile: a single file. Server mode lets the browser pull
|
||||
// node.url (zddc-server emits Content-Disposition); FS-API mode
|
||||
// reads bytes through the file handle and blob-downloads.
|
||||
//
|
||||
// FS-API (offline) mode: there's no server, so we walk the picked
|
||||
// folder ourselves, bundle every file with JSZip, and download the
|
||||
// blob. A two-pass walk (metadata first, then bytes) lets us warn
|
||||
// before loading a very large tree into memory.
|
||||
// downloadFolder: an arbitrary directory node as a .zip. Server
|
||||
// mode points an <a download> at the virtual "<node-path>.zip"
|
||||
// URL — zddc-server recognises the suffix and streams an ACL-
|
||||
// filtered archive without buffering on the client. FS-API mode
|
||||
// walks the picked handle in two passes — metadata first, then
|
||||
// bytes — so we can warn before loading a very large tree into
|
||||
// memory.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
|
|
@ -103,39 +107,75 @@
|
|||
ev.statusInfo('Downloaded ' + rootHandle.name + '.zip (' + files.length + ' files)');
|
||||
}
|
||||
|
||||
function downloadServerSubtree() {
|
||||
var dir = (state.currentPath || '/').replace(/\/$/, '');
|
||||
var name = (dir.split('/').filter(Boolean).pop()) || 'download';
|
||||
events().statusInfo('Preparing ' + name + '.zip…');
|
||||
downloadUrl(name + '.zip', dir + '/?zip=1');
|
||||
// The browser owns the download from here; clear the hint shortly.
|
||||
setTimeout(function () { events().statusClear(); }, 2500);
|
||||
}
|
||||
|
||||
var busy = false;
|
||||
|
||||
async function downloadCurrentSubtree() {
|
||||
// Download a single file node. Server mode: rely on the node's
|
||||
// own URL (the server emits Content-Disposition). FS mode: read
|
||||
// bytes through the handle and trigger a blob download. Works
|
||||
// for ordinary files, for .zip members (the loader sets node.url
|
||||
// for zip members in server mode and a ZipFileHandle offline),
|
||||
// and for the .zip file itself.
|
||||
async function downloadFile(node) {
|
||||
if (busy) return;
|
||||
var btn = document.getElementById('downloadZipBtn');
|
||||
if (!node || node.isDir) {
|
||||
events().statusError('Not a file: ' + (node && node.name));
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
if (state.source === 'server') {
|
||||
downloadServerSubtree();
|
||||
} else if (state.source === 'fs' && state.rootHandle) {
|
||||
await downloadFsSubtree(state.rootHandle);
|
||||
if (node.url) {
|
||||
events().statusInfo('Downloading ' + node.name + '…');
|
||||
downloadUrl(node.name, node.url);
|
||||
setTimeout(function () { events().statusClear(); }, 2500);
|
||||
} else if (node.handle && typeof node.handle.getFile === 'function') {
|
||||
events().statusInfo('Preparing ' + node.name + '…');
|
||||
var f = await node.handle.getFile();
|
||||
var blob = new Blob([await f.arrayBuffer()]);
|
||||
downloadBlob(node.name, blob);
|
||||
events().statusInfo('Downloaded ' + node.name);
|
||||
} else {
|
||||
events().statusError('Nothing to download — open a directory first.');
|
||||
events().statusError('No download path for ' + node.name);
|
||||
}
|
||||
} catch (e) {
|
||||
events().statusError('Download failed: ' + (e && e.message ? e.message : e));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Download an arbitrary folder node as a .zip. Server mode points
|
||||
// an <a download> at the virtual "<node-path>.zip" URL (the
|
||||
// dispatcher recognises the suffix and streams the subtree). FS
|
||||
// mode walks the directory handle.
|
||||
async function downloadFolder(node) {
|
||||
if (busy) return;
|
||||
if (!node || !node.isDir) {
|
||||
events().statusError('Not a folder: ' + (node && node.name));
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
if (state.source === 'server') {
|
||||
var tree = window.app.modules.tree;
|
||||
var dir = tree.pathFor(node).replace(/\/$/, '');
|
||||
events().statusInfo('Preparing ' + node.name + '.zip…');
|
||||
downloadUrl(node.name + '.zip', dir + '.zip');
|
||||
setTimeout(function () { events().statusClear(); }, 2500);
|
||||
} else if (state.source === 'fs' && node.handle
|
||||
&& node.handle.kind === 'directory') {
|
||||
await downloadFsSubtree(node.handle);
|
||||
} else {
|
||||
events().statusError('Cannot download ' + node.name);
|
||||
}
|
||||
} catch (e) {
|
||||
events().statusError('Download failed: ' + (e && e.message ? e.message : e));
|
||||
} finally {
|
||||
busy = false;
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
window.app.modules.download = {
|
||||
downloadCurrentSubtree: downloadCurrentSubtree
|
||||
downloadFile: downloadFile,
|
||||
downloadFolder: downloadFolder
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -69,7 +69,6 @@
|
|||
function applySourceUI() {
|
||||
var add = document.getElementById('addDirectoryBtn');
|
||||
var refresh = document.getElementById('refreshHeaderBtn');
|
||||
var dlZip = document.getElementById('downloadZipBtn');
|
||||
if (add) {
|
||||
if (state.source === 'server') {
|
||||
add.classList.remove('btn-primary');
|
||||
|
|
@ -86,18 +85,59 @@
|
|||
refresh.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
// "Download (zip)" is meaningful once a directory is loaded
|
||||
// (server or local); it zips the directory currently in view.
|
||||
if (dlZip) {
|
||||
if (state.source) {
|
||||
dlZip.classList.remove('hidden');
|
||||
} else {
|
||||
dlZip.classList.add('hidden');
|
||||
}
|
||||
|
||||
// syncURLToSelection reflects the current scope + selected node +
|
||||
// show-hidden flag into the URL bar via history.replaceState, so:
|
||||
// - bookmarks / copy-paste of the URL re-open the same view
|
||||
// - reload (e.g. after toggling admin mode, which forces a hard
|
||||
// reload to pick up the elevated cookie) lands the user back
|
||||
// on the same selection
|
||||
//
|
||||
// Uses replaceState (not pushState) so a long click sequence doesn't
|
||||
// pollute browser history. Scope changes (rescopeServer) still
|
||||
// pushState — that's the only "intentional" navigation step in the
|
||||
// SPA, and back/forward should walk between scopes, not selections.
|
||||
//
|
||||
// FS-API mode has no shareable URL, so this is a no-op there.
|
||||
function syncURLToSelection() {
|
||||
if (state.source !== 'server') return;
|
||||
var scope = state.currentPath || '/';
|
||||
if (!scope.endsWith('/')) scope += '/';
|
||||
|
||||
var params = new URLSearchParams();
|
||||
var node = state.selectedId != null ? state.nodes.get(state.selectedId) : null;
|
||||
if (node) {
|
||||
var abs = tree.pathFor(node);
|
||||
var prefix = scope.replace(/\/$/, '');
|
||||
var rel = abs;
|
||||
if (prefix && abs.indexOf(prefix + '/') === 0) {
|
||||
rel = abs.slice(prefix.length + 1);
|
||||
}
|
||||
// Directory selections get a trailing slash so the URL
|
||||
// round-trips as a navigable folder reference.
|
||||
if (node.isDir && rel && !rel.endsWith('/')) rel += '/';
|
||||
if (rel) params.set('file', rel);
|
||||
}
|
||||
if (state.showHidden) params.set('hidden', '1');
|
||||
|
||||
// URLSearchParams percent-encodes '/' to %2F; the server doesn't
|
||||
// care, but the URL bar reads better with raw slashes.
|
||||
var qs = params.toString().replace(/%2F/g, '/');
|
||||
var url = scope + (qs ? '?' + qs : '');
|
||||
try {
|
||||
history.replaceState({ zddcBrowse: true, path: url }, '', url);
|
||||
} catch (_e) { /* private browsing edge cases */ }
|
||||
}
|
||||
|
||||
async function refreshListing() {
|
||||
// Snapshot expanded paths + selection BEFORE setRoot clears the
|
||||
// tree, then re-apply after the new root is in place. Keeps
|
||||
// the user's layout (which folders were open, which row was
|
||||
// highlighted, what the preview was pinned to) stable across
|
||||
// a refresh — including the auto-refresh triggered by the
|
||||
// "Show hidden files" toggle.
|
||||
var snap = tree.snapshotState();
|
||||
if (state.source === 'server') {
|
||||
var raw;
|
||||
try {
|
||||
|
|
@ -107,6 +147,7 @@
|
|||
return;
|
||||
}
|
||||
tree.setRoot(raw);
|
||||
await tree.restoreState(snap);
|
||||
tree.render();
|
||||
statusInfo('Refreshed (' + raw.length + ' item'
|
||||
+ (raw.length === 1 ? '' : 's') + ')');
|
||||
|
|
@ -119,6 +160,7 @@
|
|||
return;
|
||||
}
|
||||
tree.setRoot(raw2);
|
||||
await tree.restoreState(snap);
|
||||
tree.render();
|
||||
statusInfo('Refreshed');
|
||||
}
|
||||
|
|
@ -132,38 +174,31 @@
|
|||
var refresh = document.getElementById('refreshHeaderBtn');
|
||||
if (refresh) refresh.addEventListener('click', refreshListing);
|
||||
|
||||
var dlZip = document.getElementById('downloadZipBtn');
|
||||
if (dlZip) dlZip.addEventListener('click', function () {
|
||||
var d = window.app.modules.download;
|
||||
if (d) d.downloadCurrentSubtree();
|
||||
});
|
||||
|
||||
// Sort dropdown — change → tree re-renders with the new sort.
|
||||
// Format of option value: "<key>:<asc|desc>". Defaults match
|
||||
// state.sort initial values (name:asc).
|
||||
var sortSel = document.getElementById('sortBy');
|
||||
if (sortSel) {
|
||||
sortSel.value = state.sort.key + ':' + (state.sort.dir > 0 ? 'asc' : 'desc');
|
||||
sortSel.addEventListener('change', function () {
|
||||
var parts = sortSel.value.split(':');
|
||||
var key = parts[0];
|
||||
var dir = parts[1] === 'desc' ? -1 : 1;
|
||||
tree.setSortExplicit(key, dir);
|
||||
// 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
|
||||
// cached on state.filterAST; tree.render reads it and skips
|
||||
// non-matching rows. Escape clears.
|
||||
var filterInput = document.getElementById('treeFilter');
|
||||
if (filterInput) {
|
||||
var filterDebounce = null;
|
||||
var applyFilter = function () {
|
||||
var raw = filterInput.value || '';
|
||||
state.filterText = raw;
|
||||
state.filterAST = raw ? window.zddc.filter.parse(raw) : null;
|
||||
filterInput.classList.toggle('filter-active', !!raw);
|
||||
tree.render();
|
||||
};
|
||||
filterInput.addEventListener('input', function () {
|
||||
if (filterDebounce) clearTimeout(filterDebounce);
|
||||
filterDebounce = setTimeout(applyFilter, 80);
|
||||
});
|
||||
}
|
||||
|
||||
// "Show hidden" checkbox — toggles state.showHidden, which the
|
||||
// loader reads to append ?hidden=1 to listing requests. Re-uses
|
||||
// the existing refreshListing flow so the tree pulls a fresh
|
||||
// listing. ACL is still server-side; this just relaxes the
|
||||
// client-visible filter for entries the user is already
|
||||
// allowed to read.
|
||||
var hiddenCb = document.getElementById('showHidden');
|
||||
if (hiddenCb) {
|
||||
hiddenCb.checked = !!state.showHidden;
|
||||
hiddenCb.addEventListener('change', function () {
|
||||
state.showHidden = hiddenCb.checked;
|
||||
refreshListing();
|
||||
filterInput.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && filterInput.value) {
|
||||
e.preventDefault();
|
||||
filterInput.value = '';
|
||||
applyFilter();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -281,6 +316,7 @@
|
|||
state.selectedId = id;
|
||||
state.lastPreviewedNodeId = id;
|
||||
tree.render(); // refresh selection highlight
|
||||
syncURLToSelection();
|
||||
var p = previewMod();
|
||||
if (p) p.showFilePreview(node);
|
||||
});
|
||||
|
|
@ -314,9 +350,734 @@
|
|||
}
|
||||
navigateIntoFolder(node);
|
||||
});
|
||||
|
||||
// Keyboard navigation in the tree. Document-level listener so
|
||||
// the user doesn't have to click into the tree first; bails
|
||||
// out cleanly when focus is in an editable field or when a
|
||||
// modal / context-menu owns the keys. Roving-tabindex-style
|
||||
// semantics, matching the W3C tree-view pattern:
|
||||
//
|
||||
// ↓ / ↑ — move selection (auto-previews files)
|
||||
// → — expand if collapsed; jump to first child
|
||||
// if already expanded; no-op otherwise
|
||||
// ← — collapse if expanded; jump to parent
|
||||
// if collapsed/leaf
|
||||
// Enter / Space — preview file / toggle folder
|
||||
// Home / End — first / last visible row
|
||||
document.addEventListener('keydown', function (e) {
|
||||
// Skip editable contexts.
|
||||
var tag = (e.target && e.target.tagName) || '';
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
if (e.target && e.target.isContentEditable) return;
|
||||
// Skip when a modal or context menu is open.
|
||||
if (document.querySelector('.modal-overlay, .zddc-menu')) return;
|
||||
// Skip if any modifier is pressed — lets Ctrl-F, Cmd-T,
|
||||
// Alt-arrow back/forward etc. fall through unchanged.
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
|
||||
var key = e.key;
|
||||
var navKey = key === 'ArrowDown' || key === 'ArrowUp'
|
||||
|| key === 'ArrowLeft' || key === 'ArrowRight'
|
||||
|| key === 'Home' || key === 'End'
|
||||
|| key === 'Enter' || key === ' ';
|
||||
if (!navKey) return;
|
||||
|
||||
var visible = tree.visibleIds();
|
||||
if (!visible.length) return;
|
||||
|
||||
// Commit to handling this key — preventDefault so the
|
||||
// browser doesn't also scroll on arrows / page-down on
|
||||
// Space. Selection / expand actions happen below.
|
||||
e.preventDefault();
|
||||
|
||||
var curIdx = visible.indexOf(state.selectedId);
|
||||
var node = state.selectedId != null
|
||||
? state.nodes.get(state.selectedId) : null;
|
||||
var expandable = !!(node && (node.isDir || node.isZip));
|
||||
var nextId = null;
|
||||
var previewModule = previewMod();
|
||||
|
||||
if (key === 'ArrowDown') {
|
||||
nextId = curIdx < 0
|
||||
? visible[0]
|
||||
: visible[Math.min(curIdx + 1, visible.length - 1)];
|
||||
} else if (key === 'ArrowUp') {
|
||||
nextId = curIdx < 0
|
||||
? visible[visible.length - 1]
|
||||
: visible[Math.max(curIdx - 1, 0)];
|
||||
} else if (key === 'Home') {
|
||||
nextId = visible[0];
|
||||
} else if (key === 'End') {
|
||||
nextId = visible[visible.length - 1];
|
||||
} else if (key === 'ArrowRight' && node) {
|
||||
if (expandable && !node.expanded) {
|
||||
tree.toggleFolder(node.id);
|
||||
return;
|
||||
}
|
||||
if (expandable && node.expanded
|
||||
&& node.childIds && node.childIds.length) {
|
||||
nextId = node.childIds[0];
|
||||
}
|
||||
} else if (key === 'ArrowLeft' && node) {
|
||||
if (expandable && node.expanded) {
|
||||
tree.toggleFolder(node.id);
|
||||
return;
|
||||
}
|
||||
if (node.parentId != null) {
|
||||
nextId = node.parentId;
|
||||
}
|
||||
} else if ((key === 'Enter' || key === ' ') && node) {
|
||||
if (expandable) {
|
||||
tree.toggleFolder(node.id);
|
||||
} else if (previewModule) {
|
||||
previewModule.showFilePreview(node);
|
||||
state.lastPreviewedNodeId = node.id;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextId == null) return;
|
||||
state.selectedId = nextId;
|
||||
var nextNode = state.nodes.get(nextId);
|
||||
tree.render();
|
||||
syncURLToSelection();
|
||||
// Auto-preview files as the keyboard cursor lands on them
|
||||
// so the right pane keeps up with selection. Folders are
|
||||
// selection-only; their preview is "expand to see inside".
|
||||
if (nextNode && !nextNode.isDir && !nextNode.isZip
|
||||
&& previewModule) {
|
||||
previewModule.showFilePreview(nextNode);
|
||||
state.lastPreviewedNodeId = nextId;
|
||||
}
|
||||
// Scroll the now-selected row into view.
|
||||
var newRow = treeBody.querySelector(
|
||||
'.tree-row[data-id="' + nextId + '"]');
|
||||
if (newRow) newRow.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
|
||||
// Right-click → context menu. Two surfaces:
|
||||
// - on a tree row: per-row menu (Open, Rename, Delete, …)
|
||||
// - on empty space in the pane: directory-scope menu
|
||||
// (New folder, Refresh, Sort by, …)
|
||||
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
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Per-row drag-drop. Any row is a drop target — folders
|
||||
// upload into themselves; files upload into their parent
|
||||
// folder. Highlighting is purely visual; server-side ACL
|
||||
// is the source of truth (a 403 surfaces as an error toast).
|
||||
wirePerRowDrop(treeBody);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-row drag/drop targets ─────────────────────────────────────────
|
||||
|
||||
// Translate a node into the directory that should receive uploads
|
||||
// dropped onto its row. Folders → themselves; files → their parent.
|
||||
// Returns a server path with a trailing slash, or null when there's
|
||||
// no usable destination (offline mode, virtual node, etc.).
|
||||
function targetDirForNode(node) {
|
||||
if (!node || node.virtual) return null;
|
||||
if (state.source !== 'server') return null;
|
||||
if (node.isZip) return null; // can't upload INTO a zip via PUT
|
||||
var dirNode = node;
|
||||
if (!node.isDir) {
|
||||
if (node.parentId == null) {
|
||||
// Top-level file → upload to current scope.
|
||||
return state.currentPath || '/';
|
||||
}
|
||||
dirNode = state.nodes.get(node.parentId);
|
||||
if (!dirNode) return null;
|
||||
}
|
||||
var p = tree.pathFor(dirNode);
|
||||
if (!p.endsWith('/')) p += '/';
|
||||
return p;
|
||||
}
|
||||
|
||||
// True when this node is a file viewed through the synthetic
|
||||
// <workflow>/received/ window — the URL has a `received/` segment
|
||||
// that's NOT preceded by `archive/<party>/` (the canonical record
|
||||
// form). A drop here is a review-comment intent: server rewrites to
|
||||
// <workflow>/<base>+C<n><suffix>.
|
||||
function isVirtualReceivedFile(node) {
|
||||
if (!node || node.isDir || state.source !== 'server') return false;
|
||||
var url = tree.pathFor(node);
|
||||
var parts = url.replace(/^\/+/, '').split('/');
|
||||
var idx = parts.indexOf('received');
|
||||
if (idx < 2) return false;
|
||||
// Canonical form: parts[idx - 2] === 'archive'. Virtual form: anything else.
|
||||
return parts[idx - 2].toLowerCase() !== 'archive';
|
||||
}
|
||||
|
||||
function dragHasFiles(e) {
|
||||
if (!e.dataTransfer || !e.dataTransfer.types) return false;
|
||||
var types = e.dataTransfer.types;
|
||||
for (var i = 0; i < types.length; i++) {
|
||||
if (types[i] === 'Files') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function wirePerRowDrop(treeBody) {
|
||||
var lastOver = null;
|
||||
function clearHighlight() {
|
||||
if (lastOver) {
|
||||
lastOver.classList.remove('is-droptarget');
|
||||
lastOver = null;
|
||||
}
|
||||
}
|
||||
treeBody.addEventListener('dragover', function (e) {
|
||||
if (!dragHasFiles(e)) return;
|
||||
var row = e.target.closest('.tree-row');
|
||||
if (!row) { clearHighlight(); return; }
|
||||
var id = parseInt(row.dataset.id, 10);
|
||||
var node = state.nodes.get(id);
|
||||
if (!node) return;
|
||||
var dest = targetDirForNode(node);
|
||||
if (!dest) {
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'none';
|
||||
clearHighlight();
|
||||
return;
|
||||
}
|
||||
e.preventDefault(); // signals "this is a drop target"
|
||||
e.stopPropagation(); // suppress doc-level overlay
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||||
if (lastOver !== row) {
|
||||
clearHighlight();
|
||||
row.classList.add('is-droptarget');
|
||||
lastOver = row;
|
||||
}
|
||||
});
|
||||
treeBody.addEventListener('dragleave', function (e) {
|
||||
// dragleave fires on row crossings too — only clear when the
|
||||
// pointer actually leaves the tree body.
|
||||
if (!e.relatedTarget || !treeBody.contains(e.relatedTarget)) {
|
||||
clearHighlight();
|
||||
}
|
||||
});
|
||||
treeBody.addEventListener('drop', async function (e) {
|
||||
if (!dragHasFiles(e)) return;
|
||||
var row = e.target.closest('.tree-row');
|
||||
clearHighlight();
|
||||
if (!row) return;
|
||||
var id = parseInt(row.dataset.id, 10);
|
||||
var node = state.nodes.get(id);
|
||||
if (!node) return;
|
||||
// Comment-upload short-circuit: drop on a file that lives
|
||||
// under the virtual <workflow>/received/ window is a "comment
|
||||
// on this file" intent. PUT to the target's URL — the server
|
||||
// rewrites to <workflow>/<base>+C<n><suffix> and the canonical
|
||||
// record (WORM) stays untouched. Confirm first so the user
|
||||
// sees what's about to happen.
|
||||
if (!node.isDir && isVirtualReceivedFile(node)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!window.confirm("Drop bytes here as a review comment on '" + node.name + "'? The server will save it in the workflow folder with a +C<n> revision modifier.")) {
|
||||
return;
|
||||
}
|
||||
var upMod = window.app.modules.upload;
|
||||
if (!upMod) return;
|
||||
var targetURL = tree.pathFor(node);
|
||||
try {
|
||||
await upMod.uploadCommentToTarget(targetURL, e.dataTransfer);
|
||||
} catch (err) {
|
||||
statusError('Comment upload failed: ' + (err.message || err));
|
||||
}
|
||||
return;
|
||||
}
|
||||
var dest = targetDirForNode(node);
|
||||
if (!dest) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // pre-empt doc-level handler
|
||||
var up = window.app.modules.upload;
|
||||
if (!up) return;
|
||||
try {
|
||||
await up.uploadToDir(dest, e.dataTransfer);
|
||||
} catch (err) {
|
||||
statusError('Upload failed: ' + (err.message || err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Create new folder / file (server mode) ────────────────────────────
|
||||
|
||||
// Reject names with path separators, leading dots, or empty input —
|
||||
// mirrors the server-side hidden-segment / no-traversal guards so
|
||||
// the user sees the rejection without a round-trip.
|
||||
function validateName(name) {
|
||||
name = (name || '').trim();
|
||||
if (!name) return { ok: false, msg: 'Name required.' };
|
||||
if (name.indexOf('/') !== -1) return { ok: false, msg: 'No slashes allowed.' };
|
||||
if (name === '.' || name === '..') return { ok: false, msg: 'Invalid name.' };
|
||||
if (name.charAt(0) === '.' || name.charAt(0) === '_') {
|
||||
return { ok: false, msg: 'Names beginning with "." or "_" are reserved.' };
|
||||
}
|
||||
return { ok: true, name: name };
|
||||
}
|
||||
|
||||
// Resolve "the directory new items go into" for a given row.
|
||||
// Folders/zips: create inside them. Files: create alongside (in
|
||||
// their parent). Used by the row-context New menu items.
|
||||
function parentDirFor(node) {
|
||||
var parentDir;
|
||||
if (!node) {
|
||||
parentDir = state.currentPath || '/';
|
||||
} else if (node.isDir || node.isZip) {
|
||||
parentDir = tree.pathFor(node);
|
||||
} else if (node.parentId != null) {
|
||||
var parent = state.nodes.get(node.parentId);
|
||||
parentDir = parent ? tree.pathFor(parent) : (state.currentPath || '/');
|
||||
} else {
|
||||
parentDir = state.currentPath || '/';
|
||||
}
|
||||
if (!parentDir.endsWith('/')) parentDir += '/';
|
||||
return parentDir;
|
||||
}
|
||||
|
||||
async function createInDir(parentDir, kind) {
|
||||
var up = window.app.modules.upload;
|
||||
if (!up) return;
|
||||
var promptMsg = kind === 'folder'
|
||||
? 'New folder name (under ' + parentDir + '):'
|
||||
: 'New markdown filename (under ' + parentDir + '):';
|
||||
var defaultName = kind === 'folder' ? 'new-folder' : 'new.md';
|
||||
var raw = window.prompt(promptMsg, defaultName);
|
||||
if (raw == null) return;
|
||||
var v = validateName(raw);
|
||||
if (!v.ok) {
|
||||
statusError(v.msg);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (kind === 'folder') {
|
||||
await up.makeDir(parentDir, v.name);
|
||||
statusInfo('Created folder ' + v.name);
|
||||
} else {
|
||||
var name = /\.(md|markdown)$/i.test(v.name) ? v.name : v.name + '.md';
|
||||
var template = '# ' + name.replace(/\.(md|markdown)$/i, '') + '\n\n';
|
||||
await up.makeFile(parentDir, name, template, 'text/markdown; charset=utf-8');
|
||||
statusInfo('Created ' + name);
|
||||
}
|
||||
await reloadDir(parentDir);
|
||||
} catch (e) {
|
||||
statusError('Create failed: ' + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
// and any expanded subdirectory.
|
||||
async function reloadDir(dirPath) {
|
||||
var loader = window.app.modules.loader;
|
||||
if (!loader) return;
|
||||
if (!dirPath.endsWith('/')) dirPath += '/';
|
||||
// Root-scope reload — refresh the visible top-level listing.
|
||||
if (dirPath === state.currentPath) {
|
||||
try {
|
||||
var es = state.source === 'server'
|
||||
? await loader.fetchServerChildren(dirPath)
|
||||
: (state.rootHandle ? await loader.fetchFsChildren(state.rootHandle) : []);
|
||||
tree.setRoot(es);
|
||||
} catch (_e) { /* swallow */ }
|
||||
tree.render();
|
||||
return;
|
||||
}
|
||||
// Otherwise find the node whose path matches and reload it.
|
||||
var noSlash = dirPath.replace(/\/$/, '');
|
||||
var hit = null;
|
||||
state.nodes.forEach(function (n) {
|
||||
if (hit || !n.isDir) return;
|
||||
if (tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n;
|
||||
});
|
||||
if (hit) {
|
||||
try {
|
||||
var raw = state.source === 'server'
|
||||
? await loader.fetchServerChildren(dirPath)
|
||||
: (hit.handle ? await loader.fetchFsChildren(hit.handle) : []);
|
||||
tree.setChildren(hit.id, raw);
|
||||
hit.expanded = true;
|
||||
} catch (_e) { /* swallow */ }
|
||||
tree.render();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rename / Delete ───────────────────────────────────────────────────
|
||||
|
||||
async function renameNode(node) {
|
||||
var up = window.app.modules.upload;
|
||||
if (!up || !up.canMutate(node)) return;
|
||||
var raw = window.prompt('Rename "' + node.name + '" to:', node.name);
|
||||
if (raw == null) return;
|
||||
var v = validateName(raw);
|
||||
if (!v.ok) { statusError(v.msg); return; }
|
||||
if (v.name === node.name) return;
|
||||
try {
|
||||
await up.renameNode(node, v.name);
|
||||
statusInfo('Renamed to ' + v.name);
|
||||
var parentPath = node.parentId != null
|
||||
? tree.pathFor(state.nodes.get(node.parentId))
|
||||
: (state.currentPath || '/');
|
||||
await reloadDir(parentPath);
|
||||
} catch (e) {
|
||||
statusError('Rename failed: ' + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNode(node) {
|
||||
var up = window.app.modules.upload;
|
||||
if (!up || !up.canMutate(node)) return;
|
||||
var what = node.isDir ? 'folder' : 'file';
|
||||
// Native confirm() is intentional — destructive actions
|
||||
// benefit from the browser's blocking, OS-styled dialog
|
||||
// (signals "this is serious"). A custom modal would look
|
||||
// friendlier; we want it to NOT look friendly.
|
||||
var msg = 'Permanently delete this ' + what + '?\n\n' + node.name;
|
||||
if (node.isDir) {
|
||||
msg += '\n\nThis will remove every file inside it.';
|
||||
}
|
||||
if (!window.confirm(msg)) return;
|
||||
try {
|
||||
await up.removeNode(node);
|
||||
statusInfo('Deleted ' + node.name);
|
||||
// Clear selection / preview when they pointed at the
|
||||
// now-gone node, so the right pane doesn't keep a ghost.
|
||||
if (state.selectedId === node.id) {
|
||||
state.selectedId = null;
|
||||
syncURLToSelection();
|
||||
}
|
||||
if (state.lastPreviewedNodeId === node.id) {
|
||||
state.lastPreviewedNodeId = null;
|
||||
var pb = document.getElementById('previewBody');
|
||||
if (pb) pb.innerHTML =
|
||||
'<div class="preview-empty">Click a file in the tree to preview it.</div>';
|
||||
var pt = document.getElementById('previewTitle');
|
||||
if (pt) pt.textContent = 'No file selected';
|
||||
var pm = document.getElementById('previewMeta');
|
||||
if (pm) pm.textContent = '';
|
||||
}
|
||||
var parentPath = node.parentId != null
|
||||
? tree.pathFor(state.nodes.get(node.parentId))
|
||||
: (state.currentPath || '/');
|
||||
await reloadDir(parentPath);
|
||||
} catch (e) {
|
||||
statusError('Delete failed: ' + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
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 },
|
||||
|
||||
// ── 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 },
|
||||
|
||||
// ── Create new (in the row's parent folder) ──
|
||||
{
|
||||
label: 'New folder',
|
||||
disabled: !serverMode,
|
||||
action: function (c) { createInside(c.node, 'folder'); }
|
||||
},
|
||||
{
|
||||
label: 'New markdown file',
|
||||
disabled: !serverMode,
|
||||
action: function (c) { createInside(c.node, 'markdown'); }
|
||||
},
|
||||
{ separator: true },
|
||||
|
||||
// ── Rename + Delete (the permission-gated pair) ──
|
||||
{
|
||||
label: 'Rename…',
|
||||
disabled: function (c) { return !canMutate(c); },
|
||||
action: function (c) { renameNode(c.node); }
|
||||
},
|
||||
{
|
||||
label: 'Delete…',
|
||||
icon: '🗑',
|
||||
danger: true,
|
||||
disabled: function (c) { return !canMutate(c); },
|
||||
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);
|
||||
}
|
||||
},
|
||||
{ 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();
|
||||
} }
|
||||
];
|
||||
}
|
||||
|
||||
// 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: !serverMode,
|
||||
action: function () { createInDir(state.currentPath || '/', 'folder'); }
|
||||
},
|
||||
{
|
||||
label: 'New markdown file',
|
||||
disabled: !serverMode,
|
||||
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();
|
||||
} }
|
||||
];
|
||||
}
|
||||
|
||||
// View mode is URL-driven, not UI-driven.
|
||||
//
|
||||
// ?view=grid → grid mode (only honored where classifier is
|
||||
|
|
@ -419,10 +1180,14 @@
|
|||
if (previewMeta) previewMeta.textContent = '';
|
||||
// pushState so the URL bar reflects the new scope. A real
|
||||
// reload would re-load browse at this URL (trailing slash →
|
||||
// ServeDirectory → embedded browse SPA).
|
||||
// ServeDirectory → embedded browse SPA). Then immediately
|
||||
// replaceState via syncURLToSelection so the new URL also
|
||||
// carries ?hidden=1 if the toggle is on (selection is null
|
||||
// at the new scope; the query gets only `hidden`).
|
||||
try {
|
||||
history.pushState({ zddcBrowse: true, path: url }, '', url);
|
||||
} catch (_e) { /* private browsing edge cases */ }
|
||||
syncURLToSelection();
|
||||
statusInfo('Entered ' + displayName);
|
||||
// The new scope may have a different default view (grid inside
|
||||
// incoming/, browse elsewhere). Re-resolve from the URL now
|
||||
|
|
|
|||
296
browse/js/hovercard.js
Normal file
296
browse/js/hovercard.js
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
// hovercard.js — rich-metadata tooltip for tree rows.
|
||||
//
|
||||
// Replaces the native title="…" attribute with a custom card that
|
||||
// surfaces every field we know about for a row: parsed ZDDC fields
|
||||
// (trackingNumber / revision / status / title / date), type, size,
|
||||
// modTime, on-server path, and URL. A delayed reveal (~350 ms) keeps
|
||||
// the card out of the way during fast traversal; it dismisses on
|
||||
// any click, right-click, scroll, or row change.
|
||||
//
|
||||
// Singleton DOM element appended to <body>; positioned fixed.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (!window.app || !window.app.modules) return;
|
||||
|
||||
var SHOW_DELAY_MS = 350;
|
||||
// Grace period after the cursor leaves the row before the card
|
||||
// hides. Lets the user move INTO the card to select / copy text;
|
||||
// the card cancels this timer on mouseenter.
|
||||
var HIDE_DELAY_MS = 200;
|
||||
|
||||
var state = window.app.state;
|
||||
var card = null;
|
||||
var showTimer = null;
|
||||
var hideTimer = null;
|
||||
var currentRow = null;
|
||||
|
||||
function ensureCard() {
|
||||
if (card) return card;
|
||||
card = document.createElement('div');
|
||||
card.className = 'tree-hovercard';
|
||||
card.setAttribute('aria-hidden', 'true');
|
||||
// Mouse interaction inside the card: cancel any pending hide
|
||||
// so the user can stay in it as long as they want, then re-
|
||||
// schedule on leave. Pointer-events:auto in the CSS lets the
|
||||
// mouse enter; user-select:text (default) lets them drag a
|
||||
// selection; right-click inside fires the browser's native
|
||||
// Copy menu since we never call preventDefault for it here.
|
||||
card.addEventListener('mouseenter', cancelHide);
|
||||
card.addEventListener('mouseleave', scheduleHide);
|
||||
document.body.appendChild(card);
|
||||
return card;
|
||||
}
|
||||
|
||||
function cancelHide() {
|
||||
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
|
||||
}
|
||||
|
||||
function scheduleHide() {
|
||||
cancelHide();
|
||||
hideTimer = setTimeout(hide, HIDE_DELAY_MS);
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (showTimer) { clearTimeout(showTimer); showTimer = null; }
|
||||
cancelHide();
|
||||
if (card) card.classList.remove('is-visible');
|
||||
currentRow = null;
|
||||
}
|
||||
|
||||
// ── Formatting (kept local so this module is self-contained) ──
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function fmtSize(bytes) {
|
||||
if (bytes == null) return '';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '';
|
||||
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
|
||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
|
||||
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||
}
|
||||
|
||||
function typeLabelFor(node) {
|
||||
if (node.isDir) return 'Folder';
|
||||
if (node.isZip) return 'Zip archive';
|
||||
if (node.ext) return node.ext.toUpperCase() + ' file';
|
||||
return 'File';
|
||||
}
|
||||
|
||||
function buildRowsHtml(node) {
|
||||
var tree = window.app.modules.tree;
|
||||
var z = window.zddc;
|
||||
var parsed = null;
|
||||
if (z) {
|
||||
parsed = node.isDir
|
||||
? z.parseFolder(node.name)
|
||||
: z.parseFilename(node.name);
|
||||
}
|
||||
|
||||
var html = '';
|
||||
|
||||
// ZDDC fields first when the basename parses.
|
||||
if (parsed && parsed.valid) {
|
||||
if (parsed.date) html += kv('Date', parsed.date, true);
|
||||
if (parsed.trackingNumber) html += kv('Tracking number', parsed.trackingNumber, true);
|
||||
if (parsed.revision) html += kv('Revision', parsed.revision, true);
|
||||
if (parsed.status) html += kv('Status', parsed.status, true);
|
||||
if (parsed.title) html += kv('Title', parsed.title);
|
||||
|
||||
// Archive references — the /<project>/.archive/<tracking>.html
|
||||
// URL is the latest issued version (highest base rev), and
|
||||
// /<project>/.archive/<tracking>_<rev>.html pins the exact
|
||||
// revision the user is currently hovering. The dispatcher
|
||||
// canonicalises both forms to project-root so links work
|
||||
// from any depth.
|
||||
if (parsed.trackingNumber) {
|
||||
var fullPath = tree ? tree.pathFor(node) : '';
|
||||
var rel = fullPath.replace(/^\/+|\/+$/g, '');
|
||||
var firstSeg = rel ? rel.split('/')[0] : '';
|
||||
if (firstSeg) {
|
||||
var encProject = encodeURIComponent(firstSeg);
|
||||
var encTracking = encodeURIComponent(parsed.trackingNumber);
|
||||
var latestUrl = '/' + encProject + '/.archive/' + encTracking + '.html';
|
||||
var latestLbl = '.archive/' + parsed.trackingNumber + '.html';
|
||||
html += kvLink('Latest', latestUrl, latestLbl);
|
||||
if (!node.isDir && parsed.revision) {
|
||||
var encRev = encodeURIComponent(parsed.revision);
|
||||
var inspectUrl = '/' + encProject + '/.archive/' + encTracking + '_' + encRev + '.html';
|
||||
var inspectLbl = '.archive/' + parsed.trackingNumber + '_' + parsed.revision + '.html';
|
||||
html += kvLink('This revision', inspectUrl, inspectLbl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html += '<div class="tree-hovercard__sep"></div>';
|
||||
} else if (node.displayName) {
|
||||
// Operator-supplied display name — only useful as info if
|
||||
// it differs from the on-disk name.
|
||||
html += kv('Display name', node.displayName);
|
||||
}
|
||||
|
||||
html += kv('Type', typeLabelFor(node));
|
||||
if (!node.isDir) html += kv('Filename', node.name, true);
|
||||
if (!node.isDir && node.size != null) html += kv('Size', fmtSize(node.size));
|
||||
if (node.modTime) html += kv('Modified', fmtDate(node.modTime));
|
||||
if (node.virtual) html += kv('Virtual', 'Not yet created on disk');
|
||||
|
||||
// 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);
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function kv(key, val, mono) {
|
||||
return '<span class="tree-hovercard__key">' + escapeHtml(key) + '</span>'
|
||||
+ '<span class="tree-hovercard__val'
|
||||
+ (mono ? ' tree-hovercard__val--mono' : '')
|
||||
+ '">' + escapeHtml(val) + '</span>';
|
||||
}
|
||||
|
||||
// kvLink — value rendered as an <a> the user can click (opens in
|
||||
// a new tab so the hover context isn't lost) or right-click to
|
||||
// copy. Used for the .archive references on ZDDC files.
|
||||
function kvLink(key, href, label) {
|
||||
return '<span class="tree-hovercard__key">' + escapeHtml(key) + '</span>'
|
||||
+ '<span class="tree-hovercard__val tree-hovercard__val--mono">'
|
||||
+ '<a href="' + escapeHtml(href) + '" target="_blank" rel="noopener">'
|
||||
+ escapeHtml(label)
|
||||
+ '</a>'
|
||||
+ '</span>';
|
||||
}
|
||||
|
||||
function render(node) {
|
||||
var z = window.zddc;
|
||||
var parsed = z
|
||||
? (node.isDir ? z.parseFolder(node.name) : z.parseFilename(node.name))
|
||||
: null;
|
||||
|
||||
var primary, secondary = '';
|
||||
if (parsed && parsed.valid) {
|
||||
primary = parsed.title;
|
||||
var parts = node.isDir
|
||||
? [parsed.date, parsed.trackingNumber, parsed.status]
|
||||
: [parsed.trackingNumber, parsed.revision, parsed.status];
|
||||
secondary = parts.filter(Boolean).join(' · ');
|
||||
} else if (node.displayName) {
|
||||
primary = node.displayName;
|
||||
} else {
|
||||
primary = node.name;
|
||||
}
|
||||
|
||||
card.innerHTML = ''
|
||||
+ '<div class="tree-hovercard__header">'
|
||||
+ '<div class="tree-hovercard__title">' + escapeHtml(primary) + '</div>'
|
||||
+ (secondary
|
||||
? '<div class="tree-hovercard__sub">' + escapeHtml(secondary) + '</div>'
|
||||
: '')
|
||||
+ '</div>'
|
||||
+ '<div class="tree-hovercard__list">' + buildRowsHtml(node) + '</div>';
|
||||
}
|
||||
|
||||
function position(row) {
|
||||
// Two-pass measure: temporarily make visible-but-invisible so
|
||||
// we can read offsetWidth / offsetHeight, compute placement,
|
||||
// then reveal at the final coordinates.
|
||||
card.style.left = '0px';
|
||||
card.style.top = '0px';
|
||||
card.style.visibility = 'hidden';
|
||||
card.classList.add('is-visible');
|
||||
var cw = card.offsetWidth;
|
||||
var ch = card.offsetHeight;
|
||||
var rect = row.getBoundingClientRect();
|
||||
var GAP = 8;
|
||||
var x = rect.right + GAP;
|
||||
if (x + cw > window.innerWidth - GAP) {
|
||||
x = rect.left - cw - GAP;
|
||||
}
|
||||
if (x < GAP) {
|
||||
// Fallback: anchor under the row (last resort when the
|
||||
// pane is wide enough that neither side fits).
|
||||
x = Math.max(GAP, Math.min(rect.left, window.innerWidth - cw - GAP));
|
||||
}
|
||||
var y = rect.top;
|
||||
if (y + ch > window.innerHeight - GAP) {
|
||||
y = Math.max(GAP, window.innerHeight - ch - GAP);
|
||||
}
|
||||
if (y < GAP) y = GAP;
|
||||
card.style.left = x + 'px';
|
||||
card.style.top = y + 'px';
|
||||
card.style.visibility = '';
|
||||
}
|
||||
|
||||
function showFor(row, node) {
|
||||
ensureCard();
|
||||
render(node);
|
||||
position(row);
|
||||
card.classList.add('is-visible');
|
||||
}
|
||||
|
||||
function init() {
|
||||
var treeBody = document.getElementById('treeBody');
|
||||
if (!treeBody) return;
|
||||
|
||||
treeBody.addEventListener('mouseover', function (e) {
|
||||
// Returning to the tree from the card cancels any pending
|
||||
// hide; the show logic below handles row changes.
|
||||
cancelHide();
|
||||
var row = e.target.closest('.tree-row');
|
||||
if (row === currentRow) return;
|
||||
// Row → row or row → empty space — reset.
|
||||
if (showTimer) { clearTimeout(showTimer); showTimer = null; }
|
||||
if (card) card.classList.remove('is-visible');
|
||||
currentRow = row || null;
|
||||
if (!row) return;
|
||||
showTimer = setTimeout(function () {
|
||||
if (currentRow !== row) return;
|
||||
var id = parseInt(row.dataset.id, 10);
|
||||
var node = state.nodes.get(id);
|
||||
if (node) showFor(row, node);
|
||||
}, SHOW_DELAY_MS);
|
||||
});
|
||||
|
||||
// Leaving the tree schedules a hide rather than hiding
|
||||
// immediately, so the cursor has time to traverse the gap to
|
||||
// the card. The card's own mouseenter cancels the hide.
|
||||
treeBody.addEventListener('mouseleave', scheduleHide);
|
||||
treeBody.addEventListener('contextmenu', hide);
|
||||
window.addEventListener('scroll', hide, true);
|
||||
window.addEventListener('resize', hide);
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') hide();
|
||||
});
|
||||
|
||||
// Click anywhere outside the card dismisses it. Clicks INSIDE
|
||||
// the card are allowed through so the user can drag-select
|
||||
// text, right-click for the browser's native Copy menu, or
|
||||
// hit Ctrl/Cmd-C.
|
||||
document.addEventListener('mousedown', function (e) {
|
||||
if (!card || !card.classList.contains('is-visible')) return;
|
||||
if (card.contains(e.target)) return;
|
||||
hide();
|
||||
}, true);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
window.app.modules.hovercard = { hide: hide };
|
||||
})();
|
||||
|
|
@ -8,6 +8,15 @@
|
|||
window.app = { modules: {}, state: {} };
|
||||
}
|
||||
|
||||
// Mount the shared Lucide outline-icon sprite into <body> before
|
||||
// the tree first renders. The sprite is hidden (display:none on
|
||||
// the outer <svg>) — it only exists so per-row <use href="#…"/>
|
||||
// refs resolve. Falls back to deferring until DOMContentLoaded
|
||||
// when <body> isn't ready yet.
|
||||
if (window.zddc && window.zddc.icons) {
|
||||
window.zddc.icons.inject();
|
||||
}
|
||||
|
||||
window.app.state = {
|
||||
// Source: 'server' | 'fs' | null. Determines how the loader
|
||||
// resolves entries.
|
||||
|
|
@ -61,6 +70,13 @@
|
|||
// scopeDefaultTool: cascade's default_tool at currentPath
|
||||
// (empty when no default declared)
|
||||
scopeDropTarget: false,
|
||||
scopeDefaultTool: ''
|
||||
scopeDefaultTool: '',
|
||||
|
||||
// Autofilter — when non-empty, the tree hides files that
|
||||
// don't match and folders whose subtree has no matches.
|
||||
// Parsed once on input change so visibleIds() / rowHtml()
|
||||
// can run filter.matches(text, ast) cheaply per node.
|
||||
filterText: '',
|
||||
filterAST: null
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@
|
|||
modTime: e.mod_time ? new Date(e.mod_time) : null,
|
||||
ext: e.is_dir ? '' : splitExt(name),
|
||||
url: e.url || null,
|
||||
// Server-computed write authority — true if the policy
|
||||
// decider would allow a PUT for the calling principal.
|
||||
// Absent / false means "save will 403"; preview editors
|
||||
// read this to mount in read-only mode.
|
||||
writable: !!e.writable,
|
||||
// FS-API specific (null in server mode):
|
||||
handle: null
|
||||
};
|
||||
|
|
@ -107,6 +112,20 @@
|
|||
// without re-implementing the cascade client-side.
|
||||
window.app.state.scopeDefaultTool =
|
||||
(resp.headers.get('X-ZDDC-Default-Tool') || '').toLowerCase();
|
||||
// X-ZDDC-On-Plan-Review surfaces whether the cascade above
|
||||
// this path has an on_plan_review block. Drives visibility of
|
||||
// the "Plan Review" right-click menu item on received/<tracking>/
|
||||
// folders.
|
||||
window.app.state.scopeOnPlanReview =
|
||||
(resp.headers.get('X-ZDDC-On-Plan-Review') || '').toLowerCase() === 'true';
|
||||
// X-ZDDC-Canonical-Folder names the canonical project-layout
|
||||
// slot this directory occupies — "incoming", "received",
|
||||
// "working", "staging", etc. Drives scope-aware menu items:
|
||||
// Accept Transmittal (folders under incoming), Stage/Unstage
|
||||
// (files under working/staging), Create Transmittal folder
|
||||
// (right-click in staging).
|
||||
window.app.state.scopeCanonicalFolder =
|
||||
(resp.headers.get('X-ZDDC-Canonical-Folder') || '').toLowerCase();
|
||||
if (resp.status === 404) {
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
276
browse/js/plan-review.js
Normal file
276
browse/js/plan-review.js
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
// plan-review.js — the doc-controller "Plan Review" workflow modal.
|
||||
//
|
||||
// Surfaced by events.js as a right-click menu item on
|
||||
// archive/<party>/received/<tracking>/ folders when the cascade above
|
||||
// has an on_plan_review block (X-ZDDC-On-Plan-Review header on the
|
||||
// listing).
|
||||
//
|
||||
// The modal collects four fields:
|
||||
//
|
||||
// - review_lead (becomes sub-admin of reviewing/<…>/)
|
||||
// - plan_review_complete_date (the committed review-done date)
|
||||
// - approver (becomes sub-admin of staging/<…>/)
|
||||
// - plan_response_date (the committed response-issue date)
|
||||
//
|
||||
// The planned dates are immutable from the sub-admins' perspective —
|
||||
// they live in the canonical submittal's .zddc
|
||||
// (received/<tracking>/.zddc) where only the doc controller (via Plan
|
||||
// Review re-run) can change them. The workflow folders' .zddc files
|
||||
// carry only the back-link + per-folder ACL.
|
||||
//
|
||||
// Title is auto-derived server-side from the first ZDDC-parseable
|
||||
// file in received/<tracking>/. Forecast dates default to the planned
|
||||
// dates at scaffolding time; the user renames the workflow folder
|
||||
// directly to update the forecast later.
|
||||
//
|
||||
// On submit, the form assembles a YAML body and POSTs it with
|
||||
// X-ZDDC-Op: plan-review to the received/<tracking>/ URL.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var REVIEW_OFFSET_DAYS = 7;
|
||||
var RESPONSE_OFFSET_DAYS = 14;
|
||||
|
||||
function statusInfo(msg) {
|
||||
var el = document.getElementById('statusBar');
|
||||
if (!el) return;
|
||||
el.textContent = msg || '';
|
||||
el.classList.remove('status-bar--error');
|
||||
el.classList.add('status-bar--info');
|
||||
}
|
||||
function statusError(msg) {
|
||||
var el = document.getElementById('statusBar');
|
||||
if (!el) return;
|
||||
el.textContent = msg || '';
|
||||
el.classList.remove('status-bar--info');
|
||||
el.classList.add('status-bar--error');
|
||||
}
|
||||
|
||||
// Compute today + N days as a YYYY-MM-DD string.
|
||||
function isoDatePlus(days) {
|
||||
var d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
var y = d.getFullYear();
|
||||
var m = ('0' + (d.getMonth() + 1)).slice(-2);
|
||||
var dd = ('0' + d.getDate()).slice(-2);
|
||||
return y + '-' + m + '-' + dd;
|
||||
}
|
||||
|
||||
// Fetch suggestion emails from /.profile/access so the originator
|
||||
// field has a datalist of likely values. Best-effort — silent on
|
||||
// failure (the field still accepts free text).
|
||||
async function fetchOriginatorSuggestions() {
|
||||
try {
|
||||
var resp = await fetch('/.profile/access', {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!resp.ok) return [];
|
||||
var data = await resp.json();
|
||||
var out = [];
|
||||
// The endpoint exposes the current user + any role members
|
||||
// visible to them. Pull anything that looks like an email
|
||||
// for the datalist; the field is otherwise free text.
|
||||
if (data && data.email) out.push(data.email);
|
||||
return out;
|
||||
} catch (_e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Build the YAML body for the plan-review POST. Quoting is minimal
|
||||
// (just enough for emails with special chars).
|
||||
function buildBody(values) {
|
||||
function yamlString(s) {
|
||||
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
||||
}
|
||||
return [
|
||||
'review_lead: ' + yamlString(values.reviewLead),
|
||||
'approver: ' + yamlString(values.approver),
|
||||
'plan_review_complete_date: ' + values.planReviewDate,
|
||||
'plan_response_date: ' + values.planResponseDate,
|
||||
''
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// Render the modal. Returns a Promise that resolves on submit
|
||||
// (with the collected values) or rejects on cancel.
|
||||
function openForm(initial) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var overlay = document.createElement('div');
|
||||
overlay.className = 'modal-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.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:24rem;max-width:32rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);font-family:inherit;';
|
||||
|
||||
box.innerHTML =
|
||||
'<h2 style="margin:0 0 0.75rem 0;font-size:1.1rem;">Plan Review — ' + escapeHtml(initial.tracking) + '</h2>' +
|
||||
'<div style="display:grid;grid-template-columns:max-content 1fr;gap:0.5rem 0.75rem;align-items:center;font-size:0.9rem;">' +
|
||||
'<label for="pr-review-lead">Review lead</label>' +
|
||||
'<input id="pr-review-lead" type="email" list="pr-people-list" required style="padding:0.25rem 0.4rem;" placeholder="email — becomes sub-admin of reviewing/<…>">' +
|
||||
'<label for="pr-review-date">Plan review complete date</label>' +
|
||||
'<input id="pr-review-date" type="date" required>' +
|
||||
'<label for="pr-approver">Approver</label>' +
|
||||
'<input id="pr-approver" type="email" list="pr-people-list" required style="padding:0.25rem 0.4rem;" placeholder="email — becomes sub-admin of staging/<…>">' +
|
||||
'<label for="pr-response-date">Plan response date</label>' +
|
||||
'<input id="pr-response-date" type="date" required>' +
|
||||
'<datalist id="pr-people-list"></datalist>' +
|
||||
'</div>' +
|
||||
'<p style="margin:0.75rem 0 0 0;font-size:0.8rem;color:#666;">Planned dates seal at first submission — they become part of the canonical record (received/<tracking>/.zddc) and the WORM zone prevents further edits. Subsequent Plan Reviews can swap the review lead or approver without changing the dates.</p>' +
|
||||
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
|
||||
'<button type="button" id="pr-cancel">Cancel</button>' +
|
||||
'<button type="button" id="pr-submit" class="btn-primary">Plan Review</button>' +
|
||||
'</div>';
|
||||
|
||||
overlay.appendChild(box);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
var reviewLeadInput = box.querySelector('#pr-review-lead');
|
||||
var approverInput = box.querySelector('#pr-approver');
|
||||
var reviewDateInput = box.querySelector('#pr-review-date');
|
||||
var responseDateInput = box.querySelector('#pr-response-date');
|
||||
|
||||
reviewDateInput.value = isoDatePlus(REVIEW_OFFSET_DAYS);
|
||||
responseDateInput.value = isoDatePlus(RESPONSE_OFFSET_DAYS);
|
||||
|
||||
// Populate the datalist with people suggestions (best
|
||||
// effort — silent on failure).
|
||||
fetchOriginatorSuggestions().then(function (emails) {
|
||||
var dl = box.querySelector('#pr-people-list');
|
||||
if (!dl) return;
|
||||
emails.forEach(function (e) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = e;
|
||||
dl.appendChild(opt);
|
||||
});
|
||||
});
|
||||
|
||||
function close() {
|
||||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
}
|
||||
|
||||
box.querySelector('#pr-cancel').addEventListener('click', function () {
|
||||
close();
|
||||
reject(new Error('cancelled'));
|
||||
});
|
||||
overlay.addEventListener('click', function (e) {
|
||||
if (e.target === overlay) {
|
||||
close();
|
||||
reject(new Error('cancelled'));
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', function escHandler(e) {
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
close();
|
||||
reject(new Error('cancelled'));
|
||||
}
|
||||
});
|
||||
|
||||
box.querySelector('#pr-submit').addEventListener('click', function () {
|
||||
var values = {
|
||||
reviewLead: reviewLeadInput.value.trim(),
|
||||
approver: approverInput.value.trim(),
|
||||
planReviewDate: reviewDateInput.value,
|
||||
planResponseDate: responseDateInput.value
|
||||
};
|
||||
if (!values.reviewLead || !values.approver
|
||||
|| !values.planReviewDate || !values.planResponseDate) {
|
||||
statusError('All fields are required.');
|
||||
return;
|
||||
}
|
||||
close();
|
||||
resolve(values);
|
||||
});
|
||||
|
||||
reviewLeadInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return ({
|
||||
'&': '&', '<': '<', '>': '>',
|
||||
'"': '"', "'": '''
|
||||
})[c];
|
||||
});
|
||||
}
|
||||
|
||||
// Detect whether a tree node is an archive/<party>/received/<tracking>/
|
||||
// folder. The path is path-shaped, not content-based — tracking-number
|
||||
// content is not inspected (per design).
|
||||
function isReceivedTrackingFolder(node) {
|
||||
if (!node || !node.isDir) return false;
|
||||
var tree = window.app.modules.tree;
|
||||
if (!tree) return false;
|
||||
var p = tree.pathFor(node).replace(/\/$/, '');
|
||||
var rel = p.replace(/^\/+/, '');
|
||||
var parts = rel.split('/');
|
||||
return parts.length === 5
|
||||
&& parts[1].toLowerCase() === 'archive'
|
||||
&& parts[3].toLowerCase() === 'received';
|
||||
}
|
||||
|
||||
// Run the Plan Review flow: open the modal, POST the result.
|
||||
async function invoke(node) {
|
||||
var tree = window.app.modules.tree;
|
||||
if (!tree) return;
|
||||
var url = tree.pathFor(node);
|
||||
if (!url.endsWith('/')) url += '/';
|
||||
var parts = url.replace(/^\/+/, '').replace(/\/$/, '').split('/');
|
||||
var tracking = parts[parts.length - 1];
|
||||
|
||||
var values;
|
||||
try {
|
||||
values = await openForm({ tracking: tracking });
|
||||
} catch (_e) {
|
||||
return; // cancelled
|
||||
}
|
||||
|
||||
statusInfo('Plan Review — submitting…');
|
||||
var body = buildBody(values);
|
||||
var resp;
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-ZDDC-Op': 'plan-review',
|
||||
'Content-Type': 'application/yaml'
|
||||
},
|
||||
body: body,
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
} catch (e) {
|
||||
statusError('Plan Review failed: ' + (e && e.message ? e.message : e));
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
var text = '';
|
||||
try { text = await resp.text(); } catch (_e) { /* ignore */ }
|
||||
statusError('Plan Review failed (' + resp.status + '): ' + text);
|
||||
return;
|
||||
}
|
||||
var data;
|
||||
try { data = await resp.json(); } catch (_e) { data = null; }
|
||||
if (data && data.reviewing && data.staging) {
|
||||
var rPart = data.reviewing.created ? 'created' : 'updated';
|
||||
var sPart = data.staging.created ? 'created' : 'updated';
|
||||
var seal = (data.received && data.received.created)
|
||||
? ' Canonical record sealed.'
|
||||
: (data.received && !data.received.zddc_written)
|
||||
? ' Canonical dates left untouched (already sealed).'
|
||||
: '';
|
||||
statusInfo('Plan Review: reviewing ' + rPart + ', staging ' + sPart + '.' + seal +
|
||||
' Reload the relevant folder to see the new entries.');
|
||||
} else {
|
||||
statusInfo('Plan Review complete.');
|
||||
}
|
||||
}
|
||||
|
||||
window.app.modules.planReview = {
|
||||
isReceivedTrackingFolder: isReceivedTrackingFolder,
|
||||
invoke: invoke
|
||||
};
|
||||
})();
|
||||
|
|
@ -304,6 +304,11 @@
|
|||
|
||||
function canSave(node) {
|
||||
if (isZipMemberNode(node)) return false;
|
||||
// Server-computed authority gate. The listing's `writable`
|
||||
// bit reflects what a PUT would do — false here means the
|
||||
// file API would 403 the save, so we mount in read-only
|
||||
// mode rather than letting the user type and lose changes.
|
||||
if (node.url && window.app.state.source === 'server' && !node.writable) return false;
|
||||
if (node.handle && typeof node.handle.createWritable === 'function') return true;
|
||||
if (node.url && window.app.state.source === 'server') return true;
|
||||
return false;
|
||||
|
|
@ -346,13 +351,18 @@
|
|||
container.appendChild(shell);
|
||||
|
||||
// ── Sidebar (col 1): front matter (top) + TOC (bottom) ──────────────
|
||||
// Sidebar is a flex column: FM section (fixed height, set
|
||||
// inline below) + horizontal resizer + TOC section (1fr).
|
||||
var sidebar = document.createElement('div');
|
||||
sidebar.className = 'md-shell__sidebar';
|
||||
sidebar.style.gridTemplateRows = lastFmHeight + 'px 1fr';
|
||||
shell.appendChild(sidebar);
|
||||
|
||||
var fmSection = document.createElement('section');
|
||||
fmSection.className = 'md-side md-side--fm';
|
||||
// Front-matter height is driven inline (persisted across
|
||||
// remounts via lastFmHeight) so the resizer's drag-handler
|
||||
// mutates a single source of truth.
|
||||
fmSection.style.height = lastFmHeight + 'px';
|
||||
var fmHeader = document.createElement('div');
|
||||
fmHeader.className = 'md-side__header';
|
||||
fmHeader.textContent = 'YAML front matter';
|
||||
|
|
@ -363,7 +373,10 @@
|
|||
fmTextarea.spellcheck = false;
|
||||
fmTextarea.autocapitalize = 'off';
|
||||
fmTextarea.autocomplete = 'off';
|
||||
fmTextarea.placeholder = 'title: Document Title\ndate: 2026-05-13\ntags: [example]';
|
||||
// No placeholder text — files with no YAML front matter render
|
||||
// as a genuinely empty pane. Showing a synthetic example would
|
||||
// make the file look like it had data when it doesn't.
|
||||
fmTextarea.placeholder = '';
|
||||
fmBody.appendChild(fmTextarea);
|
||||
fmSection.appendChild(fmHeader);
|
||||
fmSection.appendChild(fmBody);
|
||||
|
|
@ -452,10 +465,16 @@
|
|||
node.url && /\.md$/i.test(node.name);
|
||||
var convertBtns = [];
|
||||
if (serverModeMd) {
|
||||
// Virtual-extension URLs: <file>.md → <file>.docx etc.
|
||||
// The dispatcher recognises the sibling-extension pattern
|
||||
// and routes through ServeConverted. Cleaner than the
|
||||
// old `?convert=` query form — right-clicking the link
|
||||
// gives a sensible "Save as <file>.docx" prompt.
|
||||
var mdUrlBase = node.url.replace(/\.md$/i, '');
|
||||
['docx', 'html', 'pdf'].forEach(function (fmt) {
|
||||
var a = document.createElement('a');
|
||||
a.className = 'btn btn-sm btn-secondary md-shell__download';
|
||||
a.href = node.url + '?convert=' + encodeURIComponent(fmt);
|
||||
a.href = mdUrlBase + '.' + fmt;
|
||||
// target=_blank: clicks open in a new tab. The server
|
||||
// sends Content-Disposition: inline, so the new tab
|
||||
// either renders (HTML → web page; PDF → browser's
|
||||
|
|
@ -499,21 +518,44 @@
|
|||
var bodyText = initialParsed.body;
|
||||
|
||||
var initialHash = await hashContent(assembleContent(fmTextarea.value, bodyText));
|
||||
var editor = new window.toastui.Editor({
|
||||
var writableMode = canSave(node);
|
||||
// autofocus:false keeps the keyboard caret in the tree pane —
|
||||
// arrow-key nav can continue through markdown files without
|
||||
// diverting into the editor. The user clicks into the editor
|
||||
// (or tabs to it) when they actually want to type.
|
||||
var editorOpts = {
|
||||
el: editorHost,
|
||||
height: '100%',
|
||||
initialEditType: 'markdown',
|
||||
previewStyle: 'vertical',
|
||||
initialValue: bodyText,
|
||||
usageStatistics: false,
|
||||
toolbarItems: [
|
||||
['heading', 'bold', 'italic', 'strike'],
|
||||
['hr', 'quote'],
|
||||
['ul', 'ol', 'task', 'indent', 'outdent'],
|
||||
['table', 'image', 'link'],
|
||||
['code', 'codeblock']
|
||||
]
|
||||
});
|
||||
autofocus: false,
|
||||
initialValue: bodyText,
|
||||
};
|
||||
var editor;
|
||||
if (!writableMode) {
|
||||
// Read-only mount uses Toast UI's Viewer (rendered markdown,
|
||||
// no edit toolbar, no caret). The disabled Save button +
|
||||
// its tooltip carry the read-only signal — no banner here
|
||||
// since the Viewer's lack of edit chrome is already a
|
||||
// clear visual cue.
|
||||
editor = window.toastui.Editor.factory(Object.assign({}, editorOpts, {
|
||||
viewer: true,
|
||||
}));
|
||||
} else {
|
||||
editor = new window.toastui.Editor(Object.assign({}, editorOpts, {
|
||||
// WYSIWYG by default — most users want the rendered view
|
||||
// out of the gate; the markdown/WYSIWYG toggle in the
|
||||
// Toast UI toolbar still flips to source mode in one click.
|
||||
initialEditType: 'wysiwyg',
|
||||
previewStyle: 'vertical',
|
||||
toolbarItems: [
|
||||
['heading', 'bold', 'italic', 'strike'],
|
||||
['hr', 'quote'],
|
||||
['ul', 'ol', 'task', 'indent', 'outdent'],
|
||||
['table', 'image', 'link'],
|
||||
['code', 'codeblock']
|
||||
]
|
||||
}));
|
||||
}
|
||||
|
||||
currentInstance = {
|
||||
editor: editor,
|
||||
|
|
@ -525,8 +567,7 @@
|
|||
fmEl: fmTextarea
|
||||
};
|
||||
|
||||
var writable = canSave(node);
|
||||
if (!writable) {
|
||||
if (!writableMode) {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.title = 'Save not available — read-only source.';
|
||||
fmTextarea.readOnly = true;
|
||||
|
|
@ -592,7 +633,7 @@
|
|||
var dy = e.clientY - startY;
|
||||
var h = Math.max(FM_MIN, Math.min(maxFmHeight(), startH + dy));
|
||||
lastFmHeight = h;
|
||||
sidebar.style.gridTemplateRows = h + 'px 1fr';
|
||||
fmSection.style.height = h + 'px';
|
||||
e.preventDefault();
|
||||
}
|
||||
function onUp() {
|
||||
|
|
@ -616,14 +657,17 @@
|
|||
var step = e.key === 'ArrowUp' ? -24 : 24;
|
||||
var h = Math.max(FM_MIN, Math.min(maxFmHeight(), lastFmHeight + step));
|
||||
lastFmHeight = h;
|
||||
sidebar.style.gridTemplateRows = h + 'px 1fr';
|
||||
fmSection.style.height = h + 'px';
|
||||
});
|
||||
})();
|
||||
|
||||
// ── Change tracking + auto-rerender ────────────────────────────────
|
||||
function markDirty(isDirty) {
|
||||
currentInstance.dirty = isDirty;
|
||||
saveBtn.disabled = !isDirty || !writable;
|
||||
// Re-read canSave at every transition, not via a closure-captured
|
||||
// value, so the gate reflects current write authority — see the
|
||||
// matching pattern in preview-yaml.js.
|
||||
saveBtn.disabled = !isDirty || !canSave(node);
|
||||
dirtyEl.textContent = isDirty ? '● modified' : '';
|
||||
}
|
||||
|
||||
|
|
@ -644,7 +688,7 @@
|
|||
|
||||
// ── Save ───────────────────────────────────────────────────────────
|
||||
async function save() {
|
||||
if (!currentInstance.dirty || !writable) return;
|
||||
if (!currentInstance.dirty || !canSave(node)) return;
|
||||
var content = assembleContent(fmTextarea.value, editor.getMarkdown());
|
||||
try {
|
||||
statusEl.textContent = 'Saving…';
|
||||
|
|
@ -690,7 +734,7 @@
|
|||
}
|
||||
// Dirty: intercept, save, retry.
|
||||
e.preventDefault();
|
||||
if (!writable) {
|
||||
if (!canSave(node)) {
|
||||
if (window.zddc && window.zddc.toast) {
|
||||
window.zddc.toast(
|
||||
'This source is read-only — save a copy elsewhere first.',
|
||||
|
|
|
|||
559
browse/js/preview-yaml.js
Normal file
559
browse/js/preview-yaml.js
Normal file
|
|
@ -0,0 +1,559 @@
|
|||
// preview-yaml.js — YAML editor plugin for the browse preview pane.
|
||||
//
|
||||
// Routes any .yaml / .yml file, plus the .zddc cascade files
|
||||
// (`.zddc` and `*.zddc.yaml`), through a CodeMirror 5 editor with
|
||||
// syntax highlighting and live linting. js-yaml.loadAll feeds parse
|
||||
// errors into CM's lint gutter; for .zddc files an additional
|
||||
// schema-aware pass flags unknown keys, bad enum values, and wrong
|
||||
// types.
|
||||
//
|
||||
// Layout (single column):
|
||||
// ┌─────────────────────────────────────────────────────────────┐
|
||||
// │ name | dirty | status | source | [Save] │
|
||||
// ├─────────────────────────────────────────────────────────────┤
|
||||
// │ CodeMirror editor (line numbers + lint gutter) │
|
||||
// └─────────────────────────────────────────────────────────────┘
|
||||
//
|
||||
// Save (Ctrl+S) writes back via PUT (server mode) or
|
||||
// FileSystemWritableFileStream (FS-API). Zip members and
|
||||
// virtual nodes are read-only — Save stays disabled.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (!window.app || !window.app.modules) return;
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── Filename routing ────────────────────────────────────────────────────
|
||||
|
||||
// True for .zddc cascade files — `.zddc` (literal name, no ext)
|
||||
// and `<anything>.zddc.yaml` (e.g. `defaults.zddc.yaml`). These
|
||||
// get the schema-aware lint layer.
|
||||
function isZddcFile(name) {
|
||||
if (!name) return false;
|
||||
if (name === '.zddc') return true;
|
||||
return /\.zddc\.ya?ml$/i.test(name);
|
||||
}
|
||||
|
||||
function isYamlFile(node) {
|
||||
if (!node || !node.name) return false;
|
||||
if (isZddcFile(node.name)) return true;
|
||||
var ext = (node.ext || '').toLowerCase();
|
||||
return ext === 'yaml' || ext === 'yml';
|
||||
}
|
||||
|
||||
// ── Save (mirrors preview-markdown.js) ─────────────────────────────────
|
||||
|
||||
async function saveContent(node, content) {
|
||||
if (node.handle && typeof node.handle.createWritable === 'function') {
|
||||
var writable = await node.handle.createWritable();
|
||||
await writable.write(content);
|
||||
await writable.close();
|
||||
return;
|
||||
}
|
||||
if (node.url && window.app.state.source === 'server') {
|
||||
var resp = await fetch(node.url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/x-yaml; charset=utf-8' },
|
||||
body: content,
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
return;
|
||||
}
|
||||
throw new Error('No write target for this file (read-only source).');
|
||||
}
|
||||
|
||||
function isZipMemberNode(node) {
|
||||
if (node.handle && node.handle.isZipEntry) return true;
|
||||
if (node.url && window.app.state.source === 'server'
|
||||
&& /\.zip\//i.test(node.url)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function canSave(node) {
|
||||
if (isZipMemberNode(node)) return false;
|
||||
// Virtual .zddc placeholders are designed to be saved — a PUT
|
||||
// materializes the file from the synthetic body and the next
|
||||
// listing serves a real entry. Every other virtual node (per-
|
||||
// user home, canonical-folder virtuals) is just a tree
|
||||
// affordance, not a writable file.
|
||||
if (node.virtual && node.name !== '.zddc') return false;
|
||||
// Server-computed authority gate. Mirrors the markdown editor's
|
||||
// check — listing's `writable` bit is the same decision the
|
||||
// file API would reach on PUT.
|
||||
if (node.url && window.app.state.source === 'server' && !node.writable) return false;
|
||||
if (node.handle && typeof node.handle.createWritable === 'function') return true;
|
||||
if (node.url && window.app.state.source === 'server') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function hashContent(text) {
|
||||
if (!window.crypto || !window.crypto.subtle) return null;
|
||||
var enc = new TextEncoder().encode(text);
|
||||
var buf = await window.crypto.subtle.digest('SHA-256', enc);
|
||||
var bytes = new Uint8Array(buf);
|
||||
var hex = '';
|
||||
for (var i = 0; i < bytes.length; i++) {
|
||||
hex += bytes[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
// ── .zddc schema ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Mirrors the Go-side decoder in zddc/internal/zddc/*. Allowed
|
||||
// tool names are the embedded set (always available) plus the
|
||||
// composable ones served when declared in apps:. Unknown keys at
|
||||
// any level surface as warnings — typos like `defaul_tool` are
|
||||
// common and the cascade silently ignores them.
|
||||
|
||||
var ALLOWED_TOOLS = {
|
||||
archive: 1, browse: 1, landing: 1, transmittal: 1, classifier: 1,
|
||||
tables: 1, form: 1
|
||||
};
|
||||
|
||||
var TOP_KEYS = {
|
||||
title: 'string',
|
||||
acl: 'acl',
|
||||
admins: 'string[]',
|
||||
roles: 'rolemap',
|
||||
available_tools: 'tools[]',
|
||||
default_tool: 'tool',
|
||||
dir_tool: 'tool',
|
||||
auto_own: 'bool',
|
||||
auto_own_fenced: 'bool',
|
||||
virtual: 'bool',
|
||||
drop_target: 'bool',
|
||||
worm: 'string[]',
|
||||
paths: 'pathmap',
|
||||
display: 'stringmap',
|
||||
apps: 'appsmap',
|
||||
apps_pubkey: 'string',
|
||||
tables: 'stringmap',
|
||||
convert: 'convert',
|
||||
created_by: 'string',
|
||||
inherit: 'bool'
|
||||
};
|
||||
|
||||
var ACL_KEYS = { inherit: 'bool', permissions: 'stringmap',
|
||||
allow: 'string[]', deny: 'string[]' };
|
||||
var ROLE_KEYS = { members: 'string[]', reset: 'bool' };
|
||||
var CONVERT_KEYS = { client: 'string', project: 'string',
|
||||
contractor: 'string', project_number: 'string' };
|
||||
|
||||
function typeOf(v) {
|
||||
if (v === null || v === undefined) return 'null';
|
||||
if (Array.isArray(v)) return 'array';
|
||||
return typeof v; // 'string' | 'number' | 'boolean' | 'object'
|
||||
}
|
||||
|
||||
// Collect schema issues for a parsed .zddc document. Each issue is
|
||||
// { keyPath: string[], message: string, severity: 'error' | 'warning' }.
|
||||
// keyPath is used by findLine() to locate the offending source line.
|
||||
function validateZddc(doc) {
|
||||
var issues = [];
|
||||
if (typeOf(doc) === 'null') return issues;
|
||||
if (typeOf(doc) !== 'object') {
|
||||
issues.push({ keyPath: [], severity: 'error',
|
||||
message: 'Root must be a map (got ' + typeOf(doc) + ').' });
|
||||
return issues;
|
||||
}
|
||||
walkObject(doc, TOP_KEYS, [], issues);
|
||||
return issues;
|
||||
}
|
||||
|
||||
function walkObject(obj, schema, path, issues) {
|
||||
for (var key in obj) {
|
||||
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
|
||||
var here = path.concat([key]);
|
||||
var kind = schema[key];
|
||||
if (!kind) {
|
||||
issues.push({ keyPath: here, severity: 'warning',
|
||||
message: 'Unknown key "' + key + '" — typo? It will be silently ignored.' });
|
||||
continue;
|
||||
}
|
||||
checkValue(obj[key], kind, here, issues);
|
||||
}
|
||||
}
|
||||
|
||||
function checkValue(val, kind, path, issues) {
|
||||
var t = typeOf(val);
|
||||
switch (kind) {
|
||||
case 'string':
|
||||
if (t !== 'string' && t !== 'null') addTypeErr(path, kind, t, issues);
|
||||
return;
|
||||
case 'bool':
|
||||
if (t !== 'boolean' && t !== 'null') addTypeErr(path, kind, t, issues);
|
||||
return;
|
||||
case 'string[]':
|
||||
if (t !== 'array' && t !== 'null') addTypeErr(path, kind, t, issues);
|
||||
return;
|
||||
case 'tools[]':
|
||||
if (t !== 'array' && t !== 'null') {
|
||||
addTypeErr(path, kind, t, issues); return;
|
||||
}
|
||||
if (t === 'array') {
|
||||
for (var i = 0; i < val.length; i++) {
|
||||
if (typeOf(val[i]) !== 'string') {
|
||||
issues.push({ keyPath: path, severity: 'error',
|
||||
message: 'available_tools[' + i + '] must be a string.' });
|
||||
} else if (!ALLOWED_TOOLS[val[i]]) {
|
||||
issues.push({ keyPath: path, severity: 'warning',
|
||||
message: 'Unknown tool "' + val[i]
|
||||
+ '". Known: ' + Object.keys(ALLOWED_TOOLS).join(', ') + '.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
case 'tool':
|
||||
if (t === 'null') return;
|
||||
if (t !== 'string') { addTypeErr(path, kind, t, issues); return; }
|
||||
if (!ALLOWED_TOOLS[val]) {
|
||||
issues.push({ keyPath: path, severity: 'warning',
|
||||
message: 'Unknown tool "' + val + '". Known: '
|
||||
+ Object.keys(ALLOWED_TOOLS).join(', ') + '.' });
|
||||
}
|
||||
return;
|
||||
case 'stringmap':
|
||||
if (t === 'null') return;
|
||||
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
|
||||
for (var k in val) {
|
||||
if (!Object.prototype.hasOwnProperty.call(val, k)) continue;
|
||||
if (typeOf(val[k]) !== 'string') {
|
||||
issues.push({ keyPath: path.concat([k]), severity: 'error',
|
||||
message: 'Value must be a string (got '
|
||||
+ typeOf(val[k]) + ').' });
|
||||
}
|
||||
}
|
||||
return;
|
||||
case 'pathmap':
|
||||
if (t === 'null') return;
|
||||
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
|
||||
for (var seg in val) {
|
||||
if (!Object.prototype.hasOwnProperty.call(val, seg)) continue;
|
||||
if (seg.indexOf('/') !== -1) {
|
||||
issues.push({ keyPath: path.concat([seg]), severity: 'error',
|
||||
message: 'Path keys must be a single segment — '
|
||||
+ 'nest blocks instead of using "' + seg + '".' });
|
||||
}
|
||||
var v = val[seg];
|
||||
if (typeOf(v) === 'null') continue;
|
||||
if (typeOf(v) !== 'object') {
|
||||
issues.push({ keyPath: path.concat([seg]), severity: 'error',
|
||||
message: 'paths.' + seg + ' must be a map of cascade rules.' });
|
||||
continue;
|
||||
}
|
||||
walkObject(v, TOP_KEYS, path.concat([seg]), issues);
|
||||
}
|
||||
return;
|
||||
case 'appsmap':
|
||||
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:.' });
|
||||
}
|
||||
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).' });
|
||||
}
|
||||
}
|
||||
return;
|
||||
case 'rolemap':
|
||||
if (t === 'null') return;
|
||||
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
|
||||
for (var rn in val) {
|
||||
if (!Object.prototype.hasOwnProperty.call(val, rn)) continue;
|
||||
var rv = val[rn];
|
||||
if (typeOf(rv) === 'null') continue;
|
||||
if (typeOf(rv) !== 'object') {
|
||||
issues.push({ keyPath: path.concat([rn]), severity: 'error',
|
||||
message: 'roles.' + rn + ' must be a map ({members, reset}).' });
|
||||
continue;
|
||||
}
|
||||
walkObject(rv, ROLE_KEYS, path.concat([rn]), issues);
|
||||
}
|
||||
return;
|
||||
case 'acl':
|
||||
if (t === 'null') return;
|
||||
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
|
||||
walkObject(val, ACL_KEYS, path, issues);
|
||||
return;
|
||||
case 'convert':
|
||||
if (t === 'null') return;
|
||||
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
|
||||
walkObject(val, CONVERT_KEYS, path, issues);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function addTypeErr(path, expected, got, issues) {
|
||||
issues.push({ keyPath: path, severity: 'error',
|
||||
message: 'Expected ' + expected + ', got ' + got + '.' });
|
||||
}
|
||||
|
||||
// Locate the source line for a key path. .zddc files are
|
||||
// block-style YAML in practice (no flow style, no anchors), so a
|
||||
// simple indent-aware scan works: for each segment, find a line
|
||||
// matching "<indent><key>:" whose indent is deeper than the
|
||||
// previously-matched line. Falls back to line 0 if no match.
|
||||
function findLine(source, keyPath) {
|
||||
if (!keyPath || keyPath.length === 0) return 0;
|
||||
var lines = source.split('\n');
|
||||
var prevIndent = -1;
|
||||
var prevLine = 0;
|
||||
for (var i = 0; i < keyPath.length; i++) {
|
||||
var key = keyPath[i];
|
||||
var found = -1;
|
||||
// Escape regex metachars in the key.
|
||||
var keyRe = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
var re = new RegExp('^(\\s*)"?' + keyRe + '"?\\s*:');
|
||||
for (var j = prevLine; j < lines.length; j++) {
|
||||
var m = lines[j].match(re);
|
||||
if (m && m[1].length > prevIndent) {
|
||||
found = j;
|
||||
prevIndent = m[1].length;
|
||||
prevLine = j + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found === -1) return prevLine > 0 ? prevLine - 1 : 0;
|
||||
}
|
||||
return prevLine > 0 ? prevLine - 1 : 0;
|
||||
}
|
||||
|
||||
// ── CodeMirror lint helper ──────────────────────────────────────────────
|
||||
|
||||
function registerLinter(CM) {
|
||||
// The lint helper signature: function(text, options, editor) → annotations[]
|
||||
// Each annotation: { from, to, message, severity }.
|
||||
CM.registerHelper('lint', 'yaml', function (text, _opts, editor) {
|
||||
var out = [];
|
||||
if (!window.jsyaml) return out;
|
||||
var parsed;
|
||||
try {
|
||||
// loadAll handles multi-doc YAML; we only validate the
|
||||
// first doc against the schema (the .zddc cascade reads
|
||||
// only the first document).
|
||||
var docs = [];
|
||||
window.jsyaml.loadAll(text, function (d) { docs.push(d); });
|
||||
parsed = docs[0];
|
||||
} catch (e) {
|
||||
var mark = e.mark;
|
||||
var pos = mark ? CM.Pos(mark.line, mark.column) : CM.Pos(0, 0);
|
||||
out.push({ from: pos, to: pos, severity: 'error',
|
||||
message: e.message || String(e) });
|
||||
return out;
|
||||
}
|
||||
// Schema layer — only for .zddc cascade files.
|
||||
var node = editor._zddcNode;
|
||||
if (node && isZddcFile(node.name)) {
|
||||
var issues = validateZddc(parsed);
|
||||
for (var i = 0; i < issues.length; i++) {
|
||||
var ln = findLine(text, issues[i].keyPath);
|
||||
out.push({
|
||||
from: CM.Pos(ln, 0),
|
||||
to: CM.Pos(ln, (editor.getLine(ln) || '').length),
|
||||
severity: issues[i].severity,
|
||||
message: issues[i].message
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mount ───────────────────────────────────────────────────────────────
|
||||
|
||||
var currentEditor = null;
|
||||
|
||||
function dispose() {
|
||||
// CM doesn't have an explicit destroy(); GC handles it once
|
||||
// the host element is removed. Clear our reference so a stale
|
||||
// editor doesn't keep handlers alive.
|
||||
currentEditor = null;
|
||||
}
|
||||
|
||||
async function render(node, container, ctx) {
|
||||
if (typeof window.CodeMirror === 'undefined') {
|
||||
container.innerHTML =
|
||||
'<div class="preview-empty" style="color:var(--danger)">'
|
||||
+ 'CodeMirror isn\'t bundled in this build.</div>';
|
||||
return;
|
||||
}
|
||||
dispose();
|
||||
|
||||
var text;
|
||||
try {
|
||||
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)">'
|
||||
+ 'Could not read ' + escapeHtml(node.name) + ': '
|
||||
+ escapeHtml(e.message || String(e)) + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
var shell = document.createElement('div');
|
||||
shell.className = 'yaml-shell';
|
||||
container.appendChild(shell);
|
||||
|
||||
// Info header — same look as the markdown plugin's info-header
|
||||
// so the two editors feel like one family.
|
||||
var infohdr = document.createElement('div');
|
||||
infohdr.className = 'md-shell__infohdr yaml-shell__infohdr';
|
||||
|
||||
var titleEl = document.createElement('span');
|
||||
titleEl.className = 'md-shell__title';
|
||||
titleEl.textContent = node.name;
|
||||
titleEl.title = node.name;
|
||||
|
||||
var schemaTag = document.createElement('span');
|
||||
schemaTag.className = 'md-shell__source yaml-shell__schema';
|
||||
if (isZddcFile(node.name)) {
|
||||
schemaTag.textContent = '.zddc schema';
|
||||
schemaTag.title = 'Linted against the .zddc cascade schema '
|
||||
+ '(unknown keys, bad enums, and wrong types are flagged).';
|
||||
} else {
|
||||
schemaTag.textContent = 'YAML';
|
||||
}
|
||||
|
||||
var dirtyEl = document.createElement('span');
|
||||
dirtyEl.className = 'md-shell__dirty';
|
||||
|
||||
var statusEl = document.createElement('span');
|
||||
statusEl.className = 'md-shell__status';
|
||||
|
||||
var sourceEl = document.createElement('span');
|
||||
sourceEl.className = 'md-shell__source';
|
||||
if (isZipMemberNode(node)) sourceEl.textContent = 'read-only (zip)';
|
||||
else if (node.handle) sourceEl.textContent = 'local';
|
||||
else if (node.url) sourceEl.textContent = 'server';
|
||||
|
||||
var saveBtn = document.createElement('button');
|
||||
saveBtn.className = 'btn btn-sm btn-primary md-shell__save';
|
||||
saveBtn.type = 'button';
|
||||
saveBtn.textContent = 'Save';
|
||||
saveBtn.disabled = true;
|
||||
|
||||
infohdr.appendChild(titleEl);
|
||||
infohdr.appendChild(schemaTag);
|
||||
infohdr.appendChild(dirtyEl);
|
||||
infohdr.appendChild(statusEl);
|
||||
infohdr.appendChild(sourceEl);
|
||||
infohdr.appendChild(saveBtn);
|
||||
shell.appendChild(infohdr);
|
||||
|
||||
var editorHost = document.createElement('div');
|
||||
editorHost.className = 'yaml-shell__editor';
|
||||
shell.appendChild(editorHost);
|
||||
|
||||
// Register the lint helper once per page lifetime.
|
||||
if (!window.CodeMirror.__zddcYamlLinterReady) {
|
||||
registerLinter(window.CodeMirror);
|
||||
window.CodeMirror.__zddcYamlLinterReady = true;
|
||||
}
|
||||
|
||||
var writable = canSave(node);
|
||||
var editor = window.CodeMirror(editorHost, {
|
||||
value: text,
|
||||
mode: 'yaml',
|
||||
lineNumbers: true,
|
||||
tabSize: 2,
|
||||
indentUnit: 2,
|
||||
indentWithTabs: false,
|
||||
lineWrapping: false,
|
||||
gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
|
||||
lint: { hasGutters: true },
|
||||
// autofocus:false keeps the keyboard caret in the browse
|
||||
// tree pane so arrow-key nav can continue through yaml /
|
||||
// .zddc files without diverting into the editor. User
|
||||
// clicks (or tabs) into the editor when they want to type.
|
||||
autofocus: false,
|
||||
// CodeMirror's "nocursor" mode is the truest read-only:
|
||||
// selection allowed for copy, no caret, no edit affordances.
|
||||
readOnly: !writable ? 'nocursor' : false,
|
||||
});
|
||||
// Stash the node on the editor so the lint helper can decide
|
||||
// whether to apply the .zddc schema layer.
|
||||
editor._zddcNode = node;
|
||||
// Force an initial lint pass now that _zddcNode is set.
|
||||
editor.performLint();
|
||||
currentEditor = editor;
|
||||
|
||||
if (!writable) {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.title = 'Save not available — read-only source.';
|
||||
// Read-only banner above the editor explains why.
|
||||
var roBanner = document.createElement('div');
|
||||
roBanner.className = 'yaml-readonly-banner';
|
||||
roBanner.innerHTML = '<span aria-hidden="true">🔒</span>'
|
||||
+ ' Read-only — you don\'t have write access to this file.';
|
||||
editorHost.insertBefore(roBanner, editorHost.firstChild);
|
||||
}
|
||||
|
||||
var initialHash = await hashContent(text);
|
||||
|
||||
function markDirty(isDirty) {
|
||||
saveBtn.disabled = !isDirty || !canSave(node);
|
||||
dirtyEl.textContent = isDirty ? '● modified' : '';
|
||||
}
|
||||
|
||||
editor.on('change', async function () {
|
||||
var h = await hashContent(editor.getValue());
|
||||
markDirty(h !== initialHash);
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (saveBtn.disabled) return;
|
||||
// Re-check authority at click time, not via the mount-time
|
||||
// `writable` capture — the listing may have re-evaluated
|
||||
// (e.g. user toggled admin mode without a hard reload).
|
||||
if (!canSave(node)) return;
|
||||
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');
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Save failed: ' + (e.message || e);
|
||||
if (window.zddc && window.zddc.toast) {
|
||||
window.zddc.toast('Save failed: ' + (e.message || e), 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
saveBtn.addEventListener('click', save);
|
||||
editor.setOption('extraKeys', {
|
||||
'Ctrl-S': save,
|
||||
'Cmd-S': save
|
||||
});
|
||||
|
||||
// CM defers layout until its host has a size — refresh after
|
||||
// mount so the gutters and viewport sync to the grid cell.
|
||||
setTimeout(function () { try { editor.refresh(); } catch (_e) {} }, 0);
|
||||
}
|
||||
|
||||
function handles(node) {
|
||||
if (!node || node.isDir || node.isZip) return false;
|
||||
return isYamlFile(node);
|
||||
}
|
||||
|
||||
window.app.modules.yamledit = {
|
||||
handles: handles,
|
||||
render: render
|
||||
};
|
||||
})();
|
||||
|
|
@ -117,6 +117,19 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// YAML plugin: .yaml / .yml / .zddc / *.zddc.yaml route to a
|
||||
// CodeMirror 5 editor with js-yaml linting; .zddc files also
|
||||
// get a schema-aware lint pass.
|
||||
var yamlMod = window.app.modules.yamledit;
|
||||
if (yamlMod && yamlMod.handles(node)) {
|
||||
try {
|
||||
await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer });
|
||||
} catch (e) {
|
||||
renderError(container, 'YAML render failed: ' + (e.message || e));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// PDF / HTML → iframe.
|
||||
if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
|
||||
try {
|
||||
|
|
|
|||
329
browse/js/stage.js
Normal file
329
browse/js/stage.js
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
// stage.js — Stage and Unstage workflow modals.
|
||||
//
|
||||
// Stage: move a file from working/<…>/ into a transmittal folder under
|
||||
// staging/<…>/. Modal lists existing transmittal folders in staging/
|
||||
// plus a "New transmittal folder…" option that prompts for a ZDDC-
|
||||
// conforming name and mkdirs it before the move.
|
||||
//
|
||||
// Unstage: move a file from staging/<transmittal>/ back to the user's
|
||||
// working/<email>/ home (overridable).
|
||||
//
|
||||
// Both reuse the existing X-ZDDC-Op: move primitive — no new composite
|
||||
// endpoint is needed; the client just orchestrates one POST per file
|
||||
// (a multi-file selection iterates and reports aggregate status).
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function status(msg, level) {
|
||||
var t = window.zddc && window.zddc.toast;
|
||||
if (t) t(msg, level || 'info');
|
||||
}
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[c];
|
||||
});
|
||||
}
|
||||
|
||||
// ── Scope detection: path-shape, not cascade-content ──────────────
|
||||
// A file is stageable if its containing folder lives under
|
||||
// /<project>/working/<…>. Unstageable if it lives under
|
||||
// /<project>/staging/<transmittal>/<…>. Both are path-shape
|
||||
// queries — content/ACL is enforced server-side.
|
||||
|
||||
function projectAndSubtree(path) {
|
||||
var rel = path.replace(/^\/+|\/+$/g, '').split('/');
|
||||
if (rel.length < 2) return null;
|
||||
return { project: rel[0], subtree: rel[1], rest: rel.slice(2) };
|
||||
}
|
||||
|
||||
function isStageableFile(node) {
|
||||
if (!node || node.isDir || node.virtual) return false;
|
||||
var tree = window.app.modules.tree;
|
||||
if (!tree) return false;
|
||||
var p = projectAndSubtree(tree.pathFor(node));
|
||||
return !!(p && p.subtree === 'working' && p.rest.length >= 1);
|
||||
}
|
||||
function isUnstageableFile(node) {
|
||||
if (!node || node.isDir || node.virtual) return false;
|
||||
var tree = window.app.modules.tree;
|
||||
if (!tree) return false;
|
||||
var p = projectAndSubtree(tree.pathFor(node));
|
||||
// staging/<transmittal-folder>/<file> — at least one folder
|
||||
// segment between staging/ and the file.
|
||||
return !!(p && p.subtree === 'staging' && p.rest.length >= 2);
|
||||
}
|
||||
|
||||
// ── Server helpers ─────────────────────────────────────────────────
|
||||
|
||||
// Fetch directory listing JSON. Returns [] on 404.
|
||||
async function listDir(absUrl) {
|
||||
if (!absUrl.endsWith('/')) absUrl += '/';
|
||||
var resp = await fetch(absUrl, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (resp.status === 404) return [];
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' fetching ' + absUrl);
|
||||
var data = await resp.json();
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
async function fetchStagingFolders(project) {
|
||||
var entries = await listDir('/' + project + '/staging/');
|
||||
return entries
|
||||
.filter(function (e) { return e && e.isDir; })
|
||||
.map(function (e) { return e.name; });
|
||||
}
|
||||
|
||||
async function fetchSelfEmail() {
|
||||
try {
|
||||
var r = await fetch('/.profile/access', {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!r.ok) return '';
|
||||
var d = await r.json();
|
||||
return (d && d.email) || '';
|
||||
} catch (_e) { return ''; }
|
||||
}
|
||||
|
||||
// POST X-ZDDC-Op: mkdir to create a new directory. Idempotent.
|
||||
async function mkdir(absUrl) {
|
||||
var resp = await fetch(absUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'X-ZDDC-Op': 'mkdir' },
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!resp.ok) {
|
||||
var text = ''; try { text = await resp.text(); } catch (_e) {}
|
||||
throw new Error('mkdir ' + absUrl + ' failed (' + resp.status + '): ' + text);
|
||||
}
|
||||
}
|
||||
|
||||
// POST X-ZDDC-Op: move + X-ZDDC-Destination header. Reuses the
|
||||
// file-API move primitive (atomic os.Rename, dual ACL gates).
|
||||
async function moveFile(srcUrl, dstUrl) {
|
||||
var resp = await fetch(srcUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-ZDDC-Op': 'move',
|
||||
'X-ZDDC-Destination': dstUrl
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!resp.ok) {
|
||||
var text = ''; try { text = await resp.text(); } catch (_e) {}
|
||||
throw new Error('move ' + srcUrl + ' → ' + dstUrl + ' failed (' + resp.status + '): ' + text);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stage picker modal ─────────────────────────────────────────────
|
||||
|
||||
function openStagePicker(initial) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;inset: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.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:28rem;max-width:36rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);';
|
||||
|
||||
var folderList = initial.folders.map(function (name) {
|
||||
return '<label style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;cursor:pointer;">' +
|
||||
'<input type="radio" name="stage-target" value="' + escapeHtml(name) + '">' +
|
||||
'<span style="font-family:var(--code,monospace);">' + escapeHtml(name) + '</span>' +
|
||||
'</label>';
|
||||
}).join('');
|
||||
|
||||
box.innerHTML =
|
||||
'<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">Stage ' +
|
||||
initial.fileCount + ' file' + (initial.fileCount === 1 ? '' : 's') + ' to…</h2>' +
|
||||
'<p style="margin:0 0 0.5rem 0;font-size:0.85rem;color:#666;">' +
|
||||
'Pick the transmittal folder in <code>staging/</code> these files should join. ' +
|
||||
'You can move them back to <code>working/</code> later if they need correction.' +
|
||||
'</p>' +
|
||||
'<div style="max-height:14rem;overflow-y:auto;border:1px solid rgba(0,0,0,0.1);padding:0.3rem 0.6rem;margin-bottom:0.5rem;">' +
|
||||
(folderList || '<em style="color:#888;">No existing transmittal folders in staging/.</em>') +
|
||||
'<label style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;cursor:pointer;border-top:1px solid rgba(0,0,0,0.05);margin-top:0.3rem;padding-top:0.5rem;">' +
|
||||
'<input type="radio" name="stage-target" value="__new__">' +
|
||||
'<span><strong>New transmittal folder…</strong></span>' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'<div id="stage-newname-row" style="display:none;font-size:0.9rem;">' +
|
||||
'<label for="stage-newname">Folder name (ZDDC convention)</label><br>' +
|
||||
'<input id="stage-newname" type="text" style="width:100%;padding:0.3rem;font-family:var(--code,monospace);" ' +
|
||||
'placeholder="YYYY-MM-DD_TRACKING (PURPOSE) - SUBJECT">' +
|
||||
'<div id="stage-newname-feedback" style="font-size:0.8rem;color:#888;margin-top:0.2rem;"></div>' +
|
||||
'</div>' +
|
||||
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
|
||||
'<button type="button" id="stage-cancel">Cancel</button>' +
|
||||
'<button type="button" id="stage-submit" class="btn-primary">Stage</button>' +
|
||||
'</div>';
|
||||
overlay.appendChild(box);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
var newRow = box.querySelector('#stage-newname-row');
|
||||
var newInput = box.querySelector('#stage-newname');
|
||||
var feedback = box.querySelector('#stage-newname-feedback');
|
||||
box.querySelectorAll('input[name="stage-target"]').forEach(function (r) {
|
||||
r.addEventListener('change', function () {
|
||||
newRow.style.display = (r.value === '__new__' && r.checked) ? '' : 'none';
|
||||
if (r.value === '__new__' && r.checked) newInput.focus();
|
||||
});
|
||||
});
|
||||
newInput.addEventListener('input', function () {
|
||||
var v = newInput.value.trim();
|
||||
if (!v) { feedback.textContent = ''; return; }
|
||||
var parsed = window.zddc.parseFolder(v);
|
||||
if (parsed && parsed.valid) {
|
||||
feedback.style.color = '#2a8';
|
||||
feedback.textContent = '✓ tracking=' + parsed.trackingNumber +
|
||||
', status=' + parsed.status + ', title=' + parsed.title;
|
||||
} else {
|
||||
feedback.style.color = '#c33';
|
||||
feedback.textContent = '✗ does not match YYYY-MM-DD_TRACKING (PURPOSE) - SUBJECT';
|
||||
}
|
||||
});
|
||||
|
||||
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
|
||||
box.querySelector('#stage-cancel').addEventListener('click', function () {
|
||||
close(); reject(new Error('cancelled'));
|
||||
});
|
||||
overlay.addEventListener('click', function (e) {
|
||||
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
|
||||
});
|
||||
box.querySelector('#stage-submit').addEventListener('click', function () {
|
||||
var sel = box.querySelector('input[name="stage-target"]:checked');
|
||||
if (!sel) { status('Pick a destination folder.', 'error'); return; }
|
||||
if (sel.value === '__new__') {
|
||||
var name = newInput.value.trim();
|
||||
var parsed = window.zddc.parseFolder(name);
|
||||
if (!parsed || !parsed.valid) {
|
||||
status('Folder name must conform to ZDDC convention.', 'error');
|
||||
return;
|
||||
}
|
||||
close(); resolve({ create: true, folderName: name });
|
||||
} else {
|
||||
close(); resolve({ create: false, folderName: sel.value });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Unstage picker modal ───────────────────────────────────────────
|
||||
|
||||
function openUnstagePicker(initial) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;inset: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.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:28rem;max-width:36rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);';
|
||||
box.innerHTML =
|
||||
'<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">Unstage ' +
|
||||
initial.fileCount + ' file' + (initial.fileCount === 1 ? '' : 's') + '</h2>' +
|
||||
'<p style="margin:0 0 0.5rem 0;font-size:0.85rem;color:#666;">' +
|
||||
'Move these files back into your drafting workspace under <code>working/</code> ' +
|
||||
'so they can be corrected. Stage them again when ready.' +
|
||||
'</p>' +
|
||||
'<label for="unstage-target" style="font-size:0.9rem;">Destination folder</label>' +
|
||||
'<input id="unstage-target" type="text" style="width:100%;padding:0.3rem;font-family:var(--code,monospace);" value="' +
|
||||
escapeHtml(initial.defaultTarget) + '">' +
|
||||
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
|
||||
'<button type="button" id="unstage-cancel">Cancel</button>' +
|
||||
'<button type="button" id="unstage-submit" class="btn-primary">Unstage</button>' +
|
||||
'</div>';
|
||||
overlay.appendChild(box);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
var input = box.querySelector('#unstage-target');
|
||||
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
|
||||
box.querySelector('#unstage-cancel').addEventListener('click', function () {
|
||||
close(); reject(new Error('cancelled'));
|
||||
});
|
||||
overlay.addEventListener('click', function (e) {
|
||||
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
|
||||
});
|
||||
box.querySelector('#unstage-submit').addEventListener('click', function () {
|
||||
var target = input.value.trim();
|
||||
if (!target) { status('Destination is required.', 'error'); return; }
|
||||
close(); resolve({ target: target });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Action drivers ─────────────────────────────────────────────────
|
||||
|
||||
async function invokeStage(node) {
|
||||
var tree = window.app.modules.tree;
|
||||
if (!tree) return;
|
||||
var srcUrl = tree.pathFor(node);
|
||||
var info = projectAndSubtree(srcUrl);
|
||||
if (!info || info.subtree !== 'working') {
|
||||
status('Stage applies only to files under working/.', 'error');
|
||||
return;
|
||||
}
|
||||
var stagingBase = '/' + info.project + '/staging/';
|
||||
var folders;
|
||||
try { folders = await fetchStagingFolders(info.project); }
|
||||
catch (e) {
|
||||
status('Could not list staging/: ' + (e && e.message ? e.message : e), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
var choice;
|
||||
try {
|
||||
choice = await openStagePicker({ fileCount: 1, folders: folders });
|
||||
} catch (_e) { return; }
|
||||
|
||||
if (choice.create) {
|
||||
try {
|
||||
await mkdir(stagingBase + encodeURIComponent(choice.folderName) + '/');
|
||||
} catch (e) {
|
||||
status((e && e.message) || 'mkdir failed', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var dstUrl = stagingBase + encodeURIComponent(choice.folderName) + '/' + encodeURIComponent(node.name);
|
||||
try {
|
||||
await moveFile(srcUrl, dstUrl);
|
||||
} catch (e) {
|
||||
status((e && e.message) || 'move failed', 'error');
|
||||
return;
|
||||
}
|
||||
status('Staged ' + node.name + ' → staging/' + choice.folderName + '/ — reload to see the move.', 'success');
|
||||
}
|
||||
|
||||
async function invokeUnstage(node) {
|
||||
var tree = window.app.modules.tree;
|
||||
if (!tree) return;
|
||||
var srcUrl = tree.pathFor(node);
|
||||
var info = projectAndSubtree(srcUrl);
|
||||
if (!info || info.subtree !== 'staging') {
|
||||
status('Unstage applies only to files under staging/.', 'error');
|
||||
return;
|
||||
}
|
||||
var email = await fetchSelfEmail();
|
||||
var defaultTarget = '/' + info.project + '/working/' + (email || '') + '/';
|
||||
var choice;
|
||||
try {
|
||||
choice = await openUnstagePicker({ fileCount: 1, defaultTarget: defaultTarget });
|
||||
} catch (_e) { return; }
|
||||
var target = choice.target;
|
||||
if (!target.endsWith('/')) target += '/';
|
||||
var dstUrl = target + encodeURIComponent(node.name);
|
||||
try {
|
||||
await moveFile(srcUrl, dstUrl);
|
||||
} catch (e) {
|
||||
status((e && e.message) || 'move failed', 'error');
|
||||
return;
|
||||
}
|
||||
status('Unstaged ' + node.name + ' → ' + target + ' — reload to see the move.', 'success');
|
||||
}
|
||||
|
||||
window.app.modules.stage = {
|
||||
isStageableFile: isStageableFile,
|
||||
isUnstageableFile: isUnstageableFile,
|
||||
invokeStage: invokeStage,
|
||||
invokeUnstage: invokeUnstage
|
||||
};
|
||||
})();
|
||||
|
|
@ -43,7 +43,13 @@
|
|||
// True when this entry was synthesized client-side (e.g.
|
||||
// canonical project folders that don't exist on disk yet).
|
||||
// Rendered with a muted style + an "(empty)" hint.
|
||||
virtual: !!raw.virtual
|
||||
virtual: !!raw.virtual,
|
||||
// Server-computed write authority. Editors (preview-yaml,
|
||||
// preview-markdown) consult this via canSave() to decide
|
||||
// whether to mount read-only. Dropping the field here
|
||||
// silently makes every node read-only — the actual root
|
||||
// cause behind "I'm admin but the editor says read-only".
|
||||
writable: !!raw.writable
|
||||
};
|
||||
state.nodes.set(id, node);
|
||||
return node;
|
||||
|
|
@ -111,15 +117,24 @@
|
|||
}
|
||||
|
||||
// Walk nodes in render order. Skips the children of a collapsed
|
||||
// expandable.
|
||||
// expandable. When state.filterAST is set, also skips nodes that
|
||||
// don't match (files) or whose subtree has no matches (folders),
|
||||
// and force-walks into folders that have matching descendants so
|
||||
// those matches are visible even when the user hadn't expanded
|
||||
// the folder. The user's actual node.expanded flag stays untouched
|
||||
// so clearing the filter restores their original layout.
|
||||
function visibleIds() {
|
||||
var out = [];
|
||||
function walk(ids) {
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
var n = state.nodes.get(ids[i]);
|
||||
if (!n) continue;
|
||||
if (state.filterAST && !passesFilter(n)) continue;
|
||||
out.push(ids[i]);
|
||||
if ((n.isDir || n.isZip) && n.expanded) walk(n.childIds);
|
||||
if (n.isDir || n.isZip) {
|
||||
var forceWalk = !!state.filterAST;
|
||||
if (forceWalk || n.expanded) walk(n.childIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-sort everything at all levels so a sort change reorders
|
||||
|
|
@ -132,6 +147,59 @@
|
|||
return out;
|
||||
}
|
||||
|
||||
// ── Filter ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Build the haystack string we run the filter AST against. We
|
||||
// concatenate every searchable field — name, displayName, plus any
|
||||
// ZDDC parts the basename parses to — so users can type a tracking
|
||||
// number, a status code, a date, or a piece of the title.
|
||||
function filterHaystack(node) {
|
||||
var parts = [node.name];
|
||||
if (node.displayName) parts.push(node.displayName);
|
||||
var z = window.zddc;
|
||||
if (z) {
|
||||
var parsed = node.isDir ? z.parseFolder(node.name)
|
||||
: z.parseFilename(node.name);
|
||||
if (parsed && parsed.valid) {
|
||||
if (parsed.trackingNumber) parts.push(parsed.trackingNumber);
|
||||
if (parsed.title) parts.push(parsed.title);
|
||||
if (parsed.status) parts.push(parsed.status);
|
||||
if (parsed.revision) parts.push(parsed.revision);
|
||||
if (parsed.date) parts.push(parsed.date);
|
||||
}
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function nodeMatchesFilter(node) {
|
||||
if (!state.filterAST) return true;
|
||||
return window.zddc.filter.matches(filterHaystack(node), state.filterAST);
|
||||
}
|
||||
|
||||
// True when this node should appear in the filtered view: either
|
||||
// the node itself matches, or it's an expandable with at least
|
||||
// one matching descendant (so we keep the path to a match visible).
|
||||
function passesFilter(node) {
|
||||
if (!state.filterAST) return true;
|
||||
if (nodeMatchesFilter(node)) return true;
|
||||
if (!(node.isDir || node.isZip)) return false;
|
||||
if (!node.loaded) return false; // unloaded subtrees aren't searched
|
||||
for (var i = 0; i < node.childIds.length; i++) {
|
||||
var child = state.nodes.get(node.childIds[i]);
|
||||
if (child && passesFilter(child)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Is this folder being "forced open" by an active filter because
|
||||
// a descendant matches? Used by rowHtml to render the chevron as
|
||||
// expanded without mutating node.expanded.
|
||||
function filterForcesOpen(node) {
|
||||
if (!state.filterAST) return false;
|
||||
if (!(node.isDir || node.isZip)) return false;
|
||||
return passesFilter(node) && !nodeMatchesFilter(node);
|
||||
}
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────────
|
||||
|
||||
function fmtSize(bytes) {
|
||||
|
|
@ -154,6 +222,127 @@
|
|||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// Per-extension icon map → Lucide outline-icon sprite ids. The
|
||||
// actual SVG markup is produced by window.zddc.icons.html(id),
|
||||
// which inlines `<svg><use href="#id"/></svg>` so the page CSS
|
||||
// can size and tint via currentColor.
|
||||
//
|
||||
// book-marked PDF file-pen markdown
|
||||
// file-text word / txt file-spreadsheet spreadsheet
|
||||
// presentation slides file-image image
|
||||
// file-video video file-audio audio
|
||||
// ruler CAD / drawing globe web
|
||||
// file-cog config / .zddc file-code source code
|
||||
// file-archive non-nav archive folder-archive .zip (navigable)
|
||||
// file generic folder directory
|
||||
var ICON_BY_EXT = {
|
||||
pdf: 'icon-book-marked',
|
||||
md: 'icon-file-pen', markdown: 'icon-file-pen',
|
||||
doc: 'icon-file-text', docx: 'icon-file-text', rtf: 'icon-file-text', odt: 'icon-file-text',
|
||||
xls: 'icon-file-spreadsheet', xlsx: 'icon-file-spreadsheet',
|
||||
csv: 'icon-file-spreadsheet', ods: 'icon-file-spreadsheet', tsv: 'icon-file-spreadsheet',
|
||||
ppt: 'icon-presentation', pptx: 'icon-presentation', odp: 'icon-presentation',
|
||||
txt: 'icon-file-text', log: 'icon-file-text',
|
||||
jpg: 'icon-file-image', jpeg: 'icon-file-image', png: 'icon-file-image',
|
||||
gif: 'icon-file-image', webp: 'icon-file-image', svg: 'icon-file-image',
|
||||
bmp: 'icon-file-image', tif: 'icon-file-image', tiff: 'icon-file-image',
|
||||
ico: 'icon-file-image', heic: 'icon-file-image',
|
||||
mp4: 'icon-file-video', mov: 'icon-file-video', avi: 'icon-file-video',
|
||||
mkv: 'icon-file-video', webm: 'icon-file-video', m4v: 'icon-file-video',
|
||||
mp3: 'icon-file-audio', wav: 'icon-file-audio', flac: 'icon-file-audio',
|
||||
ogg: 'icon-file-audio', m4a: 'icon-file-audio', aac: 'icon-file-audio',
|
||||
dwg: 'icon-ruler', dxf: 'icon-ruler', step: 'icon-ruler',
|
||||
stp: 'icon-ruler', iges: 'icon-ruler', igs: 'icon-ruler',
|
||||
html: 'icon-globe', htm: 'icon-globe',
|
||||
yaml: 'icon-file-cog', yml: 'icon-file-cog', json: 'icon-file-cog',
|
||||
toml: 'icon-file-cog', ini: 'icon-file-cog', xml: 'icon-file-cog',
|
||||
conf: 'icon-file-cog', cfg: 'icon-file-cog',
|
||||
'7z': 'icon-file-archive', rar: 'icon-file-archive', tar: 'icon-file-archive',
|
||||
gz: 'icon-file-archive', tgz: 'icon-file-archive',
|
||||
bz2: 'icon-file-archive', xz: 'icon-file-archive',
|
||||
// Code — share one glyph across languages so users build the
|
||||
// "this is source" pattern. Distinguishing per language would
|
||||
// be visual noise without much added signal.
|
||||
js: 'icon-file-code', mjs: 'icon-file-code', cjs: 'icon-file-code',
|
||||
ts: 'icon-file-code', tsx: 'icon-file-code', jsx: 'icon-file-code',
|
||||
py: 'icon-file-code', go: 'icon-file-code', rs: 'icon-file-code',
|
||||
c: 'icon-file-code', cc: 'icon-file-code', cpp: 'icon-file-code',
|
||||
h: 'icon-file-code', hpp: 'icon-file-code', java: 'icon-file-code',
|
||||
rb: 'icon-file-code', php: 'icon-file-code', sh: 'icon-file-code',
|
||||
bash: 'icon-file-code', zsh: 'icon-file-code', lua: 'icon-file-code',
|
||||
swift: 'icon-file-code', kt: 'icon-file-code', kts: 'icon-file-code',
|
||||
css: 'icon-file-code', scss: 'icon-file-code', less: 'icon-file-code'
|
||||
};
|
||||
|
||||
function symbolForNode(node) {
|
||||
if (node.isDir) return 'icon-folder';
|
||||
if (node.isZip) return 'icon-folder-archive';
|
||||
// `.zddc` (no extension) is the cascade config — same family
|
||||
// as yaml. Match the literal basename before falling through
|
||||
// to the extension table.
|
||||
if (node.name === '.zddc') return 'icon-file-cog';
|
||||
var ext = (node.ext || '').toLowerCase();
|
||||
return ICON_BY_EXT[ext] || 'icon-file';
|
||||
}
|
||||
|
||||
function iconForNode(node) {
|
||||
return window.zddc.icons.html(symbolForNode(node));
|
||||
}
|
||||
|
||||
// Render the label cell for a row. When the basename parses as a
|
||||
// ZDDC-conformant filename (files) or transmittal folder name
|
||||
// (directories), split into a two-line layout:
|
||||
// top — trackingNumber · [revision · ]status (small, muted)
|
||||
// bot — title (normal weight)
|
||||
// Otherwise fall back to a single line.
|
||||
//
|
||||
// .zddc `display:` overrides always render as a single line — the
|
||||
// operator chose that string for a reason; we don't try to second-
|
||||
// guess it by parsing for ZDDC structure.
|
||||
function labelHtml(node) {
|
||||
// No native title="…" — the rich hovercard (browse/js/hovercard.js)
|
||||
// replaces the browser tooltip with a metadata view that's
|
||||
// both more informative and styled to match the rest of the UI.
|
||||
if (node.displayName) {
|
||||
return '<span class="tree-name__label">'
|
||||
+ escapeHtml(node.displayName)
|
||||
+ '</span>';
|
||||
}
|
||||
var z = window.zddc;
|
||||
var parsed = null;
|
||||
if (z) {
|
||||
parsed = node.isDir
|
||||
? z.parseFolder(node.name)
|
||||
: z.parseFilename(node.name);
|
||||
}
|
||||
if (parsed && parsed.valid) {
|
||||
// Folders carry a date (no revision); files carry a
|
||||
// revision (no date). Status is present on both.
|
||||
var parts;
|
||||
if (node.isDir) {
|
||||
parts = [parsed.date, parsed.trackingNumber, parsed.status];
|
||||
} else {
|
||||
parts = [parsed.trackingNumber, parsed.revision, parsed.status];
|
||||
}
|
||||
var metaText = parts.filter(Boolean).join(' · ');
|
||||
// Title-first: primary content on the top line so the row
|
||||
// reads like a normal file manager / mail list. Meta sits
|
||||
// below as the supporting "subtitle" — same hierarchy
|
||||
// pattern as Gmail, Linear, Notion file rows.
|
||||
return '<span class="tree-name__label tree-name__label--zddc">'
|
||||
+ '<span class="tree-name__title">'
|
||||
+ escapeHtml(parsed.title)
|
||||
+ '</span>'
|
||||
+ '<span class="tree-name__meta">'
|
||||
+ escapeHtml(metaText)
|
||||
+ '</span>'
|
||||
+ '</span>';
|
||||
}
|
||||
return '<span class="tree-name__label">'
|
||||
+ escapeHtml(node.name)
|
||||
+ '</span>';
|
||||
}
|
||||
|
||||
// Render a single tree row as a flat <div>. Indentation via
|
||||
// padding-left so the row's hover background spans the full
|
||||
// pane width. Files are rendered as plain rows (no anchor) —
|
||||
|
|
@ -163,26 +352,45 @@
|
|||
function rowHtml(node) {
|
||||
var indent = 0.4 + node.depth * 1.0;
|
||||
var expandable = node.isDir || node.isZip;
|
||||
var iconChar = node.isDir ? '📁' : (node.isZip ? '🗜️' : '📄');
|
||||
var iconChar = iconForNode(node);
|
||||
var chevronClass = 'tree-name__chevron'
|
||||
+ (expandable ? '' : ' tree-name__chevron--leaf');
|
||||
// Outline Lucide chevron — single sprite glyph, rotated 90°
|
||||
// via CSS for the expanded state. Leaf rows ship an empty
|
||||
// chevron span so the icon column stays aligned.
|
||||
var chevronGlyph = expandable
|
||||
? window.zddc.icons.html('icon-chevron-right')
|
||||
: '';
|
||||
// While a filter is active, folders that contain a matching
|
||||
// descendant are rendered as visually expanded so the user
|
||||
// can see the match — even if node.expanded is still false.
|
||||
// The actual flag stays untouched so clearing the filter
|
||||
// restores the user's original tree shape.
|
||||
var visuallyExpanded = node.expanded || filterForcesOpen(node);
|
||||
var selected = state.selectedId === node.id ? ' is-selected' : '';
|
||||
var virtualCls = node.virtual ? ' tree-row--virtual' : '';
|
||||
// No native title — the hovercard surfaces a dedicated
|
||||
// "Virtual: Not yet created on disk" row for these nodes.
|
||||
var virtualHint = node.virtual
|
||||
? '<span class="tree-name__hint" title="Folder not yet created on disk — opens an empty workspace">(empty)</span>'
|
||||
? '<span class="tree-name__hint">(empty)</span>'
|
||||
: '';
|
||||
// Extension chip stacked under the file icon. Files with a
|
||||
// non-empty ext get a small uppercase label; folders / zips
|
||||
// skip it (the chevron + icon glyph carries enough info).
|
||||
var extChip = (!node.isDir && !node.isZip && node.ext)
|
||||
? '<span class="tree-name__ext">' + escapeHtml(String(node.ext)) + '</span>'
|
||||
: '';
|
||||
return ''
|
||||
+ '<div class="tree-row ' + (node.expanded ? 'expanded' : '') + selected + virtualCls
|
||||
+ '<div class="tree-row ' + (visuallyExpanded ? 'expanded' : '') + selected + virtualCls
|
||||
+ '" data-id="' + node.id
|
||||
+ '" data-isdir="' + node.isDir
|
||||
+ '" data-iszip="' + node.isZip + '"'
|
||||
+ (node.virtual ? ' data-virtual="true"' : '')
|
||||
+ ' style="padding-left:' + indent + 'rem"'
|
||||
+ ' role="treeitem" tabindex="-1">'
|
||||
+ '<span class="' + chevronClass + '"></span>'
|
||||
+ '<span class="tree-name__icon">' + iconChar + '</span>'
|
||||
+ '<span class="tree-name__label" title="' + escapeHtml(node.name) + '">'
|
||||
+ escapeHtml(node.displayName || node.name) + '</span>'
|
||||
+ '<span class="' + chevronClass + '">' + chevronGlyph + '</span>'
|
||||
+ '<span class="tree-name__icon">' + iconChar + extChip + '</span>'
|
||||
+ labelHtml(node)
|
||||
+ virtualHint
|
||||
+ '</div>';
|
||||
}
|
||||
|
|
@ -196,33 +404,9 @@
|
|||
html += rowHtml(state.nodes.get(ids[i]));
|
||||
}
|
||||
body.innerHTML = html;
|
||||
updateCount();
|
||||
renderBreadcrumbs();
|
||||
}
|
||||
|
||||
// Count nodes that render at the root + every expanded subtree.
|
||||
function expandedSetSize() {
|
||||
var n = 0;
|
||||
function walk(ids) {
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
n++;
|
||||
var node = state.nodes.get(ids[i]);
|
||||
if (node && (node.isDir || node.isZip) && node.expanded) {
|
||||
walk(node.childIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(state.rootIds);
|
||||
return n;
|
||||
}
|
||||
|
||||
function updateCount() {
|
||||
var el = document.getElementById('entryCount');
|
||||
if (!el) return;
|
||||
var total = expandedSetSize();
|
||||
el.textContent = total + ' item' + (total === 1 ? '' : 's');
|
||||
}
|
||||
|
||||
// ── Breadcrumbs ──────────────────────────────────────────────────────
|
||||
|
||||
// Inline outline home icon. Stroke-based so it tints with the
|
||||
|
|
@ -431,6 +615,61 @@
|
|||
return parts.join('/');
|
||||
}
|
||||
|
||||
// ── State snapshot / restore ───────────────────────────────────────────
|
||||
//
|
||||
// Used by refresh + show-hidden so the user doesn't lose their
|
||||
// tree layout when the listing reloads. The key is the absolute
|
||||
// path of each node, computed by pathFor; on restore we walk the
|
||||
// new tree and re-apply expansion + selection to nodes whose
|
||||
// paths match.
|
||||
|
||||
function snapshotState() {
|
||||
var expanded = {};
|
||||
var selectedPath = null;
|
||||
var previewPath = null;
|
||||
state.nodes.forEach(function (n) {
|
||||
if ((n.isDir || n.isZip) && n.expanded) {
|
||||
expanded[pathFor(n)] = true;
|
||||
}
|
||||
if (n.id === state.selectedId) selectedPath = pathFor(n);
|
||||
if (n.id === state.lastPreviewedNodeId) previewPath = pathFor(n);
|
||||
});
|
||||
return {
|
||||
expanded: expanded,
|
||||
selectedPath: selectedPath,
|
||||
previewPath: previewPath
|
||||
};
|
||||
}
|
||||
|
||||
// Walk the current tree (already populated by setRoot) and re-
|
||||
// load + expand every folder whose path appears in snapshot.expanded.
|
||||
// Sets selectedId and lastPreviewedNodeId by matching the snapshot
|
||||
// paths to the freshly-issued node IDs.
|
||||
async function restoreState(snap) {
|
||||
if (!snap) return;
|
||||
async function walk(ids) {
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
var n = state.nodes.get(ids[i]);
|
||||
if (!n) continue;
|
||||
var p = pathFor(n);
|
||||
if (snap.selectedPath && p === snap.selectedPath) {
|
||||
state.selectedId = n.id;
|
||||
}
|
||||
if (snap.previewPath && p === snap.previewPath) {
|
||||
state.lastPreviewedNodeId = n.id;
|
||||
}
|
||||
if ((n.isDir || n.isZip) && snap.expanded[p]) {
|
||||
await loadChildren(n);
|
||||
if (n.loaded) {
|
||||
n.expanded = true;
|
||||
await walk(n.childIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(state.rootIds);
|
||||
}
|
||||
|
||||
// Public API
|
||||
window.app.modules.tree = {
|
||||
setRoot: setRoot,
|
||||
|
|
@ -439,6 +678,9 @@
|
|||
toggleFolder: toggleFolder,
|
||||
expandSubtree: expandSubtree,
|
||||
collapseSubtree: collapseSubtree,
|
||||
loadChildren: loadChildren,
|
||||
snapshotState: snapshotState,
|
||||
restoreState: restoreState,
|
||||
setSort: function (key) {
|
||||
if (state.sort.key === key) {
|
||||
state.sort.dir = -state.sort.dir;
|
||||
|
|
@ -455,6 +697,7 @@
|
|||
state.sort.dir = (dir === -1 ? -1 : 1);
|
||||
render();
|
||||
},
|
||||
pathFor: pathFor
|
||||
pathFor: pathFor,
|
||||
visibleIds: visibleIds
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -85,13 +85,17 @@
|
|||
return false;
|
||||
}
|
||||
|
||||
function uploadUrl(filename) {
|
||||
var base = state.currentPath || '/';
|
||||
// Join a directory path and a relative path safely. dir is expected
|
||||
// to be /-prefixed and may or may not have a trailing /; rel is a
|
||||
// forward-slash relative path (no leading /). Each segment is
|
||||
// URI-encoded so spaces and friends survive the round trip.
|
||||
function joinUrl(dir, rel) {
|
||||
var base = dir || '/';
|
||||
if (!base.endsWith('/')) base += '/';
|
||||
return base + encodeURIComponent(filename);
|
||||
return base + rel.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
async function uploadOne(file) {
|
||||
async function uploadOne(file, destDir, relPath) {
|
||||
if (file.size > UPLOAD_MAX_BYTES) {
|
||||
return {
|
||||
file: file,
|
||||
|
|
@ -101,7 +105,7 @@
|
|||
};
|
||||
}
|
||||
try {
|
||||
var resp = await fetch(uploadUrl(file.name), {
|
||||
var resp = await fetch(joinUrl(destDir, relPath), {
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
credentials: 'same-origin',
|
||||
|
|
@ -125,6 +129,351 @@
|
|||
}
|
||||
}
|
||||
|
||||
// ── Folder-upload helpers (webkitGetAsEntry recursion) ─────────────────
|
||||
// Browsers expose dropped folders only through the entries API.
|
||||
// walkEntry flattens a tree into [{ relPath, file }] so uploadOne
|
||||
// can PUT each file individually. The server's PUT auto-creates
|
||||
// intermediate directories, so no explicit mkdir is needed.
|
||||
|
||||
function readAllEntries(reader) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var collected = [];
|
||||
function loop() {
|
||||
reader.readEntries(function (batch) {
|
||||
if (batch.length === 0) return resolve(collected);
|
||||
collected = collected.concat(batch);
|
||||
loop();
|
||||
}, reject);
|
||||
}
|
||||
loop();
|
||||
});
|
||||
}
|
||||
|
||||
function entryToFile(entry) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
entry.file(resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function walkEntry(entry, prefix, out) {
|
||||
if (entry.isFile) {
|
||||
try {
|
||||
var f = await entryToFile(entry);
|
||||
out.push({ relPath: prefix + entry.name, file: f });
|
||||
} catch (_e) { /* skip unreadable file */ }
|
||||
} else if (entry.isDirectory) {
|
||||
var reader = entry.createReader();
|
||||
var kids = await readAllEntries(reader);
|
||||
for (var i = 0; i < kids.length; i++) {
|
||||
await walkEntry(kids[i], prefix + entry.name + '/', out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract { relPath, file } pairs from a DataTransfer. Uses
|
||||
// webkitGetAsEntry when available (so folder uploads work);
|
||||
// falls back to dataTransfer.files for cases where entries
|
||||
// aren't exposed (some browsers / cross-origin).
|
||||
async function collectUploads(dt) {
|
||||
var out = [];
|
||||
if (dt.items && dt.items.length) {
|
||||
var entries = [];
|
||||
for (var i = 0; i < dt.items.length; i++) {
|
||||
var item = dt.items[i];
|
||||
if (item.kind !== 'file') continue;
|
||||
var entry = typeof item.webkitGetAsEntry === 'function'
|
||||
? item.webkitGetAsEntry()
|
||||
: null;
|
||||
if (entry) {
|
||||
entries.push(entry);
|
||||
} else {
|
||||
var f = item.getAsFile();
|
||||
if (f) out.push({ relPath: f.name, file: f });
|
||||
}
|
||||
}
|
||||
for (var j = 0; j < entries.length; j++) {
|
||||
await walkEntry(entries[j], '', out);
|
||||
}
|
||||
if (out.length) return out;
|
||||
}
|
||||
if (dt.files) {
|
||||
for (var k = 0; k < dt.files.length; k++) {
|
||||
out.push({ relPath: dt.files[k].name, file: dt.files[k] });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Run a batch of uploads against an arbitrary destination directory.
|
||||
// Surfaces per-file errors as toasts; refreshes the tree afterward
|
||||
// so newly-uploaded entries appear. Returns { ok, fail } counts.
|
||||
async function uploadBatch(uploads, destDir) {
|
||||
var note = window.zddc && window.zddc.toast;
|
||||
if (note) {
|
||||
note('Uploading ' + uploads.length + ' item'
|
||||
+ (uploads.length === 1 ? '' : 's') + '…', 'info');
|
||||
}
|
||||
var ok = 0, fail = 0;
|
||||
for (var i = 0; i < uploads.length; i++) {
|
||||
var u = uploads[i];
|
||||
var res = await uploadOne(u.file, destDir, u.relPath);
|
||||
if (res.ok) ok++;
|
||||
else {
|
||||
fail++;
|
||||
if (note) {
|
||||
note('Upload failed: ' + u.relPath + ' — ' + res.message, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (note) {
|
||||
if (fail === 0) {
|
||||
note('Uploaded ' + ok + ' file' + (ok === 1 ? '' : 's')
|
||||
+ ' → ' + destDir, 'success');
|
||||
} else if (ok === 0) {
|
||||
note('All ' + fail + ' upload' + (fail === 1 ? '' : 's') + ' failed', 'error');
|
||||
} else {
|
||||
note(ok + ' uploaded, ' + fail + ' failed', 'warning');
|
||||
}
|
||||
}
|
||||
return { ok: ok, fail: fail };
|
||||
}
|
||||
|
||||
// Comment upload: PUT each dropped file's bytes to the target URL.
|
||||
// The server detects the virtual <workflow>/received/ context and
|
||||
// rewrites the destination to <workflow>/<base>+C<n><suffix>, surfacing
|
||||
// the resolved path in X-ZDDC-Resolved-Path so the status line can
|
||||
// tell the user where the bytes landed.
|
||||
async function uploadCommentToTarget(targetURL, dataTransfer) {
|
||||
var note = window.zddc && window.zddc.toast;
|
||||
var files = [];
|
||||
if (dataTransfer.files && dataTransfer.files.length) {
|
||||
for (var k = 0; k < dataTransfer.files.length; k++) {
|
||||
files.push(dataTransfer.files[k]);
|
||||
}
|
||||
}
|
||||
if (files.length === 0) {
|
||||
if (note) note('No files to upload.', 'warning');
|
||||
return;
|
||||
}
|
||||
var ok = 0;
|
||||
var lastResolved = '';
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
var f = files[i];
|
||||
if (f.size > UPLOAD_MAX_BYTES) {
|
||||
if (note) note('Skipped (too large): ' + f.name, 'error');
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
var resp = await fetch(targetURL, {
|
||||
method: 'PUT',
|
||||
body: f,
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': f.type || 'application/octet-stream' }
|
||||
});
|
||||
if (resp.ok) {
|
||||
ok++;
|
||||
var hdr = resp.headers.get('X-ZDDC-Resolved-Path') || '';
|
||||
if (hdr) lastResolved = hdr;
|
||||
} else if (note) {
|
||||
note('Comment upload failed (' + resp.status + ')', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
if (note) note('Comment upload error: ' + (e && e.message), 'error');
|
||||
}
|
||||
}
|
||||
if (note && ok > 0) {
|
||||
var msg = 'Saved ' + ok + ' comment' + (ok === 1 ? '' : 's');
|
||||
if (lastResolved) msg += ' — last at ' + lastResolved;
|
||||
note(msg, 'success');
|
||||
}
|
||||
// Reload the listing of the workflow folder so the new +Cn file
|
||||
// appears in the tree. The workflow folder is the parent of the
|
||||
// virtual `received/` (i.e., the URL with one `/received/<file>`
|
||||
// suffix stripped).
|
||||
var refreshUrl = targetURL.replace(/\/received\/[^/]+\/?$/, '/');
|
||||
try {
|
||||
var ev = window.app.modules.events;
|
||||
if (ev && typeof ev.refreshListing === 'function') {
|
||||
ev.refreshListing();
|
||||
} else if (refreshUrl) {
|
||||
// Best-effort fallback: re-navigate to the workflow folder
|
||||
// so its listing is refreshed.
|
||||
// (No action — refreshListing absence implies older browse.)
|
||||
}
|
||||
} catch (_e) { /* refresh is best-effort */ }
|
||||
}
|
||||
|
||||
// ── Create-new helpers ────────────────────────────────────────────────
|
||||
// Both go through the same server endpoints used by upload: PUT
|
||||
// for files (with an empty/template body) and POST + X-ZDDC-Op:
|
||||
// mkdir for directories. Client-side enforcement is best-effort;
|
||||
// the server's ACL is the source of truth.
|
||||
|
||||
async function makeDir(parentDir, name) {
|
||||
var url = joinUrl(parentDir, name);
|
||||
if (!url.endsWith('/')) url += '/';
|
||||
var resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-ZDDC-Op': 'mkdir' }
|
||||
});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
}
|
||||
|
||||
async function makeFile(parentDir, name, body, contentType) {
|
||||
var resp = await fetch(joinUrl(parentDir, name), {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': contentType || 'application/octet-stream' },
|
||||
body: body == null ? '' : body
|
||||
});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
}
|
||||
|
||||
// ── Delete + rename ─────────────────────────────────────────────────────
|
||||
// Both run through the same FS Access API + file-API endpoints used
|
||||
// by the create helpers above:
|
||||
// - Server mode: DELETE / POST X-ZDDC-Op: move. ACL is enforced
|
||||
// server-side; a 403/405 surfaces as an error toast.
|
||||
// - FS-API mode: FileSystemHandle.remove({recursive:true}) and
|
||||
// .move(newName) — both are Chromium-110+ features. We feature-
|
||||
// detect at the handle level; callers see a clear "not supported"
|
||||
// error message if the browser is too old.
|
||||
|
||||
function pathForNode(node) {
|
||||
var tree = window.app.modules.tree;
|
||||
return tree ? tree.pathFor(node) : '';
|
||||
}
|
||||
|
||||
function isZipMember(node) {
|
||||
if (node.handle && node.handle.isZipEntry) return true;
|
||||
if (node.url && state.source === 'server' && /\.zip\//i.test(node.url)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// True when this node's write API is reachable. The server can
|
||||
// still refuse the action on ACL grounds; this only gates the
|
||||
// menu's disabled-state for the cases where there's clearly no
|
||||
// write target at all.
|
||||
function canMutate(node) {
|
||||
if (!node || node.virtual) return false;
|
||||
if (isZipMember(node)) return false;
|
||||
if (state.source === 'server') return true;
|
||||
if (node.handle && typeof node.handle.remove === 'function') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function removeNode(node) {
|
||||
if (!node) throw new Error('no node');
|
||||
if (isZipMember(node)) {
|
||||
throw new Error('Cannot delete a file inside a zip archive.');
|
||||
}
|
||||
if (node.virtual) {
|
||||
throw new Error('Virtual folder — nothing on disk to delete.');
|
||||
}
|
||||
if (state.source === 'server') {
|
||||
var url = pathForNode(node);
|
||||
if (node.isDir && !url.endsWith('/')) url += '/';
|
||||
var resp = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 403) throw new Error('Permission denied (403).');
|
||||
if (resp.status === 405) throw new Error('Delete not allowed for this entry.');
|
||||
throw new Error('HTTP ' + resp.status);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// FS-API path. FileSystemHandle.remove() is Chromium 110+
|
||||
// (browsers that didn't ship it expose no equivalent — the
|
||||
// legacy removeEntry() lives on the PARENT directory handle
|
||||
// and we don't retain ancestor handles).
|
||||
if (node.handle && typeof node.handle.remove === 'function') {
|
||||
await node.handle.remove({ recursive: !!node.isDir });
|
||||
return;
|
||||
}
|
||||
throw new Error('Delete not supported by this browser in offline mode.');
|
||||
}
|
||||
|
||||
async function renameNode(node, newName) {
|
||||
if (!node) throw new Error('no node');
|
||||
if (!newName) throw new Error('Name required.');
|
||||
if (newName === node.name) return;
|
||||
if (isZipMember(node)) {
|
||||
throw new Error('Cannot rename a file inside a zip archive.');
|
||||
}
|
||||
if (node.virtual) {
|
||||
throw new Error('Virtual folder — nothing on disk to rename.');
|
||||
}
|
||||
if (state.source === 'server') {
|
||||
var src = pathForNode(node);
|
||||
if (node.isDir && !src.endsWith('/')) src += '/';
|
||||
// Destination = same parent, new basename.
|
||||
var lastSlash = src.replace(/\/$/, '').lastIndexOf('/');
|
||||
var parent = lastSlash >= 0 ? src.substring(0, lastSlash + 1) : '/';
|
||||
var dst = parent + encodeURIComponent(newName) + (node.isDir ? '/' : '');
|
||||
var resp = await fetch(src, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-ZDDC-Op': 'move',
|
||||
'X-ZDDC-Destination': dst
|
||||
}
|
||||
});
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 403) throw new Error('Permission denied (403).');
|
||||
if (resp.status === 409) throw new Error('A file with that name already exists.');
|
||||
throw new Error('HTTP ' + resp.status);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// FS-API: handle.move(newName) is Chromium 110+.
|
||||
if (node.handle && typeof node.handle.move === 'function') {
|
||||
await node.handle.move(newName);
|
||||
return;
|
||||
}
|
||||
throw new Error('Rename not supported by this browser in offline mode.');
|
||||
}
|
||||
|
||||
// Refresh either the root listing (when the upload targeted the
|
||||
// current scope) or just one folder node's children (when the
|
||||
// upload targeted a subfolder via a per-row drop).
|
||||
async function refreshAfterUpload(targetDir) {
|
||||
var loader = window.app.modules.loader;
|
||||
var tree = window.app.modules.tree;
|
||||
if (!loader || !tree) return;
|
||||
if (state.currentPath && targetDir === state.currentPath) {
|
||||
try {
|
||||
var es = await loader.fetchServerChildren(state.currentPath);
|
||||
tree.setRoot(es);
|
||||
tree.render();
|
||||
} catch (_e) { /* swallow */ }
|
||||
return;
|
||||
}
|
||||
// Find any tree node whose path matches targetDir and reload
|
||||
// its children. Walks state.nodes flat — n is small enough for
|
||||
// a linear scan.
|
||||
var dirNoSlash = (targetDir || '').replace(/\/$/, '');
|
||||
var hit = null;
|
||||
state.nodes.forEach(function (n) {
|
||||
if (hit || !n.isDir) return;
|
||||
if (tree.pathFor(n).replace(/\/$/, '') === dirNoSlash) hit = n;
|
||||
});
|
||||
if (hit && hit.expanded) {
|
||||
try {
|
||||
var raw = await loader.fetchServerChildren(targetDir);
|
||||
tree.setChildren(hit.id, raw);
|
||||
tree.render();
|
||||
} catch (_e) { /* swallow */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Document-level drop: targets the currently-viewed scope. The
|
||||
// per-row drop (events.js) calls uploadToDir directly with a
|
||||
// different destination.
|
||||
async function handleDrop(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
|
@ -133,46 +482,21 @@
|
|||
|
||||
if (!currentScopeAllows()) return;
|
||||
var dt = e.dataTransfer;
|
||||
if (!dt || !dt.files || dt.files.length === 0) return;
|
||||
if (!dt) return;
|
||||
var uploads = await collectUploads(dt);
|
||||
if (!uploads.length) return;
|
||||
await uploadBatch(uploads, state.currentPath);
|
||||
await refreshAfterUpload(state.currentPath);
|
||||
}
|
||||
|
||||
var files = Array.from(dt.files);
|
||||
var note = window.zddc && window.zddc.toast;
|
||||
if (note) note('Uploading ' + files.length + ' file' + (files.length === 1 ? '' : 's') + '…', 'info');
|
||||
|
||||
// Sequential — predictable progress + ordering. Can parallelise
|
||||
// later if it matters.
|
||||
var ok = 0, fail = 0;
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
var res = await uploadOne(files[i]);
|
||||
if (res.ok) {
|
||||
ok++;
|
||||
} else {
|
||||
fail++;
|
||||
if (note) {
|
||||
note('Upload failed: ' + res.file.name + ' — ' + res.message, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (note) {
|
||||
if (fail === 0) {
|
||||
note('Uploaded ' + ok + ' file' + (ok === 1 ? '' : 's'), 'success');
|
||||
} else if (ok === 0) {
|
||||
note('All ' + fail + ' upload' + (fail === 1 ? '' : 's') + ' failed', 'error');
|
||||
} else {
|
||||
note(ok + ' uploaded, ' + fail + ' failed', 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the listing so newly-uploaded files appear.
|
||||
var loader = window.app.modules.loader;
|
||||
var tree = window.app.modules.tree;
|
||||
if (loader && tree && state.currentPath) {
|
||||
try {
|
||||
var es = await loader.fetchServerChildren(state.currentPath);
|
||||
tree.setRoot(es);
|
||||
tree.render();
|
||||
} catch (_e) { /* swallow; user can hard-reload */ }
|
||||
}
|
||||
// Public entry for per-row drops or programmatic uploads. destDir
|
||||
// must be a server path (/-prefixed, slash-terminated optional).
|
||||
async function uploadToDir(destDir, dataTransfer) {
|
||||
var uploads = await collectUploads(dataTransfer);
|
||||
if (!uploads.length) return { ok: 0, fail: 0 };
|
||||
var res = await uploadBatch(uploads, destDir);
|
||||
await refreshAfterUpload(destDir);
|
||||
return res;
|
||||
}
|
||||
|
||||
function onEnter(e) {
|
||||
|
|
@ -215,6 +539,13 @@
|
|||
|
||||
window.app.modules.upload = {
|
||||
currentScopeAllows: currentScopeAllows,
|
||||
uploadToDir: uploadToDir,
|
||||
uploadCommentToTarget: uploadCommentToTarget,
|
||||
makeDir: makeDir,
|
||||
makeFile: makeFile,
|
||||
removeNode: removeNode,
|
||||
renameNode: renameNode,
|
||||
canMutate: canMutate,
|
||||
UPLOAD_MAX_BYTES: UPLOAD_MAX_BYTES
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -24,10 +24,16 @@
|
|||
<span class="app-header__title">ZDDC Browse</span>
|
||||
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- Elevation toggle slot. shared/elevation.js fills it
|
||||
when /.profile/access reports the user has admin
|
||||
authority; stays empty + hidden for non-admins so
|
||||
the chrome is quiet for the common case. -->
|
||||
<span id="elevation-toggle" class="elevation-toggle hidden"
|
||||
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
|
||||
</div>
|
||||
|
|
@ -41,7 +47,7 @@
|
|||
<ul class="welcome-list">
|
||||
<li><b>Online</b> — when this page is served by zddc-server, the
|
||||
listing for the current directory loads automatically.</li>
|
||||
<li><b>Local</b> — click <i>Add Local Directory</i> to pick any folder
|
||||
<li><b>Local</b> — click <i>Use Local Directory</i> to pick any folder
|
||||
on your computer (Chromium-based browsers).</li>
|
||||
</ul>
|
||||
<p>Once loaded: click folders to expand, click files to preview them in
|
||||
|
|
@ -54,33 +60,20 @@
|
|||
<div id="browseRoot" class="browse-root hidden">
|
||||
<div class="browse-toolbar">
|
||||
<nav class="breadcrumbs" id="breadcrumbs" aria-label="Path"></nav>
|
||||
<span class="toolbar__count" id="entryCount"></span>
|
||||
<button id="downloadZipBtn" class="btn btn-sm btn-secondary hidden"
|
||||
title="Download this folder (and everything under it you can access) as a .zip"
|
||||
aria-label="Download this folder as a zip">⤓ Download (zip)</button>
|
||||
<label class="sort-control" for="sortBy" title="Sort tree entries">
|
||||
<span class="sort-control__label">Sort:</span>
|
||||
<select id="sortBy" class="sort-control__select" aria-label="Sort tree entries">
|
||||
<option value="name:asc">Name (A→Z)</option>
|
||||
<option value="name:desc">Name (Z→A)</option>
|
||||
<option value="date:desc">Modified (new→old)</option>
|
||||
<option value="date:asc">Modified (old→new)</option>
|
||||
<option value="size:desc">Size (large→small)</option>
|
||||
<option value="size:asc">Size (small→large)</option>
|
||||
<option value="ext:asc">Type (A→Z)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="sort-control" for="showHidden"
|
||||
title="Surface .-prefixed and _-prefixed entries (.zddc, .converted/, _app/, …). ACL still applies — you only see what you'd already be allowed to read.">
|
||||
<input type="checkbox" id="showHidden" class="sort-control__checkbox"
|
||||
aria-label="Show hidden files">
|
||||
<span class="sort-control__label">Show hidden</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Browse mode (default): two-pane tree + preview -->
|
||||
<div id="browseView" class="browse-view">
|
||||
<div class="pane tree-pane" id="treePane">
|
||||
<div class="tree-pane__toolbar">
|
||||
<input type="search"
|
||||
id="treeFilter"
|
||||
class="tree-filter"
|
||||
placeholder="Filter files…"
|
||||
aria-label="Filter the tree by name, tracking number, status, revision, or title"
|
||||
autocomplete="off"
|
||||
spellcheck="false">
|
||||
</div>
|
||||
<div class="tree-pane__body" id="treeBody" role="tree" aria-label="Files"></div>
|
||||
</div>
|
||||
<div class="pane-resizer" data-resizer-for="tree-pane" aria-hidden="true"></div>
|
||||
|
|
@ -135,18 +128,22 @@
|
|||
<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>⤴ Pop out</dt>
|
||||
<dd>Open the current preview in a separate window — useful for a second
|
||||
monitor.</dd>
|
||||
<dt>ZIP files</dt>
|
||||
<dd>Behave as folders — click to inspect contents inline. JSZip is
|
||||
bundled, so this works offline.</dd>
|
||||
<dt>⤓ Download (zip)</dt>
|
||||
<dd>Downloads the directory you're currently viewing — and everything
|
||||
under it that you're allowed to see — as a single <code>.zip</code>.
|
||||
Navigate into a subfolder first to download just that subtree. Online,
|
||||
the server streams it; locally, the browser bundles the picked folder
|
||||
(a confirmation appears if it's very large).</dd>
|
||||
<dt>Download / Download ZIP</dt>
|
||||
<dd>Right-click a file for <b>Download</b>, or a folder for
|
||||
<b>Download ZIP</b> (everything under it that you're allowed to see,
|
||||
bundled into one archive). Online, the server streams it; locally,
|
||||
the browser bundles the picked folder (a confirmation appears if it's
|
||||
very large).</dd>
|
||||
<dt>Refresh</dt>
|
||||
<dd>Re-fetches the current directory listing — works for both
|
||||
local (re-enumerates the FS handle) and online (re-fetches the JSON).</dd>
|
||||
|
|
@ -154,7 +151,7 @@
|
|||
|
||||
<h3>Header buttons</h3>
|
||||
<dl>
|
||||
<dt>Add Local Directory</dt>
|
||||
<dt>Use Local Directory</dt>
|
||||
<dd>Pick a folder from your computer. Works in both modes; in online
|
||||
mode it's de-emphasized but still available.</dd>
|
||||
<dt>⟳ Refresh</dt>
|
||||
|
|
|
|||
4
classifier/build.sh
Normal file → Executable file
4
classifier/build.sh
Normal file → Executable file
|
|
@ -22,7 +22,7 @@ concat_files \
|
|||
"../shared/fonts.css" \
|
||||
"../shared/base.css" \
|
||||
"../shared/toast.css" \
|
||||
"../shared/nav.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/logo.css" \
|
||||
"css/base.css" \
|
||||
"css/layout.css" \
|
||||
|
|
@ -44,7 +44,6 @@ concat_files \
|
|||
"../shared/zddc-source.js" \
|
||||
"../shared/theme.js" \
|
||||
"../shared/toast.js" \
|
||||
"../shared/nav.js" \
|
||||
"../shared/logo.js" \
|
||||
"../shared/preview-lib.js" \
|
||||
"js/app.js" \
|
||||
|
|
@ -62,6 +61,7 @@ concat_files \
|
|||
"js/sort.js" \
|
||||
"js/excel.js" \
|
||||
"../shared/help.js" \
|
||||
"../shared/elevation.js" \
|
||||
> "$js_raw"
|
||||
|
||||
# Escape '</' in inlined JS so the HTML parser cannot mistake string contents
|
||||
|
|
|
|||
|
|
@ -28,11 +28,17 @@
|
|||
<span class="app-header__title">ZDDC Classifier</span>
|
||||
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
<!-- Elevation toggle slot. shared/elevation.js fills it
|
||||
when /.profile/access reports the user has admin
|
||||
authority; stays empty + hidden for non-admins so
|
||||
the chrome is quiet for the common case. -->
|
||||
<span id="elevation-toggle" class="elevation-toggle hidden"
|
||||
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
<button id="help-btn" class="btn btn-secondary" title="Help">?</button>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -154,7 +160,7 @@
|
|||
<li>Rename one file or all modified files at once</li>
|
||||
</ul>
|
||||
|
||||
<p>Click <strong>Add Local Directory</strong> to begin.</p>
|
||||
<p>Click <strong>Use Local Directory</strong> to begin.</p>
|
||||
|
||||
<p class="note">This application works entirely in your browser. No data is transmitted to any server.</p>
|
||||
</div>
|
||||
|
|
@ -173,7 +179,7 @@
|
|||
|
||||
<h3>Getting Started</h3>
|
||||
<ol>
|
||||
<li>Click <strong>Add Local Directory</strong> to open a folder containing files to rename.</li>
|
||||
<li>Click <strong>Use Local Directory</strong> to open a folder containing files to rename.</li>
|
||||
<li>The folder tree on the left shows all sub-folders. Click a folder to load its files.</li>
|
||||
<li>Edit cells in the spreadsheet to set the new filename components.</li>
|
||||
<li>Click <strong>Save All</strong> (or save individual rows) to rename the files on disk.</li>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ concat_files \
|
|||
"../shared/fonts.css" \
|
||||
"../shared/base.css" \
|
||||
"../shared/toast.css" \
|
||||
"../shared/nav.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/logo.css" \
|
||||
"css/form.css" \
|
||||
> "$css_temp"
|
||||
|
|
@ -29,9 +29,9 @@ concat_files \
|
|||
concat_files \
|
||||
"../shared/theme.js" \
|
||||
"../shared/toast.js" \
|
||||
"../shared/nav.js" \
|
||||
"../shared/logo.js" \
|
||||
"../shared/help.js" \
|
||||
"../shared/elevation.js" \
|
||||
"js/app.js" \
|
||||
"js/context.js" \
|
||||
"js/util.js" \
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- Elevation toggle slot. shared/elevation.js fills it
|
||||
when /.profile/access reports the user has admin
|
||||
authority; stays empty + hidden for non-admins so
|
||||
the chrome is quiet for the common case. -->
|
||||
<span id="elevation-toggle" class="elevation-toggle hidden"
|
||||
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -64,7 +64,36 @@ spec:
|
|||
- name: zddc-server
|
||||
image: {{ printf "%s:%s" .Values.runtimeImage.repository .Values.runtimeImage.tag | quote }}
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: ["/zddc/zddc-server"]
|
||||
# zddc-cgroup-init prepares cgroup v2 subtree_control then
|
||||
# exec's zddc-server. Required because cgroup v2 forbids
|
||||
# processes in a cgroup that has child cgroups; the per-
|
||||
# conversion wrapper (zddc-sandbox-exec) creates child
|
||||
# cgroups for resource caps, so the init script has to
|
||||
# move zddc-server itself out of the root cgroup first.
|
||||
# See zddc/runtime/zddc-cgroup-init in the source repo.
|
||||
command: ["/usr/local/libexec/zddc-cgroup-init", "/zddc/zddc-server"]
|
||||
# The conversion sandbox (bwrap, invoked per-call by
|
||||
# /usr/local/bin/{pandoc,chromium-browser}) needs to create
|
||||
# user + mount namespaces inside the container. Pod Security
|
||||
# Standards default policies forbid this; the chart sets the
|
||||
# minimum securityContext that lets bwrap function. If your
|
||||
# cluster's admission controller rejects these settings, you
|
||||
# have two choices: ask the platform team to allow this pod,
|
||||
# or accept that /.convert serves 503 (the rest of zddc-
|
||||
# server still works fine without conversion).
|
||||
securityContext:
|
||||
capabilities:
|
||||
add: ["SYS_ADMIN"]
|
||||
# cap-add SYS_ADMIN alone isn't enough — see the
|
||||
# zddc/runtime/zddc-sandbox-exec docstring for the full
|
||||
# set of LSM relaxations required. K8s 1.30+ supports
|
||||
# specifying seccompProfile + appArmorProfile fields;
|
||||
# if your cluster is older, you'll need annotations:
|
||||
# container.apparmor.security.beta.kubernetes.io/zddc-server: unconfined
|
||||
seccompProfile:
|
||||
type: Unconfined
|
||||
appArmorProfile:
|
||||
type: Unconfined
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
|
|
|
|||
|
|
@ -108,11 +108,16 @@ buildImage:
|
|||
tag: 1.24-alpine
|
||||
# digest: sha256:...
|
||||
|
||||
# Runtime image (main container). Must contain a basic shell + libc;
|
||||
# the static binary is copied in by the init container. Alpine is fine.
|
||||
# Runtime image (main container). Hosts the zddc-server binary copied
|
||||
# in by the init container, plus the conversion toolchain (pandoc,
|
||||
# chromium, bubblewrap) used by the /.convert endpoint. Build from
|
||||
# `zddc/runtime.Containerfile` and publish to your registry; the
|
||||
# Containerfile documents the build/publish commands. Plain alpine
|
||||
# does NOT have the conversion tools — the /.convert endpoint will
|
||||
# serve 503 until you swap in a runtime image that bundles them.
|
||||
runtimeImage:
|
||||
repository: docker.io/alpine
|
||||
tag: "3.19"
|
||||
repository: codeberg.org/varasys/zddc-server-runtime
|
||||
tag: "latest"
|
||||
# digest: sha256:...
|
||||
|
||||
# Image pull credentials, if your registry requires them. Reference a
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ concat_files \
|
|||
"../shared/fonts.css" \
|
||||
"../shared/base.css" \
|
||||
"../shared/toast.css" \
|
||||
"../shared/nav.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/logo.css" \
|
||||
"css/landing.css" \
|
||||
> "$css_temp"
|
||||
|
|
@ -31,9 +31,9 @@ concat_files \
|
|||
"../shared/zddc-filter.js" \
|
||||
"../shared/theme.js" \
|
||||
"../shared/toast.js" \
|
||||
"../shared/nav.js" \
|
||||
"../shared/logo.js" \
|
||||
"../shared/help.js" \
|
||||
"../shared/elevation.js" \
|
||||
"js/landing.js" \
|
||||
> "$js_raw"
|
||||
|
||||
|
|
|
|||
|
|
@ -128,13 +128,27 @@
|
|||
|
||||
var data = JSON.parse(body);
|
||||
if (!Array.isArray(data)) throw new Error('Expected a JSON array of projects, got ' + typeof data);
|
||||
allProjects = data.map(function(p) {
|
||||
return {
|
||||
name: String(p.name || ''),
|
||||
title: String(p.title || ''),
|
||||
url: String(p.url || '')
|
||||
};
|
||||
}).filter(function(p) { return p.name; });
|
||||
// The root JSON is now a generic listing.FileInfo[] (same
|
||||
// shape every other directory returns). Filter to
|
||||
// directories (projects are folders), strip the trailing
|
||||
// "/" the server adds to dir names, and pick up `title`
|
||||
// (the per-project .zddc title:, populated by the
|
||||
// server-side listing pipeline).
|
||||
allProjects = data
|
||||
.filter(function (p) { return p && p.is_dir; })
|
||||
.map(function (p) {
|
||||
var raw = String(p.name || '').replace(/\/$/, '');
|
||||
return {
|
||||
name: raw,
|
||||
title: String(p.title || ''),
|
||||
url: String(p.url || '')
|
||||
};
|
||||
})
|
||||
.filter(function (p) {
|
||||
if (!p.name) return false;
|
||||
var c = p.name.charAt(0);
|
||||
return c !== '.' && c !== '_';
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
loadError = e.message || String(e);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- Elevation toggle slot. shared/elevation.js fills it
|
||||
when /.profile/access reports the user has admin
|
||||
authority; stays empty + hidden for non-admins so
|
||||
the chrome is quiet for the common case. -->
|
||||
<span id="elevation-toggle" class="elevation-toggle hidden"
|
||||
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@ a:hover {
|
|||
}
|
||||
|
||||
/* Subdued / de-emphasized variant.
|
||||
Used on the "Add Local Directory" button when a tool is operating
|
||||
Used on the "Use Local Directory" button when a tool is operating
|
||||
in server (online) mode — the local-dir affordance is still
|
||||
available but visually quieter, since the typical user already
|
||||
has the directory loaded from the server. */
|
||||
|
|
@ -292,6 +292,11 @@ a:hover {
|
|||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
/* Let the left / right groups wrap to a second row at narrow
|
||||
viewports rather than overflowing the viewport edge. row-gap
|
||||
gives a small breathing strip when wrapped. */
|
||||
flex-wrap: wrap;
|
||||
row-gap: 0.3rem;
|
||||
}
|
||||
|
||||
/* Left and right groups inside .app-header. Both flex-row so their
|
||||
|
|
@ -303,16 +308,35 @@ a:hover {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
/* Allow the title to shrink (and ellipsize) before the action
|
||||
buttons get pushed off-screen at narrow viewports. */
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 0.3rem;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Title group (title + build label). Made shrinkable so narrow
|
||||
viewports don't push the action buttons out of view; the title
|
||||
itself ellipsizes via the rule below. */
|
||||
.header-title-group {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
/* Tool name inside the header. Renders in the display serif so the
|
||||
tool's identity reads as a document title, not a UI label. */
|
||||
tool's identity reads as a document title, not a UI label.
|
||||
overflow + ellipsis on min-width:0 lets the title compress
|
||||
gracefully when there's no room. */
|
||||
.app-header__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
|
|
@ -320,6 +344,9 @@ a:hover {
|
|||
color: var(--text);
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Brand logo — sits left of the title in every tool's app-header.
|
||||
|
|
|
|||
109
shared/context-menu.css
Normal file
109
shared/context-menu.css
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/* shared/context-menu.css — generic styles for window.zddc.menu.
|
||||
Mirrors the look-and-feel of native context menus: tight rows,
|
||||
five-column grid (check | icon | label | accel | arrow), subtle
|
||||
border + shadow, hover background from the shared --bg-hover token,
|
||||
danger items tinted with --danger. */
|
||||
|
||||
.zddc-menu {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
min-width: 12rem;
|
||||
max-width: 22rem;
|
||||
padding: 0.25rem 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18),
|
||||
0 2px 6px rgba(0, 0, 0, 0.10);
|
||||
font-family: var(--font);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.2;
|
||||
user-select: none;
|
||||
/* Allow focus styles inside without leaking to the menu itself. */
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.zddc-menu__sep {
|
||||
height: 1px;
|
||||
margin: 0.25rem 0;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.zddc-menu__item {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1rem 1.25rem 1fr auto 0.9rem;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
/* Suppress the focus ring on the row itself — hover/focus
|
||||
background handles the cue. */
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.zddc-menu__item:hover,
|
||||
.zddc-menu__item:focus,
|
||||
.zddc-menu__item:focus-visible {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.zddc-menu__item.is-disabled {
|
||||
color: var(--text-muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.zddc-menu__item.is-disabled:hover,
|
||||
.zddc-menu__item.is-disabled:focus {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.zddc-menu__item--danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.zddc-menu__item--danger:hover,
|
||||
.zddc-menu__item--danger:focus {
|
||||
background: var(--danger);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.zddc-menu__check {
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.zddc-menu__icon {
|
||||
font-size: 0.95rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.zddc-menu__label {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.zddc-menu__accel {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.zddc-menu__item--danger .zddc-menu__accel {
|
||||
color: inherit;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.zddc-menu__arrow {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.zddc-menu__item--has-sub .zddc-menu__arrow {
|
||||
color: var(--text);
|
||||
}
|
||||
381
shared/context-menu.js
Normal file
381
shared/context-menu.js
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
// shared/context-menu.js — generic context-menu framework exposed on
|
||||
// window.zddc.menu. Built so every ZDDC tool can drop a right-click
|
||||
// menu (or any programmatically-opened menu) onto its UI without
|
||||
// shipping its own implementation.
|
||||
//
|
||||
// API:
|
||||
// window.zddc.menu.open({ x, y, items, context })
|
||||
// window.zddc.menu.close()
|
||||
//
|
||||
// `items` is an array (or a function returning an array, evaluated
|
||||
// against `context` at open-time). Each entry is one of:
|
||||
// { label, action, icon?, accel?, disabled?, visible?, danger? }
|
||||
// — a normal menu item; `action(ctx)` fires on click/Enter.
|
||||
// { label, checked, action, ... }
|
||||
// — toggle item; `checked` may be a bool or a fn(ctx). Renders
|
||||
// a ✓ in the gutter when truthy.
|
||||
// { label, items, ... }
|
||||
// — submenu; `items` may itself be an array or fn(ctx).
|
||||
// { separator: true }
|
||||
// — horizontal divider. Leading/trailing/duplicate separators
|
||||
// are collapsed automatically so callers can build items
|
||||
// conditionally without managing dividers.
|
||||
//
|
||||
// Any of `label`, `checked`, `visible`, `disabled`, and `items` may
|
||||
// be a function — each is invoked with the context object so callers
|
||||
// can render fully context-aware menus from a single declarative
|
||||
// config.
|
||||
//
|
||||
// Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a
|
||||
// submenu, ArrowLeft / Escape backs up one level (or closes if
|
||||
// already at the root), Enter / Space activates. Click-outside,
|
||||
// window blur, scroll, and resize all dismiss.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (!window.zddc) window.zddc = {};
|
||||
if (window.zddc.menu) return;
|
||||
|
||||
var SUBMENU_HOVER_MS = 180;
|
||||
|
||||
// Open menu stack — index 0 is the root, deeper entries are
|
||||
// nested submenus. Each frame: { el, depth, parentRow? }.
|
||||
var stack = [];
|
||||
var rootContext = null;
|
||||
var submenuTimer = null;
|
||||
|
||||
function resolve(val, ctx) {
|
||||
return typeof val === 'function' ? val(ctx) : val;
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (submenuTimer) { clearTimeout(submenuTimer); submenuTimer = null; }
|
||||
for (var i = 0; i < stack.length; i++) {
|
||||
var fr = stack[i];
|
||||
if (fr.el && fr.el.parentNode) fr.el.parentNode.removeChild(fr.el);
|
||||
}
|
||||
stack = [];
|
||||
rootContext = null;
|
||||
document.removeEventListener('mousedown', onDocMouseDown, true);
|
||||
document.removeEventListener('keydown', onDocKeyDown, true);
|
||||
// blur is bound WITHOUT capture so we only react to the window
|
||||
// itself losing focus — capturing would also fire when any
|
||||
// inner element blurs (which happens every time the user moves
|
||||
// the mouse between menu rows, since hover focuses the row).
|
||||
window.removeEventListener('blur', close);
|
||||
window.removeEventListener('resize', close, true);
|
||||
window.removeEventListener('scroll', onDocScroll, true);
|
||||
}
|
||||
|
||||
function open(opts) {
|
||||
opts = opts || {};
|
||||
close();
|
||||
rootContext = opts.context || {};
|
||||
var items = resolve(opts.items, rootContext) || [];
|
||||
var el = buildMenu(items, rootContext, 0);
|
||||
document.body.appendChild(el);
|
||||
position(el, opts.x || 0, opts.y || 0, null);
|
||||
stack.push({ el: el, depth: 0 });
|
||||
|
||||
document.addEventListener('mousedown', onDocMouseDown, true);
|
||||
document.addEventListener('keydown', onDocKeyDown, true);
|
||||
window.addEventListener('blur', close);
|
||||
window.addEventListener('resize', close, true);
|
||||
window.addEventListener('scroll', onDocScroll, true);
|
||||
|
||||
focusFirst(el);
|
||||
}
|
||||
|
||||
// ── Building ─────────────────────────────────────────────────────────
|
||||
|
||||
function collapseSeparators(items) {
|
||||
var out = [];
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var it = items[i];
|
||||
if (it && it.separator) {
|
||||
if (out.length === 0) continue;
|
||||
if (out[out.length - 1].separator) continue;
|
||||
out.push(it);
|
||||
} else if (it) {
|
||||
out.push(it);
|
||||
}
|
||||
}
|
||||
while (out.length && out[out.length - 1].separator) out.pop();
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildMenu(items, ctx, depth) {
|
||||
var menu = document.createElement('div');
|
||||
menu.className = 'zddc-menu';
|
||||
menu.setAttribute('role', 'menu');
|
||||
menu.dataset.depth = String(depth);
|
||||
// Suppress the native context menu over our own menu.
|
||||
menu.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
||||
|
||||
var filtered = items.filter(function (it) {
|
||||
if (!it) return false;
|
||||
if (it.separator) return true;
|
||||
if ('visible' in it && !resolve(it.visible, ctx)) return false;
|
||||
return true;
|
||||
});
|
||||
var pruned = collapseSeparators(filtered);
|
||||
|
||||
for (var i = 0; i < pruned.length; i++) {
|
||||
menu.appendChild(buildRow(pruned[i], ctx, depth));
|
||||
}
|
||||
return menu;
|
||||
}
|
||||
|
||||
function buildRow(item, ctx, depth) {
|
||||
if (item.separator) {
|
||||
var sep = document.createElement('div');
|
||||
sep.className = 'zddc-menu__sep';
|
||||
sep.setAttribute('role', 'separator');
|
||||
return sep;
|
||||
}
|
||||
|
||||
var hasSub = !!item.items;
|
||||
var isToggle = ('checked' in item);
|
||||
var disabled = 'disabled' in item ? !!resolve(item.disabled, ctx) : false;
|
||||
|
||||
var row = document.createElement('div');
|
||||
row.className = 'zddc-menu__item';
|
||||
if (item.danger) row.classList.add('zddc-menu__item--danger');
|
||||
if (hasSub) row.classList.add('zddc-menu__item--has-sub');
|
||||
if (disabled) {
|
||||
row.classList.add('is-disabled');
|
||||
row.setAttribute('aria-disabled', 'true');
|
||||
}
|
||||
row.setAttribute('role',
|
||||
hasSub ? 'menuitem'
|
||||
: (isToggle ? 'menuitemcheckbox' : 'menuitem'));
|
||||
row.tabIndex = -1;
|
||||
|
||||
// Check gutter — present on every row so columns align.
|
||||
var check = document.createElement('span');
|
||||
check.className = 'zddc-menu__check';
|
||||
if (isToggle) {
|
||||
var on = !!resolve(item.checked, ctx);
|
||||
if (on) {
|
||||
check.textContent = '✓';
|
||||
row.classList.add('is-checked');
|
||||
row.setAttribute('aria-checked', 'true');
|
||||
} else {
|
||||
row.setAttribute('aria-checked', 'false');
|
||||
}
|
||||
}
|
||||
row.appendChild(check);
|
||||
|
||||
// Icon column.
|
||||
var icon = document.createElement('span');
|
||||
icon.className = 'zddc-menu__icon';
|
||||
if (item.icon) icon.textContent = item.icon;
|
||||
row.appendChild(icon);
|
||||
|
||||
// Label.
|
||||
var label = document.createElement('span');
|
||||
label.className = 'zddc-menu__label';
|
||||
label.textContent = String(resolve(item.label, ctx) || '');
|
||||
row.appendChild(label);
|
||||
|
||||
// Accelerator hint (visual only; no binding).
|
||||
var accel = document.createElement('span');
|
||||
accel.className = 'zddc-menu__accel';
|
||||
if (item.accel) accel.textContent = item.accel;
|
||||
row.appendChild(accel);
|
||||
|
||||
// Submenu arrow.
|
||||
var arrow = document.createElement('span');
|
||||
arrow.className = 'zddc-menu__arrow';
|
||||
if (hasSub) arrow.textContent = '▸';
|
||||
row.appendChild(arrow);
|
||||
|
||||
if (!disabled) {
|
||||
row.addEventListener('mouseenter', function () {
|
||||
// Hovering any row in a menu collapses deeper menus
|
||||
// (so traversing siblings closes a previously-opened
|
||||
// submenu) and re-focuses this row for keyboard nav.
|
||||
closeBelow(depth);
|
||||
if (submenuTimer) { clearTimeout(submenuTimer); submenuTimer = null; }
|
||||
if (hasSub) {
|
||||
submenuTimer = setTimeout(function () {
|
||||
openSubmenu(row, item, ctx, depth + 1, false);
|
||||
}, SUBMENU_HOVER_MS);
|
||||
}
|
||||
try { row.focus({ preventScroll: true }); } catch (_e) { row.focus(); }
|
||||
});
|
||||
row.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (submenuTimer) { clearTimeout(submenuTimer); submenuTimer = null; }
|
||||
if (hasSub) {
|
||||
openSubmenu(row, item, ctx, depth + 1, true);
|
||||
return;
|
||||
}
|
||||
activate(item, ctx);
|
||||
});
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function activate(item, ctx) {
|
||||
try {
|
||||
if (typeof item.action === 'function') item.action(ctx);
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function openSubmenu(parentRow, parentItem, ctx, depth, takeFocus) {
|
||||
closeBelow(depth - 1);
|
||||
var items = resolve(parentItem.items, ctx) || [];
|
||||
var el = buildMenu(items, ctx, depth);
|
||||
document.body.appendChild(el);
|
||||
var rect = parentRow.getBoundingClientRect();
|
||||
// Slight overlap so pointer-cross feels continuous.
|
||||
position(el, rect.right - 2, rect.top - 4, parentRow);
|
||||
stack.push({ el: el, depth: depth, parentRow: parentRow });
|
||||
if (takeFocus) focusFirst(el);
|
||||
}
|
||||
|
||||
function closeBelow(depth) {
|
||||
while (stack.length && stack[stack.length - 1].depth > depth) {
|
||||
var fr = stack.pop();
|
||||
if (fr.el && fr.el.parentNode) fr.el.parentNode.removeChild(fr.el);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Positioning ──────────────────────────────────────────────────────
|
||||
|
||||
function position(el, x, y, parentRow) {
|
||||
// Fixed so we ignore document scroll; measure after layout.
|
||||
el.style.position = 'fixed';
|
||||
el.style.left = '0px';
|
||||
el.style.top = '0px';
|
||||
el.style.visibility = 'hidden';
|
||||
var rect = el.getBoundingClientRect();
|
||||
var w = rect.width;
|
||||
var h = rect.height;
|
||||
var vw = window.innerWidth;
|
||||
var vh = window.innerHeight;
|
||||
|
||||
var leftX = x;
|
||||
if (leftX + w > vw - 4) {
|
||||
if (parentRow) {
|
||||
var pr = parentRow.getBoundingClientRect();
|
||||
leftX = pr.left - w + 2; // flip submenu to the left
|
||||
} else {
|
||||
leftX = Math.max(4, x - w); // flip root menu left of cursor
|
||||
}
|
||||
}
|
||||
if (leftX < 4) leftX = 4;
|
||||
|
||||
var topY = y;
|
||||
if (topY + h > vh - 4) topY = Math.max(4, vh - h - 4);
|
||||
if (topY < 4) topY = 4;
|
||||
|
||||
el.style.left = leftX + 'px';
|
||||
el.style.top = topY + 'px';
|
||||
el.style.visibility = '';
|
||||
}
|
||||
|
||||
// ── Focus + keyboard ─────────────────────────────────────────────────
|
||||
|
||||
function focusable(menuEl) {
|
||||
return Array.prototype.slice.call(
|
||||
menuEl.querySelectorAll('.zddc-menu__item:not(.is-disabled)'));
|
||||
}
|
||||
|
||||
function focusFirst(menuEl) {
|
||||
var items = focusable(menuEl);
|
||||
if (items.length) {
|
||||
try { items[0].focus({ preventScroll: true }); }
|
||||
catch (_e) { items[0].focus(); }
|
||||
}
|
||||
}
|
||||
|
||||
function onDocMouseDown(e) {
|
||||
for (var i = 0; i < stack.length; i++) {
|
||||
if (stack[i].el.contains(e.target)) return;
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
// Scroll listener uses capture so scrolls inside any element (the
|
||||
// tree pane, the document, etc.) dismiss the menu — its position
|
||||
// is fixed and would otherwise hang over stale content. Scrolls
|
||||
// that originate inside the menu itself (a future tall submenu)
|
||||
// are ignored.
|
||||
function onDocScroll(e) {
|
||||
var t = e.target;
|
||||
for (var i = 0; i < stack.length; i++) {
|
||||
if (stack[i].el === t || (t && t.nodeType === 1 && stack[i].el.contains(t))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
function onDocKeyDown(e) {
|
||||
if (!stack.length) return;
|
||||
var top = stack[stack.length - 1];
|
||||
var items = focusable(top.el);
|
||||
var active = document.activeElement;
|
||||
var idx = items.indexOf(active);
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
if (stack.length > 1) {
|
||||
var fr = stack.pop();
|
||||
if (fr.el.parentNode) fr.el.parentNode.removeChild(fr.el);
|
||||
if (fr.parentRow) fr.parentRow.focus();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
return;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (!items.length) return;
|
||||
items[idx < 0 ? 0 : (idx + 1) % items.length].focus();
|
||||
return;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
if (!items.length) return;
|
||||
items[idx < 0 ? items.length - 1
|
||||
: (idx - 1 + items.length) % items.length].focus();
|
||||
return;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
if (items.length) items[0].focus();
|
||||
return;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
if (items.length) items[items.length - 1].focus();
|
||||
return;
|
||||
case 'ArrowRight':
|
||||
if (active && active.classList.contains('zddc-menu__item--has-sub')) {
|
||||
e.preventDefault();
|
||||
active.click();
|
||||
}
|
||||
return;
|
||||
case 'ArrowLeft':
|
||||
if (stack.length > 1) {
|
||||
e.preventDefault();
|
||||
var fr2 = stack.pop();
|
||||
if (fr2.el.parentNode) fr2.el.parentNode.removeChild(fr2.el);
|
||||
if (fr2.parentRow) fr2.parentRow.focus();
|
||||
}
|
||||
return;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
if (active) {
|
||||
e.preventDefault();
|
||||
active.click();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
window.zddc.menu = { open: open, close: close };
|
||||
})();
|
||||
122
shared/elevation.css
Normal file
122
shared/elevation.css
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
||||
Renders only for users with admin scope (handled by elevation.js;
|
||||
the placeholder is `.hidden` by default). When visible, sits left
|
||||
of the theme button — sudo-style affordance for opting into admin
|
||||
powers. */
|
||||
|
||||
.elevation-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.elevation-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-dark);
|
||||
}
|
||||
|
||||
.elevation-toggle input[type="checkbox"] {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--danger);
|
||||
}
|
||||
|
||||
.elevation-toggle__label {
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Active state — when elevation is ON, the toggle reads as "armed"
|
||||
so the user can't miss that admin powers are currently live.
|
||||
:has(:checked) lets us style the wrapper based on the inner
|
||||
checkbox without JS. */
|
||||
.elevation-toggle:has(input:checked) {
|
||||
background: rgba(220, 53, 69, 0.12);
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||
easy to miss; these add an inescapable visual cue:
|
||||
1. Thin red border around the entire viewport — peripheral-
|
||||
vision reminder regardless of which tool / scroll position.
|
||||
2. Sticky banner across the top with a one-click "Drop admin"
|
||||
button so the user can disarm without hunting for the toggle.
|
||||
Both rendered ONLY when the zddc-elevate cookie is set; the
|
||||
shared/elevation.js init() syncs the body class on every page
|
||||
load and tears it down when elevation is cleared.
|
||||
|
||||
Frame uses fixed positioning + pointer-events:none so it doesn't
|
||||
reflow content or steal clicks. An inset outline on <body> was
|
||||
tried first but overdrew content in tools whose root layout butts
|
||||
right up to the viewport edge (browse split-pane, archive grid). */
|
||||
body.is-elevated::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
border: 3px solid var(--danger, #dc3545);
|
||||
pointer-events: none;
|
||||
z-index: 9200; /* above banner (9100) so the frame paints on top */
|
||||
}
|
||||
|
||||
.elevation-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: rgba(220, 53, 69, 0.95);
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 9100; /* above modal-overlay (9000) so it's never hidden */
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.elevation-banner__dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7);
|
||||
animation: elev-pulse 1.6s infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes elev-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); }
|
||||
70% { box-shadow: 0 0 0 8px rgba(255, 255, 255, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }
|
||||
}
|
||||
|
||||
.elevation-banner__msg {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.elevation-banner__off {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
color: #fff;
|
||||
padding: 0.18rem 0.65rem;
|
||||
border-radius: var(--radius, 4px);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.elevation-banner__off:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
148
shared/elevation.js
Normal file
148
shared/elevation.js
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// shared/elevation.js — admin elevation toggle.
|
||||
//
|
||||
// Sudo-style model: admins behave as normal users by default; clicking
|
||||
// the header toggle elevates the session so admin escape hatches (WORM
|
||||
// bypass, .zddc edit authority, profile admin scaffolds) start firing.
|
||||
// State is carried in a `zddc-elevate=1` cookie that the server reads
|
||||
// via handler.ACLMiddleware → zddc.Principal{Elevated}.
|
||||
//
|
||||
// Only renders the toggle when /.profile/access reports the caller has
|
||||
// some admin scope — a non-admin sees nothing, which keeps the chrome
|
||||
// quiet for the common case. The toggle fades in once access loads so
|
||||
// non-admins never even see the affordance flash.
|
||||
//
|
||||
// Click flow: set/clear the cookie, then reload the page so the server
|
||||
// sees the new state on the next render. The reload is intentional —
|
||||
// admin scaffolds in tool HTML are server-rendered for some tools, so
|
||||
// a soft state flip on the client alone wouldn't reach those.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (!window.zddc) window.zddc = {};
|
||||
if (window.zddc.elevation) return;
|
||||
|
||||
var COOKIE_NAME = 'zddc-elevate';
|
||||
|
||||
function isElevated() {
|
||||
var parts = document.cookie.split(';');
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
var kv = parts[i].trim().split('=');
|
||||
if (kv[0] === COOKIE_NAME && kv[1] === '1') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function setElevated(on) {
|
||||
if (on) {
|
||||
// SameSite=Lax blocks cross-site form-post / image-tag CSRF
|
||||
// shapes. Max-Age caps the elevation window so a forgotten
|
||||
// tab doesn't leave admin powers active indefinitely (sudo's
|
||||
// 5-minute precedent informs the number — 30 minutes is a
|
||||
// reasonable trade between annoyance and exposure).
|
||||
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800';
|
||||
} else {
|
||||
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAccess() {
|
||||
try {
|
||||
var resp = await fetch('/.profile/access', {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-cache'
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
return await resp.json();
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function render(host, elevated) {
|
||||
host.classList.remove('hidden');
|
||||
host.innerHTML =
|
||||
'<input type="checkbox" id="elevation-checkbox"'
|
||||
+ (elevated ? ' checked' : '') + '>'
|
||||
+ '<label for="elevation-checkbox" class="elevation-toggle__label">'
|
||||
+ 'Admin</label>';
|
||||
var cb = host.querySelector('#elevation-checkbox');
|
||||
cb.addEventListener('change', function () {
|
||||
setElevated(cb.checked);
|
||||
// Hard reload so server-rendered admin surfaces (profile
|
||||
// page scaffolds, hidden-entry listings) catch up. URL
|
||||
// and scroll state are preserved by the browser's normal
|
||||
// back-forward cache rules.
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
// Page-wide affordances when elevation is active. The toggle alone
|
||||
// is easy to miss — admin mode silently bypasses WORM and ACL
|
||||
// restrictions, which produces surprising "I shouldn't have been
|
||||
// able to do that" moments. A body class + a sticky banner with a
|
||||
// one-click disable make the armed state unmistakable.
|
||||
function applyArmedChrome(elevated) {
|
||||
var b = document.body;
|
||||
if (!b) return;
|
||||
if (elevated) b.classList.add('is-elevated');
|
||||
else b.classList.remove('is-elevated');
|
||||
|
||||
var banner = document.getElementById('elevation-banner');
|
||||
if (elevated) {
|
||||
if (!banner) {
|
||||
banner = document.createElement('div');
|
||||
banner.id = 'elevation-banner';
|
||||
banner.className = 'elevation-banner';
|
||||
banner.setAttribute('role', 'alert');
|
||||
banner.innerHTML =
|
||||
'<span class="elevation-banner__dot" aria-hidden="true"></span>'
|
||||
+ '<span class="elevation-banner__msg">'
|
||||
+ 'Admin mode is on — write access bypasses WORM and ACL safeguards.'
|
||||
+ '</span>'
|
||||
+ '<button type="button" class="elevation-banner__off" id="elevation-banner-off">'
|
||||
+ 'Drop admin'
|
||||
+ '</button>';
|
||||
document.body.insertBefore(banner, document.body.firstChild);
|
||||
var off = banner.querySelector('#elevation-banner-off');
|
||||
if (off) off.addEventListener('click', function () {
|
||||
setElevated(false);
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
} else if (banner) {
|
||||
banner.parentNode.removeChild(banner);
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
// Body chrome applies on every page load whether or not the
|
||||
// header has a toggle slot — the banner needs to surface in
|
||||
// tools / pages that don't host the toggle (e.g. iframed
|
||||
// classifier inside browse's grid mode), so the user can't
|
||||
// accidentally write through an elevated context elsewhere.
|
||||
applyArmedChrome(isElevated());
|
||||
|
||||
var host = document.getElementById('elevation-toggle');
|
||||
if (!host) return; // tool doesn't include the slot yet — no-op
|
||||
var access = await fetchAccess();
|
||||
if (!access) return; // anonymous / endpoint missing — no-op
|
||||
// Surface ONLY for users who have admin authority somewhere.
|
||||
// /.profile/access ships `can_elevate` as an elevation-
|
||||
// INDEPENDENT signal — true for any user named in any admin
|
||||
// list, regardless of current cookie state. The other flags
|
||||
// (is_super_admin, has_any_admin_scope) reflect EFFECTIVE
|
||||
// authority and would be false for an un-elevated admin
|
||||
// who hasn't toggled yet — so we can't gate on those.
|
||||
if (!access.can_elevate) return;
|
||||
render(host, isElevated());
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
||||
})();
|
||||
162
shared/icons.js
Normal file
162
shared/icons.js
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
// shared/icons.js — minimal outline SVG sprite for ZDDC tools.
|
||||
//
|
||||
// Vendored from Lucide (https://lucide.dev, ISC). Only the 16
|
||||
// file-type glyphs the browse tree maps to are bundled; total weight
|
||||
// is ~4.5 KB of SVG path data. Each symbol viewBox is 0 0 24 24 with
|
||||
// no stroke/fill attributes — those are applied at the call site via
|
||||
// CSS so the icons inherit `currentColor` and tint with the theme.
|
||||
//
|
||||
// API:
|
||||
// window.zddc.icons.inject() // mount sprite into <body> once
|
||||
// window.zddc.icons.html('icon-foo') // → '<svg viewBox="0 0 24 24"><use href="#icon-foo"/></svg>'
|
||||
// window.zddc.icons.ID // string set of valid symbol ids
|
||||
//
|
||||
// Callers concat html() output into innerHTML the same way they
|
||||
// previously concat'd emoji glyphs. The injected sprite is hidden
|
||||
// (`display:none` on the outer <svg>) so it costs zero layout.
|
||||
//
|
||||
// Why a sprite (rather than per-row inline paths): a hundred tree
|
||||
// rows × 300 bytes of duplicated path data is 30 KB of churn on
|
||||
// every re-render. With <use>, each row carries only a ~60-byte
|
||||
// reference. The sprite is parsed once.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (!window.zddc) window.zddc = {};
|
||||
if (window.zddc.icons) return;
|
||||
|
||||
// ── Sprite (Lucide outline glyphs, viewBox 24×24) ──────────────────────
|
||||
// Concatenated from upstream lucide-static@1.16.0 SVGs; class/style
|
||||
// attributes stripped. Order matches the icons-mapped block below
|
||||
// so a diff against Lucide's source stays readable.
|
||||
var SYMBOLS = ''
|
||||
+ '<symbol id="icon-folder" viewBox="0 0 24 24">'
|
||||
+ '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>'
|
||||
+ '</symbol>'
|
||||
+ '<symbol id="icon-folder-archive" viewBox="0 0 24 24">'
|
||||
+ '<circle cx="15" cy="19" r="2"/>'
|
||||
+ '<path d="M20.9 19.8A2 2 0 0 0 22 18V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h5.1"/>'
|
||||
+ '<path d="M15 11v-1"/>'
|
||||
+ '<path d="M15 17v-2"/>'
|
||||
+ '</symbol>'
|
||||
+ '<symbol id="icon-file" viewBox="0 0 24 24">'
|
||||
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
|
||||
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
|
||||
+ '</symbol>'
|
||||
+ '<symbol id="icon-file-text" viewBox="0 0 24 24">'
|
||||
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
|
||||
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
|
||||
+ '<path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>'
|
||||
+ '</symbol>'
|
||||
+ '<symbol id="icon-file-image" viewBox="0 0 24 24">'
|
||||
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
|
||||
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
|
||||
+ '<circle cx="10" cy="12" r="2"/>'
|
||||
+ '<path d="m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22"/>'
|
||||
+ '</symbol>'
|
||||
+ '<symbol id="icon-file-video" viewBox="0 0 24 24">'
|
||||
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
|
||||
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
|
||||
+ '<path d="M15.033 13.44a.647.647 0 0 1 0 1.12l-4.065 2.352a.645.645 0 0 1-.968-.56v-4.704a.645.645 0 0 1 .967-.56z"/>'
|
||||
+ '</symbol>'
|
||||
+ '<symbol id="icon-file-audio" viewBox="0 0 24 24">'
|
||||
+ '<path d="M4 6.835V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2h-.343"/>'
|
||||
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
|
||||
+ '<path d="M2 19a2 2 0 0 1 4 0v1a2 2 0 0 1-4 0v-4a6 6 0 0 1 12 0v4a2 2 0 0 1-4 0v-1a2 2 0 0 1 4 0"/>'
|
||||
+ '</symbol>'
|
||||
+ '<symbol id="icon-file-archive" viewBox="0 0 24 24">'
|
||||
+ '<path d="M13.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v11.5"/>'
|
||||
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
|
||||
+ '<path d="M8 12v-1"/><path d="M8 18v-2"/><path d="M8 7V6"/>'
|
||||
+ '<circle cx="8" cy="20" r="2"/>'
|
||||
+ '</symbol>'
|
||||
+ '<symbol id="icon-file-spreadsheet" viewBox="0 0 24 24">'
|
||||
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
|
||||
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
|
||||
+ '<path d="M8 13h2"/><path d="M14 13h2"/><path d="M8 17h2"/><path d="M14 17h2"/>'
|
||||
+ '</symbol>'
|
||||
+ '<symbol id="icon-file-code" viewBox="0 0 24 24">'
|
||||
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
|
||||
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
|
||||
+ '<path d="M10 12.5 8 15l2 2.5"/>'
|
||||
+ '<path d="m14 12.5 2 2.5-2 2.5"/>'
|
||||
+ '</symbol>'
|
||||
+ '<symbol id="icon-file-cog" viewBox="0 0 24 24">'
|
||||
+ '<path d="M15 8a1 1 0 0 1-1-1V2a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8z"/>'
|
||||
+ '<path d="M20 8v12a2 2 0 0 1-2 2h-4.182"/>'
|
||||
+ '<path d="m3.305 19.53.923-.382"/>'
|
||||
+ '<path d="M4 10.592V4a2 2 0 0 1 2-2h8"/>'
|
||||
+ '<path d="m4.228 16.852-.924-.383"/>'
|
||||
+ '<path d="m5.852 15.228-.383-.923"/>'
|
||||
+ '<path d="m5.852 20.772-.383.924"/>'
|
||||
+ '<path d="m8.148 15.228.383-.923"/>'
|
||||
+ '<path d="m8.53 21.696-.382-.924"/>'
|
||||
+ '<path d="m9.773 16.852.922-.383"/>'
|
||||
+ '<path d="m9.773 19.148.922.383"/>'
|
||||
+ '<circle cx="7" cy="18" r="3"/>'
|
||||
+ '</symbol>'
|
||||
+ '<symbol id="icon-file-pen" viewBox="0 0 24 24">'
|
||||
+ '<path d="M12.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v9.34"/>'
|
||||
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
|
||||
+ '<path d="M10.378 12.622a1 1 0 0 1 3 3.003L8.36 20.637a2 2 0 0 1-.854.506l-2.867.837a.5.5 0 0 1-.62-.62l.836-2.869a2 2 0 0 1 .506-.853z"/>'
|
||||
+ '</symbol>'
|
||||
+ '<symbol id="icon-book-marked" viewBox="0 0 24 24">'
|
||||
+ '<path d="M10 2v8l3-3 3 3V2"/>'
|
||||
+ '<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20"/>'
|
||||
+ '</symbol>'
|
||||
+ '<symbol id="icon-presentation" viewBox="0 0 24 24">'
|
||||
+ '<path d="M2 3h20"/>'
|
||||
+ '<path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3"/>'
|
||||
+ '<path d="m7 21 5-5 5 5"/>'
|
||||
+ '</symbol>'
|
||||
+ '<symbol id="icon-ruler" viewBox="0 0 24 24">'
|
||||
+ '<path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z"/>'
|
||||
+ '<path d="m14.5 12.5 2-2"/>'
|
||||
+ '<path d="m11.5 9.5 2-2"/>'
|
||||
+ '<path d="m8.5 6.5 2-2"/>'
|
||||
+ '<path d="m17.5 15.5 2-2"/>'
|
||||
+ '</symbol>'
|
||||
+ '<symbol id="icon-globe" viewBox="0 0 24 24">'
|
||||
+ '<circle cx="12" cy="12" r="10"/>'
|
||||
+ '<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/>'
|
||||
+ '<path d="M2 12h20"/>'
|
||||
+ '</symbol>'
|
||||
// Lightweight outline chevron — used by the tree as the
|
||||
// expand/collapse affordance. The single glyph rotates 90°
|
||||
// via CSS to indicate the expanded state, so we only ship
|
||||
// one path instead of two.
|
||||
+ '<symbol id="icon-chevron-right" viewBox="0 0 24 24">'
|
||||
+ '<path d="m9 18 6-6-6-6"/>'
|
||||
+ '</symbol>';
|
||||
|
||||
var injected = false;
|
||||
|
||||
function inject() {
|
||||
if (injected) return;
|
||||
// insertAdjacentHTML on body parses the SVG namespace correctly
|
||||
// across all modern browsers (innerHTML on a <div> wrapper has
|
||||
// historically tripped over <symbol> in some engines).
|
||||
var sprite = '<svg xmlns="http://www.w3.org/2000/svg" '
|
||||
+ 'aria-hidden="true" style="position:absolute;width:0;height:0;'
|
||||
+ 'overflow:hidden" focusable="false">'
|
||||
+ SYMBOLS
|
||||
+ '</svg>';
|
||||
if (document.body) {
|
||||
document.body.insertAdjacentHTML('afterbegin', sprite);
|
||||
injected = true;
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', inject, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Produces the per-row markup callers concat into innerHTML.
|
||||
// Bundles the size + stroke defaults inline so the SVG renders
|
||||
// correctly even before the page CSS runs (e.g. mid-paint).
|
||||
function html(symbolId) {
|
||||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" '
|
||||
+ 'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" '
|
||||
+ 'aria-hidden="true"><use href="#' + symbolId + '"/></svg>';
|
||||
}
|
||||
|
||||
window.zddc.icons = { inject: inject, html: html };
|
||||
})();
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
|
||||
Sits as a sibling immediately under .app-header (mounted by JS).
|
||||
Rendered only in online mode when a project segment is in the URL. */
|
||||
|
||||
.zddc-stage-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 1rem;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.3;
|
||||
flex-shrink: 0;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.zddc-stage-strip__project {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
margin-right: 0.15rem;
|
||||
}
|
||||
|
||||
.zddc-stage-strip__divider,
|
||||
.zddc-stage-strip__sep {
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.zddc-stage-strip__divider {
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
|
||||
.zddc-stage {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
padding: 0.1rem 0.25rem;
|
||||
border-radius: var(--radius);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.zddc-stage:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.zddc-stage--active {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.zddc-stage--active:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
204
shared/nav.js
204
shared/nav.js
|
|
@ -1,204 +0,0 @@
|
|||
// shared/nav.js — lateral navigation strip across the project's
|
||||
// cascade-declared stages. Mounted as a sibling of <header class="app-
|
||||
// header"> on DOMContentLoaded, hydrated from the project root's
|
||||
// directory listing.
|
||||
//
|
||||
// Stage discovery is cascade-driven (Phase 4c): fetch the project
|
||||
// root's JSON listing, filter to entries with `declared: true`
|
||||
// (server stamps these from the .zddc cascade's paths: tree), and
|
||||
// render in canonical workflow order with display_name overrides
|
||||
// honored. An operator who edits the project's .zddc paths: to add
|
||||
// a new declared child sees it in the strip; one who removes a
|
||||
// canonical entry sees the strip drop it.
|
||||
//
|
||||
// When the fetch fails (offline / no-server / file://), the strip
|
||||
// falls back to the hardcoded four-stage list so existing
|
||||
// deployments don't lose chrome. Hardcoded labels in this file are
|
||||
// the LAST resort — the cascade is the source of truth in normal
|
||||
// operation.
|
||||
//
|
||||
// Stage URLs follow the slash/no-slash convention: no slash opens
|
||||
// the stage's default tool. Operators on non-standard layouts can
|
||||
// override by setting window.zddc.nav.disabled = true before
|
||||
// DOMContentLoaded.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (!window.zddc) window.zddc = {};
|
||||
if (window.zddc.nav) return; // already loaded
|
||||
|
||||
// Hardcoded fallback for offline / file:// / fetch-error contexts.
|
||||
// Server-driven discovery (FETCH_STAGES below) is the normal path.
|
||||
var FALLBACK_STAGES = [
|
||||
{ name: 'archive', label: 'Archive' },
|
||||
{ name: 'working', label: 'Working' },
|
||||
{ name: 'staging', label: 'Staging' },
|
||||
{ name: 'reviewing', label: 'Reviewing' },
|
||||
];
|
||||
|
||||
// Canonical workflow order. Stages appearing in this list are
|
||||
// rendered in this order; any extras the cascade declares are
|
||||
// appended alphabetically.
|
||||
var WORKFLOW_ORDER = ['archive', 'working', 'staging', 'reviewing'];
|
||||
|
||||
function projectSegment(pathname) {
|
||||
var parts = pathname.split('/').filter(Boolean);
|
||||
if (parts.length === 0) return null;
|
||||
var first = parts[0];
|
||||
if (first.indexOf('.') !== -1) return null;
|
||||
return first;
|
||||
}
|
||||
|
||||
function currentStage(pathname, stages) {
|
||||
var parts = pathname.split('/').filter(Boolean);
|
||||
if (parts.length < 2) return null;
|
||||
var second = parts[1];
|
||||
for (var i = 0; i < stages.length; i++) {
|
||||
if (second.toLowerCase() === stages[i].name.toLowerCase()) {
|
||||
return stages[i].name;
|
||||
}
|
||||
}
|
||||
if (second === 'archive.html') return 'archive';
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldRender() {
|
||||
if (typeof location === 'undefined') return false;
|
||||
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
|
||||
if (window.zddc.nav && window.zddc.nav.disabled) return false;
|
||||
return projectSegment(location.pathname) !== null;
|
||||
}
|
||||
|
||||
function titleCase(s) {
|
||||
if (!s) return s;
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
function sortByWorkflow(stages) {
|
||||
return stages.slice().sort(function (a, b) {
|
||||
var ia = WORKFLOW_ORDER.indexOf(a.name.toLowerCase());
|
||||
var ib = WORKFLOW_ORDER.indexOf(b.name.toLowerCase());
|
||||
if (ia >= 0 && ib >= 0) return ia - ib;
|
||||
if (ia >= 0) return -1;
|
||||
if (ib >= 0) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch the project root listing and extract declared stage
|
||||
// entries. Returns [] on any error so callers fall back to the
|
||||
// hardcoded list. Each stage entry is {name, label} — label
|
||||
// honors the cascade's display: override when present.
|
||||
async function fetchStagesFor(project) {
|
||||
try {
|
||||
var resp = await fetch('/' + encodeURIComponent(project) + '/', {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!resp.ok) return [];
|
||||
var data = await resp.json();
|
||||
if (!Array.isArray(data)) return [];
|
||||
var stages = [];
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
var e = data[i];
|
||||
if (!e || !e.declared || !e.is_dir) continue;
|
||||
var bare = (e.name || '').replace(/\/$/, '');
|
||||
if (!bare) continue;
|
||||
stages.push({
|
||||
name: bare,
|
||||
label: e.display_name || titleCase(bare),
|
||||
});
|
||||
}
|
||||
return sortByWorkflow(stages);
|
||||
} catch (_e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function buildStrip(project, active, stages) {
|
||||
var nav = document.createElement('nav');
|
||||
nav.className = 'zddc-stage-strip';
|
||||
nav.setAttribute('aria-label', 'Project stage');
|
||||
|
||||
var label = document.createElement('span');
|
||||
label.className = 'zddc-stage-strip__project';
|
||||
label.textContent = project;
|
||||
nav.appendChild(label);
|
||||
|
||||
var sep0 = document.createElement('span');
|
||||
sep0.className = 'zddc-stage-strip__divider';
|
||||
sep0.setAttribute('aria-hidden', 'true');
|
||||
sep0.textContent = '/';
|
||||
nav.appendChild(sep0);
|
||||
|
||||
for (var i = 0; i < stages.length; i++) {
|
||||
var s = stages[i];
|
||||
var a = document.createElement('a');
|
||||
a.className = 'zddc-stage';
|
||||
a.href = '/' + encodeURIComponent(project) + '/' + s.name;
|
||||
a.textContent = s.label;
|
||||
if (s.name === active) {
|
||||
a.classList.add('zddc-stage--active');
|
||||
a.setAttribute('aria-current', 'page');
|
||||
}
|
||||
nav.appendChild(a);
|
||||
|
||||
if (i < stages.length - 1) {
|
||||
var sep = document.createElement('span');
|
||||
sep.className = 'zddc-stage-strip__sep';
|
||||
sep.setAttribute('aria-hidden', 'true');
|
||||
sep.textContent = '·';
|
||||
nav.appendChild(sep);
|
||||
}
|
||||
}
|
||||
|
||||
return nav;
|
||||
}
|
||||
|
||||
function mountWith(project, stages) {
|
||||
var header = document.querySelector('.app-header');
|
||||
if (!header) return;
|
||||
if (header.previousElementSibling &&
|
||||
header.previousElementSibling.classList &&
|
||||
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
|
||||
return; // already mounted
|
||||
}
|
||||
var active = currentStage(location.pathname, stages);
|
||||
var strip = buildStrip(project, active, stages);
|
||||
header.parentNode.insertBefore(strip, header);
|
||||
}
|
||||
|
||||
async function mount() {
|
||||
if (!shouldRender()) return;
|
||||
var project = projectSegment(location.pathname);
|
||||
if (!project) return;
|
||||
|
||||
// Render the hardcoded fallback immediately so the strip
|
||||
// appears with no flicker, then upgrade to cascade-resolved
|
||||
// stages once the fetch completes.
|
||||
mountWith(project, FALLBACK_STAGES);
|
||||
|
||||
var fetched = await fetchStagesFor(project);
|
||||
if (fetched.length === 0) return; // fetch failed → keep fallback
|
||||
|
||||
// Replace the strip with the cascade-driven one. Remove the
|
||||
// existing strip first so mountWith re-mounts cleanly.
|
||||
var existing = document.querySelector('.zddc-stage-strip');
|
||||
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
|
||||
mountWith(project, fetched);
|
||||
}
|
||||
|
||||
window.zddc.nav = {
|
||||
mount: mount,
|
||||
_projectSegment: projectSegment,
|
||||
_currentStage: currentStage,
|
||||
_fallbackStages: FALLBACK_STAGES,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', mount, { once: true });
|
||||
} else {
|
||||
mount();
|
||||
}
|
||||
})();
|
||||
79
shared/vendor/codemirror-yaml.min.css
vendored
Normal file
79
shared/vendor/codemirror-yaml.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
shared/vendor/codemirror-yaml.min.js
vendored
Normal file
1
shared/vendor/codemirror-yaml.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -289,13 +289,33 @@
|
|||
// Top-level helpers
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
// Strip a trailing tool .html (e.g. classifier.html) from a path
|
||||
// to land on the "directory the tool was opened in".
|
||||
// Resolve "the directory the tool was opened in" for the current
|
||||
// page URL. Two URL shapes serve a tool:
|
||||
//
|
||||
// /…/<tool>.html — file URL; strip the trailing filename.
|
||||
// /…/<dir>/ — trailing-slash directory URL; keep it.
|
||||
// /…/<dir> — bare-directory URL served by the
|
||||
// cascade's `default_tool` (e.g.
|
||||
// archive/<party>/mdl serves the tables
|
||||
// tool). Treat as the directory itself
|
||||
// and append the missing slash.
|
||||
//
|
||||
// Discrimination is "does the last segment contain a dot?" — a dot
|
||||
// is a reliable proxy for "looks like a file with an extension"
|
||||
// since neither directory names nor default_tool paths contain
|
||||
// them in this system.
|
||||
function pathToDir(pathname) {
|
||||
if (!pathname) return '/';
|
||||
if (pathname.endsWith('/')) return pathname;
|
||||
var slash = pathname.lastIndexOf('/');
|
||||
return slash >= 0 ? pathname.substring(0, slash + 1) : '/';
|
||||
var lastSeg = slash >= 0 ? pathname.substring(slash + 1) : pathname;
|
||||
if (lastSeg.indexOf('.') !== -1) {
|
||||
// Has an extension → looks like a file URL → strip the
|
||||
// filename to land on the parent directory.
|
||||
return slash >= 0 ? pathname.substring(0, slash + 1) : '/';
|
||||
}
|
||||
// No extension → the URL IS the directory; just close it.
|
||||
return pathname + '/';
|
||||
}
|
||||
|
||||
// Probe the server-mode root for the current page. Returns:
|
||||
|
|
@ -375,9 +395,14 @@
|
|||
// srcUrl points at the .md source on the server. fmt is one of
|
||||
// "docx" | "html" | "pdf". The server response status maps to a
|
||||
// friendly error message for the caller to surface (toast / status).
|
||||
//
|
||||
// URL grammar: srcUrl is the `<file>.md` source; the converted
|
||||
// form lives at `<file>.<fmt>` (virtual file extension recognised
|
||||
// by zddc-server's dispatcher). Replaces the older `?convert=`
|
||||
// query form.
|
||||
async function downloadConverted(srcUrl, fileName, fmt) {
|
||||
var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
|
||||
{ credentials: 'same-origin' });
|
||||
var convertUrl = srcUrl.replace(/\.md$/i, '') + '.' + fmt;
|
||||
var resp = await fetch(convertUrl, { credentials: 'same-origin' });
|
||||
if (!resp.ok) {
|
||||
var msg;
|
||||
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
|
||||
'REC',
|
||||
'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
|
||||
'TBD',
|
||||
];
|
||||
|
||||
var STATUS_SET = {};
|
||||
|
|
|
|||
|
|
@ -21,8 +21,9 @@ concat_files \
|
|||
"../shared/fonts.css" \
|
||||
"../shared/base.css" \
|
||||
"../shared/toast.css" \
|
||||
"../shared/nav.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/logo.css" \
|
||||
"../shared/context-menu.css" \
|
||||
"css/table.css" \
|
||||
"../form/css/form.css" \
|
||||
> "$css_temp"
|
||||
|
|
@ -38,9 +39,10 @@ concat_files \
|
|||
"../shared/zddc-source.js" \
|
||||
"../shared/theme.js" \
|
||||
"../shared/toast.js" \
|
||||
"../shared/nav.js" \
|
||||
"../shared/logo.js" \
|
||||
"../shared/help.js" \
|
||||
"../shared/elevation.js" \
|
||||
"../shared/context-menu.js" \
|
||||
"js/mode.js" \
|
||||
"js/app.js" \
|
||||
"js/context.js" \
|
||||
|
|
@ -49,8 +51,11 @@ concat_files \
|
|||
"js/sort.js" \
|
||||
"js/editor.js" \
|
||||
"js/undo.js" \
|
||||
"js/add-row.js" \
|
||||
"js/save.js" \
|
||||
"js/row-ops.js" \
|
||||
"js/clipboard.js" \
|
||||
"js/export.js" \
|
||||
"js/render.js" \
|
||||
"js/main.js" \
|
||||
"../form/js/app.js" \
|
||||
|
|
|
|||
|
|
@ -103,6 +103,14 @@
|
|||
background: var(--color-bg-zebra, rgba(0, 0, 0, 0.02));
|
||||
}
|
||||
|
||||
/* Minimum row height so a freshly-added row (every cell empty) stays
|
||||
visible — without this the row collapses to just cell padding and
|
||||
looks like a thin divider line. Acts as a floor; rows with content
|
||||
grow naturally to fit the text. */
|
||||
.zddc-table__row {
|
||||
height: 2.4em;
|
||||
}
|
||||
|
||||
.zddc-table__row--readonly {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
|
|
|||
109
tables/js/add-row.js
Normal file
109
tables/js/add-row.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// add-row.js — inline new-row creation.
|
||||
//
|
||||
// Click "+ Add row" → append a draft row at the end of state.rows,
|
||||
// focus its first editable cell, accumulate user typing into the
|
||||
// drafts buffer like any other row. On row-blur, save.js detects the
|
||||
// row.isNew flag and POSTs to <dir>/form.html (the form-create
|
||||
// endpoint). The 201 response carries the new row's Location; we swap
|
||||
// the synthetic url/yamlUrl for the real ones and the draft row
|
||||
// becomes a normal saved row.
|
||||
//
|
||||
// Synthetic identity: each new row gets a temporary "__new-<N>" url
|
||||
// so rowKey() returns something unique for selection + draft tracking.
|
||||
// The temporary url is replaced after a successful POST. There is no
|
||||
// "save on click" UX — the existing row-blur trigger is the save path,
|
||||
// same as for edits.
|
||||
(function (app) {
|
||||
'use strict';
|
||||
|
||||
let _counter = 0;
|
||||
|
||||
function makeSyntheticKey() {
|
||||
_counter += 1;
|
||||
return '__new-' + _counter;
|
||||
}
|
||||
|
||||
// Compute the form-create URL for the current page. Both
|
||||
// /<dir>/table.html and /<dir>/ (default_tool: tables) shape work;
|
||||
// /<dir>/form.html is the form handler's "create" endpoint either
|
||||
// way (the form handler keys off the in-dir convention, not the
|
||||
// visiting URL shape).
|
||||
function formCreateUrl() {
|
||||
let dir = (location.pathname || '/').replace(/\/table\.html$/, '/');
|
||||
if (!dir.endsWith('/')) dir += '/';
|
||||
return dir + 'form.html';
|
||||
}
|
||||
|
||||
// Create-and-paint: the user-facing path.
|
||||
function invoke() {
|
||||
const key = createSilent();
|
||||
if (typeof app.repaint === 'function') app.repaint();
|
||||
focusNewRow(key);
|
||||
}
|
||||
|
||||
// Push a draft row WITHOUT painting or focusing. Used by multi-row
|
||||
// paste (clipboard.js) to create N rows in a single batch, with one
|
||||
// paint at the end. Returns the synthetic url so callers can address
|
||||
// the new row in their draft writes.
|
||||
function createSilent() {
|
||||
const key = makeSyntheticKey();
|
||||
const draftRow = {
|
||||
url: key,
|
||||
yamlUrl: null,
|
||||
data: {},
|
||||
etag: null,
|
||||
editable: true,
|
||||
isNew: true,
|
||||
};
|
||||
if (!Array.isArray(app.state.rows)) {
|
||||
app.state.rows = [];
|
||||
}
|
||||
app.state.rows.push(draftRow);
|
||||
return key;
|
||||
}
|
||||
|
||||
function focusNewRow(key) {
|
||||
// After repaint, find the tr with our synthetic data-row-id and
|
||||
// tell the editor to select its first cell. Filtering may have
|
||||
// hidden the new row if a default filter excludes it; we accept
|
||||
// that — clearing filters surfaces it.
|
||||
const tbody = document.querySelector('#table-root tbody');
|
||||
if (!tbody) return;
|
||||
const trs = tbody.querySelectorAll('tr');
|
||||
for (let i = 0; i < trs.length; i++) {
|
||||
if (trs[i].getAttribute('data-row-id') === key) {
|
||||
const editor = app.modules.editor;
|
||||
if (editor && typeof editor.setSelected === 'function') {
|
||||
// Scroll into view so the user sees the new row.
|
||||
trs[i].scrollIntoView({ block: 'nearest', behavior: 'auto' });
|
||||
editor.setSelected(i, 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel-new-row helper: drop the synthetic row entirely. Used when
|
||||
// the user adds a row, makes no edits, and clicks Add again or
|
||||
// navigates away — there's nothing to save and an empty draft just
|
||||
// clutters the table. The save module calls this from row-blur when
|
||||
// it sees a new row with no drafts.
|
||||
function discardEmpty(rowId) {
|
||||
const rows = app.state.rows || [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
if (rows[i].isNew && rows[i].url === rowId) {
|
||||
rows.splice(i, 1);
|
||||
if (typeof app.repaint === 'function') app.repaint();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
app.modules.addRow = {
|
||||
invoke: invoke,
|
||||
createSilent: createSilent,
|
||||
formCreateUrl: formCreateUrl,
|
||||
discardEmpty: discardEmpty,
|
||||
};
|
||||
})(window.tablesApp);
|
||||
|
|
@ -119,17 +119,32 @@
|
|||
// --- Apply paste --------------------------------------------------
|
||||
|
||||
function applyPaste(anchorRowIdx, anchorColIdx, grid) {
|
||||
// grid is string[][]. Returns {applied: int, skipped: int}.
|
||||
// grid is string[][]. Returns {applied: int, skipped: int, created: int}.
|
||||
// When the paste extends past the last existing row, the
|
||||
// add-row module creates new draft rows on the fly so an Excel
|
||||
// copy lands as a complete data set, not a clipped one. Each
|
||||
// new row will save on its own row-blur (POST to form-create).
|
||||
const ed = editor();
|
||||
const totalRows = visibleRowCount();
|
||||
const cols = (app.context && app.context.columns) || [];
|
||||
const totalCols = cols.length;
|
||||
let applied = 0, skipped = 0;
|
||||
const addRow = app.modules.addRow;
|
||||
let applied = 0, skipped = 0, created = 0;
|
||||
|
||||
for (let r = 0; r < grid.length; r++) {
|
||||
const dstR = anchorRowIdx + r;
|
||||
if (dstR >= totalRows) { skipped += grid[r].length; continue; }
|
||||
const row = rowDataAtIndex(dstR);
|
||||
let row = null;
|
||||
if (dstR < totalRows) {
|
||||
row = rowDataAtIndex(dstR);
|
||||
} else if (addRow && typeof addRow.createSilent === 'function') {
|
||||
addRow.createSilent();
|
||||
created++;
|
||||
// After createSilent the new row is at the end of
|
||||
// state.rows but the DOM hasn't repainted yet — pull
|
||||
// straight from state.rows to address it.
|
||||
const all = (app.state && app.state.rows) || [];
|
||||
row = all[all.length - 1];
|
||||
}
|
||||
if (!row) { skipped += grid[r].length; continue; }
|
||||
for (let c = 0; c < grid[r].length; c++) {
|
||||
const dstC = anchorColIdx + c;
|
||||
|
|
@ -141,7 +156,7 @@
|
|||
applied++;
|
||||
}
|
||||
}
|
||||
return { applied: applied, skipped: skipped };
|
||||
return { applied: applied, skipped: skipped, created: created };
|
||||
}
|
||||
|
||||
function visibleRowCount() {
|
||||
|
|
@ -208,11 +223,15 @@
|
|||
const result = applyPaste(r, c, grid);
|
||||
// Trigger a re-paint so draft values display.
|
||||
if (typeof app.repaint === 'function') app.repaint();
|
||||
let msg = 'Pasted ' + result.applied + ' cell' + plural(result.applied);
|
||||
if (result.created > 0) {
|
||||
msg += ' into ' + result.created + ' new row' + plural(result.created);
|
||||
}
|
||||
if (result.skipped > 0) {
|
||||
notifyToast(
|
||||
'Pasted ' + result.applied + ' cell' + plural(result.applied) +
|
||||
'; ' + result.skipped + ' dropped (out of bounds)'
|
||||
);
|
||||
msg += '; ' + result.skipped + ' dropped (out of bounds)';
|
||||
}
|
||||
if (result.created > 0 || result.skipped > 0) {
|
||||
notifyToast(msg);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -111,16 +111,31 @@
|
|||
description: spec.description,
|
||||
columns: spec.columns,
|
||||
defaults: spec.defaults,
|
||||
// addable defaults to true; tables can opt out with
|
||||
// `addable: false` (used by project-rollup MDL/RSK where the
|
||||
// party affiliation of a new row is ambiguous — add at the
|
||||
// per-party path instead).
|
||||
addable: spec.addable !== false,
|
||||
rowSchema: rowSchema,
|
||||
rows: rows
|
||||
};
|
||||
}
|
||||
|
||||
function tableNameFromUrl(pathname) {
|
||||
// /<dir>/.../<rowsdir>/table.html → name is the rows-dir's
|
||||
// basename.
|
||||
const m = String(pathname || '').match(/\/([^\/]+)\/table\.html$/);
|
||||
return m ? m[1] : null;
|
||||
// Two URL shapes resolve to a table page:
|
||||
// Form A — /<…>/<rowsdir>/table.html (legacy/explicit
|
||||
// entry-point; the tool was opened via the
|
||||
// literal file URL).
|
||||
// Form B — /<…>/<rowsdir> or /<…>/<rowsdir>/ (served
|
||||
// by the cascade's `default_tool: tables` at
|
||||
// archive/<party>/mdl; the URL is the directory
|
||||
// itself, no trailing filename).
|
||||
// In both cases the table name is the rows-directory basename.
|
||||
const a = String(pathname || '').match(/\/([^\/]+)\/table\.html$/);
|
||||
if (a) return a[1];
|
||||
const trimmed = String(pathname || '').replace(/\/$/, '');
|
||||
const b = trimmed.match(/\/([^\/]+)$/);
|
||||
return b ? b[1] : null;
|
||||
}
|
||||
|
||||
function stripDotSlash(p) {
|
||||
|
|
@ -198,11 +213,13 @@
|
|||
return rows;
|
||||
}
|
||||
|
||||
// Re-edit URL for one row. Page is at /<dir>/table.html; row file
|
||||
// lives at /<dir>/<basename>.yaml; form re-edit URL is
|
||||
// /<dir>/<basename>.yaml.html — same directory.
|
||||
// Re-edit URL for one row. The page directory is the same
|
||||
// directory the rows live in, regardless of which URL shape
|
||||
// (Form A `…/table.html` vs Form B bare `…/<rowsdir>`) we were
|
||||
// opened with — see tableNameFromUrl.
|
||||
function rowEditUrl(rowFileName) {
|
||||
const pageDir = location.pathname.replace(/\/table\.html$/, '/');
|
||||
let pageDir = location.pathname.replace(/\/table\.html$/, '/');
|
||||
if (!pageDir.endsWith('/')) pageDir += '/';
|
||||
return pageDir + rowFileName + '.html';
|
||||
}
|
||||
|
||||
|
|
|
|||
79
tables/js/export.js
Normal file
79
tables/js/export.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// export.js — CSV download of the current table view.
|
||||
//
|
||||
// Exports what the user sees: filter + sort applied, columns in the
|
||||
// order declared by the spec. Values pass through util.formatCell so
|
||||
// date / number / boolean cells match their on-screen rendering.
|
||||
// RFC 4180 quoting (double-quote any cell with a comma, newline, or
|
||||
// quote; escape inner quotes by doubling). UTF-8 BOM prepended so
|
||||
// Excel detects the encoding without a manual import-wizard step.
|
||||
|
||||
(function (app) {
|
||||
'use strict';
|
||||
|
||||
function csvEscape(value) {
|
||||
if (value == null) return '';
|
||||
const str = String(value);
|
||||
if (/[",\r\n]/.test(str)) {
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function buildCsv(rows, columns, util) {
|
||||
const lines = [];
|
||||
lines.push(columns.map(function (c) {
|
||||
return csvEscape(c.title || c.field || '');
|
||||
}).join(','));
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
const cells = columns.map(function (c) {
|
||||
const raw = util.resolveField(row.data, c.field);
|
||||
return csvEscape(util.formatCell(raw, c.format));
|
||||
});
|
||||
lines.push(cells.join(','));
|
||||
}
|
||||
return lines.join('\r\n') + '\r\n';
|
||||
}
|
||||
|
||||
function suggestFilename() {
|
||||
const titleEl = document.getElementById('table-title');
|
||||
const raw = (titleEl && titleEl.textContent) ? titleEl.textContent : 'table';
|
||||
const base = raw.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'table';
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
return base + '-' + stamp + '.csv';
|
||||
}
|
||||
|
||||
function download(csv, filename) {
|
||||
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
||||
}
|
||||
|
||||
function invoke() {
|
||||
const ctx = app.context || {};
|
||||
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
|
||||
if (columns.length === 0) {
|
||||
return;
|
||||
}
|
||||
const state = app.state;
|
||||
const util = app.modules.util;
|
||||
const filtered = app.modules.filters.apply(state.rows, columns, state.filter, util.resolveField);
|
||||
const sorted = app.modules.sort.apply(filtered, state.sort, columns, util);
|
||||
const csv = buildCsv(sorted, columns, util);
|
||||
download(csv, suggestFilename());
|
||||
}
|
||||
|
||||
app.modules.exportCsv = {
|
||||
invoke: invoke,
|
||||
buildCsv: buildCsv,
|
||||
csvEscape: csvEscape
|
||||
};
|
||||
})(window.tablesApp);
|
||||
|
|
@ -30,19 +30,56 @@
|
|||
const countEl = document.getElementById('table-rowcount');
|
||||
const clearBtn = document.getElementById('table-clear-filters');
|
||||
const addRowBtn = document.getElementById('table-add-row');
|
||||
const exportBtn = document.getElementById('table-export-csv');
|
||||
|
||||
// Add-row button: appends a draft row inline. Save fires on
|
||||
// row-blur, which POSTs to <dir>/form.html and swaps the
|
||||
// synthetic row id for the server's response. The button shows
|
||||
// whenever the page is a real table view (http(s) + a table
|
||||
// context loaded with columns) — the test-fixture inline-context
|
||||
// harness opens tables.html directly with no URL shape, so we
|
||||
// gate on having a column list AND running over http(s).
|
||||
// Export CSV: client-side build of the current view (filtered +
|
||||
// sorted columns + values). No server round-trip, no auth gate
|
||||
// — the user already has the data on screen. Shown on every
|
||||
// table that loaded with columns, regardless of HTTP/file://.
|
||||
if (exportBtn) {
|
||||
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
|
||||
if (hasCols) {
|
||||
exportBtn.hidden = false;
|
||||
exportBtn.addEventListener('click', function () {
|
||||
const exp = app.modules.exportCsv;
|
||||
if (exp && typeof exp.invoke === 'function') {
|
||||
exp.invoke();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add-row button: link to <name>.form.html, the form-system's
|
||||
// empty-form URL for this table's row schema. POST creates a
|
||||
// new submission and the server redirects to the row's edit
|
||||
// URL. Hidden when we can't derive a table name from the
|
||||
// pathname (e.g. inline-context test harness opening tables.html
|
||||
// directly without a *.table.html URL).
|
||||
if (addRowBtn) {
|
||||
// Page is at <dir>/table.html; the row-creation form is at
|
||||
// <dir>/form.html — same directory, just swap the basename.
|
||||
if (/\/table\.html$/.test(location.pathname || '')) {
|
||||
addRowBtn.href = 'form.html';
|
||||
const onHttp = location.protocol === 'http:' || location.protocol === 'https:';
|
||||
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
|
||||
// ctx.addable === false suppresses the affordance entirely.
|
||||
// Used by project-rollup tables where the row's party
|
||||
// affiliation is ambiguous (add at the per-party path).
|
||||
const allowAdd = ctx.addable !== false;
|
||||
if (onHttp && hasCols && allowAdd) {
|
||||
addRowBtn.hidden = false;
|
||||
addRowBtn.removeAttribute('href');
|
||||
addRowBtn.setAttribute('role', 'button');
|
||||
addRowBtn.setAttribute('tabindex', '0');
|
||||
addRowBtn.style.cursor = 'pointer';
|
||||
const handleAdd = function (ev) {
|
||||
ev.preventDefault();
|
||||
const addRow = app.modules.addRow;
|
||||
if (addRow && typeof addRow.invoke === 'function') {
|
||||
addRow.invoke();
|
||||
}
|
||||
};
|
||||
addRowBtn.addEventListener('click', handleAdd);
|
||||
addRowBtn.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') handleAdd(ev);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -106,6 +143,12 @@
|
|||
editor.setSelected(state.selected.row, state.selected.col, { noFocus: true });
|
||||
}
|
||||
}
|
||||
// Row context menu re-attaches each paint — renderBody wipes
|
||||
// the tbody, taking listeners with it.
|
||||
const rowOps = app.modules.rowOps;
|
||||
if (rowOps && typeof rowOps.attach === 'function') {
|
||||
rowOps.attach();
|
||||
}
|
||||
// Re-apply Phase-3 dirty-row markers — tbody.innerHTML='' in
|
||||
// renderBody wiped them.
|
||||
const save = app.modules.save;
|
||||
|
|
|
|||
201
tables/js/row-ops.js
Normal file
201
tables/js/row-ops.js
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
// row-ops.js — row-level operations (delete, future: duplicate,
|
||||
// copy-to-table, etc.). Surfaced via a right-click context menu on
|
||||
// table rows; the editor's selection state determines which row the
|
||||
// action targets when the menu is invoked from the keyboard or from a
|
||||
// future toolbar button.
|
||||
//
|
||||
// The shared context-menu primitive (window.zddc.menu) drives the
|
||||
// rendering and keyboard behaviour. This module owns the menu spec
|
||||
// and the action handlers.
|
||||
(function (app) {
|
||||
'use strict';
|
||||
|
||||
function findRowById(rowId) {
|
||||
const all = (app.state && app.state.rows) || [];
|
||||
for (let i = 0; i < all.length; i++) {
|
||||
const editor = app.modules.editor;
|
||||
const key = editor ? editor.rowKey(all[i]) : (all[i].url || '');
|
||||
if (key === rowId) return all[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function removeRowFromState(row) {
|
||||
const all = app.state.rows || [];
|
||||
const idx = all.indexOf(row);
|
||||
if (idx >= 0) all.splice(idx, 1);
|
||||
// Drop any drafts keyed on the row's url.
|
||||
if (app.state.drafts && row.url) {
|
||||
delete app.state.drafts[row.url];
|
||||
}
|
||||
}
|
||||
|
||||
function rowDisplayName(row) {
|
||||
if (!row) return '(unknown)';
|
||||
if (row.isNew) return '(unsaved new row)';
|
||||
if (row.yamlUrl) {
|
||||
const m = row.yamlUrl.match(/[^/]+$/);
|
||||
if (m) return m[0];
|
||||
}
|
||||
return row.url || '(row)';
|
||||
}
|
||||
|
||||
async function deleteRow(rowId) {
|
||||
const row = findRowById(rowId);
|
||||
if (!row) return { status: 'noop' };
|
||||
if (row.editable === false) return { status: 'readonly' };
|
||||
|
||||
// Unsaved new row: just drop it. Nothing to call.
|
||||
if (row.isNew) {
|
||||
removeRowFromState(row);
|
||||
if (typeof app.repaint === 'function') app.repaint();
|
||||
return { status: 'ok-local' };
|
||||
}
|
||||
|
||||
if (!row.yamlUrl) {
|
||||
// file:// or fixture context — nothing to delete server-side.
|
||||
removeRowFromState(row);
|
||||
if (typeof app.repaint === 'function') app.repaint();
|
||||
return { status: 'ok-local' };
|
||||
}
|
||||
|
||||
const ok = window.confirm('Delete row "' + rowDisplayName(row) + '"?\n\nThis cannot be undone.');
|
||||
if (!ok) return { status: 'cancelled' };
|
||||
|
||||
const headers = {};
|
||||
if (row.etag) headers['If-Match'] = '"' + row.etag + '"';
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(row.yamlUrl, {
|
||||
method: 'DELETE',
|
||||
headers: headers,
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
} catch (err) {
|
||||
window.alert('Delete failed: ' + (err && err.message ? err.message : err));
|
||||
return { status: 'network-error', error: err };
|
||||
}
|
||||
if (resp.status === 200 || resp.status === 204) {
|
||||
removeRowFromState(row);
|
||||
if (typeof app.repaint === 'function') app.repaint();
|
||||
return { status: 'ok' };
|
||||
}
|
||||
if (resp.status === 412) {
|
||||
window.alert('Cannot delete: this row was changed since you loaded it. Reload to see the latest version.');
|
||||
return { status: 'conflict' };
|
||||
}
|
||||
let body = '';
|
||||
try { body = await resp.text(); } catch (_) { /* ignore */ }
|
||||
window.alert('Delete failed (' + resp.status + '): ' + body);
|
||||
return { status: 'http-error', code: resp.status };
|
||||
}
|
||||
|
||||
// Returns the list of visible-row indices currently included in
|
||||
// the editor's range selection. Empty when no range is active.
|
||||
function rangeRowIndices() {
|
||||
const range = app.state && app.state.range;
|
||||
if (!range) return [];
|
||||
const r0 = Math.min(range.anchor.row, range.focus.row);
|
||||
const r1 = Math.max(range.anchor.row, range.focus.row);
|
||||
const out = [];
|
||||
for (let r = r0; r <= r1; r++) out.push(r);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Map a visible-row index to its data-row-id (synthetic or real).
|
||||
function rowIdAtIndex(idx) {
|
||||
const trs = document.querySelectorAll('#table-root tbody > tr');
|
||||
const tr = trs[idx];
|
||||
return tr ? tr.getAttribute('data-row-id') : null;
|
||||
}
|
||||
|
||||
async function deleteRows(rowIds) {
|
||||
if (!rowIds || rowIds.length === 0) return { status: 'noop' };
|
||||
if (rowIds.length === 1) return deleteRow(rowIds[0]);
|
||||
const ok = window.confirm('Delete ' + rowIds.length + ' rows?\n\nThis cannot be undone.');
|
||||
if (!ok) return { status: 'cancelled' };
|
||||
// Walk back-to-front so removing by index from state.rows
|
||||
// doesn't shift the indices of pending deletes.
|
||||
let okCount = 0, failCount = 0;
|
||||
for (let i = rowIds.length - 1; i >= 0; i--) {
|
||||
const row = findRowById(rowIds[i]);
|
||||
if (!row) continue;
|
||||
if (row.isNew || !row.yamlUrl) {
|
||||
removeRowFromState(row);
|
||||
okCount++;
|
||||
continue;
|
||||
}
|
||||
const headers = {};
|
||||
if (row.etag) headers['If-Match'] = '"' + row.etag + '"';
|
||||
try {
|
||||
const resp = await fetch(row.yamlUrl, {
|
||||
method: 'DELETE',
|
||||
headers: headers,
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (resp.status === 200 || resp.status === 204) {
|
||||
removeRowFromState(row);
|
||||
okCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
} catch (_err) {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
if (typeof app.repaint === 'function') app.repaint();
|
||||
if (failCount > 0) {
|
||||
window.alert('Deleted ' + okCount + ' row(s); ' + failCount + ' failed.');
|
||||
}
|
||||
return { status: 'ok', deleted: okCount, failed: failCount };
|
||||
}
|
||||
|
||||
function buildRowMenu(ctx) {
|
||||
const rangeRows = ctx.rangeRowIds || [];
|
||||
const inRange = rangeRows.length > 1 && rangeRows.indexOf(ctx.rowId) !== -1;
|
||||
const targets = inRange ? rangeRows : [ctx.rowId];
|
||||
const label = targets.length > 1 ? 'Delete ' + targets.length + ' rows' : 'Delete row';
|
||||
return [
|
||||
{
|
||||
label: label,
|
||||
icon: '🗑',
|
||||
danger: true,
|
||||
disabled: !ctx.row || ctx.row.editable === false,
|
||||
action: function () {
|
||||
if (targets.length > 1) deleteRows(targets);
|
||||
else deleteRow(targets[0]);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function onRowContext(ev) {
|
||||
const tr = ev.target.closest('tr[data-row-id]');
|
||||
if (!tr) return;
|
||||
const rowId = tr.getAttribute('data-row-id');
|
||||
const row = findRowById(rowId);
|
||||
if (!row) return;
|
||||
ev.preventDefault();
|
||||
const menu = window.zddc && window.zddc.menu;
|
||||
if (!menu || typeof menu.open !== 'function') return;
|
||||
const rangeRowIds = rangeRowIndices().map(rowIdAtIndex).filter(Boolean);
|
||||
menu.open({
|
||||
x: ev.clientX,
|
||||
y: ev.clientY,
|
||||
items: buildRowMenu({ row: row, rowId: rowId, rangeRowIds: rangeRowIds }),
|
||||
context: { row: row, rowId: rowId, rangeRowIds: rangeRowIds }
|
||||
});
|
||||
}
|
||||
|
||||
function attach() {
|
||||
const tbody = document.querySelector('#table-root tbody');
|
||||
if (!tbody) return;
|
||||
tbody.addEventListener('contextmenu', onRowContext);
|
||||
}
|
||||
|
||||
app.modules.rowOps = {
|
||||
attach: attach,
|
||||
deleteRow: deleteRow,
|
||||
deleteRows: deleteRows,
|
||||
};
|
||||
})(window.tablesApp);
|
||||
|
|
@ -177,8 +177,21 @@
|
|||
async function saveRow(rowId, opts) {
|
||||
opts = opts || {};
|
||||
const { row, drafts } = rowFromState(rowId);
|
||||
if (!row || !drafts || Object.keys(drafts).length === 0) {
|
||||
return { status: 'noop' };
|
||||
if (!row) return { status: 'noop' };
|
||||
const hasDrafts = drafts && Object.keys(drafts).length > 0;
|
||||
// New (unsaved) rows: if the user added a row and then moved on
|
||||
// without typing anything, drop the empty placeholder rather
|
||||
// than POST an empty body that fails schema validation.
|
||||
if (row.isNew && !hasDrafts) {
|
||||
const addRow = app.modules.addRow;
|
||||
if (addRow && typeof addRow.discardEmpty === 'function') {
|
||||
addRow.discardEmpty(rowId);
|
||||
}
|
||||
return { status: 'discarded-empty' };
|
||||
}
|
||||
if (!hasDrafts) return { status: 'noop' };
|
||||
if (row.isNew) {
|
||||
return createRow(rowId, row, drafts, opts);
|
||||
}
|
||||
if (!row.yamlUrl) {
|
||||
// file:// mode or rows from inline-context test fixtures
|
||||
|
|
@ -281,6 +294,84 @@
|
|||
return { status: 'http-error', code: resp.status };
|
||||
}
|
||||
|
||||
// createRow handles the POST path for an isNew row. Body is YAML of
|
||||
// the row's draft data (no row.data yet — it's a fresh row). Success
|
||||
// is 201 + Location pointing at the new <id>.yaml; we swap the
|
||||
// synthetic url/yamlUrl for the real ones and clear isNew so the
|
||||
// row behaves like any other from this point on.
|
||||
async function createRow(rowId, row, drafts, opts) {
|
||||
const addRow = app.modules.addRow;
|
||||
if (!addRow || typeof addRow.formCreateUrl !== 'function') {
|
||||
setRowState(rowId, 'errored');
|
||||
return { status: 'no-create-url' };
|
||||
}
|
||||
const createUrl = addRow.formCreateUrl();
|
||||
const merged = mergeRow(row.data, drafts);
|
||||
const yamlBody = window.jsyaml.dump(merged);
|
||||
|
||||
const headers = { 'Content-Type': 'application/yaml; charset=utf-8' };
|
||||
const fetchOpts = {
|
||||
method: 'POST',
|
||||
body: yamlBody,
|
||||
headers: headers,
|
||||
credentials: 'same-origin',
|
||||
};
|
||||
if (opts && opts.keepalive) fetchOpts.keepalive = true;
|
||||
|
||||
setRowState(rowId, 'saving');
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(createUrl, fetchOpts);
|
||||
} catch (err) {
|
||||
console.error('[tables] createRow network error', err);
|
||||
setRowState(rowId, 'errored');
|
||||
return { status: 'network-error', error: err };
|
||||
}
|
||||
|
||||
if (resp.status === 201) {
|
||||
// Server wrote the row. Body is {location, filename}; we
|
||||
// also accept the Location header if the body isn't JSON.
|
||||
let body = {};
|
||||
try { body = await resp.json(); } catch (_) { /* ignore */ }
|
||||
const location = body.location || resp.headers.get('Location') || '';
|
||||
const newEtag = (resp.headers.get('ETag') || '').replace(/"/g, '');
|
||||
row.yamlUrl = location;
|
||||
row.url = location ? location + '.html' : row.url;
|
||||
row.data = merged;
|
||||
row.etag = newEtag || null;
|
||||
row.isNew = false;
|
||||
// Move the drafts entry (was keyed on the synthetic id) to
|
||||
// the new url, then clear it (data has the merged values).
|
||||
delete app.state.drafts[rowId];
|
||||
clearCellInvalid(rowId);
|
||||
setRowState(rowId, '');
|
||||
const sb = document.getElementById('table-status');
|
||||
if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus();
|
||||
// Re-paint so the row picks up its new data-row-id and any
|
||||
// server-supplied default fields surface.
|
||||
if (typeof app.repaint === 'function') app.repaint();
|
||||
return { status: 'ok' };
|
||||
}
|
||||
|
||||
if (resp.status === 422) {
|
||||
let body = {};
|
||||
try { body = await resp.json(); } catch (_) { /* ignore */ }
|
||||
clearCellInvalid(rowId);
|
||||
const errs = body.errors || [];
|
||||
for (let i = 0; i < errs.length; i++) {
|
||||
const e = errs[i];
|
||||
const field = String(e.path || '').replace(/^\//, '').split('/')[0];
|
||||
if (field) markCellInvalid(rowId, field, e.message || 'invalid');
|
||||
}
|
||||
setRowState(rowId, 'invalid');
|
||||
return { status: 'invalid', errors: errs };
|
||||
}
|
||||
|
||||
console.warn('[tables] createRow returned', resp.status);
|
||||
setRowState(rowId, 'errored');
|
||||
return { status: 'http-error', code: resp.status };
|
||||
}
|
||||
|
||||
async function useMine(rowId) {
|
||||
const { row, drafts } = rowFromState(rowId);
|
||||
if (!row || !drafts) return;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- Elevation toggle slot. shared/elevation.js fills it
|
||||
when /.profile/access reports the user has admin
|
||||
authority; stays empty + hidden for non-admins so
|
||||
the chrome is quiet for the common case. -->
|
||||
<span id="elevation-toggle" class="elevation-toggle hidden"
|
||||
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
|
||||
</div>
|
||||
|
|
@ -41,6 +47,7 @@
|
|||
<button type="button" id="table-clear-filters" class="btn btn-secondary btn-sm" hidden>Clear filters</button>
|
||||
</div>
|
||||
<div class="table-toolbar__right">
|
||||
<button type="button" id="table-export-csv" class="btn btn-secondary btn-sm" hidden>Export CSV</button>
|
||||
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,91 +0,0 @@
|
|||
// Tests for shared/nav.js — the lateral project-stage strip.
|
||||
//
|
||||
// The strip's render decision depends on location.protocol and
|
||||
// location.pathname. file:// won't render at all (online-only). To
|
||||
// exercise online behavior we spin up a tiny in-process HTTP server
|
||||
// for this spec so the page can be served from http://127.0.0.1:<port>
|
||||
// at arbitrary paths.
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import * as http from 'http';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const HTML_PATH = path.resolve('classifier/dist/classifier.html');
|
||||
|
||||
let server;
|
||||
let baseUrl;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const html = fs.readFileSync(HTML_PATH, 'utf8');
|
||||
server = http.createServer((req, res) => {
|
||||
// Serve the same classifier HTML at every path. The strip's
|
||||
// detection logic uses location.pathname; the bytes don't have
|
||||
// to vary.
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(html);
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const port = server.address().port;
|
||||
baseUrl = `http://127.0.0.1:${port}`;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (server) await new Promise(resolve => server.close(resolve));
|
||||
});
|
||||
|
||||
test.describe('shared/nav.js stage strip', () => {
|
||||
|
||||
test('does NOT render at the deployment root', async ({ page }) => {
|
||||
await page.goto(`${baseUrl}/index.html`, { waitUntil: 'load' });
|
||||
await page.waitForSelector('.app-header', { timeout: 5000 });
|
||||
await expect(page.locator('.zddc-stage-strip')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('renders for <project>/archive.html with archive active', async ({ page }) => {
|
||||
await page.goto(`${baseUrl}/projA/archive.html`, { waitUntil: 'load' });
|
||||
const strip = page.locator('.zddc-stage-strip');
|
||||
await expect(strip).toHaveCount(1);
|
||||
await expect(strip.locator('.zddc-stage-strip__project')).toHaveText('projA');
|
||||
|
||||
const stages = await strip.locator('.zddc-stage').allTextContents();
|
||||
expect(stages).toEqual(['Archive', 'Working', 'Staging', 'Reviewing']);
|
||||
|
||||
const active = strip.locator('.zddc-stage--active');
|
||||
await expect(active).toHaveCount(1);
|
||||
await expect(active).toHaveText('Archive');
|
||||
await expect(active).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
test('renders for <project>/working/foo/browse.html with working active', async ({ page }) => {
|
||||
await page.goto(`${baseUrl}/projA/working/casey/browse.html`, { waitUntil: 'load' });
|
||||
const active = page.locator('.zddc-stage-strip .zddc-stage--active');
|
||||
await expect(active).toHaveText('Working');
|
||||
});
|
||||
|
||||
test('stage links point to the canonical <project>/<stage>/ URLs', async ({ page }) => {
|
||||
await page.goto(`${baseUrl}/projA/staging/`, { waitUntil: 'load' });
|
||||
await page.waitForSelector('.zddc-stage-strip');
|
||||
|
||||
const links = await page.evaluate(() => {
|
||||
const xs = document.querySelectorAll('.zddc-stage-strip .zddc-stage');
|
||||
return Array.from(xs).map(a => ({ text: a.textContent, href: a.getAttribute('href') }));
|
||||
});
|
||||
expect(links).toEqual([
|
||||
{ text: 'Archive', href: '/projA/archive' },
|
||||
{ text: 'Working', href: '/projA/working' },
|
||||
{ text: 'Staging', href: '/projA/staging' },
|
||||
{ text: 'Reviewing', href: '/projA/reviewing' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('mounts immediately above the app-header', async ({ page }) => {
|
||||
await page.goto(`${baseUrl}/projA/archive.html`, { waitUntil: 'load' });
|
||||
const prev = await page.evaluate(() => {
|
||||
const h = document.querySelector('.app-header');
|
||||
return h && h.previousElementSibling && h.previousElementSibling.className;
|
||||
});
|
||||
expect(prev).toContain('zddc-stage-strip');
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -25,7 +25,7 @@ concat_files \
|
|||
"../shared/fonts.css" \
|
||||
"../shared/base.css" \
|
||||
"../shared/toast.css" \
|
||||
"../shared/nav.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/logo.css" \
|
||||
"css/base.css" \
|
||||
"css/layout.css" \
|
||||
|
|
@ -54,7 +54,6 @@ concat_files \
|
|||
"../shared/zddc-source.js" \
|
||||
"../shared/theme.js" \
|
||||
"../shared/toast.js" \
|
||||
"../shared/nav.js" \
|
||||
"../shared/logo.js" \
|
||||
"../shared/preview-lib.js" \
|
||||
"js/app.js" \
|
||||
|
|
@ -87,6 +86,7 @@ concat_files \
|
|||
"js/drop-zones.js" \
|
||||
"js/focus.js" \
|
||||
"../shared/help.js" \
|
||||
"../shared/elevation.js" \
|
||||
"js/main.js" \
|
||||
> "$js_raw"
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ conventions at https://codeberg.org/VARASYS/ZDDC#file-naming-convention.
|
|||
</div>
|
||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||
<!-- Publish split-button (Transmittal-specific primary action;
|
||||
other tools have "Add Local Directory" here instead) -->
|
||||
other tools have "Use Local Directory" here instead) -->
|
||||
<div class="split-button" id="bottom-menu" hidden>
|
||||
<button id="bottom-toggle" type="button" class="btn btn-primary split-button__toggle" data-no-disable="true" aria-haspopup="true" aria-expanded="false">▾</button>
|
||||
<button id="bottom-primary" type="button" class="btn btn-primary" data-no-disable="true">Publish</button>
|
||||
|
|
@ -51,6 +51,12 @@ conventions at https://codeberg.org/VARASYS/ZDDC#file-naming-convention.
|
|||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- Elevation toggle slot. shared/elevation.js fills it
|
||||
when /.profile/access reports the user has admin
|
||||
authority; stays empty + hidden for non-admins so
|
||||
the chrome is quiet for the common case. -->
|
||||
<span id="elevation-toggle" class="elevation-toggle hidden"
|
||||
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
|
||||
<button type="button" id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
<button type="button" id="help-btn" class="btn btn-secondary" aria-label="Help" title="Help">?</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -425,29 +425,17 @@ for a level whose `acl.permissions` map matches the user.
|
|||
The walk respects an **inherit fence** (see "The `inherit:` directive" below).
|
||||
A level whose `acl.inherit: false` flag is set acts as a fence: ancestors above
|
||||
it are invisible to descendants at-and-below the fence, both for grants and for
|
||||
role lookups. In strict cascade mode the fence is ignored (NIST AC-6 invariant).
|
||||
role lookups.
|
||||
|
||||
Implementation: `GrantedVerbsAtLevel` (`zddc/internal/zddc/acl.go`) computes the
|
||||
per-level grant; `EffectiveVerbs` / `AllowedAction` walk the chain; the chain
|
||||
itself is built by `EffectivePolicy` (`zddc/internal/zddc/cascade.go`); the
|
||||
fence is computed by `PolicyChain.VisibleStart`.
|
||||
|
||||
#### Cascade mode
|
||||
|
||||
The leaf-overrides-ancestor behavior above is the default — it's the historical
|
||||
commercial-tenant model where a subtree owner can grant access without
|
||||
root-admin involvement. Federal deployments needing absolute parent denies
|
||||
(NIST AC-6) start the server with `--cascade-mode=strict` (or
|
||||
`ZDDC_CASCADE_MODE=strict`):
|
||||
|
||||
- **`delegated`** (default) — leaf grant overrides ancestor explicit-deny.
|
||||
- **`strict`** — two-pass evaluation. First pass walks **root → leaf** for any
|
||||
matching explicit-deny; if found, denied (subject to root-admin bypass).
|
||||
Second pass is the leaf→root grant walk above. An ancestor explicit-deny
|
||||
cannot be overridden by any leaf grant.
|
||||
|
||||
The mode is logged at startup and surfaced on `/.profile/config`. Subtree
|
||||
`.zddc` files cannot change the mode — it's a deployment-wide policy.
|
||||
The leaf-overrides-ancestor behaviour above is the in-process decider's only
|
||||
rule. Federal deployments needing absolute parent denies (NIST AC-6) deploy
|
||||
OPA with the bundled `access_federal.rego` (or their own Rego); see
|
||||
"External OPA" below.
|
||||
|
||||
#### The `inherit:` directive
|
||||
|
||||
|
|
@ -484,14 +472,13 @@ Behaviour:
|
|||
fence; `inherit: false` does not change WORM behaviour. See
|
||||
"Canonical-folder behaviour via `.zddc` keys" below.
|
||||
|
||||
**Strict cascade mode IGNORES `inherit: false`.** NIST AC-6 requires
|
||||
ancestor explicit-denies to be absolute, and the inherit directive
|
||||
would let a leaf widen access an ancestor refused. Under
|
||||
`--cascade-mode=strict` the directive has no effect (and the bundled
|
||||
federal Rego at `--print-rego=federal` mirrors that rule). Operators
|
||||
who need fence-style "reset" semantics in a federal-track deployment
|
||||
should not use the directive — instead, restructure the tree so the
|
||||
permissive ancestor rule never appears.
|
||||
**Federal posture and `inherit: false`.** The bundled federal Rego at
|
||||
`--print-rego=federal` makes ancestor explicit-denies absolute and
|
||||
therefore ignores `inherit: false` (allowing a leaf to widen access an
|
||||
ancestor refused would defeat NIST AC-6). Operators who need fence-
|
||||
style "reset" semantics in a federal-track deployment should not use
|
||||
the directive — instead, restructure the tree so the permissive
|
||||
ancestor rule never appears.
|
||||
|
||||
The cascade tracer (`/.profile/effective-policy`) surfaces every
|
||||
level's `inherit` flag and the `chain.visible_start` index so a
|
||||
|
|
@ -939,13 +926,12 @@ have to redo the gap analysis from scratch.
|
|||
(the upstream proxy still asserts the email; role membership is
|
||||
evaluated server-side against the cascade).
|
||||
- ~~**Least-privilege bounding** (NIST AC-6)~~ — *closed.* Operators
|
||||
set `--cascade-mode=strict` (or `ZDDC_CASCADE_MODE=strict`) to
|
||||
switch the in-process Go evaluator into the federal posture: any
|
||||
ancestor explicit-deny is absolute and cannot be overridden by a
|
||||
leaf grant. The mode is logged at startup and surfaced on
|
||||
`/.profile/config`. The legacy commercial behavior is preserved as
|
||||
the default `delegated` mode. External OPA (`ZDDC_OPA_URL`) remains
|
||||
available for org-specific Rego on top of this.
|
||||
deploy OPA (`ZDDC_OPA_URL`) pointed at the bundled federal Rego
|
||||
(`zddc-server --print-rego=federal`) or their own variant. Under
|
||||
that policy any ancestor explicit-deny is absolute and cannot be
|
||||
overridden by a leaf grant. The in-process Go evaluator implements
|
||||
only the commercial "leaf grants override ancestor denies" rule;
|
||||
federal posture is exclusively the OPA path.
|
||||
- **Account lifecycle** (NIST AC-2) — emails as identifiers must tie to
|
||||
authoritative sources (PIV cert subject, IdP-managed identity). Required:
|
||||
documented integration with at least one IdP supporting federal identity
|
||||
|
|
|
|||
|
|
@ -87,24 +87,24 @@ func main() {
|
|||
"addr", cfg.Addr,
|
||||
"embedded_apps", embeddedVersionsForLog(embedded))
|
||||
|
||||
// Probe the container runtime for the MD→{docx,html,pdf} endpoint.
|
||||
// Non-fatal: if the host has no podman/docker (or the remote
|
||||
// socket is unreachable in sidecar mode), conversion requests
|
||||
// return 503 and everything else keeps working. The probe installs
|
||||
// the package-level Runner when an engine is found; the configured
|
||||
// image refs are pulled lazily on first conversion via
|
||||
// `--pull=missing` so there's no manual setup beyond installing
|
||||
// podman or docker.
|
||||
// Probe pandoc + chromium for the MD→{docx,html,pdf} endpoint.
|
||||
// Non-fatal: if either binary isn't on PATH (operator running
|
||||
// zddc-server outside the runtime image), conversion requests
|
||||
// return 503 and everything else keeps working.
|
||||
//
|
||||
// SetRemoteURL + SetScratchDir must run BEFORE Probe so the probe
|
||||
// can hit the sidecar socket when one is configured.
|
||||
convert.SetImages(cfg.ConvertPandocImage, cfg.ConvertChromiumImage)
|
||||
convert.SetRemoteURL(cfg.ConvertPodmanSocket)
|
||||
// In the production runtime image, "pandoc" and "chromium-browser"
|
||||
// on PATH resolve to wrapper scripts at /usr/local/bin/<name>
|
||||
// that put the real binary into a cgroup v2 + bwrap sandbox
|
||||
// before exec'ing it. zddc-server is unaware — it just sees
|
||||
// the corresponding tool's behavior. The wrapper reads
|
||||
// ZDDC_CONV_MEM_MAX, ZDDC_CONV_PIDS_MAX, and ZDDC_SCRATCH from
|
||||
// the child env to drive cgroup setup + scratch-dir bind mount.
|
||||
convert.SetBinaries(cfg.ConvertPandocBinary, cfg.ConvertChromiumBinary)
|
||||
convert.SetScratchDir(cfg.ConvertScratchDir)
|
||||
probeCtx, probeCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
convert.Probe(probeCtx, cfg.ConvertEngine)
|
||||
convert.Probe(probeCtx)
|
||||
probeCancel()
|
||||
convert.ConfigureLimits(cfg.ConvertMemMiB, cfg.ConvertCPUs, cfg.ConvertPIDs, cfg.ConvertTimeout)
|
||||
convert.ConfigureLimits(cfg.ConvertMemMiB, cfg.ConvertPIDs, cfg.ConvertTimeout)
|
||||
|
||||
// Client mode short-circuit: when cfg.Upstream is set, this binary
|
||||
// runs as a downstream proxy/cache/mirror rather than a master.
|
||||
|
|
@ -191,10 +191,9 @@ func main() {
|
|||
// http(s):// or unix:// values send each decision to an external
|
||||
// OPA-compatible server (federal customers, custom Rego policies).
|
||||
deciderCfg := policy.Config{
|
||||
URL: cfg.OPAURL,
|
||||
FailOpen: cfg.OPAFailOpen,
|
||||
CacheTTL: cfg.OPACacheTTL,
|
||||
CascadeMode: cfg.CascadeMode,
|
||||
URL: cfg.OPAURL,
|
||||
FailOpen: cfg.OPAFailOpen,
|
||||
CacheTTL: cfg.OPACacheTTL,
|
||||
}
|
||||
// Translate "0" (operator opt-out) to "disable cache" (negative TTL is
|
||||
// the policy package's sentinel for "skip the wrapper").
|
||||
|
|
@ -217,7 +216,6 @@ func main() {
|
|||
"mode", policyModeLabel(cfg.OPAURL),
|
||||
"url", cfg.OPAURL,
|
||||
"cache_ttl", cfg.OPACacheTTL,
|
||||
"cascade_mode", cfg.CascadeMode,
|
||||
"no_auth", cfg.NoAuth)
|
||||
|
||||
// Token store: bearer-token issuance and validation.
|
||||
|
|
@ -243,7 +241,7 @@ func main() {
|
|||
if useTLS {
|
||||
inner = handler.HSTSMiddleware(inner)
|
||||
}
|
||||
inner = handler.AccessLogMiddleware(auditLogger, inner)
|
||||
inner = handler.AccessLogMiddleware(cfg, auditLogger, inner)
|
||||
inner = handler.ACLMiddleware(cfg, decider, tokens, inner)
|
||||
mux.Handle("/", inner)
|
||||
|
||||
|
|
@ -369,7 +367,7 @@ func runClient(cfg config.Config) {
|
|||
if useTLS {
|
||||
inner = handler.HSTSMiddleware(inner)
|
||||
}
|
||||
inner = handler.AccessLogMiddleware(auditLogger, inner)
|
||||
inner = handler.AccessLogMiddleware(cfg, auditLogger, inner)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/", inner)
|
||||
|
|
@ -620,7 +618,7 @@ func serveSpecializedNoSlash(cfg config.Config, appsSrv *apps.Server, w http.Res
|
|||
return false
|
||||
}
|
||||
chain, _ := zddc.EffectivePolicy(cfg.Root, dirAbs)
|
||||
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
|
||||
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return true
|
||||
}
|
||||
|
|
@ -754,35 +752,24 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
return
|
||||
}
|
||||
|
||||
// Project list API: GET / with Accept: application/json
|
||||
if urlPath == "/" {
|
||||
accept := r.Header.Get("Accept")
|
||||
if strings.Contains(accept, "application/json") {
|
||||
handler.ServeProjectList(cfg, w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
// (Project list at GET / with Accept: application/json used to be
|
||||
// served by a bespoke handler that returned a custom JSON shape.
|
||||
// Removed in favour of routing /through the generic ServeDirectory:
|
||||
// the directory listing now carries `title` per entry, so the
|
||||
// landing page reads project names from the same shape every other
|
||||
// listing has. Single canonical wire format > exception that
|
||||
// reveals a special perspective.)
|
||||
|
||||
// Split path into segments
|
||||
segments := strings.Split(strings.Trim(urlPath, "/"), "/")
|
||||
|
||||
// Per-directory .zddc editor: <dir>/.zddc.html is a virtual URL
|
||||
// served by the existing form-based editor (same handler that
|
||||
// powers /.profile/zddc/edit?path=<dir>). Routed BEFORE the
|
||||
// dot-prefix guard so the leaf segment isn't 404'd. The handler
|
||||
// itself gates on hasAnyAdminScope; non-admins see 404.
|
||||
if handler.IsZddcEditorRequest(urlPath) {
|
||||
handler.ServeZddcEditorAtPath(cfg, 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
|
||||
// cascade summary so the user can see what's effective here.
|
||||
// GET/HEAD only; writes go through the admin-gated .zddc.html
|
||||
// form. Also carved out of the dot-prefix guard.
|
||||
if handler.IsZddcFileRequest(urlPath) {
|
||||
// cascade summary so the user can see what's effective here. The
|
||||
// leaf is carved out of the dot-prefix guard below so GET/HEAD
|
||||
// land here and PUT/DELETE/POST fall through to ServeFileAPI.
|
||||
if handler.IsZddcFileRequest(urlPath) && (r.Method == http.MethodGet || r.Method == http.MethodHead) {
|
||||
handler.ServeZddcFile(cfg, w, r)
|
||||
return
|
||||
}
|
||||
|
|
@ -809,7 +796,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
// the ?hidden flag does NOT relax).
|
||||
hiddenOK := r.URL.Query().Has("hidden") &&
|
||||
(r.Method == http.MethodGet || r.Method == http.MethodHead)
|
||||
for _, seg := range segments {
|
||||
for i, seg := range segments {
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
|
|
@ -823,6 +810,13 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
if seg == cfg.IndexPath {
|
||||
continue
|
||||
}
|
||||
// `.zddc` is the only writable dot-prefixed file: GET/HEAD was
|
||||
// handled by ServeZddcFile above; PUT/DELETE/POST fall through
|
||||
// to ServeFileAPI. Only the LEAF segment carves through —
|
||||
// `.zddc.d` and other intermediate dot dirs stay reserved.
|
||||
if seg == handler.ZddcFileBasename && i == len(segments)-1 {
|
||||
continue
|
||||
}
|
||||
if hiddenOK {
|
||||
continue
|
||||
}
|
||||
|
|
@ -936,7 +930,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
if err != nil {
|
||||
slog.Warn("ACL policy error on zip parent", "path", filepath.Dir(zipAbs), "err", err)
|
||||
}
|
||||
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
|
||||
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
|
@ -960,12 +954,25 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
// other four apps are caught by the "stat fails → app HTML?" branch
|
||||
// below, which only triggers when no concrete file is at the URL path.
|
||||
//
|
||||
// Gated by Accept: HTML requests get the landing tool, JSON requests
|
||||
// fall through to ServeDirectory and get the generic listing (with
|
||||
// per-entry titles via listing.FileInfo.Title). That keeps the wire
|
||||
// protocol uniform — a JSON listing is a JSON listing whether you
|
||||
// fetch /Project-1/ or /. Landing itself consumes the same shape.
|
||||
//
|
||||
// The landing page is intentionally public (no ACL gate). It's a
|
||||
// project picker — the per-project ACL filtering done by
|
||||
// fs.ListDirectory still hides projects an anonymous (or unauthorized)
|
||||
// caller can't reach. See also handler.ServeDirectory's matching
|
||||
// root-path bypass.
|
||||
if appsSrv != nil && (urlPath == "/" || urlPath == "/index.html") {
|
||||
//
|
||||
// (Browsers normalize `https://host` → `https://host/`, so the
|
||||
// no-slash vs slash distinction the user might want — picker on
|
||||
// bare host, browse on trailing slash — can't be expressed: the
|
||||
// HTTP request for both forms is `GET /`. The picker wins because
|
||||
// it's the only meaningful entry point that scopes ACL per-project.)
|
||||
if appsSrv != nil && (urlPath == "/" || urlPath == "/index.html") &&
|
||||
!strings.Contains(r.Header.Get("Accept"), "application/json") {
|
||||
realIndex := filepath.Join(cfg.Root, "index.html")
|
||||
if _, err := os.Stat(realIndex); os.IsNotExist(err) {
|
||||
chain, _ := zddc.EffectivePolicy(cfg.Root, cfg.Root)
|
||||
|
|
@ -990,21 +997,28 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Default MDL spec fallback: archive/<party>/mdl.table.yaml
|
||||
// and archive/<party>/mdl.form.yaml are served from embedded
|
||||
// bytes when no operator file exists on disk. The table app
|
||||
// fetches these client-side; the fallback lets a fresh
|
||||
// project work out of the box.
|
||||
// Default-spec fallback for the embedded table.yaml / form.yaml
|
||||
// files served when no operator file exists on disk:
|
||||
//
|
||||
// <project>/archive/<party>/{mdl,rsk}/{table,form}.yaml
|
||||
// <project>/archive/<party>/ssr.form.yaml
|
||||
// <project>/{ssr,mdl,rsk}/{table,form}.yaml
|
||||
//
|
||||
// The table app fetches these client-side; the fallback lets
|
||||
// a fresh project work out of the box. ACL gates against the
|
||||
// chain at the request directory; for project-level virtual
|
||||
// specs that chain is the project's, and for per-party paths
|
||||
// it's the party's archive folder.
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
if bytes, ok := handler.IsDefaultMdlSpec(cfg.Root, urlPath); ok {
|
||||
if bytes, ok := handler.IsDefaultSpec(cfg.Root, urlPath); ok {
|
||||
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath))
|
||||
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
|
||||
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-ZDDC-Source", "default-mdl-spec")
|
||||
w.Header().Set("X-ZDDC-Source", "default-spec")
|
||||
if r.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
|
|
@ -1012,18 +1026,66 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
return
|
||||
}
|
||||
}
|
||||
// File doesn't exist at this path. If the URL matches one of
|
||||
// the canonical app HTML names AND the request directory is
|
||||
// one where that app is available (working/staging/incoming
|
||||
// for classifier, staging for transmittal, anywhere for
|
||||
// archive + browse, root only for landing), resolve via the
|
||||
// apps subsystem.
|
||||
// Virtual project-level table views (SSR / MDL rollup / RSK
|
||||
// rollup). The virtual row URL doesn't exist on disk; the
|
||||
// underlying canonical file lives in <project>/archive/<party>/.
|
||||
// ACL evaluates against the canonical party-archive path so
|
||||
// non-owners see the row read-only and party owners can edit.
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
if vv := zddc.ResolveVirtualView(cfg.Root, urlPath); vv.Resolved && vv.Kind.IsRowKind() {
|
||||
chain, _ := zddc.EffectivePolicy(cfg.Root, vv.PartyArchive)
|
||||
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
handler.ServeVirtualViewRow(w, r, vv)
|
||||
return
|
||||
}
|
||||
}
|
||||
// File doesn't exist at this path. Before falling through to
|
||||
// app-HTML routing or 404, check the two virtual-file-extension
|
||||
// shapes that ZDDC exposes through the listing convention:
|
||||
//
|
||||
// <dir>.zip — subtree download (replaces `<dir>/?zip=1`)
|
||||
// <file>.docx|html|pdf — MD-source conversion of sibling <file>.md
|
||||
// (replaces `<file>.md?convert=<fmt>`)
|
||||
//
|
||||
// Both fire ONLY when stat failed at the requested URL — a
|
||||
// real file always wins. The path-suffix form lets clients
|
||||
// emit a plain <a href> + lets `curl -O` produce the right
|
||||
// filename, no query-string handling required.
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
if absDir, ok := handler.RecognizeVirtualSubtreeZip(cfg.Root, urlPath); ok {
|
||||
chain, _ := zddc.EffectivePolicy(cfg.Root, absDir)
|
||||
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
handler.ServeSubtreeZip(cfg, w, r, absDir)
|
||||
return
|
||||
}
|
||||
if mdAbs, format, ok := handler.RecognizeVirtualConvert(cfg.Root, urlPath); ok {
|
||||
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(mdAbs))
|
||||
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
handler.ServeConverted(cfg, w, r, mdAbs, format, chain)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If the URL matches one of the canonical app HTML names AND
|
||||
// the request directory is one where that app is available
|
||||
// (working/staging/incoming for classifier, staging for
|
||||
// transmittal, anywhere for archive + browse, root only for
|
||||
// landing), resolve via the apps subsystem.
|
||||
if appsSrv != nil {
|
||||
if app, requestDirRel := apps.MatchAppHTML(urlPath); app != "" {
|
||||
requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel))
|
||||
if apps.AppAvailableAt(cfg.Root, requestDir, app) {
|
||||
chain, _ := zddc.EffectivePolicy(cfg.Root, requestDir)
|
||||
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
|
||||
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
|
@ -1032,37 +1094,34 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
}
|
||||
}
|
||||
}
|
||||
// Reviewing aggregator. <project>/reviewing/[<tracking>/] is
|
||||
// a virtual view. The shape rule mirrors the other canonical
|
||||
// folders (slash → browse, no-slash → default tool):
|
||||
// - JSON request, any depth → aggregator listing (handler.ServeReviewing)
|
||||
// - HTML, no slash → browse (default tool, via DefaultAppAt;
|
||||
// browse hosts the markdown editor plugin)
|
||||
// - HTML, with slash → browse.html (via ServeDirectory).
|
||||
// browse fetches JSON which routes back
|
||||
// through here to ServeReviewing.
|
||||
// Depth-3 no-slash (reviewing/<tracking>) 302s to the slash form.
|
||||
// Depth-2 no-slash (reviewing) falls through to the canonical-
|
||||
// folder block below where DefaultAppAt routes to browse.
|
||||
// reviewing/ is no longer a virtual aggregator — it's a normal
|
||||
// directory under each project, populated by the Plan Review
|
||||
// composite endpoint with physical workflow folders. Falls
|
||||
// through to the canonical-folder block below.
|
||||
//
|
||||
// Virtual received/ window. <workflow>/received/[...] is a
|
||||
// synthetic view onto the canonical received/<tracking>/
|
||||
// declared by the workflow folder's .zddc.received_path.
|
||||
// ResolveVirtualReceived validates the parent .zddc; on a
|
||||
// match, route through the normal directory/file handlers,
|
||||
// which swap the read source to the canonical based on the
|
||||
// URL (ListDirectory and ServeFile via the absolute path).
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
if proj, tracking, sidePath, ok := handler.IsReviewingPath(urlPath); ok {
|
||||
if !strings.HasSuffix(urlPath, "/") {
|
||||
if tracking != "" {
|
||||
http.Redirect(w, r, urlPath+"/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
// Depth-2 no-slash falls through to canonical-folder block.
|
||||
} else if strings.Contains(r.Header.Get("Accept"), "application/json") {
|
||||
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Join(cfg.Root, proj))
|
||||
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
handler.ServeReviewing(cfg, w, r, proj, tracking, sidePath)
|
||||
if vr := zddc.ResolveVirtualReceived(cfg.Root, urlPath); vr.Resolved {
|
||||
if strings.HasSuffix(urlPath, "/") {
|
||||
handler.ServeDirectory(cfg, appsSrv, w, r)
|
||||
return
|
||||
}
|
||||
// HTML trailing-slash falls through to canonical-folder
|
||||
// block → ServeDirectory → embedded browse.html.
|
||||
// File read — ACL-check against the canonical
|
||||
// received's chain, then serve the canonical bytes
|
||||
// while keeping the workflow URL in the address bar.
|
||||
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(vr.ReceivedAbs))
|
||||
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
handler.ServeFile(w, r, vr.ReceivedAbs)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Cascade-declared paths: the .zddc cascade (embedded
|
||||
|
|
@ -1083,12 +1142,9 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
if (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
|
||||
(strings.HasSuffix(urlPath, "/") || filepath.Ext(urlPath) == "") &&
|
||||
zddc.IsDeclaredPath(cfg.Root, absPath) {
|
||||
if r.URL.Query().Has("zip") {
|
||||
// Subtree download of a cascade-declared dir that
|
||||
// doesn't exist on disk yet → an empty zip.
|
||||
handler.ServeSubtreeZip(cfg, w, r, absPath)
|
||||
return
|
||||
}
|
||||
// (Empty-subtree zip for cascade-declared paths is now
|
||||
// handled by RecognizeVirtualSubtreeZip at the top of
|
||||
// this branch — same handler, path-suffix grammar.)
|
||||
if strings.HasSuffix(urlPath, "/") {
|
||||
handler.ServeDirectory(cfg, appsSrv, w, r)
|
||||
return
|
||||
|
|
@ -1115,20 +1171,17 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
isRoot := urlPath == "/"
|
||||
if !isRoot {
|
||||
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
|
||||
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
|
||||
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Subtree download: GET /dir/?zip=1 streams an application/zip of
|
||||
// every readable file under this directory, ACL-filtered. Checked
|
||||
// before the slash/no-slash routing so it works on both /dir and
|
||||
// /dir/. Writes (PUT/DELETE/POST) never reach here — they're
|
||||
// intercepted by the file API earlier — so this is GET/HEAD only.
|
||||
if r.URL.Query().Has("zip") {
|
||||
handler.ServeSubtreeZip(cfg, w, r, absPath)
|
||||
return
|
||||
}
|
||||
// (Subtree downloads use the virtual `GET /dir.zip` URL —
|
||||
// see RecognizeVirtualSubtreeZip handling at the top of the
|
||||
// stat-fails branch above. Real directories stat-succeed
|
||||
// here, so the virtual zip URL stat-fails at /dir.zip and
|
||||
// matches there.)
|
||||
|
||||
// Slash/no-slash routing convention: trailing slash → the
|
||||
// directory view (handler.ServeDirectory → DirTool, which
|
||||
// resolves to browse by default; JSON requests always get the
|
||||
|
|
@ -1170,20 +1223,15 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
|
||||
// Regular file: ACL on parent directory
|
||||
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath))
|
||||
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
|
||||
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// MD→{docx,html,pdf} on-demand conversion. The endpoint reuses the
|
||||
// source file's read policy (already gated above), so no separate
|
||||
// ACL verb. Only .md sources are convertible; everything else falls
|
||||
// through to the regular file serve.
|
||||
if fmt := r.URL.Query().Get("convert"); fmt != "" &&
|
||||
strings.HasSuffix(strings.ToLower(absPath), ".md") {
|
||||
handler.ServeConverted(cfg, w, r, absPath, fmt, chain)
|
||||
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
|
||||
// normally here.)
|
||||
|
||||
handler.ServeFile(w, r, absPath)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ func TestDispatchAppsResolution(t *testing.T) {
|
|||
// fake upstream. Allow all email patterns (anonymous) so the test
|
||||
// doesn't have to set up email headers.
|
||||
zf := zddc.ZddcFile{
|
||||
ACL: zddc.ACLRules{Allow: []string{"*"}},
|
||||
ACL: zddc.ACLRules{Permissions: map[string]string{"*": "rwcd"}},
|
||||
Apps: map[string]string{
|
||||
"archive": upstream.URL + "/archive_stable.html",
|
||||
"transmittal": upstream.URL + "/transmittal_stable.html",
|
||||
|
|
@ -224,7 +224,7 @@ var _ = apps.DefaultUpstream
|
|||
func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||
"acl:\n allow:\n - \"*@example.com\"\n deny: []\n")
|
||||
"acl:\n permissions:\n \"*@example.com\": rwcd\n")
|
||||
mustMkdir(t, filepath.Join(root, "Project-A", "Working"))
|
||||
|
||||
idx, err := archive.BuildIndex(root)
|
||||
|
|
@ -289,6 +289,99 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestDispatchZddcWriteRouting pins the dispatcher's .zddc routing:
|
||||
// GET/HEAD lands on ServeZddcFile (which serves the YAML view or the
|
||||
// virtual placeholder), and PUT/DELETE/POST falls through past the
|
||||
// dot-prefix guard into ServeFileAPI. Before the .zddc-leaf carve-out,
|
||||
// PUT/DELETE 405'd at ServeZddcFile (or 404'd at the dot-prefix guard)
|
||||
// and the YAML editor's save flow had no live path.
|
||||
func TestDispatchZddcWriteRouting(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||
"admins:\n - admin@example.com\nacl:\n permissions:\n \"*@example.com\": r\n")
|
||||
mustMkdir(t, filepath.Join(root, "Project-A"))
|
||||
|
||||
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",
|
||||
MaxWriteBytes: 1 << 20,
|
||||
}
|
||||
ring := handler.NewLogRing(10)
|
||||
|
||||
withAuth := func(req *http.Request, email string, elevated bool) *http.Request {
|
||||
ctx := handler.WithEmail(req.Context(), email)
|
||||
ctx = handler.WithElevation(ctx, elevated)
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
// GET routes to ServeZddcFile — serves YAML bytes for an authorised reader.
|
||||
req := withAuth(httptest.NewRequest(http.MethodGet, "/.zddc", nil), "admin@example.com", true)
|
||||
rec := httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /.zddc: want 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/yaml") {
|
||||
t.Errorf("GET /.zddc Content-Type = %q, want application/yaml*", ct)
|
||||
}
|
||||
|
||||
// PUT must route to ServeFileAPI (not 405 from ServeZddcFile).
|
||||
body := []byte("admins:\n - admin@example.com\n - extra@example.com\n")
|
||||
req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc", bytes.NewReader(body)), "admin@example.com", true)
|
||||
rec = httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
|
||||
t.Fatalf("PUT /.zddc: want 200/201, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Read back via GET to confirm the write landed.
|
||||
req = withAuth(httptest.NewRequest(http.MethodGet, "/.zddc", nil), "admin@example.com", true)
|
||||
rec = httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||
if !strings.Contains(rec.Body.String(), "extra@example.com") {
|
||||
t.Errorf("GET after PUT: body missing PUT bytes; got %q", rec.Body.String())
|
||||
}
|
||||
|
||||
// Project-level .zddc that doesn't exist yet — PUT creates it.
|
||||
req = withAuth(httptest.NewRequest(http.MethodPut, "/Project-A/.zddc", bytes.NewReader([]byte("title: A\n"))), "admin@example.com", true)
|
||||
rec = httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("PUT /Project-A/.zddc: want 201, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// DELETE removes a .zddc.
|
||||
req = withAuth(httptest.NewRequest(http.MethodDelete, "/Project-A/.zddc", nil), "admin@example.com", true)
|
||||
rec = httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("DELETE /Project-A/.zddc: want 204, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Non-admin elevated still 403 on PUT — the carve-out only opens
|
||||
// the path past the segment guard; the decider gates ActionAdmin.
|
||||
req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc", bytes.NewReader([]byte("title: probe\n"))), "stranger@example.com", true)
|
||||
rec = httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("PUT /.zddc by stranger: want 403, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Intermediate .zddc.d segments stay reserved — only the LEAF .zddc
|
||||
// is carved through. A PUT to /.zddc.d/foo must 404 at the guard.
|
||||
req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc.d/something", bytes.NewReader([]byte("x"))), "admin@example.com", true)
|
||||
rec = httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("PUT /.zddc.d/something: want 404 (reserved segment), got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatchArchiveRedirect: any /<project>/<sub>/.../.archive/... is 302'd
|
||||
// to the canonical /<project>/.archive/... so all tracking-number references
|
||||
// converge on a single stable URL per (project, tracking) regardless of the
|
||||
|
|
@ -296,7 +389,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
|||
func TestDispatchArchiveRedirect(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||
"acl:\n allow:\n - \"*\"\n")
|
||||
"acl:\n permissions:\n \"*\": rwcd\n")
|
||||
mustMkdir(t, filepath.Join(root, "ProjectA", "Working"))
|
||||
|
||||
idx, err := archive.BuildIndex(root)
|
||||
|
|
@ -596,7 +689,7 @@ func TestDispatchEmptyCanonicalProjectFolders(t *testing.T) {
|
|||
func TestDispatchArchiveMethodGate(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||
"acl:\n allow:\n - \"*\"\n")
|
||||
"acl:\n permissions:\n \"*\": rwcd\n")
|
||||
mustMkdir(t, filepath.Join(root, "ProjectA"))
|
||||
|
||||
idx, err := archive.BuildIndex(root)
|
||||
|
|
@ -638,7 +731,7 @@ func TestDispatchArchiveMethodGate(t *testing.T) {
|
|||
func TestDispatchCaseInsensitiveURL(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||
"acl:\n allow:\n - \"*\"\n")
|
||||
"acl:\n permissions:\n \"*\": rwcd\n")
|
||||
mustMkdir(t, filepath.Join(root, "project-a", "working"))
|
||||
mustWrite(t, filepath.Join(root, "project-a", "working", "note.md"), "lowercase note")
|
||||
|
||||
|
|
@ -843,79 +936,6 @@ func mustWrite(t *testing.T, path, body string) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestDispatchSubtreeZip exercises the `?zip=1` subtree-download hook:
|
||||
// it routes to handler.ServeSubtreeZip on both the slash and no-slash
|
||||
// forms of a directory URL, and the dispatch's directory ACL gate
|
||||
// still applies (a viewer with no read access to the directory gets
|
||||
// 403 before the zip handler runs).
|
||||
func TestDispatchSubtreeZip(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||
"acl:\n permissions:\n \"*\": r\n")
|
||||
mustMkdir(t, filepath.Join(root, "Proj", "staging", "2025-01-15_AAA-EM-TRN-0001 (IFC) - T"))
|
||||
mustWrite(t, filepath.Join(root, "Proj", "staging", "2025-01-15_AAA-EM-TRN-0001 (IFC) - T", "doc.txt"), "hello")
|
||||
// A subtree only alice@x may read.
|
||||
mustMkdir(t, filepath.Join(root, "Proj", "locked"))
|
||||
mustWrite(t, filepath.Join(root, "Proj", "locked", ".zddc"),
|
||||
"acl:\n inherit: false\n permissions:\n \"alice@x\": rwcda\n")
|
||||
mustWrite(t, filepath.Join(root, "Proj", "locked", "secret.txt"), "s")
|
||||
|
||||
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) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), handler.EmailKey, email))
|
||||
rec := httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
for _, path := range []string{"/Proj/staging/?zip=1", "/Proj/staging?zip=1"} {
|
||||
rec := do(path, "bob@x")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s status=%d, want 200", path, rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/zip" {
|
||||
t.Errorf("%s Content-Type=%q", path, ct)
|
||||
}
|
||||
if rec.Header().Get("X-ZDDC-Source") != "subtree-zip" {
|
||||
t.Errorf("%s missing X-ZDDC-Source", path)
|
||||
}
|
||||
body := rec.Body.Bytes()
|
||||
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
||||
if err != nil {
|
||||
t.Fatalf("%s body not a zip: %v", path, err)
|
||||
}
|
||||
var foundDoc bool
|
||||
for _, f := range zr.File {
|
||||
if strings.HasSuffix(f.Name, "/doc.txt") || f.Name == "staging/2025-01-15_AAA-EM-TRN-0001 (IFC) - T/doc.txt" {
|
||||
foundDoc = true
|
||||
}
|
||||
}
|
||||
if !foundDoc {
|
||||
t.Errorf("%s zip missing doc.txt; entries=%d", path, len(zr.File))
|
||||
}
|
||||
}
|
||||
|
||||
// The dispatch's directory ACL gate runs before ServeSubtreeZip:
|
||||
// bob@x can't read /Proj/locked at all → 403, no zip.
|
||||
if rec := do("/Proj/locked/?zip=1", "bob@x"); rec.Code != http.StatusForbidden {
|
||||
t.Errorf("bob@x /Proj/locked/?zip=1 status=%d, want 403", rec.Code)
|
||||
}
|
||||
// alice@x can → 200 zip.
|
||||
if rec := do("/Proj/locked/?zip=1", "alice@x"); rec.Code != http.StatusOK {
|
||||
t.Errorf("alice@x /Proj/locked/?zip=1 status=%d, want 200", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGzhttpWrapper_CompressesLargeResponses asserts the gzhttp wrapper
|
||||
// behavior we wire in main(): responses above MinSize get gzip-encoded
|
||||
// when the client advertises Accept-Encoding: gzip; small responses
|
||||
|
|
@ -994,86 +1014,3 @@ func TestGzhttpWrapper_CompressesLargeResponses(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestDispatchZddcEditorAtPath verifies the per-directory <dir>/.zddc.html
|
||||
// virtual URL is recognised by the dispatcher and routed to the editor
|
||||
// handler (carved out from the dot-prefix guard). Permission gate is
|
||||
// hasAnyAdminScope; non-admins get 404.
|
||||
func TestDispatchZddcEditorAtPath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||
"admins:\n - root@example.com\n")
|
||||
mustMkdir(t, filepath.Join(root, "Project", "working"))
|
||||
mustWrite(t, filepath.Join(root, "Project", ".zddc"),
|
||||
"title: Demo Project\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)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
email string
|
||||
wantStatus int
|
||||
wantSubstr string
|
||||
}{
|
||||
{
|
||||
"root admin opens project editor",
|
||||
"/Project/.zddc.html", "root@example.com",
|
||||
http.StatusOK, "Demo Project",
|
||||
},
|
||||
{
|
||||
"root admin opens working/ editor (no .zddc on disk yet)",
|
||||
"/Project/working/.zddc.html", "root@example.com",
|
||||
http.StatusOK, ".zddc editor",
|
||||
},
|
||||
{
|
||||
"root admin opens deployment-root editor",
|
||||
"/.zddc.html", "root@example.com",
|
||||
http.StatusOK, ".zddc editor",
|
||||
},
|
||||
{
|
||||
"non-admin gets 404",
|
||||
"/Project/.zddc.html", "stranger@example.com",
|
||||
http.StatusNotFound, "",
|
||||
},
|
||||
{
|
||||
"anonymous gets 404",
|
||||
"/Project/.zddc.html", "",
|
||||
http.StatusNotFound, "",
|
||||
},
|
||||
{
|
||||
"missing directory gets 404",
|
||||
"/Project/no-such-dir/.zddc.html", "root@example.com",
|
||||
http.StatusNotFound, "",
|
||||
},
|
||||
{
|
||||
"deeper than leaf rejected",
|
||||
"/Project/.zddc.html/extra", "root@example.com",
|
||||
http.StatusNotFound, "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), handler.EmailKey, tc.email))
|
||||
rec := httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Fatalf("path=%q status=%d, want %d; body=%s",
|
||||
tc.path, rec.Code, tc.wantStatus, rec.Body.String())
|
||||
}
|
||||
if tc.wantSubstr != "" && !strings.Contains(rec.Body.String(), tc.wantSubstr) {
|
||||
t.Errorf("path=%q body missing %q", tc.path, tc.wantSubstr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,10 +34,16 @@ func NewServer(root string, cache *Cache, fetcher *Fetcher, buildVer string) *Se
|
|||
}
|
||||
|
||||
// MatchAppHTML returns the canonical app name if requestPath matches a
|
||||
// "<dir>/<app>.html" pattern for one of the five canonical apps, plus the
|
||||
// directory (relative to root) the request is rooted at.
|
||||
// "<dir>/<app>.html" pattern for one of the canonical apps, plus the
|
||||
// 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.
|
||||
//
|
||||
// Special case: GET / and GET /index.html both resolve to landing.
|
||||
// Special case: GET / and GET /index.html both resolve to landing — the
|
||||
// only entry point that scopes ACL per-project, and the conventional
|
||||
// place for a static-site index when an operator wants one.
|
||||
func MatchAppHTML(requestPath string) (app string, requestDirRel string) {
|
||||
if requestPath == "" || requestPath == "/" {
|
||||
return "landing", ""
|
||||
|
|
|
|||
|
|
@ -45,24 +45,21 @@ type Config struct {
|
|||
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.
|
||||
CascadeMode string // --cascade-mode / ZDDC_CASCADE_MODE — "delegated" (default; leaf grants override ancestor denies) or "strict" (ancestor explicit-denies are absolute, NIST AC-6).
|
||||
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).
|
||||
// The server shells out to upstream pandoc + chromium container
|
||||
// images via podman or docker, pulling each on first use via
|
||||
// `--pull=missing`. No custom image build is required — only that
|
||||
// podman or docker is on PATH and the configured image refs are
|
||||
// reachable. If no runtime is found the endpoint serves 503.
|
||||
ConvertPandocImage string // --convert-pandoc-image / ZDDC_CONVERT_PANDOC_IMAGE — image for MD→DOCX/HTML. Default docker.io/pandoc/latex:latest.
|
||||
ConvertChromiumImage string // --convert-chromium-image / ZDDC_CONVERT_CHROMIUM_IMAGE — image for HTML→PDF. Default docker.io/zenika/alpine-chrome:latest.
|
||||
ConvertEngine string // --convert-engine / ZDDC_CONVERT_ENGINE — override engine binary (default: probe for podman, then docker).
|
||||
ConvertPodmanSocket string // --convert-podman-socket / ZDDC_CONVERT_PODMAN_SOCKET — when non-empty, run podman in remote mode against this Unix socket (e.g. unix:///var/run/podman/podman.sock). Used with the Kubernetes sidecar pattern so zddc-server's own pod stays unprivileged.
|
||||
ConvertScratchDir string // --convert-scratch-dir / ZDDC_CONVERT_SCRATCH_DIR — directory used for per-conversion scratch (template + HTML/PDF intermediates). Must be a path the remote podman can see at the same path. Empty = use $TMPDIR (local-mode default).
|
||||
ConvertMemMiB int // --convert-mem-mib / ZDDC_CONVERT_MEM_MIB — per-container memory cap in MiB. Default 512.
|
||||
ConvertCPUs string // --convert-cpus / ZDDC_CONVERT_CPUS — per-container CPU limit. Default "2".
|
||||
ConvertPIDs int // --convert-pids / ZDDC_CONVERT_PIDS — per-container PID limit. Default 100.
|
||||
ConvertTimeout time.Duration // --convert-timeout / ZDDC_CONVERT_TIMEOUT — per-conversion wall clock. Default 30s.
|
||||
// zddc-server exec's `pandoc` and `chromium-browser` directly.
|
||||
// In the production runtime image those names resolve to wrapper
|
||||
// scripts at /usr/local/bin/ that put the real binary into a
|
||||
// cgroup v2 + bubblewrap sandbox before exec'ing it — see
|
||||
// zddc/runtime.Containerfile + zddc/runtime/zddc-sandbox-exec.
|
||||
// zddc-server is unaware of sandboxing; the image owns it.
|
||||
ConvertPandocBinary string // --convert-pandoc-binary / ZDDC_CONVERT_PANDOC_BINARY — pandoc binary name (PATH-resolved) or absolute path. Default "pandoc". Resolves to the wrapper script in the runtime image.
|
||||
ConvertChromiumBinary string // --convert-chromium-binary / ZDDC_CONVERT_CHROMIUM_BINARY — chromium binary name (PATH-resolved) or absolute path. Default "chromium-browser" (alpine); set to "chromium" on debian.
|
||||
ConvertScratchDir string // --convert-scratch-dir / ZDDC_CONVERT_SCRATCH_DIR — directory used for per-conversion scratch (template + HTML/PDF intermediates). The wrapper bind-mounts this into the sandbox at the same path. Empty = use $TMPDIR.
|
||||
ConvertMemMiB int // --convert-mem-mib / ZDDC_CONVERT_MEM_MIB — per-conversion memory cap in MiB (advisory; passed to the wrapper via ZDDC_CONV_MEM_MAX, applied as cgroup v2 memory.max). Default 1024.
|
||||
ConvertPIDs int // --convert-pids / ZDDC_CONVERT_PIDS — per-conversion PID cap (passed to the wrapper via ZDDC_CONV_PIDS_MAX, applied as cgroup v2 pids.max). Default 256.
|
||||
ConvertTimeout time.Duration // --convert-timeout / ZDDC_CONVERT_TIMEOUT — per-conversion wall clock (enforced in zddc-server via context.WithTimeout). Default 60s.
|
||||
}
|
||||
|
||||
// ErrHelpRequested is returned by Load when --help is passed; the caller
|
||||
|
|
@ -139,28 +136,20 @@ func Load(args []string) (Config, error) {
|
|||
"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.")
|
||||
cascadeModeFlag := fs.String("cascade-mode", getEnv("ZDDC_CASCADE_MODE", "delegated"),
|
||||
"ACL cascade evaluation mode: \"delegated\" (default — subtree allow can override ancestor deny) or \"strict\" (ancestor explicit-deny is absolute; NIST AC-6).")
|
||||
archiveRescanIntervalFlag := fs.Duration("archive-rescan-interval", parseDurationOrDefault(os.Getenv("ZDDC_ARCHIVE_RESCAN_INTERVAL"), 60*time.Second),
|
||||
"Periodic full re-walk of the archive index. Required on SMB/CIFS-backed roots where inotify misses cross-client writes. Default 60s; set 0 to disable.")
|
||||
convertPandocImageFlag := fs.String("convert-pandoc-image", getEnv("ZDDC_CONVERT_PANDOC_IMAGE", "docker.io/pandoc/latex:latest"),
|
||||
"Pandoc container image for MD→DOCX and MD→HTML. Pulled on first use via --pull=missing.")
|
||||
convertChromiumImageFlag := fs.String("convert-chromium-image", getEnv("ZDDC_CONVERT_CHROMIUM_IMAGE", "docker.io/zenika/alpine-chrome:latest"),
|
||||
"Headless Chromium container image for HTML→PDF. Pulled on first use via --pull=missing.")
|
||||
convertEngineFlag := fs.String("convert-engine", os.Getenv("ZDDC_CONVERT_ENGINE"),
|
||||
"Container engine override (default: probe for podman, then docker).")
|
||||
convertPodmanSocketFlag := fs.String("convert-podman-socket", os.Getenv("ZDDC_CONVERT_PODMAN_SOCKET"),
|
||||
"Run podman in remote mode against this Unix socket URL (e.g. unix:///var/run/podman/podman.sock). When set, the engine binary is invoked as `podman --remote --url=<this> run …`; the actual container creation happens in whatever process owns the socket (typically a podman-system-service sidecar). Empty = local mode.")
|
||||
convertPandocBinaryFlag := fs.String("convert-pandoc-binary", getEnv("ZDDC_CONVERT_PANDOC_BINARY", "pandoc"),
|
||||
"Pandoc binary name (PATH-resolved) or absolute path. Default \"pandoc\". In the runtime image this resolves to the wrapper at /usr/local/bin/pandoc which sandboxes the real binary.")
|
||||
convertChromiumBinaryFlag := fs.String("convert-chromium-binary", getEnv("ZDDC_CONVERT_CHROMIUM_BINARY", "chromium-browser"),
|
||||
"Chromium binary name (PATH-resolved) or absolute path. Default \"chromium-browser\" (alpine); set to \"chromium\" on debian/ubuntu.")
|
||||
convertScratchDirFlag := fs.String("convert-scratch-dir", os.Getenv("ZDDC_CONVERT_SCRATCH_DIR"),
|
||||
"Scratch directory for per-conversion intermediates (template, HTML, PDF). In remote mode this MUST be a path that the podman-service side can see at the same path — typically a shared emptyDir mounted at the same mountPath in both containers. Empty = use $TMPDIR (local mode).")
|
||||
convertMemMiBFlag := fs.Int("convert-mem-mib", parseIntOrDefault(os.Getenv("ZDDC_CONVERT_MEM_MIB"), 512),
|
||||
"Per-conversion container memory limit in MiB. Default 512.")
|
||||
convertCPUsFlag := fs.String("convert-cpus", getEnv("ZDDC_CONVERT_CPUS", "2"),
|
||||
"Per-conversion container CPU limit (passed to --cpus). Default 2.")
|
||||
convertPIDsFlag := fs.Int("convert-pids", parseIntOrDefault(os.Getenv("ZDDC_CONVERT_PIDS"), 100),
|
||||
"Per-conversion container PID limit. Default 100.")
|
||||
convertTimeoutFlag := fs.Duration("convert-timeout", parseDurationOrDefault(os.Getenv("ZDDC_CONVERT_TIMEOUT"), 30*time.Second),
|
||||
"Per-conversion wall-clock timeout. Default 30s.")
|
||||
"Scratch directory for per-conversion intermediates (template, HTML, PDF). The runtime image's wrapper bind-mounts this into the sandbox at the same path. Empty = use $TMPDIR.")
|
||||
convertMemMiBFlag := fs.Int("convert-mem-mib", parseIntOrDefault(os.Getenv("ZDDC_CONVERT_MEM_MIB"), 1024),
|
||||
"Per-conversion memory limit in MiB (advisory; passed to the runtime-image wrapper via ZDDC_CONV_MEM_MAX, applied as cgroup v2 memory.max). Default 1024.")
|
||||
convertPIDsFlag := fs.Int("convert-pids", parseIntOrDefault(os.Getenv("ZDDC_CONVERT_PIDS"), 256),
|
||||
"Per-conversion PID limit (passed to the runtime-image wrapper via ZDDC_CONV_PIDS_MAX, applied as cgroup v2 pids.max). Default 256.")
|
||||
convertTimeoutFlag := fs.Duration("convert-timeout", parseDurationOrDefault(os.Getenv("ZDDC_CONVERT_TIMEOUT"), 60*time.Second),
|
||||
"Per-conversion wall-clock timeout (enforced in zddc-server via context.WithTimeout). Default 60s.")
|
||||
accessLogFlag := fs.String("access-log", os.Getenv("ZDDC_ACCESS_LOG"),
|
||||
"Tee structured access logs to this file (JSON, size-rotated). "+
|
||||
"Default: <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log. "+
|
||||
|
|
@ -231,17 +220,13 @@ func Load(args []string) (Config, error) {
|
|||
OPACacheTTL: *opaCacheTTLFlag,
|
||||
AppsPubKey: *appsPubKeyFlag,
|
||||
MaxWriteBytes: *maxWriteBytesFlag,
|
||||
CascadeMode: *cascadeModeFlag,
|
||||
ArchiveRescanInterval: *archiveRescanIntervalFlag,
|
||||
ConvertPandocImage: *convertPandocImageFlag,
|
||||
ConvertChromiumImage: *convertChromiumImageFlag,
|
||||
ConvertEngine: *convertEngineFlag,
|
||||
ConvertPodmanSocket: *convertPodmanSocketFlag,
|
||||
ConvertScratchDir: *convertScratchDirFlag,
|
||||
ConvertMemMiB: *convertMemMiBFlag,
|
||||
ConvertCPUs: *convertCPUsFlag,
|
||||
ConvertPIDs: *convertPIDsFlag,
|
||||
ConvertTimeout: *convertTimeoutFlag,
|
||||
ConvertPandocBinary: *convertPandocBinaryFlag,
|
||||
ConvertChromiumBinary: *convertChromiumBinaryFlag,
|
||||
ConvertScratchDir: *convertScratchDirFlag,
|
||||
ConvertMemMiB: *convertMemMiBFlag,
|
||||
ConvertPIDs: *convertPIDsFlag,
|
||||
ConvertTimeout: *convertTimeoutFlag,
|
||||
}
|
||||
|
||||
// Default Root to the current working directory.
|
||||
|
|
@ -317,15 +302,6 @@ func Load(args []string) (Config, error) {
|
|||
return Config{}, errors.New("--tls-cert and --tls-key must both be set or both be empty")
|
||||
}
|
||||
|
||||
switch cfg.CascadeMode {
|
||||
case "", "delegated":
|
||||
cfg.CascadeMode = "delegated"
|
||||
case "strict":
|
||||
// ok
|
||||
default:
|
||||
return Config{}, fmt.Errorf("--cascade-mode must be \"delegated\" or \"strict\", got %q", cfg.CascadeMode)
|
||||
}
|
||||
|
||||
// Plain HTTP mode trusts the email header from any client. Only safe
|
||||
// behind an authenticating reverse proxy. Refuse to start when binding
|
||||
// plain HTTP to a non-loopback interface unless the operator has
|
||||
|
|
|
|||
|
|
@ -1,26 +1,29 @@
|
|||
// Package convert turns a markdown source byte-buffer into DOCX, HTML,
|
||||
// or PDF via two stock upstream container images: pandoc (default
|
||||
// `docker.io/pandoc/latex:latest`) handles MD↔DOCX and MD→HTML, and
|
||||
// a headless-chromium image (default `docker.io/zenika/alpine-chrome:latest`)
|
||||
// handles HTML→PDF. No custom image build is required — the operator
|
||||
// just needs `podman` or `docker` on PATH and the runner pulls each
|
||||
// image on first use via `--pull=missing`.
|
||||
// or PDF by exec'ing pandoc and chromium-browser. Each conversion runs
|
||||
// inside a sandbox provided by the IMAGE — typically a wrapper script
|
||||
// at /usr/local/bin/<binary> that puts the real binary into a cgroup
|
||||
// v2 + bubblewrap sandbox before exec'ing it. See
|
||||
// zddc/runtime.Containerfile for the production setup.
|
||||
//
|
||||
// zddc-server's Go code is unaware of sandboxing: it just exec's
|
||||
// "pandoc" or "chromium-browser" and gets the corresponding tool's
|
||||
// behavior back. Operators who want a different isolation strategy
|
||||
// (firejail, systemd-nspawn, podman-run, raw exec for dev) replace
|
||||
// the wrapper script in their image; the Go binary doesn't change.
|
||||
//
|
||||
// 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)
|
||||
// ToDocx(ctx, source, meta) → []byte (DOCX bytes)
|
||||
// ToHTML(ctx, source, meta) → []byte (standalone HTML)
|
||||
// ToPDF (ctx, source, meta) → []byte (PDF, via HTML + chromium)
|
||||
//
|
||||
// Probe(ctx, override) → Capabilities (call once at startup)
|
||||
// Available() → (Capabilities, bool)
|
||||
// SetImages(pandoc, chromium) — install image refs from config
|
||||
// Probe(ctx) → Capabilities (call once at startup)
|
||||
// Available() → (Capabilities, bool)
|
||||
// SetBinaries(pandoc, chromium) — install binary names from config
|
||||
// SetScratchDir(dir) — install scratch root from config
|
||||
//
|
||||
// All three converters are safe for concurrent use; each call gets a
|
||||
// fresh container. The pandoc image's entrypoint is `pandoc`, so the
|
||||
// argv we pass after the image flows straight into pandoc. The
|
||||
// alpine-chrome image's entrypoint is `chromium-browser`, so the argv
|
||||
// flows into chromium-browser. No `sh -c` wrappers, no shell quoting.
|
||||
// fresh scratch dir + (image-provided) sandbox.
|
||||
//
|
||||
// Metadata maps to the placeholders consumed by viewer-template.html.
|
||||
// title/tracking_number/revision/status/is_draft typically come from
|
||||
|
|
@ -55,42 +58,49 @@ type Metadata struct {
|
|||
NoTOC bool
|
||||
}
|
||||
|
||||
// Default images. Operator overrides via --convert-pandoc-image /
|
||||
// --convert-chromium-image (see cmd/zddc-server). pandoc/latex carries
|
||||
// TeX Live for native PDF too, so it's a superset of pandoc/core;
|
||||
// operators wanting a slimmer footprint can switch to pandoc/core.
|
||||
// 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
|
||||
// sandbox automatically. Operators running zddc-server outside the
|
||||
// runtime image with raw binaries on PATH still get a working
|
||||
// conversion endpoint — just without the per-call sandbox.
|
||||
//
|
||||
// Alpine's chromium package installs the binary as "chromium-browser";
|
||||
// debian/ubuntu ships "chromium". Operators override via
|
||||
// --convert-chromium-binary when the package on their image differs.
|
||||
const (
|
||||
DefaultPandocImage = "docker.io/pandoc/latex:latest"
|
||||
DefaultChromiumImage = "docker.io/zenika/alpine-chrome:latest"
|
||||
DefaultPandocBinary = "pandoc"
|
||||
DefaultChromiumBinary = "chromium-browser"
|
||||
)
|
||||
|
||||
var (
|
||||
pandocImage atomic.Pointer[string]
|
||||
chromiumImage atomic.Pointer[string]
|
||||
scratchDir atomic.Pointer[string]
|
||||
pandocBinary atomic.Pointer[string]
|
||||
chromiumBinary atomic.Pointer[string]
|
||||
scratchDir atomic.Pointer[string]
|
||||
)
|
||||
|
||||
// SetImages installs the image refs used for subsequent ToDocx/ToHTML/
|
||||
// ToPDF calls. Empty values keep the previous setting (or the
|
||||
// DefaultPandocImage / DefaultChromiumImage constants on first call).
|
||||
// Called from cmd/zddc-server/main.go after flag parsing.
|
||||
func SetImages(pandoc, chromium string) {
|
||||
// SetBinaries installs the binary names used by Probe/Run. Empty
|
||||
// values keep the previous setting (or the DefaultPandocBinary /
|
||||
// DefaultChromiumBinary constants on first call). The values are
|
||||
// PATH-resolved names (e.g. "pandoc", "chromium-browser") or
|
||||
// absolute paths. Called from cmd/zddc-server/main.go after flag
|
||||
// parsing.
|
||||
func SetBinaries(pandoc, chromium string) {
|
||||
if pandoc != "" {
|
||||
s := pandoc
|
||||
pandocImage.Store(&s)
|
||||
pandocBinary.Store(&s)
|
||||
}
|
||||
if chromium != "" {
|
||||
s := chromium
|
||||
chromiumImage.Store(&s)
|
||||
chromiumBinary.Store(&s)
|
||||
}
|
||||
}
|
||||
|
||||
// SetScratchDir installs the host-side scratch root used for per-call
|
||||
// intermediates (template, HTML, PDF). Empty means "use $TMPDIR" — the
|
||||
// local-mode default. In remote mode this MUST be a path the podman-
|
||||
// service sidecar can see at the same mountpoint, typically a shared
|
||||
// emptyDir mounted at /work in both containers. Called from
|
||||
// cmd/zddc-server/main.go after flag parsing.
|
||||
// SetScratchDir installs the host-side scratch root used for
|
||||
// per-call intermediates (template, HTML, PDF). Empty means "use
|
||||
// $TMPDIR". The runtime-image wrapper bind-mounts the per-call
|
||||
// scratch dir into its sandbox at the same path, so any path under
|
||||
// this root works.
|
||||
func SetScratchDir(dir string) {
|
||||
s := dir
|
||||
scratchDir.Store(&s)
|
||||
|
|
@ -103,23 +113,24 @@ func currentScratchDir() string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func currentPandocImage() string {
|
||||
if p := pandocImage.Load(); p != nil && *p != "" {
|
||||
func currentPandocBinary() string {
|
||||
if p := pandocBinary.Load(); p != nil && *p != "" {
|
||||
return *p
|
||||
}
|
||||
return DefaultPandocImage
|
||||
return DefaultPandocBinary
|
||||
}
|
||||
|
||||
func currentChromiumImage() string {
|
||||
if p := chromiumImage.Load(); p != nil && *p != "" {
|
||||
func currentChromiumBinary() string {
|
||||
if p := chromiumBinary.Load(); p != nil && *p != "" {
|
||||
return *p
|
||||
}
|
||||
return DefaultChromiumImage
|
||||
return DefaultChromiumBinary
|
||||
}
|
||||
|
||||
// ToDocx renders source markdown to DOCX bytes. One container run via
|
||||
// the pandoc image. Caller passes the full file content (envelope +
|
||||
// body); pandoc handles `markdown+yaml_metadata_block` natively.
|
||||
// ToDocx renders source markdown to DOCX bytes. Single pandoc exec;
|
||||
// no scratch dir needed (stdin → stdout). The caller passes the
|
||||
// full file content (envelope + body); pandoc handles
|
||||
// `markdown+yaml_metadata_block` natively.
|
||||
func ToDocx(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
|
||||
r := currentRunner()
|
||||
if r == nil {
|
||||
|
|
@ -132,13 +143,14 @@ func ToDocx(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
|
|||
}
|
||||
cmd = append(cmd, metadataArgs(m)...)
|
||||
cmd = append(cmd, "-")
|
||||
return r.Run(ctx, currentPandocImage(), source, nil, cmd)
|
||||
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 are bind-mounted into the container at /tpl
|
||||
// from a per-call scratch dir.
|
||||
// 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) {
|
||||
r := currentRunner()
|
||||
if r == nil {
|
||||
|
|
@ -150,6 +162,7 @@ func ToHTML(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
|
|||
}
|
||||
defer os.RemoveAll(scratch)
|
||||
|
||||
tplPath := filepath.Join(scratch, "viewer-template.html")
|
||||
cmd := []string{
|
||||
"--from=markdown+yaml_metadata_block",
|
||||
"--to=html5",
|
||||
|
|
@ -158,29 +171,27 @@ func ToHTML(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
|
|||
"--section-divs",
|
||||
"--id-prefix=",
|
||||
"--html-q-tags",
|
||||
"--template=/tpl/viewer-template.html",
|
||||
"--template=" + tplPath,
|
||||
}
|
||||
if !m.NoTOC {
|
||||
cmd = append(cmd, "--toc", "--toc-depth=6")
|
||||
}
|
||||
cmd = append(cmd, metadataArgs(m)...)
|
||||
cmd = append(cmd, "--output=-", "-")
|
||||
|
||||
mounts := []string{scratch + ":/tpl:ro"}
|
||||
return r.Run(ctx, currentPandocImage(), source, mounts, cmd)
|
||||
return r.Run(ctx, currentPandocBinary(), source, scratch, cmd)
|
||||
}
|
||||
|
||||
// ToPDF renders source markdown to PDF in two stages: pandoc produces
|
||||
// HTML using viewer-template.html (stage 1, pandoc image), then headless
|
||||
// Chromium prints that HTML to PDF (stage 2, chromium image). The
|
||||
// two-stage choice preserves the print-media CSS already authored in
|
||||
// viewer-template.html — pandoc's native --pdf-engine path uses LaTeX
|
||||
// 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.
|
||||
//
|
||||
// Chromium runs from the alpine-chrome image whose entrypoint is
|
||||
// `chromium-browser`; our cmd is the flag list passed straight to that
|
||||
// binary. The host scratch dir is bind-mounted read-write at /pdf so
|
||||
// chromium can write out.pdf and we read it back afterward.
|
||||
// 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)
|
||||
if err != nil {
|
||||
|
|
@ -205,17 +216,11 @@ func ToPDF(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
mounts := []string{scratch + ":/pdf:rw"}
|
||||
// alpine-chrome's entrypoint is `chromium-browser`. --no-sandbox is
|
||||
// required because the container drops CAP_SYS_ADMIN; the threat
|
||||
// model is "malicious markdown drives chromium RCE", contained by
|
||||
// --network=none + --cap-drop=ALL + --read-only + tmpfs.
|
||||
//
|
||||
// --disable-dev-shm-usage: without this, chromium tries to allocate
|
||||
// shared memory under /dev/shm, which our --read-only container
|
||||
// can't write to. The flag tells chromium to fall back to /tmp,
|
||||
// which is a writable tmpfs (sized in runner.go). Standard fix for
|
||||
// chromium-in-container; required by every CI/headless setup.
|
||||
// --no-sandbox: the wrapper provides the sandbox; chromium's
|
||||
// own setuid sandbox would conflict (and fails inside our
|
||||
// user-namespace anyway). --disable-dev-shm-usage: chromium's
|
||||
// shared-memory fallback writes to /dev/shm which our sandbox
|
||||
// doesn't expose; redirect to /tmp (the wrapper's tmpfs).
|
||||
cmd := []string{
|
||||
"--headless",
|
||||
"--disable-gpu",
|
||||
|
|
@ -224,10 +229,10 @@ func ToPDF(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
|
|||
"--user-data-dir=/tmp/chrome",
|
||||
"--no-pdf-header-footer",
|
||||
"--virtual-time-budget=10000",
|
||||
"--print-to-pdf=/pdf/out.pdf",
|
||||
"file:///pdf/in.html",
|
||||
"--print-to-pdf=" + pdfPath,
|
||||
"file://" + htmlPath,
|
||||
}
|
||||
if _, err := r.Run(ctx, currentChromiumImage(), nil, mounts, cmd); err != nil {
|
||||
if _, err := r.Run(ctx, currentChromiumBinary(), nil, scratch, cmd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
@ -237,7 +242,7 @@ func ToPDF(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
|
|||
}
|
||||
if len(out) < 4 || string(out[:4]) != "%PDF" {
|
||||
return nil, &ConvertError{
|
||||
Tool: "chromium",
|
||||
Tool: currentChromiumBinary(),
|
||||
ExitCode: 0,
|
||||
Stderr: "chromium did not produce a valid PDF",
|
||||
Cause: fmt.Errorf("invalid PDF magic in output (got %d bytes)", len(out)),
|
||||
|
|
@ -246,9 +251,9 @@ func ToPDF(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// metadataArgs renders Metadata into pandoc -V flags. Order is stable
|
||||
// so test fixtures don't churn. Empty values are omitted (the template
|
||||
// uses $if(...)$ blocks).
|
||||
// metadataArgs renders Metadata into pandoc -V flags. Order is
|
||||
// stable so test fixtures don't churn. Empty values are omitted
|
||||
// (the template uses $if(...)$ blocks).
|
||||
func metadataArgs(m Metadata) []string {
|
||||
var out []string
|
||||
add := func(k, v string) {
|
||||
|
|
|
|||
|
|
@ -10,25 +10,25 @@ import (
|
|||
)
|
||||
|
||||
// fakeRunner records the args it was invoked with and replays canned
|
||||
// responses. Lets us assert the command lines + image refs without
|
||||
// needing podman.
|
||||
// responses. Lets us assert command lines + binary refs + scratch
|
||||
// dirs without needing actual pandoc.
|
||||
type fakeRunner struct {
|
||||
mu sync.Mutex
|
||||
calls [][]string
|
||||
images []string
|
||||
stdin [][]byte
|
||||
mounts [][]string
|
||||
resp []byte
|
||||
err error
|
||||
mu sync.Mutex
|
||||
calls [][]string
|
||||
binaries []string
|
||||
stdin [][]byte
|
||||
scratchDir []string
|
||||
resp []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeRunner) Run(_ context.Context, image string, stdin []byte, mounts []string, cmd []string) ([]byte, error) {
|
||||
func (f *fakeRunner) Run(_ context.Context, binary string, stdin []byte, scratchDir string, cmd []string) ([]byte, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, append([]string(nil), cmd...))
|
||||
f.images = append(f.images, image)
|
||||
f.binaries = append(f.binaries, binary)
|
||||
f.stdin = append(f.stdin, append([]byte(nil), stdin...))
|
||||
f.mounts = append(f.mounts, append([]string(nil), mounts...))
|
||||
f.scratchDir = append(f.scratchDir, scratchDir)
|
||||
return f.resp, f.err
|
||||
}
|
||||
|
||||
|
|
@ -38,14 +38,14 @@ func (f *fakeRunner) lastCall() (string, []string) {
|
|||
if len(f.calls) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return f.images[len(f.images)-1], f.calls[len(f.calls)-1]
|
||||
return f.binaries[len(f.binaries)-1], f.calls[len(f.calls)-1]
|
||||
}
|
||||
|
||||
func TestToDocx_UsesPandocImage(t *testing.T) {
|
||||
func TestToDocx_UsesPandocBinary(t *testing.T) {
|
||||
f := &fakeRunner{resp: []byte("FAKE-DOCX")}
|
||||
InstallRunner(f)
|
||||
t.Cleanup(func() { InstallRunner(nil) })
|
||||
SetImages("docker.io/pandoc/latex:latest", "")
|
||||
SetBinaries("pandoc", "chromium-browser")
|
||||
|
||||
out, err := ToDocx(context.Background(), []byte("# Hello\n"), Metadata{
|
||||
Title: "Hello",
|
||||
|
|
@ -57,9 +57,9 @@ func TestToDocx_UsesPandocImage(t *testing.T) {
|
|||
if string(out) != "FAKE-DOCX" {
|
||||
t.Errorf("unexpected output: %q", out)
|
||||
}
|
||||
image, call := f.lastCall()
|
||||
if image != "docker.io/pandoc/latex:latest" {
|
||||
t.Errorf("expected pandoc image, got %q", image)
|
||||
binary, call := f.lastCall()
|
||||
if binary != "pandoc" {
|
||||
t.Errorf("expected pandoc binary, got %q", binary)
|
||||
}
|
||||
if !contains(call, "--to=docx") {
|
||||
t.Errorf("missing --to=docx: %v", call)
|
||||
|
|
@ -74,35 +74,40 @@ func TestToDocx_UsesPandocImage(t *testing.T) {
|
|||
if call[len(call)-1] != "-" {
|
||||
t.Errorf("expected stdin marker as last arg, got %q", call[len(call)-1])
|
||||
}
|
||||
// ToDocx is stdin → stdout — no scratch dir needed.
|
||||
if f.scratchDir[len(f.scratchDir)-1] != "" {
|
||||
t.Errorf("ToDocx should not need a scratch dir, got %q", f.scratchDir[len(f.scratchDir)-1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToHTML_UsesTemplateAndMountsScratch(t *testing.T) {
|
||||
func TestToHTML_UsesTemplateFromScratchDir(t *testing.T) {
|
||||
f := &fakeRunner{resp: []byte("<html>fake</html>")}
|
||||
InstallRunner(f)
|
||||
t.Cleanup(func() { InstallRunner(nil) })
|
||||
SetImages("docker.io/pandoc/latex:latest", "")
|
||||
SetBinaries("pandoc", "chromium-browser")
|
||||
|
||||
_, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{Title: "Hi"})
|
||||
if err != nil {
|
||||
t.Fatalf("ToHTML: %v", err)
|
||||
}
|
||||
image, call := f.lastCall()
|
||||
if image != "docker.io/pandoc/latex:latest" {
|
||||
t.Errorf("expected pandoc image, got %q", image)
|
||||
binary, call := f.lastCall()
|
||||
if binary != "pandoc" {
|
||||
t.Errorf("expected pandoc binary, got %q", binary)
|
||||
}
|
||||
if !contains(call, "--template=/tpl/viewer-template.html") {
|
||||
t.Errorf("template flag missing: %v", call)
|
||||
// Template flag must reference an absolute path under the scratch
|
||||
// dir (no /tpl indirection anymore — the wrapper bind-mounts the
|
||||
// scratch dir at its own path, so absolute host paths just work).
|
||||
scratch := f.scratchDir[len(f.scratchDir)-1]
|
||||
if scratch == "" {
|
||||
t.Fatalf("ToHTML must pass a scratch dir to the runner")
|
||||
}
|
||||
wantTpl := "--template=" + scratch + "/viewer-template.html"
|
||||
if !contains(call, wantTpl) {
|
||||
t.Errorf("template flag missing/wrong; want %q in %v", wantTpl, call)
|
||||
}
|
||||
if !contains(call, "--toc") {
|
||||
t.Errorf("TOC flag missing (default NoTOC=false): %v", call)
|
||||
}
|
||||
if len(f.mounts) == 0 || len(f.mounts[0]) == 0 {
|
||||
t.Fatalf("expected at least one bind mount for /tpl")
|
||||
}
|
||||
mount := f.mounts[0][0]
|
||||
if !strings.Contains(mount, ":/tpl:") {
|
||||
t.Errorf("mount missing /tpl: %q", mount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToHTML_NoTOCSuppressesTOC(t *testing.T) {
|
||||
|
|
@ -120,9 +125,9 @@ func TestToHTML_NoTOCSuppressesTOC(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// recordingRunner records every call and returns canned responses
|
||||
// in sequence. Lets ToPDF tests assert the two-stage pipeline
|
||||
// (pandoc image then chromium image).
|
||||
// recordingRunner records every call and returns canned responses in
|
||||
// sequence. Lets ToPDF tests assert the two-stage pipeline (pandoc
|
||||
// then chromium).
|
||||
type recordingRunner struct {
|
||||
mu sync.Mutex
|
||||
calls []recordedCall
|
||||
|
|
@ -132,18 +137,18 @@ type recordingRunner struct {
|
|||
}
|
||||
|
||||
type recordedCall struct {
|
||||
image string
|
||||
cmd []string
|
||||
mounts []string
|
||||
binary string
|
||||
cmd []string
|
||||
scratch string
|
||||
}
|
||||
|
||||
func (r *recordingRunner) Run(_ context.Context, image string, _ []byte, mounts []string, cmd []string) ([]byte, error) {
|
||||
func (r *recordingRunner) Run(_ context.Context, binary string, _ []byte, scratch string, cmd []string) ([]byte, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.calls = append(r.calls, recordedCall{
|
||||
image: image,
|
||||
cmd: append([]string(nil), cmd...),
|
||||
mounts: append([]string(nil), mounts...),
|
||||
binary: binary,
|
||||
cmd: append([]string(nil), cmd...),
|
||||
scratch: scratch,
|
||||
})
|
||||
if r.cursor >= len(r.resp) {
|
||||
return nil, nil
|
||||
|
|
@ -169,57 +174,63 @@ func TestScratchDir_UsedByToHTML(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("ToHTML: %v", err)
|
||||
}
|
||||
if len(f.mounts) == 0 || len(f.mounts[0]) == 0 {
|
||||
t.Fatalf("expected at least one mount")
|
||||
if len(f.scratchDir) == 0 {
|
||||
t.Fatalf("expected a scratch dir to be passed to the runner")
|
||||
}
|
||||
mount := f.mounts[0][0] // "<host>:/tpl:ro"
|
||||
if !strings.HasPrefix(mount, scratchRoot+"/") {
|
||||
t.Errorf("scratch dir not under configured root: %q (root=%q)", mount, scratchRoot)
|
||||
got := f.scratchDir[0]
|
||||
if !strings.HasPrefix(got, scratchRoot+"/") {
|
||||
t.Errorf("scratch dir not under configured root: %q (root=%q)", got, scratchRoot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToPDF_TwoStagePipeline(t *testing.T) {
|
||||
// Stage 1: pandoc emits HTML. Stage 2: chromium reads HTML from
|
||||
// the bind mount and writes /pdf/out.pdf. The fake runner can't
|
||||
// the scratch dir and writes out.pdf there. The fake runner can't
|
||||
// actually write the PDF, so we expect ToPDF to fail at the
|
||||
// read-back step — but we can still assert the two-stage call
|
||||
// shape and the right image per stage.
|
||||
// shape and the right binary per stage.
|
||||
r := &recordingRunner{
|
||||
resp: [][]byte{
|
||||
[]byte("<html><body>fake</body></html>"), // stage 1 stdout
|
||||
nil, // stage 2 stdout (chromium writes PDF to bind mount)
|
||||
nil, // stage 2 stdout (chromium writes PDF to scratch)
|
||||
},
|
||||
}
|
||||
InstallRunner(r)
|
||||
t.Cleanup(func() { InstallRunner(nil) })
|
||||
SetImages("docker.io/pandoc/latex:latest", "docker.io/zenika/alpine-chrome:latest")
|
||||
SetBinaries("pandoc", "chromium-browser")
|
||||
|
||||
_, err := ToPDF(context.Background(), []byte("# Hi\n"), Metadata{})
|
||||
// PDF read-back will fail (fake runner didn't write the file) —
|
||||
// that's expected for this test which only inspects the call
|
||||
// shape.
|
||||
// that's expected for this test which only inspects the call shape.
|
||||
if err == nil {
|
||||
t.Fatalf("expected error from PDF read-back; got nil")
|
||||
}
|
||||
if len(r.calls) != 2 {
|
||||
t.Fatalf("expected 2 container calls (pandoc + chromium); got %d", len(r.calls))
|
||||
t.Fatalf("expected 2 calls (pandoc + chromium); got %d", len(r.calls))
|
||||
}
|
||||
if r.calls[0].image != "docker.io/pandoc/latex:latest" {
|
||||
t.Errorf("stage 1 image: got %q want pandoc/latex", r.calls[0].image)
|
||||
if r.calls[0].binary != "pandoc" {
|
||||
t.Errorf("stage 1 binary: got %q want pandoc", r.calls[0].binary)
|
||||
}
|
||||
if r.calls[1].image != "docker.io/zenika/alpine-chrome:latest" {
|
||||
t.Errorf("stage 2 image: got %q want alpine-chrome", r.calls[1].image)
|
||||
if r.calls[1].binary != "chromium-browser" {
|
||||
t.Errorf("stage 2 binary: got %q want chromium-browser", r.calls[1].binary)
|
||||
}
|
||||
// Stage 2 must include the --print-to-pdf flag pointing at /pdf.
|
||||
if !contains(r.calls[1].cmd, "--print-to-pdf=/pdf/out.pdf") {
|
||||
t.Errorf("chromium call missing --print-to-pdf flag: %v", r.calls[1].cmd)
|
||||
// Stage 2 must include --print-to-pdf pointing at an absolute
|
||||
// path under the scratch dir.
|
||||
stage2 := r.calls[1]
|
||||
if stage2.scratch == "" {
|
||||
t.Fatalf("chromium call must have a scratch dir")
|
||||
}
|
||||
if !contains(r.calls[1].cmd, "--no-sandbox") {
|
||||
t.Errorf("chromium call missing --no-sandbox: %v", r.calls[1].cmd)
|
||||
wantPDF := "--print-to-pdf=" + stage2.scratch + "/out.pdf"
|
||||
if !contains(stage2.cmd, wantPDF) {
|
||||
t.Errorf("chromium call missing --print-to-pdf=%s/out.pdf: %v", stage2.scratch, stage2.cmd)
|
||||
}
|
||||
// Stage 2's bind mount must be writable (chromium writes the PDF).
|
||||
if len(r.calls[1].mounts) == 0 || !strings.Contains(r.calls[1].mounts[0], ":rw") {
|
||||
t.Errorf("chromium mount must be :rw, got %v", r.calls[1].mounts)
|
||||
if !contains(stage2.cmd, "--no-sandbox") {
|
||||
t.Errorf("chromium call missing --no-sandbox: %v", stage2.cmd)
|
||||
}
|
||||
// Stage 2 chromium reads file://<scratch>/in.html.
|
||||
wantHTML := "file://" + stage2.scratch + "/in.html"
|
||||
if !contains(stage2.cmd, wantHTML) {
|
||||
t.Errorf("chromium call missing file:// URL: %v", stage2.cmd)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -255,21 +266,6 @@ func TestMetadataArgs_OmitsEmptyAndOrdersStably(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestImageTag(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"docker.io/pandoc/latex:latest": "pandoc/latex",
|
||||
"docker.io/zenika/alpine-chrome:latest": "zenika/alpine-chrome",
|
||||
"pandoc/core": "pandoc/core",
|
||||
"quay.io/example/foo:v1": "example/foo",
|
||||
"alpine": "alpine",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := imageTag(in); got != want {
|
||||
t.Errorf("imageTag(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleflight_Collapses(t *testing.T) {
|
||||
var g singleflightGroup
|
||||
const N = 50
|
||||
|
|
|
|||
|
|
@ -11,50 +11,45 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
// remoteURL is set by Probe from cfg.ConvertPodmanSocket. Empty means
|
||||
// local mode.
|
||||
var remoteURL atomic.Pointer[string]
|
||||
|
||||
// Capabilities is the snapshot of "can we convert right now?". The
|
||||
// only hard requirement is a container runtime reachable from
|
||||
// zddc-server — image presence is left to `--pull=missing` at
|
||||
// conversion time, so a missing image surfaces as a normal
|
||||
// ConvertError (not a probe failure).
|
||||
// Capabilities is the snapshot the convert-health endpoint reports
|
||||
// and the convert entry points consult before exec'ing.
|
||||
//
|
||||
// Mode is "local" when the engine creates containers in the same
|
||||
// process as zddc-server, or "remote" when zddc-server is the client
|
||||
// of a podman-system-service sidecar (see ContainerRunner doc).
|
||||
// In the runtime-image model, "Ready" means both binaries
|
||||
// (pandoc + chromium) are present on PATH. Sandboxing + resource
|
||||
// limits live in the wrapper scripts that PATH resolves to — out
|
||||
// of zddc-server's concern. The probe doesn't try to validate
|
||||
// those; if the wrapper is broken, the first conversion surfaces
|
||||
// the failure as a ConvertError with the wrapper's stderr.
|
||||
type Capabilities struct {
|
||||
Engine string // "podman" | "docker" | ""
|
||||
EngineVer string // first line of "<engine> --version"
|
||||
Mode string // "local" or "remote"
|
||||
RemoteURL string // populated in remote mode
|
||||
PandocImage string // resolved pandoc image ref
|
||||
ChromiumImage string // resolved chromium image ref
|
||||
ProbedAt time.Time
|
||||
Err error
|
||||
PandocBinary string // resolved path, e.g. /usr/local/bin/pandoc
|
||||
PandocVersion string // first line of "pandoc --version"
|
||||
ChromiumBinary string // resolved path, e.g. /usr/local/bin/chromium-browser
|
||||
ChromiumVersion string // first line of "chromium-browser --version"
|
||||
ProbedAt time.Time
|
||||
Err error
|
||||
}
|
||||
|
||||
// Ready reports whether conversions can be attempted. The first
|
||||
// conversion may still fail if the configured image isn't reachable
|
||||
// from the host's registry (the runner will surface a clear error
|
||||
// from podman/docker stderr).
|
||||
// Ready reports whether conversions can be attempted.
|
||||
func (c Capabilities) Ready() bool {
|
||||
return c.Engine != "" && c.Err == nil
|
||||
return c.PandocBinary != "" && c.ChromiumBinary != "" && c.Err == nil
|
||||
}
|
||||
|
||||
// Reason returns a short human-friendly explanation when Ready() is
|
||||
// false. Used as the body of a 503.
|
||||
func (c Capabilities) Reason() string {
|
||||
if c.Engine == "" {
|
||||
return "no container runtime (podman or docker) found on PATH"
|
||||
}
|
||||
if c.Err != nil {
|
||||
if c.Mode == "remote" {
|
||||
return fmt.Sprintf("podman remote socket unreachable (%s): %s", c.RemoteURL, c.Err.Error())
|
||||
}
|
||||
return c.Err.Error()
|
||||
}
|
||||
var missing []string
|
||||
if c.PandocBinary == "" {
|
||||
missing = append(missing, "pandoc")
|
||||
}
|
||||
if c.ChromiumBinary == "" {
|
||||
missing = append(missing, "chromium-browser")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Sprintf("conversion binary not found on PATH: %s — runtime image is missing the conversion toolchain (see zddc/runtime.Containerfile)", strings.Join(missing, ", "))
|
||||
}
|
||||
return "unavailable"
|
||||
}
|
||||
|
||||
|
|
@ -73,143 +68,75 @@ func Available() (Capabilities, bool) {
|
|||
return *p, p.Ready()
|
||||
}
|
||||
|
||||
// SetRemoteURL installs the podman remote socket URL for subsequent
|
||||
// Probe / Reprobe calls. Empty means "local mode" (the engine binary
|
||||
// creates containers in the same process). Called from
|
||||
// cmd/zddc-server/main.go after flag parsing, before Probe.
|
||||
func SetRemoteURL(url string) {
|
||||
s := url
|
||||
remoteURL.Store(&s)
|
||||
}
|
||||
|
||||
func currentRemoteURL() string {
|
||||
if p := remoteURL.Load(); p != nil {
|
||||
return *p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Probe locates the container engine and installs a containerRunner
|
||||
// as the package default. Call once at server startup. Returns the
|
||||
// captured Capabilities for logging.
|
||||
// Probe resolves the conversion binaries on PATH and installs the
|
||||
// localRunner. Call once at server startup. Returns the captured
|
||||
// Capabilities for logging.
|
||||
//
|
||||
// Engine order: engineOverride (if non-empty) → podman → docker. First
|
||||
// hit wins. Image presence is NOT probed: the runner uses
|
||||
// `--pull=missing` so the first conversion request will pull whichever
|
||||
// image it needs.
|
||||
// Image responsibility: the binaries on PATH should be the wrapper
|
||||
// scripts at /usr/local/bin/{pandoc,chromium-browser} (shipped by
|
||||
// zddc/runtime.Containerfile). Each wrapper handles cgroup setup
|
||||
// + bwrap sandbox + exec of the real binary at /usr/bin/<name>.
|
||||
// If an operator runs zddc-server outside the runtime image with
|
||||
// raw pandoc / chromium on PATH, the conversion still works but
|
||||
// without the per-call sandbox + resource caps.
|
||||
//
|
||||
// In remote mode (SetRemoteURL with non-empty URL), the probe also
|
||||
// invokes `<engine> --remote --url=<url> version` to confirm the
|
||||
// sidecar's socket is reachable. A reachable-engine-but-unreachable-
|
||||
// socket state surfaces as Ready=false so conversion requests serve
|
||||
// 503 until the sidecar comes up.
|
||||
//
|
||||
// Any failure here is non-fatal: the server still starts, conversion
|
||||
// Failure here is non-fatal: the server still starts, conversion
|
||||
// endpoints just return 503.
|
||||
func Probe(ctx context.Context, engineOverride string) Capabilities {
|
||||
func Probe(ctx context.Context) Capabilities {
|
||||
probeCool.Lock()
|
||||
defer probeCool.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
rURL := currentRemoteURL()
|
||||
c := Capabilities{
|
||||
PandocImage: currentPandocImage(),
|
||||
ChromiumImage: currentChromiumImage(),
|
||||
Mode: "local",
|
||||
RemoteURL: rURL,
|
||||
ProbedAt: now,
|
||||
c := Capabilities{ProbedAt: time.Now()}
|
||||
|
||||
pandocBin := currentPandocBinary()
|
||||
chromiumBin := currentChromiumBinary()
|
||||
|
||||
if p, err := exec.LookPath(pandocBin); err == nil {
|
||||
c.PandocBinary = p
|
||||
if v, err := probeVersion(ctx, p); err == nil {
|
||||
c.PandocVersion = v
|
||||
}
|
||||
}
|
||||
if rURL != "" {
|
||||
c.Mode = "remote"
|
||||
if p, err := exec.LookPath(chromiumBin); err == nil {
|
||||
c.ChromiumBinary = p
|
||||
if v, err := probeVersion(ctx, p); err == nil {
|
||||
c.ChromiumVersion = v
|
||||
}
|
||||
}
|
||||
|
||||
engine := resolveEngine(engineOverride)
|
||||
if engine == "" {
|
||||
c.Err = fmt.Errorf("no container runtime found (tried: %s)", strings.Join(enginesTried(engineOverride), ", "))
|
||||
if c.PandocBinary == "" || c.ChromiumBinary == "" {
|
||||
c.Err = fmt.Errorf("%s", c.Reason())
|
||||
caps.Store(&c)
|
||||
slog.Warn("convert: probe failed", "reason", c.Err.Error())
|
||||
return c
|
||||
}
|
||||
c.Engine = engine
|
||||
|
||||
if v, err := probeVersion(ctx, engine); err == nil {
|
||||
c.EngineVer = v
|
||||
}
|
||||
|
||||
if rURL != "" {
|
||||
if err := probeRemoteSocket(ctx, engine, rURL); err != nil {
|
||||
c.Err = err
|
||||
caps.Store(&c)
|
||||
slog.Warn("convert: remote socket probe failed",
|
||||
"engine", engine, "remote_url", rURL, "err", err)
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
InstallRunner(newContainerRunner(engine, rURL))
|
||||
InstallRunner(newLocalRunner())
|
||||
caps.Store(&c)
|
||||
slog.Info("convert: ready",
|
||||
"engine", engine,
|
||||
"engine_version", c.EngineVer,
|
||||
"mode", c.Mode,
|
||||
"remote_url", c.RemoteURL,
|
||||
"pandoc_image", c.PandocImage,
|
||||
"chromium_image", c.ChromiumImage)
|
||||
"pandoc_binary", c.PandocBinary,
|
||||
"pandoc_version", c.PandocVersion,
|
||||
"chromium_binary", c.ChromiumBinary,
|
||||
"chromium_version", c.ChromiumVersion)
|
||||
return c
|
||||
}
|
||||
|
||||
// probeRemoteSocket runs `<engine> --remote --url=<url> version` with
|
||||
// a short timeout. Returns nil on success; a wrapped error otherwise.
|
||||
// The remote URL is typically a Unix socket path
|
||||
// (unix:///var/run/podman/podman.sock) in the sidecar pattern but a
|
||||
// TCP form (tcp://host:port) is accepted too.
|
||||
func probeRemoteSocket(ctx context.Context, engine, url string) error {
|
||||
c := exec.CommandContext(ctx, engine, "--remote", "--url="+url, "version", "--format={{.Client.Version}}")
|
||||
out, err := c.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("podman --remote version: %w (output: %s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reprobe re-runs Probe with the existing configuration. Used by the
|
||||
// handler when a request hits a not-Ready state — gives the operator
|
||||
// a way to recover (e.g. installed podman after the server started)
|
||||
// without a server restart. Cooldown of 60 s between probes to keep
|
||||
// error-path requests cheap.
|
||||
func Reprobe(ctx context.Context, engineOverride string) Capabilities {
|
||||
// Reprobe re-runs Probe with the existing configuration. Used by
|
||||
// the handler when a request hits a not-Ready state — gives the
|
||||
// operator a way to recover (e.g. installed pandoc after server
|
||||
// start) without a server restart. Cooldown of 60 s between probes
|
||||
// to keep error-path requests cheap.
|
||||
func Reprobe(ctx context.Context) Capabilities {
|
||||
if p := caps.Load(); p != nil {
|
||||
if time.Since(p.ProbedAt) < 60*time.Second {
|
||||
return *p
|
||||
}
|
||||
}
|
||||
return Probe(ctx, engineOverride)
|
||||
return Probe(ctx)
|
||||
}
|
||||
|
||||
func resolveEngine(override string) string {
|
||||
if override != "" {
|
||||
if p, err := exec.LookPath(override); err == nil {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
for _, name := range []string{"podman", "docker"} {
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func enginesTried(override string) []string {
|
||||
if override != "" {
|
||||
return []string{override}
|
||||
}
|
||||
return []string{"podman", "docker"}
|
||||
}
|
||||
|
||||
func probeVersion(ctx context.Context, engine string) (string, error) {
|
||||
c := exec.CommandContext(ctx, engine, "--version")
|
||||
func probeVersion(ctx context.Context, binary string) (string, error) {
|
||||
c := exec.CommandContext(ctx, binary, "--version")
|
||||
out, err := c.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
|
|
|||
|
|
@ -10,39 +10,45 @@ import (
|
|||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Runner executes a conversion sub-process and returns its stdout.
|
||||
// The host-side implementation (containerRunner) wraps `podman run`
|
||||
// or `docker run`; tests use a fake.
|
||||
// Runner executes a conversion binary and returns its stdout. The
|
||||
// production implementation (localRunner) just exec's the binary
|
||||
// directly. Tests use a fake.
|
||||
//
|
||||
// image is the OCI image to invoke (e.g. "docker.io/pandoc/latex:latest"
|
||||
// or "docker.io/zenika/alpine-chrome:latest"). stdin is piped to the
|
||||
// container's stdin. cmd is the argv passed *to the image's entrypoint*
|
||||
// — for pandoc/latex the entrypoint is `pandoc`, for alpine-chrome it
|
||||
// is `chromium-browser`. mounts is a list of "<hostPath>:<containerPath>"
|
||||
// specs handed to --volume (":ro" is added if no mode segment is
|
||||
// present).
|
||||
// binary is the PATH-resolvable name (or absolute path) of the
|
||||
// conversion tool — typically "pandoc" or "chromium-browser". In the
|
||||
// production runtime image those names resolve to wrapper scripts at
|
||||
// /usr/local/bin/ that put the real binary into a cgroup + bwrap
|
||||
// sandbox before exec'ing it. From zddc-server's perspective, that
|
||||
// indirection is invisible: it just sees pandoc behavior.
|
||||
//
|
||||
// All exec calls in this package go through Runner.Run. This is the
|
||||
// first os/exec site in the codebase; the hardening here is the
|
||||
// pattern for future shell-outs.
|
||||
// stdin is piped to the binary's stdin. scratchDir is an optional
|
||||
// host directory the binary needs to read from / write to (template
|
||||
// + intermediate HTML + PDF output); passed to the child via the
|
||||
// ZDDC_SCRATCH env var, which the wrapper script bind-mounts into
|
||||
// the sandbox at the same path. Empty means "no scratch dir
|
||||
// needed" (DOCX flow — stdin to stdout, no files).
|
||||
//
|
||||
// cmd is the argv passed to the binary. Same shape across all
|
||||
// runners; no shell quoting; no engine-specific flags.
|
||||
//
|
||||
// All exec calls in this package go through Runner.Run.
|
||||
type Runner interface {
|
||||
Run(ctx context.Context, image string, stdin []byte, mounts []string, cmd []string) ([]byte, error)
|
||||
Run(ctx context.Context, binary string, stdin []byte, scratchDir string, cmd []string) ([]byte, error)
|
||||
}
|
||||
|
||||
// ErrUnavailable means no container runtime is present on the host.
|
||||
// Handlers translate to HTTP 503.
|
||||
// ErrUnavailable means the conversion binary couldn't be found on
|
||||
// PATH. Handlers translate to HTTP 503.
|
||||
var ErrUnavailable = errors.New("conversion unavailable")
|
||||
|
||||
// ConvertError carries the failure surface from a non-zero exit.
|
||||
// Stderr is captured (truncated to 4 KiB by the runner) so callers can
|
||||
// surface pandoc/chromium's own complaint.
|
||||
// Stderr is captured (truncated to 4 KiB by the runner) so callers
|
||||
// can surface the binary's own complaint.
|
||||
type ConvertError struct {
|
||||
Tool string // image name fragment, used only for logging
|
||||
Tool string // binary name, used only for logging
|
||||
ExitCode int
|
||||
Stderr string
|
||||
Cause error
|
||||
|
|
@ -53,78 +59,154 @@ func (e *ConvertError) Error() string {
|
|||
return "<nil>"
|
||||
}
|
||||
if e.Stderr != "" {
|
||||
return fmt.Sprintf("%s exit %d: %s", e.Tool, e.ExitCode, strings.TrimSpace(e.Stderr))
|
||||
return fmt.Sprintf("%s exit %d: %s", e.Tool, e.ExitCode, e.Stderr)
|
||||
}
|
||||
return fmt.Sprintf("%s exit %d: %v", e.Tool, e.ExitCode, e.Cause)
|
||||
}
|
||||
|
||||
func (e *ConvertError) Unwrap() error { return e.Cause }
|
||||
|
||||
// containerRunner runs each conversion inside a fresh container.
|
||||
// The engine ("podman" preferred, "docker" fallback) is resolved once
|
||||
// at startup by Probe. Resource limits are configurable via
|
||||
// SetLimits (called from main.go after flag parsing). Images are passed
|
||||
// per call so the same runner handles both pandoc and chromium
|
||||
// invocations.
|
||||
// localRunner exec's the conversion binary directly. The runtime
|
||||
// image's wrapper script (at /usr/local/bin/<binary>) handles
|
||||
// sandboxing + resource limits BETWEEN this exec and the real
|
||||
// binary — invisible to this Runner.
|
||||
//
|
||||
// Two modes:
|
||||
//
|
||||
// - **local** (remoteURL=""): the engine binary creates containers
|
||||
// directly on the host that runs zddc-server. Used for bare-metal
|
||||
// and host-podman deployments. Requires podman or docker on PATH.
|
||||
//
|
||||
// - **remote** (remoteURL="unix:///var/run/podman/podman.sock" or
|
||||
// similar): the engine binary is the local podman CLIENT, invoked
|
||||
// as `podman --remote --url=<remoteURL> run …`; the actual
|
||||
// container creation happens in whatever process owns the socket
|
||||
// (typically a `podman system service` sidecar in the same pod).
|
||||
// Used for the Kubernetes sidecar pattern so zddc-server's own
|
||||
// pod stays unprivileged. Bind-mount paths must resolve identically
|
||||
// on both sides — see scratchDir.
|
||||
//
|
||||
// The runner relies on `--pull=missing` so the operator never has to
|
||||
// pre-pull images: the first request that needs an image pulls it,
|
||||
// subsequent requests use the local cache. Both podman and docker
|
||||
// honour this flag identically.
|
||||
type containerRunner struct {
|
||||
mu sync.RWMutex
|
||||
engine string
|
||||
remoteURL string
|
||||
memMiB int
|
||||
cpus string
|
||||
pids int
|
||||
timeout time.Duration
|
||||
// Resource limits stored here are advisory only; the wrapper reads
|
||||
// them via env (ZDDC_CONV_MEM_MAX, ZDDC_CONV_PIDS_MAX) and applies
|
||||
// them to its transient cgroup. Wall-clock timeout IS enforced
|
||||
// here via context.WithTimeout.
|
||||
type localRunner struct {
|
||||
mu sync.RWMutex
|
||||
memMiB int
|
||||
pids int
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func newLocalRunner() *localRunner {
|
||||
return &localRunner{
|
||||
memMiB: 1024, // 1 GiB — matches the wrapper's default
|
||||
pids: 256,
|
||||
timeout: 60 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// SetLimits updates the resource ceilings advertised to the wrapper
|
||||
// script via env vars + the wall-clock timeout enforced here.
|
||||
// Zero values keep the previous setting (or constructor defaults).
|
||||
// Safe to call from multiple goroutines.
|
||||
func (lr *localRunner) SetLimits(memMiB int, pids int, timeout time.Duration) {
|
||||
lr.mu.Lock()
|
||||
defer lr.mu.Unlock()
|
||||
if memMiB > 0 {
|
||||
lr.memMiB = memMiB
|
||||
}
|
||||
if pids > 0 {
|
||||
lr.pids = pids
|
||||
}
|
||||
if timeout > 0 {
|
||||
lr.timeout = timeout
|
||||
}
|
||||
}
|
||||
|
||||
func (lr *localRunner) Run(ctx context.Context, binary string, stdin []byte, scratchDir string, cmd []string) ([]byte, error) {
|
||||
lr.mu.RLock()
|
||||
memMiB := lr.memMiB
|
||||
pids := lr.pids
|
||||
timeout := lr.timeout
|
||||
lr.mu.RUnlock()
|
||||
|
||||
if binary == "" {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
c := exec.CommandContext(runCtx, binary, cmd...)
|
||||
c.Cancel = func() error {
|
||||
if c.Process == nil {
|
||||
return nil
|
||||
}
|
||||
return c.Process.Kill()
|
||||
}
|
||||
c.WaitDelay = 2 * time.Second
|
||||
c.SysProcAttr = sysProcAttr()
|
||||
|
||||
// Minimal env passed to the wrapper. The wrapper does
|
||||
// --clearenv inside the bwrap sandbox so the real binary
|
||||
// sees only what bwrap re-injects (HOME, PATH, LANG). These
|
||||
// vars are read by the WRAPPER itself, not the binary, to
|
||||
// drive its cgroup setup + scratch-dir bind mount.
|
||||
env := []string{
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
"HOME=" + os.TempDir(),
|
||||
fmt.Sprintf("ZDDC_CONV_MEM_MAX=%dM", memMiB),
|
||||
fmt.Sprintf("ZDDC_CONV_PIDS_MAX=%d", pids),
|
||||
}
|
||||
if scratchDir != "" {
|
||||
env = append(env, "ZDDC_SCRATCH="+scratchDir)
|
||||
}
|
||||
c.Env = env
|
||||
c.Stdin = bytes.NewReader(stdin)
|
||||
|
||||
var stdoutBuf bytes.Buffer
|
||||
c.Stdout = &limitWriter{w: &stdoutBuf, max: 128 << 20}
|
||||
stderr := newRingWriter(4 << 10)
|
||||
c.Stderr = stderr
|
||||
|
||||
if err := c.Run(); err != nil {
|
||||
exitCode := -1
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = ee.ExitCode()
|
||||
}
|
||||
if runCtx.Err() == context.DeadlineExceeded {
|
||||
return nil, &ConvertError{
|
||||
Tool: binary,
|
||||
ExitCode: exitCode,
|
||||
Stderr: stderr.String(),
|
||||
Cause: fmt.Errorf("timeout after %s: %w", timeout, runCtx.Err()),
|
||||
}
|
||||
}
|
||||
return nil, &ConvertError{
|
||||
Tool: binary,
|
||||
ExitCode: exitCode,
|
||||
Stderr: stderr.String(),
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
return stdoutBuf.Bytes(), nil
|
||||
}
|
||||
|
||||
var (
|
||||
// shared default runner, populated by InstallRunner (called from
|
||||
// the health probe at startup once the engine is known).
|
||||
// the health probe at startup once the binaries are confirmed).
|
||||
defaultRunnerMu sync.RWMutex
|
||||
defaultRunner Runner
|
||||
)
|
||||
|
||||
// InstallRunner sets the package-level Runner used by ToDocx/ToHTML/ToPDF.
|
||||
// Tests inject a fake; production code lets the health probe install a
|
||||
// containerRunner. Safe to call from multiple goroutines.
|
||||
// InstallRunner sets the package-level Runner used by ToDocx/ToHTML/
|
||||
// ToPDF. Tests inject a fake; production code lets the health probe
|
||||
// install a localRunner. Safe to call from multiple goroutines.
|
||||
func InstallRunner(r Runner) {
|
||||
defaultRunnerMu.Lock()
|
||||
defaultRunner = r
|
||||
defaultRunnerMu.Unlock()
|
||||
}
|
||||
|
||||
// ConfigureLimits applies resource limits to the package-level Runner,
|
||||
// if it's a containerRunner. No-op when no runner is installed yet
|
||||
// (the probe failed) or when the installed runner doesn't accept
|
||||
// ConfigureLimits applies resource limits to the package-level
|
||||
// Runner, if it's a localRunner. No-op when no runner is installed
|
||||
// yet (the probe failed) or when the installed runner doesn't accept
|
||||
// limits (e.g. a test fake). Zero values keep the previous setting.
|
||||
//
|
||||
// Called from cmd/zddc-server/main.go after Probe so the limits from
|
||||
// the operator's flags take effect before any conversion request lands.
|
||||
func ConfigureLimits(memMiB int, cpus string, pids int, timeout time.Duration) {
|
||||
// Called from cmd/zddc-server/main.go after Probe so the limits
|
||||
// from the operator's flags take effect before any conversion
|
||||
// request lands.
|
||||
func ConfigureLimits(memMiB int, pids int, timeout time.Duration) {
|
||||
defaultRunnerMu.RLock()
|
||||
r := defaultRunner
|
||||
defaultRunnerMu.RUnlock()
|
||||
if cr, ok := r.(*containerRunner); ok {
|
||||
cr.SetLimits(memMiB, cpus, pids, timeout)
|
||||
if lr, ok := r.(*localRunner); ok {
|
||||
lr.SetLimits(memMiB, pids, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -135,204 +217,8 @@ func currentRunner() Runner {
|
|||
return r
|
||||
}
|
||||
|
||||
// SetLimits updates the resource ceilings used for subsequent Run
|
||||
// invocations. Zero values keep the previous setting (or the defaults
|
||||
// set at construction). Safe to call from multiple goroutines.
|
||||
func (cr *containerRunner) SetLimits(memMiB int, cpus string, pids int, timeout time.Duration) {
|
||||
cr.mu.Lock()
|
||||
defer cr.mu.Unlock()
|
||||
if memMiB > 0 {
|
||||
cr.memMiB = memMiB
|
||||
}
|
||||
if cpus != "" {
|
||||
cr.cpus = cpus
|
||||
}
|
||||
if pids > 0 {
|
||||
cr.pids = pids
|
||||
}
|
||||
if timeout > 0 {
|
||||
cr.timeout = timeout
|
||||
}
|
||||
}
|
||||
|
||||
func newContainerRunner(engine, remoteURL string) *containerRunner {
|
||||
return &containerRunner{
|
||||
engine: engine,
|
||||
remoteURL: remoteURL,
|
||||
memMiB: 512,
|
||||
cpus: "2",
|
||||
pids: 100,
|
||||
timeout: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Run executes one container invocation. cmd is the argv passed to the
|
||||
// image's entrypoint (pandoc for pandoc/latex, chromium-browser for
|
||||
// alpine-chrome). mounts is a list of "<hostPath>:<containerPath>"
|
||||
// strings; ":ro" is appended when no mode segment is present. stdin is
|
||||
// piped to the container, stdout is returned as bytes (capped at
|
||||
// 128 MiB).
|
||||
//
|
||||
// Hardening:
|
||||
// - --pull=missing: image is fetched on first use, cached after.
|
||||
// Operator only needs podman/docker installed; no manual pull.
|
||||
// - --rm: container is removed on exit, even if killed.
|
||||
// - --network=none: no network inside the container. Prevents data
|
||||
// exfiltration through embedded URLs in source documents.
|
||||
// - --read-only + tmpfs on /tmp and /run: image fs is immutable;
|
||||
// pandoc/chromium scratch goes to tmpfs only.
|
||||
// - --memory / --cpus / --pids-limit: kernel-enforced caps.
|
||||
// - --cap-drop=ALL + --security-opt=no-new-privileges: standard
|
||||
// container-escape hardening.
|
||||
// - context-cancel kill + WaitDelay: a wedged podman gets force-
|
||||
// killed; pipes drop after 2s so we don't leak goroutines.
|
||||
// - cmd.Env minimal: only PATH + HOME are passed through to the
|
||||
// engine binary; the container itself sees only what the image
|
||||
// bakes in plus what --env adds (HOME=/tmp).
|
||||
//
|
||||
// Note: --user is intentionally NOT set so each image uses its
|
||||
// default user (pandoc/latex runs as root, alpine-chrome runs as
|
||||
// uid 1000). With --read-only + tmpfs + --cap-drop=ALL +
|
||||
// --network=none + --no-new-privileges the additional defense from
|
||||
// forcing nobody is small and would break alpine-chrome's own
|
||||
// user-data-dir layout.
|
||||
func (cr *containerRunner) Run(ctx context.Context, image string, stdin []byte, mounts []string, cmd []string) ([]byte, error) {
|
||||
cr.mu.RLock()
|
||||
engine := cr.engine
|
||||
remoteURL := cr.remoteURL
|
||||
memMiB := cr.memMiB
|
||||
cpus := cr.cpus
|
||||
pids := cr.pids
|
||||
timeout := cr.timeout
|
||||
cr.mu.RUnlock()
|
||||
|
||||
if engine == "" {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if image == "" {
|
||||
return nil, fmt.Errorf("convert.Run: image is empty")
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
// Client args. In remote mode, prepend --remote and --url so the
|
||||
// podman CLI dispatches the request to the sidecar's
|
||||
// `podman system service` instead of creating a container locally.
|
||||
// The remaining flags (--rm, --pull=missing, etc.) apply to the
|
||||
// container that the remote daemon will create — same wire format
|
||||
// as local mode.
|
||||
var args []string
|
||||
if remoteURL != "" {
|
||||
args = append(args, "--remote", "--url="+remoteURL)
|
||||
}
|
||||
args = append(args,
|
||||
"run",
|
||||
"--rm",
|
||||
"--pull=missing",
|
||||
"-i",
|
||||
)
|
||||
// --userns=host only in local mode: needed when zddc-server itself
|
||||
// is the one running podman inside a Kubernetes pod, because the
|
||||
// kernel won't let an inner rootless podman set up its own userns
|
||||
// via newuidmap. In remote (sidecar) mode the sidecar runs as root
|
||||
// and creates the inner container in its own (rootful) namespace,
|
||||
// so --userns=host is unnecessary and potentially noisy.
|
||||
if remoteURL == "" {
|
||||
args = append(args, "--userns=host")
|
||||
}
|
||||
args = append(args,
|
||||
"--network=none",
|
||||
"--read-only",
|
||||
// /tmp must be large enough to host chromium's shared-memory
|
||||
// fallback (--disable-dev-shm-usage redirects /dev/shm writes
|
||||
// here) plus the user-data-dir. 256 MiB is plenty for the
|
||||
// HTML→PDF flow; pandoc itself uses almost none.
|
||||
"--tmpfs=/tmp:size=256m,exec",
|
||||
"--tmpfs=/run:size=4m",
|
||||
fmt.Sprintf("--memory=%dm", memMiB),
|
||||
fmt.Sprintf("--cpus=%s", cpus),
|
||||
fmt.Sprintf("--pids-limit=%d", pids),
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges",
|
||||
"--env=HOME=/tmp",
|
||||
"--workdir=/tmp",
|
||||
)
|
||||
for _, m := range mounts {
|
||||
if !strings.Contains(m, ":ro") && !strings.Contains(m, ":rw") {
|
||||
m += ":ro"
|
||||
}
|
||||
args = append(args, "--volume="+m)
|
||||
}
|
||||
args = append(args, image)
|
||||
args = append(args, cmd...)
|
||||
|
||||
c := exec.CommandContext(runCtx, engine, args...)
|
||||
c.Cancel = func() error {
|
||||
if c.Process == nil {
|
||||
return nil
|
||||
}
|
||||
return c.Process.Kill()
|
||||
}
|
||||
c.WaitDelay = 2 * time.Second
|
||||
c.SysProcAttr = sysProcAttr()
|
||||
c.Env = []string{
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
"HOME=" + os.TempDir(),
|
||||
}
|
||||
c.Stdin = bytes.NewReader(stdin)
|
||||
|
||||
var stdoutBuf bytes.Buffer
|
||||
c.Stdout = &limitWriter{w: &stdoutBuf, max: 128 << 20}
|
||||
stderr := newRingWriter(4 << 10)
|
||||
c.Stderr = stderr
|
||||
|
||||
err := c.Run()
|
||||
if err != nil {
|
||||
exitCode := -1
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = ee.ExitCode()
|
||||
}
|
||||
toolName := imageTag(image)
|
||||
if runCtx.Err() == context.DeadlineExceeded {
|
||||
return nil, &ConvertError{
|
||||
Tool: toolName,
|
||||
ExitCode: exitCode,
|
||||
Stderr: stderr.String(),
|
||||
Cause: fmt.Errorf("timeout after %s: %w", timeout, runCtx.Err()),
|
||||
}
|
||||
}
|
||||
return nil, &ConvertError{
|
||||
Tool: toolName,
|
||||
ExitCode: exitCode,
|
||||
Stderr: stderr.String(),
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
return stdoutBuf.Bytes(), nil
|
||||
}
|
||||
|
||||
// imageTag extracts a short name for an image reference, used as the
|
||||
// "Tool" label on ConvertError. "docker.io/pandoc/latex:latest" →
|
||||
// "pandoc/latex".
|
||||
func imageTag(image string) string {
|
||||
s := image
|
||||
// Strip registry prefix.
|
||||
if i := strings.Index(s, "/"); i >= 0 {
|
||||
if strings.Contains(s[:i], ".") || strings.Contains(s[:i], ":") {
|
||||
s = s[i+1:]
|
||||
}
|
||||
}
|
||||
// Strip tag suffix.
|
||||
if i := strings.LastIndex(s, ":"); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// limitWriter caps the underlying buffer at max bytes. Writes past the
|
||||
// cap return io.ErrShortWrite, which surfaces as a Run() error — the
|
||||
// limitWriter caps the underlying buffer at max bytes. Writes past
|
||||
// the cap return an error which surfaces as a Run() error — the
|
||||
// caller then maps to 422 (output too large) at the handler edge.
|
||||
type limitWriter struct {
|
||||
w io.Writer
|
||||
|
|
@ -355,9 +241,9 @@ func (l *limitWriter) Write(p []byte) (int, error) {
|
|||
return n, err
|
||||
}
|
||||
|
||||
// ringWriter keeps only the tail of what's written — useful for stderr
|
||||
// capture where the most-recent bytes are the ones with the actual
|
||||
// error message and earlier output is usually progress noise.
|
||||
// ringWriter keeps only the tail of what's written — useful for
|
||||
// stderr capture where the most-recent bytes carry the actual error
|
||||
// message and earlier output is usually progress noise.
|
||||
type ringWriter struct {
|
||||
mu sync.Mutex
|
||||
buf []byte
|
||||
|
|
@ -391,16 +277,14 @@ func (r *ringWriter) String() string {
|
|||
// 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 container.
|
||||
// ToHTML which needs the template visible inside the sandbox.
|
||||
//
|
||||
// scratchRoot controls where the temp dir lands. Empty means "use
|
||||
// $TMPDIR" (local mode default). In remote/sidecar mode the caller
|
||||
// passes the shared mount path (e.g. "/work") so the podman-service
|
||||
// sidecar sees the bind-mount source at the same path.
|
||||
// scratchRoot controls where the temp dir lands. Empty means
|
||||
// "use $TMPDIR".
|
||||
//
|
||||
// Files are written world-readable so the container's default user
|
||||
// (root for pandoc/latex, uid 1000 for alpine-chrome) can read them
|
||||
// through the read-only bind mount regardless of the host's umask.
|
||||
// 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) {
|
||||
dir, err := os.MkdirTemp(scratchRoot, "zddc-convert-")
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ func safeJoin(fsRoot, relPath string) (string, bool) {
|
|||
// The decider is queried per subdirectory; nil falls back to the internal
|
||||
// Go evaluator (policy.InternalDecider) for tests that don't wire up
|
||||
// an explicit decider.
|
||||
func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, userEmail, baseURL string, includeHidden bool) ([]listing.FileInfo, error) {
|
||||
func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, userEmail, baseURL string, includeHidden, elevated bool) ([]listing.FileInfo, error) {
|
||||
if decider == nil {
|
||||
decider = &policy.InternalDecider{}
|
||||
}
|
||||
|
|
@ -47,6 +47,17 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
|||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
// Virtual received/ window: when the URL points at <workflow>/received/
|
||||
// (i.e. the URL traverses a `received` segment whose workflow-folder
|
||||
// parent declares received_path in its .zddc), redirect the listing
|
||||
// source to the canonical received/<tracking>/ path. Entry URLs stay
|
||||
// rooted at baseURL so the browse client keeps the workflow context —
|
||||
// drag-drop onto an entry here PUTs to <workflow>/received/<file>,
|
||||
// which serveFilePut intercepts and rewrites to <workflow>/<base>+C<n><suffix>.
|
||||
if vr := zddc.ResolveVirtualReceived(fsRoot, strings.TrimSuffix(baseURL, "/")); vr.Resolved && vr.IsRoot {
|
||||
absDir = vr.ReceivedAbs
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(absDir)
|
||||
if err != nil {
|
||||
// Empty-listing fallback for cascade-declared paths. A fresh
|
||||
|
|
@ -82,6 +93,16 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
|||
declaredSet[strings.ToLower(name)] = true
|
||||
}
|
||||
|
||||
// Parent-dir chain + active-admin status. Files in this directory
|
||||
// inherit authorization from this chain, so we compute it once
|
||||
// and reuse for every file entry's Writable bit. Subdirectories
|
||||
// build their own chain (the child cascade can differ — e.g. a
|
||||
// per-user fenced home).
|
||||
parentChain, _ := zddc.EffectivePolicy(fsRoot, absDir)
|
||||
principal := zddc.Principal{Email: userEmail, Elevated: elevated}
|
||||
parentActiveAdmin := elevated && userEmail != "" &&
|
||||
zddc.IsAdminForChain(parentChain, userEmail)
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
|
||||
|
|
@ -112,10 +133,19 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
|||
continue
|
||||
}
|
||||
subURLPath := baseURL + name + "/"
|
||||
allowed, _ := policy.AllowFromChain(ctx, decider, chain, userEmail, subURLPath)
|
||||
allowed, _ := policy.AllowFromChainP(ctx, decider, chain, principal, subURLPath)
|
||||
if !allowed {
|
||||
continue // omit denied directories silently
|
||||
}
|
||||
// Pull the title from this subdir's own .zddc, if it has
|
||||
// one. Lets clients render project / folder names without
|
||||
// a second round-trip per entry — the landing page used
|
||||
// to need a bespoke /api with this info; now the generic
|
||||
// listing carries it.
|
||||
var title string
|
||||
if zf, perr := zddc.ParseFile(filepath.Join(subAbs, ".zddc")); perr == nil {
|
||||
title = zf.Title
|
||||
}
|
||||
fi := listing.FileInfo{
|
||||
Name: name + "/",
|
||||
Size: info.Size(),
|
||||
|
|
@ -125,6 +155,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
|||
IsDir: true,
|
||||
DisplayName: displayName,
|
||||
Declared: declared,
|
||||
Title: title,
|
||||
}
|
||||
result = append(result, fi)
|
||||
continue
|
||||
|
|
@ -141,6 +172,26 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
|||
DisplayName: displayName,
|
||||
Declared: declared,
|
||||
}
|
||||
// Writable surfaces whether THIS principal could PUT this file
|
||||
// — same decision as the file API's authorizeAction would
|
||||
// reach. Uses the parent-dir chain (computed once above);
|
||||
// active-admin status short-circuits the per-file decider
|
||||
// query when the principal already holds admin authority.
|
||||
// .zddc requires ActionAdmin (not ActionWrite) so the verb
|
||||
// matches the file API's gate at fileapi.go:362-364.
|
||||
action := policy.ActionWrite
|
||||
if name == ".zddc" {
|
||||
action = policy.ActionAdmin
|
||||
}
|
||||
fileURL := baseURL + name
|
||||
if parentActiveAdmin {
|
||||
fi.Writable = true
|
||||
} else {
|
||||
allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, fileURL, action)
|
||||
if allowed {
|
||||
fi.Writable = true
|
||||
}
|
||||
}
|
||||
result = append(result, fi)
|
||||
}
|
||||
|
||||
|
|
@ -161,9 +212,143 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
|||
// to real ones.
|
||||
result = append(result, virtualCanonicalFolders(fsRoot, absDir, baseURL, result, displayMap)...)
|
||||
|
||||
// Project-level virtual table views: SSR aggregates one row per
|
||||
// party folder under archive/; MDL/RSK rollups aggregate every
|
||||
// row from each party's mdl/ or rsk/. The listing surfaces
|
||||
// synthetic row entries (Writable bit per the canonical
|
||||
// archive/<party>/ chain) plus synthetic table.yaml/form.yaml
|
||||
// entries so the tables tool's client-side walkServer finds the
|
||||
// spec without a 404 round-trip. Spec bytes are served by the
|
||||
// main.go IsDefaultSpec fallback; row reads go through
|
||||
// handler.ServeVirtualViewRow which path-injects name/party.
|
||||
if vv := zddc.ResolveVirtualView(fsRoot, strings.TrimSuffix(baseURL, "/")); vv.Resolved && vv.Kind.IsRootKind() {
|
||||
partyChains := make(map[string]zddc.PolicyChain)
|
||||
chainFor := func(partyAbs string) zddc.PolicyChain {
|
||||
if c, ok := partyChains[partyAbs]; ok {
|
||||
return c
|
||||
}
|
||||
c, _ := zddc.EffectivePolicy(fsRoot, partyAbs)
|
||||
partyChains[partyAbs] = c
|
||||
return c
|
||||
}
|
||||
appendVirtualRow := func(syntheticName, partyAbs string) {
|
||||
rowURL := baseURL + url.PathEscape(syntheticName)
|
||||
chain := chainFor(partyAbs)
|
||||
if allowed, _ := policy.AllowFromChainP(ctx, decider, chain, principal, rowURL); !allowed {
|
||||
return
|
||||
}
|
||||
partyActiveAdmin := elevated && userEmail != "" &&
|
||||
zddc.IsAdminForChain(chain, userEmail)
|
||||
writable := partyActiveAdmin
|
||||
if !writable {
|
||||
allowed, _ := policy.AllowActionFromChainP(ctx, decider, chain, principal, rowURL, policy.ActionWrite)
|
||||
writable = allowed
|
||||
}
|
||||
result = append(result, listing.FileInfo{
|
||||
Name: syntheticName,
|
||||
URL: rowURL,
|
||||
IsDir: false,
|
||||
Virtual: true,
|
||||
Writable: writable,
|
||||
})
|
||||
}
|
||||
|
||||
switch vv.Slot {
|
||||
case "ssr":
|
||||
parties, _ := zddc.ListSSRParties(fsRoot, vv.ProjectAbs)
|
||||
for _, party := range parties {
|
||||
partyAbs := filepath.Join(vv.ProjectAbs, "archive", party)
|
||||
appendVirtualRow(party+".yaml", partyAbs)
|
||||
}
|
||||
case "mdl", "rsk":
|
||||
rows, _ := zddc.ListRollupRows(fsRoot, vv.ProjectAbs, vv.Slot)
|
||||
for _, row := range rows {
|
||||
partyAbs := filepath.Join(vv.ProjectAbs, "archive", row.Party)
|
||||
appendVirtualRow(row.SyntheticName, partyAbs)
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result,
|
||||
listing.FileInfo{Name: "table.yaml", URL: baseURL + "table.yaml", IsDir: false, Virtual: true},
|
||||
listing.FileInfo{Name: "form.yaml", URL: baseURL + "form.yaml", IsDir: false, Virtual: true},
|
||||
)
|
||||
}
|
||||
|
||||
// Workflow folder: append a virtual `received/` entry whose backing
|
||||
// is .zddc.received_path. The entry's URL stays under the workflow
|
||||
// folder (baseURL + "received/") so a click navigates "into" the
|
||||
// synthetic child — the listing handler then swaps the read source
|
||||
// to the canonical received/<tracking>/ path while keeping the URL
|
||||
// context intact. Suppressed if a real `received/` already exists on
|
||||
// disk (operator override).
|
||||
if rp := zddc.WorkflowReceivedPath(absDir); rp != "" {
|
||||
hasReal := false
|
||||
for _, fi := range result {
|
||||
if fi.IsDir && strings.EqualFold(strings.TrimSuffix(fi.Name, "/"), "received") {
|
||||
hasReal = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasReal {
|
||||
result = append(result, listing.FileInfo{
|
||||
Name: "received/",
|
||||
URL: baseURL + "received/",
|
||||
IsDir: true,
|
||||
Virtual: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Surface a virtual `.zddc` entry when the on-disk file doesn't
|
||||
// exist. The /<dir>/.zddc URL always serves SOMETHING — real
|
||||
// bytes if present, a synthetic placeholder body otherwise (see
|
||||
// handler.ServeZddcFile) — so the entry resolves to a real
|
||||
// editable view either way. PUT-ing back materialises the file
|
||||
// on disk and the listing converts to a real (non-virtual) row
|
||||
// automatically on the next fetch. Only emitted when the caller
|
||||
// asked for hidden entries (?hidden=1), matching the dot-prefix
|
||||
// hide rule used for every other dotfile.
|
||||
if includeHidden {
|
||||
if v, ok := virtualZddcEntry(ctx, decider, parentChain, principal, parentActiveAdmin, absDir, baseURL); ok {
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// virtualZddcEntry returns a synthetic listing entry for absDir/.zddc
|
||||
// when no real file exists. The cascade has effective rules at every
|
||||
// path (down through embedded defaults), so editing this virtual
|
||||
// entry is always meaningful — a save promotes it to a real on-disk
|
||||
// .zddc that overrides ancestor levels for this directory.
|
||||
//
|
||||
// Writable mirrors the real-file path: ActionAdmin against the parent
|
||||
// chain, short-circuited when the principal already holds admin
|
||||
// authority. An elevated admin sees writable=true and the editor lets
|
||||
// them save; a non-admin sees writable=false and the editor mounts
|
||||
// read-only.
|
||||
func virtualZddcEntry(ctx context.Context, decider policy.Decider, parentChain zddc.PolicyChain, principal zddc.Principal, parentActiveAdmin bool, absDir, baseURL string) (listing.FileInfo, bool) {
|
||||
zddcPath := filepath.Join(absDir, ".zddc")
|
||||
if _, err := os.Stat(zddcPath); err == nil {
|
||||
return listing.FileInfo{}, false
|
||||
} else if !os.IsNotExist(err) {
|
||||
return listing.FileInfo{}, false
|
||||
}
|
||||
writable := parentActiveAdmin
|
||||
if !writable {
|
||||
allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, baseURL+".zddc", policy.ActionAdmin)
|
||||
writable = allowed
|
||||
}
|
||||
return listing.FileInfo{
|
||||
Name: ".zddc",
|
||||
URL: baseURL + ".zddc",
|
||||
IsDir: false,
|
||||
Virtual: true,
|
||||
Writable: writable,
|
||||
}, true
|
||||
}
|
||||
|
||||
// virtualCanonicalFolders returns synthetic entries for any
|
||||
// cascade-declared child name that's absent from the on-disk
|
||||
// listing. Sources from zddc.ChildrenDeclaredAt — the cascade's
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ func TestListDirectory_VirtualUserHome_AppearsWhenMissing(t *testing.T) {
|
|||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false)
|
||||
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
|
|
@ -56,7 +56,7 @@ func TestListDirectory_VirtualUserHome_SuppressedWhenRealExists(t *testing.T) {
|
|||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false)
|
||||
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
|
|
@ -74,7 +74,7 @@ func TestListDirectory_VirtualUserHome_AnonymousNoEntry(t *testing.T) {
|
|||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "" /* no viewer */, "/Proj/working/", false)
|
||||
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "" /* no viewer */, "/Proj/working/", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
|
|
@ -92,7 +92,7 @@ func TestListDirectory_VirtualUserHome_OutsideWorkingNoEntry(t *testing.T) {
|
|||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
got, err := ListDirectory(context.Background(), nil, root, "Proj/staging", "alice@example.com", "/Proj/staging/", false)
|
||||
got, err := ListDirectory(context.Background(), nil, root, "Proj/staging", "alice@example.com", "/Proj/staging/", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
|
|
@ -113,7 +113,7 @@ func TestListDirectory_VirtualUserHome_DeepWorkingNoEntry(t *testing.T) {
|
|||
|
||||
got, err := ListDirectory(context.Background(), nil, root,
|
||||
"Proj/working/alice@example.com", "alice@example.com",
|
||||
"/Proj/working/alice@example.com/", false)
|
||||
"/Proj/working/alice@example.com/", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
|
|
@ -132,7 +132,7 @@ func TestListDirectory_VirtualUserHome_CaseFoldWorking(t *testing.T) {
|
|||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
got, err := ListDirectory(context.Background(), nil, root, "Proj/Working", "alice@example.com", "/Proj/Working/", false)
|
||||
got, err := ListDirectory(context.Background(), nil, root, "Proj/Working", "alice@example.com", "/Proj/Working/", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
|
|
@ -165,7 +165,7 @@ func TestListDirectory_CanonicalProjectFolder_EmptyWhenMissing(t *testing.T) {
|
|||
|
||||
for _, stage := range []string{"working", "staging", "reviewing", "archive"} {
|
||||
got, err := ListDirectory(context.Background(), nil, root,
|
||||
"Proj/"+stage, "alice@example.com", "/Proj/"+stage+"/", false)
|
||||
"Proj/"+stage, "alice@example.com", "/Proj/"+stage+"/", false, false)
|
||||
if err != nil {
|
||||
t.Errorf("ListDirectory(Proj/%s) on missing dir: err = %v, want nil", stage, err)
|
||||
continue
|
||||
|
|
@ -199,7 +199,7 @@ func TestListDirectory_NonCanonicalMissing_StillNotFound(t *testing.T) {
|
|||
|
||||
_, err := ListDirectory(context.Background(), nil, root,
|
||||
"Proj/random-folder-that-doesnt-exist", "alice@example.com",
|
||||
"/Proj/random-folder-that-doesnt-exist/", false)
|
||||
"/Proj/random-folder-that-doesnt-exist/", false, false)
|
||||
if !os.IsNotExist(err) {
|
||||
t.Errorf("ListDirectory on a non-canonical missing path: err = %v, want IsNotExist", err)
|
||||
}
|
||||
|
|
|
|||
274
zddc/internal/handler/accepthandler.go
Normal file
274
zddc/internal/handler/accepthandler.go
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"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"
|
||||
)
|
||||
|
||||
// Accept Transmittal — the doc-controller's "file a counterparty
|
||||
// upload into the immutable received archive" step. Right-click on a
|
||||
// single transmittal folder under archive/<party>/incoming/ in the
|
||||
// browse app; the client POSTs X-ZDDC-Op: accept-transmittal with the
|
||||
// body below.
|
||||
//
|
||||
// Authorisation model — same primitives as Plan Review, no exceptions:
|
||||
//
|
||||
// - ActionWrite on incoming/<transmittal>/ (move source).
|
||||
// document_controller has rwcd on incoming/ via the cascade defaults.
|
||||
// - ActionCreate on received/<tracking>/ (move destination, WORM zone).
|
||||
// document_controller has `cr` here via worm: [document_controller].
|
||||
//
|
||||
// Operation:
|
||||
//
|
||||
// 1. Parse URL — must be a direct child of archive/<party>/incoming/.
|
||||
// 2. Validate the transmittal folder name via ParseTransmittalFolder
|
||||
// (date, tracking, status, title). Reject if not well-formed.
|
||||
// 3. Validate every file in the folder via ParseFilename. Each file's
|
||||
// parsed tracking must match the folder's tracking. Reject on any
|
||||
// non-conformance — client should cancel and tell sender to fix.
|
||||
// 4. ACL pre-flight (source write, destination create).
|
||||
// 5. mkdir received/ (parent of the destination) if missing.
|
||||
// 6. If received/<tracking>/ does NOT exist → os.Rename the whole
|
||||
// folder (atomic, fast).
|
||||
// If received/<tracking>/ DOES exist (re-submission of the same
|
||||
// tracking) → per-file move. Refuse if any child filename already
|
||||
// exists at the destination — WORM forbids overwrite.
|
||||
// 7. Optional Plan Review chain: when the body's setup_plan_review
|
||||
// flag is true, the same handler dispatches through Plan Review's
|
||||
// three-stage flow against the new received/<tracking>/ URL. The
|
||||
// ACL gates re-run there (idempotent against the same principal),
|
||||
// which is correct: both authorities are required by design.
|
||||
//
|
||||
// The accept itself does NOT write received/<tracking>/.zddc — the
|
||||
// cascade's worm: [document_controller] inheritance is enough. If
|
||||
// Plan Review is chained, IT writes the .zddc with planned dates.
|
||||
// Filesystem mtime on the moved folder records when the accept
|
||||
// happened; the audit log records who.
|
||||
|
||||
const opAcceptTransmittal = "accept-transmittal"
|
||||
|
||||
// incomingURLPattern matches /<project>/archive/<party>/incoming/<transmittal>/.
|
||||
var incomingURLPattern = regexp.MustCompile(`^/([^/]+)/archive/([^/]+)/incoming/([^/]+)/?$`)
|
||||
|
||||
type acceptRequest struct {
|
||||
ReceivedDate string `yaml:"received_date"`
|
||||
SetupPlanReview bool `yaml:"setup_plan_review"`
|
||||
ReviewLead string `yaml:"review_lead"`
|
||||
Approver string `yaml:"approver"`
|
||||
PlanReviewCompleteDate string `yaml:"plan_review_complete_date"`
|
||||
PlanResponseDate string `yaml:"plan_response_date"`
|
||||
}
|
||||
|
||||
type acceptResponse struct {
|
||||
Tracking string `json:"tracking"`
|
||||
IncomingPath string `json:"incoming_path"`
|
||||
ReceivedPath string `json:"received_path"`
|
||||
MovedFiles int `json:"moved_files"`
|
||||
Merged bool `json:"merged"`
|
||||
PlanReview *planReviewResponse `json:"plan_review,omitempty"`
|
||||
}
|
||||
|
||||
func serveAcceptTransmittal(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||
cleanURL := "/" + strings.Trim(r.URL.Path, "/") + "/"
|
||||
m := incomingURLPattern.FindStringSubmatch(cleanURL)
|
||||
if m == nil {
|
||||
http.Error(w, "Bad Request — accept-transmittal must POST to /<project>/archive/<party>/incoming/<transmittal>/", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
project, party, transmittalFolder := m[1], m[2], m[3]
|
||||
|
||||
date, tracking, _, _, ok := zddc.ParseTransmittalFolder(transmittalFolder)
|
||||
if !ok {
|
||||
http.Error(w, "Bad Request — folder name does not conform to ZDDC transmittal grammar (expected YYYY-MM-DD_<tracking> (<status>) - <title>)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_ = date // available for audit; mtime carries the actual accept time
|
||||
|
||||
body, ok2 := readBodyCapped(cfg, w, r)
|
||||
if !ok2 {
|
||||
return
|
||||
}
|
||||
var req acceptRequest
|
||||
if len(body) > 0 {
|
||||
if err := yaml.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, "Bad Request — could not parse YAML body: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.SetupPlanReview {
|
||||
if req.ReviewLead == "" || req.Approver == "" ||
|
||||
req.PlanReviewCompleteDate == "" || req.PlanResponseDate == "" {
|
||||
http.Error(w, "Bad Request — setup_plan_review requires review_lead, approver, plan_review_complete_date, plan_response_date", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
incomingAbs := filepath.Join(cfg.Root, project, "archive", party, "incoming", transmittalFolder)
|
||||
receivedAbs := filepath.Join(cfg.Root, project, "archive", party, "received", tracking)
|
||||
receivedURL := "/" + project + "/archive/" + party + "/received/" + tracking + "/"
|
||||
|
||||
// Source must exist as a directory.
|
||||
srcInfo, err := os.Stat(incomingAbs)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, "Internal Server Error — stat source: "+err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !srcInfo.IsDir() {
|
||||
http.Error(w, "Bad Request — accept-transmittal target is not a directory", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate every file in the folder before any side-effect.
|
||||
entries, err := os.ReadDir(incomingAbs)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error — read source: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var fileNames []string
|
||||
var violations []string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, ".") {
|
||||
continue // skip dotfiles silently (e.g. .zddc dropped by counterparty)
|
||||
}
|
||||
if e.IsDir() {
|
||||
violations = append(violations, name+": nested directories are not permitted in a transmittal folder")
|
||||
continue
|
||||
}
|
||||
parsed := zddc.ParseFilename(name)
|
||||
if !parsed.Valid {
|
||||
violations = append(violations, name+": does not conform to ZDDC filename grammar")
|
||||
continue
|
||||
}
|
||||
if parsed.TrackingNumber != tracking {
|
||||
violations = append(violations, fmt.Sprintf("%s: tracking %q does not match folder tracking %q", name, parsed.TrackingNumber, tracking))
|
||||
continue
|
||||
}
|
||||
fileNames = append(fileNames, name)
|
||||
}
|
||||
if len(violations) > 0 {
|
||||
http.Error(w, "Conflict — transmittal folder contents do not conform:\n"+strings.Join(violations, "\n"), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if len(fileNames) == 0 {
|
||||
http.Error(w, "Conflict — transmittal folder is empty", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// ACL pre-flight: source needs Write (rename out), destination needs Create.
|
||||
if !authorizeAction(cfg, w, r, incomingAbs, cleanURL, policy.ActionWrite) {
|
||||
return
|
||||
}
|
||||
if !authorizeAction(cfg, w, r, receivedAbs, receivedURL, policy.ActionCreate) {
|
||||
return
|
||||
}
|
||||
|
||||
email := EmailFromContext(r)
|
||||
if email == "" {
|
||||
http.Error(w, "Forbidden — no authenticated principal", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure received/'s parent exists (received/ itself materialises via
|
||||
// the rename or the per-file moves below).
|
||||
receivedParent := filepath.Dir(receivedAbs)
|
||||
if err := os.MkdirAll(receivedParent, 0o755); err != nil {
|
||||
auditFile(r, "accept-transmittal", cleanURL, http.StatusInternalServerError, 0, err)
|
||||
http.Error(w, "Internal Server Error — mkdir received/: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
merged := false
|
||||
if _, err := os.Stat(receivedAbs); err == nil {
|
||||
// Re-submission of an already-accepted tracking → merge per-file.
|
||||
// Refuse any filename collision; WORM forbids overwriting.
|
||||
merged = true
|
||||
for _, name := range fileNames {
|
||||
dst := filepath.Join(receivedAbs, name)
|
||||
if _, statErr := os.Stat(dst); statErr == nil {
|
||||
http.Error(w, "Conflict — "+name+" already exists in received/"+tracking+"/ (WORM forbids overwrite)", http.StatusConflict)
|
||||
return
|
||||
} else if !errors.Is(statErr, os.ErrNotExist) {
|
||||
http.Error(w, "Internal Server Error — stat destination: "+statErr.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, name := range fileNames {
|
||||
src := filepath.Join(incomingAbs, name)
|
||||
dst := filepath.Join(receivedAbs, name)
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
auditFile(r, "accept-transmittal", cleanURL, http.StatusInternalServerError, 0, err)
|
||||
http.Error(w, "Internal Server Error — rename "+name+": "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Best-effort: remove the now-empty incoming folder. Leaves it in
|
||||
// place if non-empty (e.g. operator left ad-hoc notes alongside
|
||||
// the conformant files); audit log captures the success either way.
|
||||
_ = os.Remove(incomingAbs)
|
||||
} else if errors.Is(err, os.ErrNotExist) {
|
||||
// Fresh acceptance → atomic folder rename.
|
||||
if err := os.Rename(incomingAbs, receivedAbs); err != nil {
|
||||
auditFile(r, "accept-transmittal", cleanURL, http.StatusInternalServerError, 0, err)
|
||||
http.Error(w, "Internal Server Error — rename folder: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "Internal Server Error — stat received: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resp := acceptResponse{
|
||||
Tracking: tracking,
|
||||
IncomingPath: cleanURL,
|
||||
ReceivedPath: receivedURL,
|
||||
MovedFiles: len(fileNames),
|
||||
Merged: merged,
|
||||
}
|
||||
|
||||
// Optional Plan Review chain. Invokes executePlanReview directly
|
||||
// against the freshly-created received/<tracking>/ path. The ACL
|
||||
// gates re-run there — the invoker still needs ActionAdmin on the
|
||||
// workflow roots and `c` on received/<tracking>/, both of which
|
||||
// they had a moment ago for the move itself. A chained failure does
|
||||
// NOT roll back the move: the canonical record is sealed, and the
|
||||
// user can re-trigger Plan Review later from the received/<tracking>/
|
||||
// folder context menu.
|
||||
if req.SetupPlanReview {
|
||||
planReq := planReviewRequest{
|
||||
ReviewLead: req.ReviewLead,
|
||||
Approver: req.Approver,
|
||||
PlanReviewCompleteDate: req.PlanReviewCompleteDate,
|
||||
PlanResponseDate: req.PlanResponseDate,
|
||||
}
|
||||
prResp, status, msg := executePlanReview(cfg, r, project, party, tracking, planReq)
|
||||
if status != http.StatusOK {
|
||||
auditFile(r, "accept-transmittal", cleanURL, status, 0, errors.New(msg))
|
||||
http.Error(w, "Chained plan-review: "+msg, status)
|
||||
return
|
||||
}
|
||||
resp.PlanReview = prResp
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("X-ZDDC-Source", "fileapi:accept-transmittal")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
auditFile(r, "accept-transmittal", cleanURL+" -> "+receivedURL, http.StatusOK, 0, nil)
|
||||
}
|
||||
193
zddc/internal/handler/accepthandler_test.go
Normal file
193
zddc/internal/handler/accepthandler_test.go
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// acceptSetup writes a tree with a conforming transmittal folder under
|
||||
// archive/Acme/incoming/ and an admin grant for alice. Returns the cfg,
|
||||
// a do() helper, and the root path.
|
||||
func acceptSetup(t *testing.T) (config.Config, func(target, email string, body []byte) *httptest.ResponseRecorder, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
mustWriteHelper(t, filepath.Join(root, ".zddc"),
|
||||
"admins:\n - alice@example.com\n"+
|
||||
"roles:\n document_controller:\n members: [alice@example.com]\n")
|
||||
for _, d := range []string{"Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation"} {
|
||||
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", d, err)
|
||||
}
|
||||
}
|
||||
// Seed two conforming files inside the transmittal folder.
|
||||
transmittalDir := filepath.Join(root, "Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation")
|
||||
mustWriteHelper(t, filepath.Join(transmittalDir, "Acme-0042_A (RFI) - Foundation.pdf"), "%PDF-")
|
||||
mustWriteHelper(t, filepath.Join(transmittalDir, "Acme-0042_A (RFI) - Cover Letter.pdf"), "%PDF-")
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
cfg := config.Config{
|
||||
Root: root,
|
||||
EmailHeader: "X-Auth-Request-Email",
|
||||
MaxWriteBytes: 64 * 1024,
|
||||
}
|
||||
do := func(target, email string, body []byte) *httptest.ResponseRecorder {
|
||||
// target may contain spaces and parens (real transmittal folder
|
||||
// names do); construct the URL from a url.URL so the request line
|
||||
// gets properly escaped and r.URL.Path comes back decoded for the
|
||||
// handler's pattern match.
|
||||
u := &url.URL{Path: target}
|
||||
req := httptest.NewRequest(http.MethodPost, u.RequestURI(), bytes.NewReader(body))
|
||||
req.Header.Set(headerOp, opAcceptTransmittal)
|
||||
req.Header.Set("Content-Type", "application/yaml")
|
||||
ctx := context.WithValue(req.Context(), EmailKey, email)
|
||||
ctx = context.WithValue(ctx, ElevatedKey, true)
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
ServeFileAPI(cfg, rec, req)
|
||||
return rec
|
||||
}
|
||||
return cfg, do, root
|
||||
}
|
||||
|
||||
// TestAccept_FreshAcceptance — a conforming transmittal folder moves
|
||||
// from incoming/ to received/, renamed to tracking-only.
|
||||
func TestAccept_FreshAcceptance(t *testing.T) {
|
||||
_, do, root := acceptSetup(t)
|
||||
target := "/Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/"
|
||||
rec := do(target, "alice@example.com", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp acceptResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v; body=%s", err, rec.Body.String())
|
||||
}
|
||||
if resp.Tracking != "Acme-0042" {
|
||||
t.Errorf("Tracking=%q, want Acme-0042", resp.Tracking)
|
||||
}
|
||||
if resp.MovedFiles != 2 {
|
||||
t.Errorf("MovedFiles=%d, want 2", resp.MovedFiles)
|
||||
}
|
||||
if resp.Merged {
|
||||
t.Errorf("Merged=true, want false on fresh acceptance")
|
||||
}
|
||||
// Folder should be at received/Acme-0042/, not the transmittal name.
|
||||
if _, err := os.Stat(filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/Acme-0042_A (RFI) - Foundation.pdf")); err != nil {
|
||||
t.Errorf("primary file not moved into received/: %v", err)
|
||||
}
|
||||
// Source should no longer exist.
|
||||
if _, err := os.Stat(filepath.Join(root, "Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation")); !os.IsNotExist(err) {
|
||||
t.Errorf("source folder still present after rename")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccept_NonConformingFilename — a file inside the transmittal
|
||||
// folder that doesn't parse rejects the whole accept and leaves the
|
||||
// source untouched.
|
||||
func TestAccept_NonConformingFilename(t *testing.T) {
|
||||
_, do, root := acceptSetup(t)
|
||||
// Drop a bad file alongside the good ones.
|
||||
mustWriteHelper(t, filepath.Join(root, "Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/random-notes.txt"), "oops")
|
||||
rec := do("/Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/", "alice@example.com", nil)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status=%d, want 409; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "random-notes.txt") {
|
||||
t.Errorf("error body should name the violating file; got %s", rec.Body.String())
|
||||
}
|
||||
// Source untouched.
|
||||
if _, err := os.Stat(filepath.Join(root, "Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation")); err != nil {
|
||||
t.Errorf("source folder removed despite rejection: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccept_NonConformingFolderName — a transmittal folder whose
|
||||
// name doesn't parse rejects with 400 (the URL pattern matches the
|
||||
// outer shape but the folder grammar fails).
|
||||
func TestAccept_NonConformingFolderName(t *testing.T) {
|
||||
_, do, root := acceptSetup(t)
|
||||
badDir := filepath.Join(root, "Project-1/archive/Acme/incoming/bad-folder-name")
|
||||
if err := os.MkdirAll(badDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := do("/Project-1/archive/Acme/incoming/bad-folder-name/", "alice@example.com", nil)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d, want 400; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccept_PlanReviewChain — setup_plan_review: true chains into
|
||||
// Plan Review and reports both results in the response.
|
||||
func TestAccept_PlanReviewChain(t *testing.T) {
|
||||
_, do, root := acceptSetup(t)
|
||||
body := []byte(strings.Join([]string{
|
||||
"setup_plan_review: true",
|
||||
"review_lead: bob@vendor.com",
|
||||
"approver: carol@example.com",
|
||||
"plan_review_complete_date: 2026-05-30",
|
||||
"plan_response_date: 2026-06-15",
|
||||
"",
|
||||
}, "\n"))
|
||||
rec := do("/Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/", "alice@example.com", body)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp acceptResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.PlanReview == nil {
|
||||
t.Fatalf("PlanReview chain absent in response: %+v", resp)
|
||||
}
|
||||
if !resp.PlanReview.Reviewing.Created || !resp.PlanReview.Staging.Created {
|
||||
t.Errorf("chained Plan Review did not converge: %+v", resp.PlanReview)
|
||||
}
|
||||
// received/.zddc must exist (Plan Review writes it).
|
||||
if _, err := os.Stat(filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/.zddc")); err != nil {
|
||||
t.Errorf("received .zddc not written by chained Plan Review: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccept_Merge — a second acceptance of the same tracking with
|
||||
// distinct filenames merges into the existing received/<tracking>/
|
||||
// folder. Re-using a filename is rejected by WORM.
|
||||
func TestAccept_Merge(t *testing.T) {
|
||||
_, do, root := acceptSetup(t)
|
||||
rec := do("/Project-1/archive/Acme/incoming/2026-05-15_Acme-0042 (RFI) - Foundation/", "alice@example.com", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("first accept status=%d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// Build a second transmittal folder with the same tracking but a
|
||||
// distinct rev so the filenames don't collide.
|
||||
secondDir := filepath.Join(root, "Project-1/archive/Acme/incoming/2026-06-01_Acme-0042 (RFI) - Followup")
|
||||
if err := os.MkdirAll(secondDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteHelper(t, filepath.Join(secondDir, "Acme-0042_B (RFI) - Foundation.pdf"), "%PDF-")
|
||||
rec = do("/Project-1/archive/Acme/incoming/2026-06-01_Acme-0042 (RFI) - Followup/", "alice@example.com", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("second accept status=%d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp acceptResponse
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if !resp.Merged {
|
||||
t.Errorf("Merged=false on re-acceptance of same tracking; want true")
|
||||
}
|
||||
// Both revs should now live in received/Acme-0042/.
|
||||
for _, name := range []string{"Acme-0042_A (RFI) - Foundation.pdf", "Acme-0042_B (RFI) - Foundation.pdf"} {
|
||||
if _, err := os.Stat(filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042", name)); err != nil {
|
||||
t.Errorf("expected %s in merged received/: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
17
zddc/internal/handler/admin_helpers.go
Normal file
17
zddc/internal/handler/admin_helpers.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// hasAnyAdminScope reports whether p has EFFECTIVE admin authority
|
||||
// anywhere in the tree. Returns false for an un-elevated principal
|
||||
// regardless of what the cascade names — the gate is in zddc.Principal
|
||||
// itself. For the "could this user opt into admin powers?" question
|
||||
// (elevation-INDEPENDENT), use zddc.HasAnyAdminGrant directly.
|
||||
func hasAnyAdminScope(fsRoot string, p zddc.Principal) bool {
|
||||
if !p.Elevated {
|
||||
return false
|
||||
}
|
||||
return zddc.HasAnyAdminGrant(fsRoot, p.Email)
|
||||
}
|
||||
|
|
@ -71,7 +71,7 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter,
|
|||
if err != nil {
|
||||
slog.Warn("ACL policy error on resolved file", "path", fileDir, "err", err)
|
||||
}
|
||||
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+target); !allowed {
|
||||
if allowed, _ := policy.AllowFromChainP(ctx, decider, chain, PrincipalFromContext(r), "/"+target); !allowed {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
|
@ -121,7 +121,7 @@ func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseW
|
|||
aclCache[fileDir] = false
|
||||
return false
|
||||
}
|
||||
v, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+targetPath)
|
||||
v, _ := policy.AllowFromChainP(ctx, decider, chain, PrincipalFromContext(r), "/"+targetPath)
|
||||
aclCache[fileDir] = v
|
||||
return v
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ func contains(xs []string, x string) bool {
|
|||
func TestServeArchive_EmptyProject404(t *testing.T) {
|
||||
root, idx := archiveTestRoot(t)
|
||||
writeZddc(t, root, ".", `acl:
|
||||
allow: ["*"]
|
||||
permissions: {"*": rwcd}
|
||||
`)
|
||||
cfg := archiveCfg(root)
|
||||
|
||||
|
|
@ -170,7 +170,7 @@ func TestServeArchive_EmptyProject404(t *testing.T) {
|
|||
func TestServeArchive_UnknownProject404(t *testing.T) {
|
||||
root, idx := archiveTestRoot(t)
|
||||
writeZddc(t, root, ".", `acl:
|
||||
allow: ["*"]
|
||||
permissions: {"*": rwcd}
|
||||
`)
|
||||
cfg := archiveCfg(root)
|
||||
|
||||
|
|
@ -191,7 +191,7 @@ func TestServeArchive_UnknownProject404(t *testing.T) {
|
|||
func TestServeArchive_ListingScopedToProject(t *testing.T) {
|
||||
root, idx := archiveTestRoot(t)
|
||||
writeZddc(t, root, ".", `acl:
|
||||
allow: ["*"]
|
||||
permissions: {"*": rwcd}
|
||||
`)
|
||||
cfg := archiveCfg(root)
|
||||
const email = "alice@example.com"
|
||||
|
|
@ -255,7 +255,7 @@ func TestServeArchive_ListingForbiddenWhenUserCanReadNothing(t *testing.T) {
|
|||
// allow list anywhere → every per-target check returns deny → the
|
||||
// filtered listing is empty → 403.
|
||||
writeZddc(t, root, ".", `acl:
|
||||
allow: ["alice@example.com"]
|
||||
permissions: {"alice@example.com": rwcd}
|
||||
`)
|
||||
cfg := archiveCfg(root)
|
||||
|
||||
|
|
@ -277,13 +277,13 @@ func TestServeArchive_ListingForbiddenWhenUserCanReadNothing(t *testing.T) {
|
|||
func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) {
|
||||
root, idx := archiveTestRoot(t)
|
||||
writeZddc(t, root, ".", `acl:
|
||||
allow: ["*"]
|
||||
permissions: {"*": rwcd}
|
||||
`)
|
||||
// Deny alice on the transmittal folder where 100_~A+C1 lives, so her
|
||||
// listing of /ProjectA/.archive/ drops that entry — but other ProjectA
|
||||
// entries stay visible.
|
||||
writeZddc(t, root, "ProjectA/2025-02-01_T2 (RTN) - Comments", `acl:
|
||||
deny: ["alice@example.com"]
|
||||
permissions: {"alice@example.com": ""}
|
||||
`)
|
||||
cfg := archiveCfg(root)
|
||||
|
||||
|
|
@ -313,10 +313,10 @@ func TestServeArchive_ResolvePerTargetACLOnly(t *testing.T) {
|
|||
// transmittal folder kicks mallory out at the per-target chain
|
||||
// ("first explicit match wins, bottom-up").
|
||||
writeZddc(t, root, ".", `acl:
|
||||
allow: ["alice@example.com", "mallory@example.com"]
|
||||
permissions: {"alice@example.com": rwcd, "mallory@example.com": rwcd}
|
||||
`)
|
||||
writeZddc(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", `acl:
|
||||
deny: ["mallory@example.com"]
|
||||
permissions: {"mallory@example.com": ""}
|
||||
`)
|
||||
cfg := archiveCfg(root)
|
||||
|
||||
|
|
@ -345,10 +345,10 @@ func TestServeArchive_ResolveBypassesProjectRootDenyWhenPerTargetAllows(t *testi
|
|||
// — so the per-target chain at the file's directory hits the local
|
||||
// allow first.
|
||||
writeZddc(t, root, ".", `acl:
|
||||
allow: ["alice@example.com"]
|
||||
permissions: {"alice@example.com": rwcd}
|
||||
`)
|
||||
writeZddc(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", `acl:
|
||||
allow: ["bob@example.com"]
|
||||
permissions: {"bob@example.com": rwcd}
|
||||
`)
|
||||
cfg := archiveCfg(root)
|
||||
|
||||
|
|
@ -382,7 +382,7 @@ func TestServeArchive_CrossProjectSameTrackingNoLeak(t *testing.T) {
|
|||
}
|
||||
|
||||
writeZddc(t, root, ".", `acl:
|
||||
allow: ["*"]
|
||||
permissions: {"*": rwcd}
|
||||
`)
|
||||
cfg := archiveCfg(root)
|
||||
const email = "alice@example.com"
|
||||
|
|
@ -442,7 +442,7 @@ func TestServeArchive_CrossProjectSameTrackingNoLeak(t *testing.T) {
|
|||
func TestServeArchive_EmptyEmailDeniedEvenWithStarAllow(t *testing.T) {
|
||||
root, idx := archiveTestRoot(t)
|
||||
writeZddc(t, root, ".", `acl:
|
||||
allow: ["*@example.com"]
|
||||
permissions: {"*@example.com": rwcd}
|
||||
`)
|
||||
cfg := archiveCfg(root)
|
||||
|
||||
|
|
@ -464,7 +464,7 @@ func TestServeArchive_EmptyEmailDeniedEvenWithStarAllow(t *testing.T) {
|
|||
func TestServeArchive_ListingContentNegotiation(t *testing.T) {
|
||||
root, idx := archiveTestRoot(t)
|
||||
writeZddc(t, root, ".", `acl:
|
||||
allow: ["*"]
|
||||
permissions: {"*": rwcd}
|
||||
`)
|
||||
cfg := archiveCfg(root)
|
||||
const email = "alice@example.com"
|
||||
|
|
|
|||
470
zddc/internal/handler/auth_invariants_test.go
Normal file
470
zddc/internal/handler/auth_invariants_test.go
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// auth_invariants_test.go — behavioral lock-in for the admin/elevation/
|
||||
// WORM invariants. These tests must pass against the CURRENT code before
|
||||
// the consolidation refactor (single bypass site in InternalDecider) so
|
||||
// the refactor can be validated against a green baseline.
|
||||
//
|
||||
// Each test covers one invariant called out in the security audit. The
|
||||
// names are deliberately verbose — when one fails, the failure message
|
||||
// alone tells you which property got broken.
|
||||
|
||||
// invariantsFixture sets up a synthetic ZDDC root with:
|
||||
//
|
||||
// - admin@example.com — root super-admin
|
||||
// - alice@example.com — subtree admin of Project-1/working (via per-dir
|
||||
// .zddc admins:) — used to test subtree scope
|
||||
// - bob@example.com — document_controller role member (gets WORM cr
|
||||
// on received/ + issued/ via cascade defaults)
|
||||
// - eve@example.com — non-admin, project_team only (read-only across
|
||||
// the project per defaults)
|
||||
//
|
||||
// Plus one file each in working/, issued/, received/ so we can exercise
|
||||
// reads + writes across the cascade.
|
||||
func invariantsFixture(t *testing.T) (config.Config, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
|
||||
mustWriteHelper(t, filepath.Join(root, ".zddc"),
|
||||
"admins:\n - admin@example.com\n"+
|
||||
"roles:\n"+
|
||||
" document_controller:\n members: [bob@example.com]\n"+
|
||||
" project_team:\n members: [\"*@example.com\"]\n")
|
||||
|
||||
for _, d := range []string{
|
||||
"Project-1/working/eve@example.com",
|
||||
"Project-1/archive/Acme/received/Acme-0042",
|
||||
"Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test",
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", d, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Subtree-admin grant: alice administers Project-1/working/.
|
||||
mustWriteHelper(t,
|
||||
filepath.Join(root, "Project-1/working/.zddc"),
|
||||
"admins:\n - alice@example.com\n")
|
||||
|
||||
// Files to act on.
|
||||
mustWriteHelper(t,
|
||||
filepath.Join(root, "Project-1/working/eve@example.com/draft.md"),
|
||||
"# eve's draft\n")
|
||||
mustWriteHelper(t,
|
||||
filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/Acme-0042_A (RFI) - Test.pdf"),
|
||||
"%PDF-A\n")
|
||||
mustWriteHelper(t,
|
||||
filepath.Join(root, "Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md"),
|
||||
"# issued draft\n")
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
return config.Config{
|
||||
Root: root,
|
||||
EmailHeader: "X-Auth-Request-Email",
|
||||
MaxWriteBytes: 64 * 1024,
|
||||
}, root
|
||||
}
|
||||
|
||||
// do executes a request with the given email / elevation flag. URL-encoding
|
||||
// is computed from the path so spaces and parens (real ZDDC filenames)
|
||||
// round-trip cleanly.
|
||||
func doReq(cfg config.Config, method, urlPath, email string, elevated bool, body []byte, op string) *httptest.ResponseRecorder {
|
||||
u := &url.URL{Path: urlPath}
|
||||
req := httptest.NewRequest(method, u.RequestURI(), bytes.NewReader(body))
|
||||
if op != "" {
|
||||
req.Header.Set(headerOp, op)
|
||||
}
|
||||
ctx := context.WithValue(req.Context(), EmailKey, email)
|
||||
ctx = context.WithValue(ctx, ElevatedKey, elevated)
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
ServeFileAPI(cfg, rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// ── Invariant 1 — Un-elevated admin has no admin authority ────────────────
|
||||
|
||||
func TestInvariant_UnelevatedAdminCannotBypassWorm(t *testing.T) {
|
||||
cfg, _ := invariantsFixture(t)
|
||||
target := "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md"
|
||||
rec := doReq(cfg, http.MethodPut, target, "admin@example.com", false, []byte("# mutated\n"), "")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("un-elevated admin write succeeded: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvariant_UnelevatedAdminCannotEditZddc(t *testing.T) {
|
||||
// .zddc edits route through the decider as ActionAdmin. The bypass
|
||||
// for elevated admins fires only when Principal.Elevated is true.
|
||||
// Exercised at the HTTP boundary: a PUT to .zddc from an un-elevated
|
||||
// super-admin must return Forbidden.
|
||||
cfg, _ := invariantsFixture(t)
|
||||
target := "/Project-1/working/.zddc"
|
||||
rec := doReq(cfg, http.MethodPut, target, "admin@example.com", false, []byte("title: mutated\n"), "")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("un-elevated admin .zddc write succeeded: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvariant_ElevatedAdminCanEditZddc(t *testing.T) {
|
||||
// Positive control: a super-admin who has elevated CAN write any
|
||||
// .zddc. The decider's IsActiveAdmin short-circuit fires in
|
||||
// AllowActionFromChainP and the file API write proceeds.
|
||||
cfg, _ := invariantsFixture(t)
|
||||
target := "/Project-1/working/.zddc"
|
||||
rec := doReq(cfg, http.MethodPut, target, "admin@example.com", true, []byte("title: elevated edit\n"), "")
|
||||
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
|
||||
t.Fatalf("elevated admin .zddc write blocked: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Invariant 2 — Elevated admin can do everything (positive control) ─────
|
||||
|
||||
func TestInvariant_ElevatedAdminBypassesWorm(t *testing.T) {
|
||||
cfg, _ := invariantsFixture(t)
|
||||
target := "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md"
|
||||
rec := doReq(cfg, http.MethodPut, target, "admin@example.com", true, []byte("# fix-mis-filed\n"), "")
|
||||
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
|
||||
t.Fatalf("elevated admin write blocked: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Invariant 3 — Subtree admin scope ──────────────────────────────────────
|
||||
|
||||
func TestInvariant_ElevatedSubtreeAdminWritesInScope(t *testing.T) {
|
||||
cfg, _ := invariantsFixture(t)
|
||||
target := "/Project-1/working/eve@example.com/draft.md"
|
||||
rec := doReq(cfg, http.MethodPut, target, "alice@example.com", true, []byte("# alice override\n"), "")
|
||||
// alice is subtree admin of Project-1/working/ — should override eve's
|
||||
// fenced auto-own and write through.
|
||||
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
|
||||
t.Fatalf("elevated subtree admin write in scope blocked: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvariant_ElevatedSubtreeAdminBlockedOutsideScope(t *testing.T) {
|
||||
cfg, _ := invariantsFixture(t)
|
||||
// alice is subtree admin of /Project-1/working/, NOT of /Project-1/archive/.
|
||||
target := "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md"
|
||||
rec := doReq(cfg, http.MethodPut, target, "alice@example.com", true, []byte("# out-of-scope\n"), "")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("subtree admin escaped scope: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Invariant 4 — .zddc strict-ancestor self-elevation prevention ─────────
|
||||
|
||||
// Strict-ancestor was retired — a subtree admin owns their .zddc.
|
||||
// These tests pin the post-change contract: an elevated admin
|
||||
// granted in /<dir>/.zddc CAN edit that file (add collaborators,
|
||||
// adjust ACLs, even — accidentally — remove themselves). Footgun
|
||||
// is recoverable via super-admin restore.
|
||||
|
||||
func TestInvariant_SubtreeAdminCanEditOwnSubtreeZddc(t *testing.T) {
|
||||
cfg, _ := invariantsFixture(t)
|
||||
p := zddc.Principal{Email: "alice@example.com", Elevated: true}
|
||||
dir := filepath.Join(cfg.Root, "Project-1/working")
|
||||
chain, err := zddc.EffectivePolicy(cfg.Root, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("EffectivePolicy: %v", err)
|
||||
}
|
||||
if !zddc.IsAdminForChain(chain, p.Email) {
|
||||
t.Fatalf("subtree admin lost authority to edit own .zddc — strict-ancestor wasn't supposed to apply")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvariant_SubtreeAdminCanEditDeeperZddc(t *testing.T) {
|
||||
cfg, _ := invariantsFixture(t)
|
||||
p := zddc.Principal{Email: "alice@example.com", Elevated: true}
|
||||
dir := filepath.Join(cfg.Root, "Project-1/working/eve@example.com")
|
||||
chain, err := zddc.EffectivePolicy(cfg.Root, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("EffectivePolicy: %v", err)
|
||||
}
|
||||
if !zddc.IsAdminForChain(chain, p.Email) {
|
||||
t.Fatalf("subtree admin blocked from editing deeper .zddc")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Invariant 5 — Empty email never matches ────────────────────────────────
|
||||
|
||||
func TestInvariant_EmptyEmailHasNoAuthority(t *testing.T) {
|
||||
cfg, _ := invariantsFixture(t)
|
||||
target := "/Project-1/working/eve@example.com/draft.md"
|
||||
rec := doReq(cfg, http.MethodPut, target, "", true, []byte("# anon\n"), "")
|
||||
if rec.Code != http.StatusForbidden && rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("empty-email write succeeded: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Invariant 6 — WORM cr survives for document_controller (no admin) ─────
|
||||
|
||||
func TestInvariant_DocControllerCanCreateInWormZone(t *testing.T) {
|
||||
cfg, _ := invariantsFixture(t)
|
||||
// bob is a document_controller (per role membership) but NOT an admin.
|
||||
// He must be able to CREATE new files in received/<tracking>/ even
|
||||
// without elevation — the WORM cr grant carries.
|
||||
target := "/Project-1/archive/Acme/received/Acme-0042/Acme-0042_B (RFI) - Followup.pdf"
|
||||
rec := doReq(cfg, http.MethodPut, target, "bob@example.com", false, []byte("%PDF-B\n"), "")
|
||||
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
|
||||
t.Fatalf("doc_controller blocked from WORM create: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvariant_DocControllerCannotOverwriteInWormZone(t *testing.T) {
|
||||
cfg, _ := invariantsFixture(t)
|
||||
// bob can CREATE in WORM but cannot OVERWRITE — the worm strip
|
||||
// removes w/d for everyone, even WORM-listed principals.
|
||||
target := "/Project-1/archive/Acme/received/Acme-0042/Acme-0042_A (RFI) - Test.pdf"
|
||||
rec := doReq(cfg, http.MethodPut, target, "bob@example.com", false, []byte("%PDF-mutated\n"), "")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("doc_controller bypassed WORM overwrite-strip: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Invariant 7 — project_team has read but no write ──────────────────────
|
||||
|
||||
func TestInvariant_ProjectTeamCanReadCannotWrite(t *testing.T) {
|
||||
cfg, _ := invariantsFixture(t)
|
||||
// eve is project_team (r at project level) and the file lives under
|
||||
// her own working/ home — but she is NOT in any admin list and not
|
||||
// elevated, so writes must be ACL-gated.
|
||||
//
|
||||
// In her own home, eve has auto-own rwcda via the working/<email>/
|
||||
// auto-own pattern; the cascade gives her create+write there. So
|
||||
// the right test is a write OUTSIDE her home — into a peer's area
|
||||
// or into archive.
|
||||
target := "/Project-1/archive/Acme/received/Acme-0042/Acme-0042_A (RFI) - Test.pdf"
|
||||
rec := doReq(cfg, http.MethodPut, target, "eve@example.com", false, []byte("# eve overwrite\n"), "")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("project_team escaped WORM strip: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Invariant 8 — Forward-auth endpoint requires admin membership ─────────
|
||||
|
||||
func TestInvariant_ForwardAuthEndpointGatesOnAdminsList(t *testing.T) {
|
||||
cfg, _ := invariantsFixture(t)
|
||||
for _, tc := range []struct {
|
||||
email string
|
||||
want int
|
||||
why string
|
||||
}{
|
||||
{"admin@example.com", http.StatusOK, "root admin"},
|
||||
{"alice@example.com", http.StatusForbidden, "subtree admin only — /.auth/admin gates on ROOT admins:, not subtree"},
|
||||
{"eve@example.com", http.StatusForbidden, "non-admin"},
|
||||
{"", http.StatusForbidden, "anonymous"},
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/.auth/admin", nil)
|
||||
ctx := context.WithValue(req.Context(), EmailKey, tc.email)
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
ServeAuthAdmin(cfg, rec, req)
|
||||
if rec.Code != tc.want {
|
||||
t.Errorf("/.auth/admin for %q (%s): got %d, want %d",
|
||||
tc.email, tc.why, rec.Code, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Invariant 10 — .zddc write matrix at root / project / subtree ─────────
|
||||
|
||||
// TestInvariant_ZddcPutMatrix exercises every (principal × elevation ×
|
||||
// target) combination for PUT to a .zddc file. The decider's
|
||||
// IsActiveAdmin short-circuit is the single bypass; this matrix locks
|
||||
// down that it only fires for an Elevated principal who is named in
|
||||
// the admins: list of some level on the target's chain.
|
||||
//
|
||||
// Targets:
|
||||
// - /.zddc — root file (root admins: govern)
|
||||
// - /Project-1/.zddc — project file (no on-disk .zddc;
|
||||
// write must materialise it; root
|
||||
// admins still govern via cascade)
|
||||
// - /Project-1/working/.zddc — subtree file; alice administers
|
||||
// this subtree via its own admins:
|
||||
// list (so alice's write doesn't
|
||||
// require root-admin authority).
|
||||
//
|
||||
// Expected status: 200 or 201 on success; 403 on denial; 404 only when
|
||||
// resolveTargetPath rejects the path (e.g. empty email gets 403 from
|
||||
// the decider, not 404).
|
||||
func TestInvariant_ZddcPutMatrix(t *testing.T) {
|
||||
type principal struct {
|
||||
email string
|
||||
elevated bool
|
||||
}
|
||||
rootAdminElevated := principal{"admin@example.com", true}
|
||||
rootAdminUnelevated := principal{"admin@example.com", false}
|
||||
subtreeAdminElevated := principal{"alice@example.com", true}
|
||||
subtreeAdminUnelevated := principal{"alice@example.com", false}
|
||||
nonAdmin := principal{"eve@example.com", true}
|
||||
anon := principal{"", true}
|
||||
|
||||
const (
|
||||
ok = http.StatusOK
|
||||
den = http.StatusForbidden
|
||||
)
|
||||
cases := []struct {
|
||||
name string
|
||||
target string
|
||||
who principal
|
||||
want int
|
||||
}{
|
||||
// Root .zddc
|
||||
{"root admin elevated → root .zddc", "/.zddc", rootAdminElevated, ok},
|
||||
{"root admin un-elevated → root .zddc", "/.zddc", rootAdminUnelevated, den},
|
||||
{"subtree admin elevated → root .zddc", "/.zddc", subtreeAdminElevated, den},
|
||||
{"subtree admin un-elevated → root .zddc", "/.zddc", subtreeAdminUnelevated, den},
|
||||
{"non-admin → root .zddc", "/.zddc", nonAdmin, den},
|
||||
{"anonymous → root .zddc", "/.zddc", anon, den},
|
||||
|
||||
// Project .zddc (no on-disk file yet — PUT creates it)
|
||||
{"root admin elevated → project .zddc", "/Project-1/.zddc", rootAdminElevated, http.StatusCreated},
|
||||
{"root admin un-elevated → project .zddc", "/Project-1/.zddc", rootAdminUnelevated, den},
|
||||
{"subtree admin elevated (out-of-scope) → project .zddc", "/Project-1/.zddc", subtreeAdminElevated, den},
|
||||
{"non-admin → project .zddc", "/Project-1/.zddc", nonAdmin, den},
|
||||
|
||||
// Subtree .zddc (alice administers this subtree)
|
||||
{"root admin elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminElevated, ok},
|
||||
{"root admin un-elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminUnelevated, den},
|
||||
{"subtree admin elevated → subtree .zddc", "/Project-1/working/.zddc", subtreeAdminElevated, ok},
|
||||
{"subtree admin un-elevated → subtree .zddc", "/Project-1/working/.zddc", subtreeAdminUnelevated, den},
|
||||
{"non-admin → subtree .zddc", "/Project-1/working/.zddc", nonAdmin, den},
|
||||
{"anonymous → subtree .zddc", "/Project-1/working/.zddc", anon, den},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg, _ := invariantsFixture(t)
|
||||
body := []byte("title: matrix probe\n")
|
||||
rec := doReq(cfg, http.MethodPut, tc.target, tc.who.email, tc.who.elevated, body, "")
|
||||
if tc.want == den {
|
||||
if rec.Code != http.StatusForbidden && rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("want denial, got %d body=%s", rec.Code, dumpBody(rec))
|
||||
}
|
||||
} else if rec.Code != tc.want {
|
||||
t.Fatalf("want %d, got %d body=%s", tc.want, rec.Code, dumpBody(rec))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvariant_ZddcDeleteMatrix mirrors ZddcPutMatrix for DELETE. The
|
||||
// project-level .zddc target is dropped (no on-disk file → 404 lives
|
||||
// outside the auth surface). The cases that remain pin: only an
|
||||
// elevated admin with authority over the .zddc's directory can drop
|
||||
// the file.
|
||||
func TestInvariant_ZddcDeleteMatrix(t *testing.T) {
|
||||
type principal struct {
|
||||
email string
|
||||
elevated bool
|
||||
}
|
||||
rootAdminElevated := principal{"admin@example.com", true}
|
||||
rootAdminUnelevated := principal{"admin@example.com", false}
|
||||
subtreeAdminElevated := principal{"alice@example.com", true}
|
||||
subtreeAdminUnelevated := principal{"alice@example.com", false}
|
||||
nonAdmin := principal{"eve@example.com", true}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
target string
|
||||
who principal
|
||||
want int
|
||||
}{
|
||||
{"root admin elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminElevated, http.StatusNoContent},
|
||||
{"root admin un-elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminUnelevated, http.StatusForbidden},
|
||||
{"subtree admin elevated → own .zddc", "/Project-1/working/.zddc", subtreeAdminElevated, http.StatusNoContent},
|
||||
{"subtree admin un-elevated → own .zddc", "/Project-1/working/.zddc", subtreeAdminUnelevated, http.StatusForbidden},
|
||||
{"non-admin → subtree .zddc", "/Project-1/working/.zddc", nonAdmin, http.StatusForbidden},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg, _ := invariantsFixture(t)
|
||||
rec := doReq(cfg, http.MethodDelete, tc.target, tc.who.email, tc.who.elevated, nil, "")
|
||||
if rec.Code != tc.want {
|
||||
t.Fatalf("want %d, got %d body=%s", tc.want, rec.Code, dumpBody(rec))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Invariant 11 — anti-bypass: un-elevated admin gets nothing extra ──────
|
||||
|
||||
// TestInvariant_UnelevatedAdminNoSilentBypass is the anti-test for the
|
||||
// elevation gate. For every (admin-flavour × action) tuple, an
|
||||
// un-elevated admin must behave exactly like a non-admin: they may
|
||||
// only do what an explicit ACL grant permits. The fixture's admin and
|
||||
// alice both have NO baseline ACL grant outside their admin scope, so
|
||||
// every action below MUST 403 — any pass indicates a bypass leak.
|
||||
func TestInvariant_UnelevatedAdminNoSilentBypass(t *testing.T) {
|
||||
cfg, _ := invariantsFixture(t)
|
||||
type op struct {
|
||||
method string
|
||||
path string
|
||||
body []byte
|
||||
op string
|
||||
}
|
||||
probes := []op{
|
||||
// .zddc writes (ActionAdmin)
|
||||
{http.MethodPut, "/.zddc", []byte("title: x\n"), ""},
|
||||
{http.MethodPut, "/Project-1/working/.zddc", []byte("title: x\n"), ""},
|
||||
{http.MethodDelete, "/Project-1/working/.zddc", nil, ""},
|
||||
// WORM writes (ActionWrite / ActionCreate stripped)
|
||||
{http.MethodPut, "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md", []byte("# mutate\n"), ""},
|
||||
{http.MethodPut, "/Project-1/archive/Acme/received/Acme-0042/new.pdf", []byte("%PDF\n"), ""},
|
||||
{http.MethodDelete, "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md", nil, ""},
|
||||
// Regular write into someone else's working/ home (no ACL grant)
|
||||
{http.MethodPut, "/Project-1/working/eve@example.com/draft.md", []byte("# steal\n"), ""},
|
||||
}
|
||||
|
||||
admins := []struct {
|
||||
name string
|
||||
email string
|
||||
}{
|
||||
{"root super-admin", "admin@example.com"},
|
||||
{"subtree admin (alice)", "alice@example.com"},
|
||||
}
|
||||
|
||||
for _, a := range admins {
|
||||
for _, p := range probes {
|
||||
t.Run(a.name+" "+p.method+" "+p.path, func(t *testing.T) {
|
||||
rec := doReq(cfg, p.method, p.path, a.email, false, p.body, p.op)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("BYPASS LEAK: %s un-elevated reached %s %s with status %d body=%s",
|
||||
a.email, p.method, p.path, rec.Code, dumpBody(rec))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Invariant 9 — Profile admin endpoints 404 (not 403) for non-admins ────
|
||||
|
||||
func TestInvariant_ProfileAdminEndpointsHideFromNonAdmins(t *testing.T) {
|
||||
// These checks lock in the existence-hiding property: non-admins must
|
||||
// see 404, never 403, so they can't probe which paths exist.
|
||||
t.Skip("requires the profile handler dispatcher entry point; skip until the refactor confirms ServeProfile signature")
|
||||
}
|
||||
|
||||
// dump prints the rec body when t.Logf would help debugging — used in
|
||||
// failure messages to avoid silently empty 403 cases.
|
||||
func dumpBody(rec *httptest.ResponseRecorder) string {
|
||||
s := rec.Body.String()
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
|
@ -32,14 +32,21 @@ const AuthPathPrefix = "/.auth"
|
|||
// noticeable overhead.
|
||||
//
|
||||
// Scope: gates ON ROOT-ADMIN STATUS ONLY. This is intentionally
|
||||
// stricter than the regular acl.allow / acl.deny chain — admin-only
|
||||
// stricter than the regular acl.permissions chain — admin-only
|
||||
// endpoints (the dev-shell IDE, future maintenance routes) shouldn't
|
||||
// fall through to subtree-level allowances. For per-route ACL, callers
|
||||
// continue using the existing handlers (archive, profile, etc.) which
|
||||
// consult AllowedWithChain.
|
||||
// consult the policy decider.
|
||||
func ServeAuthAdmin(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||
email := EmailFromContext(r)
|
||||
if email == "" || !zddc.IsAdmin(cfg.Root, email) {
|
||||
// Elevation-independent gate. Upstream proxies (Caddy forward_auth
|
||||
// for the dev-shell IDE) call this from a different cookie scope
|
||||
// than the zddc-server origin, so the elevation cookie can't reach
|
||||
// here even when the user has it set. This is a coarse "is this
|
||||
// email a root admin?" check, not a per-action authority decision —
|
||||
// construct a synthetically-elevated Principal so the underlying
|
||||
// admin check evaluates the admins: list as usual.
|
||||
if email == "" || !zddc.IsAdmin(cfg.Root, zddc.Principal{Email: email, Elevated: true}) {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ func TestServeAuthAdmin(t *testing.T) {
|
|||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := requestWithEmail(http.MethodGet, AuthPathPrefix+"/admin", tc.email)
|
||||
req := requestAsAdmin(http.MethodGet, AuthPathPrefix+"/admin", tc.email)
|
||||
rec := httptest.NewRecorder()
|
||||
ServeAuthAdmin(cfg, rec, req)
|
||||
if rec.Code != tc.wantStatus {
|
||||
|
|
@ -53,7 +53,7 @@ func TestServeAuthAdmin_NoZddcRootDeniesEverything(t *testing.T) {
|
|||
cfg, _ := profileTestRoot(t, nil)
|
||||
|
||||
for _, email := range []string{"", "alice@example.com", "anyone@example.com"} {
|
||||
req := requestWithEmail(http.MethodGet, AuthPathPrefix+"/admin", email)
|
||||
req := requestAsAdmin(http.MethodGet, AuthPathPrefix+"/admin", email)
|
||||
rec := httptest.NewRecorder()
|
||||
ServeAuthAdmin(cfg, rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
|
|
|
|||
|
|
@ -18,13 +18,19 @@ import (
|
|||
|
||||
// On-demand MD→{docx,html,pdf} conversion endpoint.
|
||||
//
|
||||
// GET /<path>/foo.md?convert=docx|html|pdf
|
||||
// GET /<path>/foo.docx (or .html / .pdf)
|
||||
//
|
||||
// The source file's read policy (already enforced by the dispatcher
|
||||
// before this handler runs) gates the response. The converted bytes
|
||||
// are cached at <dir>/.converted/<base>.<ext>, with mtime synced to the
|
||||
// source — so a fast-path GET that finds a fresh cache hit serves the
|
||||
// disk file via http.ServeContent without invoking pandoc at all.
|
||||
// The URL is the rendered form of a sibling `foo.md` source. The
|
||||
// dispatcher recognises the pattern via RecognizeVirtualConvert when
|
||||
// a stat on `foo.docx` (etc.) fails AND `foo.md` exists; only then is
|
||||
// ServeConverted invoked. A real on-disk `foo.docx` wins precedence
|
||||
// and serves its bytes normally.
|
||||
//
|
||||
// The source file's read policy (enforced by the dispatcher before this
|
||||
// handler runs) gates the response. The converted bytes are cached at
|
||||
// <dir>/.converted/<base>.<ext>, with mtime synced to the source — so a
|
||||
// fast-path GET that finds a fresh cache hit serves the disk file via
|
||||
// http.ServeContent without invoking pandoc at all.
|
||||
//
|
||||
// When the cache is stale (or absent) the handler:
|
||||
// 1. Reads source bytes.
|
||||
|
|
@ -42,6 +48,39 @@ var convertSF singleflightGroup
|
|||
// runner itself enforces a finer-grained timeout on the container.
|
||||
const convertTimeout = 90 * time.Second
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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) {
|
||||
lower := strings.ToLower(urlPath)
|
||||
for _, ext := range []string{".docx", ".html", ".pdf"} {
|
||||
if !strings.HasSuffix(lower, ext) {
|
||||
continue
|
||||
}
|
||||
base := urlPath[:len(urlPath)-len(ext)]
|
||||
if base == "" || strings.HasSuffix(base, "/") {
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// ServeConverted is the entry point. format is the requested target
|
||||
// extension; chain is the already-resolved ACL chain (re-used here
|
||||
// only to extract the convert: cascade metadata).
|
||||
|
|
@ -58,7 +97,7 @@ func ServeConverted(cfg config.Config, w http.ResponseWriter, r *http.Request, s
|
|||
if !ok {
|
||||
// One re-probe attempt — gives the operator a way to recover
|
||||
// after building the image without restarting the server.
|
||||
caps = convert.Reprobe(r.Context(), os.Getenv("ZDDC_CONVERT_ENGINE"))
|
||||
caps = convert.Reprobe(r.Context())
|
||||
if !caps.Ready() {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
http.Error(w, "Service Unavailable — "+caps.Reason(), http.StatusServiceUnavailable)
|
||||
|
|
|
|||
69
zddc/internal/handler/default-project-mdl.table.yaml
Normal file
69
zddc/internal/handler/default-project-mdl.table.yaml
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# Default project-rollup Master Deliverables List spec, served by
|
||||
# zddc-server when no operator-supplied table.yaml exists at
|
||||
# <project>/mdl/.
|
||||
#
|
||||
# This view aggregates every deliverable row from every party under
|
||||
# <project>/archive/. Each synthetic row is backed by the real file
|
||||
# at <project>/archive/<party>/mdl/<file>.yaml; the leading `party`
|
||||
# column is derived from the row's source folder (path-injected by
|
||||
# the server, not stored in the YAML).
|
||||
#
|
||||
# + Add row is suppressed in this view because the party affiliation
|
||||
# would be ambiguous — add deliverables at the per-party path
|
||||
# (<project>/archive/<party>/mdl/) and they'll appear here on next
|
||||
# load.
|
||||
|
||||
title: Project Deliverables (all parties)
|
||||
description: Every deliverable across all parties under archive/. Click a row to edit; add rows at the per-party MDL view.
|
||||
|
||||
addable: false
|
||||
|
||||
columns:
|
||||
- field: party
|
||||
title: Package
|
||||
width: 7em
|
||||
- field: originator
|
||||
title: Originator
|
||||
width: 8em
|
||||
- field: phase
|
||||
title: Phase
|
||||
width: 5em
|
||||
- field: project
|
||||
title: Project
|
||||
width: 8em
|
||||
- field: area
|
||||
title: Area
|
||||
width: 5em
|
||||
- field: discipline
|
||||
title: Disc.
|
||||
width: 5em
|
||||
- field: type
|
||||
title: Type
|
||||
width: 6em
|
||||
- field: sequence
|
||||
title: Seq.
|
||||
width: 5em
|
||||
- field: suffix
|
||||
title: Suffix
|
||||
width: 5em
|
||||
- field: title
|
||||
title: Deliverable
|
||||
- field: plannedRevision
|
||||
title: Rev.
|
||||
width: 5em
|
||||
- field: plannedDate
|
||||
title: Planned
|
||||
format: date
|
||||
width: 8em
|
||||
- field: status
|
||||
title: Status
|
||||
width: 6em
|
||||
enum: [DFT, IFR, IFA, IFC, AFC, AB]
|
||||
- field: owner
|
||||
title: Owner
|
||||
width: 12em
|
||||
|
||||
defaults:
|
||||
sort:
|
||||
- { field: party, dir: asc }
|
||||
- { field: plannedDate, dir: asc }
|
||||
56
zddc/internal/handler/default-project-rsk.table.yaml
Normal file
56
zddc/internal/handler/default-project-rsk.table.yaml
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# Default project-rollup Risk Register spec, served by zddc-server
|
||||
# when no operator-supplied table.yaml exists at <project>/rsk/.
|
||||
#
|
||||
# This view aggregates every risk row from every party under
|
||||
# <project>/archive/. Each synthetic row is backed by the real file
|
||||
# at <project>/archive/<party>/rsk/<file>.yaml; the leading `party`
|
||||
# column is derived from the row's source folder (path-injected by
|
||||
# the server, not stored in the YAML).
|
||||
#
|
||||
# + Add row is suppressed in this view because the party affiliation
|
||||
# would be ambiguous — add risks at the per-party path
|
||||
# (<project>/archive/<party>/rsk/) and they'll appear here on next
|
||||
# load.
|
||||
|
||||
title: Project Risk Register (all parties)
|
||||
description: Every risk across all parties under archive/. Click a row to edit; add rows at the per-party RSK view.
|
||||
|
||||
addable: false
|
||||
|
||||
columns:
|
||||
- field: party
|
||||
title: Package
|
||||
width: 7em
|
||||
- field: id
|
||||
title: ID
|
||||
width: 6em
|
||||
- field: title
|
||||
title: Risk
|
||||
- field: category
|
||||
title: Category
|
||||
width: 10em
|
||||
- field: likelihood
|
||||
title: L
|
||||
width: 4em
|
||||
- field: impact
|
||||
title: I
|
||||
width: 4em
|
||||
- field: severity
|
||||
title: Sev
|
||||
width: 5em
|
||||
- field: owner
|
||||
title: Owner
|
||||
width: 12em
|
||||
- field: status
|
||||
title: Status
|
||||
width: 9em
|
||||
enum: [open, mitigated, accepted, closed]
|
||||
- field: dueDate
|
||||
title: Due
|
||||
format: date
|
||||
width: 8em
|
||||
|
||||
defaults:
|
||||
sort:
|
||||
- { field: severity, dir: desc }
|
||||
- { field: party, dir: asc }
|
||||
83
zddc/internal/handler/default-rsk.form.yaml
Normal file
83
zddc/internal/handler/default-rsk.form.yaml
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# Default row schema for a Risk Register entry, served by
|
||||
# zddc-server when no operator-supplied form.yaml exists at
|
||||
# archive/<party>/rsk/.
|
||||
#
|
||||
# Likelihood and impact use the standard 1-5 ordinal scales;
|
||||
# severity is also 1-25 (typically L*I) and stored on each row so
|
||||
# operators can override it when the simple product doesn't capture
|
||||
# the actual risk profile.
|
||||
#
|
||||
# To customize: drop your own form.yaml into archive/<party>/rsk/
|
||||
# (the same directory as table.yaml). Tighten constraints with
|
||||
# `enum:`, `pattern:`, etc. Add fields and they'll appear in the
|
||||
# row-edit form; add a matching column to table.yaml to surface
|
||||
# the field in the table view too.
|
||||
|
||||
title: Risk
|
||||
description: One identified risk. Likelihood and impact use 1-5 ordinals; severity is stored separately so it can be overridden when L*I underrepresents the residual exposure.
|
||||
|
||||
schema:
|
||||
type: object
|
||||
required: [id, title]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
title: ID
|
||||
description: Stable identifier, e.g. R-001.
|
||||
minLength: 1
|
||||
title:
|
||||
type: string
|
||||
title: Risk
|
||||
minLength: 1
|
||||
category:
|
||||
type: string
|
||||
title: Category
|
||||
description: Free-form grouping (schedule, cost, technical, regulatory, ...).
|
||||
description:
|
||||
type: string
|
||||
title: Description
|
||||
likelihood:
|
||||
type: integer
|
||||
title: Likelihood
|
||||
description: 1 (rare) to 5 (almost certain).
|
||||
minimum: 1
|
||||
maximum: 5
|
||||
impact:
|
||||
type: integer
|
||||
title: Impact
|
||||
description: 1 (negligible) to 5 (catastrophic).
|
||||
minimum: 1
|
||||
maximum: 5
|
||||
severity:
|
||||
type: integer
|
||||
title: Severity
|
||||
description: Residual risk score. Typically likelihood * impact (1-25), but operators can override.
|
||||
minimum: 1
|
||||
maximum: 25
|
||||
mitigation:
|
||||
type: string
|
||||
title: Mitigation
|
||||
description: Plan for reducing this risk's likelihood or impact.
|
||||
owner:
|
||||
type: string
|
||||
title: Owner
|
||||
description: Email or party name responsible for tracking this risk.
|
||||
status:
|
||||
type: string
|
||||
title: Status
|
||||
enum: [open, mitigated, accepted, closed]
|
||||
dueDate:
|
||||
type: string
|
||||
title: Due date
|
||||
format: date
|
||||
notes:
|
||||
type: string
|
||||
title: Notes
|
||||
ui:
|
||||
description:
|
||||
ui:widget: textarea
|
||||
mitigation:
|
||||
ui:widget: textarea
|
||||
notes:
|
||||
ui:widget: textarea
|
||||
51
zddc/internal/handler/default-rsk.table.yaml
Normal file
51
zddc/internal/handler/default-rsk.table.yaml
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Default Risk Register spec, served by zddc-server when no
|
||||
# operator-supplied table.yaml exists at archive/<party>/rsk/.
|
||||
#
|
||||
# Columns cover the standard risk-register fields: identifier, title,
|
||||
# category, likelihood / impact / severity scores, owner, status, and
|
||||
# due date. Severity is stored on each row (1-25, typically L*I) so
|
||||
# operators can override it when the simple product doesn't capture
|
||||
# the actual risk profile.
|
||||
#
|
||||
# To customize: drop your own table.yaml + form.yaml into the same
|
||||
# directory (archive/<party>/rsk/). The whole directory IS the table —
|
||||
# spec, row-edit form, and rows are siblings. Override examples mirror
|
||||
# the MDL table.yaml customization patterns.
|
||||
|
||||
title: Risk Register
|
||||
description: Risks tracked for this party. Severity is the residual risk score; sort defaults to severity descending.
|
||||
|
||||
columns:
|
||||
- field: id
|
||||
title: ID
|
||||
width: 6em
|
||||
- field: title
|
||||
title: Risk
|
||||
- field: category
|
||||
title: Category
|
||||
width: 10em
|
||||
- field: likelihood
|
||||
title: L
|
||||
width: 4em
|
||||
- field: impact
|
||||
title: I
|
||||
width: 4em
|
||||
- field: severity
|
||||
title: Sev
|
||||
width: 5em
|
||||
- field: owner
|
||||
title: Owner
|
||||
width: 12em
|
||||
- field: status
|
||||
title: Status
|
||||
width: 9em
|
||||
enum: [open, mitigated, accepted, closed]
|
||||
- field: dueDate
|
||||
title: Due
|
||||
format: date
|
||||
width: 8em
|
||||
|
||||
defaults:
|
||||
sort:
|
||||
- { field: severity, dir: desc }
|
||||
- { field: dueDate, dir: asc }
|
||||
76
zddc/internal/handler/default-ssr.form.yaml
Normal file
76
zddc/internal/handler/default-ssr.form.yaml
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Default row schema for a Supplier / Subcontractor Status Report
|
||||
# entry, served by zddc-server when no operator-supplied form.yaml
|
||||
# exists at <project>/archive/<party>/ssr.form.yaml.
|
||||
#
|
||||
# The `name` field doubles as the party folder name (the row's
|
||||
# stable identifier). It's required on create (+ Add row materializes
|
||||
# <project>/archive/<name>/) but is stripped from the YAML on save —
|
||||
# the folder name IS the identity, so storing it inside the file too
|
||||
# would just be a denormalization. On read the dispatcher injects
|
||||
# name back into the row data so this form (and the SSR table)
|
||||
# can display it.
|
||||
#
|
||||
# Pattern excludes leading `.` and `_` to avoid colliding with
|
||||
# fileapi.go's dot/underscore-prefix guards on file paths.
|
||||
#
|
||||
# To customize: drop your own form.yaml into
|
||||
# <project>/archive/<party>/ (sibling to the party's ssr.yaml).
|
||||
|
||||
title: Supplier / Subcontractor Status
|
||||
description: One party's status report. The party name doubles as the archive folder name and is required when creating a new row.
|
||||
|
||||
schema:
|
||||
type: object
|
||||
required: [name, vendorType, contractNo, scopeSummary]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
title: Party (folder name)
|
||||
description: Becomes <project>/archive/<name>/. Typical naming = MasterFormat 4-digit code + C|P + sequence digit (e.g. 0330C1).
|
||||
pattern: "^[A-Za-z0-9][A-Za-z0-9.-]*$"
|
||||
minLength: 1
|
||||
vendorType:
|
||||
type: string
|
||||
title: Vendor type
|
||||
enum: [subcontractor, supplier, consultant, vendor, other]
|
||||
contractNo:
|
||||
type: string
|
||||
title: Contract / PO number
|
||||
scopeSummary:
|
||||
type: string
|
||||
title: Scope summary
|
||||
contractValue:
|
||||
type: number
|
||||
title: Contract value
|
||||
awardDate:
|
||||
type: string
|
||||
title: Award date
|
||||
format: date
|
||||
kickoffDate:
|
||||
type: string
|
||||
title: Kickoff date
|
||||
format: date
|
||||
scheduleStatus:
|
||||
type: string
|
||||
title: Schedule status
|
||||
enum: [on-track, at-risk, behind, completed, on-hold]
|
||||
deliverablesStatus:
|
||||
type: string
|
||||
title: Deliverables status
|
||||
enum: [on-track, at-risk, behind, completed]
|
||||
paymentStatus:
|
||||
type: string
|
||||
title: Payment status
|
||||
enum: [current, overdue, hold, complete]
|
||||
ownerContact:
|
||||
type: string
|
||||
title: Owner contact (email)
|
||||
notes:
|
||||
type: string
|
||||
title: Notes
|
||||
ui:
|
||||
scopeSummary:
|
||||
ui:widget: textarea
|
||||
notes:
|
||||
ui:widget: textarea
|
||||
62
zddc/internal/handler/default-ssr.table.yaml
Normal file
62
zddc/internal/handler/default-ssr.table.yaml
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Default Supplier / Subcontractor Status Report spec, served by
|
||||
# zddc-server when no operator-supplied table.yaml exists at
|
||||
# <project>/ssr/.
|
||||
#
|
||||
# The SSR is a project-level aggregation: one row per party folder
|
||||
# under <project>/archive/, each row backed by
|
||||
# <project>/archive/<party>/ssr.yaml. The synthetic `name` column
|
||||
# shows the party folder name (which is the row's stable identifier);
|
||||
# typical naming encodes a MasterFormat 4-digit code plus C|P plus
|
||||
# a sequence digit (e.g. 0330C1, 0440P2).
|
||||
#
|
||||
# To customize: drop your own table.yaml + form.yaml at
|
||||
# <project>/ssr/table.yaml + form.yaml (the cascade declares
|
||||
# <project>/ssr/ as virtual, but the spec files themselves can be
|
||||
# real overrides). Add columns or tighten enums as your project's
|
||||
# subcontract reporting requires.
|
||||
|
||||
title: Supplier / Subcontractor Status
|
||||
description: One row per party folder under archive/. Click + Add row to create a new party (folder + metadata).
|
||||
|
||||
columns:
|
||||
- field: name
|
||||
title: Party
|
||||
width: 8em
|
||||
- field: vendorType
|
||||
title: Type
|
||||
width: 9em
|
||||
- field: contractNo
|
||||
title: Contract
|
||||
width: 10em
|
||||
- field: scopeSummary
|
||||
title: Scope
|
||||
- field: contractValue
|
||||
title: Value
|
||||
width: 10em
|
||||
- field: awardDate
|
||||
title: Award
|
||||
format: date
|
||||
width: 8em
|
||||
- field: kickoffDate
|
||||
title: Kickoff
|
||||
format: date
|
||||
width: 8em
|
||||
- field: scheduleStatus
|
||||
title: Schedule
|
||||
width: 9em
|
||||
enum: [on-track, at-risk, behind, completed, on-hold]
|
||||
- field: deliverablesStatus
|
||||
title: Deliv.
|
||||
width: 9em
|
||||
enum: [on-track, at-risk, behind, completed]
|
||||
- field: paymentStatus
|
||||
title: Pmt.
|
||||
width: 8em
|
||||
enum: [current, overdue, hold, complete]
|
||||
- field: ownerContact
|
||||
title: Owner contact
|
||||
width: 14em
|
||||
|
||||
defaults:
|
||||
sort:
|
||||
- { field: name, dir: asc }
|
||||
|
|
@ -77,7 +77,7 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit
|
|||
}
|
||||
isRoot := dirPath == ""
|
||||
if !isRoot {
|
||||
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, urlPath); !allowed {
|
||||
if allowed, _ := policy.AllowFromChainP(ctx, decider, chain, PrincipalFromContext(r), urlPath); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
|
@ -117,7 +117,7 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit
|
|||
// used by ?zip and ?convert= elsewhere in the dispatcher.
|
||||
includeHidden := r.URL.Query().Has("hidden")
|
||||
|
||||
entries, err := appfs.ListDirectory(ctx, decider, cfg.Root, dirPath, email, baseURL, includeHidden)
|
||||
entries, err := appfs.ListDirectory(ctx, decider, cfg.Root, dirPath, email, baseURL, includeHidden, ElevatedFromContext(r))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
|
|
@ -147,6 +147,22 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit
|
|||
if dt := zddc.DefaultToolAt(cfg.Root, absDir); dt != "" {
|
||||
w.Header().Set("X-ZDDC-Default-Tool", dt)
|
||||
}
|
||||
// X-ZDDC-On-Plan-Review surfaces whether the cascade above this
|
||||
// path has an on_plan_review block configured. Browse uses it to
|
||||
// show/hide the "Plan Review" right-click menu item without
|
||||
// re-implementing the cascade client-side. Boolean; absent header
|
||||
// = false.
|
||||
if zddc.OnPlanReviewAt(cfg.Root, absDir) != nil {
|
||||
w.Header().Set("X-ZDDC-On-Plan-Review", "true")
|
||||
}
|
||||
// X-ZDDC-Canonical-Folder names the canonical project-layout slot
|
||||
// this directory occupies — "incoming", "received", "working",
|
||||
// "staging", etc. Drives scope-aware context-menu visibility for
|
||||
// Accept Transmittal, Stage/Unstage, and Create Transmittal folder.
|
||||
// Absent header means the directory is not at a canonical slot.
|
||||
if cf := zddc.CanonicalFolderAt(cfg.Root, absDir); cf != "" {
|
||||
w.Header().Set("X-ZDDC-Canonical-Folder", cf)
|
||||
}
|
||||
|
||||
if strings.Contains(accept, "application/json") {
|
||||
// Content-hash ETag on the listing payload. Re-fetched on every
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ func TestServeDirectoryRootIsPublic(t *testing.T) {
|
|||
// nothing else. A user without that email would have been 403'd before
|
||||
// the bypass.
|
||||
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
||||
[]byte("admins:\n - admin@example.com\nacl:\n allow:\n - admin@example.com\n"),
|
||||
[]byte("admins:\n - admin@example.com\nacl:\n permissions:\n admin@example.com: rwcd\n"),
|
||||
0o644); err != nil {
|
||||
t.Fatalf("write root .zddc: %v", err)
|
||||
}
|
||||
|
|
@ -41,11 +41,11 @@ func TestServeDirectoryRootIsPublic(t *testing.T) {
|
|||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "PublicProj", ".zddc"),
|
||||
[]byte("acl:\n allow: [\"*\"]\n"), 0o644); err != nil {
|
||||
[]byte("acl:\n permissions:\n \"*\": rwcd\n"), 0o644); err != nil {
|
||||
t.Fatalf("write PublicProj .zddc: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "PrivateProj", ".zddc"),
|
||||
[]byte("acl:\n allow: [admin@example.com]\n"), 0o644); err != nil {
|
||||
[]byte("acl:\n permissions:\n admin@example.com: rwcd\n"), 0o644); err != nil {
|
||||
t.Fatalf("write PrivateProj .zddc: %v", err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ const (
|
|||
|
||||
opMove = "move"
|
||||
opMkdir = "mkdir"
|
||||
// opSSRRename / opPlanReview / opAcceptTransmittal are declared
|
||||
// alongside their handler files. Listed in the dispatch switch
|
||||
// below so they're discoverable from a single place.
|
||||
)
|
||||
|
||||
// IsWriteMethod reports whether this method is handled by the file API.
|
||||
|
|
@ -96,10 +99,16 @@ func resolveTargetPath(cfg config.Config, urlPath string) (absPath, cleanURL str
|
|||
// Reject hidden / reserved segments. Mirrors dispatch's guard,
|
||||
// applied here too because external callers reach ServeFileAPI
|
||||
// only via dispatch — but defense in depth costs nothing.
|
||||
for _, seg := range strings.Split(strings.Trim(cleanURL, "/"), "/") {
|
||||
// Carve-out: `.zddc` as a leaf segment is writable (admin-gated)
|
||||
// via the file API. Other dot/underscore segments stay reserved.
|
||||
segs := strings.Split(strings.Trim(cleanURL, "/"), "/")
|
||||
for i, seg := range segs {
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
if seg == ZddcFileBasename && i == len(segs)-1 {
|
||||
continue
|
||||
}
|
||||
if seg == "_app" || strings.HasPrefix(seg, ".") || strings.HasPrefix(seg, "_") {
|
||||
return "", "", false, http.StatusNotFound, "reserved path segment"
|
||||
}
|
||||
|
|
@ -118,17 +127,12 @@ func resolveTargetPath(cfg config.Config, urlPath string) (absPath, cleanURL str
|
|||
// that create a brand-new file inherit the parent directory's chain).
|
||||
// Returns allowed=false with the response status already written on deny.
|
||||
//
|
||||
// Admin escape hatches: root admins (IsAdmin) and subtree admins
|
||||
// (IsSubtreeAdmin) get unconditional access — the cascade evaluator
|
||||
// and the WORM mask do not see their requests at all. This matches
|
||||
// the existing admin-bypass semantics in /.profile/zddc and is the
|
||||
// only way to mutate filed documents in Issued/Received.
|
||||
//
|
||||
// .zddc writes use the stricter CanEditZddc rule (strict-ancestor
|
||||
// admin authority) regardless of the action verb, since the file
|
||||
// being written is itself the source of the authority decision and
|
||||
// the strict-ancestor rule is the existing defense against
|
||||
// self-elevation.
|
||||
// All admin / WORM / ACL logic lives downstream in the decider's single
|
||||
// bypass site (policy.InternalDecider.Allow). AllowActionFromChainP
|
||||
// computes IsActiveAdmin from the chain and Principal.Elevated, with
|
||||
// the strict-ancestor rule applied when action == ActionAdmin (the
|
||||
// caller tags .zddc writes that way). The handler does NOT make
|
||||
// admin/elevation decisions of its own — one bypass site, one helper.
|
||||
func authorizeAction(cfg config.Config, w http.ResponseWriter, r *http.Request, absPath, urlPath, action string) bool {
|
||||
probe := filepath.Dir(absPath)
|
||||
for {
|
||||
|
|
@ -143,39 +147,14 @@ func authorizeAction(cfg config.Config, w http.ResponseWriter, r *http.Request,
|
|||
probe = filepath.Dir(probe)
|
||||
}
|
||||
|
||||
email := EmailFromContext(r)
|
||||
|
||||
// Admin bypass — root and subtree.
|
||||
if zddc.IsAdmin(cfg.Root, email) {
|
||||
return true
|
||||
}
|
||||
if zddc.IsSubtreeAdmin(cfg.Root, probe, email) {
|
||||
return true
|
||||
}
|
||||
|
||||
// .zddc writes: CanEditZddc enforces the strict-ancestor rule that
|
||||
// prevents a subtree admin from elevating themselves by editing the
|
||||
// .zddc that grants their authority. Non-admins fall through to the
|
||||
// regular decider — they will be denied unless an explicit `a` verb
|
||||
// is granted to a non-admin role at this path, which is unusual.
|
||||
if filepath.Base(absPath) == ".zddc" {
|
||||
zddcDir := filepath.Dir(absPath)
|
||||
if zddc.CanEditZddc(cfg.Root, zddcDir, email) {
|
||||
return true
|
||||
}
|
||||
// Non-admin .zddc writes go through the normal cascade with
|
||||
// action=admin. Most deployments will have no acl.permissions
|
||||
// entry granting `a`, so this denies; operators who want
|
||||
// non-admin .zddc edits can grant `a` explicitly.
|
||||
}
|
||||
|
||||
p := PrincipalFromContext(r)
|
||||
chain, err := zddc.EffectivePolicy(cfg.Root, probe)
|
||||
if err != nil {
|
||||
slog.Warn("file API ACL chain error", "path", absPath, "err", err)
|
||||
}
|
||||
|
||||
decider := DeciderFromContext(r)
|
||||
allowed, _ := policy.AllowActionFromChain(r.Context(), decider, chain, email, urlPath, action)
|
||||
allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, urlPath, action)
|
||||
if !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return false
|
||||
|
|
@ -321,6 +300,65 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Virtual project-level table views — SSR / MDL rollup / RSK
|
||||
// rollup. The PUT URL lives in <project>/{ssr,mdl,rsk}/...; the
|
||||
// underlying bytes belong inside <project>/archive/<party>/. We
|
||||
// rewrite abs + cleanURL to the canonical path so the rest of
|
||||
// this function (ACL gate, ETag, audit, conversion-cache purge)
|
||||
// operates on the real file location.
|
||||
//
|
||||
// SSR row PUTs land at archive/<party>/ssr.yaml; MDL/RSK rollup
|
||||
// row PUTs land at archive/<party>/<slot>/<file>.yaml. Same
|
||||
// shape as the virtual-received rewrite below.
|
||||
if vv := zddc.ResolveVirtualView(cfg.Root, cleanURL); vv.Resolved && vv.Kind.IsRowKind() {
|
||||
abs = vv.CanonicalAbs
|
||||
cleanURL = vv.CanonicalURL
|
||||
w.Header().Set("X-ZDDC-Resolved-Path", cleanURL)
|
||||
}
|
||||
|
||||
// Virtual received/ rewrite. When the PUT targets a file under the
|
||||
// synthetic <workflow>/received/<file> URL, the canonical record is
|
||||
// WORM — we can't write there. Convention: treat the drop as a
|
||||
// review comment, write it into the workflow folder as
|
||||
// <base>+C<n><suffix> where n increments past any existing comments
|
||||
// on the same target. The target filename comes from the URL's
|
||||
// final segment.
|
||||
if vr := zddc.ResolveVirtualReceived(cfg.Root, cleanURL); vr.Resolved && !vr.IsRoot {
|
||||
targetName := filepath.Base(vr.SuffixURL)
|
||||
commentName, cerr := zddc.CommentResolvedName(vr.WorkflowAbs, targetName)
|
||||
if cerr != nil {
|
||||
http.Error(w, "Bad Request — comment upload requires a ZDDC-parseable target filename: "+cerr.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Race-fix: if the computed filename already exists (concurrent
|
||||
// upload), step the counter forward until we find a free slot.
|
||||
abs = filepath.Join(vr.WorkflowAbs, commentName)
|
||||
for i := 0; i < 32; i++ {
|
||||
if _, err := os.Stat(abs); errors.Is(err, os.ErrNotExist) {
|
||||
break
|
||||
} else if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Bump: recompute with one more existing sibling.
|
||||
commentName, cerr = zddc.CommentResolvedName(vr.WorkflowAbs, targetName)
|
||||
if cerr != nil {
|
||||
http.Error(w, "Internal Server Error — comment counter: "+cerr.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
abs = filepath.Join(vr.WorkflowAbs, commentName)
|
||||
}
|
||||
// Rewrite cleanURL so audit logs + response headers reflect
|
||||
// the actual destination, not the virtual one. Surface to the
|
||||
// client via X-ZDDC-Resolved-Path so the status line can show
|
||||
// "Saved as <resolved name>".
|
||||
cleanURL = vr.WorkflowURL + commentName
|
||||
w.Header().Set("X-ZDDC-Resolved-Path", cleanURL)
|
||||
// Continue with normal write flow — ACL on the workflow folder
|
||||
// gates the write, and existed=false (new file) selects
|
||||
// ActionCreate.
|
||||
}
|
||||
|
||||
// Resolve canonical-folder casing on the way in (no side effects): a
|
||||
// request for /Project/working/foo.md when the on-disk folder is
|
||||
// Working/ should land in Working/, not create a duplicate sibling.
|
||||
|
|
@ -405,6 +443,21 @@ func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request)
|
|||
return
|
||||
}
|
||||
|
||||
// Virtual project-level table views. SSR row deletes are refused
|
||||
// (would orphan the party folder and its mdl/rsk contents) — use
|
||||
// the archive view to delete a party. MDL/RSK rollup row deletes
|
||||
// pass through to the canonical archive/<party>/<slot>/<file>.yaml
|
||||
// path with the normal ACL gate.
|
||||
if vv := zddc.ResolveVirtualView(cfg.Root, cleanURL); vv.Resolved && vv.Kind.IsRowKind() {
|
||||
if vv.Kind == zddc.VirtualViewSSRRow {
|
||||
http.Error(w, "Method Not Allowed — delete the party folder via the archive view, not the SSR table", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
abs = vv.CanonicalAbs
|
||||
cleanURL = vv.CanonicalURL
|
||||
w.Header().Set("X-ZDDC-Resolved-Path", cleanURL)
|
||||
}
|
||||
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
|
|
@ -450,6 +503,12 @@ func serveFilePost(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
serveFileMove(cfg, w, r)
|
||||
case opMkdir:
|
||||
serveFileMkdir(cfg, w, r)
|
||||
case opPlanReview:
|
||||
servePlanReview(cfg, w, r)
|
||||
case opAcceptTransmittal:
|
||||
serveAcceptTransmittal(cfg, w, r)
|
||||
case opSSRRename:
|
||||
serveSSRRename(cfg, w, r)
|
||||
case "":
|
||||
http.Error(w, "Bad Request — missing "+headerOp+" header", http.StatusBadRequest)
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ func fileAPITestSetup(t *testing.T, dirs []string, seed map[string]string) (cfg
|
|||
|
||||
// Root .zddc grants writer access to *@example.com.
|
||||
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
||||
[]byte("acl:\n allow:\n - \"*@example.com\"\n deny: []\n"), 0o644); err != nil {
|
||||
[]byte("acl:\n permissions:\n \"*@example.com\": rwcd\n"), 0o644); err != nil {
|
||||
t.Fatalf("write root .zddc: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -69,6 +69,7 @@ func fileAPITestSetup(t *testing.T, dirs []string, seed map[string]string) (cfg
|
|||
req.Header.Set(k, v)
|
||||
}
|
||||
ctx := context.WithValue(req.Context(), EmailKey, email)
|
||||
ctx = context.WithValue(ctx, ElevatedKey, true)
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
ServeFileAPI(cfg, rec, req)
|
||||
|
|
@ -137,7 +138,7 @@ func TestFileAPI_PutDenyForbidden(t *testing.T) {
|
|||
// Tighten ACL to a different domain — alice@example.com no longer
|
||||
// matches and writes must be 403.
|
||||
if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"),
|
||||
[]byte("acl:\n allow:\n - \"*@allowed.com\"\n deny: []\n"), 0o644); err != nil {
|
||||
[]byte("acl:\n permissions:\n \"*@allowed.com\": rwcd\n"), 0o644); err != nil {
|
||||
t.Fatalf("rewrite .zddc: %v", err)
|
||||
}
|
||||
zddc.InvalidateCache(cfg.Root)
|
||||
|
|
@ -151,7 +152,10 @@ func TestFileAPI_PutDenyForbidden(t *testing.T) {
|
|||
func TestFileAPI_PutHiddenSegmentRejected(t *testing.T) {
|
||||
_, do, _ := fileAPITestSetup(t, nil, nil)
|
||||
|
||||
for _, p := range []string{"/.zddc", "/foo/.hidden", "/_app/spoof.html", "/_template/x"} {
|
||||
// .zddc as a leaf is carved out — gated on admin authority via the
|
||||
// decider, not blocked at the segment guard. Every other dot/
|
||||
// underscore segment stays reserved.
|
||||
for _, p := range []string{"/foo/.hidden", "/_app/spoof.html", "/_template/x", "/.zddc.d/x"} {
|
||||
rec := do(http.MethodPut, p, "alice@example.com", []byte("x"), nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("want 404 for %s, got %d", p, rec.Code)
|
||||
|
|
@ -166,6 +170,7 @@ func TestFileAPI_PutOversizeRejected(t *testing.T) {
|
|||
body := bytes.Repeat([]byte("A"), 32)
|
||||
req := httptest.NewRequest(http.MethodPut, "/Incoming/big.bin", bytes.NewReader(body))
|
||||
ctx := context.WithValue(req.Context(), EmailKey, "alice@example.com")
|
||||
ctx = context.WithValue(ctx, ElevatedKey, true)
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
ServeFileAPI(cfg, rec, req)
|
||||
|
|
@ -435,7 +440,6 @@ acl:
|
|||
Root: root,
|
||||
EmailHeader: "X-Auth-Request-Email",
|
||||
MaxWriteBytes: 1024 * 1024,
|
||||
CascadeMode: "delegated",
|
||||
}
|
||||
decider := &policy.InternalDecider{}
|
||||
|
||||
|
|
@ -450,6 +454,7 @@ acl:
|
|||
req.Header.Set(k, v)
|
||||
}
|
||||
ctx := context.WithValue(req.Context(), EmailKey, email)
|
||||
ctx = context.WithValue(ctx, ElevatedKey, true)
|
||||
ctx = context.WithValue(ctx, DeciderKey, decider)
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
|
|
@ -625,45 +630,6 @@ func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestFileAPI_StrictMode_AncestorDenyAbsolute(t *testing.T) {
|
||||
cfg, _, root := rolePermissionsTestSetup(t)
|
||||
cfg.CascadeMode = "strict"
|
||||
|
||||
// Add a strict-mode lockout at root: deny vendor_acme everywhere.
|
||||
rootZ, _ := os.ReadFile(filepath.Join(root, ".zddc"))
|
||||
updated := strings.Replace(string(rootZ), "_doc_controller: rwcda\n",
|
||||
"_doc_controller: rwcda\n vendor_acme: \"\"\n", 1)
|
||||
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(updated), 0o644); err != nil {
|
||||
t.Fatalf("rewrite root: %v", err)
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
// Build a strict-mode decider so the file API uses the new mode.
|
||||
decider := &policy.InternalDecider{Mode: zddc.ModeStrict}
|
||||
|
||||
doStrict := func(method, target, email string, body []byte) *httptest.ResponseRecorder {
|
||||
var req *http.Request
|
||||
if body != nil {
|
||||
req = httptest.NewRequest(method, target, bytes.NewReader(body))
|
||||
} else {
|
||||
req = httptest.NewRequest(method, target, nil)
|
||||
}
|
||||
ctx := context.WithValue(req.Context(), EmailKey, email)
|
||||
ctx = context.WithValue(ctx, DeciderKey, decider)
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
ServeFileAPI(cfg, rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// Vendor's leaf rwcd grant in archive/Acme/.zddc is overridden by
|
||||
// the root deny under strict mode.
|
||||
rec := doStrict(http.MethodPut, "/Project-X/archive/Acme/incoming/blocked.pdf", "rep@acme.com", []byte("nope"))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("strict mode: vendor should be denied by root explicit-deny, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- staging↔working mirror -------------------------------------------------
|
||||
|
||||
// stagingMirrorURL builds a URL-safe target path for a transmittal folder
|
||||
|
|
|
|||
|
|
@ -77,16 +77,21 @@ type formContext struct {
|
|||
|
||||
// FormRequest describes a recognized form-system request.
|
||||
type FormRequest struct {
|
||||
// Kind is one of: "render-empty", "create", "render-edit", "update".
|
||||
// Kind is one of: "render-empty", "create", "render-edit", "update",
|
||||
// or "create-via-ssr" (the special SSR create flow which materializes
|
||||
// a new party folder + ssr.yaml).
|
||||
Kind string
|
||||
// SpecPath is the absolute filesystem path to the <name>.form.yaml.
|
||||
SpecPath string
|
||||
// DataPath is the absolute filesystem path to the data .yaml; empty for
|
||||
// render-empty / create.
|
||||
// render-empty / create / create-via-ssr.
|
||||
DataPath string
|
||||
// SubmitURL is the URL the form should POST back to (the server-injected
|
||||
// "submit to my own URL" value).
|
||||
SubmitURL string
|
||||
// Project carries the project name for create-via-ssr requests. Empty
|
||||
// for all other kinds.
|
||||
Project string
|
||||
}
|
||||
|
||||
// RecognizeFormRequest classifies r as a form-system request, or returns nil
|
||||
|
|
@ -103,17 +108,38 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
|
|||
if !strings.HasSuffix(urlPath, ".html") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SSR create: /<project>/ssr/form.html maps to the special create
|
||||
// path that materializes a new party folder (mkdir archive/<name>/)
|
||||
// AND writes archive/<name>/ssr.yaml. Recognized before the generic
|
||||
// form.html branch so it doesn't get misrouted as an in-dir create.
|
||||
if project, ok := zddc.IsSSRCreateURL(urlPath); ok {
|
||||
kind := "render-empty"
|
||||
if method == http.MethodPost {
|
||||
kind = "create-via-ssr"
|
||||
}
|
||||
// SpecPath is the embedded default SSR form schema; the loader
|
||||
// falls back to embedded bytes via IsDefaultSpecAbs. The path
|
||||
// itself is the virtual <project>/ssr/form.yaml location.
|
||||
specAbs := filepath.Join(fsRoot, project, "ssr", "form.yaml")
|
||||
return &FormRequest{
|
||||
Kind: kind,
|
||||
SpecPath: specAbs,
|
||||
SubmitURL: urlPath,
|
||||
Project: project,
|
||||
}
|
||||
}
|
||||
|
||||
underlying := strings.TrimSuffix(urlPath, ".html")
|
||||
|
||||
// specEligible accepts a spec path that exists on disk OR matches
|
||||
// the default-MDL virtual-fallback shape at archive/<party>/mdl/.
|
||||
// Without this, the default-MDL row form would 404 on a fresh
|
||||
// archive even though the table view renders.
|
||||
// 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 {
|
||||
if fileExists(specAbs) {
|
||||
return true
|
||||
}
|
||||
if _, ok := IsDefaultMdlSpecAbs(fsRoot, specAbs); ok {
|
||||
if _, ok := IsDefaultSpecAbs(fsRoot, specAbs); ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
|
@ -154,7 +180,36 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
|
|||
|
||||
if strings.HasSuffix(underlying, ".yaml") {
|
||||
// /<dir>/<id>.yaml.html — re-edit / update. Spec lives in the
|
||||
// SAME directory as the row file (<dir>/form.yaml).
|
||||
// SAME directory as the row file (<dir>/form.yaml) UNLESS the
|
||||
// URL maps to one of the project-level virtual views, in which
|
||||
// case the canonical SpecPath / DataPath are inside the per-
|
||||
// party archive folder. ResolveVirtualView handles the rewrite;
|
||||
// SubmitURL stays as the virtual URL so the form POSTs back to
|
||||
// the same endpoint (which re-resolves to the same canonical
|
||||
// paths on the second pass).
|
||||
if vv := zddc.ResolveVirtualView(fsRoot, underlying); vv.Resolved && vv.Kind.IsRowKind() {
|
||||
var specPath string
|
||||
switch vv.Kind {
|
||||
case zddc.VirtualViewSSRRow:
|
||||
specPath = vv.SchemaAbs
|
||||
case zddc.VirtualViewMDLRow, zddc.VirtualViewRSKRow:
|
||||
specPath = filepath.Join(vv.PartyArchive, vv.Slot, "form.yaml")
|
||||
}
|
||||
if !specEligible(specPath) {
|
||||
return nil
|
||||
}
|
||||
kind := "render-edit"
|
||||
if method == http.MethodPost {
|
||||
kind = "update"
|
||||
}
|
||||
return &FormRequest{
|
||||
Kind: kind,
|
||||
SpecPath: specPath,
|
||||
DataPath: vv.CanonicalAbs,
|
||||
SubmitURL: urlPath,
|
||||
}
|
||||
}
|
||||
|
||||
dataRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/")))
|
||||
dataAbs := filepath.Join(fsRoot, dataRel)
|
||||
if !strings.HasPrefix(dataAbs, fsRoot+string(filepath.Separator)) && dataAbs != fsRoot {
|
||||
|
|
@ -192,6 +247,8 @@ func ServeForm(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *ht
|
|||
serveFormCreate(cfg, req, w, r)
|
||||
case "update":
|
||||
serveFormUpdate(cfg, req, w, r)
|
||||
case "create-via-ssr":
|
||||
serveFormCreateSSR(cfg, req, w, r)
|
||||
default:
|
||||
http.Error(w, "unknown form request kind", http.StatusInternalServerError)
|
||||
}
|
||||
|
|
@ -202,8 +259,6 @@ func ServeForm(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *ht
|
|||
// in v0 — POST returns JSON 422 and the client patches errors into the live
|
||||
// form via JS).
|
||||
func serveFormRender(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request, validationErrs []jsonschema.Error) {
|
||||
email := EmailFromContext(r)
|
||||
|
||||
// ACL: read-rights at the directory holding the spec (and, for edits, at
|
||||
// the directory holding the data file). Cascade chain is the same for
|
||||
// every entity in the same directory — a single check covers both.
|
||||
|
|
@ -215,7 +270,7 @@ func serveFormRender(cfg config.Config, req *FormRequest, w http.ResponseWriter,
|
|||
if err != nil {
|
||||
slog.Warn("form: policy error", "path", gateDir, "err", err)
|
||||
}
|
||||
if allowed, _ := policy.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, r.URL.Path); !allowed {
|
||||
if allowed, _ := policy.AllowFromChainP(r.Context(), DeciderFromContext(r), chain, PrincipalFromContext(r), r.URL.Path); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
|
@ -294,7 +349,7 @@ func serveFormCreate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
|
|||
if err != nil {
|
||||
slog.Warn("form: policy error", "path", gateDir, "err", err)
|
||||
}
|
||||
if allowed, _ := policy.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, r.URL.Path); !allowed {
|
||||
if allowed, _ := policy.AllowFromChainP(r.Context(), DeciderFromContext(r), chain, PrincipalFromContext(r), r.URL.Path); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
|
@ -377,7 +432,7 @@ func serveFormUpdate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
|
|||
if err != nil {
|
||||
slog.Warn("form: policy error", "path", req.DataPath, "err", err)
|
||||
}
|
||||
if allowed, _ := policy.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, r.URL.Path); !allowed {
|
||||
if allowed, _ := policy.AllowFromChainP(r.Context(), DeciderFromContext(r), chain, PrincipalFromContext(r), r.URL.Path); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
|
@ -419,13 +474,13 @@ func serveFormUpdate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
|
|||
func loadFormSpec(fsRoot, path string) (*FormSpec, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
// Default-MDL virtual fallback: when the operator hasn't placed
|
||||
// an mdl.form.yaml under archive/<party>/, serve the embedded
|
||||
// default. Mirrors the static-handler fallback for direct YAML
|
||||
// fetches so the form recognizer and the loader agree on what
|
||||
// "this spec exists" means.
|
||||
// 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.
|
||||
if os.IsNotExist(err) {
|
||||
if bytes, ok := IsDefaultMdlSpecAbs(fsRoot, path); ok {
|
||||
if bytes, ok := IsDefaultSpecAbs(fsRoot, path); ok {
|
||||
data = bytes
|
||||
} else {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ func formTestSetup(t *testing.T, zddcFiles map[string]string) (config.Config, fu
|
|||
req = httptest.NewRequest(method, target, nil)
|
||||
}
|
||||
ctx := context.WithValue(req.Context(), EmailKey, email)
|
||||
ctx = context.WithValue(ctx, ElevatedKey, true)
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
|
|
@ -228,7 +229,7 @@ func mustWrite(t *testing.T, path, body string) {
|
|||
func TestRenderEmptyForm(t *testing.T) {
|
||||
_, do := formTestSetup(t, map[string]string{
|
||||
"": `acl:
|
||||
allow: ["*@example.com"]
|
||||
permissions: {"*@example.com": rwcd}
|
||||
`,
|
||||
})
|
||||
rec := do(http.MethodGet, "/Working/safety/form.html", "casey@example.com", "")
|
||||
|
|
@ -252,7 +253,7 @@ func TestRenderEmptyForm(t *testing.T) {
|
|||
func TestRenderEmptyForm_ACLDeny(t *testing.T) {
|
||||
_, do := formTestSetup(t, map[string]string{
|
||||
"": `acl:
|
||||
allow: ["root@example.com"]
|
||||
permissions: {"root@example.com": rwcd}
|
||||
`,
|
||||
})
|
||||
rec := do(http.MethodGet, "/Working/safety/form.html", "stranger@example.com", "")
|
||||
|
|
@ -264,7 +265,7 @@ func TestRenderEmptyForm_ACLDeny(t *testing.T) {
|
|||
func TestCreateSubmission_Valid(t *testing.T) {
|
||||
cfg, do := formTestSetup(t, map[string]string{
|
||||
"": `acl:
|
||||
allow: ["*@example.com"]
|
||||
permissions: {"*@example.com": rwcd}
|
||||
`,
|
||||
})
|
||||
|
||||
|
|
@ -304,7 +305,7 @@ func TestCreateSubmission_Valid(t *testing.T) {
|
|||
func TestCreateSubmission_Invalid_Returns422(t *testing.T) {
|
||||
_, do := formTestSetup(t, map[string]string{
|
||||
"": `acl:
|
||||
allow: ["*@example.com"]
|
||||
permissions: {"*@example.com": rwcd}
|
||||
`,
|
||||
})
|
||||
|
||||
|
|
@ -342,7 +343,7 @@ func TestCreateSubmission_Invalid_Returns422(t *testing.T) {
|
|||
func TestCreateSubmission_ACLDeny(t *testing.T) {
|
||||
_, do := formTestSetup(t, map[string]string{
|
||||
"": `acl:
|
||||
allow: ["root@example.com"]
|
||||
permissions: {"root@example.com": rwcd}
|
||||
`,
|
||||
})
|
||||
body := `{"date":"2026-05-01","location":"Site A"}`
|
||||
|
|
@ -355,7 +356,7 @@ func TestCreateSubmission_ACLDeny(t *testing.T) {
|
|||
func TestCreateSubmission_NoAuth_Returns401(t *testing.T) {
|
||||
_, do := formTestSetup(t, map[string]string{
|
||||
"": `acl:
|
||||
allow: ["*"]
|
||||
permissions: {"*": rwcd}
|
||||
`,
|
||||
})
|
||||
body := `{"date":"2026-05-01","location":"Site A"}`
|
||||
|
|
@ -368,7 +369,7 @@ func TestCreateSubmission_NoAuth_Returns401(t *testing.T) {
|
|||
func TestCreateSubmission_FilenameCollision(t *testing.T) {
|
||||
cfg, do := formTestSetup(t, map[string]string{
|
||||
"": `acl:
|
||||
allow: ["*@example.com"]
|
||||
permissions: {"*@example.com": rwcd}
|
||||
`,
|
||||
})
|
||||
body := `{"date":"2026-05-01","location":"Site A"}`
|
||||
|
|
@ -402,7 +403,7 @@ func TestCreateSubmission_FilenameCollision(t *testing.T) {
|
|||
func TestRenderEdit_LoadsSubmission(t *testing.T) {
|
||||
cfg, do := formTestSetup(t, map[string]string{
|
||||
"": `acl:
|
||||
allow: ["*@example.com"]
|
||||
permissions: {"*@example.com": rwcd}
|
||||
`,
|
||||
})
|
||||
|
||||
|
|
@ -430,7 +431,7 @@ func TestRenderEdit_LoadsSubmission(t *testing.T) {
|
|||
func TestUpdateSubmission_OverwritesFile(t *testing.T) {
|
||||
cfg, do := formTestSetup(t, map[string]string{
|
||||
"": `acl:
|
||||
allow: ["*@example.com"]
|
||||
permissions: {"*@example.com": rwcd}
|
||||
`,
|
||||
})
|
||||
|
||||
|
|
@ -464,7 +465,7 @@ func TestUpdateSubmission_OverwritesFile(t *testing.T) {
|
|||
func TestUpdateSubmission_NotFound(t *testing.T) {
|
||||
_, do := formTestSetup(t, map[string]string{
|
||||
"": `acl:
|
||||
allow: ["*@example.com"]
|
||||
permissions: {"*@example.com": rwcd}
|
||||
`,
|
||||
})
|
||||
body := `{"date":"2026-05-01","location":"Site A"}`
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/auth"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
|
|
@ -26,6 +29,23 @@ const EmailKey contextKey = "email"
|
|||
// "swap internal evaluator for external OPA" plumbing change.
|
||||
const DeciderKey contextKey = "policy-decider"
|
||||
|
||||
// ElevatedKey is the context key for the per-request elevation flag.
|
||||
// Drives zddc.Principal{Elevated} for admin-authority checks. Set by
|
||||
// ACLMiddleware according to the request's auth shape:
|
||||
// - Bearer tokens are implicitly elevated (machine clients can't
|
||||
// toggle a cookie; they're expected to act with the bearer's full
|
||||
// authority).
|
||||
// - Header-auth (browser) sessions elevate iff the request carries
|
||||
// a `zddc-elevate=1` cookie. The cookie is set/cleared by the
|
||||
// elevation toggle UI in the tool headers.
|
||||
const ElevatedKey contextKey = "elevated"
|
||||
|
||||
// elevationCookieName is the cookie clients set to elevate their admin
|
||||
// powers for header-auth (browser) sessions. Value "1" = elevated; any
|
||||
// other value (or absent) = treat as non-admin even if the email is
|
||||
// named in admin lists.
|
||||
const elevationCookieName = "zddc-elevate"
|
||||
|
||||
// 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
|
||||
|
|
@ -49,6 +69,7 @@ const DeciderKey contextKey = "policy-decider"
|
|||
func ACLMiddleware(cfg config.Config, decider policy.Decider, tokens *auth.Store, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var email string
|
||||
var elevated bool
|
||||
if bearer := bearerToken(r); bearer != "" {
|
||||
if tokens == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
|
|
@ -63,8 +84,20 @@ func ACLMiddleware(cfg config.Config, decider policy.Decider, tokens *auth.Store
|
|||
return
|
||||
}
|
||||
email = tok.Email
|
||||
// Bearer-token callers (CLI tools, scripts, mirror clients)
|
||||
// can't toggle a cookie — they're expected to operate with
|
||||
// the bearer's full authority. Implicit elevation keeps the
|
||||
// admin functions usable from the machine-client path.
|
||||
elevated = true
|
||||
} else {
|
||||
email = r.Header.Get(cfg.EmailHeader)
|
||||
// Browser sessions opt in to admin powers via the UI's
|
||||
// elevation toggle, which sets a `zddc-elevate=1` cookie.
|
||||
// Absent / any other value → treat as non-admin even when
|
||||
// the email is named in admin lists.
|
||||
if c, err := r.Cookie(elevationCookieName); err == nil && c.Value == "1" {
|
||||
elevated = true
|
||||
}
|
||||
}
|
||||
// DEBUG-level header dump for diagnosing proxy / SSO header
|
||||
// passthrough. Off by default (LogLevel info); enable with
|
||||
|
|
@ -79,6 +112,7 @@ func ACLMiddleware(cfg config.Config, decider policy.Decider, tokens *auth.Store
|
|||
"observed", email,
|
||||
"headers", r.Header)
|
||||
ctx := context.WithValue(r.Context(), EmailKey, email)
|
||||
ctx = context.WithValue(ctx, ElevatedKey, elevated)
|
||||
if decider != nil {
|
||||
ctx = context.WithValue(ctx, DeciderKey, decider)
|
||||
}
|
||||
|
|
@ -116,6 +150,91 @@ func WithEmail(ctx context.Context, email string) context.Context {
|
|||
return context.WithValue(ctx, EmailKey, email)
|
||||
}
|
||||
|
||||
// ElevatedFromContext reports whether the request has opted into its
|
||||
// admin powers. False for any request that wasn't tagged by
|
||||
// ACLMiddleware (including tests that don't install it), so admin
|
||||
// checks fail closed.
|
||||
func ElevatedFromContext(r *http.Request) bool {
|
||||
if v, ok := r.Context().Value(ElevatedKey).(bool); ok {
|
||||
return v
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// WithElevation returns a context carrying the elevation flag under
|
||||
// ElevatedKey. Test seam for the matching PrincipalFromContext lookup.
|
||||
func WithElevation(ctx context.Context, elevated bool) context.Context {
|
||||
return context.WithValue(ctx, ElevatedKey, elevated)
|
||||
}
|
||||
|
||||
// activeAdminForRequest reports whether the elevated principal would
|
||||
// trigger the decider's admin-bypass branch on the chain at the
|
||||
// request's target path, AND which chain level conferred that
|
||||
// authority. Returned level is 0-based (root=0) when authority is
|
||||
// active, -1 otherwise.
|
||||
//
|
||||
// Best-effort: walks the closest existing ancestor (mirroring the
|
||||
// file API's authorize logic) so a write targeting a not-yet-
|
||||
// existing file still answers correctly. Returns -1 on anonymous
|
||||
// or un-elevated requests without touching the filesystem. The
|
||||
// cascade is mtime-cached upstream, so the per-request cost is one
|
||||
// map lookup in the common case.
|
||||
func activeAdminForRequest(cfg config.Config, r *http.Request, elevated bool, email string) int {
|
||||
if !elevated || email == "" || email == "anonymous" {
|
||||
return -1
|
||||
}
|
||||
cleanURL := strings.TrimSuffix(r.URL.Path, "/")
|
||||
if cleanURL == "" {
|
||||
cleanURL = "/"
|
||||
}
|
||||
rel := strings.TrimPrefix(cleanURL, "/")
|
||||
if rel == "" {
|
||||
// Root request: chain is just the root .zddc.
|
||||
chain, err := zddc.EffectivePolicy(cfg.Root, cfg.Root)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return zddc.AdminLevelInChain(chain, email)
|
||||
}
|
||||
abs := filepath.Join(cfg.Root, filepath.FromSlash(rel))
|
||||
if !strings.HasPrefix(abs, cfg.Root+string(filepath.Separator)) && abs != cfg.Root {
|
||||
return -1
|
||||
}
|
||||
probe := abs
|
||||
for {
|
||||
if info, err := os.Stat(probe); err == nil && info.IsDir() {
|
||||
break
|
||||
}
|
||||
if probe == cfg.Root {
|
||||
break
|
||||
}
|
||||
parent := filepath.Dir(probe)
|
||||
if parent == probe {
|
||||
break
|
||||
}
|
||||
probe = parent
|
||||
}
|
||||
chain, err := zddc.EffectivePolicy(cfg.Root, probe)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return zddc.AdminLevelInChain(chain, email)
|
||||
}
|
||||
|
||||
// PrincipalFromContext bundles the request's authenticated email plus
|
||||
// its elevation flag into a zddc.Principal — the value type the admin
|
||||
// functions (IsAdmin, IsSubtreeAdmin) consume. One call per admin-check
|
||||
// site replaces the previous ad-hoc email argument AND the previous
|
||||
// "did I remember to gate this?" review burden: the type system
|
||||
// enforces the gate by requiring a Principal value, which can only
|
||||
// come from ACLMiddleware-tagged contexts.
|
||||
func PrincipalFromContext(r *http.Request) zddc.Principal {
|
||||
return zddc.Principal{
|
||||
Email: EmailFromContext(r),
|
||||
Elevated: ElevatedFromContext(r),
|
||||
}
|
||||
}
|
||||
|
||||
// DeciderFromContext extracts the policy decider from the request
|
||||
// context. Returns the internal decider as a fallback if none was
|
||||
// installed — this matches the "no OPA configured" semantics and
|
||||
|
|
@ -181,7 +300,7 @@ func HSTSMiddleware(next http.Handler) http.Handler {
|
|||
// so an operator gets a persisted audit trail on disk in addition to the
|
||||
// stderr stream — useful when stderr is not journald-captured (e.g.
|
||||
// container logging where the orchestrator drops stderr after restarts).
|
||||
func AccessLogMiddleware(auditLogger *slog.Logger, next http.Handler) http.Handler {
|
||||
func AccessLogMiddleware(cfg config.Config, auditLogger *slog.Logger, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Capture request start time
|
||||
start := time.Now()
|
||||
|
|
@ -201,15 +320,38 @@ func AccessLogMiddleware(auditLogger *slog.Logger, next http.Handler) http.Handl
|
|||
// Calculate duration
|
||||
durationMs := int(time.Since(start).Milliseconds())
|
||||
|
||||
// Get email from context
|
||||
// Get email + elevation from context. `elevated` records the
|
||||
// per-request opt-in (sudo-style); `active_admin` says whether
|
||||
// the elevated user actually held admin authority on the path
|
||||
// the request targeted — i.e., whether the single bypass
|
||||
// branch in policy.InternalDecider.Allow would have fired
|
||||
// here. Surfacing both lets forensics distinguish:
|
||||
// elevated=false, active_admin=false: normal user
|
||||
// elevated=true, active_admin=false: tried to elevate but no
|
||||
// admin authority on this
|
||||
// path (subtree-admin
|
||||
// cooled by scope)
|
||||
// elevated=true, active_admin=true: admin authority active,
|
||||
// WORM/ACL bypassed
|
||||
email := EmailFromContext(r)
|
||||
if email == "" {
|
||||
email = "anonymous"
|
||||
}
|
||||
elevated := ElevatedFromContext(r)
|
||||
// adminLevel: 0-based chain index of the admins: entry that
|
||||
// conferred authority on this request, or -1 if no admin
|
||||
// authority applies. Lets forensics tell "root admin acted"
|
||||
// (level 0) apart from "subtree admin acted" (level N) apart
|
||||
// from "not admin" (-1). The active_admin bool is its
|
||||
// presence/absence projected to a boolean.
|
||||
adminLevel := activeAdminForRequest(cfg, r, elevated, email)
|
||||
|
||||
args := []any{
|
||||
"ts", start.Format(time.RFC3339),
|
||||
"email", email,
|
||||
"elevated", elevated,
|
||||
"active_admin", adminLevel >= 0,
|
||||
"chain_admin_level", adminLevel,
|
||||
"method", r.Method,
|
||||
"path", requestedPath,
|
||||
"status", wrapped.status,
|
||||
|
|
|
|||
|
|
@ -2,13 +2,17 @@ package handler
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// TestAccessLogReadsEmailFromACLContext is a regression test for a bug where
|
||||
|
|
@ -32,7 +36,7 @@ func TestAccessLogReadsEmailFromACLContext(t *testing.T) {
|
|||
|
||||
// Correct order: ACL is outer, AccessLog is inner. AccessLog reads
|
||||
// email from the context ACL populated.
|
||||
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(nil, noop))
|
||||
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(cfg, nil, noop))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
|
||||
|
|
@ -60,7 +64,7 @@ func TestAccessLogAnonymousWhenNoEmail(t *testing.T) {
|
|||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(nil, noop))
|
||||
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(cfg, nil, noop))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
// Note: no X-Auth-Request-Email header set.
|
||||
|
|
@ -90,7 +94,7 @@ func TestAccessLogOuterDoesNotSeeInnerContext(t *testing.T) {
|
|||
})
|
||||
|
||||
// Inverted order — the ORIGINAL buggy chain.
|
||||
chain := AccessLogMiddleware(nil, ACLMiddleware(cfg, nil, nil, noop))
|
||||
chain := AccessLogMiddleware(cfg, nil, ACLMiddleware(cfg, nil, nil, noop))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
|
||||
|
|
@ -119,7 +123,7 @@ func TestAccessLogMiddleware_AuditLoggerReceivesSameFields(t *testing.T) {
|
|||
})
|
||||
|
||||
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
|
||||
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(auditLogger, noop))
|
||||
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(cfg, auditLogger, noop))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/some/path", nil)
|
||||
req.Header.Set("X-Auth-Request-Email", "bob@example.com")
|
||||
|
|
@ -136,3 +140,113 @@ func TestAccessLogMiddleware_AuditLoggerReceivesSameFields(t *testing.T) {
|
|||
t.Errorf("audit log missing status code; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccessLog_ChainAdminLevelAttribution pins the audit-log forensic
|
||||
// invariant: every request record carries `chain_admin_level` matching
|
||||
// the .zddc admins: level that conferred admin authority on this
|
||||
// request, or -1 when no admin authority applies. Forensics use this to
|
||||
// distinguish a root-admin write from a subtree-admin write from a
|
||||
// non-admin write — three operationally distinct events that used to
|
||||
// be conflated under a single `is_admin` boolean.
|
||||
//
|
||||
// Truth table the middleware must emit:
|
||||
//
|
||||
// (elevated, in admins at level N) → chain_admin_level=N, active_admin=true
|
||||
// (elevated, in admins at no level) → chain_admin_level=-1, active_admin=false
|
||||
// (not elevated, in admins) → chain_admin_level=-1, active_admin=false
|
||||
// (anonymous, elevation flag ignored) → chain_admin_level=-1, active_admin=false
|
||||
func TestAccessLog_ChainAdminLevelAttribution(t *testing.T) {
|
||||
// Fixture: root admin at level 0; subtree admin at level 1 (Project-1).
|
||||
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)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "Project-1"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir Project-1: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "Project-1", ".zddc"),
|
||||
[]byte("admins:\n - alice@example.com\n"), 0o644); err != nil {
|
||||
t.Fatalf("write subtree .zddc: %v", err)
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
zddc.InvalidateCache(filepath.Join(root, "Project-1"))
|
||||
|
||||
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 {
|
||||
Email string `json:"email"`
|
||||
Elevated bool `json:"elevated"`
|
||||
ActiveAdmin bool `json:"active_admin"`
|
||||
ChainAdminLevel int `json:"chain_admin_level"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
parse := func(t *testing.T, buf *bytes.Buffer) record {
|
||||
t.Helper()
|
||||
var rec record
|
||||
if err := json.Unmarshal(buf.Bytes(), &rec); err != nil {
|
||||
t.Fatalf("audit log not valid JSON: %v; raw=%s", err, buf.String())
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
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},
|
||||
{"subtree admin elevated probing own subtree → level 1", "alice@example.com", true, "/Project-1/", 1, true},
|
||||
{"subtree admin elevated probing root → -1 (out of scope)", "alice@example.com", true, "/", -1, false},
|
||||
{"root admin un-elevated → -1 (no live authority)", "root@example.com", false, "/", -1, false},
|
||||
{"non-admin elevated → -1 (elevation alone confers nothing)", "stranger@example.com", true, "/", -1, false},
|
||||
{"anonymous → -1", "", false, "/", -1, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
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, tc.path, nil)
|
||||
if tc.email != "" {
|
||||
req.Header.Set("X-Auth-Request-Email", tc.email)
|
||||
}
|
||||
if tc.elevate {
|
||||
req.AddCookie(&http.Cookie{Name: "zddc-elevate", Value: "1"})
|
||||
}
|
||||
chain.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
rec := parse(t, &buf)
|
||||
if rec.ChainAdminLevel != tc.wantLevel {
|
||||
t.Errorf("chain_admin_level = %d, want %d", rec.ChainAdminLevel, tc.wantLevel)
|
||||
}
|
||||
if rec.ActiveAdmin != tc.wantActive {
|
||||
t.Errorf("active_admin = %v, want %v", rec.ActiveAdmin, tc.wantActive)
|
||||
}
|
||||
// active_admin is the projection of chain_admin_level — these
|
||||
// two fields must agree on every record. Asserted explicitly
|
||||
// so a future refactor that drops the chain_admin_level field
|
||||
// (or recomputes active_admin from a different source) trips
|
||||
// this test before the forensic invariant rots.
|
||||
if rec.ActiveAdmin != (rec.ChainAdminLevel >= 0) {
|
||||
t.Errorf("active_admin must equal (chain_admin_level >= 0); got active=%v level=%d",
|
||||
rec.ActiveAdmin, rec.ChainAdminLevel)
|
||||
}
|
||||
// Elevation flag must round-trip independently — distinguishes
|
||||
// "tried to elevate, no authority" (elevated=true, active=false)
|
||||
// from "didn't elevate" (elevated=false, active=false).
|
||||
if rec.Elevated != tc.elevate {
|
||||
t.Errorf("elevated = %v, want %v", rec.Elevated, tc.elevate)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
85
zddc/internal/handler/paths.go
Normal file
85
zddc/internal/handler/paths.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// URL ↔ filesystem path math used by several handler files. Pure
|
||||
// string manipulation — no I/O, no policy decisions — so it lives
|
||||
// in its own file rather than being attached to any one feature.
|
||||
|
||||
// resolvePath translates a URL `path=` query (relative to fsRoot, with
|
||||
// '/' separator and leading '/') into an absolute filesystem path. It
|
||||
// rejects path traversal and any segment beginning with '.' or '_' so
|
||||
// reserved namespaces (e.g. .devshell) cannot be addressed through
|
||||
// admin APIs. Returns the cleaned absolute path or an error suitable
|
||||
// for a 404.
|
||||
func resolvePath(fsRoot, urlPath string) (string, error) {
|
||||
urlPath = strings.TrimSpace(urlPath)
|
||||
if urlPath == "" {
|
||||
urlPath = "/"
|
||||
}
|
||||
if !strings.HasPrefix(urlPath, "/") {
|
||||
return "", errors.New("path must be absolute (start with /)")
|
||||
}
|
||||
cleanURL := filepath.ToSlash(filepath.Clean(urlPath))
|
||||
|
||||
// Reject reserved-prefix segments so callers cannot create
|
||||
// .foo/.zddc or _bar/.zddc through admin APIs.
|
||||
for _, seg := range strings.Split(strings.Trim(cleanURL, "/"), "/") {
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(seg, ".") || strings.HasPrefix(seg, "_") {
|
||||
return "", errors.New("reserved-prefix path segment")
|
||||
}
|
||||
}
|
||||
|
||||
rel := strings.TrimPrefix(cleanURL, "/")
|
||||
abs := filepath.Join(fsRoot, filepath.FromSlash(rel))
|
||||
abs = filepath.Clean(abs)
|
||||
|
||||
// Path containment.
|
||||
if abs != fsRoot && !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) {
|
||||
return "", errors.New("path escapes root")
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
// urlPathOf produces the URL form of an absolute filesystem path under
|
||||
// fsRoot. Returns "/" for fsRoot itself, otherwise "/<rel>".
|
||||
func urlPathOf(fsRoot, abs string) string {
|
||||
if abs == fsRoot {
|
||||
return "/"
|
||||
}
|
||||
rel, err := filepath.Rel(fsRoot, abs)
|
||||
if err != nil {
|
||||
return "/"
|
||||
}
|
||||
return "/" + filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
// chainDirs reproduces EffectivePolicy's directory walk so callers can
|
||||
// label each policy-chain level with the directory it came from. Used
|
||||
// by the virtual-.zddc body to annotate which ancestor contributed
|
||||
// which rule.
|
||||
func chainDirs(fsRoot, dirPath string) []string {
|
||||
fsRoot = filepath.Clean(fsRoot)
|
||||
dirPath = filepath.Clean(dirPath)
|
||||
dirs := []string{fsRoot}
|
||||
if dirPath == fsRoot {
|
||||
return dirs
|
||||
}
|
||||
rel, err := filepath.Rel(fsRoot, dirPath)
|
||||
if err != nil || rel == "." {
|
||||
return dirs
|
||||
}
|
||||
current := fsRoot
|
||||
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
||||
current = filepath.Join(current, part)
|
||||
dirs = append(dirs, current)
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
462
zddc/internal/handler/planreview.go
Normal file
462
zddc/internal/handler/planreview.go
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"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"
|
||||
)
|
||||
|
||||
// Plan Review — the doc-controller's "establish the canonical record"
|
||||
// step. Right-click on archive/<party>/received/<tracking>/ in the
|
||||
// browse app; the client POSTs X-ZDDC-Op: plan-review with the body
|
||||
// below.
|
||||
//
|
||||
// Authorisation model — no ACL exception, only existing grants:
|
||||
//
|
||||
// - Create authority on received/<tracking>/. The doc_controller
|
||||
// gets this from `worm: [document_controller]` on received/ in the
|
||||
// cascade defaults; the same `c` (write-once-create) verb that
|
||||
// lets them file canonical submittals lets them establish this
|
||||
// .zddc once.
|
||||
// - ActionAdmin on reviewing_root/.zddc + staging_root/.zddc. The
|
||||
// invoker must already administer those subtrees per the cascade
|
||||
// defaults.
|
||||
//
|
||||
// Operation:
|
||||
//
|
||||
// 1. Workflow folders converge first (idempotent — match by
|
||||
// .zddc.received_path; mkdir if missing; rewrite workflow .zddc
|
||||
// with received_path + ACL).
|
||||
// 2. Write received/<tracking>/.zddc — but only if it doesn't exist.
|
||||
// The .zddc schema is server-constrained to {planned_review_date,
|
||||
// planned_response_date, created_by} — no ACL, admins, or other
|
||||
// fields, so this write cannot escalate the invoker's authority.
|
||||
// If the file already exists, the canonical record is sealed; the
|
||||
// dates in the request are ignored and the workflow folders are
|
||||
// converged on top.
|
||||
//
|
||||
// So Plan Review's first run establishes the canonical commitment;
|
||||
// subsequent runs can only re-converge the workflow ACLs (e.g. swap
|
||||
// review lead). The planned dates are write-once — to change them, an
|
||||
// admin must edit received/<tracking>/.zddc directly via their admin
|
||||
// authority (which under the cascade defaults is nobody beneath the
|
||||
// root admin; deliberate).
|
||||
|
||||
const opPlanReview = "plan-review"
|
||||
|
||||
// planReviewRequest is the YAML body the browse client POSTs.
|
||||
type planReviewRequest struct {
|
||||
ReviewLead string `yaml:"review_lead"`
|
||||
Approver string `yaml:"approver"`
|
||||
PlanReviewCompleteDate string `yaml:"plan_review_complete_date"`
|
||||
PlanResponseDate string `yaml:"plan_response_date"`
|
||||
}
|
||||
|
||||
// planReviewResponse is the JSON returned to the client.
|
||||
type planReviewResponse struct {
|
||||
Tracking string `json:"tracking"`
|
||||
Title string `json:"title"`
|
||||
Reviewing planReviewFolderOK `json:"reviewing"`
|
||||
Staging planReviewFolderOK `json:"staging"`
|
||||
Received planReviewFolderOK `json:"received"`
|
||||
}
|
||||
|
||||
type planReviewFolderOK struct {
|
||||
Path string `json:"path"`
|
||||
Created bool `json:"created"`
|
||||
ZddcWritten bool `json:"zddc_written"`
|
||||
}
|
||||
|
||||
// receivedURLPattern matches /<project>/archive/<party>/received/<tracking>/
|
||||
// — Plan Review is only valid at that depth. Trailing slash required.
|
||||
var receivedURLPattern = regexp.MustCompile(`^/([^/]+)/archive/([^/]+)/received/([^/]+)/?$`)
|
||||
|
||||
func servePlanReview(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||
// 1. URL must be a received-tracking folder.
|
||||
cleanURL := "/" + strings.Trim(r.URL.Path, "/") + "/"
|
||||
m := receivedURLPattern.FindStringSubmatch(cleanURL)
|
||||
if m == nil {
|
||||
http.Error(w, "Bad Request — plan-review must POST to /<project>/archive/<party>/received/<tracking>/", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
project, party, tracking := m[1], m[2], m[3]
|
||||
|
||||
// 2. Body parse.
|
||||
body, ok := readBodyCapped(cfg, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req planReviewRequest
|
||||
if err := yaml.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, "Bad Request — could not parse YAML body: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.ReviewLead == "" || req.Approver == "" ||
|
||||
req.PlanReviewCompleteDate == "" || req.PlanResponseDate == "" {
|
||||
http.Error(w, "Bad Request — body must include review_lead, approver, plan_review_complete_date, plan_response_date", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp, status, msg := executePlanReview(cfg, r, project, party, tracking, req)
|
||||
if status != http.StatusOK {
|
||||
auditFile(r, "plan-review", cleanURL, status, 0, nil)
|
||||
http.Error(w, msg, status)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("X-ZDDC-Source", "fileapi:plan-review")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
auditFile(r, "plan-review", cleanURL, http.StatusOK, 0, nil)
|
||||
}
|
||||
|
||||
// executePlanReview runs the Plan Review three-stage flow against an
|
||||
// already-resolved received/<tracking>/ path. URL and body parsing
|
||||
// happen in the caller. Returns the response struct on success;
|
||||
// non-200 (status, message) on auth or execution failure. The caller
|
||||
// is responsible for writing the HTTP response.
|
||||
//
|
||||
// Exposed so accept-transmittal can chain Plan Review in the same
|
||||
// request without round-tripping through HTTP.
|
||||
func executePlanReview(cfg config.Config, r *http.Request, project, party, tracking string, req planReviewRequest) (*planReviewResponse, int, string) {
|
||||
receivedRel := filepath.ToSlash(filepath.Join("archive", party, "received", tracking))
|
||||
receivedAbs := filepath.Join(cfg.Root, project, filepath.FromSlash(receivedRel))
|
||||
cleanURL := "/" + project + "/archive/" + party + "/received/" + tracking + "/"
|
||||
|
||||
prCfg := zddc.OnPlanReviewAt(cfg.Root, receivedAbs)
|
||||
if prCfg == nil || prCfg.ReviewingRoot == "" || prCfg.StagingRoot == "" {
|
||||
return nil, http.StatusConflict, "Conflict — on_plan_review is not configured in the cascade for this subtree"
|
||||
}
|
||||
reviewingRoot := filepath.Join(cfg.Root, project, filepath.FromSlash(strings.Trim(prCfg.ReviewingRoot, "/")))
|
||||
stagingRoot := filepath.Join(cfg.Root, project, filepath.FromSlash(strings.Trim(prCfg.StagingRoot, "/")))
|
||||
|
||||
// Pre-flight authorisation. No ACL exception — we use existing
|
||||
// cascade grants:
|
||||
// (a) ActionAdmin on reviewing_root and staging_root proves the
|
||||
// invoker is subtree-admin of the workflow roots and can
|
||||
// write the workflow .zddc files.
|
||||
// (b) The invoker has `c` (write-once-create) authority on
|
||||
// received/<tracking>/. For the doc_controller this comes
|
||||
// from `worm: [document_controller]` on received/ in the
|
||||
// cascade defaults — the same authority that lets them file
|
||||
// canonical submittals lets them establish this .zddc once.
|
||||
p := PrincipalFromContext(r)
|
||||
email := EmailFromContext(r)
|
||||
if email == "" {
|
||||
return nil, http.StatusForbidden, "Forbidden — no authenticated principal"
|
||||
}
|
||||
// All three pre-flight checks go through the consolidated decider.
|
||||
// AllowActionFromChainP routes ActionAdmin .zddc edits and the
|
||||
// single admin-bypass branch for elevated admins. No manual
|
||||
// IsAdmin / IsSubtreeAdmin branching here.
|
||||
decider := DeciderFromContext(r)
|
||||
for _, root := range []string{reviewingRoot, stagingRoot} {
|
||||
chain, perr := zddc.EffectivePolicy(cfg.Root, root)
|
||||
if perr != nil {
|
||||
return nil, http.StatusInternalServerError, "Internal Server Error — cascade lookup: " + perr.Error()
|
||||
}
|
||||
rel, _ := filepath.Rel(cfg.Root, root)
|
||||
rootURL := "/" + filepath.ToSlash(rel) + "/.zddc"
|
||||
allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, rootURL, policy.ActionAdmin)
|
||||
if !allowed {
|
||||
return nil, http.StatusForbidden, fmt.Sprintf("Forbidden — %s lacks subtree-admin authority for %s",
|
||||
email, strings.TrimPrefix(root, cfg.Root+string(filepath.Separator)))
|
||||
}
|
||||
}
|
||||
// Verify `c` (create) authority on received/<tracking>/. Elevated
|
||||
// admins short-circuit inside the decider; non-admin doc_controllers
|
||||
// come through the WORM-list grant. One code path either way.
|
||||
{
|
||||
chain, perr := zddc.EffectivePolicy(cfg.Root, receivedAbs)
|
||||
if perr != nil {
|
||||
return nil, http.StatusInternalServerError, "Internal Server Error — cascade lookup: " + perr.Error()
|
||||
}
|
||||
allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, cleanURL, policy.ActionCreate)
|
||||
if !allowed {
|
||||
return nil, http.StatusForbidden, fmt.Sprintf("Forbidden — %s lacks create authority on %s (filing this submittal requires the doc_controller WORM grant)",
|
||||
email, strings.TrimPrefix(receivedAbs, cfg.Root+string(filepath.Separator)))
|
||||
}
|
||||
}
|
||||
|
||||
// Derive a title from received/<tracking>/'s contents — first
|
||||
// ZDDC-parseable filename's title field wins. Fallback to the
|
||||
// tracking number itself so the folder name always has a tail.
|
||||
title := deriveTitleFromReceived(receivedAbs)
|
||||
if title == "" {
|
||||
title = tracking
|
||||
}
|
||||
|
||||
// Materialise roots + received/<tracking>/ ancestors (the received
|
||||
// folder itself was created when the doc controller moved the
|
||||
// submittal in; defensive ensure here for tests).
|
||||
for _, root := range []string{reviewingRoot, stagingRoot, receivedAbs} {
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return nil, http.StatusInternalServerError, "Internal Server Error — ensure dirs: " + err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
// received/<tracking>/.zddc is WRITE-ONCE — the canonical commitment.
|
||||
// First-run creates it under the invoker's WORM-`c` authority
|
||||
// (verified above); subsequent runs leave it alone and the request's
|
||||
// date fields are ignored. The schema is server-constrained: only
|
||||
// planned_review_date + planned_response_date + created_by are written.
|
||||
// No ACL, admins, or other content — so this write cannot escalate
|
||||
// the invoker's authority.
|
||||
receivedResult, err := establishReceivedPlanDates(receivedAbs, req.PlanReviewCompleteDate, req.PlanResponseDate, email, cfg.Root)
|
||||
if err != nil {
|
||||
return nil, http.StatusInternalServerError, "Internal Server Error — received .zddc: " + err.Error()
|
||||
}
|
||||
|
||||
// Converge the workflow folders.
|
||||
reviewingResult, err := convergeWorkflowFolder(workflowConverge{
|
||||
fsRoot: cfg.Root,
|
||||
root: reviewingRoot,
|
||||
forecast: req.PlanReviewCompleteDate,
|
||||
tracking: tracking,
|
||||
title: title,
|
||||
receivedRel: receivedRel,
|
||||
acl: map[string]string{req.ReviewLead: "rwcda"},
|
||||
creatorEmail: email,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, http.StatusInternalServerError, "Internal Server Error — reviewing convergence: " + err.Error()
|
||||
}
|
||||
|
||||
stagingResult, err := convergeWorkflowFolder(workflowConverge{
|
||||
fsRoot: cfg.Root,
|
||||
root: stagingRoot,
|
||||
forecast: req.PlanResponseDate,
|
||||
tracking: tracking,
|
||||
title: title,
|
||||
receivedRel: receivedRel,
|
||||
acl: map[string]string{req.Approver: "rwcda"},
|
||||
creatorEmail: email,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, http.StatusInternalServerError, "Internal Server Error — staging convergence: " + err.Error()
|
||||
}
|
||||
|
||||
return &planReviewResponse{
|
||||
Tracking: tracking,
|
||||
Title: title,
|
||||
Reviewing: planReviewFolderOK{
|
||||
Path: "/" + filepath.ToSlash(reviewingResult.relPath) + "/",
|
||||
Created: reviewingResult.created,
|
||||
ZddcWritten: reviewingResult.zddcWritten,
|
||||
},
|
||||
Staging: planReviewFolderOK{
|
||||
Path: "/" + filepath.ToSlash(stagingResult.relPath) + "/",
|
||||
Created: stagingResult.created,
|
||||
ZddcWritten: stagingResult.zddcWritten,
|
||||
},
|
||||
Received: planReviewFolderOK{
|
||||
Path: "/" + filepath.ToSlash(receivedResult.relPath) + "/",
|
||||
Created: receivedResult.created,
|
||||
ZddcWritten: receivedResult.zddcWritten,
|
||||
},
|
||||
}, http.StatusOK, ""
|
||||
}
|
||||
|
||||
// establishReceivedPlanDates writes received/<tracking>/.zddc with the
|
||||
// committed planned dates iff the file doesn't yet exist. If it does,
|
||||
// the canonical record is already sealed and the call is a no-op
|
||||
// (zddcWritten=false in the result); the request's date fields are
|
||||
// silently ignored on subsequent runs. The schema is server-constrained
|
||||
// to just the two date fields + created_by — no ACL or admin grants.
|
||||
func establishReceivedPlanDates(receivedAbs, planReview, planResponse, creatorEmail, fsRoot string) (workflowResult, error) {
|
||||
var res workflowResult
|
||||
res.absPath = receivedAbs
|
||||
if rel, err := filepath.Rel(fsRoot, receivedAbs); err == nil {
|
||||
res.relPath = filepath.ToSlash(rel)
|
||||
} else {
|
||||
res.relPath = receivedAbs
|
||||
}
|
||||
zddcPath := filepath.Join(receivedAbs, ".zddc")
|
||||
if _, err := os.Stat(zddcPath); err == nil {
|
||||
// Sealed — leave alone. zddcWritten stays false.
|
||||
return res, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return res, err
|
||||
}
|
||||
zf := zddc.ZddcFile{
|
||||
PlannedReviewDate: planReview,
|
||||
PlannedResponseDate: planResponse,
|
||||
CreatedBy: creatorEmail,
|
||||
}
|
||||
if err := zddc.WriteFile(receivedAbs, zf); err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.zddcWritten = true
|
||||
res.created = true // first-time establishment
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// deriveTitleFromReceived scans received/<tracking>/ for ZDDC-parseable
|
||||
// filenames and returns the first one's title field. Empty if no
|
||||
// parseable file is found.
|
||||
func deriveTitleFromReceived(receivedAbs string) string {
|
||||
entries, err := os.ReadDir(receivedAbs)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
// Sort for deterministic title selection (first alphabetical wins).
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
parsed := zddc.ParseFilename(name)
|
||||
if parsed.Valid && parsed.Title != "" {
|
||||
return parsed.Title
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// workflowConverge captures the parameters for converging a single
|
||||
// reviewing/ or staging/ workflow folder.
|
||||
type workflowConverge struct {
|
||||
fsRoot string // master root (cfg.Root) — used to compute response paths
|
||||
root string // absolute path of reviewing_root or staging_root
|
||||
forecast string // initial forecast date for the folder name (YYYY-MM-DD)
|
||||
tracking string // tracking number
|
||||
title string // derived title
|
||||
receivedRel string // relative path to canonical submittal, e.g. archive/Acme/received/Acme-0042
|
||||
acl map[string]string // per-folder ACL grants (principal → verb-set)
|
||||
creatorEmail string // creator/audit email
|
||||
}
|
||||
|
||||
// workflowResult is the post-convergence summary for one folder.
|
||||
type workflowResult struct {
|
||||
relPath string // server-relative path (no leading slash, no trailing slash)
|
||||
absPath string
|
||||
created bool // true iff this convergence run mkdir'd the folder
|
||||
zddcWritten bool // true iff a .zddc was written (always true on success)
|
||||
}
|
||||
|
||||
// convergeWorkflowFolder converges one of the workflow folders (reviewing
|
||||
// or staging) toward the desired state. Idempotent on re-run.
|
||||
func convergeWorkflowFolder(c workflowConverge) (workflowResult, error) {
|
||||
var res workflowResult
|
||||
|
||||
// Search the root for an existing folder whose .zddc.received_path
|
||||
// matches. If found, use it — the user controls the folder name via
|
||||
// direct rename, so we don't fight their date.
|
||||
existing, err := findWorkflowFolderByReceivedPath(c.root, c.receivedRel)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
target := existing
|
||||
if target == "" {
|
||||
// No match — mkdir at <root>/<forecast>_<tracking> (TBD) - <title>/.
|
||||
// Append _2, _3 to disambiguate exact-name collisions with a
|
||||
// folder belonging to a DIFFERENT submittal.
|
||||
baseName := sanitiseFolderName(fmt.Sprintf("%s_%s (TBD) - %s", c.forecast, c.tracking, c.title))
|
||||
candidate := filepath.Join(c.root, baseName)
|
||||
for n := 2; n <= 100; n++ {
|
||||
if _, statErr := os.Stat(candidate); errors.Is(statErr, os.ErrNotExist) {
|
||||
break
|
||||
} else if statErr != nil {
|
||||
return res, statErr
|
||||
}
|
||||
candidate = filepath.Join(c.root, fmt.Sprintf("%s_%d", baseName, n))
|
||||
if n == 100 {
|
||||
return res, fmt.Errorf("convergence: exhausted suffix attempts for %s", baseName)
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(candidate, 0o755); err != nil {
|
||||
return res, fmt.Errorf("mkdir workflow folder: %w", err)
|
||||
}
|
||||
target = candidate
|
||||
res.created = true
|
||||
}
|
||||
|
||||
// Write .zddc with desired content. Overwrites if present. Workflow
|
||||
// .zddc carries received_path + acl ONLY — no planned dates (those
|
||||
// live in the canonical received/.zddc, which the sub-admins
|
||||
// cannot modify).
|
||||
zf := zddc.ZddcFile{
|
||||
ReceivedPath: c.receivedRel,
|
||||
CreatedBy: c.creatorEmail,
|
||||
}
|
||||
if len(c.acl) > 0 {
|
||||
zf.ACL = zddc.ACLRules{Permissions: c.acl}
|
||||
}
|
||||
if err := zddc.WriteFile(target, zf); err != nil {
|
||||
return res, fmt.Errorf("write workflow .zddc: %w", err)
|
||||
}
|
||||
res.zddcWritten = true
|
||||
|
||||
res.absPath = target
|
||||
if rel, err := filepath.Rel(c.fsRoot, target); err == nil {
|
||||
res.relPath = filepath.ToSlash(rel)
|
||||
} else {
|
||||
res.relPath = target
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// findWorkflowFolderByReceivedPath scans root for direct children
|
||||
// whose .zddc has received_path matching the given relative path.
|
||||
// Returns the matching absolute path, or "" if none.
|
||||
func findWorkflowFolderByReceivedPath(root, receivedRel string) (string, error) {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
want := filepath.ToSlash(filepath.Clean(receivedRel))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
zddcPath := filepath.Join(root, e.Name(), ".zddc")
|
||||
zf, perr := zddc.ParseFile(zddcPath)
|
||||
if perr != nil {
|
||||
slog.Warn("plan-review: parse workflow .zddc", "path", zddcPath, "err", perr)
|
||||
continue
|
||||
}
|
||||
if zf.ReceivedPath == "" {
|
||||
continue
|
||||
}
|
||||
got := filepath.ToSlash(filepath.Clean(zf.ReceivedPath))
|
||||
if got == want {
|
||||
return filepath.Join(root, e.Name()), nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// sanitiseFolderName replaces filesystem-troublesome characters in a
|
||||
// title with safe substitutes. Conservative — keeps the ZDDC folder
|
||||
// grammar (the parens and the " - " separator) intact while taming
|
||||
// arbitrary user input in the title segment.
|
||||
func sanitiseFolderName(name string) string {
|
||||
repl := strings.NewReplacer(
|
||||
"/", "-",
|
||||
"\\", "-",
|
||||
":", "-",
|
||||
"\x00", "",
|
||||
)
|
||||
return strings.TrimSpace(repl.Replace(name))
|
||||
}
|
||||
321
zddc/internal/handler/planreview_test.go
Normal file
321
zddc/internal/handler/planreview_test.go
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// planReviewSetup writes a tree shaped like a real ZDDC project with
|
||||
// `archive/Acme/received/Acme-0042/` populated and an admin grant for
|
||||
// alice@example.com. Returns the cfg, a do() helper that POSTs Plan
|
||||
// Review requests, and the root path.
|
||||
func planReviewSetup(t *testing.T) (config.Config, func(target, email string, body []byte) *httptest.ResponseRecorder, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
|
||||
// Root .zddc grants alice subtree-admin everywhere AND sets the
|
||||
// document_controller role so the cascade's reviewing/+staging/
|
||||
// admin grants resolve to her. The role membership also confers
|
||||
// `c` authority on received/ via the WORM list in the defaults,
|
||||
// which Plan Review's pre-flight requires.
|
||||
mustWriteHelper(t, filepath.Join(root, ".zddc"),
|
||||
"admins:\n - alice@example.com\n"+
|
||||
"roles:\n document_controller:\n members: [alice@example.com]\n")
|
||||
|
||||
for _, d := range []string{"Project-1/archive/Acme/received/Acme-0042"} {
|
||||
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", d, err)
|
||||
}
|
||||
}
|
||||
// Seed a ZDDC-parseable file so the title derives correctly.
|
||||
mustWriteHelper(t, filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/Acme-0042_A (RFI) - Foundation.pdf"),
|
||||
"%PDF-")
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
cfg := config.Config{
|
||||
Root: root,
|
||||
EmailHeader: "X-Auth-Request-Email",
|
||||
MaxWriteBytes: 64 * 1024,
|
||||
}
|
||||
|
||||
do := func(target, email string, body []byte) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, target, bytes.NewReader(body))
|
||||
req.Header.Set(headerOp, opPlanReview)
|
||||
req.Header.Set("Content-Type", "application/yaml")
|
||||
ctx := context.WithValue(req.Context(), EmailKey, email)
|
||||
ctx = context.WithValue(ctx, ElevatedKey, true)
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
ServeFileAPI(cfg, rec, req)
|
||||
return rec
|
||||
}
|
||||
return cfg, do, root
|
||||
}
|
||||
|
||||
func mustWriteHelper(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir parent of %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func planReviewBody() string {
|
||||
return strings.Join([]string{
|
||||
"review_lead: bob@vendor.com",
|
||||
"approver: carol@example.com",
|
||||
"plan_review_complete_date: 2026-05-30",
|
||||
"plan_response_date: 2026-06-15",
|
||||
}, "\n") + "\n"
|
||||
}
|
||||
|
||||
// TestPlanReview_FreshConvergence runs Plan Review against a tree with
|
||||
// no existing workflow folders. Expects both reviewing/ and staging/
|
||||
// to be created, each with a .zddc declaring received_path +
|
||||
// planned_date, and the response to confirm both were created.
|
||||
func TestPlanReview_FreshConvergence(t *testing.T) {
|
||||
cfg, do, root := planReviewSetup(t)
|
||||
|
||||
rec := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com",
|
||||
[]byte(planReviewBody()))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp planReviewResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v; body=%s", err, rec.Body.String())
|
||||
}
|
||||
if resp.Tracking != "Acme-0042" {
|
||||
t.Errorf("Tracking=%q, want Acme-0042", resp.Tracking)
|
||||
}
|
||||
if !resp.Reviewing.Created || !resp.Reviewing.ZddcWritten {
|
||||
t.Errorf("Reviewing not fully converged: %+v", resp.Reviewing)
|
||||
}
|
||||
if !resp.Staging.Created || !resp.Staging.ZddcWritten {
|
||||
t.Errorf("Staging not fully converged: %+v", resp.Staging)
|
||||
}
|
||||
|
||||
// Workflow folders: should carry received_path + ACL only.
|
||||
for _, side := range []struct {
|
||||
path string
|
||||
wantDate string
|
||||
actor string
|
||||
}{
|
||||
{resp.Reviewing.Path, "2026-05-30", "bob@vendor.com"},
|
||||
{resp.Staging.Path, "2026-06-15", "carol@example.com"},
|
||||
} {
|
||||
abs := filepath.Join(root, filepath.FromSlash(strings.Trim(side.path, "/")))
|
||||
base := filepath.Base(abs)
|
||||
if !strings.HasPrefix(base, side.wantDate) {
|
||||
t.Errorf("folder %q does not start with date %q", base, side.wantDate)
|
||||
}
|
||||
zf, err := zddc.ParseFile(filepath.Join(abs, ".zddc"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s/.zddc: %v", abs, err)
|
||||
}
|
||||
if zf.ReceivedPath != "archive/Acme/received/Acme-0042" {
|
||||
t.Errorf("%s: received_path=%q", abs, zf.ReceivedPath)
|
||||
}
|
||||
// Workflow .zddc must NOT carry planned dates — those live in
|
||||
// the canonical received/.zddc and are sealed.
|
||||
if zf.PlannedReviewDate != "" || zf.PlannedResponseDate != "" {
|
||||
t.Errorf("%s: workflow .zddc must not carry planned dates", abs)
|
||||
}
|
||||
if v, ok := zf.ACL.Permissions[side.actor]; !ok || v != "rwcda" {
|
||||
t.Errorf("%s: ACL[%s]=%q, want rwcda", abs, side.actor, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Canonical received/.zddc: planned dates are sealed here.
|
||||
zfRecv, err := zddc.ParseFile(filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/.zddc"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse received .zddc: %v", err)
|
||||
}
|
||||
if zfRecv.PlannedReviewDate != "2026-05-30" {
|
||||
t.Errorf("received planned_review_date=%q", zfRecv.PlannedReviewDate)
|
||||
}
|
||||
if zfRecv.PlannedResponseDate != "2026-06-15" {
|
||||
t.Errorf("received planned_response_date=%q", zfRecv.PlannedResponseDate)
|
||||
}
|
||||
// Constrained schema: no ACL, no admins, no roles, no received_path.
|
||||
if len(zfRecv.ACL.Permissions) != 0 || len(zfRecv.Admins) != 0 ||
|
||||
len(zfRecv.Roles) != 0 || zfRecv.ReceivedPath != "" {
|
||||
t.Errorf("received .zddc has unexpected content: acl=%v admins=%v roles=%v rp=%q",
|
||||
zfRecv.ACL.Permissions, zfRecv.Admins, zfRecv.Roles, zfRecv.ReceivedPath)
|
||||
}
|
||||
if resp.Title != "Foundation" {
|
||||
t.Errorf("Title=%q, want Foundation (from received file)", resp.Title)
|
||||
}
|
||||
_ = cfg
|
||||
}
|
||||
|
||||
// TestPlanReview_Idempotent runs Plan Review twice with the same body;
|
||||
// the second run is a no-op (created=false everywhere) and folder/.zddc
|
||||
// state is unchanged.
|
||||
func TestPlanReview_Idempotent(t *testing.T) {
|
||||
_, do, root := planReviewSetup(t)
|
||||
|
||||
first := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com",
|
||||
[]byte(planReviewBody()))
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("first status=%d; body=%s", first.Code, first.Body.String())
|
||||
}
|
||||
var firstResp planReviewResponse
|
||||
if err := json.Unmarshal(first.Body.Bytes(), &firstResp); err != nil {
|
||||
t.Fatalf("decode first: %v", err)
|
||||
}
|
||||
|
||||
second := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com",
|
||||
[]byte(planReviewBody()))
|
||||
if second.Code != http.StatusOK {
|
||||
t.Fatalf("second status=%d; body=%s", second.Code, second.Body.String())
|
||||
}
|
||||
var secondResp planReviewResponse
|
||||
if err := json.Unmarshal(second.Body.Bytes(), &secondResp); err != nil {
|
||||
t.Fatalf("decode second: %v", err)
|
||||
}
|
||||
|
||||
if secondResp.Reviewing.Created || secondResp.Staging.Created {
|
||||
t.Errorf("second run created=true: %+v", secondResp)
|
||||
}
|
||||
if firstResp.Reviewing.Path != secondResp.Reviewing.Path {
|
||||
t.Errorf("reviewing path drifted: %q vs %q",
|
||||
firstResp.Reviewing.Path, secondResp.Reviewing.Path)
|
||||
}
|
||||
if firstResp.Staging.Path != secondResp.Staging.Path {
|
||||
t.Errorf("staging path drifted: %q vs %q",
|
||||
firstResp.Staging.Path, secondResp.Staging.Path)
|
||||
}
|
||||
|
||||
// Confirm no duplicate folders snuck in.
|
||||
reviewingRoot := filepath.Join(root, "Project-1", "reviewing")
|
||||
entries, err := os.ReadDir(reviewingRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", reviewingRoot, err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("reviewing/ has %d entries, want 1", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlanReview_ReceivedZddcIsWriteOnce — re-running Plan Review with
|
||||
// different planned dates leaves received/.zddc alone (sealed at first
|
||||
// run). Workflow folder ACLs can still be re-converged on subsequent
|
||||
// runs.
|
||||
func TestPlanReview_ReceivedZddcIsWriteOnce(t *testing.T) {
|
||||
_, do, root := planReviewSetup(t)
|
||||
|
||||
if rec := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com",
|
||||
[]byte(planReviewBody())); rec.Code != http.StatusOK {
|
||||
t.Fatalf("first POST status=%d; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Second run with a different review_lead AND a different planned
|
||||
// date. The workflow .zddc should reflect the new actor, but the
|
||||
// canonical received/.zddc must keep its original dates.
|
||||
updated := strings.Join([]string{
|
||||
"review_lead: dave@vendor.com",
|
||||
"approver: carol@example.com",
|
||||
"plan_review_complete_date: 2099-01-01", // attempted but should be ignored
|
||||
"plan_response_date: 2099-01-15",
|
||||
}, "\n") + "\n"
|
||||
if rec := do("/Project-1/archive/Acme/received/Acme-0042/", "alice@example.com",
|
||||
[]byte(updated)); rec.Code != http.StatusOK {
|
||||
t.Fatalf("second POST status=%d; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// received/.zddc unchanged.
|
||||
zfRecv, err := zddc.ParseFile(filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/.zddc"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse received: %v", err)
|
||||
}
|
||||
if zfRecv.PlannedReviewDate != "2026-05-30" || zfRecv.PlannedResponseDate != "2026-06-15" {
|
||||
t.Errorf("received dates drifted: review=%q response=%q",
|
||||
zfRecv.PlannedReviewDate, zfRecv.PlannedResponseDate)
|
||||
}
|
||||
|
||||
// reviewing/.zddc reflects the new review_lead.
|
||||
reviewingRoot := filepath.Join(root, "Project-1", "reviewing")
|
||||
entries, err := os.ReadDir(reviewingRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", reviewingRoot, err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 reviewing folder, got %d", len(entries))
|
||||
}
|
||||
zf, err := zddc.ParseFile(filepath.Join(reviewingRoot, entries[0].Name(), ".zddc"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if _, ok := zf.ACL.Permissions["dave@vendor.com"]; !ok {
|
||||
t.Errorf("reviewing ACL did not switch to dave: %v", zf.ACL.Permissions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlanReview_Forbidden — a user without admin authority on the
|
||||
// workflow roots gets 403 and no folders are created.
|
||||
func TestPlanReview_Forbidden(t *testing.T) {
|
||||
_, do, root := planReviewSetup(t)
|
||||
|
||||
rec := do("/Project-1/archive/Acme/received/Acme-0042/", "stranger@vendor.com",
|
||||
[]byte(planReviewBody()))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status=%d, want 403; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "Project-1", "reviewing")); err == nil {
|
||||
// reviewing/ should not have been materialised. The mkdir
|
||||
// happens AFTER the ACL check in the handler, so refusal
|
||||
// guarantees no state change.
|
||||
entries, _ := os.ReadDir(filepath.Join(root, "Project-1", "reviewing"))
|
||||
if len(entries) > 0 {
|
||||
t.Errorf("reviewing/ created despite 403: %d entries", len(entries))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommentResolvedName — counter scope is per-target, plain target
|
||||
// gets +C1, subsequent targets get sequential +C2/+C3.
|
||||
func TestCommentResolvedName(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
resolved, err := zddc.CommentResolvedName(root, "Acme-0042_A (RFI) - Foundation.pdf")
|
||||
if err != nil {
|
||||
t.Fatalf("first: %v", err)
|
||||
}
|
||||
if resolved != "Acme-0042_A+C1 (RFI) - Foundation.pdf" {
|
||||
t.Errorf("first=%q, want +C1", resolved)
|
||||
}
|
||||
|
||||
// Seed a +C1 file; next should be +C2.
|
||||
if err := os.WriteFile(filepath.Join(root, resolved), []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
resolved2, err := zddc.CommentResolvedName(root, "Acme-0042_A (RFI) - Foundation.pdf")
|
||||
if err != nil {
|
||||
t.Fatalf("second: %v", err)
|
||||
}
|
||||
if resolved2 != "Acme-0042_A+C2 (RFI) - Foundation.pdf" {
|
||||
t.Errorf("second=%q, want +C2", resolved2)
|
||||
}
|
||||
|
||||
// Different target → independent counter at +C1.
|
||||
resolvedB, err := zddc.CommentResolvedName(root, "Acme-0042_B (RFI) - Foundation-Spec.pdf")
|
||||
if err != nil {
|
||||
t.Fatalf("B: %v", err)
|
||||
}
|
||||
if resolvedB != "Acme-0042_B+C1 (RFI) - Foundation-Spec.pdf" {
|
||||
t.Errorf("B=%q, want +C1", resolvedB)
|
||||
}
|
||||
}
|
||||
56
zddc/internal/handler/profile_assets.go
Normal file
56
zddc/internal/handler/profile_assets.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
)
|
||||
|
||||
// Custom CSS pipeline. Lets an operator drop `.profile.css` at the
|
||||
// deployment root and have it picked up automatically as styling for
|
||||
// the profile page.
|
||||
|
||||
const profileCustomCSSName = ".profile.css"
|
||||
|
||||
// hasCustomProfileCSS reports whether <fsRoot>/.profile.css exists.
|
||||
// The profile template uses this to decide whether to inject the
|
||||
// <link> tag.
|
||||
func hasCustomProfileCSS(fsRoot string) bool {
|
||||
_, err := os.Stat(filepath.Join(fsRoot, profileCustomCSSName))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// profileAssetsPathPrefix is the URL prefix for admin static assets.
|
||||
// The only consumer is the profile page, which emits a <link> to
|
||||
// /custom.css when an operator has placed one at root.
|
||||
const profileAssetsPathPrefix = ProfilePathPrefix + "/assets"
|
||||
|
||||
// serveProfileAssets handles GET /.profile/assets/<file>. V1 only
|
||||
// ships `custom.css` (passthrough of <root>/.profile.css when present);
|
||||
// other paths return 404 so we don't accidentally expose arbitrary
|
||||
// files. The caller (profilehandler.go) has already gated on admin
|
||||
// scope.
|
||||
func serveProfileAssets(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", "GET")
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
rest := strings.TrimPrefix(r.URL.Path, profileAssetsPathPrefix+"/")
|
||||
switch rest {
|
||||
case "custom.css":
|
||||
path := filepath.Join(cfg.Root, profileCustomCSSName)
|
||||
if fi, err := os.Stat(path); err != nil || fi.IsDir() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeFile(w, r, path)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
|
@ -34,51 +34,57 @@ func ServeProfile(cfg config.Config, ring *LogRing, idx *archive.Index, w http.R
|
|||
sub = "/"
|
||||
}
|
||||
|
||||
// Delegated to ServeZddc; that handler has its own hasAnyAdminScope gate.
|
||||
if sub == "/zddc" || strings.HasPrefix(sub, "/zddc/") {
|
||||
ServeZddc(cfg, w, r)
|
||||
// /assets/ serves the profile page's custom.css when an operator
|
||||
// has placed one at root.
|
||||
if strings.HasPrefix(sub, "/assets/") {
|
||||
serveProfileAssets(cfg, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
email := EmailFromContext(r)
|
||||
|
||||
// adminOnly wraps an admin-gated sub-handler. Routes that need root-
|
||||
// admin authority (sudo-style, elevation-gated) deny with 404 (not
|
||||
// 403) so a non-admin probing the namespace can't enumerate which
|
||||
// admin-only resources exist. Single helper instead of five copy-
|
||||
// pasted gates.
|
||||
adminOnly := func(fn http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !zddc.IsAdmin(cfg.Root, PrincipalFromContext(r)) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fn(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
switch sub {
|
||||
case "/", "":
|
||||
serveProfilePage(cfg, w, r)
|
||||
case "/access":
|
||||
writeJSON(w, enumerateAccess(r.Context(), DeciderFromContext(r), cfg, email))
|
||||
writeJSON(w, enumerateAccess(r.Context(), DeciderFromContext(r), cfg, PrincipalFromContext(r)))
|
||||
case "/projects":
|
||||
serveProfileProjectsCreate(cfg, w, r)
|
||||
case "/whoami":
|
||||
if !zddc.IsAdmin(cfg.Root, email) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
serveProfileWhoami(cfg, email, w, r)
|
||||
adminOnly(func(w http.ResponseWriter, r *http.Request) {
|
||||
serveProfileWhoami(cfg, email, w, r)
|
||||
})(w, r)
|
||||
case "/config":
|
||||
if !zddc.IsAdmin(cfg.Root, email) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
serveProfileConfig(cfg, w, r)
|
||||
adminOnly(func(w http.ResponseWriter, r *http.Request) {
|
||||
serveProfileConfig(cfg, w, r)
|
||||
})(w, r)
|
||||
case "/logs":
|
||||
if !zddc.IsAdmin(cfg.Root, email) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
serveProfileLogs(ring, w, r)
|
||||
adminOnly(func(w http.ResponseWriter, r *http.Request) {
|
||||
serveProfileLogs(ring, w, r)
|
||||
})(w, r)
|
||||
case "/effective-policy":
|
||||
if !zddc.IsAdmin(cfg.Root, email) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
serveProfileEffectivePolicy(cfg, w, r)
|
||||
adminOnly(func(w http.ResponseWriter, r *http.Request) {
|
||||
serveProfileEffectivePolicy(cfg, w, r)
|
||||
})(w, r)
|
||||
case "/reindex":
|
||||
if !zddc.IsAdmin(cfg.Root, email) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
serveProfileReindex(cfg, idx, email, w, r)
|
||||
adminOnly(func(w http.ResponseWriter, r *http.Request) {
|
||||
serveProfileReindex(cfg, idx, email, w, r)
|
||||
})(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
|
@ -110,51 +116,82 @@ func serveProfileReindex(cfg config.Config, idx *archive.Index, email string, w
|
|||
})
|
||||
}
|
||||
|
||||
// treeEntry is one row in the AccessView's AdminSubtrees list — every
|
||||
// directory containing a .zddc that the caller administers. The profile
|
||||
// page renders them inline; the create-project form's parent-selector
|
||||
// seeds from the same list.
|
||||
type treeEntry struct {
|
||||
Path string `json:"path"`
|
||||
Title string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
// AccessView is the data the profile page lazy-loads from /.profile/access
|
||||
// after first paint. The HTML shell renders only Email/EmailHeader/
|
||||
// IsSuperAdmin (all cheap); Projects + AdminSubtrees + HasAnyAdminScope come
|
||||
// in via JS. EditableParentChoices is what the create-project form's
|
||||
// parent-selector renders — derived from AdminSubtrees on the client.
|
||||
// in via JS. AdminSubtrees doubles as the create-project parent-selector
|
||||
// source — every entry is editable, since subtree admins own their own
|
||||
// .zddc.
|
||||
//
|
||||
// IsSuperAdmin and HasAnyAdminScope reflect EFFECTIVE authority — gated
|
||||
// by elevation. CanElevate is the independent "do you have any admin
|
||||
// grant ANYWHERE in the tree, regardless of elevation?" signal that the
|
||||
// header elevation toggle reads to decide whether to show itself.
|
||||
type AccessView struct {
|
||||
Email string `json:"email"`
|
||||
EmailHeader string `json:"email_header"`
|
||||
IsSuperAdmin bool `json:"is_super_admin"`
|
||||
HasAnyAdminScope bool `json:"has_any_admin_scope"`
|
||||
Projects []ProjectInfo `json:"projects"`
|
||||
AdminSubtrees []treeEntry `json:"admin_subtrees"`
|
||||
EditableParentChoices []treeEntry `json:"editable_parent_choices"`
|
||||
Email string `json:"email"`
|
||||
EmailHeader string `json:"email_header"`
|
||||
IsSuperAdmin bool `json:"is_super_admin"`
|
||||
HasAnyAdminScope bool `json:"has_any_admin_scope"`
|
||||
CanElevate bool `json:"can_elevate"`
|
||||
// CanCreateProject is true when the caller is authorized to mkdir a
|
||||
// new top-level project — either via the root .zddc granting `c` to
|
||||
// their email/role, or via super-admin authority (elevated). Drives
|
||||
// the visibility of the profile page's "+ New project" form so the
|
||||
// UI doesn't dangle an affordance the server would 404.
|
||||
CanCreateProject bool `json:"can_create_project"`
|
||||
Projects []ProjectInfo `json:"projects"`
|
||||
AdminSubtrees []treeEntry `json:"admin_subtrees"`
|
||||
}
|
||||
|
||||
// enumerateAccess builds an AccessView for the given caller. Used by the
|
||||
// JSON endpoint at /.profile/access; the HTML page no longer calls this on
|
||||
// the request hot path — it ships a shell first and the client fetches the
|
||||
// view after first paint.
|
||||
func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Config, email string) AccessView {
|
||||
// view after first paint. The principal carries elevation: an un-elevated
|
||||
// admin reports IsSuperAdmin=false here, so the UI naturally renders the
|
||||
// non-elevated view (no admin scaffolds shown) until the user opts in.
|
||||
func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Config, p zddc.Principal) AccessView {
|
||||
view := AccessView{
|
||||
Email: email,
|
||||
Email: p.Email,
|
||||
EmailHeader: cfg.EmailHeader,
|
||||
IsSuperAdmin: zddc.IsAdmin(cfg.Root, email),
|
||||
IsSuperAdmin: zddc.IsAdmin(cfg.Root, p),
|
||||
}
|
||||
view.Projects, _ = EnumerateProjects(ctx, decider, cfg, email)
|
||||
view.AdminSubtrees = enumerateAdminSubtrees(cfg, email)
|
||||
view.Projects, _ = EnumerateProjects(ctx, decider, cfg, p)
|
||||
view.AdminSubtrees = enumerateAdminSubtrees(cfg, p)
|
||||
view.HasAnyAdminScope = view.IsSuperAdmin || len(view.AdminSubtrees) > 0
|
||||
for _, t := range view.AdminSubtrees {
|
||||
if t.CanEdit {
|
||||
view.EditableParentChoices = append(view.EditableParentChoices, t)
|
||||
}
|
||||
// CanElevate is the elevation-INDEPENDENT discovery flag: "does
|
||||
// this user have admin authority that they could opt into?"
|
||||
// Drives the header elevation toggle's visibility — an un-
|
||||
// elevated admin still needs to see the toggle they'd flip.
|
||||
view.CanElevate = zddc.HasAnyAdminGrant(cfg.Root, p.Email)
|
||||
// CanCreateProject mirrors the gate in serveProfileProjectsCreate —
|
||||
// same decider call, same authority, no daylight between the UI
|
||||
// affordance and the endpoint.
|
||||
if rootChain, perr := zddc.EffectivePolicy(cfg.Root, cfg.Root); perr == nil {
|
||||
allowed, _ := policy.AllowActionFromChainP(ctx, decider, rootChain, p, "/", policy.ActionCreate)
|
||||
view.CanCreateProject = allowed
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
// enumerateAdminSubtrees lists every directory containing a .zddc that the
|
||||
// caller can see as an admin (super-admin or subtree-admin). Each entry
|
||||
// carries can_edit so the page can label read-only entries (the file that
|
||||
// grants the user's own authority).
|
||||
func enumerateAdminSubtrees(cfg config.Config, email string) []treeEntry {
|
||||
// caller can see as an admin (super-admin or subtree-admin). Every entry
|
||||
// is editable — subtree admins own their own .zddc. Returns empty for an
|
||||
// un-elevated principal — the elevation flag short-circuits each admin
|
||||
// check below.
|
||||
func enumerateAdminSubtrees(cfg config.Config, p zddc.Principal) []treeEntry {
|
||||
dirs, _ := zddc.ScanZddcFiles(cfg.Root)
|
||||
out := make([]treeEntry, 0, len(dirs))
|
||||
for _, d := range dirs {
|
||||
if !zddc.IsSubtreeAdmin(cfg.Root, d, email) && !zddc.IsAdmin(cfg.Root, email) {
|
||||
if !zddc.IsSubtreeAdmin(cfg.Root, d, p) && !zddc.IsAdmin(cfg.Root, p) {
|
||||
continue
|
||||
}
|
||||
var title string
|
||||
|
|
@ -162,9 +199,8 @@ func enumerateAdminSubtrees(cfg config.Config, email string) []treeEntry {
|
|||
title = zf.Title
|
||||
}
|
||||
out = append(out, treeEntry{
|
||||
Path: urlPathOf(cfg.Root, d),
|
||||
CanEdit: zddc.CanEditZddc(cfg.Root, d, email),
|
||||
Title: title,
|
||||
Path: urlPathOf(cfg.Root, d),
|
||||
Title: title,
|
||||
})
|
||||
}
|
||||
return out
|
||||
|
|
@ -228,7 +264,6 @@ func serveProfileConfig(cfg config.Config, w http.ResponseWriter, r *http.Reques
|
|||
IndexPath string `json:"index_path"`
|
||||
EmailHeader string `json:"email_header"`
|
||||
CORSOrigins []string `json:"cors_origins"`
|
||||
CascadeMode string `json:"cascade_mode"`
|
||||
}
|
||||
writeJSON(w, response{
|
||||
Root: cfg.Root,
|
||||
|
|
@ -240,7 +275,6 @@ func serveProfileConfig(cfg config.Config, w http.ResponseWriter, r *http.Reques
|
|||
IndexPath: cfg.IndexPath,
|
||||
EmailHeader: cfg.EmailHeader,
|
||||
CORSOrigins: cfg.CORSOrigins,
|
||||
CascadeMode: cfg.CascadeMode,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -316,9 +350,9 @@ func levelRank(s string) int {
|
|||
// "chain": {
|
||||
// "has_any_file": true,
|
||||
// "levels": [
|
||||
// {"path": "/", "exists": true, "acl": {"allow": [...]}, "admins": [...]},
|
||||
// {"path": "/", "exists": true, "acl": {"permissions": {...}}, "admins": [...]},
|
||||
// {"path": "/Project-X/", "exists": false},
|
||||
// {"path": "/Project-X/sub/", "exists": true, "acl": {"allow": [...]}}
|
||||
// {"path": "/Project-X/sub/", "exists": true, "acl": {"permissions": {...}}}
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
|
|
@ -384,17 +418,15 @@ func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *ht
|
|||
// don't have per-level existence, but ZddcFile.Admins/ACL being
|
||||
// non-empty is a reasonable proxy).
|
||||
out := struct {
|
||||
Path string `json:"path"`
|
||||
Email string `json:"email"`
|
||||
Decision bool `json:"decision"`
|
||||
DeciderKind string `json:"decider_kind"`
|
||||
CascadeMode string `json:"cascade_mode"`
|
||||
Path string `json:"path"`
|
||||
Email string `json:"email"`
|
||||
Decision bool `json:"decision"`
|
||||
DeciderKind string `json:"decider_kind"`
|
||||
Chain struct {
|
||||
HasAnyFile bool `json:"has_any_file"`
|
||||
HasAnyFile bool `json:"has_any_file"`
|
||||
// VisibleStart is the lowest chain index whose grants are
|
||||
// visible to evaluation at the leaf, accounting for any
|
||||
// inherit:false fence in delegated mode. In strict mode it
|
||||
// is always 0 (fences are ignored under AC-6).
|
||||
// inherit:false fence.
|
||||
VisibleStart int `json:"visible_start"`
|
||||
Levels []levelView `json:"levels"`
|
||||
} `json:"chain"`
|
||||
|
|
@ -403,11 +435,9 @@ func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *ht
|
|||
Email: probeEmail,
|
||||
Decision: allow,
|
||||
DeciderKind: deciderKind(decider),
|
||||
CascadeMode: cfg.CascadeMode,
|
||||
}
|
||||
out.Chain.HasAnyFile = chain.HasAnyFile
|
||||
mode, _ := zddc.ParseCascadeMode(cfg.CascadeMode)
|
||||
out.Chain.VisibleStart = chain.VisibleStart(len(chain.Levels)-1, mode)
|
||||
out.Chain.VisibleStart = chain.VisibleStart(len(chain.Levels) - 1)
|
||||
|
||||
// Reconstruct level paths from cfg.Root. This mirrors how
|
||||
// zddc.EffectivePolicy builds the chain (see cascade.go).
|
||||
|
|
@ -438,33 +468,30 @@ func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *ht
|
|||
entry := levelView{
|
||||
Index: i,
|
||||
ZddcPath: lp,
|
||||
Exists: len(lvl.Admins) > 0 || len(lvl.ACL.Allow) > 0 || len(lvl.ACL.Deny) > 0 || len(lvl.ACL.Permissions) > 0 || lvl.ACL.Inherit != nil,
|
||||
Exists: len(lvl.Admins) > 0 || len(lvl.ACL.Permissions) > 0 || lvl.ACL.Inherit != nil,
|
||||
Inherit: lvl.ACL.Inherit,
|
||||
}
|
||||
if entry.Exists {
|
||||
entry.Acl = &lvl.ACL
|
||||
entry.Admins = lvl.Admins
|
||||
}
|
||||
// Per-level email match: would this level's deny or allow
|
||||
// patterns hit the email if checked? Reuses the same
|
||||
// MatchesPattern code the live evaluator does.
|
||||
// Per-level email match: which permissions entry at this level
|
||||
// would hit the email? Empty verbs = explicit deny; any non-
|
||||
// empty verbs = grant. Mirrors GrantedVerbsAtLevel.
|
||||
anyMatch := false
|
||||
decisionAtLevel := "no_match"
|
||||
for _, p := range lvl.ACL.Deny {
|
||||
if zddc.MatchesPattern(p, probeEmail) {
|
||||
anyMatch = true
|
||||
for pattern, verbs := range lvl.ACL.Permissions {
|
||||
if !zddc.MatchesPattern(pattern, probeEmail) {
|
||||
continue
|
||||
}
|
||||
anyMatch = true
|
||||
if verbs == "" {
|
||||
decisionAtLevel = "deny"
|
||||
break
|
||||
}
|
||||
}
|
||||
if !anyMatch {
|
||||
for _, p := range lvl.ACL.Allow {
|
||||
if zddc.MatchesPattern(p, probeEmail) {
|
||||
anyMatch = true
|
||||
decisionAtLevel = "allow"
|
||||
break
|
||||
}
|
||||
}
|
||||
decisionAtLevel = "allow"
|
||||
// Don't break — keep scanning so an explicit deny still
|
||||
// wins over a same-level grant.
|
||||
}
|
||||
entry.AnyMatch = anyMatch
|
||||
entry.Decision = decisionAtLevel
|
||||
|
|
|
|||
|
|
@ -41,13 +41,26 @@ func profileTestRoot(t *testing.T, admins []string) (config.Config, *LogRing) {
|
|||
}, NewLogRing(50)
|
||||
}
|
||||
|
||||
// requestWithEmail builds a request whose context already carries email (as
|
||||
// the real ACLMiddleware would inject) and whose path is path.
|
||||
func requestWithEmail(method, path, email string) *http.Request {
|
||||
// requestAsAdmin builds a test request whose context carries email
|
||||
// AND Elevated=true — the wire shape ACLMiddleware would inject for
|
||||
// a bearer-token caller or a browser session with the elevation
|
||||
// cookie set. Name is the convention: every admin-action test should
|
||||
// reach for THIS helper, so the call site visibly opts into admin
|
||||
// authority. Tests that need to exercise the un-elevated path use
|
||||
// requestAsUserMaybeElevated(method, path, email, false) explicitly —
|
||||
// see the un-elevated negative tests in admin_test.go for that shape.
|
||||
func requestAsAdmin(method, path, email string) *http.Request {
|
||||
return requestAsUserMaybeElevated(method, path, email, true)
|
||||
}
|
||||
|
||||
// requestAsUserMaybeElevated is the explicit form. Tests for the
|
||||
// "un-elevated admin should fail closed" gate pass elevated=false.
|
||||
func requestAsUserMaybeElevated(method, path, email string, elevated bool) *http.Request {
|
||||
r := httptest.NewRequest(method, path, nil)
|
||||
if email != "" {
|
||||
r.Header.Set("X-Auth-Request-Email", email)
|
||||
ctx := context.WithValue(r.Context(), EmailKey, email)
|
||||
ctx = context.WithValue(ctx, ElevatedKey, elevated)
|
||||
r = r.WithContext(ctx)
|
||||
}
|
||||
return r
|
||||
|
|
@ -107,7 +120,7 @@ func TestServeProfileGateMatrix(t *testing.T) {
|
|||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, tc.path, tc.email))
|
||||
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, tc.path, tc.email))
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Errorf("status = %d, want %d (body: %s)", rec.Code, tc.wantStatus, rec.Body.String())
|
||||
}
|
||||
|
|
@ -118,7 +131,7 @@ func TestServeProfileGateMatrix(t *testing.T) {
|
|||
func TestServeProfileWhoamiPayload(t *testing.T) {
|
||||
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
|
||||
rec := httptest.NewRecorder()
|
||||
r := requestWithEmail(http.MethodGet, "/.profile/whoami", "alice@example.com")
|
||||
r := requestAsAdmin(http.MethodGet, "/.profile/whoami", "alice@example.com")
|
||||
r.Header.Set("X-Other-Header", "hi there")
|
||||
|
||||
ServeProfile(cfg, ring, nil, rec, r)
|
||||
|
|
@ -159,7 +172,7 @@ func TestServeProfileConfigPayload(t *testing.T) {
|
|||
cfg.CORSOrigins = []string{"https://zddc.varasys.io"}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/config", "alice@example.com"))
|
||||
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/config", "alice@example.com"))
|
||||
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
|
|
@ -186,7 +199,7 @@ func TestServeProfileLogsPayload(t *testing.T) {
|
|||
logger.Warn("second", "code", 42)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/logs", "alice@example.com"))
|
||||
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/logs", "alice@example.com"))
|
||||
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
|
|
@ -213,7 +226,7 @@ func TestServeProfileLogsLevelFilter(t *testing.T) {
|
|||
|
||||
rec := httptest.NewRecorder()
|
||||
ServeProfile(cfg, ring, nil, rec,
|
||||
requestWithEmail(http.MethodGet, "/.profile/logs?level=warn", "alice@example.com"))
|
||||
requestAsAdmin(http.MethodGet, "/.profile/logs?level=warn", "alice@example.com"))
|
||||
|
||||
var got []map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
|
|
@ -283,7 +296,7 @@ func TestServeProfileHTMLLayered(t *testing.T) {
|
|||
|
||||
render := func(email string) string {
|
||||
rec := httptest.NewRecorder()
|
||||
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/", email))
|
||||
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/", email))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("email=%q status=%d body=%s", email, rec.Code, rec.Body.String())
|
||||
}
|
||||
|
|
@ -377,7 +390,7 @@ func TestServeProfileHTMLLayered(t *testing.T) {
|
|||
func TestServeProfileAccessJSON(t *testing.T) {
|
||||
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
|
||||
rec := httptest.NewRecorder()
|
||||
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/access", "alice@example.com"))
|
||||
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/access", "alice@example.com"))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
|
@ -394,11 +407,11 @@ func TestServeProfileAccessJSON(t *testing.T) {
|
|||
}
|
||||
|
||||
// Subtree-admin discovery used to live in the HTML render; now it flows
|
||||
// through /.profile/access. Verify the JSON endpoint exposes everything
|
||||
// the IIFE needs to hydrate the Editable + Create scaffolds: AdminSubtrees
|
||||
// for the read-only list, EditableParentChoices for the parent-selector
|
||||
// options, and HasAnyAdminScope so the IIFE knows whether to clone the
|
||||
// <template>. Pure non-admins get an empty access view and no scaffold.
|
||||
// through /.profile/access. Verify the JSON endpoint exposes what the
|
||||
// IIFE needs to hydrate the Editable + Create scaffolds: AdminSubtrees
|
||||
// for both the read-only list AND the parent-selector options, and
|
||||
// HasAnyAdminScope so the IIFE knows whether to clone the <template>.
|
||||
// Pure non-admins get an empty access view and no scaffold.
|
||||
func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - alice@example.com\n"), 0o644); err != nil {
|
||||
|
|
@ -419,7 +432,7 @@ func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) {
|
|||
fetchAccess := func(email string) AccessView {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/access", email))
|
||||
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/access", email))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("email=%q status=%d body=%s", email, rec.Code, rec.Body.String())
|
||||
}
|
||||
|
|
@ -438,16 +451,12 @@ func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) {
|
|||
if len(carol.AdminSubtrees) != 0 {
|
||||
t.Errorf("carol AdminSubtrees = %v, want empty", carol.AdminSubtrees)
|
||||
}
|
||||
if len(carol.EditableParentChoices) != 0 {
|
||||
t.Errorf("carol EditableParentChoices = %v, want empty", carol.EditableParentChoices)
|
||||
}
|
||||
|
||||
// Subtree-admin: AdminSubtrees lists projects/ so the create-project
|
||||
// parent dropdown can offer it; HasAnyAdminScope triggers template
|
||||
// hydration. The projects/.zddc is NOT editable by bob — he cannot
|
||||
// edit the file that grants him his own authority — so
|
||||
// EditableParentChoices is empty and the Editable-files list will
|
||||
// render its "None" placeholder.
|
||||
// hydration. Subtree admins own their .zddc (strict-ancestor retired),
|
||||
// so bob's projects/ entry is plainly listed and the Editable-files
|
||||
// list will render it inline.
|
||||
bob := fetchAccess("bob@example.com")
|
||||
if bob.IsSuperAdmin {
|
||||
t.Errorf("bob IsSuperAdmin = true, want false")
|
||||
|
|
@ -462,17 +471,11 @@ func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) {
|
|||
for _, s := range bob.AdminSubtrees {
|
||||
if strings.HasSuffix(s.Path, "/projects") {
|
||||
gotProjects = true
|
||||
if s.CanEdit {
|
||||
t.Errorf("bob's projects/ entry CanEdit = true; he should not be able to edit the .zddc granting his own authority")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !gotProjects {
|
||||
t.Errorf("bob AdminSubtrees missing projects/: %+v", bob.AdminSubtrees)
|
||||
}
|
||||
if len(bob.EditableParentChoices) != 0 {
|
||||
t.Errorf("bob EditableParentChoices = %+v, want empty (his only subtree is one he can't edit)", bob.EditableParentChoices)
|
||||
}
|
||||
|
||||
// Super-admin: AdminSubtrees enumerates every .zddc directory.
|
||||
alice := fetchAccess("alice@example.com")
|
||||
|
|
@ -495,14 +498,14 @@ func TestServeProfileEffectivePolicy(t *testing.T) {
|
|||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(cfg.Root, "Closed-Project", ".zddc"),
|
||||
[]byte("acl:\n allow:\n - alice@mycompany.com\n"), 0o644); err != nil {
|
||||
[]byte("acl:\n permissions:\n alice@mycompany.com: rwcd\n"), 0o644); err != nil {
|
||||
t.Fatalf("write child .zddc: %v", err)
|
||||
}
|
||||
zddc.InvalidateCache(cfg.Root)
|
||||
|
||||
// Trace alice (allowed at the leaf).
|
||||
rec := httptest.NewRecorder()
|
||||
r := requestWithEmail(http.MethodGet,
|
||||
r := requestAsAdmin(http.MethodGet,
|
||||
"/.profile/effective-policy?path=/Closed-Project/&email=alice@mycompany.com",
|
||||
"super@admin.com")
|
||||
ServeProfile(cfg, ring, nil, rec, r)
|
||||
|
|
@ -546,7 +549,7 @@ func TestServeProfileEffectivePolicy(t *testing.T) {
|
|||
|
||||
// Trace bob (not allow-listed; root has no broad allow either).
|
||||
rec2 := httptest.NewRecorder()
|
||||
r2 := requestWithEmail(http.MethodGet,
|
||||
r2 := requestAsAdmin(http.MethodGet,
|
||||
"/.profile/effective-policy?path=/Closed-Project/&email=bob@mycompany.com",
|
||||
"super@admin.com")
|
||||
ServeProfile(cfg, ring, nil, rec2, r2)
|
||||
|
|
@ -590,9 +593,8 @@ func TestServeProfileEffectivePolicy_InheritFence(t *testing.T) {
|
|||
zddc.InvalidateCache(cfg.Root)
|
||||
|
||||
type respShape struct {
|
||||
Decision bool `json:"decision"`
|
||||
CascadeMode string `json:"cascade_mode"`
|
||||
Chain struct {
|
||||
Decision bool `json:"decision"`
|
||||
Chain struct {
|
||||
VisibleStart int `json:"visible_start"`
|
||||
Levels []struct {
|
||||
Index int `json:"index"`
|
||||
|
|
@ -604,7 +606,7 @@ func TestServeProfileEffectivePolicy_InheritFence(t *testing.T) {
|
|||
// Trace a my-company user — fenced out at the leaf, despite root grant.
|
||||
rec := httptest.NewRecorder()
|
||||
ServeProfile(cfg, ring, nil, rec,
|
||||
requestWithEmail(http.MethodGet,
|
||||
requestAsAdmin(http.MethodGet,
|
||||
"/.profile/effective-policy?path=/Vendor/&email=alice@mycompany.com",
|
||||
"super@admin.com"))
|
||||
if rec.Code != http.StatusOK {
|
||||
|
|
@ -637,13 +639,13 @@ func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) {
|
|||
// .zddc exists but has no admins list — page is still reachable,
|
||||
// but the admin/super-admin sections are absent.
|
||||
cfg, ring := profileTestRoot(t, nil)
|
||||
if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"), []byte("acl:\n allow: [\"*\"]\n"), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"), []byte("acl:\n permissions:\n \"*\": rwcd\n"), 0o644); err != nil {
|
||||
t.Fatalf("write .zddc: %v", err)
|
||||
}
|
||||
zddc.InvalidateCache(cfg.Root)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/", "alice@example.com"))
|
||||
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/", "alice@example.com"))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
|
@ -654,7 +656,7 @@ func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) {
|
|||
|
||||
// Per-resource gates remain.
|
||||
rec = httptest.NewRecorder()
|
||||
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/whoami", "alice@example.com"))
|
||||
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/whoami", "alice@example.com"))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("/.profile/whoami status = %d, want 404 (no admins configured)", rec.Code)
|
||||
}
|
||||
|
|
@ -676,7 +678,9 @@ func TestServeProfileProjectsCreate(t *testing.T) {
|
|||
req := httptest.NewRequest(http.MethodPost, "/.profile/projects", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if email != "" {
|
||||
req = req.WithContext(context.WithValue(req.Context(), EmailKey, email))
|
||||
ctx := context.WithValue(req.Context(), EmailKey, email)
|
||||
ctx = context.WithValue(ctx, ElevatedKey, true)
|
||||
req = req.WithContext(ctx)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
ServeProfile(cfg, ring, nil, rec, req)
|
||||
|
|
@ -684,6 +688,8 @@ func TestServeProfileProjectsCreate(t *testing.T) {
|
|||
}
|
||||
|
||||
// Happy path: super-admin creates /alpha with no .zddc body.
|
||||
// Post-refactor: the .zddc IS auto-written with the creator in
|
||||
// admins: so they own the new project from birth.
|
||||
rec := post("root@example.com", `{"parent":"/", "name":"alpha"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("happy path status=%d body=%s", rec.Code, rec.Body.String())
|
||||
|
|
@ -691,8 +697,12 @@ func TestServeProfileProjectsCreate(t *testing.T) {
|
|||
if _, err := os.Stat(filepath.Join(root, "alpha")); err != nil {
|
||||
t.Errorf("alpha dir not created on disk: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "alpha", ".zddc")); err == nil {
|
||||
t.Errorf(".zddc should NOT be auto-written when no fields supplied")
|
||||
if _, err := os.Stat(filepath.Join(root, "alpha", ".zddc")); err != nil {
|
||||
t.Errorf(".zddc should be auto-written with creator as admin: %v", err)
|
||||
} else if zf, perr := zddc.ParseFile(filepath.Join(root, "alpha", ".zddc")); perr == nil {
|
||||
if len(zf.Admins) != 1 || zf.Admins[0] != "root@example.com" {
|
||||
t.Errorf("alpha .zddc Admins=%v, want [root@example.com]", zf.Admins)
|
||||
}
|
||||
}
|
||||
|
||||
// Body with a title also writes a .zddc.
|
||||
|
|
@ -744,7 +754,9 @@ func TestServeProfileProjectsCreate(t *testing.T) {
|
|||
|
||||
// Method other than POST is 405.
|
||||
req := httptest.NewRequest(http.MethodGet, "/.profile/projects", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
|
||||
ctx := context.WithValue(req.Context(), EmailKey, "root@example.com")
|
||||
ctx = context.WithValue(ctx, ElevatedKey, true)
|
||||
req = req.WithContext(ctx)
|
||||
rec = httptest.NewRecorder()
|
||||
ServeProfile(cfg, ring, nil, rec, req)
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
|
|
@ -762,10 +774,12 @@ func TestServeProfileProjectsCreateValidatesZddc(t *testing.T) {
|
|||
zddc.InvalidateCache(root)
|
||||
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
||||
|
||||
body := `{"parent":"/", "name":"badproject", "acl":{"allow":["bad@@glob"], "deny":[]}}`
|
||||
body := `{"parent":"/", "name":"badproject", "acl":{"permissions":{"bad@@glob":"rwcd"}}}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/.profile/projects", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
|
||||
ctx := context.WithValue(req.Context(), EmailKey, "root@example.com")
|
||||
ctx = context.WithValue(ctx, ElevatedKey, true)
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
ServeProfile(cfg, NewLogRing(50), nil, rec, req)
|
||||
|
||||
|
|
@ -801,7 +815,9 @@ func TestSubtreeAdminCanCreateInScope(t *testing.T) {
|
|||
body := `{"parent":"` + parent + `", "name":"` + name + `"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/.profile/projects", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
|
||||
ctx := context.WithValue(req.Context(), EmailKey, "alice@example.com")
|
||||
ctx = context.WithValue(ctx, ElevatedKey, true)
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
ServeProfile(cfg, NewLogRing(50), nil, rec, req)
|
||||
return rec.Code
|
||||
|
|
@ -843,7 +859,7 @@ func TestServeProfileReindexPOST(t *testing.T) {
|
|||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := requestWithEmail(http.MethodPost, "/.profile/reindex", "alice@example.com")
|
||||
req := requestAsAdmin(http.MethodPost, "/.profile/reindex", "alice@example.com")
|
||||
ServeProfile(cfg, ring, idx, rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
|
|
@ -881,7 +897,7 @@ func TestAdminPathHardCut(t *testing.T) {
|
|||
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
|
||||
for _, p := range []string{"/.admin/", "/.admin/whoami", "/.admin/zddc/edit?path=/"} {
|
||||
rec := httptest.NewRecorder()
|
||||
req := requestWithEmail(http.MethodGet, p, "alice@example.com")
|
||||
req := requestAsAdmin(http.MethodGet, p, "alice@example.com")
|
||||
// Calling ServeProfile directly with /.admin path: it should not match
|
||||
// the /.profile prefix and so return 404. (The real-world path is
|
||||
// dispatch() routing — covered in main_test.go.)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue