Add ticket file attachments with simple in-browser Excel editing.
Store uploads on disk under DataDir and expose them in the web ticket view so .xlsx files can be previewed and saved as cell values without YouTrack or TUI sync. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
const maxAttachmentBytes = 10 << 20 // 10 MiB
|
||||
|
||||
func (s *Server) attachmentsRoot() string {
|
||||
root := strings.TrimSpace(s.DataDir)
|
||||
if root == "" {
|
||||
root = "."
|
||||
}
|
||||
return filepath.Join(root, "attachments")
|
||||
}
|
||||
|
||||
func (s *Server) attachmentPath(ticketID, attachmentID string) string {
|
||||
return filepath.Join(s.attachmentsRoot(), ticketID, attachmentID)
|
||||
}
|
||||
|
||||
func (s *Server) removeTicketAttachmentDir(ticketID string) {
|
||||
_ = os.RemoveAll(filepath.Join(s.attachmentsRoot(), ticketID))
|
||||
}
|
||||
|
||||
func sanitizeFilename(name string) string {
|
||||
name = filepath.Base(strings.TrimSpace(name))
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return "file"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case unicode.IsLetter(r), unicode.IsDigit(r):
|
||||
b.WriteRune(r)
|
||||
case r == '.', r == '-', r == '_', r == ' ':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
out := strings.TrimSpace(b.String())
|
||||
if out == "" || out == "." || out == ".." {
|
||||
return "file"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) loadTicketAttachment(projectID, ticketID, attachmentID string) (sqlite.Attachment, error) {
|
||||
t, err := s.Tickets.Get(ticketID)
|
||||
if err != nil || t.ProjectID != projectID {
|
||||
return sqlite.Attachment{}, fmt.Errorf("ticket not found")
|
||||
}
|
||||
a, err := s.Attachments.Get(attachmentID)
|
||||
if err != nil || a.TicketID != ticketID || a.ProjectID != projectID {
|
||||
return sqlite.Attachment{}, fmt.Errorf("attachment not found")
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleListAttachments(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
t, err := s.Tickets.Get(tid)
|
||||
if err != nil || t.ProjectID != id {
|
||||
writeError(w, http.StatusNotFound, "ticket not found")
|
||||
return
|
||||
}
|
||||
list, err := s.Attachments.ListByTicket(tid)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
t, err := s.Tickets.Get(tid)
|
||||
if err != nil || t.ProjectID != id {
|
||||
writeError(w, http.StatusNotFound, "ticket not found")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxAttachmentBytes+512)
|
||||
if err := r.ParseMultipartForm(maxAttachmentBytes + 512); err != nil {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
|
||||
return
|
||||
}
|
||||
file, hdr, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "missing file")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if hdr.Size > maxAttachmentBytes {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
|
||||
return
|
||||
}
|
||||
|
||||
filename := sanitizeFilename(hdr.Filename)
|
||||
contentType := hdr.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
a, err := s.Attachments.Create(sqlite.Attachment{
|
||||
TicketID: tid,
|
||||
ProjectID: id,
|
||||
Filename: filename,
|
||||
ContentType: contentType,
|
||||
SizeBytes: 0,
|
||||
CreatedBy: u.ID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
dir := filepath.Join(s.attachmentsRoot(), tid)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
_ = s.Attachments.Delete(a.ID)
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
path := s.attachmentPath(tid, a.ID)
|
||||
dst, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
_ = s.Attachments.Delete(a.ID)
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
n, copyErr := io.Copy(dst, io.LimitReader(file, maxAttachmentBytes+1))
|
||||
closeErr := dst.Close()
|
||||
if copyErr != nil || closeErr != nil || n > maxAttachmentBytes {
|
||||
_ = os.Remove(path)
|
||||
_ = s.Attachments.Delete(a.ID)
|
||||
if n > maxAttachmentBytes {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
|
||||
return
|
||||
}
|
||||
if copyErr != nil {
|
||||
writeError(w, http.StatusInternalServerError, copyErr.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, closeErr.Error())
|
||||
return
|
||||
}
|
||||
_ = s.Attachments.UpdateSize(a.ID, n)
|
||||
a.SizeBytes = n
|
||||
writeJSON(w, http.StatusCreated, a)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
aid := r.PathValue("aid")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
a, err := s.loadTicketAttachment(id, tid, aid)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
path := s.attachmentPath(tid, aid)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "file missing")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", a.ContentType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, strings.ReplaceAll(a.Filename, `"`, "")))
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", st.Size()))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.Copy(w, f)
|
||||
}
|
||||
|
||||
func (s *Server) handlePutAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
aid := r.PathValue("aid")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
a, err := s.loadTicketAttachment(id, tid, aid)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxAttachmentBytes+1)
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
|
||||
return
|
||||
}
|
||||
if int64(len(data)) > maxAttachmentBytes {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "file too large (max 10 MiB)")
|
||||
return
|
||||
}
|
||||
|
||||
path := s.attachmentPath(tid, aid)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
size := int64(len(data))
|
||||
if err := s.Attachments.UpdateSize(aid, size); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
a.SizeBytes = size
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if ct != "" && ct != "application/octet-stream" {
|
||||
a.ContentType = ct
|
||||
}
|
||||
writeJSON(w, http.StatusOK, a)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
aid := r.PathValue("aid")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
if _, err := s.loadTicketAttachment(id, tid, aid); err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
_ = os.Remove(s.attachmentPath(tid, aid))
|
||||
if err := s.Attachments.Delete(aid); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
Reference in New Issue
Block a user