- Add Delete/DeleteKeys/KeyFromURL methods to S3Storage - DeleteAttachment handler now removes the S3 object after DB delete - DeleteComment collects attachment URLs before CASCADE, then cleans S3 - DeleteIssue collects all attachment URLs (issue + comment level) before CASCADE, then cleans S3 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
318 lines
9.3 KiB
Go
318 lines
9.3 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
const maxUploadSize = 10 << 20 // 10 MB
|
|
|
|
// Allowed MIME type prefixes and exact types for uploads.
|
|
var allowedContentTypes = map[string]bool{
|
|
"image/png": true,
|
|
"image/jpeg": true,
|
|
"image/gif": true,
|
|
"image/webp": true,
|
|
"image/svg+xml": true,
|
|
"application/pdf": true,
|
|
"text/plain": true,
|
|
"text/csv": true,
|
|
"application/json": true,
|
|
"video/mp4": true,
|
|
"video/webm": true,
|
|
"audio/mpeg": true,
|
|
"audio/wav": true,
|
|
"application/zip": true,
|
|
}
|
|
|
|
func isContentTypeAllowed(ct string) bool {
|
|
// Normalize: take only the media type, strip parameters like charset.
|
|
ct = strings.TrimSpace(strings.SplitN(ct, ";", 2)[0])
|
|
ct = strings.ToLower(ct)
|
|
return allowedContentTypes[ct]
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Response types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type AttachmentResponse struct {
|
|
ID string `json:"id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
IssueID *string `json:"issue_id"`
|
|
CommentID *string `json:"comment_id"`
|
|
UploaderType string `json:"uploader_type"`
|
|
UploaderID string `json:"uploader_id"`
|
|
Filename string `json:"filename"`
|
|
URL string `json:"url"`
|
|
DownloadURL string `json:"download_url"`
|
|
ContentType string `json:"content_type"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
func (h *Handler) attachmentToResponse(a db.Attachment) AttachmentResponse {
|
|
resp := AttachmentResponse{
|
|
ID: uuidToString(a.ID),
|
|
WorkspaceID: uuidToString(a.WorkspaceID),
|
|
UploaderType: a.UploaderType,
|
|
UploaderID: uuidToString(a.UploaderID),
|
|
Filename: a.Filename,
|
|
URL: a.Url,
|
|
DownloadURL: a.Url,
|
|
ContentType: a.ContentType,
|
|
SizeBytes: a.SizeBytes,
|
|
CreatedAt: a.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
|
|
}
|
|
if h.CFSigner != nil {
|
|
resp.DownloadURL = h.CFSigner.SignedURL(a.Url, time.Now().Add(5*time.Minute))
|
|
}
|
|
if a.IssueID.Valid {
|
|
s := uuidToString(a.IssueID)
|
|
resp.IssueID = &s
|
|
}
|
|
if a.CommentID.Valid {
|
|
s := uuidToString(a.CommentID)
|
|
resp.CommentID = &s
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// groupAttachments loads attachments for multiple comments and groups them by comment ID.
|
|
func (h *Handler) groupAttachments(r *http.Request, commentIDs []pgtype.UUID) map[string][]AttachmentResponse {
|
|
if len(commentIDs) == 0 {
|
|
return nil
|
|
}
|
|
attachments, err := h.Queries.ListAttachmentsByCommentIDs(r.Context(), commentIDs)
|
|
if err != nil {
|
|
slog.Error("failed to load attachments for comments", "error", err)
|
|
return nil
|
|
}
|
|
grouped := make(map[string][]AttachmentResponse, len(commentIDs))
|
|
for _, a := range attachments {
|
|
cid := uuidToString(a.CommentID)
|
|
grouped[cid] = append(grouped[cid], h.attachmentToResponse(a))
|
|
}
|
|
return grouped
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// UploadFile — POST /api/upload-file
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
|
|
if h.Storage == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "file upload not configured")
|
|
return
|
|
}
|
|
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
workspaceID := resolveWorkspaceID(r)
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
|
|
|
|
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
|
writeError(w, http.StatusBadRequest, "file too large or invalid multipart form")
|
|
return
|
|
}
|
|
defer r.MultipartForm.RemoveAll()
|
|
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("missing file field: %v", err))
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// Sniff actual content type from file bytes instead of trusting the client header.
|
|
buf := make([]byte, 512)
|
|
n, err := file.Read(buf)
|
|
if err != nil && err != io.EOF {
|
|
writeError(w, http.StatusBadRequest, "failed to read file")
|
|
return
|
|
}
|
|
contentType := http.DetectContentType(buf[:n])
|
|
if !isContentTypeAllowed(contentType) {
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("file type not allowed: %s", contentType))
|
|
return
|
|
}
|
|
// Seek back so the full file is uploaded.
|
|
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to read file")
|
|
return
|
|
}
|
|
|
|
data, err := io.ReadAll(file)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "failed to read file")
|
|
return
|
|
}
|
|
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
slog.Error("failed to generate file key", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
key := hex.EncodeToString(b) + path.Ext(header.Filename)
|
|
|
|
link, err := h.Storage.Upload(r.Context(), key, data, contentType, header.Filename)
|
|
if err != nil {
|
|
slog.Error("file upload failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "upload failed")
|
|
return
|
|
}
|
|
|
|
// If workspace context is available, create an attachment record.
|
|
if workspaceID != "" {
|
|
uploaderType, uploaderID := h.resolveActor(r, userID, workspaceID)
|
|
|
|
params := db.CreateAttachmentParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
UploaderType: uploaderType,
|
|
UploaderID: parseUUID(uploaderID),
|
|
Filename: header.Filename,
|
|
Url: link,
|
|
ContentType: contentType,
|
|
SizeBytes: int64(len(data)),
|
|
}
|
|
|
|
// Optional issue_id / comment_id from form fields
|
|
if issueID := r.FormValue("issue_id"); issueID != "" {
|
|
params.IssueID = parseUUID(issueID)
|
|
}
|
|
if commentID := r.FormValue("comment_id"); commentID != "" {
|
|
params.CommentID = parseUUID(commentID)
|
|
}
|
|
|
|
att, err := h.Queries.CreateAttachment(r.Context(), params)
|
|
if err != nil {
|
|
slog.Error("failed to create attachment record", "error", err)
|
|
// S3 upload succeeded but DB record failed — still return the link
|
|
// so the file is usable. Log the error for investigation.
|
|
} else {
|
|
writeJSON(w, http.StatusOK, h.attachmentToResponse(att))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Fallback response (no workspace context, e.g. avatar upload)
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
"filename": header.Filename,
|
|
"link": link,
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ListAttachments — GET /api/issues/{id}/attachments
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (h *Handler) ListAttachments(w http.ResponseWriter, r *http.Request) {
|
|
issueID := chi.URLParam(r, "id")
|
|
issue, ok := h.loadIssueForUser(w, r, issueID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
attachments, err := h.Queries.ListAttachmentsByIssue(r.Context(), db.ListAttachmentsByIssueParams{
|
|
IssueID: issue.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
})
|
|
if err != nil {
|
|
slog.Error("failed to list attachments", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to list attachments")
|
|
return
|
|
}
|
|
|
|
resp := make([]AttachmentResponse, len(attachments))
|
|
for i, a := range attachments {
|
|
resp[i] = h.attachmentToResponse(a)
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DeleteAttachment — DELETE /api/attachments/{id}
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (h *Handler) DeleteAttachment(w http.ResponseWriter, r *http.Request) {
|
|
attachmentID := chi.URLParam(r, "id")
|
|
workspaceID := resolveWorkspaceID(r)
|
|
if workspaceID == "" {
|
|
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
|
return
|
|
}
|
|
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
att, err := h.Queries.GetAttachment(r.Context(), db.GetAttachmentParams{
|
|
ID: parseUUID(attachmentID),
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "attachment not found")
|
|
return
|
|
}
|
|
|
|
// Only the uploader (or workspace admin) can delete
|
|
uploaderID := uuidToString(att.UploaderID)
|
|
isUploader := att.UploaderType == "member" && uploaderID == userID
|
|
member, hasMember := ctxMember(r.Context())
|
|
isAdmin := hasMember && (member.Role == "admin" || member.Role == "owner")
|
|
|
|
if !isUploader && !isAdmin {
|
|
writeError(w, http.StatusForbidden, "not authorized to delete this attachment")
|
|
return
|
|
}
|
|
|
|
if err := h.Queries.DeleteAttachment(r.Context(), db.DeleteAttachmentParams{
|
|
ID: att.ID,
|
|
WorkspaceID: att.WorkspaceID,
|
|
}); err != nil {
|
|
slog.Error("failed to delete attachment", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to delete attachment")
|
|
return
|
|
}
|
|
|
|
h.deleteS3Object(r.Context(), att.Url)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// deleteS3Object removes a single file from S3 by its CDN URL.
|
|
func (h *Handler) deleteS3Object(ctx context.Context, url string) {
|
|
if h.Storage == nil || url == "" {
|
|
return
|
|
}
|
|
h.Storage.Delete(ctx, h.Storage.KeyFromURL(url))
|
|
}
|
|
|
|
// deleteS3Objects removes multiple files from S3 by their CDN URLs.
|
|
func (h *Handler) deleteS3Objects(ctx context.Context, urls []string) {
|
|
if h.Storage == nil || len(urls) == 0 {
|
|
return
|
|
}
|
|
keys := make([]string, len(urls))
|
|
for i, u := range urls {
|
|
keys[i] = h.Storage.KeyFromURL(u)
|
|
}
|
|
h.Storage.DeleteKeys(ctx, keys)
|
|
}
|