Compare commits

...
9 Commits
Author SHA1 Message Date
irrlichtandClaude Opus 5.5 07a2b64eff Statistik: tägliche SVG-Diagramme statt CSS-Balken
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
2026-10-06 20:13:30 +02:00
irrlichtandClaude Opus 5.5 047ad392d9 Datenschutz: Anonymisierung nicht absolut versprechen
Mit Zusatzwissen ließ sich ein Netz aus der Reichweitenmessung einer
Person zuordnen. Die Erklärung sagt deshalb nur noch, dass einzelne
Personen in der Regel nicht bestimmbar sind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-10-06 19:37:18 +02:00
irrlichtandClaude Opus 5.5 26b0182391 Linkvorschau für den ersten Link eines Beitrags
Der Server holt zum ersten Link Titel, Seiten- bzw. Kanalname und ein
kleines Vorschaubild (320 px) und speichert das Bild lokal; der Browser
lädt nur von uns, Besucher-IPs gehen nie an die verlinkte Seite.
YouTube über oEmbed (alle Linkformen kanonisch als watch?v=<id>), alle
übrigen Seiten über OpenGraph bzw. <title>. Abruf asynchron in einem
Worker, eine Zeile je Link in link_preview, Auffrischen nach 30 Tagen.

Der Abruf-Client lässt nur öffentliche IPs auf Port 80/443 zu, geprüft
nach DNS-Auflösung und bei jeder Weiterleitung (SSRF). Bestehende
Beiträge: kver link-previews. Neue Abhängigkeit golang.org/x/net, damit
go 1.26. DEV bekommt ein beschreibbares Volume für die Vorschaubilder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-10-06 18:47:21 +02:00
irrlichtandClaude Opus 5.5 a8f392edba Plan Android-App (notes/android.md)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-10-06 18:47:21 +02:00
irrlichtandClaude Opus 5.5 764ad50b8e Passwort-Reset: Review-Fixes
- Token vor bcrypt prüfen, damit beliebige Tokens keine CPU-Last erzeugen
- CLI meldet DB-Fehler nicht mehr als "Nutzer nicht gefunden"
- Reset in einem Browser mit fremder Session räumt diese mit ab
- Konto-Löschen entfernt auch offene Reset-Links

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:44:32 +02:00
irrlichtandClaude Opus 5.5 81a74375b7 Passwort-Reset per Einmal-Link (kver reset-link)
Ohne E-Mail-System erzeugt der Admin per CLI einen Link (/reset#<token>),
gültig 72 h und einmal. Gespeichert wird nur der SHA-256 des Tokens; es
steht im Fragment und geht nur im POST-Body an die API. Der Reset meldet
alle alten Sessions ab und loggt direkt neu ein. Passwortregeln jetzt
gemeinsam in checkNewPassword.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:56:05 +02:00
irrlichtandClaude Opus 5.5 b6cf8c7fa0 Abstimmung Ja/Nein statt Links/Rechts
Modus-Werte yes/no statt left/right in DB, API und Statistik; Knöpfe
"Ja"/"Nein". Bestehende Stimmen müssen per SQL migriert werden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-29 18:14:57 +02:00
irrlichtandClaude Opus 5.5 3d2447fe35 Melden rechts in der Byline jeder Karte, auch anonym
- Melden-Knopf in der Byline aller Beiträge (außer eigenen), Grund-Formular
  darunter; ersetzt den Knopf unter dem fokussierten Beitrag
- /api/entry/{pid}/report öffentlich, 10/min pro IP; ohne Session anonym
  (uid 0), Doppelmeldungen nur für Angemeldete unterdrückt
- Klicks in Formularen öffnen die Karte nicht mehr

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-29 18:05:54 +02:00
irrlichtandClaude Opus 5.5 80c333cd58 Melden-Button: Beiträge mit optionalem Grund melden
- report-Tabelle wieder im Schema (kompatibel zur bestehenden auf PROD)
- POST /api/entry/{pid}/report, nur angemeldet, speichert Melder-uid;
  erneutes Melden legt keine zweite offene Meldung an
- "Melden" auf der Beitragsseite für fremde, nicht gelöschte Beiträge
- Test und API-Doku

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-29 17:53:10 +02:00
31 changed files with 2686 additions and 216 deletions
+17 -12
View File
@@ -172,18 +172,7 @@ func handleNewUser(w http.ResponseWriter, r *http.Request) error {
}
pass1 := r.FormValue("pass1")
pass2 := r.FormValue("pass2")
switch {
case len(pass1) < 10:
fields["pass1"] = "Das Passwort ist kürzer als 10 Zeichen."
// bcrypt verarbeitet maximal 72 Bytes; GenerateFromPassword gäbe darüber
// einen Fehler -> hier abfangen statt als generischer 500 zu enden.
case len(pass1) > 72:
fields["pass1"] = "Das Passwort ist länger als 72 Bytes."
}
if pass1 != pass2 {
fields["pass2"] = "Passwort und Passwortbestätigung sind ungleich."
}
checkNewPassword(pass1, r.FormValue("pass2"), fields)
if len(fields) > 0 {
e := Invalid("user.invalid", "Die Angaben sind unvollständig oder ungültig.")
@@ -204,6 +193,22 @@ func handleNewUser(w http.ResponseWriter, r *http.Request) error {
return nil
}
// checkNewPassword trägt Fehler zu einem neuen Passwort (pass1) und seiner
// Wiederholung (pass2) in fields ein. Gemeinsam für Registrierung und Reset.
func checkNewPassword(pass1, pass2 string, fields map[string]string) {
switch {
case len(pass1) < 10:
fields["pass1"] = "Das Passwort ist kürzer als 10 Zeichen."
// bcrypt verarbeitet maximal 72 Bytes; GenerateFromPassword gäbe darüber
// einen Fehler -> hier abfangen statt als generischer 500 zu enden.
case len(pass1) > 72:
fields["pass1"] = "Das Passwort ist länger als 72 Bytes."
}
if pass1 != pass2 {
fields["pass2"] = "Passwort und Passwortbestätigung sind ungleich."
}
}
func handleSessionInfo(w http.ResponseWriter, r *http.Request) error {
s, ok := getSession(r)
if !ok {
+566
View File
@@ -0,0 +1,566 @@
package main
import (
"fmt"
"html"
"log"
"math"
"net/http"
"strconv"
"strings"
"sync"
"time"
_ "time/tzdata" // Alpine-Image hat keine Zonendaten
)
// Diagramme der Statistik-Seite. Sie werden einmal am Tag (kurz nach
// Mitternacht, deutsche Zeit) als fertiges SVG gerendert und im Speicher
// gehalten; ein Seitenaufruf liefert nur noch die Strings aus. Das SVG nutzt
// ausschließlich Präsentationsattribute (stroke, fill ...) und kein style-
// Attribut -> inline eingebettet von der strikten CSP nicht betroffen, und die
// Schrift erbt es von der Seite.
//
// Der laufende Tag fehlt bewusst: er wäre bis zum nächsten Rendern immer nur
// ein halber Tag und würde die Kurven am rechten Rand abknicken lassen.
var berlin = mustLoadLocation("Europe/Berlin")
func mustLoadLocation(name string) *time.Location {
loc, err := time.LoadLocation(name)
if err != nil {
panic(err)
}
return loc
}
// StatsCharts ist die Antwort von /api/stats/charts. Jedes Diagramm gibt es
// schmal (Handy, schmale Fenster) und breit (Widescreen); welches sichtbar ist,
// entscheidet das CSS. So bleibt die Schrift in beiden Fällen etwa 1:1.
type StatsCharts struct {
Generated int64 `json:"generated"`
Charts map[string]ChartPair `json:"charts"`
}
type ChartPair struct {
Narrow string `json:"narrow"`
Wide string `json:"wide"`
}
// both rendert ein Diagramm in beiden Größen.
func both(render func(sz size) string) ChartPair {
return ChartPair{Narrow: render(narrow), Wide: render(wide)}
}
var (
chartMu sync.RWMutex
chartCache StatsCharts
chartStop = make(chan struct{})
chartDone = make(chan struct{})
)
// startChartWorker rendert sofort und danach jede Nacht neu. Schlägt das
// Rendern fehl, bleibt der alte Stand stehen und es gibt nach 15 Minuten einen
// neuen Versuch.
func startChartWorker() {
go func() {
defer close(chartDone)
for {
wait := 15 * time.Minute
if err := renderCharts(time.Now()); err != nil {
log.Printf("statistik-diagramme: %v", err)
} else {
wait = time.Until(nextChartRun(time.Now()))
}
select {
case <-chartStop:
return
case <-time.After(wait):
}
}
}()
}
// stopChartWorker wartet ein laufendes Rendern ab (vor db.Close rufen).
func stopChartWorker() {
close(chartStop)
<-chartDone
}
// nextChartRun ist eine Minute nach der nächsten Mitternacht in Berlin.
func nextChartRun(now time.Time) time.Time {
y, m, d := now.In(berlin).Date()
return time.Date(y, m, d+1, 0, 1, 0, 0, berlin)
}
func renderCharts(now time.Time) error {
impr, err := collectImpressionStats(now)
if err != nil {
return err
}
ent, err := collectEntryStats(now)
if err != nil {
return err
}
set := StatsCharts{
Generated: now.Unix(),
Charts: map[string]ChartPair{
"impressions": both(func(sz size) string {
return lineChart(sz, "Aufrufe pro Tag", impr.days, impr.hits, "Aufrufe")
}),
"networks": both(func(sz size) string {
return lineChart(sz, "Netze pro Tag", impr.days, impr.networks, "Netze")
}),
"entries": both(func(sz size) string {
return lineChart(sz, "Beiträge pro Tag", ent.days, ent.perDay, "Beiträge")
}),
"weekday": both(func(sz size) string {
return stackedBars(sz, "Beiträge pro Wochentag", weekdayNames[:], weekdayLong[:], ent.weekday)
}),
"hour": both(func(sz size) string {
return stackedBars(sz, "Beiträge pro Tageszeit", hourLabels(), hourRanges(), ent.hour)
}),
},
}
chartMu.Lock()
chartCache = set
chartMu.Unlock()
return nil
}
// handleStatsCharts liefert die fertigen SVGs. Ist noch nichts gerendert
// (Start läuft noch, Tests ohne Worker), wird einmalig direkt gerendert.
func handleStatsCharts(w http.ResponseWriter, r *http.Request) error {
chartMu.RLock()
set := chartCache
chartMu.RUnlock()
if set.Generated == 0 {
if err := renderCharts(time.Now()); err != nil {
return Internal(err)
}
chartMu.RLock()
set = chartCache
chartMu.RUnlock()
}
w.Header().Set("Cache-Control", "public, max-age=600")
writeJSON(w, http.StatusOK, set)
return nil
}
// --- Daten ---
type impressionStats struct {
days []time.Time // lückenlos, ohne heute
hits, networks []float64
}
// collectImpressionStats summiert Aufrufe und verschiedene Netze je Tag. Die
// Impression-Tage sind UTC-Daten (siehe recordImpression).
func collectImpressionStats(now time.Time) (impressionStats, error) {
var s impressionStats
today := civilDate(now.UTC())
rows, err := db.Query(
`SELECT day, SUM(hits)::bigint, COUNT(DISTINCT anon_ip)
FROM impression
WHERE day < $1
GROUP BY day`, today.Format(time.DateOnly))
if err != nil {
return s, err
}
defer rows.Close()
type counts struct{ hits, networks int64 }
byDay := map[time.Time]counts{}
var first time.Time
for rows.Next() {
var day string
var c counts
if err := rows.Scan(&day, &c.hits, &c.networks); err != nil {
return s, err
}
d, err := time.Parse(time.DateOnly, day)
if err != nil {
continue
}
byDay[d] = c
if first.IsZero() || d.Before(first) {
first = d
}
}
if err := rows.Err(); err != nil {
return s, err
}
if first.IsZero() {
return s, nil
}
s.days = dayRange(first, today)
for _, d := range s.days {
c := byDay[d]
s.hits = append(s.hits, float64(c.hits))
s.networks = append(s.networks, float64(c.networks))
}
return s, nil
}
// Reihenfolge der Stapel: Threads unten, Antworten darüber.
var postKinds = [2]string{"Threads", "Antworten"}
type entryStats struct {
days []time.Time // erster Beitragstag bis gestern (deutsche Zeit)
perDay []float64
weekday [][2]float64 // Mo..So, Ø Beiträge je solchem Tag
hour [][2]float64 // 0..23 Uhr, Ø Beiträge je Tag
}
// collectEntryStats zählt alle Beiträge einschließlich gelöschter (created_at
// bleibt beim Löschen erhalten). Tage, Wochentage und Stunden gelten in
// deutscher Zeit. Wochentag und Stunde sind Durchschnitte: Summe geteilt durch
// die Zahl der Montage, Dienstage ... bzw. aller Tage im Zeitraum.
func collectEntryStats(now time.Time) (entryStats, error) {
var s entryStats
today := civilDate(now.In(berlin))
midnight := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, berlin)
rows, err := db.Query(
`SELECT created_at, reply_to <> 0 FROM entry WHERE created_at > 0 AND created_at < $1`,
midnight.Unix())
if err != nil {
return s, err
}
defer rows.Close()
perDay := map[time.Time]float64{}
var weekdaySum [7][2]float64
var hourSum [24][2]float64
var first time.Time
for rows.Next() {
var created int64
var reply bool
if err := rows.Scan(&created, &reply); err != nil {
return s, err
}
t := time.Unix(created, 0).In(berlin)
d := civilDate(t)
kind := 0
if reply {
kind = 1
}
perDay[d]++
weekdaySum[isoWeekday(d)][kind]++
hourSum[t.Hour()][kind]++
if first.IsZero() || d.Before(first) {
first = d
}
}
if err := rows.Err(); err != nil {
return s, err
}
s.weekday = make([][2]float64, 7)
s.hour = make([][2]float64, 24)
if first.IsZero() {
return s, nil
}
s.days = dayRange(first, today)
var weekdayCount [7]float64
for _, d := range s.days {
s.perDay = append(s.perDay, perDay[d])
weekdayCount[isoWeekday(d)]++
}
for i := range s.weekday {
for k := range postKinds {
if weekdayCount[i] > 0 {
s.weekday[i][k] = weekdaySum[i][k] / weekdayCount[i]
}
}
}
n := float64(len(s.days))
for i := range s.hour {
for k := range postKinds {
s.hour[i][k] = hourSum[i][k] / n
}
}
return s, nil
}
// civilDate ist das Kalenderdatum von t (in dessen Zone) als UTC-Mitternacht.
// Gerechnet wird mit diesen Werten, damit Sommerzeitwechsel keine 23- oder
// 25-Stunden-Tage in die Tagesfolge bringen.
func civilDate(t time.Time) time.Time {
y, m, d := t.Date()
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
}
// dayRange liefert alle Tage von first bis ausschließlich end.
func dayRange(first, end time.Time) []time.Time {
var days []time.Time
for d := first; d.Before(end); d = d.AddDate(0, 0, 1) {
days = append(days, d)
}
return days
}
// isoWeekday: Montag = 0 ... Sonntag = 6.
func isoWeekday(d time.Time) int {
return (int(d.Weekday()) + 6) % 7
}
var weekdayNames = [7]string{"Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"}
var weekdayLong = [7]string{"Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"}
// hourRanges: "9–10 Uhr" für die Infozeile.
func hourRanges() []string {
l := make([]string, 24)
for i := range l {
l[i] = fmt.Sprintf("%d–%d Uhr", i, i+1)
}
return l
}
func hourLabels() []string {
l := make([]string, 24)
for i := range l {
l[i] = strconv.Itoa(i)
}
return l
}
// --- SVG ---
// Maße im viewBox-Raum. narrow entspricht der schmalen Inhaltsspalte (600 px),
// wide der breiten Statistik-Seite (1200 px); bis dorthin wird 1:1 gezeichnet,
// darunter skaliert das SVG mit.
type size struct{ w, h int }
var (
narrow = size{600, 240}
wide = size{1200, 300}
)
const (
padLeft = 44
padRight = 12
padTop = 12
padBottom = 26
fontSize = 13
)
var (
colorGrid = "#ddd"
colorStack = [2]string{"#444", "#aaa"}
)
type svg struct{ strings.Builder }
func (b *svg) f(format string, a ...any) { fmt.Fprintf(b, format, a...) }
func svgOpen(b *svg, sz size, label string) {
b.f(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" role="img" aria-label="%s" font-size="%d" fill="black">`,
sz.w, sz.h, html.EscapeString(label), fontSize)
}
func emptyChart(sz size, label string) string {
var b svg
svgOpen(&b, sz, label)
b.f(`<text x="%d" y="%d" text-anchor="middle" fill="gray">Noch keine Daten.</text></svg>`, sz.w/2, sz.h/2)
return b.String()
}
// niceScale wählt eine runde Schrittweite (1, 2, 5 × 10^k) für etwa vier
// Gitterlinien und die darauf aufgerundete Obergrenze.
func niceScale(max float64) (step, top float64) {
if max <= 0 {
max = 1
}
raw := max / 4
mag := math.Pow(10, math.Floor(math.Log10(raw)))
step = 10 * mag
for _, m := range []float64{1, 2, 5} {
if m*mag >= raw {
step = m * mag
break
}
}
return step, math.Ceil(max/step-1e-9) * step
}
// fmtNum schreibt eine Zahl mit höchstens zwei Nachkommastellen und
// deutschem Komma.
func fmtNum(v float64) string {
return strings.Replace(strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64), ".", ",", 1)
}
// yAxis zeichnet Gitterlinien mit Beschriftung und liefert die Abbildung
// Wert -> y-Koordinate.
func yAxis(b *svg, sz size, max float64, top int) func(float64) float64 {
step, maxV := niceScale(max)
bottom := float64(sz.h - padBottom)
y := func(v float64) float64 { return bottom - v/maxV*(bottom-float64(top)) }
for v := 0.0; v <= maxV+step/2; v += step {
yy := y(v)
color := colorGrid
if v == 0 {
color = "black"
}
b.f(`<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" stroke="%s"/>`, padLeft, yy, sz.w-padRight, yy, color)
b.f(`<text x="%d" y="%.1f" text-anchor="end">%s</text>`, padLeft-6, yy+4, fmtNum(v))
}
return y
}
func maxOf(vals []float64) float64 {
m := 0.0
for _, v := range vals {
m = math.Max(m, v)
}
return m
}
// lineChart zeichnet eine Tagesreihe als Linie. Alle Tage seit Beginn werden
// auf die volle Breite gestaucht; unsichtbare Streifen je Tag tragen den
// Tooltip (<title>), denselben Text in data-tip für die Infozeile unter dem
// Diagramm (StatsPage.tsx) und eine senkrechte Markierung.
func lineChart(sz size, label string, days []time.Time, vals []float64, unit string) string {
if len(days) == 0 {
return emptyChart(sz, label)
}
var b svg
svgOpen(&b, sz, label)
y := yAxis(&b, sz, maxOf(vals), padTop)
plotW := float64(sz.w - padLeft - padRight)
n := len(days)
x := func(i int) float64 {
if n == 1 {
return padLeft + plotW/2
}
return padLeft + float64(i)*plotW/float64(n-1)
}
bottom := sz.h - padBottom
for _, i := range dateTicks(days) {
xx := x(i)
b.f(`<line x1="%.1f" y1="%d" x2="%.1f" y2="%d" stroke="black"/>`, xx, bottom, xx, bottom+4)
tx := math.Min(math.Max(xx, padLeft+16), float64(sz.w-padRight-16))
b.f(`<text x="%.1f" y="%d" text-anchor="middle">%s</text>`, tx, bottom+18, dateTickLabel(days[i]))
}
var pts strings.Builder
for i, v := range vals {
fmt.Fprintf(&pts, "%.1f,%.1f ", x(i), y(v))
}
if n == 1 {
b.f(`<circle cx="%.1f" cy="%.1f" r="3"/>`, x(0), y(vals[0]))
} else {
b.f(`<polyline points="%s" fill="none" stroke="black" stroke-width="1.5" stroke-linejoin="round"/>`,
strings.TrimSpace(pts.String()))
}
slot := plotW / float64(max(n-1, 1))
for i, d := range days {
tip := fmt.Sprintf("%s, %s: %s %s", weekdayNames[isoWeekday(d)], d.Format("02.01.2006"), fmtNum(vals[i]), unit)
// Die Markierungslinie ist versteckt; das CSS zeigt sie beim Hovern
// über den Streifen (.chart g:hover .mark).
b.f(`<g data-tip="%s"><title>%s</title><rect x="%.1f" y="%d" width="%.1f" height="%d" fill="transparent"/>`,
tip, tip, x(i)-slot/2, padTop, slot, bottom-padTop)
b.f(`<line class="mark" x1="%.1f" y1="%d" x2="%.1f" y2="%d" stroke="black" visibility="hidden" pointer-events="none"/></g>`,
x(i), padTop, x(i), bottom)
}
b.f(`</svg>`)
return b.String()
}
var monthNames = [12]string{"Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"}
// dateTicks wählt die beschrifteten Tage: Monatsanfänge, bei langen Reihen
// nur jedes Quartal, Halbjahr oder Jahr; bei Reihen unter zwei Monaten die
// Montage. Höchstens acht Beschriftungen.
func dateTicks(days []time.Time) []int {
var months []int
for i, d := range days {
if d.Day() == 1 {
months = append(months, i)
}
}
if len(months) >= 2 {
for _, step := range []int{1, 3, 6, 12} {
var picked []int
for _, i := range months {
if (int(days[i].Month())-1)%step == 0 {
picked = append(picked, i)
}
}
if len(picked) <= 8 || step == 12 {
return picked
}
}
}
var mondays []int
for i, d := range days {
if d.Weekday() == time.Monday {
mondays = append(mondays, i)
}
}
for len(mondays) > 8 {
var half []int
for j := 0; j < len(mondays); j += 2 {
half = append(half, mondays[j])
}
mondays = half
}
return mondays
}
// dateTickLabel: "02.10." bei Wochen-Ticks, sonst Monatsname; der Januar
// zeigt stattdessen die Jahreszahl.
func dateTickLabel(d time.Time) string {
if d.Day() != 1 {
return d.Format("02.01.")
}
if d.Month() == time.January {
return strconv.Itoa(d.Year())
}
return monthNames[d.Month()-1]
}
// stackedBars zeichnet Ø Threads (unten) und Antworten (oben) je Kategorie
// als gestapelte Säulen mit Legende.
func stackedBars(sz size, label string, labels, tipLabels []string, vals [][2]float64) string {
totals := make([]float64, len(vals))
for i, v := range vals {
totals[i] = v[0] + v[1]
}
if maxOf(totals) == 0 {
return emptyChart(sz, label)
}
var b svg
svgOpen(&b, sz, label)
top := padTop + 20
x0 := padLeft + 4
for k, name := range postKinds {
b.f(`<rect x="%d" y="%d" width="11" height="11" fill="%s"/>`, x0, padTop-2, colorStack[k])
b.f(`<text x="%d" y="%d">%s</text>`, x0+16, padTop+8, name)
x0 += 16 + 9*len(name) + 16
}
y := yAxis(&b, sz, maxOf(totals), top)
bottom := float64(sz.h - padBottom)
slot := float64(sz.w-padLeft-padRight) / float64(len(vals))
barW := slot * 0.7
for i, v := range vals {
x := padLeft + float64(i)*slot + (slot-barW)/2
tip := html.EscapeString(fmt.Sprintf("%s: Ø %s Beiträge pro Tag (%s Threads, %s Antworten)",
tipLabels[i], fmtNum(totals[i]), fmtNum(v[0]), fmtNum(v[1])))
b.f(`<g data-tip="%s"><title>%s</title>`, tip, tip)
// Unsichtbare volle Säule, damit auch kurze Balken gut zu treffen sind.
b.f(`<rect x="%.1f" y="%d" width="%.1f" height="%.1f" fill="transparent"/>`,
padLeft+float64(i)*slot, top, slot, bottom-float64(top))
base := 0.0
for k := range postKinds {
if v[k] > 0 {
b.f(`<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s"/>`,
x, y(base+v[k]), barW, y(base)-y(base+v[k]), colorStack[k])
}
base += v[k]
}
b.f(`</g>`)
b.f(`<text x="%.1f" y="%.0f" text-anchor="middle">%s</text>`, x+barW/2, bottom+18, html.EscapeString(labels[i]))
}
b.f(`</svg>`)
return b.String()
}
+199
View File
@@ -0,0 +1,199 @@
package main
import (
"encoding/json"
"encoding/xml"
"io"
"math"
"net/http"
"strings"
"testing"
"time"
)
func TestNiceScale(t *testing.T) {
cases := []struct{ max, step, top float64 }{
{0, 0.5, 1},
{1, 0.5, 1},
{7, 2, 8},
{123, 50, 150},
{0.37, 0.1, 0.4},
{1000, 500, 1000},
}
for _, c := range cases {
step, top := niceScale(c.max)
if math.Abs(step-c.step) > 1e-9 || math.Abs(top-c.top) > 1e-9 {
t.Errorf("niceScale(%v) = %v, %v; erwartet %v, %v", c.max, step, top, c.step, c.top)
}
}
}
func TestDateTicks(t *testing.T) {
day := func(s string) time.Time { d, _ := time.Parse(time.DateOnly, s); return d }
labels := func(from, to string) []string {
days := dayRange(day(from), day(to))
var l []string
for _, i := range dateTicks(days) {
l = append(l, dateTickLabel(days[i]))
}
return strings.Fields(strings.Join(l, " "))
}
if got := strings.Join(labels("2026-06-18", "2026-10-06"), " "); got != "Jul Aug Sep Okt" {
t.Errorf("Monate: %q", got)
}
if got := strings.Join(labels("2025-12-13", "2027-12-01"), " "); got != "2026 Apr Jul Okt 2027 Apr Jul Okt" {
t.Errorf("Quartale: %q", got)
}
if got := strings.Join(labels("2026-09-20", "2026-10-06"), " "); got != "21.09. 28.09. 05.10." {
t.Errorf("Wochen: %q", got)
}
}
// Ende-zu-Ende: Beiträge und Aufrufe direkt in die DB, Aggregate prüfen,
// dann über die API ausliefern und jedes SVG als XML parsen.
func TestStatsCharts(t *testing.T) {
srv := newTestServer(t)
at := func(s string) int64 {
tm, err := time.ParseInLocation("2006-01-02 15:04", s, berlin)
if err != nil {
t.Fatal(err)
}
return tm.Unix()
}
// Mo 2026-09-28 bis Mi 2026-09-30; "heute" ist Do 2026-10-01.
posts := []struct {
created int64
reply int64
deleted int64
}{
{at("2026-09-28 09:30"), 0, 0},
{at("2026-09-28 09:45"), 1, 0},
{at("2026-09-28 23:30"), 1, 1}, // gelöscht, zählt trotzdem; UTC wäre es 21 Uhr
{at("2026-09-30 00:10"), 0, 0},
{at("2026-10-01 08:00"), 0, 0}, // heute -> fehlt
}
for i, p := range posts {
if _, err := db.Exec(`INSERT INTO entry (pid, uid, created_at, reply_to, deleted) VALUES ($1, 1, $2, $3, $4)`,
100+i, p.created, p.reply, p.deleted); err != nil {
t.Fatal(err)
}
}
for _, imp := range []struct {
day, ip string
hits int
}{
{"2026-09-27", "10.1.0.0", 3},
{"2026-09-27", "10.2.0.0", 2},
{"2026-09-29", "10.1.0.0", 1},
{"2026-10-01", "10.1.0.0", 9}, // heute -> fehlt
} {
db.Exec(`INSERT INTO impression (day, anon_ip, path, hits) VALUES ($1, $2, '/', $3)`, imp.day, imp.ip, imp.hits)
}
now := time.Date(2026, 10, 1, 12, 0, 0, 0, berlin)
ent, err := collectEntryStats(now)
if err != nil {
t.Fatal(err)
}
if len(ent.days) != 3 || ent.perDay[0] != 3 || ent.perDay[1] != 0 || ent.perDay[2] != 1 {
t.Errorf("perDay %v über %d Tage", ent.perDay, len(ent.days))
}
// Je ein Mo/Di/Mi im Zeitraum -> Ø = Summe.
if ent.weekday[0] != [2]float64{1, 2} || ent.weekday[2] != [2]float64{1, 0} || ent.weekday[3] != [2]float64{} {
t.Errorf("weekday %v", ent.weekday)
}
// Stunden: Summe / 3 Tage.
if ent.hour[9] != [2]float64{1.0 / 3, 1.0 / 3} || ent.hour[23] != [2]float64{0, 1.0 / 3} || ent.hour[0] != [2]float64{1.0 / 3, 0} {
t.Errorf("hour 0/9/23: %v %v %v", ent.hour[0], ent.hour[9], ent.hour[23])
}
impr, err := collectImpressionStats(now)
if err != nil {
t.Fatal(err)
}
wantHits, wantNets := []float64{5, 0, 1, 0}, []float64{2, 0, 1, 0}
if len(impr.days) != 4 {
t.Fatalf("impression-Tage: %v", impr.days)
}
for i := range wantHits {
if impr.hits[i] != wantHits[i] || impr.networks[i] != wantNets[i] {
t.Errorf("Tag %d: %v/%v", i, impr.hits[i], impr.networks[i])
}
}
if err := renderCharts(now); err != nil {
t.Fatal(err)
}
resp, err := http.Get(srv.URL + "/api/stats/charts")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var set StatsCharts
if err := json.NewDecoder(resp.Body).Decode(&set); err != nil {
t.Fatal(err)
}
for _, name := range []string{"impressions", "networks", "entries", "weekday", "hour"} {
pair := set.Charts[name]
if !strings.Contains(pair.Narrow, `viewBox="0 0 600 `) || !strings.Contains(pair.Wide, `viewBox="0 0 1200 `) {
t.Errorf("%s: falsche Größen", name)
}
for _, s := range []string{pair.Narrow, pair.Wide} {
if !strings.HasPrefix(s, "<svg") || strings.Contains(s, "style=") {
t.Errorf("%s: kein CSP-taugliches SVG", name)
}
d := xml.NewDecoder(strings.NewReader(s))
for {
if _, err := d.Token(); err == io.EOF {
break
} else if err != nil {
t.Errorf("%s: kein gültiges XML: %v", name, err)
break
}
}
}
}
if !strings.Contains(set.Charts["weekday"].Wide, `data-tip="Montag: Ø 3 Beiträge pro Tag (1 Threads, 2 Antworten)"`) {
t.Error("Tooltip Montag fehlt")
}
}
func TestEmptyCharts(t *testing.T) {
newTestServer(t)
if err := renderCharts(time.Now()); err != nil {
t.Fatal(err)
}
for name, pair := range chartCache.Charts {
if !strings.Contains(pair.Narrow, "Noch keine Daten.") || !strings.Contains(pair.Wide, "Noch keine Daten.") {
t.Errorf("%s ohne Daten: %+v", name, pair)
}
}
}
// Ø Beiträge/Tag: nur die letzten 30 vollen Tage, gelöschte zählen, heute nicht.
func TestStatsDetailAverage(t *testing.T) {
srv := newTestServer(t)
now := time.Now().In(berlin)
midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, berlin)
for i, created := range []int64{
midnight.Add(-time.Hour).Unix(), // gestern
midnight.AddDate(0, 0, -30).Unix(), // erster Tag des Fensters
midnight.AddDate(0, 0, -30).Unix() - 1, // davor
midnight.Add(time.Minute).Unix(), // heute
midnight.AddDate(0, 0, -10).Add(time.Hour).Unix(), // gelöscht
} {
db.Exec(`INSERT INTO entry (pid, uid, created_at, deleted) VALUES ($1, 1, $2, $3)`, 200+i, created, i/4)
}
resp, err := http.Get(srv.URL + "/api/stats/detail")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var body struct {
Avg float64 `json:"entries_per_day_30"`
}
json.NewDecoder(resp.Body).Decode(&body)
if math.Abs(body.Avg-3.0/30) > 1e-9 {
t.Errorf("entries_per_day_30 = %v, erwartet 0,1", body.Avg)
}
}
+96
View File
@@ -0,0 +1,96 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"strings"
)
// runCommand führt einen Admin-Unterbefehl aus (kver <befehl> ...). Im
// Container: podman exec kver kver <befehl> ...
func runCommand(args []string) error {
switch args[0] {
case "reset-link":
if len(args) != 2 {
return errors.New("Verwendung: kver reset-link <nutzername>")
}
return cmdResetLink(args[1])
case "link-previews":
if len(args) != 1 {
return errors.New("Verwendung: kver link-previews")
}
return cmdLinkPreviews()
default:
return fmt.Errorf("unbekannter Befehl %q (bekannt: reset-link, link-previews)", args[0])
}
}
// cmdResetLink erzeugt einen Passwort-Reset-Link (siehe reset.go) und gibt ihn
// aus. KVER_BASE_URL (z. B. https://example.org) macht daraus einen fertigen
// Link; ohne steht nur der Pfad da.
func cmdResetLink(username string) error {
username = sanitizeUsername(username)
token, expires, err := createResetToken(username)
if errors.Is(err, errUserNotFound) {
return fmt.Errorf("Nutzer %q nicht gefunden", username)
} else if err != nil {
return err
}
base := strings.TrimSuffix(os.Getenv("KVER_BASE_URL"), "/")
fmt.Printf("Reset-Link für %s (gültig bis %s, einmalig):\n\n %s/reset#%s\n\n",
username, expires.Format("02.01.2006 15:04 MST"), base, token)
fmt.Println("Ältere Reset-Links dieses Nutzers sind damit ungültig.")
return nil
}
// cmdLinkPreviews trägt bei bestehenden Beiträgen den ersten Link nach
// (entry.link_url) und holt alle offenen Vorschauen nacheinander. Läuft der
// Server parallel, holt er höchstens dieselben Links noch einmal -- harmlos.
func cmdLinkPreviews() error {
rows, err := db.Query(`SELECT pid, content FROM entry WHERE deleted = 0 AND link_url = '' AND content LIKE '%http%'`)
if err != nil {
return err
}
links := map[int64]string{}
for rows.Next() {
var pid int64
var content string
if err := rows.Scan(&pid, &content); err != nil {
rows.Close()
return err
}
if key := previewKey(content); key != "" {
links[pid] = key
}
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for pid, key := range links {
if _, err := db.Exec(`UPDATE entry SET link_url = $1 WHERE pid = $2`, key, pid); err != nil {
return err
}
if _, err := db.Exec(`INSERT INTO link_preview (url) VALUES ($1) ON CONFLICT DO NOTHING`, key); err != nil {
return err
}
}
fmt.Printf("%d Beiträge mit Link nachgetragen.\n", len(links))
pending := pendingPreviews(100000)
for i, key := range pending {
fmt.Printf("[%d/%d] %s\n", i+1, len(pending), key)
fetchPreview(context.Background(), key)
}
var ok, none int
if err := db.QueryRow(
`SELECT COUNT(*) FILTER (WHERE status = 'ok'), COUNT(*) FILTER (WHERE status = 'none') FROM link_preview`).
Scan(&ok, &none); err != nil {
return err
}
fmt.Printf("Vorschauen: %d fertig, %d ohne Vorschau.\n", ok, none)
return nil
}
+38
View File
@@ -47,6 +47,8 @@ CREATE TABLE IF NOT EXISTS entry (
bump_count BIGINT NOT NULL DEFAULT 0,
last_bump BIGINT NOT NULL DEFAULT 0
);
-- Kanonische URL des ersten Links im Inhalt (previewKey), '' ohne Link.
ALTER TABLE entry ADD COLUMN IF NOT EXISTS link_url TEXT NOT NULL DEFAULT '';
CREATE TABLE IF NOT EXISTS vote (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
@@ -71,6 +73,41 @@ CREATE TABLE IF NOT EXISTS impression (
PRIMARY KEY (day, anon_ip, path)
);
-- Meldungen von Beiträgen. handled bleibt vorerst 0; die Abarbeitung folgt
-- mit der Moderation (notes/todo.md).
CREATE TABLE IF NOT EXISTS report (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
pid BIGINT NOT NULL,
uid BIGINT NOT NULL DEFAULT 0,
reason TEXT NOT NULL DEFAULT '',
created_at BIGINT NOT NULL,
handled BIGINT NOT NULL DEFAULT 0
);
-- Einmal-Links zum Passwort-Reset, per CLI erzeugt (kver reset-link, siehe
-- reset.go). Gespeichert wird nur der SHA-256 des Tokens: wer die DB lesen
-- kann, kann damit trotzdem kein Passwort setzen.
CREATE TABLE IF NOT EXISTS password_reset (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
uid BIGINT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
created_at BIGINT NOT NULL,
expires BIGINT NOT NULL
);
-- Linkvorschauen, eine Zeile je kanonischer URL (siehe preview.go), geteilt
-- von allen Beiträgen mit diesem Link. status: pending (wird geholt), ok,
-- none (keine Vorschau möglich). thumb ist das lokal gespeicherte Bild.
CREATE TABLE IF NOT EXISTS link_preview (
url TEXT PRIMARY KEY,
kind TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
site TEXT NOT NULL DEFAULT '',
thumb TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
fetched_at BIGINT NOT NULL DEFAULT 0
);
-- 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)
@@ -80,6 +117,7 @@ CREATE INDEX IF NOT EXISTS idx_entry_reply_to ON entry(reply_to);
CREATE INDEX IF NOT EXISTS idx_entry_uid ON entry(uid);
CREATE INDEX IF NOT EXISTS idx_vote_pid ON vote(pid);
CREATE INDEX IF NOT EXISTS idx_session_uid ON session(uid);
CREATE INDEX IF NOT EXISTS idx_report_open ON report(handled, created_at);
`
func initDB(dsn string) error {
+8 -1
View File
@@ -41,7 +41,14 @@ podman build -q -t "$IMAGE" . 9>&-
mounts=()
[[ -d "$DATA/data" ]] && mounts+=(-v "$DATA/data:/app/data:ro" -e KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb)
[[ -d "$DATA/media" ]] && mounts+=(-v "$DATA/media:/app/static/media:ro")
if [[ -d "$DATA/media" ]]; then
mounts+=(-v "$DATA/media:/app/static/media:ro")
# Linkvorschau-Bilder schreibt der Server selbst: beschreibbares Volume über
# dem read-only Media-Mount. Der Mountpunkt muss im Quellverzeichnis
# existieren, weil der Container ihn im read-only Mount nicht anlegen kann.
mkdir -p "$DATA/media/preview"
mounts+=(-v kver-dev-preview:/app/static/media/preview:U)
fi
podman rm -f "$NAME" >/dev/null 2>&1 9>&- || true
podman run -d --name "$NAME" -p "127.0.0.1:$PORT:8080" \
+164 -9
View File
@@ -187,8 +187,8 @@ func TestCreateEntryRequiresAuth(t *testing.T) {
}
type tally struct {
Left int `json:"left"`
Right int `json:"right"`
Yes int `json:"yes"`
No int `json:"no"`
Selected string `json:"selected"`
}
@@ -209,16 +209,16 @@ func TestVoting(t *testing.T) {
return tl
}
if got := vote(alice, "left"); got.Left != 1 || got.Selected != "left" {
t.Fatalf("alice left: %+v", got)
if got := vote(alice, "yes"); got.Yes != 1 || got.Selected != "yes" {
t.Fatalf("alice yes: %+v", got)
}
if got := vote(bob, "right"); got.Left != 1 || got.Right != 1 {
t.Fatalf("bob right: %+v", got)
if got := vote(bob, "no"); got.Yes != 1 || got.No != 1 {
t.Fatalf("bob no: %+v", got)
}
if got := vote(alice, "left"); got.Left != 0 || got.Selected != "none" {
if got := vote(alice, "yes"); got.Yes != 0 || got.Selected != "none" {
t.Fatalf("alice toggle off: %+v", got)
}
if got := vote(alice, "right"); got.Right != 2 || got.Selected != "right" {
if got := vote(alice, "no"); got.No != 2 || got.Selected != "no" {
t.Fatalf("alice switch: %+v", got)
}
}
@@ -230,7 +230,7 @@ func TestVoteRequiresAuthAndValidMode(t *testing.T) {
voteURL := fmt.Sprintf("%s/api/entry/%d/vote", srv.URL, pid)
anon := newClient(t)
resp := postForm(t, anon, voteURL, url.Values{"mode": {"left"}})
resp := postForm(t, anon, voteURL, url.Values{"mode": {"yes"}})
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("anon vote: erwartet 401, bekam %d", resp.StatusCode)
@@ -550,6 +550,54 @@ func TestSoftDelete(t *testing.T) {
}
}
func TestReport(t *testing.T) {
srv := newTestServer(t)
alice := registerAndLogin(t, srv, "alice")
bob := registerAndLogin(t, srv, "bob")
pid := createEntry(t, alice, srv, "strittig")
report := func(c *http.Client, pid int64, reason string) int {
resp := postForm(t, c, fmt.Sprintf("%s/api/entry/%d/report", srv.URL, pid), url.Values{"reason": {reason}})
resp.Body.Close()
return resp.StatusCode
}
openReports := func() (n int) {
if err := db.QueryRow(`SELECT count(*) FROM report WHERE pid = $1 AND handled = 0`, pid).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
if got := report(bob, pid, " Spam "); got != http.StatusOK {
t.Fatalf("report: erwartet 200, bekam %d", got)
}
var reason string
db.QueryRow(`SELECT reason FROM report WHERE pid = $1`, pid).Scan(&reason)
if reason != "Spam" {
t.Fatalf("grund: %q", reason)
}
// Erneut melden legt keine zweite offene Meldung an, ein anderer Nutzer
// schon. Anonyme Meldungen (uid 0) sind nicht unterscheidbar und zählen alle.
report(bob, pid, "")
report(alice, pid, "")
report(newClient(t), pid, "")
if got := report(newClient(t), pid, ""); got != http.StatusOK {
t.Fatalf("anon report: erwartet 200, bekam %d", got)
}
if n := openReports(); n != 4 {
t.Fatalf("offene meldungen: erwartet 4, bekam %d", n)
}
if got := report(bob, 999999, ""); got != http.StatusNotFound {
t.Fatalf("report unbekannt: erwartet 404, bekam %d", got)
}
postForm(t, alice, fmt.Sprintf("%s/api/entry/%d/delete", srv.URL, pid), url.Values{}).Body.Close()
if got := report(bob, pid, ""); got != http.StatusNotFound {
t.Fatalf("report gelöscht: erwartet 404, bekam %d", got)
}
}
func TestRenameUser(t *testing.T) {
srv := newTestServer(t)
alice := registerAndLogin(t, srv, "alice")
@@ -639,6 +687,113 @@ func TestDeleteUser(t *testing.T) {
}
}
func TestPasswordReset(t *testing.T) {
srv := newTestServer(t)
old := registerAndLogin(t, srv, "alice")
token, _, err := createResetToken("alice")
if err != nil {
t.Fatalf("createResetToken: %v", err)
}
if _, _, err := createResetToken("niemand"); err != errUserNotFound {
t.Fatalf("unbekannter Nutzer: erwartet errUserNotFound, bekam %v", err)
}
c := newClient(t)
status := func(path string, form url.Values) int {
t.Helper()
resp := postForm(t, c, srv.URL+path, form)
resp.Body.Close()
return resp.StatusCode
}
// Vorab-Prüfung: gültig bzw. unbekannt.
if got := status("/api/auth/reset/check", url.Values{"token": {token}}); got != http.StatusOK {
t.Fatalf("check: erwartet 200, bekam %d", got)
}
if got := status("/api/auth/reset/check", url.Values{"token": {"falsch"}}); got != http.StatusNotFound {
t.Fatalf("check mit falschem Token: erwartet 404, bekam %d", got)
}
// Zu kurzes Passwort -> 400, der Link bleibt gültig.
if got := status("/api/auth/reset", url.Values{
"token": {token}, "pass1": {"kurz"}, "pass2": {"kurz"},
}); got != http.StatusBadRequest {
t.Fatalf("kurzes Passwort: erwartet 400, bekam %d", got)
}
if got := status("/api/auth/reset", url.Values{
"token": {token}, "pass1": {"neuesgeheimnis"}, "pass2": {"neuesgeheimnis"},
}); got != http.StatusOK {
t.Fatalf("reset: erwartet 200, bekam %d", got)
}
// Direkt eingeloggt; die alte Session ist weg.
if got := getStatus(t, c, srv.URL+"/api/user/info"); got != http.StatusOK {
t.Fatalf("nach Reset eingeloggt: erwartet 200, bekam %d", got)
}
if got := getStatus(t, old, srv.URL+"/api/user/info"); got != http.StatusUnauthorized {
t.Fatalf("alte Session nach Reset: erwartet 401, bekam %d", got)
}
// Link gilt nur einmal.
if got := status("/api/auth/reset", url.Values{
"token": {token}, "pass1": {"nochandersxx"}, "pass2": {"nochandersxx"},
}); got != http.StatusNotFound {
t.Fatalf("Link zweimal benutzt: erwartet 404, bekam %d", got)
}
// Altes Passwort tot, neues geht.
login := func(pass string) int {
resp := postForm(t, newClient(t), srv.URL+"/api/auth/login", url.Values{
"user": {"alice"}, "pass": {pass}, "timeout": {"86400"},
})
resp.Body.Close()
return resp.StatusCode
}
if got := login("supersecret1"); got != http.StatusUnauthorized {
t.Fatalf("altes Passwort: erwartet 401, bekam %d", got)
}
if got := login("neuesgeheimnis"); got != http.StatusOK {
t.Fatalf("neues Passwort: erwartet 200, bekam %d", got)
}
// Reset in einem Browser, in dem noch bob angemeldet ist: bobs Session
// wird mit abgeräumt statt verwaist gültig zu bleiben.
bob := registerAndLogin(t, srv, "bob")
var bobSession string
u, _ := url.Parse(srv.URL)
for _, ck := range bob.Jar.Cookies(u) {
if ck.Name == "session" {
bobSession = ck.Value
}
}
tok, _, _ := createResetToken("alice")
resp := postForm(t, bob, srv.URL+"/api/auth/reset", url.Values{
"token": {tok}, "pass1": {"drittesgeheim"}, "pass2": {"drittesgeheim"},
})
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("reset mit fremder Session: erwartet 200, bekam %d", resp.StatusCode)
}
var n int
db.QueryRow(`SELECT COUNT(*) FROM session WHERE value = $1`, bobSession).Scan(&n)
if n != 0 {
t.Fatalf("bobs Session nach Reset im selben Browser noch gültig")
}
// Ein neuer Link macht den vorigen ungültig; abgelaufene gelten nicht.
first, _, _ := createResetToken("alice")
second, _, _ := createResetToken("alice")
if got := status("/api/auth/reset/check", url.Values{"token": {first}}); got != http.StatusNotFound {
t.Fatalf("überholter Link: erwartet 404, bekam %d", got)
}
db.Exec(`UPDATE password_reset SET expires = 0`)
if got := status("/api/auth/reset/check", url.Values{"token": {second}}); got != http.StatusNotFound {
t.Fatalf("abgelaufener Link: erwartet 404, bekam %d", got)
}
}
// postFormFrom schickt wie postForm als API-Client (Accept: application/json),
// zusätzlich mit gesetzter Client-IP.
// postFormFrom verhält sich wie postForm, setzt aber X-Real-IP -> middleware.RealIP
+63 -17
View File
@@ -34,6 +34,8 @@ type Entry struct {
LastBump int64 `json:"last_bump"`
Username string `json:"username"`
Avatar string `json:"avatar"`
// Preview ist die fertige Vorschau des ersten Links, siehe preview.go.
Preview *Preview `json:"preview,omitempty"`
// Latest ist nur in Feed und Antwortliste gesetzt, siehe attachLatest.
Latest *Latest `json:"latest,omitempty"`
}
@@ -47,20 +49,29 @@ type Latest struct {
}
// LEFT JOIN + COALESCE, damit soft-gelöschte Beiträge (uid auf 0 gesetzt) als
// [deleted]-Platzhalter erhalten bleiben und Threads nicht verwaisen.
// [deleted]-Platzhalter erhalten bleiben und Threads nicht verwaisen. Die
// Linkvorschau hängt nur dran, wenn sie fertig ist (status ok).
const entrySelect = `
SELECT e.pid, e.uid, e.created_at, e.content, e.filepath,
e.reply_to, e.reply_count, e.last_activity, e.deleted,
e.bump_count, e.last_bump,
COALESCE(u.username, ''), COALESCE(u.avatar, '')
FROM entry e LEFT JOIN account u ON u.uid = e.uid`
COALESCE(u.username, ''), COALESCE(u.avatar, ''),
COALESCE(lp.url, ''), COALESCE(lp.kind, ''), COALESCE(lp.title, ''),
COALESCE(lp.site, ''), COALESCE(lp.thumb, '')
FROM entry e LEFT JOIN account u ON u.uid = e.uid
LEFT JOIN link_preview lp ON lp.url = e.link_url AND lp.status = 'ok'`
// scanEntry liest eine Zeile mit entrySelect-Spalten (aus *sql.Row oder *sql.Rows).
func scanEntry(row interface{ Scan(...any) error }) (Entry, error) {
var e Entry
var p Preview
err := row.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted,
&e.BumpCount, &e.LastBump, &e.Username, &e.Avatar)
&e.BumpCount, &e.LastBump, &e.Username, &e.Avatar,
&p.URL, &p.Kind, &p.Title, &p.Site, &p.Thumb)
if p.URL != "" {
e.Preview = &p
}
return e, err
}
@@ -308,6 +319,7 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
}
now := time.Now().Unix()
linkURL := previewKey(content)
tx, err := db.Begin()
if err != nil {
@@ -320,10 +332,10 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
for try := 0; ; try++ {
pid = int64(rand.IntN(999999999999)) + 1
res, err := tx.Exec(
`INSERT INTO entry (pid, uid, created_at, content, filepath, reply_to, reply_count, last_activity)
VALUES ($1, $2, $3, $4, $5, $6, 0, $7)
`INSERT INTO entry (pid, uid, created_at, content, filepath, reply_to, reply_count, last_activity, link_url)
VALUES ($1, $2, $3, $4, $5, $6, 0, $7, $8)
ON CONFLICT (pid) DO NOTHING`,
pid, uid, now, content, filepath, replyTo, now,
pid, uid, now, content, filepath, replyTo, now, linkURL,
)
if err != nil {
tx.Rollback()
@@ -362,6 +374,7 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
if err := tx.Commit(); err != nil {
return Internal(err)
}
requestPreview(linkURL)
writeJSON(w, http.StatusCreated, map[string]any{
"pid": pid,
@@ -389,9 +402,11 @@ func handleEditEntry(w http.ResponseWriter, r *http.Request) error {
return Invalid("entry.empty", "Inhalt darf nicht leer sein.").At("content")
}
if _, err := db.Exec(`UPDATE entry SET content = $1 WHERE pid = $2`, content, pid); err != nil {
linkURL := previewKey(content)
if _, err := db.Exec(`UPDATE entry SET content = $1, link_url = $2 WHERE pid = $3`, content, linkURL, pid); err != nil {
return Internal(err)
}
requestPreview(linkURL)
writeJSON(w, http.StatusOK, map[string]any{"pid": pid, "content": content})
return nil
@@ -429,7 +444,7 @@ func handleDeleteEntry(w http.ResponseWriter, r *http.Request) error {
}
if _, err := db.Exec(
`UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE pid = $1`, pid,
`UPDATE entry SET deleted = 1, content = '', filepath = '', link_url = '', uid = 0 WHERE pid = $1`, pid,
); err != nil {
return Internal(err)
}
@@ -441,6 +456,37 @@ func handleDeleteEntry(w http.ResponseWriter, r *http.Request) error {
return nil
}
// handleReport meldet einen Beitrag, optional mit Grund. Melden darf jeder;
// ohne Session ist der Melder anonym (uid 0). Angemeldete legen für denselben
// Beitrag keine zweite Meldung an, solange ihre erste offen ist.
func handleReport(w http.ResponseWriter, r *http.Request) error {
pid, err := pidParam(r)
if err != nil {
return err
}
e, err := entryByPID(pid)
if err != nil || e.Deleted != 0 {
return errEntryNotFound
}
reason := truncateRunes(strings.TrimSpace(r.FormValue("reason")), 500)
var uid int64
if s, ok := getSession(r); ok {
uid = s.UID
}
if _, err := db.Exec(
`INSERT INTO report (pid, uid, reason, created_at)
SELECT $1, $2, $3, $4
WHERE $2::bigint = 0 OR NOT EXISTS (SELECT 1 FROM report WHERE pid = $1 AND uid = $2 AND handled = 0)`,
pid, uid, reason, time.Now().Unix(),
); err != nil {
return Internal(err)
}
writeJSON(w, http.StatusOK, map[string]string{"status": "reported"})
return nil
}
// bumpStep ist der Cooldown-Zuwachs je bereits erfolgtem Bump: nach dem 1. Bump
// ein Tag Sperre, nach dem 10. Bump zehn Tage. So wird Hochspülen mit jedem Mal
// teurer.
@@ -509,20 +555,20 @@ func handleBump(w http.ResponseWriter, r *http.Request) error {
// writeTally schreibt die Zähler und die eigene Auswahl (selected) für einen
// Beitrag. Ist uid == 0 (nicht eingeloggt), ist selected immer "none".
func writeTally(w http.ResponseWriter, pid, uid int64) error {
var left, right int64
var yes, no int64
var selected string
if err := db.QueryRow(
`SELECT COUNT(*) FILTER (WHERE mode = 'left'),
COUNT(*) FILTER (WHERE mode = 'right'),
`SELECT COUNT(*) FILTER (WHERE mode = 'yes'),
COUNT(*) FILTER (WHERE mode = 'no'),
COALESCE(MAX(mode) FILTER (WHERE uid = $2), 'none')
FROM vote WHERE pid = $1`, pid, uid).
Scan(&left, &right, &selected); err != nil {
Scan(&yes, &no, &selected); err != nil {
return Internal(err)
}
writeJSON(w, http.StatusOK, map[string]any{
"pid": pid,
"left": left,
"right": right,
"yes": yes,
"no": no,
"selected": selected,
})
return nil
@@ -554,8 +600,8 @@ func handleVote(w http.ResponseWriter, r *http.Request) error {
uid := uidFromContext(r.Context())
mode := r.FormValue("mode")
if mode != "left" && mode != "right" {
return Invalid("vote.bad_mode", "Ungültiger Modus (left oder right)").At("mode")
if mode != "yes" && mode != "no" {
return Invalid("vote.bad_mode", "Ungültiger Modus (yes oder no)").At("mode")
}
// Nur auf existierende Beiträge abstimmen, sonst entstehen Waisen-Votes.
+4 -2
View File
@@ -13,7 +13,8 @@ function plural(n: number, one: string, many: string) {
// Layout ist der gemeinsame Seitenrahmen: die Startseite hat Tagline und
// Headerbar, alle anderen Seiten den "zurück zum Feed"-Knopf.
export function Layout() {
const isHome = useLocation().pathname === "/";
const path = useLocation().pathname;
const isHome = path === "/";
const days = Math.floor((Date.now() - dataLossStart) / 86400000);
return (
@@ -38,7 +39,8 @@ export function Layout() {
)}
</header>
<main>
{/* Die Statistik-Seite darf auf breiten Bildschirmen breiter werden. */}
<main className={path === "/stats" ? "wide" : undefined}>
<Outlet />
</main>
</>
+80 -31
View File
@@ -117,6 +117,10 @@ main {
margin: auto;
}
main.wide {
max-width: 1200px;
}
main > section,
main > div,
main > h2,
@@ -204,6 +208,55 @@ a.button:hover, button:hover {
max-width: 100%;
}
/* Linkvorschau: abgesetzte Karte unter dem Text, kleines Vorschaubild links,
Titel und Seiten- bzw. Kanalname daneben. Rahmen wie bei den Buttons,
weiß auch beim Card-Hover. */
.link-preview {
display: flex;
align-items: center;
border: 1px solid black;
background-color: white;
margin: 0 0 0.5rem;
text-decoration: none;
color: black;
}
.link-preview:hover .link-preview-title {
text-decoration: underline;
}
.card .link-preview img {
flex: 0 0 auto;
align-self: stretch;
width: 160px;
max-width: 40%;
aspect-ratio: 16 / 9;
object-fit: cover;
margin: 0;
border-right: 1px solid black;
}
.link-preview-text {
min-width: 0;
padding: 0.3rem 0.5rem;
}
.link-preview-title,
.link-preview-site {
display: block;
overflow-wrap: break-word;
}
.link-preview-title {
font-size: 1.1rem;
line-height: 1.3;
}
.link-preview-site {
font-size: 0.9rem;
color: gray;
}
/* --- Avatare --- */
img.avatar {
display: inline-block;
@@ -235,6 +288,9 @@ img.avatar-lg { width: 72px; height: 72px; }
auf 1rem liegt). Hier explizit angleichen -> ganze Meta-Zeile gleich groß. */
.byline span { font-size: 1rem; }
/* Melden steht rechts in der Byline. */
.byline .report { margin-left: auto; }
/* Profilkopf: großer Avatar links neben Name und "Mitglied seit". */
.profile-head {
display: flex;
@@ -365,41 +421,34 @@ form input[type="file"] {
line-height: 1.1;
}
/* Horizontale Balken (Aufrufe/Tag, Betreiber). Breite setzt React per CSSOM
(kein Inline-style im HTML -> CSP-konform). */
.bars {
padding: 0.3rem 0.5rem 0.6rem;
/* Server-SVGs (charts.go): schmal 600, breit 1200 im viewBox-Raum. Ab 900 px
Fensterbreite die breite Fassung, damit die Schrift nicht zu groß wird. */
.chart {
padding: 0.3rem 0 0.6rem;
}
.bar-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.15rem 0;
.chart svg {
display: block;
width: 100%;
height: auto;
}
.bar-label {
flex: 0 0 7rem;
text-align: right;
font-size: 0.95rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.chart-wide {
display: none;
}
.bar-track {
flex: 1;
background: #eee;
/* Senkrechte Markierung des Tages unter dem Zeiger (Liniendiagramme). */
.chart g:hover .mark {
visibility: visible;
}
.bar-fill {
height: 1.1rem;
background: gray;
/* Infozeile unter dem Diagramm: Höhe immer reserviert. */
.chart-tip {
margin: 0.2rem 0 0;
padding-left: 0.5rem;
min-height: 1.3em;
}
.bar-value {
flex: 0 0 auto;
text-align: right;
font-size: 0.95rem;
@media (min-width: 900px) {
.chart-narrow {
display: none;
}
.chart-wide {
display: block;
}
/* ASN-Namen sind länger als ein Datum -> mehr Platz, Umbruch statt Ellipse. */
#stat-asns .bar-label {
flex-basis: 13rem;
white-space: normal;
}
+26 -4
View File
@@ -1,16 +1,18 @@
import type { MouseEvent } from "react";
import { Link, useNavigate } from "react-router";
import type { Entry } from "../types";
import type { Entry, LinkPreview } from "../types";
import { avatarUrl, formatTime, linkify } from "../format";
import { Votes } from "./Votes";
import { useBump } from "./Bump";
import { useReport } from "./Report";
import { useMe } from "../auth";
// Klick auf die Karte öffnet den Beitrag -- außer der Klick galt einem Link
// oder Knopf darin.
// Klick auf die Karte öffnet den Beitrag -- außer der Klick galt einem Link,
// Knopf oder Formular darin.
function useOpenOnClick(pid: number) {
const navigate = useNavigate();
return (ev: MouseEvent) => {
if (!(ev.target as Element).closest("a, button")) navigate(`/e/${pid}`);
if (!(ev.target as Element).closest("a, button, form")) navigate(`/e/${pid}`);
};
}
@@ -34,9 +36,26 @@ function DeletedCard({ entry: e }: { entry: Entry }) {
);
}
// LinkPreviewCard zeigt die Vorschau des ersten Links als abgesetzte Karte:
// kleines Bild links, Titel und Seite daneben.
// Alles darin kommt von unserem Server; erst der Klick führt zur fremden Seite.
function LinkPreviewCard({ preview: p }: { preview: LinkPreview }) {
return (
<a className="link-preview" href={p.url} target="_blank" rel="noopener noreferrer">
{p.thumb && <img src={`/${p.thumb}`} alt="" loading="lazy" />}
<span className="link-preview-text">
<span className="link-preview-title">{p.title}</span>
{p.site && <span className="link-preview-site">{p.site}</span>}
</span>
</a>
);
}
function LiveCard({ entry: e }: { entry: Entry }) {
const open = useOpenOnClick(e.pid);
const bump = useBump(e);
const report = useReport(e.pid);
const own = useMe().data?.uid === e.uid;
const profile = `/u/${encodeURIComponent(e.username)}`;
return (
@@ -49,11 +68,14 @@ function LiveCard({ entry: e }: { entry: Entry }) {
<Link to={profile}>{e.username}</Link> · {formatTime(e.created_at)}
<span className="cooldown">{bump.cooldown}</span>
</span>
{!own && report.button}
</div>
<div className="threadline-wrapper">
<div className="threadline"></div>
<div className="body">
{report.form}
<div className="content">{linkify(e.content)}</div>
{e.preview && <LinkPreviewCard preview={e.preview} />}
{e.filepath && <img src={`/${e.filepath}`} alt="" loading="lazy" />}
<div className="interactions">
<Link className="button reply" to={`/e/${e.pid}`}>Antworten ({e.reply_count})</Link>
+38
View File
@@ -0,0 +1,38 @@
import { useState, type FormEvent } from "react";
import { useMutation } from "@tanstack/react-query";
import { api, errMsg } from "../api";
// useReport hält das Melden eines Beitrags: der Knopf steht rechts in der
// Byline, das Formular für den optionalen Grund darunter im Beitrag --
// deshalb ein Hook statt einer Komponente (wie useBump). Melden geht auch
// ohne Anmeldung, der Melder ist dann anonym.
export function useReport(pid: number) {
const [open, setOpen] = useState(false);
const report = useMutation({
mutationFn: (fd: FormData) => api.post(`/entry/${pid}/report`, fd),
onSuccess: () => setOpen(false),
});
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
report.mutate(new FormData(e.currentTarget));
};
const cancel = () => {
report.reset();
setOpen(false);
};
return {
button: report.isSuccess
? <span className="report">gemeldet</span>
: <button className="report" type="button" disabled={open} onClick={() => setOpen(true)}>Melden</button>,
form: open && (
<form onSubmit={onSubmit}>
<textarea name="reason" rows={2} maxLength={500} placeholder="Grund (optional)"></textarea>{" "}
<button type="submit" disabled={report.isPending}>Melden</button>{" "}
<button type="button" onClick={cancel}>Abbrechen</button>{" "}
<p className="msg">{report.isError ? errMsg(report.error, "Melden fehlgeschlagen.") : ""}</p>
</form>
),
};
}
+3 -3
View File
@@ -47,7 +47,7 @@ export function Votes({ pid }: { pid: number }) {
let counts = "– / –";
if (vote.isError) counts = errMsg(vote.error, "Fehler");
else if (tally.isError) counts = errMsg(tally.error, "Fehler");
else if (t) counts = `${t.left} / ${t.right}`;
else if (t) counts = `${t.yes} / ${t.no}`;
const button = (mode: VoteMode, label: string) => (
<button
@@ -61,9 +61,9 @@ export function Votes({ pid }: { pid: number }) {
return (
<div className="row">
{button("left", "Links")}
{button("yes", "Ja")}
<span className="muted">{counts}</span>
{button("right", "Rechts")}
{button("no", "Nein")}
</div>
);
}
+2
View File
@@ -8,6 +8,7 @@ import { FeedPage } from "./pages/FeedPage";
import { EntryPage } from "./pages/EntryPage";
import { UserPage } from "./pages/UserPage";
import { StatsPage } from "./pages/StatsPage";
import { ResetPage } from "./pages/ResetPage";
import "./app.css";
const queryClient = new QueryClient({
@@ -31,6 +32,7 @@ const router = createBrowserRouter([
{ path: "/e/:pid", element: <EntryPage /> },
{ path: "/u/:username", element: <UserPage /> },
{ path: "/stats", element: <StatsPage /> },
{ path: "/reset", element: <ResetPage /> },
],
},
]);
+65
View File
@@ -0,0 +1,65 @@
import { useState, type FormEvent } from "react";
import { useNavigate } from "react-router";
import { useQuery } from "@tanstack/react-query";
import { api, errMsg } from "../api";
import { useAuthChanged } from "../auth";
// ResetPage: neues Passwort über einen Reset-Link (/reset#<token>, erzeugt per
// kver reset-link). Das Token steht im Fragment, damit es nie in einer URL beim
// Server landet; es geht nur im POST-Body raus.
export function ResetPage() {
const [token] = useState(() => window.location.hash.slice(1));
const [msg, setMsg] = useState("");
const [busy, setBusy] = useState(false);
const navigate = useNavigate();
const authChanged = useAuthChanged();
const check = useQuery({
queryKey: ["reset", token],
queryFn: () => {
const fd = new FormData();
fd.append("token", token);
return api.post<{ username: string }>("/auth/reset/check", fd);
},
enabled: token !== "",
staleTime: Infinity,
});
// Nach Erfolg ist man eingeloggt -> ab in den Feed.
const submit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
fd.append("token", token);
setBusy(true);
try {
await api.post("/auth/reset", fd);
await authChanged();
navigate("/");
} catch (err) {
setMsg(errMsg(err, "Passwort konnte nicht gesetzt werden."));
} finally {
setBusy(false);
}
};
let error = "";
if (token === "") error = "Der Link ist unvollständig.";
else if (check.isError) error = errMsg(check.error, "Der Link konnte nicht geprüft werden.");
return (
<section id="reset">
<h2>Passwort neu setzen</h2>
{error && <p className="msg">{error}</p>}
{check.data && (
<form id="reset-form" onSubmit={submit}>
<p>für <strong>{check.data.username}</strong></p>
<input name="username" type="hidden" value={check.data.username} autoComplete="username" />
<input name="pass1" type="password" placeholder="Neues Passwort" autoComplete="new-password" />{" "}
<input name="pass2" type="password" placeholder="Passwort wiederholen" autoComplete="new-password" />{" "}
<button type="submit" disabled={busy}>Passwort setzen</button>
</form>
)}
<p className="msg" id="reset-msg">{msg}</p>
</section>
);
}
+50 -44
View File
@@ -1,53 +1,55 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api, errMsg } from "../api";
import type { StatsDetail } from "../types";
import type { ChartPair, StatsCharts, StatsDetail } from "../types";
// Statistik-Seite: Zahlen + einfache CSS-Balken (keine Chart-Library).
// Balkenbreiten setzt React per CSSOM (element.style), nicht als
// style-Attribut im HTML -> von der strikten CSP nicht betroffen.
// Statistik-Seite: Zahlen und Diagramme, die der Server einmal täglich als SVG
// rendert (charts.go). Die SVGs kommen ohne style-Attribute aus -> von der
// strikten CSP nicht betroffen.
// NumCard ist eine Kennzahl-Kachel: große Zahl + Beschriftung darunter.
function NumCard({ value, label }: { value: number; label: string }) {
return (
<div>
<span className="num">{value}</span>
<span className="num">{value.toLocaleString("de-DE", { maximumFractionDigits: 1, useGrouping: false })}</span>
<span className="muted">{label}</span>
</div>
);
}
// Bars zeichnet eine Liste {label, value} als horizontale Balken. Die Breite
// ist relativ zum größten Wert.
function Bars({ items, formatLabel }: {
items: { label: string; value: number }[];
formatLabel?: (label: string) => string;
}) {
if (items.length === 0) return <p className="muted">Noch keine Daten.</p>;
const max = Math.max(...items.map((it) => it.value), 1);
// Wertespalte für alle Zeilen gleich breit: an der längsten Zahl ausrichten
// (in ch), damit die Tracks bündig sind und nicht je nach Stellenzahl wandern.
const valWidth = Math.max(...items.map((it) => String(it.value).length));
return items.map((it) => (
<div className="bar-row" key={it.label}>
<span className="bar-label">{formatLabel ? formatLabel(it.label) : it.label}</span>
<div className="bar-track">
<div className="bar-fill" style={{ width: `${(it.value / max) * 100}%` }}></div>
// Chart bettet ein Server-SVG inline ein, damit es die Schrift der Seite erbt.
// Der Inhalt stammt vollständig aus charts.go (nur Zahlen und feste Texte).
// Beide Größen stehen im DOM, das CSS zeigt je nach Fensterbreite eine.
// Die Infozeile darunter ist immer reserviert (kein Springen) und zeigt den
// data-tip des Datenpunkts unter dem Zeiger; Tippen genügt auf Touch-Geräten.
function Chart({ svg }: { svg?: ChartPair }) {
const [tip, setTip] = useState("");
if (!svg) return null;
const pick = (e: React.MouseEvent) => {
const el = (e.target as Element).closest("[data-tip]");
if (el) setTip(el.getAttribute("data-tip") ?? "");
};
return (
<div className="chart" onMouseOver={pick} onClick={pick} onMouseLeave={() => setTip("")}>
<div className="chart-narrow" dangerouslySetInnerHTML={{ __html: svg.narrow }} />
<div className="chart-wide" dangerouslySetInnerHTML={{ __html: svg.wide }} />
<p className="chart-tip">{tip || "\u00a0"}</p>
</div>
<span className="bar-value" style={{ width: `${valWidth}ch` }}>{it.value}</span>
</div>
));
);
}
const dayAxis = (day: string) => day.slice(5); // "MM-DD" reicht auf der Achse
export function StatsPage() {
const stats = useQuery({
queryKey: ["stats"],
queryFn: () => api.get<StatsDetail>("/stats/detail"),
});
const charts = useQuery({
queryKey: ["stats-charts"],
queryFn: () => api.get<StatsCharts>("/stats/charts"),
staleTime: 10 * 60 * 1000,
});
const s = stats.data;
// Backend liefert die Tage absteigend -> für die Achse drehen.
const daily = s ? s.daily.slice().reverse() : [];
const c = charts.data?.charts;
return (
<section>
@@ -62,8 +64,9 @@ export function StatsPage() {
<NumCard value={s.entries} label="Beiträge" />
<NumCard value={s.toplevel} label="davon Threads" />
<NumCard value={s.replies} label="davon Antworten" />
<NumCard value={s.votes.left} label="Stimmen Links" />
<NumCard value={s.votes.right} label="Stimmen Rechts" />
<NumCard value={s.entries_per_day_30} label="Ø Beiträge/Tag (30 Tage)" />
<NumCard value={s.votes.yes} label="Stimmen Ja" />
<NumCard value={s.votes.no} label="Stimmen Nein" />
</>
)}
</div>
@@ -82,23 +85,26 @@ export function StatsPage() {
)}
</div>
<h3>Netze nach Betreiber</h3>
<p className="muted">
Aufrufe je autonomem System (Netzbetreiber), aufgelöst über GeoLite2-ASN.
</p>
<div id="stat-asns" className="bars">
{s && <Bars items={s.top_asns.map((a) => ({ label: a.asn, value: a.hits }))} />}
</div>
<h3>Aufrufe pro Tag</h3>
<div id="stat-daily" className="bars">
{s && <Bars items={daily.map((d) => ({ label: d.day, value: d.impressions }))} formatLabel={dayAxis} />}
</div>
<Chart svg={c?.impressions} />
<h3>Verschiedene Netze pro Tag</h3>
<div id="stat-networks" className="bars">
{s && <Bars items={daily.map((d) => ({ label: d.day, value: d.visitors }))} formatLabel={dayAxis} />}
</div>
<Chart svg={c?.networks} />
<h3>Beiträge pro Tag</h3>
<p className="muted">
Alle Beiträge einschließlich gelöschter, in deutscher Zeit. Wochentag und
Tageszeit zeigen den Durchschnitt pro Tag seit dem ersten Beitrag.
Die Diagramme werden einmal täglich erstellt und enden mit gestern.
</p>
{charts.isError && <p className="msg">{errMsg(charts.error, "Diagramme konnten nicht geladen werden.")}</p>}
<Chart svg={c?.entries} />
<h3>Beiträge pro Wochentag</h3>
<Chart svg={c?.weekday} />
<h3>Beiträge pro Tageszeit</h3>
<Chart svg={c?.hour} />
</section>
);
}
+28 -6
View File
@@ -15,23 +15,34 @@ export interface Entry {
last_bump: number;
username: string;
avatar: string;
// Vorschau des ersten Links, sobald der Server sie geholt hat.
preview?: LinkPreview;
// Nur in Feed und Antwortliste: jüngster Beitrag im Teilbaum, skipped =
// Beiträge auf dem Pfad dazwischen (0 = direkte Antwort).
latest?: { entry: Entry; skipped: number };
}
// Vom Server geholte Linkvorschau; thumb liegt lokal (leer = kein Bild).
export interface LinkPreview {
url: string;
kind: "youtube" | "page";
title: string;
site: string;
thumb: string;
}
export interface Thread {
entry: Entry;
ancestors: Entry[];
replies: Entry[];
}
export type VoteMode = "left" | "right";
export type VoteMode = "yes" | "no";
export interface VoteTally {
pid: number;
left: number;
right: number;
yes: number;
no: number;
selected: VoteMode | "none";
}
@@ -65,11 +76,22 @@ export interface StatsDetail {
entries: number;
toplevel: number;
replies: number;
votes: { left: number; right: number };
votes: { yes: number; no: number };
impressions_total: number;
visitors_total: number;
daily: { day: string; impressions: number; visitors: number }[];
top_asns: { asn: string; hits: number; networks: number }[];
entries_per_day_30: number;
}
// Fertig gerenderte SVG-Diagramme der Statistik-Seite (einmal täglich,
// charts.go), je schmal (600) und breit (1200).
export interface ChartPair {
narrow: string;
wide: string;
}
export interface StatsCharts {
generated: number;
charts: Record<"impressions" | "networks" | "entries" | "weekday" | "hour", ChartPair>;
}
// Fehlerformat: {"error": {...}}, bei manchen Fehlern mit Zusatzfeldern
+2 -1
View File
@@ -119,6 +119,7 @@ E-Mail: <a>stress@bnd.wtf</a>
<li>Optional: Hochgeladenes Bild (JPG, PNG, GIF; max. 1024x1024 Pixel)</li>
</ul>
<em>Sichtbarkeit:</em> Beiträge und Benutzername sind öffentlich einsehbar<br>
<em>Linkvorschau:</em> Enthält ein Beitrag einen Link, ruft unser Server von der verlinkten Seite Titel, Seiten- bzw. Kanalname und Vorschaubild ab (bei YouTube über den öffentlichen oEmbed-Dienst von YouTube) und speichert sie auf unserem Server. Die Vorschau wird Ihnen ausschließlich von unserem Server ausgeliefert; beim Anzeigen werden keine Daten an die verlinkte Seite oder an YouTube übermittelt. Erst wenn Sie die Vorschau oder den Link anklicken, rufen Sie die fremde Seite selbst auf.<br>
<em>Zweck:</em> Bereitstellung der Forumsfunktion<br>
<em>Rechtsgrundlage:</em> Vertragserfüllung (Art. 6 Abs. 1 lit. b DSGVO)<br>
<em>Speicherdauer:</em> Bis zur Löschung durch den Nutzer
@@ -152,7 +153,7 @@ E-Mail: <a>stress@bnd.wtf</a>
</ul>
<em>Zweck:</em> Erfassung des Seitenwachstums und der Reichweite (Seitenaufrufe)<br>
<em>Rechtsgrundlage:</em> Berechtigtes Interesse (Art. 6 Abs. 1 lit. f DSGVO)<br>
<em>Hinweis:</em> Durch die Anonymisierung und Aggregation ist keine Rückverfolgung auf einzelne Personen möglich.
<em>Hinweis:</em> Die Daten sind so stark gekürzt und zusammengefasst, dass einzelne Personen daraus in der Regel nicht bestimmt werden können.
</p>
<p><strong>6. Datenlöschung</strong><br><br>
+6 -5
View File
@@ -1,14 +1,15 @@
module kver
go 1.25.0
go 1.26.0
require (
github.com/go-chi/chi/v5 v5.3.0
github.com/go-chi/httprate v0.15.0
github.com/jackc/pgx/v5 v5.11.0
github.com/oschwald/maxminddb-golang v1.13.1
golang.org/x/crypto v0.52.0
golang.org/x/crypto v0.57.0
golang.org/x/image v0.41.0
golang.org/x/net v0.59.0
)
require (
@@ -17,7 +18,7 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
)
+10 -8
View File
@@ -28,16 +28,18 @@ 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=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+4
View File
@@ -15,6 +15,10 @@ KVER_DSN=postgres://kver:passwort@host:5432/kontrollverlust?sslmode=disable
# Optional: Pfad zur GeoLite2-ASN-DB im Container (leer = ASN-Auflösung aus).
#KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb
# Optional: öffentliche Adresse, damit `kver reset-link` einen fertigen Link
# ausgibt statt nur des Pfads.
#KVER_BASE_URL=https://example.org
# Selten nötig -- die Container-Defaults passen normalerweise:
#KVER_ADDR=:8080
+23
View File
@@ -37,6 +37,16 @@ func main() {
}
defer db.Close()
// Unterbefehle (z. B. reset-link) statt Server: laufen gegen dieselbe DB
// und beenden sich danach, siehe command.go.
if len(os.Args) > 1 {
if err := runCommand(os.Args[1:]); err != nil {
db.Close()
log.Fatal(err)
}
return
}
// Optionale GeoLite2-ASN-Datenbank für die netzwerktopologische Einordnung
// der Impressions. Inaktiv, wenn KVER_GEOIP_ASN nicht gesetzt ist.
initGeoASN()
@@ -47,6 +57,10 @@ func main() {
// die geschlossene DB oder den unmap-ten GeoIP-Reader zugreifen.
startImpressionWorker()
defer stopImpressionWorker()
startPreviewWorker()
defer stopPreviewWorker()
startChartWorker()
defer stopChartWorker()
srv := &http.Server{
Addr: envOr("KVER_ADDR", ":8080"),
@@ -124,6 +138,8 @@ func routes() http.Handler {
r.Use(limitByIP(20, time.Minute))
r.Post("/auth/login", api(handleLogin))
r.Post("/auth/newuser", api(handleNewUser))
r.Post("/auth/reset/check", api(handleResetCheck))
r.Post("/auth/reset", api(handleReset))
})
// Logout als POST: SameSite=Lax schickt Cookies bei Top-Level-GET-
// Navigationen mit -- als GET könnte eine fremde Seite Nutzer per Link
@@ -132,11 +148,15 @@ func routes() http.Handler {
r.Get("/auth/sessioninfo", api(handleSessionInfo))
r.Get("/stats", api(handleStats))
r.Get("/stats/detail", api(handleStatsDetail))
r.Get("/stats/charts", api(handleStatsCharts))
// --- Entries (öffentlich) ---
r.Get("/entry/feed/{page}", api(handleFeed))
r.Get("/entry/{pid}/votes", api(handleVotes))
r.Get("/entry/{pid}/thread", api(handleThread))
// Melden darf jeder (anonym ohne Session), gedrosselt pro IP, damit der
// offene Endpunkt kein Spam-Kanal wird.
r.With(limitByIP(10, time.Minute)).Post("/entry/{pid}/report", api(handleReport))
// --- User (öffentlich) ---
r.Get("/u/{username}/info", api(handleUserPage))
@@ -166,6 +186,9 @@ func routes() http.Handler {
r.With(trackImpression).Get("/e/{pid}", serveApp)
r.With(trackImpression).Get("/u/{username}", serveApp)
r.Get("/stats", serveApp)
// Reset-Seite ohne Tracking; das Token steht im Fragment (#...) und
// erreicht den Server über diese Route nie, siehe reset.go.
r.Get("/reset", serveApp)
// Alte Adresse der Statistik-Seite (Lesezeichen, Links von außen).
r.Get("/stats.html", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/stats", http.StatusMovedPermanently)
+112
View File
@@ -0,0 +1,112 @@
# Android-App (Plan, zurückgestellt)
Stand 2026-10-02. Noch nichts davon ist umgesetzt; das hier ist die
Entscheidungsgrundlage für später.
## Anforderungen
- PGP clientseitig: signieren, verifizieren, ver-/entschlüsseln
- private Schlüssel lokal auf dem Gerät speichern
- Push-Benachrichtigungen
- Kamera-Zugriff
## Entscheidung: Capacitor
Der bestehende React-Code wird mit Capacitor als APK gebündelt. Eine native
Kotlin-App ist nicht nötig; nativer Code beschränkt sich auf (fertige oder
kleine eigene) Plugins.
### Verworfen: PWA / Trusted Web Activity
Wäre fast ohne Umbau machbar (Manifest, Service Worker,
`/.well-known/assetlinks.json`, Bubblewrap), scheitert aber am PGP-Ziel:
Bei TWA/PWA liefert der Server den Code bei jedem Start neu aus. Wer den
Server kontrolliert (Angreifer, kompromittiertes PROD, Anordnung), kann
JavaScript nachschieben, das den privaten Schlüssel ausliest. Clientseitige
Kryptographie soll gerade das Vertrauen in den Server überflüssig machen.
Mit Capacitor steckt der Code signiert im APK; Updates laufen über den
App-Signaturschlüssel, nicht über einen PROD-Deploy.
### Verworfen: native App (Kotlin/Compose)
Kompletter Neubau des Frontends ohne Mehrwert für die Anforderungen; passt
außerdem schlecht zum bewusst rohen Design, das in der Web-Version schon
existiert.
## Umbau Frontend
- **API-Basis-URL:** `frontend/src/api.ts` ruft fest `/api…` auf. Das wird
konfigurierbar (z. B. `import.meta.env.VITE_API_BASE`), weil die App unter
`https://localhost` läuft, nicht auf der Domain.
- **Absolute URLs:** Bild-URLs aus der API und geteilte Links
(`/entry/123`) müssen die Domain enthalten.
- **Deep Links:** Android App Links für `https://<domain>/entry/…`.
- **Routing:** `createBrowserRouter` funktioniert unter Capacitor weiter.
## Umbau Backend
- **Bearer-Token:** Der Session-Cookie (`auth.go`, `HttpOnly`,
`SameSite=Lax`) kommt im WebView cross-origin nicht zuverlässig an.
Zweiter Login-Modus: Token im JSON-Body zurückgeben, Client schickt
`Authorization: Bearer …`, `getSession` akzeptiert beides. Die Web-Version
bleibt beim Cookie.
- **CORS:** Middleware für `Access-Control-Allow-Origin` mit den App-Origins
(`https://localhost`, `capacitor://localhost`) inkl. Preflight.
- **`checkOrigin`** (`main.go`): App-Origins zulassen bzw. Requests mit
Bearer-Token ausnehmen. Ohne Cookie gibt es dort kein CSRF-Risiko.
## PGP
- **Bibliothek:** OpenPGP.js im Bundle, kein nativer Code. Kommt mit der
CSP `default-src 'self'` aus.
- **Web-Version:** Derselbe Code kann dort laufen, aber mit der schwächeren
Garantie (servergelieferter Code, siehe oben). Sollte in der UI kenntlich
sein.
- **Schlüsselablage:** Der Android Keystore kann PGP-Schlüssel nicht direkt
halten (keine Curve25519/PGP-Formate, gibt Schlüssel nie heraus). Daher:
1. Im Keystore liegt ein hardwaregestützter AES-Schlüssel, optional an
Biometrie/PIN gebunden.
2. Mit ihm wird der PGP-Private-Key verschlüsselt.
3. Das Ergebnis liegt im App-Speicher.
Dafür gibt es Capacitor-Plugins (Secure Storage, Biometrie); notfalls
ein eigenes Plugin, ca. 100 Zeilen Kotlin. IndexedDB allein ist die
schwächere Alternative (nicht hardwaregebunden, XSS-lesbar).
- **Optional OpenKeychain:** Über dessen OpenPGP-API (Intents) kann die App
Signieren/Entschlüsseln delegieren und hält selbst nie einen Schlüssel.
Eigenes Capacitor-Plugin; nur als Zusatzoption, nicht als einziger Weg.
### Vorab klären (betrifft das Backend)
Signiert der Client Beiträge, muss der Server das abbilden:
- Tabelle mit öffentlichen Schlüsseln pro Account (Upload, Wechsel,
Widerruf)
- Signaturfeld an `entry`
- API liefert Signatur und Schlüssel mit, damit andere Clients
verifizieren können
Kann unabhängig von der App vorbereitet werden.
## Push
- Firebase Cloud Messaging über das Capacitor-Push-Plugin.
- Backend: Tabelle für Device-Tokens pro Account, FCM-Sender.
- Inhalt nur „es gibt etwas Neues“ oder PGP-verschlüsselt, sonst liest
Google mit.
## Kamera
Capacitor-Kamera-Plugin; alternativ reicht `<input type="file" capture>`
ganz ohne Plugin.
## Reihenfolge (Vorschlag)
1. Backend: Bearer-Token, CORS, `checkOrigin`
2. Frontend: API-Basis-URL, absolute URLs
3. Capacitor-Projekt, APK-Build, App Links
4. Kamera
5. Push (FCM, Device-Tokens)
6. PGP: Schlüsseltabelle/Signaturfeld im Backend, OpenPGP.js,
Keystore-gestützte Ablage
7. optional OpenKeychain-Plugin
+25 -4
View File
@@ -42,7 +42,7 @@ Endpunkte antworten ohne gültige Session mit `401 auth.required`.
**CSRF:** Nicht-GET-Requests mit fremdem `Origin`-Header werden mit
`403 csrf.bad_origin` abgewiesen.
**Rate-Limits** (pro IP): Login und Registrierung 20/min.
**Rate-Limits** (pro IP): Login, Registrierung und Passwort-Reset 20/min.
---
@@ -54,10 +54,17 @@ Endpunkte antworten ohne gültige Session mit `401 auth.required`.
| POST | `/api/auth/newuser` | – | `user` (3–32 Zeichen), `pass1` (min. 10), `pass2` | `201 {"username"}` |
| POST | `/api/auth/logout` | – | – | `200 {"status":"ok"}`, löscht Cookie |
| GET | `/api/auth/sessioninfo` | ✓ | – | `200 {"uid","created_at","expires","description"}`, ohne Session `401` |
| POST | `/api/auth/reset/check` | – | `token` | `200 {"username"}`; ungültig/abgelaufen/benutzt: `404 reset.invalid` |
| POST | `/api/auth/reset` | – | `token`, `pass1` (min. 10), `pass2` | `200 {"username"}` + Cookie (eingeloggt); löscht alle alten Sessions, Link verbraucht |
Login-Fehler: `401 auth.bad_credentials` (gleich für unbekannten Nutzer und
falsches Passwort).
Reset-Links erzeugt nur der Admin per CLI (`kver reset-link <nutzername>`,
siehe deploy.md). Sie zeigen auf `/reset#<token>`, gelten 72 h und einmal; ein
neuer Link macht ältere desselben Nutzers ungültig. Das Token steht im
Fragment und geht nur im POST-Body an die API, damit es nicht in Logs landet.
---
## Beiträge
@@ -71,6 +78,18 @@ Ein Beitrag (`Entry`):
"last_bump": 1700000400, "username": "max", "avatar": "static/media/…" }
```
Enthält der Text einen Link, trägt der Beitrag `preview`, sobald der Server
die Vorschau des **ersten** Links geholt hat (asynchron nach dem Anlegen bzw.
Bearbeiten; vorher und ohne verwertbare Vorschau fehlt das Feld). `url` ist
die kanonische Adresse (YouTube-Links aller Formen als `watch?v=<id>`),
`kind` ist `youtube` oder `page`, `site` Kanal- bzw. Seitenname, `thumb` ein
lokales Bild (leer, wenn es keins gibt). Siehe `preview.go`.
```json
"preview": { "url": "https://www.youtube.com/watch?v=…", "kind": "youtube",
"title": "…", "site": "…", "thumb": "static/media/preview/….jpg" }
```
In Hauptfeed und `replies` des Threads trägt jeder Beitrag mit Antworten
zusätzlich `latest`: den jüngsten nicht gelöschten Beitrag aus seinem ganzen
Teilbaum (nach `created_at`, höchstens 50 Ebenen tief). `skipped` zählt die
@@ -85,11 +104,12 @@ Antwort). Ohne passenden Nachfahren fehlt das Feld.
|---------|------|------|---------|--------|
| GET | `/api/entry/feed/{page}` | – | – | `200 [Entry]` mit `latest`: Threads nach letzter Aktivität, 20 pro Seite, Seite 0–100, `[]` am Ende |
| GET | `/api/entry/{pid}/thread` | – | – | `200 {"entry", "ancestors": [Entry], "replies": [Entry]}`; `ancestors` Root zuerst, `replies` (max. 100) nach letzter Aktivität, mit `latest` |
| GET | `/api/entry/{pid}/votes` | – | – | `200 {"pid","left","right","selected"}` (`selected`: `left`/`right`/`none`) |
| GET | `/api/entry/{pid}/votes` | – | – | `200 {"pid","yes","no","selected"}` (`selected`: `yes`/`no`/`none`) |
| POST | `/api/entry/{pid}/report` | – | `reason` (optional, max. 500) | `200 {"status":"reported"}`. Ohne Session anonym (uid 0); Angemeldete legen keine zweite offene Meldung an; gelöschte Beiträge `404`; 10/min pro IP |
| POST | `/api/entry/create` | ✓ | multipart: `content` (max. 1000), `file` (optional), `reply_to` (optional) | `201 {"pid","content","filepath","reply_to"}` |
| POST | `/api/entry/{pid}/edit` | ✓ Autor | `content` | `200 {"pid","content"}` |
| POST | `/api/entry/{pid}/delete` | ✓ Autor | – | `200 {"status":"deleted"}` (Soft-Delete, Thread bleibt) |
| POST | `/api/entry/{pid}/vote` | ✓ | `mode` = `left`/`right` | `200` wie `votes`. Gleiche Stimme erneut zieht sie zurück |
| POST | `/api/entry/{pid}/vote` | ✓ | `mode` = `yes`/`no` | `200` wie `votes`. Gleiche Stimme erneut zieht sie zurück |
| POST | `/api/entry/{pid}/bump` | ✓ | – | `200 {"pid","bump_count","last_bump","last_activity","retry_after"}` |
Beim Bump wächst der Cooldown mit jedem Repost um einen Tag. Im Cooldown
@@ -118,7 +138,8 @@ TOR-Anfragen auf `create` werden mit `403 tor.blocked` abgewiesen.
| Methode | Pfad | Erfolg |
|---------|------|--------|
| GET | `/api/stats` | `200 {"users","entries"}` |
| GET | `/api/stats/detail` | `200 {"users","entries","toplevel","replies","votes":{"left","right"},"impressions_total","visitors_total","daily":[…],"top_asns":[…]}` |
| GET | `/api/stats/detail` | `200 {"users","entries","toplevel","replies","votes":{"yes","no"},"impressions_total","visitors_total","entries_per_day_30"}` (Ø Beiträge pro Tag der letzten 30 vollen Tage) |
| GET | `/api/stats/charts` | `200 {"generated","charts":{"impressions","networks","entries","weekday","hour"}}`, je `{"narrow","wide"}` als fertiges SVG (600 bzw. 1200 breit), einmal täglich gerendert |
---
+14
View File
@@ -63,6 +63,7 @@ Konfiguration über Umgebungsvariablen:
| `KVER_ADDR` | `:8080` | Listen-Adresse im Container |
| `KVER_DSN` | _(Pflicht)_ | Postgres-Verbindung, z. B. `postgres://kver:pw@host:5432/kontrollverlust?sslmode=disable` |
| `KVER_GEOIP_ASN` | _(leer)_ | Pfad zur GeoLite2-ASN-`.mmdb`; leer = ASN-Auflösung aus |
| `KVER_BASE_URL` | _(leer)_ | Öffentliche Adresse für `kver reset-link`; leer = nur Pfad |
## GeoLite2-ASN-Datenbank (optional)
@@ -217,3 +218,16 @@ systemctl --user restart kver
Die App fährt bei SIGTERM sauber herunter (laufende Requests werden zu Ende
bedient, dann wird die DB-Verbindung geschlossen).
## Passwort zurücksetzen
Ohne E-Mail-System erzeugt der Admin einen Einmal-Link und gibt ihn dem
Nutzer persönlich:
```sh
podman exec kver kver reset-link <nutzername>
```
Der Link (`/reset#<token>`) gilt 72 Stunden und nur einmal; ein neuer Link
macht ältere desselben Nutzers ungültig. Beim Setzen des neuen Passworts
werden alle bestehenden Sessions des Nutzers abgemeldet.
+3 -2
View File
@@ -1,7 +1,7 @@
# TODO
- Moderation (Melden, Queue, Abhilfe) als eigenständiges Projekt
- Passwort-Reset
- Passwort ändern im Profil; Reset per E-Mail (bisher nur Admin-Link, `kver reset-link`)
- Suche
- Hashtags / Erwähnungen
- Block / Mute
@@ -11,4 +11,5 @@
- RSS / Atom Feed
- Feed ohne JS (früherer SSR-Stand: Tag `ssr-archiv`)
- robots.txt / Sitemap
- Links/Rechts Auswertung
- Ja/Nein Auswertung
- Android-App mit clientseitigem PGP, Push, Kamera (Plan: `notes/android.md`)
+567
View File
@@ -0,0 +1,567 @@
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"image"
"image/jpeg"
"io"
"log"
"mime"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"regexp"
"strings"
"syscall"
"time"
"golang.org/x/image/webp"
"golang.org/x/net/html"
"golang.org/x/net/html/charset"
xdraw "golang.org/x/image/draw"
)
// Linkvorschau: Zum ersten Link eines Beitrags holt der Server Titel, Seiten-
// bzw. Kanalname und Vorschaubild und speichert das Bild lokal. Der Browser
// lädt die Vorschau damit ausschließlich von uns -- Besucher-IPs gehen nie an
// die verlinkte Seite (kein Einwilligungsbedarf, CSP bleibt 'self').
//
// Ablauf: Beim Anlegen/Bearbeiten wird der erste Link kanonisiert (previewKey)
// und als entry.link_url gespeichert. requestPreview legt dazu eine Zeile in
// link_preview an und reicht sie an den Worker; der holt die Daten im
// Hintergrund (das Posten wartet nie). Mehrere Beiträge mit demselben Link
// teilen sich eine Zeile und ein Bild. entrySelect joint die fertige Vorschau
// an jeden Beitrag.
//
// Provider: YouTube (oEmbed, liefert auch ohne Cookie-Banner Titel und Kanal)
// und allgemein OpenGraph/<title> für alle übrigen Seiten. Weitere Spezialfälle
// kommen als eigener Zweig in previewKey/fetchPreview dazu.
// Preview ist die fertige Vorschau eines Links, wie die API sie ausliefert.
type Preview struct {
URL string `json:"url"` // Linkziel der Vorschau (kanonisch)
Kind string `json:"kind"` // "youtube" | "page"
Title string `json:"title"` // Video- bzw. Seitentitel
Site string `json:"site"` // Kanal- bzw. Seitenname
Thumb string `json:"thumb"` // lokales Bild unter static/media/preview, ggf. ""
}
const (
previewRefreshOK = 30 * 24 * 60 * 60 // erfolgreiche Vorschau nach 30 Tagen auffrischen
previewRetryFailed = 24 * 60 * 60 // fehlgeschlagene frühestens nach einem Tag erneut
maxPreviewURL = 2048
maxPageBytes = 1 << 20 // gelesener HTML-Anfang
maxThumbBytes = 8 << 20
thumbSize = 320 // Zielkante des gespeicherten Vorschaubilds (klein, steht neben dem Text)
previewDir = "static/media/preview"
)
// linkPattern findet Links wie linkify im Frontend (format.tsx), damit die
// Vorschau exakt zum ersten dort verlinkten Text gehört. \p{Z} und U+FEFF
// gleichen Gos ASCII-\s an das Unicode-\s von JavaScript an.
var linkPattern = regexp.MustCompile(`https?://[^\s\p{Z}\x{feff}<]+`)
var youtubeIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{11}$`)
// previewKey liefert die kanonische URL des ersten Links in content oder "".
// YouTube-Links aller Formen werden auf watch?v=<id> gebracht, damit dasselbe
// Video nur einmal geholt wird.
func previewKey(content string) string {
raw := linkPattern.FindString(content)
if raw == "" || len(raw) > maxPreviewURL {
return ""
}
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" {
return ""
}
if id := youtubeID(u); id != "" {
return "https://www.youtube.com/watch?v=" + id
}
return raw
}
// youtubeID erkennt die Video-ID in watch?v=, youtu.be/, /shorts/, /live/,
// /embed/ und /v/ auf youtube.com, m./music./www.-Varianten und
// youtube-nocookie.com. "" für alles andere (Kanäle, Playlists, ...).
func youtubeID(u *url.URL) string {
host := strings.ToLower(u.Hostname())
for _, p := range []string{"www.", "m.", "music."} {
host = strings.TrimPrefix(host, p)
}
seg := strings.Split(strings.Trim(u.Path, "/"), "/")
var id string
switch host {
case "youtu.be":
id = seg[0]
case "youtube.com", "youtube-nocookie.com":
switch {
case u.Path == "/watch":
id = u.Query().Get("v")
case len(seg) >= 2 && (seg[0] == "shorts" || seg[0] == "live" || seg[0] == "embed" || seg[0] == "v"):
id = seg[1]
}
}
if !youtubeIDPattern.MatchString(id) {
return ""
}
return id
}
// requestPreview sorgt dafür, dass zu key eine Vorschau existiert: neue Links
// werden angelegt, veraltete oder vor über einem Tag gescheiterte erneut
// angestoßen. Nur dann geht der Key an den Worker. Ist dessen Puffer voll,
// bleibt die Zeile auf pending und wird beim nächsten Start nachgeholt.
func requestPreview(key string) {
if key == "" {
return
}
now := time.Now().Unix()
res, err := db.Exec(
`INSERT INTO link_preview (url) VALUES ($1)
ON CONFLICT (url) DO UPDATE SET status = 'pending'
WHERE (link_preview.status = 'ok' AND link_preview.fetched_at < $2)
OR (link_preview.status = 'none' AND link_preview.fetched_at < $3)`,
key, now-previewRefreshOK, now-previewRetryFailed)
if err != nil {
log.Printf("linkvorschau %s: %v", key, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
return
}
select {
case previewCh <- key:
default:
}
}
// Ein einzelner Worker holt die Vorschauen nacheinander, wie beim Impression-
// Worker deckelt das die ausgehenden Requests (Link-Spam erzeugt keine Flut).
// Beim Shutdown bricht previewCtx einen laufenden Abruf ab; was noch im Puffer
// liegt, bleibt pending und wird beim nächsten Start nachgeholt.
var (
previewCh = make(chan string, 64)
previewDone = make(chan struct{})
previewCtx, previewShutdown = context.WithCancel(context.Background())
)
// startPreviewWorker startet den Worker (nach initDB) und holt zuerst die
// liegengebliebenen pending-Zeilen nach.
func startPreviewWorker() {
go func() {
defer close(previewDone)
for _, key := range pendingPreviews(64) {
fetchPreview(previewCtx, key)
}
for key := range previewCh {
fetchPreview(previewCtx, key)
}
}()
}
// stopPreviewWorker bricht laufende Abrufe ab und wartet auf den Worker (vor
// db.Close rufen).
func stopPreviewWorker() {
previewShutdown()
close(previewCh)
<-previewDone
}
func pendingPreviews(limit int) []string {
rows, err := db.Query(`SELECT url FROM link_preview WHERE status = 'pending' LIMIT $1`, limit)
if err != nil {
log.Printf("linkvorschau: %v", err)
return nil
}
defer rows.Close()
var keys []string
for rows.Next() {
var k string
if rows.Scan(&k) == nil {
keys = append(keys, k)
}
}
return keys
}
// fetchPreview holt die Vorschau zu key und speichert das Ergebnis: status ok
// mit Daten oder none (Seite ohne verwertbaren Titel, Video privat/gelöscht,
// Fehler). Bei abgebrochenem ctx (Shutdown) bleibt die Zeile pending.
func fetchPreview(ctx context.Context, key string) {
if ctx.Err() != nil {
return
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
var p Preview
var imgs []string // Bildkandidaten, der erste ladbare gewinnt
var err error
if u, _ := url.Parse(key); u != nil && youtubeID(u) != "" {
p, imgs, err = fetchYouTube(ctx, youtubeID(u))
} else {
p, imgs, err = fetchPage(ctx, key)
}
if errors.Is(err, context.Canceled) && previewCtx.Err() != nil {
return
}
now := time.Now().Unix()
thumb := thumbPath(key)
if err != nil {
log.Printf("linkvorschau %s: %v", key, err)
os.Remove(thumb)
db.Exec(`UPDATE link_preview SET status = 'none', title = '', site = '', thumb = '', fetched_at = $2 WHERE url = $1`, key, now)
return
}
stored := false
for _, img := range imgs {
if err := storeThumb(ctx, img, thumb); err != nil {
log.Printf("linkvorschau %s: bild %s: %v", key, img, err)
continue
}
stored = true
break
}
if !stored {
os.Remove(thumb)
thumb = ""
}
if previewCtx.Err() != nil {
return // Shutdown mitten im Bildabruf: pending lassen, nicht ohne Bild speichern
}
db.Exec(
`UPDATE link_preview SET status = 'ok', kind = $2, title = $3, site = $4, thumb = $5, fetched_at = $6 WHERE url = $1`,
key, p.Kind, truncateRunes(p.Title, 300), truncateRunes(p.Site, 100), thumb, now)
}
// thumbPath leitet den Bildnamen aus dem Key ab: deterministisch (Auffrischen
// überschreibt dasselbe Bild) und ohne Nutzereingabe im Dateinamen.
func thumbPath(key string) string {
sum := sha256.Sum256([]byte(key))
return previewDir + "/" + hex.EncodeToString(sum[:10]) + ".jpg"
}
// YouTube-Endpunkte; Variablen, damit Tests sie auf einen lokalen Server lenken.
// mqdefault (320x180) gibt es für jedes Video, in 16:9 ohne schwarze Balken
// und genau in der Größe, in der wir es zeigen.
var (
youtubeOEmbed = "https://www.youtube.com/oembed"
youtubeThumbs = []string{"https://i.ytimg.com/vi/%s/mqdefault.jpg"}
)
// fetchYouTube fragt den öffentlichen oEmbed-Endpunkt (ohne API-Key). Das
// Bild kommt von der festen Thumbnail-Adresse statt aus thumbnail_url, so
// bestimmt allein die geprüfte ID, welche Hosts wir ansprechen.
func fetchYouTube(ctx context.Context, id string) (Preview, []string, error) {
watch := "https://www.youtube.com/watch?v=" + id
resp, err := previewGet(ctx, youtubeOEmbed+"?format=json&url="+url.QueryEscape(watch), "application/json")
if err != nil {
return Preview{}, nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// 401/403: privat oder nicht einbettbar, 404: gelöscht.
return Preview{}, nil, fmt.Errorf("oembed: status %d", resp.StatusCode)
}
var o struct {
Title string `json:"title"`
AuthorName string `json:"author_name"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&o); err != nil {
return Preview{}, nil, fmt.Errorf("oembed: %w", err)
}
if o.Title == "" {
return Preview{}, nil, errors.New("oembed: kein Titel")
}
imgs := make([]string, len(youtubeThumbs))
for i, f := range youtubeThumbs {
imgs[i] = fmt.Sprintf(f, id)
}
return Preview{URL: watch, Kind: "youtube", Title: o.Title, Site: o.AuthorName}, imgs, nil
}
// fetchPage liest den Kopf einer HTML-Seite und nimmt OpenGraph- bzw.
// Twitter-Angaben, ersatzweise <title>. Ohne Titel gibt es keine Vorschau.
func fetchPage(ctx context.Context, pageURL string) (Preview, []string, error) {
resp, err := previewGet(ctx, pageURL, "text/html,application/xhtml+xml")
if err != nil {
return Preview{}, nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Preview{}, nil, fmt.Errorf("status %d", resp.StatusCode)
}
ct := resp.Header.Get("Content-Type")
if mt, _, _ := mime.ParseMediaType(ct); mt != "text/html" && mt != "application/xhtml+xml" {
return Preview{}, nil, fmt.Errorf("kein HTML: %q", ct)
}
body, err := charset.NewReader(io.LimitReader(resp.Body, maxPageBytes), ct)
if err != nil {
return Preview{}, nil, err
}
m := parsePageMeta(body)
title := firstNonEmpty(m["og:title"], m["twitter:title"], m["title"])
if title == "" {
return Preview{}, nil, errors.New("kein Titel")
}
// Nach Redirects gilt die endgültige Adresse als Basis für relative Bild-URLs.
base := resp.Request.URL
site := firstNonEmpty(m["og:site_name"], strings.TrimPrefix(base.Hostname(), "www."))
var imgs []string
if ref := firstNonEmpty(m["og:image:secure_url"], m["og:image"], m["og:image:url"], m["twitter:image"], m["twitter:image:src"]); ref != "" {
if u, err := base.Parse(ref); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
imgs = []string{u.String()}
}
}
return Preview{URL: pageURL, Kind: "page", Title: title, Site: site}, imgs, nil
}
// parsePageMeta sammelt <meta property|name=... content=...> und den
// <title>-Text aus dem Dokumentkopf. Kleingeschriebene Schlüssel, jeweils der
// erste Treffer zählt; Schluss beim <body> bzw. </head>.
func parsePageMeta(r io.Reader) map[string]string {
m := map[string]string{}
set := func(k, v string) {
v = strings.Join(strings.Fields(strings.ToValidUTF8(v, "")), " ")
if _, ok := m[k]; !ok && v != "" {
m[k] = v
}
}
z := html.NewTokenizer(r)
inTitle := false
for {
switch z.Next() {
case html.ErrorToken:
return m
case html.StartTagToken, html.SelfClosingTagToken:
t := z.Token()
switch t.Data {
case "body":
return m
case "title":
inTitle = true
case "meta":
var key, content string
for _, a := range t.Attr {
switch a.Key {
case "property", "name":
if key == "" {
key = strings.ToLower(a.Val)
}
case "content":
content = a.Val
}
}
if key != "" && key != "title" {
set(key, content)
}
}
case html.TextToken:
if inTitle {
set("title", string(z.Text()))
}
case html.EndTagToken:
switch z.Token().Data {
case "title":
inTitle = false
case "head":
return m
}
}
}
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
// storeThumb lädt ein Bild, verkleinert es auf thumbSize und speichert es als
// JPEG unter name. Das Re-Encode verwirft Metadaten und alles, was kein Bild
// ist; animierte GIFs werden zum ersten Frame.
func storeThumb(ctx context.Context, imgURL, name string) error {
resp, err := previewGet(ctx, imgURL, "image/*")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("status %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxThumbBytes+1))
if err != nil {
return err
}
if len(data) > maxThumbBytes {
return errors.New("bild zu groß")
}
// WebP gezielt dekodieren statt global zu registrieren -- sonst nähme
// auch der Upload (storeImage) plötzlich WebP an.
decodeConfig, decode := image.DecodeConfig, func(r io.Reader) (image.Image, error) {
img, _, err := image.Decode(r)
return img, err
}
if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
decodeConfig = func(r io.Reader) (image.Config, string, error) {
c, err := webp.DecodeConfig(r)
return c, "webp", err
}
decode = webp.Decode
}
cfg, _, err := decodeConfig(bytes.NewReader(data))
if err != nil {
return err
}
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > maxImagePixels {
return fmt.Errorf("bild zu groß: %dx%d", cfg.Width, cfg.Height)
}
img, err := decode(bytes.NewReader(data))
if err != nil {
return err
}
b := img.Bounds()
nw, nh := b.Dx(), b.Dy()
if nw > thumbSize || nh > thumbSize {
if nw > nh {
nw, nh = thumbSize, max(nh*thumbSize/nw, 1)
} else {
nw, nh = max(nw*thumbSize/nh, 1), thumbSize
}
}
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
// Weißer Grund für transparente PNG/WebP, JPEG kennt keinen Alphakanal.
xdraw.Draw(dst, dst.Bounds(), image.White, image.Point{}, xdraw.Src)
xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, b, xdraw.Over, nil)
if err := os.MkdirAll(previewDir, 0o755); err != nil {
return err
}
// Erst in eine Temp-Datei, dann umbenennen: ein Auffrischen ersetzt das
// alte Bild atomar, Besucher sehen nie ein halb geschriebenes.
tmp, err := os.CreateTemp(previewDir, ".tmp-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if err := jpeg.Encode(tmp, dst, &jpeg.Options{Quality: 80}); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmp.Name(), 0o644); err != nil {
return err
}
return os.Rename(tmp.Name(), name)
}
// previewGet ist ein GET über previewClient mit ehrlichem User-Agent.
func previewGet(ctx context.Context, target, accept string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "kver-linkvorschau/1.0")
req.Header.Set("Accept", accept)
req.Header.Set("Accept-Language", "de,en;q=0.5")
return previewClient.Do(req)
}
// previewClient ruft beliebige, von Nutzern gepostete URLs ab und muss daher
// gegen SSRF gehärtet sein: Wer "http://postgres:5432/" oder eine Adresse im
// internen Netz postet, darf unseren Server nicht dorthin schicken. Geprüft
// wird in Dialer.Control, also NACH der DNS-Auflösung auf der tatsächlich
// angewählten IP -- das deckt auch DNS-Rebinding und jeden Redirect-Schritt ab.
// Kein Proxy aus der Umgebung (der würde die Prüfung umgehen).
var previewClient = &http.Client{
Timeout: 20 * time.Second,
Transport: &http.Transport{
Proxy: nil,
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
Control: func(network, address string, _ syscall.RawConn) error {
return previewDialGuard(address)
},
}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
MaxIdleConns: 4,
IdleConnTimeout: 30 * time.Second,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("zu viele Weiterleitungen")
}
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
return fmt.Errorf("weiterleitung auf %q", req.URL.Scheme)
}
return nil
},
}
// previewDialGuard lässt nur öffentliche Adressen auf Port 80/443 zu.
// Variable, damit Tests gegen einen lokalen httptest-Server laufen können.
var previewDialGuard = func(address string) error {
host, port, err := net.SplitHostPort(address)
if err != nil {
return err
}
if port != "80" && port != "443" {
return fmt.Errorf("port %s nicht erlaubt", port)
}
ip, err := netip.ParseAddr(host)
if err != nil {
return err
}
if !isPublicIP(ip) {
return fmt.Errorf("adresse %s nicht öffentlich", ip)
}
return nil
}
// Nicht-öffentliche Bereiche, die IsGlobalUnicast/IsPrivate nicht abdecken.
var reservedNets = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"), // "dieses Netz"
netip.MustParsePrefix("100.64.0.0/10"), // Carrier-Grade-NAT
netip.MustParsePrefix("192.0.0.0/24"), // IETF-Protokollzuweisungen
netip.MustParsePrefix("192.0.2.0/24"), // Dokumentation
netip.MustParsePrefix("198.18.0.0/15"), // Benchmarking
netip.MustParsePrefix("198.51.100.0/24"), // Dokumentation
netip.MustParsePrefix("203.0.113.0/24"), // Dokumentation
netip.MustParsePrefix("240.0.0.0/4"), // reserviert, inkl. Broadcast
netip.MustParsePrefix("64:ff9b::/96"), // NAT64: bildet IPv4 inkl. interner Netze ab
netip.MustParsePrefix("64:ff9b:1::/48"), // lokales NAT64
netip.MustParsePrefix("2001:db8::/32"), // Dokumentation
netip.MustParsePrefix("2002::/16"), // 6to4: kann interne IPv4 einbetten
}
func isPublicIP(ip netip.Addr) bool {
ip = ip.Unmap()
if !ip.IsGlobalUnicast() || ip.IsPrivate() {
return false
}
for _, p := range reservedNets {
if p.Contains(ip) {
return false
}
}
return true
}
+273
View File
@@ -0,0 +1,273 @@
package main
import (
"context"
"encoding/json"
"image"
"image/png"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"os"
"strings"
"testing"
)
func TestPreviewKey(t *testing.T) {
yt := "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
cases := map[string]string{
"guck mal https://youtu.be/dQw4w9WgXcQ?t=42": yt,
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=x": yt,
"https://m.youtube.com/watch?feature=share&v=dQw4w9WgXcQ": yt,
"https://youtube.com/shorts/dQw4w9WgXcQ": yt,
"https://music.youtube.com/watch?v=dQw4w9WgXcQ": yt,
"https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ": yt,
"https://www.youtube.com/@kanal": "https://www.youtube.com/@kanal",
"https://www.youtube.com/watch?v=kurz": "https://www.youtube.com/watch?v=kurz",
"erst https://example.org/a dann https://youtu.be/dQw4w9WgXcQ": "https://example.org/a",
"ohne Link": "",
"ftp://example.org": "",
"https://example.org/x danach": "https://example.org/x",
"https://example.org/<b>": "https://example.org/",
}
for in, want := range cases {
if got := previewKey(in); got != want {
t.Errorf("previewKey(%q) = %q, erwartet %q", in, got, want)
}
}
if got := previewKey("https://example.org/" + strings.Repeat("a", maxPreviewURL)); got != "" {
t.Errorf("überlange URL ergab %q", got)
}
}
func TestIsPublicIP(t *testing.T) {
cases := map[string]bool{
"93.184.216.34": true,
"2a00:1450:4001::200e": true,
"127.0.0.1": false,
"10.1.2.3": false,
"172.16.0.1": false,
"192.168.161.4": false,
"169.254.169.254": false, // Cloud-Metadaten
"100.64.0.1": false,
"0.1.2.3": false,
"0.0.0.0": false,
"255.255.255.255": false,
"::1": false,
"fd00::1": false,
"fe80::1": false,
"::ffff:127.0.0.1": false,
"::ffff:93.184.216.34": true,
"64:ff9b::a00:1": false,
"2002:c0a8:101::1": false,
"224.0.0.1": false,
}
for in, want := range cases {
if got := isPublicIP(netip.MustParseAddr(in)); got != want {
t.Errorf("isPublicIP(%s) = %v, erwartet %v", in, got, want)
}
}
}
// Der echte Wächter muss interne Ziele abweisen, ohne dass ein Request das
// Netz verlässt -- auch nach einer Weiterleitung.
func TestPreviewClientBlocksInternal(t *testing.T) {
internal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("interner Server wurde erreicht")
}))
defer internal.Close()
if _, err := previewGet(context.Background(), internal.URL, "text/html"); err == nil {
t.Fatal("Abruf von 127.0.0.1 wurde nicht blockiert")
}
if err := previewDialGuard("8.8.8.8:5432"); err == nil {
t.Error("Port 5432 wurde nicht blockiert")
}
}
func TestParsePageMeta(t *testing.T) {
doc := `<!doctype html><html><head>
<title> Seiten
titel </title>
<meta property="og:title" content="OG-Titel">
<meta property="og:title" content="zweiter OG-Titel">
<meta name="twitter:image" content="/bild.png">
<meta property="OG:Site_Name" content="Beispiel">
</head><body><meta property="og:image" content="/zu-spaet.png"></body></html>`
m := parsePageMeta(strings.NewReader(doc))
want := map[string]string{
"title": "Seiten titel",
"og:title": "OG-Titel",
"twitter:image": "/bild.png",
"og:site_name": "Beispiel",
}
for k, v := range want {
if m[k] != v {
t.Errorf("%s = %q, erwartet %q", k, m[k], v)
}
}
if _, ok := m["og:image"]; ok {
t.Error("Meta aus dem body wurde übernommen")
}
}
// fakeRemote stellt eine Seite mit og-Tags, ein Bild und einen oEmbed-
// Endpunkt bereit und lenkt die Vorschau-Abrufe für die Testdauer dorthin.
func fakeRemote(t *testing.T) *httptest.Server {
t.Helper()
var buf strings.Builder
img := image.NewRGBA(image.Rect(0, 0, 1280, 720))
png.Encode(&buf, img)
pngData := buf.String()
mux := http.NewServeMux()
mux.HandleFunc("/seite", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<html><head><meta property="og:title" content="Ein Artikel">
<meta property="og:image" content="/bild.png"></head><body></body></html>`))
})
mux.HandleFunc("/umleitung", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/seite", http.StatusFound)
})
mux.HandleFunc("/ohne-titel", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<html><head></head><body>nix</body></html>`))
})
mux.HandleFunc("/bild.png", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write([]byte(pngData))
})
mux.HandleFunc("/oembed", func(w http.ResponseWriter, r *http.Request) {
u, _ := url.Parse(r.URL.Query().Get("url"))
if u == nil || u.Query().Get("v") != "dQw4w9WgXcQ" {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(map[string]string{"title": "Ein Video", "author_name": "Ein Kanal"})
})
mux.HandleFunc("/vi/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write([]byte(pngData))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
oldGuard, oldOEmbed, oldThumbs := previewDialGuard, youtubeOEmbed, youtubeThumbs
previewDialGuard = func(string) error { return nil }
youtubeOEmbed = srv.URL + "/oembed"
// Erster Kandidat fehlt -> der nächste greift.
youtubeThumbs = []string{srv.URL + "/fehlt/%s.jpg", srv.URL + "/vi/%s/hqdefault.jpg"}
t.Cleanup(func() {
previewDialGuard, youtubeOEmbed, youtubeThumbs = oldGuard, oldOEmbed, oldThumbs
})
// Bilder landen relativ zum CWD unter static/media/preview.
dir := t.TempDir()
wd, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chdir(wd) })
return srv
}
func TestFetchPage(t *testing.T) {
remote := fakeRemote(t)
p, img, err := fetchPage(context.Background(), remote.URL+"/umleitung")
if err != nil {
t.Fatal(err)
}
if p.Title != "Ein Artikel" || p.Site != "127.0.0.1" || p.URL != remote.URL+"/umleitung" {
t.Errorf("unerwartete Vorschau %+v", p)
}
// Relatives Bild gegen die Adresse NACH der Weiterleitung aufgelöst.
if len(img) != 1 || img[0] != remote.URL+"/bild.png" {
t.Errorf("bild = %q", img)
}
if _, _, err := fetchPage(context.Background(), remote.URL+"/ohne-titel"); err == nil {
t.Error("Seite ohne Titel ergab eine Vorschau")
}
if _, _, err := fetchPage(context.Background(), remote.URL+"/bild.png"); err == nil {
t.Error("Bild als Seite ergab eine Vorschau")
}
name := thumbPath("x")
if err := storeThumb(context.Background(), img[0], name); err != nil {
t.Fatal(err)
}
f, err := os.Open(name)
if err != nil {
t.Fatal(err)
}
defer f.Close()
cfg, format, err := image.DecodeConfig(f)
if err != nil || format != "jpeg" || cfg.Width != thumbSize || cfg.Height != 180 {
t.Errorf("thumb: %s %dx%d %v", format, cfg.Width, cfg.Height, err)
}
}
// Ende-zu-Ende: Beitrag mit YouTube-Link anlegen, Vorschau holen, im Feed
// sehen; Bearbeiten ohne Link nimmt sie wieder weg.
func TestLinkPreviewInFeed(t *testing.T) {
srv := newTestServer(t)
fakeRemote(t)
c := registerAndLogin(t, srv, "vorschau")
pid := createEntry(t, c, srv, "schau https://youtu.be/dQw4w9WgXcQ und https://example.org")
key := "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
feedPreview := func() *Preview {
t.Helper()
resp, err := http.Get(srv.URL + "/api/entry/feed/0")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var entries []Entry
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
t.Fatal(err)
}
for _, e := range entries {
if e.PID == pid {
return e.Preview
}
}
t.Fatalf("Beitrag %d fehlt im Feed", pid)
return nil
}
if p := feedPreview(); p != nil {
t.Errorf("Vorschau vor dem Abruf: %+v", p)
}
fetchPreview(context.Background(), key)
p := feedPreview()
if p == nil {
t.Fatal("keine Vorschau nach dem Abruf")
}
if p.URL != key || p.Kind != "youtube" || p.Title != "Ein Video" || p.Site != "Ein Kanal" || p.Thumb != thumbPath(key) {
t.Errorf("unerwartete Vorschau %+v", p)
}
if _, err := os.Stat(p.Thumb); err != nil {
t.Errorf("thumb fehlt: %v", err)
}
// Ein privates/gelöschtes Video ergibt keine Vorschau.
other := "https://www.youtube.com/watch?v=aaaaaaaaaaa"
requestPreview(other)
fetchPreview(context.Background(), other)
var status string
db.QueryRow(`SELECT status FROM link_preview WHERE url = $1`, other).Scan(&status)
if status != "none" {
t.Errorf("status = %q, erwartet none", status)
}
resp := postForm(t, c, srv.URL+"/api/entry/"+itoa(pid)+"/edit", url.Values{"content": {"jetzt ohne Link"}})
resp.Body.Close()
if p := feedPreview(); p != nil {
t.Errorf("Vorschau nach Bearbeiten ohne Link: %+v", p)
}
}
func itoa(n int64) string {
b, _ := json.Marshal(n)
return string(b)
}
+172
View File
@@ -0,0 +1,172 @@
package main
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"net/http"
"time"
"golang.org/x/crypto/bcrypt"
)
// Passwort-Reset ohne E-Mail: der Admin erzeugt per CLI (kver reset-link) einen
// Einmal-Link und gibt ihn dem Nutzer persönlich. Der Link zeigt auf
// /reset#<token> -- im Fragment, damit das Token weder im Access-Log noch in
// der Impression-Statistik noch im Referer landet. Die Seite schickt es per
// POST an die API.
// resetTTL ist die Gültigkeit eines Reset-Links. Großzügig, weil er von Hand
// übergeben wird; er gilt ohnehin nur einmal.
const resetTTL = 72 * time.Hour
// errResetInvalid deckt unbekannt, abgelaufen und schon benutzt ab -- für den
// Nutzer ist das dasselbe: einen neuen Link holen.
var errResetInvalid = NotFound("reset.invalid", "Der Link ist ungültig oder abgelaufen.")
// hashResetToken: in der DB steht nur der Hash. SHA-256 statt bcrypt genügt,
// das Token ist 256 Bit Zufall und nicht zu erraten.
func hashResetToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// createResetToken legt für username einen neuen Reset-Link an und liefert das
// Token im Klartext (nur hier sichtbar). Ältere Links desselben Nutzers werden
// ungültig, abgelaufene aller Nutzer weggeräumt.
func createResetToken(username string) (string, time.Time, error) {
var uid int64
err := db.QueryRow(`SELECT uid FROM account WHERE username = $1`, username).Scan(&uid)
if err == sql.ErrNoRows {
return "", time.Time{}, errUserNotFound
} else if err != nil {
return "", time.Time{}, err
}
token, err := newToken()
if err != nil {
return "", time.Time{}, err
}
now := time.Now()
expires := now.Add(resetTTL)
tx, err := db.Begin()
if err != nil {
return "", time.Time{}, err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM password_reset WHERE uid = $1 OR expires < $2`, uid, now.Unix()); err != nil {
return "", time.Time{}, err
}
if _, err := tx.Exec(
`INSERT INTO password_reset (uid, token_hash, created_at, expires) VALUES ($1, $2, $3, $4)`,
uid, hashResetToken(token), now.Unix(), expires.Unix(),
); err != nil {
return "", time.Time{}, err
}
return token, expires, tx.Commit()
}
// handleResetCheck prüft einen Link vorab, damit die Seite den Namen zeigen
// (oder gleich "abgelaufen" melden) kann, bevor jemand ein Passwort tippt.
// POST statt GET, damit das Token nicht in der URL und damit im Log steht.
func handleResetCheck(w http.ResponseWriter, r *http.Request) error {
username, err := resetUsername(r.FormValue("token"))
if err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]string{"username": username})
return nil
}
// resetUsername liefert den Nutzer zu einem gültigen Link, ohne ihn zu
// verbrauchen.
func resetUsername(token string) (string, error) {
var username string
err := db.QueryRow(
`SELECT a.username FROM password_reset p JOIN account a ON a.uid = p.uid
WHERE p.token_hash = $1 AND p.expires >= $2`,
hashResetToken(token), time.Now().Unix(),
).Scan(&username)
if err == sql.ErrNoRows {
return "", errResetInvalid
} else if err != nil {
return "", Internal(err)
}
return username, nil
}
// handleReset setzt das neue Passwort (pass1/pass2 wie bei der Registrierung),
// verbraucht den Link, meldet alle bestehenden Sessions ab und loggt den
// Nutzer direkt neu ein.
func handleReset(w http.ResponseWriter, r *http.Request) error {
// Erst die Eingabe prüfen: ein Tippfehler soll den Link nicht verbrauchen.
pass1 := r.FormValue("pass1")
fields := map[string]string{}
checkNewPassword(pass1, r.FormValue("pass2"), fields)
if len(fields) > 0 {
e := Invalid("user.invalid", "Die Angaben sind unvollständig oder ungültig.")
e.Fields = fields
return e
}
// Vorab ohne Verbrauch prüfen: bcrypt kostet spürbar CPU und soll nicht
// für jedes beliebige Token anlaufen. Verbindlich ist erst das DELETE unten.
token := r.FormValue("token")
if _, err := resetUsername(token); err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(pass1), bcrypt.DefaultCost)
if err != nil {
return Internal(err)
}
tx, err := db.Begin()
if err != nil {
return Internal(err)
}
defer tx.Rollback()
// DELETE ... RETURNING verbraucht den Link atomar: zwei gleichzeitige
// Requests mit demselben Token können nicht beide durchkommen.
var uid int64
err = tx.QueryRow(
`DELETE FROM password_reset WHERE token_hash = $1 AND expires >= $2 RETURNING uid`,
hashResetToken(token), time.Now().Unix(),
).Scan(&uid)
if err == sql.ErrNoRows {
return errResetInvalid
} else if err != nil {
return Internal(err)
}
var username string
if err := tx.QueryRow(
`UPDATE account SET password = $1 WHERE uid = $2 RETURNING username`, hash, uid,
).Scan(&username); err != nil {
// Konto inzwischen gelöscht: der Link führt ins Leere.
if err == sql.ErrNoRows {
return errResetInvalid
}
return Internal(err)
}
// Wer das alte Passwort kannte, soll mit dessen Sessions nicht drin bleiben.
if _, err := tx.Exec(`DELETE FROM session WHERE uid = $1`, uid); err != nil {
return Internal(err)
}
if err := tx.Commit(); err != nil {
return Internal(err)
}
// War im Browser noch jemand anderes angemeldet, wird dessen Cookie gleich
// überschrieben -- die Session dazu soll dann nicht verwaist gültig bleiben.
if s, ok := getSession(r); ok {
db.Exec(`DELETE FROM session WHERE value = $1`, s.Value)
}
if err := startSession(w, r, uid); err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]string{"username": username})
return nil
}
+15 -56
View File
@@ -236,18 +236,19 @@ func handleStats(w http.ResponseWriter, r *http.Request) error {
return nil
}
// handleStatsDetail liefert die Kennzahlen für die Statistik-Seite. Anders als
// 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, votesLeft, votesRight int64
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 = 'left'),
(SELECT COUNT(*) FROM vote WHERE mode = 'right')`).
Scan(&users, &entries, &toplevel, &votesLeft, &votesRight); err != nil {
(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)
}
@@ -257,54 +258,13 @@ func handleStatsDetail(w http.ResponseWriter, r *http.Request) error {
return Internal(err)
}
// Tägliche Impressions + grobe Unique-Netze, jüngste 30 Tage zuerst. Das
// Frontend dreht für die Achse auf aufsteigend.
daily := []map[string]any{}
rows, err := db.Query(
`SELECT day, SUM(hits)::bigint, COUNT(DISTINCT anon_ip)
FROM impression
GROUP BY day
ORDER BY day DESC
LIMIT 30`)
if err != nil {
return Internal(err)
}
defer rows.Close()
for rows.Next() {
var day string
var hits, visitors int64
if err := rows.Scan(&day, &hits, &visitors); err != nil {
return Internal(err)
}
daily = append(daily, map[string]any{"day": day, "impressions": hits, "visitors": visitors})
}
if err := rows.Err(); err != nil {
return Internal(err)
}
// Netze nach Betreiber (autonomes System). Leere asn ('') -> "(unbekannt)".
topASNs := []map[string]any{}
rows, err = db.Query(
`SELECT CASE WHEN asn = '' THEN '(unbekannt)' ELSE asn END AS a,
SUM(hits)::bigint AS h,
COUNT(DISTINCT anon_ip)
FROM impression
GROUP BY a
ORDER BY h DESC
LIMIT 10`)
if err != nil {
return Internal(err)
}
defer rows.Close()
for rows.Next() {
var asn string
var hits, networks int64
if err := rows.Scan(&asn, &hits, &networks); err != nil {
return Internal(err)
}
topASNs = append(topASNs, map[string]any{"asn": asn, "hits": hits, "networks": networks})
}
if err := rows.Err(); err != nil {
// 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)
}
@@ -313,11 +273,10 @@ func handleStatsDetail(w http.ResponseWriter, r *http.Request) error {
"entries": entries,
"toplevel": toplevel,
"replies": entries - toplevel,
"votes": map[string]any{"left": votesLeft, "right": votesRight},
"votes": map[string]any{"yes": votesYes, "no": votesNo},
"impressions_total": imprTotal,
"visitors_total": visitorsTotal,
"daily": daily,
"top_asns": topASNs,
"entries_per_day_30": float64(recent) / 30,
})
return nil
}
+6 -4
View File
@@ -166,9 +166,10 @@ func handleSetAvatar(w http.ResponseWriter, r *http.Request) error {
}
// handleUserDelete löscht den eingeloggten Account dauerhaft, nach Bestätigung
// durch das Passwort (Formularfeld pass1). Sessions und Votes werden in einer
// Transaktion mitgelöscht; die eigenen Beiträge werden soft-gelöscht (als
// [deleted]-Platzhalter erhalten), damit fremde Antworten nicht verwaisen.
// durch das Passwort (Formularfeld pass1). Sessions, Reset-Links und Votes
// werden in einer Transaktion mitgelöscht; die eigenen Beiträge werden
// soft-gelöscht (als [deleted]-Platzhalter erhalten), damit fremde Antworten
// nicht verwaisen.
// Zugehörige Mediendateien werden best effort entfernt.
func handleUserDelete(w http.ResponseWriter, r *http.Request) error {
uid := uidFromContext(r.Context())
@@ -205,8 +206,9 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) error {
}
for _, q := range []string{
`DELETE FROM session WHERE uid = $1`,
`DELETE FROM password_reset WHERE uid = $1`,
`DELETE FROM vote WHERE uid = $1`,
`UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE uid = $1`,
`UPDATE entry SET deleted = 1, content = '', filepath = '', link_url = '', uid = 0 WHERE uid = $1`,
`DELETE FROM account WHERE uid = $1`,
} {
if _, err := tx.Exec(q, uid); err != nil {