package fs import ( "net/url" "os" "path/filepath" "strings" "codeberg.org/VARASYS/ZDDC/zddc/internal/listing" "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. func ListDirectory(fsRoot, dirPath, userEmail, baseURL string) ([]listing.FileInfo, error) { absDir, ok := safeJoin(fsRoot, dirPath) if !ok { return nil, os.ErrNotExist } entries, err := os.ReadDir(absDir) if err != nil { return nil, err } var result []listing.FileInfo 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 || !zddc.AllowedWithChain(chain, userEmail) { 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) } return result, nil }