// 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 // 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 } // RecognizeTableRequest classifies r as a table-system request, or // returns nil if it falls through to other handlers. Discovery is // strictly .zddc-declarative — a *.table.html URL with no matching // `tables:` entry in /.zddc returns nil so it falls through to // the static-file path (404 unless an operator dropped a real file). // // 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") { return nil } // Split /.table.html into dir + name. stem := strings.TrimSuffix(urlPath, ".table.html") if stem == "" || stem == "/" { return nil } dirRel := filepath.Dir(filepath.FromSlash(strings.TrimPrefix(stem, "/"))) name := filepath.Base(filepath.FromSlash(strings.TrimPrefix(stem, "/"))) if name == "" || name == "." || name == "/" { return nil } dirAbs := filepath.Join(fsRoot, dirRel) if !strings.HasPrefix(dirAbs, fsRoot+string(filepath.Separator)) && dirAbs != fsRoot { return nil } zddcPath := filepath.Join(dirAbs, ".zddc") zf, err := zddc.ParseFile(zddcPath) if err != nil { // Malformed .zddc — log and pass through; static handler will 500 // if it cares. Recognition just says "not a declared table here." slog.Warn("table: .zddc parse error", "path", zddcPath, "err", err) return nil } specRel, ok := zf.Tables[name] if !ok { return nil } specAbs := filepath.Join(dirAbs, filepath.FromSlash(specRel)) if !strings.HasPrefix(specAbs, fsRoot+string(filepath.Separator)) && specAbs != fsRoot { return nil } if !fileExists(specAbs) { return nil } return &TableRequest{ Name: name, SpecPath: specAbs, Dir: dirAbs, } } // 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) { email := EmailFromContext(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.AllowActionFromChain(r.Context(), decider, chain, email, 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) }