Moderation entfernt, JSON-API unter /api

- Moderationsseite und Melde-Funktion (report) komplett ausgebaut; folgt
  als eigenständiges Projekt
- alle API-Endpunkte unter /api, dort ausschließlich JSON: auch 404, 405,
  Rate-Limit (429) und Panics (500)
- Fehler nur über HTTP-Status; stille DB-Fehler in stats, logout und
  Vote-Zählern liefern jetzt 500 statt Nullen
- /auth/headerbar entfernt (Frontend nutzt /api/user/info)
- Frontend auf /api und statusbasierte Auswertung umgestellt
- notes/api.md neu als Referenz der aktuellen API

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
irrlicht
2026-09-26 23:10:02 +02:00
co-authored by Claude Opus 5.5
parent 2714fdb1a5
commit 030a5bd943
21 changed files with 430 additions and 726 deletions
+86 -189
View File
@@ -1,228 +1,125 @@
# 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 und Registrierung 20/min.
---
## Auth
### `POST /auth/login`
| Methode | Pfad | Auth | Request | Erfolg |
|---------|------|------|---------|--------|
| POST | `/api/auth/login` | – | `user`, `pass`, `timeout` (s, Default 1 Tag, max. 30 Tage) | `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` |
Einloggen.
**Request (form-encoded):**
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `user` | string | Nutzername |
| `pass` | string | Passwort |
| `timeout` | int | Session-Dauer in Sekunden |
**Response:**
- `200` + setzt `session`-Cookie → Redirect auf `/`
- `200` + Fehlertext bei falschen Credentials
Login-Fehler: `401 auth.bad_credentials` (gleich für unbekannten Nutzer und
falsches Passwort).
---
### `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
{
"uid": 123456,
"created_at": 1700000000,
"expires": 1700086400,
"description": ""
}
{ "pid": 123456789, "uid": 42, "created_at": 1700000000, "content": "…",
"filepath": "static/media/…", "reply_to": 0, "reply_count": 3,
"last_activity": 1700000500, "deleted": 0, "bump_count": 1,
"last_bump": 1700000400, "username": "max", "avatar": "static/media/…" }
```
---
| Methode | Pfad | Auth | Request | Erfolg |
|---------|------|------|---------|--------|
| GET | `/api/entry/feed/{page}` | – | – | `200 [Entry]`: Threads nach letzter Aktivität, 20 pro Seite, Seite 0–100, `[]` am Ende |
| GET | `/api/entry/{pid}/thread` | – | – | `200 {"entry", "ancestors": [Entry], "replies": [Entry]}` |
| GET | `/api/entry/{pid}/votes` | – | – | `200 {"pid","left","right","selected"}` (`selected`: `left`/`right`/`none`) |
| POST | `/api/entry/create` | ✓ | multipart: `content` (max. 1000), `file` (optional), `reply_to` (optional) | `201 {"pid","content","filepath","reply_to"}` |
| POST | `/api/entry/{pid}/edit` | ✓ Autor | `content` | `200 {"pid","content"}` |
| POST | `/api/entry/{pid}/delete` | ✓ Autor | – | `200 {"status":"deleted"}` (Soft-Delete, Thread bleibt) |
| POST | `/api/entry/{pid}/vote` | ✓ | `mode` = `left`/`right` | `200` wie `votes`. Gleiche Stimme erneut zieht sie zurück |
| POST | `/api/entry/{pid}/bump` | ✓ | – | `200 {"pid","bump_count","last_bump","last_activity","retry_after"}` |
## Entries (Beiträge)
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`).
### `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
[
{
"pid": 123456789,
"created_at": 1700000000,
"uid": 42,
"content": "...",
"filepath": "static/media/...",
"username": "max"
}
]
```
Leeres Array `[]` wenn keine weiteren Einträge vorhanden.
TOR-Anfragen auf `create` werden mit `403 tor.blocked` abgewiesen.
---
### `POST /entry/create`
## Nutzer
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
| Methode | Pfad | Auth | Request | Erfolg |
|---------|------|------|---------|--------|
| 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` |
---
### `GET /entry/:pid/votes`
## Statistik
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.
| Methode | Pfad | Erfolg |
|---------|------|--------|
| GET | `/api/stats` | `200 {"users","entries"}` |
| GET | `/api/stats/detail` | `200 {"users","entries","toplevel","replies","votes":{"left","right"},"impressions_total","visitors_total","daily":[…],"top_asns":[…]}` |
---
### `POST /entry/:pid/vote`
## Seiten (kein JSON)
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
}
```
---
### `POST /user/delete`
Eigenen Account dauerhaft löschen. Beiträge, Votes und Sessions werden
mitgelöscht, zugehörige Mediendateien best effort entfernt.
**Auth:** erforderlich
**Request (form-encoded):**
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `pass1` | string | Passwort zur Bestätigung |
**Response:**
- `200` `{"status": "deleted"}` + löscht den `session`-Cookie
- `403` bei falschem Passwort
`/`, `/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
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
+1 -1
View File
@@ -196,7 +196,7 @@ Volume=kver-media:/app/static/media
Environment=KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb
# Healthcheck hier statt im Containerfile: Podman baut OCI-Images,
# 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
[Service]
+1 -2
View File
@@ -1,7 +1,6 @@
# TODO
- Moderation: handled setzen
- Moderation: Löschen aus Queue
- Moderation (Melden, Queue, Abhilfe) als eigenständiges Projekt
- Passwort-Reset
- Suche
- Hashtags / Erwähnungen