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 }