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 /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// path. Entry URLs stay // rooted at baseURL so the browse client keeps the workflow context — // drag-drop onto an entry here PUTs to /received/, // which serveFilePut intercepts and rewrites to /+C. 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//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) 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.AllowFromChainP(ctx, decider, chain, principal, 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. // .zddc requires ActionAdmin (not ActionWrite) so the verb // matches the file API's gate at fileapi.go:362-364. action := policy.ActionWrite if name == ".zddc" { action = policy.ActionAdmin } fileURL := baseURL + name if parentActiveAdmin { fi.Writable = true } else { allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, fileURL, action) if allowed { fi.Writable = true } } result = append(result, fi) } // Per-user virtual home: when listing /working/ for an // authenticated viewer, surface a synthetic / 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// 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 //.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. 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 } writable := parentActiveAdmin if !writable { allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, baseURL+".zddc", policy.ActionAdmin) writable = allowed } return listing.FileInfo{ Name: ".zddc", URL: baseURL + ".zddc", IsDir: false, Virtual: true, Writable: writable, }, 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//; 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 / 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 /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)] }