feat(zddc-server): apps section in .zddc editor
Extends the form-based .zddc editor at /.profile/zddc/edit?path=<dir> with an Apps section between Admins and the Effective chain. The section is a six-row table — default plus the five canonical apps — with one text input per row. Each row's right column shows a server- rendered "Resolves to" preview computed by walking the cascade through this directory and applying default + per-app composition. The preview displays the final URL, "embedded (build-time default)", or "local file: <path>" so operators see exactly what will be served. Help text covers the full spec syntax (channel/version/URL/path forms, :channel shorthand, default key) plus the ?v= per-request override and its cache-only security constraint. Permission gating is unchanged: existing CanEditZddc() strict-ancestor rule applies — subtree admins cannot edit the file that grants their own authority. Field-level errors land inline next to the input, just like the existing ACL/admins fields. POST handler (internal/handler/zddchandler.go) accepts a new Apps map in the JSON write request, validates via the existing zddc.ValidateFile flow (which now enforces apps.<name> spec syntax), and writes atomically through the unchanged zddc.WriteFile path. Three new tests: round-trip apps including the default key, per-field validation error returns, and editor renders the apps section with existing .zddc values pre-filled. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8b6a2dc3e3
commit
7b764956bd
3 changed files with 167 additions and 5 deletions
|
|
@ -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=<dir>. The form posts JSON back to
|
||||
// /.profile/zddc?path=<dir>; 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(`<!DOCTYPE html>
|
|||
.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(`<!DOCTYPE html>
|
|||
<button type="button" class="add" data-target="admins">+ Add admin</button>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Apps (tool HTML sources)</h2>
|
||||
<p class="help">
|
||||
Override which build of each tool the server serves at this directory and below.
|
||||
Spec is one of: <code>stable</code> / <code>beta</code> / <code>alpha</code>,
|
||||
<code>v0.0.4</code> / <code>v0.0</code> / <code>v0</code> (canonical upstream),
|
||||
<code>https://my-mirror/releases</code> (URL prefix — composes with channel from <code>default</code>),
|
||||
<code>https://my-mirror/releases:beta</code> (URL prefix + channel),
|
||||
<code>:beta</code> (channel-only override of <code>default</code>'s URL),
|
||||
<code>https://my-fork/archive.html</code> (terminal full URL),
|
||||
<code>./local.html</code> or <code>/abs/path.html</code> (terminal local file).
|
||||
Leave any row blank to inherit from a parent <code>.zddc</code> file.
|
||||
The <code>default</code> row provides the baseline URL prefix and channel for any app not overridden per-name.
|
||||
</p>
|
||||
<p class="help muted">
|
||||
Per-request override: any user can append <code>?v=<spec></code> to a tool URL (e.g. <code>?v=beta</code>, <code>?v=v0.0.4</code>, <code>?v=:alpha</code>) to ask for a different build for one request. <strong>Security:</strong> <code>?v=</code> serves only versions already in the cache (<code><ZDDC_ROOT>/_app/</code>); cache misses return 404 so users can't trigger arbitrary upstream fetches. Local-path specs are also rejected from <code>?v=</code>.
|
||||
</p>
|
||||
<table class="apps-table">
|
||||
<thead><tr><th class="k">Key</th><th class="v">Value</th><th class="r">Resolves to</th></tr></thead>
|
||||
<tbody>
|
||||
{{ range .AppsRows }}<tr>
|
||||
<td class="k"><code>{{ .Key }}</code></td>
|
||||
<td class="v"><input type="text" data-apps-key="{{ .Key }}" value="{{ .Value }}" placeholder="(inherit)"><span class="err"></span></td>
|
||||
<td class="r"><span class="muted">{{ .ResolvesTo }}</span></td>
|
||||
</tr>{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="help muted">The <em>Resolves to</em> column reflects the saved state of the cascade — save and reload to see how edits compose.</p>
|
||||
</section>
|
||||
|
||||
<section class="card chain">
|
||||
<details {{ if not .Exists }}open{{ end }}>
|
||||
<summary>Effective chain (inherited rules)</summary>
|
||||
|
|
@ -269,11 +340,16 @@ admins:{{ range .Admins }} {{ . }}{{ end }}</pre></details>{{ 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 }}</pre></details>{{ end }}
|
|||
|
||||
function showErrors(errs) {
|
||||
errs.forEach(function(e) {
|
||||
// Apps fields look like "apps.<key>" — 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 }}</pre></details>{{ 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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue