ZDDC/zddc/internal/handler/profilepage.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

464 lines
21 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"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
)
// profileView is the data passed to the profile template.
type profileView struct {
AccessView
ProfilePathPrefix string
AssetsPathPrefix string
HasCustomCSS bool
HasEditableSubtrees bool
EditableParentChoices []treeEntry // AdminSubtrees filtered to CanEdit; used as create-project parents
}
// serveProfilePage renders the universal profile page at GET /.profile/.
// Reachable to anyone (anonymous included); admin / super-admin sections
// are conditionally rendered server-side based on the caller's effective
// access — non-admin HTML contains zero admin markup.
func serveProfilePage(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
}
view := profileView{
AccessView: enumerateAccess(cfg, EmailFromContext(r)),
ProfilePathPrefix: ProfilePathPrefix,
AssetsPathPrefix: zddcAssetsPathPrefix,
HasCustomCSS: hasCustomProfileCSS(cfg.Root),
}
for _, t := range view.AdminSubtrees {
if t.CanEdit {
view.EditableParentChoices = append(view.EditableParentChoices, t)
}
}
view.HasEditableSubtrees = len(view.EditableParentChoices) > 0
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if err := profileTemplate.Execute(w, view); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// profileTemplate is the html/template for the profile page. Single page,
// three layered blocks (universal / admin / super-admin), inline styles
// using the same custom-property naming as the editor so a future merge
// with shared/base.css stays trivial. One inline IIFE handles theme,
// localStorage, and the create-project AJAX submit.
var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>zddc-server — profile</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;
}
}
[data-theme="light"] {
--bg: #fff; --bg-alt: #f7f7f8; --text: #222; --muted: #666;
--border: #d0d4dc; --primary: #2563eb; --primary-bg: #eff6ff;
--danger: #b00020; --warn: #b15c00; --ok: #0a7d2c;
}
[data-theme="dark"] {
--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; }
.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"], select { 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, select: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); }
table { width: 100%; border-collapse: collapse; font-size: .95em; }
th, td { text-align: left; padding: .35rem .5rem; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-weight: 500; }
td.value { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; word-break: break-all; }
td.numeric { text-align: right; font-variant-numeric: tabular-nums; }
ul.bare { list-style: none; padding: 0; margin: 0; }
ul.bare li { padding: .2rem 0; }
.badge { display: inline-block; padding: .1rem .5rem; border-radius: 999px; font-size: .8em; border: 1px solid var(--border); background: var(--bg); }
.badge.yes { background: var(--primary-bg); border-color: var(--primary); color: var(--primary); }
.ok-banner { background: var(--primary-bg); border: 1px solid var(--primary); border-radius: var(--radius); padding: .55rem .85rem; margin-bottom: 1rem; color: var(--text); }
pre { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: .5rem .7rem; margin: .35rem 0; overflow-x: auto; font-size: 12px; max-height: 20rem; }
pre.err { border-color: var(--danger); color: var(--danger); }
code { background: var(--bg); padding: 0 .25rem; border-radius: 2px; font-size: 12px; border: 1px solid var(--border); }
.theme-pick label { display: inline-flex; align-items: center; gap: .25rem; margin-right: 1rem; }
.ls-actions { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .6rem; }
</style>
{{ if .HasCustomCSS }}<link rel="stylesheet" href="{{ .AssetsPathPrefix }}/custom.css">{{ end }}
</head>
<body>
<div class="breadcrumb"><a href="/">&larr; home</a> &nbsp;/&nbsp; <span>profile</span></div>
<h1>Your profile</h1>
<section class="card">
<h2>Identity</h2>
{{ if .Email }}
<p>Signed in as <code>{{ .Email }}</code>.</p>
{{ else }}
<p>Not signed in. The server reads identity from the <code>{{ .EmailHeader }}</code> header. If you expected to be authenticated, your reverse proxy or SSO gateway is not forwarding it.</p>
{{ end }}
<p class="muted">Configured email header: <code>{{ .EmailHeader }}</code></p>
</section>
<section class="card">
<h2>Effective access</h2>
<p>
Super-admin: {{ if .IsSuperAdmin }}<span class="badge yes">yes</span>{{ else }}<span class="badge">no</span>{{ end }}
</p>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Visible projects</h3>
{{ if .Projects }}
<ul class="bare">
{{ range .Projects }}<li><a href="{{ .URL }}">{{ if .Title }}{{ .Title }}{{ else }}{{ .Name }}{{ end }}</a> <span class="muted">({{ .URL }})</span></li>{{ end }}
</ul>
{{ else }}
<p class="muted">No projects accessible.</p>
{{ end }}
{{ if .HasAnyAdminScope }}
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Subtrees you administer</h3>
{{ if .AdminSubtrees }}
<ul class="bare">
{{ range .AdminSubtrees }}<li><code>{{ .Path }}</code>{{ if .Title }}{{ .Title }}{{ end }} {{ if .CanEdit }}<span class="muted">(editable)</span>{{ else }}<span class="muted">(read-only — you cannot edit the file granting your own authority)</span>{{ end }}</li>{{ end }}
</ul>
{{ else }}
<p class="muted">None.</p>
{{ end }}
{{ end }}
</section>
<section class="card">
<h2>Theme</h2>
<p class="help">Applies to every ZDDC tool you open in this browser. Stored in <code>localStorage["zddc-theme"]</code>.</p>
<div class="theme-pick">
<label><input type="radio" name="theme" value="auto"> auto (system)</label>
<label><input type="radio" name="theme" value="light"> light</label>
<label><input type="radio" name="theme" value="dark"> dark</label>
</div>
</section>
<section class="card">
<h2>Local storage</h2>
<p class="help">Browser-side state used by the ZDDC tools at this origin. The profile page can read and write it for you.</p>
<table id="ls-table"><thead><tr><th>Key</th><th>Value</th><th class="numeric">Bytes</th></tr></thead><tbody></tbody></table>
<div class="ls-actions">
<button type="button" id="ls-export">Export all (JSON)</button>
<button type="button" id="ls-import">Import from JSON…</button>
<input type="file" id="ls-import-file" accept="application/json,.json" hidden>
<button type="button" id="ls-clear" class="danger">Clear all</button>
</div>
</section>
{{ if .HasAnyAdminScope }}
<section class="card">
<h2>Editable .zddc files</h2>
<p class="help">Open the form-based editor for any subtree you administer.</p>
{{ if .HasEditableSubtrees }}
<ul class="bare">
{{ range .EditableParentChoices }}<li><a href="{{ $.ProfilePathPrefix }}/zddc/edit?path={{ .Path }}"><code>{{ .Path }}/.zddc</code></a>{{ if .Title }}{{ .Title }}{{ end }}</li>{{ end }}
</ul>
{{ else }}
<p class="muted">No <code>.zddc</code> files within your edit authority. Subtree admins cannot edit the file that grants their own authority — only an admin from a higher level can.</p>
{{ end }}
</section>
<section class="card">
<h2>Create new project folder</h2>
<p class="help">Creates a directory under the chosen parent. If you fill in any of title / allow / deny / admins, a starter <code>.zddc</code> is also written; otherwise the directory is empty and inherits ACL from its ancestors.</p>
<div id="cp-ok" class="ok-banner" hidden>Created.</div>
<form id="cp-form" autocomplete="off">
<label>Parent
<select name="parent" id="cp-parent">
{{ if .IsSuperAdmin }}<option value="/">/ (root)</option>{{ end }}
{{ range .AdminSubtrees }}<option value="{{ .Path }}">{{ .Path }}</option>{{ end }}
</select>
</label>
<label>Name
<input type="text" name="name" id="cp-name" maxlength="64" placeholder="e.g. Site-3" required>
<span class="err" id="cp-name-err"></span>
</label>
<label>Title (optional)
<input type="text" name="title" id="cp-title" maxlength="200">
</label>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">ACL — Allow (optional)</h3>
<div class="list" data-field="acl.allow"></div>
<button type="button" class="add" data-target="acl.allow">+ Add allow rule</button>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">ACL — Deny (optional)</h3>
<div class="list" data-field="acl.deny"></div>
<button type="button" class="add" data-target="acl.deny">+ Add deny rule</button>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Admins (optional)</h3>
<div class="list" data-field="admins"></div>
<button type="button" class="add" data-target="admins">+ Add admin</button>
<div style="margin-top: 1rem;">
<button type="submit" class="primary">Create</button>
</div>
</form>
</section>
{{ end }}
{{ if .IsSuperAdmin }}
<section class="card">
<h2>Server config <button type="button" data-diag="config">refresh</button></h2>
<p class="help">Effective config from environment variables. Read-only.</p>
<pre id="diag-config">loading…</pre>
</section>
<section class="card">
<h2>Logs <button type="button" data-diag="logs">refresh</button>
<span class="muted">level: <select id="diag-level"><option value="">all</option><option value="debug">debug+</option><option value="info" selected>info+</option><option value="warn">warn+</option><option value="error">error</option></select></span>
</h2>
<pre id="diag-logs">loading…</pre>
</section>
<section class="card">
<h2>whoami <button type="button" data-diag="whoami">refresh</button></h2>
<p class="help">Headers as they arrived at the binary. Useful for debugging SSO header passthrough.</p>
<pre id="diag-whoami">loading…</pre>
</section>
{{ end }}
<script>
(function() {
var prefix = {{ .ProfilePathPrefix }};
var hasAdminScope = {{ .HasAnyAdminScope }};
var isSuper = {{ .IsSuperAdmin }};
// ── Theme ──────────────────────────────────────────────────────────────
var THEME_KEY = "zddc-theme";
function applyTheme(v) {
if (v === "light" || v === "dark") {
document.documentElement.setAttribute("data-theme", v);
} else {
document.documentElement.removeAttribute("data-theme");
}
}
var current = (function() { try { return localStorage.getItem(THEME_KEY) || "auto"; } catch (e) { return "auto"; } })();
applyTheme(current);
document.querySelectorAll('input[name="theme"]').forEach(function(r) {
if (r.value === current) r.checked = true;
r.addEventListener("change", function() {
try { localStorage.setItem(THEME_KEY, r.value); } catch (e) {}
applyTheme(r.value);
});
});
// ── Local storage panel ────────────────────────────────────────────────
function bytes(s) { return new TextEncoder().encode(s).length; }
function renderLS() {
var tbody = document.querySelector("#ls-table tbody");
tbody.textContent = "";
var keys = [];
for (var i = 0; i < localStorage.length; i++) keys.push(localStorage.key(i));
keys.sort();
if (keys.length === 0) {
var tr = document.createElement("tr");
var td = document.createElement("td");
td.colSpan = 3; td.className = "muted"; td.textContent = "(empty)";
tr.appendChild(td); tbody.appendChild(tr);
return;
}
keys.forEach(function(k) {
var v = localStorage.getItem(k) || "";
var tr = document.createElement("tr");
var tdK = document.createElement("td"); tdK.textContent = k;
var tdV = document.createElement("td"); tdV.className = "value";
tdV.textContent = v.length > 200 ? v.slice(0, 200) + "…" : v;
var tdB = document.createElement("td"); tdB.className = "numeric"; tdB.textContent = bytes(v);
tr.appendChild(tdK); tr.appendChild(tdV); tr.appendChild(tdB);
tbody.appendChild(tr);
});
}
renderLS();
document.getElementById("ls-export").addEventListener("click", function() {
var dump = {};
for (var i = 0; i < localStorage.length; i++) {
var k = localStorage.key(i);
dump[k] = localStorage.getItem(k);
}
var blob = new Blob([JSON.stringify(dump, null, 2)], { type: "application/json" });
var a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "zddc-localstorage.json";
a.click();
URL.revokeObjectURL(a.href);
});
document.getElementById("ls-import").addEventListener("click", function() {
document.getElementById("ls-import-file").click();
});
document.getElementById("ls-import-file").addEventListener("change", function(ev) {
var file = ev.target.files && ev.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function() {
try {
var parsed = JSON.parse(String(reader.result || ""));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
alert("Import file must be a JSON object of string→string."); return;
}
Object.keys(parsed).forEach(function(k) {
var v = parsed[k];
if (typeof v === "string") localStorage.setItem(k, v);
});
renderLS();
alert("Imported " + Object.keys(parsed).length + " keys.");
} catch (e) {
alert("Import failed: " + e);
}
};
reader.readAsText(file);
ev.target.value = "";
});
document.getElementById("ls-clear").addEventListener("click", function() {
if (!confirm("Clear ALL localStorage at this origin? This includes theme and any saved presets.")) return;
localStorage.clear();
renderLS();
applyTheme("auto");
document.querySelectorAll('input[name="theme"]').forEach(function(r) { r.checked = (r.value === "auto"); });
});
// ── Create project ────────────────────────────────────────────────────
if (hasAdminScope) {
function rowFor(field) {
var div = document.createElement("div"); div.className = "row";
var input = document.createElement("input");
input.type = "text"; input.dataset.field = field;
var del = document.createElement("button");
del.type = "button"; del.textContent = ""; del.className = "del";
div.appendChild(input); div.appendChild(del);
return div;
}
document.querySelectorAll("#cp-form button.add").forEach(function(btn) {
btn.addEventListener("click", function() {
var field = btn.dataset.target;
document.querySelector('#cp-form .list[data-field="' + field + '"]').appendChild(rowFor(field));
});
});
document.getElementById("cp-form").addEventListener("click", function(e) {
if (e.target && e.target.classList && e.target.classList.contains("del")) {
e.target.closest(".row").remove();
}
});
function collectList(field) {
var out = [];
document.querySelectorAll('#cp-form .list[data-field="' + field + '"] input').forEach(function(i) {
if (i.value.trim()) out.push(i.value.trim());
});
return out;
}
document.getElementById("cp-form").addEventListener("submit", function(ev) {
ev.preventDefault();
document.getElementById("cp-name-err").textContent = "";
document.getElementById("cp-ok").hidden = true;
var allow = collectList("acl.allow");
var deny = collectList("acl.deny");
var admins = collectList("admins");
var title = document.getElementById("cp-title").value.trim();
var body = {
parent: document.getElementById("cp-parent").value,
name: document.getElementById("cp-name").value.trim()
};
if (title) body.title = title;
if (allow.length || deny.length) body.acl = { allow: allow, deny: deny };
if (admins.length) body.admins = admins;
fetch(prefix + "/projects", {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
credentials: "same-origin",
body: JSON.stringify(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) {
var parsed = {};
try { parsed = JSON.parse(res.text); } catch (e) {}
var ok = document.getElementById("cp-ok");
ok.hidden = false;
ok.textContent = "Created " + (parsed.path || "(unknown path)") + ". Reload to see it in the lists above.";
return;
}
try {
var p = JSON.parse(res.text);
if (p && p.errors && p.errors.length) {
document.getElementById("cp-name-err").textContent = p.errors.map(function(e) { return e.field + ": " + e.message; }).join("; ");
return;
}
} catch (e) {}
document.getElementById("cp-name-err").textContent = "HTTP " + res.status + ": " + res.text;
});
});
}
// ── Super-admin diagnostics ───────────────────────────────────────────
if (isSuper) {
function loadDiag(target, qs) {
var el = document.getElementById("diag-" + target);
el.classList.remove("err");
el.textContent = "loading…";
var url = prefix + "/" + target + (qs ? ("?" + qs) : "");
fetch(url, { headers: { Accept: "application/json" }, credentials: "same-origin" })
.then(function(r) { return r.text().then(function(t) { return { ok: r.ok, status: r.status, text: t }; }); })
.then(function(res) {
if (!res.ok) {
el.classList.add("err");
el.textContent = "HTTP " + res.status + "\n\n" + res.text;
return;
}
try { el.textContent = JSON.stringify(JSON.parse(res.text), null, 2); }
catch (e) { el.textContent = res.text; }
}).catch(function(err) { el.classList.add("err"); el.textContent = String(err); });
}
document.querySelectorAll("button[data-diag]").forEach(function(b) {
b.addEventListener("click", function() {
var t = b.dataset.diag;
var qs = t === "logs" ? ("level=" + (document.getElementById("diag-level").value || "")) : "";
loadDiag(t, qs);
});
});
var lvl = document.getElementById("diag-level");
if (lvl) lvl.addEventListener("change", function() { loadDiag("logs", "level=" + (lvl.value || "")); });
loadDiag("config");
loadDiag("logs", "level=info");
loadDiag("whoami");
}
})();
</script>
</body>
</html>
`))