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>
222 lines
6.0 KiB
Go
222 lines
6.0 KiB
Go
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)
|
|
}
|