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:
ZDDC 2026-05-14 12:15:41 -05:00
parent 2d114fcb96
commit 050902fa9e
20 changed files with 394 additions and 10 deletions

View file

@ -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`.
### 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.

View file

@ -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.

View file

@ -22,6 +22,7 @@ concat_files \
"../shared/fonts.css" \
"../shared/base.css" \
"../shared/toast.css" \
"../shared/elevation.css" \
"../shared/nav.css" \
"../shared/logo.css" \
"css/base.css" \
@ -64,6 +65,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

View file

@ -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.
}

View file

@ -32,10 +32,16 @@
<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">
<!-- 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>
@ -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>

View file

@ -22,6 +22,7 @@ concat_files \
"../shared/fonts.css" \
"../shared/base.css" \
"../shared/toast.css" \
"../shared/elevation.css" \
"../shared/nav.css" \
"../shared/logo.css" \
"css/base.css" \
@ -62,6 +63,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

View file

@ -28,10 +28,16 @@
<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">
<!-- 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>
@ -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>

View file

@ -21,6 +21,7 @@ concat_files \
"../shared/fonts.css" \
"../shared/base.css" \
"../shared/toast.css" \
"../shared/elevation.css" \
"../shared/nav.css" \
"../shared/logo.css" \
"css/form.css" \
@ -32,6 +33,7 @@ concat_files \
"../shared/nav.js" \
"../shared/logo.js" \
"../shared/help.js" \
"../shared/elevation.js" \
"js/app.js" \
"js/context.js" \
"js/util.js" \

View file

@ -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>

View file

@ -21,6 +21,7 @@ concat_files \
"../shared/fonts.css" \
"../shared/base.css" \
"../shared/toast.css" \
"../shared/elevation.css" \
"../shared/nav.css" \
"../shared/logo.css" \
"css/landing.css" \
@ -34,6 +35,7 @@ concat_files \
"../shared/nav.js" \
"../shared/logo.js" \
"../shared/help.js" \
"../shared/elevation.js" \
"js/landing.js" \
> "$js_raw"

View file

@ -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>

47
shared/elevation.css Normal file
View 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
View 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 };
})();

View file

@ -21,6 +21,7 @@ concat_files \
"../shared/fonts.css" \
"../shared/base.css" \
"../shared/toast.css" \
"../shared/elevation.css" \
"../shared/nav.css" \
"../shared/logo.css" \
"css/table.css" \
@ -41,6 +42,7 @@ concat_files \
"../shared/nav.js" \
"../shared/logo.js" \
"../shared/help.js" \
"../shared/elevation.js" \
"js/mode.js" \
"js/app.js" \
"js/context.js" \

View file

@ -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>

View file

@ -25,6 +25,7 @@ concat_files \
"../shared/fonts.css" \
"../shared/base.css" \
"../shared/toast.css" \
"../shared/elevation.css" \
"../shared/nav.css" \
"../shared/logo.css" \
"css/base.css" \
@ -87,6 +88,7 @@ concat_files \
"js/drop-zones.js" \
"js/focus.js" \
"../shared/help.js" \
"../shared/elevation.js" \
"js/main.js" \
> "$js_raw"

View file

@ -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">&#x25BE;</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>

View 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)
}

View 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
}

View 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)
}
}