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:
2026-10-27 23:53:17 +01:00
co-authored by Cursor
parent 614cb94b46
commit 6e6b13270a
13 changed files with 1154 additions and 16 deletions
+275
View File
@@ -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"})
}
+171
View File
@@ -0,0 +1,171 @@
package server
import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"task/internal/sqlite"
)
func TestAttachmentUploadListDownloadPutDelete(t *testing.T) {
tmp := t.TempDir()
db, err := sqlite.Open(filepath.Join(tmp, "t.db"), []byte("secret"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.CreateUser("admin", "pass", true); err != nil {
t.Fatal(err)
}
dataDir := filepath.Join(tmp, "data")
srv := New(db)
srv.DataDir = dataDir
handler := srv.Handler()
loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "pass"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
}
cookie := rec.Result().Cookies()[0]
createBody, _ := json.Marshal(map[string]string{"name": "Demo"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create project %d %s", rec.Code, rec.Body.String())
}
var proj map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &proj)
pid, _ := proj["id"].(string)
ticketBody, _ := json.Marshal(map[string]string{"title": "Work"})
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create ticket %d %s", rec.Code, rec.Body.String())
}
var ticket map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &ticket)
tid, _ := ticket["id"].(string)
var body bytes.Buffer
mw := multipart.NewWriter(&body)
fw, err := mw.CreateFormFile("file", "notes.txt")
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write([]byte("hello attach")); err != nil {
t.Fatal(err)
}
_ = mw.Close()
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("upload %d %s", rec.Code, rec.Body.String())
}
var att map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &att)
aid, _ := att["id"].(string)
if aid == "" {
t.Fatal("no attachment id")
}
if att["filename"] != "notes.txt" {
t.Fatalf("filename %v", att["filename"])
}
if int64(att["sizeBytes"].(float64)) != 12 {
t.Fatalf("size %v", att["sizeBytes"])
}
diskPath := filepath.Join(dataDir, "attachments", tid, aid)
if _, err := os.Stat(diskPath); err != nil {
t.Fatalf("disk file: %v", err)
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments", nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("list %d %s", rec.Code, rec.Body.String())
}
var list []map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &list)
if len(list) != 1 {
t.Fatalf("list len %d", len(list))
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments/"+aid, nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("download %d %s", rec.Code, rec.Body.String())
}
got, _ := io.ReadAll(rec.Body)
if string(got) != "hello attach" {
t.Fatalf("download body %q", got)
}
putBody := []byte("updated content!!")
req = httptest.NewRequest(http.MethodPut, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments/"+aid, bytes.NewReader(putBody))
req.Header.Set("Content-Type", "text/plain")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("put %d %s", rec.Code, rec.Body.String())
}
_ = json.Unmarshal(rec.Body.Bytes(), &att)
if int64(att["sizeBytes"].(float64)) != int64(len(putBody)) {
t.Fatalf("put size %v", att["sizeBytes"])
}
disk, err := os.ReadFile(diskPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(disk, putBody) {
t.Fatalf("disk after put %q", disk)
}
req = httptest.NewRequest(http.MethodDelete, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments/"+aid, nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("delete %d %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(diskPath); !os.IsNotExist(err) {
t.Fatalf("file should be gone: %v", err)
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets/"+tid+"/attachments", nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
_ = json.Unmarshal(rec.Body.Bytes(), &list)
if len(list) != 0 {
t.Fatalf("list after delete %d", len(list))
}
}
+22 -13
View File
@@ -13,24 +13,27 @@ import (
// Server is the HTTP API + embedded UI.
type Server struct {
DB *sqlite.DB
Projects *sqlite.ProjectStore
Tickets *sqlite.TicketStore
Times *sqlite.TimeStore
Sync *syncbackend.Registry
Secure bool // set Secure on cookies when behind TLS
Static fs.FS
DB *sqlite.DB
Projects *sqlite.ProjectStore
Tickets *sqlite.TicketStore
Times *sqlite.TimeStore
Attachments *sqlite.AttachmentStore
Sync *syncbackend.Registry
DataDir string // root for on-disk attachments (cfg.DataDir)
Secure bool // set Secure on cookies when behind TLS
Static fs.FS
}
// New constructs a server from an open DB.
func New(db *sqlite.DB) *Server {
return &Server{
DB: db,
Projects: sqlite.NewProjectStore(db),
Tickets: sqlite.NewTicketStore(db),
Times: sqlite.NewTimeStore(db),
Sync: syncbackend.NewRegistry(),
Static: webembed.FS(),
DB: db,
Projects: sqlite.NewProjectStore(db),
Tickets: sqlite.NewTicketStore(db),
Times: sqlite.NewTimeStore(db),
Attachments: sqlite.NewAttachmentStore(db),
Sync: syncbackend.NewRegistry(),
Static: webembed.FS(),
}
}
@@ -89,6 +92,12 @@ func (s *Server) routes(mux *http.ServeMux) {
api.HandleFunc("DELETE /projects/{id}/tickets/{tid}", s.requireAuth(s.handleDeleteTicket))
api.HandleFunc("POST /projects/{id}/tickets/{tid}/move", s.requireAuth(s.handleMoveTicket))
api.HandleFunc("GET /projects/{id}/tickets/{tid}/attachments", s.requireAuth(s.handleListAttachments))
api.HandleFunc("POST /projects/{id}/tickets/{tid}/attachments", s.requireAuth(s.handleUploadAttachment))
api.HandleFunc("GET /projects/{id}/tickets/{tid}/attachments/{aid}", s.requireAuth(s.handleGetAttachment))
api.HandleFunc("PUT /projects/{id}/tickets/{tid}/attachments/{aid}", s.requireAuth(s.handlePutAttachment))
api.HandleFunc("DELETE /projects/{id}/tickets/{tid}/attachments/{aid}", s.requireAuth(s.handleDeleteAttachment))
api.HandleFunc("GET /projects/{id}/tickets/{tid}/revisions", s.requireAuth(s.handleListTicketRevisions))
api.HandleFunc("POST /projects/{id}/tickets/{tid}/revisions/{rid}/restore", s.requireAuth(s.handleRestoreTicketRevision))
+1
View File
@@ -125,6 +125,7 @@ func (s *Server) handleDeleteTicket(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.removeTicketAttachmentDir(tid)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}