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:
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