diff --git a/cmd/tm-server/main.go b/cmd/tm-server/main.go
index 4eb3603..2ebc75b 100644
--- a/cmd/tm-server/main.go
+++ b/cmd/tm-server/main.go
@@ -97,6 +97,7 @@ func runServer(listen, dbPath string, secure bool) error {
return err
}
srv := server.New(db)
+ srv.DataDir = cfg.DataDir
srv.Secure = secure
if strings.EqualFold(os.Getenv("TM_SEED_DEMO"), "1") ||
strings.EqualFold(os.Getenv("TM_SEED_DEMO"), "true") {
diff --git a/internal/server/attachment_handlers.go b/internal/server/attachment_handlers.go
new file mode 100644
index 0000000..c8f7eeb
--- /dev/null
+++ b/internal/server/attachment_handlers.go
@@ -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"})
+}
diff --git a/internal/server/attachment_handlers_test.go b/internal/server/attachment_handlers_test.go
new file mode 100644
index 0000000..ddbb870
--- /dev/null
+++ b/internal/server/attachment_handlers_test.go
@@ -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))
+ }
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index 48f02b5..2091df3 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -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))
diff --git a/internal/server/ticket_handlers.go b/internal/server/ticket_handlers.go
index 12f24fd..3cf6294 100644
--- a/internal/server/ticket_handlers.go
+++ b/internal/server/ticket_handlers.go
@@ -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"})
}
diff --git a/internal/sqlite/attachments.go b/internal/sqlite/attachments.go
new file mode 100644
index 0000000..781a179
--- /dev/null
+++ b/internal/sqlite/attachments.go
@@ -0,0 +1,131 @@
+package sqlite
+
+import (
+ "database/sql"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// Attachment is metadata for a file attached to a ticket.
+type Attachment struct {
+ ID string `json:"id"`
+ TicketID string `json:"ticketId"`
+ ProjectID string `json:"projectId"`
+ Filename string `json:"filename"`
+ ContentType string `json:"contentType"`
+ SizeBytes int64 `json:"sizeBytes"`
+ CreatedBy string `json:"createdBy"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+
+// AttachmentStore persists attachment metadata.
+type AttachmentStore struct {
+ db *DB
+}
+
+// NewAttachmentStore returns an attachment metadata store.
+func NewAttachmentStore(db *DB) *AttachmentStore {
+ return &AttachmentStore{db: db}
+}
+
+const attachmentCols = `id, ticket_id, project_id, filename, content_type, size_bytes, created_by, created_at`
+
+func scanAttachment(scanner interface{ Scan(dest ...any) error }) (Attachment, error) {
+ var a Attachment
+ var created string
+ err := scanner.Scan(
+ &a.ID, &a.TicketID, &a.ProjectID, &a.Filename, &a.ContentType,
+ &a.SizeBytes, &a.CreatedBy, &created,
+ )
+ if err != nil {
+ return Attachment{}, err
+ }
+ a.CreatedAt = parseTime(created)
+ return a, nil
+}
+
+// ListByTicket returns attachments for a ticket, newest first.
+func (s *AttachmentStore) ListByTicket(ticketID string) ([]Attachment, error) {
+ rows, err := s.db.SQL.Query(
+ `SELECT `+attachmentCols+` FROM ticket_attachments WHERE ticket_id = ? ORDER BY created_at DESC`,
+ ticketID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []Attachment
+ for rows.Next() {
+ a, err := scanAttachment(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, a)
+ }
+ if out == nil {
+ out = []Attachment{}
+ }
+ return out, rows.Err()
+}
+
+// Get returns one attachment by id.
+func (s *AttachmentStore) Get(id string) (Attachment, error) {
+ row := s.db.SQL.QueryRow(`SELECT `+attachmentCols+` FROM ticket_attachments WHERE id = ?`, id)
+ a, err := scanAttachment(row)
+ if err == sql.ErrNoRows {
+ return Attachment{}, fmt.Errorf("attachment not found")
+ }
+ return a, err
+}
+
+// Create inserts attachment metadata.
+func (s *AttachmentStore) Create(a Attachment) (Attachment, error) {
+ if a.ID == "" {
+ a.ID = uuid.NewString()
+ }
+ if a.Filename == "" {
+ return Attachment{}, fmt.Errorf("filename required")
+ }
+ if a.ContentType == "" {
+ a.ContentType = "application/octet-stream"
+ }
+ if a.CreatedAt.IsZero() {
+ a.CreatedAt = time.Now().UTC()
+ }
+ _, err := s.db.SQL.Exec(
+ `INSERT INTO ticket_attachments (`+attachmentCols+`) VALUES (?,?,?,?,?,?,?,?)`,
+ a.ID, a.TicketID, a.ProjectID, a.Filename, a.ContentType, a.SizeBytes, a.CreatedBy, formatTime(a.CreatedAt),
+ )
+ if err != nil {
+ return Attachment{}, err
+ }
+ return a, nil
+}
+
+// UpdateSize updates size after a replace/overwrite.
+func (s *AttachmentStore) UpdateSize(id string, sizeBytes int64) error {
+ res, err := s.db.SQL.Exec(`UPDATE ticket_attachments SET size_bytes = ? WHERE id = ?`, sizeBytes, id)
+ if err != nil {
+ return err
+ }
+ n, _ := res.RowsAffected()
+ if n == 0 {
+ return fmt.Errorf("attachment not found")
+ }
+ return nil
+}
+
+// Delete removes attachment metadata.
+func (s *AttachmentStore) Delete(id string) error {
+ res, err := s.db.SQL.Exec(`DELETE FROM ticket_attachments WHERE id = ?`, id)
+ if err != nil {
+ return err
+ }
+ n, _ := res.RowsAffected()
+ if n == 0 {
+ return fmt.Errorf("attachment not found")
+ }
+ return nil
+}
diff --git a/internal/sqlite/migrations/004_attachments.sql b/internal/sqlite/migrations/004_attachments.sql
new file mode 100644
index 0000000..e5ee117
--- /dev/null
+++ b/internal/sqlite/migrations/004_attachments.sql
@@ -0,0 +1,12 @@
+CREATE TABLE IF NOT EXISTS ticket_attachments (
+ id TEXT PRIMARY KEY,
+ ticket_id TEXT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
+ filename TEXT NOT NULL,
+ content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
+ size_bytes INTEGER NOT NULL,
+ created_by TEXT NOT NULL REFERENCES users(id),
+ created_at TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_attachments_ticket ON ticket_attachments(ticket_id);
diff --git a/web/package-lock.json b/web/package-lock.json
index f94dd2c..8d629bb 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -10,7 +10,8 @@
"dependencies": {
"marked": "^18.1.2",
"turndown": "^7.2.4",
- "turndown-plugin-gfm": "^1.0.2"
+ "turndown-plugin-gfm": "^1.0.2",
+ "xlsx": "^0.18.5"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.1",
@@ -414,6 +415,15 @@
"node": ">=0.4.0"
}
},
+ "node_modules/adler-32": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
+ "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/aria-query": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
@@ -434,6 +444,19 @@
"node": ">= 0.4"
}
},
+ "node_modules/cfb": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
+ "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "adler-32": "~1.3.0",
+ "crc-32": "~1.2.0"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -444,6 +467,27 @@
"node": ">=6"
}
},
+ "node_modules/codepage": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
+ "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/crc-32": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
+ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
+ "license": "Apache-2.0",
+ "bin": {
+ "crc32": "bin/crc32.njs"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -514,6 +558,15 @@
}
}
},
+ "node_modules/frac": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
+ "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -967,6 +1020,18 @@
"node": ">=0.10.0"
}
},
+ "node_modules/ssf": {
+ "version": "0.11.2",
+ "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
+ "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "frac": "~1.1.2"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/svelte": {
"version": "5.57.3",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.3.tgz",
@@ -1138,6 +1203,45 @@
}
}
},
+ "node_modules/wmf": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
+ "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/word": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
+ "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/xlsx": {
+ "version": "0.18.5",
+ "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
+ "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "adler-32": "~1.3.0",
+ "cfb": "~1.2.1",
+ "codepage": "~1.15.0",
+ "crc-32": "~1.2.1",
+ "ssf": "~0.11.2",
+ "wmf": "~1.0.1",
+ "word": "~0.3.0"
+ },
+ "bin": {
+ "xlsx": "bin/xlsx.njs"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/zimmerframe": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz",
diff --git a/web/package.json b/web/package.json
index c191b9f..bbaa660 100644
--- a/web/package.json
+++ b/web/package.json
@@ -16,6 +16,7 @@
"dependencies": {
"marked": "^18.1.2",
"turndown": "^7.2.4",
- "turndown-plugin-gfm": "^1.0.2"
+ "turndown-plugin-gfm": "^1.0.2",
+ "xlsx": "^0.18.5"
}
}
diff --git a/web/src/App.svelte b/web/src/App.svelte
index 6eb236a..62a02b8 100644
--- a/web/src/App.svelte
+++ b/web/src/App.svelte
@@ -3,6 +3,7 @@
import { api } from './lib/api.js'
import { parseRoute, buildHash, currentHash } from './lib/route.js'
import MarkdownEditor from './lib/MarkdownEditor.svelte'
+ import ExcelEditor from './lib/ExcelEditor.svelte'
let user = $state(null)
let loading = $state(true)
@@ -57,6 +58,9 @@
let dragTicketId = $state('')
let dragOverStatus = $state('')
let suppressTicketClick = false
+ let attachments = $state([])
+ let excelAtt = $state(null)
+ let attachBusy = $state(false)
const boardStatuses = [
['todo', 'OPEN'],
@@ -377,15 +381,57 @@
editTitle = t.title
editDesc = t.description || ''
editStatus = t.status
+ excelAtt = null
cancelEditTime()
revisions = await api.listRevisions(projectId, t.id)
const all = await api.listTime(projectId)
ticketTime = all.filter((e) => e.ticketId === t.id)
+ attachments = await api.listAttachments(projectId, t.id)
await refreshTimer()
topTab = 'projects'
projectView = 'ticket'
}
+ function formatBytes(n) {
+ if (n < 1024) return `${n} B`
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`
+ }
+
+ function isExcelName(name) {
+ return /\.xlsx$/i.test(name || '')
+ }
+
+ async function onAttachFile(e) {
+ const file = e.target?.files?.[0]
+ e.target.value = ''
+ if (!file || !selected) return
+ attachBusy = true
+ error = ''
+ try {
+ await api.uploadAttachment(projectId, selected.id, file)
+ attachments = await api.listAttachments(projectId, selected.id)
+ msg = 'Uploaded'
+ } catch (err) {
+ error = err.message
+ } finally {
+ attachBusy = false
+ }
+ }
+
+ async function removeAttachment(a) {
+ if (!selected || !confirm(`Delete ${a.filename}?`)) return
+ await api.deleteAttachment(projectId, selected.id, a.id)
+ if (excelAtt?.id === a.id) excelAtt = null
+ attachments = await api.listAttachments(projectId, selected.id)
+ msg = 'Attachment deleted'
+ }
+
+ async function refreshAttachments() {
+ if (!selected) return
+ attachments = await api.listAttachments(projectId, selected.id)
+ }
+
async function openTicket(t) {
await showTicket(t)
syncRoute()
@@ -892,6 +938,47 @@