The site-root .zddc.zip bundle was existence-hidden (404) over HTTP for
everyone. Now an active (elevated) admin over its directory can browse it
in the file tree like any other zip: GET /.zddc.zip/ lists members, GET
/.zddc.zip/<member> extracts one, and a bare GET downloads it. Everyone
else — including the same admin un-elevated — still gets 404 for every URL
shape, which additionally closes a prior by-name member read (the old gate
only 404'd the bundle base, so /.zddc.zip/<member> leaked to any reader of
the root).
The dispatch gate now keys off the bundle segment anywhere in the path and
requires activeAdminForBundle (mirrors ActiveAdminForSidecar). The listing
(fs.ListDirectory) surfaces the .zddc.d reserve and .zddc.zip bundle only to
an active admin, so non-admins don't even see the names under ?hidden=1.
Client needs no change: splitExtension('.zddc.zip').extension == 'zip', so
browse already renders it as a navigable archive (tree.js isZip). Internal
apps.Bundle FS resolution never goes through dispatch, so it's unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
419 lines
16 KiB
Go
419 lines
16 KiB
Go
package fs
|
|
|
|
import (
|
|
"context"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/listing"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// ListDirectory returns a Caddy-compatible JSON listing for the directory at
|
|
// filepath.Join(fsRoot, dirPath), filtered by ACL for userEmail.
|
|
//
|
|
// Rules:
|
|
// - Hidden files (.-prefixed and _-prefixed) are excluded by default
|
|
// unless includeHidden is true (typically driven by ?hidden=1 on
|
|
// the request).
|
|
// - *.portfolio files appear as virtual directories (stem + "/")
|
|
// - Subdirectories for which the user lacks access are omitted (not 403'd inline)
|
|
// - dirPath="" means the root of the served tree
|
|
//
|
|
// baseURL should end with "/" and is the URL prefix for this directory.
|
|
//
|
|
// The decider is queried per subdirectory; nil falls back to the internal
|
|
// Go evaluator (policy.InternalDecider) for tests that don't wire up
|
|
// an explicit decider.
|
|
func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, userEmail, baseURL string, includeHidden, elevated bool) ([]listing.FileInfo, error) {
|
|
if decider == nil {
|
|
decider = &policy.InternalDecider{}
|
|
}
|
|
absDir, ok := safeJoin(fsRoot, dirPath)
|
|
if !ok {
|
|
return nil, os.ErrNotExist
|
|
}
|
|
|
|
// Virtual received/ window: when the URL points at <workflow>/received/
|
|
// (i.e. the URL traverses a `received` segment whose workflow-folder
|
|
// parent declares received_path in its .zddc), redirect the listing
|
|
// source to the canonical received/<tracking>/ path. Entry URLs stay
|
|
// rooted at baseURL so the browse client keeps the workflow context —
|
|
// drag-drop onto an entry here PUTs to <workflow>/received/<file>,
|
|
// which serveFilePut intercepts and rewrites to <workflow>/<base>+C<n><suffix>.
|
|
if vr := zddc.ResolveVirtualReceived(fsRoot, strings.TrimSuffix(baseURL, "/")); vr.Resolved && vr.IsRoot {
|
|
absDir = vr.ReceivedAbs
|
|
}
|
|
|
|
entries, err := os.ReadDir(absDir)
|
|
if err != nil {
|
|
// Empty-listing fallback for cascade-declared paths. A fresh
|
|
// project doesn't have working/, staging/, reviewing/, or
|
|
// archive/<party>/incoming/ on disk until something is
|
|
// written into them — but the cascade (defaults.zddc.yaml
|
|
// plus any on-disk overrides) declares them via paths:, so
|
|
// the stage-strip / file nav can link unconditionally.
|
|
// Returning [] gives a usable empty view (the tables peers
|
|
// still surface their synthetic spec entries below).
|
|
if os.IsNotExist(err) && zddc.IsDeclaredPath(fsRoot, absDir) {
|
|
entries = nil
|
|
} else {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Empty (not nil) so the JSON encoder emits [] rather than null
|
|
// when no entries match — clients (browse, archive) expect an array.
|
|
result := make([]listing.FileInfo, 0, len(entries))
|
|
|
|
// Display overrides for this directory's children, sourced from
|
|
// THIS directory's .zddc `display:` map. Built once and looked up
|
|
// case-insensitively per entry. Empty map = no overrides.
|
|
displayMap := readDisplayMap(absDir)
|
|
|
|
// Set of cascade-declared child names (lowercase) for this dir.
|
|
// Entries with a matching name get Declared=true so clients can
|
|
// pick out the canonical-convention children without
|
|
// re-implementing the cascade.
|
|
declaredSet := make(map[string]bool)
|
|
for _, name := range zddc.ChildrenDeclaredAt(fsRoot, absDir) {
|
|
declaredSet[strings.ToLower(name)] = true
|
|
}
|
|
|
|
// Parent-dir chain + active-admin status. Files in this directory
|
|
// inherit authorization from this chain, so we compute it once
|
|
// and reuse for every file entry's Writable bit. Subdirectories
|
|
// build their own chain (the child cascade can differ — e.g. a
|
|
// per-user fenced home).
|
|
parentChain, _ := zddc.EffectivePolicy(fsRoot, absDir)
|
|
principal := zddc.Principal{Email: userEmail, Elevated: elevated}
|
|
parentActiveAdmin := elevated && userEmail != "" &&
|
|
zddc.IsAdminForChain(parentChain, userEmail)
|
|
// Edit-history is a subtree behavior; resolved once for this dir and
|
|
// flagged on each eligible (markdown) file so the browse client knows
|
|
// where to offer the History/diff affordances.
|
|
historyEnabled := parentChain.EffectiveHistory()
|
|
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
|
|
// Hidden file filter. '.' marks system/internal state (.zddc,
|
|
// .converted/, .zddc.d/) and '_' marks operator scaffolding
|
|
// (_app, _template). Both prefixes are hidden by default;
|
|
// includeHidden=true (set via ?hidden=1 in the request) surfaces
|
|
// them. The ACL chain still applies — anyone who can't read
|
|
// the parent directory sees nothing regardless of this flag.
|
|
if !includeHidden && (strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_")) {
|
|
continue
|
|
}
|
|
|
|
// Reserved config — the .zddc.d sidecar reserve and the .zddc.zip
|
|
// config bundle — is surfaced only to an active (elevated) admin over
|
|
// this directory. Everyone else can't open it anyway (dispatch 404s
|
|
// the access), so listing the names would just advertise hidden
|
|
// config. The plain .zddc file stays visible/editable (handled below).
|
|
if (strings.EqualFold(name, ".zddc.d") || strings.EqualFold(name, ".zddc.zip")) && !parentActiveAdmin {
|
|
continue
|
|
}
|
|
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
isDir := entry.IsDir()
|
|
displayName := lookupDisplay(displayMap, name)
|
|
declared := declaredSet[strings.ToLower(name)]
|
|
|
|
if isDir {
|
|
// ACL check for subdirectory
|
|
subAbs := filepath.Join(absDir, name)
|
|
chain, err := zddc.EffectivePolicy(fsRoot, subAbs)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
subURLPath := baseURL + name + "/"
|
|
subVerbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, subURLPath)
|
|
if !subVerbs.Has(zddc.VerbR) {
|
|
continue // omit denied directories silently
|
|
}
|
|
// Pull the title from this subdir's own .zddc, if it has
|
|
// one. Lets clients render project / folder names without
|
|
// a second round-trip per entry — the landing page used
|
|
// to need a bespoke /api with this info; now the generic
|
|
// listing carries it.
|
|
var title string
|
|
if zf, perr := zddc.ParseFile(filepath.Join(subAbs, ".zddc")); perr == nil {
|
|
title = zf.Title
|
|
}
|
|
fi := listing.FileInfo{
|
|
Name: name + "/",
|
|
Size: info.Size(),
|
|
URL: baseURL + url.PathEscape(name) + "/",
|
|
ModTime: info.ModTime(),
|
|
Mode: uint32(info.Mode()),
|
|
IsDir: true,
|
|
DisplayName: displayName,
|
|
Declared: declared,
|
|
Title: title,
|
|
Verbs: subVerbs.String(),
|
|
}
|
|
result = append(result, fi)
|
|
continue
|
|
}
|
|
|
|
// Regular file
|
|
fi := listing.FileInfo{
|
|
Name: name,
|
|
Size: info.Size(),
|
|
URL: baseURL + url.PathEscape(name),
|
|
ModTime: info.ModTime(),
|
|
Mode: uint32(info.Mode()),
|
|
IsDir: false,
|
|
DisplayName: displayName,
|
|
Declared: declared,
|
|
}
|
|
// Verbs surfaces what the principal can do at this file's URL,
|
|
// computed against the parent-dir chain (files inherit from
|
|
// parent; they have no .zddc of their own). Writable is the
|
|
// legacy single-bit projection — it stays in lockstep with
|
|
// the verbs string for the transition window. For .zddc files
|
|
// the legacy gate maps Writable to the admin verb (a) instead
|
|
// of write (w), matching fileapi.go's ActionAdmin gate at
|
|
// the .zddc URL.
|
|
fileURL := baseURL + name
|
|
fileVerbs := policy.EffectiveVerbsFromChainP(ctx, decider, parentChain, principal, fileURL)
|
|
fi.Verbs = fileVerbs.String()
|
|
writableBit := zddc.VerbW
|
|
if name == ".zddc" {
|
|
writableBit = zddc.VerbA
|
|
}
|
|
fi.Writable = fileVerbs.Has(writableBit) || parentActiveAdmin
|
|
fi.History = historyEnabled && strings.EqualFold(filepath.Ext(name), ".md")
|
|
result = append(result, fi)
|
|
}
|
|
|
|
// At a project root, surface the cascade-declared top-level peers
|
|
// (archive + the party-partitioned workspaces/registers) as virtual
|
|
// entries when no on-disk variant exists. The browse client
|
|
// previously did this client-side; moving it server-side lets the
|
|
// directory's `display:` map apply to virtual entries the same way
|
|
// it applies to real ones.
|
|
result = append(result, virtualCanonicalFolders(ctx, decider, fsRoot, absDir, principal, baseURL, result, displayMap)...)
|
|
|
|
// Tables register peers (ssr/mdl/rsk) at the project root:
|
|
//
|
|
// The peer root lists normally — mdl/ and rsk/ show their party
|
|
// subdirs (folder-nav), ssr/ shows its flat ssr/<party>.yaml rows.
|
|
// For mdl/rsk the tables tool aggregates the cross-party table by
|
|
// recursing one level into the party subdirs CLIENT-side (works in
|
|
// both online + offline modes); the $party column comes from the
|
|
// server-injected row content (see ServeInjectedRow). All three just
|
|
// advertise the synthetic table.yaml/form.yaml entries here so the
|
|
// tables tool's walkServer finds the spec without a 404 (spec bytes
|
|
// come from main.go's IsDefaultSpec fallback).
|
|
if segsURL := strings.Split(strings.Trim(baseURL, "/"), "/"); len(segsURL) == 2 && zddc.IsRowSlot(segsURL[1]) {
|
|
// Advertise the tables specs (skip any already present on disk).
|
|
have := make(map[string]bool, len(result))
|
|
for _, fi := range result {
|
|
have[fi.Name] = true
|
|
}
|
|
for _, spec := range []string{"table.yaml", "form.yaml"} {
|
|
if have[spec] {
|
|
continue
|
|
}
|
|
specURL := baseURL + spec
|
|
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, parentChain, principal, specURL)
|
|
if !verbs.Has(zddc.VerbR) {
|
|
continue
|
|
}
|
|
result = append(result, listing.FileInfo{
|
|
Name: spec,
|
|
URL: specURL,
|
|
IsDir: false,
|
|
Virtual: true,
|
|
Verbs: verbs.String(),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Workflow folder: append a virtual `received/` entry whose backing
|
|
// is .zddc.received_path. The entry's URL stays under the workflow
|
|
// folder (baseURL + "received/") so a click navigates "into" the
|
|
// synthetic child — the listing handler then swaps the read source
|
|
// to the canonical received/<tracking>/ path while keeping the URL
|
|
// context intact. Suppressed if a real `received/` already exists on
|
|
// disk (operator override).
|
|
if rp := zddc.WorkflowReceivedPath(absDir); rp != "" {
|
|
hasReal := false
|
|
for _, fi := range result {
|
|
if fi.IsDir && strings.EqualFold(strings.TrimSuffix(fi.Name, "/"), "received") {
|
|
hasReal = true
|
|
break
|
|
}
|
|
}
|
|
if !hasReal {
|
|
receivedURL := baseURL + "received/"
|
|
// Verbs against the canonical workflow's chain — the
|
|
// virtual `received/` resolves to a read-through window
|
|
// onto received/<tracking>/; writes go through serveFilePut
|
|
// which rewrites to a +Cn revision. Read is the only verb
|
|
// surfaced here.
|
|
vrVerbs := policy.EffectiveVerbsFromChainP(ctx, decider, parentChain, principal, receivedURL)
|
|
if vrVerbs.Has(zddc.VerbR) {
|
|
result = append(result, listing.FileInfo{
|
|
Name: "received/",
|
|
URL: receivedURL,
|
|
IsDir: true,
|
|
Virtual: true,
|
|
Verbs: (vrVerbs & zddc.VerbR).String(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Surface a virtual `.zddc` entry when the on-disk file doesn't
|
|
// exist. The /<dir>/.zddc URL always serves SOMETHING — real
|
|
// bytes if present, a synthetic placeholder body otherwise (see
|
|
// handler.ServeZddcFile) — so the entry resolves to a real
|
|
// editable view either way. PUT-ing back materialises the file
|
|
// on disk and the listing converts to a real (non-virtual) row
|
|
// automatically on the next fetch. Only emitted when the caller
|
|
// asked for hidden entries (?hidden=1), matching the dot-prefix
|
|
// hide rule used for every other dotfile.
|
|
if includeHidden {
|
|
if v, ok := virtualZddcEntry(ctx, decider, parentChain, principal, parentActiveAdmin, absDir, baseURL); ok {
|
|
result = append(result, v)
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// virtualZddcEntry returns a synthetic listing entry for absDir/.zddc
|
|
// when no real file exists. The cascade has effective rules at every
|
|
// path (down through embedded defaults), so editing this virtual
|
|
// entry is always meaningful — a save promotes it to a real on-disk
|
|
// .zddc that overrides ancestor levels for this directory.
|
|
//
|
|
// Writable mirrors the real-file path: ActionAdmin against the parent
|
|
// chain, short-circuited when the principal already holds admin
|
|
// authority. An elevated admin sees writable=true and the editor lets
|
|
// them save; a non-admin sees writable=false and the editor mounts
|
|
// read-only. Verbs carries the full verb set so a client can also gate
|
|
// other affordances (e.g. delete on the editor's toolbar).
|
|
func virtualZddcEntry(ctx context.Context, decider policy.Decider, parentChain zddc.PolicyChain, principal zddc.Principal, parentActiveAdmin bool, absDir, baseURL string) (listing.FileInfo, bool) {
|
|
zddcPath := filepath.Join(absDir, ".zddc")
|
|
if _, err := os.Stat(zddcPath); err == nil {
|
|
return listing.FileInfo{}, false
|
|
} else if !os.IsNotExist(err) {
|
|
return listing.FileInfo{}, false
|
|
}
|
|
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, parentChain, principal, baseURL+".zddc")
|
|
return listing.FileInfo{
|
|
Name: ".zddc",
|
|
URL: baseURL + ".zddc",
|
|
IsDir: false,
|
|
Virtual: true,
|
|
Writable: verbs.Has(zddc.VerbA) || parentActiveAdmin,
|
|
Verbs: verbs.String(),
|
|
}, true
|
|
}
|
|
|
|
// virtualCanonicalFolders returns synthetic entries for any
|
|
// cascade-declared child name that's absent from the on-disk
|
|
// listing. Sources from zddc.ChildrenDeclaredAt — the cascade's
|
|
// effective paths: at dirPath enumerates the expected children
|
|
// (archive, working, staging, reviewing at a project root; mdl,
|
|
// incoming, received, issued under archive/<party>/; whatever an
|
|
// operator added via on-disk .zddc paths:). Case-insensitive
|
|
// presence check suppresses a virtual entry when the on-disk
|
|
// directory exists in any case. Verbs are computed against each
|
|
// synthetic child's would-be chain so client-side gating matches
|
|
// what a real on-disk folder would carry.
|
|
func virtualCanonicalFolders(ctx context.Context, decider policy.Decider, fsRoot, absDir string, principal zddc.Principal, baseURL string,
|
|
real []listing.FileInfo, displayMap map[string]string) []listing.FileInfo {
|
|
|
|
declared := zddc.ChildrenDeclaredAt(fsRoot, absDir)
|
|
if len(declared) == 0 {
|
|
return nil
|
|
}
|
|
|
|
present := make(map[string]bool, len(real))
|
|
for _, fi := range real {
|
|
if !fi.IsDir {
|
|
continue
|
|
}
|
|
bare := strings.TrimSuffix(fi.Name, "/")
|
|
present[strings.ToLower(bare)] = true
|
|
}
|
|
|
|
var synth []listing.FileInfo
|
|
for _, name := range declared {
|
|
if present[strings.ToLower(name)] {
|
|
continue
|
|
}
|
|
childAbs := filepath.Join(absDir, name)
|
|
chain, err := zddc.EffectivePolicy(fsRoot, childAbs)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
childURL := baseURL + url.PathEscape(name) + "/"
|
|
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, childURL)
|
|
if !verbs.Has(zddc.VerbR) {
|
|
continue
|
|
}
|
|
synth = append(synth, listing.FileInfo{
|
|
Name: name + "/",
|
|
URL: childURL,
|
|
IsDir: true,
|
|
Virtual: true,
|
|
DisplayName: lookupDisplay(displayMap, name),
|
|
Declared: true, // synthesized entries are by definition cascade-declared
|
|
Verbs: verbs.String(),
|
|
})
|
|
}
|
|
return synth
|
|
}
|
|
|
|
// readDisplayMap parses dirAbs/.zddc and returns its Display map (or
|
|
// nil when the file doesn't exist or has no display block). All keys
|
|
// are case-folded to lowercase so lookupDisplay's case-insensitive
|
|
// match is a simple map read.
|
|
func readDisplayMap(dirAbs string) map[string]string {
|
|
zf, err := zddc.ParseFile(filepath.Join(dirAbs, ".zddc"))
|
|
if err != nil || len(zf.Display) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(zf.Display))
|
|
for k, v := range zf.Display {
|
|
if v == "" {
|
|
continue
|
|
}
|
|
out[strings.ToLower(strings.TrimSpace(k))] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
// lookupDisplay returns the custom display label for name (matched
|
|
// case-insensitively against displayMap's keys), or "" when no
|
|
// override applies.
|
|
func lookupDisplay(displayMap map[string]string, name string) string {
|
|
if len(displayMap) == 0 {
|
|
return ""
|
|
}
|
|
return displayMap[strings.ToLower(name)]
|
|
}
|