When an HTML GET hits a directory that's the rows-dir of a registered
table — i.e. parent declares `tables: { <name>: ... }` with a valid
spec, OR the default-MDL fallback applies at archive/<party>/mdl/ —
ServeDirectory now 302s to <parent>/<name>.table.html so users land
on the table view instead of a bare browse listing of the row-yaml
files. JSON GETs on the same URL fall through unchanged so the table
client can still enumerate row files.
Detection reuses RecognizeTableRequest: synthesize the equivalent
.table.html URL from the directory request and let the existing
recognizer apply its operator-vs-default-vs-missing-spec rules. No
duplicated validation.
Updates main_test.go's TestDispatchSlashRouting to expect the new
behavior on archive/<party>/mdl/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
266 lines
9.4 KiB
Go
266 lines
9.4 KiB
Go
// Package handler — tablehandler.go: directory-of-YAML table view.
|
|
//
|
|
// URL convention:
|
|
//
|
|
// GET /<dir>/<name>.table.html → tables.html, only when <dir>/.zddc
|
|
// declares tables: { <name>: <spec-path> }
|
|
// 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 <dir>/.zddc declare a table named <name>,
|
|
// 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/<party>/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/<party>/
|
|
// 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 <project>/archive/<party>/ (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, "/")
|
|
// <project>/archive/<party>/<file> = 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 <parent>/<name>.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 <dir>/.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 <dir>/<name>.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/<party>/. 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 <project>/archive/<party>/
|
|
// 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)
|
|
}
|