diff --git a/backend/files.go b/backend/files.go new file mode 100644 index 0000000..015adef --- /dev/null +++ b/backend/files.go @@ -0,0 +1,351 @@ +package main + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "fn-registry/functions/infra" +) + +// Issue 0128: adjuntos de archivos por card. + +const ( + maxUploadBytes = 10 << 20 // 10 MiB + uploadsSubdir = "uploads" +) + +type CardFile struct { + ID string `json:"id"` + CardID string `json:"card_id"` + UploaderID string `json:"uploader_id"` + Filename string `json:"filename"` + MIME string `json:"mime"` + Size int64 `json:"size"` + Source string `json:"source"` + URL string `json:"url"` + CreatedAt string `json:"created_at"` +} + +func (db *DB) CreateCardFile(cardID, uploaderID, filename, mimeType, storedPath, source string, size int64) (*CardFile, error) { + id := newID() + now := nowRFC3339() + if source == "" { + source = "upload" + } + _, err := db.conn.Exec( + `INSERT INTO card_files + (id, card_id, uploader_id, filename, mime, size, stored_path, source, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, cardID, uploaderID, filename, mimeType, size, storedPath, source, now, + ) + if err != nil { + return nil, err + } + return &CardFile{ + ID: id, + CardID: cardID, + UploaderID: uploaderID, + Filename: filename, + MIME: mimeType, + Size: size, + Source: source, + URL: "/api/files/" + id, + CreatedAt: now, + }, nil +} + +func (db *DB) ListCardFiles(cardID string) ([]CardFile, error) { + rows, err := db.conn.Query( + `SELECT id, card_id, uploader_id, filename, mime, size, source, created_at + FROM card_files + WHERE card_id = ? AND deleted_at IS NULL + ORDER BY created_at ASC`, + cardID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + out := []CardFile{} + for rows.Next() { + var f CardFile + if err := rows.Scan(&f.ID, &f.CardID, &f.UploaderID, &f.Filename, &f.MIME, &f.Size, &f.Source, &f.CreatedAt); err != nil { + return nil, err + } + f.URL = "/api/files/" + f.ID + out = append(out, f) + } + return out, rows.Err() +} + +type storedCardFile struct { + ID string + CardID string + Filename string + MIME string + Size int64 + StoredPath string +} + +func (db *DB) GetCardFile(id string) (*storedCardFile, error) { + var f storedCardFile + err := db.conn.QueryRow( + `SELECT id, card_id, filename, mime, size, stored_path + FROM card_files + WHERE id = ? AND deleted_at IS NULL`, + id, + ).Scan(&f.ID, &f.CardID, &f.Filename, &f.MIME, &f.Size, &f.StoredPath) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &f, nil +} + +func (db *DB) SoftDeleteCardFile(id string) (int64, error) { + res, err := db.conn.Exec( + `UPDATE card_files SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL`, + nowRFC3339(), id, + ) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func uploadsDir(workdir string) string { + return filepath.Join(workdir, uploadsSubdir) +} + +func safeFilename(name string) string { + name = filepath.Base(name) + name = strings.ReplaceAll(name, string(os.PathSeparator), "_") + name = strings.ReplaceAll(name, "/", "_") + name = strings.ReplaceAll(name, "\\", "_") + name = strings.TrimSpace(name) + if name == "" || name == "." || name == ".." { + return "file" + } + return name +} + +func randomFilePrefix() string { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} + +// POST /api/cards/{id}/files (multipart, field "file", optional "source") +func handleUploadCardFile(db *DB, workdir string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cardID := r.PathValue("id") + if cardID == "" { + badRequest(w, "card id required") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+1<<20) + if err := r.ParseMultipartForm(maxUploadBytes); err != nil { + badRequest(w, "multipart parse: "+err.Error()) + return + } + + file, header, err := r.FormFile("file") + if err != nil { + badRequest(w, "missing 'file' field: "+err.Error()) + return + } + defer file.Close() + + if header.Size > maxUploadBytes { + infra.HTTPErrorResponse(w, infra.HTTPError{ + Status: http.StatusRequestEntityTooLarge, + Code: "file_too_large", + Message: fmt.Sprintf("file exceeds %d bytes", maxUploadBytes), + }) + return + } + + source := r.FormValue("source") + switch source { + case "", "upload": + source = "upload" + case "description", "chat": + // keep + default: + source = "upload" + } + + dir := filepath.Join(uploadsDir(workdir), cardID) + if err := os.MkdirAll(dir, 0o755); err != nil { + serverError(w, fmt.Errorf("mkdir uploads: %w", err)) + return + } + + fname := safeFilename(header.Filename) + fileID := newID() + storedPath := filepath.Join(dir, randomFilePrefix()+"__"+fname) + + out, err := os.Create(storedPath) + if err != nil { + serverError(w, fmt.Errorf("create file: %w", err)) + return + } + written, copyErr := io.Copy(out, file) + closeErr := out.Close() + if copyErr != nil { + os.Remove(storedPath) + serverError(w, fmt.Errorf("write file: %w", copyErr)) + return + } + if closeErr != nil { + os.Remove(storedPath) + serverError(w, fmt.Errorf("close file: %w", closeErr)) + return + } + if written > maxUploadBytes { + os.Remove(storedPath) + infra.HTTPErrorResponse(w, infra.HTTPError{ + Status: http.StatusRequestEntityTooLarge, + Code: "file_too_large", + Message: fmt.Sprintf("file exceeds %d bytes", maxUploadBytes), + }) + return + } + + mimeType := header.Header.Get("Content-Type") + if mimeType == "" { + mimeType = mime.TypeByExtension(filepath.Ext(fname)) + } + if mimeType == "" { + mimeType = "application/octet-stream" + } + + actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey) + + // Use the random-prefixed path on disk but a stable file id in the DB. + cf, err := db.CreateCardFile(cardID, actor, fname, mimeType, storedPath, source, written) + if err != nil { + os.Remove(storedPath) + serverError(w, err) + return + } + // We generated newID() in CreateCardFile; align the on-disk filename with that id + // is not required since stored_path is what we serve from. + _ = fileID + + infra.HTTPJSONResponse(w, http.StatusCreated, cf) + } +} + +// GET /api/cards/{id}/files +func handleListCardFiles(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cardID := r.PathValue("id") + if cardID == "" { + badRequest(w, "card id required") + return + } + files, err := db.ListCardFiles(cardID) + if err != nil { + serverError(w, err) + return + } + infra.HTTPJSONResponse(w, http.StatusOK, files) + } +} + +// GET /api/files/{id} +func handleServeFile(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + badRequest(w, "file id required") + return + } + f, err := db.GetCardFile(id) + if err != nil { + serverError(w, err) + return + } + if f == nil { + notFound(w, "file not found") + return + } + fh, err := os.Open(f.StoredPath) + if err != nil { + notFound(w, "file missing on disk") + return + } + defer fh.Close() + if f.MIME != "" { + w.Header().Set("Content-Type", f.MIME) + } + w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size)) + disposition := "inline" + if !isInlineMIME(f.MIME) { + disposition = "attachment" + } + w.Header().Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, sanitizeHeaderFilename(f.Filename))) + w.Header().Set("Cache-Control", "private, max-age=3600") + _, _ = io.Copy(w, fh) + } +} + +// DELETE /api/files/{id} +func handleDeleteCardFile(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + badRequest(w, "file id required") + return + } + n, err := db.SoftDeleteCardFile(id) + if err != nil { + serverError(w, err) + return + } + if n == 0 { + notFound(w, "file not found") + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +func isInlineMIME(m string) bool { + if m == "" { + return false + } + m = strings.ToLower(m) + switch { + case strings.HasPrefix(m, "image/"): + return true + case m == "application/pdf": + return true + case strings.HasPrefix(m, "text/"): + return true + } + return false +} + +func sanitizeHeaderFilename(name string) string { + name = strings.ReplaceAll(name, `"`, "") + name = strings.ReplaceAll(name, "\n", "") + name = strings.ReplaceAll(name, "\r", "") + return name +} diff --git a/backend/handlers.go b/backend/handlers.go index 13d74ca..34db7f8 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -629,6 +629,11 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str {Method: "GET", Path: "/api/metrics", Handler: handleMetrics(db)}, {Method: "GET", Path: "/api/tags", Handler: handleListTags(db)}, {Method: "GET", Path: "/api/requesters", Handler: handleListRequesters(db)}, + // Issue 0128: adjuntos de archivos. + {Method: "POST", Path: "/api/cards/{id}/files", Handler: handleUploadCardFile(db, chatWorkdir)}, + {Method: "GET", Path: "/api/cards/{id}/files", Handler: handleListCardFiles(db)}, + {Method: "GET", Path: "/api/files/{id}", Handler: handleServeFile(db)}, + {Method: "DELETE", Path: "/api/files/{id}", Handler: handleDeleteCardFile(db)}, } } diff --git a/backend/migrations/014_card_files.sql b/backend/migrations/014_card_files.sql new file mode 100644 index 0000000..111ad61 --- /dev/null +++ b/backend/migrations/014_card_files.sql @@ -0,0 +1,16 @@ +-- Issue 0128: adjuntos de archivos por card. +CREATE TABLE IF NOT EXISTS card_files ( + id TEXT PRIMARY KEY, + card_id TEXT NOT NULL, + uploader_id TEXT NOT NULL DEFAULT '', + filename TEXT NOT NULL, + mime TEXT NOT NULL DEFAULT '', + size INTEGER NOT NULL DEFAULT 0, + stored_path TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'upload', + created_at TEXT NOT NULL, + deleted_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_card_files_card_active + ON card_files(card_id, deleted_at);