forgejo-tickets/internal/handlers/public/tickets.go

664 lines
20 KiB
Go

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)
statusFilter := c.Query("status")
priorityFilter := c.Query("priority")
productFilter := c.Query("product")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
const pageSize = 20
var tickets []models.Ticket
if err := h.deps.DB.Preload("Repo").Where("user_id = ?", user.ID).Order("created_at DESC").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{}{},
"StatusFilter": statusFilter,
"PriorityFilter": priorityFilter,
"ProductFilter": productFilter,
})
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)
}
// Determine Forgejo API state filter
apiState := "all"
if statusFilter == "closed" {
apiState = "closed"
} else if statusFilter == "open" || statusFilter == "in_progress" {
apiState = "open"
}
// 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, apiState, "")
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,
})
}
}
}
// Collect unique product names before filtering
productSet := map[string]bool{}
for _, v := range views {
if v.RepoName != "" {
productSet[v.RepoName] = true
}
}
var products []string
for p := range productSet {
products = append(products, p)
}
sort.Strings(products)
// Apply in-memory filters
filtered := views[:0]
for _, v := range views {
if statusFilter != "" && v.Status != statusFilter {
continue
}
if priorityFilter != "" && v.Priority != priorityFilter {
continue
}
if productFilter != "" && v.RepoName != productFilter {
continue
}
filtered = append(filtered, v)
}
views = filtered
// 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
})
// Paginate
totalPages := (len(views) + pageSize - 1) / pageSize
if totalPages < 1 {
totalPages = 1
}
if page > totalPages {
page = totalPages
}
start := (page - 1) * pageSize
end := start + pageSize
if end > len(views) {
end = len(views)
}
views = views[start:end]
h.deps.Renderer.Render(c.Writer, c.Request, "tickets/list", map[string]interface{}{
"Tickets": views,
"StatusFilter": statusFilter,
"PriorityFilter": priorityFilter,
"ProductFilter": productFilter,
"Products": products,
"Page": page,
"TotalPages": totalPages,
})
}
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)
}