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:
@@ -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
|
||||
}
|
||||
@@ -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)},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user