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