From 01be0c523e2d93ce934b6939da5047f4ac347655 Mon Sep 17 00:00:00 2001 From: irrlicht Date: Mon, 22 Jun 2026 11:40:27 +0200 Subject: [PATCH] Repost --- db.go | 29 +++++++++++++++++--- endpoints_test.go | 48 +++++++++++++++++++++++++++++++++ entry.go | 69 +++++++++++++++++++++++++++++++++++++++++++++-- main.go | 1 + web/css/app.css | 11 ++++++++ web/js/feed.js | 44 ++++++++++++++++++++++++++++-- 6 files changed, 195 insertions(+), 7 deletions(-) diff --git a/db.go b/db.go index 5b82f3d..c5d753a 100644 --- a/db.go +++ b/db.go @@ -2,6 +2,7 @@ package main import ( "database/sql" + "strings" _ "modernc.org/sqlite" ) @@ -38,7 +39,9 @@ CREATE TABLE IF NOT EXISTS entry ( reply_to INTEGER NOT NULL DEFAULT 0, reply_count INTEGER NOT NULL DEFAULT 0, last_activity INTEGER NOT NULL DEFAULT 0, - deleted INTEGER NOT NULL DEFAULT 0 + deleted INTEGER NOT NULL DEFAULT 0, + bump_count INTEGER NOT NULL DEFAULT 0, + last_bump INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS vote ( @@ -86,6 +89,26 @@ func initDB(dsn string) error { if err = db.Ping(); err != nil { return err } - _, err = db.Exec(schema) - return err + if _, err = db.Exec(schema); err != nil { + return err + } + return migrate() +} + +// migrate ergänzt Spalten, die nach dem ersten Deploy dazukamen. CREATE TABLE IF +// NOT EXISTS fasst eine bestehende Tabelle nicht an -> neue Spalten brauchen ein +// ALTER. SQLite kann ADD COLUMN nicht "IF NOT EXISTS", deshalb schlucken wir den +// "duplicate column"-Fehler: bei einer frischen DB sind die Spalten schon aus +// dem Schema da, bei einer alten werden sie hier nachgezogen. +func migrate() error { + alters := []string{ + `ALTER TABLE entry ADD COLUMN bump_count INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE entry ADD COLUMN last_bump INTEGER NOT NULL DEFAULT 0`, + } + for _, stmt := range alters { + if _, err := db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { + return err + } + } + return nil } diff --git a/endpoints_test.go b/endpoints_test.go index ef20e08..949ccc9 100644 --- a/endpoints_test.go +++ b/endpoints_test.go @@ -221,6 +221,54 @@ func TestVoteRequiresAuthAndValidMode(t *testing.T) { } } +func TestBump(t *testing.T) { + srv := newTestServer(t) + alice := registerAndLogin(t, srv, "alice") + bob := registerAndLogin(t, srv, "bob") + pid := createEntry(t, alice, srv, "bump me") + bumpURL := fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid) + + // Anonym: 401. + resp := postForm(t, newClient(t), bumpURL, url.Values{}) + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("anon bump: erwartet 401, bekam %d", resp.StatusCode) + } + + // Erster Bump (bump_count 0 -> kein Cooldown): 200, danach bump_count 1. + resp = postForm(t, alice, bumpURL, url.Values{}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("erster bump: erwartet 200, bekam %d", resp.StatusCode) + } + var out struct { + BumpCount int64 `json:"bump_count"` + RetryAfter int64 `json:"retry_after"` + } + json.NewDecoder(resp.Body).Decode(&out) + if out.BumpCount != 1 || out.RetryAfter != 86400 { + t.Fatalf("nach erstem bump: %+v", out) + } + + // Cooldown gilt pro Beitrag (global): auch ein anderer Nutzer wird sofort + // abgewiesen. + resp = postForm(t, bob, bumpURL, url.Values{}) + resp.Body.Close() + if resp.StatusCode != http.StatusTooManyRequests { + t.Fatalf("bump im cooldown: erwartet 429, bekam %d", resp.StatusCode) + } + + // Gelöschter Beitrag lässt sich nicht bumpen. + del := createEntry(t, alice, srv, "weg gleich") + resp = postForm(t, alice, fmt.Sprintf("%s/entry/%d/delete", srv.URL, del), url.Values{}) + resp.Body.Close() + resp = postForm(t, alice, fmt.Sprintf("%s/entry/%d/bump", srv.URL, del), url.Values{}) + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("bump gelöscht: erwartet 404, bekam %d", resp.StatusCode) + } +} + // createReply erstellt eine Antwort auf parentPID und liefert deren pid. func createReply(t *testing.T, c *http.Client, srv *httptest.Server, content string, parentPID int64) int64 { t.Helper() diff --git a/entry.go b/entry.go index c3c6538..2267d6b 100644 --- a/entry.go +++ b/entry.go @@ -30,6 +30,8 @@ type Entry struct { ReplyCount int64 `json:"reply_count"` LastActivity int64 `json:"last_activity"` Deleted int64 `json:"deleted"` + BumpCount int64 `json:"bump_count"` + LastBump int64 `json:"last_bump"` Username string `json:"username"` Avatar string `json:"avatar"` } @@ -39,6 +41,7 @@ type Entry struct { const entrySelect = ` SELECT e.pid, e.uid, e.created_at, e.content, e.filepath, e.reply_to, e.reply_count, e.last_activity, e.deleted, + e.bump_count, e.last_bump, COALESCE(u.username, ''), COALESCE(u.avatar, '') FROM entry e LEFT JOIN user u ON u.uid = e.uid` @@ -49,7 +52,8 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) { for rows.Next() { var e Entry if err := rows.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath, - &e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted, &e.Username, &e.Avatar); err != nil { + &e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted, + &e.BumpCount, &e.LastBump, &e.Username, &e.Avatar); err != nil { return nil, err } entries = append(entries, e) @@ -62,7 +66,8 @@ func entryByPID(pid int64) (Entry, error) { var e Entry err := db.QueryRow(entrySelect+` WHERE e.pid = ?`, pid).Scan( &e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath, - &e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted, &e.Username, &e.Avatar) + &e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted, + &e.BumpCount, &e.LastBump, &e.Username, &e.Avatar) return e, err } @@ -341,6 +346,66 @@ func handleDeleteEntry(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } +// bumpStep ist der Cooldown-Zuwachs je bereits erfolgtem Bump: nach dem 1. Bump +// ein Tag Sperre, nach dem 10. Bump zehn Tage. So wird Hochspülen mit jedem Mal +// teurer. +const bumpStep = 24 * 60 * 60 + +// handleBump hebt last_activity eines Beitrags auf jetzt -> er steigt im Feed +// (Roots, sortiert nach last_activity) bzw. in der Antwortliste eines Threads +// wieder nach oben. Tritt an die Stelle der früheren "Bump"-Kommentare. Gegen +// Spam wächst der Cooldown linear mit der Zahl bisheriger Bumps (siehe bumpStep); +// er sitzt am Beitrag selbst (global), nicht am bumpenden Nutzer. Jeder +// Eingeloggte darf bumpen (Route in der requireAuth-Gruppe). +func handleBump(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 bumpCount, lastBump, deleted int64 + if err := db.QueryRow(`SELECT bump_count, last_bump, deleted FROM entry WHERE pid = ?`, pid). + Scan(&bumpCount, &lastBump, &deleted); err != nil { + writeError(w, http.StatusNotFound, "Beitrag nicht gefunden") + return + } + if deleted != 0 { + writeError(w, http.StatusNotFound, "Beitrag nicht gefunden") + return + } + + now := time.Now().Unix() + // Cooldown = bisherige Bumps in Tagen. Beim ersten Bump (bumpCount 0) ist die + // Sperre 0 -> sofort erlaubt; danach wächst sie mit jedem Bump. + if remaining := lastBump + bumpCount*bumpStep - now; remaining > 0 { + w.Header().Set("Retry-After", strconv.FormatInt(remaining, 10)) + writeJSON(w, http.StatusTooManyRequests, map[string]any{ + "error": "Dieser Beitrag wurde erst kürzlich repostet.", + "retry_after": remaining, + "bump_count": bumpCount, + }) + return + } + + if _, err := db.Exec( + `UPDATE entry SET last_activity = ?, bump_count = bump_count + 1, last_bump = ? WHERE pid = ?`, + now, now, pid, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Bumpen fehlgeschlagen") + return + } + + bumpCount++ + writeJSON(w, http.StatusOK, map[string]any{ + "pid": pid, + "bump_count": bumpCount, + "last_bump": now, + "last_activity": now, + "retry_after": bumpCount * bumpStep, // bis zum nächsten erlaubten Bump + }) +} + // 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) { diff --git a/main.go b/main.go index af38b8b..bf67587 100644 --- a/main.go +++ b/main.go @@ -115,6 +115,7 @@ func routes() http.Handler { r.Use(requireAuth) r.Post("/entry/create", handleCreateEntry) r.Post("/entry/{pid}/vote", handleVote) + r.Post("/entry/{pid}/bump", handleBump) r.Post("/entry/{pid}/edit", handleEditEntry) r.Post("/entry/{pid}/delete", handleDeleteEntry) r.Get("/user/info", handleUserInfo) diff --git a/web/css/app.css b/web/css/app.css index d31e33e..4898eab 100644 --- a/web/css/app.css +++ b/web/css/app.css @@ -248,10 +248,21 @@ img.avatar-lg { width: 72px; height: 72px; } oben in der Byline). */ .interactions { display: flex; + align-items: center; justify-content: center; + gap: 1rem; margin-top: 0.25rem; } +/* Bump-Button: tritt an die Stelle der früheren Bump-Kommentare. Im Cooldown + gesperrt (gilt pro Beitrag für alle), der Button zeigt dann die Restzeit. */ +.bump:disabled { + color: gray; + border-color: #ccc; + cursor: default; + text-decoration: none; +} + .row { display: flex; align-items: center; diff --git a/web/js/feed.js b/web/js/feed.js index decfc02..8e8ec1d 100644 --- a/web/js/feed.js +++ b/web/js/feed.js @@ -47,11 +47,13 @@ function renderEntry(e) { const when = new Date(e.created_at * 1000).toLocaleString("de-DE"); const replyLabel = e.reply_count === 1 ? "1 Antwort" : `${e.reply_count} Antworten`; + const repostLabel = + e.bump_count === 1 ? "1 Repost" : `${e.bump_count} Reposts`; // Soft-gelöschter Beitrag: nur Platzhalter, damit der Thread navigierbar bleibt. if (e.deleted) { el.innerHTML = ` -
[deleted] · ${when} · ${replyLabel}
+
[deleted] · ${when} · ${replyLabel} · ${repostLabel}
@@ -74,7 +76,7 @@ function renderEntry(e) { el.innerHTML = `
@@ -87,11 +89,13 @@ function renderEntry(e) { – / –
+
`; setupVoting(el, e.pid); + setupBump(el, e); el.addEventListener("click", (ev) => { if (!ev.target.closest("a, button")) location.href = `/e/${e.pid}`; }); @@ -126,6 +130,42 @@ function setupVoting(el, pid) { api.get(`/entry/${pid}/votes`).then(render); } +// setupBump verdrahtet den Bump-Button. Der Cooldown wächst pro Beitrag linear +// (Backend, siehe handleBump); hier wird nur angezeigt und auf 429 reagiert. Eine +// Live-Aktualisierung gibt es bewusst nicht: der Zustand stammt aus den +// Beitragsdaten beim Laden, plus die Antwort des eigenen Klicks. +function setupBump(el, e) { + const btn = el.querySelector(".bump"); + if (!btn) return; + + // sekunden bis zum nächsten erlaubten Bump aus den geladenen Daten. + const nowSec = () => Math.floor(Date.now() / 1000); + + // lock sperrt den Button und zeigt die grobe Restzeit (auf Tage aufgerundet, + // mindestens 1 -> nie "0 Tage", solange noch Restzeit übrig ist). + function lock(retryAfter) { + btn.disabled = true; + const days = Math.max(1, Math.ceil(retryAfter / 86400)); + btn.textContent = `Repost (${days} ${days === 1 ? "Tag" : "Tage"})`; + } + + const remaining = e.last_bump + e.bump_count * 86400 - nowSec(); + if (e.bump_count > 0 && remaining > 0) lock(remaining); + + btn.addEventListener("click", async () => { + const res = await api.post(`/entry/${e.pid}/bump`, new FormData()); + if (res.ok) { + btn.disabled = true; + btn.textContent = "repostet"; + } else if (res.status === 429) { + lock(res.data.retry_after || 86400); + } else { + // 401 (nicht angemeldet) o. Ä. -> Fehlertext zeigen, Button frei lassen. + btn.textContent = res.data.error || "Fehler"; + } + }); +} + // createFeed hängt Infinite-Scroll an feedEl/sentinelEl und lädt Seiten über // urlFor(page). Gibt { reset } zurück, um den Feed neu zu laden. function createFeed(feedEl, sentinelEl, urlFor) {