Compare commits
9
Commits
2bae608d76
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07a2b64eff | ||
|
|
047ad392d9 | ||
|
|
26b0182391 | ||
|
|
a8f392edba | ||
|
|
764ad50b8e | ||
|
|
81a74375b7 | ||
|
|
b6cf8c7fa0 | ||
|
|
3d2447fe35 | ||
|
|
80c333cd58 |
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user