// Package server wires HTTP routes to gitread reads, render helpers, and templ // views. Handlers never open repositories directly — they go through gitread. package server import ( "fmt" "html/template" "io/fs" "net/http" pathpkg "path" "strings" "custard/internal/backlog" "custard/internal/config" "custard/internal/gitread" "custard/internal/license" "custard/internal/release" "custard/internal/render" "custard/internal/status" "custard/web" "custard/web/templates" "github.com/a-h/templ" ) const logLimit = 100 // Server holds dependencies shared across handlers. type Server struct { cfg config.Config store *gitread.Store status *status.Store } // New builds the server. When cfg.SoftServeDB is set it attaches Soft Serve's // database for repo-visibility gating; a failure to open is fatal (returned) // rather than silently falling back to exposing every repo. func New(cfg config.Config) (*Server, error) { store := gitread.New(cfg.ReposPath) if cfg.SoftServeDB != "" { if err := store.WithDB(cfg.SoftServeDB); err != nil { return nil, fmt.Errorf("open soft-serve db %q: %w", cfg.SoftServeDB, err) } } st, err := status.Open(cfg.StatePath) if err != nil { return nil, fmt.Errorf("open status store %q: %w", cfg.StatePath, err) } return &Server{cfg: cfg, store: store, status: st}, nil } // Handler builds the route table. Tree/blob/raw register twice so the ref-only // form (no path) and the path form both match the Go 1.22 pattern mux. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /{$}", s.handleIndex) mux.HandleFunc("GET /r/{repo}", s.handleRepo) mux.HandleFunc("GET /r/{repo}/files", s.handleFiles) mux.HandleFunc("GET /r/{repo}/readme", s.handleReadme) mux.HandleFunc("GET /r/{repo}/refs", s.handleRefs) mux.HandleFunc("GET /r/{repo}/log/{ref}", s.handleLog) mux.HandleFunc("GET /r/{repo}/commit/{sha}", s.handleCommit) mux.HandleFunc("GET /r/{repo}/tree/{ref}", s.handleTree) mux.HandleFunc("GET /r/{repo}/tree/{ref}/{path...}", s.handleTree) mux.HandleFunc("GET /r/{repo}/blob/{ref}/{path...}", s.handleBlob) mux.HandleFunc("GET /r/{repo}/raw/{ref}/{path...}", s.handleRaw) mux.HandleFunc("GET /r/{repo}/issues", s.handleIssues) mux.HandleFunc("GET /r/{repo}/issues/{id}", s.handleIssue) mux.HandleFunc("POST /hooks/release", s.handleReleaseHook) mux.HandleFunc("POST /status", s.handleStatus) staticFS, _ := fs.Sub(web.Static, "static") mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticFS))) return mux } func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { repos, err := s.store.List() if err != nil { s.fail(w, r, http.StatusInternalServerError, "could not list repositories") return } page := templates.IndexPage{Meta: templates.Meta{Theme: readTheme(r)}, Repos: repos} s.render(w, r, templates.Index(page)) } // handleRepo is the repo landing: a repo with a README opens to it; otherwise // it shows the file tree. func (s *Server) handleRepo(w http.ResponseWriter, r *http.Request) { name := r.PathValue("repo") if !s.store.Exists(name) { s.notFound(w, r) return } branch, err := s.store.DefaultBranch(name) if err != nil { // Empty repository: show a stub page rather than failing. s.render(w, r, templates.Repo(templates.RepoPage{Meta: templates.Meta{Repo: name, Tab: "code", Theme: readTheme(r)}})) return } if md, ok := s.readme(name, branch); ok { page := templates.ReadmePage{Meta: s.meta(r, name, branch, "readme"), Readme: md} s.render(w, r, templates.Readme(page)) return } s.renderFiles(w, r, name, branch) } // handleFiles is the file-tree (code) view, the destination of the code tab. func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) { name := r.PathValue("repo") branch, err := s.store.DefaultBranch(name) if err != nil { s.notFound(w, r) return } s.renderFiles(w, r, name, branch) } func (s *Server) renderFiles(w http.ResponseWriter, r *http.Request, name, branch string) { refs, _ := s.store.Refs(name) entries, _ := s.store.Tree(name, branch, "") page := templates.RepoPage{ Meta: s.meta(r, name, branch, "code"), DefaultBranch: branch, Entries: entries, Last: s.lastCommit(name, branch), } if refs != nil { page.Branches = len(refs.Branches) page.Tags = len(refs.Tags) } s.render(w, r, templates.Repo(page)) } func (s *Server) handleReadme(w http.ResponseWriter, r *http.Request) { name := r.PathValue("repo") branch, err := s.store.DefaultBranch(name) if err != nil { s.notFound(w, r) return } md, ok := s.readme(name, branch) if !ok { s.notFound(w, r) return } page := templates.ReadmePage{Meta: s.meta(r, name, branch, "readme"), Readme: md} s.render(w, r, templates.Readme(page)) } func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) { name, ref, path := r.PathValue("repo"), r.PathValue("ref"), r.PathValue("path") entries, err := s.store.Tree(name, ref, path) if err != nil { s.notFound(w, r) return } page := templates.TreePage{ Meta: s.meta(r, name, ref, "code"), Path: strings.Trim(path, "/"), Crumbs: templates.BuildCrumbs(path), Entries: entries, } s.render(w, r, templates.Tree(page)) } func (s *Server) handleBlob(w http.ResponseWriter, r *http.Request) { name, ref, path := r.PathValue("repo"), r.PathValue("ref"), r.PathValue("path") data, err := s.store.Blob(name, ref, path) if err != nil { s.notFound(w, r) return } page := templates.BlobPage{ Meta: s.meta(r, name, ref, "code"), Path: strings.Trim(path, "/"), Crumbs: templates.BuildCrumbs(path), Size: int64(len(data)), } switch { case render.IsMarkdown(path): fm, body := render.SplitFrontmatter(data) md, err := render.Markdown(body, render.MD{Repo: name, Ref: ref, Dir: pathpkg.Dir(strings.Trim(path, "/"))}) if err == nil { page.IsMarkdown = true page.Markdown = md page.Frontmatter = render.ParseFrontmatter(fm) break } fallthrough default: code, ok := render.Highlight(path, data) if ok { page.Code = code } else { page.IsBinary = true } } s.render(w, r, templates.Blob(page)) } func (s *Server) handleRaw(w http.ResponseWriter, r *http.Request) { name, ref, path := r.PathValue("repo"), r.PathValue("ref"), r.PathValue("path") data, err := s.store.Blob(name, ref, path) if err != nil { s.notFound(w, r) return } // Detect type so images (README gifs/pngs) render; source files sniff to text. w.Header().Set("Content-Type", http.DetectContentType(data)) w.Header().Set("X-Content-Type-Options", "nosniff") _, _ = w.Write(data) } func (s *Server) handleLog(w http.ResponseWriter, r *http.Request) { name, ref := r.PathValue("repo"), r.PathValue("ref") commits, err := s.store.Log(name, ref, logLimit) if err != nil { s.notFound(w, r) return } page := templates.LogPage{Meta: s.meta(r, name, ref, "log"), Commits: commits} s.render(w, r, templates.Log(page)) } func (s *Server) handleCommit(w http.ResponseWriter, r *http.Request) { name, sha := r.PathValue("repo"), r.PathValue("sha") detail, err := s.store.Commit(name, sha) if err != nil { s.notFound(w, r) return } page := templates.CommitPage{ Meta: s.meta(r, name, sha, "log"), // badge the viewed commit, not the branch HEAD Detail: detail, Files: render.SplitDiff(detail.Diff), } s.render(w, r, templates.Commit(page)) } func (s *Server) handleRefs(w http.ResponseWriter, r *http.Request) { name := r.PathValue("repo") refs, err := s.store.Refs(name) if err != nil { s.notFound(w, r) return } page := templates.RefsPage{Meta: s.meta(r, name, "", "refs"), Refs: refs} s.render(w, r, templates.Refs(page)) } func (s *Server) handleIssues(w http.ResponseWriter, r *http.Request) { name := r.PathValue("repo") ref, err := s.store.DefaultBranch(name) if err != nil { s.notFound(w, r) return } tasks, err := backlog.List(s.store, name, ref) if err != nil { s.notFound(w, r) return } page := templates.IssuesPage{ Meta: s.meta(r, name, ref, "issues"), Groups: templates.GroupByStatus(tasks), Total: len(tasks), } s.render(w, r, templates.Issues(page)) } func (s *Server) handleIssue(w http.ResponseWriter, r *http.Request) { name, id := r.PathValue("repo"), r.PathValue("id") ref, err := s.store.DefaultBranch(name) if err != nil { s.notFound(w, r) return } task, ok := backlog.Get(s.store, name, ref, id) if !ok { s.notFound(w, r) return } page := templates.IssuePage{Meta: s.meta(r, name, ref, "issues"), Task: task} if task.Body != "" { if body, err := render.Markdown([]byte(task.Body), render.MD{Repo: name, Ref: ref, Dir: backlog.Dir}); err == nil { page.Body = body } } s.render(w, r, templates.Issue(page)) } // --- helpers --- func (s *Server) lastCommit(name, ref string) *gitread.Commit { commits, err := s.store.Log(name, ref, 1) if err != nil || len(commits) == 0 { return nil } return &commits[0] } // readme finds and renders a root README, trying common filenames. func (s *Server) readme(name, ref string) (template.HTML, bool) { for _, fn := range readmeFiles { data, err := s.store.Blob(name, ref, fn) if err != nil { continue } html, err := render.Markdown(data, render.MD{Repo: name, Ref: ref, Dir: pathpkg.Dir(fn)}) if err != nil { continue } return html, true } return "", false } // meta builds shared page chrome: active tab, issues-tab visibility, theme, // and (in a repo context) the read-only HTTP clone URL for the footer. func (s *Server) meta(r *http.Request, name, ref, tab string) templates.Meta { m := templates.Meta{Repo: name, Ref: ref, Tab: tab, HasIssues: s.hasIssues(name, ref), HasReadme: s.hasReadme(name, ref), Theme: readTheme(r)} if name != "" && s.cfg.SoftServeHTTP != "" { m.CloneURL = strings.TrimRight(s.cfg.SoftServeHTTP, "/") + "/" + name + ".git" } m.License = s.license(name, ref) m.DeployState, m.DeployURL = s.deployBadge(name, ref) if name != "" { if t, ok := s.store.LatestTag(name); ok { m.Version, m.VersionMsg = t.Name, t.Message } } return m } // deployBadge returns the deploy state of the commit at ref (default branch when // ref is empty). Only repos that actually deploy through custard — i.e. their // .custard.yaml declares deploy.preview — get a badge; CLIs, the forge itself, // and anything not web-deployed get none. "" state = no badge. func (s *Server) deployBadge(name, ref string) (state, url string) { if name == "" { return "", "" } branch, err := s.store.DefaultBranch(name) if err != nil { return "", "" } if cfg, ok := release.ReadRepoConfig(s.store, name, branch); !ok || cfg.Deploy.Preview == "" { return "", "" // repo doesn't deploy via custard → no badge } if ref == "" { ref = branch } commits, err := s.store.Log(name, ref, 1) if err != nil || len(commits) == 0 { return "unverified", "" } st, u := s.status.Badge(name, commits[0].Hash) if st == "" { return "unverified", "" } return st, u } // license detects the repo's license at ref (default branch when ref is empty). func (s *Server) license(name, ref string) *license.License { if ref == "" { b, err := s.store.DefaultBranch(name) if err != nil { return nil } ref = b } path, data, ok := s.store.LicenseFile(name, ref) if !ok { return nil } lic := license.Detect(data) lic.Path = path return &lic } // readTheme resolves the data-theme from the "theme" cookie, defaulting safely. func readTheme(r *http.Request) string { if c, err := r.Cookie("theme"); err == nil { return templates.ValidTheme(c.Value) } return templates.DefaultTheme } // hasIssues reports whether the repo carries backlog tasks (drives the tab). // An empty ref falls back to the default branch. func (s *Server) hasIssues(name, ref string) bool { if ref == "" { b, err := s.store.DefaultBranch(name) if err != nil { return false } ref = b } return backlog.Has(s.store, name, ref) } // readmeFiles are the root README names tried, in order. var readmeFiles = []string{"README.md", "readme.md", "README.markdown", "README"} // hasReadme reports whether the repo has a root README (drives the tab). func (s *Server) hasReadme(name, ref string) bool { if ref == "" { b, err := s.store.DefaultBranch(name) if err != nil { return false } ref = b } for _, fn := range readmeFiles { if _, err := s.store.Blob(name, ref, fn); err == nil { return true } } return false } func (s *Server) render(w http.ResponseWriter, r *http.Request, c templ.Component) { w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = c.Render(r.Context(), w) } func (s *Server) notFound(w http.ResponseWriter, r *http.Request) { s.fail(w, r, http.StatusNotFound, "not found") } func (s *Server) fail(w http.ResponseWriter, r *http.Request, code int, msg string) { w.WriteHeader(code) _ = templates.Error(templates.Meta{Theme: readTheme(r)}, code, msg).Render(r.Context(), w) }