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:
2026-04-22 15:45:57 +02:00
commit c7d7ee287c
30 changed files with 2223 additions and 0 deletions

194
internal/store/artifacts.go Normal file
View 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")
}

View File

@@ -0,0 +1,50 @@
CREATE TABLE IF NOT EXISTS artifacts (
slug TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
size_bytes INTEGER NOT NULL,
password_hash TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER,
view_count INTEGER NOT NULL DEFAULT 0,
last_viewed_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_artifacts_created_at ON artifacts(created_at DESC);
CREATE TABLE IF NOT EXISTS views (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL,
viewed_at INTEGER NOT NULL,
visitor TEXT NOT NULL,
referrer TEXT,
FOREIGN KEY (slug) REFERENCES artifacts(slug) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_views_slug_time ON views(slug, viewed_at);
CREATE INDEX IF NOT EXISTS idx_views_time ON views(viewed_at);
CREATE TABLE IF NOT EXISTS daily_stats (
slug TEXT NOT NULL,
day TEXT NOT NULL,
views INTEGER NOT NULL DEFAULT 0,
uniques INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (slug, day),
FOREIGN KEY (slug) REFERENCES artifacts(slug) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_daily_stats_day ON daily_stats(day);
CREATE TABLE IF NOT EXISTS unlock_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL,
at INTEGER NOT NULL,
visitor TEXT NOT NULL,
success INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_unlock_attempts_slug_time ON unlock_attempts(slug, at);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);

80
internal/store/store.go Normal file
View File

@@ -0,0 +1,80 @@
package store
import (
"database/sql"
"embed"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
type Store struct {
DB *sql.DB
DataDir string
}
func Open(dataDir string) (*Store, error) {
if err := os.MkdirAll(filepath.Join(dataDir, "artifacts"), 0o755); err != nil {
return nil, fmt.Errorf("create data dir: %w", err)
}
dbPath := filepath.Join(dataDir, "artifacto.db")
dsn := "file:" + dbPath + "?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
db.SetMaxOpenConns(1)
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping db: %w", err)
}
s := &Store{DB: db, DataDir: dataDir}
if err := s.migrate(); err != nil {
return nil, fmt.Errorf("migrate: %w", err)
}
return s, nil
}
func (s *Store) Close() error { return s.DB.Close() }
func (s *Store) migrate() error {
entries, err := migrationsFS.ReadDir("migrations")
if err != nil {
return err
}
for _, e := range entries {
if e.IsDir() {
continue
}
b, err := migrationsFS.ReadFile("migrations/" + e.Name())
if err != nil {
return err
}
if _, err := s.DB.Exec(string(b)); err != nil {
return fmt.Errorf("apply %s: %w", e.Name(), err)
}
}
return nil
}
func (s *Store) ArtifactPath(slug string) string {
return filepath.Join(s.DataDir, "artifacts", slug+".html")
}
func (s *Store) MetaGet(key string) (string, error) {
var v string
err := s.DB.QueryRow(`SELECT value FROM meta WHERE key = ?`, key).Scan(&v)
if err == sql.ErrNoRows {
return "", nil
}
return v, err
}
func (s *Store) MetaSet(key, value string) error {
_, err := s.DB.Exec(`INSERT INTO meta(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, key, value)
return err
}

221
internal/store/views.go Normal file
View File

@@ -0,0 +1,221 @@
package store
import (
"fmt"
"time"
)
func (s *Store) LogView(slug, visitor, referrer string, at time.Time) error {
var ref any
if referrer != "" {
ref = referrer
}
_, err := s.DB.Exec(`
INSERT INTO views (slug, viewed_at, visitor, referrer)
VALUES (?, ?, ?, ?)
`, slug, at.Unix(), visitor, ref)
return err
}
func (s *Store) LogUnlockAttempt(slug, visitor string, success bool, at time.Time) error {
v := 0
if success {
v = 1
}
_, err := s.DB.Exec(`
INSERT INTO unlock_attempts (slug, at, visitor, success)
VALUES (?, ?, ?, ?)
`, slug, at.Unix(), visitor, v)
return err
}
// RecentFailedUnlocks counts failed unlock attempts for (slug, visitor) within the window.
func (s *Store) RecentFailedUnlocks(slug, visitor string, since time.Time) (int, error) {
var n int
err := s.DB.QueryRow(`
SELECT COUNT(*) FROM unlock_attempts
WHERE slug = ? AND visitor = ? AND success = 0 AND at >= ?
`, slug, visitor, since.Unix()).Scan(&n)
return n, err
}
// Rollup merges "views" rows from a given day into daily_stats. Idempotent: replaces existing rows for that day.
func (s *Store) Rollup(day time.Time) error {
dayStr := day.Format("2006-01-02")
start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, day.Location()).Unix()
end := start + 86400
tx, err := s.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM daily_stats WHERE day = ?`, dayStr); err != nil {
return err
}
_, err = tx.Exec(`
INSERT INTO daily_stats (slug, day, views, uniques)
SELECT slug, ?, COUNT(*), COUNT(DISTINCT visitor)
FROM views
WHERE viewed_at >= ? AND viewed_at < ?
GROUP BY slug
`, dayStr, start, end)
if err != nil {
return err
}
return tx.Commit()
}
// PruneRawViews removes view rows older than `keep` days.
func (s *Store) PruneRawViews(keep int) error {
cutoff := time.Now().AddDate(0, 0, -keep).Unix()
_, err := s.DB.Exec(`DELETE FROM views WHERE viewed_at < ?`, cutoff)
return err
}
type DashboardStats struct {
TotalArtifacts int64
Views7d int64
Views30d int64
StorageBytes int64
}
func (s *Store) Dashboard() (DashboardStats, error) {
var st DashboardStats
if err := s.DB.QueryRow(`SELECT COUNT(*), COALESCE(SUM(size_bytes),0) FROM artifacts`).
Scan(&st.TotalArtifacts, &st.StorageBytes); err != nil {
return st, err
}
since7 := time.Now().AddDate(0, 0, -7).Unix()
since30 := time.Now().AddDate(0, 0, -30).Unix()
// Combine recent "views" table + daily_stats for dates covered by either.
if err := s.DB.QueryRow(`SELECT COUNT(*) FROM views WHERE viewed_at >= ?`, since7).Scan(&st.Views7d); err != nil {
return st, err
}
var fromRollup int64
if err := s.DB.QueryRow(`
SELECT COALESCE(SUM(views),0) FROM daily_stats
WHERE day >= ?
`, time.Now().AddDate(0, 0, -30).Format("2006-01-02")).Scan(&fromRollup); err != nil {
return st, err
}
var fromRaw int64
if err := s.DB.QueryRow(`SELECT COUNT(*) FROM views WHERE viewed_at >= ?`, since30).Scan(&fromRaw); err != nil {
return st, err
}
// For 30d, prefer raw if rollup is empty (fresh install); they should mostly match since Rollup prunes raw after 30d.
if fromRaw > fromRollup {
st.Views30d = fromRaw
} else {
st.Views30d = fromRollup
}
return st, nil
}
// sparkline returns last N days of view counts (oldest → newest). Uses daily_stats then patches today from raw views.
func (s *Store) sparkline(slug string, days int) ([]int64, error) {
out := make([]int64, days)
today := time.Now()
for i := 0; i < days; i++ {
day := today.AddDate(0, 0, -(days - 1 - i))
dayStr := day.Format("2006-01-02")
var v int64
err := s.DB.QueryRow(`SELECT COALESCE(views,0) FROM daily_stats WHERE slug = ? AND day = ?`, slug, dayStr).Scan(&v)
if err != nil && err.Error() != "sql: no rows in result set" {
return nil, err
}
// Patch with raw views if rollup hasn't happened yet for this day (mainly: today).
start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, day.Location()).Unix()
end := start + 86400
var raw int64
if err := s.DB.QueryRow(`SELECT COUNT(*) FROM views WHERE slug = ? AND viewed_at >= ? AND viewed_at < ?`, slug, start, end).Scan(&raw); err != nil {
return nil, err
}
if raw > v {
v = raw
}
out[i] = v
}
return out, nil
}
type DayPoint struct {
Day string
Views int64
Unique int64
}
// SeriesFor returns a 30-day series for a single artifact, oldest → newest.
func (s *Store) SeriesFor(slug string, days int) ([]DayPoint, error) {
out := make([]DayPoint, days)
today := time.Now()
for i := 0; i < days; i++ {
day := today.AddDate(0, 0, -(days - 1 - i))
dayStr := day.Format("2006-01-02")
var dp DayPoint
dp.Day = dayStr
var v, u int64
if err := s.DB.QueryRow(`
SELECT COALESCE(views,0), COALESCE(uniques,0)
FROM daily_stats WHERE slug = ? AND day = ?
`, slug, dayStr).Scan(&v, &u); err != nil && err.Error() != "sql: no rows in result set" {
return nil, err
}
start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, day.Location()).Unix()
end := start + 86400
var raw, rawU int64
if err := s.DB.QueryRow(`
SELECT COUNT(*), COUNT(DISTINCT visitor)
FROM views WHERE slug = ? AND viewed_at >= ? AND viewed_at < ?
`, slug, start, end).Scan(&raw, &rawU); err != nil {
return nil, err
}
if raw > v {
v = raw
}
if rawU > u {
u = rawU
}
dp.Views = v
dp.Unique = u
out[i] = dp
}
return out, nil
}
type ReferrerCount struct {
Host string
Count int64
}
func (s *Store) TopReferrers(slug string, limit int) ([]ReferrerCount, error) {
rows, err := s.DB.Query(`
SELECT COALESCE(referrer, ''), COUNT(*) as n
FROM views WHERE slug = ?
GROUP BY referrer
ORDER BY n DESC
LIMIT ?
`, slug, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ReferrerCount
for rows.Next() {
var r ReferrerCount
if err := rows.Scan(&r.Host, &r.Count); err != nil {
return nil, err
}
out = append(out, r)
}
return out, nil
}
// Debug helper used in smoke tests.
func (s *Store) Stringer() string {
return fmt.Sprintf("store(%s)", s.DataDir)
}