Compare commits

..

No commits in common. "9a3e4d8fa7f0f3e2170aca45b4ef476f23767471" and "0fae93696dac6a7d56ddb8f0bdcbe9553ff4fbc0" have entirely different histories.

29 changed files with 104 additions and 517 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -383,7 +383,9 @@
if (!container) return; if (!container) return;
try { try {
// jszip + docx-preview vendored by build.sh — already in scope. 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');
const blob = await getFileBlob(file); const blob = await getFileBlob(file);
const arrayBuffer = await blob.arrayBuffer(); const arrayBuffer = await blob.arrayBuffer();

View file

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZDDC Classifier</title> <title>ZDDC Classifier</title>
<link rel="icon" type="image/svg+xml" href="{{FAVICON}}"> <link rel="icon" type="image/svg+xml" href="{{FAVICON}}">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<style> <style>
{{CSS_PLACEHOLDER}} {{CSS_PLACEHOLDER}}
</style> </style>

View file

@ -1792,7 +1792,7 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Markdown</span> <span class="app-header__title">ZDDC Markdown</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · 8df0def</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · 62ce6e9</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary" title="Add a local directory">Add Local 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> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh directory" aria-label="Refresh" style="font-size:1.1rem;"></button>

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

@ -20,9 +20,6 @@ import (
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler" "codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
"codeberg.org/VARASYS/ZDDC/zddc/internal/tlsutil" "codeberg.org/VARASYS/ZDDC/zddc/internal/tlsutil"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"github.com/klauspost/compress/gzhttp"
"gopkg.in/natefinch/lumberjack.v2"
) )
// version is the binary's own version, injected at build time via // version is the binary's own version, injected at build time via
@ -109,20 +106,13 @@ func main() {
// the context the outer ACL middleware set. // the context the outer ACL middleware set.
// CORSMiddleware — Origin / preflight handling. // CORSMiddleware — Origin / preflight handling.
// dispatch — the actual request handler. // dispatch — the actual request handler.
auditLogger := setupAccessAuditLog(cfg.AccessLog) mux.Handle("/", handler.ACLMiddleware(cfg, handler.AccessLogMiddleware(handler.CORSMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("/", handler.ACLMiddleware(cfg, handler.AccessLogMiddleware(auditLogger, handler.CORSMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
dispatch(cfg, idx, logRing, appsServer, w, r) dispatch(cfg, idx, logRing, appsServer, w, r)
}))))) })))))
gzWrapper, err := newGzipWrapper()
if err != nil {
slog.Error("gzhttp wrapper init", "err", err)
os.Exit(1)
}
srv := &http.Server{ srv := &http.Server{
Addr: cfg.Addr, Addr: cfg.Addr,
Handler: gzWrapper(mux), Handler: mux,
TLSConfig: tlsCfg, TLSConfig: tlsCfg,
} }
@ -156,58 +146,6 @@ func main() {
slog.Info("stopped") slog.Info("stopped")
} }
// setupAccessAuditLog constructs a slog.Logger writing JSON lines to a
// size-rotated file at the operator-configured path. Returns nil if no
// path is configured — AccessLogMiddleware then logs only to stderr
// (existing behavior).
//
// Rotation is via lumberjack: 100 MB per file, 10 backups, 90-day max
// age, gzip compression on rotated files. Tuning is fixed (not exposed
// as flags) — these defaults match what an audit-trail use case needs;
// operators wanting stricter retention can wire up logrotate against
// the rotated files themselves.
//
// File-permission posture: lumberjack creates new logs with mode 0600
// (running user only). For multi-user audit access, the operator should
// use group-readable parent directory permissions and either chmod the
// log out-of-band or run a forwarder that has its own read access.
// Parent directory must already exist — this function does NOT mkdir,
// since we'd need to assume too much about umask/owner.
func setupAccessAuditLog(path string) *slog.Logger {
if path == "" {
return nil
}
rotator := &lumberjack.Logger{
Filename: path,
MaxSize: 100, // megabytes per file before rotation
MaxBackups: 10,
MaxAge: 90, // days
Compress: true,
}
// JSON handler — line-delimited JSON is the format every standard
// log shipper (Vector, Loki promtail, fluentbit, journalbeat) parses
// natively, and stays grep-friendly for ad-hoc inspection.
h := slog.NewJSONHandler(rotator, &slog.HandlerOptions{Level: slog.LevelInfo})
slog.Info("access log file enabled",
"path", path, "max_size_mb", 100, "max_backups", 10, "max_age_days", 90)
return slog.New(h)
}
// newGzipWrapper builds the gzip middleware applied to the entire mux.
// MinSize(1024) skips compressing tiny responses where the framing
// overhead exceeds the savings (304 Not Modified, error pages, small
// JSON listings under ~1 KB). The wrapper honors Accept-Encoding (passes
// through unchanged when the client doesn't advertise gzip), appends
// Vary: Accept-Encoding automatically, and passes through 304s untouched.
// Yields ~75% size reduction on the larger embedded HTML responses
// (mdedit: 920 KB → ~250 KB on the wire).
//
// Extracted so tests can construct an equivalent wrapper without going
// through the full main() server boot.
func newGzipWrapper() (func(http.Handler) http.HandlerFunc, error) {
return gzhttp.NewWrapper(gzhttp.MinSize(1024))
}
// setupApps creates the cache + fetcher + server. No seeding, no refresh, // setupApps creates the cache + fetcher + server. No seeding, no refresh,
// no admin UI — the server fetches once on first request, caches forever // no admin UI — the server fetches once on first request, caches forever
// in <ZDDC_ROOT>/_app/, and falls back to the embedded HTML on any failure. // in <ZDDC_ROOT>/_app/, and falls back to the embedded HTML on any failure.

View file

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

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

@ -896,7 +896,7 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Browse</span> <span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · 8df0def</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · 62ce6e9</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing" style="font-size:1.1rem;"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing" style="font-size:1.1rem;"></button>

File diff suppressed because one or more lines are too long

View file

@ -885,7 +885,7 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC</span> <span class="app-header__title">ZDDC</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · 8df0def</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · 62ce6e9</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -1792,7 +1792,7 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Markdown</span> <span class="app-header__title">ZDDC Markdown</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · 8df0def</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · 62ce6e9</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary" title="Add a local directory">Add Local 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> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh directory" aria-label="Refresh" style="font-size:1.1rem;"></button>

File diff suppressed because one or more lines are too long

View file

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

View file

@ -1,13 +1,13 @@
package apps package apps
import ( import (
"crypto/sha256" "bytes"
"encoding/hex"
"errors" "errors"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
) )
@ -92,14 +92,14 @@ func (s *Server) Serve(w http.ResponseWriter, r *http.Request, app string, chain
// Malformed `.zddc` spec — operator's fault. Log and serve embedded. // Malformed `.zddc` spec — operator's fault. Log and serve embedded.
s.Fetcher.Logger.Warn("apps.Resolve failed; serving embedded", s.Fetcher.Logger.Warn("apps.Resolve failed; serving embedded",
"app", app, "request_dir", requestDir, "err", err) "app", app, "request_dir", requestDir, "err", err)
s.serveEmbedded(w, r, app, err) s.serveEmbedded(w, app, err)
return return
} }
if !hasOverride { if !hasOverride {
// No `.zddc apps:` entry anywhere up the chain and no `?v=` either → // No `.zddc apps:` entry anywhere up the chain and no `?v=` either →
// embedded is the authoritative default. // embedded is the authoritative default.
s.serveEmbedded(w, r, app, nil) s.serveEmbedded(w, app, nil)
return return
} }
@ -138,7 +138,7 @@ func (s *Server) Serve(w http.ResponseWriter, r *http.Request, app string, chain
s.Fetcher.Logger.Warn("path source unreadable; serving embedded", s.Fetcher.Logger.Warn("path source unreadable; serving embedded",
"app", app, "path", src.Path, "err", err) "app", app, "path", src.Path, "err", err)
} }
s.serveEmbedded(w, r, app, err) s.serveEmbedded(w, app, err)
return return
} }
s.serveBody(w, r, body, "path:"+src.Path) s.serveBody(w, r, body, "path:"+src.Path)
@ -149,7 +149,7 @@ func (s *Server) Serve(w http.ResponseWriter, r *http.Request, app string, chain
body, err := s.Fetcher.Fetch(r.Context(), src.URL) body, err := s.Fetcher.Fetch(r.Context(), src.URL)
if err != nil { if err != nil {
s.Fetcher.LogEmbeddedFallback(app, src.URL, err) s.Fetcher.LogEmbeddedFallback(app, src.URL, err)
s.serveEmbedded(w, r, app, err) s.serveEmbedded(w, app, err)
return return
} }
sourceTag := "fetch:" + src.URL sourceTag := "fetch:" + src.URL
@ -161,39 +161,14 @@ func (s *Server) Serve(w http.ResponseWriter, r *http.Request, app string, chain
s.serveBody(w, r, body, sourceTag) s.serveBody(w, r, body, sourceTag)
} }
// 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 (50920 KB depending on the tool).
func writeWithETag(w http.ResponseWriter, r *http.Request, body []byte, etag, contentType, sourceHeader string) {
quotedTag := `"` + etag + `"`
w.Header().Set("ETag", quotedTag)
w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
w.Header().Set("Content-Type", contentType)
w.Header().Set("X-ZDDC-Source", sourceHeader)
if match := r.Header.Get("If-None-Match"); match != "" && match == quotedTag {
w.WriteHeader(http.StatusNotModified)
return
}
_, _ = w.Write(body)
}
// 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) { 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) w.Header().Set("Content-Type", "text/html; charset=utf-8")
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))
} }
func (s *Server) serveEmbedded(w http.ResponseWriter, r *http.Request, app string, _ error) { func (s *Server) serveEmbedded(w http.ResponseWriter, app string, _ error) {
body := EmbeddedBytes(app) body := EmbeddedBytes(app)
if len(body) == 0 { if len(body) == 0 {
w.Header().Set("Retry-After", "60") w.Header().Set("Retry-After", "60")
@ -204,7 +179,8 @@ func (s *Server) serveEmbedded(w http.ResponseWriter, r *http.Request, app strin
http.StatusServiceUnavailable) http.StatusServiceUnavailable)
return return
} }
writeWithETag(w, r, body, EmbeddedETag(app), w.Header().Set("Content-Type", "text/html; charset=utf-8")
"text/html; charset=utf-8", w.Header().Set("X-ZDDC-Source", "embedded:"+app+"@"+s.BuildVer)
"embedded:"+app+"@"+s.BuildVer) w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate")
_, _ = w.Write(body)
} }

View file

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

View file

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

View file

@ -118,19 +118,12 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
} }
return return
} }
// ETag + max-age=0 + must-revalidate: every request re-validates and
// gets a 304 unless the binary has been redeployed (the ETag is a
// content hash, computed once at startup and memoized in apps.embed).
// Saves re-transmitting ~230 KB of browse.html on every page load
// while still picking up redeploys immediately.
etag := `"` + apps.EmbeddedETag("browse") + `"`
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-ZDDC-Source", "embedded:browse") w.Header().Set("X-ZDDC-Source", "embedded:browse")
if match := r.Header.Get("If-None-Match"); match != "" && match == etag { // no-cache here too — browse.html has session-tied content (the
w.WriteHeader(http.StatusNotModified) // directory listing it loads via fetch), and we want browser to
return // always re-validate so deployed-binary updates appear immediately
} // rather than after a 5-minute cache window.
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(body) _, _ = w.Write(body)
} }

View file

@ -741,7 +741,7 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="form-title">ZDDC Form</span> <span class="app-header__title" id="form-title">ZDDC Form</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · 8df0def</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · 62ce6e9</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

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

View file

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