be8dbb4902
GET /entry/{pid}/interactions/{mode} now reads the vote tally and, for
mode=left/right (auth required), toggles the user's vote: new vote, repeat
same mode removes it, different mode switches. Returns {left, right,
selected}. A UNIQUE(uid, pid) constraint on the vote table prevents
duplicate votes. The JS frontend renders Links/Rechts buttons with live
counts under each entry.
This completes the voting feature that was left commented-out and broken
in the Flask version.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
227 lines
5.5 KiB
Go
227 lines
5.5 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"image"
|
|
"image/gif"
|
|
"image/jpeg"
|
|
_ "image/png"
|
|
"math/rand/v2"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
xdraw "golang.org/x/image/draw"
|
|
)
|
|
|
|
type Entry struct {
|
|
PID int64 `json:"pid"`
|
|
UID int64 `json:"uid"`
|
|
CreatedAt int64 `json:"created_at"`
|
|
Content string `json:"content"`
|
|
Filepath string `json:"filepath"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
func handleFeed(w http.ResponseWriter, r *http.Request) {
|
|
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
|
|
if page < 0 {
|
|
page = 0
|
|
}
|
|
if page > 100 {
|
|
page = 100
|
|
}
|
|
|
|
const count = 20
|
|
offset := page * count
|
|
|
|
rows, err := db.Query(`
|
|
SELECT e.pid, e.uid, e.created_at, e.content, e.filepath, u.username
|
|
FROM entry e JOIN user u ON u.uid = e.uid
|
|
ORDER BY e.created_at DESC
|
|
LIMIT ? OFFSET ?`, count, offset)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
entries := []Entry{}
|
|
for rows.Next() {
|
|
var e Entry
|
|
if err := rows.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath, &e.Username); err != nil {
|
|
continue
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
writeJSON(w, http.StatusOK, entries)
|
|
}
|
|
|
|
func handleCreateEntry(w http.ResponseWriter, r *http.Request) {
|
|
uid := uidFromContext(r.Context())
|
|
|
|
if err := r.ParseMultipartForm(16 << 20); err != nil && err != http.ErrNotMultipart {
|
|
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
|
return
|
|
}
|
|
|
|
content := strings.TrimSpace(r.FormValue("content"))
|
|
if len(content) > 1000 {
|
|
content = content[:1000]
|
|
}
|
|
|
|
filepath := "none"
|
|
if r.MultipartForm != nil {
|
|
if files := r.MultipartForm.File["file"]; len(files) > 0 {
|
|
if stored, err := storeImage(files[0]); err == nil && stored != "" {
|
|
filepath = stored
|
|
}
|
|
}
|
|
}
|
|
|
|
if filepath == "none" && content == "" {
|
|
writeError(w, http.StatusBadRequest, "Leerer Beitrag!")
|
|
return
|
|
}
|
|
|
|
pid := int64(rand.IntN(999999999999))
|
|
now := time.Now().Unix()
|
|
if _, err := db.Exec(
|
|
`INSERT INTO entry (pid, uid, created_at, content, filepath) VALUES (?, ?, ?, ?, ?)`,
|
|
pid, uid, now, content, filepath,
|
|
); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"pid": pid,
|
|
"content": content,
|
|
"filepath": filepath,
|
|
})
|
|
}
|
|
|
|
// 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"
|
|
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)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"pid": pid,
|
|
"left": left,
|
|
"right": right,
|
|
"selected": selected,
|
|
})
|
|
}
|
|
|
|
// 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) {
|
|
const maxSize = 1024
|
|
|
|
src, err := fh.Open()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer src.Close()
|
|
|
|
img, format, err := image.Decode(src)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
b := img.Bounds()
|
|
width, height := b.Dx(), b.Dy()
|
|
|
|
nw, nh := width, height
|
|
if width > maxSize || height > maxSize {
|
|
if width > height {
|
|
nw, nh = maxSize, height*maxSize/width
|
|
} else {
|
|
nw, nh = width*maxSize/height, maxSize
|
|
}
|
|
}
|
|
|
|
ext := "jpg"
|
|
if format == "gif" {
|
|
ext = "gif"
|
|
}
|
|
|
|
if err := os.MkdirAll("static/media", 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
name := fmt.Sprintf("static/media/%d-%d.%s", time.Now().Unix(), rand.IntN(999999), ext)
|
|
|
|
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
|
xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, b, xdraw.Over, nil)
|
|
|
|
out, err := os.Create(name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer out.Close()
|
|
|
|
if ext == "gif" {
|
|
err = gif.Encode(out, dst, nil)
|
|
} else {
|
|
err = jpeg.Encode(out, dst, &jpeg.Options{Quality: 85})
|
|
}
|
|
if err != nil {
|
|
os.Remove(name)
|
|
return "", err
|
|
}
|
|
return name, nil
|
|
}
|