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>
378 lines
14 KiB
Go
378 lines
14 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
|
|
// virtualUserHomeEntry below still fires for working/.
|
|
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, false)
|
|
|
|
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
|
|
}
|
|
|
|
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 + "/"
|
|
allowed, _ := policy.AllowFromChain(ctx, decider, chain, userEmail, subURLPath)
|
|
if !allowed {
|
|
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,
|
|
}
|
|
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,
|
|
}
|
|
// Writable surfaces whether THIS principal could PUT this file
|
|
// — same decision as the file API's authorizeAction would
|
|
// reach. Uses the parent-dir chain (computed once above);
|
|
// active-admin status short-circuits the per-file decider
|
|
// query when the principal already holds admin authority.
|
|
fileURL := baseURL + name
|
|
if parentActiveAdmin {
|
|
fi.Writable = true
|
|
} else {
|
|
allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, fileURL, policy.ActionWrite)
|
|
if allowed {
|
|
fi.Writable = true
|
|
}
|
|
}
|
|
result = append(result, fi)
|
|
}
|
|
|
|
// Per-user virtual home: when listing <project>/working/ for an
|
|
// authenticated viewer, surface a synthetic <viewer-email>/ entry if
|
|
// no real folder of any case variant already exists for them. A
|
|
// first write to that path materialises a real folder with auto-own
|
|
// .zddc; subsequent listings drop the synthetic entry naturally.
|
|
if syn, ok := virtualUserHomeEntry(fsRoot, dirPath, userEmail, baseURL, result); ok {
|
|
result = append(result, syn)
|
|
}
|
|
|
|
// At a project root, surface the four canonical project folders
|
|
// (archive/working/staging/reviewing) as virtual entries when no
|
|
// on-disk variant exists in any case. 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(fsRoot, absDir, baseURL, result, displayMap)...)
|
|
|
|
// 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 {
|
|
result = append(result, listing.FileInfo{
|
|
Name: "received/",
|
|
URL: baseURL + "received/",
|
|
IsDir: true,
|
|
Virtual: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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(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.
|
|
func virtualZddcEntry(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
|
|
}
|
|
return listing.FileInfo{
|
|
Name: ".zddc",
|
|
URL: baseURL + ".zddc",
|
|
IsDir: false,
|
|
Virtual: true,
|
|
}, 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.
|
|
func virtualCanonicalFolders(fsRoot, absDir, 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
|
|
}
|
|
synth = append(synth, listing.FileInfo{
|
|
Name: name + "/",
|
|
URL: baseURL + url.PathEscape(name) + "/",
|
|
IsDir: true,
|
|
Virtual: true,
|
|
DisplayName: lookupDisplay(displayMap, name),
|
|
Declared: true, // synthesized entries are by definition cascade-declared
|
|
})
|
|
}
|
|
return synth
|
|
}
|
|
|
|
// virtualUserHomeEntry returns the synthetic <viewer-email>/ entry that
|
|
// should be appended to a working/ listing, or (zero, false) when no
|
|
// synthetic entry applies.
|
|
//
|
|
// Conditions for the entry to fire:
|
|
// - dirPath case-folds to <project>/working at depth-2 of fsRoot
|
|
// - viewerEmail is non-empty
|
|
// - real does not already contain a directory entry that case-folds
|
|
// to viewerEmail (so a materialised home doesn't get duplicated)
|
|
func virtualUserHomeEntry(fsRoot, dirPath, viewerEmail, baseURL string, real []listing.FileInfo) (listing.FileInfo, bool) {
|
|
if viewerEmail == "" {
|
|
return listing.FileInfo{}, false
|
|
}
|
|
rel := strings.Trim(filepath.ToSlash(dirPath), "/")
|
|
parts := strings.Split(rel, "/")
|
|
if len(parts) != 2 || !strings.EqualFold(parts[1], "working") {
|
|
return listing.FileInfo{}, false
|
|
}
|
|
for _, fi := range real {
|
|
if !fi.IsDir {
|
|
continue
|
|
}
|
|
// fi.Name carries a trailing slash for dirs.
|
|
bare := strings.TrimSuffix(fi.Name, "/")
|
|
if strings.EqualFold(bare, viewerEmail) {
|
|
return listing.FileInfo{}, false
|
|
}
|
|
}
|
|
return listing.FileInfo{
|
|
Name: viewerEmail + "/",
|
|
URL: baseURL + url.PathEscape(viewerEmail) + "/",
|
|
IsDir: true,
|
|
Virtual: true,
|
|
}, true
|
|
}
|
|
|
|
// 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)]
|
|
}
|