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=. The form posts JSON back to // /.profile/zddc?path=; 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(` .zddc editor — {{ .Path }} {{ if .HasCustomCSS }}{{ end }}

.zddc editor

{{ if .IsRoot }}Editing the root /.zddc.{{ else }}Editing {{ .Path }}/.zddc.{{ end }} You are signed in as {{ .Email }}.

{{ if not .CanEdit }}
Read-only. 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.
{{ end }}

Title

Surfaced on the project picker for this folder. Optional — projects without a title show their directory name.

ACL — Allow

Email-glob patterns for users granted access here. Examples: *@example.com, alice@*, alice@example.com. * matches any non-empty email but does not cross the @ boundary.

{{ range $i, $v := .File.ACL.Allow }}
{{ end }}

ACL — Deny

Deny is checked first; a parent allow cannot override a deeper deny. Same glob syntax as Allow.

{{ range $i, $v := .File.ACL.Deny }}
{{ end }}

{{ if .IsRoot }}Super-admins (bootstrap){{ else }}Subtree admins of {{ .Path }}{{ end }}

{{ if .IsRoot }}Anyone here is an unrestricted admin of the entire server. They can edit any .zddc file, including this one. The very first super-admin is created by hand-editing this file at server install time. You cannot remove yourself from this list. {{ else }}Anyone here can edit .zddc files anywhere below this directory. They cannot edit this file (where their authority comes from), so they cannot remove their delegator or add peers at their own level. {{ end }}

{{ range $i, $v := .File.Admins }}
{{ end }}

Apps (tool HTML sources)

Override which build of each tool the server serves at this directory and below. Spec is one of: stable / beta / alpha, v0.0.4 / v0.0 / v0 (canonical upstream), https://my-mirror/releases (URL prefix — composes with channel from default), https://my-mirror/releases:beta (URL prefix + channel), :beta (channel-only override of default's URL), https://my-fork/archive.html (terminal full URL), ./local.html or /abs/path.html (terminal local file). Leave any row blank to inherit from a parent .zddc file. The default row provides the baseline URL prefix and channel for any app not overridden per-name.

Per-request override: any user can append ?v=<spec> to a tool URL (e.g. ?v=beta, ?v=v0.0.4, ?v=:alpha) to ask for a different build for one request. Security: ?v= serves only versions already in the cache (<ZDDC_ROOT>/_app/); cache misses return 404 so users can't trigger arbitrary upstream fetches. Local-path specs are also rejected from ?v=.

{{ range .AppsRows }}{{ end }}
KeyValueResolves to
{{ .Key }} {{ .ResolvesTo }}

The Resolves to column reflects the saved state of the cascade — save and reload to see how edits compose.

Effective chain (inherited rules) {{ range .EffectiveChain }}
{{ .Dir }}/.zddc {{ if not .Exists }}(no file at this level){{ end }}
title: {{ .Title }}
allow: {{ range .ACL.Allow }}{{ . }} {{ end }}
deny:  {{ range .ACL.Deny }}{{ . }} {{ end }}
admins:{{ range .Admins }} {{ . }}{{ end }}
{{ end }}
Cancel / refresh
`))