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.IsArchivePartyMdlDir(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)) 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() 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, } 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, } 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) } return result, nil } // 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 }