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")
|
||||
}
|
||||
50
internal/store/migrations/001_init.sql
Normal file
50
internal/store/migrations/001_init.sql
Normal 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
80
internal/store/store.go
Normal 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
221
internal/store/views.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user