repos.go
⎇
Raw
1package web
2
3import (
4 "context"
5 "errors"
6 "net/http"
7 "net/url"
8 "os"
9 "regexp"
10 "slices"
11 "strings"
12
13 "github.com/go-chi/chi/v5"
14
15 "hearthforge/internal/db"
16 "hearthforge/internal/gitcmd"
17 "hearthforge/internal/markdown"
18 "hearthforge/internal/util"
19 "hearthforge/internal/web/views"
20)
21
22// Page sizes and input caps for the repository pages.
23const (
24 reposPerPage = 20
25 commitsPerPage = 20
26 branchesPerPage = 30
27 tagsPerPage = 30
28 maxLabelName = 50
29 maxBranchName = 255
30 maxTagName = 255
31 maxTagMessage = 500
32 maxFilePathBytes = 1000
33)
34
35// readmeNames are tried in order when looking for a directory's README.
36var readmeNames = []string{"README.md", "readme.md", "README", "readme"}
37
38var (
39 validBranchName = regexp.MustCompile(`^[a-zA-Z0-9._][a-zA-Z0-9._\-/]*$`)
40 validTagName = regexp.MustCompile(`^[a-zA-Z0-9._\-+]+$`)
41 validHexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
42 markdownExt = regexp.MustCompile(`(?i)\.mdx?$`)
43)
44
45// repoRoutes registers the repository browser and its admin actions.
46func (s *Server) repoRoutes(r chi.Router) {
47 r.Get("/allowed_signers", s.allowedSigners)
48 r.Get("/", s.repoList)
49 r.Post("/sort", s.repoSort)
50
51 r.Group(func(r chi.Router) {
52 r.Use(s.requireAdmin)
53 r.Get("/new", s.newRepoPage)
54 r.Post("/new", s.createRepo)
55 })
56
57 r.Get("/{repo}", s.repoHome)
58 r.Get("/{repo}/branch-switch", s.branchSwitch)
59 r.Get("/{repo}/tree/{ref}", s.treeRoot)
60 r.Get("/{repo}/tree/{ref}/*", s.treePath)
61 r.Get("/{repo}/blob/{ref}/*", s.blobView)
62 r.Get("/{repo}/raw/{ref}/*", s.rawFile)
63 r.Get("/{repo}/commits/{ref}", s.commitLog)
64 r.Get("/{repo}/commit/{sha}", s.commitDetail)
65 r.Get("/{repo}/branches", s.branchList)
66 r.Get("/{repo}/tags", s.tagList)
67
68 r.Group(func(r chi.Router) {
69 r.Use(s.requireAdmin)
70 r.Get("/{repo}/edit/{ref}/*", s.editFilePage)
71 r.Post("/{repo}/edit/{ref}/*", s.editFile)
72 r.Get("/{repo}/new-file/{ref}", s.newFilePage)
73 r.Post("/{repo}/new-file/{ref}", s.createFile)
74 r.Post("/{repo}/delete-file/{ref}/*", s.deleteFile)
75
76 r.Get("/{repo}/settings", s.repoSettings)
77 r.Post("/{repo}/settings", s.saveRepoSettings)
78 r.Post("/{repo}/settings/delete", s.deleteRepo)
79 r.Post("/{repo}/settings/rename", s.renameRepo)
80 r.Post("/{repo}/settings/labels", s.createLabel)
81 r.Post("/{repo}/settings/labels/delete", s.deleteLabel)
82
83 r.Post("/{repo}/branches/create", s.createBranch)
84 r.Post("/{repo}/branches/delete", s.deleteBranch)
85 r.Post("/{repo}/branches/rename", s.renameBranch)
86 r.Post("/{repo}/tags/create", s.createTag)
87 r.Post("/{repo}/tags/delete", s.deleteTag)
88 })
89}
90
91// adminRepo loads the repo for an admin-only route. Private repos are visible
92// because the caller already went through requireAdmin.
93func (s *Server) adminRepo(w http.ResponseWriter, r *http.Request) (*db.Repo, bool) {
94 repo, err := s.DB.RepoByName(r.Context(), chi.URLParam(r, "repo"))
95 if err != nil {
96 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
97 return nil, false
98 }
99 if repo == nil {
100 http.Error(w, "Not found", http.StatusNotFound)
101 return nil, false
102 }
103 return repo, true
104}
105
106// gitStatusCode maps the gitcmd sentinels onto HTTP statuses.
107func gitStatusCode(err error) int {
108 switch {
109 case errors.Is(err, gitcmd.ErrNotFound), errors.Is(err, gitcmd.ErrBadRef):
110 return http.StatusNotFound
111 case errors.Is(err, gitcmd.ErrExists), errors.Is(err, gitcmd.ErrRefChanged),
112 errors.Is(err, gitcmd.ErrConflict):
113 return http.StatusConflict
114 case errors.Is(err, gitcmd.ErrInvalidName), errors.Is(err, gitcmd.ErrInvalidRef):
115 return http.StatusBadRequest
116 default:
117 return http.StatusInternalServerError
118 }
119}
120
121// refParam returns a decoded route parameter. Pass "*" for the catch-all file
122// path segment.
123//
124// chi routes on the escaped path when net/url kept one, and on the decoded
125// path otherwise. Only the first form still needs decoding, and a branch like
126// "feature/widgets" only reaches us that way. Decoding the second form too
127// would turn a file named "a%2e" into "a.".
128func refParam(r *http.Request, name string) string {
129 raw := chi.URLParam(r, name)
130 if r.URL.RawPath == "" {
131 return raw
132 }
133 if decoded, err := url.PathUnescape(raw); err == nil {
134 return decoded
135 }
136 return raw
137}
138
139// backTo redirects to page with one query parameter set.
140func (s *Server) backTo(w http.ResponseWriter, r *http.Request, page, key, msg string) {
141 redirectTo(w, r, page+"?"+key+"="+queryEscape(msg))
142}
143
144// allowedSigners serves the file used to verify commit signatures locally.
145func (s *Server) allowedSigners(w http.ResponseWriter, r *http.Request) {
146 data, err := os.ReadFile(s.Cfg.AllowedSignersPath())
147 if err != nil {
148 http.Error(w, "Not found", http.StatusNotFound)
149 return
150 }
151 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
152 w.Write(data)
153}
154
155// repoSort stores the list ordering in a long-lived cookie.
156func (s *Server) repoSort(w http.ResponseWriter, r *http.Request) {
157 sort := "created"
158 if r.FormValue("sort") == "name" {
159 sort = "name"
160 }
161 http.SetCookie(w, &http.Cookie{
162 Name: "repo_sort",
163 Value: sort,
164 Path: "/",
165 SameSite: http.SameSiteLaxMode,
166 Secure: s.Cfg.PublicHTTPS,
167 MaxAge: yearSeconds,
168 })
169 redirectTo(w, r, "/")
170}
171
172func (s *Server) repoList(w http.ResponseWriter, r *http.Request) {
173 u := User(r)
174 isAdmin := u != nil && u.IsAdmin
175 search := strings.TrimSpace(r.URL.Query().Get("q"))
176 sort := "created"
177 if c, err := r.Cookie("repo_sort"); err == nil && c.Value == "name" {
178 sort = "name"
179 }
180 pattern := ""
181 if search != "" {
182 pattern = db.EscapeLike(search)
183 }
184
185 total, err := s.DB.CountRepos(r.Context(), isAdmin, pattern)
186 if err != nil {
187 http.Error(w, "Database error", http.StatusInternalServerError)
188 return
189 }
190 page := util.Paginate(util.ParsePage(r.URL.Query().Get("page")), total, reposPerPage)
191 repos, err := s.DB.ListRepos(r.Context(), isAdmin, pattern, sort, reposPerPage, page.Offset)
192 if err != nil {
193 http.Error(w, "Database error", http.StatusInternalServerError)
194 return
195 }
196
197 tmpl := "/?page={page}"
198 if search != "" {
199 tmpl += "&q=" + url.QueryEscape(search)
200 }
201 views.Render(w, http.StatusOK, views.RepoList(s.Cfg, u, repos, search, sort,
202 views.PageInfo{Page: page.Page, TotalPages: page.TotalPages, URLTemplate: tmpl}))
203}
204
205func (s *Server) newRepoPage(w http.ResponseWriter, r *http.Request) {
206 views.Render(w, http.StatusOK, views.NewRepo(s.Cfg, User(r), ""))
207}
208
209// sanitizeBranch drops every character a branch name may not contain.
210func sanitizeBranch(s string) string {
211 return strings.Map(func(c rune) rune {
212 switch {
213 case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
214 return c
215 case c == '.', c == '_', c == '/', c == '-':
216 return c
217 }
218 return -1
219 }, s)
220}
221
222func (s *Server) createRepo(w http.ResponseWriter, r *http.Request) {
223 name := r.FormValue("name")
224 if !gitcmd.ValidRepoName(name) {
225 views.Render(w, http.StatusOK, views.NewRepo(s.Cfg, User(r), "Invalid repository name"))
226 return
227 }
228 branch := sanitizeBranch(strings.TrimSpace(r.FormValue("default_branch")))
229 if branch == "" {
230 branch = "main"
231 }
232 existing, err := s.DB.RepoByName(r.Context(), name)
233 if err != nil {
234 http.Error(w, "Database error", http.StatusInternalServerError)
235 return
236 }
237 if existing != nil {
238 views.Render(w, http.StatusOK, views.NewRepo(s.Cfg, User(r), "Repository name already taken"))
239 return
240 }
241
242 var description *string
243 if d := r.FormValue("description"); d != "" {
244 description = &d
245 }
246 if _, err := s.DB.CreateRepo(r.Context(), name, description,
247 r.FormValue("is_private") == "1", branch, db.NowISO()); err != nil {
248 http.Error(w, "Database error", http.StatusInternalServerError)
249 return
250 }
251 // Roll the record back when git init fails so the two stay in sync.
252 if err := s.Git.Init(r.Context(), name, branch); err != nil {
253 _ = s.DB.DeleteRepoByName(r.Context(), name)
254 http.Error(w, "Failed to create repository", http.StatusInternalServerError)
255 return
256 }
257 redirectTo(w, r, "/"+name)
258}
259
260// readme finds a README in an already-listed directory and returns its
261// contents and its path relative to the repo root.
262func (s *Server) readme(r *http.Request, repoName, ref, dir string,
263 entries []gitcmd.TreeEntry,
264) (string, string) {
265 names := map[string]bool{}
266 for _, e := range entries {
267 names[e.Name] = true
268 }
269 prefix := ""
270 if dir != "" {
271 prefix = dir + "/"
272 }
273 for _, name := range readmeNames {
274 if !names[name] {
275 continue
276 }
277 content, err := s.Git.Show(r.Context(), repoName, ref, prefix+name)
278 if err != nil || len(content) == 0 {
279 return "", ""
280 }
281 return string(content), prefix + name
282 }
283 return "", ""
284}
285
286// renderReadme renders README markdown with repo-relative link rewriting.
287func (s *Server) renderReadme(content, repoName, ref, dir, resolved string) string {
288 key := ""
289 if resolved != "" {
290 key = "readme:" + repoName + ":" + resolved + ":" + dir
291 }
292 return s.MD.Render(content, key, &markdown.Context{Repo: repoName, Ref: ref, Dir: dir})
293}
294
295func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
296 repo, ok := s.visibleRepo(w, r)
297 if !ok {
298 return
299 }
300 var (
301 entries []gitcmd.TreeEntry
302 branches, tags []string
303 readmeHTML, path string
304 )
305 hasContent := s.Git.HasCommits(r.Context(), repo.Name)
306 if hasContent {
307 entries, _ = s.Git.LsTree(r.Context(), repo.Name, repo.DefaultBranch, "")
308 branches, _ = s.Git.Branches(r.Context(), repo.Name)
309 tags, _ = s.Git.Tags(r.Context(), repo.Name)
310 resolved, _ := s.Git.ResolveRef(r.Context(), repo.Name, repo.DefaultBranch)
311 if content, p := s.readme(r, repo.Name, repo.DefaultBranch, "", entries); p != "" {
312 readmeHTML = s.renderReadme(content, repo.Name, repo.DefaultBranch, "", resolved)
313 path = p
314 }
315 }
316 views.Render(w, http.StatusOK, views.RepoHome(s.Cfg, User(r), repo, entries,
317 readmeHTML, path, hasContent, branches, tags))
318}
319
320// branchSwitch turns the ref selector's GET form into a redirect.
321func (s *Server) branchSwitch(w http.ResponseWriter, r *http.Request) {
322 repo, ok := s.visibleRepo(w, r)
323 if !ok {
324 return
325 }
326 q := r.URL.Query()
327 ref := strings.TrimSpace(q.Get("rev"))
328 if ref == "" {
329 redirectTo(w, r, "/"+repo.Name)
330 return
331 }
332 subpath := q.Get("path")
333 switch {
334 case q.Get("view") == "commits":
335 redirectTo(w, r, "/"+repo.Name+"/commits/"+views.EscapePath(ref))
336 case q.Get("view") == "blob" && subpath != "":
337 redirectTo(w, r, "/"+repo.Name+"/blob/"+views.EscapePath(ref)+"/"+views.EscapePath(subpath))
338 case subpath != "":
339 redirectTo(w, r, "/"+repo.Name+"/tree/"+views.EscapePath(ref)+"/"+views.EscapePath(subpath))
340 default:
341 redirectTo(w, r, "/"+repo.Name+"/tree/"+views.EscapePath(ref))
342 }
343}
344
345func (s *Server) repoSettings(w http.ResponseWriter, r *http.Request) {
346 repo, ok := s.adminRepo(w, r)
347 if !ok {
348 return
349 }
350 branches, _ := s.Git.Branches(r.Context(), repo.Name)
351 labels, err := s.DB.ListLabels(r.Context(), repo.ID)
352 if err != nil {
353 http.Error(w, "Database error", http.StatusInternalServerError)
354 return
355 }
356 secrets, err := s.DB.ListCiSecrets(r.Context(), repo.ID)
357 if err != nil {
358 http.Error(w, "Database error", http.StatusInternalServerError)
359 return
360 }
361 q := r.URL.Query()
362 views.Render(w, http.StatusOK, views.RepoSettings(s.Cfg, User(r), repo, branches, labels,
363 secrets, q.Get("success"), q.Get("error")))
364}
365
366// trimmedOrNil returns nil for an empty field so the column stays NULL.
367func trimmedOrNil(v string) *string {
368 t := strings.TrimSpace(v)
369 if t == "" {
370 return nil
371 }
372 return &t
373}
374
375func (s *Server) saveRepoSettings(w http.ResponseWriter, r *http.Request) {
376 repo, ok := s.adminRepo(w, r)
377 if !ok {
378 return
379 }
380 if tooLong(w, r.FormValue("issue_template"), s.Cfg.MaxTextBodyBytes) ||
381 tooLong(w, r.FormValue("patch_template"), s.Cfg.MaxTextBodyBytes) {
382 return
383 }
384 settings := "/" + repo.Name + "/settings"
385 branches, _ := s.Git.Branches(r.Context(), repo.Name)
386 newBranch := strings.TrimSpace(r.FormValue("default_branch"))
387 if newBranch == "" {
388 newBranch = repo.DefaultBranch
389 }
390 if len(branches) > 0 && !slices.Contains(branches, newBranch) {
391 s.backTo(w, r, settings, "error", `Branch "`+newBranch+`" does not exist.`)
392 return
393 }
394
395 err := s.DB.UpdateRepoSettings(r.Context(), repo.ID, trimmedOrNil(r.FormValue("description")),
396 r.FormValue("is_private") == "1", r.FormValue("is_pinned") == "1",
397 r.FormValue("allow_user_labels") == "1", newBranch,
398 trimmedOrNil(r.FormValue("issue_template")), trimmedOrNil(r.FormValue("patch_template")))
399 if err != nil {
400 http.Error(w, "Database error", http.StatusInternalServerError)
401 return
402 }
403 if slices.Contains(branches, newBranch) {
404 // A failed HEAD update only affects the default checkout, so the
405 // saved settings still stand.
406 _ = s.Git.SetHead(r.Context(), repo.Name, newBranch)
407 }
408 redirectTo(w, r, settings+"?success=Settings+saved.")
409}
410
411func (s *Server) deleteRepo(w http.ResponseWriter, r *http.Request) {
412 repo, ok := s.adminRepo(w, r)
413 if !ok {
414 return
415 }
416 // Remove the on-disk repo first. If that fails the repo stays reachable
417 // instead of becoming an orphaned directory.
418 if err := os.RemoveAll(s.Git.RepoPath(repo.Name)); err != nil {
419 http.Error(w, "Failed to delete repository", http.StatusInternalServerError)
420 return
421 }
422 if err := s.DB.DeleteRepo(r.Context(), repo.ID); err != nil {
423 http.Error(w, "Database error", http.StatusInternalServerError)
424 return
425 }
426 s.Git.InvalidateRefCache(repo.Name)
427 // CI cache volumes are labelled by repo name and survive the DB cascade.
428 s.purgeCaches(repo.Name)
429 redirectTo(w, r, "/")
430}
431
432// purgeCaches drops the repo's CI cache volumes. It is best effort and never
433// blocks the response.
434func (s *Server) purgeCaches(repoName string) {
435 if s.CI == nil {
436 return
437 }
438 go func() {
439 _, _ = s.CI.PurgeRepoCaches(context.Background(), repoName)
440 }()
441}
442
443func (s *Server) renameRepo(w http.ResponseWriter, r *http.Request) {
444 repo, ok := s.adminRepo(w, r)
445 if !ok {
446 return
447 }
448 oldName := repo.Name
449 settings := "/" + oldName + "/settings"
450 newName := strings.TrimSpace(r.FormValue("new_name"))
451
452 switch {
453 case newName == oldName:
454 s.backTo(w, r, settings, "error", "New name is the same as the current name.")
455 return
456 case strings.EqualFold(newName, oldName):
457 s.backTo(w, r, settings, "error", "Case-only renames are not supported.")
458 return
459 case !gitcmd.ValidRepoName(newName):
460 s.backTo(w, r, settings, "error", "Invalid repository name.")
461 return
462 }
463 clash, err := s.DB.RepoByName(r.Context(), newName)
464 if err != nil {
465 http.Error(w, "Database error", http.StatusInternalServerError)
466 return
467 }
468 if clash != nil {
469 s.backTo(w, r, settings, "error", "Repository name already taken.")
470 return
471 }
472
473 fromPath, toPath := s.Git.RepoPath(oldName), s.Git.RepoPath(newName)
474 if _, err := os.Stat(toPath); err == nil {
475 s.backTo(w, r, settings, "error", "A directory for that name already exists on disk.")
476 return
477 }
478 if err := os.Rename(fromPath, toPath); err != nil {
479 s.backTo(w, r, settings, "error", "Failed to rename repository on disk.")
480 return
481 }
482 if err := s.DB.RenameRepo(r.Context(), repo.ID, newName); err != nil {
483 // Put the directory back so disk and database stay consistent.
484 _ = os.Rename(toPath, fromPath)
485 s.backTo(w, r, settings, "error", "Failed to update repository record.")
486 return
487 }
488 s.Git.InvalidateRefCache(oldName)
489 // Cache volumes carry the old name and would detach from later runs.
490 s.purgeCaches(oldName)
491 s.backTo(w, r, "/"+newName+"/settings", "success", "Repository renamed.")
492}
493
494func (s *Server) createLabel(w http.ResponseWriter, r *http.Request) {
495 repo, ok := s.adminRepo(w, r)
496 if !ok {
497 return
498 }
499 settings := "/" + repo.Name + "/settings"
500 name := strings.TrimSpace(r.FormValue("name"))
501 color := strings.TrimSpace(r.FormValue("color"))
502 if name == "" || len(name) > maxLabelName {
503 s.backTo(w, r, settings, "error", "Label name must be 1–50 characters.")
504 return
505 }
506 if !validHexColor.MatchString(color) {
507 s.backTo(w, r, settings, "error", "Invalid color.")
508 return
509 }
510 if err := s.DB.CreateLabel(r.Context(), repo.ID, name, color, db.NowISO()); err != nil {
511 s.backTo(w, r, settings, "error", "A label with that name already exists.")
512 return
513 }
514 redirectTo(w, r, settings+"?success=Label+created.")
515}
516
517func (s *Server) deleteLabel(w http.ResponseWriter, r *http.Request) {
518 repo, ok := s.adminRepo(w, r)
519 if !ok {
520 return
521 }
522 settings := "/" + repo.Name + "/settings"
523 id, _ := leadingInt(r.FormValue("id"))
524 label, err := s.DB.LabelInRepo(r.Context(), id, repo.ID)
525 if err != nil {
526 http.Error(w, "Database error", http.StatusInternalServerError)
527 return
528 }
529 if label == nil {
530 s.backTo(w, r, settings, "error", "Label not found.")
531 return
532 }
533 if err := s.DB.DeleteLabel(r.Context(), id); err != nil {
534 http.Error(w, "Database error", http.StatusInternalServerError)
535 return
536 }
537 redirectTo(w, r, settings+"?success=Label+deleted.")
538}
539