initial commit: Artifacto v0.1
Self-hosted HTML artifact publisher. Single Go binary, SQLite metadata, HTML on disk. Features: password-protected artifacts, per-artifact expiration, admin dashboard with view metrics (privacy-preserving daily-salt visitor hash, sparklines, 30-day SVG chart, top referrers), rate-limited unlock endpoint. Packaged as a Docker image (two-stage, CGO_ENABLED=0, pure-Go SQLite). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
194
internal/store/artifacts.go
Normal file
194
internal/store/artifacts.go
Normal file
@@ -0,0 +1,194 @@
|
||||
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(5)
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user