diff --git a/zddc/internal/handler/zddceditor.go b/zddc/internal/handler/zddceditor.go index 6206a44..b040893 100644 --- a/zddc/internal/handler/zddceditor.go +++ b/zddc/internal/handler/zddceditor.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" + "codeberg.org/VARASYS/ZDDC/zddc/internal/apps" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) @@ -21,10 +22,20 @@ type editorView struct { HasCustomCSS bool File zddc.ZddcFile EffectiveChain []chainEntry + AppsRows []appsRow ProfilePathPrefix string // /.profile AssetsPathPrefix string // /.profile/zddc/assets } +// appsRow renders one line of the Apps section: the apps key (default or +// app name), its current value at THIS level (may be empty), and the +// preview of how it resolves once the cascade is applied. +type appsRow struct { + Key string // "default" or canonical app name + Value string // current spec at this .zddc level (empty = inherits) + ResolvesTo string // human-readable preview line +} + // serveZddcEditor renders the form-based .zddc editor at // GET /.profile/zddc/edit?path=. The form posts JSON back to // /.profile/zddc?path=; the inline JS shim handles dynamic-row @@ -70,6 +81,27 @@ func serveZddcEditor(cfg config.Config, w http.ResponseWriter, r *http.Request) }) } + // Apps rows: for default + each canonical app, show the current value at + // THIS level (zf.Apps[key]) and the resolved preview given the cascade. + // Default key first, then canonical apps in declared order. + keys := append([]string{zddc.AppsDefaultKey}, zddc.AppNames...) + rows := make([]appsRow, 0, len(keys)) + for _, k := range keys { + row := appsRow{Key: k, Value: zf.Apps[k]} + if k == zddc.AppsDefaultKey { + // "default" doesn't resolve to a single URL on its own — it's + // the baseline. Render a brief description. + if row.Value == "" { + row.ResolvesTo = "(unset — apps fall back to canonical " + apps.DefaultUpstreamReleases + " + " + apps.DefaultChannel + ")" + } else { + row.ResolvesTo = "baseline for any app not overridden below" + } + } else { + row.ResolvesTo = apps.PreviewLine(chain, k, cfg.Root, abs) + } + rows = append(rows, row) + } + view := editorView{ Path: urlPathOf(cfg.Root, abs), IsRoot: abs == cfg.Root, @@ -79,6 +111,7 @@ func serveZddcEditor(cfg config.Config, w http.ResponseWriter, r *http.Request) HasCustomCSS: hasCustomProfileCSS(cfg.Root), File: zf, EffectiveChain: entries, + AppsRows: rows, ProfilePathPrefix: ProfilePathPrefix, AssetsPathPrefix: zddcAssetsPathPrefix, } @@ -139,6 +172,14 @@ var editorTemplate = template.Must(template.New("editor").Parse(` .row { display: flex; gap: .5rem; align-items: center; margin-bottom: .35rem; } .row input[type="text"] { flex: 1; max-width: none; } .row .err { color: var(--danger); font-size: .85em; margin-left: .5rem; } + .apps-table { width: 100%; border-collapse: collapse; margin-top: .5rem; } + .apps-table th { text-align: left; font-weight: 600; padding: .35rem .5rem; border-bottom: 1px solid var(--border); color: var(--muted); font-size: .85em; } + .apps-table td { padding: .3rem .5rem; vertical-align: middle; border-bottom: 1px solid var(--border); } + .apps-table td.k { width: 8em; white-space: nowrap; } + .apps-table td.k code { font-weight: 600; } + .apps-table td.v input { max-width: none; width: 100%; } + .apps-table td.v .err { color: var(--danger); font-size: .85em; display: block; margin-top: .15rem; } + .apps-table td.r { font-size: .85em; word-break: break-all; } button { font: inherit; padding: .35rem .85rem; cursor: pointer; border: 1px solid var(--border); background: var(--bg); color: var(--text); border-radius: var(--radius); } button:hover { background: var(--primary-bg); } button.primary { background: var(--primary); color: white; border-color: var(--primary); } @@ -212,6 +253,36 @@ var editorTemplate = template.Must(template.New("editor").Parse(` +
+

Apps (tool HTML sources)

+

+ Override which build of each tool the server serves at this directory and below. + Spec is one of: stable / beta / alpha, + v0.0.4 / v0.0 / v0 (canonical upstream), + https://my-mirror/releases (URL prefix — composes with channel from default), + https://my-mirror/releases:beta (URL prefix + channel), + :beta (channel-only override of default's URL), + https://my-fork/archive.html (terminal full URL), + ./local.html or /abs/path.html (terminal local file). + Leave any row blank to inherit from a parent .zddc file. + The default row provides the baseline URL prefix and channel for any app not overridden per-name. +

+

+ Per-request override: any user can append ?v=<spec> to a tool URL (e.g. ?v=beta, ?v=v0.0.4, ?v=:alpha) to ask for a different build for one request. Security: ?v= serves only versions already in the cache (<ZDDC_ROOT>/_app/); cache misses return 404 so users can't trigger arbitrary upstream fetches. Local-path specs are also rejected from ?v=. +

+ + + + {{ range .AppsRows }} + + + + {{ end }} + +
KeyValueResolves to
{{ .Key }}{{ .ResolvesTo }}
+

The Resolves to column reflects the saved state of the cascade — save and reload to see how edits compose.

+
+
Effective chain (inherited rules) @@ -269,11 +340,16 @@ admins:{{ range .Admins }} {{ . }}{{ end }}
{{ end }} }); function collect() { - var out = { title: "", acl: { allow: [], deny: [] }, admins: [] }; + var out = { title: "", acl: { allow: [], deny: [] }, admins: [], apps: {} }; out.title = document.getElementById("f-title").value; document.querySelectorAll('.list[data-field="acl.allow"] input').forEach(function(i) { if (i.value.trim()) out.acl.allow.push(i.value.trim()); }); document.querySelectorAll('.list[data-field="acl.deny"] input').forEach(function(i) { if (i.value.trim()) out.acl.deny.push(i.value.trim()); }); document.querySelectorAll('.list[data-field="admins"] input').forEach(function(i) { if (i.value.trim()) out.admins.push(i.value.trim()); }); + document.querySelectorAll('input[data-apps-key]').forEach(function(i) { + var k = i.dataset.appsKey; + var v = i.value.trim(); + if (v) out.apps[k] = v; + }); return out; } @@ -284,6 +360,16 @@ admins:{{ range .Admins }} {{ . }}{{ end }}{{ end }} function showErrors(errs) { errs.forEach(function(e) { + // Apps fields look like "apps." — surface inline next to the row. + if (e.field.indexOf("apps.") === 0) { + var key = e.field.substring("apps.".length); + var input = document.querySelector('input[data-apps-key="' + CSS.escape(key) + '"]'); + if (input) { + var span = input.parentElement.querySelector(".err"); + if (span) span.textContent = e.message; + return; + } + } var sel = '[data-field="' + CSS.escape(e.field) + '"]'; var input = document.querySelector(sel); if (input) { @@ -291,7 +377,6 @@ admins:{{ range .Admins }} {{ . }}{{ end }}{{ end }} if (span) span.textContent = e.message; } else { // Top-level field error (e.g. "admins" without index, or "title"). - var card = document.querySelector('h2 + .help, [name="' + e.field + '"]'); alert(e.field + ": " + e.message); } }); diff --git a/zddc/internal/handler/zddchandler.go b/zddc/internal/handler/zddchandler.go index 644bc5b..2efd6c5 100644 --- a/zddc/internal/handler/zddchandler.go +++ b/zddc/internal/handler/zddchandler.go @@ -145,9 +145,10 @@ type zddcGetResponse struct { } type zddcWriteRequest struct { - Title string `json:"title"` - ACL zddc.ACLRules `json:"acl"` - Admins []string `json:"admins"` + Title string `json:"title"` + ACL zddc.ACLRules `json:"acl"` + Admins []string `json:"admins"` + Apps map[string]string `json:"apps,omitempty"` } type writeError struct { @@ -261,6 +262,7 @@ func serveZddcWrite(cfg config.Config, abs, email string, w http.ResponseWriter, Title: req.Title, ACL: req.ACL, Admins: req.Admins, + Apps: req.Apps, } if errs := zddc.ValidateFile(zf); len(errs) > 0 { diff --git a/zddc/internal/handler/zddchandler_test.go b/zddc/internal/handler/zddchandler_test.go index f401fdb..613637c 100644 --- a/zddc/internal/handler/zddchandler_test.go +++ b/zddc/internal/handler/zddchandler_test.go @@ -214,6 +214,81 @@ func TestServeZddcWriteRoundTrip(t *testing.T) { } } +func TestServeZddcWriteAppsRoundTrip(t *testing.T) { + _, do := zddcTestSetup(t, map[string]string{ + "": "admins:\n - root@example.com\n", + "projects": "", + }) + body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":[],"apps":{` + + `"default":"https://zddc.varasys.io/releases:stable",` + + `"classifier":":beta",` + + `"archive":"https://my.local.stuff/releases"}}` + rec := do(http.MethodPost, "/.profile/zddc?path=/projects", "root@example.com", body) + if rec.Code != http.StatusOK { + t.Fatalf("write status=%d body=%s", rec.Code, rec.Body.String()) + } + rec = do(http.MethodGet, "/.profile/zddc?path=/projects", "root@example.com", "") + if rec.Code != http.StatusOK { + t.Fatalf("get status=%d body=%s", rec.Code, rec.Body.String()) + } + var resp zddcGetResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if got := resp.File.Apps["default"]; got != "https://zddc.varasys.io/releases:stable" { + t.Errorf("default round-trip = %q", got) + } + if got := resp.File.Apps["classifier"]; got != ":beta" { + t.Errorf("classifier round-trip = %q", got) + } + if got := resp.File.Apps["archive"]; got != "https://my.local.stuff/releases" { + t.Errorf("archive round-trip = %q", got) + } +} + +func TestServeZddcWriteAppsRejectsBadSpec(t *testing.T) { + _, do := zddcTestSetup(t, map[string]string{ + "": "admins:\n - root@example.com\n", + "projects": "", + }) + body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":[],"apps":{"archive":"this is garbage"}}` + rec := do(http.MethodPost, "/.profile/zddc?path=/projects", "root@example.com", body) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d (want 400)", rec.Code) + } + if !strings.Contains(rec.Body.String(), `"apps.archive"`) { + t.Errorf("expected per-field error for apps.archive; got %s", rec.Body.String()) + } +} + +func TestServeZddcEditorRendersAppsSection(t *testing.T) { + _, do := zddcTestSetup(t, map[string]string{ + "": "admins:\n - root@example.com\n", + "projects": "apps:\n default: \":beta\"\n classifier: \"v0.0.4\"\n", + }) + rec := do(http.MethodGet, "/.profile/zddc/edit?path=/projects", "root@example.com", "") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, want := range []string{ + "Apps (tool HTML sources)", + `data-apps-key="default"`, + `data-apps-key="archive"`, + `data-apps-key="classifier"`, + `data-apps-key="mdedit"`, + `data-apps-key="transmittal"`, + `data-apps-key="landing"`, + `value=":beta"`, + `value="v0.0.4"`, + "classifier_v0.0.4.html", // preview reflects the cascaded resolution + } { + if !strings.Contains(body, want) { + t.Errorf("editor body missing %q", want) + } + } +} + func TestServeZddcTreeFiltersByVisibility(t *testing.T) { _, do := zddcTestSetup(t, map[string]string{ "": "admins:\n - root@example.com\n",