Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 2h ago 4b8c9e1d
history blame raw 3.8 KiB
1// Package pageview keeps daily page-view totals in the instance database.
2//
3// The table stores one row per UTC day and account, plus one row for anonymous
4// viewers. It does not record paths, referrers, addresses, or user agents: the
5// status page only needs to answer “how much is this instance being read?” and
6// “how many accounts are reading it?”. Daily aggregation keeps the database
7// small and makes retention a simple delete if that ever becomes necessary.
8package pageview
9
10import (
11 "database/sql"
12 "fmt"
13 "os"
14 "time"
15
16 _ "github.com/mattn/go-sqlite3"
17)
18
19// Count is one period's totals. Users is distinct signed-in accounts; anonymous
20// views have no account and therefore contribute to Views but not Users.
21type Count struct {
22 Views int64
23 Users int
24}
25
26// Stats is what the status page shows. Periods are inclusive of today.
27type Stats struct {
28 Today Count
29 Week Count
30 Month Count
31 UTCNow time.Time
32}
33
34type Store struct {
35 db *sql.DB
36}
37
38// Open creates the table in an existing users.db. The roster database is the
39// authoritative instance file, so page-view counters travel with a backup and do
40// not introduce another file an operator has to remember.
41func Open(path string) (*Store, error) {
42 if _, err := os.Stat(path); err != nil {
43 return nil, fmt.Errorf("pageview: %s is missing; run vole admin init", path)
44 }
45 db, err := sql.Open("sqlite3", path+"?_busy_timeout=5000&_journal_mode=WAL&_txlock=immediate")
46 if err != nil {
47 return nil, fmt.Errorf("pageview: %w", err)
48 }
49 db.SetMaxOpenConns(2)
50 if _, err := db.Exec(`
51CREATE TABLE IF NOT EXISTS page_views (
52 day TEXT NOT NULL,
53 account TEXT NOT NULL DEFAULT '',
54 views INTEGER NOT NULL DEFAULT 0,
55 PRIMARY KEY (day, account)
56);
57CREATE INDEX IF NOT EXISTS page_views_day ON page_views(day);`); err != nil {
58 db.Close()
59 return nil, fmt.Errorf("pageview: %w", err)
60 }
61 return &Store{db: db}, nil
62}
63
64func (s *Store) Close() error {
65 if s == nil {
66 return nil
67 }
68 return s.db.Close()
69}
70
71// Record adds one view. account is empty for an anonymous viewer. The UPSERT
72// makes this safe when two requests for the same account arrive together.
73func (s *Store) Record(account string, now time.Time) error {
74 day := dayKey(now)
75 _, err := s.db.Exec(`INSERT INTO page_views(day, account, views) VALUES (?, ?, 1)
76 ON CONFLICT(day, account) DO UPDATE SET views = views + 1`, day, account)
77 if err != nil {
78 return fmt.Errorf("pageview: record: %w", err)
79 }
80 return nil
81}
82
83// Stats reads today, the last seven days and the last thirty days. A user who
84// reads on three days is one unique user in the wider period, which is why the
85// query counts distinct accounts rather than summing daily user counts.
86func (s *Store) Stats(now time.Time) (Stats, error) {
87 now = now.UTC()
88 today := dayKey(now)
89 stats := Stats{UTCNow: now}
90 var err error
91 if stats.Today, err = s.count(today); err != nil {
92 return Stats{}, err
93 }
94 if stats.Week, err = s.since(dayKey(now.AddDate(0, 0, -6))); err != nil {
95 return Stats{}, err
96 }
97 if stats.Month, err = s.since(dayKey(now.AddDate(0, 0, -29))); err != nil {
98 return Stats{}, err
99 }
100 return stats, nil
101}
102
103func (s *Store) count(day string) (Count, error) {
104 return s.scan(`SELECT COALESCE(SUM(views), 0),
105 COUNT(DISTINCT CASE WHEN account <> '' THEN account END)
106 FROM page_views WHERE day = ?`, day)
107}
108
109func (s *Store) since(day string) (Count, error) {
110 return s.scan(`SELECT COALESCE(SUM(views), 0),
111 COUNT(DISTINCT CASE WHEN account <> '' THEN account END)
112 FROM page_views WHERE day >= ?`, day)
113}
114
115func (s *Store) scan(query string, arg any) (Count, error) {
116 var c Count
117 if err := s.db.QueryRow(query, arg).Scan(&c.Views, &c.Users); err != nil {
118 return Count{}, fmt.Errorf("pageview: stats: %w", err)
119 }
120 return c, nil
121}
122
123func dayKey(t time.Time) string { return t.UTC().Format("2006-01-02") }
124