Files
synapbus/internal/api/attachments_handler.go
T
Algis DumbrisandClaude Opus 4.6 667b7a4c2e feat: file attachments and thread visibility (009-attachments-threads)
Web UI: paperclip button for file upload (images, PDFs, text), inline
attachment cards with file icon/name/size, image thumbnails with
fullscreen overlay, attachment display in thread panel.

Threads: always-visible reply count badges on messages, clickable to
open thread panel. reply_count and attachments enriched in all API
responses via batch queries.

MCP: attachments parameter on send_message tool, updated tool
descriptions for threading and attachment workflow guidance.

Backend: file type validation (allowlist), AttachmentLinker interface
to avoid circular deps, GetReplyCounts batch query, EnrichMessages
method on MessagingService.

Admin CLI: synapbus attachments backup/restore with tar.gz archives,
dedup-safe restore.

24 new test cases across 4 packages. All 24 test packages pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:36:21 +02:00

163 lines
5.1 KiB
Go

package api
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/synapbus/synapbus/internal/attachments"
)
// AttachmentsHandler provides REST API endpoints for attachment operations.
// These endpoints are intended for the Web UI, not for agent-to-agent use
// (agents use MCP tools instead).
type AttachmentsHandler struct {
service *attachments.Service
logger *slog.Logger
}
// NewAttachmentsHandler creates a new attachments REST handler.
func NewAttachmentsHandler(service *attachments.Service) *AttachmentsHandler {
return &AttachmentsHandler{
service: service,
logger: slog.Default().With("component", "api-attachments"),
}
}
// Download streams an attachment file to the client.
// GET /api/attachments/{hash}
func (h *AttachmentsHandler) Download(w http.ResponseWriter, r *http.Request) {
hash := chi.URLParam(r, "hash")
if hash == "" {
http.Error(w, `{"error":"hash parameter required"}`, http.StatusBadRequest)
return
}
result, err := h.service.Download(r.Context(), hash)
if err != nil {
switch err {
case attachments.ErrNotFound, attachments.ErrFileMissing:
http.Error(w, `{"error":"attachment not found"}`, http.StatusNotFound)
default:
h.logger.Error("download attachment failed", "hash", hash, "error", err)
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
}
return
}
defer result.Content.Close()
w.Header().Set("Content-Type", result.MIMEType)
if result.Size > 0 {
w.Header().Set("Content-Length", fmt.Sprintf("%d", result.Size))
}
// Images are displayed inline; everything else triggers a download.
if attachments.IsImageType(result.MIMEType) {
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", result.Filename))
} else {
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", result.Filename))
}
if _, err := streamContent(w, result.Content); err != nil {
h.logger.Error("stream attachment failed", "hash", hash, "error", err)
}
}
// Metadata returns attachment metadata as JSON.
// GET /api/attachments/{hash}/meta
func (h *AttachmentsHandler) Metadata(w http.ResponseWriter, r *http.Request) {
hash := chi.URLParam(r, "hash")
if hash == "" {
http.Error(w, `{"error":"hash parameter required"}`, http.StatusBadRequest)
return
}
result, err := h.service.Download(r.Context(), hash)
if err != nil {
switch err {
case attachments.ErrNotFound, attachments.ErrFileMissing:
http.Error(w, `{"error":"attachment not found"}`, http.StatusNotFound)
default:
h.logger.Error("get attachment metadata failed", "hash", hash, "error", err)
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
}
return
}
// Close the content reader immediately since we only need metadata.
result.Content.Close()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"hash": result.Hash,
"original_filename": result.Filename,
"mime_type": result.MIMEType,
"size": result.Size,
"is_image": attachments.IsImageType(result.MIMEType),
})
}
// Upload handles multipart file uploads from the Web UI.
// POST /api/attachments
func (h *AttachmentsHandler) Upload(w http.ResponseWriter, r *http.Request) {
// Limit request body to MaxFileSize + overhead for multipart headers.
r.Body = http.MaxBytesReader(w, r.Body, attachments.MaxFileSize+1024*1024)
if err := r.ParseMultipartForm(attachments.MaxFileSize); err != nil {
http.Error(w, `{"error":"file too large or invalid multipart form"}`, http.StatusBadRequest)
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, `{"error":"file field required"}`, http.StatusBadRequest)
return
}
defer file.Close()
// Extract uploader identity from context (set by auth middleware).
uploadedBy := "web-ui"
if ownerID, ok := OwnerIDFromContext(r.Context()); ok {
uploadedBy = fmt.Sprintf("owner-%d", ownerID)
}
req := attachments.UploadRequest{
Content: file,
Filename: header.Filename,
UploadedBy: uploadedBy,
}
result, err := h.service.Upload(r.Context(), req)
if err != nil {
switch err {
case attachments.ErrEmptyFile:
http.Error(w, `{"error":"empty file not allowed"}`, http.StatusBadRequest)
case attachments.ErrFileTooLarge:
http.Error(w, `{"error":"file exceeds maximum size of 50MB"}`, http.StatusRequestEntityTooLarge)
case attachments.ErrUnsupportedType:
http.Error(w, `{"error":"unsupported file type: only images, PDFs, and text files are allowed"}`, http.StatusBadRequest)
default:
h.logger.Error("upload attachment failed", "error", err)
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]any{
"hash": result.Hash,
"size": result.Size,
"mime_type": result.MIMEType,
"original_filename": result.Filename,
})
}
// streamContent copies the reader to the response writer.
func streamContent(dst http.ResponseWriter, src io.Reader) (int64, error) {
return io.Copy(dst, src)
}