Add a virtual-URL alias so the existing form-based .zddc editor is reachable at the natural directory location (<dir>/.zddc.html) in addition to the legacy /.profile/zddc/edit?path=<dir> entry. Both flow through the same renderZddcEditor body — same template, same gate, same form-posts-to-/.profile/zddc semantics. Wiring: - IsZddcEditorRequest(urlPath) reports whether the URL ends with the .zddc.html leaf (case-fold not needed; .zddc is itself case- sensitive on disk). - ServeZddcEditorAtPath strips the leaf, resolves the parent dir, asserts the dir exists, gates on hasAnyAdminScope, calls the shared renderer. - The dispatcher routes IsZddcEditorRequest URLs BEFORE the dot- prefix segment guard (which would otherwise 404 the .zddc.html leaf). The route is method-gated GET-only; mutations still go through PUT/POST/DELETE on <dir>/.zddc via the file API. Permission model unchanged from the /.profile entry: hasAnyAdminScope gates visibility of the editor itself; CanEditZddc decides whether the form is interactive or read-only at the requested directory. Subtree admins can still inspect ancestor cascade ACLs (intended since the cascade is what determines their authority). Test (TestDispatchZddcEditorAtPath): root admin opens project / working/ / deployment-root editors; non-admin and anonymous both 404; missing directory 404; trailing-segment-after-leaf 404. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
503 lines
22 KiB
Go
503 lines
22 KiB
Go
package handler
|
||
|
||
import (
|
||
"html/template"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"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
|
||
}
|
||
abs, err := resolvePath(cfg.Root, r.URL.Query().Get("path"))
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
renderZddcEditor(cfg, w, r, abs)
|
||
}
|
||
|
||
// ServeZddcEditorAtPath is the per-directory entry to the editor. The
|
||
// dispatcher routes <dir>/.zddc.html requests here; the directory is
|
||
// derived from the URL path (parent of the .zddc.html leaf) rather
|
||
// than from a query parameter.
|
||
//
|
||
// Permission gate: the user must have an admin authority somewhere
|
||
// in the tree (same gate as the /.profile/zddc namespace). A non-
|
||
// admin sees 404 — no leak that an editor would otherwise be
|
||
// available. Within the editor, CanEditZddc decides whether the form
|
||
// is interactive or read-only at THIS specific .zddc; non-editors
|
||
// can still inspect the cascade if they have any admin scope.
|
||
func ServeZddcEditorAtPath(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)
|
||
if !hasAnyAdminScope(cfg.Root, email) {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
// URL is <dir>/.zddc.html (or "/.zddc.html" for the deployment
|
||
// root). Strip the leaf to get the directory.
|
||
urlPath := strings.TrimSuffix(r.URL.Path, "/")
|
||
leafPath := "/" + ZddcEditorBasename
|
||
if !strings.HasSuffix(urlPath, leafPath) {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
dirURL := strings.TrimSuffix(urlPath, leafPath)
|
||
if dirURL == "" {
|
||
dirURL = "/"
|
||
}
|
||
abs, err := resolvePath(cfg.Root, dirURL)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
// The directory must exist on disk; the per-path editor URL is a
|
||
// view onto an existing tree position, not a way to materialise
|
||
// arbitrary new directories. (The /.profile editor accepts a
|
||
// missing dir for the legacy path-as-query workflow.)
|
||
if info, err := os.Stat(abs); err != nil || !info.IsDir() {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
renderZddcEditor(cfg, w, r, abs)
|
||
}
|
||
|
||
// ZddcEditorBasename is the URL leaf that the dispatcher recognises as
|
||
// a per-directory editor request. The dot-prefix guard carves this one
|
||
// segment out so the editor reaches the handler.
|
||
const ZddcEditorBasename = ".zddc.html"
|
||
|
||
// IsZddcEditorRequest reports whether urlPath ends with the editor's
|
||
// virtual basename. Used by the dispatcher to route the request to
|
||
// ServeZddcEditorAtPath ahead of the dot-prefix guard.
|
||
func IsZddcEditorRequest(urlPath string) bool {
|
||
clean := strings.TrimSuffix(urlPath, "/")
|
||
return strings.HasSuffix(clean, "/"+ZddcEditorBasename) ||
|
||
clean == "/"+ZddcEditorBasename
|
||
}
|
||
|
||
// renderZddcEditor renders the editor template against the .zddc at
|
||
// abs (which may not exist on disk yet). Shared between the
|
||
// /.profile/zddc/edit?path= entry and the per-directory <dir>/.zddc.html
|
||
// entry.
|
||
func renderZddcEditor(cfg config.Config, w http.ResponseWriter, r *http.Request, abs string) {
|
||
email := EmailFromContext(r)
|
||
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 }}/">← profile</a> /
|
||
<a href="{{ .ProfilePathPrefix }}/zddc/edit?path=/">root</a>{{ if not .IsRoot }} / <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=<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>
|
||
{{ 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>
|
||
`))
|