Files
Artifacto/internal/store/views.go
dtoro c7d7ee287c 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>
2026-04-22 15:45:57 +02:00

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)
}