diff --git a/db.go b/db.go index ccdf46d..5b82f3d 100644 --- a/db.go +++ b/db.go @@ -49,6 +49,21 @@ CREATE TABLE IF NOT EXISTS vote ( UNIQUE(uid, pid) ); +-- Seitenaufrufe, aggregiert pro (Tag, anonymisiertes Netz, Pfad). Es wird nie +-- eine volle IP gespeichert: anon_ip ist auf /16 (IPv4) bzw. /32 (IPv6) gekürzt +-- (siehe maskIP). asn ist das autonome System (Netzbetreiber), aufgelöst aus dem +-- bereits anonymisierten Netz (siehe lookupASN) -- nicht im PK, da pro Netz +-- deterministisch. Aggregation statt Eventlog -> die Tabelle wächst nur mit +-- Tag × Netz × Pfad, nicht pro Request. +CREATE TABLE IF NOT EXISTS impression ( + day TEXT NOT NULL, + anon_ip TEXT NOT NULL, + path TEXT NOT NULL DEFAULT '', + asn TEXT NOT NULL DEFAULT '', + hits INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (day, anon_ip, path) +); + -- Indizes für die häufigen Zugriffspfade; ohne sie werden Feed (ORDER BY -- last_activity), Thread (WHERE reply_to) und Vote-Zähler (WHERE pid) mit -- wachsender Tabelle zu Full-Table-Scans. username/session.value/vote(uid,pid) diff --git a/go.mod b/go.mod index 1a84e92..b0a7f0d 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/go-chi/chi/v5 v5.3.0 github.com/go-chi/httprate v0.15.0 + github.com/oschwald/maxminddb-golang v1.13.1 golang.org/x/crypto v0.52.0 golang.org/x/image v0.41.0 modernc.org/sqlite v1.51.0 diff --git a/go.sum b/go.sum index 2faa3f5..ef52ecb 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= @@ -16,8 +18,14 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= +github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= @@ -35,6 +43,8 @@ golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= diff --git a/main.go b/main.go index efded11..af38b8b 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,11 @@ func main() { } defer db.Close() + // Optionale GeoLite2-ASN-Datenbank für die netzwerktopologische Einordnung + // der Impressions. Inaktiv, wenn KVER_GEOIP_ASN nicht gesetzt ist. + initGeoASN() + defer closeGeoASN() + srv := &http.Server{ Addr: envOr("KVER_ADDR", ":8080"), Handler: routes(), @@ -88,15 +93,20 @@ func routes() http.Handler { r.Get("/auth/sessioninfo", handleSessionInfo) r.Get("/auth/headerbar", handleHeaderbar) r.Get("/stats", handleStats) + r.Get("/stats/detail", handleStatsDetail) + + // HTML-Seiten: mit Impression-Tracking (anonymisiert, siehe stats.go). Nur + // echte Seitenaufrufe, nicht API/Assets. + r.With(trackImpression).Get("/", serveIndex) // --- Entries --- r.Get("/entry/feed/{page}", handleFeed) r.Get("/entry/{pid}/votes", handleVotes) r.Get("/entry/{pid}/thread", handleThread) - r.Get("/e/{pid}", serveEntryPage) + r.With(trackImpression).Get("/e/{pid}", serveEntryPage) // --- User (öffentlich) --- - r.Get("/u/{username}", serveUserPage) + r.With(trackImpression).Get("/u/{username}", serveUserPage) r.Get("/u/{username}/info", handleUserPage) r.Get("/u/{username}/feed/{page}", handleUserFeed) diff --git a/stats.go b/stats.go new file mode 100644 index 0000000..3d756c2 --- /dev/null +++ b/stats.go @@ -0,0 +1,210 @@ +package main + +import ( + "fmt" + "log" + "net" + "net/http" + "time" + + "github.com/oschwald/maxminddb-golang" +) + +// geoASN ist der optionale MaxMind-GeoLite2-ASN-Reader. Nil, wenn keine DB +// konfiguriert ist (KVER_GEOIP_ASN leer) oder das Öffnen fehlschlug -> die +// ASN-Auflösung degradiert dann still zu "" und die App läuft normal weiter. +var geoASN *maxminddb.Reader + +// asnRecord deckt nur die beiden Felder der GeoLite2-ASN-DB ab, die wir brauchen. +type asnRecord struct { + Number uint `maxminddb:"autonomous_system_number"` + Org string `maxminddb:"autonomous_system_organization"` +} + +// initGeoASN öffnet die ASN-Datenbank einmalig beim Start. Fehlt der Pfad oder +// die Datei, bleibt das Feature inaktiv (kein harter Fehler). +func initGeoASN() { + path := envOr("KVER_GEOIP_ASN", "") + if path == "" { + return + } + r, err := maxminddb.Open(path) + if err != nil { + log.Printf("GeoIP-ASN deaktiviert: %v", err) + return + } + geoASN = r + log.Printf("GeoIP-ASN aktiv: %s", path) +} + +// closeGeoASN gibt den Reader frei (beim Shutdown). +func closeGeoASN() { + if geoASN != nil { + geoASN.Close() + } +} + +// clientIP zieht die IP aus r.RemoteAddr (mit oder ohne Port). Nil, wenn nicht +// parsebar. +func clientIP(remoteAddr string) net.IP { + host := remoteAddr + if h, _, err := net.SplitHostPort(remoteAddr); err == nil { + host = h + } + return net.ParseIP(host) +} + +// maskIP kürzt eine Client-IP auf das Netz: IPv4 auf /16 (erste 16 Bit), IPv6 +// auf /32 (erste 32 Bit). /32 statt /48, weil Endkunden oft ein ganzes /48 oder +// /56 zugeteilt bekommen -> ein /48 könnte einen einzelnen Anschluss +// identifizieren; /32 landet auf Provider-Ebene und ist so grob wie das IPv4- +// /16. Liefert nil bei nil. +func maskIP(ip net.IP) net.IP { + if ip == nil { + return nil + } + if v4 := ip.To4(); v4 != nil { + return v4.Mask(net.CIDRMask(16, 32)) + } + return ip.Mask(net.CIDRMask(32, 128)) +} + +// lookupASN löst das bereits anonymisierte Netz zum autonomen System auf. Die +// volle IP wird dafür bewusst NICHT verwendet -> die Auflösung ist auf +// Netzebene grob (innerhalb eines /16 mit mehreren ASNs nicht immer exakt), aber +// es verlässt nie eine vollständige IP diese Funktion. Liefert z.B. +// "AS3320 Deutsche Telekom AG" oder "" (keine DB/kein Treffer). +func lookupASN(ip net.IP) string { + if geoASN == nil || ip == nil { + return "" + } + var rec asnRecord + if err := geoASN.Lookup(ip, &rec); err != nil || rec.Number == 0 { + return "" + } + if rec.Org == "" { + return fmt.Sprintf("AS%d", rec.Number) + } + return fmt.Sprintf("AS%d %s", rec.Number, rec.Org) +} + +// trackImpression zählt einen Seitenaufruf pro (Tag, anonymisiertes Netz, Pfad) +// und vermerkt das autonome System. Die IP wird zuerst maskiert; sowohl die +// Speicherung als auch die ASN-Auflösung arbeiten nur auf dem anonymisierten +// Netz. Gezielt nur auf den HTML-Seiten-Routen, nicht auf API oder Assets. +func trackImpression(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + netw := maskIP(clientIP(r.RemoteAddr)) + anon := "" + if netw != nil { + anon = netw.String() + } + recordImpression(r.URL.Path, anon, lookupASN(netw)) + next.ServeHTTP(w, r) + }) +} + +// recordImpression erhöht den Zähler für (heute, Netz, Pfad) per Upsert. Das asn +// wird nur beim ersten Insert gesetzt (pro Netz stabil). Fehler werden bewusst +// geschluckt: ein verlorener Zählwert darf nie eine Seite stören. +func recordImpression(path, anonIP, asn string) { + if anonIP == "" { + return + } + day := time.Now().UTC().Format("2006-01-02") + db.Exec( + `INSERT INTO impression (day, anon_ip, path, asn, hits) VALUES (?, ?, ?, ?, 1) + ON CONFLICT(day, anon_ip, path) DO UPDATE SET hits = hits + 1`, + day, anonIP, path, asn, + ) +} + +// handleStatsDetail liefert die Kennzahlen für die Statistik-Seite. Anders als +// /stats (Tagline, bei jedem Aufruf) darf das hier ruhig mehrere Aggregate +// rechnen. Öffentlich, kein Auth. +func handleStatsDetail(w http.ResponseWriter, r *http.Request) { + var users, entries, toplevel, votesLeft, votesRight int64 + db.QueryRow(`SELECT COUNT(*) FROM user`).Scan(&users) + db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0`).Scan(&entries) + db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0 AND reply_to = 0`).Scan(&toplevel) + db.QueryRow(`SELECT COUNT(*) FROM vote WHERE mode = 'left'`).Scan(&votesLeft) + db.QueryRow(`SELECT COUNT(*) FROM vote WHERE mode = 'right'`).Scan(&votesRight) + + var imprTotal, visitorsTotal int64 + db.QueryRow(`SELECT COALESCE(SUM(hits), 0), COUNT(DISTINCT anon_ip) FROM impression`). + Scan(&imprTotal, &visitorsTotal) + + // Tägliche Impressions + grobe Unique-Netze, jüngste 30 Tage zuerst. Das + // Frontend dreht für die Achse auf aufsteigend. + daily := []map[string]any{} + if rows, err := db.Query( + `SELECT day, SUM(hits), COUNT(DISTINCT anon_ip) + FROM impression + GROUP BY day + ORDER BY day DESC + LIMIT 30`); err == nil { + defer rows.Close() + for rows.Next() { + var day string + var hits, visitors int64 + rows.Scan(&day, &hits, &visitors) + daily = append(daily, map[string]any{"day": day, "impressions": hits, "visitors": visitors}) + } + } + + // Beliebteste Seiten nach Impressions. + topPages := []map[string]any{} + if rows, err := db.Query( + `SELECT path, SUM(hits) AS h + FROM impression + GROUP BY path + ORDER BY h DESC + LIMIT 10`); err == nil { + defer rows.Close() + for rows.Next() { + var path string + var hits int64 + rows.Scan(&path, &hits) + topPages = append(topPages, map[string]any{"path": path, "hits": hits}) + } + } + + // Netze nach Betreiber (autonomes System). Leere asn ('') -> "(unbekannt)". + topASNs := []map[string]any{} + if rows, err := db.Query( + `SELECT CASE WHEN asn = '' THEN '(unbekannt)' ELSE asn END AS a, + SUM(hits) AS h, + COUNT(DISTINCT anon_ip) + FROM impression + GROUP BY a + ORDER BY h DESC + LIMIT 10`); err == nil { + defer rows.Close() + for rows.Next() { + var asn string + var hits, networks int64 + rows.Scan(&asn, &hits, &networks) + topASNs = append(topASNs, map[string]any{"asn": asn, "hits": hits, "networks": networks}) + } + } + + writeJSON(w, http.StatusOK, map[string]any{ + "users": users, + "entries": entries, + "toplevel": toplevel, + "replies": entries - toplevel, + "votes": map[string]any{"left": votesLeft, "right": votesRight}, + "impressions_total": imprTotal, + "visitors_total": visitorsTotal, + "daily": daily, + "top_pages": topPages, + "top_asns": topASNs, + }) +} + +// serveIndex liefert die Startseite. Eigene Route (statt FileServer-Catch-All), +// damit trackImpression nur echte Seitenaufrufe von "/" zählt und nicht jedes +// Asset. +func serveIndex(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "web/index.html") +}