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 }