This commit is contained in:
2026-06-22 11:40:27 +02:00
parent e965775809
commit 01be0c523e
6 changed files with 195 additions and 7 deletions
+25 -2
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"database/sql" "database/sql"
"strings"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -38,7 +39,9 @@ CREATE TABLE IF NOT EXISTS entry (
reply_to INTEGER NOT NULL DEFAULT 0, reply_to INTEGER NOT NULL DEFAULT 0,
reply_count INTEGER NOT NULL DEFAULT 0, reply_count INTEGER NOT NULL DEFAULT 0,
last_activity 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 ( CREATE TABLE IF NOT EXISTS vote (
@@ -86,6 +89,26 @@ func initDB(dsn string) error {
if err = db.Ping(); err != nil { if err = db.Ping(); err != nil {
return err return err
} }
_, err = db.Exec(schema) if _, err = db.Exec(schema); err != nil {
return err 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
}
+48
View File
@@ -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. // 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 { func createReply(t *testing.T, c *http.Client, srv *httptest.Server, content string, parentPID int64) int64 {
t.Helper() t.Helper()
+67 -2
View File
@@ -30,6 +30,8 @@ type Entry struct {
ReplyCount int64 `json:"reply_count"` ReplyCount int64 `json:"reply_count"`
LastActivity int64 `json:"last_activity"` LastActivity int64 `json:"last_activity"`
Deleted int64 `json:"deleted"` Deleted int64 `json:"deleted"`
BumpCount int64 `json:"bump_count"`
LastBump int64 `json:"last_bump"`
Username string `json:"username"` Username string `json:"username"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
} }
@@ -39,6 +41,7 @@ type Entry struct {
const entrySelect = ` const entrySelect = `
SELECT e.pid, e.uid, e.created_at, e.content, e.filepath, SELECT e.pid, e.uid, e.created_at, e.content, e.filepath,
e.reply_to, e.reply_count, e.last_activity, e.deleted, e.reply_to, e.reply_count, e.last_activity, e.deleted,
e.bump_count, e.last_bump,
COALESCE(u.username, ''), COALESCE(u.avatar, '') COALESCE(u.username, ''), COALESCE(u.avatar, '')
FROM entry e LEFT JOIN user u ON u.uid = e.uid` 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() { for rows.Next() {
var e Entry var e Entry
if err := rows.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath, 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 return nil, err
} }
entries = append(entries, e) entries = append(entries, e)
@@ -62,7 +66,8 @@ func entryByPID(pid int64) (Entry, error) {
var e Entry var e Entry
err := db.QueryRow(entrySelect+` WHERE e.pid = ?`, pid).Scan( err := db.QueryRow(entrySelect+` WHERE e.pid = ?`, pid).Scan(
&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath, &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 return e, err
} }
@@ -341,6 +346,66 @@ func handleDeleteEntry(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) 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. // voteTally liefert die Zähler und die eigene Auswahl (selected) für einen Beitrag.
// Ist uid == 0 (nicht eingeloggt), ist selected immer "none". // Ist uid == 0 (nicht eingeloggt), ist selected immer "none".
func voteTally(pid, uid int64) (left, right int, selected string) { func voteTally(pid, uid int64) (left, right int, selected string) {
+1
View File
@@ -115,6 +115,7 @@ func routes() http.Handler {
r.Use(requireAuth) r.Use(requireAuth)
r.Post("/entry/create", handleCreateEntry) r.Post("/entry/create", handleCreateEntry)
r.Post("/entry/{pid}/vote", handleVote) r.Post("/entry/{pid}/vote", handleVote)
r.Post("/entry/{pid}/bump", handleBump)
r.Post("/entry/{pid}/edit", handleEditEntry) r.Post("/entry/{pid}/edit", handleEditEntry)
r.Post("/entry/{pid}/delete", handleDeleteEntry) r.Post("/entry/{pid}/delete", handleDeleteEntry)
r.Get("/user/info", handleUserInfo) r.Get("/user/info", handleUserInfo)
+11
View File
@@ -248,10 +248,21 @@ img.avatar-lg { width: 72px; height: 72px; }
oben in der Byline). */ oben in der Byline). */
.interactions { .interactions {
display: flex; display: flex;
align-items: center;
justify-content: center; justify-content: center;
gap: 1rem;
margin-top: 0.25rem; 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 { .row {
display: flex; display: flex;
align-items: center; align-items: center;
+42 -2
View File
@@ -47,11 +47,13 @@ function renderEntry(e) {
const when = new Date(e.created_at * 1000).toLocaleString("de-DE"); const when = new Date(e.created_at * 1000).toLocaleString("de-DE");
const replyLabel = const replyLabel =
e.reply_count === 1 ? "1 Antwort" : `${e.reply_count} Antworten`; 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. // Soft-gelöschter Beitrag: nur Platzhalter, damit der Thread navigierbar bleibt.
if (e.deleted) { if (e.deleted) {
el.innerHTML = ` el.innerHTML = `
<div class="muted byline"><span>[deleted] · ${when} · <a href="/e/${e.pid}">${replyLabel}</a></span></div> <div class="muted byline"><span>[deleted] · ${when} · <a href="/e/${e.pid}">${replyLabel}</a> · ${repostLabel}</span></div>
<div class="threadline-wrapper"> <div class="threadline-wrapper">
<div class="threadline"></div> <div class="threadline"></div>
<div class="body"> <div class="body">
@@ -74,7 +76,7 @@ function renderEntry(e) {
el.innerHTML = ` el.innerHTML = `
<div class="muted byline"> <div class="muted byline">
<a href="/u/${user}"><img class="avatar avatar-sm" src="${avatarUrl(e.avatar)}" alt="" loading="lazy"></a> <a href="/u/${user}"><img class="avatar avatar-sm" src="${avatarUrl(e.avatar)}" alt="" loading="lazy"></a>
<span><a href="/u/${user}">${escapeHtml(e.username)}</a> · ${when} · <a href="/e/${e.pid}">${replyLabel}</a></span> <span><a href="/u/${user}">${escapeHtml(e.username)}</a> · ${when} · <a href="/e/${e.pid}">${replyLabel}</a> · ${repostLabel}</span>
</div> </div>
<div class="threadline-wrapper"> <div class="threadline-wrapper">
<div class="threadline"></div> <div class="threadline"></div>
@@ -87,11 +89,13 @@ function renderEntry(e) {
<span class="muted"> / </span> <span class="muted"> / </span>
<button class="ghost" data-mode="right">Rechts</button> <button class="ghost" data-mode="right">Rechts</button>
</div> </div>
<button class="bump" type="button">Repost</button>
</div> </div>
</div> </div>
</div> </div>
`; `;
setupVoting(el, e.pid); setupVoting(el, e.pid);
setupBump(el, e);
el.addEventListener("click", (ev) => { el.addEventListener("click", (ev) => {
if (!ev.target.closest("a, button")) location.href = `/e/${e.pid}`; 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); 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 // 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. // urlFor(page). Gibt { reset } zurück, um den Feed neu zu laden.
function createFeed(feedEl, sentinelEl, urlFor) { function createFeed(feedEl, sentinelEl, urlFor) {