5-char slugs over a 32-char alphabet are ~25 bits; brute-forceable at HTTP speeds once the endpoint is public. 8 chars = 40 bits (~10^12 combinations), infeasible to scan within useful timescales. Existing 5-char slugs still resolve; only new artifacts use the longer form.
195 lines
4.3 KiB
Go
195 lines
4.3 KiB
Go
package store
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Crockford base32 without I, L, O, U.
|
|
const slugAlphabet = "0123456789abcdefghjkmnpqrstvwxyz"
|
|
|
|
var ErrNotFound = errors.New("artifact not found")
|
|
|
|
type Artifact struct {
|
|
Slug string
|
|
Title string
|
|
SizeBytes int64
|
|
HasPassword bool
|
|
PasswordHash string
|
|
CreatedAt time.Time
|
|
ExpiresAt *time.Time
|
|
ViewCount int64
|
|
LastViewedAt *time.Time
|
|
}
|
|
|
|
type CreateOptions struct {
|
|
Slug string // may be empty
|
|
Title string
|
|
Body []byte
|
|
PasswordHash string
|
|
ExpiresAt *time.Time
|
|
}
|
|
|
|
func (s *Store) CreateArtifact(opts CreateOptions) (*Artifact, error) {
|
|
slug := strings.ToLower(strings.TrimSpace(opts.Slug))
|
|
if slug == "" {
|
|
g, err := s.generateSlug(8)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
slug = g
|
|
} else {
|
|
if existing, _ := s.GetArtifact(slug); existing != nil {
|
|
return nil, fmt.Errorf("slug already in use: %s", slug)
|
|
}
|
|
}
|
|
|
|
path := s.ArtifactPath(slug)
|
|
if err := os.WriteFile(path, opts.Body, 0o644); err != nil {
|
|
return nil, fmt.Errorf("write artifact: %w", err)
|
|
}
|
|
|
|
now := time.Now().Unix()
|
|
var expires sql.NullInt64
|
|
if opts.ExpiresAt != nil {
|
|
expires = sql.NullInt64{Int64: opts.ExpiresAt.Unix(), Valid: true}
|
|
}
|
|
var pwh sql.NullString
|
|
if opts.PasswordHash != "" {
|
|
pwh = sql.NullString{String: opts.PasswordHash, Valid: true}
|
|
}
|
|
|
|
_, err := s.DB.Exec(`
|
|
INSERT INTO artifacts (slug, title, size_bytes, password_hash, created_at, expires_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
`, slug, opts.Title, int64(len(opts.Body)), pwh, now, expires)
|
|
if err != nil {
|
|
_ = os.Remove(path)
|
|
return nil, fmt.Errorf("insert artifact: %w", err)
|
|
}
|
|
|
|
return s.GetArtifact(slug)
|
|
}
|
|
|
|
func (s *Store) GetArtifact(slug string) (*Artifact, error) {
|
|
row := s.DB.QueryRow(`
|
|
SELECT slug, title, size_bytes, password_hash, created_at, expires_at, view_count, last_viewed_at
|
|
FROM artifacts WHERE slug = ?
|
|
`, slug)
|
|
return scanArtifact(row)
|
|
}
|
|
|
|
func (s *Store) DeleteArtifact(slug string) error {
|
|
_, err := s.DB.Exec(`DELETE FROM artifacts WHERE slug = ?`, slug)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_ = os.Remove(s.ArtifactPath(slug))
|
|
return nil
|
|
}
|
|
|
|
type ListItem struct {
|
|
Artifact
|
|
Spark []int64 // 7-day recent views (oldest → newest)
|
|
}
|
|
|
|
func (s *Store) ListArtifacts() ([]ListItem, error) {
|
|
rows, err := s.DB.Query(`
|
|
SELECT slug, title, size_bytes, password_hash, created_at, expires_at, view_count, last_viewed_at
|
|
FROM artifacts
|
|
ORDER BY created_at DESC
|
|
`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []ListItem
|
|
for rows.Next() {
|
|
a, err := scanArtifact(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, ListItem{Artifact: *a})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Fill sparklines in one query scan.
|
|
for i := range out {
|
|
spark, err := s.sparkline(out[i].Slug, 7)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[i].Spark = spark
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Store) TouchView(slug string, at time.Time) error {
|
|
_, err := s.DB.Exec(`
|
|
UPDATE artifacts
|
|
SET view_count = view_count + 1, last_viewed_at = ?
|
|
WHERE slug = ?
|
|
`, at.Unix(), slug)
|
|
return err
|
|
}
|
|
|
|
type rowScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanArtifact(r rowScanner) (*Artifact, error) {
|
|
var a Artifact
|
|
var pwh sql.NullString
|
|
var expires sql.NullInt64
|
|
var lastView sql.NullInt64
|
|
var created int64
|
|
err := r.Scan(&a.Slug, &a.Title, &a.SizeBytes, &pwh, &created, &expires, &a.ViewCount, &lastView)
|
|
if err == sql.ErrNoRows {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
a.CreatedAt = time.Unix(created, 0)
|
|
if pwh.Valid {
|
|
a.PasswordHash = pwh.String
|
|
a.HasPassword = true
|
|
}
|
|
if expires.Valid {
|
|
t := time.Unix(expires.Int64, 0)
|
|
a.ExpiresAt = &t
|
|
}
|
|
if lastView.Valid {
|
|
t := time.Unix(lastView.Int64, 0)
|
|
a.LastViewedAt = &t
|
|
}
|
|
return &a, nil
|
|
}
|
|
|
|
func (s *Store) generateSlug(n int) (string, error) {
|
|
buf := make([]byte, n)
|
|
for attempt := 0; attempt < 10; attempt++ {
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
slug := make([]byte, n)
|
|
for i, b := range buf {
|
|
slug[i] = slugAlphabet[int(b)%len(slugAlphabet)]
|
|
}
|
|
candidate := string(slug)
|
|
existing, _ := s.GetArtifact(candidate)
|
|
if existing == nil {
|
|
return candidate, nil
|
|
}
|
|
}
|
|
return "", errors.New("could not generate unique slug")
|
|
}
|