package public import ( "io" "mime" "net/http" "sort" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/mattnite/forgejo-tickets/internal/auth" "github.com/mattnite/forgejo-tickets/internal/forgejo" "github.com/mattnite/forgejo-tickets/internal/markdown" "github.com/mattnite/forgejo-tickets/internal/models" "github.com/rs/zerolog/log" ) type TicketHandler struct { deps Dependencies } func (h *TicketHandler) List(c *gin.Context) { user := auth.CurrentUser(c) var tickets []models.Ticket if err := h.deps.DB.Preload("Repo").Where("user_id = ?", user.ID).Order("created_at DESC").Limit(50).Find(&tickets).Error; err != nil { log.Error().Err(err).Msg("list tickets error") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to load tickets") return } if len(tickets) == 0 { h.deps.Renderer.Render(c.Writer, c.Request, "tickets/list", map[string]interface{}{ "Tickets": []map[string]interface{}{}, }) return } // Group tickets by repo type repoGroup struct { repo models.Repo tickets []models.Ticket } repoMap := map[uuid.UUID]*repoGroup{} for _, t := range tickets { if _, ok := repoMap[t.RepoID]; !ok { repoMap[t.RepoID] = &repoGroup{repo: t.Repo} } repoMap[t.RepoID].tickets = append(repoMap[t.RepoID].tickets, t) } // Fetch issues from Forgejo per repo and build view models type ticketView struct { ID uuid.UUID Title string Status string Priority string Pinned bool RepoName string DueDate *time.Time CreatedAt interface{} } var views []ticketView for _, group := range repoMap { issues, err := h.deps.ForgejoClient.ListIssues(group.repo.ForgejoOwner, group.repo.ForgejoRepo, "all", "") if err != nil { log.Error().Err(err).Str("repo", group.repo.Name).Msg("forgejo list issues error") // Show tickets with unknown status on API failure for _, t := range group.tickets { views = append(views, ticketView{ ID: t.ID, Title: "Unable to load", Status: "open", RepoName: group.repo.Name, CreatedAt: t.CreatedAt, }) } continue } issueByNumber := map[int64]*forgejo.Issue{} for i := range issues { issueByNumber[issues[i].Number] = &issues[i] } for _, t := range group.tickets { if issue, ok := issueByNumber[t.ForgejoIssueNumber]; ok { views = append(views, ticketView{ ID: t.ID, Title: issue.Title, Status: forgejo.DeriveStatus(issue), Priority: forgejo.DerivePriority(issue), Pinned: issue.PinOrder > 0, RepoName: group.repo.Name, DueDate: issue.DueDate, CreatedAt: t.CreatedAt, }) } } } // Sort: pinned first, then by created date sort.SliceStable(views, func(i, j int) bool { if views[i].Pinned != views[j].Pinned { return views[i].Pinned } return false // preserve existing order for non-pinned }) h.deps.Renderer.Render(c.Writer, c.Request, "tickets/list", map[string]interface{}{ "Tickets": views, }) } func (h *TicketHandler) NewForm(c *gin.Context) { user := auth.CurrentUser(c) var repos []models.Repo if err := h.deps.DB. Joins("JOIN user_repos ON user_repos.repo_id = repos.id"). Where("user_repos.user_id = ? AND repos.active = ?", user.ID, true). Order("repos.name ASC"). Find(&repos).Error; err != nil { log.Error().Err(err).Msg("list repos error") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to load products") return } h.deps.Renderer.Render(c.Writer, c.Request, "tickets/new", map[string]interface{}{ "Repos": repos, }) } func (h *TicketHandler) Create(c *gin.Context) { user := auth.CurrentUser(c) // Parse multipart form first (ensures files are available) form, err := c.MultipartForm() if err != nil { log.Error().Err(err).Msg("parse multipart form error") } getField := func(name string) string { if form != nil && form.Value[name] != nil { return form.Value[name][0] } return "" } repoID, err := uuid.Parse(getField("repo_id")) if err != nil { h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid product selection") return } // Validate the user has access to this repo var userRepo models.UserRepo if err := h.deps.DB.Where("user_id = ? AND repo_id = ?", user.ID, repoID).First(&userRepo).Error; err != nil { h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusForbidden, "You do not have access to this project") return } title := getField("title") description := getField("description") if title == "" || description == "" { var repos []models.Repo h.deps.DB. Joins("JOIN user_repos ON user_repos.repo_id = repos.id"). Where("user_repos.user_id = ? AND repos.active = ?", user.ID, true). Order("repos.name ASC"). Find(&repos) h.deps.Renderer.Render(c.Writer, c.Request, "tickets/new", map[string]interface{}{ "Repos": repos, "Error": "Title and description are required", "Title": title, "Description": description, "RepoID": repoID.String(), }) return } // Look up the repo var repo models.Repo if err := h.deps.DB.First(&repo, "id = ?", repoID).Error; err != nil { h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to find product") return } // Synchronous Forgejo issue creation var labelIDs []int64 label, err := h.deps.ForgejoClient.GetOrCreateLabel(repo.ForgejoOwner, repo.ForgejoRepo, "customer", "#0075ca") if err != nil { log.Error().Err(err).Msg("forgejo get/create label error") } else { labelIDs = append(labelIDs, label.ID) } issue, err := h.deps.ForgejoClient.CreateIssue(repo.ForgejoOwner, repo.ForgejoRepo, forgejo.CreateIssueRequest{ Title: title, Body: description + "\n\n---\n*Submitted by: " + user.Name + " <" + user.Email + ">*", Labels: labelIDs, }) if err != nil { log.Error().Err(err).Msg("forgejo create issue error") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusServiceUnavailable, "Service temporarily unavailable. Please try again later.") return } // Apply customer label after creation (CreateIssue labels field is unreliable) if len(labelIDs) > 0 { if err := h.deps.ForgejoClient.AddLabels(repo.ForgejoOwner, repo.ForgejoRepo, issue.Number, labelIDs); err != nil { log.Error().Err(err).Msg("forgejo add customer label error") } } // Upload attachments if any if form != nil && form.File["attachments"] != nil { for _, fh := range form.File["attachments"] { f, err := fh.Open() if err != nil { log.Error().Err(err).Str("file", fh.Filename).Msg("open uploaded file error") continue } _, err = h.deps.ForgejoClient.CreateIssueAttachment(repo.ForgejoOwner, repo.ForgejoRepo, issue.Number, fh.Filename, f) f.Close() if err != nil { log.Error().Err(err).Str("file", fh.Filename).Msg("upload attachment error") } } } // Create local ticket mapping ticket := models.Ticket{ UserID: user.ID, RepoID: repoID, ForgejoIssueNumber: issue.Number, } if err := h.deps.DB.Create(&ticket).Error; err != nil { log.Error().Err(err).Msg("create ticket error") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to create ticket") return } c.Redirect(http.StatusSeeOther, "/tickets/"+ticket.ID.String()) } func (h *TicketHandler) Detail(c *gin.Context) { user := auth.CurrentUser(c) ticketID, err := uuid.Parse(c.Param("id")) if err != nil { h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid ticket ID") return } var ticket models.Ticket if err := h.deps.DB.First(&ticket, "id = ?", ticketID).Error; err != nil { h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusNotFound, "Ticket not found") return } if ticket.UserID != user.ID { h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusForbidden, "Access denied") return } var repo models.Repo h.deps.DB.First(&repo, "id = ?", ticket.RepoID) // Fetch issue from Forgejo issue, err := h.deps.ForgejoClient.GetIssue(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber) if err != nil { log.Error().Err(err).Msg("forgejo get issue error") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusServiceUnavailable, "Unable to load ticket details. Please try again later.") return } // Fetch comments (includes assets) and timeline (includes events) comments, err := h.deps.ForgejoClient.ListIssueComments(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber) if err != nil { log.Error().Err(err).Msg("forgejo list comments error") } timelineEvents, err := h.deps.ForgejoClient.ListIssueTimeline(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber) if err != nil { log.Error().Err(err).Msg("forgejo list timeline error") timelineEvents = nil } // Build comment asset lookup (timeline may not include assets) commentAssets := map[int64][]forgejo.Attachment{} for _, cm := range comments { if len(cm.Assets) > 0 { commentAssets[cm.ID] = cm.Assets } } // Merge assets into timeline events for i := range timelineEvents { if timelineEvents[i].Type == "comment" && len(timelineEvents[i].Assets) == 0 { if assets, ok := commentAssets[timelineEvents[i].ID]; ok { timelineEvents[i].Assets = assets } } } // Strip the "Submitted by" footer from the issue body cleanBody, _ := forgejo.StripCommentFooter(issue.Body) timeline := forgejo.BuildTimelineViews(timelineEvents, h.deps.ForgejoClient.BotLogin, false) // Build assignee names var assigneeNames []string for _, a := range issue.Assignees { assigneeNames = append(assigneeNames, a.DisplayName()) } // Build mention map: extract @usernames from body + comments, look up display names var allTexts []string allTexts = append(allTexts, cleanBody) for _, tv := range timeline { if tv.Type == "comment" { allTexts = append(allTexts, tv.Body) } } usernames := markdown.ExtractMentions(allTexts...) mentions := map[string]string{} for _, username := range usernames { u, err := h.deps.ForgejoClient.GetUser(username) if err != nil { mentions[username] = username } else { mentions[username] = u.DisplayName() } } // Extract related issue references allText := strings.Join(allTexts, "\n") refNumbers := forgejo.ExtractIssueReferences(allText) // Check visibility of referenced issues var relatedIssues []forgejo.RelatedIssue if len(refNumbers) > 0 { // Build a set of issue numbers this customer has tickets for var userTickets []models.Ticket h.deps.DB.Where("user_id = ? AND repo_id = ?", user.ID, ticket.RepoID).Find(&userTickets) ticketByIssue := map[int64]models.Ticket{} for _, ut := range userTickets { ticketByIssue[ut.ForgejoIssueNumber] = ut } for _, refNum := range refNumbers { if refNum == ticket.ForgejoIssueNumber { continue // skip self-reference } ri := forgejo.RelatedIssue{Number: refNum} if ut, ok := ticketByIssue[refNum]; ok { // Customer has access to this issue refIssue, err := h.deps.ForgejoClient.GetIssue(repo.ForgejoOwner, repo.ForgejoRepo, refNum) if err == nil { ri.Title = refIssue.Title ri.IsVisible = true ri.DisplayText = refIssue.Title ri.TicketID = ut.ID.String() } else { ri.DisplayText = "[Internal Ticket]" } } else { ri.DisplayText = "[Internal Ticket]" } relatedIssues = append(relatedIssues, ri) } } h.deps.Renderer.Render(c.Writer, c.Request, "tickets/detail", map[string]interface{}{ "Ticket": map[string]interface{}{ "ID": ticket.ID, "Title": issue.Title, "Description": cleanBody, "Status": forgejo.DeriveStatus(issue), "Priority": forgejo.DerivePriority(issue), "Pinned": issue.PinOrder > 0, "Assignees": strings.Join(assigneeNames, ", "), "DueDate": issue.DueDate, "Attachments": issue.Assets, "CreatedAt": ticket.CreatedAt, }, "Repo": repo, "Timeline": timeline, "RelatedIssues": relatedIssues, "Mentions": mentions, }) } func (h *TicketHandler) AddComment(c *gin.Context) { user := auth.CurrentUser(c) ticketID, err := uuid.Parse(c.Param("id")) if err != nil { h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid ticket ID") return } var ticket models.Ticket if err := h.deps.DB.First(&ticket, "id = ?", ticketID).Error; err != nil { h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusNotFound, "Ticket not found") return } if ticket.UserID != user.ID { h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusForbidden, "Access denied") return } // Parse multipart form first (ensures files are available) form, err := c.MultipartForm() if err != nil { log.Error().Err(err).Msg("parse multipart form error") } body := "" if form != nil && form.Value["body"] != nil { body = form.Value["body"][0] } hasAttachments := form != nil && len(form.File["attachments"]) > 0 if body == "" && !hasAttachments { c.Redirect(http.StatusSeeOther, "/tickets/"+ticket.ID.String()) return } // Post comment directly to Forgejo var repo models.Repo if err := h.deps.DB.First(&repo, "id = ?", ticket.RepoID).Error; err != nil { h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to find product") return } // Build comment body — use placeholder if only attachments commentBody := body if commentBody == "" { commentBody = "(attached files)" } comment, err := h.deps.ForgejoClient.CreateComment(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber, forgejo.CreateCommentRequest{ Body: commentBody + "\n\n---\n*Customer comment by: " + user.Name + " <" + user.Email + ">*", }) if err != nil { log.Error().Err(err).Msg("forgejo create comment error") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusServiceUnavailable, "Unable to add comment. Please try again later.") return } // Upload attachments to comment if hasAttachments { for _, fh := range form.File["attachments"] { f, err := fh.Open() if err != nil { log.Error().Err(err).Str("file", fh.Filename).Msg("open uploaded file error") continue } _, err = h.deps.ForgejoClient.CreateCommentAttachment(repo.ForgejoOwner, repo.ForgejoRepo, comment.ID, fh.Filename, f) f.Close() if err != nil { log.Error().Err(err).Str("file", fh.Filename).Msg("upload comment attachment error") } } } c.Redirect(http.StatusSeeOther, "/tickets/"+ticket.ID.String()) } // verifyTicketOwnership validates ticket access and returns the ticket and repo. func (h *TicketHandler) verifyTicketOwnership(c *gin.Context) (*models.Ticket, *models.Repo, bool) { user := auth.CurrentUser(c) ticketID, err := uuid.Parse(c.Param("id")) if err != nil { h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid ticket ID") return nil, nil, false } var ticket models.Ticket if err := h.deps.DB.First(&ticket, "id = ?", ticketID).Error; err != nil { h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusNotFound, "Ticket not found") return nil, nil, false } if ticket.UserID != user.ID { h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusForbidden, "Access denied") return nil, nil, false } var repo models.Repo h.deps.DB.First(&repo, "id = ?", ticket.RepoID) return &ticket, &repo, true } // proxyAssetDownload fetches an asset from Forgejo API and streams it to the client. func (h *TicketHandler) proxyAssetDownload(c *gin.Context, assetURL, filename string) { // First, resolve the actual download URL from the API metadata endpoint. downloadURL, err := h.deps.ForgejoClient.GetAttachmentURL(assetURL) if err != nil { log.Error().Err(err).Str("url", assetURL).Msg("failed to resolve attachment download URL") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadGateway, "Failed to download file") return } resp, err := h.deps.ForgejoClient.ProxyDownload(downloadURL) if err != nil { log.Error().Err(err).Str("url", downloadURL).Msg("proxy attachment download error") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadGateway, "Failed to download file") return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { h.deps.Renderer.RenderError(c.Writer, c.Request, resp.StatusCode, "Failed to download file") return } contentType := resp.Header.Get("Content-Type") if contentType == "" { contentType = "application/octet-stream" } c.Header("Content-Type", contentType) c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename})) if cl := resp.Header.Get("Content-Length"); cl != "" { c.Header("Content-Length", cl) } c.Status(http.StatusOK) io.Copy(c.Writer, resp.Body) } // DownloadIssueAttachment proxies an issue-level attachment download via Forgejo API. func (h *TicketHandler) DownloadIssueAttachment(c *gin.Context) { ticket, repo, ok := h.verifyTicketOwnership(c) if !ok { return } attachmentID := c.Param("attachmentId") filename := c.Param("filename") assetURL := h.deps.ForgejoClient.BaseURL() + "/api/v1/repos/" + repo.ForgejoOwner + "/" + repo.ForgejoRepo + "/issues/" + strconv.FormatInt(ticket.ForgejoIssueNumber, 10) + "/assets/" + attachmentID h.proxyAssetDownload(c, assetURL, filename) } // DownloadCommentAttachment proxies a comment-level attachment download via Forgejo API. func (h *TicketHandler) DownloadCommentAttachment(c *gin.Context) { ticket, repo, ok := h.verifyTicketOwnership(c) if !ok { return } commentID := c.Param("commentId") attachmentID := c.Param("attachmentId") filename := c.Param("filename") // Validate that the comment belongs to this ticket's issue to prevent IDOR commentIDInt, err := strconv.ParseInt(commentID, 10, 64) if err != nil { h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid comment ID") return } comment, err := h.deps.ForgejoClient.GetComment(repo.ForgejoOwner, repo.ForgejoRepo, commentIDInt) if err != nil { log.Error().Err(err).Int64("commentID", commentIDInt).Msg("failed to verify comment ownership") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusNotFound, "Comment not found") return } issueNumber, err := comment.IssueNumber() if err != nil || issueNumber != ticket.ForgejoIssueNumber { h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusForbidden, "Access denied") return } assetURL := h.deps.ForgejoClient.BaseURL() + "/api/v1/repos/" + repo.ForgejoOwner + "/" + repo.ForgejoRepo + "/issues/comments/" + commentID + "/assets/" + attachmentID h.proxyAssetDownload(c, assetURL, filename) }