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