aab4f12fc4
review findings: - MessageBody: only http(s) and relative paths allowed for links; data:image/* allowed for inline images. Rejects javascript:, data:text/html, vbscript: which would execute via <a href>. Unsafe matches fall back to plain text. - files.go: remove unused fileID var generated then discarded.
347 lines
8.0 KiB
Go
347 lines
8.0 KiB
Go
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)
|
|
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)
|
|
|
|
cf, err := db.CreateCardFile(cardID, actor, fname, mimeType, storedPath, source, written)
|
|
if err != nil {
|
|
os.Remove(storedPath)
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|