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