ZDDC/zddc/internal/handler/directory.go
ZDDC 55328c8c28 feat(browse): editors honor server-side write authority + don't steal focus
Listing JSON gains a writable bool per file row, computed by running
the policy decider with ActionWrite against the parent-dir chain
(with the same admin-bypass branch the file API uses). Cost: one
extra decider call per file in the listing, sharing the parent
chain so the cascade walk is amortized.

Browse loader stores writable on every tree node. The markdown and
YAML editors read it and gate their canSave + initial mount:

- !writable markdown → Toast UI Viewer (rendered, no edit toolbar,
  no caret). Banner above explains why save is disabled.
- !writable YAML → CodeMirror readOnly:'nocursor' (selection for
  copy, no caret). Banner above explains why save is disabled.

Both editors gain autofocus:false so keyboard nav in the browse
tree doesn't divert into the editor — arrow keys keep moving through
files and folders without the caret jumping. User clicks (or tabs)
into the editor when they actually want to type.

.zddc files already route through preview-yaml's isZddcFile path;
bare .zddc (no ext) matches because that function checks the
literal name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:42:36 -05:00

240 lines
9.5 KiB
Go

package handler
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
appfs "codeberg.org/VARASYS/ZDDC/zddc/internal/fs"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// listingETag returns a hex-encoded SHA-256 prefix of the rendered JSON
// listing body. Truncated to 16 chars (64 bits) — collisions on a
// listing of any realistic size are vanishingly unlikely, and the short
// header keeps the wire footprint trim.
func listingETag(body []byte) string {
h := sha256.Sum256(body)
return hex.EncodeToString(h[:8])
}
// safeJoin joins fsRoot and relPath, then verifies the result is under fsRoot.
// Returns ("", false) if relPath would escape fsRoot.
func safeJoin(fsRoot, relPath string) (string, bool) {
abs := filepath.Join(fsRoot, filepath.FromSlash(relPath))
if !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) && abs != fsRoot {
return "", false
}
return abs, true
}
// ServeDirectory handles a request for a directory path.
// If Accept: application/json → return Caddy-compatible JSON listing.
// Otherwise → return minimal HTML.
//
// appsSrv is optional: when non-nil, HTML responses resolve the
// `browse` tool through the apps subsystem so a `.zddc apps:` cascade
// entry can override the embedded bytes (handy for live alpha-dev
// iteration: point apps.browse: at a path source and every ./build is
// served from disk without recompiling the binary). When nil, the
// embedded copy is served directly — same behavior as before the
// cascade hook was added.
func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWriter, r *http.Request) {
urlPath := r.URL.Path
if !strings.HasSuffix(urlPath, "/") {
http.Redirect(w, r, urlPath+"/", http.StatusFound)
return
}
email := EmailFromContext(r)
decider := DeciderFromContext(r)
ctx := r.Context()
// Compute relative dir path (no leading or trailing slash)
dirPath := strings.TrimPrefix(urlPath, "/")
dirPath = strings.TrimSuffix(dirPath, "/")
// ACL check on this directory itself.
// Bypassed at the root path: the landing page is a public project
// picker. Per-project filtering inside fs.ListDirectory still hides
// directories the caller can't reach.
absDir, ok := safeJoin(cfg.Root, dirPath)
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
chain, err := zddc.EffectivePolicy(cfg.Root, absDir)
if err != nil {
slog.Warn("ACL policy error", "path", absDir, "err", err)
}
isRoot := dirPath == ""
if !isRoot {
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
}
accept := r.Header.Get("Accept")
// For HTML requests, serve index.html if present (landing page convention)
if !strings.Contains(accept, "application/json") {
indexPath := filepath.Join(absDir, "index.html")
if info, err := os.Stat(indexPath); err == nil && !info.IsDir() {
ServeFile(w, r, indexPath)
return
}
// Tables redirect: when this directory is the rows directory of
// a registered table — i.e. the parent declares
// `tables: { <name>: ... }` with a valid spec, OR the default-MDL
// fallback kicks in at archive/<party>/mdl/ — bounce HTML
// requests to the canonical <parent>/<name>.table.html URL so
// users land on the table view instead of a bare folder listing.
// JSON requests fall through unchanged so the table client can
// still enumerate row files.
if redirect := tableRowsRedirect(cfg.Root, urlPath); redirect != "" {
http.Redirect(w, r, redirect, http.StatusFound)
return
}
}
// Build base URL for listing entries
baseURL := urlPath // relative URLs suffice for JSON listings
// ?hidden=1 surfaces dot- and underscore-prefixed entries. ACL is
// still the only real gate — anyone who can't read this dir sees
// nothing regardless. browse pipes the flag through when its
// "Show hidden" toggle is on. Matches the bare-flag convention
// used by ?zip and ?convert= elsewhere in the dispatcher.
includeHidden := r.URL.Query().Has("hidden")
entries, err := appfs.ListDirectory(ctx, decider, cfg.Root, dirPath, email, baseURL, includeHidden, ElevatedFromContext(r))
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "Not Found", http.StatusNotFound)
} else {
slog.Error("listing directory", "path", dirPath, "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
// Vary: Accept is critical — the same URL serves either the JSON
// listing or the embedded browse.html depending on the Accept
// header. Without Vary, browsers/CDNs cache one response and
// serve it for the other Accept value, breaking browse.html's
// auto-detect (which fetches the same URL with Accept: JSON).
w.Header().Set("Vary", "Accept")
// Surface cascade-resolved scope flags via response headers so
// the browse SPA can render scope-aware UI (drop-zone overlay,
// grid-mode auto-activation, future affordances) without
// re-implementing the cascade client-side. Keep the header
// surface tight — only routing-shape signals go here; ACL
// details stay server-side.
if zddc.DropTargetAt(cfg.Root, absDir) {
w.Header().Set("X-ZDDC-Drop-Target", "true")
}
if dt := zddc.DefaultToolAt(cfg.Root, absDir); dt != "" {
w.Header().Set("X-ZDDC-Default-Tool", dt)
}
// X-ZDDC-On-Plan-Review surfaces whether the cascade above this
// path has an on_plan_review block configured. Browse uses it to
// show/hide the "Plan Review" right-click menu item without
// re-implementing the cascade client-side. Boolean; absent header
// = false.
if zddc.OnPlanReviewAt(cfg.Root, absDir) != nil {
w.Header().Set("X-ZDDC-On-Plan-Review", "true")
}
// X-ZDDC-Canonical-Folder names the canonical project-layout slot
// this directory occupies — "incoming", "received", "working",
// "staging", etc. Drives scope-aware context-menu visibility for
// Accept Transmittal, Stage/Unstage, and Create Transmittal folder.
// Absent header means the directory is not at a canonical slot.
if cf := zddc.CanonicalFolderAt(cfg.Root, absDir); cf != "" {
w.Header().Set("X-ZDDC-Canonical-Folder", cf)
}
if strings.Contains(accept, "application/json") {
// Content-hash ETag on the listing payload. Re-fetched on every
// request (the cascade is walked, ACL filter applied, JSON
// rendered, hashed) — that's the same server work the previous
// no-cache version did. The win is on the *response*: identical
// listings (e.g. the same vendor refreshing their archive page)
// short-circuit to 304 with no body.
//
// Crucially, this scheme tolerates unreliable filesystem
// watching (Azure SMB, network shares with delayed inotify):
// the ETag is the actual response hash, not a watcher-derived
// invalidation token, so it can never lie about content.
body, err := json.Marshal(entries)
if err != nil {
slog.Error("encoding directory listing", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
etag := `"` + listingETag(body) + `"`
w.Header().Set("Content-Type", "application/json")
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate")
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
_, _ = w.Write(body)
return
}
// Browser HTML fallback: serve the directory's DirTool — the
// trailing-slash half of the slash/no-slash convention. It
// resolves to "browse" by default (the single-file file-tree SPA
// whose autoDetectServerMode loads the JSON listing for the
// current directory and renders it as a sortable, filterable
// tree); an operator's `.zddc dir_tool:` can point a subtree's
// slash form at another directory-oriented tool. Either way it
// goes through the apps subsystem when wired up, so `.zddc apps:`
// source overrides are honored at directory URLs too (not just
// /<dir>/<tool>.html). When appsSrv is nil we serve the embedded
// browse copy directly — same behavior as before the hook.
dirTool := zddc.DirToolAt(cfg.Root, absDir)
if appsSrv != nil && zddc.IsKnownApp(dirTool) {
appsSrv.Serve(w, r, dirTool, chain, absDir)
return
}
body := apps.EmbeddedBytes("browse")
if len(body) == 0 {
// Bootstrap state: a fresh build hasn't populated browse.html
// into the embed yet. Fall through to JSON for clients that
// will still parse it.
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
if err := json.NewEncoder(w).Encode(entries); err != nil {
slog.Error("encoding directory listing (no-embed fallback)", "err", err)
}
return
}
// ETag + max-age=0 + must-revalidate: every request re-validates and
// gets a 304 unless the binary has been redeployed (the ETag is a
// content hash, computed once at startup and memoized in apps.embed).
// Saves re-transmitting ~230 KB of browse.html on every page load
// while still picking up redeploys immediately.
etag := `"` + apps.EmbeddedETag("browse") + `"`
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-ZDDC-Source", "embedded:browse")
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
_, _ = w.Write(body)
}