package handler import ( "context" "encoding/json" "log/slog" "net/http" "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 project list response. // // Title is read from the project's own .zddc file (its `title:` field) when // present; absent or empty means the landing 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"` } // 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. // // Response carries a content-hash ETag. The landing page polls this // endpoint on every paint, and the response (a small JSON array of // project names + URLs the caller can reach) rarely changes between // polls, so 304s save a meaningful amount of cumulative bandwidth. // The hash is computed from the actual response body, so it tolerates // unreliable filesystem watching. func ServeProjectList(cfg config.Config, w http.ResponseWriter, r *http.Request) { projects, err := EnumerateProjects(r.Context(), DeciderFromContext(r), cfg, EmailFromContext(r)) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } body, err := json.Marshal(projects) if err != nil { slog.Error("encoding project list", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } etag := `"` + listingETag(body) + `"` w.Header().Set("Content-Type", "application/json") w.Header().Set("ETag", etag) w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate") if match := r.Header.Get("If-None-Match"); match != "" && match == etag { w.WriteHeader(http.StatusNotModified) return } _, _ = w.Write(body) } // EnumerateProjects returns the visible top-level projects for the given // caller, reusing the same access logic as ServeProjectList. Exported so // the profile page can render the same list server-side without an HTTP // round-trip. 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 }