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