Split voting into clean read/write endpoints

Replaces the GET-mutates-state interactions route with a proper split:
- GET  /entry/{pid}/votes  reads the tally (public, read-only)
- POST /entry/{pid}/vote   casts/toggles the vote (auth, mode in body)

Shared voteTally/writeTally helpers back both handlers. Invalid mode now
returns 400. Frontend updated to read via GET and vote via POST.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 11:03:33 +02:00
parent be8dbb4902
commit 38da498754
4 changed files with 96 additions and 64 deletions
+65 -43
View File
@@ -106,58 +106,23 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) {
}) })
} }
// handleInteractions liest den Abstimmungsstand eines Beitrags und schaltet bei // voteTally liefert die Zähler und die eigene Auswahl (selected) für einen Beitrag.
// mode=left/right die Stimme des angemeldeten Nutzers um: // Ist uid == 0 (nicht eingeloggt), ist selected immer "none".
// - keine bisherige Stimme -> neue Stimme func voteTally(pid, uid int64) (left, right int, selected string) {
// - gleiche Stimme erneut -> Stimme zurückziehen (Toggle) selected = "none"
// - andere Stimme -> auf den neuen Modus wechseln
// mode=none liest nur (z. B. beim Laden des Feeds).
func handleInteractions(w http.ResponseWriter, r *http.Request) {
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige pid")
return
}
mode := chi.URLParam(r, "mode")
var uid int64
if s, ok := getSession(r); ok {
uid = s.UID
}
if mode == "left" || mode == "right" {
if uid == 0 {
writeError(w, http.StatusUnauthorized, "Melde dich an, um abzustimmen!")
return
}
var prev string
err := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&prev)
switch {
case err == sql.ErrNoRows:
db.Exec(`INSERT INTO vote (uid, pid, mode) VALUES (?, ?, ?)`, uid, pid, mode)
case err == nil && prev == mode:
db.Exec(`DELETE FROM vote WHERE uid = ? AND pid = ?`, uid, pid)
case err == nil:
db.Exec(`UPDATE vote SET mode = ? WHERE uid = ? AND pid = ?`, mode, uid, pid)
default:
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
return
}
}
selected := "none"
if uid != 0 { if uid != 0 {
var cur string var cur string
if err := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&cur); err == nil { if err := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&cur); err == nil {
selected = cur selected = cur
} }
} }
var left, right int
db.QueryRow(`SELECT COUNT(*) FROM vote WHERE pid = ? AND mode = 'left'`, pid).Scan(&left) db.QueryRow(`SELECT COUNT(*) FROM vote WHERE pid = ? AND mode = 'left'`, pid).Scan(&left)
db.QueryRow(`SELECT COUNT(*) FROM vote WHERE pid = ? AND mode = 'right'`, pid).Scan(&right) db.QueryRow(`SELECT COUNT(*) FROM vote WHERE pid = ? AND mode = 'right'`, pid).Scan(&right)
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, "left": left,
@@ -166,6 +131,63 @@ func handleInteractions(w http.ResponseWriter, r *http.Request) {
}) })
} }
// handleVotes liest den Abstimmungsstand eines Beitrags (read-only, öffentlich).
func handleVotes(w http.ResponseWriter, r *http.Request) {
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige pid")
return
}
var uid int64
if s, ok := getSession(r); ok {
uid = s.UID
}
writeTally(w, pid, uid)
}
// handleVote gibt die Stimme des angemeldeten Nutzers ab oder schaltet sie um:
// - keine bisherige Stimme -> neue Stimme
// - gleiche Stimme erneut -> Stimme zurückziehen (Toggle)
// - andere Stimme -> auf den neuen Modus wechseln
func handleVote(w http.ResponseWriter, r *http.Request) {
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige pid")
return
}
uid := uidFromContext(r.Context())
mode := r.FormValue("mode")
if mode != "left" && mode != "right" {
writeError(w, http.StatusBadRequest, "Ungültiger Modus (left oder right)")
return
}
var prev string
readErr := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&prev)
if readErr != nil && readErr != sql.ErrNoRows {
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
return
}
var execErr error
switch {
case readErr == sql.ErrNoRows:
_, execErr = db.Exec(`INSERT INTO vote (uid, pid, mode) VALUES (?, ?, ?)`, uid, pid, mode)
case prev == mode:
_, execErr = db.Exec(`DELETE FROM vote WHERE uid = ? AND pid = ?`, uid, pid)
default:
_, execErr = db.Exec(`UPDATE vote SET mode = ? WHERE uid = ? AND pid = ?`, mode, uid, pid)
}
if execErr != nil {
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
return
}
writeTally(w, pid, uid)
}
// storeImage skaliert ein hochgeladenes Bild auf max. 1024px und speichert es unter static/media. // storeImage skaliert ein hochgeladenes Bild auf max. 1024px und speichert es unter static/media.
// Hinweis: animierte GIFs werden derzeit auf das erste Frame reduziert. // Hinweis: animierte GIFs werden derzeit auf das erste Frame reduziert.
func storeImage(fh *multipart.FileHeader) (string, error) { func storeImage(fh *multipart.FileHeader) (string, error) {
+2 -1
View File
@@ -28,7 +28,7 @@ func main() {
// --- Entries --- // --- Entries ---
r.Get("/entry/feed/{page}", handleFeed) r.Get("/entry/feed/{page}", handleFeed)
r.Get("/entry/{pid}/interactions/{mode}", handleInteractions) r.Get("/entry/{pid}/votes", handleVotes)
// --- User (öffentlich) --- // --- User (öffentlich) ---
r.Get("/u/{username}", handleUserPage) r.Get("/u/{username}", handleUserPage)
@@ -37,6 +37,7 @@ func main() {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(requireAuth) r.Use(requireAuth)
r.Post("/entry/create", handleCreateEntry) r.Post("/entry/create", handleCreateEntry)
r.Post("/entry/{pid}/vote", handleVote)
r.Get("/user/info", handleUserInfo) r.Get("/user/info", handleUserInfo)
r.Post("/user/delete", handleUserDelete) r.Post("/user/delete", handleUserDelete)
}) })
+23 -17
View File
@@ -135,30 +135,37 @@ Mindestens `content` oder `file` muss vorhanden sein.
--- ---
### `GET /entry/:pid/interactions/:mode` ### `GET /entry/:pid/votes`
Abstimmungsstand lesen und optional die eigene Stimme umschalten. Abstimmungsstand eines Beitrags lesen (read-only).
**Parameter:**
| Name | Typ | Beschreibung |
|------|-----|--------------|
| `pid` | int | Post-ID |
| `mode` | string | `none` (nur lesen), `left` oder `right` (abstimmen) |
**Voting-Logik** (nur bei `left`/`right`, Auth erforderlich):
- keine bisherige Stimme → neue Stimme
- gleiche Stimme erneut → Stimme zurückziehen (Toggle)
- andere Stimme → auf neuen Modus wechseln
**Response:** **Response:**
```json ```json
{ "pid": 123, "left": 4, "right": 2, "selected": "left" } { "pid": 123, "left": 4, "right": 2, "selected": "left" }
``` ```
`selected` ist `none`, wenn nicht eingeloggt oder keine Stimme abgegeben. `selected` ist `none`, wenn nicht eingeloggt oder keine Stimme abgegeben.
Bei `left`/`right` ohne Login → `401`.
> Hinweis: Mutation per GET ist aus dem ursprünglichen HTMX-Design übernommen. ---
> Sauberer wäre ein separater `POST` fürs Abstimmen — siehe offene Punkte.
### `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
--- ---
@@ -249,7 +256,6 @@ CREATE TABLE vote (
## Offene Punkte ## Offene Punkte
- Voting läuft per GET (HTMX-Erbe) — perspektivisch auf `POST` umstellen (CSRF/Idempotenz)
- Kein Rate-Limiting außer dem zufälligen `sleep` beim Login - Kein Rate-Limiting außer dem zufälligen `sleep` beim Login
- `session`-Token ist nur `randbelow(999999999) + timestamp` — sollte auf `crypto/rand` umgestellt werden - `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 - Fehler-Responses sind aktuell Plaintext/HTML — in der Go-API einheitlich JSON
+6 -3
View File
@@ -111,12 +111,15 @@ function setupVoting(el, pid) {
buttons.forEach((b) => buttons.forEach((b) =>
b.addEventListener("click", async () => { b.addEventListener("click", async () => {
render(await api.get(`/entry/${pid}/interactions/${b.dataset.mode}`)); const fd = new FormData();
fd.append("mode", b.dataset.mode);
const res = await api.post(`/entry/${pid}/vote`, fd);
render(res.data);
}) })
); );
// Anfangszustand laden, ohne zu werten. // Anfangszustand laden (read-only).
api.get(`/entry/${pid}/interactions/none`).then(render); api.get(`/entry/${pid}/votes`).then(render);
} }
async function loadMore() { async function loadMore() {