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"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||||
)
|
)
|
||||||
|
|
@ -21,10 +22,20 @@ type editorView struct {
|
||||||
HasCustomCSS bool
|
HasCustomCSS bool
|
||||||
File zddc.ZddcFile
|
File zddc.ZddcFile
|
||||||
EffectiveChain []chainEntry
|
EffectiveChain []chainEntry
|
||||||
|
AppsRows []appsRow
|
||||||
ProfilePathPrefix string // /.profile
|
ProfilePathPrefix string // /.profile
|
||||||
AssetsPathPrefix string // /.profile/zddc/assets
|
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
|
// serveZddcEditor renders the form-based .zddc editor at
|
||||||
// GET /.profile/zddc/edit?path=<dir>. The form posts JSON back to
|
// GET /.profile/zddc/edit?path=<dir>. The form posts JSON back to
|
||||||
// /.profile/zddc?path=<dir>; the inline JS shim handles dynamic-row
|
// /.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{
|
view := editorView{
|
||||||
Path: urlPathOf(cfg.Root, abs),
|
Path: urlPathOf(cfg.Root, abs),
|
||||||
IsRoot: abs == cfg.Root,
|
IsRoot: abs == cfg.Root,
|
||||||
|
|
@ -79,6 +111,7 @@ func serveZddcEditor(cfg config.Config, w http.ResponseWriter, r *http.Request)
|
||||||
HasCustomCSS: hasCustomProfileCSS(cfg.Root),
|
HasCustomCSS: hasCustomProfileCSS(cfg.Root),
|
||||||
File: zf,
|
File: zf,
|
||||||
EffectiveChain: entries,
|
EffectiveChain: entries,
|
||||||
|
AppsRows: rows,
|
||||||
ProfilePathPrefix: ProfilePathPrefix,
|
ProfilePathPrefix: ProfilePathPrefix,
|
||||||
AssetsPathPrefix: zddcAssetsPathPrefix,
|
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 { display: flex; gap: .5rem; align-items: center; margin-bottom: .35rem; }
|
||||||
.row input[type="text"] { flex: 1; max-width: none; }
|
.row input[type="text"] { flex: 1; max-width: none; }
|
||||||
.row .err { color: var(--danger); font-size: .85em; margin-left: .5rem; }
|
.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 { 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:hover { background: var(--primary-bg); }
|
||||||
button.primary { background: var(--primary); color: white; border-color: var(--primary); }
|
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>
|
<button type="button" class="add" data-target="admins">+ Add admin</button>
|
||||||
</section>
|
</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">
|
<section class="card chain">
|
||||||
<details {{ if not .Exists }}open{{ end }}>
|
<details {{ if not .Exists }}open{{ end }}>
|
||||||
<summary>Effective chain (inherited rules)</summary>
|
<summary>Effective chain (inherited rules)</summary>
|
||||||
|
|
@ -269,11 +340,16 @@ admins:{{ range .Admins }} {{ . }}{{ end }}</pre></details>{{ end }}
|
||||||
});
|
});
|
||||||
|
|
||||||
function collect() {
|
function collect() {
|
||||||
var out = { title: "", acl: { allow: [], deny: [] }, admins: [] };
|
var out = { title: "", acl: { allow: [], deny: [] }, admins: [], apps: {} };
|
||||||
out.title = document.getElementById("f-title").value;
|
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.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="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('.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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -284,6 +360,16 @@ admins:{{ range .Admins }} {{ . }}{{ end }}</pre></details>{{ end }}
|
||||||
|
|
||||||
function showErrors(errs) {
|
function showErrors(errs) {
|
||||||
errs.forEach(function(e) {
|
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 sel = '[data-field="' + CSS.escape(e.field) + '"]';
|
||||||
var input = document.querySelector(sel);
|
var input = document.querySelector(sel);
|
||||||
if (input) {
|
if (input) {
|
||||||
|
|
@ -291,7 +377,6 @@ admins:{{ range .Admins }} {{ . }}{{ end }}</pre></details>{{ end }}
|
||||||
if (span) span.textContent = e.message;
|
if (span) span.textContent = e.message;
|
||||||
} else {
|
} else {
|
||||||
// Top-level field error (e.g. "admins" without index, or "title").
|
// 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);
|
alert(e.field + ": " + e.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -145,9 +145,10 @@ type zddcGetResponse struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type zddcWriteRequest struct {
|
type zddcWriteRequest struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
ACL zddc.ACLRules `json:"acl"`
|
ACL zddc.ACLRules `json:"acl"`
|
||||||
Admins []string `json:"admins"`
|
Admins []string `json:"admins"`
|
||||||
|
Apps map[string]string `json:"apps,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type writeError struct {
|
type writeError struct {
|
||||||
|
|
@ -261,6 +262,7 @@ func serveZddcWrite(cfg config.Config, abs, email string, w http.ResponseWriter,
|
||||||
Title: req.Title,
|
Title: req.Title,
|
||||||
ACL: req.ACL,
|
ACL: req.ACL,
|
||||||
Admins: req.Admins,
|
Admins: req.Admins,
|
||||||
|
Apps: req.Apps,
|
||||||
}
|
}
|
||||||
|
|
||||||
if errs := zddc.ValidateFile(zf); len(errs) > 0 {
|
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) {
|
func TestServeZddcTreeFiltersByVisibility(t *testing.T) {
|
||||||
_, do := zddcTestSetup(t, map[string]string{
|
_, do := zddcTestSetup(t, map[string]string{
|
||||||
"": "admins:\n - root@example.com\n",
|
"": "admins:\n - root@example.com\n",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue