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 @@ +

Attachments

+
+ + Max 10 MiB +
+ {#if excelAtt} + (excelAtt = null)} + onsaved={refreshAttachments} + /> + {/if} + +

Time

+ +
+ + + {#if loading} +

Loading spreadsheet…

+ {:else if workbook} +
+ {#each sheetNames as name} + + {/each} +
+
+ + +
+
+ + + {#each grid as row, ri} + + + {#each row as cell, ci} + + {/each} + + {/each} + +
{ri + 1} + setCell(ri, ci, e.currentTarget.value)} + aria-label={`R${ri + 1}C${ci + 1}`} + /> +
+
+ {/if} + diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 5a2837b..cea225e 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -1,9 +1,29 @@ async function request(path, opts = {}) { + const headers = { ...(opts.headers || {}) } + if (opts.body != null && !(opts.body instanceof FormData) && !headers['Content-Type']) { + headers['Content-Type'] = 'application/json' + } const res = await fetch(`/api/v1${path}`, { credentials: 'include', - headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) }, ...opts, + headers, }) + if (opts.raw) { + if (!res.ok) { + const text = await res.text() + let data = null + try { + data = text ? JSON.parse(text) : null + } catch { + data = { error: text } + } + const err = new Error((data && data.error) || res.statusText) + err.status = res.status + err.data = data + throw err + } + return res + } const text = await res.text() let data = null try { @@ -95,4 +115,33 @@ export const api = { createUser: (body) => request('/users', { method: 'POST', body: JSON.stringify(body) }), patchUser: (id, body) => request(`/users/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), deleteUser: (id) => request(`/users/${id}`, { method: 'DELETE' }), + + listAttachments: (projectId, tid) => + request(`/projects/${projectId}/tickets/${tid}/attachments`), + uploadAttachment: (projectId, tid, file) => { + const fd = new FormData() + fd.append('file', file) + return request(`/projects/${projectId}/tickets/${tid}/attachments`, { + method: 'POST', + body: fd, + }) + }, + downloadAttachment: async (projectId, tid, aid) => { + const res = await request(`/projects/${projectId}/tickets/${tid}/attachments/${aid}`, { + raw: true, + }) + return res.arrayBuffer() + }, + downloadAttachmentUrl: (projectId, tid, aid) => + `/api/v1/projects/${projectId}/tickets/${tid}/attachments/${aid}`, + putAttachment: (projectId, tid, aid, body, { contentType } = {}) => + request(`/projects/${projectId}/tickets/${tid}/attachments/${aid}`, { + method: 'PUT', + headers: contentType ? { 'Content-Type': contentType } : {}, + body, + }), + deleteAttachment: (projectId, tid, aid) => + request(`/projects/${projectId}/tickets/${tid}/attachments/${aid}`, { + method: 'DELETE', + }), }