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[:]) }