ZDDC/zddc/internal/handler/zddceditor.go
ZDDC cb46c2ef8c feat(zddc-server): user profile page replaces /.admin/
Replaces the super-admin-only /.admin/ surface with a public-by-default
/.profile/ page that layers admin tools server-side based on the
caller's effective access:

- Universal (everyone, anonymous included): identity card, effective
  access summary, theme picker, localStorage utilities (export / import
  / clear, landing-presets viewer).
- Subtree admins additionally see: editable .zddc files list (linking
  to the existing form-based editor) and a "Create new project folder"
  form.
- Super-admins additionally see: server config, log viewer, whoami
  headers (the old /.admin/ JSON endpoints, repointed under /.profile/).

Project creation is gated on CanEditZddc(newDir) — the same strict-
ancestor rule that already governs .zddc writes — so no new authority
concept is introduced. ValidateProjectName mirrors the existing
reserved-prefix policy (no leading '.' or '_', no path separators).

/.admin/* is hard-cut: no redirect shim. Old URLs fall through to the
existing dot-prefix guard and 404. Custom CSS file rename: prefer
<root>/.profile.css, fall back to legacy <root>/.admin.css.

Per-resource 404 leakage gates preserved on whoami / config / logs /
zddc / projects so non-admin callers cannot detect the existence of
admin-only sub-resources.

Tree-wide gofmt -w applied as a side-effect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:32:02 -05:00

345 lines
15 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/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
ProfilePathPrefix string // /.profile
AssetsPathPrefix string // /.profile/zddc/assets
}
// 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,
})
}
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,
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; }
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 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: [] };
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()); });
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) {
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").
var card = document.querySelector('h2 + .help, [name="' + e.field + '"]');
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>
`))