Compare commits

...

15 commits

Author SHA1 Message Date
f176bea645 fix(ci): chart-bump uses the SHA baked into the embed (not git HEAD)
./build beta runs locally at HEAD=N, generates embed/*.html with
build-label SHA=N, then the operator commits the generated bytes
as commit N+1. After push, git HEAD on the runner = N+1 but the
served website's BUILD_LABEL still encodes N (it's baked into the
HTML at build time, before the commit).

Previously the bump script computed BETA_VERSION using
`git rev-parse --short HEAD` = N+1, so the chart's appVersion
(and the dev image's tag, and the kubelet's pull) said N+1 while
the served label said N. Two SHAs for the same bytes — confusing
when triaging "is this image actually the latest?".

Read the SHA from zddc/internal/apps/embedded/versions.txt instead
(third pipe-delimited field of any line). That's the single source
of truth for what bytes were baked, and it lines up with what users
see in every tool's header.

Manually re-bumped chart develop after committing this script:
  appVersion: 0.0.16-beta-9a3e4d8 → 0.0.16-beta-8df0def
2026-05-04 07:01:11 -05:00
9a3e4d8fa7 chore(embedded): cut v0.0.16-beta with loading-efficiency wins
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 6s
Bake into the dev binary:
  - ETag + max-age=0 on embedded HTML (304s on repeat loads)
  - gzip compression middleware (~75% wire-size reduction)
  - vendored jszip + docx-preview in archive/transmittal/classifier
  - tee'd file-based access log via --access-log
2026-05-04 06:29:23 -05:00
8df0defbd2 feat(server): tee access log to a rotated file for on-disk audit trail
Add --access-log <path> (env ZDDC_ACCESS_LOG). When set, every access-
log record is written as a JSON line to the configured file in
addition to the existing slog.Default() stderr output. Empty (default)
keeps the prior behavior — stderr only.

Rotation via gopkg.in/natefinch/lumberjack.v2:
  100 MB per file, 10 backups, 90-day max age, gzip rotated files.

Operator usage (e.g. behind a Caddy/quadlet stack):
  zddc-server --access-log /srv/.zddc.d/logs/access.log ...

Architecture:
  AccessLogMiddleware now takes an optional *slog.Logger. main.go wires
  it via setupAccessAuditLog() which builds a slog.JSONHandler over a
  lumberjack rotator. Stderr emission stays via slog.Default(); the
  audit logger gets the same fields in line-delimited JSON, the format
  every standard log shipper (Vector, Loki, fluentbit, journalbeat)
  parses natively.

Tests cover the audit logger receiving the same email/path/status
fields as the stderr stream.
2026-05-03 23:38:14 -05:00
eaecaaee29 perf(tools): vendor jszip + docx-preview for archive/transmittal/classifier
Same pattern as the browse fix. archive, transmittal, classifier
previously CDN-loaded jszip + docx-preview on first preview of a
.zip / .docx file via shared/preview-lib.js's loadLibrary helper.
That meant each first-preview blocked on a CDN round-trip + parse,
and broke entirely under restrictive networks or CSPs.

Vendor both libs under shared/vendor/ and concat them at the top of
each tool's build, ahead of init.js. window.JSZip + window.docx are
now defined immediately on page load. Drop the redundant loadLibrary
calls (and classifier's stray <script src="cdn..."> tag in the
template, plus archive's bespoke loadJSZip helper in export.js).

xlsx (SheetJS) intentionally stays CDN-loaded — at ~900 KB it's too
large to inline, and only fires on .xlsx preview which is a rarer
path.

Bundle size impact (uncompressed):
  archive:     304 KB → 476 KB  (+172 KB)
  transmittal: 449 KB → 621 KB  (+172 KB)
  classifier:  252 KB → 424 KB  (+172 KB)

With the gzip middleware (~75% reduction on HTML) and ETag-cached
revalidation now in place, the wire-size delta is ~40 KB per tool
on the first load and 0 on every subsequent load until redeploy.
2026-05-03 23:34:28 -05:00
c22bb19dab perf(server): gzip compression middleware on the entire mux
Add github.com/klauspost/compress/gzhttp wrapper around the request
handler. With MinSize(1024), responses ≥ 1 KB get gzip-encoded when
the client advertises Accept-Encoding: gzip; smaller bodies + 304
Not Modified pass through unchanged.

The wrapper auto-appends Vary: Accept-Encoding (compatible with the
existing Vary: Accept on directory.go's content-negotiated path).

Live-tested against zddc-server -root /tmp/empty:
  GET / w/ Accept-Encoding: gzip → 20.9 KB compressed (was 80.9 KB
                                   uncompressed). 74% reduction.
  Decompresses cleanly back to the original bytes.

Helps every code path that bypasses Caddy: devshell pods, local dev
binaries, tests, anywhere zddc-server is hit directly. Production
behind Caddy already had compression at the proxy layer; this just
makes the Go server self-sufficient.

Tests in cmd/zddc-server/main_test.go cover:
- large body + Accept-Encoding → compressed + Vary header
- small body → not compressed (under MinSize)
- no Accept-Encoding header → plain bytes
2026-05-03 23:31:18 -05:00
e021f14609 perf(server): ETag + max-age=0 on embedded HTML responses
The apps subsystem previously sent Cache-Control: public, max-age=300|3600,
must-revalidate but no ETag. With must-revalidate and no validator, the
browser cannot return 304 — it has to refetch the full body once max-age
expires. For mdedit that's 920 KB on every reload after an hour.

Add a content-addressed ETag (sha256 hex prefix, 32 chars) to:
- apps/handler.go's serveBody + serveEmbedded (both paths now emit ETag
  + handle If-None-Match short-circuit to 304)
- handler/directory.go's embedded:browse fallback (mirror behavior so
  the bare-directory landing serves the same way)

Drop max-age to 0 with must-revalidate: every page load revalidates,
but a matching ETag returns 304 with empty body. Steady-state cost of
a reload drops from N KB to a few hundred bytes. When the binary is
redeployed, the ETag changes (content hash) and the next request
returns 200 with the new bytes.

Tests in apps/handler_test.go cover both paths:
- TestServer_Embedded_ConditionalGET: full GET, matching INM, stale INM
- TestEmbeddedETag_Stable: same bytes → same ETag, different → different

Live smoke (curl against zddc-server -root /tmp/empty):
  GET /            → 200, ETag set, body = 80919 bytes (landing.html)
  GET / + INM:tag  → 304 Not Modified, empty body
2026-05-03 23:28:18 -05:00
0fae93696d chore(embedded): cut v0.0.16-beta with public-landing fix
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 5s
Bake the public-landing-page server change into the dev binary.
2026-05-03 22:53:37 -05:00
62ce6e9f63 feat(server): public landing page (root bypasses dir-level ACL)
GET / and GET /index.html previously enforced the root .zddc's
top-level acl: gate before serving the landing page. On a deployment
where only specific emails are allowed at root, anonymous (and
unauthorized) callers got 403 — they couldn't even see the project
picker that would tell them which projects were available to them.

Make the landing page public:
  - cmd/zddc-server: drop the AllowedWithChain gate from the
    apps.Serve("landing") branch; drop it from the IsDir branch when
    urlPath == "/".
  - handler/directory.go: matching bypass for ServeDirectory at the
    root path (covers Accept: application/json and the case where a
    real /index.html exists on disk).

Per-project ACL is preserved end-to-end:
  - fs.ListDirectory continues to filter sub-entries per email, so
    anonymous callers see only projects whose .zddc allows them.
  - Subdirectory requests still hit the ACL gate.

Regression test in handler/directory_test.go covers all four cases
(anonymous public, anonymous filters out private, admin sees both,
anonymous still 403 on private subdir). Full go test ./... passes.
2026-05-03 22:53:14 -05:00
633411770c chore(embedded): cut v0.0.16-beta
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 4s
Bake the standardized headers + archive bugfix + browse refactor
into the dev binary. Triggers notify-chart-dev → bumps tnd-zddc-chart
develop with appVersion=0.0.16-beta-<sha>.
2026-05-03 22:22:13 -05:00
e67c1b2e06 fix(archive): point getElementById at the real root id
drag-drop.js and the unsupported-browser handler in app.js both
referenced getElementById('app'), but the template's root has
id="appContainer". The mismatch was masked in production because
sourceMode='http' skips dragDrop.init() — only file:// (sourceMode=
'local') tripped over it, throwing "Cannot read properties of null
(reading 'addEventListener')" at app load.

Surfaced while header-standardizing the other tools; fixed by
pointing both callers at #appContainer.
2026-05-03 22:21:51 -05:00
bbb75a87af chore(headers): standardize across all 7 tools
Bring every tool's header in line with archive's pattern:

  [logo] [title] [version] [Add Local Directory] [⟳] ............... [◐] [?]
  ------------- header-left ---------------       ----- header-right -

Changes per tool:

* browse: rename "Select Directory" → "Add Local Directory"; add the
  red-non-stable wrap to the build label (was missing); add a help
  panel + bundle shared/help.js.

* classifier: rename selectDirectoryBtn → addDirectoryBtn,
  refreshBtn → refreshHeaderBtn for consistency. Update all JS
  callers and welcome-screen copy to the new label.

* mdedit: same id rename. Move the previously-in-pane refresh
  button into the header. Stop renaming the dir button to
  "Directory: <name>" once a folder is loaded — instead use the
  shared btn--subtle variant to de-emphasize while keeping the
  standard label.

* transmittal: convert non-standard <div class="app-header"> with
  spacer/icons containers to <header class="app-header"> with the
  canonical header-left/header-right pair. Move the publish split-
  button into header-left (Transmittal-specific primary action).
  Remove dead .app-header__spacer/__icons/header-icon-btn CSS now
  that nothing references those classes.

* landing, form: add help-btn + help-panel + bundle shared/help.js.
  Each panel is tool-specific (project picker docs for landing,
  schema-driven form docs for form).

Cross-cutting:

* shared/base.css: promote .btn--subtle from browse/css/tree.css
  so any tool with an online mode can de-emphasize Add Local
  Directory consistently.

Verified all 7 tools in headless Chromium: header structure correct,
build label red on non-stable cuts, help panel opens + closes via
button + Esc.
2026-05-03 22:17:02 -05:00
a7e84dae15 refactor(ci): extract chart-bump logic to .forgejo/scripts/
Both notify-chart-dev.yml and the notify-chart-prod job in
deploy-release.yml were carrying ~80 lines of inline shell each,
duplicating the clone-bump-push flow. Extract to a single script:

  .forgejo/scripts/notify-chart-bump.sh <beta|stable> [VERSION]

Three benefits:

1. **Locally testable**. The script is invocable directly:
     CHART_FORGEJO_TOKEN=$FORGEJO_TOKEN \
       .forgejo/scripts/notify-chart-bump.sh beta
   No more "push to main and watch what the runner does" debug loop.

2. **Manual escape hatch**. When CI is broken, the same script is
   how we recover. The 0.0.16-beta-1ddd331 chart bump preceding
   this commit was performed via this very script.

3. **Runner-quirk-immune**. The previous three commits chased a
   Forgejo runner v12.9.0 phantom-SIGPIPE bug that would only
   surface under the runner's `bash -e -o pipefail {0}` wrapper.
   A real script with its own `#!/usr/bin/env bash` and explicit
   error handling sidesteps the wrapper entirely.

The workflow YAMLs shrink to checkout + run-script. No GITHUB_OUTPUT
plumbing, no inline if/else gates, no shell flag overrides. The
behavior is identical to the prior inline versions.
2026-05-03 21:56:17 -05:00
1ddd331f58 fix(ci): notify-chart-dev — drop default pipefail, set -eu after early echos
The Forgejo runner v12.9.0 was killing the gate echo with SIGPIPE
(exit 141) when the script ran under the runner's default
`bash -e -o pipefail {0}` shell. Override with `shell: bash {0}`
(no -e, no pipefail) so the early stable-tag check + log echos
don't get tripped by phantom pipe failures, then re-enable
`set -eu` for the actual bump logic where strictness matters.
2026-05-03 21:48:37 -05:00
2a70359b0a fix(ci): inline notify-chart-dev gate into bump step
The two-step pattern was failing under Forgejo runner v12.9.0 — the
gate step exited 141 immediately after echoing + writing GITHUB_OUTPUT,
even with no pipelines involved. Folding the stable-tag check into the
bump step's own strict-mode shell removes the cross-step boundary that
the runner was tripping over.
2026-05-03 21:46:25 -05:00
4a78ce4473 fix(ci): notify-chart-dev gate avoids SIGPIPE; add workflow_dispatch
The gate step's `git tag --points-at HEAD | grep -q '^zddc-server-v'`
exits 141 (SIGPIPE) under bash's pipefail when grep finishes early —
the runner's strict-mode wrapper then fails the step even though the
if-condition logic completed correctly. Materialize the tag list with
git's native --list glob filter and test it with `[ -n ]` instead, so
no pipeline is involved.

Also add workflow_dispatch so we can re-fire this workflow on a fresh
commit without needing a no-op edit under zddc/internal/apps/embedded/
to match the paths filter.
2026-05-03 21:43:01 -05:00
51 changed files with 1688 additions and 484 deletions

View file

@ -0,0 +1,152 @@
#!/usr/bin/env bash
# notify-chart-bump.sh — bump appVersion on tnd-zddc-chart and push.
#
# Replaces the inline shell that previously lived in
# .forgejo/workflows/notify-chart-dev.yml and the notify-chart-prod
# job in deploy-release.yml. Extracting the logic to a real script
# means we can:
# 1. test it locally without going through the runner
# (CHART_FORGEJO_TOKEN=$FORGEJO_TOKEN ./.forgejo/scripts/notify-chart-bump.sh beta)
# 2. invoke manually as an escape hatch when CI is broken
# 3. avoid runner-version shell-wrapper quirks (e.g. Forgejo
# runner v12.9.0 reporting phantom SIGPIPE on bare echo + set -eu)
#
# Usage:
# notify-chart-bump.sh beta
# Bump chart's develop branch with appVersion = <next-stable>-beta-<sha>
# (next-stable = max(zddc-server-v* tag) + 1).
# Self-skips if HEAD has a zddc-server-v* tag (the stable workflow
# owns the bump in that case).
#
# notify-chart-bump.sh stable VERSION
# Bump chart's main + develop branches with appVersion = VERSION.
# Called from deploy-release.yml's notify-chart-prod job, where
# VERSION = "${GITHUB_REF#refs/tags/zddc-server-v}".
#
# Requires:
# - run from the ZDDC repo root, with full git history (all tags)
# - $CHART_FORGEJO_TOKEN with repo:write on BMCD/tnd-zddc-chart
set -eu
CHANNEL="${1:?usage: $(basename "$0") <beta|stable> [VERSION]}"
EXPLICIT_VERSION="${2:-}"
if [ -z "${CHART_FORGEJO_TOKEN:-}" ]; then
echo "::error::CHART_FORGEJO_TOKEN env not set" >&2
exit 1
fi
CHART_REPO="git.varasys.io/BMCD/tnd-zddc-chart.git"
CHART_URL="https://oauth2:${CHART_FORGEJO_TOKEN}@${CHART_REPO}"
case "$CHANNEL" in
beta)
# Self-skip if HEAD has a stable tag — prod workflow owns the
# bump in that case (avoids both workflows racing on develop).
STABLE_TAGS=$(git tag --points-at HEAD --list 'zddc-server-v*')
if [ -n "$STABLE_TAGS" ]; then
echo "HEAD has stable tag ($STABLE_TAGS) — stable workflow handles chart bump; skipping"
exit 0
fi
LATEST_STABLE=$(git tag --list 'zddc-server-v*' --sort=-v:refname | head -1)
if [ -z "$LATEST_STABLE" ]; then
echo "::error::no zddc-server-v* tags exist; cannot derive next-stable target" >&2
exit 1
fi
MAJ=$(echo "${LATEST_STABLE#zddc-server-v}" | cut -d. -f1)
MIN=$(echo "${LATEST_STABLE#zddc-server-v}" | cut -d. -f2)
PAT=$(echo "${LATEST_STABLE#zddc-server-v}" | cut -d. -f3)
NEXT_STABLE="$MAJ.$MIN.$((PAT + 1))"
# Use the SHA baked into the embedded files (third field of
# versions.txt: "<tool>=<version> · <date> · <sha>"), NOT
# `git rev-parse HEAD`. This matters because `./build beta`
# runs locally at HEAD=N, then the operator commits the
# generated embed files as N+1; the embed label encodes N
# while git HEAD on push is N+1. If we used N+1 here, the
# chart's appVersion (N+1) wouldn't match the build label
# users see in the served website (N) — confusing on its
# face when triaging "is this image current?". Reading from
# versions.txt guarantees they line up.
VERSIONS_FILE="zddc/internal/apps/embedded/versions.txt"
SHORT_SHA=$(awk -F' · ' '/^[a-z]+=/ { print $NF; exit }' "$VERSIONS_FILE" \
| tr -d '[:space:]')
if [ -z "$SHORT_SHA" ]; then
echo "::error::could not parse SHA from $VERSIONS_FILE" >&2
cat "$VERSIONS_FILE" >&2
exit 1
fi
TARGET_VERSION="${NEXT_STABLE}-beta-${SHORT_SHA}"
BRANCHES="develop"
TRIGGER_DESC="ZDDC beta cut"
TRAILER="Triggered by push to git.varasys.io/VARASYS/ZDDC main with embedded/* changes (a ./build beta cut). Bumps appVersion so the dev Docker image is tagged zddc:$TARGET_VERSION, ensuring kubelet pulls a fresh image on the next helm upgrade."
;;
stable)
if [ -z "$EXPLICIT_VERSION" ]; then
echo "::error::stable channel requires an explicit VERSION arg" >&2
exit 1
fi
TARGET_VERSION="$EXPLICIT_VERSION"
# Bump both branches: main fires BMCD pipeline-prod (prod image
# rebuild), develop fires pipeline-dev so dev follows stable
# whenever no beta is active.
BRANCHES="main develop"
TRIGGER_DESC="ZDDC stable cut"
TRAILER="Triggered by zddc-server-v$TARGET_VERSION tag push on git.varasys.io/VARASYS/ZDDC. Bumps appVersion so prod (and dev tracking stable) rebuild against the new ZDDC stable."
;;
*)
echo "::error::unknown channel '$CHANNEL' (expected: beta | stable)" >&2
exit 1
;;
esac
echo "Bumping tnd-zddc-chart appVersion → $TARGET_VERSION"
echo "Branches: $BRANCHES (HEAD=$(git rev-parse HEAD))"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
cd "$TMP"
for BRANCH in $BRANCHES; do
echo ""
echo "=== bumping $BRANCH ==="
rm -rf tnd-zddc-chart
git clone --depth=20 --branch="$BRANCH" "$CHART_URL"
cd tnd-zddc-chart
CURRENT=$(grep '^appVersion:' chart/Chart.yaml \
| sed -E 's/^appVersion: *"?([^"]*)"?.*/\1/')
if [ "$CURRENT" = "$TARGET_VERSION" ]; then
echo " $BRANCH already at $TARGET_VERSION; skipping"
cd ..
continue
fi
sed -i "s/^appVersion: .*/appVersion: \"$TARGET_VERSION\"/" chart/Chart.yaml
OLD_CHART_VER=$(grep '^version:' chart/Chart.yaml | awk '{print $2}')
MAJC=$(echo "$OLD_CHART_VER" | cut -d. -f1)
MINC=$(echo "$OLD_CHART_VER" | cut -d. -f2)
PATC=$(echo "$OLD_CHART_VER" | cut -d. -f3)
NEW_CHART_VER="$MAJC.$MINC.$((PATC + 1))"
sed -i "s/^version: .*/version: $NEW_CHART_VER/" chart/Chart.yaml
echo " appVersion: $CURRENT$TARGET_VERSION"
echo " version: $OLD_CHART_VER$NEW_CHART_VER"
git config user.name "ZDDC Release Bot"
git config user.email "noreply@zddc.varasys.io"
git add chart/Chart.yaml
git commit \
-m "chore(chart): auto-bump appVersion to $TARGET_VERSION ($TRIGGER_DESC)" \
-m "$TRAILER" \
-m "Auto-generated by .forgejo/scripts/notify-chart-bump.sh. The next ZDDC beta or stable cut will overwrite this."
git push origin "$BRANCH"
echo " pushed $BRANCH"
cd ..
done
echo ""
echo "Done."

View file

@ -113,68 +113,15 @@ jobs:
# directly to GitHub. The chart repo is mirrored Forgejo→GitHub # directly to GitHub. The chart repo is mirrored Forgejo→GitHub
# one-way; pushing directly to GitHub would be silently overwritten # one-way; pushing directly to GitHub would be silently overwritten
# the next time Forgejo's mirror syncs (force-push semantics). # the next time Forgejo's mirror syncs (force-push semantics).
# The runner reaches git.varasys.io via the caddy-net network it
# joined when the runner container was provisioned.
CHART_FORGEJO_TOKEN: ${{ secrets.CHART_FORGEJO_TOKEN }} CHART_FORGEJO_TOKEN: ${{ secrets.CHART_FORGEJO_TOKEN }}
steps: steps:
- name: Auto-bump tnd-zddc-chart appVersion on main + develop (via Forgejo) - uses: actions/checkout@v4
run: | with:
set -eu fetch-depth: 0
VERSION="${GITHUB_REF#refs/tags/zddc-server-v}" - name: Bump chart for stable cut
echo "ZDDC stable cut: $VERSION" # All bump logic lives in .forgejo/scripts/notify-chart-bump.sh
# — same script the dev workflow uses. See its header for
# Sanity: make sure the secret was injected. If not, fail loud # behavior. Local invocation:
# (rather than silently failing on the git push later). # CHART_FORGEJO_TOKEN=$FORGEJO_TOKEN \
if [ -z "${CHART_FORGEJO_TOKEN:-}" ]; then # .forgejo/scripts/notify-chart-bump.sh stable X.Y.Z
echo "::error::CHART_FORGEJO_TOKEN secret not set on this repo" >&2 run: ./.forgejo/scripts/notify-chart-bump.sh stable "${GITHUB_REF#refs/tags/zddc-server-v}"
exit 1
fi
git config --global user.name "ZDDC Release Bot"
git config --global user.email "noreply@zddc.varasys.io"
# Push the same appVersion bump to both branches so prod and
# dev images both rebuild against the new ZDDC stable. Loop
# is idempotent per-branch — if a branch's appVersion already
# matches the new version, it's a no-op for that branch.
# The push goes to Forgejo (BMCD/tnd-zddc-chart on
# git.varasys.io); Forgejo's push-mirror replicates the bump
# to GitHub on the next sync (which is sync_on_commit: true).
TMP=$(mktemp -d)
cd "$TMP"
for BRANCH in main develop; do
echo ""
echo "=== bumping $BRANCH ==="
rm -rf tnd-zddc-chart
git clone --depth=20 --branch="$BRANCH" \
"https://oauth2:${CHART_FORGEJO_TOKEN}@git.varasys.io/BMCD/tnd-zddc-chart.git"
cd tnd-zddc-chart
CURRENT=$(grep '^appVersion:' chart/Chart.yaml | sed -E 's/^appVersion: *"?([^"]*)"?.*/\1/')
if [ "$CURRENT" = "$VERSION" ]; then
echo " $BRANCH already at $VERSION; nothing to do"
cd ..
continue
fi
sed -i "s/^appVersion: .*/appVersion: \"$VERSION\"/" chart/Chart.yaml
OLD_CHART_VER=$(grep '^version:' chart/Chart.yaml | awk '{print $2}')
MAJ=$(echo "$OLD_CHART_VER" | cut -d. -f1)
MIN=$(echo "$OLD_CHART_VER" | cut -d. -f2)
PAT=$(echo "$OLD_CHART_VER" | cut -d. -f3)
NEW_PAT=$((PAT + 1))
NEW_CHART_VER="$MAJ.$MIN.$NEW_PAT"
sed -i "s/^version: .*/version: $NEW_CHART_VER/" chart/Chart.yaml
echo " appVersion: $CURRENT → $VERSION"
echo " version: $OLD_CHART_VER → $NEW_CHART_VER"
git add chart/Chart.yaml
git commit \
-m "chore(chart): auto-bump appVersion to $VERSION (ZDDC stable cut)" \
-m "Triggered by zddc-server-v$VERSION tag push on git.varasys.io/VARASYS/ZDDC. Bumps appVersion so the $BRANCH-branch image is tagged zddc:$VERSION, ensuring kubelet pulls a fresh image on the next helm upgrade. Chart version bumped to $NEW_CHART_VER (patch) so JFrog has a clean chart history per deploy." \
-m "Auto-generated by .forgejo/workflows/deploy-release.yml's notify-chart-prod job. Do not edit manually — the next ZDDC stable cut will overwrite this commit's changes."
git push origin "$BRANCH"
echo " pushed $BRANCH bump to Forgejo - mirror replicates to GitHub - BMCD pipeline-$([ \"$BRANCH\" = main ] && echo prod || echo dev) will fire"
cd ..
done

View file

@ -1,105 +1,35 @@
name: Notify chart dev on beta cut name: Notify chart dev on beta cut
# Mirrors deploy-release.yml's notify-chart-prod job, but for beta.
# Triggers when a push to ZDDC main touches zddc/internal/apps/embedded/* # Triggers when a push to ZDDC main touches zddc/internal/apps/embedded/*
# — i.e. a `./build beta` cut whose embedded artifacts the operator # — i.e. a `./build beta` cut whose embedded artifacts the operator
# committed to main. Pushes a chart appVersion bump to the chart's # committed to main. Pushes a chart appVersion bump to the chart's
# develop branch, which fires BMCD's pipeline-dev → dev image rebuilt # develop branch, which fires BMCD's pipeline-dev → dev image rebuilt
# with the new beta-labeled bytes baked in. # with the new beta-labeled bytes baked in.
# #
# Stable cuts ALSO touch embedded/, but their workflow path is the # All logic lives in .forgejo/scripts/notify-chart-bump.sh — see that
# tag-triggered notify-chart-prod in deploy-release.yml. To avoid # script's header for behavior. Workflows just provide checkout +
# double-firing when a stable cut pushes main + tags together, we # secret + invocation. Local invocation is supported (and supported
# check if HEAD has a zddc-server-v* tag and skip if so — the # without --force-with-lease shenanigans):
# stable workflow handles the chart bump in that case. #
# CHART_FORGEJO_TOKEN=$FORGEJO_TOKEN .forgejo/scripts/notify-chart-bump.sh beta
on: on:
push: push:
branches: [main] branches: [main]
paths: paths:
- 'zddc/internal/apps/embedded/**' - 'zddc/internal/apps/embedded/**'
# Manual trigger — useful for re-firing without a no-op embedded/
# change to satisfy the paths filter (e.g. after fixing the script
# or workflow itself).
workflow_dispatch:
jobs: jobs:
notify-chart-dev: notify-chart-dev:
runs-on: host runs-on: host
env: env:
# Push to Forgejo (BMCD/tnd-zddc-chart on git.varasys.io), NOT
# directly to GitHub. See notify-chart-prod's comment in
# deploy-release.yml for the full rationale (mirror is one-way
# Forgejo→GitHub; direct GitHub pushes get silently overwritten
# on the next mirror sync).
CHART_FORGEJO_TOKEN: ${{ secrets.CHART_FORGEJO_TOKEN }} CHART_FORGEJO_TOKEN: ${{ secrets.CHART_FORGEJO_TOKEN }}
steps: steps:
- name: Checkout (need tags to detect stable cut) - uses: actions/checkout@v4
uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0 # script needs full tag history
- run: ./.forgejo/scripts/notify-chart-bump.sh beta
- name: Detect cut type (skip if HEAD has stable tag)
id: gate
run: |
set -eu
if git tag --points-at HEAD | grep -q '^zddc-server-v'; then
echo "is_beta=false" >> "$GITHUB_OUTPUT"
echo "HEAD has zddc-server-v* tag — stable workflow handles this; skipping dev notify"
else
echo "is_beta=true" >> "$GITHUB_OUTPUT"
echo "No stable tag at HEAD; treating as beta cut"
fi
- name: Auto-bump chart develop appVersion + push
if: steps.gate.outputs.is_beta == 'true'
run: |
set -eu
if [ -z "${CHART_FORGEJO_TOKEN:-}" ]; then
echo "::error::CHART_FORGEJO_TOKEN secret not set on this repo" >&2
exit 1
fi
# Compose a beta version string that's unique per ZDDC commit.
# Uses the next-stable target (max of latest tag + 1, mirrors
# ./build's _coordinated_next_stable) and the short SHA.
# Example: "0.0.11-beta-c099676". Always unique per push.
LATEST_STABLE=$(git tag --list 'zddc-server-v*' --sort=-v:refname | head -1)
MAJ=$(echo "${LATEST_STABLE#zddc-server-v}" | cut -d. -f1)
MIN=$(echo "${LATEST_STABLE#zddc-server-v}" | cut -d. -f2)
PAT=$(echo "${LATEST_STABLE#zddc-server-v}" | cut -d. -f3)
NEXT_STABLE="$MAJ.$MIN.$((PAT + 1))"
SHORT_SHA=$(git rev-parse --short=7 HEAD)
BETA_VERSION="${NEXT_STABLE}-beta-${SHORT_SHA}"
echo "ZDDC beta cut: $BETA_VERSION (HEAD=$(git rev-parse HEAD))"
TMP=$(mktemp -d)
cd "$TMP"
git clone --depth=20 --branch=develop \
"https://oauth2:${CHART_FORGEJO_TOKEN}@git.varasys.io/BMCD/tnd-zddc-chart.git"
cd tnd-zddc-chart
# Idempotent: same SHA ⇒ same version ⇒ no-op.
CURRENT=$(grep '^appVersion:' chart/Chart.yaml | sed -E 's/^appVersion: *"?([^"]*)"?.*/\1/')
if [ "$CURRENT" = "$BETA_VERSION" ]; then
echo "Chart develop already at $BETA_VERSION; nothing to do"
exit 0
fi
sed -i "s/^appVersion: .*/appVersion: \"$BETA_VERSION\"/" chart/Chart.yaml
OLD_CHART_VER=$(grep '^version:' chart/Chart.yaml | awk '{print $2}')
MAJC=$(echo "$OLD_CHART_VER" | cut -d. -f1)
MINC=$(echo "$OLD_CHART_VER" | cut -d. -f2)
PATC=$(echo "$OLD_CHART_VER" | cut -d. -f3)
NEW_CHART_VER="$MAJC.$MINC.$((PATC + 1))"
sed -i "s/^version: .*/version: $NEW_CHART_VER/" chart/Chart.yaml
echo " appVersion: $CURRENT → $BETA_VERSION"
echo " version: $OLD_CHART_VER → $NEW_CHART_VER"
git config user.name "ZDDC Release Bot"
git config user.email "noreply@zddc.varasys.io"
git add chart/Chart.yaml
git commit \
-m "chore(chart): auto-bump appVersion to $BETA_VERSION (ZDDC beta cut)" \
-m "Triggered by push to git.varasys.io/VARASYS/ZDDC main with embedded/* changes (a ./build beta cut). Bumps appVersion so the dev Docker image is tagged zddc:$BETA_VERSION, ensuring kubelet pulls a fresh image on the next helm upgrade." \
-m "Auto-generated by .forgejo/workflows/notify-chart-dev.yml. The next ZDDC beta or stable cut will overwrite this."
git push origin develop
echo "pushed chart develop bump to Forgejo - mirror replicates to GitHub - BMCD pipeline-dev will fire"

View file

@ -27,8 +27,14 @@ concat_files \
"css/print.css" \ "css/print.css" \
> "$css_temp" > "$css_temp"
# JavaScript files to concatenate in order # JavaScript files to concatenate in order. Vendored libraries first
# (jszip, docx-preview) so window.JSZip + window.docx are defined before
# any tool code runs — replaces the previous CDN loadLibrary() calls in
# table.js + export.js. xlsx is intentionally still CDN-loaded on demand
# (~900 KB; too large to inline).
concat_files \ concat_files \
"../shared/vendor/jszip.min.js" \
"../shared/vendor/docx-preview.min.js" \
"../shared/zddc.js" \ "../shared/zddc.js" \
"../shared/hash.js" \ "../shared/hash.js" \
"../shared/theme.js" \ "../shared/theme.js" \

View file

@ -300,7 +300,7 @@
// Show unsupported browser message // Show unsupported browser message
function showUnsupportedBrowserMessage() { function showUnsupportedBrowserMessage() {
const app = document.getElementById('app'); const app = document.getElementById('appContainer');
app.innerHTML = ` app.innerHTML = `
<div class="empty-state"> <div class="empty-state">
<div class="empty-state-content"> <div class="empty-state-content">

View file

@ -24,8 +24,10 @@
// Handle drops on grouping folders (for creating transmittals) // Handle drops on grouping folders (for creating transmittals)
document.getElementById('groupingFoldersList').addEventListener('drop', handleDrop, false); document.getElementById('groupingFoldersList').addEventListener('drop', handleDrop, false);
// Handle drops on the main app area (for adding directories) // Handle drops on the main app area (for adding directories).
document.getElementById('app').addEventListener('drop', handleAppDrop, false); // Note: the root element is id="appContainer" (id="app" was a
// stale reference that crashed dragDrop init in local mode).
document.getElementById('appContainer').addEventListener('drop', handleAppDrop, false);
} }
// Prevent default behaviors // Prevent default behaviors

View file

@ -53,10 +53,12 @@
return; return;
} }
// Check if JSZip is loaded // JSZip is vendored (concat'd by build.sh), so window.JSZip is
// already defined. Defensive check in case a future refactor
// reorders things.
if (typeof JSZip === 'undefined') { if (typeof JSZip === 'undefined') {
// Dynamically load JSZip alert('JSZip library not bundled — rebuild archive with shared/vendor/jszip.min.js');
await loadJSZip(); return;
} }
const zip = new JSZip(); const zip = new JSZip();
@ -123,17 +125,6 @@
} }
} }
// Load JSZip library dynamically
function loadJSZip() {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// Show progress indicator // Show progress indicator
function showProgress(message, current, total) { function showProgress(message, current, total) {
let progressDiv = document.getElementById('progressIndicator'); let progressDiv = document.getElementById('progressIndicator');
@ -260,7 +251,6 @@
rowsToCSV, rowsToCSV,
exportCSV, exportCSV,
downloadSelected, downloadSelected,
loadJSZip,
showProgress, showProgress,
hideProgress, hideProgress,
downloadFile, downloadFile,

View file

@ -609,9 +609,9 @@
if (!container) return; if (!container) return;
try { try {
await loadLibrary('https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js'); // jszip + docx-preview are vendored (concatenated by build.sh
await loadLibrary('https://cdn.jsdelivr.net/npm/docx-preview@latest/dist/docx-preview.min.js'); // ahead of every tool module), so window.JSZip and window.docx
// are already defined here.
const arrayBuffer = await (file.handle const arrayBuffer = await (file.handle
? file.handle.getFile().then(f => f.arrayBuffer()) ? file.handle.getFile().then(f => f.arrayBuffer())
: fetch(file.url).then(r => r.arrayBuffer())); : fetch(file.url).then(r => r.arrayBuffer()));

View file

@ -34,6 +34,7 @@ concat_files \
"../shared/zddc.js" \ "../shared/zddc.js" \
"../shared/zddc-filter.js" \ "../shared/zddc-filter.js" \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/help.js" \
"../shared/preview-lib.js" \ "../shared/preview-lib.js" \
"js/init.js" \ "js/init.js" \
"js/loader.js" \ "js/loader.js" \
@ -51,8 +52,13 @@ tool=browse
compute_build_label "$tool" "$@" compute_build_label "$tool" "$@"
# Replace template placeholders with concatenated CSS/JS + label. # Replace template placeholders with concatenated CSS/JS + label.
# Non-stable build labels (alpha/beta/dev-dirty) are wrapped in a red
# span — same convention as every other tool (compute_build_label
# sets $is_red=1 for non-stable cuts). Keeps the visual cue
# consistent across tool headers.
awk -v css_file="$css_temp" -v js_file="$js_temp" \ awk -v css_file="$css_temp" -v js_file="$js_temp" \
-v build_label="$build_label" -v favicon="$favicon_data_uri" ' -v build_label="$build_label" -v is_red="$is_red" \
-v favicon="$favicon_data_uri" '
/\{\{CSS_PLACEHOLDER\}\}/ { /\{\{CSS_PLACEHOLDER\}\}/ {
while ((getline line < css_file) > 0) print line while ((getline line < css_file) > 0) print line
close(css_file); next close(css_file); next
@ -61,8 +67,15 @@ awk -v css_file="$css_temp" -v js_file="$js_temp" \
while ((getline line < js_file) > 0) print line while ((getline line < js_file) > 0) print line
close(js_file); next close(js_file); next
} }
{ /\{\{BUILD_LABEL\}\}/ {
if (is_red == "1") {
gsub(/\{\{BUILD_LABEL\}\}/, "<span style=\"color:red;font-weight:bold\">" build_label "</span>")
} else {
gsub(/\{\{BUILD_LABEL\}\}/, build_label) gsub(/\{\{BUILD_LABEL\}\}/, build_label)
}
print; next
}
{
gsub(/\{\{FAVICON\}\}/, favicon) gsub(/\{\{FAVICON\}\}/, favicon)
print print
} }

View file

@ -142,22 +142,6 @@
outline-offset: -1px; outline-offset: -1px;
} }
/* Subtle button variant used for "Select Directory" when the page
is server-backed (the user usually doesn't need to switch to a
local folder; we keep the option visible but quiet). */
.btn.btn--subtle {
background: transparent;
color: var(--text-muted);
border-color: var(--border);
box-shadow: none;
font-weight: normal;
}
.btn.btn--subtle:hover {
color: var(--text);
background: var(--bg-hover, rgba(0,0,0,0.04));
}
/* Table — folders + files in a tree */ /* Table — folders + files in a tree */
.browse-table { .browse-table {

View file

@ -24,8 +24,8 @@
<span class="app-header__title">ZDDC Browse</span> <span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp">{{BUILD_LABEL}}</span> <span class="build-timestamp">{{BUILD_LABEL}}</span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Select Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing" style="font-size:1.1rem;"></button>
</div> </div>
<div class="header-right"> <div class="header-right">
<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>
@ -42,7 +42,7 @@
<ul> <ul>
<li><b>Online</b> — when this page is served by zddc-server, the <li><b>Online</b> — when this page is served by zddc-server, the
listing for the current directory loads automatically.</li> listing for the current directory loads automatically.</li>
<li><b>Local</b> — click <i>Select Directory</i> to pick any folder <li><b>Local</b> — click <i>Add Local Directory</i> to pick any folder
on your computer (Chromium-based browsers).</li> on your computer (Chromium-based browsers).</li>
</ul> </ul>
<p>Once loaded: click a folder to expand it, <b>shift-click</b> <p>Once loaded: click a folder to expand it, <b>shift-click</b>
@ -103,6 +103,87 @@
<div id="statusBar" class="status-bar"></div> <div id="statusBar" class="status-bar"></div>
<!-- Help Panel -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header">
<h2 id="help-panel-title" class="help-panel__title">Help — ZDDC Browse</h2>
<button type="button" class="help-panel__close" id="help-panel-close" aria-label="Close">&times;</button>
</div>
<div class="help-panel__body">
<h3>What is Browse?</h3>
<p>Browse is a directory listing for ZDDC archives — and any directory. It works in two modes:</p>
<dl>
<dt>Online</dt>
<dd>When the page is served by zddc-server, the listing for the current
URL directory loads automatically. Breadcrumbs link to ancestor folders.</dd>
<dt>Local</dt>
<dd>Click <strong>Add Local Directory</strong> to pick any folder on your
computer. Local mode requires a Chromium-based browser (File System
Access API).</dd>
</dl>
<h3>Tree navigation</h3>
<dl>
<dt>Click a folder</dt>
<dd>Toggle expand/collapse on that folder.</dd>
<dt>Shift-click a folder</dt>
<dd>Recursive expand or collapse — applies to the whole subtree.</dd>
<dt>Click a file</dt>
<dd>Open in the preview popup. Modifier-click (Ctrl/Cmd) or middle-click
opens in a new tab.</dd>
<dt>ZIP files</dt>
<dd>Behave as folders — click to inspect contents inline. JSZip is
bundled, so this works offline.</dd>
<dt>Column headers</dt>
<dd>Click to sort; click again to reverse.</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>
</dl>
<h3>Filter rows</h3>
<p>Two filter rows live in the table header:</p>
<dl>
<dt>📄 file row</dt>
<dd>Filter by file name (left input) and/or extension (Type input).
File matches stay visible together with their ancestor folders, so
the path to each hit is always shown.</dd>
<dt>📁 folder row</dt>
<dd>Filter by folder name. Matching folders show with their entire
subtree. Combined with file filter: file must also be inside a
matching folder's subtree (intersection).</dd>
</dl>
<p>Filter syntax (shared across all ZDDC tools):</p>
<dl>
<dt><code>term</code></dt>
<dd>Contains "term" (case-insensitive)</dd>
<dt><code>!term</code></dt>
<dd>Does not contain</dd>
<dt><code>^term</code></dt>
<dd>Starts with</dd>
<dt><code>term$</code></dt>
<dd>Ends with</dd>
<dt><code>a b</code></dt>
<dd>Both (AND)</dd>
<dt><code>a | b</code></dt>
<dd>Either (OR)</dd>
<dt><code>el.*spc</code></dt>
<dd>Regex — any-char + any-sequence</dd>
</dl>
<h3>Header buttons</h3>
<dl>
<dt>Add 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>
<dd>Re-load the current directory listing.</dd>
<dt>◐ Theme</dt>
<dd>Cycle auto / light / dark.</dd>
</dl>
</div>
</aside>
<script> <script>
{{JS_PLACEHOLDER}} {{JS_PLACEHOLDER}}
</script> </script>

View file

@ -25,8 +25,14 @@ concat_files \
"css/spreadsheet.css" \ "css/spreadsheet.css" \
> "$css_temp" > "$css_temp"
# JavaScript files to concatenate in order # JavaScript files to concatenate in order. Vendored libraries first
# (jszip, docx-preview) so window.JSZip + window.docx are defined before
# any tool code runs. Replaces the previous <script src="cdn..."> tag in
# template.html plus the loadLibrary CDN calls in preview.js. xlsx stays
# CDN-loaded on demand (~900 KB; too large to inline).
concat_files \ concat_files \
"../shared/vendor/jszip.min.js" \
"../shared/vendor/docx-preview.min.js" \
"../shared/zddc.js" \ "../shared/zddc.js" \
"../shared/hash.js" \ "../shared/hash.js" \
"../shared/theme.js" \ "../shared/theme.js" \

View file

@ -60,7 +60,7 @@
*/ */
function showBrowserWarning() { function showBrowserWarning() {
const warning = document.getElementById('browserWarning'); const warning = document.getElementById('browserWarning');
const selectBtn = document.getElementById('selectDirectoryBtn'); const selectBtn = document.getElementById('addDirectoryBtn');
if (warning) { if (warning) {
warning.classList.remove('hidden'); warning.classList.remove('hidden');
} }
@ -80,8 +80,8 @@
mainApp: document.getElementById('mainApp'), mainApp: document.getElementById('mainApp'),
// Header buttons // Header buttons
selectDirectoryBtn: document.getElementById('selectDirectoryBtn'), addDirectoryBtn: document.getElementById('addDirectoryBtn'),
refreshBtn: document.getElementById('refreshBtn'), refreshHeaderBtn: document.getElementById('refreshHeaderBtn'),
saveAllBtn: document.getElementById('saveAllBtn'), saveAllBtn: document.getElementById('saveAllBtn'),
cancelAllBtn: document.getElementById('cancelAllBtn'), cancelAllBtn: document.getElementById('cancelAllBtn'),
exportHashesBtn: document.getElementById('exportHashesBtn'), exportHashesBtn: document.getElementById('exportHashesBtn'),
@ -115,8 +115,8 @@
*/ */
function setupEventListeners() { function setupEventListeners() {
// Directory selection // Directory selection
app.dom.selectDirectoryBtn.addEventListener('click', handleSelectDirectory); app.dom.addDirectoryBtn.addEventListener('click', handleSelectDirectory);
app.dom.refreshBtn.addEventListener('click', handleRefresh); app.dom.refreshHeaderBtn.addEventListener('click', handleRefresh);
// Drag and drop on welcome screen // Drag and drop on welcome screen
setupWelcomeDragDrop(); setupWelcomeDragDrop();
@ -278,7 +278,7 @@
await app.modules.scanner.scanDirectory(dirHandle); await app.modules.scanner.scanDirectory(dirHandle);
// Show refresh button now that a directory is loaded // Show refresh button now that a directory is loaded
if (app.dom.refreshBtn) { app.dom.refreshBtn.classList.remove('hidden'); } if (app.dom.refreshHeaderBtn) { app.dom.refreshHeaderBtn.classList.remove('hidden'); }
} }
/** /**

View file

@ -383,9 +383,7 @@
if (!container) return; if (!container) return;
try { try {
await loadLibrary('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js'); // jszip + docx-preview vendored by build.sh — already in scope.
await loadLibrary('https://cdn.jsdelivr.net/npm/docx-preview@latest/dist/docx-preview.min.js');
const blob = await getFileBlob(file); const blob = await getFileBlob(file);
const arrayBuffer = await blob.arrayBuffer(); const arrayBuffer = await blob.arrayBuffer();

View file

@ -5,7 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZDDC Classifier</title> <title>ZDDC Classifier</title>
<link rel="icon" type="image/svg+xml" href="{{FAVICON}}"> <link rel="icon" type="image/svg+xml" href="{{FAVICON}}">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<style> <style>
{{CSS_PLACEHOLDER}} {{CSS_PLACEHOLDER}}
</style> </style>
@ -29,8 +28,8 @@
<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="selectDirectoryBtn" class="btn btn-primary">Select Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="refreshBtn" 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">
<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>
@ -149,7 +148,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>Select Directory</strong> to begin.</p> <p>Click <strong>Add 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>
@ -168,7 +167,7 @@
<h3>Getting Started</h3> <h3>Getting Started</h3>
<ol> <ol>
<li>Click <strong>Select Directory</strong> to open a folder containing files to rename.</li> <li>Click <strong>Add 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>

View file

@ -24,6 +24,7 @@ concat_files \
concat_files \ concat_files \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/help.js" \
"js/app.js" \ "js/app.js" \
"js/context.js" \ "js/context.js" \
"js/util.js" \ "js/util.js" \

View file

@ -27,6 +27,7 @@
</div> </div>
<div class="header-right"> <div class="header-right">
<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>
</div> </div>
</header> </header>
@ -38,6 +39,51 @@
</div> </div>
</main> </main>
<!-- Help Panel -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header">
<h2 id="help-panel-title" class="help-panel__title">Help — ZDDC Form</h2>
<button type="button" class="help-panel__close" id="help-panel-close" aria-label="Close">&times;</button>
</div>
<div class="help-panel__body">
<h3>What is this form?</h3>
<p>This is a schema-driven form rendered by zddc-server. Every
<code>&lt;name&gt;.form.yaml</code> file in the archive becomes an
editable form at <code>&lt;path&gt;/&lt;name&gt;.form.html</code>.
Submissions are saved as <code>&lt;name&gt;/&lt;id&gt;.yaml</code>
files alongside the schema, and re-render with their data filled in
when revisited.</p>
<h3>Filling in the form</h3>
<dl>
<dt>Required fields</dt>
<dd>Marked with an asterisk in their label. Submitting with a
required field empty re-renders the form with an inline error.</dd>
<dt>Validation</dt>
<dd>Server-side via JSON Schema 2020-12 (subset). Client-side
hints (<code>required</code>, <code>min</code>, <code>max</code>,
<code>pattern</code>) are added where the schema specifies them.</dd>
<dt>Submit</dt>
<dd>POSTs to the same URL the form was loaded from. On success the
browser navigates to the saved submission's URL. On failure the
form re-renders with errors inline at each invalid field.</dd>
</dl>
<h3>Editing existing submissions</h3>
<p>Open the saved submission's URL — the form re-renders with its
current data and any errors. Submitting overwrites the same file.
History is in git via your normal commit cycle.</p>
<h3>Header buttons</h3>
<dl>
<dt>◐ Theme</dt>
<dd>Cycle auto / light / dark.</dd>
<dt>? Help</dt>
<dd>This panel. Press <kbd>Esc</kbd> to close.</dd>
</dl>
</div>
</aside>
<!-- <!--
Server injects the form context here on render. Shape: Server injects the form context here on render. Shape:
{ {

View file

@ -26,6 +26,7 @@ concat_files \
"../shared/zddc.js" \ "../shared/zddc.js" \
"../shared/zddc-filter.js" \ "../shared/zddc-filter.js" \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/help.js" \
"js/landing.js" \ "js/landing.js" \
> "$js_raw" > "$js_raw"

View file

@ -27,6 +27,7 @@
</div> </div>
<div class="header-right"> <div class="header-right">
<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>
</div> </div>
</header> </header>
@ -91,6 +92,52 @@
</div> </div>
</main> </main>
<!-- Help Panel -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header">
<h2 id="help-panel-title" class="help-panel__title">Help — ZDDC</h2>
<button type="button" class="help-panel__close" id="help-panel-close" aria-label="Close">&times;</button>
</div>
<div class="help-panel__body">
<h3>What is this page?</h3>
<p>This is the ZDDC archive landing page — a project picker. It lists every
project (top-level directory) you have access to on this server, plus any
<strong>groups</strong> you've defined for opening multiple projects at once.</p>
<h3>Projects</h3>
<p>Click a project to open it. The project's archive view (list of folders +
files, with all the standard ZDDC tools available inside) loads in the same
tab. Use back/forward to navigate between projects and the picker.</p>
<h3>Groups</h3>
<p>A group bundles a set of projects you commonly open together. Click
<strong>+ New group</strong>, give it a name, click projects to include
them, then save. Opening a group opens all its projects in one go.</p>
<dl>
<dt>Save group</dt>
<dd>Persist the selection as a named group on this server (visible to
other users with access to the same projects).</dd>
<dt>Open selected</dt>
<dd>Open the currently-checked projects without saving as a group.</dd>
<dt>Cancel</dt>
<dd>Exit select mode without saving.</dd>
</dl>
<h3>Access</h3>
<p>Projects and groups are filtered by your account's permissions.
If a URL references a project you don't have access to, a warning banner
appears and the inaccessible items are skipped silently.</p>
<h3>Header buttons</h3>
<dl>
<dt>◐ Theme</dt>
<dd>Cycle auto / light / dark.</dd>
<dt>? Help</dt>
<dd>This panel. Press <kbd>Esc</kbd> to close.</dd>
</dl>
</div>
</aside>
<script> <script>
{{JS_PLACEHOLDER}} {{JS_PLACEHOLDER}}
</script> </script>

View file

@ -438,6 +438,24 @@ a:hover {
background: var(--bg-secondary); background: var(--bg-secondary);
} }
/* Subdued / de-emphasized variant.
Used on the "Add 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. */
.btn.btn--subtle {
background: transparent;
color: var(--text-muted);
border-color: var(--border);
box-shadow: none;
font-weight: normal;
}
.btn.btn--subtle:not(:disabled):hover {
color: var(--text);
background: var(--bg-secondary);
}
.btn-success { .btn-success {
background: var(--success); background: var(--success);
color: var(--text-light); color: var(--text-light);
@ -1774,9 +1792,10 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Markdown</span> <span class="app-header__title">ZDDC Markdown</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · 582db6d</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · 8df0def</span></span>
</div> </div>
<button id="select-directory" class="btn btn-primary" title="Select a Directory">Select Directory</button> <button id="addDirectoryBtn" class="btn btn-primary" title="Add a local directory">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh directory" aria-label="Refresh" style="font-size:1.1rem;"></button>
</div> </div>
<div class="header-right"> <div class="header-right">
<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>
@ -1792,7 +1811,6 @@ body.help-open .app-header {
<span>Files</span> <span>Files</span>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button id="new-file-root" class="btn btn-secondary btn-sm hidden" title="New file in root directory">+</button> <button id="new-file-root" class="btn btn-secondary btn-sm hidden" title="New file in root directory">+</button>
<button id="refresh-directory" class="btn btn-secondary btn-sm hidden" title="Refresh directory"></button>
</div> </div>
</div> </div>
</div> </div>
@ -1806,7 +1824,7 @@ body.help-open .app-header {
<div class="pane content-pane flex-1 relative flex flex-col bg-white dark:bg-gray-900 overflow-hidden" id="main-content"> <div class="pane content-pane flex-1 relative flex flex-col bg-white dark:bg-gray-900 overflow-hidden" id="main-content">
<div id="welcome-screen" class="welcome-screen hidden flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400 text-center p-6"> <div id="welcome-screen" class="welcome-screen hidden flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400 text-center p-6">
<p id="welcome-hint" class="text-sm">Click <strong>Scratchpad</strong> in the file list to start editing,<br>or <strong>Select Directory</strong> to work with files.</p> <p id="welcome-hint" class="text-sm">Click <strong>Scratchpad</strong> in the file list to start editing,<br>or <strong>Add Local Directory</strong> to work with files.</p>
<p id="welcome-firefox" class="text-sm text-amber-600 hidden mt-2">Your browser doesn't support the File System API.<br>Use <strong>Scratchpad</strong> to edit markdown and download as a file.</p> <p id="welcome-firefox" class="text-sm text-amber-600 hidden mt-2">Your browser doesn't support the File System API.<br>Use <strong>Scratchpad</strong> to edit markdown and download as a file.</p>
</div> </div>
@ -1850,7 +1868,7 @@ body.help-open .app-header {
<h3>Getting Started</h3> <h3>Getting Started</h3>
<ol> <ol>
<li>Click <strong>Select Directory</strong> to open a folder. The file tree on the left will populate with all files in that folder.</li> <li>Click <strong>Add Local Directory</strong> to open a folder. The file tree on the left will populate with all files in that folder.</li>
<li>Click any Markdown file (<code>.md</code>) in the tree to open it in the editor.</li> <li>Click any Markdown file (<code>.md</code>) in the tree to open it in the editor.</li>
<li>Use the <strong>Scratchpad</strong> entry (always visible at the top of the tree) for temporary notes without saving to disk.</li> <li>Use the <strong>Scratchpad</strong> entry (always visible at the top of the tree) for temporary notes without saving to disk.</li>
</ol> </ol>
@ -2954,7 +2972,7 @@ const SCRATCHPAD_WELCOME = [
'Use this **Scratchpad** for quick notes. Download it any time with the ⬇', 'Use this **Scratchpad** for quick notes. Download it any time with the ⬇',
'button on the Scratchpad row in the file list.', 'button on the Scratchpad row in the file list.',
'', '',
'Click **Select Directory** above to open a folder of Markdown files,', 'Click **Add Local Directory** above to open a folder of Markdown files,',
'or just start typing here.', 'or just start typing here.',
'', '',
].join('\n'); ].join('\n');
@ -3754,12 +3772,19 @@ async function openDirectory() {
* @param {string} directoryName - Name of the selected directory * @param {string} directoryName - Name of the selected directory
*/ */
function updateDirectoryStatus(directoryName) { function updateDirectoryStatus(directoryName) {
const selectDirectoryBtn = document.getElementById('select-directory'); // Standardized header pattern (across all ZDDC tools): the button
// keeps the label "Add Local Directory"; de-emphasize it once a
// directory is loaded (the user can still click to pick another)
// by applying the shared btn--subtle variant. The directory name
// is shown in the file-nav pane, not on the button.
const selectDirectoryBtn = document.getElementById('addDirectoryBtn');
if (selectDirectoryBtn) { if (selectDirectoryBtn) {
selectDirectoryBtn.textContent = `Directory: ${directoryName}`; selectDirectoryBtn.classList.remove('btn-primary');
selectDirectoryBtn.classList.add('btn--subtle');
selectDirectoryBtn.title = `Loaded: ${directoryName} — click to switch`;
} }
const refreshBtn = document.getElementById('refresh-directory'); const refreshBtn = document.getElementById('refreshHeaderBtn');
if (refreshBtn) { if (refreshBtn) {
refreshBtn.classList.remove('hidden'); refreshBtn.classList.remove('hidden');
} }
@ -4260,8 +4285,8 @@ async function loadServerDirectory() {
// Only enter server-source mode if the host actually serves JSON directory // Only enter server-source mode if the host actually serves JSON directory
// listings (zddc-server / Caddy). On a plain static host the probe fails // listings (zddc-server / Caddy). On a plain static host the probe fails
// and we must leave "Select Directory" visible so the user can still load // and we must leave "Add Local Directory" visible so the user can still
// local files. // load local files.
try { try {
const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' }); const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' });
if (!resp.ok) return; if (!resp.ok) return;
@ -4286,12 +4311,18 @@ async function loadServerDirectory() {
entries: {}, entries: {},
}; };
// Surface refresh, hide write-only controls. "Select Directory" stays // Surface refresh, hide write-only controls. "Add Local Directory"
// visible so the user can switch to a local folder at any time. // stays visible (de-emphasized via btn--subtle) so the user can
const refreshBtn = document.getElementById('refresh-directory'); // switch to a local folder at any time.
const refreshBtn = document.getElementById('refreshHeaderBtn');
if (refreshBtn) refreshBtn.classList.remove('hidden'); if (refreshBtn) refreshBtn.classList.remove('hidden');
const newFileRootBtn = document.getElementById('new-file-root'); const newFileRootBtn = document.getElementById('new-file-root');
if (newFileRootBtn) newFileRootBtn.classList.add('hidden'); if (newFileRootBtn) newFileRootBtn.classList.add('hidden');
const addDirBtn = document.getElementById('addDirectoryBtn');
if (addDirBtn) {
addDirBtn.classList.remove('btn-primary');
addDirBtn.classList.add('btn--subtle');
}
const stats = await readServerDirectory(baseUrl, fileTree, 0); const stats = await readServerDirectory(baseUrl, fileTree, 0);
renderFileTree(); renderFileTree();
@ -6028,14 +6059,14 @@ function initializeFileNavResizer() {
* Set up all event listeners for the application * Set up all event listeners for the application
*/ */
function setupEventListeners() { function setupEventListeners() {
// Select directory button // Add Local Directory button (was id="select-directory" / "refresh-directory")
const selectDirectoryBtn = document.getElementById('select-directory'); const selectDirectoryBtn = document.getElementById('addDirectoryBtn');
if (selectDirectoryBtn) { if (selectDirectoryBtn) {
selectDirectoryBtn.addEventListener('click', openDirectory); selectDirectoryBtn.addEventListener('click', openDirectory);
} }
// Refresh directory button // Refresh button (now in header, was in file-nav pane)
const refreshDirectoryBtn = document.getElementById('refresh-directory'); const refreshDirectoryBtn = document.getElementById('refreshHeaderBtn');
if (refreshDirectoryBtn) { if (refreshDirectoryBtn) {
refreshDirectoryBtn.addEventListener('click', refreshDirectory); refreshDirectoryBtn.addEventListener('click', refreshDirectory);
} }
@ -6136,7 +6167,7 @@ document.addEventListener('DOMContentLoaded', function () {
* Initialize UI based on File System API availability * Initialize UI based on File System API availability
*/ */
function initializeApiAvailability() { function initializeApiAvailability() {
const selectDirectoryBtn = document.getElementById('select-directory'); const selectDirectoryBtn = document.getElementById('addDirectoryBtn');
const welcomeHint = document.getElementById('welcome-hint'); const welcomeHint = document.getElementById('welcome-hint');
const welcomeFirefox = document.getElementById('welcome-firefox'); const welcomeFirefox = document.getElementById('welcome-firefox');

View file

@ -37,7 +37,7 @@ const SCRATCHPAD_WELCOME = [
'Use this **Scratchpad** for quick notes. Download it any time with the ⬇', 'Use this **Scratchpad** for quick notes. Download it any time with the ⬇',
'button on the Scratchpad row in the file list.', 'button on the Scratchpad row in the file list.',
'', '',
'Click **Select Directory** above to open a folder of Markdown files,', 'Click **Add Local Directory** above to open a folder of Markdown files,',
'or just start typing here.', 'or just start typing here.',
'', '',
].join('\n'); ].join('\n');

View file

@ -6,14 +6,14 @@
* Set up all event listeners for the application * Set up all event listeners for the application
*/ */
function setupEventListeners() { function setupEventListeners() {
// Select directory button // Add Local Directory button (was id="select-directory" / "refresh-directory")
const selectDirectoryBtn = document.getElementById('select-directory'); const selectDirectoryBtn = document.getElementById('addDirectoryBtn');
if (selectDirectoryBtn) { if (selectDirectoryBtn) {
selectDirectoryBtn.addEventListener('click', openDirectory); selectDirectoryBtn.addEventListener('click', openDirectory);
} }
// Refresh directory button // Refresh button (now in header, was in file-nav pane)
const refreshDirectoryBtn = document.getElementById('refresh-directory'); const refreshDirectoryBtn = document.getElementById('refreshHeaderBtn');
if (refreshDirectoryBtn) { if (refreshDirectoryBtn) {
refreshDirectoryBtn.addEventListener('click', refreshDirectory); refreshDirectoryBtn.addEventListener('click', refreshDirectory);
} }

View file

@ -171,12 +171,19 @@ async function openDirectory() {
* @param {string} directoryName - Name of the selected directory * @param {string} directoryName - Name of the selected directory
*/ */
function updateDirectoryStatus(directoryName) { function updateDirectoryStatus(directoryName) {
const selectDirectoryBtn = document.getElementById('select-directory'); // Standardized header pattern (across all ZDDC tools): the button
// keeps the label "Add Local Directory"; de-emphasize it once a
// directory is loaded (the user can still click to pick another)
// by applying the shared btn--subtle variant. The directory name
// is shown in the file-nav pane, not on the button.
const selectDirectoryBtn = document.getElementById('addDirectoryBtn');
if (selectDirectoryBtn) { if (selectDirectoryBtn) {
selectDirectoryBtn.textContent = `Directory: ${directoryName}`; selectDirectoryBtn.classList.remove('btn-primary');
selectDirectoryBtn.classList.add('btn--subtle');
selectDirectoryBtn.title = `Loaded: ${directoryName} — click to switch`;
} }
const refreshBtn = document.getElementById('refresh-directory'); const refreshBtn = document.getElementById('refreshHeaderBtn');
if (refreshBtn) { if (refreshBtn) {
refreshBtn.classList.remove('hidden'); refreshBtn.classList.remove('hidden');
} }
@ -677,8 +684,8 @@ async function loadServerDirectory() {
// Only enter server-source mode if the host actually serves JSON directory // Only enter server-source mode if the host actually serves JSON directory
// listings (zddc-server / Caddy). On a plain static host the probe fails // listings (zddc-server / Caddy). On a plain static host the probe fails
// and we must leave "Select Directory" visible so the user can still load // and we must leave "Add Local Directory" visible so the user can still
// local files. // load local files.
try { try {
const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' }); const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' });
if (!resp.ok) return; if (!resp.ok) return;
@ -703,12 +710,18 @@ async function loadServerDirectory() {
entries: {}, entries: {},
}; };
// Surface refresh, hide write-only controls. "Select Directory" stays // Surface refresh, hide write-only controls. "Add Local Directory"
// visible so the user can switch to a local folder at any time. // stays visible (de-emphasized via btn--subtle) so the user can
const refreshBtn = document.getElementById('refresh-directory'); // switch to a local folder at any time.
const refreshBtn = document.getElementById('refreshHeaderBtn');
if (refreshBtn) refreshBtn.classList.remove('hidden'); if (refreshBtn) refreshBtn.classList.remove('hidden');
const newFileRootBtn = document.getElementById('new-file-root'); const newFileRootBtn = document.getElementById('new-file-root');
if (newFileRootBtn) newFileRootBtn.classList.add('hidden'); if (newFileRootBtn) newFileRootBtn.classList.add('hidden');
const addDirBtn = document.getElementById('addDirectoryBtn');
if (addDirBtn) {
addDirBtn.classList.remove('btn-primary');
addDirBtn.classList.add('btn--subtle');
}
const stats = await readServerDirectory(baseUrl, fileTree, 0); const stats = await readServerDirectory(baseUrl, fileTree, 0);
renderFileTree(); renderFileTree();

View file

@ -32,7 +32,7 @@ document.addEventListener('DOMContentLoaded', function () {
* Initialize UI based on File System API availability * Initialize UI based on File System API availability
*/ */
function initializeApiAvailability() { function initializeApiAvailability() {
const selectDirectoryBtn = document.getElementById('select-directory'); const selectDirectoryBtn = document.getElementById('addDirectoryBtn');
const welcomeHint = document.getElementById('welcome-hint'); const welcomeHint = document.getElementById('welcome-hint');
const welcomeFirefox = document.getElementById('welcome-firefox'); const welcomeFirefox = document.getElementById('welcome-firefox');

View file

@ -29,7 +29,8 @@
<span class="app-header__title">ZDDC Markdown</span> <span class="app-header__title">ZDDC Markdown</span>
<span class="build-timestamp">{{BUILD_LABEL}}</span> <span class="build-timestamp">{{BUILD_LABEL}}</span>
</div> </div>
<button id="select-directory" class="btn btn-primary" title="Select a Directory">Select Directory</button> <button id="addDirectoryBtn" class="btn btn-primary" title="Add a local directory">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh directory" aria-label="Refresh" style="font-size:1.1rem;"></button>
</div> </div>
<div class="header-right"> <div class="header-right">
<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>
@ -45,7 +46,6 @@
<span>Files</span> <span>Files</span>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button id="new-file-root" class="btn btn-secondary btn-sm hidden" title="New file in root directory">+</button> <button id="new-file-root" class="btn btn-secondary btn-sm hidden" title="New file in root directory">+</button>
<button id="refresh-directory" class="btn btn-secondary btn-sm hidden" title="Refresh directory"></button>
</div> </div>
</div> </div>
</div> </div>
@ -59,7 +59,7 @@
<div class="pane content-pane flex-1 relative flex flex-col bg-white dark:bg-gray-900 overflow-hidden" id="main-content"> <div class="pane content-pane flex-1 relative flex flex-col bg-white dark:bg-gray-900 overflow-hidden" id="main-content">
<div id="welcome-screen" class="welcome-screen hidden flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400 text-center p-6"> <div id="welcome-screen" class="welcome-screen hidden flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400 text-center p-6">
<p id="welcome-hint" class="text-sm">Click <strong>Scratchpad</strong> in the file list to start editing,<br>or <strong>Select Directory</strong> to work with files.</p> <p id="welcome-hint" class="text-sm">Click <strong>Scratchpad</strong> in the file list to start editing,<br>or <strong>Add Local Directory</strong> to work with files.</p>
<p id="welcome-firefox" class="text-sm text-amber-600 hidden mt-2">Your browser doesn't support the File System API.<br>Use <strong>Scratchpad</strong> to edit markdown and download as a file.</p> <p id="welcome-firefox" class="text-sm text-amber-600 hidden mt-2">Your browser doesn't support the File System API.<br>Use <strong>Scratchpad</strong> to edit markdown and download as a file.</p>
</div> </div>
@ -103,7 +103,7 @@
<h3>Getting Started</h3> <h3>Getting Started</h3>
<ol> <ol>
<li>Click <strong>Select Directory</strong> to open a folder. The file tree on the left will populate with all files in that folder.</li> <li>Click <strong>Add Local Directory</strong> to open a folder. The file tree on the left will populate with all files in that folder.</li>
<li>Click any Markdown file (<code>.md</code>) in the tree to open it in the editor.</li> <li>Click any Markdown file (<code>.md</code>) in the tree to open it in the editor.</li>
<li>Use the <strong>Scratchpad</strong> entry (always visible at the top of the tree) for temporary notes without saving to disk.</li> <li>Use the <strong>Scratchpad</strong> entry (always visible at the top of the tree) for temporary notes without saving to disk.</li>
</ol> </ol>

View file

@ -210,6 +210,24 @@ a:hover {
background: var(--bg-secondary); background: var(--bg-secondary);
} }
/* Subdued / de-emphasized variant.
Used on the "Add 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. */
.btn.btn--subtle {
background: transparent;
color: var(--text-muted);
border-color: var(--border);
box-shadow: none;
font-weight: normal;
}
.btn.btn--subtle:not(:disabled):hover {
color: var(--text);
background: var(--bg-secondary);
}
.btn-success { .btn-success {
background: var(--success); background: var(--success);
color: var(--text-light); color: var(--text-light);

8
shared/vendor/docx-preview.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -36,8 +36,13 @@ concat_files \
"css/print.css" \ "css/print.css" \
> "$css_temp" > "$css_temp"
# JavaScript files to concatenate in order # JavaScript files to concatenate in order. Vendored libraries first
# (jszip, docx-preview) so window.JSZip + window.docx are defined before
# any tool code runs — replaces the previous CDN loadLibrary() calls
# scattered through files-preview.js. xlsx stays CDN-loaded on demand.
concat_files \ concat_files \
"../shared/vendor/jszip.min.js" \
"../shared/vendor/docx-preview.min.js" \
"../shared/zddc.js" \ "../shared/zddc.js" \
"../shared/hash.js" \ "../shared/hash.js" \
"../shared/theme.js" \ "../shared/theme.js" \

View file

@ -372,37 +372,6 @@
box-sizing: content-box; box-sizing: content-box;
} }
.app-header__spacer {
flex: 1;
}
.app-header__icons {
display: flex;
align-items: center;
gap: 0.5rem;
}
.header-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 0.25rem;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
padding: 0;
text-decoration: none;
transition: color 0.15s, background 0.15s;
}
.header-icon-btn:hover {
color: var(--primary-hover);
background: var(--primary-light);
}
/* ── Fixed footer status bar at viewport bottom ───────── */ /* ── Fixed footer status bar at viewport bottom ───────── */
.page-footer { .page-footer {
position: fixed; position: fixed;

View file

@ -200,8 +200,7 @@
return; return;
} }
try { try {
await loadLibrary('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js'); // jszip + docx-preview vendored by build.sh — already in scope.
await loadLibrary('https://cdn.jsdelivr.net/npm/docx-preview@latest/dist/docx-preview.min.js');
var arrayBuffer = await getFileArrayBuffer(file); var arrayBuffer = await getFileArrayBuffer(file);
container.innerHTML = ''; container.innerHTML = '';
await window.docx.renderAsync(arrayBuffer, container); await window.docx.renderAsync(arrayBuffer, container);
@ -474,7 +473,7 @@
} }
try { try {
updatePreviewStatus('Loading ZIP...'); updatePreviewStatus('Loading ZIP...');
await loadLibrary('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js'); // JSZip vendored by build.sh — already in scope.
var arrayBuffer = await zipFile.arrayBuffer(); var arrayBuffer = await zipFile.arrayBuffer();
var zip = await JSZip.loadAsync(arrayBuffer); var zip = await JSZip.loadAsync(arrayBuffer);
var sourceEntries = []; var sourceEntries = [];

View file

@ -27,13 +27,8 @@ conventions at https://codeberg.org/VARASYS/ZDDC#file-naming-convention.
</head> </head>
<body class="font-sans text-gray-900"> <body class="font-sans text-gray-900">
<div class="app-header print:hidden" data-no-disable="true"> <header class="app-header print:hidden" data-no-disable="true">
<div class="split-button" id="bottom-menu" hidden> <div class="header-left">
<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>
<div class="dropdown-menu hidden" role="menu" id="bottom-dropdown"></div>
</div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true"> <svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
<rect width="64" height="64" rx="12" fill="#1e3a5f"/> <rect width="64" height="64" rx="12" fill="#1e3a5f"/>
<g fill="#fff"> <g fill="#fff">
@ -46,12 +41,20 @@ conventions at https://codeberg.org/VARASYS/ZDDC#file-naming-convention.
<span class="app-header__title">ZDDC Transmittal</span> <span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp">{{BUILD_LABEL}}</span> <span class="build-timestamp">{{BUILD_LABEL}}</span>
</div> </div>
<div class="app-header__spacer"></div> <span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<div class="app-header__icons"> <!-- Publish split-button (Transmittal-specific primary action;
other tools have "Add 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>
<div class="dropdown-menu hidden" role="menu" id="bottom-dropdown"></div>
</div>
</div>
<div class="header-right">
<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>
</div> </header>
<div class="page-container"> <div class="page-container">
<form id="transmittal-form"> <form id="transmittal-form">
<input type="hidden" id="mode" value="edit"> <input type="hidden" id="mode" value="edit">

View file

@ -20,6 +20,9 @@ import (
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler" "codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
"codeberg.org/VARASYS/ZDDC/zddc/internal/tlsutil" "codeberg.org/VARASYS/ZDDC/zddc/internal/tlsutil"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"github.com/klauspost/compress/gzhttp"
"gopkg.in/natefinch/lumberjack.v2"
) )
// version is the binary's own version, injected at build time via // version is the binary's own version, injected at build time via
@ -106,13 +109,20 @@ func main() {
// the context the outer ACL middleware set. // the context the outer ACL middleware set.
// CORSMiddleware — Origin / preflight handling. // CORSMiddleware — Origin / preflight handling.
// dispatch — the actual request handler. // dispatch — the actual request handler.
mux.Handle("/", handler.ACLMiddleware(cfg, handler.AccessLogMiddleware(handler.CORSMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auditLogger := setupAccessAuditLog(cfg.AccessLog)
mux.Handle("/", handler.ACLMiddleware(cfg, handler.AccessLogMiddleware(auditLogger, handler.CORSMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
dispatch(cfg, idx, logRing, appsServer, w, r) dispatch(cfg, idx, logRing, appsServer, w, r)
}))))) })))))
gzWrapper, err := newGzipWrapper()
if err != nil {
slog.Error("gzhttp wrapper init", "err", err)
os.Exit(1)
}
srv := &http.Server{ srv := &http.Server{
Addr: cfg.Addr, Addr: cfg.Addr,
Handler: mux, Handler: gzWrapper(mux),
TLSConfig: tlsCfg, TLSConfig: tlsCfg,
} }
@ -146,6 +156,58 @@ func main() {
slog.Info("stopped") slog.Info("stopped")
} }
// setupAccessAuditLog constructs a slog.Logger writing JSON lines to a
// size-rotated file at the operator-configured path. Returns nil if no
// path is configured — AccessLogMiddleware then logs only to stderr
// (existing behavior).
//
// Rotation is via lumberjack: 100 MB per file, 10 backups, 90-day max
// age, gzip compression on rotated files. Tuning is fixed (not exposed
// as flags) — these defaults match what an audit-trail use case needs;
// operators wanting stricter retention can wire up logrotate against
// the rotated files themselves.
//
// File-permission posture: lumberjack creates new logs with mode 0600
// (running user only). For multi-user audit access, the operator should
// use group-readable parent directory permissions and either chmod the
// log out-of-band or run a forwarder that has its own read access.
// Parent directory must already exist — this function does NOT mkdir,
// since we'd need to assume too much about umask/owner.
func setupAccessAuditLog(path string) *slog.Logger {
if path == "" {
return nil
}
rotator := &lumberjack.Logger{
Filename: path,
MaxSize: 100, // megabytes per file before rotation
MaxBackups: 10,
MaxAge: 90, // days
Compress: true,
}
// JSON handler — line-delimited JSON is the format every standard
// log shipper (Vector, Loki promtail, fluentbit, journalbeat) parses
// natively, and stays grep-friendly for ad-hoc inspection.
h := slog.NewJSONHandler(rotator, &slog.HandlerOptions{Level: slog.LevelInfo})
slog.Info("access log file enabled",
"path", path, "max_size_mb", 100, "max_backups", 10, "max_age_days", 90)
return slog.New(h)
}
// newGzipWrapper builds the gzip middleware applied to the entire mux.
// MinSize(1024) skips compressing tiny responses where the framing
// overhead exceeds the savings (304 Not Modified, error pages, small
// JSON listings under ~1 KB). The wrapper honors Accept-Encoding (passes
// through unchanged when the client doesn't advertise gzip), appends
// Vary: Accept-Encoding automatically, and passes through 304s untouched.
// Yields ~75% size reduction on the larger embedded HTML responses
// (mdedit: 920 KB → ~250 KB on the wire).
//
// Extracted so tests can construct an equivalent wrapper without going
// through the full main() server boot.
func newGzipWrapper() (func(http.Handler) http.HandlerFunc, error) {
return gzhttp.NewWrapper(gzhttp.MinSize(1024))
}
// setupApps creates the cache + fetcher + server. No seeding, no refresh, // setupApps creates the cache + fetcher + server. No seeding, no refresh,
// no admin UI — the server fetches once on first request, caches forever // no admin UI — the server fetches once on first request, caches forever
// in <ZDDC_ROOT>/_app/, and falls back to the embedded HTML on any failure. // in <ZDDC_ROOT>/_app/, and falls back to the embedded HTML on any failure.
@ -297,14 +359,16 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// no real index.html on disk → serve via apps.Serve("landing"). The // no real index.html on disk → serve via apps.Serve("landing"). The
// other four apps are caught by the "stat fails → app HTML?" branch // 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. // below, which only triggers when no concrete file is at the URL path.
//
// 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") { if appsSrv != nil && (urlPath == "/" || urlPath == "/index.html") {
realIndex := filepath.Join(cfg.Root, "index.html") realIndex := filepath.Join(cfg.Root, "index.html")
if _, err := os.Stat(realIndex); os.IsNotExist(err) { if _, err := os.Stat(realIndex); os.IsNotExist(err) {
chain, _ := zddc.EffectivePolicy(cfg.Root, cfg.Root) chain, _ := zddc.EffectivePolicy(cfg.Root, cfg.Root)
if !zddc.AllowedWithChain(chain, email) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if apps.AppAvailableAt(cfg.Root, cfg.Root, "landing") { if apps.AppAvailableAt(cfg.Root, cfg.Root, "landing") {
appsSrv.Serve(w, r, "landing", chain, cfg.Root) appsSrv.Serve(w, r, "landing", chain, cfg.Root)
return return
@ -353,12 +417,19 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
} }
if info.IsDir() { if info.IsDir() {
// ACL check // ACL check — bypassed at the root path so the landing page (the
// project picker) is reachable by anyone, including anonymous.
// Per-project filtering happens inside ServeDirectory →
// fs.ListDirectory, which hides directories the caller can't
// reach. Subdirectory requests still hit this gate.
isRoot := urlPath == "/"
if !isRoot {
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath) chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
if !zddc.AllowedWithChain(chain, email) { if !zddc.AllowedWithChain(chain, email) {
http.Error(w, "Forbidden", http.StatusForbidden) http.Error(w, "Forbidden", http.StatusForbidden)
return return
} }
}
if !strings.HasSuffix(urlPath, "/") { if !strings.HasSuffix(urlPath, "/") {
http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently) http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently)
return return

View file

@ -210,3 +210,81 @@ func mustWrite(t *testing.T, path, body string) {
t.Fatalf("write %s: %v", path, err) t.Fatalf("write %s: %v", path, err)
} }
} }
// 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
// pass through uncompressed; HEAD requests still set Vary correctly.
//
// We construct the wrapper the same way main() does (1024 byte minsize)
// and exercise it against a tiny test handler — full end-to-end is
// covered by the live curl smoke test in CI / dev verification.
func TestGzhttpWrapper_CompressesLargeResponses(t *testing.T) {
// Re-create the wrapper config from main.go so this test stays in
// sync with the real wiring.
wrapper, err := newGzipWrapper()
if err != nil {
t.Fatalf("newGzipWrapper: %v", err)
}
largeBody := strings.Repeat("ZDDC ", 4000) // ~20 KB, well over MinSize
smallBody := "ok"
handler := wrapper(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if r.URL.Path == "/large" {
_, _ = w.Write([]byte(largeBody))
} else {
_, _ = w.Write([]byte(smallBody))
}
}))
srv := httptest.NewServer(handler)
defer srv.Close()
t.Run("large body with Accept-Encoding gzip → compressed", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/large", nil)
req.Header.Set("Accept-Encoding", "gzip")
// Disable transparent decompression so we can read the raw bytes
// and confirm the wire format.
client := &http.Client{Transport: &http.Transport{DisableCompression: true}}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if got := resp.Header.Get("Content-Encoding"); got != "gzip" {
t.Errorf("Content-Encoding = %q, want gzip", got)
}
if got := resp.Header.Get("Vary"); !strings.Contains(strings.ToLower(got), "accept-encoding") {
t.Errorf("Vary = %q, want to contain Accept-Encoding", got)
}
})
t.Run("small body → not compressed", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/small", nil)
req.Header.Set("Accept-Encoding", "gzip")
client := &http.Client{Transport: &http.Transport{DisableCompression: true}}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if got := resp.Header.Get("Content-Encoding"); got == "gzip" {
t.Errorf("Content-Encoding = gzip; small response should not be compressed")
}
})
t.Run("no Accept-Encoding → not compressed", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/large", nil)
client := &http.Client{Transport: &http.Transport{DisableCompression: true}}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if got := resp.Header.Get("Content-Encoding"); got != "" {
t.Errorf("Content-Encoding = %q; client without Accept-Encoding should get plain", got)
}
})
}

View file

@ -4,6 +4,8 @@ go 1.24
require ( require (
github.com/fsnotify/fsnotify v1.9.0 github.com/fsnotify/fsnotify v1.9.0
github.com/klauspost/compress v1.18.6
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )

View file

@ -1,8 +1,12 @@
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,6 +1,11 @@
package apps package apps
import _ "embed" import (
"crypto/sha256"
"encoding/hex"
_ "embed"
"sync"
)
// Embedded fallback: the five tool HTMLs from the time the binary was // Embedded fallback: the five tool HTMLs from the time the binary was
// built. Used as a last-resort served-bytes when (cache miss) AND // built. Used as a last-resort served-bytes when (cache miss) AND
@ -56,3 +61,29 @@ func EmbeddedBytes(app string) []byte {
} }
return b return b
} }
// EmbeddedETag returns a strong ETag (sha256-hex prefix, 32 chars) for the
// app's embedded bytes. Computed lazily on first call per-app and memoized
// — the embedded slot is fixed for the binary's lifetime, so the ETag
// changes only when the binary is redeployed. Empty slot returns "".
//
// Used by apps.Server.serveEmbedded to issue conditional-GET-friendly
// responses: with this ETag + Cache-Control: max-age=0, must-revalidate,
// every page load revalidates and gets a 304 unless the binary has been
// updated. Saves re-transmitting 50920 KB tool HTMLs on every reload.
func EmbeddedETag(app string) string {
if v, ok := etagCacheByApp.Load(app); ok {
return v.(string)
}
body := EmbeddedBytes(app)
if body == nil {
return ""
}
sum := sha256.Sum256(body)
etag := hex.EncodeToString(sum[:])[:32]
etagCacheByApp.Store(app, etag)
return etag
}
// etagCacheByApp memoizes EmbeddedETag results keyed by app name.
var etagCacheByApp sync.Map

File diff suppressed because one or more lines are too long

View file

@ -218,6 +218,24 @@ a:hover {
background: var(--bg-secondary); background: var(--bg-secondary);
} }
/* Subdued / de-emphasized variant.
Used on the "Add 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. */
.btn.btn--subtle {
background: transparent;
color: var(--text-muted);
border-color: var(--border);
box-shadow: none;
font-weight: normal;
}
.btn.btn--subtle:not(:disabled):hover {
color: var(--text);
background: var(--bg-secondary);
}
.btn-success { .btn-success {
background: var(--success); background: var(--success);
color: var(--text-light); color: var(--text-light);
@ -722,22 +740,6 @@ body {
outline-offset: -1px; outline-offset: -1px;
} }
/* Subtle button variant — used for "Select Directory" when the page
is server-backed (the user usually doesn't need to switch to a
local folder; we keep the option visible but quiet). */
.btn.btn--subtle {
background: transparent;
color: var(--text-muted);
border-color: var(--border);
box-shadow: none;
font-weight: normal;
}
.btn.btn--subtle:hover {
color: var(--text);
background: var(--bg-hover, rgba(0,0,0,0.04));
}
/* Table — folders + files in a tree */ /* Table — folders + files in a tree */
.browse-table { .browse-table {
@ -894,10 +896,10 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Browse</span> <span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp">v0.0.16-beta · 2026-05-04 · 582db6d</span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · 8df0def</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Select Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing" style="font-size:1.1rem;"></button>
</div> </div>
<div class="header-right"> <div class="header-right">
<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>
@ -914,7 +916,7 @@ body {
<ul> <ul>
<li><b>Online</b> — when this page is served by zddc-server, the <li><b>Online</b> — when this page is served by zddc-server, the
listing for the current directory loads automatically.</li> listing for the current directory loads automatically.</li>
<li><b>Local</b> — click <i>Select Directory</i> to pick any folder <li><b>Local</b> — click <i>Add Local Directory</i> to pick any folder
on your computer (Chromium-based browsers).</li> on your computer (Chromium-based browsers).</li>
</ul> </ul>
<p>Once loaded: click a folder to expand it, <b>shift-click</b> <p>Once loaded: click a folder to expand it, <b>shift-click</b>
@ -975,6 +977,87 @@ body {
<div id="statusBar" class="status-bar"></div> <div id="statusBar" class="status-bar"></div>
<!-- Help Panel -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header">
<h2 id="help-panel-title" class="help-panel__title">Help — ZDDC Browse</h2>
<button type="button" class="help-panel__close" id="help-panel-close" aria-label="Close">&times;</button>
</div>
<div class="help-panel__body">
<h3>What is Browse?</h3>
<p>Browse is a directory listing for ZDDC archives — and any directory. It works in two modes:</p>
<dl>
<dt>Online</dt>
<dd>When the page is served by zddc-server, the listing for the current
URL directory loads automatically. Breadcrumbs link to ancestor folders.</dd>
<dt>Local</dt>
<dd>Click <strong>Add Local Directory</strong> to pick any folder on your
computer. Local mode requires a Chromium-based browser (File System
Access API).</dd>
</dl>
<h3>Tree navigation</h3>
<dl>
<dt>Click a folder</dt>
<dd>Toggle expand/collapse on that folder.</dd>
<dt>Shift-click a folder</dt>
<dd>Recursive expand or collapse — applies to the whole subtree.</dd>
<dt>Click a file</dt>
<dd>Open in the preview popup. Modifier-click (Ctrl/Cmd) or middle-click
opens in a new tab.</dd>
<dt>ZIP files</dt>
<dd>Behave as folders — click to inspect contents inline. JSZip is
bundled, so this works offline.</dd>
<dt>Column headers</dt>
<dd>Click to sort; click again to reverse.</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>
</dl>
<h3>Filter rows</h3>
<p>Two filter rows live in the table header:</p>
<dl>
<dt>📄 file row</dt>
<dd>Filter by file name (left input) and/or extension (Type input).
File matches stay visible together with their ancestor folders, so
the path to each hit is always shown.</dd>
<dt>📁 folder row</dt>
<dd>Filter by folder name. Matching folders show with their entire
subtree. Combined with file filter: file must also be inside a
matching folder's subtree (intersection).</dd>
</dl>
<p>Filter syntax (shared across all ZDDC tools):</p>
<dl>
<dt><code>term</code></dt>
<dd>Contains "term" (case-insensitive)</dd>
<dt><code>!term</code></dt>
<dd>Does not contain</dd>
<dt><code>^term</code></dt>
<dd>Starts with</dd>
<dt><code>term$</code></dt>
<dd>Ends with</dd>
<dt><code>a b</code></dt>
<dd>Both (AND)</dd>
<dt><code>a | b</code></dt>
<dd>Either (OR)</dd>
<dt><code>el.*spc</code></dt>
<dd>Regex — any-char + any-sequence</dd>
</dl>
<h3>Header buttons</h3>
<dl>
<dt>Add 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>
<dd>Re-load the current directory listing.</dd>
<dt>◐ Theme</dt>
<dd>Cycle auto / light / dark.</dd>
</dl>
</div>
</aside>
<script> <script>
/*! /*!
@ -1616,6 +1699,53 @@ https://github.com/nodeca/pako/blob/main/LICENSE
} }
}()); }());
/**
* ZDDC shared help panel — open/close logic.
* Works with all four tools regardless of their module pattern.
* Expects: #help-btn, #help-panel, #help-panel-close in the DOM.
*/
(function () {
'use strict';
function init() {
var helpBtn = document.getElementById('help-btn');
var panel = document.getElementById('help-panel');
var closeBtn = document.getElementById('help-panel-close');
if (!helpBtn || !panel) { return; }
function isOpen() { return !panel.hidden; }
function openPanel() {
panel.hidden = false;
document.body.classList.add('help-open');
}
function closePanel() {
panel.hidden = true;
document.body.classList.remove('help-open');
}
helpBtn.addEventListener('click', function () {
if (isOpen()) { closePanel(); } else { openPanel(); }
});
if (closeBtn) {
closeBtn.addEventListener('click', closePanel);
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && isOpen()) { closePanel(); }
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
}());
/** /**
* ZDDC — shared preview helpers * ZDDC — shared preview helpers
* *

File diff suppressed because one or more lines are too long

View file

@ -218,6 +218,24 @@ a:hover {
background: var(--bg-secondary); background: var(--bg-secondary);
} }
/* Subdued / de-emphasized variant.
Used on the "Add 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. */
.btn.btn--subtle {
background: transparent;
color: var(--text-muted);
border-color: var(--border);
box-shadow: none;
font-weight: normal;
}
.btn.btn--subtle:not(:disabled):hover {
color: var(--text);
background: var(--bg-secondary);
}
.btn-success { .btn-success {
background: var(--success); background: var(--success);
color: var(--text-light); color: var(--text-light);
@ -867,11 +885,12 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC</span> <span class="app-header__title">ZDDC</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · 582db6d</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · 8df0def</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
<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>
</div> </div>
</header> </header>
@ -936,6 +955,52 @@ body {
</div> </div>
</main> </main>
<!-- Help Panel -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header">
<h2 id="help-panel-title" class="help-panel__title">Help — ZDDC</h2>
<button type="button" class="help-panel__close" id="help-panel-close" aria-label="Close">&times;</button>
</div>
<div class="help-panel__body">
<h3>What is this page?</h3>
<p>This is the ZDDC archive landing page — a project picker. It lists every
project (top-level directory) you have access to on this server, plus any
<strong>groups</strong> you've defined for opening multiple projects at once.</p>
<h3>Projects</h3>
<p>Click a project to open it. The project's archive view (list of folders +
files, with all the standard ZDDC tools available inside) loads in the same
tab. Use back/forward to navigate between projects and the picker.</p>
<h3>Groups</h3>
<p>A group bundles a set of projects you commonly open together. Click
<strong>+ New group</strong>, give it a name, click projects to include
them, then save. Opening a group opens all its projects in one go.</p>
<dl>
<dt>Save group</dt>
<dd>Persist the selection as a named group on this server (visible to
other users with access to the same projects).</dd>
<dt>Open selected</dt>
<dd>Open the currently-checked projects without saving as a group.</dd>
<dt>Cancel</dt>
<dd>Exit select mode without saving.</dd>
</dl>
<h3>Access</h3>
<p>Projects and groups are filtered by your account's permissions.
If a URL references a project you don't have access to, a warning banner
appears and the inaccessible items are skipped silently.</p>
<h3>Header buttons</h3>
<dl>
<dt>◐ Theme</dt>
<dd>Cycle auto / light / dark.</dd>
<dt>? Help</dt>
<dd>This panel. Press <kbd>Esc</kbd> to close.</dd>
</dl>
</div>
</aside>
<script> <script>
/** /**
* ZDDC — shared naming convention library * ZDDC — shared naming convention library
@ -1564,6 +1629,53 @@ body {
} }
}()); }());
/**
* ZDDC shared help panel — open/close logic.
* Works with all four tools regardless of their module pattern.
* Expects: #help-btn, #help-panel, #help-panel-close in the DOM.
*/
(function () {
'use strict';
function init() {
var helpBtn = document.getElementById('help-btn');
var panel = document.getElementById('help-panel');
var closeBtn = document.getElementById('help-panel-close');
if (!helpBtn || !panel) { return; }
function isOpen() { return !panel.hidden; }
function openPanel() {
panel.hidden = false;
document.body.classList.add('help-open');
}
function closePanel() {
panel.hidden = true;
document.body.classList.remove('help-open');
}
helpBtn.addEventListener('click', function () {
if (isOpen()) { closePanel(); } else { openPanel(); }
});
if (closeBtn) {
closeBtn.addEventListener('click', closePanel);
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && isOpen()) { closePanel(); }
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
}());
(function() { (function() {
'use strict'; 'use strict';
// ZDDC landing page — project picker. // ZDDC landing page — project picker.

View file

@ -438,6 +438,24 @@ a:hover {
background: var(--bg-secondary); background: var(--bg-secondary);
} }
/* Subdued / de-emphasized variant.
Used on the "Add 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. */
.btn.btn--subtle {
background: transparent;
color: var(--text-muted);
border-color: var(--border);
box-shadow: none;
font-weight: normal;
}
.btn.btn--subtle:not(:disabled):hover {
color: var(--text);
background: var(--bg-secondary);
}
.btn-success { .btn-success {
background: var(--success); background: var(--success);
color: var(--text-light); color: var(--text-light);
@ -1774,9 +1792,10 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Markdown</span> <span class="app-header__title">ZDDC Markdown</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · 582db6d</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · 8df0def</span></span>
</div> </div>
<button id="select-directory" class="btn btn-primary" title="Select a Directory">Select Directory</button> <button id="addDirectoryBtn" class="btn btn-primary" title="Add a local directory">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh directory" aria-label="Refresh" style="font-size:1.1rem;"></button>
</div> </div>
<div class="header-right"> <div class="header-right">
<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>
@ -1792,7 +1811,6 @@ body.help-open .app-header {
<span>Files</span> <span>Files</span>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button id="new-file-root" class="btn btn-secondary btn-sm hidden" title="New file in root directory">+</button> <button id="new-file-root" class="btn btn-secondary btn-sm hidden" title="New file in root directory">+</button>
<button id="refresh-directory" class="btn btn-secondary btn-sm hidden" title="Refresh directory"></button>
</div> </div>
</div> </div>
</div> </div>
@ -1806,7 +1824,7 @@ body.help-open .app-header {
<div class="pane content-pane flex-1 relative flex flex-col bg-white dark:bg-gray-900 overflow-hidden" id="main-content"> <div class="pane content-pane flex-1 relative flex flex-col bg-white dark:bg-gray-900 overflow-hidden" id="main-content">
<div id="welcome-screen" class="welcome-screen hidden flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400 text-center p-6"> <div id="welcome-screen" class="welcome-screen hidden flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400 text-center p-6">
<p id="welcome-hint" class="text-sm">Click <strong>Scratchpad</strong> in the file list to start editing,<br>or <strong>Select Directory</strong> to work with files.</p> <p id="welcome-hint" class="text-sm">Click <strong>Scratchpad</strong> in the file list to start editing,<br>or <strong>Add Local Directory</strong> to work with files.</p>
<p id="welcome-firefox" class="text-sm text-amber-600 hidden mt-2">Your browser doesn't support the File System API.<br>Use <strong>Scratchpad</strong> to edit markdown and download as a file.</p> <p id="welcome-firefox" class="text-sm text-amber-600 hidden mt-2">Your browser doesn't support the File System API.<br>Use <strong>Scratchpad</strong> to edit markdown and download as a file.</p>
</div> </div>
@ -1850,7 +1868,7 @@ body.help-open .app-header {
<h3>Getting Started</h3> <h3>Getting Started</h3>
<ol> <ol>
<li>Click <strong>Select Directory</strong> to open a folder. The file tree on the left will populate with all files in that folder.</li> <li>Click <strong>Add Local Directory</strong> to open a folder. The file tree on the left will populate with all files in that folder.</li>
<li>Click any Markdown file (<code>.md</code>) in the tree to open it in the editor.</li> <li>Click any Markdown file (<code>.md</code>) in the tree to open it in the editor.</li>
<li>Use the <strong>Scratchpad</strong> entry (always visible at the top of the tree) for temporary notes without saving to disk.</li> <li>Use the <strong>Scratchpad</strong> entry (always visible at the top of the tree) for temporary notes without saving to disk.</li>
</ol> </ol>
@ -2954,7 +2972,7 @@ const SCRATCHPAD_WELCOME = [
'Use this **Scratchpad** for quick notes. Download it any time with the ⬇', 'Use this **Scratchpad** for quick notes. Download it any time with the ⬇',
'button on the Scratchpad row in the file list.', 'button on the Scratchpad row in the file list.',
'', '',
'Click **Select Directory** above to open a folder of Markdown files,', 'Click **Add Local Directory** above to open a folder of Markdown files,',
'or just start typing here.', 'or just start typing here.',
'', '',
].join('\n'); ].join('\n');
@ -3754,12 +3772,19 @@ async function openDirectory() {
* @param {string} directoryName - Name of the selected directory * @param {string} directoryName - Name of the selected directory
*/ */
function updateDirectoryStatus(directoryName) { function updateDirectoryStatus(directoryName) {
const selectDirectoryBtn = document.getElementById('select-directory'); // Standardized header pattern (across all ZDDC tools): the button
// keeps the label "Add Local Directory"; de-emphasize it once a
// directory is loaded (the user can still click to pick another)
// by applying the shared btn--subtle variant. The directory name
// is shown in the file-nav pane, not on the button.
const selectDirectoryBtn = document.getElementById('addDirectoryBtn');
if (selectDirectoryBtn) { if (selectDirectoryBtn) {
selectDirectoryBtn.textContent = `Directory: ${directoryName}`; selectDirectoryBtn.classList.remove('btn-primary');
selectDirectoryBtn.classList.add('btn--subtle');
selectDirectoryBtn.title = `Loaded: ${directoryName} — click to switch`;
} }
const refreshBtn = document.getElementById('refresh-directory'); const refreshBtn = document.getElementById('refreshHeaderBtn');
if (refreshBtn) { if (refreshBtn) {
refreshBtn.classList.remove('hidden'); refreshBtn.classList.remove('hidden');
} }
@ -4260,8 +4285,8 @@ async function loadServerDirectory() {
// Only enter server-source mode if the host actually serves JSON directory // Only enter server-source mode if the host actually serves JSON directory
// listings (zddc-server / Caddy). On a plain static host the probe fails // listings (zddc-server / Caddy). On a plain static host the probe fails
// and we must leave "Select Directory" visible so the user can still load // and we must leave "Add Local Directory" visible so the user can still
// local files. // load local files.
try { try {
const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' }); const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' });
if (!resp.ok) return; if (!resp.ok) return;
@ -4286,12 +4311,18 @@ async function loadServerDirectory() {
entries: {}, entries: {},
}; };
// Surface refresh, hide write-only controls. "Select Directory" stays // Surface refresh, hide write-only controls. "Add Local Directory"
// visible so the user can switch to a local folder at any time. // stays visible (de-emphasized via btn--subtle) so the user can
const refreshBtn = document.getElementById('refresh-directory'); // switch to a local folder at any time.
const refreshBtn = document.getElementById('refreshHeaderBtn');
if (refreshBtn) refreshBtn.classList.remove('hidden'); if (refreshBtn) refreshBtn.classList.remove('hidden');
const newFileRootBtn = document.getElementById('new-file-root'); const newFileRootBtn = document.getElementById('new-file-root');
if (newFileRootBtn) newFileRootBtn.classList.add('hidden'); if (newFileRootBtn) newFileRootBtn.classList.add('hidden');
const addDirBtn = document.getElementById('addDirectoryBtn');
if (addDirBtn) {
addDirBtn.classList.remove('btn-primary');
addDirBtn.classList.add('btn--subtle');
}
const stats = await readServerDirectory(baseUrl, fileTree, 0); const stats = await readServerDirectory(baseUrl, fileTree, 0);
renderFileTree(); renderFileTree();
@ -6028,14 +6059,14 @@ function initializeFileNavResizer() {
* Set up all event listeners for the application * Set up all event listeners for the application
*/ */
function setupEventListeners() { function setupEventListeners() {
// Select directory button // Add Local Directory button (was id="select-directory" / "refresh-directory")
const selectDirectoryBtn = document.getElementById('select-directory'); const selectDirectoryBtn = document.getElementById('addDirectoryBtn');
if (selectDirectoryBtn) { if (selectDirectoryBtn) {
selectDirectoryBtn.addEventListener('click', openDirectory); selectDirectoryBtn.addEventListener('click', openDirectory);
} }
// Refresh directory button // Refresh button (now in header, was in file-nav pane)
const refreshDirectoryBtn = document.getElementById('refresh-directory'); const refreshDirectoryBtn = document.getElementById('refreshHeaderBtn');
if (refreshDirectoryBtn) { if (refreshDirectoryBtn) {
refreshDirectoryBtn.addEventListener('click', refreshDirectory); refreshDirectoryBtn.addEventListener('click', refreshDirectory);
} }
@ -6136,7 +6167,7 @@ document.addEventListener('DOMContentLoaded', function () {
* Initialize UI based on File System API availability * Initialize UI based on File System API availability
*/ */
function initializeApiAvailability() { function initializeApiAvailability() {
const selectDirectoryBtn = document.getElementById('select-directory'); const selectDirectoryBtn = document.getElementById('addDirectoryBtn');
const welcomeHint = document.getElementById('welcome-hint'); const welcomeHint = document.getElementById('welcome-hint');
const welcomeFirefox = document.getElementById('welcome-firefox'); const welcomeFirefox = document.getElementById('welcome-firefox');

File diff suppressed because one or more lines are too long

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line. # Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.16-beta · 2026-05-04 · 582db6d archive=v0.0.16-beta · 2026-05-04 · 8df0def
transmittal=v0.0.16-beta · 2026-05-04 · 582db6d transmittal=v0.0.16-beta · 2026-05-04 · 8df0def
classifier=v0.0.16-beta · 2026-05-04 · 582db6d classifier=v0.0.16-beta · 2026-05-04 · 8df0def
mdedit=v0.0.16-beta · 2026-05-04 · 582db6d mdedit=v0.0.16-beta · 2026-05-04 · 8df0def
landing=v0.0.16-beta · 2026-05-04 · 582db6d landing=v0.0.16-beta · 2026-05-04 · 8df0def
form=v0.0.16-beta · 2026-05-04 · 582db6d form=v0.0.16-beta · 2026-05-04 · 8df0def
browse=v0.0.16-beta · 2026-05-04 · 582db6d browse=v0.0.16-beta · 2026-05-04 · 8df0def

View file

@ -1,13 +1,13 @@
package apps package apps
import ( import (
"bytes" "crypto/sha256"
"encoding/hex"
"errors" "errors"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
) )
@ -92,14 +92,14 @@ func (s *Server) Serve(w http.ResponseWriter, r *http.Request, app string, chain
// Malformed `.zddc` spec — operator's fault. Log and serve embedded. // Malformed `.zddc` spec — operator's fault. Log and serve embedded.
s.Fetcher.Logger.Warn("apps.Resolve failed; serving embedded", s.Fetcher.Logger.Warn("apps.Resolve failed; serving embedded",
"app", app, "request_dir", requestDir, "err", err) "app", app, "request_dir", requestDir, "err", err)
s.serveEmbedded(w, app, err) s.serveEmbedded(w, r, app, err)
return return
} }
if !hasOverride { if !hasOverride {
// No `.zddc apps:` entry anywhere up the chain and no `?v=` either → // No `.zddc apps:` entry anywhere up the chain and no `?v=` either →
// embedded is the authoritative default. // embedded is the authoritative default.
s.serveEmbedded(w, app, nil) s.serveEmbedded(w, r, app, nil)
return return
} }
@ -138,7 +138,7 @@ func (s *Server) Serve(w http.ResponseWriter, r *http.Request, app string, chain
s.Fetcher.Logger.Warn("path source unreadable; serving embedded", s.Fetcher.Logger.Warn("path source unreadable; serving embedded",
"app", app, "path", src.Path, "err", err) "app", app, "path", src.Path, "err", err)
} }
s.serveEmbedded(w, app, err) s.serveEmbedded(w, r, app, err)
return return
} }
s.serveBody(w, r, body, "path:"+src.Path) s.serveBody(w, r, body, "path:"+src.Path)
@ -149,7 +149,7 @@ func (s *Server) Serve(w http.ResponseWriter, r *http.Request, app string, chain
body, err := s.Fetcher.Fetch(r.Context(), src.URL) body, err := s.Fetcher.Fetch(r.Context(), src.URL)
if err != nil { if err != nil {
s.Fetcher.LogEmbeddedFallback(app, src.URL, err) s.Fetcher.LogEmbeddedFallback(app, src.URL, err)
s.serveEmbedded(w, app, err) s.serveEmbedded(w, r, app, err)
return return
} }
sourceTag := "fetch:" + src.URL sourceTag := "fetch:" + src.URL
@ -161,14 +161,39 @@ func (s *Server) Serve(w http.ResponseWriter, r *http.Request, app string, chain
s.serveBody(w, r, body, sourceTag) s.serveBody(w, r, body, sourceTag)
} }
func (s *Server) serveBody(w http.ResponseWriter, r *http.Request, body []byte, sourceHeader string) { // writeWithETag writes body with a strong ETag derived from `etag`, the
w.Header().Set("Content-Type", "text/html; charset=utf-8") // cache-friendly headers, and short-circuits to 304 Not Modified when the
// client's `If-None-Match` matches. `max-age=0, must-revalidate` means the
// browser revalidates on every load — and the matching ETag returns 304
// with empty body, so the steady-state cost of a reload is ~200 bytes
// instead of the full HTML payload (50920 KB depending on the tool).
func writeWithETag(w http.ResponseWriter, r *http.Request, body []byte, etag, contentType, sourceHeader string) {
quotedTag := `"` + etag + `"`
w.Header().Set("ETag", quotedTag)
w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
w.Header().Set("Content-Type", contentType)
w.Header().Set("X-ZDDC-Source", sourceHeader) w.Header().Set("X-ZDDC-Source", sourceHeader)
w.Header().Set("Cache-Control", "public, max-age=3600, must-revalidate")
http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(body)) if match := r.Header.Get("If-None-Match"); match != "" && match == quotedTag {
w.WriteHeader(http.StatusNotModified)
return
}
_, _ = w.Write(body)
} }
func (s *Server) serveEmbedded(w http.ResponseWriter, app string, _ error) { // bodyETag computes a stable 32-hex-char ETag for an arbitrary body. Used
// for the URL/path-sourced response path (the bytes vary per cache-fetch
// or per file read, so memoizing per-app would be wrong).
func bodyETag(body []byte) string {
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:])[:32]
}
func (s *Server) serveBody(w http.ResponseWriter, r *http.Request, body []byte, sourceHeader string) {
writeWithETag(w, r, body, bodyETag(body), "text/html; charset=utf-8", sourceHeader)
}
func (s *Server) serveEmbedded(w http.ResponseWriter, r *http.Request, app string, _ error) {
body := EmbeddedBytes(app) body := EmbeddedBytes(app)
if len(body) == 0 { if len(body) == 0 {
w.Header().Set("Retry-After", "60") w.Header().Set("Retry-After", "60")
@ -179,8 +204,7 @@ func (s *Server) serveEmbedded(w http.ResponseWriter, app string, _ error) {
http.StatusServiceUnavailable) http.StatusServiceUnavailable)
return return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") writeWithETag(w, r, body, EmbeddedETag(app),
w.Header().Set("X-ZDDC-Source", "embedded:"+app+"@"+s.BuildVer) "text/html; charset=utf-8",
w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate") "embedded:"+app+"@"+s.BuildVer)
_, _ = w.Write(body)
} }

View file

@ -290,3 +290,88 @@ func TestServer_VParam_FullURLForm(t *testing.T) {
t.Errorf("body=%q", rec.Body.String()) t.Errorf("body=%q", rec.Body.String())
} }
} }
// TestServer_Embedded_ConditionalGET verifies the ETag/If-None-Match dance
// for the embedded fallback path: a fresh GET returns 200 with an ETag,
// and a follow-up with a matching If-None-Match returns 304 + empty body.
// This is the cache-friendliness fix that lets a browser revalidate
// against zddc-server's embedded HTML without re-transferring the bytes.
func TestServer_Embedded_ConditionalGET(t *testing.T) {
srv, _, root := newTestServer(t, []byte("upstream"))
saved := embeddedArchive
embeddedArchive = []byte("EMBEDDED archive bytes for ETag test")
defer func() {
embeddedArchive = saved
etagCacheByApp.Delete("archive") // reset memoization for sibling tests
}()
etagCacheByApp.Delete("archive") // ensure clean state for THIS test
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
// First request: full body + ETag header.
rec1 := httptest.NewRecorder()
srv.Serve(rec1, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
if rec1.Code != http.StatusOK {
t.Fatalf("first GET: status=%d body=%s", rec1.Code, rec1.Body.String())
}
etag := rec1.Header().Get("ETag")
if etag == "" {
t.Fatalf("first GET: missing ETag header")
}
if cc := rec1.Header().Get("Cache-Control"); !strings.Contains(cc, "max-age=0") || !strings.Contains(cc, "must-revalidate") {
t.Errorf("first GET: Cache-Control=%q (want max-age=0 + must-revalidate)", cc)
}
if !strings.Contains(rec1.Body.String(), "EMBEDDED archive bytes") {
t.Errorf("first GET: body=%q", rec1.Body.String())
}
// Second request with matching If-None-Match: 304, empty body.
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
req2.Header.Set("If-None-Match", etag)
srv.Serve(rec2, req2, "archive", chain, root)
if rec2.Code != http.StatusNotModified {
t.Fatalf("If-None-Match match: status=%d (want 304)", rec2.Code)
}
if rec2.Body.Len() != 0 {
t.Errorf("304 response should have empty body; got %d bytes", rec2.Body.Len())
}
// Third request with stale If-None-Match: 200, full body.
rec3 := httptest.NewRecorder()
req3 := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
req3.Header.Set("If-None-Match", `"deadbeef"`)
srv.Serve(rec3, req3, "archive", chain, root)
if rec3.Code != http.StatusOK {
t.Errorf("stale If-None-Match: status=%d (want 200)", rec3.Code)
}
if rec3.Body.Len() == 0 {
t.Errorf("stale If-None-Match: empty body; want full")
}
}
// TestEmbeddedETag_Stable asserts EmbeddedETag is deterministic and
// content-addressed: same bytes → same ETag, different bytes → different.
func TestEmbeddedETag_Stable(t *testing.T) {
saved := embeddedArchive
defer func() {
embeddedArchive = saved
etagCacheByApp.Delete("archive")
}()
embeddedArchive = []byte("alpha")
etagCacheByApp.Delete("archive")
a1 := EmbeddedETag("archive")
a2 := EmbeddedETag("archive")
if a1 == "" || a1 != a2 {
t.Errorf("EmbeddedETag should be stable for same bytes; got %q vs %q", a1, a2)
}
embeddedArchive = []byte("beta")
etagCacheByApp.Delete("archive")
b := EmbeddedETag("archive")
if b == a1 {
t.Errorf("EmbeddedETag should differ for different bytes; both %q", b)
}
}

View file

@ -23,6 +23,7 @@ type Config struct {
IndexPath string // --index-path / ZDDC_INDEX_PATH — virtual archive prefix (default .archive) IndexPath string // --index-path / ZDDC_INDEX_PATH — virtual archive prefix (default .archive)
EmailHeader string // --email-header / ZDDC_EMAIL_HEADER — auth header name (default X-Auth-Request-Email) EmailHeader string // --email-header / ZDDC_EMAIL_HEADER — auth header name (default X-Auth-Request-Email)
CORSOrigins []string // --cors-origin / ZDDC_CORS_ORIGIN — comma-separated allowlist; default https://zddc.varasys.io; empty disables CORSOrigins []string // --cors-origin / ZDDC_CORS_ORIGIN — comma-separated allowlist; default https://zddc.varasys.io; empty disables
AccessLog string // --access-log / ZDDC_ACCESS_LOG — file path for tee'd JSON access log; empty = stderr only
} }
// ErrHelpRequested is returned by Load when --help is passed; the caller // ErrHelpRequested is returned by Load when --help is passed; the caller
@ -73,6 +74,8 @@ func Load(args []string) (Config, error) {
"Comma-separated CORS allowlist. Empty = CORS disabled. Default: ZDDC_CORS_ORIGIN or https://zddc.varasys.io.") "Comma-separated CORS allowlist. Empty = CORS disabled. Default: ZDDC_CORS_ORIGIN or https://zddc.varasys.io.")
insecureDirectFlag := fs.Bool("insecure-direct", os.Getenv("ZDDC_INSECURE_DIRECT") == "1", insecureDirectFlag := fs.Bool("insecure-direct", os.Getenv("ZDDC_INSECURE_DIRECT") == "1",
"Allow plain HTTP on non-loopback addresses (only safe behind an authenticating proxy).") "Allow plain HTTP on non-loopback addresses (only safe behind an authenticating proxy).")
accessLogFlag := fs.String("access-log", os.Getenv("ZDDC_ACCESS_LOG"),
"Tee structured access logs to this file (JSON, size-rotated). Empty = stderr only.")
helpFlag := fs.Bool("help", false, "Print this help and exit.") helpFlag := fs.Bool("help", false, "Print this help and exit.")
versionFlag := fs.Bool("version", false, "Print version info and exit.") versionFlag := fs.Bool("version", false, "Print version info and exit.")
@ -113,6 +116,7 @@ func Load(args []string) (Config, error) {
IndexPath: *indexPathFlag, IndexPath: *indexPathFlag,
EmailHeader: *emailHeaderFlag, EmailHeader: *emailHeaderFlag,
CORSOrigins: resolveCORS(corsFlagSet, *corsOriginFlag), CORSOrigins: resolveCORS(corsFlagSet, *corsOriginFlag),
AccessLog: *accessLogFlag,
} }
// Default Root to the current working directory. // Default Root to the current working directory.
@ -182,6 +186,7 @@ func Usage(w io.Writer) {
fs.String("email-header", "X-Auth-Request-Email", "HTTP header carrying the authenticated user's email.") fs.String("email-header", "X-Auth-Request-Email", "HTTP header carrying the authenticated user's email.")
fs.String("cors-origin", "", "Comma-separated CORS allowlist. Empty = CORS disabled.") fs.String("cors-origin", "", "Comma-separated CORS allowlist. Empty = CORS disabled.")
fs.Bool("insecure-direct", false, "Allow plain HTTP on non-loopback addresses.") fs.Bool("insecure-direct", false, "Allow plain HTTP on non-loopback addresses.")
fs.String("access-log", "", "Tee structured access logs to this file (JSON, size-rotated). Empty = stderr only.")
fs.Bool("help", false, "Print this help and exit.") fs.Bool("help", false, "Print this help and exit.")
fs.Bool("version", false, "Print version info and exit.") fs.Bool("version", false, "Print version info and exit.")
fs.PrintDefaults() fs.PrintDefaults()

View file

@ -40,7 +40,10 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
dirPath := strings.TrimPrefix(urlPath, "/") dirPath := strings.TrimPrefix(urlPath, "/")
dirPath = strings.TrimSuffix(dirPath, "/") dirPath = strings.TrimSuffix(dirPath, "/")
// ACL check on this directory itself // ACL check on this directory itself.
// Bypassed at the root path: the landing page is a public project
// picker. Per-project filtering inside fs.ListDirectory still hides
// directories the caller can't reach.
absDir, ok := safeJoin(cfg.Root, dirPath) absDir, ok := safeJoin(cfg.Root, dirPath)
if !ok { if !ok {
http.Error(w, "Not Found", http.StatusNotFound) http.Error(w, "Not Found", http.StatusNotFound)
@ -50,7 +53,8 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
slog.Warn("ACL policy error", "path", absDir, "err", err) slog.Warn("ACL policy error", "path", absDir, "err", err)
} }
if !zddc.AllowedWithChain(chain, email) { isRoot := dirPath == ""
if !isRoot && !zddc.AllowedWithChain(chain, email) {
http.Error(w, "Forbidden", http.StatusForbidden) http.Error(w, "Forbidden", http.StatusForbidden)
return return
} }
@ -114,12 +118,19 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
} }
return return
} }
// ETag + max-age=0 + must-revalidate: every request re-validates and
// gets a 304 unless the binary has been redeployed (the ETag is a
// content hash, computed once at startup and memoized in apps.embed).
// Saves re-transmitting ~230 KB of browse.html on every page load
// while still picking up redeploys immediately.
etag := `"` + apps.EmbeddedETag("browse") + `"`
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-ZDDC-Source", "embedded:browse") w.Header().Set("X-ZDDC-Source", "embedded:browse")
// no-cache here too — browse.html has session-tied content (the if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
// directory listing it loads via fetch), and we want browser to w.WriteHeader(http.StatusNotModified)
// always re-validate so deployed-binary updates appear immediately return
// rather than after a 5-minute cache window. }
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(body) _, _ = w.Write(body)
} }

View file

@ -0,0 +1,128 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
)
// TestServeDirectoryRootIsPublic asserts that the landing page (the root
// directory listing) is reachable by anyone, including anonymous callers
// whose email is empty AND whose access would be denied by a restrictive
// root .zddc. Per-project filtering inside fs.ListDirectory still hides
// directories the caller can't reach (separately verified below).
//
// The behavior was changed when "Everyone needs to have access to the
// landing page" became the explicit requirement; this test is the
// regression guard.
func TestServeDirectoryRootIsPublic(t *testing.T) {
root := t.TempDir()
// Restrictive root .zddc — only admin@example.com is allowed by ACL,
// 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"),
0o644); err != nil {
t.Fatalf("write root .zddc: %v", err)
}
// One project visible to everyone, one only to admin.
for _, name := range []string{"PublicProj", "PrivateProj"} {
if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", name, err)
}
}
if err := os.WriteFile(filepath.Join(root, "PublicProj", ".zddc"),
[]byte("acl:\n allow: [\"*\"]\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 {
t.Fatalf("write PrivateProj .zddc: %v", err)
}
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
t.Run("anonymous JSON GET / does not 403", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Accept", "application/json")
// Anonymous: empty email in context.
req = req.WithContext(context.WithValue(req.Context(), EmailKey, ""))
rec := httptest.NewRecorder()
ServeDirectory(cfg, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (root is public); body = %s",
rec.Code, rec.Body.String())
}
})
t.Run("anonymous JSON GET / hides private projects", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Accept", "application/json")
req = req.WithContext(context.WithValue(req.Context(), EmailKey, ""))
rec := httptest.NewRecorder()
ServeDirectory(cfg, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
}
var entries []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &entries); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, rec.Body.String())
}
names := map[string]bool{}
for _, e := range entries {
if n, ok := e["name"].(string); ok {
names[n] = true
}
}
if !names["PublicProj/"] {
t.Errorf("PublicProj missing from anonymous listing: %v", names)
}
if names["PrivateProj/"] {
t.Errorf("PrivateProj leaked to anonymous listing: %v", names)
}
})
t.Run("admin JSON GET / sees both projects", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Accept", "application/json")
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "admin@example.com"))
rec := httptest.NewRecorder()
ServeDirectory(cfg, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("admin status = %d, want 200", rec.Code)
}
var entries []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &entries); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(entries) != 2 {
t.Errorf("admin should see both projects; got %d", len(entries))
}
})
t.Run("anonymous still gets 403 on private subdirectory", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/PrivateProj/", nil)
req.Header.Set("Accept", "application/json")
req = req.WithContext(context.WithValue(req.Context(), EmailKey, ""))
rec := httptest.NewRecorder()
ServeDirectory(cfg, rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("private subdir for anonymous: status = %d, want 403", rec.Code)
}
})
}

View file

@ -218,6 +218,24 @@ a:hover {
background: var(--bg-secondary); background: var(--bg-secondary);
} }
/* Subdued / de-emphasized variant.
Used on the "Add 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. */
.btn.btn--subtle {
background: transparent;
color: var(--text-muted);
border-color: var(--border);
box-shadow: none;
font-weight: normal;
}
.btn.btn--subtle:not(:disabled):hover {
color: var(--text);
background: var(--bg-secondary);
}
.btn-success { .btn-success {
background: var(--success); background: var(--success);
color: var(--text-light); color: var(--text-light);
@ -723,11 +741,12 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="form-title">ZDDC Form</span> <span class="app-header__title" id="form-title">ZDDC Form</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · 582db6d</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · 8df0def</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
<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>
</div> </div>
</header> </header>
@ -739,6 +758,51 @@ body.help-open .app-header {
</div> </div>
</main> </main>
<!-- Help Panel -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header">
<h2 id="help-panel-title" class="help-panel__title">Help — ZDDC Form</h2>
<button type="button" class="help-panel__close" id="help-panel-close" aria-label="Close">&times;</button>
</div>
<div class="help-panel__body">
<h3>What is this form?</h3>
<p>This is a schema-driven form rendered by zddc-server. Every
<code>&lt;name&gt;.form.yaml</code> file in the archive becomes an
editable form at <code>&lt;path&gt;/&lt;name&gt;.form.html</code>.
Submissions are saved as <code>&lt;name&gt;/&lt;id&gt;.yaml</code>
files alongside the schema, and re-render with their data filled in
when revisited.</p>
<h3>Filling in the form</h3>
<dl>
<dt>Required fields</dt>
<dd>Marked with an asterisk in their label. Submitting with a
required field empty re-renders the form with an inline error.</dd>
<dt>Validation</dt>
<dd>Server-side via JSON Schema 2020-12 (subset). Client-side
hints (<code>required</code>, <code>min</code>, <code>max</code>,
<code>pattern</code>) are added where the schema specifies them.</dd>
<dt>Submit</dt>
<dd>POSTs to the same URL the form was loaded from. On success the
browser navigates to the saved submission's URL. On failure the
form re-renders with errors inline at each invalid field.</dd>
</dl>
<h3>Editing existing submissions</h3>
<p>Open the saved submission's URL — the form re-renders with its
current data and any errors. Submitting overwrites the same file.
History is in git via your normal commit cycle.</p>
<h3>Header buttons</h3>
<dl>
<dt>◐ Theme</dt>
<dd>Cycle auto / light / dark.</dd>
<dt>? Help</dt>
<dd>This panel. Press <kbd>Esc</kbd> to close.</dd>
</dl>
</div>
</aside>
<!-- <!--
Server injects the form context here on render. Shape: Server injects the form context here on render. Shape:
{ {
@ -838,6 +902,53 @@ body.help-open .app-header {
} }
}()); }());
/**
* ZDDC shared help panel — open/close logic.
* Works with all four tools regardless of their module pattern.
* Expects: #help-btn, #help-panel, #help-panel-close in the DOM.
*/
(function () {
'use strict';
function init() {
var helpBtn = document.getElementById('help-btn');
var panel = document.getElementById('help-panel');
var closeBtn = document.getElementById('help-panel-close');
if (!helpBtn || !panel) { return; }
function isOpen() { return !panel.hidden; }
function openPanel() {
panel.hidden = false;
document.body.classList.add('help-open');
}
function closePanel() {
panel.hidden = true;
document.body.classList.remove('help-open');
}
helpBtn.addEventListener('click', function () {
if (isOpen()) { closePanel(); } else { openPanel(); }
});
if (closeBtn) {
closeBtn.addEventListener('click', closePanel);
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && isOpen()) { closePanel(); }
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
}());
(function (global) { (function (global) {
'use strict'; 'use strict';
if (global.formApp) { if (global.formApp) {

View file

@ -67,8 +67,19 @@ func (rw *responseWriter) Write(b []byte) (int, error) {
return n, err return n, err
} }
// AccessLogMiddleware logs a structured line per HTTP request after the response is written. // AccessLogMiddleware logs a structured line per HTTP request after the
func AccessLogMiddleware(next http.Handler) http.Handler { // response is written.
//
// Always emits to slog.Default() (stderr) so server-lifecycle logs and
// access logs share an output stream by default.
//
// If `auditLogger` is non-nil, the same structured fields are also written
// to it. The intended caller wires up auditLogger with a JSON handler
// pointing at a rotating file (see cmd/zddc-server's setupAccessAuditLog),
// 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 {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Capture request start time // Capture request start time
start := time.Now() start := time.Now()
@ -88,8 +99,7 @@ func AccessLogMiddleware(next http.Handler) http.Handler {
email = "anonymous" email = "anonymous"
} }
// Log access args := []any{
slog.Info("access",
"ts", start.Format(time.RFC3339), "ts", start.Format(time.RFC3339),
"email", email, "email", email,
"method", r.Method, "method", r.Method,
@ -97,6 +107,15 @@ func AccessLogMiddleware(next http.Handler) http.Handler {
"status", wrapped.status, "status", wrapped.status,
"bytes", wrapped.bytes, "bytes", wrapped.bytes,
"duration_ms", durationMs, "duration_ms", durationMs,
) }
// Stderr stream (existing behavior).
slog.Info("access", args...)
// Audit file (when configured). Same fields, separate handler so
// the file can be JSON-formatted regardless of stderr's handler.
if auditLogger != nil {
auditLogger.Info("access", args...)
}
}) })
} }

View file

@ -32,7 +32,7 @@ func TestAccessLogReadsEmailFromACLContext(t *testing.T) {
// Correct order: ACL is outer, AccessLog is inner. AccessLog reads // Correct order: ACL is outer, AccessLog is inner. AccessLog reads
// email from the context ACL populated. // email from the context ACL populated.
chain := ACLMiddleware(cfg, AccessLogMiddleware(noop)) chain := ACLMiddleware(cfg, AccessLogMiddleware(nil, noop))
req := httptest.NewRequest(http.MethodGet, "/foo", nil) req := httptest.NewRequest(http.MethodGet, "/foo", nil)
req.Header.Set("X-Auth-Request-Email", "alice@example.com") req.Header.Set("X-Auth-Request-Email", "alice@example.com")
@ -60,7 +60,7 @@ func TestAccessLogAnonymousWhenNoEmail(t *testing.T) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}) })
chain := ACLMiddleware(cfg, AccessLogMiddleware(noop)) chain := ACLMiddleware(cfg, AccessLogMiddleware(nil, noop))
req := httptest.NewRequest(http.MethodGet, "/foo", nil) req := httptest.NewRequest(http.MethodGet, "/foo", nil)
// Note: no X-Auth-Request-Email header set. // Note: no X-Auth-Request-Email header set.
@ -90,7 +90,7 @@ func TestAccessLogOuterDoesNotSeeInnerContext(t *testing.T) {
}) })
// Inverted order — the ORIGINAL buggy chain. // Inverted order — the ORIGINAL buggy chain.
chain := AccessLogMiddleware(ACLMiddleware(cfg, noop)) chain := AccessLogMiddleware(nil, ACLMiddleware(cfg, noop))
req := httptest.NewRequest(http.MethodGet, "/foo", nil) req := httptest.NewRequest(http.MethodGet, "/foo", nil)
req.Header.Set("X-Auth-Request-Email", "alice@example.com") req.Header.Set("X-Auth-Request-Email", "alice@example.com")
@ -104,3 +104,35 @@ func TestAccessLogOuterDoesNotSeeInnerContext(t *testing.T) {
t.Errorf("expected the inverted (buggy) chain to fall back to email=anonymous, got: %s", got) t.Errorf("expected the inverted (buggy) chain to fall back to email=anonymous, got: %s", got)
} }
} }
// TestAccessLogMiddleware_AuditLoggerReceivesSameFields verifies the
// optional audit-logger argument: when non-nil, it gets a parallel copy
// of every access record. Used by main.go to tee access logs to a
// rotating file in addition to stderr.
func TestAccessLogMiddleware_AuditLoggerReceivesSameFields(t *testing.T) {
var auditBuf bytes.Buffer
auditLogger := slog.New(slog.NewJSONHandler(&auditBuf, &slog.HandlerOptions{Level: slog.LevelInfo}))
noop := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
_, _ = w.Write([]byte("hi"))
})
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
chain := ACLMiddleware(cfg, AccessLogMiddleware(auditLogger, noop))
req := httptest.NewRequest(http.MethodGet, "/some/path", nil)
req.Header.Set("X-Auth-Request-Email", "bob@example.com")
chain.ServeHTTP(httptest.NewRecorder(), req)
out := auditBuf.String()
if !strings.Contains(out, `"email":"bob@example.com"`) {
t.Errorf("audit log missing email field; got: %s", out)
}
if !strings.Contains(out, `"path":"/some/path"`) {
t.Errorf("audit log missing path; got: %s", out)
}
if !strings.Contains(out, `"status":418`) {
t.Errorf("audit log missing status code; got: %s", out)
}
}