A 250-char multipart filename was accepted and echoed verbatim in Content-Disposition. SanitizeFilename already truncates; lower the cap from 255 to 128 so DB rows and header echoes stay bounded (#248).
151 lines
4.4 KiB
Go
151 lines
4.4 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
|
|
}
|
|
|
|
// MaxFilenameLen caps stored attachment filenames (bytes) to bound DB
|
|
// rows and Content-Disposition echoes. 128 keeps names readable while
|
|
// stopping filename-bloat abuse; longer names truncate.
|
|
const MaxFilenameLen = 128
|
|
|
|
// 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[:])
|
|
}
|