package web import ( "context" "errors" "net/http" "net/url" "os" "regexp" "slices" "strings" "github.com/go-chi/chi/v5" "hearthforge/internal/db" "hearthforge/internal/gitcmd" "hearthforge/internal/markdown" "hearthforge/internal/util" "hearthforge/internal/web/views" ) // Page sizes and input caps for the repository pages. const ( reposPerPage = 20 commitsPerPage = 20 branchesPerPage = 30 tagsPerPage = 30 maxLabelName = 50 maxBranchName = 255 maxTagName = 255 maxTagMessage = 500 maxFilePathBytes = 1000 ) // readmeNames are tried in order when looking for a directory's README. var readmeNames = []string{"README.md", "readme.md", "README", "readme"} var ( validBranchName = regexp.MustCompile(`^[a-zA-Z0-9._][a-zA-Z0-9._\-/]*$`) validTagName = regexp.MustCompile(`^[a-zA-Z0-9._\-+]+$`) validHexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) markdownExt = regexp.MustCompile(`(?i)\.mdx?$`) ) // repoRoutes registers the repository browser and its admin actions. func (s *Server) repoRoutes(r chi.Router) { r.Get("/allowed_signers", s.allowedSigners) r.Get("/", s.repoList) r.Post("/sort", s.repoSort) r.Group(func(r chi.Router) { r.Use(s.requireAdmin) r.Get("/new", s.newRepoPage) r.Post("/new", s.createRepo) }) r.Get("/{repo}", s.repoHome) r.Get("/{repo}/branch-switch", s.branchSwitch) r.Get("/{repo}/tree/{ref}", s.treeRoot) r.Get("/{repo}/tree/{ref}/*", s.treePath) r.Get("/{repo}/blob/{ref}/*", s.blobView) r.Get("/{repo}/raw/{ref}/*", s.rawFile) r.Get("/{repo}/commits/{ref}", s.commitLog) r.Get("/{repo}/commit/{sha}", s.commitDetail) r.Get("/{repo}/branches", s.branchList) r.Get("/{repo}/tags", s.tagList) r.Group(func(r chi.Router) { r.Use(s.requireAdmin) r.Get("/{repo}/edit/{ref}/*", s.editFilePage) r.Post("/{repo}/edit/{ref}/*", s.editFile) r.Get("/{repo}/new-file/{ref}", s.newFilePage) r.Post("/{repo}/new-file/{ref}", s.createFile) r.Post("/{repo}/delete-file/{ref}/*", s.deleteFile) r.Get("/{repo}/settings", s.repoSettings) r.Post("/{repo}/settings", s.saveRepoSettings) r.Post("/{repo}/settings/delete", s.deleteRepo) r.Post("/{repo}/settings/rename", s.renameRepo) r.Post("/{repo}/settings/labels", s.createLabel) r.Post("/{repo}/settings/labels/delete", s.deleteLabel) r.Post("/{repo}/branches/create", s.createBranch) r.Post("/{repo}/branches/delete", s.deleteBranch) r.Post("/{repo}/branches/rename", s.renameBranch) r.Post("/{repo}/tags/create", s.createTag) r.Post("/{repo}/tags/delete", s.deleteTag) }) } // adminRepo loads the repo for an admin-only route. Private repos are visible // because the caller already went through requireAdmin. func (s *Server) adminRepo(w http.ResponseWriter, r *http.Request) (*db.Repo, bool) { repo, err := s.DB.RepoByName(r.Context(), chi.URLParam(r, "repo")) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return nil, false } if repo == nil { http.Error(w, "Not found", http.StatusNotFound) return nil, false } return repo, true } // gitStatusCode maps the gitcmd sentinels onto HTTP statuses. func gitStatusCode(err error) int { switch { case errors.Is(err, gitcmd.ErrNotFound), errors.Is(err, gitcmd.ErrBadRef): return http.StatusNotFound case errors.Is(err, gitcmd.ErrExists), errors.Is(err, gitcmd.ErrRefChanged), errors.Is(err, gitcmd.ErrConflict): return http.StatusConflict case errors.Is(err, gitcmd.ErrInvalidName), errors.Is(err, gitcmd.ErrInvalidRef): return http.StatusBadRequest default: return http.StatusInternalServerError } } // refParam returns a decoded route parameter. Pass "*" for the catch-all file // path segment. // // chi routes on the escaped path when net/url kept one, and on the decoded // path otherwise. Only the first form still needs decoding, and a branch like // "feature/widgets" only reaches us that way. Decoding the second form too // would turn a file named "a%2e" into "a.". func refParam(r *http.Request, name string) string { raw := chi.URLParam(r, name) if r.URL.RawPath == "" { return raw } if decoded, err := url.PathUnescape(raw); err == nil { return decoded } return raw } // backTo redirects to page with one query parameter set. func (s *Server) backTo(w http.ResponseWriter, r *http.Request, page, key, msg string) { redirectTo(w, r, page+"?"+key+"="+queryEscape(msg)) } // allowedSigners serves the file used to verify commit signatures locally. func (s *Server) allowedSigners(w http.ResponseWriter, r *http.Request) { data, err := os.ReadFile(s.Cfg.AllowedSignersPath()) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Write(data) } // repoSort stores the list ordering in a long-lived cookie. func (s *Server) repoSort(w http.ResponseWriter, r *http.Request) { sort := "created" if r.FormValue("sort") == "name" { sort = "name" } http.SetCookie(w, &http.Cookie{ Name: "repo_sort", Value: sort, Path: "/", SameSite: http.SameSiteLaxMode, Secure: s.Cfg.PublicHTTPS, MaxAge: yearSeconds, }) redirectTo(w, r, "/") } func (s *Server) repoList(w http.ResponseWriter, r *http.Request) { u := User(r) isAdmin := u != nil && u.IsAdmin search := strings.TrimSpace(r.URL.Query().Get("q")) sort := "created" if c, err := r.Cookie("repo_sort"); err == nil && c.Value == "name" { sort = "name" } pattern := "" if search != "" { pattern = db.EscapeLike(search) } total, err := s.DB.CountRepos(r.Context(), isAdmin, pattern) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } page := util.Paginate(util.ParsePage(r.URL.Query().Get("page")), total, reposPerPage) repos, err := s.DB.ListRepos(r.Context(), isAdmin, pattern, sort, reposPerPage, page.Offset) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } tmpl := "/?page={page}" if search != "" { tmpl += "&q=" + url.QueryEscape(search) } views.Render(w, http.StatusOK, views.RepoList(s.Cfg, u, repos, search, sort, views.PageInfo{Page: page.Page, TotalPages: page.TotalPages, URLTemplate: tmpl})) } func (s *Server) newRepoPage(w http.ResponseWriter, r *http.Request) { views.Render(w, http.StatusOK, views.NewRepo(s.Cfg, User(r), "")) } // sanitizeBranch drops every character a branch name may not contain. func sanitizeBranch(s string) string { return strings.Map(func(c rune) rune { switch { case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': return c case c == '.', c == '_', c == '/', c == '-': return c } return -1 }, s) } func (s *Server) createRepo(w http.ResponseWriter, r *http.Request) { name := r.FormValue("name") if !gitcmd.ValidRepoName(name) { views.Render(w, http.StatusOK, views.NewRepo(s.Cfg, User(r), "Invalid repository name")) return } branch := sanitizeBranch(strings.TrimSpace(r.FormValue("default_branch"))) if branch == "" { branch = "main" } existing, err := s.DB.RepoByName(r.Context(), name) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } if existing != nil { views.Render(w, http.StatusOK, views.NewRepo(s.Cfg, User(r), "Repository name already taken")) return } var description *string if d := r.FormValue("description"); d != "" { description = &d } if _, err := s.DB.CreateRepo(r.Context(), name, description, r.FormValue("is_private") == "1", branch, db.NowISO()); err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } // Roll the record back when git init fails so the two stay in sync. if err := s.Git.Init(r.Context(), name, branch); err != nil { _ = s.DB.DeleteRepoByName(r.Context(), name) http.Error(w, "Failed to create repository", http.StatusInternalServerError) return } redirectTo(w, r, "/"+name) } // readme finds a README in an already-listed directory and returns its // contents and its path relative to the repo root. func (s *Server) readme(r *http.Request, repoName, ref, dir string, entries []gitcmd.TreeEntry, ) (string, string) { names := map[string]bool{} for _, e := range entries { names[e.Name] = true } prefix := "" if dir != "" { prefix = dir + "/" } for _, name := range readmeNames { if !names[name] { continue } content, err := s.Git.Show(r.Context(), repoName, ref, prefix+name) if err != nil || len(content) == 0 { return "", "" } return string(content), prefix + name } return "", "" } // renderReadme renders README markdown with repo-relative link rewriting. func (s *Server) renderReadme(content, repoName, ref, dir, resolved string) string { key := "" if resolved != "" { key = "readme:" + repoName + ":" + resolved + ":" + dir } return s.MD.Render(content, key, &markdown.Context{Repo: repoName, Ref: ref, Dir: dir}) } func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } var ( entries []gitcmd.TreeEntry branches, tags []string readmeHTML, path string ) hasContent := s.Git.HasCommits(r.Context(), repo.Name) if hasContent { entries, _ = s.Git.LsTree(r.Context(), repo.Name, repo.DefaultBranch, "") branches, _ = s.Git.Branches(r.Context(), repo.Name) tags, _ = s.Git.Tags(r.Context(), repo.Name) resolved, _ := s.Git.ResolveRef(r.Context(), repo.Name, repo.DefaultBranch) if content, p := s.readme(r, repo.Name, repo.DefaultBranch, "", entries); p != "" { readmeHTML = s.renderReadme(content, repo.Name, repo.DefaultBranch, "", resolved) path = p } } views.Render(w, http.StatusOK, views.RepoHome(s.Cfg, User(r), repo, entries, readmeHTML, path, hasContent, branches, tags)) } // branchSwitch turns the ref selector's GET form into a redirect. func (s *Server) branchSwitch(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } q := r.URL.Query() ref := strings.TrimSpace(q.Get("rev")) if ref == "" { redirectTo(w, r, "/"+repo.Name) return } subpath := q.Get("path") switch { case q.Get("view") == "commits": redirectTo(w, r, "/"+repo.Name+"/commits/"+views.EscapePath(ref)) case q.Get("view") == "blob" && subpath != "": redirectTo(w, r, "/"+repo.Name+"/blob/"+views.EscapePath(ref)+"/"+views.EscapePath(subpath)) case subpath != "": redirectTo(w, r, "/"+repo.Name+"/tree/"+views.EscapePath(ref)+"/"+views.EscapePath(subpath)) default: redirectTo(w, r, "/"+repo.Name+"/tree/"+views.EscapePath(ref)) } } func (s *Server) repoSettings(w http.ResponseWriter, r *http.Request) { repo, ok := s.adminRepo(w, r) if !ok { return } branches, _ := s.Git.Branches(r.Context(), repo.Name) labels, err := s.DB.ListLabels(r.Context(), repo.ID) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } secrets, err := s.DB.ListCiSecrets(r.Context(), repo.ID) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } q := r.URL.Query() views.Render(w, http.StatusOK, views.RepoSettings(s.Cfg, User(r), repo, branches, labels, secrets, q.Get("success"), q.Get("error"))) } // trimmedOrNil returns nil for an empty field so the column stays NULL. func trimmedOrNil(v string) *string { t := strings.TrimSpace(v) if t == "" { return nil } return &t } func (s *Server) saveRepoSettings(w http.ResponseWriter, r *http.Request) { repo, ok := s.adminRepo(w, r) if !ok { return } if tooLong(w, r.FormValue("issue_template"), s.Cfg.MaxTextBodyBytes) || tooLong(w, r.FormValue("patch_template"), s.Cfg.MaxTextBodyBytes) { return } settings := "/" + repo.Name + "/settings" branches, _ := s.Git.Branches(r.Context(), repo.Name) newBranch := strings.TrimSpace(r.FormValue("default_branch")) if newBranch == "" { newBranch = repo.DefaultBranch } if len(branches) > 0 && !slices.Contains(branches, newBranch) { s.backTo(w, r, settings, "error", `Branch "`+newBranch+`" does not exist.`) return } err := s.DB.UpdateRepoSettings(r.Context(), repo.ID, trimmedOrNil(r.FormValue("description")), r.FormValue("is_private") == "1", r.FormValue("is_pinned") == "1", r.FormValue("allow_user_labels") == "1", newBranch, trimmedOrNil(r.FormValue("issue_template")), trimmedOrNil(r.FormValue("patch_template"))) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } if slices.Contains(branches, newBranch) { // A failed HEAD update only affects the default checkout, so the // saved settings still stand. _ = s.Git.SetHead(r.Context(), repo.Name, newBranch) } redirectTo(w, r, settings+"?success=Settings+saved.") } func (s *Server) deleteRepo(w http.ResponseWriter, r *http.Request) { repo, ok := s.adminRepo(w, r) if !ok { return } // Remove the on-disk repo first. If that fails the repo stays reachable // instead of becoming an orphaned directory. if err := os.RemoveAll(s.Git.RepoPath(repo.Name)); err != nil { http.Error(w, "Failed to delete repository", http.StatusInternalServerError) return } if err := s.DB.DeleteRepo(r.Context(), repo.ID); err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } s.Git.InvalidateRefCache(repo.Name) // CI cache volumes are labelled by repo name and survive the DB cascade. s.purgeCaches(repo.Name) redirectTo(w, r, "/") } // purgeCaches drops the repo's CI cache volumes. It is best effort and never // blocks the response. func (s *Server) purgeCaches(repoName string) { if s.CI == nil { return } go func() { _, _ = s.CI.PurgeRepoCaches(context.Background(), repoName) }() } func (s *Server) renameRepo(w http.ResponseWriter, r *http.Request) { repo, ok := s.adminRepo(w, r) if !ok { return } oldName := repo.Name settings := "/" + oldName + "/settings" newName := strings.TrimSpace(r.FormValue("new_name")) switch { case newName == oldName: s.backTo(w, r, settings, "error", "New name is the same as the current name.") return case strings.EqualFold(newName, oldName): s.backTo(w, r, settings, "error", "Case-only renames are not supported.") return case !gitcmd.ValidRepoName(newName): s.backTo(w, r, settings, "error", "Invalid repository name.") return } clash, err := s.DB.RepoByName(r.Context(), newName) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } if clash != nil { s.backTo(w, r, settings, "error", "Repository name already taken.") return } fromPath, toPath := s.Git.RepoPath(oldName), s.Git.RepoPath(newName) if _, err := os.Stat(toPath); err == nil { s.backTo(w, r, settings, "error", "A directory for that name already exists on disk.") return } if err := os.Rename(fromPath, toPath); err != nil { s.backTo(w, r, settings, "error", "Failed to rename repository on disk.") return } if err := s.DB.RenameRepo(r.Context(), repo.ID, newName); err != nil { // Put the directory back so disk and database stay consistent. _ = os.Rename(toPath, fromPath) s.backTo(w, r, settings, "error", "Failed to update repository record.") return } s.Git.InvalidateRefCache(oldName) // Cache volumes carry the old name and would detach from later runs. s.purgeCaches(oldName) s.backTo(w, r, "/"+newName+"/settings", "success", "Repository renamed.") } func (s *Server) createLabel(w http.ResponseWriter, r *http.Request) { repo, ok := s.adminRepo(w, r) if !ok { return } settings := "/" + repo.Name + "/settings" name := strings.TrimSpace(r.FormValue("name")) color := strings.TrimSpace(r.FormValue("color")) if name == "" || len(name) > maxLabelName { s.backTo(w, r, settings, "error", "Label name must be 1–50 characters.") return } if !validHexColor.MatchString(color) { s.backTo(w, r, settings, "error", "Invalid color.") return } if err := s.DB.CreateLabel(r.Context(), repo.ID, name, color, db.NowISO()); err != nil { s.backTo(w, r, settings, "error", "A label with that name already exists.") return } redirectTo(w, r, settings+"?success=Label+created.") } func (s *Server) deleteLabel(w http.ResponseWriter, r *http.Request) { repo, ok := s.adminRepo(w, r) if !ok { return } settings := "/" + repo.Name + "/settings" id, _ := leadingInt(r.FormValue("id")) label, err := s.DB.LabelInRepo(r.Context(), id, repo.ID) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } if label == nil { s.backTo(w, r, settings, "error", "Label not found.") return } if err := s.DB.DeleteLabel(r.Context(), id); err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } redirectTo(w, r, settings+"?success=Label+deleted.") }