package handler import ( "encoding/json" "log/slog" "net/http" "os" "path/filepath" "strings" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) // ProjectInfo is a single entry in the project list response. type ProjectInfo struct { Name string `json:"name"` URL string `json:"url"` } // ServeProjectList handles GET / with Accept: application/json. // It returns all top-level directories under cfg.Root that the requesting // user has access to, as a JSON array of ProjectInfo. func ServeProjectList(cfg config.Config, w http.ResponseWriter, r *http.Request) { email := EmailFromContext(r) entries, err := os.ReadDir(cfg.Root) if err != nil { slog.Error("reading root directory", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } 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 !zddc.AllowedWithChain(chain, email) { continue } projects = append(projects, ProjectInfo{ Name: name, URL: "/" + name + "/", }) } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-cache") if err := json.NewEncoder(w).Encode(projects); err != nil { slog.Error("encoding project list", "err", err) } }