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
// mode=left/right die Stimme des angemeldeten Nutzers um:
// - keine bisherige Stimme -> neue Stimme
// - gleiche Stimme erneut -> Stimme zurückziehen (Toggle)
// - 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"
// voteTally liefert die Zähler und die eigene Auswahl (selected) für einen Beitrag.
// Ist uid == 0 (nicht eingeloggt), ist selected immer "none".
func voteTally(pid, uid int64) (left, right int, selected string) {
selected = "none"
if uid != 0 {
var cur string
if err := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&cur); err == nil {
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 = '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{
"pid": pid,
"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.
// Hinweis: animierte GIFs werden derzeit auf das erste Frame reduziert.
func storeImage(fh *multipart.FileHeader) (string, error) {
+2 -1
View File
@@ -28,7 +28,7 @@ func main() {
// --- Entries ---
r.Get("/entry/feed/{page}", handleFeed)
r.Get("/entry/{pid}/interactions/{mode}", handleInteractions)
r.Get("/entry/{pid}/votes", handleVotes)
// --- User (öffentlich) ---
r.Get("/u/{username}", handleUserPage)
@@ -37,6 +37,7 @@ func main() {
r.Group(func(r chi.Router) {
r.Use(requireAuth)
r.Post("/entry/create", handleCreateEntry)
r.Post("/entry/{pid}/vote", handleVote)
r.Get("/user/info", handleUserInfo)
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.
**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
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.
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
- Voting läuft per GET (HTMX-Erbe) — perspektivisch auf `POST` umstellen (CSRF/Idempotenz)
- 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
+6 -3
View File
@@ -111,12 +111,15 @@ function setupVoting(el, pid) {
buttons.forEach((b) =>
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.
api.get(`/entry/${pid}/interactions/none`).then(render);
// Anfangszustand laden (read-only).
api.get(`/entry/${pid}/votes`).then(render);
}
async function loadMore() {