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>
81 lines
1.8 KiB
Go
81 lines
1.8 KiB
Go
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
|
|
}
|