ZDDC/zddc/internal/handler/appsvirtual.go
2026-06-11 13:32:31 -05:00

126 lines
5.5 KiB
Go

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()))
}