attachment fixes

This commit is contained in:
Matthew Knight 2026-02-14 14:11:05 -08:00
parent 0e52d7ef98
commit 210fa4ee2d
No known key found for this signature in database
5 changed files with 99 additions and 96 deletions

View File

@ -141,6 +141,7 @@ type TimelineView struct {
EventText string EventText string
CreatedAt time.Time CreatedAt time.Time
Attachments []Attachment Attachments []Attachment
CommentID int64 // needed so templates can generate comment-asset download URLs
} }
// RelatedIssue represents a cross-referenced issue with visibility info. // RelatedIssue represents a cross-referenced issue with visibility info.
@ -256,6 +257,7 @@ func BuildTimelineViews(events []TimelineEvent, botLogin string, isAdmin bool) [
IsTeam: !isCustomer, IsTeam: !isCustomer,
CreatedAt: e.CreatedAt, CreatedAt: e.CreatedAt,
Attachments: e.Assets, Attachments: e.Assets,
CommentID: e.ID,
}) })
case "close": case "close":
views = append(views, TimelineView{ views = append(views, TimelineView{

View File

@ -77,7 +77,8 @@ func NewRouter(deps Dependencies) *gin.Engine {
authenticated.POST("/tickets", ticketHandler.Create) authenticated.POST("/tickets", ticketHandler.Create)
authenticated.GET("/tickets/:id", ticketHandler.Detail) authenticated.GET("/tickets/:id", ticketHandler.Detail)
authenticated.POST("/tickets/:id/comments", ticketHandler.AddComment) authenticated.POST("/tickets/:id/comments", ticketHandler.AddComment)
authenticated.GET("/tickets/:id/attachments/:attachmentId/*filename", ticketHandler.DownloadAttachment) authenticated.GET("/tickets/:id/assets/:attachmentId/*filename", ticketHandler.DownloadIssueAttachment)
authenticated.GET("/tickets/:id/comments/:commentId/assets/:attachmentId/*filename", ticketHandler.DownloadCommentAttachment)
} }
} }

View File

@ -137,7 +137,20 @@ func (h *TicketHandler) NewForm(c *gin.Context) {
func (h *TicketHandler) Create(c *gin.Context) { func (h *TicketHandler) Create(c *gin.Context) {
user := auth.CurrentUser(c) user := auth.CurrentUser(c)
repoID, err := uuid.Parse(c.PostForm("repo_id")) // 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 { if err != nil {
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid product selection") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid product selection")
return return
@ -150,8 +163,8 @@ func (h *TicketHandler) Create(c *gin.Context) {
return return
} }
title := c.PostForm("title") title := getField("title")
description := c.PostForm("description") description := getField("description")
if title == "" || description == "" { if title == "" || description == "" {
var repos []models.Repo var repos []models.Repo
@ -198,7 +211,6 @@ func (h *TicketHandler) Create(c *gin.Context) {
} }
// Upload attachments if any // Upload attachments if any
form, _ := c.MultipartForm()
if form != nil && form.File["attachments"] != nil { if form != nil && form.File["attachments"] != nil {
for _, fh := range form.File["attachments"] { for _, fh := range form.File["attachments"] {
f, err := fh.Open() f, err := fh.Open()
@ -398,10 +410,17 @@ func (h *TicketHandler) AddComment(c *gin.Context) {
return return
} }
body := c.PostForm("body") // 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]
}
// Check if there are attachments
form, _ := c.MultipartForm()
hasAttachments := form != nil && len(form.File["attachments"]) > 0 hasAttachments := form != nil && len(form.File["attachments"]) > 0
if body == "" && !hasAttachments { if body == "" && !hasAttachments {
@ -450,119 +469,88 @@ func (h *TicketHandler) AddComment(c *gin.Context) {
c.Redirect(http.StatusSeeOther, "/tickets/"+ticket.ID.String()) c.Redirect(http.StatusSeeOther, "/tickets/"+ticket.ID.String())
} }
// DownloadAttachment proxies an attachment download from Forgejo. // verifyTicketOwnership validates ticket access and returns the ticket and repo.
func (h *TicketHandler) DownloadAttachment(c *gin.Context) { func (h *TicketHandler) verifyTicketOwnership(c *gin.Context) (*models.Ticket, *models.Repo, bool) {
user := auth.CurrentUser(c) user := auth.CurrentUser(c)
ticketID, err := uuid.Parse(c.Param("id")) ticketID, err := uuid.Parse(c.Param("id"))
if err != nil { if err != nil {
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid ticket ID") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid ticket ID")
return return nil, nil, false
} }
var ticket models.Ticket var ticket models.Ticket
if err := h.deps.DB.First(&ticket, "id = ?", ticketID).Error; err != nil { 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") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusNotFound, "Ticket not found")
return return nil, nil, false
} }
if ticket.UserID != user.ID { if ticket.UserID != user.ID {
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusForbidden, "Access denied") 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) {
resp, err := h.deps.ForgejoClient.ProxyDownload(assetURL)
if err != nil {
log.Error().Err(err).Str("url", assetURL).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", "attachment; 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 return
} }
attachmentID := c.Param("attachmentId") attachmentID := c.Param("attachmentId")
filename := c.Param("filename") filename := c.Param("filename")
var repo models.Repo assetURL := h.deps.ForgejoClient.BaseURL() + "/api/v1/repos/" + repo.ForgejoOwner + "/" + repo.ForgejoRepo + "/issues/" + strconv.FormatInt(ticket.ForgejoIssueNumber, 10) + "/assets/" + attachmentID
h.deps.DB.First(&repo, "id = ?", ticket.RepoID)
// Build the Forgejo download URL h.proxyAssetDownload(c, assetURL, filename)
downloadURL := h.deps.ForgejoClient.BaseURL() + "/attachments/" + attachmentID
resp, err := h.deps.ForgejoClient.ProxyDownload(downloadURL)
if err != nil {
log.Error().Err(err).Msg("proxy 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
}
// Forward content type and set download headers
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
c.Header("Content-Type", contentType)
c.Header("Content-Disposition", "attachment; 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)
} }
// GetIssueAttachment proxies an issue-level attachment download using the Forgejo asset API. // DownloadCommentAttachment proxies a comment-level attachment download via Forgejo API.
func (h *TicketHandler) GetIssueAttachment(c *gin.Context) { func (h *TicketHandler) DownloadCommentAttachment(c *gin.Context) {
user := auth.CurrentUser(c) _, repo, ok := h.verifyTicketOwnership(c)
if !ok {
ticketID, err := uuid.Parse(c.Param("id"))
if err != nil {
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid ticket ID")
return return
} }
var ticket models.Ticket commentID := c.Param("commentId")
if err := h.deps.DB.First(&ticket, "id = ?", ticketID).Error; err != nil { attachmentID := c.Param("attachmentId")
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
}
attachmentID, err := strconv.ParseInt(c.Param("attachmentId"), 10, 64)
if err != nil {
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid attachment ID")
return
}
filename := c.Param("filename") filename := c.Param("filename")
var repo models.Repo assetURL := h.deps.ForgejoClient.BaseURL() + "/api/v1/repos/" + repo.ForgejoOwner + "/" + repo.ForgejoRepo + "/issues/comments/" + commentID + "/assets/" + attachmentID
h.deps.DB.First(&repo, "id = ?", ticket.RepoID)
// Use the Forgejo API to get the asset h.proxyAssetDownload(c, assetURL, filename)
assetURL := h.deps.ForgejoClient.BaseURL() + "/api/v1/repos/" + repo.ForgejoOwner + "/" + repo.ForgejoRepo + "/issues/" + strconv.FormatInt(ticket.ForgejoIssueNumber, 10) + "/assets/" + strconv.FormatInt(attachmentID, 10)
resp, err := h.deps.ForgejoClient.ProxyDownload(assetURL)
if err != nil {
log.Error().Err(err).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", "attachment; 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)
} }

View File

@ -15,6 +15,17 @@
background-color: var(--color-blue-100); background-color: var(--color-blue-100);
} }
/* Make inline code visually distinct */
.prose :where(code):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
background-color: var(--color-gray-100);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
}
.prose :where(code):not(:where([class~="not-prose"], [class~="not-prose"] *))::before,
.prose :where(code):not(:where([class~="not-prose"], [class~="not-prose"] *))::after {
content: none;
}
/* Task list checkbox styling */ /* Task list checkbox styling */
.prose input[type="checkbox"] { .prose input[type="checkbox"] {
margin-right: 0.375rem; margin-right: 0.375rem;

View File

@ -47,7 +47,7 @@
<h3 class="text-sm font-medium text-gray-700 mb-2">Attachments</h3> <h3 class="text-sm font-medium text-gray-700 mb-2">Attachments</h3>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{{range .Ticket.Attachments}} {{range .Ticket.Attachments}}
<a href="/tickets/{{$.Data.Ticket.ID}}/attachments/{{.ID}}/{{.Name}}" class="inline-flex items-center gap-1 rounded-md bg-gray-100 px-2.5 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200"> <a href="/tickets/{{$.Data.Ticket.ID}}/assets/{{.ID}}/{{.Name}}" class="inline-flex items-center gap-1 rounded-md bg-gray-100 px-2.5 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200">
{{.Name}} <span class="text-gray-400">({{.Size}} bytes)</span> {{.Name}} <span class="text-gray-400">({{.Size}} bytes)</span>
</a> </a>
{{end}} {{end}}
@ -92,9 +92,10 @@
</div> </div>
<div class="text-sm text-gray-700 prose prose-sm max-w-none">{{renderMarkdown .Body $.Data.Mentions}}</div> <div class="text-sm text-gray-700 prose prose-sm max-w-none">{{renderMarkdown .Body $.Data.Mentions}}</div>
{{if .Attachments}} {{if .Attachments}}
{{$commentID := .CommentID}}
<div class="mt-2 flex flex-wrap gap-2"> <div class="mt-2 flex flex-wrap gap-2">
{{range .Attachments}} {{range .Attachments}}
<a href="/tickets/{{$.Data.Ticket.ID}}/attachments/{{.ID}}/{{.Name}}" class="inline-flex items-center gap-1 rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-200"> <a href="/tickets/{{$.Data.Ticket.ID}}/comments/{{$commentID}}/assets/{{.ID}}/{{.Name}}" class="inline-flex items-center gap-1 rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-200">
{{.Name}} {{.Name}}
</a> </a>
{{end}} {{end}}