Repost
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user