Modus-Werte yes/no statt left/right in DB, API und Statistik; Knöpfe "Ja"/"Nein". Bestehende Stimmen müssen per SQL migriert werden. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
137 lines
6.1 KiB
Markdown
137 lines
6.1 KiB
Markdown
# kver API
|
||
|
||
Alle Endpunkte liegen unter `/api` und antworten ausschließlich mit JSON.
|
||
Außerhalb von `/api` liefert das Backend nur Seiten und Dateien des Frontends.
|
||
|
||
---
|
||
|
||
## Konventionen
|
||
|
||
**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
|
||
|
||
| 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` |
|
||
|
||
Login-Fehler: `401 auth.bad_credentials` (gleich für unbekannten Nutzer und
|
||
falsches Passwort).
|
||
|
||
---
|
||
|
||
## Beiträge
|
||
|
||
Ein Beitrag (`Entry`):
|
||
|
||
```json
|
||
{ "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/…" }
|
||
```
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## Nutzer
|
||
|
||
| 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` |
|
||
|
||
---
|
||
|
||
## Statistik
|
||
|
||
| Methode | Pfad | Erfolg |
|
||
|---------|------|--------|
|
||
| GET | `/api/stats` | `200 {"users","entries"}` |
|
||
| GET | `/api/stats/detail` | `200 {"users","entries","toplevel","replies","votes":{"yes","no"},"impressions_total","visitors_total","daily":[…],"top_asns":[…]}` |
|
||
|
||
---
|
||
|
||
## 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
|
||
|
||
Postgres; maßgeblich ist die Konstante `schema` in `db.go`.
|