package handler import ( "context" "log/slog" "os" "path/filepath" "strings" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/policy" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) // ProjectInfo is a single entry in the EnumerateProjects result. // Used by the profile page (server-rendered) to list projects the // caller can reach. NOT the wire shape for GET / Accept: application/json // anymore — that endpoint now returns listing.FileInfo entries with // per-entry Title, matching every other directory listing. // // Title is read from the project's own .zddc file (its `title:` field) when // present; absent or empty means the profile page shows just the directory // name. omitempty keeps the JSON small for projects without titles. type ProjectInfo struct { Name string `json:"name"` URL string `json:"url"` Title string `json:"title,omitempty"` } // EnumerateProjects returns the visible top-level projects for the given // caller. Exported for the profile page's server-rendered project list. // A nil decider falls back to the internal Go evaluator. func EnumerateProjects(ctx context.Context, decider policy.Decider, cfg config.Config, email string) ([]ProjectInfo, error) { if decider == nil { decider = &policy.InternalDecider{} } entries, err := os.ReadDir(cfg.Root) if err != nil { slog.Error("reading root directory", "err", err) return nil, err } var projects []ProjectInfo for _, entry := range entries { if !entry.IsDir() { continue } name := entry.Name() // Skip hidden directories. Both '.' and '_' are reserved prefixes: // '.' for system/internal state (matches the listing-pipeline filter // and the dispatch dot-prefix guard); '_' for operator-managed // scaffolding like install.zip's _template/ directory that should // be reachable by direct URL but not appear in the project picker. if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") { continue } absPath := filepath.Join(cfg.Root, name) chain, err := zddc.EffectivePolicy(cfg.Root, absPath) if err != nil { slog.Warn("ACL policy error", "path", absPath, "err", err) } if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+name+"/"); !allowed { continue } // Title comes from /.zddc — optional, ignored on parse error. var title string if zf, err := zddc.ParseFile(filepath.Join(absPath, ".zddc")); err == nil { title = zf.Title } projects = append(projects, ProjectInfo{ Name: name, URL: "/" + name + "/", Title: title, }) } return projects, nil }