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:
co-authored by
Claude Opus 5.5
parent
2714fdb1a5
commit
030a5bd943
+86
-189
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user