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"}) }