chore: elevation slot in every tool + docs + helper file splits + smell cleanup
Polish pass after the big refactor in 2d114fc.
== Header elevation slot propagated ==
shared/elevation.{js,css} surface a header checkbox for admins.
30-minute sudo-style cookie window (Max-Age=1800, SameSite=Lax).
Only renders when /.profile/access reports can_elevate=true; quiet
for non-admins. Slot added to all 7 tool templates and concat'd
into all 7 build.sh files; admin in any tool now sees the toggle.
Three text-rename ride-alongs in archive/classifier/transmittal
templates: "Add Local Directory" → "Use Local Directory" (the same
rename that landed in browse earlier in this branch).
== Docs ==
- CLAUDE.md gets an "Admin elevation is sudo-style" paragraph in
the "Things that bite if you forget" section.
- AGENTS.md gets a dedicated "Admin elevation (sudo-style)" section
alongside "Bearer tokens" — same depth as the existing auth docs.
== Helper file splits ==
The retired form editor's shared helpers got bundled into a single
zddc_admin.go in the cleanup; that name is now misleading. Split by
concern:
- admin_helpers.go: hasAnyAdminScope (the only admin-specific helper)
- paths.go: resolvePath, urlPathOf, chainDirs (URL ↔ filesystem path
math — used by several profile / zddc-file handlers)
- profile_assets.go (renamed from zddc_admin_assets.go): custom CSS
pipeline. URL renamed from /.profile/zddc/assets/ → /.profile/assets/
since /.profile/zddc/ no longer hosts an editor.
- treeEntry moves to profilehandler.go (alongside AccessView, its
only consumer).
- writeError moves to profileprojects.go (its only consumer).
== Smell cleanup ==
- zddc.HasAnyAdminGrant(fsRoot, email) — new elevation-independent
primitive that walks the cascade and reports whether email is named
in any admin: list anywhere. Replaces the synthetic-elevated probe
hack in enumerateAccess (`Principal{Email, Elevated: true}` was
"lying" to the elevation gate to ask what it would say). The handler's
hasAnyAdminScope collapses to a 4-line wrapper that gates on
p.Elevated and delegates.
- Access-log middleware records `elevated` per request, so forensics
can distinguish "admin acting as user" from "admin exercising power."
- browse/js/app.js's ?file= deep link walks multi-segment paths. Each
intermediate segment is matched + expanded; the leaf gets
selected/previewed. Auto-shows hidden when any segment starts with
. or _. Silently no-ops on unresolved segments.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2d114fcb96
commit
050902fa9e
20 changed files with 394 additions and 10 deletions
12
AGENTS.md
12
AGENTS.md
|
|
@ -569,6 +569,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`.
|
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
|
### 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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- **`</` 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.
|
- **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.
|
- **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.
|
- **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.
|
- **Build scripts are POSIX `sh` with `set -eu`**, not bash. `concat_files` takes positional args only.
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ concat_files \
|
||||||
"../shared/fonts.css" \
|
"../shared/fonts.css" \
|
||||||
"../shared/base.css" \
|
"../shared/base.css" \
|
||||||
"../shared/toast.css" \
|
"../shared/toast.css" \
|
||||||
|
"../shared/elevation.css" \
|
||||||
"../shared/nav.css" \
|
"../shared/nav.css" \
|
||||||
"../shared/logo.css" \
|
"../shared/logo.css" \
|
||||||
"css/base.css" \
|
"css/base.css" \
|
||||||
|
|
@ -64,6 +65,7 @@ concat_files \
|
||||||
"js/events.js" \
|
"js/events.js" \
|
||||||
"js/app.js" \
|
"js/app.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
|
"../shared/elevation.js" \
|
||||||
> "$js_raw"
|
> "$js_raw"
|
||||||
|
|
||||||
# Escape '</' in inlined JS so the HTML parser cannot mistake string contents
|
# Escape '</' in inlined JS so the HTML parser cannot mistake string contents
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@
|
||||||
|
|
||||||
// Apply UI differences based on source mode
|
// Apply UI differences based on source mode
|
||||||
function applySourceModeUI() {
|
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.
|
// in HTTP mode the user can augment the online archive with local directories.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,16 @@
|
||||||
<span class="app-header__title">ZDDC Archive</span>
|
<span class="app-header__title">ZDDC Archive</span>
|
||||||
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
||||||
</div>
|
</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>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<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="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>
|
<button id="help-btn" class="btn btn-secondary" title="Help">?</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -240,7 +246,7 @@
|
||||||
<div id="noDirectoryMessage" class="empty-state empty-state--overlay">
|
<div id="noDirectoryMessage" class="empty-state empty-state--overlay">
|
||||||
<div class="empty-state__inner empty-state__inner--centered">
|
<div class="empty-state__inner empty-state__inner--centered">
|
||||||
<h2>Welcome to ZDDC Archive</h2>
|
<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>This browser provides a convenient interface for searching and retrieving files from ZDDC-compliant archives.</p>
|
||||||
<p><strong>How to navigate:</strong></p>
|
<p><strong>How to navigate:</strong></p>
|
||||||
<ul class="welcome-list">
|
<ul class="welcome-list">
|
||||||
|
|
@ -285,7 +291,7 @@
|
||||||
<h3>Getting Started</h3>
|
<h3>Getting Started</h3>
|
||||||
<ol>
|
<ol>
|
||||||
<li>When opened from a web server, the archive loads automatically from that server.</li>
|
<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>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>
|
<li>Select folders in the left panel to see their files in the main table.</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ concat_files \
|
||||||
"../shared/fonts.css" \
|
"../shared/fonts.css" \
|
||||||
"../shared/base.css" \
|
"../shared/base.css" \
|
||||||
"../shared/toast.css" \
|
"../shared/toast.css" \
|
||||||
|
"../shared/elevation.css" \
|
||||||
"../shared/nav.css" \
|
"../shared/nav.css" \
|
||||||
"../shared/logo.css" \
|
"../shared/logo.css" \
|
||||||
"css/base.css" \
|
"css/base.css" \
|
||||||
|
|
@ -62,6 +63,7 @@ concat_files \
|
||||||
"js/sort.js" \
|
"js/sort.js" \
|
||||||
"js/excel.js" \
|
"js/excel.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
|
"../shared/elevation.js" \
|
||||||
> "$js_raw"
|
> "$js_raw"
|
||||||
|
|
||||||
# Escape '</' in inlined JS so the HTML parser cannot mistake string contents
|
# Escape '</' in inlined JS so the HTML parser cannot mistake string contents
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,16 @@
|
||||||
<span class="app-header__title">ZDDC Classifier</span>
|
<span class="app-header__title">ZDDC Classifier</span>
|
||||||
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
||||||
</div>
|
</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>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<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="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>
|
<button id="help-btn" class="btn btn-secondary" title="Help">?</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -154,7 +160,7 @@
|
||||||
<li>Rename one file or all modified files at once</li>
|
<li>Rename one file or all modified files at once</li>
|
||||||
</ul>
|
</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>
|
<p class="note">This application works entirely in your browser. No data is transmitted to any server.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -173,7 +179,7 @@
|
||||||
|
|
||||||
<h3>Getting Started</h3>
|
<h3>Getting Started</h3>
|
||||||
<ol>
|
<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>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>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>
|
<li>Click <strong>Save All</strong> (or save individual rows) to rename the files on disk.</li>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ concat_files \
|
||||||
"../shared/fonts.css" \
|
"../shared/fonts.css" \
|
||||||
"../shared/base.css" \
|
"../shared/base.css" \
|
||||||
"../shared/toast.css" \
|
"../shared/toast.css" \
|
||||||
|
"../shared/elevation.css" \
|
||||||
"../shared/nav.css" \
|
"../shared/nav.css" \
|
||||||
"../shared/logo.css" \
|
"../shared/logo.css" \
|
||||||
"css/form.css" \
|
"css/form.css" \
|
||||||
|
|
@ -32,6 +33,7 @@ concat_files \
|
||||||
"../shared/nav.js" \
|
"../shared/nav.js" \
|
||||||
"../shared/logo.js" \
|
"../shared/logo.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
|
"../shared/elevation.js" \
|
||||||
"js/app.js" \
|
"js/app.js" \
|
||||||
"js/context.js" \
|
"js/context.js" \
|
||||||
"js/util.js" \
|
"js/util.js" \
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<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="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>
|
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ concat_files \
|
||||||
"../shared/fonts.css" \
|
"../shared/fonts.css" \
|
||||||
"../shared/base.css" \
|
"../shared/base.css" \
|
||||||
"../shared/toast.css" \
|
"../shared/toast.css" \
|
||||||
|
"../shared/elevation.css" \
|
||||||
"../shared/nav.css" \
|
"../shared/nav.css" \
|
||||||
"../shared/logo.css" \
|
"../shared/logo.css" \
|
||||||
"css/landing.css" \
|
"css/landing.css" \
|
||||||
|
|
@ -34,6 +35,7 @@ concat_files \
|
||||||
"../shared/nav.js" \
|
"../shared/nav.js" \
|
||||||
"../shared/logo.js" \
|
"../shared/logo.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
|
"../shared/elevation.js" \
|
||||||
"js/landing.js" \
|
"js/landing.js" \
|
||||||
> "$js_raw"
|
> "$js_raw"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<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="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>
|
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
47
shared/elevation.css
Normal file
47
shared/elevation.css
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
103
shared/elevation.js
Normal file
103
shared/elevation.js
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
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 };
|
||||||
|
})();
|
||||||
|
|
@ -21,6 +21,7 @@ concat_files \
|
||||||
"../shared/fonts.css" \
|
"../shared/fonts.css" \
|
||||||
"../shared/base.css" \
|
"../shared/base.css" \
|
||||||
"../shared/toast.css" \
|
"../shared/toast.css" \
|
||||||
|
"../shared/elevation.css" \
|
||||||
"../shared/nav.css" \
|
"../shared/nav.css" \
|
||||||
"../shared/logo.css" \
|
"../shared/logo.css" \
|
||||||
"css/table.css" \
|
"css/table.css" \
|
||||||
|
|
@ -41,6 +42,7 @@ concat_files \
|
||||||
"../shared/nav.js" \
|
"../shared/nav.js" \
|
||||||
"../shared/logo.js" \
|
"../shared/logo.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
|
"../shared/elevation.js" \
|
||||||
"js/mode.js" \
|
"js/mode.js" \
|
||||||
"js/app.js" \
|
"js/app.js" \
|
||||||
"js/context.js" \
|
"js/context.js" \
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<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="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>
|
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ concat_files \
|
||||||
"../shared/fonts.css" \
|
"../shared/fonts.css" \
|
||||||
"../shared/base.css" \
|
"../shared/base.css" \
|
||||||
"../shared/toast.css" \
|
"../shared/toast.css" \
|
||||||
|
"../shared/elevation.css" \
|
||||||
"../shared/nav.css" \
|
"../shared/nav.css" \
|
||||||
"../shared/logo.css" \
|
"../shared/logo.css" \
|
||||||
"css/base.css" \
|
"css/base.css" \
|
||||||
|
|
@ -87,6 +88,7 @@ concat_files \
|
||||||
"js/drop-zones.js" \
|
"js/drop-zones.js" \
|
||||||
"js/focus.js" \
|
"js/focus.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
|
"../shared/elevation.js" \
|
||||||
"js/main.js" \
|
"js/main.js" \
|
||||||
> "$js_raw"
|
> "$js_raw"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ conventions at https://codeberg.org/VARASYS/ZDDC#file-naming-convention.
|
||||||
</div>
|
</div>
|
||||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||||
<!-- Publish split-button (Transmittal-specific primary action;
|
<!-- Publish split-button (Transmittal-specific primary action;
|
||||||
other tools have "Add Local Directory" here instead) -->
|
other tools have "Use Local Directory" here instead) -->
|
||||||
<div class="split-button" id="bottom-menu" hidden>
|
<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-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>
|
<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>
|
</div>
|
||||||
<div class="header-right">
|
<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="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>
|
<button type="button" id="help-btn" class="btn btn-secondary" aria-label="Help" title="Help">?</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
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)
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
71
zddc/internal/handler/profile_assets.go
Normal file
71
zddc/internal/handler/profile_assets.go
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
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` (or the
|
||||||
|
// legacy `.admin.css`) at the deployment root and have it picked up
|
||||||
|
// automatically as styling for the profile page. Previously lived
|
||||||
|
// alongside the retired form editor; kept because the profile page
|
||||||
|
// still relies on it.
|
||||||
|
|
||||||
|
const (
|
||||||
|
profileCustomCSSName = ".profile.css"
|
||||||
|
adminCustomCSSName = ".admin.css" // legacy fallback
|
||||||
|
)
|
||||||
|
|
||||||
|
// hasCustomProfileCSS reports whether <fsRoot>/.profile.css (or the
|
||||||
|
// legacy .admin.css) exists. The profile template uses this to decide
|
||||||
|
// whether to inject the <link> tag.
|
||||||
|
func hasCustomProfileCSS(fsRoot string) bool {
|
||||||
|
if _, err := os.Stat(filepath.Join(fsRoot, profileCustomCSSName)); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(fsRoot, adminCustomCSSName)); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// profileAssetsPathPrefix is the URL prefix for admin static assets.
|
||||||
|
// Lived at /.profile/zddc/assets/ during the form-editor era; renamed
|
||||||
|
// once the form editor retired. 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, falling back to <root>/.admin.css); 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() {
|
||||||
|
path = filepath.Join(cfg.Root, adminCustomCSSName)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue