attachment fixes
This commit is contained in:
parent
0e52d7ef98
commit
210fa4ee2d
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
// DownloadCommentAttachment proxies a comment-level attachment download via Forgejo API.
|
||||||
h.deps.Renderer.RenderError(c.Writer, c.Request, resp.StatusCode, "Failed to download file")
|
func (h *TicketHandler) DownloadCommentAttachment(c *gin.Context) {
|
||||||
|
_, repo, ok := h.verifyTicketOwnership(c)
|
||||||
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward content type and set download headers
|
commentID := c.Param("commentId")
|
||||||
contentType := resp.Header.Get("Content-Type")
|
attachmentID := c.Param("attachmentId")
|
||||||
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.
|
|
||||||
func (h *TicketHandler) GetIssueAttachment(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
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue