#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)
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
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[:])
|
||||
}
|
||||
Reference in New Issue
Block a user