Compare commits
15 commits
ae758550a8
...
f176bea645
| Author | SHA1 | Date | |
|---|---|---|---|
| f176bea645 | |||
| 9a3e4d8fa7 | |||
| 8df0defbd2 | |||
| eaecaaee29 | |||
| c22bb19dab | |||
| e021f14609 | |||
| 0fae93696d | |||
| 62ce6e9f63 | |||
| 633411770c | |||
| e67c1b2e06 | |||
| bbb75a87af | |||
| a7e84dae15 | |||
| 1ddd331f58 | |||
| 2a70359b0a | |||
| 4a78ce4473 |
51 changed files with 1688 additions and 484 deletions
152
.forgejo/scripts/notify-chart-bump.sh
Executable file
152
.forgejo/scripts/notify-chart-bump.sh
Executable 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."
|
||||
|
|
@ -113,68 +113,15 @@ jobs:
|
|||
# directly to GitHub. The chart repo is mirrored Forgejo→GitHub
|
||||
# one-way; pushing directly to GitHub would be silently overwritten
|
||||
# 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 }}
|
||||
steps:
|
||||
- name: Auto-bump tnd-zddc-chart appVersion on main + develop (via Forgejo)
|
||||
run: |
|
||||
set -eu
|
||||
VERSION="${GITHUB_REF#refs/tags/zddc-server-v}"
|
||||
echo "ZDDC stable cut: $VERSION"
|
||||
|
||||
# Sanity: make sure the secret was injected. If not, fail loud
|
||||
# (rather than silently failing on the git push later).
|
||||
if [ -z "${CHART_FORGEJO_TOKEN:-}" ]; then
|
||||
echo "::error::CHART_FORGEJO_TOKEN secret not set on this repo" >&2
|
||||
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
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Bump chart for stable cut
|
||||
# All bump logic lives in .forgejo/scripts/notify-chart-bump.sh
|
||||
# — same script the dev workflow uses. See its header for
|
||||
# behavior. Local invocation:
|
||||
# CHART_FORGEJO_TOKEN=$FORGEJO_TOKEN \
|
||||
# .forgejo/scripts/notify-chart-bump.sh stable X.Y.Z
|
||||
run: ./.forgejo/scripts/notify-chart-bump.sh stable "${GITHUB_REF#refs/tags/zddc-server-v}"
|
||||
|
|
|
|||
|
|
@ -1,105 +1,35 @@
|
|||
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/*
|
||||
# — i.e. a `./build beta` cut whose embedded artifacts the operator
|
||||
# committed to main. Pushes a chart appVersion bump to the chart's
|
||||
# develop branch, which fires BMCD's pipeline-dev → dev image rebuilt
|
||||
# with the new beta-labeled bytes baked in.
|
||||
#
|
||||
# Stable cuts ALSO touch embedded/, but their workflow path is the
|
||||
# tag-triggered notify-chart-prod in deploy-release.yml. To avoid
|
||||
# double-firing when a stable cut pushes main + tags together, we
|
||||
# check if HEAD has a zddc-server-v* tag and skip if so — the
|
||||
# stable workflow handles the chart bump in that case.
|
||||
# All logic lives in .forgejo/scripts/notify-chart-bump.sh — see that
|
||||
# script's header for behavior. Workflows just provide checkout +
|
||||
# secret + invocation. Local invocation is supported (and supported
|
||||
# without --force-with-lease shenanigans):
|
||||
#
|
||||
# CHART_FORGEJO_TOKEN=$FORGEJO_TOKEN .forgejo/scripts/notify-chart-bump.sh beta
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '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:
|
||||
notify-chart-dev:
|
||||
runs-on: host
|
||||
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 }}
|
||||
steps:
|
||||
- name: Checkout (need tags to detect stable cut)
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- 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"
|
||||
fetch-depth: 0 # script needs full tag history
|
||||
- run: ./.forgejo/scripts/notify-chart-bump.sh beta
|
||||
|
|
|
|||
|
|
@ -27,8 +27,14 @@ concat_files \
|
|||
"css/print.css" \
|
||||
> "$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 \
|
||||
"../shared/vendor/jszip.min.js" \
|
||||
"../shared/vendor/docx-preview.min.js" \
|
||||
"../shared/zddc.js" \
|
||||
"../shared/hash.js" \
|
||||
"../shared/theme.js" \
|
||||
|
|
|
|||
|
|
@ -300,7 +300,7 @@
|
|||
|
||||
// Show unsupported browser message
|
||||
function showUnsupportedBrowserMessage() {
|
||||
const app = document.getElementById('app');
|
||||
const app = document.getElementById('appContainer');
|
||||
app.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-content">
|
||||
|
|
|
|||
|
|
@ -24,8 +24,10 @@
|
|||
// Handle drops on grouping folders (for creating transmittals)
|
||||
document.getElementById('groupingFoldersList').addEventListener('drop', handleDrop, false);
|
||||
|
||||
// Handle drops on the main app area (for adding directories)
|
||||
document.getElementById('app').addEventListener('drop', handleAppDrop, false);
|
||||
// Handle drops on the main app area (for adding directories).
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -53,12 +53,14 @@
|
|||
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') {
|
||||
// Dynamically load JSZip
|
||||
await loadJSZip();
|
||||
alert('JSZip library not bundled — rebuild archive with shared/vendor/jszip.min.js');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const zip = new JSZip();
|
||||
const selectedFiles = [];
|
||||
|
||||
|
|
@ -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
|
||||
function showProgress(message, current, total) {
|
||||
let progressDiv = document.getElementById('progressIndicator');
|
||||
|
|
@ -260,7 +251,6 @@
|
|||
rowsToCSV,
|
||||
exportCSV,
|
||||
downloadSelected,
|
||||
loadJSZip,
|
||||
showProgress,
|
||||
hideProgress,
|
||||
downloadFile,
|
||||
|
|
|
|||
|
|
@ -609,13 +609,13 @@
|
|||
if (!container) return;
|
||||
|
||||
try {
|
||||
await loadLibrary('https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js');
|
||||
await loadLibrary('https://cdn.jsdelivr.net/npm/docx-preview@latest/dist/docx-preview.min.js');
|
||||
|
||||
// jszip + docx-preview are vendored (concatenated by build.sh
|
||||
// ahead of every tool module), so window.JSZip and window.docx
|
||||
// are already defined here.
|
||||
const arrayBuffer = await (file.handle
|
||||
? file.handle.getFile().then(f => f.arrayBuffer())
|
||||
: fetch(file.url).then(r => r.arrayBuffer()));
|
||||
|
||||
|
||||
container.innerHTML = '';
|
||||
await window.docx.renderAsync(arrayBuffer, container);
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ concat_files \
|
|||
"../shared/zddc.js" \
|
||||
"../shared/zddc-filter.js" \
|
||||
"../shared/theme.js" \
|
||||
"../shared/help.js" \
|
||||
"../shared/preview-lib.js" \
|
||||
"js/init.js" \
|
||||
"js/loader.js" \
|
||||
|
|
@ -51,8 +52,13 @@ tool=browse
|
|||
compute_build_label "$tool" "$@"
|
||||
|
||||
# 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" \
|
||||
-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\}\}/ {
|
||||
while ((getline line < css_file) > 0) print line
|
||||
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
|
||||
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)
|
||||
}
|
||||
print; next
|
||||
}
|
||||
{
|
||||
gsub(/\{\{BUILD_LABEL\}\}/, build_label)
|
||||
gsub(/\{\{FAVICON\}\}/, favicon)
|
||||
print
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,22 +142,6 @@
|
|||
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 */
|
||||
|
||||
.browse-table {
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@
|
|||
<span class="app-header__title">ZDDC Browse</span>
|
||||
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Select Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</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" style="font-size:1.1rem;">⟳</button>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
<ul>
|
||||
<li><b>Online</b> — when this page is served by zddc-server, the
|
||||
listing for the current directory loads automatically.</li>
|
||||
<li><b>Local</b> — click <i>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>
|
||||
</ul>
|
||||
<p>Once loaded: click a folder to expand it, <b>shift-click</b>
|
||||
|
|
@ -103,6 +103,87 @@
|
|||
|
||||
<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">×</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>
|
||||
{{JS_PLACEHOLDER}}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -25,8 +25,14 @@ concat_files \
|
|||
"css/spreadsheet.css" \
|
||||
> "$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 \
|
||||
"../shared/vendor/jszip.min.js" \
|
||||
"../shared/vendor/docx-preview.min.js" \
|
||||
"../shared/zddc.js" \
|
||||
"../shared/hash.js" \
|
||||
"../shared/theme.js" \
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@
|
|||
*/
|
||||
function showBrowserWarning() {
|
||||
const warning = document.getElementById('browserWarning');
|
||||
const selectBtn = document.getElementById('selectDirectoryBtn');
|
||||
const selectBtn = document.getElementById('addDirectoryBtn');
|
||||
if (warning) {
|
||||
warning.classList.remove('hidden');
|
||||
}
|
||||
|
|
@ -80,8 +80,8 @@
|
|||
mainApp: document.getElementById('mainApp'),
|
||||
|
||||
// Header buttons
|
||||
selectDirectoryBtn: document.getElementById('selectDirectoryBtn'),
|
||||
refreshBtn: document.getElementById('refreshBtn'),
|
||||
addDirectoryBtn: document.getElementById('addDirectoryBtn'),
|
||||
refreshHeaderBtn: document.getElementById('refreshHeaderBtn'),
|
||||
saveAllBtn: document.getElementById('saveAllBtn'),
|
||||
cancelAllBtn: document.getElementById('cancelAllBtn'),
|
||||
exportHashesBtn: document.getElementById('exportHashesBtn'),
|
||||
|
|
@ -115,8 +115,8 @@
|
|||
*/
|
||||
function setupEventListeners() {
|
||||
// Directory selection
|
||||
app.dom.selectDirectoryBtn.addEventListener('click', handleSelectDirectory);
|
||||
app.dom.refreshBtn.addEventListener('click', handleRefresh);
|
||||
app.dom.addDirectoryBtn.addEventListener('click', handleSelectDirectory);
|
||||
app.dom.refreshHeaderBtn.addEventListener('click', handleRefresh);
|
||||
|
||||
// Drag and drop on welcome screen
|
||||
setupWelcomeDragDrop();
|
||||
|
|
@ -278,7 +278,7 @@
|
|||
await app.modules.scanner.scanDirectory(dirHandle);
|
||||
|
||||
// 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'); }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -383,12 +383,10 @@
|
|||
if (!container) return;
|
||||
|
||||
try {
|
||||
await loadLibrary('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js');
|
||||
await loadLibrary('https://cdn.jsdelivr.net/npm/docx-preview@latest/dist/docx-preview.min.js');
|
||||
|
||||
// jszip + docx-preview vendored by build.sh — already in scope.
|
||||
const blob = await getFileBlob(file);
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
|
||||
|
||||
container.innerHTML = '';
|
||||
await window.docx.renderAsync(arrayBuffer, container);
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ZDDC Classifier</title>
|
||||
<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>
|
||||
{{CSS_PLACEHOLDER}}
|
||||
</style>
|
||||
|
|
@ -29,8 +28,8 @@
|
|||
<span class="app-header__title">ZDDC Classifier</span>
|
||||
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
||||
</div>
|
||||
<button id="selectDirectoryBtn" class="btn btn-primary">Select 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="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
|
|
@ -149,7 +148,7 @@
|
|||
<li>Rename one file or all modified files at once</li>
|
||||
</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>
|
||||
</div>
|
||||
|
|
@ -168,7 +167,7 @@
|
|||
|
||||
<h3>Getting Started</h3>
|
||||
<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>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>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ concat_files \
|
|||
|
||||
concat_files \
|
||||
"../shared/theme.js" \
|
||||
"../shared/help.js" \
|
||||
"js/app.js" \
|
||||
"js/context.js" \
|
||||
"js/util.js" \
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
</div>
|
||||
<div class="header-right">
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -38,6 +39,51 @@
|
|||
</div>
|
||||
</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">×</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><name>.form.yaml</code> file in the archive becomes an
|
||||
editable form at <code><path>/<name>.form.html</code>.
|
||||
Submissions are saved as <code><name>/<id>.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:
|
||||
{
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ concat_files \
|
|||
"../shared/zddc.js" \
|
||||
"../shared/zddc-filter.js" \
|
||||
"../shared/theme.js" \
|
||||
"../shared/help.js" \
|
||||
"js/landing.js" \
|
||||
> "$js_raw"
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
</div>
|
||||
<div class="header-right">
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -91,6 +92,52 @@
|
|||
</div>
|
||||
</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">×</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>
|
||||
{{JS_PLACEHOLDER}}
|
||||
</script>
|
||||
|
|
|
|||
69
mdedit/dist/mdedit.html
vendored
69
mdedit/dist/mdedit.html
vendored
|
|
@ -438,6 +438,24 @@ a:hover {
|
|||
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 {
|
||||
background: var(--success);
|
||||
color: var(--text-light);
|
||||
|
|
@ -1774,9 +1792,10 @@ body.help-open .app-header {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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>
|
||||
<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 class="header-right">
|
||||
<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>
|
||||
<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="refresh-directory" class="btn btn-secondary btn-sm hidden" title="Refresh directory">↻</button>
|
||||
</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 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>
|
||||
</div>
|
||||
|
||||
|
|
@ -1850,7 +1868,7 @@ body.help-open .app-header {
|
|||
|
||||
<h3>Getting Started</h3>
|
||||
<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>Use the <strong>Scratchpad</strong> entry (always visible at the top of the tree) for temporary notes without saving to disk.</li>
|
||||
</ol>
|
||||
|
|
@ -2954,7 +2972,7 @@ const SCRATCHPAD_WELCOME = [
|
|||
'Use this **Scratchpad** for quick notes. Download it any time with the ⬇',
|
||||
'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.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
|
@ -3754,12 +3772,19 @@ async function openDirectory() {
|
|||
* @param {string} directoryName - Name of the selected directory
|
||||
*/
|
||||
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) {
|
||||
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) {
|
||||
refreshBtn.classList.remove('hidden');
|
||||
}
|
||||
|
|
@ -4260,8 +4285,8 @@ async function loadServerDirectory() {
|
|||
|
||||
// Only enter server-source mode if the host actually serves JSON directory
|
||||
// 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
|
||||
// local files.
|
||||
// and we must leave "Add Local Directory" visible so the user can still
|
||||
// load local files.
|
||||
try {
|
||||
const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' });
|
||||
if (!resp.ok) return;
|
||||
|
|
@ -4286,12 +4311,18 @@ async function loadServerDirectory() {
|
|||
entries: {},
|
||||
};
|
||||
|
||||
// Surface refresh, hide write-only controls. "Select Directory" stays
|
||||
// visible so the user can switch to a local folder at any time.
|
||||
const refreshBtn = document.getElementById('refresh-directory');
|
||||
// Surface refresh, hide write-only controls. "Add Local Directory"
|
||||
// stays visible (de-emphasized via btn--subtle) so the user can
|
||||
// switch to a local folder at any time.
|
||||
const refreshBtn = document.getElementById('refreshHeaderBtn');
|
||||
if (refreshBtn) refreshBtn.classList.remove('hidden');
|
||||
const newFileRootBtn = document.getElementById('new-file-root');
|
||||
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);
|
||||
renderFileTree();
|
||||
|
|
@ -6028,14 +6059,14 @@ function initializeFileNavResizer() {
|
|||
* Set up all event listeners for the application
|
||||
*/
|
||||
function setupEventListeners() {
|
||||
// Select directory button
|
||||
const selectDirectoryBtn = document.getElementById('select-directory');
|
||||
// Add Local Directory button (was id="select-directory" / "refresh-directory")
|
||||
const selectDirectoryBtn = document.getElementById('addDirectoryBtn');
|
||||
if (selectDirectoryBtn) {
|
||||
selectDirectoryBtn.addEventListener('click', openDirectory);
|
||||
}
|
||||
|
||||
// Refresh directory button
|
||||
const refreshDirectoryBtn = document.getElementById('refresh-directory');
|
||||
// Refresh button (now in header, was in file-nav pane)
|
||||
const refreshDirectoryBtn = document.getElementById('refreshHeaderBtn');
|
||||
if (refreshDirectoryBtn) {
|
||||
refreshDirectoryBtn.addEventListener('click', refreshDirectory);
|
||||
}
|
||||
|
|
@ -6136,7 +6167,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
* Initialize UI based on File System API availability
|
||||
*/
|
||||
function initializeApiAvailability() {
|
||||
const selectDirectoryBtn = document.getElementById('select-directory');
|
||||
const selectDirectoryBtn = document.getElementById('addDirectoryBtn');
|
||||
const welcomeHint = document.getElementById('welcome-hint');
|
||||
const welcomeFirefox = document.getElementById('welcome-firefox');
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ const SCRATCHPAD_WELCOME = [
|
|||
'Use this **Scratchpad** for quick notes. Download it any time with the ⬇',
|
||||
'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.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@
|
|||
* Set up all event listeners for the application
|
||||
*/
|
||||
function setupEventListeners() {
|
||||
// Select directory button
|
||||
const selectDirectoryBtn = document.getElementById('select-directory');
|
||||
// Add Local Directory button (was id="select-directory" / "refresh-directory")
|
||||
const selectDirectoryBtn = document.getElementById('addDirectoryBtn');
|
||||
if (selectDirectoryBtn) {
|
||||
selectDirectoryBtn.addEventListener('click', openDirectory);
|
||||
}
|
||||
|
||||
// Refresh directory button
|
||||
const refreshDirectoryBtn = document.getElementById('refresh-directory');
|
||||
// Refresh button (now in header, was in file-nav pane)
|
||||
const refreshDirectoryBtn = document.getElementById('refreshHeaderBtn');
|
||||
if (refreshDirectoryBtn) {
|
||||
refreshDirectoryBtn.addEventListener('click', refreshDirectory);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,12 +171,19 @@ async function openDirectory() {
|
|||
* @param {string} directoryName - Name of the selected directory
|
||||
*/
|
||||
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) {
|
||||
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) {
|
||||
refreshBtn.classList.remove('hidden');
|
||||
}
|
||||
|
|
@ -677,8 +684,8 @@ async function loadServerDirectory() {
|
|||
|
||||
// Only enter server-source mode if the host actually serves JSON directory
|
||||
// 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
|
||||
// local files.
|
||||
// and we must leave "Add Local Directory" visible so the user can still
|
||||
// load local files.
|
||||
try {
|
||||
const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' });
|
||||
if (!resp.ok) return;
|
||||
|
|
@ -703,12 +710,18 @@ async function loadServerDirectory() {
|
|||
entries: {},
|
||||
};
|
||||
|
||||
// Surface refresh, hide write-only controls. "Select Directory" stays
|
||||
// visible so the user can switch to a local folder at any time.
|
||||
const refreshBtn = document.getElementById('refresh-directory');
|
||||
// Surface refresh, hide write-only controls. "Add Local Directory"
|
||||
// stays visible (de-emphasized via btn--subtle) so the user can
|
||||
// switch to a local folder at any time.
|
||||
const refreshBtn = document.getElementById('refreshHeaderBtn');
|
||||
if (refreshBtn) refreshBtn.classList.remove('hidden');
|
||||
const newFileRootBtn = document.getElementById('new-file-root');
|
||||
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);
|
||||
renderFileTree();
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
* Initialize UI based on File System API availability
|
||||
*/
|
||||
function initializeApiAvailability() {
|
||||
const selectDirectoryBtn = document.getElementById('select-directory');
|
||||
const selectDirectoryBtn = document.getElementById('addDirectoryBtn');
|
||||
const welcomeHint = document.getElementById('welcome-hint');
|
||||
const welcomeFirefox = document.getElementById('welcome-firefox');
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@
|
|||
<span class="app-header__title">ZDDC Markdown</span>
|
||||
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
||||
</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 class="header-right">
|
||||
<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>
|
||||
<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="refresh-directory" class="btn btn-secondary btn-sm hidden" title="Refresh directory">↻</button>
|
||||
</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 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>
|
||||
</div>
|
||||
|
||||
|
|
@ -103,7 +103,7 @@
|
|||
|
||||
<h3>Getting Started</h3>
|
||||
<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>Use the <strong>Scratchpad</strong> entry (always visible at the top of the tree) for temporary notes without saving to disk.</li>
|
||||
</ol>
|
||||
|
|
|
|||
|
|
@ -210,6 +210,24 @@ a:hover {
|
|||
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 {
|
||||
background: var(--success);
|
||||
color: var(--text-light);
|
||||
|
|
|
|||
8
shared/vendor/docx-preview.min.js
vendored
Normal file
8
shared/vendor/docx-preview.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -36,8 +36,13 @@ concat_files \
|
|||
"css/print.css" \
|
||||
> "$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 \
|
||||
"../shared/vendor/jszip.min.js" \
|
||||
"../shared/vendor/docx-preview.min.js" \
|
||||
"../shared/zddc.js" \
|
||||
"../shared/hash.js" \
|
||||
"../shared/theme.js" \
|
||||
|
|
|
|||
|
|
@ -372,37 +372,6 @@
|
|||
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 ───────── */
|
||||
.page-footer {
|
||||
position: fixed;
|
||||
|
|
|
|||
|
|
@ -200,8 +200,7 @@
|
|||
return;
|
||||
}
|
||||
try {
|
||||
await loadLibrary('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js');
|
||||
await loadLibrary('https://cdn.jsdelivr.net/npm/docx-preview@latest/dist/docx-preview.min.js');
|
||||
// jszip + docx-preview vendored by build.sh — already in scope.
|
||||
var arrayBuffer = await getFileArrayBuffer(file);
|
||||
container.innerHTML = '';
|
||||
await window.docx.renderAsync(arrayBuffer, container);
|
||||
|
|
@ -474,7 +473,7 @@
|
|||
}
|
||||
try {
|
||||
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 zip = await JSZip.loadAsync(arrayBuffer);
|
||||
var sourceEntries = [];
|
||||
|
|
|
|||
|
|
@ -27,31 +27,34 @@ conventions at https://codeberg.org/VARASYS/ZDDC#file-naming-convention.
|
|||
</head>
|
||||
|
||||
<body class="font-sans text-gray-900">
|
||||
<div class="app-header print:hidden" data-no-disable="true">
|
||||
<div class="split-button" id="bottom-menu" hidden>
|
||||
<button id="bottom-toggle" type="button" class="btn btn-primary split-button__toggle" data-no-disable="true" aria-haspopup="true" aria-expanded="false">▾</button>
|
||||
<button id="bottom-primary" type="button" class="btn btn-primary" data-no-disable="true">Publish</button>
|
||||
<div class="dropdown-menu hidden" role="menu" id="bottom-dropdown"></div>
|
||||
<header class="app-header print:hidden" data-no-disable="true">
|
||||
<div class="header-left">
|
||||
<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"/>
|
||||
<g fill="#fff">
|
||||
<rect x="14" y="18" width="36" height="7"/>
|
||||
<polygon points="43,25 50,25 21,43 14,43"/>
|
||||
<rect x="14" y="43" width="36" height="7"/>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Transmittal</span>
|
||||
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
||||
</div>
|
||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||
<!-- Publish split-button (Transmittal-specific primary action;
|
||||
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">▾</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>
|
||||
<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">
|
||||
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
|
||||
<g fill="#fff">
|
||||
<rect x="14" y="18" width="36" height="7"/>
|
||||
<polygon points="43,25 50,25 21,43 14,43"/>
|
||||
<rect x="14" y="43" width="36" height="7"/>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Transmittal</span>
|
||||
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
||||
</div>
|
||||
<div class="app-header__spacer"></div>
|
||||
<div class="app-header__icons">
|
||||
<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="help-btn" class="btn btn-secondary" aria-label="Help" title="Help">?</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="page-container">
|
||||
<form id="transmittal-form">
|
||||
<input type="hidden" id="mode" value="edit">
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ import (
|
|||
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/tlsutil"
|
||||
"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
|
||||
|
|
@ -106,13 +109,20 @@ func main() {
|
|||
// the context the outer ACL middleware set.
|
||||
// CORSMiddleware — Origin / preflight handling.
|
||||
// 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)
|
||||
})))))
|
||||
|
||||
gzWrapper, err := newGzipWrapper()
|
||||
if err != nil {
|
||||
slog.Error("gzhttp wrapper init", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: mux,
|
||||
Handler: gzWrapper(mux),
|
||||
TLSConfig: tlsCfg,
|
||||
}
|
||||
|
||||
|
|
@ -146,6 +156,58 @@ func main() {
|
|||
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,
|
||||
// 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.
|
||||
|
|
@ -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
|
||||
// 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.
|
||||
//
|
||||
// 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") {
|
||||
realIndex := filepath.Join(cfg.Root, "index.html")
|
||||
if _, err := os.Stat(realIndex); os.IsNotExist(err) {
|
||||
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") {
|
||||
appsSrv.Serve(w, r, "landing", chain, cfg.Root)
|
||||
return
|
||||
|
|
@ -353,11 +417,18 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
}
|
||||
|
||||
if info.IsDir() {
|
||||
// ACL check
|
||||
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
|
||||
if !zddc.AllowedWithChain(chain, email) {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
// 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)
|
||||
if !zddc.AllowedWithChain(chain, email) {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(urlPath, "/") {
|
||||
http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently)
|
||||
|
|
|
|||
|
|
@ -210,3 +210,81 @@ func mustWrite(t *testing.T, path, body string) {
|
|||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ go 1.24
|
|||
|
||||
require (
|
||||
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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
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/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/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/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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
package apps
|
||||
|
||||
import _ "embed"
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
_ "embed"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Embedded fallback: the five tool HTMLs from the time the binary was
|
||||
// built. Used as a last-resort served-bytes when (cache miss) AND
|
||||
|
|
@ -56,3 +61,29 @@ func EmbeddedBytes(app string) []byte {
|
|||
}
|
||||
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 50–920 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
|
|
@ -218,6 +218,24 @@ a:hover {
|
|||
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 {
|
||||
background: var(--success);
|
||||
color: var(--text-light);
|
||||
|
|
@ -722,22 +740,6 @@ body {
|
|||
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 */
|
||||
|
||||
.browse-table {
|
||||
|
|
@ -894,10 +896,10 @@ body {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Select Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</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" style="font-size:1.1rem;">⟳</button>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
|
|
@ -914,7 +916,7 @@ body {
|
|||
<ul>
|
||||
<li><b>Online</b> — when this page is served by zddc-server, the
|
||||
listing for the current directory loads automatically.</li>
|
||||
<li><b>Local</b> — click <i>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>
|
||||
</ul>
|
||||
<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>
|
||||
|
||||
<!-- 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">×</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>
|
||||
/*!
|
||||
|
||||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -218,6 +218,24 @@ a:hover {
|
|||
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 {
|
||||
background: var(--success);
|
||||
color: var(--text-light);
|
||||
|
|
@ -867,11 +885,12 @@ body {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.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 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="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -936,6 +955,52 @@ body {
|
|||
</div>
|
||||
</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">×</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>
|
||||
/**
|
||||
* 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() {
|
||||
'use strict';
|
||||
// ZDDC landing page — project picker.
|
||||
|
|
|
|||
|
|
@ -438,6 +438,24 @@ a:hover {
|
|||
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 {
|
||||
background: var(--success);
|
||||
color: var(--text-light);
|
||||
|
|
@ -1774,9 +1792,10 @@ body.help-open .app-header {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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>
|
||||
<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 class="header-right">
|
||||
<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>
|
||||
<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="refresh-directory" class="btn btn-secondary btn-sm hidden" title="Refresh directory">↻</button>
|
||||
</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 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>
|
||||
</div>
|
||||
|
||||
|
|
@ -1850,7 +1868,7 @@ body.help-open .app-header {
|
|||
|
||||
<h3>Getting Started</h3>
|
||||
<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>Use the <strong>Scratchpad</strong> entry (always visible at the top of the tree) for temporary notes without saving to disk.</li>
|
||||
</ol>
|
||||
|
|
@ -2954,7 +2972,7 @@ const SCRATCHPAD_WELCOME = [
|
|||
'Use this **Scratchpad** for quick notes. Download it any time with the ⬇',
|
||||
'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.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
|
@ -3754,12 +3772,19 @@ async function openDirectory() {
|
|||
* @param {string} directoryName - Name of the selected directory
|
||||
*/
|
||||
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) {
|
||||
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) {
|
||||
refreshBtn.classList.remove('hidden');
|
||||
}
|
||||
|
|
@ -4260,8 +4285,8 @@ async function loadServerDirectory() {
|
|||
|
||||
// Only enter server-source mode if the host actually serves JSON directory
|
||||
// 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
|
||||
// local files.
|
||||
// and we must leave "Add Local Directory" visible so the user can still
|
||||
// load local files.
|
||||
try {
|
||||
const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' });
|
||||
if (!resp.ok) return;
|
||||
|
|
@ -4286,12 +4311,18 @@ async function loadServerDirectory() {
|
|||
entries: {},
|
||||
};
|
||||
|
||||
// Surface refresh, hide write-only controls. "Select Directory" stays
|
||||
// visible so the user can switch to a local folder at any time.
|
||||
const refreshBtn = document.getElementById('refresh-directory');
|
||||
// Surface refresh, hide write-only controls. "Add Local Directory"
|
||||
// stays visible (de-emphasized via btn--subtle) so the user can
|
||||
// switch to a local folder at any time.
|
||||
const refreshBtn = document.getElementById('refreshHeaderBtn');
|
||||
if (refreshBtn) refreshBtn.classList.remove('hidden');
|
||||
const newFileRootBtn = document.getElementById('new-file-root');
|
||||
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);
|
||||
renderFileTree();
|
||||
|
|
@ -6028,14 +6059,14 @@ function initializeFileNavResizer() {
|
|||
* Set up all event listeners for the application
|
||||
*/
|
||||
function setupEventListeners() {
|
||||
// Select directory button
|
||||
const selectDirectoryBtn = document.getElementById('select-directory');
|
||||
// Add Local Directory button (was id="select-directory" / "refresh-directory")
|
||||
const selectDirectoryBtn = document.getElementById('addDirectoryBtn');
|
||||
if (selectDirectoryBtn) {
|
||||
selectDirectoryBtn.addEventListener('click', openDirectory);
|
||||
}
|
||||
|
||||
// Refresh directory button
|
||||
const refreshDirectoryBtn = document.getElementById('refresh-directory');
|
||||
// Refresh button (now in header, was in file-nav pane)
|
||||
const refreshDirectoryBtn = document.getElementById('refreshHeaderBtn');
|
||||
if (refreshDirectoryBtn) {
|
||||
refreshDirectoryBtn.addEventListener('click', refreshDirectory);
|
||||
}
|
||||
|
|
@ -6136,7 +6167,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
* Initialize UI based on File System API availability
|
||||
*/
|
||||
function initializeApiAvailability() {
|
||||
const selectDirectoryBtn = document.getElementById('select-directory');
|
||||
const selectDirectoryBtn = document.getElementById('addDirectoryBtn');
|
||||
const welcomeHint = document.getElementById('welcome-hint');
|
||||
const welcomeFirefox = document.getElementById('welcome-firefox');
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,8 +1,8 @@
|
|||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||
archive=v0.0.16-beta · 2026-05-04 · 582db6d
|
||||
transmittal=v0.0.16-beta · 2026-05-04 · 582db6d
|
||||
classifier=v0.0.16-beta · 2026-05-04 · 582db6d
|
||||
mdedit=v0.0.16-beta · 2026-05-04 · 582db6d
|
||||
landing=v0.0.16-beta · 2026-05-04 · 582db6d
|
||||
form=v0.0.16-beta · 2026-05-04 · 582db6d
|
||||
browse=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 · 8df0def
|
||||
classifier=v0.0.16-beta · 2026-05-04 · 8df0def
|
||||
mdedit=v0.0.16-beta · 2026-05-04 · 8df0def
|
||||
landing=v0.0.16-beta · 2026-05-04 · 8df0def
|
||||
form=v0.0.16-beta · 2026-05-04 · 8df0def
|
||||
browse=v0.0.16-beta · 2026-05-04 · 8df0def
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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.
|
||||
s.Fetcher.Logger.Warn("apps.Resolve failed; serving embedded",
|
||||
"app", app, "request_dir", requestDir, "err", err)
|
||||
s.serveEmbedded(w, app, err)
|
||||
s.serveEmbedded(w, r, app, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !hasOverride {
|
||||
// No `.zddc apps:` entry anywhere up the chain and no `?v=` either →
|
||||
// embedded is the authoritative default.
|
||||
s.serveEmbedded(w, app, nil)
|
||||
s.serveEmbedded(w, r, app, nil)
|
||||
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",
|
||||
"app", app, "path", src.Path, "err", err)
|
||||
}
|
||||
s.serveEmbedded(w, app, err)
|
||||
s.serveEmbedded(w, r, app, err)
|
||||
return
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
s.Fetcher.LogEmbeddedFallback(app, src.URL, err)
|
||||
s.serveEmbedded(w, app, err)
|
||||
s.serveEmbedded(w, r, app, err)
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
func (s *Server) serveBody(w http.ResponseWriter, r *http.Request, body []byte, sourceHeader string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
// writeWithETag writes body with a strong ETag derived from `etag`, the
|
||||
// cache-friendly headers, and short-circuits to 304 Not Modified when the
|
||||
// client's `If-None-Match` matches. `max-age=0, must-revalidate` means the
|
||||
// browser revalidates on every load — and the matching ETag returns 304
|
||||
// with empty body, so the steady-state cost of a reload is ~200 bytes
|
||||
// instead of the full HTML payload (50–920 KB depending on the tool).
|
||||
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("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)
|
||||
if len(body) == 0 {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
|
|
@ -179,8 +204,7 @@ func (s *Server) serveEmbedded(w http.ResponseWriter, app string, _ error) {
|
|||
http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("X-ZDDC-Source", "embedded:"+app+"@"+s.BuildVer)
|
||||
w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate")
|
||||
_, _ = w.Write(body)
|
||||
writeWithETag(w, r, body, EmbeddedETag(app),
|
||||
"text/html; charset=utf-8",
|
||||
"embedded:"+app+"@"+s.BuildVer)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -290,3 +290,88 @@ func TestServer_VParam_FullURLForm(t *testing.T) {
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ type Config struct {
|
|||
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)
|
||||
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
|
||||
|
|
@ -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.")
|
||||
insecureDirectFlag := fs.Bool("insecure-direct", os.Getenv("ZDDC_INSECURE_DIRECT") == "1",
|
||||
"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.")
|
||||
versionFlag := fs.Bool("version", false, "Print version info and exit.")
|
||||
|
||||
|
|
@ -113,6 +116,7 @@ func Load(args []string) (Config, error) {
|
|||
IndexPath: *indexPathFlag,
|
||||
EmailHeader: *emailHeaderFlag,
|
||||
CORSOrigins: resolveCORS(corsFlagSet, *corsOriginFlag),
|
||||
AccessLog: *accessLogFlag,
|
||||
}
|
||||
|
||||
// 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("cors-origin", "", "Comma-separated CORS allowlist. Empty = CORS disabled.")
|
||||
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("version", false, "Print version info and exit.")
|
||||
fs.PrintDefaults()
|
||||
|
|
|
|||
|
|
@ -40,7 +40,10 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
dirPath := strings.TrimPrefix(urlPath, "/")
|
||||
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)
|
||||
if !ok {
|
||||
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 {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
|
@ -114,12 +118,19 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
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("X-ZDDC-Source", "embedded:browse")
|
||||
// no-cache here too — browse.html has session-tied content (the
|
||||
// directory listing it loads via fetch), and we want browser to
|
||||
// always re-validate so deployed-binary updates appear immediately
|
||||
// rather than after a 5-minute cache window.
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
|
|
|||
128
zddc/internal/handler/directory_test.go
Normal file
128
zddc/internal/handler/directory_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -218,6 +218,24 @@ a:hover {
|
|||
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 {
|
||||
background: var(--success);
|
||||
color: var(--text-light);
|
||||
|
|
@ -723,11 +741,12 @@ body.help-open .app-header {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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 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="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -739,6 +758,51 @@ body.help-open .app-header {
|
|||
</div>
|
||||
</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">×</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><name>.form.yaml</code> file in the archive becomes an
|
||||
editable form at <code><path>/<name>.form.html</code>.
|
||||
Submissions are saved as <code><name>/<id>.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:
|
||||
{
|
||||
|
|
@ -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) {
|
||||
'use strict';
|
||||
if (global.formApp) {
|
||||
|
|
|
|||
|
|
@ -67,8 +67,19 @@ func (rw *responseWriter) Write(b []byte) (int, error) {
|
|||
return n, err
|
||||
}
|
||||
|
||||
// AccessLogMiddleware logs a structured line per HTTP request after the response is written.
|
||||
func AccessLogMiddleware(next http.Handler) http.Handler {
|
||||
// AccessLogMiddleware logs a structured line per HTTP request after the
|
||||
// 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) {
|
||||
// Capture request start time
|
||||
start := time.Now()
|
||||
|
|
@ -88,8 +99,7 @@ func AccessLogMiddleware(next http.Handler) http.Handler {
|
|||
email = "anonymous"
|
||||
}
|
||||
|
||||
// Log access
|
||||
slog.Info("access",
|
||||
args := []any{
|
||||
"ts", start.Format(time.RFC3339),
|
||||
"email", email,
|
||||
"method", r.Method,
|
||||
|
|
@ -97,6 +107,15 @@ func AccessLogMiddleware(next http.Handler) http.Handler {
|
|||
"status", wrapped.status,
|
||||
"bytes", wrapped.bytes,
|
||||
"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...)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ func TestAccessLogReadsEmailFromACLContext(t *testing.T) {
|
|||
|
||||
// Correct order: ACL is outer, AccessLog is inner. AccessLog reads
|
||||
// email from the context ACL populated.
|
||||
chain := ACLMiddleware(cfg, AccessLogMiddleware(noop))
|
||||
chain := ACLMiddleware(cfg, AccessLogMiddleware(nil, noop))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
|
||||
|
|
@ -60,7 +60,7 @@ func TestAccessLogAnonymousWhenNoEmail(t *testing.T) {
|
|||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
chain := ACLMiddleware(cfg, AccessLogMiddleware(noop))
|
||||
chain := ACLMiddleware(cfg, AccessLogMiddleware(nil, noop))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
// Note: no X-Auth-Request-Email header set.
|
||||
|
|
@ -90,7 +90,7 @@ func TestAccessLogOuterDoesNotSeeInnerContext(t *testing.T) {
|
|||
})
|
||||
|
||||
// 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.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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue