// 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, dirAbs) describes a request // for the default mdl.table.yaml or mdl.form.yaml under archive// // where no operator file exists. Caller is the static-file handler. // // Returns the embedded bytes + true when the fallback should fire. // Returns nil + false when an operator-supplied file exists or the path // is not eligible for the fallback. func IsDefaultMdlSpec(fsRoot, urlPath string) ([]byte, bool) { base := strings.ToLower(filepath.Base(urlPath)) var bytes []byte switch base { case "mdl.table.yaml": bytes = embeddedDefaultMdlTable case "mdl.form.yaml": bytes = embeddedDefaultMdlForm default: return nil, false } if !isAtArchivePartyLevel(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 } // 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") } // 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 is the rows-dir of a // registered table. Returns "" when no redirect should fire. // // Recognition reuses RecognizeTableRequest by synthesizing the // equivalent /.table.html URL from urlPath and asking // the table-recognizer whether it's a real, declared (or default-MDL) // table. This keeps validation in one place — operator-declared tables // require both a `tables:` entry AND an existing spec file. func tableRowsRedirect(fsRoot, urlPath string) string { // urlPath is the directory request — e.g. "/proj/archive/Acme/mdl/". trimmed := strings.TrimSuffix(urlPath, "/") if trimmed == "" || trimmed == "/" { return "" } slash := strings.LastIndex(trimmed, "/") if slash < 0 { return "" } parent := trimmed[:slash+1] // includes trailing slash name := trimmed[slash+1:] if name == "" { return "" } synthesized := parent + name + ".table.html" if RecognizeTableRequest(fsRoot, http.MethodGet, synthesized) == nil { return "" } return synthesized } // 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 && !isNotExistError(err) { // 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 } if specRel, ok := zf.Tables[name]; ok { // Operator explicitly declared this table — honour it strictly. // If the declared spec file is missing, return nil so the URL // 404s rather than silently falling back to the default. This // keeps a typo in the operator's .zddc visible. 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, } } // No operator declaration — apply the default MDL spec fallback at // archive//. The spec bytes are served by IsDefaultMdlSpec via // the static-file dispatcher. if strings.EqualFold(name, "mdl") && isArchivePartyDir(fsRoot, dirAbs) { return &TableRequest{ Name: "mdl", SpecPath: filepath.Join(dirAbs, "mdl.table.yaml"), // virtual; static handler may serve embedded bytes Dir: dirAbs, } } return nil } // 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") } // isArchivePartyDir reports whether dirAbs is a /archive// // directory under fsRoot, with archive case-folded. func isArchivePartyDir(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) != 3 { return false } return strings.EqualFold(parts[1], "archive") } // 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) }