rin
Public Open-source lightweight git hosting.
repo.go · 751 lines · 24.8 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 h.setFlashT(c, "flash.repo_settings_saved")
368 return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
369 }
370
371 func (h *Handler) RepoRenameSubmit(c *fiber.Ctx) error {
372 r, _, err := h.loadAuthorizedRepo(c, true)
373 if err != nil {
374 return nil
375 }
376
377 newName := strings.TrimSpace(c.FormValue("new_name"))
378 if err := h.repoSvc.Rename(c.UserContext(), r, newName); err != nil {
379 h.setFlashT(c, "flash.repo_rename_failed", err.Error())
380 return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
381 }
382
383 h.setFlashT(c, "flash.repo_renamed", r.Name)
384 return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
385 }
386
387 func (h *Handler) RepoDeleteSubmit(c *fiber.Ctx) error {
388 r, _, err := h.loadAuthorizedRepo(c, true)
389 if err != nil {
390 return nil
391 }
392
393 confirmName := strings.TrimSpace(c.FormValue("confirm_name"))
394 if !strings.EqualFold(confirmName, r.Name) {
395 h.setFlashT(c, "flash.repo_confirm_mismatch")
396 return c.Redirect(fmt.Sprintf("/%s/%s/settings", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
397 }
398
399 if err := h.repoSvc.Delete(c.UserContext(), r); err != nil {
400 h.logger.Error("delete repo failed", "err", err)
401 return h.renderErrorT(c, fiber.StatusInternalServerError, "err.delete_error", "err.repo_delete_failed")
402 }
403
404 h.setFlashT(c, "flash.repo_deleted", r.Name)
405 return c.Redirect("/"+r.OwnerUsername, fiber.StatusSeeOther)
406 }
407
408 func (h *Handler) UserProfilePage(c *fiber.Ctx) error {
409 username := c.Params("username")
410 profileUser, err := h.userSvc.GetByUsername(c.UserContext(), username)
411 if err != nil {
412 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.user_not_found", username)
413 }
414
415 viewer := middleware.CurrentUser(c)
416 if viewer == nil && !h.db.IsAnonymousReadEnabled(c.UserContext()) {
417 return c.Redirect("/login", fiber.StatusSeeOther)
418 }
419
420 includePrivate := viewer != nil && (viewer.ID == profileUser.ID || viewer.IsAdmin)
421 repos, _ := h.repoSvc.ListByOwner(c.UserContext(), profileUser.ID, includePrivate)
422
423 publicCount := 0
424 privateCount := 0
425 for _, rp := range repos {
426 if rp.IsPrivate {
427 privateCount++
428 } else {
429 publicCount++
430 }
431 }
432
433 lang, _ := c.Locals("lang").(i18n.Lang)
434 title := "@" + profileUser.Username
435 if profileUser.FullName != "" {
436 title = profileUser.FullName + " (@" + profileUser.Username + ")"
437 }
438 data := h.baseData(c, title)
439 data["ProfileUser"] = profileUser
440 data["PageHeading"] = i18n.T(lang, "nav.repositories")
441 data["Repos"] = repos
442 data["PublicRepoCount"] = publicCount
443 data["PrivateRepoCount"] = privateCount
444 data["ShowPrivateCount"] = includePrivate
445
446 // GitHub-style profile README: a repository named after its owner
447 // (<user>/<user>) has its README rendered on the profile page.
448 if profileRepo, err := h.repoSvc.GetByOwnerAndName(c.UserContext(), profileUser.Username, profileUser.Username); err == nil && profileRepo != nil {
449 if !profileRepo.IsPrivate || includePrivate {
450 if diskPath, err := h.repoSvc.DiskPath(profileUser.Username, profileUser.Username); err == nil {
451 if blob, err := git.FindReadme(c.UserContext(), diskPath, profileRepo.DefaultBranch, ""); err == nil && blob != nil && strings.TrimSpace(string(blob.Data)) != "" {
452 data["ProfileReadmeHTML"] = views.RenderMarkdown(blob.Data)
453 }
454 }
455 }
456 }
457
458 return h.render(c, fiber.StatusOK, "profile", data)
459 }
460
461 func (h *Handler) RepoBranchesPage(c *fiber.Ctx) error {
462 r, diskPath, err := h.loadAuthorizedRepo(c, false)
463 if err != nil {
464 return nil
465 }
466 viewer := middleware.CurrentUser(c)
467
468 branches, _ := git.ListBranchDetails(c.UserContext(), diskPath, r.DefaultBranch)
469 tags, _ := git.ListTagDetails(c.UserContext(), diskPath)
470
471 data := h.baseData(c, h.tr(c, "repo.tab.branches")+" · "+r.OwnerUsername+"/"+r.Name)
472 data["Repo"] = r
473 data["Ref"] = r.DefaultBranch
474 data["ActiveTab"] = "branches"
475 data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
476 data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
477 data["BranchDetails"] = branches
478 data["TagDetails"] = tags
479 data["IsEmpty"] = len(branches) == 0
480
481 return h.render(c, fiber.StatusOK, "branches", data)
482 }
483
484 func (h *Handler) RepoCreateBranchSubmit(c *fiber.Ctx) error {
485 r, diskPath, err := h.loadAuthorizedRepo(c, true)
486 if err != nil {
487 return nil
488 }
489 branchName := strings.TrimSpace(c.FormValue("branch_name"))
490 fromRef := strings.TrimSpace(c.FormValue("from_ref"))
491 if fromRef == "" {
492 fromRef = r.DefaultBranch
493 }
494
495 if err := git.CreateBranch(c.UserContext(), diskPath, branchName, fromRef); err != nil {
496 h.setFlashT(c, "flash.branch_create_failed", err.Error())
497 } else {
498 h.setFlashT(c, "flash.branch_created", branchName)
499 }
500 return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
501 }
502
503 func (h *Handler) RepoDeleteBranchSubmit(c *fiber.Ctx) error {
504 r, diskPath, err := h.loadAuthorizedRepo(c, true)
505 if err != nil {
506 return nil
507 }
508 branchName := strings.TrimSpace(c.FormValue("branch_name"))
509 if strings.EqualFold(branchName, r.DefaultBranch) {
510 h.setFlashT(c, "flash.branch_default_delete")
511 return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
512 }
513
514 if err := git.DeleteBranch(c.UserContext(), diskPath, branchName); err != nil {
515 h.setFlashT(c, "flash.branch_delete_failed", err.Error())
516 } else {
517 h.setFlashT(c, "flash.branch_deleted", branchName)
518 }
519 return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
520 }
521
522 func (h *Handler) RepoCreateTagSubmit(c *fiber.Ctx) error {
523 r, diskPath, err := h.loadAuthorizedRepo(c, true)
524 if err != nil {
525 return nil
526 }
527 tagName := strings.TrimSpace(c.FormValue("tag_name"))
528 fromRef := strings.TrimSpace(c.FormValue("from_ref"))
529 if fromRef == "" {
530 fromRef = r.DefaultBranch
531 }
532 message := strings.TrimSpace(c.FormValue("message"))
533
534 if err := git.CreateTag(c.UserContext(), diskPath, tagName, fromRef, message); err != nil {
535 h.setFlashT(c, "flash.tag_create_failed", err.Error())
536 } else {
537 h.setFlashT(c, "flash.tag_created", tagName)
538 }
539 return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
540 }
541
542 func (h *Handler) RepoDeleteTagSubmit(c *fiber.Ctx) error {
543 r, diskPath, err := h.loadAuthorizedRepo(c, true)
544 if err != nil {
545 return nil
546 }
547 tagName := strings.TrimSpace(c.FormValue("tag_name"))
548
549 if err := git.DeleteTag(c.UserContext(), diskPath, tagName); err != nil {
550 h.setFlashT(c, "flash.tag_delete_failed", err.Error())
551 } else {
552 h.setFlashT(c, "flash.tag_deleted", tagName)
553 }
554 return c.Redirect(fmt.Sprintf("/%s/%s/branches", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
555 }
556
557 func (h *Handler) RepoArchiveZip(c *fiber.Ctx) error {
558 r, diskPath, err := h.loadAuthorizedRepo(c, false)
559 if err != nil {
560 return nil
561 }
562
563 filename := c.Params("*")
564 isTarGz := strings.HasSuffix(filename, ".tar.gz") || strings.HasSuffix(filename, ".tgz")
565 ref := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSuffix(filename, ".tar.gz"), ".tgz"), ".zip")
566 ref, _ = url.PathUnescape(ref)
567 if ref == "" {
568 ref = r.DefaultBranch
569 }
570
571 prefix := r.Name + "-" + strings.ReplaceAll(ref, "/", "-")
572 if isTarGz {
573 tarBytes, err := git.ArchiveTarGz(c.UserContext(), diskPath, ref, prefix)
574 if err != nil {
575 h.logger.Error("archive tar.gz failed", "ref", ref, "err", err)
576 return h.renderErrorT(c, fiber.StatusNotFound, "err.archive_error", "err.archive_tar_failed", ref)
577 }
578 c.Set(fiber.HeaderContentType, "application/gzip")
579 c.Set(fiber.HeaderContentDisposition, fmt.Sprintf(`attachment; filename="%s.tar.gz"`, prefix))
580 return c.Send(tarBytes)
581 }
582
583 zipBytes, err := git.ArchiveZip(c.UserContext(), diskPath, ref, prefix)
584 if err != nil {
585 h.logger.Error("archive zip failed", "ref", ref, "err", err)
586 return h.renderErrorT(c, fiber.StatusNotFound, "err.archive_error", "err.archive_zip_failed", ref)
587 }
588
589 c.Set(fiber.HeaderContentType, "application/zip")
590 c.Set(fiber.HeaderContentDisposition, fmt.Sprintf(`attachment; filename="%s.zip"`, prefix))
591 return c.Send(zipBytes)
592 }
593
594 func (h *Handler) RepoIssuesPage(c *fiber.Ctx) error {
595 r, _, err := h.loadAuthorizedRepo(c, false)
596 if err != nil {
597 return nil
598 }
599 if !r.EnableIssues {
600 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled")
601 }
602
603 state := c.Query("state", "open")
604 isClosed := state == "closed"
605
606 issues, _ := h.repoSvc.ListIssues(c.UserContext(), r.ID, isClosed)
607 openCount, closedCount, _ := h.repoSvc.CountIssues(c.UserContext(), r.ID)
608
609 viewer := middleware.CurrentUser(c)
610 data := h.baseData(c, h.tr(c, "repo.tab.issues")+" · "+r.OwnerUsername+"/"+r.Name)
611 data["Repo"] = r
612 data["Ref"] = r.DefaultBranch
613 data["ActiveTab"] = "issues"
614 data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
615 data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
616 data["Issues"] = issues
617 data["OpenCount"] = openCount
618 data["ClosedCount"] = closedCount
619 data["ShowClosed"] = isClosed
620
621 return h.render(c, fiber.StatusOK, "issues", data)
622 }
623
624 func (h *Handler) RepoNewIssuePage(c *fiber.Ctx) error {
625 r, _, err := h.loadAuthorizedRepo(c, false)
626 if err != nil {
627 return nil
628 }
629 if !r.EnableIssues {
630 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled")
631 }
632
633 viewer := middleware.CurrentUser(c)
634 data := h.baseData(c, h.tr(c, "issue.new")+" · "+r.OwnerUsername+"/"+r.Name)
635 data["Repo"] = r
636 data["Ref"] = r.DefaultBranch
637 data["ActiveTab"] = "issues"
638 data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
639 data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
640
641 return h.render(c, fiber.StatusOK, "issue_new", data)
642 }
643
644 func (h *Handler) RepoNewIssueSubmit(c *fiber.Ctx) error {
645 r, _, err := h.loadAuthorizedRepo(c, false)
646 if err != nil {
647 return nil
648 }
649 if !r.EnableIssues {
650 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled")
651 }
652
653 u := middleware.CurrentUser(c)
654 title := c.FormValue("title")
655 body := c.FormValue("body")
656
657 iss, err := h.repoSvc.CreateIssue(c.UserContext(), r.ID, u.ID, title, body)
658 if err != nil {
659 h.setFlashT(c, "flash.error", err.Error())
660 return c.Redirect(fmt.Sprintf("/%s/%s/issues/new", r.OwnerUsername, r.Name), fiber.StatusSeeOther)
661 }
662
663 return c.Redirect(fmt.Sprintf("/%s/%s/issues/%d", r.OwnerUsername, r.Name, iss.Number), fiber.StatusSeeOther)
664 }
665
666 func (h *Handler) RepoIssueDetailPage(c *fiber.Ctx) error {
667 r, _, err := h.loadAuthorizedRepo(c, false)
668 if err != nil {
669 return nil
670 }
671 if !r.EnableIssues {
672 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled")
673 }
674
675 num, _ := strconv.Atoi(c.Params("number"))
676 iss, err := h.repoSvc.GetIssue(c.UserContext(), r.ID, num)
677 if err != nil {
678 return h.renderErrorT(c, fiber.StatusNotFound, "err.issue_not_found", "err.issue_not_found_desc")
679 }
680
681 comments, _ := h.repoSvc.ListIssueComments(c.UserContext(), iss.ID)
682 viewer := middleware.CurrentUser(c)
683 canToggle := viewer != nil && (viewer.ID == iss.AuthorID || h.repoSvc.CanWrite(c.UserContext(), r, viewer))
684
685 data := h.baseData(c, fmt.Sprintf("#%d: %s · %s/%s", iss.Number, iss.Title, r.OwnerUsername, r.Name))
686 data["Repo"] = r
687 data["Ref"] = r.DefaultBranch
688 data["ActiveTab"] = "issues"
689 data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer)
690 data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name)
691 data["Issue"] = iss
692 data["Comments"] = comments
693 data["CanToggleIssue"] = canToggle
694
695 return h.render(c, fiber.StatusOK, "issue_detail", data)
696 }
697
698 func (h *Handler) RepoIssueCommentSubmit(c *fiber.Ctx) error {
699 r, _, err := h.loadAuthorizedRepo(c, false)
700 if err != nil {
701 return nil
702 }
703 if !r.EnableIssues {
704 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled")
705 }
706
707 num, _ := strconv.Atoi(c.Params("number"))
708 iss, err := h.repoSvc.GetIssue(c.UserContext(), r.ID, num)
709 if err != nil {
710 return h.renderErrorT(c, fiber.StatusNotFound, "err.issue_not_found", "err.issue_not_found_desc")
711 }
712
713 u := middleware.CurrentUser(c)
714 body := c.FormValue("body")
715 if err := h.repoSvc.CreateIssueComment(c.UserContext(), iss.ID, u.ID, body); err != nil {
716 h.setFlashT(c, "flash.comment_error", err.Error())
717 }
718 return c.Redirect(fmt.Sprintf("/%s/%s/issues/%d", r.OwnerUsername, r.Name, iss.Number), fiber.StatusSeeOther)
719 }
720
721 func (h *Handler) RepoIssueStateSubmit(c *fiber.Ctx) error {
722 r, _, err := h.loadAuthorizedRepo(c, false)
723 if err != nil {
724 return nil
725 }
726 if !r.EnableIssues {
727 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.issues_disabled")
728 }
729
730 num, _ := strconv.Atoi(c.Params("number"))
731 iss, err := h.repoSvc.GetIssue(c.UserContext(), r.ID, num)
732 if err != nil {
733 return h.renderErrorT(c, fiber.StatusNotFound, "err.issue_not_found", "err.issue_not_found_desc")
734 }
735
736 viewer := middleware.CurrentUser(c)
737 if viewer == nil || (viewer.ID != iss.AuthorID && !h.repoSvc.CanWrite(c.UserContext(), r, viewer)) {
738 return h.renderErrorT(c, fiber.StatusForbidden, "err.forbidden", "err.issue_state_forbidden")
739 }
740
741 targetState := c.FormValue("state")
742 isClosed := targetState == "closed"
743 _ = h.repoSvc.SetIssueClosed(c.UserContext(), iss.ID, isClosed)
744
745 if isClosed {
746 h.setFlashT(c, "flash.issue_closed", iss.Number)
747 } else {
748 h.setFlashT(c, "flash.issue_reopened", iss.Number)
749 }
750 return c.Redirect(fmt.Sprintf("/%s/%s/issues/%d", r.OwnerUsername, r.Name, iss.Number), fiber.StatusSeeOther)
751 }
752