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