diff --git a/entry.go b/entry.go index 390d7bc..393f075 100644 --- a/entry.go +++ b/entry.go @@ -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) { diff --git a/main.go b/main.go index bd10518..202ecfe 100644 --- a/main.go +++ b/main.go @@ -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) }) diff --git a/notes/api.md b/notes/api.md index 3e14524..38175fd 100644 --- a/notes/api.md +++ b/notes/api.md @@ -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 diff --git a/web/js/app.js b/web/js/app.js index e03736a..4d6acc6 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -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() {