feat(server): /_apps/ — virtual public directory of standalone tool HTMLs
A no-auth virtual folder so anyone can grab a tool and run it against their own local filesystem: GET /_apps/ is an index (Download / Open links); GET /_apps/<tool>.html serves that tool's HTML (?download forces a save). Prefers the site .zddc.zip bundle member (freshest), falls back to the binary's embedded copy; tables/form come from the embedded tables bundle. Carries no data, so it's served before the ACL/cascade and the reserved-prefix guard; `_`-prefixed + virtual means no collision with content. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3d02084397
commit
237c353845
3 changed files with 181 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
126
zddc/internal/handler/appsvirtual.go
Normal file
126
zddc/internal/handler/appsvirtual.go
Normal file
|
|
@ -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/<file> 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/<tool>.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(`<!doctype html><html lang="en"><head><meta charset="utf-8">`)
|
||||
b.WriteString(`<meta name="viewport" content="width=device-width, initial-scale=1">`)
|
||||
b.WriteString(`<title>ZDDC standalone apps</title><style>`)
|
||||
b.WriteString(`body{font-family:system-ui,-apple-system,sans-serif;max-width:48rem;margin:2rem auto;padding:0 1rem;line-height:1.5;color:#222}`)
|
||||
b.WriteString(`h1{font-size:1.4rem;margin:0 0 .3rem}.lead{color:#555;font-size:.92rem;margin:0 0 1.2rem}`)
|
||||
b.WriteString(`.app{border:1px solid #e2e2e2;border-radius:8px;padding:.7rem 1rem;margin:.55rem 0}`)
|
||||
b.WriteString(`.app h2{font-size:1.05rem;margin:0 0 .15rem}.app p{margin:.15rem 0 .55rem;color:#555;font-size:.88rem}`)
|
||||
b.WriteString(`a.btn{display:inline-block;padding:.28rem .7rem;border:1px solid #2868c8;border-radius:5px;color:#2868c8;text-decoration:none;margin-right:.4rem;font-size:.85rem}`)
|
||||
b.WriteString(`a.btn:hover{background:#2868c8;color:#fff}`)
|
||||
b.WriteString(`</style></head><body>`)
|
||||
b.WriteString(`<h1>ZDDC standalone apps</h1>`)
|
||||
b.WriteString(`<p class="lead">Each tool is a single, self-contained HTML file. <strong>Download</strong> 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). <strong>Open</strong> runs it here, against the server.</p>`)
|
||||
for _, a := range standaloneApps {
|
||||
b.WriteString(`<div class="app"><h2>` + html.EscapeString(a.Name) + `</h2>`)
|
||||
b.WriteString(`<p>` + html.EscapeString(a.Desc) + `</p>`)
|
||||
b.WriteString(`<a class="btn" href="` + AppsVirtualPrefix + a.File + `?download=1" download="` + a.File + `">Download</a>`)
|
||||
b.WriteString(`<a class="btn" href="` + AppsVirtualPrefix + a.File + `" target="_blank" rel="noopener">Open</a>`)
|
||||
b.WriteString(`</div>`)
|
||||
}
|
||||
b.WriteString(`</body></html>`)
|
||||
_, _ = w.Write([]byte(b.String()))
|
||||
}
|
||||
42
zddc/internal/handler/appsvirtual_test.go
Normal file
42
zddc/internal/handler/appsvirtual_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue