Adds a UI checkbox next to the existing Sort dropdown that surfaces
hidden entries when ACL would otherwise allow read. Default off
(matches today's filtered behavior). On toggle, browse re-fetches
the current directory with ?hidden=1 and re-renders.
┌─ browse toolbar ─────────────────────────────────────────────┐
│ Sort: [Name (A→Z) ▾] ☐ Show hidden │
└──────────────────────────────────────────────────────────────┘
Server-side surface:
- internal/fs/tree.go ListDirectory gains an `includeHidden bool`
parameter. The .-prefix filter (previously hard-coded) now also
drops _-prefix entries (matches dispatch's reserved-prefix guard)
and honors the new flag.
- internal/handler/directory.go reads `?hidden=1` from the request
and threads it through.
- cmd/zddc-server/main.go dispatcher relaxes its dot-prefix and
_-prefix guards for GET/HEAD when `?hidden=1` is set, so clicking
a hidden entry's link works. `_app/` (apps cache) stays
unconditionally reserved — those bytes must go through the apps
resolver. Writes to hidden paths stay blocked (the file API has
its own segment check that the flag does NOT relax).
- internal/listing/listing.go: signature parity (the lower-level
helper that's used by tests + non-cascade listing paths).
Security model unchanged: the ACL chain on the parent dir is the only
real gate. Whoever can read the dir can see its contents — toggling
"Show hidden" just stops the client-side filter from masking
.-prefixed and _-prefixed entries. Hidden paths today:
• <dir>/.zddc ACL YAML — already exposed via /.profile/zddc
• <dir>/.converted/<base> cached MD→DOCX/HTML/PDF, same sensitivity as source
• <root>/.zddc.d/tokens/ per-token metadata; filename = sha256(token)
so not bearer-usable. Default root ACL
restricts to admins; matches /.tokens UI.
• <root>/.zddc.d/logs/ access logs; same admins-only audience
• <root>/_app/ cached upstream tool HTML (public)
• <root>/_template/ install.zip scaffolding (public)
None of these contain bearer credentials or secret material that the
existing ACL doesn't already gate. The walls are still the cascade.
224 lines
8.6 KiB
Go
224 lines
8.6 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)
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|