Statistik: anonymes Impression-Tracking mit ASN-Einordnung
Seitenaufrufe werden serverseitig pro (Tag, anonymisiertes Netz, Pfad) gezaehlt -- nur auf den HTML-Seiten-Routen, nicht auf API/Assets. Die Client-IP wird sofort maskiert (IPv4 /16, IPv6 /32); die vollstaendige IP wird nie gespeichert oder weiterverwendet. Optional loest eine lokale GeoLite2-ASN-DB (Env KVER_GEOIP_ASN) den Netzbetreiber aus dem bereits anonymisierten Netz auf -- auch dafuer wird nicht die volle IP herangezogen. Fehlt die DB, bleibt das Feld leer. Neuer Endpunkt GET /stats/detail liefert die Aggregate; die Tabelle impression speichert nur Zaehlwerte (kein Eventlog). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,21 @@ CREATE TABLE IF NOT EXISTS vote (
|
|||||||
UNIQUE(uid, pid)
|
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
|
-- 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
|
-- 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)
|
-- wachsender Tabelle zu Full-Table-Scans. username/session.value/vote(uid,pid)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/go-chi/chi/v5 v5.3.0
|
github.com/go-chi/chi/v5 v5.3.0
|
||||||
github.com/go-chi/httprate v0.15.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/crypto v0.52.0
|
||||||
golang.org/x/image v0.41.0
|
golang.org/x/image v0.41.0
|
||||||
modernc.org/sqlite v1.51.0
|
modernc.org/sqlite v1.51.0
|
||||||
|
|||||||
@@ -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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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=
|
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/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
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/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 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
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 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
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{
|
srv := &http.Server{
|
||||||
Addr: envOr("KVER_ADDR", ":8080"),
|
Addr: envOr("KVER_ADDR", ":8080"),
|
||||||
Handler: routes(),
|
Handler: routes(),
|
||||||
@@ -88,15 +93,20 @@ func routes() http.Handler {
|
|||||||
r.Get("/auth/sessioninfo", handleSessionInfo)
|
r.Get("/auth/sessioninfo", handleSessionInfo)
|
||||||
r.Get("/auth/headerbar", handleHeaderbar)
|
r.Get("/auth/headerbar", handleHeaderbar)
|
||||||
r.Get("/stats", handleStats)
|
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 ---
|
// --- Entries ---
|
||||||
r.Get("/entry/feed/{page}", handleFeed)
|
r.Get("/entry/feed/{page}", handleFeed)
|
||||||
r.Get("/entry/{pid}/votes", handleVotes)
|
r.Get("/entry/{pid}/votes", handleVotes)
|
||||||
r.Get("/entry/{pid}/thread", handleThread)
|
r.Get("/entry/{pid}/thread", handleThread)
|
||||||
r.Get("/e/{pid}", serveEntryPage)
|
r.With(trackImpression).Get("/e/{pid}", serveEntryPage)
|
||||||
|
|
||||||
// --- User (öffentlich) ---
|
// --- User (öffentlich) ---
|
||||||
r.Get("/u/{username}", serveUserPage)
|
r.With(trackImpression).Get("/u/{username}", serveUserPage)
|
||||||
r.Get("/u/{username}/info", handleUserPage)
|
r.Get("/u/{username}/info", handleUserPage)
|
||||||
r.Get("/u/{username}/feed/{page}", handleUserFeed)
|
r.Get("/u/{username}/feed/{page}", handleUserFeed)
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user