Files
Kontrollverlust/entry.go
T
irrlicht e210df3e2e Code-Cleanup: toter Check, none-Sentinel, Login-Jitter raus
- entry.go: redundantes stored != "" entfernt
- filepath-Sentinel "none" -> leerer String (Go-Zero-Value),
  Checks in entry.go/user.go und web/js/app.js vereinfacht
- auth.go: zufaelligen Login-Jitter samt loginJitter-Variable und
  Test-Seam entfernt (war nur Spielerei, kein echter Schutz)
- notes/migrations.md: einmalige "none"->"" Migration dokumentiert

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:33:41 +02:00

249 lines
6.1 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 := ""
if r.MultipartForm != nil {
if files := r.MultipartForm.File["file"]; len(files) > 0 {
if stored, err := storeImage(files[0]); err == nil {
filepath = stored
}
}
}
if filepath == "" && 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,
})
}
// 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
}
}
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,
"right": right,
"selected": selected,
})
}
// 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) {
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
}