// Package handler — tablehandler.go: directory-of-YAML table view. // // URL convention: // // GET //.table.html → tables.html, only when /.zddc // declares tables: { : } // AND the spec file exists. // // Discovery is .zddc-declarative (no auto-mount on file presence). The // handler's only jobs are: // // 1. Recognize the URL — does /.zddc declare a table named , // and does the referenced *.table.yaml spec actually exist? If not, // fall through to the static-file pipeline. // 2. Gate on the cascading ACL at the request directory (read action). // 3. Serve the static tables.html bytes. // // All rendering is client-side. The page (built from tables/) detects // HTTP vs file:// mode, then walks the directory via shared/zddc-source.js // (HttpDirectoryHandle in HTTP mode, real FileSystemDirectoryHandle in // file mode), reads .zddc, parses the spec, and renders rows in the // browser. The server does not pre-parse the spec, list rows, or compute // per-row "editable" — the client does, and ACL is enforced naturally: // HTTP-mode reads/writes go through the cascade-aware file API, and // local-mode reads/writes are bounded by whatever the OS gave the // FS-Access handle. package handler import ( _ "embed" "log/slog" "net/http" "path/filepath" "strings" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/policy" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) //go:embed tables.html var embeddedTablesHTML []byte //go:embed default-mdl.table.yaml var embeddedDefaultMdlTable []byte //go:embed default-mdl.form.yaml var embeddedDefaultMdlForm []byte //go:embed default-rsk.table.yaml var embeddedDefaultRskTable []byte //go:embed default-rsk.form.yaml var embeddedDefaultRskForm []byte //go:embed default-ssr.table.yaml var embeddedDefaultSsrTable []byte //go:embed default-ssr.form.yaml var embeddedDefaultSsrForm []byte //go:embed default-project-mdl.table.yaml var embeddedDefaultProjectMdlTable []byte //go:embed default-project-rsk.table.yaml var embeddedDefaultProjectRskTable []byte //go:embed default-project-mdl.form.yaml var embeddedDefaultProjectMdlForm []byte //go:embed default-project-rsk.form.yaml var embeddedDefaultProjectRskForm []byte // DefaultMdlTableYAML returns the embedded default mdl.table.yaml bytes. // Used by callers that need the canonical spec without going through // the URL-recognition path. func DefaultMdlTableYAML() []byte { return embeddedDefaultMdlTable } // DefaultMdlFormYAML returns the embedded default mdl.form.yaml bytes. func DefaultMdlFormYAML() []byte { return embeddedDefaultMdlForm } // DefaultRskTableYAML returns the embedded default rsk.table.yaml bytes. func DefaultRskTableYAML() []byte { return embeddedDefaultRskTable } // DefaultRskFormYAML returns the embedded default rsk.form.yaml bytes. func DefaultRskFormYAML() []byte { return embeddedDefaultRskForm } // DefaultSsrTableYAML returns the embedded default ssr.table.yaml bytes. func DefaultSsrTableYAML() []byte { return embeddedDefaultSsrTable } // DefaultSsrFormYAML returns the embedded default ssr.form.yaml bytes. func DefaultSsrFormYAML() []byte { return embeddedDefaultSsrForm } // DefaultProjectMdlTableYAML returns the embedded project-rollup // mdl.table.yaml bytes. func DefaultProjectMdlTableYAML() []byte { return embeddedDefaultProjectMdlTable } // DefaultProjectRskTableYAML returns the embedded project-rollup // rsk.table.yaml bytes. func DefaultProjectRskTableYAML() []byte { return embeddedDefaultProjectRskTable } // DefaultProjectMdlFormYAML returns the embedded project-rollup // mdl.form.yaml bytes. Differs from the per-party MDL form by an // additional required `party` field — the routing key for the // rollup create handler. func DefaultProjectMdlFormYAML() []byte { return embeddedDefaultProjectMdlForm } // DefaultProjectRskFormYAML returns the embedded project-rollup // rsk.form.yaml bytes. func DefaultProjectRskFormYAML() []byte { return embeddedDefaultProjectRskForm } // IsDefaultSpec reports whether urlPath is one of the embedded // default-spec virtual files served when no operator file exists on // disk. Recognized URL shapes: // // /mdl/{table.yaml, form.yaml} aggregate (with $party) // /rsk/{table.yaml, form.yaml} aggregate (with $party) // /ssr/{table.yaml, form.yaml} registry table // /mdl//{table.yaml, form.yaml} per-party // /rsk//{table.yaml, form.yaml} per-party // // Returns embedded bytes + true when the fallback should fire; nil + // false when an operator file exists at that path or the URL is not // eligible. Operator files always win. func IsDefaultSpec(fsRoot, urlPath string) ([]byte, bool) { rel := strings.TrimPrefix(filepath.ToSlash(urlPath), "/") abs := filepath.Join(fsRoot, filepath.FromSlash(rel)) if !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) && abs != fsRoot { return nil, false } return IsDefaultSpecAbs(fsRoot, abs) } // IsDefaultSpecAbs is the abs-path-keyed variant of IsDefaultSpec. // Used by handlers that hold a filesystem path rather than a URL. func IsDefaultSpecAbs(fsRoot, absPath string) ([]byte, bool) { if !strings.HasPrefix(absPath, fsRoot+string(filepath.Separator)) && absPath != fsRoot { return nil, false } rel, err := filepath.Rel(fsRoot, absPath) if err != nil { return nil, false } rel = filepath.ToSlash(rel) if rel == "" || rel == "." || strings.HasPrefix(rel, "../") { return nil, false } bytes := classifyDefaultSpec(rel) if bytes == nil { return nil, false } // Operator file wins if it exists on disk. if fileExists(absPath) { return nil, false } return bytes, true } // classifyDefaultSpec maps a slash-form path (relative to fsRoot) to // the matching embedded default-spec bytes, or nil if the path does // not name one of the recognized virtual fallback files. func classifyDefaultSpec(rel string) []byte { parts := strings.Split(rel, "/") switch len(parts) { case 4: // /// — per-party register specs // (mdl//, rsk//). The single-party table/form, // no $party column. peer := strings.ToLower(parts[1]) file := strings.ToLower(parts[3]) switch peer { case "mdl": switch file { case "table.yaml": return embeddedDefaultMdlTable case "form.yaml": return embeddedDefaultMdlForm } case "rsk": switch file { case "table.yaml": return embeddedDefaultRskTable case "form.yaml": return embeddedDefaultRskForm } } case 3: // // — peer-root specs. ssr/ is a flat // register; mdl/ and rsk/ are the cross-party AGGREGATE tables // (the project-level spec carries the $party column). peer := strings.ToLower(parts[1]) file := strings.ToLower(parts[2]) switch peer { case "ssr": switch file { case "table.yaml": return embeddedDefaultSsrTable case "form.yaml": return embeddedDefaultSsrForm } case "mdl": switch file { case "table.yaml": return embeddedDefaultProjectMdlTable case "form.yaml": return embeddedDefaultProjectMdlForm } case "rsk": switch file { case "table.yaml": return embeddedDefaultProjectRskTable case "form.yaml": return embeddedDefaultProjectRskForm } } } return nil } // isAtArchivePartyLevel reports whether urlPath refers to a file // directly under /archive// (depth-3 directory). The // canonical-folder names are case-folded. func isAtArchivePartyLevel(fsRoot, urlPath string) bool { rel := strings.Trim(filepath.ToSlash(urlPath), "/") parts := strings.Split(rel, "/") if len(parts) != 4 { return false } return strings.EqualFold(parts[1], "archive") } // TableRequest describes a recognized table-system request. type TableRequest struct { // Name is the table's URL stem (the key declared in .zddc tables). Name string // SpecPath is the absolute filesystem path to the *.table.yaml. // May reference a virtual path when the spec is served from // embedded defaults. SpecPath string // Dir is the absolute path to the request directory (where the // .zddc declared the table). Dir string } // tableRowsRedirect reports the canonical //table.html URL to // redirect to when (urlPath) names a directory that contains a // table.yaml (or matches one of the default-spec fallbacks). Returns // "" when no redirect should fire. // // Recognition reuses RecognizeTableRequest by synthesizing the // equivalent table.html and asking the recognizer whether // it's a real (or default-spec) table. Single source of truth for // validation. func tableRowsRedirect(fsRoot, urlPath string) string { if urlPath == "" || urlPath == "/" { return "" } if !strings.HasSuffix(urlPath, "/") { urlPath += "/" } synthesized := urlPath + "table.html" tr := RecognizeTableRequest(fsRoot, http.MethodGet, synthesized) if tr == nil { return "" } // Default-spec case (no on-disk table.yaml): follow the slash/no- // slash convention — slash form serves browse, no-slash serves // tables (handled by the dispatcher). Redirecting here would // override the convention and force the user into the table view // from any //{mdl,rsk}/ click. if !fileExists(tr.SpecPath) { return "" } return synthesized } // RecognizeTableRequest classifies r as a table-system request, or // returns nil if it falls through to other handlers. Discovery is // presence-based and self-contained: a //table.html URL fires // when /table.yaml exists on disk, or when one of the default- // spec fallbacks applies (per-party mdl/rsk under archive//, // or project-level ssr/mdl/rsk virtual aggregations). // // The table's "name" is the directory's basename for on-disk and // per-party-virtual tables (e.g. "mdl"); for project-level virtual // tables it's the slot name ("ssr", "mdl", "rsk"). // // Methods other than GET return nil — the table is read-only at the // URL level. Writes go through the file API directly. func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest { if method != http.MethodGet { return nil } if !strings.HasSuffix(urlPath, "/table.html") && urlPath != "/table.html" { return nil } rel := strings.TrimSuffix(strings.TrimPrefix(urlPath, "/"), "/table.html") rel = strings.TrimSuffix(rel, "table.html") rel = strings.Trim(rel, "/") if rel == "" { return nil } dirAbs := filepath.Join(fsRoot, filepath.FromSlash(rel)) if !strings.HasPrefix(dirAbs, fsRoot+string(filepath.Separator)) && dirAbs != fsRoot { return nil } name := filepath.Base(dirAbs) specAbs := filepath.Join(dirAbs, "table.yaml") // Presence-based discovery: /table.yaml on disk. if fileExists(specAbs) { return &TableRequest{Name: name, SpecPath: specAbs, Dir: dirAbs} } // Default-spec fallbacks — the rows-dir itself may not exist on // disk yet (fully virtual). The static-file dispatcher serves the // embedded spec bytes from IsDefaultSpecAbs when the client // fetches /table.yaml client-side. if slot, ok := classifyVirtualTableDir(fsRoot, dirAbs); ok { return &TableRequest{Name: slot, SpecPath: specAbs, Dir: dirAbs} } return nil } // classifyVirtualTableDir reports whether dirAbs is one of the // virtual-spec table dirs and returns its slot name ("mdl", "rsk", // or "ssr"). Recognizes both per-party slots // (/archive//{mdl,rsk}) and project-level slots // (/{ssr,mdl,rsk}). func classifyVirtualTableDir(fsRoot, dirAbs string) (string, bool) { rel, err := filepath.Rel(fsRoot, dirAbs) if err != nil { return "", false } rel = filepath.ToSlash(rel) if strings.HasPrefix(rel, "../") || rel == ".." || rel == "." { return "", false } parts := strings.Split(rel, "/") switch len(parts) { case 2: // / — aggregate ssr/mdl/rsk table. slot := strings.ToLower(parts[1]) if slot == "ssr" || slot == "mdl" || slot == "rsk" { return slot, true } case 3: // // — per-party mdl/rsk table. slot := strings.ToLower(parts[1]) if slot == "mdl" || slot == "rsk" { return slot, true } } return "", false } // isNotExistError reports whether err indicates a missing file. Local // helper to avoid pulling errors.Is into the handler package. func isNotExistError(err error) bool { return err != nil && strings.Contains(err.Error(), "no such file or directory") } // ServeTable serves the static tables.html bytes for a recognized // request. ACL gate is the read action at the request directory; on // allow, the embedded HTML is written verbatim. The client takes over // from there — see tables/js/main.js. func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r *http.Request) { p := PrincipalFromContext(r) decider := DeciderFromContext(r) chain, err := zddc.EffectivePolicy(cfg.Root, req.Dir) if err != nil { slog.Warn("table: policy error", "path", req.Dir, "err", err) } if allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, r.URL.Path, policy.ActionRead); !allowed { writeForbidden(w, policy.ActionRead) return } if len(embeddedTablesHTML) == 0 { http.Error(w, "table renderer not built into this binary", http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(embeddedTablesHTML) }