Der Server rendert einmal täglich (0:01 Uhr deutscher Zeit) Liniendiagramme für Aufrufe, Netze und Beiträge pro Tag sowie gestapelte Säulen (Threads/ Antworten) für den Ø pro Wochentag und pro Tageszeit, je schmal (600) und breit (1200), ausgeliefert über /api/stats/charts. Hovern zeigt den Datenpunkt in einer Infozeile und markiert ihn mit einer senkrechten Linie. Die Statistik-Seite wird auf breiten Bildschirmen bis 1200 px breit. "Netze nach Betreiber" entfällt; neu ist die Kennzahl Ø Beiträge/Tag der letzten 30 Tage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
283 lines
9.3 KiB
Go
283 lines
9.3 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"math/big"
|
|
"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 -> wir kennen nur das maskierte
|
|
// Netz (/16 bei IPv4, /32 bei IPv6).
|
|
//
|
|
// Ein solches Netz gehört oft nicht einem einzigen AS: gerade im APNIC-Raum ist
|
|
// ein /16 in viele kleine Blöcke zerstückelt. Ein Lookup auf die Basisadresse
|
|
// liefert dann nur den (zufälligen) Inhaber von Offset 0. Stattdessen zählen wir
|
|
// per NetworksWithin alle ASNs im Netz und wählen das mit der größten
|
|
// Adress-Abdeckung; gibt es weitere, hängen wir "(n weitere Treffer)" an. So
|
|
// steht bei einem homogenen Netz schlicht der Betreiber, bei einem
|
|
// zersplitterten der dominante plus ein ehrlicher Unsicherheits-Hinweis.
|
|
//
|
|
// Liefert z.B. "AS3320 Deutsche Telekom AG", "AS8881 1&1 Versatel GmbH
|
|
// (2 weitere Treffer)" oder "" (keine DB/kein Treffer).
|
|
func lookupASN(ip net.IP) string {
|
|
if geoASN == nil || ip == nil {
|
|
return ""
|
|
}
|
|
// Das anonymisierte Netz rekonstruieren — gleiche Maske wie maskIP.
|
|
var network *net.IPNet
|
|
if v4 := ip.To4(); v4 != nil {
|
|
network = &net.IPNet{IP: v4, Mask: net.CIDRMask(16, 32)}
|
|
} else {
|
|
network = &net.IPNet{IP: ip, Mask: net.CIDRMask(32, 128)}
|
|
}
|
|
|
|
// Adress-Abdeckung je AS aufsummieren. big.Int, weil ein IPv6-Teilnetz im
|
|
// /32 bis zu 2^96 Adressen umfasst und int64 sprengen würde.
|
|
type cover struct {
|
|
org string
|
|
count *big.Int
|
|
}
|
|
covers := map[uint]*cover{}
|
|
nets := geoASN.NetworksWithin(network)
|
|
for nets.Next() {
|
|
var rec asnRecord
|
|
subnet, err := nets.Network(&rec)
|
|
if err != nil || rec.Number == 0 {
|
|
continue
|
|
}
|
|
ones, bits := subnet.Mask.Size()
|
|
size := new(big.Int).Lsh(big.NewInt(1), uint(bits-ones))
|
|
c := covers[rec.Number]
|
|
if c == nil {
|
|
c = &cover{org: rec.Org, count: new(big.Int)}
|
|
covers[rec.Number] = c
|
|
}
|
|
c.count.Add(c.count, size)
|
|
}
|
|
if len(covers) == 0 {
|
|
return ""
|
|
}
|
|
|
|
// AS mit der größten Abdeckung wählen. Bei Gleichstand das kleinere ASN,
|
|
// damit das Ergebnis bei zufälliger Map-Reihenfolge stabil bleibt.
|
|
var bestNum uint
|
|
var best *cover
|
|
for num, c := range covers {
|
|
if best == nil {
|
|
best, bestNum = c, num
|
|
continue
|
|
}
|
|
if cmp := c.count.Cmp(best.count); cmp > 0 || (cmp == 0 && num < bestNum) {
|
|
best, bestNum = c, num
|
|
}
|
|
}
|
|
|
|
label := fmt.Sprintf("AS%d", bestNum)
|
|
if best.org != "" {
|
|
label += " " + best.org
|
|
}
|
|
if extra := len(covers) - 1; extra > 0 {
|
|
label += fmt.Sprintf(" (%d weitere Treffer)", extra)
|
|
}
|
|
return label
|
|
}
|
|
|
|
// Impressions laufen über einen einzelnen Worker statt einer Goroutine pro
|
|
// Request. Das deckelt die Nebenläufigkeit (ein Flood erzeugt keine unbegrenzten
|
|
// Goroutinen mit je einem ASN-Lookup) und macht den Shutdown sicher: der Worker
|
|
// wird gestoppt, BEVOR db und geoASN geschlossen werden -- eine freilaufende
|
|
// Goroutine könnte sonst nach closeGeoASN() auf den unmap-ten Reader zugreifen
|
|
// (SIGSEGV; middleware.Recoverer fängt Panics in eigenen Goroutinen nicht).
|
|
// Bei vollem Puffer wird der Zählwert verworfen -- ein Seitenaufruf darf nie
|
|
// auf die Statistik warten.
|
|
type impressionEvent struct {
|
|
remote, path string
|
|
}
|
|
|
|
var (
|
|
impressionCh = make(chan impressionEvent, 256)
|
|
impressionDone = make(chan struct{})
|
|
)
|
|
|
|
// startImpressionWorker startet den Schreib-Worker (nach initDB/initGeoASN).
|
|
func startImpressionWorker() {
|
|
go func() {
|
|
defer close(impressionDone)
|
|
for ev := range impressionCh {
|
|
netw := maskIP(clientIP(ev.remote))
|
|
anon := ""
|
|
if netw != nil {
|
|
anon = netw.String()
|
|
}
|
|
recordImpression(ev.path, anon, lookupASN(netw))
|
|
}
|
|
}()
|
|
}
|
|
|
|
// stopImpressionWorker lässt den Worker die gepufferten Events noch schreiben
|
|
// und kehrt erst zurück, wenn er fertig ist (vor db.Close/closeGeoASN rufen).
|
|
func stopImpressionWorker() {
|
|
close(impressionCh)
|
|
<-impressionDone
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// Die Erfassung läuft asynchron über den Impression-Worker: der Seitenaufruf
|
|
// darf nicht auf den DB-Schreibvorgang warten. r ist
|
|
// nach Rückkehr des Handlers nicht mehr sicher nutzbar -> wir kopieren die zwei
|
|
// Werte vorher heraus.
|
|
func trackImpression(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
select {
|
|
case impressionCh <- impressionEvent{remote: r.RemoteAddr, path: r.URL.Path}:
|
|
default: // Puffer voll oder Worker nicht gestartet (Tests): verwerfen
|
|
}
|
|
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, $2, $3, $4, 1)
|
|
ON CONFLICT (day, anon_ip, path) DO UPDATE SET hits = impression.hits + 1`,
|
|
day, anonIP, path, asn,
|
|
)
|
|
}
|
|
|
|
// handleStats liefert die Kennzahlen für die Statuszeile unter dem Titel:
|
|
// registrierte Nutzer und nicht-gelöschte Beiträge.
|
|
func handleStats(w http.ResponseWriter, r *http.Request) error {
|
|
var users, entries int64
|
|
if err := db.QueryRow(
|
|
`SELECT (SELECT COUNT(*) FROM account), (SELECT COUNT(*) FROM entry WHERE deleted = 0)`).
|
|
Scan(&users, &entries); err != nil {
|
|
return Internal(err)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"users": users,
|
|
"entries": entries,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// handleStatsDetail liefert die Kennzahlen für die Statistik-Seite (die
|
|
// Diagramme kommen getrennt aus handleStatsCharts, charts.go). 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) error {
|
|
var users, entries, toplevel, votesYes, votesNo int64
|
|
if err := db.QueryRow(
|
|
`SELECT (SELECT COUNT(*) FROM account),
|
|
(SELECT COUNT(*) FROM entry WHERE deleted = 0),
|
|
(SELECT COUNT(*) FROM entry WHERE deleted = 0 AND reply_to = 0),
|
|
(SELECT COUNT(*) FROM vote WHERE mode = 'yes'),
|
|
(SELECT COUNT(*) FROM vote WHERE mode = 'no')`).
|
|
Scan(&users, &entries, &toplevel, &votesYes, &votesNo); err != nil {
|
|
return Internal(err)
|
|
}
|
|
|
|
var imprTotal, visitorsTotal int64
|
|
if err := db.QueryRow(`SELECT COALESCE(SUM(hits), 0)::bigint, COUNT(DISTINCT anon_ip) FROM impression`).
|
|
Scan(&imprTotal, &visitorsTotal); err != nil {
|
|
return Internal(err)
|
|
}
|
|
|
|
// Laufender Durchschnitt: Beiträge (wie in den Diagrammen einschließlich
|
|
// gelöschter) der letzten 30 vollen Tage deutscher Zeit, ohne heute.
|
|
now := time.Now().In(berlin)
|
|
midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, berlin)
|
|
var recent int64
|
|
if err := db.QueryRow(`SELECT COUNT(*) FROM entry WHERE created_at >= $1 AND created_at < $2`,
|
|
midnight.AddDate(0, 0, -30).Unix(), midnight.Unix()).Scan(&recent); err != nil {
|
|
return Internal(err)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"users": users,
|
|
"entries": entries,
|
|
"toplevel": toplevel,
|
|
"replies": entries - toplevel,
|
|
"votes": map[string]any{"yes": votesYes, "no": votesNo},
|
|
"impressions_total": imprTotal,
|
|
"visitors_total": visitorsTotal,
|
|
"entries_per_day_30": float64(recent) / 30,
|
|
})
|
|
return nil
|
|
}
|