Files
Kontrollverlust/notes/api.md
T
irrlichtandClaude Opus 5.5 07a2b64eff Statistik: tägliche SVG-Diagramme statt CSS-Balken
Der Server rendert einmal täglich (0:01 Uhr deutscher Zeit) Liniendiagramme
für Aufrufe, Netze und Beiträge pro Tag sowie gestapelte Säulen (Threads/
Antworten) für den Ø pro Wochentag und pro Tageszeit, je schmal (600) und
breit (1200), ausgeliefert über /api/stats/charts. Hovern zeigt den
Datenpunkt in einer Infozeile und markiert ihn mit einer senkrechten Linie.

Die Statistik-Seite wird auf breiten Bildschirmen bis 1200 px breit.
"Netze nach Betreiber" entfällt; neu ist die Kennzahl Ø Beiträge/Tag der
letzten 30 Tage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-10-06 20:13:30 +02:00

157 lines
7.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, Registrierung und Passwort-Reset 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` |
| POST | `/api/auth/reset/check` | – | `token` | `200 {"username"}`; ungültig/abgelaufen/benutzt: `404 reset.invalid` |
| POST | `/api/auth/reset` | – | `token`, `pass1` (min. 10), `pass2` | `200 {"username"}` + Cookie (eingeloggt); löscht alle alten Sessions, Link verbraucht |
Login-Fehler: `401 auth.bad_credentials` (gleich für unbekannten Nutzer und
falsches Passwort).
Reset-Links erzeugt nur der Admin per CLI (`kver reset-link <nutzername>`,
siehe deploy.md). Sie zeigen auf `/reset#<token>`, gelten 72 h und einmal; ein
neuer Link macht ältere desselben Nutzers ungültig. Das Token steht im
Fragment und geht nur im POST-Body an die API, damit es nicht in Logs landet.
---
## Beiträge
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/…" }
```
Enthält der Text einen Link, trägt der Beitrag `preview`, sobald der Server
die Vorschau des **ersten** Links geholt hat (asynchron nach dem Anlegen bzw.
Bearbeiten; vorher und ohne verwertbare Vorschau fehlt das Feld). `url` ist
die kanonische Adresse (YouTube-Links aller Formen als `watch?v=<id>`),
`kind` ist `youtube` oder `page`, `site` Kanal- bzw. Seitenname, `thumb` ein
lokales Bild (leer, wenn es keins gibt). Siehe `preview.go`.
```json
"preview": { "url": "https://www.youtube.com/watch?v=…", "kind": "youtube",
"title": "…", "site": "…", "thumb": "static/media/preview/….jpg" }
```
In Hauptfeed und `replies` des Threads trägt jeder Beitrag mit Antworten
zusätzlich `latest`: den jüngsten nicht gelöschten Beitrag aus seinem ganzen
Teilbaum (nach `created_at`, höchstens 50 Ebenen tief). `skipped` zählt die
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","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
Postgres; maßgeblich ist die Konstante `schema` in `db.go`.