ZDDC/zddc/internal/handler/zddceditor.go
ZDDC 7b764956bd 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>
2026-05-01 15:25:42 -05:00

430 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
import (
"html/template"
"net/http"
"os"
"path/filepath"
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// editorView is the data passed to the editor template. Field naming is
// kept short for template ergonomics.
type editorView struct {
Path string
IsRoot bool
CanEdit bool
Exists bool
Email string
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
// add/remove and surfaces field errors from the JSON response.
func serveZddcEditor(cfg config.Config, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
email := EmailFromContext(r)
abs, err := resolvePath(cfg.Root, r.URL.Query().Get("path"))
if err != nil {
http.NotFound(w, r)
return
}
zf, err := zddc.ParseFile(filepath.Join(abs, ".zddc"))
if err != nil {
http.Error(w, "Cannot parse existing .zddc: "+err.Error(), http.StatusBadRequest)
return
}
exists := false
if _, err := os.Stat(filepath.Join(abs, ".zddc")); err == nil {
exists = true
}
chain, _ := zddc.EffectivePolicy(cfg.Root, abs)
dirs := chainDirs(cfg.Root, abs)
entries := make([]chainEntry, 0, len(chain.Levels))
for i, level := range chain.Levels {
levelDir := dirs[i]
levelExists := false
if _, err := os.Stat(filepath.Join(levelDir, ".zddc")); err == nil {
levelExists = true
}
entries = append(entries, chainEntry{
Dir: urlPathOf(cfg.Root, levelDir),
Exists: levelExists,
Title: level.Title,
ACL: level.ACL,
Admins: level.Admins,
})
}
// 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,
CanEdit: zddc.CanEditZddc(cfg.Root, abs, email),
Exists: exists,
Email: email,
HasCustomCSS: hasCustomProfileCSS(cfg.Root),
File: zf,
EffectiveChain: entries,
AppsRows: rows,
ProfilePathPrefix: ProfilePathPrefix,
AssetsPathPrefix: zddcAssetsPathPrefix,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if err := editorTemplate.Execute(w, view); err != nil {
// Headers may already be flushed; best effort.
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// editorTemplate is the html/template body for the editor page.
//
// Style choices:
// - inline CSS uses the same custom-property naming as shared/base.css
// so a future server-side merge with shared/base.css remains trivial.
// - inline JS is one IIFE, ~80 lines, handling: add/remove row,
// collect-into-JSON-on-submit, render server-side field errors.
// - the form falls back to a plain HTTP POST (urlencoded) without JS;
// a tiny same-handler endpoint accepts urlencoded too. (V1: JS only;
// no-JS fallback is documented as a TODO in the file header.)
var editorTemplate = template.Must(template.New("editor").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>.zddc editor — {{ .Path }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--bg: #fff; --bg-alt: #f7f7f8; --text: #222; --muted: #666;
--border: #d0d4dc; --primary: #2563eb; --primary-bg: #eff6ff;
--danger: #b00020; --warn: #b15c00; --ok: #0a7d2c;
--radius: 4px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1c1f; --bg-alt: #23262b; --text: #e8e8ea; --muted: #a0a4ad;
--border: #353941; --primary: #60a5fa; --primary-bg: #1e293b;
--danger: #ff7080; --warn: #f5b056; --ok: #4ad27c;
}
}
body { font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; margin: 1.5rem; color: var(--text); background: var(--bg); max-width: 980px; }
h1 { margin: 0 0 .25rem; font-size: 1.4rem; }
h2 { margin: 1.5rem 0 .25rem; font-size: 1.05rem; }
.muted { color: var(--muted); }
.breadcrumb { color: var(--muted); margin-bottom: 1rem; }
.breadcrumb a { color: var(--primary); text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
.ro-banner { background: var(--bg-alt); border: 1px solid var(--border); border-radius: var(--radius); padding: .6rem .9rem; margin-bottom: 1rem; }
.ro-banner.read-only { border-color: var(--warn); }
.card { background: var(--bg-alt); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem 1.1rem; margin-bottom: 1rem; }
.card h2 { margin-top: 0; }
.card .help { color: var(--muted); font-size: .9em; margin: .3rem 0 .6rem; }
label { display: block; margin-bottom: .5rem; }
input[type="text"] { width: 100%; max-width: 32rem; padding: .35rem .5rem; font: inherit; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); box-sizing: border-box; }
input[type="text"]:focus { outline: 2px solid var(--primary); outline-offset: -1px; }
.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); }
button.primary:hover { filter: brightness(1.1); }
button.danger { color: var(--danger); border-color: var(--danger); }
button.danger:hover { background: rgba(176, 0, 32, 0.06); }
.actions { display: flex; gap: .5rem; align-items: center; margin-top: 1.2rem; }
.actions .spacer { flex: 1; }
.chain { font-size: .9em; }
.chain details { margin-bottom: .25rem; }
.chain summary { cursor: pointer; }
.chain pre { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: .5rem .7rem; margin: .35rem 0 .5rem; overflow-x: auto; }
.ok-banner { background: var(--primary-bg); border: 1px solid var(--primary); border-radius: var(--radius); padding: .55rem .85rem; margin-bottom: 1rem; color: var(--text); }
fieldset[disabled] input, fieldset[disabled] button { opacity: .55; cursor: not-allowed; }
code { background: var(--bg); padding: 0 .25rem; border-radius: 2px; font-size: 12px; border: 1px solid var(--border); }
</style>
{{ if .HasCustomCSS }}<link rel="stylesheet" href="{{ .AssetsPathPrefix }}/custom.css">{{ end }}
</head>
<body>
<div class="breadcrumb">
<a href="{{ .ProfilePathPrefix }}/">&larr; profile</a> &nbsp;/&nbsp;
<a href="{{ .ProfilePathPrefix }}/zddc/edit?path=/">root</a>{{ if not .IsRoot }} &nbsp;/&nbsp; <span>{{ .Path }}</span>{{ end }}
</div>
<h1>.zddc editor</h1>
<p class="muted">{{ if .IsRoot }}Editing the root <code>/.zddc</code>.{{ else }}Editing <code>{{ .Path }}/.zddc</code>.{{ end }} You are signed in as <code>{{ .Email }}</code>.</p>
{{ if not .CanEdit }}
<div class="ro-banner read-only"><strong>Read-only.</strong> You can view this file's contents and the inherited rules below, but you do not have permission to edit it. Subtree admins cannot edit the .zddc file that grants their own authority — only an admin from a higher level can.</div>
{{ end }}
<div id="ok-banner" class="ok-banner" hidden>Saved.</div>
<form id="editor" autocomplete="off">
<fieldset {{ if not .CanEdit }}disabled{{ end }} style="border:0; padding:0; margin:0;">
<section class="card">
<h2>Title</h2>
<p class="help">Surfaced on the project picker for this folder. Optional — projects without a title show their directory name.</p>
<input type="text" name="title" id="f-title" maxlength="200" value="{{ .File.Title }}">
</section>
<section class="card">
<h2>ACL — Allow</h2>
<p class="help">Email-glob patterns for users granted access here. Examples: <code>*@example.com</code>, <code>alice@*</code>, <code>alice@example.com</code>. <code>*</code> matches any non-empty email but does not cross the <code>@</code> boundary.</p>
<div class="list" data-field="acl.allow">
{{ range $i, $v := .File.ACL.Allow }}<div class="row"><input type="text" data-field="acl.allow[{{ $i }}]" value="{{ $v }}"><button type="button" class="del"></button><span class="err"></span></div>{{ end }}
</div>
<button type="button" class="add" data-target="acl.allow">+ Add allow rule</button>
</section>
<section class="card">
<h2>ACL — Deny</h2>
<p class="help">Deny is checked first; a parent allow cannot override a deeper deny. Same glob syntax as Allow.</p>
<div class="list" data-field="acl.deny">
{{ range $i, $v := .File.ACL.Deny }}<div class="row"><input type="text" data-field="acl.deny[{{ $i }}]" value="{{ $v }}"><button type="button" class="del"></button><span class="err"></span></div>{{ end }}
</div>
<button type="button" class="add" data-target="acl.deny">+ Add deny rule</button>
</section>
<section class="card">
<h2>{{ if .IsRoot }}Super-admins (bootstrap){{ else }}Subtree admins of {{ .Path }}{{ end }}</h2>
<p class="help">
{{ if .IsRoot }}Anyone here is an unrestricted admin of the entire server. They can edit any <code>.zddc</code> file, including this one. The very first super-admin is created by hand-editing this file at server install time. <strong>You cannot remove yourself</strong> from this list.
{{ else }}Anyone here can edit <code>.zddc</code> files anywhere <em>below</em> this directory. They <strong>cannot</strong> edit this file (where their authority comes from), so they cannot remove their delegator or add peers at their own level.
{{ end }}
</p>
<div class="list" data-field="admins">
{{ range $i, $v := .File.Admins }}<div class="row"><input type="text" data-field="admins[{{ $i }}]" value="{{ $v }}"><button type="button" class="del"></button><span class="err"></span></div>{{ end }}
</div>
<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=&lt;spec&gt;</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>&lt;ZDDC_ROOT&gt;/_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>
{{ range .EffectiveChain }}<details><summary><code>{{ .Dir }}/.zddc</code> {{ if not .Exists }}<span class="muted">(no file at this level)</span>{{ end }}</summary><pre>title: {{ .Title }}
allow: {{ range .ACL.Allow }}{{ . }} {{ end }}
deny: {{ range .ACL.Deny }}{{ . }} {{ end }}
admins:{{ range .Admins }} {{ . }}{{ end }}</pre></details>{{ end }}
</details>
</section>
<div class="actions">
<button type="submit" class="primary" id="save">Save</button>
<button type="button" id="del" class="danger" {{ if .IsRoot }}disabled title="Cannot delete root .zddc"{{ end }}>Delete file</button>
<span class="spacer"></span>
<a href="?path={{ .Path }}">Cancel / refresh</a>
</div>
</fieldset>
</form>
<script>
(function() {
var path = {{ .Path }};
var canEdit = {{ .CanEdit }};
var isRoot = {{ .IsRoot }};
var apiURL = "{{ .ProfilePathPrefix }}/zddc?path=" + encodeURIComponent(path);
function rowFor(field, value) {
var div = document.createElement("div");
div.className = "row";
var input = document.createElement("input");
input.type = "text";
input.dataset.field = field;
input.value = value || "";
var del = document.createElement("button");
del.type = "button"; del.className = "del"; del.textContent = "";
var err = document.createElement("span");
err.className = "err";
div.appendChild(input); div.appendChild(del); div.appendChild(err);
return div;
}
document.querySelectorAll("button.add").forEach(function(btn) {
btn.addEventListener("click", function() {
var field = btn.dataset.target;
var list = document.querySelector('.list[data-field="' + field + '"]');
var n = list.querySelectorAll(".row").length;
list.appendChild(rowFor(field + "[" + n + "]"));
});
});
document.addEventListener("click", function(e) {
if (e.target && e.target.matches(".del")) {
e.target.closest(".row").remove();
}
});
function collect() {
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;
}
function clearErrors() {
document.querySelectorAll(".row .err").forEach(function(e) { e.textContent = ""; });
document.getElementById("ok-banner").hidden = true;
}
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) {
var span = input.parentElement.querySelector(".err");
if (span) span.textContent = e.message;
} else {
// Top-level field error (e.g. "admins" without index, or "title").
alert(e.field + ": " + e.message);
}
});
}
if (canEdit) {
document.getElementById("editor").addEventListener("submit", function(ev) {
ev.preventDefault();
clearErrors();
var body = JSON.stringify(collect());
fetch(apiURL, {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
credentials: "same-origin",
body: body
}).then(function(r) {
return r.text().then(function(t) { return { ok: r.ok, status: r.status, text: t }; });
}).then(function(res) {
if (res.ok) {
document.getElementById("ok-banner").hidden = false;
window.scrollTo(0, 0);
return;
}
try {
var parsed = JSON.parse(res.text);
if (parsed.errors) { showErrors(parsed.errors); return; }
} catch (e) {}
alert("HTTP " + res.status + ": " + res.text);
}).catch(function(err) {
alert(String(err));
});
});
if (!isRoot) {
document.getElementById("del").addEventListener("click", function() {
if (!confirm("Delete " + path + "/.zddc?\n\nInherited rules from parent .zddc files will still apply.")) return;
fetch(apiURL, { method: "DELETE", credentials: "same-origin" }).then(function(r) {
if (r.ok) {
window.location.href = "?path=" + encodeURIComponent(path);
} else {
r.text().then(function(t) { alert("HTTP " + r.status + ": " + t); });
}
});
});
}
}
})();
</script>
</body>
</html>
`))