// 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 // DefaultMdlTableYAML returns the embedded default mdl.table.yaml bytes. // Used by the static-file handler to serve the default spec at // archive//mdl.table.yaml when no operator file exists on disk. func DefaultMdlTableYAML() []byte { return embeddedDefaultMdlTable } // DefaultMdlFormYAML returns the embedded default mdl.form.yaml bytes. func DefaultMdlFormYAML() []byte { return embeddedDefaultMdlForm } // IsDefaultMdlSpec reports whether urlPath is one of the default-MDL // virtual files served when no operator file exists on disk: // // /archive//mdl/table.yaml // /archive//mdl/form.yaml // // The MDL files live INSIDE the rows-dir along with row YAMLs so the // whole directory is self-contained — copying mdl/ moves the spec, // the form, and all rows together. Returns embedded bytes + true when // the fallback should fire; nil + false when an operator-supplied // file exists or the path is not eligible. func IsDefaultMdlSpec(fsRoot, urlPath string) ([]byte, bool) { base := strings.ToLower(filepath.Base(urlPath)) var bytes []byte switch base { case "table.yaml": bytes = embeddedDefaultMdlTable case "form.yaml": bytes = embeddedDefaultMdlForm default: return nil, false } if !isAtArchivePartyMdlLevel(fsRoot, urlPath) { return nil, false } // Operator file wins if it exists on disk. 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 } if fileExists(abs) { return nil, false } return bytes, true } // IsDefaultMdlSpecAbs is the abs-path-keyed variant of IsDefaultMdlSpec. // Used by handlers that hold a filesystem path rather than a URL. // Returns the embedded default bytes + true when absPath is the // virtual archive//{mdl.table.yaml, mdl.form.yaml} fallback. func IsDefaultMdlSpecAbs(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 } urlPath := "/" + filepath.ToSlash(rel) return IsDefaultMdlSpec(fsRoot, urlPath) } // 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, "/") // /archive// = 4 segments if len(parts) != 4 { return false } return strings.EqualFold(parts[1], "archive") } // isAtArchivePartyMdlLevel reports whether urlPath refers to a file // directly under /archive//mdl/ (depth-4 directory). // Used by the default-MDL fallback after the spec/form moved INSIDE // the rows-dir for self-containment. func isAtArchivePartyMdlLevel(fsRoot, urlPath string) bool { rel := strings.Trim(filepath.ToSlash(urlPath), "/") parts := strings.Split(rel, "/") // /archive//mdl/ = 5 segments if len(parts) != 5 { return false } return strings.EqualFold(parts[1], "archive") && strings.EqualFold(parts[3], "mdl") } // 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. // Validated to exist at recognition time. 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 the default-MDL fallback). 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-MDL) table. Single source of truth for // validation. func tableRowsRedirect(fsRoot, urlPath string) string { // urlPath is the directory request — e.g. "/proj/archive/Acme/mdl/". if urlPath == "" || urlPath == "/" { return "" } if !strings.HasSuffix(urlPath, "/") { urlPath += "/" } synthesized := urlPath + "table.html" tr := RecognizeTableRequest(fsRoot, http.MethodGet, synthesized) if tr == nil { return "" } // Default-MDL 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/ 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 the default-MDL // fallback at archive//mdl/ applies. // // The spec, the row-edit form, and all rows live together in . // Copying elsewhere copies everything needed to re-host the // table — that's the whole point of the in-dir layout. // // The table's "name" is the directory's basename (so the URL // //mdl/table.html names the "mdl" table, with rows in mdl/). // // 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 } // Strip /table.html — what remains is the rows-dir. rel := strings.TrimSuffix(strings.TrimPrefix(urlPath, "/"), "/table.html") rel = strings.TrimSuffix(rel, "table.html") // handles "/table.html" at root → "" rel = strings.Trim(rel, "/") if rel == "" { // /table.html at root has no rows-dir to name. 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-MDL virtual-spec fallback at archive//mdl/. The // spec bytes come from IsDefaultMdlSpec via the static-file // dispatcher when no on-disk file exists at that path; the rows-dir // itself doesn't need to exist either (fully virtual archive). if isAtArchivePartyMdlDir(fsRoot, dirAbs) { return &TableRequest{Name: "mdl", SpecPath: specAbs, Dir: dirAbs} } return nil } // isAtArchivePartyMdlDir reports whether dirAbs is exactly // //archive//mdl. Used by the default-MDL // fallback to recognize the virtual rows-dir whether or not it // exists on disk. func isAtArchivePartyMdlDir(fsRoot, dirAbs 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, "/") if len(parts) != 4 { return false } return strings.EqualFold(parts[1], "archive") && strings.EqualFold(parts[3], "mdl") } // 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 { http.Error(w, "Forbidden", http.StatusForbidden) 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) }