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 and .zddc files are excluded // - *.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) ([]listing.FileInfo, error) { if decider == nil { decider = &policy.InternalDecider{} } absDir, ok := safeJoin(fsRoot, dirPath) if !ok { return nil, os.ErrNotExist } entries, err := os.ReadDir(absDir) if err != nil { // Empty-listing fallback for canonical project folders. A fresh // project doesn't have working/, staging/, reviewing/, or even // archive/ on disk until something is written into them // (EnsureCanonicalAncestors materialises lazily). The stage-strip // nav links into these folders unconditionally; without this // fallback, a click on "Working" against a fresh project 404s. // Returning [] makes the click land on a usable empty view; the // virtualUserHomeEntry below still fires for working/ so the // user sees their own home placeholder. if os.IsNotExist(err) && (zddc.IsProjectRootFolder(dirPath) || zddc.IsArchivePartyFolder(dirPath)) { 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) for _, entry := range entries { name := entry.Name() // Skip hidden files and dotfiles (including .zddc) if strings.HasPrefix(name, ".") { continue } info, err := entry.Info() if err != nil { continue } isDir := entry.IsDir() displayName := lookupDisplay(displayMap, 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 } fi := listing.FileInfo{ Name: name + "/", Size: info.Size(), URL: baseURL + url.PathEscape(name) + "/", ModTime: info.ModTime(), Mode: uint32(info.Mode()), IsDir: true, DisplayName: displayName, } 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, } 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, dirPath, baseURL, result, displayMap)...) return result, nil } // virtualCanonicalFolders returns synthetic entries for any canonical // project-root folder absent from real. Fires only when dirPath is a // depth-1 directory under fsRoot (the project root); other depths get // an empty slice. Case-insensitive presence check so an on-disk // "Archive" suppresses the lowercase "archive" virtual entry. func virtualCanonicalFolders(fsRoot, dirPath, baseURL string, real []listing.FileInfo, displayMap map[string]string) []listing.FileInfo { rel := strings.Trim(filepath.ToSlash(dirPath), "/") if rel == "" { return nil } parts := strings.Split(rel, "/") if len(parts) != 1 { return nil // not a project root } 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 zddc.ProjectRootFolders { if present[name] { continue } synth = append(synth, listing.FileInfo{ Name: name + "/", URL: baseURL + url.PathEscape(name) + "/", IsDir: true, Virtual: true, DisplayName: lookupDisplay(displayMap, name), }) } 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)] }