diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 1c9c107..dd7c1c7 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -807,6 +807,19 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps return } + // /_apps/ — virtual, public directory of the standalone tool HTMLs, so + // people can download one and run it against their own local filesystem. + // Tool UI only, no data, no auth. Before the reserved-prefix ('_'/'.') + // guard so it isn't 404'd. + if urlPath == "/_apps" { + http.Redirect(w, r, handler.AppsVirtualPrefix, http.StatusMovedPermanently) + return + } + if strings.HasPrefix(urlPath, handler.AppsVirtualPrefix) { + handler.ServeApps(appsSrv, w, r) + return + } + // Auth check endpoints — machine-only forward_auth targets used by // upstream proxies (e.g. the dev-shell pod's Caddy in front of // code-server) to gate routes on root-admin status. Handled before diff --git a/zddc/internal/handler/appsvirtual.go b/zddc/internal/handler/appsvirtual.go new file mode 100644 index 0000000..b550527 --- /dev/null +++ b/zddc/internal/handler/appsvirtual.go @@ -0,0 +1,126 @@ +package handler + +import ( + "html" + "net/http" + "strings" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/apps" +) + +// AppsVirtualPrefix is a virtual, public directory that serves the standalone +// tool HTMLs so anyone can grab one and run it against their OWN local +// filesystem — download it, open it from disk, and it runs offline via the +// browser's File System Access picker. It carries no project data, so it's +// served with no auth/ACL. `_`-prefixed names are system-reserved (mkdir +// rejects them), so /_apps/ never collides with content, and it's virtual — +// nothing exists on disk. +const AppsVirtualPrefix = "/_apps/" + +type standaloneApp struct { + File string // URL + filename, e.g. "classifier.html" + Name string + Desc string +} + +// The order here is the order shown on the index; "local-first" tools lead. +var standaloneApps = []standaloneApp{ + {"classifier.html", "Classifier", "Rename a local folder of files to ZDDC naming conventions."}, + {"browse.html", "Browse", "Browse + edit a local directory tree — markdown, YAML, CSV, and more."}, + {"transmittal.html", "Transmittal", "Assemble a transmittal package from local files."}, + {"tables.html", "Tables", "View and edit a directory of YAML rows as a sortable table."}, + {"form.html", "Form", "Edit a single YAML record with a schema-driven form."}, + {"archive.html", "Archive", "Review an archive of ZDDC-named files."}, +} + +// appBytesForFile returns the HTML for a /_apps/ request, or nil for an +// unknown file. It prefers the site .zddc.zip bundle member (operator +// override / the freshest dev build) and falls back to the binary's embedded +// copy. tables + form share the one embedded tables/form bundle. +func appBytesForFile(appsSrv *apps.Server, file string) []byte { + if appsSrv != nil && appsSrv.Bundle != nil { + if b, ok := appsSrv.Bundle.Member(file); ok && len(b) > 0 { + return b + } + } + switch file { + case "classifier.html": + return apps.EmbeddedBytes("classifier") + case "browse.html": + return apps.EmbeddedBytes("browse") + case "transmittal.html": + return apps.EmbeddedBytes("transmittal") + case "archive.html": + return apps.EmbeddedBytes("archive") + case "tables.html", "form.html": + return EmbeddedTablesHTML() + } + return nil +} + +// ServeApps handles GET/HEAD under /_apps/. "/_apps/" → an index of the +// standalone tools (Download / Open links); "/_apps/.html" → that tool's +// embedded HTML. Append ?download to force a save dialog. No auth — tool UI +// only, no data. +func ServeApps(appsSrv *apps.Server, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + rest := strings.TrimPrefix(r.URL.Path, AppsVirtualPrefix) + if rest == "" || rest == "index" { + serveAppsIndex(w, r) + return + } + if strings.Contains(rest, "/") { + http.NotFound(w, r) + return + } + body := appBytesForFile(appsSrv, rest) + if body == nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + // Conditional-GET-friendly: revalidate each load; bytes change only on a + // binary redeploy. (Index is no-store; it's tiny + generated.) + w.Header().Set("Cache-Control", "max-age=0, must-revalidate") + if r.URL.Query().Get("download") != "" { + w.Header().Set("Content-Disposition", `attachment; filename="`+rest+`"`) + } + if r.Method == http.MethodHead { + return + } + _, _ = w.Write(body) +} + +func serveAppsIndex(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + if r.Method == http.MethodHead { + return + } + var b strings.Builder + b.WriteString(``) + b.WriteString(``) + b.WriteString(`ZDDC standalone apps`) + b.WriteString(`

ZDDC standalone apps

`) + b.WriteString(`

Each tool is a single, self-contained HTML file. Download one and open it from your disk to run it offline against a folder on your own machine (use a Chromium-family browser — it needs the File System Access API). Open runs it here, against the server.

`) + for _, a := range standaloneApps { + b.WriteString(`

` + html.EscapeString(a.Name) + `

`) + b.WriteString(`

` + html.EscapeString(a.Desc) + `

`) + b.WriteString(`Download`) + b.WriteString(`Open`) + b.WriteString(`
`) + } + b.WriteString(``) + _, _ = w.Write([]byte(b.String())) +} diff --git a/zddc/internal/handler/appsvirtual_test.go b/zddc/internal/handler/appsvirtual_test.go new file mode 100644 index 0000000..5451cee --- /dev/null +++ b/zddc/internal/handler/appsvirtual_test.go @@ -0,0 +1,42 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestServeApps(t *testing.T) { + // Index lists the tools. + rec := httptest.NewRecorder() + ServeApps(nil, rec, httptest.NewRequest(http.MethodGet, AppsVirtualPrefix, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("index: want 200, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Classifier") { + t.Errorf("index should list Classifier") + } + + // A known tool resolves to HTML (embedded bytes may be empty in a fresh + // checkout, so accept 200 with a body OR 404 only when the slot is empty). + rec = httptest.NewRecorder() + ServeApps(nil, rec, httptest.NewRequest(http.MethodGet, AppsVirtualPrefix+"classifier.html", nil)) + if rec.Code != http.StatusOK && rec.Code != http.StatusNotFound { + t.Errorf("classifier.html: unexpected %d", rec.Code) + } + + // Unknown name → 404. + rec = httptest.NewRecorder() + ServeApps(nil, rec, httptest.NewRequest(http.MethodGet, AppsVirtualPrefix+"nope.html", nil)) + if rec.Code != http.StatusNotFound { + t.Errorf("unknown: want 404, got %d", rec.Code) + } + + // Path traversal / subpath → 404. + rec = httptest.NewRecorder() + ServeApps(nil, rec, httptest.NewRequest(http.MethodGet, AppsVirtualPrefix+"a/b.html", nil)) + if rec.Code != http.StatusNotFound { + t.Errorf("subpath: want 404, got %d", rec.Code) + } +}