rin
Public Open-source lightweight git hosting.
repo.go · 768 lines · 25.7 KB
Raw
1 package handlers
2
3 import (
4 "fmt"
5 "net/url"
6 "path"
7 "strconv"
8 "strings"
9
10 "polar/internal/git"
11 "polar/internal/i18n"
12 "polar/internal/repo"
13 "polar/internal/web/middleware"
14 "polar/internal/web/views"
15
16 "github.com/gofiber/fiber/v2"
17 )
18
19 type BreadcrumbItem struct {
20 Name string
21 Path string
22 IsLast bool
23 }
24
25 func buildBreadcrumbs(subPath string) []BreadcrumbItem {
26 subPath = strings.Trim(subPath, "/")
27 if subPath == "" {
28 return nil
29 }
30 parts := strings.Split(subPath, "/")
31 items := make([]BreadcrumbItem, 0, len(parts))
32 var acc []string
33 for idx, p := range parts {
34 acc = append(acc, p)
35 items = append(items, BreadcrumbItem{
36 Name: p,
37 Path: strings.Join(acc, "/"),
38 IsLast: idx == len(parts)-1,
39 })
40 }
41 return items
42 }
43
44 func (h *Handler) NewRepoPage(c *fiber.Ctx) error {
45 lang, _ := c.Locals("lang").(i18n.Lang)
46 data := h.baseData(c, i18n.T(lang, "repo.new.title"))
47 return h.render(c, fiber.StatusOK, "repo_new", data)
48 }
49
50 func (h *Handler) NewRepoSubmit(c *fiber.Ctx) error {
51 u := middleware.CurrentUser(c)
52 lang, _ := c.Locals("lang").(i18n.Lang)
53
54 name := strings.TrimSpace(c.FormValue("name"))
55 desc := strings.TrimSpace(c.FormValue("description"))
56 isPriv := c.FormValue("visibility") == "private"
57
58 r, err := h.repoSvc.Create(c.UserContext(), u, name, desc, isPriv)
59 if err != nil {
60 data := h.baseData(c, i18n.T(lang, "repo.new.title"))
61 data["FormName"] = name
62 data["FormDescription"] = desc
63 data["Error"] = err.Error()
64 return h.render(c, fiber.StatusBadRequest, "repo_new", data)
65 }
66
67 return c.Redirect(fmt.Sprintf("/%s/%s", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
68 }
69
70 func (h *Handler) loadAuthorizedRepo(c *fiber.Ctx, requireWrite bool) (*repo.Repository, string, error) {
71 owner := c.Params("owner")
72 repoName := strings.TrimSuffix(c.Params("repo"), ".git")
73 viewer := middleware.CurrentUser(c)
74
75 if viewer == nil && !h.db.IsAnonymousReadEnabled(c.UserContext()) {
76 _ = c.Redirect("/login", fiber.StatusSeeOther)
77 return nil, "", fiber.ErrUnauthorized
78 }
79
80 r, err := h.repoSvc.GetByOwnerAndName(c.UserContext(), owner, repoName)
81 if err != nil {
82 _ = h.renderErrorT(c, fiber.StatusNotFound, "err.repo_not_found", "err.repo_not_found_desc")
83 return nil, "", err
84 }
85
86 if requireWrite {
87 if !h.repoSvc.CanWrite(c.UserContext(), r, viewer) {
88 _ = h.renderErrorT(c, fiber.StatusForbidden, "err.access_denied", "err.repo_write_denied")
89 return nil, "", fiber.ErrForbidden
90 }
91 } else {
92 if !h.repoSvc.CanRead(c.UserContext(), r, viewer) {
93 _ = h.renderErrorT(c, fiber.StatusNotFound, "err.repo_not_found", "err.repo_private_desc")
94 return nil, "", fiber.ErrNotFound
95 }
96 }
97
98 diskPath, err := h.repoSvc.DiskPath(r.OwnerUsername, r.Name)
99 if err != nil {
100 _ = h.renderError(c, fiber.StatusBadRequest, i18n.T(h.langOf(c), "err.invalid_repo_path"), err.Error())
101 return nil, "", err
102 }
103
104 return r, diskPath, nil
105 }
106
107 func (h *Handler) RepoHomePage(c *fiber.Ctx) error {
108 return h.renderRepoTree(c, "", "")
109 }
110
111 func (h *Handler) RepoTreePage(c *fiber.Ctx) error {
112 ref, _ := url.PathUnescape(c.Params("ref"))
113 subPath, _ := url.PathUnescape(c.Params("*"))
114 return h.renderRepoTree(c, ref, subPath)
115 }
116
117 func (h *Handler) renderRepoTree(c *fiber.Ctx, ref, subPath string) error {
118 r, diskPath, err := h.loadAuthorizedRepo(c, false)
119 if err != nil {
120 return nil
121 }
122 viewer := middleware.CurrentUser(c)
123
124 headBranch, isEmpty := git.EnsureDefaultBranch(c.UserContext(), diskPath, r.DefaultBranch)
125 if ref == "" {
126 ref = headBranch
127 }
128
129 cloneURL := h.cloneURL(c, r.OwnerUsername, r.Name)
130 data := h.baseData(c, r.OwnerUsername+"/"+r.Name)
131 data["Repo"] = r
132 data["Ref"] = ref
133 data["SubPath"] = strings.Trim(subPath, "/")
134 data["ActiveTab"] = "code"
135 data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
136 data["CloneURL"] = cloneURL
137 data["IsEmpty"] = isEmpty
138
139 if isEmpty {
140 data["QuickStartNew"] = fmt.Sprintf(
141 "echo \"# %s\" >> README.md\ngit init -b %s\ngit add README.md\ngit commit -m \"Initial commit\"\ngit remote add origin %s\ngit push -u origin %s",
142 r.Name, r.DefaultBranch, cloneURL, r.DefaultBranch,
143 )
144 data["QuickStartExisting"] = fmt.Sprintf(
145 "git remote add origin %s\ngit branch -M %s\ngit push -u origin %s",
146 cloneURL, r.DefaultBranch, r.DefaultBranch,
147 )
148 return h.render(c, fiber.StatusOK, "repo", data)
149 }
150
151 branches, _ := git.ListBranches(c.UserContext(), diskPath)
152 tags, _ := git.ListTags(c.UserContext(), diskPath)
153 entries, err := git.ListTree(c.UserContext(), diskPath, ref, subPath)
154 if err != nil {
155 return h.renderErrorT(c, fiber.StatusNotFound, "err.path_not_found", "err.path_not_found_desc", ref)
156 }
157
158 latestCommit, _ := git.GetLatestCommit(c.UserContext(), diskPath, ref, subPath)
159 allFiles, _ := git.ListAllFiles(c.UserContext(), diskPath, ref)
160
161 data["Branches"] = branches
162 data["Tags"] = tags
163 data["Entries"] = entries
164 data["LatestCommit"] = latestCommit
165 data["PaletteFiles"] = allFiles
166 data["Breadcrumbs"] = buildBreadcrumbs(subPath)
167 if strings.Trim(subPath, "/") != "" {
168 data["ParentPath"] = path.Dir(strings.Trim(subPath, "/"))
169 }
170
171 if readmeBlob, err := git.FindReadme(c.UserContext(), diskPath, ref, subPath); err == nil && readmeBlob != nil {
172 data["ReadmeName"] = readmeBlob.Name
173 data["ReadmeHTML"] = views.RenderMarkdown(readmeBlob.Data)
174 }
175
176 return h.render(c, fiber.StatusOK, "repo", data)
177 }
178
179 func (h *Handler) RepoBlobPage(c *fiber.Ctx) error {
180 r, diskPath, err := h.loadAuthorizedRepo(c, false)
181 if err != nil {
182 return nil
183 }
184 viewer := middleware.CurrentUser(c)
185
186 ref, _ := url.PathUnescape(c.Params("ref"))
187 subPath, _ := url.PathUnescape(c.Params("*"))
188
189 blob, err := git.ReadBlob(c.UserContext(), diskPath, ref, subPath)
190 if err != nil {
191 return h.renderErrorT(c, fiber.StatusNotFound, "err.file_not_found", "err.file_not_found_desc", ref)
192 }
193
194 branches, _ := git.ListBranches(c.UserContext(), diskPath)
195 tags, _ := git.ListTags(c.UserContext(), diskPath)
196 allFiles, _ := git.ListAllFiles(c.UserContext(), diskPath, ref)
197
198 data := h.baseData(c, blob.Name+" · "+r.OwnerUsername+"/"+r.Name)
199 data["Repo"] = r
200 data["Ref"] = ref
201 data["SubPath"] = blob.Path
202 data["ActiveTab"] = "code"
203 data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
204 data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
205 data["Branches"] = branches
206 data["Tags"] = tags
207 data["Blob"] = blob
208 data["Breadcrumbs"] = buildBreadcrumbs(blob.Path)
209 data["PaletteFiles"] = allFiles
210
211 if !blob.IsBinary {
212 rawText := string(blob.Data)
213 data["RawText"] = rawText
214 data["HighlightedLines"] = views.HighlightCode(blob.Name, rawText)
215 }
216
217 return h.render(c, fiber.StatusOK, "blob", data)
218 }
219
220 func (h *Handler) RepoRawBlob(c *fiber.Ctx) error {
221 _, diskPath, err := h.loadAuthorizedRepo(c, false)
222 if err != nil {
223 return nil
224 }
225
226 ref, _ := url.PathUnescape(c.Params("ref"))
227 subPath, _ := url.PathUnescape(c.Params("*"))
228
229 blob, err := git.ReadBlob(c.UserContext(), diskPath, ref, subPath)
230 if err != nil {
231 return c.Status(fiber.StatusNotFound).SendString("File not found")
232 }
233
234 if blob.IsBinary {
235 c.Set(fiber.HeaderContentType, "application/octet-stream")
236 } else {
237 c.Set(fiber.HeaderContentType, "text/plain; charset=utf-8")
238 }
239 return c.Send(blob.Data)
240 }
241
242 func (h *Handler) RepoCommitsPage(c *fiber.Ctx) error {
243 r, diskPath, err := h.loadAuthorizedRepo(c, false)
244 if err != nil {
245 return nil
246 }
247 viewer := middleware.CurrentUser(c)
248
249 ref, _ := url.PathUnescape(c.Params("ref"))
250 if ref == "" {
251 ref, _ = git.EnsureDefaultBranch(c.UserContext(), diskPath, r.DefaultBranch)
252 }
253
254 page, _ := strconv.Atoi(c.Query("page", "1"))
255 if page < 1 {
256 page = 1
257 }
258 const perPage = 30
259 offset := (page - 1) * perPage
260
261 commits, _ := git.ListCommits(c.UserContext(), diskPath, ref, perPage+1, offset)
262 hasMore := false
263 if len(commits) > perPage {
264 hasMore = true
265 commits = commits[:perPage]
266 }
267 branches, _ := git.ListBranches(c.UserContext(), diskPath)
268
269 data := h.baseData(c, h.tr(c, "repo.tab.commits")+" · "+r.OwnerUsername+"/"+r.Name)
270 data["Repo"] = r
271 data["Ref"] = ref
272 data["ActiveTab"] = "commits"
273 data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
274 data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
275 data["Branches"] = branches
276 data["Commits"] = commits
277 data["Page"] = page
278 data["HasMore"] = hasMore
279
280 return h.render(c, fiber.StatusOK, "commits", data)
281 }
282
283 func (h *Handler) RepoCommitDetailPage(c *fiber.Ctx) error {
284 r, diskPath, err := h.loadAuthorizedRepo(c, false)
285 if err != nil {
286 return nil
287 }
288 viewer := middleware.CurrentUser(c)
289 hash := c.Params("hash")
290
291 detail, err := git.GetCommitDiff(c.UserContext(), diskPath, hash)
292 if err != nil {
293 return h.renderErrorT(c, fiber.StatusNotFound, "err.commit_not_found", "err.commit_not_found_desc", hash)
294 }
295
296 data := h.baseData(c, detail.Commit.ShortHash+" · "+r.OwnerUsername+"/"+r.Name)
297 data["Repo"] = r
298 data["Ref"] = r.DefaultBranch
299 data["ActiveTab"] = "commits"
300 data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
301 data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
302 data["Detail"] = detail
303
304 return h.render(c, fiber.StatusOK, "commit", data)
305 }
306
307 func (h *Handler) RepoSettingsPage(c *fiber.Ctx) error {
308 r, diskPath, err := h.loadAuthorizedRepo(c, true)
309 if err != nil {
310 return nil
311 }
312 branches, _ := git.ListBranches(c.UserContext(), diskPath)
313
314 data := h.baseData(c, h.tr(c, "repo.tab.settings")+" · "+r.OwnerUsername+"/"+r.Name)
315 data["Repo"] = r
316 data["Ref"] = r.DefaultBranch
317 data["ActiveTab"] = "settings"
318 data["CanWrite"] = true
319 data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
320 data["Branches"] = branches
321
322 viewer := middleware.CurrentUser(c)
323 collaborators, _ := h.repoSvc.ListCollaborators(c.UserContext(), r.ID)
324 data["Collaborators"] = collaborators
325 data["CanManageCollab"] = viewer != nil && viewer.ID == r.OwnerID
326
327 return h.render(c, fiber.StatusOK, "repo_settings", data)
328 }
329
330 func (h *Handler) RepoSettingsSubmit(c *fiber.Ctx) error {
331 r, _, err := h.loadAuthorizedRepo(c, true)
332 if err != nil {
333 return nil
334 }
335
336 desc := c.FormValue("description")
337 defaultBranch := c.FormValue("default_branch")
338 isPriv := c.FormValue("visibility") == "private"
339
340 enableIssues := c.FormValue("enable_issues") == "true" || c.FormValue("enable_issues") == "on" || c.FormValue("enable_issues") == "1"
341 enablePulls := c.FormValue("enable_pulls") == "true" || c.FormValue("enable_pulls") == "on" || c.FormValue("enable_pulls") == "1"
342 enableReleases := c.FormValue("enable_releases") == "true" || c.FormValue("enable_releases") == "on" || c.FormValue("enable_releases") == "1"
343 enableActivity := c.FormValue("enable_activity") == "true" || c.FormValue("enable_activity") == "on" || c.FormValue("enable_activity") == "1"
344
345 if err := h.repoSvc.Update(c.UserContext(), r, desc, isPriv, defaultBranch, enableIssues, enablePulls, enableReleases, enableActivity); err != nil {
346 h.setFlashT(c, "flash.error", err.Error())
347 return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
348 }
349
350 if c.FormValue("remove_avatar") == "true" {
351 if err := h.repoSvc.UpdateAvatar(c.UserContext(), r.ID, ""); err != nil {
352 h.logger.Error("remove repository avatar failed", "repo_id", r.ID, "err", err)
353 h.setFlashT(c, "flash.image_save_failed")
354 return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
355 }
356 } else if avatarData, err := readImageField(c, "avatar_data", "avatar_file", maxAvatarImageBytes); err != nil {
357 h.setFlashT(c, "flash.image_invalid")
358 return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
359 } else if avatarData != "" {
360 if err := h.repoSvc.UpdateAvatar(c.UserContext(), r.ID, avatarData); err != nil {
361 h.logger.Error("update repository avatar failed", "repo_id", r.ID, "err", err)
362 h.setFlashT(c, "flash.image_save_failed")
363 return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
364 }
365 }
366
367 if c.FormValue("remove_background") == "true" {
368 if err := h.repoSvc.UpdateBackground(c.UserContext(), r.ID, ""); err != nil {
369 h.logger.Error("remove repository background failed", "repo_id", r.ID, "err", err)
370 h.setFlashT(c, "flash.image_save_failed")
371 return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
372 }
373 } else if backgroundData, err := readImageField(c, "background_data", "background_file", maxBackgroundImageBytes); err != nil {
374 h.setFlashT(c, "flash.image_invalid")
375 return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
376 } else if backgroundData != "" {
377 if err := h.repoSvc.UpdateBackground(c.UserContext(), r.ID, backgroundData); err != nil {
378 h.logger.Error("update repository background failed", "repo_id", r.ID, "err", err)
379 h.setFlashT(c, "flash.image_save_failed")
380 return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
381 }
382 }
383
384 h.setFlashT(c, "flash.repo_settings_saved")
385 return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
386 }
387
388 func (h *Handler) RepoRenameSubmit(c *fiber.Ctx) error {
389 r, _, err := h.loadAuthorizedRepo(c, true)
390 if err != nil {
391 return nil
392 }
393
394 newName := strings.TrimSpace(c.FormValue("new_name"))
395 if err := h.repoSvc.Rename(c.UserContext(), r, newName); err != nil {
396 h.setFlashT(c, "flash.repo_rename_failed", err.Error())
397 return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
398 }
399
400 h.setFlashT(c, "flash.repo_renamed", r.Name)
401 return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
402 }
403
404 func (h *Handler) RepoDeleteSubmit(c *fiber.Ctx) error {
405 r, _, err := h.loadAuthorizedRepo(c, true)
406 if err != nil {
407 return nil
408 }
409
410 confirmName := strings.TrimSpace(c.FormValue("confirm_name"))
411 if !strings.EqualFold(confirmName, r.Name) {
412 h.setFlashT(c, "flash.repo_confirm_mismatch")
413 return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
414 }
415
416 if err := h.repoSvc.Delete(c.UserContext(), r); err != nil {
417 h.logger.Error("delete repo failed", "err", err)
418 return h.renderErrorT(c, fiber.StatusInternalServerError, "err.delete_error", "err.repo_delete_failed")
419 }
420
421 h.setFlashT(c, "flash.repo_deleted", r.Name)
422 return c.Redirect("/"+r.OwnerUsername, fiber.StatusSeeOther)
423 }
424
425 func (h *Handler) UserProfilePage(c *fiber.Ctx) error {
426 username := c.Params("username")
427 profileUser, err := h.userSvc.GetByUsername(c.UserContext(), username)
428 if err != nil {
429 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.user_not_found", username)
430 }
431
432 viewer := middleware.CurrentUser(c)
433 if viewer == nil && !h.db.IsAnonymousReadEnabled(c.UserContext()) {
434 return c.Redirect("/login", fiber.StatusSeeOther)
435 }
436
437 includePrivate := viewer != nil && (viewer.ID == profileUser.ID || viewer.IsAdmin)
438 repos, _ := h.repoSvc.ListByOwner(c.UserContext(), profileUser.ID, includePrivate)
439
440 publicCount := 0
441 privateCount := 0
442 for _, rp := range repos {
443 if rp.IsPrivate {
444 privateCount++
445 } else {
446 publicCount++
447 }
448 }
449
450 lang, _ := c.Locals("lang").(i18n.Lang)
451 title := "@" + profileUser.Username
452 if profileUser.FullName != "" {
453 title = profileUser.FullName + " (@" + profileUser.Username + ")"
454 }
455 data := h.baseData(c, title)
456 data["ProfileUser"] = profileUser
457 data["PageHeading"] = i18n.T(lang, "nav.repositories")
458 data["Repos"] = repos
459 data["PublicRepoCount"] = publicCount
460 data["PrivateRepoCount"] = privateCount
461 data["ShowPrivateCount"] = includePrivate
462
463 // GitHub-style profile README: a repository named after its owner
464 // (<user>/<user>) has its README rendered on the profile page.
465 if profileRepo, err := h.repoSvc.GetByOwnerAndName(c.UserContext(), profileUser.Username, profileUser.Username); err == nil && profileRepo != nil {
466 if !profileRepo.IsPrivate || includePrivate {
467 if diskPath, err := h.repoSvc.DiskPath(profileUser.Username, profileUser.Username); err == nil {
468 if blob, err := git.FindReadme(c.UserContext(), diskPath, profileRepo.DefaultBranch, ""); err == nil && blob != nil && strings.TrimSpace(string(blob.Data)) != "" {
469 data["ProfileReadmeHTML"] = views.RenderMarkdown(blob.Data)
470 }
471 }
472 }
473 }
474
475 return h.render(c, fiber.StatusOK, "profile", data)
476 }
477
478 func (h *Handler) RepoBranchesPage(c *fiber.Ctx) error {
479 r, diskPath, err := h.loadAuthorizedRepo(c, false)
480 if err != nil {
481 return nil
482 }
483 viewer := middleware.CurrentUser(c)
484
485 branches, _ := git.ListBranchDetails(c.UserContext(), diskPath, r.DefaultBranch)
486 tags, _ := git.ListTagDetails(c.UserContext(), diskPath)
487
488 data := h.baseData(c, h.tr(c, "repo.tab.branches")+" · "+r.OwnerUsername+"/"+r.Name)
489 data["Repo"] = r
490 data["Ref"] = r.DefaultBranch
491 data["ActiveTab"] = "branches"
492 data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
493 data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
494 data["BranchDetails"] = branches
495 data["TagDetails"] = tags
496 data["IsEmpty"] = len(branches) == 0
497
498 return h.render(c, fiber.StatusOK, "branches", data)
499 }
500
501 func (h *Handler) RepoCreateBranchSubmit(c *fiber.Ctx) error {
502 r, diskPath, err := h.loadAuthorizedRepo(c, true)
503 if err != nil {
504 return nil
505 }
506 branchName := strings.TrimSpace(c.FormValue("branch_name"))
507 fromRef := strings.TrimSpace(c.FormValue("from_ref"))
508 if fromRef == "" {
509 fromRef = r.DefaultBranch
510 }
511
512 if err := git.CreateBranch(c.UserContext(), diskPath, branchName, fromRef); err != nil {
513 h.setFlashT(c, "flash.branch_create_failed", err.Error())
514 } else {
515 h.setFlashT(c, "flash.branch_created", branchName)
516 }
517 return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
518 }
519
520 func (h *Handler) RepoDeleteBranchSubmit(c *fiber.Ctx) error {
521 r, diskPath, err := h.loadAuthorizedRepo(c, true)
522 if err != nil {
523 return nil
524 }
525 branchName := strings.TrimSpace(c.FormValue("branch_name"))
526 if strings.EqualFold(branchName, r.DefaultBranch) {
527 h.setFlashT(c, "flash.branch_default_delete")
528 return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
529 }
530
531 if err := git.DeleteBranch(c.UserContext(), diskPath, branchName); err != nil {
532 h.setFlashT(c, "flash.branch_delete_failed", err.Error())
533 } else {
534 h.setFlashT(c, "flash.branch_deleted", branchName)
535 }
536 return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
537 }
538
539 func (h *Handler) RepoCreateTagSubmit(c *fiber.Ctx) error {
540 r, diskPath, err := h.loadAuthorizedRepo(c, true)
541 if err != nil {
542 return nil
543 }
544 tagName := strings.TrimSpace(c.FormValue("tag_name"))
545 fromRef := strings.TrimSpace(c.FormValue("from_ref"))
546 if fromRef == "" {
547 fromRef = r.DefaultBranch
548 }
549 message := strings.TrimSpace(c.FormValue("message"))
550
551 if err := git.CreateTag(c.UserContext(), diskPath, tagName, fromRef, message); err != nil {
552 h.setFlashT(c, "flash.tag_create_failed", err.Error())
553 } else {
554 h.setFlashT(c, "flash.tag_created", tagName)
555 }
556 return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
557 }
558
559 func (h *Handler) RepoDeleteTagSubmit(c *fiber.Ctx) error {
560 r, diskPath, err := h.loadAuthorizedRepo(c, true)
561 if err != nil {
562 return nil
563 }
564 tagName := strings.TrimSpace(c.FormValue("tag_name"))
565
566 if err := git.DeleteTag(c.UserContext(), diskPath, tagName); err != nil {
567 h.setFlashT(c, "flash.tag_delete_failed", err.Error())
568 } else {
569 h.setFlashT(c, "flash.tag_deleted", tagName)
570 }
571 return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
572 }
573
574 func (h *Handler) RepoArchiveZip(c *fiber.Ctx) error {
575 r, diskPath, err := h.loadAuthorizedRepo(c, false)
576 if err != nil {
577 return nil
578 }
579
580 filename := c.Params("*")
581 isTarGz := strings.HasSuffix(filename, ".tar.gz") || strings.HasSuffix(filename, ".tgz")
582 ref := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSuffix(filename, ".tar.gz"), ".tgz"), ".zip")
583 ref, _ = url.PathUnescape(ref)
584 if ref == "" {
585 ref = r.DefaultBranch
586 }
587
588 prefix := r.Name + "-" + strings.ReplaceAll(ref, "/", "-")
589 if isTarGz {
590 tarBytes, err := git.ArchiveTarGz(c.UserContext(), diskPath, ref, prefix)
591 if err != nil {
592 h.logger.Error("archive tar.gz failed", "ref", ref, "err", err)
593 return h.renderErrorT(c, fiber.StatusNotFound, "err.archive_error", "err.archive_tar_failed", ref)
594 }
595 c.Set(fiber.HeaderContentType, "application/gzip")
596 c.Set(fiber.HeaderContentDisposition, fmt.Sprintf(`attachment; filename="%s.tar.gz"`, prefix))
597 return c.Send(tarBytes)
598 }
599
600 zipBytes, err := git.ArchiveZip(c.UserContext(), diskPath, ref, prefix)
601 if err != nil {
602 h.logger.Error("archive zip failed", "ref", ref, "err", err)
603 return h.renderErrorT(c, fiber.StatusNotFound, "err.archive_error", "err.archive_zip_failed", ref)
604 }
605
606 c.Set(fiber.HeaderContentType, "application/zip")
607 c.Set(fiber.HeaderContentDisposition, fmt.Sprintf(`attachment; filename="%s.zip"`, prefix))
608 return c.Send(zipBytes)
609 }
610
611 func (h *Handler) RepoIssuesPage(c *fiber.Ctx) error {
612 r, _, err := h.loadAuthorizedRepo(c, false)
613 if err != nil {
614 return nil
615 }
616 if !r.EnableIssues {
617 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled")
618 }
619
620 state := c.Query("state", "open")
621 isClosed := state == "closed"
622
623 issues, _ := h.repoSvc.ListIssues(c.UserContext(), r.ID, isClosed)
624 openCount, closedCount, _ := h.repoSvc.CountIssues(c.UserContext(), r.ID)
625
626 viewer := middleware.CurrentUser(c)
627 data := h.baseData(c, h.tr(c, "repo.tab.issues")+" · "+r.OwnerUsername+"/"+r.Name)
628 data["Repo"] = r
629 data["Ref"] = r.DefaultBranch
630 data["ActiveTab"] = "issues"
631 data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
632 data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
633 data["Issues"] = issues
634 data["OpenCount"] = openCount
635 data["ClosedCount"] = closedCount
636 data["ShowClosed"] = isClosed
637
638 return h.render(c, fiber.StatusOK, "issues", data)
639 }
640
641 func (h *Handler) RepoNewIssuePage(c *fiber.Ctx) error {
642 r, _, err := h.loadAuthorizedRepo(c, false)
643 if err != nil {
644 return nil
645 }
646 if !r.EnableIssues {
647 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled")
648 }
649
650 viewer := middleware.CurrentUser(c)
651 data := h.baseData(c, h.tr(c, "issue.new")+" · "+r.OwnerUsername+"/"+r.Name)
652 data["Repo"] = r
653 data["Ref"] = r.DefaultBranch
654 data["ActiveTab"] = "issues"
655 data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
656 data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
657
658 return h.render(c, fiber.StatusOK, "issue_new", data)
659 }
660
661 func (h *Handler) RepoNewIssueSubmit(c *fiber.Ctx) error {
662 r, _, err := h.loadAuthorizedRepo(c, false)
663 if err != nil {
664 return nil
665 }
666 if !r.EnableIssues {
667 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled")
668 }
669
670 u := middleware.CurrentUser(c)
671 title := c.FormValue("title")
672 body := c.FormValue("body")
673
674 iss, err := h.repoSvc.CreateIssue(c.UserContext(), r.ID, u.ID, title, body)
675 if err != nil {
676 h.setFlashT(c, "flash.error", err.Error())
677 return c.Redirect(fmt.Sprintf("/%s/%s/issues/new", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
678 }
679
680 return c.Redirect(fmt.Sprintf("/%s/%s/issues/%d", r.OwnerUsername, r.Name, iss.Number), fiber.StatusSeeOther)
681 }
682
683 func (h *Handler) RepoIssueDetailPage(c *fiber.Ctx) error {
684 r, _, err := h.loadAuthorizedRepo(c, false)
685 if err != nil {
686 return nil
687 }
688 if !r.EnableIssues {
689 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled")
690 }
691
692 num, _ := strconv.Atoi(c.Params("number"))
693 iss, err := h.repoSvc.GetIssue(c.UserContext(), r.ID, num)
694 if err != nil {
695 return h.renderErrorT(c, fiber.StatusNotFound, "err.issue_not_found", "err.issue_not_found_desc")
696 }
697
698 comments, _ := h.repoSvc.ListIssueComments(c.UserContext(), iss.ID)
699 viewer := middleware.CurrentUser(c)
700 canToggle := viewer != nil && (viewer.ID == iss.AuthorID || h.repoSvc.CanWrite(c.UserContext(), r, viewer))
701
702 data := h.baseData(c, fmt.Sprintf("#%d: %s · %s/%s", iss.Number, iss.Title, r.OwnerUsername, r.Name))
703 data["Repo"] = r
704 data["Ref"] = r.DefaultBranch
705 data["ActiveTab"] = "issues"
706 data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
707 data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
708 data["Issue"] = iss
709 data["Comments"] = comments
710 data["CanToggleIssue"] = canToggle
711
712 return h.render(c, fiber.StatusOK, "issue_detail", data)
713 }
714
715 func (h *Handler) RepoIssueCommentSubmit(c *fiber.Ctx) error {
716 r, _, err := h.loadAuthorizedRepo(c, false)
717 if err != nil {
718 return nil
719 }
720 if !r.EnableIssues {
721 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled")
722 }
723
724 num, _ := strconv.Atoi(c.Params("number"))
725 iss, err := h.repoSvc.GetIssue(c.UserContext(), r.ID, num)
726 if err != nil {
727 return h.renderErrorT(c, fiber.StatusNotFound, "err.issue_not_found", "err.issue_not_found_desc")
728 }
729
730 u := middleware.CurrentUser(c)
731 body := c.FormValue("body")
732 if err := h.repoSvc.CreateIssueComment(c.UserContext(), iss.ID, u.ID, body); err != nil {
733 h.setFlashT(c, "flash.comment_error", err.Error())
734 }
735 return c.Redirect(fmt.Sprintf("/%s/%s/issues/%d", r.OwnerUsername, r.Name, iss.Number), fiber.StatusSeeOther)
736 }
737
738 func (h *Handler) RepoIssueStateSubmit(c *fiber.Ctx) error {
739 r, _, err := h.loadAuthorizedRepo(c, false)
740 if err != nil {
741 return nil
742 }
743 if !r.EnableIssues {
744 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled")
745 }
746
747 num, _ := strconv.Atoi(c.Params("number"))
748 iss, err := h.repoSvc.GetIssue(c.UserContext(), r.ID, num)
749 if err != nil {
750 return h.renderErrorT(c, fiber.StatusNotFound, "err.issue_not_found", "err.issue_not_found_desc")
751 }
752
753 viewer := middleware.CurrentUser(c)
754 if viewer == nil || (viewer.ID != iss.AuthorID && !h.repoSvc.CanWrite(c.UserContext(), r, viewer)) {
755 return h.renderErrorT(c, fiber.StatusForbidden, "err.forbidden", "err.issue_state_forbidden")
756 }
757
758 targetState := c.FormValue("state")
759 isClosed := targetState == "closed"
760 _ = h.repoSvc.SetIssueClosed(c.UserContext(), iss.ID, isClosed)
761
762 if isClosed {
763 h.setFlashT(c, "flash.issue_closed", iss.Number)
764 } else {
765 h.setFlashT(c, "flash.issue_reopened", iss.Number)
766 }
767 return c.Redirect(fmt.Sprintf("/%s/%s/issues/%d", r.OwnerUsername, r.Name, iss.Number), fiber.StatusSeeOther)
768 }
769