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:
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user