feat(backend): card file attachments (issue 0128)

- migration 014_card_files: tabla con soft-delete + index activo
- handlers POST/GET/DELETE en backend/files.go
- routes /api/cards/{id}/files, /api/files/{id}
- limite 10MB, storage en uploads/<card_id>/<random>__<safe>
This commit is contained in:
2026-05-27 10:51:52 +02:00
parent 1923fd31a4
commit 2401eb5abc
3 changed files with 372 additions and 0 deletions
+351
View File
@@ -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
}
+5
View File
@@ -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)},
}
}
+16
View File
@@ -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);