Files
palette/internal/store/attachment.go
T
fen 4948ef9f1c
CI / test (pull_request) Successful in 26s
CI / docker (pull_request) Skipped
#38 iteration 1: file attachments, 1 file per paste
- internal/store/blob.go: BlobStore interface + fs implementation with
  traversal-safe keys (<paste-id>/<sha256>), put/get/stat/delete
- attachments table migration (id, paste_id, filename sanitized to 255,
  mime sniffed server-side, size, sha256, created_at)
- POST /api/pastes now accepts multipart/form-data with a 'file' part;
  1 file = 1 paste: file replaces text content when both are sent
- 25 MB per-file limit enforced server-side (413 file_too_large)
- GET /f/{attachment-id}/{filename}: stored sniffed mime, nosniff,
  inline only for images/pdf, html/svg/xml forced to text/plain (#34 rule)
- paste view renders attachment chip + inline image preview
- /new: dropzone with file picker, drag-and-drop, Ctrl+V file paste,
  file chip with name/size/remove, matches pill/radius design
- tests: blob roundtrip/traversal/sanitize; multipart create (mime
  sniffing, client mime ignored, size limit, two-file reject, html/svg
  forcing, 404s, password/expiry fields)
2026-09-09 22:21:15 -05:00

148 lines
4.2 KiB
Go

package store
import (
"crypto/sha256"
"database/sql"
"errors"
"fmt"
"path/filepath"
"strings"
"time"
)
// #38: file attachments, one file per paste (iteration 1). A paste either
// has text content OR one attached file. Bytes live in the BlobStore; this
// table carries the metadata.
type Attachment struct {
ID string `json:"id"`
PasteID string `json:"paste_id"`
Filename string `json:"filename"`
Mime string `json:"mime"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
CreatedAt int64 `json:"created_at"`
SizeHuman string `json:"-"` // template-only: human-readable size
}
const MaxFilenameLen = 255
// ErrFileTooLarge is returned when an attachment exceeds the per-file cap.
var ErrFileTooLarge = errors.New("file too large")
// SanitizeFilename cleans a user-supplied filename: strips directory
// components, control chars, and caps at 255 bytes. The extension is never
// trusted for mime decisions (mime is sniffed server-side).
func SanitizeFilename(name string) string {
name = filepath.Base(strings.ReplaceAll(name, "\\", "/"))
name = strings.TrimSpace(name)
var b strings.Builder
for _, r := range name {
if r < 32 || r == 127 {
continue
}
b.WriteRune(r)
}
name = b.String()
if len(name) > MaxFilenameLen {
runes := []rune(name)
for len(string(runes)) > MaxFilenameLen {
runes = runes[:len(runes)-1]
}
name = string(runes)
}
if name == "" || name == "." || name == ".." {
name = "file"
}
return name
}
// CreateAttachment stores the file bytes and inserts the attachments row.
// The mime MUST already be sniffed server-side (http.DetectContentType by
// the caller); it is never taken from the client.
func (s *Store) CreateAttachment(a *Attachment, r interface{ Read([]byte) (int, error) }, blobs BlobStore) error {
pasteID := a.PasteID
sha, size, err := blobs.Put(pasteID+"/pending", r)
if err != nil {
return err
}
if a.Size > 0 && size > a.Size {
// caller-provided pre-check limit; treat as too large
blobs.Delete(pasteID + "/" + sha)
return ErrFileTooLarge
}
a.SHA256 = sha
a.Size = size
now := time.Now().Unix()
id := genSlug(20)
_, err = s.db.Exec(`INSERT INTO attachments (id, paste_id, filename, mime, size, sha256, created_at)
VALUES (?,?,?,?,?,?,?)`, id, pasteID, a.Filename, a.Mime, size, sha, now)
if err != nil {
blobs.Delete(pasteID + "/" + sha)
return err
}
a.ID = id
a.CreatedAt = now
return nil
}
// GetAttachment returns the attachment row for a paste, or nil.
// One file per paste (iteration 1), so a single-row lookup keyed on paste.
func (s *Store) GetAttachmentForPaste(pasteID string) (*Attachment, error) {
row := s.db.QueryRow(`SELECT id, paste_id, filename, mime, size, sha256, created_at
FROM attachments WHERE paste_id = ? ORDER BY created_at ASC LIMIT 1`, pasteID)
return scanAttachment(row)
}
func (s *Store) GetAttachment(id string) (*Attachment, error) {
row := s.db.QueryRow(`SELECT id, paste_id, filename, mime, size, sha256, created_at
FROM attachments WHERE id = ?`, id)
return scanAttachment(row)
}
func scanAttachment(row *sql.Row) (*Attachment, error) {
var a Attachment
err := row.Scan(&a.ID, &a.PasteID, &a.Filename, &a.Mime, &a.Size, &a.SHA256, &a.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
a.SizeHuman = humanBytes(a.Size)
return &a, nil
}
// humanBytes formats a byte count for display (KB/MB).
func humanBytes(n int64) string {
switch {
case n >= 1<<20:
return fmt.Sprintf("%.1f MB", float64(n)/(1<<20))
case n >= 1<<10:
return fmt.Sprintf("%.1f KB", float64(n)/(1<<10))
default:
return fmt.Sprintf("%d B", n)
}
}
// DeleteAttachment removes the row and its blob.
func (s *Store) DeleteAttachment(a *Attachment, blobs BlobStore) error {
blobs.Delete(a.PasteID + "/" + a.SHA256)
_, err := s.db.Exec(`DELETE FROM attachments WHERE id = ?`, a.ID)
return err
}
// HasAttachment reports whether a paste already carries a file (#38: one file per paste).
func (s *Store) HasAttachment(pasteID string) bool {
var n int
s.db.QueryRow(`SELECT COUNT(*) FROM attachments WHERE paste_id = ?`, pasteID).Scan(&n)
return n > 0
}
// HashBytes is a small helper used by handlers to name/verify blobs.
func HashBytes(b []byte) string {
h := sha256.Sum256(b)
return string(h[:])
}