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