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>
276 lines
7.5 KiB
Go
276 lines
7.5 KiB
Go
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"})
|
|
}
|