Compare commits
25
Commits
2714fdb1a5
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07a2b64eff | ||
|
|
047ad392d9 | ||
|
|
26b0182391 | ||
|
|
a8f392edba | ||
|
|
764ad50b8e | ||
|
|
81a74375b7 | ||
|
|
b6cf8c7fa0 | ||
|
|
3d2447fe35 | ||
|
|
80c333cd58 | ||
|
|
2bae608d76 | ||
|
|
43c640dee6 | ||
|
|
df0cfec132 | ||
|
|
d1bd0f5f1d | ||
|
|
4616e8acbb | ||
|
|
ade853f560 | ||
|
|
8e79e94bbb | ||
|
|
4e0d27c4a6 | ||
|
|
9a7282ed1d | ||
|
|
9cac33d167 | ||
|
|
728ac9b45e | ||
|
|
ca65ddcacc | ||
|
|
b82c432bce | ||
|
|
bd5c59bcad | ||
|
|
37279ac90d | ||
|
|
030a5bd943 |
+4
-1
@@ -12,6 +12,9 @@ kver
|
|||||||
*.db.bak
|
*.db.bak
|
||||||
*.env
|
*.env
|
||||||
static/media/
|
static/media/
|
||||||
migrate.sh
|
|
||||||
Containerfile
|
Containerfile
|
||||||
.containerignore
|
.containerignore
|
||||||
|
frontend/node_modules/
|
||||||
|
web/
|
||||||
|
dev-up.sh
|
||||||
|
githooks/
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ original-kver.db
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
static/media/
|
static/media/
|
||||||
prod_backup/
|
prod_backup/
|
||||||
|
frontend/node_modules/
|
||||||
|
/web/
|
||||||
|
|||||||
+14
-6
@@ -1,5 +1,14 @@
|
|||||||
# Build-Stage: statisches Binary, CGO aus (pgx und modernc.org/sqlite für den
|
# Frontend-Stage: React-Build nach /src/web (siehe frontend/vite.config.ts).
|
||||||
# einmaligen Import sind pures Go).
|
# Erst nur die Paketdateien, damit npm ci bei reinen Code-Änderungen aus dem
|
||||||
|
# Cache kommt.
|
||||||
|
FROM docker.io/library/node:24-alpine AS frontend
|
||||||
|
WORKDIR /src/frontend
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Build-Stage: statisches Binary, CGO aus (pgx ist pures Go).
|
||||||
FROM docker.io/library/golang:1.26-alpine AS build
|
FROM docker.io/library/golang:1.26-alpine AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
@@ -12,15 +21,14 @@ FROM docker.io/library/alpine:3.22
|
|||||||
RUN adduser -D -u 1000 kver
|
RUN adduser -D -u 1000 kver
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /kver /usr/local/bin/kver
|
COPY --from=build /kver /usr/local/bin/kver
|
||||||
COPY web/ web/
|
COPY --from=frontend /src/web web/
|
||||||
COPY static/ static/
|
COPY static/ static/
|
||||||
# data: GeoLite2-ASN-DB (und die alte SQLite-DB für den Import);
|
# data: GeoLite2-ASN-DB; static/media: Uploads. Beide als Volume persistieren.
|
||||||
# static/media: Uploads. Beide als Volume persistieren.
|
|
||||||
RUN mkdir -p data static/media && chown kver:kver data static/media
|
RUN mkdir -p data static/media && chown kver:kver data static/media
|
||||||
USER kver
|
USER kver
|
||||||
ENV KVER_ADDR=:8080
|
ENV KVER_ADDR=:8080
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
VOLUME ["/app/data", "/app/static/media"]
|
VOLUME ["/app/data", "/app/static/media"]
|
||||||
HEALTHCHECK --interval=30s --timeout=3s \
|
HEALTHCHECK --interval=30s --timeout=3s \
|
||||||
CMD wget -qO /dev/null http://localhost:8080/entry/feed/0 || exit 1
|
CMD wget -qO /dev/null http://localhost:8080/api/stats || exit 1
|
||||||
ENTRYPOINT ["/usr/local/bin/kver"]
|
ENTRYPOINT ["/usr/local/bin/kver"]
|
||||||
|
|||||||
@@ -69,8 +69,6 @@ func handleLogin(w http.ResponseWriter, r *http.Request) error {
|
|||||||
return Invalid("user.name_short", "Der Nutzername ist zu kurz.").At("user")
|
return Invalid("user.name_short", "Der Nutzername ist zu kurz.").At("user")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gleiche Meldung für "Nutzer existiert nicht" und "Passwort falsch": sonst
|
|
||||||
// wird der Endpunkt zum Nutzernamen-Orakel.
|
|
||||||
var uid int64
|
var uid int64
|
||||||
var hash []byte
|
var hash []byte
|
||||||
err := db.QueryRow(`SELECT uid, password FROM account WHERE username = $1`, username).Scan(&uid, &hash)
|
err := db.QueryRow(`SELECT uid, password FROM account WHERE username = $1`, username).Scan(&uid, &hash)
|
||||||
@@ -96,9 +94,10 @@ func handleLogin(w http.ResponseWriter, r *http.Request) error {
|
|||||||
func startSession(w http.ResponseWriter, r *http.Request, uid int64) error {
|
func startSession(w http.ResponseWriter, r *http.Request, uid int64) error {
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
// timeout ist clientgesteuert -> serverseitig deckeln, damit niemand eine
|
// timeout ist clientgesteuert -> serverseitig deckeln, damit niemand eine
|
||||||
// quasi-unbegrenzte Session anlegen kann. Default 1 Tag, Maximum 30 Tage.
|
// quasi-unbegrenzte Session anlegen kann. Default 1 Tag, Maximum 1 Jahr
|
||||||
|
// (größte Option im Login-Formular).
|
||||||
timeoutSec, _ := strconv.ParseInt(r.FormValue("timeout"), 10, 64)
|
timeoutSec, _ := strconv.ParseInt(r.FormValue("timeout"), 10, 64)
|
||||||
const maxTimeout = 30 * 86400
|
const maxTimeout = 365 * 86400
|
||||||
if timeoutSec <= 0 {
|
if timeoutSec <= 0 {
|
||||||
timeoutSec = 86400
|
timeoutSec = 86400
|
||||||
}
|
}
|
||||||
@@ -133,10 +132,20 @@ func startSession(w http.ResponseWriter, r *http.Request, uid int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleLogout(w http.ResponseWriter, r *http.Request) {
|
func handleLogout(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
// Scheitert das Löschen, bliebe die Session serverseitig gültig -- das
|
||||||
|
// muss der Client erfahren, statt ein "ok" zu sehen.
|
||||||
if s, ok := getSession(r); ok {
|
if s, ok := getSession(r); ok {
|
||||||
db.Exec(`DELETE FROM session WHERE value = $1`, s.Value)
|
if _, err := db.Exec(`DELETE FROM session WHERE value = $1`, s.Value); err != nil {
|
||||||
|
return Internal(err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
clearSessionCookie(w, r)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: "session",
|
Name: "session",
|
||||||
Value: "",
|
Value: "",
|
||||||
@@ -147,7 +156,6 @@ func handleLogout(w http.ResponseWriter, r *http.Request) {
|
|||||||
Secure: isHTTPS(r),
|
Secure: isHTTPS(r),
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleNewUser sammelt alle Validierungsfehler ein, statt beim ersten
|
// handleNewUser sammelt alle Validierungsfehler ein, statt beim ersten
|
||||||
@@ -159,23 +167,12 @@ func handleNewUser(w http.ResponseWriter, r *http.Request) error {
|
|||||||
username := sanitizeUsername(r.FormValue("user"))
|
username := sanitizeUsername(r.FormValue("user"))
|
||||||
if len(username) < 3 {
|
if len(username) < 3 {
|
||||||
fields["user"] = "Der Nutzername ist zu kurz."
|
fields["user"] = "Der Nutzername ist zu kurz."
|
||||||
} else if idFromUsername(username) != 0 {
|
} else if usernameTaken(username) {
|
||||||
fields["user"] = "Der Nutzername wird bereits verwendet."
|
fields["user"] = "Der Nutzername wird bereits verwendet."
|
||||||
}
|
}
|
||||||
|
|
||||||
pass1 := r.FormValue("pass1")
|
pass1 := r.FormValue("pass1")
|
||||||
pass2 := r.FormValue("pass2")
|
checkNewPassword(pass1, r.FormValue("pass2"), fields)
|
||||||
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."
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(fields) > 0 {
|
if len(fields) > 0 {
|
||||||
e := Invalid("user.invalid", "Die Angaben sind unvollständig oder ungültig.")
|
e := Invalid("user.invalid", "Die Angaben sind unvollständig oder ungültig.")
|
||||||
@@ -196,6 +193,22 @@ func handleNewUser(w http.ResponseWriter, r *http.Request) error {
|
|||||||
return nil
|
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 {
|
func handleSessionInfo(w http.ResponseWriter, r *http.Request) error {
|
||||||
s, ok := getSession(r)
|
s, ok := getSession(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -210,18 +223,8 @@ func handleSessionInfo(w http.ResponseWriter, r *http.Request) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleHeaderbar(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_, ok := getSession(r)
|
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"loggedin": ok})
|
|
||||||
}
|
|
||||||
|
|
||||||
func sanitizeUsername(u string) string {
|
func sanitizeUsername(u string) string {
|
||||||
u = strings.TrimSpace(u)
|
u = truncateRunes(strings.TrimSpace(u), 32)
|
||||||
// Auf 32 Zeichen (Runen) kürzen, nicht 32 Bytes -- sonst kann der Schnitt
|
|
||||||
// mitten in eine Multibyte-Rune fallen und kaputtes UTF-8 erzeugen.
|
|
||||||
if r := []rune(u); len(r) > 32 {
|
|
||||||
u = string(r[:32])
|
|
||||||
}
|
|
||||||
u = reNonWord.ReplaceAllString(u, "")
|
u = reNonWord.ReplaceAllString(u, "")
|
||||||
u = reSpace.ReplaceAllString(u, "_")
|
u = reSpace.ReplaceAllString(u, "_")
|
||||||
return u
|
return u
|
||||||
|
|||||||
@@ -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,
|
bump_count BIGINT NOT NULL DEFAULT 0,
|
||||||
last_bump 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 (
|
CREATE TABLE IF NOT EXISTS vote (
|
||||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
@@ -71,11 +73,8 @@ CREATE TABLE IF NOT EXISTS impression (
|
|||||||
PRIMARY KEY (day, anon_ip, path)
|
PRIMARY KEY (day, anon_ip, path)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Meldungen zu Beiträgen (DSA-Melde-/Abhilfeverfahren). Bewusst ohne Auth:
|
-- Meldungen von Beiträgen. handled bleibt vorerst 0; die Abarbeitung folgt
|
||||||
-- jeder darf melden, weil es eine Moderationshilfe ist. uid ist der Melder,
|
-- mit der Moderation (notes/todo.md).
|
||||||
-- vorerst immer 0 (anonym) -- die Spalte existiert nur, um bei Spam später den
|
|
||||||
-- Absender nachziehen zu können, ohne das Schema zu ändern. handled: 0 offen,
|
|
||||||
-- 1 von der Moderation erledigt. reason ist ein optionaler Freitext.
|
|
||||||
CREATE TABLE IF NOT EXISTS report (
|
CREATE TABLE IF NOT EXISTS report (
|
||||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
pid BIGINT NOT NULL,
|
pid BIGINT NOT NULL,
|
||||||
@@ -85,6 +84,30 @@ CREATE TABLE IF NOT EXISTS report (
|
|||||||
handled BIGINT NOT NULL DEFAULT 0
|
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
|
-- Indizes für die häufigen Zugriffspfade; ohne sie werden Feed (ORDER BY
|
||||||
-- last_activity), Thread (WHERE reply_to) und Vote-Zähler (WHERE pid) mit
|
-- last_activity), Thread (WHERE reply_to) und Vote-Zähler (WHERE pid) mit
|
||||||
-- wachsender Tabelle zu Full-Table-Scans. username/session.value/vote(uid,pid)
|
-- wachsender Tabelle zu Full-Table-Scans. username/session.value/vote(uid,pid)
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# dev-up.sh — baut das Image aus dem aktuellen Arbeitsverzeichnis neu und
|
||||||
|
# startet den lokalen DEV-Container (kver-dev) damit neu.
|
||||||
|
#
|
||||||
|
# Verwendung:
|
||||||
|
# ./dev-up.sh
|
||||||
|
#
|
||||||
|
# Läuft auch automatisch nach jedem Commit (githooks/post-commit, im
|
||||||
|
# Hintergrund, Log in .git/dev-up.log). Einrichten einmalig per:
|
||||||
|
# git config core.hooksPath githooks
|
||||||
|
#
|
||||||
|
# Voraussetzungen:
|
||||||
|
# - dev.env mit KVER_DSN (Host "localhost" wird für den Container auf
|
||||||
|
# host.containers.internal umgeschrieben)
|
||||||
|
# - Daten/Medien aus dem PROD-Dump unter $KVER_DEV_DATA (read-only gemountet)
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
NAME=kver-dev
|
||||||
|
IMAGE=localhost/kver:dev
|
||||||
|
PORT="${KVER_DEV_PORT:-8081}"
|
||||||
|
DATA="${KVER_DEV_DATA:-./prod_backup/2026-09-26}"
|
||||||
|
|
||||||
|
# Nur ein Lauf gleichzeitig (schnell aufeinanderfolgende Commits). podman
|
||||||
|
# bekommt fd 9 jeweils nicht mit (9>&-): conmon lebt so lange wie der
|
||||||
|
# Container und würde den Lock sonst für immer halten.
|
||||||
|
exec 9>.git/dev-up.lock
|
||||||
|
flock 9
|
||||||
|
|
||||||
|
DSN="$(grep '^KVER_DSN=' dev.env | cut -d= -f2- | sed 's/@localhost:/@host.containers.internal:/')"
|
||||||
|
if [[ -z "$DSN" ]]; then
|
||||||
|
echo "FEHLER: KVER_DSN fehlt in dev.env." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== $(date '+%F %T') Baue $IMAGE ($(git rev-parse --short HEAD))"
|
||||||
|
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)
|
||||||
|
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" \
|
||||||
|
-e KVER_DSN="$DSN" \
|
||||||
|
"${mounts[@]}" \
|
||||||
|
--security-opt label=disable "$IMAGE" >/dev/null 9>&-
|
||||||
|
|
||||||
|
# Warten, bis die API antwortet.
|
||||||
|
for _ in $(seq 30); do
|
||||||
|
if curl -fs -o /dev/null "http://127.0.0.1:$PORT/api/stats"; then
|
||||||
|
echo "== läuft: http://127.0.0.1:$PORT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "FEHLER: $NAME antwortet nicht. Log:" >&2
|
||||||
|
podman logs --tail 20 "$NAME" >&2
|
||||||
|
exit 1
|
||||||
+329
-82
@@ -84,7 +84,7 @@ func registerAndLogin(t *testing.T, srv *httptest.Server, username string) *http
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
c := newClient(t)
|
c := newClient(t)
|
||||||
|
|
||||||
resp := postForm(t, c, srv.URL+"/auth/newuser", url.Values{
|
resp := postForm(t, c, srv.URL+"/api/auth/newuser", url.Values{
|
||||||
"user": {username}, "pass1": {"supersecret1"}, "pass2": {"supersecret1"},
|
"user": {username}, "pass1": {"supersecret1"}, "pass2": {"supersecret1"},
|
||||||
})
|
})
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
@@ -92,7 +92,7 @@ func registerAndLogin(t *testing.T, srv *httptest.Server, username string) *http
|
|||||||
t.Fatalf("register %s: status %d", username, resp.StatusCode)
|
t.Fatalf("register %s: status %d", username, resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp = postForm(t, c, srv.URL+"/auth/login", url.Values{
|
resp = postForm(t, c, srv.URL+"/api/auth/login", url.Values{
|
||||||
"user": {username}, "pass": {"supersecret1"}, "timeout": {"86400"},
|
"user": {username}, "pass": {"supersecret1"}, "timeout": {"86400"},
|
||||||
})
|
})
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
@@ -105,7 +105,7 @@ func registerAndLogin(t *testing.T, srv *httptest.Server, username string) *http
|
|||||||
// createEntry erstellt einen Beitrag und liefert dessen pid.
|
// createEntry erstellt einen Beitrag und liefert dessen pid.
|
||||||
func createEntry(t *testing.T, c *http.Client, srv *httptest.Server, content string) int64 {
|
func createEntry(t *testing.T, c *http.Client, srv *httptest.Server, content string) int64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
resp := postForm(t, c, srv.URL+"/entry/create", url.Values{"content": {content}})
|
resp := postForm(t, c, srv.URL+"/api/entry/create", url.Values{"content": {content}})
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusCreated {
|
if resp.StatusCode != http.StatusCreated {
|
||||||
t.Fatalf("create entry: status %d", resp.StatusCode)
|
t.Fatalf("create entry: status %d", resp.StatusCode)
|
||||||
@@ -119,44 +119,34 @@ func createEntry(t *testing.T, c *http.Client, srv *httptest.Server, content str
|
|||||||
return out.PID
|
return out.PID
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegisterLoginHeaderbar(t *testing.T) {
|
func TestRegisterLoginSession(t *testing.T) {
|
||||||
srv := newTestServer(t)
|
srv := newTestServer(t)
|
||||||
c := registerAndLogin(t, srv, "alice")
|
c := registerAndLogin(t, srv, "alice")
|
||||||
|
|
||||||
resp, err := c.Get(srv.URL + "/auth/headerbar")
|
if got := getStatus(t, c, srv.URL+"/api/auth/sessioninfo"); got != http.StatusOK {
|
||||||
|
t.Fatalf("sessioninfo nach Login: erwartet 200, bekam %d", got)
|
||||||
|
}
|
||||||
|
// Anonymer Client hat keine Session.
|
||||||
|
if got := getStatus(t, newClient(t), srv.URL+"/api/auth/sessioninfo"); got != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("sessioninfo anonym: erwartet 401, bekam %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getStatus macht einen GET und liefert nur den Statuscode.
|
||||||
|
func getStatus(t *testing.T, c *http.Client, urlStr string) int {
|
||||||
|
t.Helper()
|
||||||
|
resp, err := c.Get(urlStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
return resp.StatusCode
|
||||||
var h struct {
|
|
||||||
LoggedIn bool `json:"loggedin"`
|
|
||||||
}
|
|
||||||
json.NewDecoder(resp.Body).Decode(&h)
|
|
||||||
if !h.LoggedIn {
|
|
||||||
t.Fatal("erwartete loggedin=true nach Login")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Anonymer Client ist nicht eingeloggt.
|
|
||||||
anon := newClient(t)
|
|
||||||
resp2, err := anon.Get(srv.URL + "/auth/headerbar")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer resp2.Body.Close()
|
|
||||||
var h2 struct {
|
|
||||||
LoggedIn bool `json:"loggedin"`
|
|
||||||
}
|
|
||||||
json.NewDecoder(resp2.Body).Decode(&h2)
|
|
||||||
if h2.LoggedIn {
|
|
||||||
t.Fatal("anonymer Client sollte nicht eingeloggt sein")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFeedEmptyThenPopulated(t *testing.T) {
|
func TestFeedEmptyThenPopulated(t *testing.T) {
|
||||||
srv := newTestServer(t)
|
srv := newTestServer(t)
|
||||||
|
|
||||||
resp, err := http.Get(srv.URL + "/entry/feed/0")
|
resp, err := http.Get(srv.URL + "/api/entry/feed/0")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -170,7 +160,7 @@ func TestFeedEmptyThenPopulated(t *testing.T) {
|
|||||||
alice := registerAndLogin(t, srv, "alice")
|
alice := registerAndLogin(t, srv, "alice")
|
||||||
createEntry(t, alice, srv, "hallo welt")
|
createEntry(t, alice, srv, "hallo welt")
|
||||||
|
|
||||||
resp, err = http.Get(srv.URL + "/entry/feed/0")
|
resp, err = http.Get(srv.URL + "/api/entry/feed/0")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -189,7 +179,7 @@ func TestCreateEntryRequiresAuth(t *testing.T) {
|
|||||||
srv := newTestServer(t)
|
srv := newTestServer(t)
|
||||||
|
|
||||||
anon := newClient(t)
|
anon := newClient(t)
|
||||||
resp := postForm(t, anon, srv.URL+"/entry/create", url.Values{"content": {"x"}})
|
resp := postForm(t, anon, srv.URL+"/api/entry/create", url.Values{"content": {"x"}})
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusUnauthorized {
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
t.Fatalf("create ohne Login: erwartet 401, bekam %d", resp.StatusCode)
|
t.Fatalf("create ohne Login: erwartet 401, bekam %d", resp.StatusCode)
|
||||||
@@ -197,8 +187,8 @@ func TestCreateEntryRequiresAuth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type tally struct {
|
type tally struct {
|
||||||
Left int `json:"left"`
|
Yes int `json:"yes"`
|
||||||
Right int `json:"right"`
|
No int `json:"no"`
|
||||||
Selected string `json:"selected"`
|
Selected string `json:"selected"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +199,7 @@ func TestVoting(t *testing.T) {
|
|||||||
pid := createEntry(t, alice, srv, "vote me")
|
pid := createEntry(t, alice, srv, "vote me")
|
||||||
|
|
||||||
vote := func(c *http.Client, mode string) tally {
|
vote := func(c *http.Client, mode string) tally {
|
||||||
resp := postForm(t, c, fmt.Sprintf("%s/entry/%d/vote", srv.URL, pid), url.Values{"mode": {mode}})
|
resp := postForm(t, c, fmt.Sprintf("%s/api/entry/%d/vote", srv.URL, pid), url.Values{"mode": {mode}})
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
t.Fatalf("vote %s: status %d", mode, resp.StatusCode)
|
t.Fatalf("vote %s: status %d", mode, resp.StatusCode)
|
||||||
@@ -219,16 +209,16 @@ func TestVoting(t *testing.T) {
|
|||||||
return tl
|
return tl
|
||||||
}
|
}
|
||||||
|
|
||||||
if got := vote(alice, "left"); got.Left != 1 || got.Selected != "left" {
|
if got := vote(alice, "yes"); got.Yes != 1 || got.Selected != "yes" {
|
||||||
t.Fatalf("alice left: %+v", got)
|
t.Fatalf("alice yes: %+v", got)
|
||||||
}
|
}
|
||||||
if got := vote(bob, "right"); got.Left != 1 || got.Right != 1 {
|
if got := vote(bob, "no"); got.Yes != 1 || got.No != 1 {
|
||||||
t.Fatalf("bob right: %+v", got)
|
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)
|
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)
|
t.Fatalf("alice switch: %+v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,10 +227,10 @@ func TestVoteRequiresAuthAndValidMode(t *testing.T) {
|
|||||||
srv := newTestServer(t)
|
srv := newTestServer(t)
|
||||||
alice := registerAndLogin(t, srv, "alice")
|
alice := registerAndLogin(t, srv, "alice")
|
||||||
pid := createEntry(t, alice, srv, "x")
|
pid := createEntry(t, alice, srv, "x")
|
||||||
voteURL := fmt.Sprintf("%s/entry/%d/vote", srv.URL, pid)
|
voteURL := fmt.Sprintf("%s/api/entry/%d/vote", srv.URL, pid)
|
||||||
|
|
||||||
anon := newClient(t)
|
anon := newClient(t)
|
||||||
resp := postForm(t, anon, voteURL, url.Values{"mode": {"left"}})
|
resp := postForm(t, anon, voteURL, url.Values{"mode": {"yes"}})
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusUnauthorized {
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
t.Fatalf("anon vote: erwartet 401, bekam %d", resp.StatusCode)
|
t.Fatalf("anon vote: erwartet 401, bekam %d", resp.StatusCode)
|
||||||
@@ -258,7 +248,7 @@ func TestBump(t *testing.T) {
|
|||||||
alice := registerAndLogin(t, srv, "alice")
|
alice := registerAndLogin(t, srv, "alice")
|
||||||
bob := registerAndLogin(t, srv, "bob")
|
bob := registerAndLogin(t, srv, "bob")
|
||||||
pid := createEntry(t, alice, srv, "bump me")
|
pid := createEntry(t, alice, srv, "bump me")
|
||||||
bumpURL := fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid)
|
bumpURL := fmt.Sprintf("%s/api/entry/%d/bump", srv.URL, pid)
|
||||||
|
|
||||||
// Anonym: 401.
|
// Anonym: 401.
|
||||||
resp := postForm(t, newClient(t), bumpURL, url.Values{})
|
resp := postForm(t, newClient(t), bumpURL, url.Values{})
|
||||||
@@ -292,9 +282,9 @@ func TestBump(t *testing.T) {
|
|||||||
|
|
||||||
// Gelöschter Beitrag lässt sich nicht bumpen.
|
// Gelöschter Beitrag lässt sich nicht bumpen.
|
||||||
del := createEntry(t, alice, srv, "weg gleich")
|
del := createEntry(t, alice, srv, "weg gleich")
|
||||||
resp = postForm(t, alice, fmt.Sprintf("%s/entry/%d/delete", srv.URL, del), url.Values{})
|
resp = postForm(t, alice, fmt.Sprintf("%s/api/entry/%d/delete", srv.URL, del), url.Values{})
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
resp = postForm(t, alice, fmt.Sprintf("%s/entry/%d/bump", srv.URL, del), url.Values{})
|
resp = postForm(t, alice, fmt.Sprintf("%s/api/entry/%d/bump", srv.URL, del), url.Values{})
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusNotFound {
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
t.Fatalf("bump gelöscht: erwartet 404, bekam %d", resp.StatusCode)
|
t.Fatalf("bump gelöscht: erwartet 404, bekam %d", resp.StatusCode)
|
||||||
@@ -304,7 +294,7 @@ func TestBump(t *testing.T) {
|
|||||||
// createReply erstellt eine Antwort auf parentPID und liefert deren pid.
|
// createReply erstellt eine Antwort auf parentPID und liefert deren pid.
|
||||||
func createReply(t *testing.T, c *http.Client, srv *httptest.Server, content string, parentPID int64) int64 {
|
func createReply(t *testing.T, c *http.Client, srv *httptest.Server, content string, parentPID int64) int64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
resp := postForm(t, c, srv.URL+"/entry/create", url.Values{
|
resp := postForm(t, c, srv.URL+"/api/entry/create", url.Values{
|
||||||
"content": {content}, "reply_to": {fmt.Sprint(parentPID)},
|
"content": {content}, "reply_to": {fmt.Sprint(parentPID)},
|
||||||
})
|
})
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -324,6 +314,10 @@ type threadEntry struct {
|
|||||||
Deleted int64 `json:"deleted"`
|
Deleted int64 `json:"deleted"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
|
Latest *struct {
|
||||||
|
Entry threadEntry `json:"entry"`
|
||||||
|
Skipped int64 `json:"skipped"`
|
||||||
|
} `json:"latest"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// threadJSON ist die Antwortform von /entry/{pid}/thread. Nicht zu verwechseln
|
// threadJSON ist die Antwortform von /entry/{pid}/thread. Nicht zu verwechseln
|
||||||
@@ -336,7 +330,7 @@ type threadJSON struct {
|
|||||||
|
|
||||||
func getThread(t *testing.T, srv *httptest.Server, pid int64) threadJSON {
|
func getThread(t *testing.T, srv *httptest.Server, pid int64) threadJSON {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
resp, err := http.Get(fmt.Sprintf("%s/entry/%d/thread", srv.URL, pid))
|
resp, err := http.Get(fmt.Sprintf("%s/api/entry/%d/thread", srv.URL, pid))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -378,7 +372,7 @@ func TestThreading(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Hauptfeed: nur der Root, keine Antworten.
|
// Hauptfeed: nur der Root, keine Antworten.
|
||||||
resp, _ := http.Get(srv.URL + "/entry/feed/0")
|
resp, _ := http.Get(srv.URL + "/api/entry/feed/0")
|
||||||
var feed []struct {
|
var feed []struct {
|
||||||
PID int64 `json:"pid"`
|
PID int64 `json:"pid"`
|
||||||
}
|
}
|
||||||
@@ -389,7 +383,7 @@ func TestThreading(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Profil-Feed: alle drei Beiträge (Root + Antworten).
|
// Profil-Feed: alle drei Beiträge (Root + Antworten).
|
||||||
resp, _ = http.Get(srv.URL + "/u/alice/feed/0")
|
resp, _ = http.Get(srv.URL + "/api/u/alice/feed/0")
|
||||||
var profile []struct {
|
var profile []struct {
|
||||||
PID int64 `json:"pid"`
|
PID int64 `json:"pid"`
|
||||||
}
|
}
|
||||||
@@ -406,7 +400,7 @@ func TestEditEntry(t *testing.T) {
|
|||||||
bob := registerAndLogin(t, srv, "bob")
|
bob := registerAndLogin(t, srv, "bob")
|
||||||
|
|
||||||
pid := createEntry(t, alice, srv, "original")
|
pid := createEntry(t, alice, srv, "original")
|
||||||
editURL := fmt.Sprintf("%s/entry/%d/edit", srv.URL, pid)
|
editURL := fmt.Sprintf("%s/api/entry/%d/edit", srv.URL, pid)
|
||||||
|
|
||||||
// Eigentümer bearbeitet -> 200, neuer Inhalt sichtbar.
|
// Eigentümer bearbeitet -> 200, neuer Inhalt sichtbar.
|
||||||
resp := postForm(t, alice, editURL, url.Values{"content": {"korrigiert"}})
|
resp := postForm(t, alice, editURL, url.Values{"content": {"korrigiert"}})
|
||||||
@@ -433,14 +427,14 @@ func TestEditEntry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Unbekannte pid -> 404.
|
// Unbekannte pid -> 404.
|
||||||
resp = postForm(t, alice, srv.URL+"/entry/999999/edit", url.Values{"content": {"x"}})
|
resp = postForm(t, alice, srv.URL+"/api/entry/999999/edit", url.Values{"content": {"x"}})
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusNotFound {
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
t.Fatalf("edit unbekannt: erwartet 404, bekam %d", resp.StatusCode)
|
t.Fatalf("edit unbekannt: erwartet 404, bekam %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nach Soft-Delete ist der Beitrag nicht mehr bearbeitbar (uid=0) -> 403.
|
// Nach Soft-Delete ist der Beitrag nicht mehr bearbeitbar (uid=0) -> 403.
|
||||||
postForm(t, alice, fmt.Sprintf("%s/entry/%d/delete", srv.URL, pid), url.Values{}).Body.Close()
|
postForm(t, alice, fmt.Sprintf("%s/api/entry/%d/delete", srv.URL, pid), url.Values{}).Body.Close()
|
||||||
resp = postForm(t, alice, editURL, url.Values{"content": {"wieder da"}})
|
resp = postForm(t, alice, editURL, url.Values{"content": {"wieder da"}})
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusForbidden {
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
@@ -448,6 +442,64 @@ func TestEditEntry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// latestOf beschreibt Latest eines Beitrags knapp für Vergleiche und
|
||||||
|
// Fehlermeldungen: "pid/skipped" oder "-".
|
||||||
|
func latestOf(e threadEntry) string {
|
||||||
|
if e.Latest == nil {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d/%d", e.Latest.Entry.PID, e.Latest.Skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatestDescendant(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
alice := registerAndLogin(t, srv, "alice")
|
||||||
|
|
||||||
|
// root ── a ── b ── c
|
||||||
|
// └─ d (d vor b und c erstellt)
|
||||||
|
root := createEntry(t, alice, srv, "wurzel")
|
||||||
|
a := createReply(t, alice, srv, "a", root)
|
||||||
|
d := createReply(t, alice, srv, "d", root)
|
||||||
|
b := createReply(t, alice, srv, "b", a)
|
||||||
|
c := createReply(t, alice, srv, "c", b)
|
||||||
|
|
||||||
|
feed := func() []threadEntry {
|
||||||
|
resp, err := http.Get(srv.URL + "/api/entry/feed/0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var f []threadEntry
|
||||||
|
json.NewDecoder(resp.Body).Decode(&f)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed: Root mit c, dazwischen a und b.
|
||||||
|
if f := feed(); len(f) != 1 || latestOf(f[0]) != fmt.Sprintf("%d/2", c) {
|
||||||
|
t.Fatalf("feed latest: erwartet %d/2, bekam %s", c, latestOf(f[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Antworten auf Root: a mit c (b dazwischen), d ohne Nachfahren.
|
||||||
|
want := map[int64]string{a: fmt.Sprintf("%d/1", c), d: "-"}
|
||||||
|
for _, r := range getThread(t, srv, root).Replies {
|
||||||
|
if got := latestOf(r); got != want[r.PID] {
|
||||||
|
t.Fatalf("reply %d latest: erwartet %s, bekam %s", r.PID, want[r.PID], got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direkte Antwort: skipped 0.
|
||||||
|
if r := getThread(t, srv, a).Replies; len(r) != 1 || latestOf(r[0]) != fmt.Sprintf("%d/0", c) {
|
||||||
|
t.Fatalf("b latest: erwartet %d/0, bekam %+v", c, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gelöschter jüngster Beitrag wird übersprungen -> nächstjüngerer (b).
|
||||||
|
resp := postForm(t, alice, fmt.Sprintf("%s/api/entry/%d/delete", srv.URL, c), url.Values{})
|
||||||
|
resp.Body.Close()
|
||||||
|
if f := feed(); latestOf(f[0]) != fmt.Sprintf("%d/1", b) {
|
||||||
|
t.Fatalf("feed latest nach delete: erwartet %d/1, bekam %s", b, latestOf(f[0]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSoftDelete(t *testing.T) {
|
func TestSoftDelete(t *testing.T) {
|
||||||
srv := newTestServer(t)
|
srv := newTestServer(t)
|
||||||
alice := registerAndLogin(t, srv, "alice")
|
alice := registerAndLogin(t, srv, "alice")
|
||||||
@@ -456,7 +508,7 @@ func TestSoftDelete(t *testing.T) {
|
|||||||
root := createEntry(t, alice, srv, "wurzel")
|
root := createEntry(t, alice, srv, "wurzel")
|
||||||
reply := createReply(t, alice, srv, "antwort", root)
|
reply := createReply(t, alice, srv, "antwort", root)
|
||||||
|
|
||||||
delURL := fmt.Sprintf("%s/entry/%d/delete", srv.URL, root)
|
delURL := fmt.Sprintf("%s/api/entry/%d/delete", srv.URL, root)
|
||||||
|
|
||||||
// Fremder darf nicht löschen.
|
// Fremder darf nicht löschen.
|
||||||
resp := postForm(t, bob, delURL, url.Values{})
|
resp := postForm(t, bob, delURL, url.Values{})
|
||||||
@@ -482,7 +534,7 @@ func TestSoftDelete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Der gelöschte Root erscheint weiterhin im Hauptfeed (Thread bleibt erreichbar).
|
// Der gelöschte Root erscheint weiterhin im Hauptfeed (Thread bleibt erreichbar).
|
||||||
feedResp, _ := http.Get(srv.URL + "/entry/feed/0")
|
feedResp, _ := http.Get(srv.URL + "/api/entry/feed/0")
|
||||||
var feed []threadEntry
|
var feed []threadEntry
|
||||||
json.NewDecoder(feedResp.Body).Decode(&feed)
|
json.NewDecoder(feedResp.Body).Decode(&feed)
|
||||||
feedResp.Body.Close()
|
feedResp.Body.Close()
|
||||||
@@ -491,34 +543,82 @@ func TestSoftDelete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Nicht existierender Beitrag -> 404.
|
// Nicht existierender Beitrag -> 404.
|
||||||
resp = postForm(t, alice, srv.URL+"/entry/999999/delete", url.Values{})
|
resp = postForm(t, alice, srv.URL+"/api/entry/999999/delete", url.Values{})
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusNotFound {
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
t.Fatalf("delete unbekannt: erwartet 404, bekam %d", resp.StatusCode)
|
t.Fatalf("delete unbekannt: erwartet 404, bekam %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestRenameUser(t *testing.T) {
|
||||||
srv := newTestServer(t)
|
srv := newTestServer(t)
|
||||||
alice := registerAndLogin(t, srv, "alice")
|
alice := registerAndLogin(t, srv, "alice")
|
||||||
registerAndLogin(t, srv, "bob")
|
registerAndLogin(t, srv, "bob")
|
||||||
|
|
||||||
// Zu kurz -> 400.
|
// Zu kurz -> 400.
|
||||||
resp := postForm(t, alice, srv.URL+"/user/rename", url.Values{"user": {"ab"}})
|
resp := postForm(t, alice, srv.URL+"/api/user/rename", url.Values{"user": {"ab"}})
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusBadRequest {
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
t.Fatalf("zu kurz: erwartet 400, bekam %d", resp.StatusCode)
|
t.Fatalf("zu kurz: erwartet 400, bekam %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bereits vergeben -> 409.
|
// Bereits vergeben -> 409.
|
||||||
resp = postForm(t, alice, srv.URL+"/user/rename", url.Values{"user": {"bob"}})
|
resp = postForm(t, alice, srv.URL+"/api/user/rename", url.Values{"user": {"bob"}})
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusConflict {
|
if resp.StatusCode != http.StatusConflict {
|
||||||
t.Fatalf("vergeben: erwartet 409, bekam %d", resp.StatusCode)
|
t.Fatalf("vergeben: erwartet 409, bekam %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gültig -> 200 + neuer Name.
|
// Gültig -> 200 + neuer Name.
|
||||||
resp = postForm(t, alice, srv.URL+"/user/rename", url.Values{"user": {"alice2"}})
|
resp = postForm(t, alice, srv.URL+"/api/user/rename", url.Values{"user": {"alice2"}})
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
t.Fatalf("rename: erwartet 200, bekam %d", resp.StatusCode)
|
t.Fatalf("rename: erwartet 200, bekam %d", resp.StatusCode)
|
||||||
@@ -532,12 +632,12 @@ func TestRenameUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Profil unter dem neuen Namen erreichbar, alter Name weg.
|
// Profil unter dem neuen Namen erreichbar, alter Name weg.
|
||||||
r2, _ := http.Get(srv.URL + "/u/alice2/info")
|
r2, _ := http.Get(srv.URL + "/api/u/alice2/info")
|
||||||
r2.Body.Close()
|
r2.Body.Close()
|
||||||
if r2.StatusCode != http.StatusOK {
|
if r2.StatusCode != http.StatusOK {
|
||||||
t.Fatalf("/u/alice2/info: erwartet 200, bekam %d", r2.StatusCode)
|
t.Fatalf("/u/alice2/info: erwartet 200, bekam %d", r2.StatusCode)
|
||||||
}
|
}
|
||||||
r3, _ := http.Get(srv.URL + "/u/alice/info")
|
r3, _ := http.Get(srv.URL + "/api/u/alice/info")
|
||||||
r3.Body.Close()
|
r3.Body.Close()
|
||||||
if r3.StatusCode != http.StatusNotFound {
|
if r3.StatusCode != http.StatusNotFound {
|
||||||
t.Fatalf("/u/alice/info: erwartet 404, bekam %d", r3.StatusCode)
|
t.Fatalf("/u/alice/info: erwartet 404, bekam %d", r3.StatusCode)
|
||||||
@@ -554,14 +654,14 @@ func TestDeleteUser(t *testing.T) {
|
|||||||
reply := createReply(t, bob, srv, "bobs antwort", root)
|
reply := createReply(t, bob, srv, "bobs antwort", root)
|
||||||
|
|
||||||
// Falsches Passwort -> 403.
|
// Falsches Passwort -> 403.
|
||||||
resp := postForm(t, alice, srv.URL+"/user/delete", url.Values{"pass1": {"falsch"}})
|
resp := postForm(t, alice, srv.URL+"/api/user/delete", url.Values{"pass1": {"falsch"}})
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusForbidden {
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
t.Fatalf("falsches Passwort: erwartet 403, bekam %d", resp.StatusCode)
|
t.Fatalf("falsches Passwort: erwartet 403, bekam %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Richtiges Passwort -> 200.
|
// Richtiges Passwort -> 200.
|
||||||
resp = postForm(t, alice, srv.URL+"/user/delete", url.Values{"pass1": {"supersecret1"}})
|
resp = postForm(t, alice, srv.URL+"/api/user/delete", url.Values{"pass1": {"supersecret1"}})
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
t.Fatalf("delete: erwartet 200, bekam %d", resp.StatusCode)
|
t.Fatalf("delete: erwartet 200, bekam %d", resp.StatusCode)
|
||||||
@@ -569,7 +669,7 @@ func TestDeleteUser(t *testing.T) {
|
|||||||
|
|
||||||
// Login danach schlägt fehl.
|
// Login danach schlägt fehl.
|
||||||
fresh := newClient(t)
|
fresh := newClient(t)
|
||||||
resp = postForm(t, fresh, srv.URL+"/auth/login", url.Values{
|
resp = postForm(t, fresh, srv.URL+"/api/auth/login", url.Values{
|
||||||
"user": {"alice"}, "pass": {"supersecret1"}, "timeout": {"86400"},
|
"user": {"alice"}, "pass": {"supersecret1"}, "timeout": {"86400"},
|
||||||
})
|
})
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
@@ -587,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),
|
// postFormFrom schickt wie postForm als API-Client (Accept: application/json),
|
||||||
// zusätzlich mit gesetzter Client-IP.
|
// zusätzlich mit gesetzter Client-IP.
|
||||||
// postFormFrom verhält sich wie postForm, setzt aber X-Real-IP -> middleware.RealIP
|
// postFormFrom verhält sich wie postForm, setzt aber X-Real-IP -> middleware.RealIP
|
||||||
@@ -613,34 +820,74 @@ func TestLogoutPost(t *testing.T) {
|
|||||||
srv := newTestServer(t)
|
srv := newTestServer(t)
|
||||||
c := registerAndLogin(t, srv, "alice")
|
c := registerAndLogin(t, srv, "alice")
|
||||||
|
|
||||||
resp, err := c.Get(srv.URL + "/auth/logout")
|
if got := getStatus(t, c, srv.URL+"/api/auth/logout"); got != http.StatusMethodNotAllowed {
|
||||||
if err != nil {
|
t.Fatalf("GET logout: erwartet 405, bekam %d", got)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
}
|
||||||
resp.Body.Close()
|
if got := getStatus(t, c, srv.URL+"/api/auth/sessioninfo"); got != http.StatusOK {
|
||||||
// GET fällt in den FileServer-Catch-All -> 404. Hauptsache: kein Logout.
|
t.Fatalf("GET logout darf nicht ausloggen, sessioninfo: %d", got)
|
||||||
if resp.StatusCode != http.StatusNotFound {
|
|
||||||
t.Fatalf("GET logout: erwartet 404, bekam %d", resp.StatusCode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp = postForm(t, c, srv.URL+"/auth/logout", url.Values{})
|
resp := postForm(t, c, srv.URL+"/api/auth/logout", url.Values{})
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
t.Fatalf("POST logout: erwartet 200, bekam %d", resp.StatusCode)
|
t.Fatalf("POST logout: erwartet 200, bekam %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
if got := getStatus(t, c, srv.URL+"/api/auth/sessioninfo"); got != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("nach Logout: erwartet 401, bekam %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resp, err = c.Get(srv.URL + "/auth/headerbar")
|
// TestAPIErrorsAreJSON prüft, dass auch Fehler außerhalb der Handler (unbekannter
|
||||||
|
// Pfad, falsche Methode, Rate-Limit, Panic) als JSON mit passendem Status
|
||||||
|
// hinausgehen.
|
||||||
|
func TestAPIErrorsAreJSON(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
|
||||||
|
check := func(name string, resp *http.Response, wantStatus int, wantCode string) {
|
||||||
|
t.Helper()
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != wantStatus {
|
||||||
|
t.Fatalf("%s: erwartet %d, bekam %d", name, wantStatus, resp.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
|
||||||
|
t.Fatalf("%s: Content-Type %q", name, ct)
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Error apiErrorBody `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil || body.Error.Code != wantCode {
|
||||||
|
t.Fatalf("%s: erwartet code %q, bekam %+v (%v)", name, wantCode, body, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/api/gibtsnicht")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
check("unbekannter Pfad", resp, http.StatusNotFound, "api.not_found")
|
||||||
var h struct {
|
|
||||||
LoggedIn bool `json:"loggedin"`
|
resp, err = http.Get(srv.URL + "/api/entry/create")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
json.NewDecoder(resp.Body).Decode(&h)
|
check("falsche Methode", resp, http.StatusMethodNotAllowed, "api.method_not_allowed")
|
||||||
if h.LoggedIn {
|
|
||||||
t.Fatal("nach Logout sollte loggedin=false sein")
|
limited := limitByIP(1, time.Minute)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {}))
|
||||||
|
for i, want := range []int{http.StatusOK, http.StatusTooManyRequests} {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
limited.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", nil))
|
||||||
|
if rec.Code != want {
|
||||||
|
t.Fatalf("rate-limit Anfrage %d: erwartet %d, bekam %d", i+1, want, rec.Code)
|
||||||
}
|
}
|
||||||
|
if want == http.StatusTooManyRequests {
|
||||||
|
check("rate-limit", rec.Result(), want, "rate.limited")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
recoverJSON(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("kaputt") })).
|
||||||
|
ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
check("panic", rec.Result(), http.StatusInternalServerError, "internal")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCrossOriginPostRejected prüft den CSRF-Schutz: POSTs mit fremdem
|
// TestCrossOriginPostRejected prüft den CSRF-Schutz: POSTs mit fremdem
|
||||||
@@ -650,7 +897,7 @@ func TestCrossOriginPostRejected(t *testing.T) {
|
|||||||
c := registerAndLogin(t, srv, "alice")
|
c := registerAndLogin(t, srv, "alice")
|
||||||
|
|
||||||
post := func(origin string) int {
|
post := func(origin string) int {
|
||||||
req, err := http.NewRequest(http.MethodPost, srv.URL+"/entry/create",
|
req, err := http.NewRequest(http.MethodPost, srv.URL+"/api/entry/create",
|
||||||
strings.NewReader(url.Values{"content": {"x"}}.Encode()))
|
strings.NewReader(url.Values{"content": {"x"}}.Encode()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -706,13 +953,13 @@ func TestBlockTOREntry(t *testing.T) {
|
|||||||
srv := newTestServer(t)
|
srv := newTestServer(t)
|
||||||
c := registerAndLogin(t, srv, "toruser")
|
c := registerAndLogin(t, srv, "toruser")
|
||||||
|
|
||||||
resp := postFormFrom(t, c, srv.URL+"/entry/create", "10.89.1.2", url.Values{"content": {"aus tor"}})
|
resp := postFormFrom(t, c, srv.URL+"/api/entry/create", "10.89.1.2", url.Values{"content": {"aus tor"}})
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusForbidden {
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
t.Fatalf("TOR-Beitrag: erwartet 403, bekam %d", resp.StatusCode)
|
t.Fatalf("TOR-Beitrag: erwartet 403, bekam %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp = postFormFrom(t, c, srv.URL+"/entry/create", "203.0.113.7", url.Values{"content": {"normal"}})
|
resp = postFormFrom(t, c, srv.URL+"/api/entry/create", "203.0.113.7", url.Values{"content": {"normal"}})
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusCreated {
|
if resp.StatusCode != http.StatusCreated {
|
||||||
t.Fatalf("Nicht-TOR-Beitrag: erwartet 201, bekam %d", resp.StatusCode)
|
t.Fatalf("Nicht-TOR-Beitrag: erwartet 201, bekam %d", resp.StatusCode)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
@@ -9,7 +8,6 @@ import (
|
|||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -36,26 +34,58 @@ type Entry struct {
|
|||||||
LastBump int64 `json:"last_bump"`
|
LastBump int64 `json:"last_bump"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Avatar string `json:"avatar"`
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest ist der jüngste (nicht gelöschte) Beitrag im Teilbaum unter einem
|
||||||
|
// Beitrag -- die Antwort, die die Diskussion zuletzt fortgesetzt hat. Skipped
|
||||||
|
// zählt die Beiträge, die auf dem Pfad dazwischen liegen (0 = direkte Antwort).
|
||||||
|
type Latest struct {
|
||||||
|
Entry Entry `json:"entry"`
|
||||||
|
Skipped int64 `json:"skipped"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LEFT JOIN + COALESCE, damit soft-gelöschte Beiträge (uid auf 0 gesetzt) als
|
// 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 = `
|
const entrySelect = `
|
||||||
SELECT e.pid, e.uid, e.created_at, e.content, e.filepath,
|
SELECT e.pid, e.uid, e.created_at, e.content, e.filepath,
|
||||||
e.reply_to, e.reply_count, e.last_activity, e.deleted,
|
e.reply_to, e.reply_count, e.last_activity, e.deleted,
|
||||||
e.bump_count, e.last_bump,
|
e.bump_count, e.last_bump,
|
||||||
COALESCE(u.username, ''), COALESCE(u.avatar, '')
|
COALESCE(u.username, ''), COALESCE(u.avatar, ''),
|
||||||
FROM entry e LEFT JOIN account u ON u.uid = e.uid`
|
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'`
|
||||||
|
|
||||||
// scanEntries liest Entry-Zeilen aus einem Query mit entrySelect-Spalten.
|
// scanEntry liest eine Zeile mit entrySelect-Spalten (aus *sql.Row oder *sql.Rows).
|
||||||
func scanEntries(rows *sql.Rows) ([]Entry, error) {
|
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,
|
||||||
|
&p.URL, &p.Kind, &p.Title, &p.Site, &p.Thumb)
|
||||||
|
if p.URL != "" {
|
||||||
|
e.Preview = &p
|
||||||
|
}
|
||||||
|
return e, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryEntries führt einen Query mit entrySelect-Spalten aus.
|
||||||
|
func queryEntries(query string, args ...any) ([]Entry, error) {
|
||||||
|
rows, err := db.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
entries := []Entry{}
|
entries := []Entry{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var e Entry
|
e, err := scanEntry(rows)
|
||||||
if err := rows.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
|
if err != nil {
|
||||||
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted,
|
|
||||||
&e.BumpCount, &e.LastBump, &e.Username, &e.Avatar); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
entries = append(entries, e)
|
entries = append(entries, e)
|
||||||
@@ -65,12 +95,7 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) {
|
|||||||
|
|
||||||
// entryByPID liefert einen einzelnen Beitrag oder sql.ErrNoRows.
|
// entryByPID liefert einen einzelnen Beitrag oder sql.ErrNoRows.
|
||||||
func entryByPID(pid int64) (Entry, error) {
|
func entryByPID(pid int64) (Entry, error) {
|
||||||
var e Entry
|
return scanEntry(db.QueryRow(entrySelect+` WHERE e.pid = $1`, pid))
|
||||||
err := db.QueryRow(entrySelect+` WHERE e.pid = $1`, pid).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)
|
|
||||||
return e, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -80,70 +105,110 @@ const (
|
|||||||
maxFeedPage = 100
|
maxFeedPage = 100
|
||||||
)
|
)
|
||||||
|
|
||||||
// feedWhere baut die Filterbedingung für Feed-Abfragen.
|
// feedPage liefert Seite page (Routenparameter {page}) eines Feed-Queries mit
|
||||||
func feedWhere(uid int64, rootsOnly bool) (string, []any) {
|
// entrySelect-Spalten; LIMIT und OFFSET werden hinter args angehängt.
|
||||||
args := []any{}
|
func feedPage(r *http.Request, query string, args ...any) ([]Entry, error) {
|
||||||
where := []string{}
|
|
||||||
if uid != 0 {
|
|
||||||
args = append(args, uid)
|
|
||||||
where = append(where, fmt.Sprintf("e.uid = $%d", len(args)))
|
|
||||||
}
|
|
||||||
if rootsOnly {
|
|
||||||
where = append(where, "e.reply_to = 0")
|
|
||||||
}
|
|
||||||
if len(where) == 0 {
|
|
||||||
return "", args
|
|
||||||
}
|
|
||||||
return " WHERE " + strings.Join(where, " AND "), args
|
|
||||||
}
|
|
||||||
|
|
||||||
// feedPage liefert eine Feed-Seite (feedPageSize Beiträge). Ist uid != 0, nur die Beiträge
|
|
||||||
// dieses Nutzers. rootsOnly blendet Antworten aus und sortiert nach Aktivität;
|
|
||||||
// sonst werden alle Beiträge chronologisch geliefert.
|
|
||||||
func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) {
|
|
||||||
if page < 0 {
|
|
||||||
page = 0
|
|
||||||
}
|
|
||||||
if page > maxFeedPage {
|
|
||||||
page = maxFeedPage
|
|
||||||
}
|
|
||||||
|
|
||||||
where, args := feedWhere(uid, rootsOnly)
|
|
||||||
query := entrySelect + where
|
|
||||||
if rootsOnly {
|
|
||||||
query += ` ORDER BY e.last_activity DESC`
|
|
||||||
} else {
|
|
||||||
query += ` ORDER BY e.created_at DESC`
|
|
||||||
}
|
|
||||||
query += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
|
|
||||||
args = append(args, feedPageSize, page*feedPageSize)
|
|
||||||
|
|
||||||
rows, err := db.Query(query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return scanEntries(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleFeed(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
|
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
|
||||||
entries, err := feedPage(page, 0, true)
|
page = min(max(page, 0), maxFeedPage)
|
||||||
|
n := len(args)
|
||||||
|
query += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, n+1, n+2)
|
||||||
|
return queryEntries(query, append(args, feedPageSize, page*feedPageSize)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachLatest hängt an jeden Beitrag den jüngsten nicht gelöschten Beitrag
|
||||||
|
// aus seinem Teilbaum (Latest). So bildet eine flache Liste auch tiefe
|
||||||
|
// Diskussionen ab: [Beitrag] -- "k weitere" -- [jüngste Antwort], ein Klick
|
||||||
|
// führt ans aktuelle Ende.
|
||||||
|
//
|
||||||
|
// Ein Query für alle Beiträge: die rekursive CTE steigt von allen zugleich
|
||||||
|
// ab, top merkt sich den Ausgangsbeitrag, depth die Tiefe darunter. Das Limit
|
||||||
|
// deckelt die Tiefe wie bei der Ahnenkette (handleThread).
|
||||||
|
func attachLatest(entries []Entry) error {
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pids := make([]int64, len(entries))
|
||||||
|
for i, e := range entries {
|
||||||
|
pids[i] = e.PID
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.Query(
|
||||||
|
`WITH RECURSIVE sub(top, pid, depth) AS (
|
||||||
|
SELECT e.reply_to, e.pid, 1 FROM entry e WHERE e.reply_to = ANY($1)
|
||||||
|
UNION ALL
|
||||||
|
SELECT s.top, e.pid, s.depth + 1
|
||||||
|
FROM entry e JOIN sub s ON e.reply_to = s.pid
|
||||||
|
WHERE s.depth < 50
|
||||||
|
)
|
||||||
|
SELECT DISTINCT ON (s.top) s.top, s.pid, s.depth
|
||||||
|
FROM sub s JOIN entry x ON x.pid = s.pid
|
||||||
|
WHERE x.deleted = 0
|
||||||
|
ORDER BY s.top, x.created_at DESC, x.id DESC`, pids)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
type hit struct{ pid, depth int64 }
|
||||||
|
hits := map[int64]hit{}
|
||||||
|
latestPIDs := []int64{}
|
||||||
|
for rows.Next() {
|
||||||
|
var top int64
|
||||||
|
var h hit
|
||||||
|
if err := rows.Scan(&top, &h.pid, &h.depth); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hits[top] = h
|
||||||
|
latestPIDs = append(latestPIDs, h.pid)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(latestPIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
latest, err := queryEntries(entrySelect+` WHERE e.pid = ANY($1)`, latestPIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
byPID := make(map[int64]Entry, len(latest))
|
||||||
|
for _, e := range latest {
|
||||||
|
byPID[e.PID] = e
|
||||||
|
}
|
||||||
|
for i := range entries {
|
||||||
|
if h, ok := hits[entries[i].PID]; ok {
|
||||||
|
if e, ok := byPID[h.pid]; ok {
|
||||||
|
entries[i].Latest = &Latest{Entry: e, Skipped: h.depth - 1}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleFeed liefert die Wurzelbeiträge nach letzter Aktivität, jeweils mit
|
||||||
|
// ihrer jüngsten Antwort.
|
||||||
|
func handleFeed(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
entries, err := feedPage(r, entrySelect+` WHERE e.reply_to = 0 ORDER BY e.last_activity DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return Internal(err)
|
||||||
|
}
|
||||||
|
if err := attachLatest(entries); err != nil {
|
||||||
return Internal(err)
|
return Internal(err)
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, entries)
|
writeJSON(w, http.StatusOK, entries)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUserFeed liefert den Feed eines einzelnen Nutzers (öffentlich, inkl. Antworten).
|
// handleUserFeed liefert alle Beiträge eines Nutzers chronologisch, Antworten
|
||||||
|
// eingeschlossen.
|
||||||
func handleUserFeed(w http.ResponseWriter, r *http.Request) error {
|
func handleUserFeed(w http.ResponseWriter, r *http.Request) error {
|
||||||
var uid int64
|
var uid int64
|
||||||
if err := db.QueryRow(`SELECT uid FROM account WHERE username = $1`, chi.URLParam(r, "username")).Scan(&uid); err != nil {
|
if err := db.QueryRow(`SELECT uid FROM account WHERE username = $1`, chi.URLParam(r, "username")).Scan(&uid); err != nil {
|
||||||
return errUserNotFound
|
return errUserNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
|
entries, err := feedPage(r, entrySelect+` WHERE e.uid = $1 ORDER BY e.created_at DESC`, uid)
|
||||||
entries, err := feedPage(page, uid, false)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Internal(err)
|
return Internal(err)
|
||||||
}
|
}
|
||||||
@@ -151,12 +216,6 @@ func handleUserFeed(w http.ResponseWriter, r *http.Request) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// serveEntryPage liefert die statische Focus-Seite; das Frontend lädt die Daten
|
|
||||||
// dann über /entry/{pid}/thread.
|
|
||||||
func serveEntryPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.ServeFile(w, r, "web/entry.html")
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errEntryNotFound = NotFound("entry.not_found", "Beitrag nicht gefunden")
|
errEntryNotFound = NotFound("entry.not_found", "Beitrag nicht gefunden")
|
||||||
errEntryNotOwned = Forbidden("entry.not_owner", "Das ist nicht dein Beitrag.")
|
errEntryNotOwned = Forbidden("entry.not_owner", "Das ist nicht dein Beitrag.")
|
||||||
@@ -172,49 +231,30 @@ func pidParam(r *http.Request) (int64, error) {
|
|||||||
return pid, nil
|
return pid, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleThread liefert einen Beitrag mit seiner Ahnenkette (Root zuerst) und den
|
// thread ist ein Beitrag mit seinem Kontext: Ahnenkette (Root zuerst) und
|
||||||
// direkten Antworten (öffentlich).
|
// direkte Antworten samt deren jüngstem Nachfahren.
|
||||||
|
type thread struct {
|
||||||
|
Entry Entry `json:"entry"`
|
||||||
|
Ancestors []Entry `json:"ancestors"`
|
||||||
|
Replies []Entry `json:"replies"`
|
||||||
|
}
|
||||||
|
|
||||||
func handleThread(w http.ResponseWriter, r *http.Request) error {
|
func handleThread(w http.ResponseWriter, r *http.Request) error {
|
||||||
pid, err := pidParam(r)
|
pid, err := pidParam(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
t, err := threadData(pid)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"entry": t.Entry,
|
|
||||||
"ancestors": t.Ancestors,
|
|
||||||
"replies": t.Replies,
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// thread ist ein Beitrag mit seinem Kontext: Ahnenkette (Root zuerst) und
|
|
||||||
// direkte Antworten.
|
|
||||||
type thread struct {
|
|
||||||
Entry Entry
|
|
||||||
Ancestors []Entry
|
|
||||||
Replies []Entry
|
|
||||||
}
|
|
||||||
|
|
||||||
// threadData holt einen Thread aus der Datenbank.
|
|
||||||
func threadData(pid int64) (thread, error) {
|
|
||||||
entry, err := entryByPID(pid)
|
entry, err := entryByPID(pid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return thread{}, errEntryNotFound
|
return errEntryNotFound
|
||||||
}
|
}
|
||||||
t := thread{Entry: entry, Ancestors: []Entry{}, Replies: []Entry{}}
|
t := thread{Entry: entry, Ancestors: []Entry{}}
|
||||||
|
|
||||||
// Ahnenkette (Root zuerst) per rekursiver CTE in einem Query statt bis zu
|
// depth zählt vom Beitrag aufwärts; ORDER BY depth DESC dreht die Kette
|
||||||
// 50 Einzelqueries. depth zählt vom Beitrag aufwärts; ORDER BY depth DESC
|
// auf Root-zuerst. Das Limit deckelt die Kettenlänge und bricht auch einen
|
||||||
// dreht die Kette auf Root-zuerst. Das Limit deckelt die Kettenlänge und
|
// (theoretischen) Zyklus ab.
|
||||||
// bricht auch einen (theoretischen) Zyklus ab.
|
|
||||||
if entry.ReplyTo != 0 {
|
if entry.ReplyTo != 0 {
|
||||||
rows, err := db.Query(
|
t.Ancestors, err = queryEntries(
|
||||||
`WITH RECURSIVE chain(pid, depth) AS (
|
`WITH RECURSIVE chain(pid, depth) AS (
|
||||||
SELECT $1::bigint, 1
|
SELECT $1::bigint, 1
|
||||||
UNION ALL
|
UNION ALL
|
||||||
@@ -225,21 +265,20 @@ func threadData(pid int64) (thread, error) {
|
|||||||
JOIN chain c ON c.pid = e.pid
|
JOIN chain c ON c.pid = e.pid
|
||||||
ORDER BY c.depth DESC`, entry.ReplyTo)
|
ORDER BY c.depth DESC`, entry.ReplyTo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return thread{}, Internal(err)
|
return Internal(err)
|
||||||
}
|
|
||||||
if t.Ancestors, err = scanEntries(rows); err != nil {
|
|
||||||
return thread{}, Internal(err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := db.Query(entrySelect+` WHERE e.reply_to = $1 ORDER BY e.last_activity DESC LIMIT 100`, pid)
|
t.Replies, err = queryEntries(entrySelect+` WHERE e.reply_to = $1 ORDER BY e.last_activity DESC LIMIT 100`, pid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return thread{}, Internal(err)
|
return Internal(err)
|
||||||
}
|
}
|
||||||
if t.Replies, err = scanEntries(rows); err != nil {
|
if err := attachLatest(t.Replies); err != nil {
|
||||||
return thread{}, Internal(err)
|
return Internal(err)
|
||||||
}
|
}
|
||||||
return t, nil
|
|
||||||
|
writeJSON(w, http.StatusOK, t)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
|
func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
|
||||||
@@ -280,6 +319,7 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
|
linkURL := previewKey(content)
|
||||||
|
|
||||||
tx, err := db.Begin()
|
tx, err := db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -292,10 +332,10 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
|
|||||||
for try := 0; ; try++ {
|
for try := 0; ; try++ {
|
||||||
pid = int64(rand.IntN(999999999999)) + 1
|
pid = int64(rand.IntN(999999999999)) + 1
|
||||||
res, err := tx.Exec(
|
res, err := tx.Exec(
|
||||||
`INSERT INTO entry (pid, uid, created_at, content, filepath, reply_to, reply_count, last_activity)
|
`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)
|
VALUES ($1, $2, $3, $4, $5, $6, 0, $7, $8)
|
||||||
ON CONFLICT (pid) DO NOTHING`,
|
ON CONFLICT (pid) DO NOTHING`,
|
||||||
pid, uid, now, content, filepath, replyTo, now,
|
pid, uid, now, content, filepath, replyTo, now, linkURL,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
@@ -334,6 +374,7 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
|
|||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return Internal(err)
|
return Internal(err)
|
||||||
}
|
}
|
||||||
|
requestPreview(linkURL)
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, map[string]any{
|
writeJSON(w, http.StatusCreated, map[string]any{
|
||||||
"pid": pid,
|
"pid": pid,
|
||||||
@@ -361,9 +402,11 @@ func handleEditEntry(w http.ResponseWriter, r *http.Request) error {
|
|||||||
return Invalid("entry.empty", "Inhalt darf nicht leer sein.").At("content")
|
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)
|
return Internal(err)
|
||||||
}
|
}
|
||||||
|
requestPreview(linkURL)
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"pid": pid, "content": content})
|
writeJSON(w, http.StatusOK, map[string]any{"pid": pid, "content": content})
|
||||||
return nil
|
return nil
|
||||||
@@ -401,7 +444,7 @@ func handleDeleteEntry(w http.ResponseWriter, r *http.Request) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err := db.Exec(
|
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 {
|
); err != nil {
|
||||||
return Internal(err)
|
return Internal(err)
|
||||||
}
|
}
|
||||||
@@ -413,6 +456,37 @@ func handleDeleteEntry(w http.ResponseWriter, r *http.Request) error {
|
|||||||
return nil
|
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
|
// 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
|
// ein Tag Sperre, nach dem 10. Bump zehn Tage. So wird Hochspülen mit jedem Mal
|
||||||
// teurer.
|
// teurer.
|
||||||
@@ -420,10 +494,9 @@ const bumpStep = 24 * 60 * 60
|
|||||||
|
|
||||||
// handleBump hebt last_activity eines Beitrags auf jetzt -> er steigt im Feed
|
// handleBump hebt last_activity eines Beitrags auf jetzt -> er steigt im Feed
|
||||||
// (Roots, sortiert nach last_activity) bzw. in der Antwortliste eines Threads
|
// (Roots, sortiert nach last_activity) bzw. in der Antwortliste eines Threads
|
||||||
// wieder nach oben. Tritt an die Stelle der früheren "Bump"-Kommentare. Gegen
|
// wieder nach oben. Gegen Spam wächst der Cooldown linear mit der Zahl
|
||||||
// Spam wächst der Cooldown linear mit der Zahl bisheriger Bumps (siehe bumpStep);
|
// bisheriger Bumps (siehe bumpStep); er sitzt am Beitrag selbst (global), nicht
|
||||||
// er sitzt am Beitrag selbst (global), nicht am bumpenden Nutzer. Jeder
|
// am bumpenden Nutzer.
|
||||||
// Eingeloggte darf bumpen (Route in der requireAuth-Gruppe).
|
|
||||||
func handleBump(w http.ResponseWriter, r *http.Request) error {
|
func handleBump(w http.ResponseWriter, r *http.Request) error {
|
||||||
pid, err := pidParam(r)
|
pid, err := pidParam(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -455,9 +528,8 @@ func handleBump(w http.ResponseWriter, r *http.Request) error {
|
|||||||
if remaining < 0 {
|
if remaining < 0 {
|
||||||
remaining = 0
|
remaining = 0
|
||||||
}
|
}
|
||||||
// Bewusst kein Taxonomie-Fehler -- die Antwort trägt
|
// Kein Taxonomie-Fehler: die Antwort trägt neben dem Fehler Nutzdaten
|
||||||
// Nutzdaten (retry_after, bump_count), die das Frontend zum Sperren des
|
// (retry_after, bump_count), mit denen das Frontend den Button sperrt.
|
||||||
// Buttons braucht, und der Cooldown ist ein regulärer Ausgang.
|
|
||||||
w.Header().Set("Retry-After", strconv.FormatInt(remaining, 10))
|
w.Header().Set("Retry-After", strconv.FormatInt(remaining, 10))
|
||||||
writeJSON(w, http.StatusTooManyRequests, map[string]any{
|
writeJSON(w, http.StatusTooManyRequests, map[string]any{
|
||||||
"error": apiErrorBody{
|
"error": apiErrorBody{
|
||||||
@@ -480,48 +552,26 @@ func handleBump(w http.ResponseWriter, r *http.Request) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// voteTally liefert die Zähler und die eigene Auswahl (selected) für einen Beitrag.
|
// writeTally schreibt die Zähler und die eigene Auswahl (selected) für einen
|
||||||
// Ist uid == 0 (nicht eingeloggt), ist selected immer "none".
|
// Beitrag. Ist uid == 0 (nicht eingeloggt), ist selected immer "none".
|
||||||
func voteTally(pid, uid int64) (left, right int, selected string) {
|
func writeTally(w http.ResponseWriter, pid, uid int64) error {
|
||||||
selected = "none"
|
var yes, no int64
|
||||||
if uid != 0 {
|
var selected string
|
||||||
var cur string
|
if err := db.QueryRow(
|
||||||
if err := db.QueryRow(`SELECT mode FROM vote WHERE uid = $1 AND pid = $2`, uid, pid).Scan(&cur); err == nil {
|
`SELECT COUNT(*) FILTER (WHERE mode = 'yes'),
|
||||||
selected = cur
|
COUNT(*) FILTER (WHERE mode = 'no'),
|
||||||
|
COALESCE(MAX(mode) FILTER (WHERE uid = $2), 'none')
|
||||||
|
FROM vote WHERE pid = $1`, pid, uid).
|
||||||
|
Scan(&yes, &no, &selected); err != nil {
|
||||||
|
return Internal(err)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Beide Zähler in einem Query statt zwei COUNTs.
|
|
||||||
rows, err := db.Query(`SELECT mode, COUNT(*) FROM vote WHERE pid = $1 GROUP BY mode`, pid)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("voteTally pid=%d: %v", pid, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var mode string
|
|
||||||
var n int
|
|
||||||
if err := rows.Scan(&mode, &n); err != nil {
|
|
||||||
log.Printf("voteTally pid=%d: %v", pid, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch mode {
|
|
||||||
case "left":
|
|
||||||
left = n
|
|
||||||
case "right":
|
|
||||||
right = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeTally(w http.ResponseWriter, pid, uid int64) {
|
|
||||||
left, right, selected := voteTally(pid, uid)
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"pid": pid,
|
"pid": pid,
|
||||||
"left": left,
|
"yes": yes,
|
||||||
"right": right,
|
"no": no,
|
||||||
"selected": selected,
|
"selected": selected,
|
||||||
})
|
})
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleVotes liest den Abstimmungsstand eines Beitrags (read-only, öffentlich).
|
// handleVotes liest den Abstimmungsstand eines Beitrags (read-only, öffentlich).
|
||||||
@@ -535,34 +585,7 @@ func handleVotes(w http.ResponseWriter, r *http.Request) error {
|
|||||||
if s, ok := getSession(r); ok {
|
if s, ok := getSession(r); ok {
|
||||||
uid = s.UID
|
uid = s.UID
|
||||||
}
|
}
|
||||||
writeTally(w, pid, uid)
|
return writeTally(w, pid, uid)
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleReport nimmt eine Meldung zu einem Beitrag entgegen (DSA-Melde-
|
|
||||||
// verfahren). Bewusst ohne Auth -- jeder darf melden, weil es eine
|
|
||||||
// Moderationshilfe ist. Die Melder-uid wird vorerst nicht gespeichert (immer
|
|
||||||
// 0); nur der optionale Grund landet in der DB. Die Route ist per IP
|
|
||||||
// rate-limited (siehe main.go), damit der offene Endpunkt kein Spam-Kanal wird.
|
|
||||||
func handleReport(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
pid, err := pidParam(r)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Nur existierende Beiträge melden, sonst entstehen Waisen-Meldungen.
|
|
||||||
if _, err := entryByPID(pid); err != nil {
|
|
||||||
return errEntryNotFound
|
|
||||||
}
|
|
||||||
reason := truncateRunes(strings.TrimSpace(r.FormValue("reason")), 500)
|
|
||||||
if _, err := db.Exec(
|
|
||||||
`INSERT INTO report (pid, uid, reason, created_at, handled) VALUES ($1, 0, $2, $3, 0)`,
|
|
||||||
pid, reason, time.Now().Unix(),
|
|
||||||
); err != nil {
|
|
||||||
return Internal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleVote gibt die Stimme des angemeldeten Nutzers ab oder schaltet sie um:
|
// handleVote gibt die Stimme des angemeldeten Nutzers ab oder schaltet sie um:
|
||||||
@@ -577,8 +600,8 @@ func handleVote(w http.ResponseWriter, r *http.Request) error {
|
|||||||
uid := uidFromContext(r.Context())
|
uid := uidFromContext(r.Context())
|
||||||
|
|
||||||
mode := r.FormValue("mode")
|
mode := r.FormValue("mode")
|
||||||
if mode != "left" && mode != "right" {
|
if mode != "yes" && mode != "no" {
|
||||||
return Invalid("vote.bad_mode", "Ungültiger Modus (left oder right)").At("mode")
|
return Invalid("vote.bad_mode", "Ungültiger Modus (yes oder no)").At("mode")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nur auf existierende Beiträge abstimmen, sonst entstehen Waisen-Votes.
|
// Nur auf existierende Beiträge abstimmen, sonst entstehen Waisen-Votes.
|
||||||
@@ -604,8 +627,7 @@ func handleVote(w http.ResponseWriter, r *http.Request) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
writeTally(w, pid, uid)
|
return writeTally(w, pid, uid)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -622,8 +644,7 @@ const (
|
|||||||
|
|
||||||
// errUploadTooLargeOrBroken unterscheidet die beiden Gründe, an denen
|
// errUploadTooLargeOrBroken unterscheidet die beiden Gründe, an denen
|
||||||
// ParseMultipartForm scheitern kann: das Limit von MaxBytesReader (der Upload
|
// ParseMultipartForm scheitern kann: das Limit von MaxBytesReader (der Upload
|
||||||
// war zu groß -> 413) oder kaputtes Multipart (-> 400). Vorher endete beides
|
// war zu groß -> 413) oder kaputtes Multipart (-> 400).
|
||||||
// als 400, was dem Nutzer nicht sagte, dass die Datei schlicht zu groß war.
|
|
||||||
func errUploadTooLargeOrBroken(err error) error {
|
func errUploadTooLargeOrBroken(err error) error {
|
||||||
var tooLarge *http.MaxBytesError
|
var tooLarge *http.MaxBytesError
|
||||||
if errors.As(err, &tooLarge) {
|
if errors.As(err, &tooLarge) {
|
||||||
@@ -715,7 +736,7 @@ func storeImage(fh *multipart.FileHeader) (string, error) {
|
|||||||
if format == "gif" {
|
if format == "gif" {
|
||||||
ext = "gif"
|
ext = "gif"
|
||||||
}
|
}
|
||||||
name := fmt.Sprintf("static/media/%d-%d.%s", time.Now().Unix(), rand.IntN(999999), ext)
|
name := newMediaName(ext)
|
||||||
|
|
||||||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||||
xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, b, xdraw.Over, nil)
|
xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, b, xdraw.Over, nil)
|
||||||
@@ -742,7 +763,7 @@ func storeImage(fh *multipart.FileHeader) (string, error) {
|
|||||||
// serverseitig erzeugten Namen nach static/media. Der hochgeladene Dateiname
|
// serverseitig erzeugten Namen nach static/media. Der hochgeladene Dateiname
|
||||||
// fließt nie ein (kein Path-Traversal).
|
// fließt nie ein (kein Path-Traversal).
|
||||||
func copyUpload(src io.Reader, ext string) (string, error) {
|
func copyUpload(src io.Reader, ext string) (string, error) {
|
||||||
name := fmt.Sprintf("static/media/%d-%d.%s", time.Now().Unix(), rand.IntN(999999), ext)
|
name := newMediaName(ext)
|
||||||
out, err := os.Create(name)
|
out, err := os.Create(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -761,3 +782,8 @@ func copyUpload(src io.Reader, ext string) (string, error) {
|
|||||||
}
|
}
|
||||||
return name, nil
|
return name, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newMediaName erzeugt einen serverseitigen Dateinamen unter static/media.
|
||||||
|
func newMediaName(ext string) string {
|
||||||
|
return fmt.Sprintf("static/media/%d-%d.%s", time.Now().Unix(), rand.IntN(999999), ext)
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fehler-Taxonomie: trennt die drei Informationen, die früher an jeder
|
// Fehler-Taxonomie: trennt, wie schwer ein Fehler ist (Kind), was genau
|
||||||
// Fehler-Aufrufstelle verklebt waren -- wie schwer der Fehler ist (Kind),
|
// passiert ist (Code) und was der Nutzer lesen soll (Msg).
|
||||||
// was genau passiert ist (Code) und was der Nutzer lesen soll (Msg).
|
|
||||||
//
|
//
|
||||||
// Der Sinn der Trennung: derselbe Fehler muss künftig von zwei Adaptern
|
// Der Sinn der Trennung: das Frontend zeigt Msg an und markiert Field, ein
|
||||||
// unterschiedlich dargestellt werden. Der HTML-Pfad braucht Msg und Field, um
|
// Client braucht Code, um ohne Parsen deutscher Strings reagieren zu können,
|
||||||
// ein Formular mit Fehlermarkierung neu zu rendern; ein API-Client braucht
|
// und der HTTP-Status ergibt sich allein aus Kind.
|
||||||
// Code, um ohne Parsen deutscher Strings reagieren zu können.
|
|
||||||
|
|
||||||
type Kind uint8
|
type Kind uint8
|
||||||
|
|
||||||
@@ -26,7 +24,7 @@ const (
|
|||||||
KindConflict // Name vergeben, Doppel-Post
|
KindConflict // Name vergeben, Doppel-Post
|
||||||
KindTooLarge // Upload über dem Limit
|
KindTooLarge // Upload über dem Limit
|
||||||
KindRateLimited // zu viele Anfragen
|
KindRateLimited // zu viele Anfragen
|
||||||
KindUnavailable // Feature nicht konfiguriert
|
KindMethodNotAllowed // Route existiert, aber nicht mit dieser Methode
|
||||||
)
|
)
|
||||||
|
|
||||||
// Error ist der Fehlertyp, den die Service-Schicht nach oben gibt. Kind ist
|
// Error ist der Fehlertyp, den die Service-Schicht nach oben gibt. Kind ist
|
||||||
@@ -56,7 +54,9 @@ func NotFound(code, msg string) *Error { return &Error{Kind: KindNotFound, Co
|
|||||||
func Conflict(code, msg string) *Error { return &Error{Kind: KindConflict, Code: code, Msg: msg} }
|
func Conflict(code, msg string) *Error { return &Error{Kind: KindConflict, Code: code, Msg: msg} }
|
||||||
func TooLarge(code, msg string) *Error { return &Error{Kind: KindTooLarge, Code: code, Msg: msg} }
|
func TooLarge(code, msg string) *Error { return &Error{Kind: KindTooLarge, Code: code, Msg: msg} }
|
||||||
func RateLimited(code, msg string) *Error { return &Error{Kind: KindRateLimited, Code: code, Msg: msg} }
|
func RateLimited(code, msg string) *Error { return &Error{Kind: KindRateLimited, Code: code, Msg: msg} }
|
||||||
func Unavailable(code, msg string) *Error { return &Error{Kind: KindUnavailable, Code: code, Msg: msg} }
|
func MethodNotAllowed(code, msg string) *Error {
|
||||||
|
return &Error{Kind: KindMethodNotAllowed, Code: code, Msg: msg}
|
||||||
|
}
|
||||||
|
|
||||||
// Internal verschluckt die Ursache absichtlich: der Nutzer sieht nie einen
|
// Internal verschluckt die Ursache absichtlich: der Nutzer sieht nie einen
|
||||||
// DB- oder Dateisystemfehler (der Interna und Pfade verraten würde), das Log
|
// DB- oder Dateisystemfehler (der Interna und Pfade verraten würde), das Log
|
||||||
@@ -91,9 +91,7 @@ func (e *Error) WithCause(cause error) *Error {
|
|||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
// asError normalisiert alles, was kein *Error ist, zu einem Internal. Damit
|
// asError normalisiert alles, was kein *Error ist, zu einem Internal.
|
||||||
// müssen die Adapter keinen Sonderfall für "nackte" Fehler kennen, die aus
|
|
||||||
// Bibliotheken oder noch nicht umgestellten Stellen kommen.
|
|
||||||
func asError(err error) *Error {
|
func asError(err error) *Error {
|
||||||
var e *Error
|
var e *Error
|
||||||
if errors.As(err, &e) {
|
if errors.As(err, &e) {
|
||||||
@@ -102,9 +100,7 @@ func asError(err error) *Error {
|
|||||||
return Internal(err)
|
return Internal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// statusOf bildet Kind auf HTTP ab. Beide HTTP-Adapter (HTML und JSON) teilen
|
// statusOf bildet Kind auf HTTP ab -- die einzige Stelle, an der das passiert.
|
||||||
// sich diese Zuordnung -- unterschiedliche Statuscodes für denselben Fehler
|
|
||||||
// wären genau die Divergenz, die die Taxonomie verhindern soll.
|
|
||||||
func statusOf(k Kind) int {
|
func statusOf(k Kind) int {
|
||||||
switch k {
|
switch k {
|
||||||
case KindInvalid:
|
case KindInvalid:
|
||||||
@@ -121,17 +117,15 @@ func statusOf(k Kind) int {
|
|||||||
return http.StatusRequestEntityTooLarge
|
return http.StatusRequestEntityTooLarge
|
||||||
case KindRateLimited:
|
case KindRateLimited:
|
||||||
return http.StatusTooManyRequests
|
return http.StatusTooManyRequests
|
||||||
case KindUnavailable:
|
case KindMethodNotAllowed:
|
||||||
return http.StatusServiceUnavailable
|
return http.StatusMethodNotAllowed
|
||||||
default:
|
default:
|
||||||
return http.StatusInternalServerError
|
return http.StatusInternalServerError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// logInternal protokolliert nur echte Serverfehler -- Validierungs- und
|
// logInternal protokolliert nur echte Serverfehler -- Validierungs- und
|
||||||
// Rechtefehler sind normaler Betrieb und würden das Log zumüllen. Beide
|
// Rechtefehler sind normaler Betrieb und würden das Log zumüllen.
|
||||||
// Adapter rufen das auf, damit ein Fehler unabhängig vom Ausgabeformat
|
|
||||||
// genau einmal im Log landet.
|
|
||||||
func logInternal(e *Error) {
|
func logInternal(e *Error) {
|
||||||
if e.Kind != KindInternal {
|
if e.Kind != KindInternal {
|
||||||
return
|
return
|
||||||
|
|||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// webDir enthält das gebaute React-Frontend (frontend/ -> npm run build):
|
||||||
|
// index.html plus gehashte Dateien unter assets/.
|
||||||
|
const webDir = "web"
|
||||||
|
|
||||||
|
// serveApp liefert die index.html des Frontends; das Routing übernimmt der
|
||||||
|
// Client. Nur für die Seitenrouten des Frontends registriert (siehe routes) --
|
||||||
|
// beliebige andere Pfade bleiben ein echtes 404, statt als Seite zu gelten und
|
||||||
|
// in der Impression-Statistik zu landen. no-cache, damit nach einem Deploy
|
||||||
|
// sofort die neue index.html mit den neuen Asset-Namen geladen wird.
|
||||||
|
func serveApp(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
http.ServeFile(w, r, webDir+"/index.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveDoc liefert einen Rechtstext. Sie werden mit dem Frontend gebaut
|
||||||
|
// (frontend/static/docs), damit sie dasselbe app.css bekommen.
|
||||||
|
func serveDoc(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
http.ServeFile(w, r, webDir+"/static/docs/"+path.Base(chi.URLParam(r, "page")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveAssets liefert die gebauten JS/CSS-Dateien. Ihre Namen enthalten einen
|
||||||
|
// Inhalts-Hash, sie ändern sich also nie -> dauerhaft cachebar. Verzeichnisse
|
||||||
|
// gibt es nicht zu sehen (kein Listing).
|
||||||
|
func serveAssets() http.Handler {
|
||||||
|
files := http.FileServer(http.Dir(webDir))
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasSuffix(r.URL.Path, "/") {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
files.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Kontrollverlust</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<p class="msg">Diese Seite braucht JavaScript.</p>
|
||||||
|
</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1373
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "kver-frontend",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"typecheck": "tsc"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.104.0",
|
||||||
|
"react": "^19.3.0",
|
||||||
|
"react-dom": "^19.3.0",
|
||||||
|
"react-router": "^8.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.6.3",
|
||||||
|
"@types/react": "^19.3.0",
|
||||||
|
"@types/react-dom": "^19.3.0",
|
||||||
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vite": "^8.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Link, Outlet, useLocation } from "react-router";
|
||||||
|
import { api } from "./api";
|
||||||
|
import { useAuthChanged, useMe } from "./auth";
|
||||||
|
|
||||||
|
// Tage ohne Datenverlust (fester Startpunkt, Kontinuität seit der alten
|
||||||
|
// Version).
|
||||||
|
const dataLossStart = new Date("2025-12-13T12:44:00").getTime();
|
||||||
|
|
||||||
|
function plural(n: number, one: string, many: string) {
|
||||||
|
return `${n} ${n === 1 ? one : many}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layout ist der gemeinsame Seitenrahmen: die Startseite hat Tagline und
|
||||||
|
// Headerbar, alle anderen Seiten den "zurück zum Feed"-Knopf.
|
||||||
|
export function Layout() {
|
||||||
|
const path = useLocation().pathname;
|
||||||
|
const isHome = path === "/";
|
||||||
|
const days = Math.floor((Date.now() - dataLossStart) / 86400000);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header>
|
||||||
|
<nav id="legal">
|
||||||
|
<a href="/static/docs/impressum.html">Impressum</a> -{" "}
|
||||||
|
<a href="/static/docs/datenschutz.html">Datenschutz</a> -{" "}
|
||||||
|
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -{" "}
|
||||||
|
<Link to="/stats">Statistik</Link>
|
||||||
|
</nav>
|
||||||
|
<Link to="/"><h1>Kontrollverlust</h1></Link>
|
||||||
|
{isHome ? (
|
||||||
|
<>
|
||||||
|
<p id="stats" className="tagline">
|
||||||
|
Ohne Datenverlust seit {plural(days, "Tag", "Tagen")}
|
||||||
|
</p>
|
||||||
|
<Headerbar />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div id="headerbar"><Link className="button" to="/">zurück zum Feed</Link></div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Die Statistik-Seite darf auf breiten Bildschirmen breiter werden. */}
|
||||||
|
<main className={path === "/stats" ? "wide" : undefined}>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headerbar der Startseite: angemeldet Profil-Link und Logout, sonst leer.
|
||||||
|
function Headerbar() {
|
||||||
|
const me = useMe().data;
|
||||||
|
const authChanged = useAuthChanged();
|
||||||
|
if (!me) return <div id="headerbar"></div>;
|
||||||
|
|
||||||
|
// POST statt GET: als GET wäre Logout per fremdem Link auslösbar (CSRF).
|
||||||
|
const logout = async () => {
|
||||||
|
await api.post("/auth/logout").catch(() => undefined);
|
||||||
|
await authChanged();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div id="headerbar">
|
||||||
|
<Link className="button" to={`/u/${encodeURIComponent(me.username)}`}>mein Profil</Link>{" "}
|
||||||
|
<button id="logout-btn" onClick={logout}>Logout</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { ApiErrorBody } from "./types";
|
||||||
|
|
||||||
|
// ApiError ist jeder Nicht-2xx-Status der API. Ob ein Aufruf geklappt hat,
|
||||||
|
// entscheidet allein der HTTP-Status; der Body liefert Code und Meldung.
|
||||||
|
// extra hält Zusatzfelder neben "error" (z. B. retry_after beim Repost).
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly code: string;
|
||||||
|
readonly field?: string;
|
||||||
|
readonly fields?: Record<string, string>;
|
||||||
|
readonly extra: Record<string, unknown>;
|
||||||
|
|
||||||
|
constructor(status: number, body: ApiErrorBody | undefined, extra: Record<string, unknown>) {
|
||||||
|
super(body?.message ?? `HTTP ${status}`);
|
||||||
|
this.status = status;
|
||||||
|
this.code = body?.code ?? "unknown";
|
||||||
|
this.field = body?.field;
|
||||||
|
this.fields = body?.fields;
|
||||||
|
this.extra = extra;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(method: "GET" | "POST", path: string, body?: FormData): Promise<T> {
|
||||||
|
const r = await fetch(`/api${path}`, {
|
||||||
|
method,
|
||||||
|
body,
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
// Auch Fehler kommen als JSON; ein kaputter Body (Proxy-Fehlerseite o. Ä.)
|
||||||
|
// darf die Auswertung des Status nicht verhindern.
|
||||||
|
const data: unknown = await r.json().catch(() => undefined);
|
||||||
|
if (!r.ok) {
|
||||||
|
const { error, ...extra } = (data ?? {}) as { error?: ApiErrorBody } & Record<string, unknown>;
|
||||||
|
throw new ApiError(r.status, error, extra);
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: <T>(path: string) => request<T>("GET", path),
|
||||||
|
post: <T>(path: string, body: FormData = new FormData()) => request<T>("POST", path, body),
|
||||||
|
};
|
||||||
|
|
||||||
|
// errMsg macht aus einem Fehler den Anzeigetext. Liegen mehrere Feldfehler vor
|
||||||
|
// (Registrierung), werden sie zu einem Satz zusammengezogen. Der code ist
|
||||||
|
// bewusst nicht Teil der Anzeige -- er ist für Logik da, nicht für Menschen.
|
||||||
|
export function errMsg(err: unknown, fallback: string): string {
|
||||||
|
if (!(err instanceof ApiError)) return fallback;
|
||||||
|
const fields = err.fields ? Object.values(err.fields) : [];
|
||||||
|
if (fields.length) return fields.join(" ");
|
||||||
|
return err.message || fallback;
|
||||||
|
}
|
||||||
@@ -87,9 +87,7 @@ h2 {
|
|||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dezente Reihe anarchistischer Sigillen unter der Tagline. Einheitliche Höhe,
|
/* Reihe anarchistischer Sigillen im Kopf der Beitragsordnung (Easter Egg).
|
||||||
gedämpft per opacity (die SVGs sind teils Strich-, teils Flächenzeichnungen,
|
|
||||||
eine gemeinsame Farbe gäbe es nicht -> Transparenz vereinheitlicht den Look).
|
|
||||||
aria-hidden im Markup: reine Dekoration, kein Vorlesen. */
|
aria-hidden im Markup: reine Dekoration, kein Vorlesen. */
|
||||||
#sigils {
|
#sigils {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -119,6 +117,10 @@ main {
|
|||||||
margin: auto;
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main.wide {
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
main > section,
|
main > section,
|
||||||
main > div,
|
main > div,
|
||||||
main > h2,
|
main > h2,
|
||||||
@@ -206,6 +208,55 @@ a.button:hover, button:hover {
|
|||||||
max-width: 100%;
|
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 --- */
|
/* --- Avatare --- */
|
||||||
img.avatar {
|
img.avatar {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -237,6 +288,9 @@ img.avatar-lg { width: 72px; height: 72px; }
|
|||||||
auf 1rem liegt). Hier explizit angleichen -> ganze Meta-Zeile gleich groß. */
|
auf 1rem liegt). Hier explizit angleichen -> ganze Meta-Zeile gleich groß. */
|
||||||
.byline span { font-size: 1rem; }
|
.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". */
|
/* Profilkopf: großer Avatar links neben Name und "Mitglied seit". */
|
||||||
.profile-head {
|
.profile-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -258,9 +312,13 @@ img.avatar-lg { width: 72px; height: 72px; }
|
|||||||
Die Strich-Spalte ist halb so breit wie der Avatar, ihr rechter Rand (= der
|
Die Strich-Spalte ist halb so breit wie der Avatar, ihr rechter Rand (= der
|
||||||
Strich) liegt damit unter der Mitte des Byline-Avatars. Der Abstand zum
|
Strich) liegt damit unter der Mitte des Byline-Avatars. Der Abstand zum
|
||||||
Inhalt ist ebenfalls die halbe Avatarbreite -> Inhalt beginnt unter dem
|
Inhalt ist ebenfalls die halbe Avatarbreite -> Inhalt beginnt unter dem
|
||||||
rechten Avatarrand. Der .body füllt per flex:1 den Rest. */
|
rechten Avatarrand. Der .body füllt per flex:1 den Rest.
|
||||||
|
Dasselbe gilt für die Diskussionskette in Feed und Antworten (Discussion:
|
||||||
|
Beitrag, ggf. "k weitere Beiträge", jüngste Antwort) an allen Gliedern
|
||||||
|
außer dem letzten. */
|
||||||
#ancestors .threadline,
|
#ancestors .threadline,
|
||||||
#replies .threadline {
|
#replies .threadline,
|
||||||
|
.chain > :not(:last-child) .threadline {
|
||||||
display: block;
|
display: block;
|
||||||
/* Box um die Linienbreite schmaler -> die rechts gezeichnete border-right
|
/* Box um die Linienbreite schmaler -> die rechts gezeichnete border-right
|
||||||
landet genau auf der Avatarmitte statt rechts daneben. */
|
landet genau auf der Avatarmitte statt rechts daneben. */
|
||||||
@@ -268,12 +326,32 @@ img.avatar-lg { width: 72px; height: 72px; }
|
|||||||
margin-right: calc(var(--avatar-sm) / 2);
|
margin-right: calc(var(--avatar-sm) / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Nur die Vorfahren bilden einen Thread -> Linie. Antworten nur eingerückt. */
|
/* Strich nur, wo eine Kette ist: Vorfahren und Diskussionsketten. Direkte
|
||||||
#ancestors .threadline { border-right: var(--line-w) solid gray; }
|
Antworten ohne Kette sind nur eingerückt. */
|
||||||
|
#ancestors .threadline,
|
||||||
|
.chain > :not(:last-child) .threadline {
|
||||||
|
border-right: var(--line-w) solid gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "k weitere Beiträge" unterbricht den Strich: der Button steht bündig mit
|
||||||
|
dem Avatar, der Strich läuft oben auf ihn zu und setzt darunter (::after)
|
||||||
|
wieder an bis zum nächsten Avatar. .chain-gap a hebt den Button-Margin auf,
|
||||||
|
damit Strich und Rahmen sich berühren. */
|
||||||
|
.chain-gap a {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.chain-gap::after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
width: 0;
|
||||||
|
height: 0.6rem;
|
||||||
|
margin-left: calc(var(--avatar-sm) / 2 - var(--line-w));
|
||||||
|
border-left: var(--line-w) solid gray;
|
||||||
|
}
|
||||||
|
|
||||||
/* Interaktionsleiste: Antworten-Button links, Vote-Buttons mittig, Repost
|
/* Interaktionsleiste: Antworten-Button links, Vote-Buttons mittig, Repost
|
||||||
rechts. Antwort- und Repost-Zahl stehen in den Buttons; der Repost-Cooldown
|
rechts. Antwort- und Repost-Zahl stehen in den Buttons; der Repost-Cooldown
|
||||||
wandert in die Byline (siehe setupBump). */
|
wandert in die Byline (siehe useBump). */
|
||||||
.interactions {
|
.interactions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -282,8 +360,7 @@ img.avatar-lg { width: 72px; height: 72px; }
|
|||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bump-Button: tritt an die Stelle der früheren Bump-Kommentare. Im Cooldown
|
/* Repost-Button: im Cooldown gesperrt (gilt pro Beitrag für alle). */
|
||||||
gesperrt (gilt pro Beitrag für alle), der Button zeigt dann die Restzeit. */
|
|
||||||
.bump:disabled {
|
.bump:disabled {
|
||||||
color: gray;
|
color: gray;
|
||||||
border-color: #ccc;
|
border-color: #ccc;
|
||||||
@@ -302,58 +379,6 @@ img.avatar-lg { width: 72px; height: 72px; }
|
|||||||
.ghost.selected { font-weight: bold; }
|
.ghost.selected { font-weight: bold; }
|
||||||
.msg { color: red; }
|
.msg { color: red; }
|
||||||
|
|
||||||
/* Melden sitzt rechtsbündig in der Meta-Zeile (weit weg von den Interaktionen).
|
|
||||||
margin-left:auto schiebt es im Byline-Flex ganz nach rechts; position:relative
|
|
||||||
verankert das aufklappende Grund-Feld. Der <details>-Zweischritt (erst öffnen,
|
|
||||||
dann absenden) verhindert versehentliche Meldungen. */
|
|
||||||
details.report {
|
|
||||||
margin-left: auto;
|
|
||||||
position: relative;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
/* summary wie ein Button, aber zurückhaltend (grau), da Moderationshilfe. */
|
|
||||||
details.report > summary {
|
|
||||||
list-style: none;
|
|
||||||
user-select: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
color: gray;
|
|
||||||
background-color: white;
|
|
||||||
}
|
|
||||||
details.report > summary::-webkit-details-marker { display: none; }
|
|
||||||
details.report > summary:hover { text-decoration: underline; }
|
|
||||||
details.report[open] > summary { color: #a00; border-color: #a00; }
|
|
||||||
details.report.reported > summary {
|
|
||||||
color: green;
|
|
||||||
border-color: green;
|
|
||||||
cursor: default;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
/* Aufgeklapptes Grund-Feld schwebt unter dem Button, damit die Meta-Zeile nicht
|
|
||||||
springt. Über dem Inhalt (z-index) und rechtsbündig ausgerichtet. */
|
|
||||||
.report-form {
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
z-index: 2;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.25rem;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
background-color: white;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.report-form input { font-family: inherit; font-size: 0.9rem; }
|
|
||||||
|
|
||||||
p.warning {
|
|
||||||
background-color: #faa;
|
|
||||||
padding: 10px 15px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Formulare --- */
|
/* --- Formulare --- */
|
||||||
form {
|
form {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
@@ -380,11 +405,6 @@ form input[type="file"] {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
hr {
|
|
||||||
border: none;
|
|
||||||
border-top: 1px solid #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Statistik-Seite --- */
|
/* --- Statistik-Seite --- */
|
||||||
.statgrid {
|
.statgrid {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -401,41 +421,34 @@ hr {
|
|||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Horizontale Balken (Aufrufe/Tag, beliebteste Seiten). Breite wird per JS
|
/* Server-SVGs (charts.go): schmal 600, breit 1200 im viewBox-Raum. Ab 900 px
|
||||||
gesetzt (CSSOM, kein Inline-style -> CSP-konform). */
|
Fensterbreite die breite Fassung, damit die Schrift nicht zu groß wird. */
|
||||||
.bars {
|
.chart {
|
||||||
padding: 0.3rem 0.5rem 0.6rem;
|
padding: 0.3rem 0 0.6rem;
|
||||||
}
|
}
|
||||||
.bar-row {
|
.chart svg {
|
||||||
display: flex;
|
display: block;
|
||||||
align-items: center;
|
width: 100%;
|
||||||
gap: 0.5rem;
|
height: auto;
|
||||||
margin: 0.15rem 0;
|
|
||||||
}
|
}
|
||||||
.bar-label {
|
.chart-wide {
|
||||||
flex: 0 0 7rem;
|
display: none;
|
||||||
text-align: right;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
.bar-track {
|
/* Senkrechte Markierung des Tages unter dem Zeiger (Liniendiagramme). */
|
||||||
flex: 1;
|
.chart g:hover .mark {
|
||||||
background: #eee;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
.bar-fill {
|
/* Infozeile unter dem Diagramm: Höhe immer reserviert. */
|
||||||
height: 1.1rem;
|
.chart-tip {
|
||||||
background: gray;
|
margin: 0.2rem 0 0;
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
min-height: 1.3em;
|
||||||
}
|
}
|
||||||
.bar-value {
|
@media (min-width: 900px) {
|
||||||
flex: 0 0 auto;
|
.chart-narrow {
|
||||||
text-align: right;
|
display: none;
|
||||||
font-size: 0.95rem;
|
}
|
||||||
}
|
.chart-wide {
|
||||||
|
display: block;
|
||||||
/* ASN-Namen sind länger als Datum/Pfad -> mehr Platz, Umbruch statt Ellipse. */
|
}
|
||||||
#stat-asns .bar-label {
|
|
||||||
flex-basis: 13rem;
|
|
||||||
white-space: normal;
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { ApiError, api } from "./api";
|
||||||
|
import type { Me } from "./types";
|
||||||
|
|
||||||
|
const meKey = ["me"];
|
||||||
|
|
||||||
|
// useMe liefert das eigene Konto oder null, wenn niemand angemeldet ist. 401
|
||||||
|
// ist hier kein Fehler, sondern die Antwort "nicht angemeldet".
|
||||||
|
export function useMe() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: meKey,
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
return await api.get<Me>("/user/info");
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 401) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// useAuthChanged liefert die Aktion nach Login/Logout: alles, was vom
|
||||||
|
// Anmeldestatus abhängt, neu laden -- das eigene Konto und die eigene Auswahl
|
||||||
|
// bei den Votes.
|
||||||
|
export function useAuthChanged() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return () =>
|
||||||
|
Promise.all([
|
||||||
|
qc.invalidateQueries({ queryKey: meKey }),
|
||||||
|
qc.invalidateQueries({ queryKey: ["votes"] }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useState, type FormEvent, type MouseEvent } from "react";
|
||||||
|
import { api, errMsg } from "../api";
|
||||||
|
import { useAuthChanged } from "../auth";
|
||||||
|
|
||||||
|
// AuthBox: Login und Registrierung im Wechsel. Die {" "} sind Absicht:
|
||||||
|
// Überschrift, Inputs und Knöpfe sind inline, der Abstand ist sichtbar.
|
||||||
|
export function AuthBox() {
|
||||||
|
const [mode, setMode] = useState<"login" | "register">("login");
|
||||||
|
const [msg, setMsg] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const authChanged = useAuthChanged();
|
||||||
|
|
||||||
|
const show = (which: "login" | "register") => (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setMode(which);
|
||||||
|
setMsg("");
|
||||||
|
};
|
||||||
|
|
||||||
|
// submit sperrt das Formular für die Dauer des Requests und zeigt einen
|
||||||
|
// Fehler als Meldung; action wirft bei Misserfolg (ApiError).
|
||||||
|
const submit = (action: (fd: FormData) => Promise<unknown>, fallback: string) =>
|
||||||
|
async (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await action(new FormData(e.currentTarget));
|
||||||
|
setMsg("");
|
||||||
|
await authChanged();
|
||||||
|
} catch (err) {
|
||||||
|
setMsg(errMsg(err, fallback));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = submit((fd) => api.post("/auth/login", fd), "Login fehlgeschlagen.");
|
||||||
|
|
||||||
|
// Nach der Registrierung direkt mit denselben Daten einloggen, damit der
|
||||||
|
// Nutzer gleich drin ist. newuser meldet alle Feldfehler auf einmal.
|
||||||
|
const register = submit(async (fd) => {
|
||||||
|
await api.post("/auth/newuser", fd);
|
||||||
|
const creds = new FormData();
|
||||||
|
creds.append("user", String(fd.get("user")));
|
||||||
|
creds.append("pass", String(fd.get("pass1")));
|
||||||
|
creds.append("timeout", "86400");
|
||||||
|
await api.post("/auth/login", creds);
|
||||||
|
}, "Registrierung fehlgeschlagen.");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="auth-box">
|
||||||
|
<form id="login-form" hidden={mode !== "login"} onSubmit={login}>
|
||||||
|
<h2>Login</h2>{" "}
|
||||||
|
<input name="user" placeholder="Nutzername" autoComplete="username" />{" "}
|
||||||
|
<input name="pass" type="password" placeholder="Passwort" autoComplete="current-password" />{" "}
|
||||||
|
<label>
|
||||||
|
Angemeldet bleiben für{" "}
|
||||||
|
<select name="timeout" defaultValue="86400">
|
||||||
|
<option value="3600">1 Stunde</option>
|
||||||
|
<option value="86400">1 Tag</option>
|
||||||
|
<option value="604800">1 Woche</option>
|
||||||
|
<option value="31536000">1 Jahr</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<br />
|
||||||
|
<button type="submit" disabled={busy}>Einloggen</button>{" "}
|
||||||
|
<p className="muted">Noch kein Konto? <a href="#" onClick={show("register")}>Registrieren</a></p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form id="register-form" hidden={mode !== "register"} onSubmit={register}>
|
||||||
|
<h2>Registrieren</h2>{" "}
|
||||||
|
<input name="user" placeholder="Nutzername" autoComplete="username" />{" "}
|
||||||
|
<input name="pass1" type="password" placeholder="Passwort" autoComplete="new-password" />{" "}
|
||||||
|
<input name="pass2" type="password" placeholder="Passwort wiederholen" autoComplete="new-password" />{" "}
|
||||||
|
<button type="submit" disabled={busy}>Konto erstellen</button>{" "}
|
||||||
|
<p className="muted">Schon registriert? <a href="#" onClick={show("login")}>Zum Login</a></p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="msg" id="auth-msg">{msg}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { ApiError, api, errMsg } from "../api";
|
||||||
|
import type { BumpResult, Entry } from "../types";
|
||||||
|
|
||||||
|
const day = 86400;
|
||||||
|
const nowSec = () => Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
// cooldownText ist der Hinweis in der Byline: Restzeit auf Stunden
|
||||||
|
// aufgerundet (mindestens 1 -> nie "0h", solange Restzeit übrig ist).
|
||||||
|
function cooldownText(retryAfter: number): string {
|
||||||
|
return ` · Repost in ${Math.max(1, Math.ceil(retryAfter / 3600))}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// useBump hält den Repost-Zustand eines Beitrags. Der Cooldown wächst pro
|
||||||
|
// Beitrag linear (Backend, siehe handleBump); hier wird nur angezeigt und auf
|
||||||
|
// 429 reagiert. Eine Live-Aktualisierung gibt es bewusst nicht: der Zustand
|
||||||
|
// stammt aus den Beitragsdaten beim Laden, plus die Antwort des eigenen Klicks.
|
||||||
|
// Der Hinweis steht in der Byline, der Knopf unten -- deshalb ein Hook statt
|
||||||
|
// einer Komponente.
|
||||||
|
export function useBump(e: Entry) {
|
||||||
|
const initial = e.last_bump + e.bump_count * day - nowSec();
|
||||||
|
const [count, setCount] = useState(e.bump_count);
|
||||||
|
const [retryAfter, setRetryAfter] = useState(e.bump_count > 0 && initial > 0 ? initial : 0);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const bump = useMutation({
|
||||||
|
mutationFn: () => api.post<BumpResult>(`/entry/${e.pid}/bump`),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
setCount(r.bump_count);
|
||||||
|
setRetryAfter(r.retry_after || day);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
if (err instanceof ApiError && err.status === 429) {
|
||||||
|
setRetryAfter(Number(err.extra.retry_after) || day);
|
||||||
|
} else {
|
||||||
|
// 401 (nicht angemeldet) o. Ä. -> Fehlertext zeigen, Knopf frei lassen.
|
||||||
|
setError(errMsg(err, "Fehler"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const locked = retryAfter > 0;
|
||||||
|
return {
|
||||||
|
cooldown: locked ? cooldownText(retryAfter) : "",
|
||||||
|
button: (
|
||||||
|
<button
|
||||||
|
className="bump"
|
||||||
|
type="button"
|
||||||
|
disabled={locked || bump.isPending}
|
||||||
|
onClick={() => bump.mutate()}
|
||||||
|
>
|
||||||
|
{error || `Repost (${count})`}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { MouseEvent } from "react";
|
||||||
|
import { Link, useNavigate } from "react-router";
|
||||||
|
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,
|
||||||
|
// Knopf oder Formular darin.
|
||||||
|
function useOpenOnClick(pid: number) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (ev: MouseEvent) => {
|
||||||
|
if (!(ev.target as Element).closest("a, button, form")) navigate(`/e/${pid}`);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntryCard({ entry }: { entry: Entry }) {
|
||||||
|
return entry.deleted ? <DeletedCard entry={entry} /> : <LiveCard entry={entry} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft-gelöschter Beitrag: nur Platzhalter, damit der Thread navigierbar bleibt.
|
||||||
|
function DeletedCard({ entry: e }: { entry: Entry }) {
|
||||||
|
const open = useOpenOnClick(e.pid);
|
||||||
|
return (
|
||||||
|
<article className="card" onClick={open}>
|
||||||
|
<div className="muted byline"><span>[deleted] · {formatTime(e.created_at)}</span></div>
|
||||||
|
<div className="threadline-wrapper">
|
||||||
|
<div className="threadline"></div>
|
||||||
|
<div className="body">
|
||||||
|
<div className="content">[deleted]</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<article className="card" onClick={open}>
|
||||||
|
<div className="muted byline">
|
||||||
|
<Link to={profile}>
|
||||||
|
<img className="avatar avatar-sm" src={avatarUrl(e.avatar)} alt="" loading="lazy" />
|
||||||
|
</Link>
|
||||||
|
<span>
|
||||||
|
<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>
|
||||||
|
<Votes pid={e.pid} />
|
||||||
|
{bump.button}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discussion zeigt einen Beitrag mit der Antwort, die seine Diskussion zuletzt
|
||||||
|
// fortgesetzt hat: [Beitrag] ── "k weitere Beiträge" ── [jüngste Antwort].
|
||||||
|
// Der Strich verbindet die Glieder wie in der Ahnenkette; "k weitere" steht
|
||||||
|
// als Unterbrechung auf dem Strich und führt wie die Antwort selbst zu deren
|
||||||
|
// Seite, wo die ganze Kette darüber steht.
|
||||||
|
export function Discussion({ entry: e }: { entry: Entry }) {
|
||||||
|
if (!e.latest) return <EntryCard entry={e} />;
|
||||||
|
const { entry: latest, skipped } = e.latest;
|
||||||
|
return (
|
||||||
|
<div className="chain">
|
||||||
|
<EntryCard entry={e} />
|
||||||
|
{skipped > 0 && (
|
||||||
|
<div className="chain-gap">
|
||||||
|
<Link className="button" to={`/e/${latest.pid}`}>
|
||||||
|
{skipped === 1 ? "1 weiterer Beitrag" : `${skipped} weitere Beiträge`}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<EntryCard entry={latest} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { api, errMsg } from "../api";
|
||||||
|
import type { Entry } from "../types";
|
||||||
|
import { Discussion } from "./EntryCard";
|
||||||
|
|
||||||
|
// Das Backend deckelt die Seitenzahl bei 100 und liefert darüber wieder
|
||||||
|
// Seite 100 -- ohne diese Grenze würde der Feed sie endlos wiederholen.
|
||||||
|
const maxPage = 100;
|
||||||
|
|
||||||
|
// Feed lädt Seiten per Infinite-Scroll: queryKey identifiziert den Feed im
|
||||||
|
// Cache (zum Invalidieren nach eigenen Beiträgen), urlFor(page) baut den
|
||||||
|
// API-Pfad der Seite. as ist das Container-Element: <section> auf der
|
||||||
|
// Startseite, <div> innerhalb des Profils.
|
||||||
|
export function Feed({ queryKey, urlFor, as: Container = "section" }: {
|
||||||
|
queryKey: unknown[];
|
||||||
|
urlFor: (page: number) => string;
|
||||||
|
as?: "section" | "div";
|
||||||
|
}) {
|
||||||
|
const feed = useInfiniteQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn: ({ pageParam }) => api.get<Entry[]>(urlFor(pageParam)),
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (last, pages) =>
|
||||||
|
last.length && pages.length <= maxPage ? pages.length : undefined,
|
||||||
|
});
|
||||||
|
const sentinel = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { hasNextPage, isFetching, isError, fetchNextPage } = feed;
|
||||||
|
const canLoad = hasNextPage && !isFetching && !isError;
|
||||||
|
|
||||||
|
// rootMargin lädt schon kurz vor Erreichen des Sentinels nach. Der Observer
|
||||||
|
// wird nach jeder geladenen Seite neu angelegt und meldet dabei den
|
||||||
|
// aktuellen Zustand: füllt die Seite den Viewport nicht, liegt der Sentinel
|
||||||
|
// noch im Sichtbereich und es wird direkt weitergeladen.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sentinel.current;
|
||||||
|
if (!el || !canLoad) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(items) => {
|
||||||
|
if (items.some((i) => i.isIntersecting)) fetchNextPage();
|
||||||
|
},
|
||||||
|
{ rootMargin: "300px" },
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [canLoad, fetchNextPage]);
|
||||||
|
|
||||||
|
// Zwischen zwei Seiten kann ein Beitrag durch einen Repost nach vorn
|
||||||
|
// rutschen und auf der nächsten Seite erneut auftauchen -- nur einmal zeigen.
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const entries = (feed.data?.pages.flat() ?? []).filter((e) => !seen.has(e.pid) && seen.add(e.pid));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Container id="feed">
|
||||||
|
{entries.map((e) => <Discussion key={e.pid} entry={e} />)}
|
||||||
|
{feed.isError && <p className="msg">{errMsg(feed.error, "Feed konnte nicht geladen werden.")}</p>}
|
||||||
|
{feed.isSuccess && !hasNextPage && <p className="muted">YOU REACHED THE END!</p>}
|
||||||
|
</Container>
|
||||||
|
<div id="sentinel" ref={sentinel}></div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { api, errMsg } from "../api";
|
||||||
|
import { pasteImage } from "../paste";
|
||||||
|
|
||||||
|
// PostForm verfasst einen Beitrag, mit replyTo eine Antwort darauf.
|
||||||
|
export function PostForm({ replyTo, rows, placeholder, submitLabel, fallback, onDone }: {
|
||||||
|
replyTo?: string;
|
||||||
|
rows: number;
|
||||||
|
placeholder: string;
|
||||||
|
submitLabel: string;
|
||||||
|
fallback: string;
|
||||||
|
onDone: () => void;
|
||||||
|
}) {
|
||||||
|
const [pasteMsg, setPasteMsg] = useState("");
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: (fd: FormData) => api.post("/entry/create", fd),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.currentTarget;
|
||||||
|
const fd = new FormData(form);
|
||||||
|
if (replyTo) fd.append("reply_to", replyTo);
|
||||||
|
create.mutate(fd, {
|
||||||
|
onSuccess: () => {
|
||||||
|
form.reset();
|
||||||
|
setPasteMsg("");
|
||||||
|
onDone();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onPaste={(e) => {
|
||||||
|
if (pasteImage(e)) setPasteMsg("Bild aus Zwischenablage übernommen.");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<textarea name="content" rows={rows} maxLength={1000} placeholder={placeholder}></textarea>{" "}
|
||||||
|
<p className="muted">{pasteMsg}</p>{" "}
|
||||||
|
<input name="file" type="file" accept="image/*" />{" "}
|
||||||
|
<button type="submit" disabled={create.isPending}>{submitLabel}</button>
|
||||||
|
</form>
|
||||||
|
<p className="msg">{create.isError ? errMsg(create.error, fallback) : ""}</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api, errMsg } from "../api";
|
||||||
|
import type { VoteMode, VoteTally } from "../types";
|
||||||
|
|
||||||
|
// toggled rechnet vor, was der Server aus einer Stimme macht (siehe
|
||||||
|
// handleVote): gleiche Stimme zieht sie zurück, andere wechselt. Damit
|
||||||
|
// schaltet der Knopf sofort um; die Serverantwort überschreibt danach.
|
||||||
|
function toggled(t: VoteTally, mode: VoteMode): VoteTally {
|
||||||
|
const next = { ...t };
|
||||||
|
if (t.selected !== "none") next[t.selected]--;
|
||||||
|
if (t.selected === mode) {
|
||||||
|
next.selected = "none";
|
||||||
|
} else {
|
||||||
|
next[mode]++;
|
||||||
|
next.selected = mode;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Votes({ pid }: { pid: number }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const key = ["votes", pid];
|
||||||
|
const tally = useQuery({
|
||||||
|
queryKey: key,
|
||||||
|
queryFn: () => api.get<VoteTally>(`/entry/${pid}/votes`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const vote = useMutation({
|
||||||
|
mutationFn: (mode: VoteMode) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("mode", mode);
|
||||||
|
return api.post<VoteTally>(`/entry/${pid}/vote`, fd);
|
||||||
|
},
|
||||||
|
onMutate: async (mode) => {
|
||||||
|
await qc.cancelQueries({ queryKey: key });
|
||||||
|
const prev = qc.getQueryData<VoteTally>(key);
|
||||||
|
if (prev) qc.setQueryData(key, toggled(prev, mode));
|
||||||
|
return { prev };
|
||||||
|
},
|
||||||
|
onError: (_err, _mode, ctx) => {
|
||||||
|
if (ctx?.prev) qc.setQueryData(key, ctx.prev);
|
||||||
|
},
|
||||||
|
onSuccess: (data) => qc.setQueryData(key, data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const t = tally.data;
|
||||||
|
let counts = "– / –";
|
||||||
|
if (vote.isError) counts = errMsg(vote.error, "Fehler");
|
||||||
|
else if (tally.isError) counts = errMsg(tally.error, "Fehler");
|
||||||
|
else if (t) counts = `${t.yes} / ${t.no}`;
|
||||||
|
|
||||||
|
const button = (mode: VoteMode, label: string) => (
|
||||||
|
<button
|
||||||
|
className={t?.selected === mode ? "ghost selected" : "ghost"}
|
||||||
|
data-mode={mode}
|
||||||
|
onClick={() => vote.mutate(mode)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row">
|
||||||
|
{button("yes", "Ja")}
|
||||||
|
<span className="muted">{counts}</span>
|
||||||
|
{button("no", "Nein")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// avatarUrl liefert den Bildpfad oder das Platzhalterbild bei leerem Avatar.
|
||||||
|
export function avatarUrl(path: string): string {
|
||||||
|
return path ? `/${path}` : "/static/assets/no_profile_pic.jpg";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(unix: number): string {
|
||||||
|
return new Date(unix * 1000).toLocaleString("de-DE");
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlPattern = /https?:\/\/[^\s<]+/g;
|
||||||
|
|
||||||
|
// linkify macht aus URLs im Text Links und aus Zeilenumbrüchen <br>. Den Text
|
||||||
|
// escaped React selbst.
|
||||||
|
export function linkify(text: string): ReactNode[] {
|
||||||
|
const out: ReactNode[] = [];
|
||||||
|
text.split("\n").forEach((line, i) => {
|
||||||
|
if (i > 0) out.push(<br key={`br${i}`} />);
|
||||||
|
let last = 0;
|
||||||
|
for (const m of line.matchAll(urlPattern)) {
|
||||||
|
const start = m.index;
|
||||||
|
if (start > last) out.push(line.slice(last, start));
|
||||||
|
out.push(
|
||||||
|
<a key={`a${i}-${start}`} href={m[0]} target="_blank" rel="noopener">
|
||||||
|
{m[0]}
|
||||||
|
</a>,
|
||||||
|
);
|
||||||
|
last = start + m[0].length;
|
||||||
|
}
|
||||||
|
if (last < line.length) out.push(line.slice(last));
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { RouterProvider, createBrowserRouter } from "react-router";
|
||||||
|
import { ApiError } from "./api";
|
||||||
|
import { Layout } from "./Layout";
|
||||||
|
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({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
// 4xx ist eine Antwort, kein Aussetzer -- erneut fragen ändert nichts.
|
||||||
|
retry: (count, err) => !(err instanceof ApiError && err.status < 500) && count < 2,
|
||||||
|
// Beim Zurückwechseln in den Tab nicht den ganzen Feed neu laden; die
|
||||||
|
// Daten werden gezielt nach eigenen Aktionen invalidiert.
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 30_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
element: <Layout />,
|
||||||
|
children: [
|
||||||
|
{ path: "/", element: <FeedPage /> },
|
||||||
|
{ path: "/e/:pid", element: <EntryPage /> },
|
||||||
|
{ path: "/u/:username", element: <UserPage /> },
|
||||||
|
{ path: "/stats", element: <StatsPage /> },
|
||||||
|
{ path: "/reset", element: <ResetPage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { useParams } from "react-router";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api, errMsg } from "../api";
|
||||||
|
import { useMe } from "../auth";
|
||||||
|
import { Discussion, EntryCard } from "../components/EntryCard";
|
||||||
|
import { PostForm } from "../components/PostForm";
|
||||||
|
import type { Entry, Thread } from "../types";
|
||||||
|
|
||||||
|
// Beim Wechsel zu einem anderen Beitrag frisch anfangen (offene Bearbeitung,
|
||||||
|
// Meldungen) -- deshalb hängt die Seite per key an der pid.
|
||||||
|
export function EntryPage() {
|
||||||
|
const { pid = "" } = useParams();
|
||||||
|
return <ThreadView key={pid} pid={pid} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThreadView({ pid }: { pid: string }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const thread = useQuery({
|
||||||
|
queryKey: ["thread", pid],
|
||||||
|
queryFn: () => api.get<Thread>(`/entry/${pid}/thread`),
|
||||||
|
});
|
||||||
|
const me = useMe().data;
|
||||||
|
|
||||||
|
// Nach eigenen Änderungen den Thread und alle Feeds (Antwortzahl, Inhalt)
|
||||||
|
// neu laden.
|
||||||
|
const refresh = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["thread"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (thread.isError) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section id="ancestors"></section>
|
||||||
|
<div id="focus">{errMsg(thread.error, "Beitrag konnte nicht geladen werden.")}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = thread.data;
|
||||||
|
const isOwner = !!me && !!data && me.uid === data.entry.uid && !data.entry.deleted;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section id="ancestors">
|
||||||
|
{data?.ancestors.map((a) => <EntryCard key={a.pid} entry={a} />)}
|
||||||
|
</section>
|
||||||
|
<div id="focus">
|
||||||
|
{data && <EntryCard entry={data.entry} />}
|
||||||
|
{data && isOwner && <OwnerActions entry={data.entry} onChange={refresh} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section id="reply-box" hidden={!me}>
|
||||||
|
<PostForm
|
||||||
|
replyTo={pid}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Antworten…"
|
||||||
|
submitLabel="Antworten"
|
||||||
|
fallback="Antwort fehlgeschlagen."
|
||||||
|
onDone={refresh}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="replies">
|
||||||
|
{data && !data.replies.length && <p className="muted">Noch keine Antworten.</p>}
|
||||||
|
{data?.replies.map((e) => <Discussion key={e.pid} entry={e} />)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// OwnerActions: Bearbeiten und Löschen für den eigenen, nicht gelöschten Beitrag.
|
||||||
|
function OwnerActions({ entry, onChange }: { entry: Entry; onChange: () => void }) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
const edit = useMutation({
|
||||||
|
mutationFn: (fd: FormData) => api.post(`/entry/${entry.pid}/edit`, fd),
|
||||||
|
onSuccess: () => {
|
||||||
|
setEditing(false);
|
||||||
|
onChange();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: () => api.post(`/entry/${entry.pid}/delete`),
|
||||||
|
onSuccess: onChange,
|
||||||
|
onError: (err) => alert(errMsg(err, "Löschen fehlgeschlagen.")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
edit.mutate(new FormData(e.currentTarget));
|
||||||
|
};
|
||||||
|
const cancel = () => {
|
||||||
|
edit.reset();
|
||||||
|
setEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button disabled={editing} onClick={() => setEditing(true)}>Bearbeiten</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm("Beitrag wirklich löschen?")) del.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
{editing && (
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<textarea name="content" rows={4} maxLength={1000} defaultValue={entry.content}></textarea>{" "}
|
||||||
|
<button type="submit">Speichern</button>{" "}
|
||||||
|
<button type="button" className="cancel" onClick={cancel}>Abbrechen</button>{" "}
|
||||||
|
<p className="msg">{edit.isError ? errMsg(edit.error, "Bearbeiten fehlgeschlagen.") : ""}</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useMe } from "../auth";
|
||||||
|
import { AuthBox } from "../components/AuthBox";
|
||||||
|
import { Feed } from "../components/Feed";
|
||||||
|
import { PostForm } from "../components/PostForm";
|
||||||
|
|
||||||
|
// Solange der Anmeldestatus noch lädt, weder Login noch Formular zeigen --
|
||||||
|
// sonst blitzt bei Angemeldeten kurz die Login-Box auf.
|
||||||
|
export function FeedPage() {
|
||||||
|
const me = useMe();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{me.isSuccess && (me.data ? <Compose /> : <AuthBox />)}
|
||||||
|
<Feed queryKey={["feed"]} urlFor={(page) => `/entry/feed/${page}`} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nach einem neuen Beitrag springt der Feed zurück auf Seite 0, damit er oben
|
||||||
|
// steht.
|
||||||
|
function Compose() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return (
|
||||||
|
<section id="compose">
|
||||||
|
<PostForm
|
||||||
|
rows={4}
|
||||||
|
placeholder="Was gibt's Neues?"
|
||||||
|
submitLabel="Eintrag erstellen"
|
||||||
|
fallback="Beitrag fehlgeschlagen."
|
||||||
|
onDone={() => qc.resetQueries({ queryKey: ["feed"] })}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api, errMsg } from "../api";
|
||||||
|
import type { ChartPair, StatsCharts, StatsDetail } from "../types";
|
||||||
|
|
||||||
|
// 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.toLocaleString("de-DE", { maximumFractionDigits: 1, useGrouping: false })}</span>
|
||||||
|
<span className="muted">{label}</span>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
const c = charts.data?.charts;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h2>Statistik</h2>
|
||||||
|
|
||||||
|
<h3>Auf einen Blick</h3>
|
||||||
|
<div id="stat-overview" className="statgrid">
|
||||||
|
{stats.isError && <p className="msg">{errMsg(stats.error, "Statistik konnte nicht geladen werden.")}</p>}
|
||||||
|
{s && (
|
||||||
|
<>
|
||||||
|
<NumCard value={s.users} label={s.users === 1 ? "Profil" : "Profile"} />
|
||||||
|
<NumCard value={s.entries} label="Beiträge" />
|
||||||
|
<NumCard value={s.toplevel} label="davon Threads" />
|
||||||
|
<NumCard value={s.replies} label="davon Antworten" />
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<h3>Reichweite</h3>
|
||||||
|
<p className="muted">
|
||||||
|
Seitenaufrufe werden mit anonymisierten Netzen gezählt (IPv4 /16, IPv6 /32).
|
||||||
|
„Netze" ist eine grobe Annäherung an Besucher, keine Personenzählung.
|
||||||
|
</p>
|
||||||
|
<div id="stat-reach" className="statgrid">
|
||||||
|
{s && (
|
||||||
|
<>
|
||||||
|
<NumCard value={s.impressions_total} label="Aufrufe gesamt" />
|
||||||
|
<NumCard value={s.visitors_total} label="Netze gesamt" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Aufrufe pro Tag</h3>
|
||||||
|
<Chart svg={c?.impressions} />
|
||||||
|
|
||||||
|
<h3>Verschiedene Netze pro Tag</h3>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api, errMsg } from "../api";
|
||||||
|
import { useAuthChanged, useMe } from "../auth";
|
||||||
|
import { avatarUrl } from "../format";
|
||||||
|
import { Feed } from "../components/Feed";
|
||||||
|
import type { Profile } from "../types";
|
||||||
|
|
||||||
|
// Beim Wechsel zu einem anderen Profil frisch anfangen (Meldungen, Formulare).
|
||||||
|
export function UserPage() {
|
||||||
|
const { username = "" } = useParams();
|
||||||
|
return <ProfileView key={username} username={username} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileView({ username }: { username: string }) {
|
||||||
|
const profile = useQuery({
|
||||||
|
queryKey: ["profile", username],
|
||||||
|
queryFn: () => api.get<Profile>(`/u/${encodeURIComponent(username)}/info`),
|
||||||
|
});
|
||||||
|
const me = useMe().data;
|
||||||
|
// Nach einem Upload das neue Bild sofort zeigen: Zeitstempel am URL
|
||||||
|
// umgeht den Browser-Cache.
|
||||||
|
const [avatarBust, setAvatarBust] = useState("");
|
||||||
|
|
||||||
|
const info = profile.data;
|
||||||
|
let name = "";
|
||||||
|
if (profile.isError) name = errMsg(profile.error, "Profil konnte nicht geladen werden.");
|
||||||
|
else if (info) name = info.username;
|
||||||
|
|
||||||
|
const isOwner = !!me && !!info && me.username === info.username;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="profile">
|
||||||
|
<div className="profile-head">
|
||||||
|
<img
|
||||||
|
id="profile-avatar"
|
||||||
|
className="avatar avatar-lg"
|
||||||
|
src={avatarUrl(info?.avatar ?? "") + avatarBust}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h2 id="profile-name">{name}</h2>
|
||||||
|
<p id="profile-info" className="muted">
|
||||||
|
{info && `Mitglied seit ${new Date(info.created_at * 1000).toLocaleDateString("de-DE")}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="settings" hidden={!isOwner}>
|
||||||
|
{isOwner && <Settings onAvatar={() => setAvatarBust(`?t=${Date.now()}`)} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{info && (
|
||||||
|
<Feed
|
||||||
|
as="div"
|
||||||
|
queryKey={["feed", "user", username]}
|
||||||
|
urlFor={(page) => `/u/${encodeURIComponent(username)}/feed/${page}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// useFormAction verdrahtet ein Einstellungsformular: POST auf path, bei Erfolg
|
||||||
|
// onSuccess mit Formular und Antwort, bei Fehler die Meldung unter dem Formular.
|
||||||
|
function useFormAction<T>(path: string, fallback: string, onSuccess: (form: HTMLFormElement, data: T) => void) {
|
||||||
|
const m = useMutation({ mutationFn: (fd: FormData) => api.post<T>(path, fd) });
|
||||||
|
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.currentTarget;
|
||||||
|
m.mutate(new FormData(form), { onSuccess: (data) => onSuccess(form, data) });
|
||||||
|
};
|
||||||
|
return { onSubmit, busy: m.isPending, msg: m.isError ? errMsg(m.error, fallback) : "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings: nur für den Eigentümer.
|
||||||
|
function Settings({ onAvatar }: { onAvatar: () => void }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const authChanged = useAuthChanged();
|
||||||
|
|
||||||
|
// Neues Bild und neuer Name tauchen auch in den Beitragskarten auf -> die
|
||||||
|
// Feeds und das eigene Konto neu laden.
|
||||||
|
const refresh = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["thread"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["profile"] });
|
||||||
|
return authChanged();
|
||||||
|
};
|
||||||
|
|
||||||
|
const avatar = useFormAction<{ avatar: string }>("/user/avatar", "Hochladen fehlgeschlagen.", (form) => {
|
||||||
|
form.reset();
|
||||||
|
onAvatar();
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
const rename = useFormAction<{ username: string }>("/user/rename", "Umbenennen fehlgeschlagen.", async (_form, data) => {
|
||||||
|
await refresh();
|
||||||
|
navigate(`/u/${encodeURIComponent(data.username)}`);
|
||||||
|
});
|
||||||
|
const del = useFormAction("/user/delete", "Löschen fehlgeschlagen.", async () => {
|
||||||
|
await refresh();
|
||||||
|
navigate("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2>Einstellungen</h2>
|
||||||
|
<details>
|
||||||
|
<summary>Profilbild ändern</summary>
|
||||||
|
<form id="avatar-form" onSubmit={avatar.onSubmit}>
|
||||||
|
<input name="avatar" type="file" accept="image/*" />{" "}
|
||||||
|
<button type="submit" disabled={avatar.busy}>Bild hochladen</button>
|
||||||
|
</form>
|
||||||
|
<p className="msg" id="avatar-msg">{avatar.msg}</p>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>Name ändern</summary>
|
||||||
|
<form id="rename-form" onSubmit={rename.onSubmit}>
|
||||||
|
<input name="user" type="text" placeholder="Neuer Nutzername" autoComplete="username" />{" "}
|
||||||
|
<button type="submit" disabled={rename.busy}>Namen ändern</button>
|
||||||
|
</form>
|
||||||
|
<p className="msg" id="rename-msg">{rename.msg}</p>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>Konto löschen</summary>
|
||||||
|
<form
|
||||||
|
id="delete-form"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
if (confirm("Konto wirklich unwiderruflich löschen?")) del.onSubmit(e);
|
||||||
|
else e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="muted">Löscht dein Konto samt Beiträgen und Votes unwiderruflich.</p>{" "}
|
||||||
|
<input name="pass1" type="password" placeholder="Passwort bestätigen" autoComplete="current-password" />{" "}
|
||||||
|
<button type="submit" disabled={del.busy}>Konto endgültig löschen</button>
|
||||||
|
</form>
|
||||||
|
<p className="msg" id="delete-msg">{del.msg}</p>
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ClipboardEvent } from "react";
|
||||||
|
|
||||||
|
// pasteImage erlaubt Strg+V eines Bildes aus der Zwischenablage direkt im
|
||||||
|
// Formular: das Bild wandert in den File-Input (über DataTransfer, weil sich
|
||||||
|
// .files nicht direkt setzen lässt). Liegt kein Bild vor (Text o. Ä.), tun wir
|
||||||
|
// nichts und lassen den normalen Paste durchlaufen -> preventDefault nur, wenn
|
||||||
|
// wir wirklich ein Bild gegriffen haben. Gibt true zurück, wenn ein Bild
|
||||||
|
// übernommen wurde.
|
||||||
|
export function pasteImage(e: ClipboardEvent<HTMLFormElement>): boolean {
|
||||||
|
const item = [...(e.clipboardData?.items ?? [])].find(
|
||||||
|
(it) => it.kind === "file" && it.type.startsWith("image/"),
|
||||||
|
);
|
||||||
|
const original = item?.getAsFile();
|
||||||
|
if (!original) return false;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Browser benennen Clipboard-Bilder uneinheitlich ("grafik.png" o. Ä.) ->
|
||||||
|
// neu verpacken mit sprechendem Namen. Endung aus dem MIME-Typ, sonst png.
|
||||||
|
const ext = (original.type.split("/")[1] || "png").toLowerCase();
|
||||||
|
const file = new File([original], `zwischenablage.${ext}`, { type: original.type });
|
||||||
|
|
||||||
|
const input = e.currentTarget.querySelector<HTMLInputElement>("input[name='file']");
|
||||||
|
if (!input) return false;
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.items.add(file);
|
||||||
|
input.files = dt.files;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Typen der JSON-API; Referenz ist notes/api.md. Zeitstempel sind
|
||||||
|
// Unix-Sekunden.
|
||||||
|
|
||||||
|
export interface Entry {
|
||||||
|
pid: number;
|
||||||
|
uid: number;
|
||||||
|
created_at: number;
|
||||||
|
content: string;
|
||||||
|
filepath: string;
|
||||||
|
reply_to: number;
|
||||||
|
reply_count: number;
|
||||||
|
last_activity: number;
|
||||||
|
deleted: number;
|
||||||
|
bump_count: number;
|
||||||
|
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 = "yes" | "no";
|
||||||
|
|
||||||
|
export interface VoteTally {
|
||||||
|
pid: number;
|
||||||
|
yes: number;
|
||||||
|
no: number;
|
||||||
|
selected: VoteMode | "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BumpResult {
|
||||||
|
pid: number;
|
||||||
|
bump_count: number;
|
||||||
|
last_bump: number;
|
||||||
|
last_activity: number;
|
||||||
|
retry_after: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/user/info -- das eigene Konto.
|
||||||
|
export interface Me {
|
||||||
|
uid: number;
|
||||||
|
username: string;
|
||||||
|
created_at: number;
|
||||||
|
last_login: number;
|
||||||
|
avatar: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/u/{username}/info -- öffentliches Profil.
|
||||||
|
export interface Profile {
|
||||||
|
uid: number;
|
||||||
|
username: string;
|
||||||
|
created_at: number;
|
||||||
|
avatar: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsDetail {
|
||||||
|
users: number;
|
||||||
|
entries: number;
|
||||||
|
toplevel: number;
|
||||||
|
replies: number;
|
||||||
|
votes: { yes: number; no: number };
|
||||||
|
impressions_total: number;
|
||||||
|
visitors_total: 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
|
||||||
|
// daneben (Repost-Cooldown: retry_after, bump_count).
|
||||||
|
export interface ApiErrorBody {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
field?: string;
|
||||||
|
fields?: Record<string, string>;
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Beitragsordnung - Kontrollverlust</title>
|
<title>Beitragsordnung - Kontrollverlust</title>
|
||||||
<link rel="stylesheet" href="/css/app.css">
|
<link rel="stylesheet" href="/src/app.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<a href="/static/docs/impressum.html">Impressum</a> -
|
<a href="/static/docs/impressum.html">Impressum</a> -
|
||||||
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
|
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
|
||||||
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
|
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
|
||||||
<a href="/stats.html">Statistik</a>
|
<a href="/stats">Statistik</a>
|
||||||
</nav>
|
</nav>
|
||||||
<a href="/"><h1>Kontrollverlust</h1></a>
|
<a href="/"><h1>Kontrollverlust</h1></a>
|
||||||
<div id="sigils" aria-hidden="true">
|
<div id="sigils" aria-hidden="true">
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Datenschutz - Kontrollverlust</title>
|
<title>Datenschutz - Kontrollverlust</title>
|
||||||
<link rel="stylesheet" href="/css/app.css">
|
<link rel="stylesheet" href="/src/app.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<a href="/static/docs/impressum.html">Impressum</a> -
|
<a href="/static/docs/impressum.html">Impressum</a> -
|
||||||
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
|
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
|
||||||
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
|
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
|
||||||
<a href="/stats.html">Statistik</a>
|
<a href="/stats">Statistik</a>
|
||||||
</nav>
|
</nav>
|
||||||
<a href="/"><h1>Kontrollverlust</h1></a>
|
<a href="/"><h1>Kontrollverlust</h1></a>
|
||||||
</header>
|
</header>
|
||||||
@@ -119,6 +119,7 @@ E-Mail: <a>stress@bnd.wtf</a>
|
|||||||
<li>Optional: Hochgeladenes Bild (JPG, PNG, GIF; max. 1024x1024 Pixel)</li>
|
<li>Optional: Hochgeladenes Bild (JPG, PNG, GIF; max. 1024x1024 Pixel)</li>
|
||||||
</ul>
|
</ul>
|
||||||
<em>Sichtbarkeit:</em> Beiträge und Benutzername sind öffentlich einsehbar<br>
|
<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>Zweck:</em> Bereitstellung der Forumsfunktion<br>
|
||||||
<em>Rechtsgrundlage:</em> Vertragserfüllung (Art. 6 Abs. 1 lit. b DSGVO)<br>
|
<em>Rechtsgrundlage:</em> Vertragserfüllung (Art. 6 Abs. 1 lit. b DSGVO)<br>
|
||||||
<em>Speicherdauer:</em> Bis zur Löschung durch den Nutzer
|
<em>Speicherdauer:</em> Bis zur Löschung durch den Nutzer
|
||||||
@@ -152,7 +153,7 @@ E-Mail: <a>stress@bnd.wtf</a>
|
|||||||
</ul>
|
</ul>
|
||||||
<em>Zweck:</em> Erfassung des Seitenwachstums und der Reichweite (Seitenaufrufe)<br>
|
<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>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>
|
||||||
|
|
||||||
<p><strong>6. Datenlöschung</strong><br><br>
|
<p><strong>6. Datenlöschung</strong><br><br>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Impressum - Kontrollverlust</title>
|
<title>Impressum - Kontrollverlust</title>
|
||||||
<link rel="stylesheet" href="/css/app.css">
|
<link rel="stylesheet" href="/src/app.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<a href="/static/docs/impressum.html">Impressum</a> -
|
<a href="/static/docs/impressum.html">Impressum</a> -
|
||||||
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
|
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
|
||||||
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
|
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
|
||||||
<a href="/stats.html">Statistik</a>
|
<a href="/stats">Statistik</a>
|
||||||
</nav>
|
</nav>
|
||||||
<a href="/"><h1>Kontrollverlust</h1></a>
|
<a href="/"><h1>Kontrollverlust</h1></a>
|
||||||
</header>
|
</header>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2022",
|
||||||
|
"lib": ["es2022", "dom", "dom.iterable"],
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
// Im Dev-Betrieb läuft das Go-Backend separat (siehe dev.env, Port 8081).
|
||||||
|
// Vite reicht /api und /static dorthin durch -- für den Browser bleibt alles
|
||||||
|
// eine Origin, Session-Cookie und Origin-Check funktionieren unverändert.
|
||||||
|
//
|
||||||
|
// changeOrigin: false ist Pflicht: die Kurzform (nur URL) setzt es auf true
|
||||||
|
// und schreibt den Host-Header auf das Backend um. Dann passt der Origin des
|
||||||
|
// Browsers nicht mehr zum Host und checkOrigin weist jeden POST als CSRF ab.
|
||||||
|
const backend = process.env.KVER_BACKEND ?? "http://127.0.0.1:8081";
|
||||||
|
const proxy = { target: backend, changeOrigin: false };
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
// Das Go-Backend liefert das Build aus web/ aus (siehe frontend.go).
|
||||||
|
// emptyOutDir muss explizit an sein, weil das Ziel außerhalb von frontend/
|
||||||
|
// liegt.
|
||||||
|
// Die Rechtstexte sind eigene statische Seiten ohne JS; als weitere Einstiege
|
||||||
|
// bekommen sie dasselbe gebaute app.css wie die App.
|
||||||
|
build: {
|
||||||
|
outDir: "../web",
|
||||||
|
emptyOutDir: true,
|
||||||
|
rollupOptions: {
|
||||||
|
input: [
|
||||||
|
"index.html",
|
||||||
|
"static/docs/impressum.html",
|
||||||
|
"static/docs/datenschutz.html",
|
||||||
|
"static/docs/beitragsordnung.html",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": proxy,
|
||||||
|
"/static": proxy,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Nach jedem Commit den DEV-Container im Hintergrund neu bauen und starten,
|
||||||
|
# damit der Commit nicht auf den Build wartet. Ausgabe: .git/dev-up.log
|
||||||
|
# Aussetzen für einen Commit: KVER_NO_DEV_UP=1 git commit ...
|
||||||
|
[[ -n "${KVER_NO_DEV_UP:-}" ]] && exit 0
|
||||||
|
root="$(git rev-parse --show-toplevel)"
|
||||||
|
nohup "$root/dev-up.sh" >"$root/.git/dev-up.log" 2>&1 </dev/null &
|
||||||
|
echo "dev-up: DEV-Container wird neu gebaut (Log: .git/dev-up.log)"
|
||||||
@@ -1,32 +1,24 @@
|
|||||||
module kver
|
module kver
|
||||||
|
|
||||||
go 1.25.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-chi/chi/v5 v5.3.0
|
github.com/go-chi/chi/v5 v5.3.0
|
||||||
github.com/go-chi/httprate v0.15.0
|
github.com/go-chi/httprate v0.15.0
|
||||||
github.com/jackc/pgx/v5 v5.11.0
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
github.com/oschwald/maxminddb-golang v1.13.1
|
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/image v0.41.0
|
||||||
modernc.org/sqlite v1.59.0
|
golang.org/x/net v0.59.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
|
||||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
modernc.org/libc v1.75.7 // indirect
|
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
|
||||||
modernc.org/memory v1.12.1 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
|
||||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
||||||
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
@@ -23,16 +15,10 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
|
|||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
|
||||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
|
||||||
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
||||||
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
@@ -42,49 +28,19 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
|||||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
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.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
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 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
|
||||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
|
||||||
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
|
||||||
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
|
||||||
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
|
|
||||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
|
||||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
|
||||||
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
|
||||||
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
|
||||||
modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
|
|
||||||
modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
|
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
|
||||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
|
||||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
|
||||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
|
||||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
|
||||||
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
|
|
||||||
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
|
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
|
||||||
|
|||||||
+2
-4
@@ -3,10 +3,8 @@ package main
|
|||||||
import "net/http"
|
import "net/http"
|
||||||
|
|
||||||
// handlerFunc ist die Handler-Signatur dieser App: wie http.HandlerFunc, aber
|
// handlerFunc ist die Handler-Signatur dieser App: wie http.HandlerFunc, aber
|
||||||
// mit Fehler-Rückgabe. Der Gewinn ist nicht nur Kürze -- die alte Form
|
// mit Fehler-Rückgabe. "return err" beendet den Handler zwingend -- ein
|
||||||
// (writeError(...); return) machte ein vergessenes return zu einem stillen Bug:
|
// vergessenes return nach dem Fehlerschreiben kann es so nicht geben.
|
||||||
// der Handler lief weiter und schrieb eine zweite Antwort in denselben
|
|
||||||
// ResponseWriter. Mit "return err" kann das strukturell nicht mehr passieren.
|
|
||||||
type handlerFunc func(http.ResponseWriter, *http.Request) error
|
type handlerFunc func(http.ResponseWriter, *http.Request) error
|
||||||
|
|
||||||
// api hängt einen fehlerliefernden Handler in chi ein; der Fehler geht als
|
// api hängt einen fehlerliefernden Handler in chi ein; der Fehler geht als
|
||||||
|
|||||||
-135
@@ -1,135 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
|
||||||
)
|
|
||||||
|
|
||||||
// importTable beschreibt eine Tabelle für den einmaligen Umzug SQLite -> Postgres.
|
|
||||||
// Die Spalten heißen in beiden Datenbanken gleich; nur user heißt jetzt account.
|
|
||||||
type importTable struct {
|
|
||||||
src, dst string
|
|
||||||
cols []string
|
|
||||||
identity bool // hat eine id-Identity-Spalte, deren Sequenz nachgezogen werden muss
|
|
||||||
}
|
|
||||||
|
|
||||||
var importTables = []importTable{
|
|
||||||
{"user", "account", []string{"id", "uid", "username", "password", "created_at", "last_login", "avatar"}, true},
|
|
||||||
{"session", "session", []string{"id", "uid", "value", "created_at", "expires", "description"}, true},
|
|
||||||
{"entry", "entry", []string{"id", "pid", "uid", "created_at", "content", "filepath", "reply_to",
|
|
||||||
"reply_count", "last_activity", "deleted", "bump_count", "last_bump"}, true},
|
|
||||||
{"vote", "vote", []string{"id", "uid", "pid", "mode"}, true},
|
|
||||||
{"impression", "impression", []string{"day", "anon_ip", "path", "asn", "hits"}, false},
|
|
||||||
{"report", "report", []string{"id", "pid", "uid", "reason", "created_at", "handled"}, true},
|
|
||||||
}
|
|
||||||
|
|
||||||
// importSQLite kopiert alle Daten aus der SQLite-Datei path in die (per initDB
|
|
||||||
// bereits verbundene und mit Schema versehene) Postgres-Datenbank. Alles in einer
|
|
||||||
// Transaktion: entweder ist der Umzug vollständig oder es ist nichts passiert.
|
|
||||||
// Bricht ab, wenn Zieltabellen schon Daten enthalten -- ein zweiter Lauf soll
|
|
||||||
// nichts doppeln.
|
|
||||||
func importSQLite(path string) error {
|
|
||||||
src, err := sql.Open("sqlite", "file:"+path+"?mode=ro&immutable=1")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer src.Close()
|
|
||||||
if err := src.Ping(); err != nil {
|
|
||||||
return fmt.Errorf("sqlite öffnen: %w", err)
|
|
||||||
}
|
|
||||||
var check string
|
|
||||||
if err := src.QueryRow(`PRAGMA integrity_check`).Scan(&check); err != nil || check != "ok" {
|
|
||||||
return fmt.Errorf("sqlite integrity_check: %q %v", check, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, t := range importTables {
|
|
||||||
var n int64
|
|
||||||
if err := db.QueryRow(`SELECT COUNT(*) FROM ` + t.dst).Scan(&n); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if n > 0 {
|
|
||||||
return fmt.Errorf("zieltabelle %s ist nicht leer (%d zeilen) -- abbruch", t.dst, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
for _, t := range importTables {
|
|
||||||
var exists int
|
|
||||||
src.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, t.src).Scan(&exists)
|
|
||||||
if exists == 0 {
|
|
||||||
fmt.Printf("%-10s -> %-10s fehlt in sqlite, übersprungen\n", t.src, t.dst)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
n, err := copyTable(src, tx, t)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%s: %w", t.src, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var got int64
|
|
||||||
if err := tx.QueryRow(`SELECT COUNT(*) FROM ` + t.dst).Scan(&got); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if got != n {
|
|
||||||
return fmt.Errorf("%s: %d gelesen, aber %d in postgres", t.src, n, got)
|
|
||||||
}
|
|
||||||
if t.identity {
|
|
||||||
if _, err := tx.Exec(fmt.Sprintf(
|
|
||||||
`SELECT setval(pg_get_serial_sequence('%s', 'id'), COALESCE(MAX(id), 0) + 1, false) FROM %s`,
|
|
||||||
t.dst, t.dst)); err != nil {
|
|
||||||
return fmt.Errorf("%s: sequenz: %w", t.dst, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf("%-10s -> %-10s %6d zeilen\n", t.src, t.dst, n)
|
|
||||||
}
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyTable(src *sql.DB, tx *sql.Tx, t importTable) (int64, error) {
|
|
||||||
cols := strings.Join(t.cols, ", ")
|
|
||||||
rows, err := src.Query(`SELECT ` + cols + ` FROM "` + t.src + `"`)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
ph := make([]string, len(t.cols))
|
|
||||||
for i := range ph {
|
|
||||||
ph[i] = fmt.Sprintf("$%d", i+1)
|
|
||||||
}
|
|
||||||
override := ""
|
|
||||||
if t.identity {
|
|
||||||
override = " OVERRIDING SYSTEM VALUE"
|
|
||||||
}
|
|
||||||
stmt, err := tx.Prepare(`INSERT INTO ` + t.dst + ` (` + cols + `)` + override +
|
|
||||||
` VALUES (` + strings.Join(ph, ", ") + `)`)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer stmt.Close()
|
|
||||||
|
|
||||||
var n int64
|
|
||||||
vals := make([]any, len(t.cols))
|
|
||||||
ptrs := make([]any, len(t.cols))
|
|
||||||
for i := range vals {
|
|
||||||
ptrs[i] = &vals[i]
|
|
||||||
}
|
|
||||||
for rows.Next() {
|
|
||||||
if err := rows.Scan(ptrs...); err != nil {
|
|
||||||
return n, err
|
|
||||||
}
|
|
||||||
if _, err := stmt.Exec(vals...); err != nil {
|
|
||||||
return n, fmt.Errorf("zeile %d: %w", n+1, err)
|
|
||||||
}
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
return n, rows.Err()
|
|
||||||
}
|
|
||||||
+4
-6
@@ -12,15 +12,13 @@
|
|||||||
# Postgres-Verbindung. PFLICHT. Sonderzeichen im Passwort URL-kodieren.
|
# Postgres-Verbindung. PFLICHT. Sonderzeichen im Passwort URL-kodieren.
|
||||||
KVER_DSN=postgres://kver:passwort@host:5432/kontrollverlust?sslmode=disable
|
KVER_DSN=postgres://kver:passwort@host:5432/kontrollverlust?sslmode=disable
|
||||||
|
|
||||||
# Basic-Auth für die Moderationsseite /moderation. PFLICHT: ohne diese beiden
|
|
||||||
# Werte bleibt /moderation gesperrt (HTTP 503, fail-closed). Langes Zufalls-
|
|
||||||
# passwort verwenden.
|
|
||||||
KVER_MOD_USER=moderator
|
|
||||||
KVER_MOD_PASS=hier-ein-langes-zufallspasswort
|
|
||||||
|
|
||||||
# Optional: Pfad zur GeoLite2-ASN-DB im Container (leer = ASN-Auflösung aus).
|
# Optional: Pfad zur GeoLite2-ASN-DB im Container (leer = ASN-Auflösung aus).
|
||||||
#KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb
|
#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:
|
# Selten nötig -- die Container-Defaults passen normalerweise:
|
||||||
#KVER_ADDR=:8080
|
#KVER_ADDR=:8080
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"runtime/debug"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,9 +19,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// KVER_DIR: Verzeichnis mit web/ und static/. Per Chdir werden alle
|
// KVER_DIR: Verzeichnis mit web/ (gebautes Frontend) und static/. Per
|
||||||
// relativen Pfade (auch Uploads) auf einmal verankert -- die App ist damit
|
// Chdir werden alle relativen Pfade (auch Uploads) auf einmal verankert --
|
||||||
// nicht mehr an das Start-CWD gebunden.
|
// die App ist damit nicht mehr an das Start-CWD gebunden.
|
||||||
if dir := os.Getenv("KVER_DIR"); dir != "" {
|
if dir := os.Getenv("KVER_DIR"); dir != "" {
|
||||||
if err := os.Chdir(dir); err != nil {
|
if err := os.Chdir(dir); err != nil {
|
||||||
log.Fatalf("KVER_DIR: %v", err)
|
log.Fatalf("KVER_DIR: %v", err)
|
||||||
@@ -35,12 +37,13 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
// Einmaliger Umzug: kver import-sqlite /pfad/zur/kver.db
|
// Unterbefehle (z. B. reset-link) statt Server: laufen gegen dieselbe DB
|
||||||
if len(os.Args) == 3 && os.Args[1] == "import-sqlite" {
|
// und beenden sich danach, siehe command.go.
|
||||||
if err := importSQLite(os.Args[2]); err != nil {
|
if len(os.Args) > 1 {
|
||||||
log.Fatalf("import: %v", err)
|
if err := runCommand(os.Args[1:]); err != nil {
|
||||||
|
db.Close()
|
||||||
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
log.Print("import abgeschlossen")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +57,10 @@ func main() {
|
|||||||
// die geschlossene DB oder den unmap-ten GeoIP-Reader zugreifen.
|
// die geschlossene DB oder den unmap-ten GeoIP-Reader zugreifen.
|
||||||
startImpressionWorker()
|
startImpressionWorker()
|
||||||
defer stopImpressionWorker()
|
defer stopImpressionWorker()
|
||||||
|
startPreviewWorker()
|
||||||
|
defer stopPreviewWorker()
|
||||||
|
startChartWorker()
|
||||||
|
defer stopChartWorker()
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: envOr("KVER_ADDR", ":8080"),
|
Addr: envOr("KVER_ADDR", ":8080"),
|
||||||
@@ -67,7 +74,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Graceful Shutdown: auf SIGINT/SIGTERM laufende Requests zu Ende bedienen,
|
// Graceful Shutdown: auf SIGINT/SIGTERM laufende Requests zu Ende bedienen,
|
||||||
// dann die DB sauber schließen (WAL-Checkpoint) statt hart abzubrechen.
|
// dann über die defers Worker und DB sauber schließen.
|
||||||
idle := make(chan struct{})
|
idle := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
sig := make(chan os.Signal, 1)
|
sig := make(chan os.Signal, 1)
|
||||||
@@ -98,6 +105,10 @@ func envOr(key, fallback string) string {
|
|||||||
|
|
||||||
// routes baut den HTTP-Handler mit allen Endpunkten. In main() und in den
|
// routes baut den HTTP-Handler mit allen Endpunkten. In main() und in den
|
||||||
// Tests identisch verwendet.
|
// Tests identisch verwendet.
|
||||||
|
//
|
||||||
|
// Aufteilung: alles unter /api spricht ausschließlich JSON -- auch 404, 405,
|
||||||
|
// Rate-Limit und Panics. Der Rest liefert das Frontend (frontend.go) und
|
||||||
|
// statische Dateien.
|
||||||
func routes() http.Handler {
|
func routes() http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
// RealIP übernimmt X-Forwarded-For/X-Real-IP vom Reverse-Proxy, damit
|
// RealIP übernimmt X-Forwarded-For/X-Real-IP vom Reverse-Proxy, damit
|
||||||
@@ -107,49 +118,47 @@ func routes() http.Handler {
|
|||||||
// spoofbar.
|
// spoofbar.
|
||||||
r.Use(middleware.RealIP)
|
r.Use(middleware.RealIP)
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(recoverJSON)
|
||||||
r.Use(secHeaders)
|
r.Use(secHeaders)
|
||||||
r.Use(checkOrigin)
|
r.Use(checkOrigin)
|
||||||
|
|
||||||
|
r.Route("/api", func(r chi.Router) {
|
||||||
|
r.NotFound(api(func(http.ResponseWriter, *http.Request) error {
|
||||||
|
return NotFound("api.not_found", "Unbekannter Endpunkt.")
|
||||||
|
}))
|
||||||
|
r.MethodNotAllowed(api(func(http.ResponseWriter, *http.Request) error {
|
||||||
|
return MethodNotAllowed("api.method_not_allowed", "Methode nicht erlaubt.")
|
||||||
|
}))
|
||||||
|
|
||||||
// --- Auth (öffentlich) ---
|
// --- Auth (öffentlich) ---
|
||||||
// Login und Registrierung pro IP drosseln (Brute-Force-Bremse). bcrypt bremst
|
// Login und Registrierung pro IP drosseln (Brute-Force-Bremse). bcrypt
|
||||||
// zusätzlich, aber das Limit kappt automatisiertes Durchprobieren früh.
|
// bremst zusätzlich, aber das Limit kappt automatisiertes Durchprobieren
|
||||||
|
// früh.
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(httprate.LimitByIP(20, time.Minute))
|
r.Use(limitByIP(20, time.Minute))
|
||||||
r.Post("/auth/login", api(handleLogin))
|
r.Post("/auth/login", api(handleLogin))
|
||||||
r.Post("/auth/newuser", api(handleNewUser))
|
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-
|
// Logout als POST: SameSite=Lax schickt Cookies bei Top-Level-GET-
|
||||||
// Navigationen mit -- als GET könnte eine fremde Seite Nutzer per Link
|
// Navigationen mit -- als GET könnte eine fremde Seite Nutzer per Link
|
||||||
// ausloggen (CSRF). POST ist durch Lax + checkOrigin gedeckt.
|
// ausloggen (CSRF). POST ist durch Lax + checkOrigin gedeckt.
|
||||||
r.Post("/auth/logout", handleLogout)
|
r.Post("/auth/logout", api(handleLogout))
|
||||||
r.Get("/auth/sessioninfo", api(handleSessionInfo))
|
r.Get("/auth/sessioninfo", api(handleSessionInfo))
|
||||||
r.Get("/auth/headerbar", handleHeaderbar)
|
r.Get("/stats", api(handleStats))
|
||||||
r.Get("/stats", handleStats)
|
r.Get("/stats/detail", api(handleStatsDetail))
|
||||||
r.Get("/stats/detail", handleStatsDetail)
|
r.Get("/stats/charts", api(handleStatsCharts))
|
||||||
|
|
||||||
// Moderations-Queue (Meldungen). Basic-Auth direkt in der App (modBasicAuth),
|
// --- Entries (öffentlich) ---
|
||||||
// weil TOR-Zugriffe die Go-App direkt erreichen und NICHT über Caddy laufen --
|
|
||||||
// ein rein proxy-seitiger Schutz hätte diese Lücke offen gelassen. Caddy
|
|
||||||
// erzwingt zusätzlich TLS für den Clearnet-Weg.
|
|
||||||
r.With(modBasicAuth).Get("/moderation", api(handleModeration))
|
|
||||||
|
|
||||||
// HTML-Seiten: mit Impression-Tracking (anonymisiert, siehe stats.go). Nur
|
|
||||||
// echte Seitenaufrufe, nicht API/Assets.
|
|
||||||
r.With(trackImpression).Get("/", serveIndex)
|
|
||||||
|
|
||||||
// --- Entries ---
|
|
||||||
r.Get("/entry/feed/{page}", api(handleFeed))
|
r.Get("/entry/feed/{page}", api(handleFeed))
|
||||||
r.Get("/entry/{pid}/votes", api(handleVotes))
|
r.Get("/entry/{pid}/votes", api(handleVotes))
|
||||||
r.Get("/entry/{pid}/thread", api(handleThread))
|
r.Get("/entry/{pid}/thread", api(handleThread))
|
||||||
r.With(trackImpression).Get("/e/{pid}", serveEntryPage)
|
// Melden darf jeder (anonym ohne Session), gedrosselt pro IP, damit der
|
||||||
|
// offene Endpunkt kein Spam-Kanal wird.
|
||||||
// Melden: öffentlich (jeder darf melden, Moderationshilfe), aber pro IP
|
r.With(limitByIP(10, time.Minute)).Post("/entry/{pid}/report", api(handleReport))
|
||||||
// gedrosselt, damit der auth-lose Endpunkt nicht zum Spam-Kanal wird.
|
|
||||||
r.With(httprate.LimitByIP(10, time.Minute)).Post("/entry/{pid}/report", api(handleReport))
|
|
||||||
|
|
||||||
// --- User (öffentlich) ---
|
// --- User (öffentlich) ---
|
||||||
r.With(trackImpression).Get("/u/{username}", serveUserPage)
|
|
||||||
r.Get("/u/{username}/info", api(handleUserPage))
|
r.Get("/u/{username}/info", api(handleUserPage))
|
||||||
r.Get("/u/{username}/feed/{page}", api(handleUserFeed))
|
r.Get("/u/{username}/feed/{page}", api(handleUserFeed))
|
||||||
|
|
||||||
@@ -166,19 +175,66 @@ func routes() http.Handler {
|
|||||||
r.Post("/user/avatar", api(handleSetAvatar))
|
r.Post("/user/avatar", api(handleSetAvatar))
|
||||||
r.Post("/user/delete", api(handleUserDelete))
|
r.Post("/user/delete", api(handleUserDelete))
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Hochgeladene Medien + alte statische Assets. nosniff (global via
|
// --- Frontend (React) ---
|
||||||
// secHeaders) verhindert, dass der Browser eine durchgereichte (nicht
|
// Die Seitenrouten entsprechen denen des Client-Routers (frontend/src/
|
||||||
// re-kodierte) Datei als HTML/JS interpretiert -> schließt
|
// main.tsx) und liefern alle dieselbe index.html. Impression-Tracking
|
||||||
// Polyglot/Stored-XSS über Bild-Uploads.
|
// (anonymisiert, siehe stats.go) nur auf echten Seitenaufrufen, nicht auf
|
||||||
|
// API/Assets; die Statistik-Seite zählt wie bisher nicht mit.
|
||||||
|
r.With(trackImpression).Get("/", serveApp)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
r.Handle("/assets/*", serveAssets())
|
||||||
|
r.Get("/static/docs/{page}", serveDoc)
|
||||||
|
|
||||||
|
// Hochgeladene Medien + statische Dateien (Schriften, Bilder).
|
||||||
|
// nosniff (global via secHeaders) verhindert, dass der Browser eine
|
||||||
|
// durchgereichte (nicht re-kodierte) Datei als HTML/JS interpretiert ->
|
||||||
|
// schließt Polyglot/Stored-XSS über Bild-Uploads.
|
||||||
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||||
|
|
||||||
// Neues JS-Frontend
|
|
||||||
r.Handle("/*", http.FileServer(http.Dir("web")))
|
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// limitByIP ist httprate.LimitByIP mit JSON-Antwort statt des Klartext-429.
|
||||||
|
func limitByIP(n int, window time.Duration) func(http.Handler) http.Handler {
|
||||||
|
return httprate.Limit(n, window,
|
||||||
|
httprate.WithKeyFuncs(httprate.KeyByIP),
|
||||||
|
httprate.WithLimitHandler(api(func(http.ResponseWriter, *http.Request) error {
|
||||||
|
return RateLimited("rate.limited", "Zu viele Anfragen. Bitte kurz warten.")
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recoverJSON ersetzt middleware.Recoverer: eine Panic wird geloggt (mit Stack)
|
||||||
|
// und als normaler 500 im JSON-Fehlerformat beantwortet. http.ErrAbortHandler
|
||||||
|
// ist ein gewollter Verbindungsabbruch und wird durchgereicht.
|
||||||
|
func recoverJSON(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer func() {
|
||||||
|
rec := recover()
|
||||||
|
if rec == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rec == http.ErrAbortHandler {
|
||||||
|
panic(rec)
|
||||||
|
}
|
||||||
|
log.Printf("panic: %v\n%s", rec, debug.Stack())
|
||||||
|
writeAPIError(w, Internal(fmt.Errorf("panic: %v", rec)))
|
||||||
|
}()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// secHeaders setzt Standard-Security-Header auf alle Antworten:
|
// secHeaders setzt Standard-Security-Header auf alle Antworten:
|
||||||
// - nosniff: Browser darf den deklarierten Content-Type nicht überstimmen
|
// - nosniff: Browser darf den deklarierten Content-Type nicht überstimmen
|
||||||
// (wichtig für durchgereichte GIF-Uploads, siehe storeImage).
|
// (wichtig für durchgereichte GIF-Uploads, siehe storeImage).
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ const uidKey ctxKey = "uid"
|
|||||||
// der Onion-Hidden-Service reicht über einen lokalen Proxy weiter, eine echte
|
// der Onion-Hidden-Service reicht über einen lokalen Proxy weiter, eine echte
|
||||||
// Client-IP gibt es dabei nicht -> die Anfragen erscheinen mit einer IP aus
|
// Client-IP gibt es dabei nicht -> die Anfragen erscheinen mit einer IP aus
|
||||||
// diesem Netz (siehe Impression-Log: 10.89.0.0). Beiträge von dort werden
|
// diesem Netz (siehe Impression-Log: 10.89.0.0). Beiträge von dort werden
|
||||||
// vorerst komplett abgewiesen; eine Moderations-/Freigabeseite kommt später.
|
// vorerst komplett abgewiesen.
|
||||||
// Per KVER_TOR_NET (CIDR) konfigurierbar; leer schaltet die Sperre ab.
|
// Per KVER_TOR_NET (CIDR) konfigurierbar; leer schaltet die Sperre ab.
|
||||||
var torNet *net.IPNet
|
var torNet *net.IPNet
|
||||||
|
|
||||||
|
|||||||
-196
@@ -1,196 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# migrate.sh — liest kver-original.db und erzeugt daraus eine frische kver.db
|
|
||||||
# mit dem aktuellen Go-Schema. Die Quelldatenbank wird nie verändert.
|
|
||||||
# Idempotent: kver.db wird bei jedem Lauf neu erstellt.
|
|
||||||
#
|
|
||||||
# Verwendung:
|
|
||||||
# ./migrate.sh [quelldatei [zieldatei]] # Standard: kver-original.db -> kver.db
|
|
||||||
#
|
|
||||||
# Schalter (als Umgebungsvariablen):
|
|
||||||
# REPLIES=keep|drop Antworten (reply_to != 0) übernehmen oder verwerfen (Default: keep)
|
|
||||||
#
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SRC="${1:-original-kver.db}"
|
|
||||||
DST="${2:-kver.db}"
|
|
||||||
REPLIES="${REPLIES:-keep}"
|
|
||||||
|
|
||||||
if [[ ! -f "$SRC" ]]; then
|
|
||||||
echo "FEHLER: Quelldatenbank '$SRC' nicht gefunden." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ "$REPLIES" != "keep" && "$REPLIES" != "drop" ]]; then
|
|
||||||
echo "FEHLER: REPLIES muss 'keep' oder 'drop' sein (war: '$REPLIES')." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ "$SRC" -ef "$DST" ]]; then
|
|
||||||
echo "FEHLER: Quelle und Ziel sind dieselbe Datei." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
qs() { sqlite3 "$SRC" "$1"; }
|
|
||||||
qd() { sqlite3 "$DST" "$1"; }
|
|
||||||
|
|
||||||
echo "== kver-Migration =="
|
|
||||||
echo "Quelle: $SRC"
|
|
||||||
echo "Ziel: $DST"
|
|
||||||
echo "REPLIES: $REPLIES"
|
|
||||||
echo
|
|
||||||
|
|
||||||
echo "Quelle:"
|
|
||||||
echo " Einträge gesamt: $(qs 'SELECT COUNT(*) FROM entry;')"
|
|
||||||
echo " davon deleted=1: $(qs 'SELECT COUNT(*) FROM entry WHERE deleted=1;')"
|
|
||||||
echo " davon Antworten: $(qs 'SELECT COUNT(*) FROM entry WHERE reply_to!=0;')"
|
|
||||||
echo " User: $(qs 'SELECT COUNT(*) FROM user;')"
|
|
||||||
|
|
||||||
# Zieldatenbank immer frisch anlegen
|
|
||||||
rm -f "$DST"
|
|
||||||
echo
|
|
||||||
echo "Erstelle $DST mit aktuellem Schema..."
|
|
||||||
|
|
||||||
sqlite3 "$DST" <<'SCHEMA'
|
|
||||||
.bail on
|
|
||||||
CREATE TABLE user (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
uid INTEGER UNIQUE,
|
|
||||||
username TEXT UNIQUE,
|
|
||||||
password BLOB,
|
|
||||||
created_at INTEGER,
|
|
||||||
last_login INTEGER,
|
|
||||||
avatar TEXT NOT NULL DEFAULT ''
|
|
||||||
);
|
|
||||||
CREATE TABLE session (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
uid INTEGER,
|
|
||||||
value TEXT UNIQUE,
|
|
||||||
created_at INTEGER,
|
|
||||||
expires INTEGER,
|
|
||||||
description TEXT
|
|
||||||
);
|
|
||||||
CREATE TABLE entry (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
pid INTEGER UNIQUE NOT NULL,
|
|
||||||
uid INTEGER NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
content TEXT NOT NULL DEFAULT '',
|
|
||||||
filepath TEXT NOT NULL DEFAULT '',
|
|
||||||
reply_to INTEGER NOT NULL DEFAULT 0,
|
|
||||||
reply_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
last_activity INTEGER NOT NULL DEFAULT 0,
|
|
||||||
deleted INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
CREATE TABLE vote (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
uid INTEGER,
|
|
||||||
pid INTEGER,
|
|
||||||
mode TEXT,
|
|
||||||
UNIQUE(uid, pid)
|
|
||||||
);
|
|
||||||
SCHEMA
|
|
||||||
|
|
||||||
# Prüfen, welche optionalen Spalten in der Quelle vorhanden sind.
|
|
||||||
# Ältere Python-DBs haben reply_count und last_activity noch nicht.
|
|
||||||
has_col() {
|
|
||||||
sqlite3 "$SRC" "SELECT COUNT(*) FROM pragma_table_info('$1') WHERE name='$2';"
|
|
||||||
}
|
|
||||||
|
|
||||||
reply_count_expr="0"
|
|
||||||
[[ "$(has_col entry reply_count)" == "1" ]] && reply_count_expr="COALESCE(e.reply_count, 0)"
|
|
||||||
|
|
||||||
last_activity_expr="e.created_at"
|
|
||||||
[[ "$(has_col entry last_activity)" == "1" ]] && last_activity_expr="COALESCE(e.last_activity, e.created_at)"
|
|
||||||
|
|
||||||
# Profilbild: alte DBs haben die Spalte nicht -> leerer String.
|
|
||||||
avatar_expr="''"
|
|
||||||
[[ "$(has_col user avatar)" == "1" ]] && avatar_expr="COALESCE(u.avatar, '')"
|
|
||||||
|
|
||||||
replies_filter=""
|
|
||||||
[[ "$REPLIES" == "drop" ]] && replies_filter="AND e.reply_to = 0"
|
|
||||||
|
|
||||||
has_vote_table=$(qs "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='vote';")
|
|
||||||
|
|
||||||
# Quell-Pfad für SQL-String-Literal escapen (einfache Anführungszeichen verdoppeln)
|
|
||||||
src_sql="${SRC//\'/\'\'}"
|
|
||||||
|
|
||||||
sqlite3 "$DST" <<SQL
|
|
||||||
.bail on
|
|
||||||
ATTACH '$src_sql' AS src;
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
-- User übernehmen; nur Spalten des aktuellen Schemas (Quelle kann Extra-Spalten haben)
|
|
||||||
INSERT INTO user (id, uid, username, password, created_at, last_login, avatar)
|
|
||||||
SELECT u.id, u.uid, u.username, u.password, u.created_at, u.last_login, $avatar_expr FROM src.user u;
|
|
||||||
|
|
||||||
-- Einträge transformieren:
|
|
||||||
-- filepath NULL und 'none' -> '' (Go-Zero-Value; scanEntries erwartet string, nicht *string)
|
|
||||||
-- content NULL -> ''
|
|
||||||
-- deleted: nur nicht-gelöschte Einträge werden übernommen
|
|
||||||
-- orphans: Einträge ohne bekannten User werden verworfen
|
|
||||||
INSERT INTO entry (pid, uid, created_at, content, filepath, reply_to, reply_count, last_activity, deleted)
|
|
||||||
SELECT
|
|
||||||
e.pid,
|
|
||||||
e.uid,
|
|
||||||
e.created_at,
|
|
||||||
COALESCE(e.content, ''),
|
|
||||||
COALESCE(NULLIF(e.filepath, 'none'), ''),
|
|
||||||
COALESCE(e.reply_to, 0),
|
|
||||||
$reply_count_expr,
|
|
||||||
$last_activity_expr,
|
|
||||||
0
|
|
||||||
FROM src.entry e
|
|
||||||
WHERE e.deleted = 0
|
|
||||||
AND e.uid IN (SELECT uid FROM src.user)
|
|
||||||
$replies_filter;
|
|
||||||
|
|
||||||
-- Absolute /srv/kver/-Pfade aus der alten Python-Prod-Umgebung normalisieren.
|
|
||||||
UPDATE entry SET filepath = REPLACE(filepath, '/srv/kver/', '') WHERE filepath LIKE '/srv/kver/%';
|
|
||||||
|
|
||||||
-- Sessions leer lassen: Alt-Tokens sind Integer (Python-Format),
|
|
||||||
-- Go erzeugt crypto/rand-Hex-Tokens -> alle Alt-Sessions sind wertlos.
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
DETACH src;
|
|
||||||
VACUUM;
|
|
||||||
SQL
|
|
||||||
|
|
||||||
# Votes separat, da die Tabelle in alten DBs fehlen kann
|
|
||||||
if [[ "$has_vote_table" == "1" ]]; then
|
|
||||||
sqlite3 "$DST" <<SQL2
|
|
||||||
.bail on
|
|
||||||
ATTACH '$src_sql' AS src;
|
|
||||||
INSERT OR IGNORE INTO vote (uid, pid, mode)
|
|
||||||
SELECT uid, pid, mode FROM src.vote
|
|
||||||
WHERE uid IN (SELECT uid FROM entry)
|
|
||||||
AND pid IN (SELECT pid FROM entry);
|
|
||||||
DETACH src;
|
|
||||||
SQL2
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "Nachher:"
|
|
||||||
echo " Einträge gesamt: $(qd 'SELECT COUNT(*) FROM entry;')"
|
|
||||||
echo " davon Antworten: $(qd 'SELECT COUNT(*) FROM entry WHERE reply_to!=0;')"
|
|
||||||
echo " sichtbar im Feed: $(qd 'SELECT COUNT(*) FROM entry WHERE reply_to=0;')"
|
|
||||||
echo " User: $(qd 'SELECT COUNT(*) FROM user;')"
|
|
||||||
echo " Votes: $(qd 'SELECT COUNT(*) FROM vote;')"
|
|
||||||
|
|
||||||
# Medien-Check: referenzierte Bilder, die im Dateisystem fehlen
|
|
||||||
missing=0
|
|
||||||
total=0
|
|
||||||
while IFS= read -r fp; do
|
|
||||||
[[ -z "$fp" ]] && continue
|
|
||||||
total=$((total+1))
|
|
||||||
[[ -f "$fp" ]] || missing=$((missing+1))
|
|
||||||
done < <(qd "SELECT filepath FROM entry WHERE filepath != '';")
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "Medien:"
|
|
||||||
echo " referenziert: $total"
|
|
||||||
echo " fehlend im Dateisystem: $missing"
|
|
||||||
if [[ "$missing" -gt 0 ]]; then
|
|
||||||
echo " -> static/media/ aus der Produktion kopieren, sonst liefern diese Bilder 404."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "Fertig."
|
|
||||||
-147
@@ -1,147 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/subtle"
|
|
||||||
"html/template"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// modBasicAuth schützt die Moderationsseite per HTTP Basic Auth direkt in der
|
|
||||||
// App. Nötig, weil TOR-Zugriffe direkt an die Go-App gehen und NICHT über Caddy
|
|
||||||
// laufen -- der proxy-seitige Schutz greift dort also nicht. Credentials kommen
|
|
||||||
// aus KVER_MOD_USER/KVER_MOD_PASS und werden einmalig beim Routing-Aufbau
|
|
||||||
// gelesen. Fail-closed: sind sie nicht gesetzt, bleibt die Seite gesperrt (503),
|
|
||||||
// damit sie nie versehentlich offen ist. Fehler gehen wie überall als JSON
|
|
||||||
// hinaus; der Browser zeigt den Login-Dialog trotzdem (WWW-Authenticate). Vergleich über SHA-256 + ConstantTime,
|
|
||||||
// damit weder Inhalt noch Länge der Credentials über Timing durchsickern.
|
|
||||||
func modBasicAuth(next http.Handler) http.Handler {
|
|
||||||
wantUser := envOr("KVER_MOD_USER", "")
|
|
||||||
wantPass := envOr("KVER_MOD_PASS", "")
|
|
||||||
// Einmalig beim Routing-Aufbau warnen statt bei jedem Request: der Hinweis
|
|
||||||
// richtet sich an den Betreiber, nicht an den Besucher. Die Namen der
|
|
||||||
// Umgebungsvariablen bleiben deshalb auch aus der Fehlerseite heraus --
|
|
||||||
// die Seite ist öffentlich erreichbar.
|
|
||||||
if wantUser == "" || wantPass == "" {
|
|
||||||
log.Printf("moderation gesperrt: KVER_MOD_USER/KVER_MOD_PASS nicht gesetzt")
|
|
||||||
}
|
|
||||||
return guard(func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
if wantUser == "" || wantPass == "" {
|
|
||||||
return Unavailable("mod.not_configured", "Die Moderationsseite ist nicht konfiguriert.")
|
|
||||||
}
|
|
||||||
u, p, ok := r.BasicAuth()
|
|
||||||
uSum, wuSum := sha256.Sum256([]byte(u)), sha256.Sum256([]byte(wantUser))
|
|
||||||
pSum, wpSum := sha256.Sum256([]byte(p)), sha256.Sum256([]byte(wantPass))
|
|
||||||
userOK := subtle.ConstantTimeCompare(uSum[:], wuSum[:]) == 1
|
|
||||||
passOK := subtle.ConstantTimeCompare(pSum[:], wpSum[:]) == 1
|
|
||||||
if !ok || !userOK || !passOK {
|
|
||||||
// Der Header muss vor dem Rendern stehen, sonst fragt der Browser
|
|
||||||
// nicht nach Zugangsdaten -- deshalb bekommt der Prüfschritt hier
|
|
||||||
// den ResponseWriter.
|
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="Moderation", charset="UTF-8"`)
|
|
||||||
return Unauth("mod.auth_required", "Zugang erforderlich.")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Moderationsseite: spiegelt die report-Tabelle als serverseitig gerenderte,
|
|
||||||
// JS-freie HTML-Tabelle (offene Meldungen zuerst). Bewusst OHNE App-seitige
|
|
||||||
// Auth -- der Zugriff wird ausschließlich vom vorgelagerten Reverse-Proxy
|
|
||||||
// (Caddy: erzwingt TLS + Basic-Auth) geschützt. Die Route darf daher niemals
|
|
||||||
// direkt, am Proxy vorbei, erreichbar sein.
|
|
||||||
//
|
|
||||||
// html/template escaped reason/content automatisch -> der attacker-kontrollierte
|
|
||||||
// Meldegrund kann kein Markup einschleusen. Kein Inline-CSS/JS wegen der CSP;
|
|
||||||
// das gemeinsame Stylesheet wird als externe (same-origin) Datei verlinkt.
|
|
||||||
|
|
||||||
type reportRow struct {
|
|
||||||
PID int64
|
|
||||||
Reason string
|
|
||||||
When string
|
|
||||||
Handled bool
|
|
||||||
Username string
|
|
||||||
Content string
|
|
||||||
}
|
|
||||||
|
|
||||||
var moderationTmpl = template.Must(template.New("moderation").Parse(`<!doctype html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<meta name="robots" content="noindex, nofollow">
|
|
||||||
<title>Moderation · Meldungen</title>
|
|
||||||
<link rel="stylesheet" href="/css/app.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<h1>Meldungen</h1>
|
|
||||||
<p class="muted">{{len .}} Meldung(en), offene zuerst.</p>
|
|
||||||
<table border="1" cellpadding="6" cellspacing="0">
|
|
||||||
<thead>
|
|
||||||
<tr><th>Status</th><th>Zeit</th><th>Beitrag</th><th>Autor</th><th>Inhalt (Auszug)</th><th>Grund</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .}}
|
|
||||||
<tr>
|
|
||||||
<td>{{if .Handled}}erledigt{{else}}<strong>offen</strong>{{end}}</td>
|
|
||||||
<td>{{.When}}</td>
|
|
||||||
<td><a href="/e/{{.PID}}">{{.PID}}</a></td>
|
|
||||||
<td>{{if .Username}}{{.Username}}{{else}}[gelöscht/unbekannt]{{end}}</td>
|
|
||||||
<td>{{.Content}}</td>
|
|
||||||
<td>{{if .Reason}}{{.Reason}}{{else}}<span class="muted">–</span>{{end}}</td>
|
|
||||||
</tr>
|
|
||||||
{{else}}
|
|
||||||
<tr><td colspan="6" class="muted">Keine Meldungen.</td></tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>`))
|
|
||||||
|
|
||||||
func handleModeration(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
// Offene Meldungen zuerst (handled ASC), innerhalb dessen die jüngsten oben.
|
|
||||||
rows, err := db.Query(
|
|
||||||
`SELECT r.pid, r.reason, r.created_at, r.handled,
|
|
||||||
COALESCE(u.username, ''), COALESCE(e.content, '')
|
|
||||||
FROM report r
|
|
||||||
LEFT JOIN entry e ON e.pid = r.pid
|
|
||||||
LEFT JOIN account u ON u.uid = e.uid
|
|
||||||
ORDER BY r.handled ASC, r.created_at DESC`)
|
|
||||||
if err != nil {
|
|
||||||
return Internal(err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
reports := []reportRow{}
|
|
||||||
for rows.Next() {
|
|
||||||
var rr reportRow
|
|
||||||
var created int64
|
|
||||||
var handled int
|
|
||||||
if err := rows.Scan(&rr.PID, &rr.Reason, &created, &handled, &rr.Username, &rr.Content); err != nil {
|
|
||||||
return Internal(err)
|
|
||||||
}
|
|
||||||
rr.Handled = handled != 0
|
|
||||||
rr.When = time.Unix(created, 0).UTC().Format("2006-01-02 15:04 UTC")
|
|
||||||
// Inhalt nur als kurzer Auszug -- die Volansicht hängt am Beitrags-Link.
|
|
||||||
// Runenweise kürzen, damit der Schnitt kein Multibyte-Zeichen zerreißt.
|
|
||||||
if excerpt := truncateRunes(rr.Content, 120); excerpt != rr.Content {
|
|
||||||
rr.Content = excerpt + "…"
|
|
||||||
}
|
|
||||||
reports = append(reports, rr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// rows.Err() nach der Schleife: ein Fehler mitten im Iterieren würde sonst
|
|
||||||
// als "einfach zu Ende" durchgehen und eine unvollständige Liste als
|
|
||||||
// vollständige Moderationsansicht ausliefern.
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return Internal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
moderationTmpl.Execute(w, reports)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
+120
-192
@@ -1,228 +1,156 @@
|
|||||||
# kver API Definition
|
# kver API
|
||||||
|
|
||||||
Abgeleitet aus dem bestehenden Flask-Code. Basis für die Go-Reimplementierung.
|
Alle Endpunkte liegen unter `/api` und antworten ausschließlich mit JSON.
|
||||||
|
Außerhalb von `/api` liefert das Backend nur Seiten und Dateien des Frontends.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Authentifizierung
|
## Konventionen
|
||||||
|
|
||||||
Sessions werden als Cookie (`session`) übertragen. Alle geschützten Endpunkte erwarten diesen Cookie.
|
**Erfolg oder Fehler steht im HTTP-Status.** `2xx` heißt Erfolg, alles andere
|
||||||
|
Fehler. Einen `200` mit Fehler im Body gibt es nicht. Fehler haben immer diese
|
||||||
|
Form:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": { "code": "entry.not_found", "message": "Beitrag nicht gefunden",
|
||||||
|
"field": "content", "fields": { "user": "…" } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `code`: stabil, maschinenlesbar (`<bereich>.<sache>`), für Logik
|
||||||
|
- `message`: deutsche Meldung für Menschen
|
||||||
|
- `field` / `fields`: optional, betroffene Formularfelder (`fields` nur bei der
|
||||||
|
Registrierung, die alle Feldfehler auf einmal meldet)
|
||||||
|
|
||||||
|
| Status | Bedeutung |
|
||||||
|
|--------|-----------|
|
||||||
|
| `400` | Eingabe ungültig |
|
||||||
|
| `401` | nicht angemeldet / falsche Zugangsdaten |
|
||||||
|
| `403` | angemeldet, aber nicht erlaubt (fremder Beitrag, falsches Passwort, TOR, fremder Origin) |
|
||||||
|
| `404` | Objekt oder Endpunkt existiert nicht (`api.not_found`) |
|
||||||
|
| `405` | Endpunkt existiert, aber nicht mit dieser Methode (`api.method_not_allowed`) |
|
||||||
|
| `409` | Konflikt (Nutzername vergeben) |
|
||||||
|
| `413` | Upload zu groß |
|
||||||
|
| `429` | zu viele Anfragen (`rate.limited`) oder Repost-Cooldown |
|
||||||
|
| `500` | Serverfehler (`internal`, Ursache nur im Log) |
|
||||||
|
|
||||||
|
**Requests** sind `application/x-www-form-urlencoded` bzw. `multipart/form-data`
|
||||||
|
(Uploads). Zeitstempel sind Unix-Sekunden.
|
||||||
|
|
||||||
|
**Auth** läuft über das Cookie `session` (HttpOnly, SameSite=Lax). Geschützte
|
||||||
|
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, Registrierung und Passwort-Reset 20/min.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
### `POST /auth/login`
|
| Methode | Pfad | Auth | Request | Erfolg |
|
||||||
|
|---------|------|------|---------|--------|
|
||||||
|
| POST | `/api/auth/login` | – | `user`, `pass`, `timeout` (s, Default 1 Tag, max. 1 Jahr) | `200 {"status":"ok"}` + Cookie; bereits angemeldet: `200 {"status":"already_logged_in"}` |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
Einloggen.
|
Login-Fehler: `401 auth.bad_credentials` (gleich für unbekannten Nutzer und
|
||||||
|
falsches Passwort).
|
||||||
|
|
||||||
**Request (form-encoded):**
|
Reset-Links erzeugt nur der Admin per CLI (`kver reset-link <nutzername>`,
|
||||||
| Feld | Typ | Beschreibung |
|
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
|
||||||
| `user` | string | Nutzername |
|
Fragment und geht nur im POST-Body an die API, damit es nicht in Logs landet.
|
||||||
| `pass` | string | Passwort |
|
|
||||||
| `timeout` | int | Session-Dauer in Sekunden |
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
- `200` + setzt `session`-Cookie → Redirect auf `/`
|
|
||||||
- `200` + Fehlertext bei falschen Credentials
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `GET /auth/login`
|
## Beiträge
|
||||||
|
|
||||||
Login-Seite anzeigen (wird in der Go-API nicht benötigt, Frontend übernimmt das).
|
Ein Beitrag (`Entry`):
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `POST /auth/newuser`
|
|
||||||
|
|
||||||
Neuen Account registrieren.
|
|
||||||
|
|
||||||
**Request (form-encoded):**
|
|
||||||
| Feld | Typ | Beschreibung |
|
|
||||||
|------|-----|--------------|
|
|
||||||
| `user` | string | Nutzername (3–32 Zeichen, `[a-zA-Z0-9._\s-]`) |
|
|
||||||
| `pass1` | string | Passwort (min. 10 Zeichen) |
|
|
||||||
| `pass2` | string | Passwort-Bestätigung |
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
- `200` bei Erfolg
|
|
||||||
- `200` + Fehlerliste bei Validierungsfehler
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `POST /auth/logout`
|
|
||||||
|
|
||||||
Session beenden und Cookie löschen. POST statt GET: SameSite=Lax schützt
|
|
||||||
GET-Navigationen nicht, ein fremder Link könnte Nutzer sonst ausloggen (CSRF).
|
|
||||||
|
|
||||||
**Response:** `200` `{"status": "ok"}`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET /auth/headerbar`
|
|
||||||
|
|
||||||
Gibt zurück ob der Nutzer eingeloggt ist (für die UI-Headerleiste).
|
|
||||||
|
|
||||||
**Response:** HTML-Partial (in Go-API: JSON)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET /auth/sessioninfo`
|
|
||||||
|
|
||||||
Infos zur aktuellen Session.
|
|
||||||
|
|
||||||
**Auth:** erforderlich
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "pid": 123456789, "uid": 42, "created_at": 1700000000, "content": "…",
|
||||||
"uid": 123456,
|
"filepath": "static/media/…", "reply_to": 0, "reply_count": 3,
|
||||||
"created_at": 1700000000,
|
"last_activity": 1700000500, "deleted": 0, "bump_count": 1,
|
||||||
"expires": 1700086400,
|
"last_bump": 1700000400, "username": "max", "avatar": "static/media/…" }
|
||||||
"description": ""
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
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`.
|
||||||
|
|
||||||
## Entries (Beiträge)
|
|
||||||
|
|
||||||
### `GET /entry/feed/:page`
|
|
||||||
|
|
||||||
Paginierter Feed aller Beiträge, neueste zuerst.
|
|
||||||
|
|
||||||
**Parameter:**
|
|
||||||
| Name | Typ | Beschreibung |
|
|
||||||
|------|-----|--------------|
|
|
||||||
| `page` | int | Seite (0–100), 20 Einträge pro Seite |
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
```json
|
||||||
[
|
"preview": { "url": "https://www.youtube.com/watch?v=…", "kind": "youtube",
|
||||||
{
|
"title": "…", "site": "…", "thumb": "static/media/preview/….jpg" }
|
||||||
"pid": 123456789,
|
|
||||||
"created_at": 1700000000,
|
|
||||||
"uid": 42,
|
|
||||||
"content": "...",
|
|
||||||
"filepath": "static/media/...",
|
|
||||||
"username": "max"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
Leeres Array `[]` wenn keine weiteren Einträge vorhanden.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `POST /entry/create`
|
|
||||||
|
|
||||||
Neuen Beitrag erstellen.
|
|
||||||
|
|
||||||
**Auth:** erforderlich
|
|
||||||
|
|
||||||
**Request (multipart/form-data):**
|
|
||||||
| Feld | Typ | Beschreibung |
|
|
||||||
|------|-----|--------------|
|
|
||||||
| `content` | string | Text (max. 1000 Zeichen) |
|
|
||||||
| `file` | file (optional) | Bild (JPG oder GIF, wird auf max. 1024px skaliert) |
|
|
||||||
|
|
||||||
Mindestens `content` oder `file` muss vorhanden sein.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
- `201` bei Erfolg
|
|
||||||
- `400` bei leerem Beitrag
|
|
||||||
- `401` wenn nicht eingeloggt
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET /entry/:pid/votes`
|
|
||||||
|
|
||||||
Abstimmungsstand eines Beitrags lesen (read-only).
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{ "pid": 123, "left": 4, "right": 2, "selected": "left" }
|
|
||||||
```
|
|
||||||
`selected` ist `none`, wenn nicht eingeloggt oder keine Stimme abgegeben.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `POST /entry/:pid/vote`
|
|
||||||
|
|
||||||
Eigene Stimme abgeben oder umschalten.
|
|
||||||
|
|
||||||
**Auth:** erforderlich
|
|
||||||
|
|
||||||
**Request (form-encoded):**
|
|
||||||
| Feld | Typ | Beschreibung |
|
|
||||||
|------|-----|--------------|
|
|
||||||
| `mode` | string | `left` oder `right` |
|
|
||||||
|
|
||||||
**Voting-Logik:**
|
|
||||||
- keine bisherige Stimme → neue Stimme
|
|
||||||
- gleiche Stimme erneut → Stimme zurückziehen (Toggle)
|
|
||||||
- andere Stimme → auf neuen Modus wechseln
|
|
||||||
|
|
||||||
**Response:** wie `GET /entry/:pid/votes` (aktualisierter Stand)
|
|
||||||
- `400` bei ungültigem Modus
|
|
||||||
- `401` ohne Login
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## User
|
|
||||||
|
|
||||||
### `GET /u/:username`
|
|
||||||
|
|
||||||
Öffentliche Profilseite eines Nutzers.
|
|
||||||
|
|
||||||
**Response:** Nutzerprofil + Beiträge (Details noch offen)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `GET /user/info`
|
|
||||||
|
|
||||||
Eigene Account-Informationen.
|
|
||||||
|
|
||||||
**Auth:** erforderlich
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"uid": 42,
|
|
||||||
"username": "max",
|
|
||||||
"created_at": 1700000000
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
Beiträge auf dem Pfad dazwischen, gelöschte eingeschlossen (0 = direkte
|
||||||
|
Antwort). Ohne passenden Nachfahren fehlt das Feld.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"latest": { "entry": Entry, "skipped": 2 }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Methode | Pfad | Auth | Request | Erfolg |
|
||||||
|
|---------|------|------|---------|--------|
|
||||||
|
| 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","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` = `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
|
||||||
|
antwortet der Endpunkt mit `429 entry.bump_cooldown`. Dazu kommen der Header
|
||||||
|
`Retry-After` und im Body `retry_after` und `bump_count` (neben `error`).
|
||||||
|
|
||||||
|
TOR-Anfragen auf `create` werden mit `403 tor.blocked` abgewiesen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `POST /user/delete`
|
## Nutzer
|
||||||
|
|
||||||
Eigenen Account dauerhaft löschen. Beiträge, Votes und Sessions werden
|
| Methode | Pfad | Auth | Request | Erfolg |
|
||||||
mitgelöscht, zugehörige Mediendateien best effort entfernt.
|
|---------|------|------|---------|--------|
|
||||||
|
| GET | `/api/u/{username}/info` | – | – | `200 {"uid","username","created_at","avatar"}` |
|
||||||
|
| GET | `/api/u/{username}/feed/{page}` | – | – | `200 [Entry]`: alle Beiträge inkl. Antworten, chronologisch |
|
||||||
|
| GET | `/api/user/info` | ✓ | – | `200 {"uid","username","created_at","last_login","avatar"}` |
|
||||||
|
| POST | `/api/user/rename` | ✓ | `user` | `200 {"username"}`; vergeben: `409 user.name_taken` |
|
||||||
|
| POST | `/api/user/avatar` | ✓ | multipart: `avatar` | `200 {"avatar"}` |
|
||||||
|
| POST | `/api/user/delete` | ✓ | `pass1` | `200 {"status":"deleted"}`, löscht Konto, Beiträge, Votes, Sessions; falsches Passwort: `403 auth.bad_password` |
|
||||||
|
|
||||||
**Auth:** erforderlich
|
---
|
||||||
|
|
||||||
**Request (form-encoded):**
|
## Statistik
|
||||||
| Feld | Typ | Beschreibung |
|
|
||||||
|------|-----|--------------|
|
|
||||||
| `pass1` | string | Passwort zur Bestätigung |
|
|
||||||
|
|
||||||
**Response:**
|
| Methode | Pfad | Erfolg |
|
||||||
- `200` `{"status": "deleted"}` + löscht den `session`-Cookie
|
|---------|------|--------|
|
||||||
- `403` bei falschem Passwort
|
| GET | `/api/stats` | `200 {"users","entries"}` |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seiten (kein JSON)
|
||||||
|
|
||||||
|
`/`, `/e/{pid}`, `/u/{username}` liefern die HTML-Seiten des Frontends und
|
||||||
|
zählen anonymisierte Impressions. `/static/*` enthält Uploads und Dokumente,
|
||||||
|
alles andere kommt aus `web/`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Datenbankschema
|
## Datenbankschema
|
||||||
|
|
||||||
Postgres; maßgeblich ist die Konstante `schema` in `db.go`.
|
Postgres; maßgeblich ist die Konstante `schema` in `db.go`.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Offene Punkte
|
|
||||||
|
|
||||||
- Kein Rate-Limiting außer dem zufälligen `sleep` beim Login
|
|
||||||
- `session`-Token ist nur `randbelow(999999999) + timestamp` — sollte auf `crypto/rand` umgestellt werden
|
|
||||||
- Fehler-Responses sind aktuell Plaintext/HTML — in der Go-API einheitlich JSON
|
|
||||||
|
|||||||
+20
-20
@@ -27,8 +27,11 @@ Beim ersten Deployment: DB und Medien einmalig in die Volumes kopieren
|
|||||||
podman build -t kver .
|
podman build -t kver .
|
||||||
```
|
```
|
||||||
|
|
||||||
Multi-Stage-Build (`Containerfile`): statisches Go-Binary (CGO aus), Laufzeit-
|
Multi-Stage-Build (`Containerfile`): das React-Frontend wird in einer
|
||||||
Image ist Alpine mit unprivilegiertem Nutzer.
|
Node-Stage gebaut (`npm ci && npm run build`), das Go-Binary statisch (CGO
|
||||||
|
aus); das Laufzeit-Image ist Alpine mit unprivilegiertem Nutzer und enthält
|
||||||
|
nur Binary, gebautes Frontend (`web/`) und `static/`. Auf dem Server wird
|
||||||
|
also weder Node noch Go gebraucht, nur Podman.
|
||||||
|
|
||||||
Die Warnung `HEALTHCHECK is not supported for OCI image format` ist erwartbar:
|
Die Warnung `HEALTHCHECK is not supported for OCI image format` ist erwartbar:
|
||||||
Podman baut OCI-Images, dort gibt es kein eingebettetes HEALTHCHECK. Der
|
Podman baut OCI-Images, dort gibt es kein eingebettetes HEALTHCHECK. Der
|
||||||
@@ -60,6 +63,7 @@ Konfiguration über Umgebungsvariablen:
|
|||||||
| `KVER_ADDR` | `:8080` | Listen-Adresse im Container |
|
| `KVER_ADDR` | `:8080` | Listen-Adresse im Container |
|
||||||
| `KVER_DSN` | _(Pflicht)_ | Postgres-Verbindung, z. B. `postgres://kver:pw@host:5432/kontrollverlust?sslmode=disable` |
|
| `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_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)
|
## GeoLite2-ASN-Datenbank (optional)
|
||||||
|
|
||||||
@@ -92,23 +96,6 @@ REVOKE CONNECT, TEMPORARY ON DATABASE kontrollverlust FROM PUBLIC;
|
|||||||
Tabellen und Indizes legt die App beim Start selbst an (`CREATE ... IF NOT
|
Tabellen und Indizes legt die App beim Start selbst an (`CREATE ... IF NOT
|
||||||
EXISTS`, als `kver` → gehören `kver`). Nicht als Admin anlegen.
|
EXISTS`, als `kver` → gehören `kver`). Nicht als Admin anlegen.
|
||||||
|
|
||||||
## Umzug SQLite → Postgres (einmalig)
|
|
||||||
|
|
||||||
Dienst stoppen, dann mit dem neuen Image die alte Datei aus dem Volume
|
|
||||||
importieren. Der Import läuft in einer Transaktion, prüft die Zeilenzahlen und
|
|
||||||
bricht ab, wenn in Postgres schon Daten liegen:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
systemctl --user stop kver
|
|
||||||
podman run --rm --env-file /etc/kver/kver.env \
|
|
||||||
-v kver-data:/app/data:ro \
|
|
||||||
kver import-sqlite /app/data/kver.db
|
|
||||||
systemctl --user start kver
|
|
||||||
```
|
|
||||||
|
|
||||||
Der Container braucht dabei (und im Betrieb) dieselbe Netzwerkanbindung an
|
|
||||||
Postgres wie der Dienst selbst.
|
|
||||||
|
|
||||||
## Bestehende Medien übernehmen
|
## Bestehende Medien übernehmen
|
||||||
|
|
||||||
Medien einmalig ins Volume kopieren (Volumes müssen existieren;
|
Medien einmalig ins Volume kopieren (Volumes müssen existieren;
|
||||||
@@ -196,7 +183,7 @@ Volume=kver-media:/app/static/media
|
|||||||
Environment=KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb
|
Environment=KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb
|
||||||
# Healthcheck hier statt im Containerfile: Podman baut OCI-Images,
|
# Healthcheck hier statt im Containerfile: Podman baut OCI-Images,
|
||||||
# die kennen kein eingebettetes HEALTHCHECK.
|
# die kennen kein eingebettetes HEALTHCHECK.
|
||||||
HealthCmd=wget -qO /dev/null http://localhost:8080/entry/feed/0
|
HealthCmd=wget -qO /dev/null http://localhost:8080/api/stats
|
||||||
HealthInterval=30s
|
HealthInterval=30s
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
@@ -231,3 +218,16 @@ systemctl --user restart kver
|
|||||||
|
|
||||||
Die App fährt bei SIGTERM sauber herunter (laufende Requests werden zu Ende
|
Die App fährt bei SIGTERM sauber herunter (laufende Requests werden zu Ende
|
||||||
bedient, dann wird die DB-Verbindung geschlossen).
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Frontend (React)
|
||||||
|
|
||||||
|
Quellcode in `frontend/`: Vite, React, TypeScript, React Router, TanStack Query.
|
||||||
|
|
||||||
|
**Optik:** bewusst brutalistisch-roh. Markup, IDs und Klassen entsprechen dem
|
||||||
|
früheren Vanilla-Frontend, `frontend/src/app.css` ist das einzige Stylesheet.
|
||||||
|
Umbauten ändern Funktion, nicht Aussehen.
|
||||||
|
|
||||||
|
## Entwickeln
|
||||||
|
|
||||||
|
Zwei Terminals:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
set -a; . ./dev.env; set +a; go run . # Backend auf :8081
|
||||||
|
cd frontend && npm run dev # http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite reicht `/api` und `/static` ans Backend durch (`frontend/vite.config.ts`,
|
||||||
|
`changeOrigin: false` ist Pflicht, sonst scheitert jeder POST am Origin-Check).
|
||||||
|
|
||||||
|
## Bauen
|
||||||
|
|
||||||
|
`npm run build` in `frontend/` prüft die Typen und schreibt nach `../web/`
|
||||||
|
(gitignored). Von dort liefert das Go-Backend aus (`frontend.go`), lokal also
|
||||||
|
auch ohne Vite: `npm run build`, dann `go run .`.
|
||||||
|
|
||||||
|
Die Rechtstexte (`frontend/static/docs/*.html`) sind reine HTML-Seiten ohne JS,
|
||||||
|
aber weitere Einstiege des Builds (`vite.config.ts`). So bekommen sie dasselbe
|
||||||
|
gebaute `app.css`; ausgeliefert unter `/static/docs/…` (`serveDoc`).
|
||||||
|
|
||||||
|
### DEV-Container
|
||||||
|
|
||||||
|
`./dev-up.sh` baut das Image aus dem Arbeitsverzeichnis und startet den
|
||||||
|
Container `kver-dev` auf http://127.0.0.1:8081 neu (DSN aus `dev.env`, Daten
|
||||||
|
und Medien read-only aus `prod_backup/`). Mit `git config core.hooksPath
|
||||||
|
githooks` läuft das nach jedem Commit automatisch im Hintergrund (Log:
|
||||||
|
`.git/dev-up.log`); einmalig aussetzen mit `KVER_NO_DEV_UP=1 git commit …`.
|
||||||
|
|
||||||
|
## Aufbau
|
||||||
|
|
||||||
|
| Datei | Zweck |
|
||||||
|
|-------|-------|
|
||||||
|
| `src/api.ts` | API-Client; jeder Nicht-2xx-Status wird zu `ApiError` |
|
||||||
|
| `src/types.ts` | Typen der API-Antworten (Referenz: `notes/api.md`) |
|
||||||
|
| `src/auth.ts` | `useMe()` (eigenes Konto oder `null`), `useAuthChanged()` |
|
||||||
|
| `src/main.tsx` | Router, QueryClient |
|
||||||
|
| `src/Layout.tsx` | Header, Headerbar |
|
||||||
|
| `src/pages/*` | Startseite, Thread, Profil, Statistik |
|
||||||
|
| `src/components/*` | Feed, Beitragskarte, Votes, Repost, Login, Beitrag verfassen |
|
||||||
|
|
||||||
|
Seitenrouten des Clients (`/`, `/e/:pid`, `/u/:username`, `/stats`) müssen
|
||||||
|
auch in `routes()` (main.go) stehen, sonst liefert ein direkter Aufruf 404.
|
||||||
|
|
||||||
|
## Impressions
|
||||||
|
|
||||||
|
Das Backend zählt Seitenaufrufe beim Ausliefern der `index.html`. Navigation
|
||||||
|
innerhalb der App lädt keine Seite mehr vom Server und wird deshalb nicht
|
||||||
|
gezählt -- nur der erste Aufruf bzw. ein Reload.
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
# Datenbank-Migrationen
|
|
||||||
|
|
||||||
Manuelle Schritte, die beim Deploy gegen eine **bestehende** Datenbank nötig sind.
|
|
||||||
Eine frische DB (Schema aus `db.go`) braucht keine davon.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## `avatar`-Spalte in `user`
|
|
||||||
|
|
||||||
**Wann:** beim Deploy der Profilbild-Version gegen eine bestehende `kver.db`.
|
|
||||||
|
|
||||||
**Hintergrund:** Profilbilder werden als Pfad (`static/media/...`) in der neuen Spalte
|
|
||||||
`user.avatar` gespeichert (leerer String = kein Bild, Frontend zeigt dann das
|
|
||||||
Platzhalterbild). `CREATE TABLE IF NOT EXISTS` ergänzt die Spalte bei einer
|
|
||||||
vorhandenen Tabelle nicht.
|
|
||||||
|
|
||||||
**Migration:**
|
|
||||||
```sql
|
|
||||||
ALTER TABLE user ADD COLUMN avatar TEXT NOT NULL DEFAULT '';
|
|
||||||
```
|
|
||||||
|
|
||||||
Bestehende Nutzer haben damit kein Bild (`''`). Nur einmalig ausführen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## `filepath` NULL → leerer String
|
|
||||||
|
|
||||||
**Wann:** beim Umstieg auf die Go-Version, falls `entry.filepath` NULL-Werte enthält.
|
|
||||||
|
|
||||||
**Hintergrund:** `scanEntries` scannt `filepath` in ein Go-`string`-Feld (kein Pointer).
|
|
||||||
SQLite NULL in ein `string` scannen schlägt fehl; `scanEntries` überspringt diese Zeilen
|
|
||||||
still mit `continue`. Ergebnis: Einträge ohne Bild fehlen im Feed komplett.
|
|
||||||
Das aktuelle Go-Schema definiert `filepath TEXT NOT NULL DEFAULT ''`. `migrate.sh`
|
|
||||||
normalisiert NULL → `''` beim Kopieren aus der Quelldatenbank.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
UPDATE entry SET filepath = '' WHERE filepath IS NULL;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## `filepath`-Sentinel `"none"` → leerer String
|
|
||||||
|
|
||||||
**Wann:** beim Umstieg auf die Go-Version, falls eine alte `kver.db` aus der
|
|
||||||
Python-Zeit weiterverwendet wird.
|
|
||||||
|
|
||||||
**Hintergrund:** Die Python-Version schrieb für Beiträge ohne Bild den
|
|
||||||
String `"none"` in `entry.filepath`. Die Go-Version nutzt stattdessen den
|
|
||||||
leeren String (Go-Zero-Value) und prüft nur noch auf `filepath == ""`.
|
|
||||||
Alte `"none"`-Zeilen würden sonst im Feed als Bildpfad interpretiert
|
|
||||||
(`<img src="/none">` → kaputtes Bild).
|
|
||||||
|
|
||||||
**Migration:**
|
|
||||||
```sql
|
|
||||||
UPDATE entry SET filepath = '' WHERE filepath = 'none';
|
|
||||||
```
|
|
||||||
|
|
||||||
Idempotent — kann gefahrlos mehrfach laufen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Threading-Spalten in `entry`
|
|
||||||
|
|
||||||
**Wann:** beim Deploy der Threading-Version gegen eine bestehende `kver.db`, die
|
|
||||||
die Tabelle `entry` noch ohne die Threading-Spalten hat.
|
|
||||||
|
|
||||||
**Hintergrund:** Das Threading führt `reply_to` (pid des Eltern-Posts, `0` = Root),
|
|
||||||
`reply_count` (Anzahl Antworten im gesamten Teilbaum) und `last_activity`
|
|
||||||
(Sortierschlüssel des Hauptfeeds) ein. `CREATE TABLE IF NOT EXISTS` ergänzt diese
|
|
||||||
Spalten bei einer vorhandenen Tabelle nicht.
|
|
||||||
|
|
||||||
**Migration:**
|
|
||||||
```sql
|
|
||||||
ALTER TABLE entry ADD COLUMN reply_to INTEGER NOT NULL DEFAULT 0;
|
|
||||||
ALTER TABLE entry ADD COLUMN reply_count INTEGER NOT NULL DEFAULT 0;
|
|
||||||
ALTER TABLE entry ADD COLUMN last_activity INTEGER;
|
|
||||||
UPDATE entry SET last_activity = created_at WHERE last_activity IS NULL;
|
|
||||||
```
|
|
||||||
|
|
||||||
Bestehende Zeilen werden so zu Root-Beiträgen (`reply_to = 0`, `reply_count = 0`)
|
|
||||||
mit `last_activity = created_at`. Nicht idempotent — die `ALTER TABLE`-Zeilen nur
|
|
||||||
einmalig ausführen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Soft-Delete-Spalte in `entry`
|
|
||||||
|
|
||||||
**Wann:** beim Deploy der Soft-Delete-Version gegen eine bestehende `kver.db`.
|
|
||||||
|
|
||||||
**Hintergrund:** Statt Beiträge hart zu löschen werden sie als `[deleted]`-Platzhalter
|
|
||||||
markiert (`deleted = 1`, Inhalt/Bild/Autor geleert), damit Antworten nicht verwaisen.
|
|
||||||
Das Frontend und die Feeds erkennen gelöschte Beiträge an dieser Spalte.
|
|
||||||
|
|
||||||
**Migration:**
|
|
||||||
```sql
|
|
||||||
ALTER TABLE entry ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;
|
|
||||||
```
|
|
||||||
|
|
||||||
Bestehende Beiträge bleiben damit sichtbar (`deleted = 0`). Nur einmalig ausführen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## `asn`-Spalte in `impression`
|
|
||||||
|
|
||||||
**Wann:** beim Deploy der ASN-Auswertung gegen eine bestehende `impression`-Tabelle.
|
|
||||||
|
|
||||||
**Hintergrund:** Pro Impression wird das autonome System (Netzbetreiber) gespeichert,
|
|
||||||
aufgelöst aus der vollen Client-IP *vor* dem Maskieren (siehe `lookupASN`). Die Spalte
|
|
||||||
liegt bewusst **nicht** im Primärschlüssel — pro `anon_ip` ist das ASN deterministisch,
|
|
||||||
also entstehen keine Zeilen-Duplikate, und ein simples `ADD COLUMN` genügt (kein
|
|
||||||
Tabellen-Rebuild). `CREATE TABLE IF NOT EXISTS` ergänzt die Spalte bei einer
|
|
||||||
vorhandenen Tabelle nicht.
|
|
||||||
|
|
||||||
**Migration:**
|
|
||||||
```sql
|
|
||||||
ALTER TABLE impression ADD COLUMN asn TEXT NOT NULL DEFAULT '';
|
|
||||||
```
|
|
||||||
|
|
||||||
Bestehende Zeilen haben damit kein ASN (`''`, in der Auswertung „(unbekannt)"). Nur
|
|
||||||
einmalig ausführen.
|
|
||||||
+4
-4
@@ -1,8 +1,7 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
- Moderation: handled setzen
|
- Moderation (Melden, Queue, Abhilfe) als eigenständiges Projekt
|
||||||
- Moderation: Löschen aus Queue
|
- Passwort ändern im Profil; Reset per E-Mail (bisher nur Admin-Link, `kver reset-link`)
|
||||||
- Passwort-Reset
|
|
||||||
- Suche
|
- Suche
|
||||||
- Hashtags / Erwähnungen
|
- Hashtags / Erwähnungen
|
||||||
- Block / Mute
|
- Block / Mute
|
||||||
@@ -12,4 +11,5 @@
|
|||||||
- RSS / Atom Feed
|
- RSS / Atom Feed
|
||||||
- Feed ohne JS (früherer SSR-Stand: Tag `ssr-archiv`)
|
- Feed ohne JS (früherer SSR-Stand: Tag `ssr-archiv`)
|
||||||
- robots.txt / Sitemap
|
- 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
|
||||||
|
}
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
$(() => {
|
|
||||||
// $('#auth-visible').hide()
|
|
||||||
|
|
||||||
// Startdatum festlegen (Beispiel: 1. Januar 2025, 00:00:00 Uhr)
|
|
||||||
const startDate = new Date('2025-12-13T12:44:00').getTime();
|
|
||||||
|
|
||||||
function updateCounter() {
|
|
||||||
const now = new Date().getTime();
|
|
||||||
const diff = now - startDate;
|
|
||||||
|
|
||||||
// Berechne Tage, Stunden, Minuten
|
|
||||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
||||||
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
|
||||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
|
||||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
|
||||||
|
|
||||||
// Zeige den Counter an
|
|
||||||
$('#uptime-counter').html(`<strong>${days}</strong> Tage, <strong>${hours}</strong> Stunden, <strong>${minutes}</strong> Minuten und <strong>${seconds}</strong> Sekunden ohne Datenverlust!`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aktualisiere den Counter sofort und dann jede Minute
|
|
||||||
updateCounter();
|
|
||||||
setInterval(updateCounter, 1000);
|
|
||||||
|
|
||||||
let agreedToWarning = document.cookie.includes("agreedToWarning=True")
|
|
||||||
if(agreedToWarning) {
|
|
||||||
$("#contentnotice").hide()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function setCookie(cname, cvalue, exdays) {
|
|
||||||
const d = new Date();
|
|
||||||
d.setTime(d.getTime() + (exdays*24*60*60*1000));
|
|
||||||
let expires = "expires="+ d.toUTCString();
|
|
||||||
document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/";
|
|
||||||
}
|
|
||||||
@@ -220,56 +220,52 @@ func recordImpression(path, anonIP, asn string) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleStatsDetail liefert die Kennzahlen für die Statistik-Seite. Anders als
|
// handleStats liefert die Kennzahlen für die Statuszeile unter dem Titel:
|
||||||
|
// registrierte Nutzer und nicht-gelöschte Beiträge.
|
||||||
|
func handleStats(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
var users, entries int64
|
||||||
|
if err := db.QueryRow(
|
||||||
|
`SELECT (SELECT COUNT(*) FROM account), (SELECT COUNT(*) FROM entry WHERE deleted = 0)`).
|
||||||
|
Scan(&users, &entries); err != nil {
|
||||||
|
return Internal(err)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"users": users,
|
||||||
|
"entries": entries,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStatsDetail liefert die Kennzahlen für die Statistik-Seite (die
|
||||||
|
// Diagramme kommen getrennt aus handleStatsCharts, charts.go). Anders als
|
||||||
// /stats (Tagline, bei jedem Aufruf) darf das hier ruhig mehrere Aggregate
|
// /stats (Tagline, bei jedem Aufruf) darf das hier ruhig mehrere Aggregate
|
||||||
// rechnen. Öffentlich, kein Auth.
|
// rechnen. Öffentlich, kein Auth.
|
||||||
func handleStatsDetail(w http.ResponseWriter, r *http.Request) {
|
func handleStatsDetail(w http.ResponseWriter, r *http.Request) error {
|
||||||
var users, entries, toplevel, votesLeft, votesRight int64
|
var users, entries, toplevel, votesYes, votesNo int64
|
||||||
db.QueryRow(`SELECT COUNT(*) FROM account`).Scan(&users)
|
if err := db.QueryRow(
|
||||||
db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0`).Scan(&entries)
|
`SELECT (SELECT COUNT(*) FROM account),
|
||||||
db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0 AND reply_to = 0`).Scan(&toplevel)
|
(SELECT COUNT(*) FROM entry WHERE deleted = 0),
|
||||||
db.QueryRow(`SELECT COUNT(*) FROM vote WHERE mode = 'left'`).Scan(&votesLeft)
|
(SELECT COUNT(*) FROM entry WHERE deleted = 0 AND reply_to = 0),
|
||||||
db.QueryRow(`SELECT COUNT(*) FROM vote WHERE mode = 'right'`).Scan(&votesRight)
|
(SELECT COUNT(*) FROM vote WHERE mode = 'yes'),
|
||||||
|
(SELECT COUNT(*) FROM vote WHERE mode = 'no')`).
|
||||||
|
Scan(&users, &entries, &toplevel, &votesYes, &votesNo); err != nil {
|
||||||
|
return Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
var imprTotal, visitorsTotal int64
|
var imprTotal, visitorsTotal int64
|
||||||
db.QueryRow(`SELECT COALESCE(SUM(hits), 0)::bigint, COUNT(DISTINCT anon_ip) FROM impression`).
|
if err := db.QueryRow(`SELECT COALESCE(SUM(hits), 0)::bigint, COUNT(DISTINCT anon_ip) FROM impression`).
|
||||||
Scan(&imprTotal, &visitorsTotal)
|
Scan(&imprTotal, &visitorsTotal); err != nil {
|
||||||
|
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{}
|
|
||||||
if rows, err := db.Query(
|
|
||||||
`SELECT day, SUM(hits)::bigint, COUNT(DISTINCT anon_ip)
|
|
||||||
FROM impression
|
|
||||||
GROUP BY day
|
|
||||||
ORDER BY day DESC
|
|
||||||
LIMIT 30`); err == nil {
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var day string
|
|
||||||
var hits, visitors int64
|
|
||||||
rows.Scan(&day, &hits, &visitors)
|
|
||||||
daily = append(daily, map[string]any{"day": day, "impressions": hits, "visitors": visitors})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Netze nach Betreiber (autonomes System). Leere asn ('') -> "(unbekannt)".
|
// Laufender Durchschnitt: Beiträge (wie in den Diagrammen einschließlich
|
||||||
topASNs := []map[string]any{}
|
// gelöschter) der letzten 30 vollen Tage deutscher Zeit, ohne heute.
|
||||||
if rows, err := db.Query(
|
now := time.Now().In(berlin)
|
||||||
`SELECT CASE WHEN asn = '' THEN '(unbekannt)' ELSE asn END AS a,
|
midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, berlin)
|
||||||
SUM(hits)::bigint AS h,
|
var recent int64
|
||||||
COUNT(DISTINCT anon_ip)
|
if err := db.QueryRow(`SELECT COUNT(*) FROM entry WHERE created_at >= $1 AND created_at < $2`,
|
||||||
FROM impression
|
midnight.AddDate(0, 0, -30).Unix(), midnight.Unix()).Scan(&recent); err != nil {
|
||||||
GROUP BY a
|
return Internal(err)
|
||||||
ORDER BY h DESC
|
|
||||||
LIMIT 10`); err == nil {
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var asn string
|
|
||||||
var hits, networks int64
|
|
||||||
rows.Scan(&asn, &hits, &networks)
|
|
||||||
topASNs = append(topASNs, map[string]any{"asn": asn, "hits": hits, "networks": networks})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
@@ -277,17 +273,10 @@ func handleStatsDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
"entries": entries,
|
"entries": entries,
|
||||||
"toplevel": toplevel,
|
"toplevel": toplevel,
|
||||||
"replies": entries - toplevel,
|
"replies": entries - toplevel,
|
||||||
"votes": map[string]any{"left": votesLeft, "right": votesRight},
|
"votes": map[string]any{"yes": votesYes, "no": votesNo},
|
||||||
"impressions_total": imprTotal,
|
"impressions_total": imprTotal,
|
||||||
"visitors_total": visitorsTotal,
|
"visitors_total": visitorsTotal,
|
||||||
"daily": daily,
|
"entries_per_day_30": float64(recent) / 30,
|
||||||
"top_asns": topASNs,
|
|
||||||
})
|
})
|
||||||
}
|
return nil
|
||||||
|
|
||||||
// serveIndex liefert die Startseite. Eigene Route (statt FileServer-Catch-All),
|
|
||||||
// damit trackImpression nur echte Seitenaufrufe von "/" zählt und nicht jedes
|
|
||||||
// Asset.
|
|
||||||
func serveIndex(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.ServeFile(w, r, "web/index.html")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,19 +54,10 @@ func createUser(username, password string) (int64, error) {
|
|||||||
return 0, lastErr
|
return 0, lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// idFromUsername liefert die interne id oder 0, wenn der Nutzer nicht existiert.
|
func usernameTaken(username string) bool {
|
||||||
func idFromUsername(username string) int64 {
|
var taken bool
|
||||||
var id int64
|
db.QueryRow(`SELECT EXISTS (SELECT 1 FROM account WHERE username = $1)`, username).Scan(&taken)
|
||||||
if err := db.QueryRow(`SELECT id FROM account WHERE username = $1`, username).Scan(&id); err != nil {
|
return taken
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
// serveUserPage liefert die statische Profilseite; das Frontend lädt die Daten
|
|
||||||
// dann über /u/{username}/info und /u/{username}/feed/{page}.
|
|
||||||
func serveUserPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.ServeFile(w, r, "web/user.html")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleUserPage(w http.ResponseWriter, r *http.Request) error {
|
func handleUserPage(w http.ResponseWriter, r *http.Request) error {
|
||||||
@@ -87,18 +78,6 @@ func handleUserPage(w http.ResponseWriter, r *http.Request) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleStats liefert die Kennzahlen für die Statuszeile unter dem Titel:
|
|
||||||
// registrierte Nutzer und nicht-gelöschte Beiträge.
|
|
||||||
func handleStats(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var users, entries int64
|
|
||||||
db.QueryRow(`SELECT COUNT(*) FROM account`).Scan(&users)
|
|
||||||
db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0`).Scan(&entries)
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"users": users,
|
|
||||||
"entries": entries,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleUserInfo(w http.ResponseWriter, r *http.Request) error {
|
func handleUserInfo(w http.ResponseWriter, r *http.Request) error {
|
||||||
uid := uidFromContext(r.Context())
|
uid := uidFromContext(r.Context())
|
||||||
|
|
||||||
@@ -137,7 +116,7 @@ func handleUserRename(w http.ResponseWriter, r *http.Request) error {
|
|||||||
if name == current {
|
if name == current {
|
||||||
return Invalid("user.name_unchanged", "Das ist bereits dein Name.").At("user")
|
return Invalid("user.name_unchanged", "Das ist bereits dein Name.").At("user")
|
||||||
}
|
}
|
||||||
if idFromUsername(name) != 0 {
|
if usernameTaken(name) {
|
||||||
return errUsernameTaken
|
return errUsernameTaken
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,9 +166,10 @@ func handleSetAvatar(w http.ResponseWriter, r *http.Request) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleUserDelete löscht den eingeloggten Account dauerhaft, nach Bestätigung
|
// handleUserDelete löscht den eingeloggten Account dauerhaft, nach Bestätigung
|
||||||
// durch das Passwort (Formularfeld pass1). Sessions und Votes werden in einer
|
// durch das Passwort (Formularfeld pass1). Sessions, Reset-Links und Votes
|
||||||
// Transaktion mitgelöscht; die eigenen Beiträge werden soft-gelöscht (als
|
// werden in einer Transaktion mitgelöscht; die eigenen Beiträge werden
|
||||||
// [deleted]-Platzhalter erhalten), damit fremde Antworten nicht verwaisen.
|
// soft-gelöscht (als [deleted]-Platzhalter erhalten), damit fremde Antworten
|
||||||
|
// nicht verwaisen.
|
||||||
// Zugehörige Mediendateien werden best effort entfernt.
|
// Zugehörige Mediendateien werden best effort entfernt.
|
||||||
func handleUserDelete(w http.ResponseWriter, r *http.Request) error {
|
func handleUserDelete(w http.ResponseWriter, r *http.Request) error {
|
||||||
uid := uidFromContext(r.Context())
|
uid := uidFromContext(r.Context())
|
||||||
@@ -226,8 +206,9 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) error {
|
|||||||
}
|
}
|
||||||
for _, q := range []string{
|
for _, q := range []string{
|
||||||
`DELETE FROM session WHERE uid = $1`,
|
`DELETE FROM session WHERE uid = $1`,
|
||||||
|
`DELETE FROM password_reset WHERE uid = $1`,
|
||||||
`DELETE FROM vote 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`,
|
`DELETE FROM account WHERE uid = $1`,
|
||||||
} {
|
} {
|
||||||
if _, err := tx.Exec(q, uid); err != nil {
|
if _, err := tx.Exec(q, uid); err != nil {
|
||||||
@@ -244,16 +225,7 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) error {
|
|||||||
os.Remove(fp)
|
os.Remove(fp)
|
||||||
}
|
}
|
||||||
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
clearSessionCookie(w, r)
|
||||||
Name: "session",
|
|
||||||
Value: "",
|
|
||||||
Path: "/",
|
|
||||||
Expires: time.Unix(0, 0),
|
|
||||||
MaxAge: -1,
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: isHTTPS(r),
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
# Kontrollverlust — Responsible Disclosure
|
|
||||||
# Siehe auch Beitragsordnung §7 (Technische Regeln).
|
|
||||||
Contact: mailto:stress@bnd.wtf
|
|
||||||
Expires: 2027-06-15T00:00:00.000Z
|
|
||||||
Preferred-Languages: de, en
|
|
||||||
Canonical: https://kver.xyz/.well-known/security.txt
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Kontrollverlust</title>
|
|
||||||
<link rel="stylesheet" href="/css/app.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<nav id="legal">
|
|
||||||
<a href="/static/docs/impressum.html">Impressum</a> -
|
|
||||||
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
|
|
||||||
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
|
|
||||||
<a href="/stats.html">Statistik</a>
|
|
||||||
</nav>
|
|
||||||
<a href="/"><h1>Kontrollverlust</h1></a>
|
|
||||||
<div id="headerbar"><a class="button" href="/">zurück zum Feed</a></div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<section id="ancestors"></section>
|
|
||||||
<div id="focus"></div>
|
|
||||||
|
|
||||||
<!-- Nur für eingeloggte Nutzer -->
|
|
||||||
<section id="reply-box" hidden>
|
|
||||||
<form id="reply-form">
|
|
||||||
<textarea name="content" rows="3" maxlength="1000" placeholder="Antworten…"></textarea>
|
|
||||||
<p class="muted" id="reply-paste-msg"></p>
|
|
||||||
<input name="file" type="file" accept="image/*">
|
|
||||||
<button type="submit">Antworten</button>
|
|
||||||
</form>
|
|
||||||
<p class="msg" id="reply-msg"></p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="replies"></section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script src="/js/feed.js"></script>
|
|
||||||
<script src="/js/entry.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Kontrollverlust</title>
|
|
||||||
<link rel="stylesheet" href="/css/app.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<nav id="legal">
|
|
||||||
<a href="/static/docs/impressum.html">Impressum</a> -
|
|
||||||
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
|
|
||||||
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
|
|
||||||
<a href="/stats.html">Statistik</a>
|
|
||||||
</nav>
|
|
||||||
<a href="/"><h1>Kontrollverlust</h1></a>
|
|
||||||
<p id="stats" class="tagline"></p>
|
|
||||||
<div id="headerbar"></div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<noscript>
|
|
||||||
<p class="msg">Diese Seite braucht JavaScript.</p>
|
|
||||||
</noscript>
|
|
||||||
|
|
||||||
<!-- Login / Registrierung -->
|
|
||||||
<section id="auth-box">
|
|
||||||
<form id="login-form">
|
|
||||||
<h2>Login</h2>
|
|
||||||
<input name="user" placeholder="Nutzername" autocomplete="username">
|
|
||||||
<input name="pass" type="password" placeholder="Passwort" autocomplete="current-password">
|
|
||||||
<input name="timeout" type="hidden" value="86400">
|
|
||||||
<button type="submit">Einloggen</button>
|
|
||||||
<p class="muted">Noch kein Konto? <a href="#" id="show-register">Registrieren</a></p>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form id="register-form" hidden>
|
|
||||||
<h2>Registrieren</h2>
|
|
||||||
<input name="user" placeholder="Nutzername" autocomplete="username">
|
|
||||||
<input name="pass1" type="password" placeholder="Passwort" autocomplete="new-password">
|
|
||||||
<input name="pass2" type="password" placeholder="Passwort wiederholen" autocomplete="new-password">
|
|
||||||
<button type="submit">Konto erstellen</button>
|
|
||||||
<p class="muted">Schon registriert? <a href="#" id="show-login">Zum Login</a></p>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p class="msg" id="auth-msg"></p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Neuer Beitrag -->
|
|
||||||
<section id="compose" hidden>
|
|
||||||
<form id="entry-form">
|
|
||||||
<textarea name="content" rows="4" maxlength="1000" placeholder="Was gibt's Neues?"></textarea>
|
|
||||||
<p class="muted" id="paste-msg"></p>
|
|
||||||
<input name="file" type="file" accept="image/*">
|
|
||||||
<button type="submit">Eintrag erstellen</button>
|
|
||||||
</form>
|
|
||||||
<p class="msg" id="entry-msg"></p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Feed -->
|
|
||||||
<section id="feed"></section>
|
|
||||||
<div id="sentinel"></div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script src="/js/feed.js"></script>
|
|
||||||
<script src="/js/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
-123
@@ -1,123 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
// --- Headerbar (eingeloggt?) ---
|
|
||||||
let loggedIn = false;
|
|
||||||
|
|
||||||
async function refreshHeader() {
|
|
||||||
const h = await api.get("/auth/headerbar");
|
|
||||||
loggedIn = !!h.loggedin;
|
|
||||||
|
|
||||||
document.getElementById("auth-box").hidden = loggedIn;
|
|
||||||
document.getElementById("compose").hidden = !loggedIn;
|
|
||||||
|
|
||||||
const bar = document.getElementById("headerbar");
|
|
||||||
if (loggedIn) {
|
|
||||||
const me = await api.get("/user/info");
|
|
||||||
const user = encodeURIComponent(me.username);
|
|
||||||
bar.innerHTML = `<a class="button" href="/u/${user}">mein Profil</a> <button id="logout-btn">Logout</button>`;
|
|
||||||
document.getElementById("logout-btn").addEventListener("click", async () => {
|
|
||||||
// POST statt GET: als GET wäre Logout per fremdem Link auslösbar (CSRF).
|
|
||||||
await api.post("/auth/logout", new FormData());
|
|
||||||
await refreshHeader();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
bar.innerHTML = "";
|
|
||||||
showAuth("login");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Login / Registrierung ---
|
|
||||||
const loginForm = document.getElementById("login-form");
|
|
||||||
const registerForm = document.getElementById("register-form");
|
|
||||||
const authMsg = document.getElementById("auth-msg");
|
|
||||||
|
|
||||||
function showAuth(which) {
|
|
||||||
loginForm.hidden = which !== "login";
|
|
||||||
registerForm.hidden = which !== "register";
|
|
||||||
authMsg.textContent = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById("show-register").addEventListener("click", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
showAuth("register");
|
|
||||||
});
|
|
||||||
document.getElementById("show-login").addEventListener("click", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
showAuth("login");
|
|
||||||
});
|
|
||||||
|
|
||||||
loginForm.addEventListener("submit", async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const res = await api.post("/auth/login", new FormData(e.target));
|
|
||||||
if (res.ok) {
|
|
||||||
await refreshHeader();
|
|
||||||
} else {
|
|
||||||
authMsg.textContent = errMsg(res.data, "Login fehlgeschlagen.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerForm.addEventListener("submit", async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const data = new FormData(e.target);
|
|
||||||
const res = await api.post("/auth/newuser", data);
|
|
||||||
if (!res.ok) {
|
|
||||||
// newuser meldet alle Feldfehler auf einmal (error.fields).
|
|
||||||
authMsg.textContent = errMsg(res.data, "Registrierung fehlgeschlagen.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Direkt mit denselben Daten einloggen, damit der Nutzer gleich drin ist.
|
|
||||||
const login = new FormData();
|
|
||||||
login.append("user", data.get("user"));
|
|
||||||
login.append("pass", data.get("pass1"));
|
|
||||||
login.append("timeout", "86400");
|
|
||||||
await api.post("/auth/login", login);
|
|
||||||
await refreshHeader();
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Feed ---
|
|
||||||
const feed = createFeed(
|
|
||||||
document.getElementById("feed"),
|
|
||||||
document.getElementById("sentinel"),
|
|
||||||
(page) => `/entry/feed/${page}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Neuer Beitrag ---
|
|
||||||
document.getElementById("entry-form").addEventListener("submit", async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const res = await api.post("/entry/create", new FormData(e.target));
|
|
||||||
const msg = document.getElementById("entry-msg");
|
|
||||||
if (res.ok) {
|
|
||||||
msg.textContent = "";
|
|
||||||
document.getElementById("paste-msg").textContent = "";
|
|
||||||
e.target.reset();
|
|
||||||
feed.reset();
|
|
||||||
} else {
|
|
||||||
msg.textContent = errMsg(res.data, "Beitrag fehlgeschlagen.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Bild aus der Zwischenablage einfügen ---
|
|
||||||
setupClipboardPaste(
|
|
||||||
document.getElementById("entry-form"),
|
|
||||||
document.getElementById("paste-msg")
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Statuszeile unter dem Titel ---
|
|
||||||
// Tage ohne Datenverlust (fester Startpunkt, Kontinuität seit der alten
|
|
||||||
// Version).
|
|
||||||
const dataLossStart = new Date("2025-12-13T12:44:00").getTime();
|
|
||||||
|
|
||||||
function plural(n, one, many) {
|
|
||||||
return `${n} ${n === 1 ? one : many}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStats() {
|
|
||||||
const el = document.getElementById("stats");
|
|
||||||
if (!el) return;
|
|
||||||
const days = Math.floor((Date.now() - dataLossStart) / 86400000);
|
|
||||||
el.textContent = `Ohne Datenverlust seit ${plural(days, "Tag", "Tagen")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Start ---
|
|
||||||
refreshHeader();
|
|
||||||
renderStats();
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
// pid aus dem Pfad /e/<pid>.
|
|
||||||
const pid = location.pathname.split("/")[2] || "";
|
|
||||||
|
|
||||||
const replyForm = document.getElementById("reply-form");
|
|
||||||
setupClipboardPaste(replyForm, document.getElementById("reply-paste-msg"));
|
|
||||||
|
|
||||||
replyForm.addEventListener("submit", async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const fd = new FormData(e.target);
|
|
||||||
fd.append("reply_to", pid);
|
|
||||||
const res = await api.post("/entry/create", fd);
|
|
||||||
if (res.ok) {
|
|
||||||
e.target.reset();
|
|
||||||
document.getElementById("reply-msg").textContent = "";
|
|
||||||
document.getElementById("reply-paste-msg").textContent = "";
|
|
||||||
load();
|
|
||||||
} else {
|
|
||||||
document.getElementById("reply-msg").textContent =
|
|
||||||
errMsg(res.data, "Antwort fehlgeschlagen.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
const data = await api.get(`/entry/${pid}/thread`);
|
|
||||||
|
|
||||||
const focus = document.getElementById("focus");
|
|
||||||
if (data.error) {
|
|
||||||
focus.textContent = "Beitrag nicht gefunden";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ahnenkette (Root zuerst), dann der fokussierte Beitrag.
|
|
||||||
const ancestors = document.getElementById("ancestors");
|
|
||||||
ancestors.innerHTML = "";
|
|
||||||
data.ancestors.forEach((a) => ancestors.appendChild(renderEntry(a)));
|
|
||||||
|
|
||||||
focus.innerHTML = "";
|
|
||||||
focus.appendChild(renderEntry(data.entry));
|
|
||||||
|
|
||||||
// Eingeloggt? Reply-Box zeigen; eigener, nicht gelöschter Beitrag -> Bearbeiten/Löschen.
|
|
||||||
const me = await api.get("/user/info");
|
|
||||||
document.getElementById("reply-box").hidden = !me.uid;
|
|
||||||
if (me.uid === data.entry.uid && !data.entry.deleted) {
|
|
||||||
const editBtn = document.createElement("button");
|
|
||||||
editBtn.textContent = "Bearbeiten";
|
|
||||||
editBtn.addEventListener("click", () => {
|
|
||||||
editBtn.disabled = true;
|
|
||||||
const form = document.createElement("form");
|
|
||||||
form.innerHTML = `
|
|
||||||
<textarea name="content" rows="4" maxlength="1000"></textarea>
|
|
||||||
<button type="submit">Speichern</button>
|
|
||||||
<button type="button" class="cancel">Abbrechen</button>
|
|
||||||
<p class="msg"></p>`;
|
|
||||||
form.querySelector("textarea").value = data.entry.content;
|
|
||||||
form.addEventListener("submit", async (ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
const res = await api.post(`/entry/${pid}/edit`, new FormData(form));
|
|
||||||
if (res.ok) load();
|
|
||||||
else form.querySelector(".msg").textContent =
|
|
||||||
errMsg(res.data, "Bearbeiten fehlgeschlagen.");
|
|
||||||
});
|
|
||||||
form.querySelector(".cancel").addEventListener("click", load);
|
|
||||||
focus.appendChild(form);
|
|
||||||
});
|
|
||||||
|
|
||||||
const delBtn = document.createElement("button");
|
|
||||||
delBtn.textContent = "Löschen";
|
|
||||||
delBtn.addEventListener("click", async () => {
|
|
||||||
if (!confirm("Beitrag wirklich löschen?")) return;
|
|
||||||
const res = await api.post(`/entry/${pid}/delete`, new FormData());
|
|
||||||
if (res.ok) load();
|
|
||||||
else alert(errMsg(res.data, "Löschen fehlgeschlagen."));
|
|
||||||
});
|
|
||||||
|
|
||||||
focus.appendChild(editBtn);
|
|
||||||
focus.appendChild(delBtn);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Direkte Antworten.
|
|
||||||
const replies = document.getElementById("replies");
|
|
||||||
replies.innerHTML = "";
|
|
||||||
if (!data.replies.length) {
|
|
||||||
const p = document.createElement("p");
|
|
||||||
p.className = "muted";
|
|
||||||
p.textContent = "Noch keine Antworten.";
|
|
||||||
replies.appendChild(p);
|
|
||||||
} else {
|
|
||||||
data.replies.forEach((e) => replies.appendChild(renderEntry(e)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
load();
|
|
||||||
-315
@@ -1,315 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
// Die API antwortet ausschließlich mit JSON; der Accept-Header sagt das explizit.
|
|
||||||
const JSON_HEADERS = { Accept: "application/json" };
|
|
||||||
|
|
||||||
const api = {
|
|
||||||
async get(url) {
|
|
||||||
const r = await fetch(url, {
|
|
||||||
credentials: "same-origin",
|
|
||||||
headers: JSON_HEADERS,
|
|
||||||
});
|
|
||||||
return r.json();
|
|
||||||
},
|
|
||||||
async post(url, formData) {
|
|
||||||
const r = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
credentials: "same-origin",
|
|
||||||
headers: JSON_HEADERS,
|
|
||||||
});
|
|
||||||
return { ok: r.ok, status: r.status, data: await r.json().catch(() => ({})) };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// errMsg zieht die Nutzermeldung aus dem Fehlerformat der API:
|
|
||||||
// {"error": {"code", "message", "field", "fields"}}. Liegen mehrere
|
|
||||||
// Feldfehler vor (Registrierung), werden sie zu einem Satz zusammengezogen.
|
|
||||||
// Der code ist bewusst nicht Teil der Anzeige -- er ist für Logik da, nicht
|
|
||||||
// für Menschen.
|
|
||||||
function errMsg(data, fallback) {
|
|
||||||
const e = data && data.error;
|
|
||||||
if (!e) return fallback;
|
|
||||||
const fields = e.fields ? Object.values(e.fields) : [];
|
|
||||||
if (fields.length) return fields.join(" ");
|
|
||||||
return e.message || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escaped für Text- UND Attributkontext. Der frühere textContent->innerHTML-
|
|
||||||
// Trick ließ Anführungszeichen stehen (in Textknoten harmlos); linkify steckt
|
|
||||||
// den Wert aber in ein href="..."-Attribut, wo ein " ausbrechen würde.
|
|
||||||
function escapeHtml(s) {
|
|
||||||
return String(s ?? "")
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
// avatarUrl liefert den Bildpfad oder das Platzhalterbild bei leerem Avatar.
|
|
||||||
function avatarUrl(path) {
|
|
||||||
return path ? `/${path}` : "/static/assets/no_profile_pic.jpg";
|
|
||||||
}
|
|
||||||
|
|
||||||
function linkify(s) {
|
|
||||||
const escaped = escapeHtml(s);
|
|
||||||
const url = /https?:\/\/[^\s<]+/g;
|
|
||||||
return escaped
|
|
||||||
.replace(url, (m) => `<a href="${m}" target="_blank" rel="noopener">${m}</a>`)
|
|
||||||
.replace(/\n/g, "<br>");
|
|
||||||
}
|
|
||||||
|
|
||||||
// setupClipboardPaste erlaubt Strg+V eines Bildes aus der Zwischenablage direkt
|
|
||||||
// im Formular: das Bild wandert in den File-Input (über DataTransfer, weil sich
|
|
||||||
// .files nicht direkt setzen lässt). Liegt kein Bild vor (Text o. Ä.), tun wir
|
|
||||||
// nichts und lassen den normalen Paste durchlaufen -> preventDefault nur, wenn
|
|
||||||
// wir wirklich ein Bild gegriffen haben. msgEl (optional) zeigt den Hinweis.
|
|
||||||
function setupClipboardPaste(form, msgEl) {
|
|
||||||
form.addEventListener("paste", (e) => {
|
|
||||||
const item = [...(e.clipboardData?.items ?? [])].find(
|
|
||||||
(it) => it.kind === "file" && it.type.startsWith("image/")
|
|
||||||
);
|
|
||||||
if (!item) return;
|
|
||||||
|
|
||||||
const original = item.getAsFile();
|
|
||||||
if (!original) return;
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Browser benennt Clipboard-Bilder uneinheitlich ("grafik.png" o. Ä.) -> neu
|
|
||||||
// verpacken mit sprechendem Namen. Endung aus dem MIME-Typ, sonst png.
|
|
||||||
const ext = (original.type.split("/")[1] || "png").toLowerCase();
|
|
||||||
const file = new File([original], `zwischenablage.${ext}`, { type: original.type });
|
|
||||||
|
|
||||||
const input = form.querySelector("input[name='file']");
|
|
||||||
const dt = new DataTransfer();
|
|
||||||
dt.items.add(file);
|
|
||||||
input.files = dt.files;
|
|
||||||
|
|
||||||
if (msgEl) msgEl.textContent = "Bild aus Zwischenablage übernommen.";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEntry(e) {
|
|
||||||
const el = document.createElement("article");
|
|
||||||
el.className = "card";
|
|
||||||
|
|
||||||
const when = new Date(e.created_at * 1000).toLocaleString("de-DE");
|
|
||||||
|
|
||||||
// Soft-gelöschter Beitrag: nur Platzhalter, damit der Thread navigierbar bleibt.
|
|
||||||
if (e.deleted) {
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="muted byline"><span>[deleted] · ${when}</span></div>
|
|
||||||
<div class="threadline-wrapper">
|
|
||||||
<div class="threadline"></div>
|
|
||||||
<div class="body">
|
|
||||||
<div class="content">[deleted]</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
el.addEventListener("click", (ev) => {
|
|
||||||
if (!ev.target.closest("a, button")) location.href = `/e/${e.pid}`;
|
|
||||||
});
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = encodeURIComponent(e.username);
|
|
||||||
let media = "";
|
|
||||||
if (e.filepath) {
|
|
||||||
media = `<img src="/${e.filepath}" alt="" loading="lazy">`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Antwort- und Repost-Zahl wandern in die Buttons unten; in der Byline bleibt
|
|
||||||
// nur Name, Zeit und (im Cooldown) der Hinweis bis zum nächsten Repost.
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="muted byline">
|
|
||||||
<a href="/u/${user}"><img class="avatar avatar-sm" src="${avatarUrl(e.avatar)}" alt="" loading="lazy"></a>
|
|
||||||
<span><a href="/u/${user}">${escapeHtml(e.username)}</a> · ${when}<span class="cooldown"></span></span>
|
|
||||||
<details class="report">
|
|
||||||
<summary>Melden</summary>
|
|
||||||
<form class="report-form" method="post" action="/entry/${e.pid}/report">
|
|
||||||
<label>Grund <span class="muted">(optional)</span>
|
|
||||||
<input type="text" name="reason" maxlength="500" autocomplete="off">
|
|
||||||
</label>
|
|
||||||
<button type="submit">Absenden</button>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
<div class="threadline-wrapper">
|
|
||||||
<div class="threadline"></div>
|
|
||||||
<div class="body">
|
|
||||||
<div class="content">${linkify(e.content)}</div>
|
|
||||||
${media}
|
|
||||||
<div class="interactions">
|
|
||||||
<a class="button reply" href="/e/${e.pid}">Antworten (${e.reply_count})</a>
|
|
||||||
<div class="row">
|
|
||||||
<button class="ghost" data-mode="left">Links</button>
|
|
||||||
<span class="muted">– / –</span>
|
|
||||||
<button class="ghost" data-mode="right">Rechts</button>
|
|
||||||
</div>
|
|
||||||
<button class="bump" type="button">Repost (${e.bump_count})</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
setupVoting(el, e.pid);
|
|
||||||
setupBump(el, e);
|
|
||||||
setupReport(el, e.pid);
|
|
||||||
el.addEventListener("click", (ev) => {
|
|
||||||
if (!ev.target.closest("a, button")) location.href = `/e/${e.pid}`;
|
|
||||||
});
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupVoting(el, pid) {
|
|
||||||
const box = el.querySelector(".row");
|
|
||||||
const counts = box.querySelector("span");
|
|
||||||
const buttons = box.querySelectorAll(".ghost");
|
|
||||||
|
|
||||||
function render(state) {
|
|
||||||
if (state.error) {
|
|
||||||
counts.textContent = errMsg(state, "Fehler");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
counts.textContent = `${state.left} / ${state.right}`;
|
|
||||||
buttons.forEach((b) =>
|
|
||||||
b.classList.toggle("selected", b.dataset.mode === state.selected)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
buttons.forEach((b) =>
|
|
||||||
b.addEventListener("click", async () => {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("mode", b.dataset.mode);
|
|
||||||
const res = await api.post(`/entry/${pid}/vote`, fd);
|
|
||||||
render(res.data);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
api.get(`/entry/${pid}/votes`).then(render);
|
|
||||||
}
|
|
||||||
|
|
||||||
// setupBump verdrahtet den Bump-Button. Der Cooldown wächst pro Beitrag linear
|
|
||||||
// (Backend, siehe handleBump); hier wird nur angezeigt und auf 429 reagiert. Eine
|
|
||||||
// Live-Aktualisierung gibt es bewusst nicht: der Zustand stammt aus den
|
|
||||||
// Beitragsdaten beim Laden, plus die Antwort des eigenen Klicks.
|
|
||||||
function setupBump(el, e) {
|
|
||||||
const btn = el.querySelector(".bump");
|
|
||||||
if (!btn) return;
|
|
||||||
const cooldown = el.querySelector(".cooldown");
|
|
||||||
|
|
||||||
// sekunden bis zum nächsten erlaubten Bump aus den geladenen Daten.
|
|
||||||
const nowSec = () => Math.floor(Date.now() / 1000);
|
|
||||||
|
|
||||||
// lock sperrt den Button; die Restzeit steht jetzt oben in der Byline, auf
|
|
||||||
// Stunden aufgerundet (mindestens 1 -> nie "0h", solange Restzeit übrig ist).
|
|
||||||
function lock(retryAfter) {
|
|
||||||
btn.disabled = true;
|
|
||||||
const hours = Math.max(1, Math.ceil(retryAfter / 3600));
|
|
||||||
if (cooldown) cooldown.textContent = ` · Repost in ${hours}h`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remaining = e.last_bump + e.bump_count * 86400 - nowSec();
|
|
||||||
if (e.bump_count > 0 && remaining > 0) lock(remaining);
|
|
||||||
|
|
||||||
btn.addEventListener("click", async () => {
|
|
||||||
const res = await api.post(`/entry/${e.pid}/bump`, new FormData());
|
|
||||||
if (res.ok) {
|
|
||||||
btn.textContent = `Repost (${res.data.bump_count})`;
|
|
||||||
lock(res.data.retry_after || 86400);
|
|
||||||
} else if (res.status === 429) {
|
|
||||||
lock(res.data.retry_after || 86400);
|
|
||||||
} else {
|
|
||||||
// 401 (nicht angemeldet) o. Ä. -> Fehlertext zeigen, Button frei lassen.
|
|
||||||
btn.textContent = errMsg(res.data, "Fehler");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// setupReport verdrahtet den Melden-Aufklapper. Der <details>-Zweischritt
|
|
||||||
// (öffnen -> absenden) verhindert versehentliche Meldungen und hält das Grund-
|
|
||||||
// Feld eingeklappt. Der Submit wird abgefangen, um Inline-Feedback zu geben
|
|
||||||
// statt die Seite neu zu laden. stopPropagation, weil
|
|
||||||
// die Karte sonst bei jedem Klick nach /e/{pid} navigieren würde.
|
|
||||||
function setupReport(el, pid) {
|
|
||||||
const details = el.querySelector("details.report");
|
|
||||||
if (!details) return;
|
|
||||||
details.addEventListener("click", (ev) => ev.stopPropagation());
|
|
||||||
|
|
||||||
const form = details.querySelector(".report-form");
|
|
||||||
const summary = details.querySelector("summary");
|
|
||||||
form.addEventListener("submit", async (ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
const res = await api.post(`/entry/${pid}/report`, new FormData(form));
|
|
||||||
if (res.ok) {
|
|
||||||
form.remove();
|
|
||||||
details.open = false;
|
|
||||||
details.classList.add("reported");
|
|
||||||
summary.textContent = "Gemeldet – danke";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let msg = form.querySelector(".msg");
|
|
||||||
if (!msg) {
|
|
||||||
msg = document.createElement("span");
|
|
||||||
msg.className = "msg";
|
|
||||||
form.append(msg);
|
|
||||||
}
|
|
||||||
msg.textContent = errMsg(res.data, "Fehler beim Melden");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// createFeed hängt Infinite-Scroll an feedEl/sentinelEl und lädt Seiten über
|
|
||||||
// urlFor(page). Gibt { reset } zurück, um den Feed neu zu laden.
|
|
||||||
function createFeed(feedEl, sentinelEl, urlFor) {
|
|
||||||
let page = 0;
|
|
||||||
let loading = false;
|
|
||||||
let done = false;
|
|
||||||
|
|
||||||
async function loadMore() {
|
|
||||||
if (loading || done) return;
|
|
||||||
loading = true;
|
|
||||||
|
|
||||||
const entries = await api.get(urlFor(page));
|
|
||||||
if (!entries.length) {
|
|
||||||
done = true;
|
|
||||||
const end = document.createElement("p");
|
|
||||||
end.className = "muted";
|
|
||||||
end.textContent = "YOU REACHED THE END!";
|
|
||||||
feedEl.appendChild(end);
|
|
||||||
} else {
|
|
||||||
entries.forEach((e) => feedEl.appendChild(renderEntry(e)));
|
|
||||||
page++;
|
|
||||||
}
|
|
||||||
loading = false;
|
|
||||||
|
|
||||||
// Der IntersectionObserver feuert nur bei Zustandswechseln. Füllt die
|
|
||||||
// gerade geladene Seite den Viewport nicht über den Sentinel hinaus, gäbe
|
|
||||||
// es keinen Wechsel und nie ein Nachladen -> hier aktiv weiterladen, bis
|
|
||||||
// der Sentinel aus dem Sichtbereich wandert oder das Ende erreicht ist.
|
|
||||||
if (!done && sentinelVisible()) loadMore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function sentinelVisible() {
|
|
||||||
return sentinelEl.getBoundingClientRect().top <= window.innerHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
page = 0;
|
|
||||||
done = false;
|
|
||||||
feedEl.innerHTML = "";
|
|
||||||
loadMore();
|
|
||||||
}
|
|
||||||
|
|
||||||
// rootMargin lädt schon kurz vor Erreichen des Sentinels nach (flüssiger und
|
|
||||||
// robust gegen den Fall eines 0-hohen Sentinels exakt am Dokumentende).
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(items) => {
|
|
||||||
if (items.some((i) => i.isIntersecting)) loadMore();
|
|
||||||
},
|
|
||||||
{ rootMargin: "300px" }
|
|
||||||
);
|
|
||||||
observer.observe(sentinelEl);
|
|
||||||
|
|
||||||
loadMore();
|
|
||||||
return { reset };
|
|
||||||
}
|
|
||||||
-108
@@ -1,108 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
// Statistik-Seite: holt die Aggregate von /stats/detail und rendert Zahlen +
|
|
||||||
// einfache CSS-Balken (keine Chart-Library, CSP-konform). Balkenbreiten werden
|
|
||||||
// per CSSOM (el.style.width) gesetzt -> kein Inline-style-Attribut, daher von
|
|
||||||
// der strikten CSP nicht betroffen.
|
|
||||||
|
|
||||||
function plural(n, one, many) {
|
|
||||||
return `${n} ${n === 1 ? one : many}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// numCard baut eine Kennzahl-Kachel: große Zahl + Beschriftung darunter.
|
|
||||||
function numCard(value, label) {
|
|
||||||
const cell = document.createElement("div");
|
|
||||||
const num = document.createElement("span");
|
|
||||||
num.className = "num";
|
|
||||||
num.textContent = value;
|
|
||||||
const cap = document.createElement("span");
|
|
||||||
cap.className = "muted";
|
|
||||||
cap.textContent = label;
|
|
||||||
cell.append(num, cap);
|
|
||||||
return cell;
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderBars zeichnet eine Liste {label, value} als horizontale Balken. Die
|
|
||||||
// Breite ist relativ zum größten Wert.
|
|
||||||
function renderBars(container, items, formatLabel) {
|
|
||||||
container.textContent = "";
|
|
||||||
if (items.length === 0) {
|
|
||||||
const p = document.createElement("p");
|
|
||||||
p.className = "muted";
|
|
||||||
p.textContent = "Noch keine Daten.";
|
|
||||||
container.append(p);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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));
|
|
||||||
for (const it of items) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "bar-row";
|
|
||||||
|
|
||||||
const label = document.createElement("span");
|
|
||||||
label.className = "bar-label";
|
|
||||||
label.textContent = formatLabel ? formatLabel(it.label) : it.label;
|
|
||||||
|
|
||||||
const track = document.createElement("div");
|
|
||||||
track.className = "bar-track";
|
|
||||||
const fill = document.createElement("div");
|
|
||||||
fill.className = "bar-fill";
|
|
||||||
fill.style.width = `${(it.value / max) * 100}%`;
|
|
||||||
track.append(fill);
|
|
||||||
|
|
||||||
const val = document.createElement("span");
|
|
||||||
val.className = "bar-value";
|
|
||||||
val.style.width = `${valWidth}ch`;
|
|
||||||
val.textContent = it.value;
|
|
||||||
|
|
||||||
row.append(label, track, val);
|
|
||||||
container.append(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function render() {
|
|
||||||
const s = await api.get("/stats/detail");
|
|
||||||
|
|
||||||
// --- Auf einen Blick ---
|
|
||||||
const overview = document.getElementById("stat-overview");
|
|
||||||
overview.append(
|
|
||||||
numCard(s.users ?? 0, s.users === 1 ? "Profil" : "Profile"),
|
|
||||||
numCard(s.entries ?? 0, "Beiträge"),
|
|
||||||
numCard(s.toplevel ?? 0, "davon Threads"),
|
|
||||||
numCard(s.replies ?? 0, "davon Antworten"),
|
|
||||||
numCard(s.votes?.left ?? 0, "Stimmen Links"),
|
|
||||||
numCard(s.votes?.right ?? 0, "Stimmen Rechts"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Reichweite ---
|
|
||||||
document.getElementById("stat-reach").append(
|
|
||||||
numCard(s.impressions_total ?? 0, "Aufrufe gesamt"),
|
|
||||||
numCard(s.visitors_total ?? 0, "Netze gesamt"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Netze nach Betreiber (ASN) ---
|
|
||||||
renderBars(
|
|
||||||
document.getElementById("stat-asns"),
|
|
||||||
(s.top_asns ?? []).map((a) => ({ label: a.asn, value: a.hits })),
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Aufrufe pro Tag (Backend liefert absteigend -> für die Achse drehen) ---
|
|
||||||
const daily = (s.daily ?? []).slice().reverse();
|
|
||||||
const dayAxis = (day) => day.slice(5); // "MM-DD" reicht auf der Achse
|
|
||||||
renderBars(
|
|
||||||
document.getElementById("stat-daily"),
|
|
||||||
daily.map((d) => ({ label: d.day, value: d.impressions })),
|
|
||||||
dayAxis,
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Verschiedene Netze pro Tag ---
|
|
||||||
renderBars(
|
|
||||||
document.getElementById("stat-networks"),
|
|
||||||
daily.map((d) => ({ label: d.day, value: d.visitors })),
|
|
||||||
dayAxis,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
render();
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
// Nutzername aus dem Pfad /u/<name>.
|
|
||||||
const username = decodeURIComponent(location.pathname.split("/")[2] || "");
|
|
||||||
|
|
||||||
document.getElementById("rename-form").addEventListener("submit", async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const res = await api.post("/user/rename", new FormData(e.target));
|
|
||||||
if (res.ok) {
|
|
||||||
location.href = `/u/${encodeURIComponent(res.data.username)}`;
|
|
||||||
} else {
|
|
||||||
document.getElementById("rename-msg").textContent =
|
|
||||||
errMsg(res.data, "Umbenennen fehlgeschlagen.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("avatar-form").addEventListener("submit", async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const res = await api.post("/user/avatar", new FormData(e.target));
|
|
||||||
if (res.ok) {
|
|
||||||
// Cache umgehen, damit das neue Bild sofort erscheint.
|
|
||||||
document.getElementById("profile-avatar").src = `${avatarUrl(res.data.avatar)}?t=${Date.now()}`;
|
|
||||||
e.target.reset();
|
|
||||||
document.getElementById("avatar-msg").textContent = "";
|
|
||||||
} else {
|
|
||||||
document.getElementById("avatar-msg").textContent =
|
|
||||||
errMsg(res.data, "Hochladen fehlgeschlagen.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("delete-form").addEventListener("submit", async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!confirm("Konto wirklich unwiderruflich löschen?")) return;
|
|
||||||
const res = await api.post("/user/delete", new FormData(e.target));
|
|
||||||
if (res.ok) {
|
|
||||||
location.href = "/";
|
|
||||||
} else {
|
|
||||||
document.getElementById("delete-msg").textContent =
|
|
||||||
errMsg(res.data, "Löschen fehlgeschlagen.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
const info = await api.get(`/u/${encodeURIComponent(username)}/info`);
|
|
||||||
if (info.error) {
|
|
||||||
document.getElementById("profile-name").textContent = "Nutzer nicht gefunden";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById("profile-name").textContent = info.username;
|
|
||||||
document.getElementById("profile-avatar").src = avatarUrl(info.avatar);
|
|
||||||
const since = new Date(info.created_at * 1000).toLocaleDateString("de-DE");
|
|
||||||
document.getElementById("profile-info").textContent = `Mitglied seit ${since}`;
|
|
||||||
|
|
||||||
// Eigene Seite? -> Einstellungen einblenden.
|
|
||||||
const me = await api.get("/user/info");
|
|
||||||
const isOwner = me.username === info.username;
|
|
||||||
document.getElementById("settings").hidden = !isOwner;
|
|
||||||
|
|
||||||
createFeed(
|
|
||||||
document.getElementById("feed"),
|
|
||||||
document.getElementById("sentinel"),
|
|
||||||
(page) => `/u/${encodeURIComponent(username)}/feed/${page}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
init();
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Statistik – Kontrollverlust</title>
|
|
||||||
<link rel="stylesheet" href="/css/app.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<nav id="legal">
|
|
||||||
<a href="/static/docs/impressum.html">Impressum</a> -
|
|
||||||
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
|
|
||||||
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
|
|
||||||
<a href="/stats.html">Statistik</a>
|
|
||||||
</nav>
|
|
||||||
<a href="/"><h1>Kontrollverlust</h1></a>
|
|
||||||
<div id="headerbar"><a class="button" href="/">zurück zum Feed</a></div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<section>
|
|
||||||
<h2>Statistik</h2>
|
|
||||||
|
|
||||||
<h3>Auf einen Blick</h3>
|
|
||||||
<div id="stat-overview" class="statgrid"></div>
|
|
||||||
|
|
||||||
<h3>Reichweite</h3>
|
|
||||||
<p class="muted">
|
|
||||||
Seitenaufrufe werden mit anonymisierten Netzen gezählt (IPv4 /16, IPv6 /32).
|
|
||||||
„Netze" ist eine grobe Annäherung an Besucher, keine Personenzählung.
|
|
||||||
</p>
|
|
||||||
<div id="stat-reach" class="statgrid"></div>
|
|
||||||
|
|
||||||
<h3>Netze nach Betreiber</h3>
|
|
||||||
<p class="muted">
|
|
||||||
Aufrufe je autonomem System (Netzbetreiber), aufgelöst über GeoLite2-ASN.
|
|
||||||
</p>
|
|
||||||
<div id="stat-asns" class="bars"></div>
|
|
||||||
|
|
||||||
<h3>Aufrufe pro Tag</h3>
|
|
||||||
<div id="stat-daily" class="bars"></div>
|
|
||||||
|
|
||||||
<h3>Verschiedene Netze pro Tag</h3>
|
|
||||||
<div id="stat-networks" class="bars"></div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script src="/js/feed.js"></script>
|
|
||||||
<script src="/js/stats.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Kontrollverlust</title>
|
|
||||||
<link rel="stylesheet" href="/css/app.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<nav id="legal">
|
|
||||||
<a href="/static/docs/impressum.html">Impressum</a> -
|
|
||||||
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
|
|
||||||
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
|
|
||||||
<a href="/stats.html">Statistik</a>
|
|
||||||
</nav>
|
|
||||||
<a href="/"><h1>Kontrollverlust</h1></a>
|
|
||||||
<div id="headerbar"><a class="button" href="/">zurück zum Feed</a></div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<section id="profile">
|
|
||||||
<div class="profile-head">
|
|
||||||
<img id="profile-avatar" class="avatar avatar-lg" src="/static/assets/no_profile_pic.jpg" alt="">
|
|
||||||
<div>
|
|
||||||
<h2 id="profile-name"></h2>
|
|
||||||
<p id="profile-info" class="muted"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Nur für den Eigentümer sichtbar -->
|
|
||||||
<div id="settings" hidden>
|
|
||||||
<h2>Einstellungen</h2>
|
|
||||||
<details>
|
|
||||||
<summary>Profilbild ändern</summary>
|
|
||||||
<form id="avatar-form">
|
|
||||||
<input name="avatar" type="file" accept="image/*">
|
|
||||||
<button type="submit">Bild hochladen</button>
|
|
||||||
</form>
|
|
||||||
<p class="msg" id="avatar-msg"></p>
|
|
||||||
</details>
|
|
||||||
<details>
|
|
||||||
<summary>Name ändern</summary>
|
|
||||||
<form id="rename-form">
|
|
||||||
<input name="user" type="text" placeholder="Neuer Nutzername" autocomplete="username">
|
|
||||||
<button type="submit">Namen ändern</button>
|
|
||||||
</form>
|
|
||||||
<p class="msg" id="rename-msg"></p>
|
|
||||||
</details>
|
|
||||||
<details>
|
|
||||||
<summary>Konto löschen</summary>
|
|
||||||
<form id="delete-form">
|
|
||||||
<p class="muted">Löscht dein Konto samt Beiträgen und Votes unwiderruflich.</p>
|
|
||||||
<input name="pass1" type="password" placeholder="Passwort bestätigen" autocomplete="current-password">
|
|
||||||
<button type="submit">Konto endgültig löschen</button>
|
|
||||||
</form>
|
|
||||||
<p class="msg" id="delete-msg"></p>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="feed"></div>
|
|
||||||
<div id="sentinel"></div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script src="/js/feed.js"></script>
|
|
||||||
<script src="/js/user.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Reference in New Issue
Block a user