Files
Artifacto/internal/store/artifacts.go
claudio 7de9ee2a7c Default to 8-char slugs for public-internet-safe entropy
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.
2026-04-22 23:18:36 +02:00

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")
}