diff --git a/endpoints_test.go b/endpoints_test.go index 95bb8d0..877bcf0 100644 --- a/endpoints_test.go +++ b/endpoints_test.go @@ -288,6 +288,85 @@ func TestBump(t *testing.T) { } } +// TestRepostOhneJavaScript deckt den Repost-Weg ohne JS ab: der Knopf muss im +// Feed stehen, der Klick per 303 an den Beitrag zurückführen, danach gesperrt +// sein -- und ein erneuter Klick im Cooldown eine HTML-Seite liefern, nicht die +// JSON-Zeile, die ein Browser roh anzeigen würde. +func TestRepostOhneJavaScript(t *testing.T) { + srv := newTestServer(t) + alice := registerAndLogin(t, srv, "alice") + pid := createEntry(t, alice, srv, "hebt euch") + + // Angemeldet: Repost-Formular mit Rücksprungziel, Knopf noch frei. + _, page := getHTML(t, alice, srv.URL+"/feed") + if !strings.Contains(page, fmt.Sprintf(`action="/entry/%d/bump"`, pid)) { + t.Fatalf("kein repost-formular im feed:\n%s", excerpt(page, "interactions")) + } + if !strings.Contains(page, "Repost (0)") { + t.Errorf("repost-zähler falsch:\n%s", excerpt(page, "bump")) + } + if strings.Contains(page, `class="bump" type="submit" disabled`) { + t.Errorf("repost-knopf ohne bisherigen repost gesperrt:\n%s", excerpt(page, "bump")) + } + + // Reposten -> 303 zurück an den Beitrag. + resp := postFormHTML(t, alice, fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid), + url.Values{"return_to": {fmt.Sprintf("/feed#e%d", pid)}}) + resp.Body.Close() + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("repost: erwartet 303, bekam %d", resp.StatusCode) + } + if got, want := resp.Header.Get("Location"), fmt.Sprintf("/feed#e%d", pid); got != want { + t.Fatalf("repost: Location = %q, erwartet %q", got, want) + } + + // Jetzt im Cooldown: Zähler hoch, Knopf gesperrt, Wartezeit sichtbar. + _, page = getHTML(t, alice, srv.URL+"/feed") + if !strings.Contains(page, "Repost (1)") { + t.Errorf("repost-zähler nicht erhöht:\n%s", excerpt(page, "bump")) + } + if !strings.Contains(page, `class="bump" type="submit" disabled`) { + t.Errorf("repost-knopf im cooldown nicht gesperrt:\n%s", excerpt(page, "bump")) + } + + // Erneuter Repost im Cooldown -> HTML-Fehlerseite, nicht rohes JSON. + resp = postFormHTML(t, alice, fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid), url.Values{}) + cooldown := body(t, resp) + if resp.StatusCode != http.StatusTooManyRequests { + t.Fatalf("repost im cooldown: erwartet 429, bekam %d", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Fatalf("repost im cooldown: erwartet HTML, bekam %q", ct) + } + if !strings.Contains(cooldown, "erst kürzlich repostet") { + t.Errorf("keine verständliche meldung:\n%s", cooldown) + } + + // Der JSON-Client bekommt weiterhin JSON mit Nutzdaten. + resp = postForm(t, alice, fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid), url.Values{}) + jsonBody := body(t, resp) + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Fatalf("API-Client: erwartet JSON, bekam %q", ct) + } + if !strings.Contains(jsonBody, `"retry_after"`) { + t.Errorf("JSON-Antwort ohne retry_after:\n%s", jsonBody) + } + + // Nicht angemeldet: kein Formular im Feed, POST -> 401 als HTML. + _, anonPage := getHTML(t, newClient(t), srv.URL+"/feed") + if strings.Contains(anonPage, fmt.Sprintf(`action="/entry/%d/bump"`, pid)) { + t.Error("repost-formular für nicht angemeldete besucher") + } + resp = postFormHTML(t, newClient(t), fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid), url.Values{}) + page = body(t, resp) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("anon repost: erwartet 401, bekam %d", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Fatalf("anon repost: erwartet HTML, bekam %q", ct) + } +} + // 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() @@ -1212,6 +1291,11 @@ func TestBeitragOhneJavaScript(t *testing.T) { if !strings.Contains(page, `type="file"`) { t.Errorf("kein dateifeld:\n%s", excerpt(page, "compose")) } + // Das Formular trägt den Rücksprungort mit, damit ein abgelehnter Post ihn + // nicht verliert und die neu gerenderten Knöpfe auf "/feed" zeigen statt "/". + if !strings.Contains(page, `name="return_to" value="/feed"`) { + t.Errorf("beitragsformular ohne return_to:\n%s", excerpt(page, "compose")) + } // Nicht angemeldet: kein Formular. _, anonPage := getHTML(t, newClient(t), srv.URL+"/feed") @@ -1252,7 +1336,9 @@ func TestBeitragOhneJavaScript(t *testing.T) { } // Leerer Beitrag -> Feed mit Meldung und erhaltenem Text, kein Redirect. - resp = postFormHTML(t, alice, srv.URL+"/entry/create", url.Values{"content": {" "}}) + // return_to wie vom Formular mitgeschickt. + resp = postFormHTML(t, alice, srv.URL+"/entry/create", + url.Values{"content": {" "}, "return_to": {"/feed"}, "page": {"0"}}) failPage := body(t, resp) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("leerer beitrag: erwartet 400, bekam %d", resp.StatusCode) @@ -1264,6 +1350,14 @@ func TestBeitragOhneJavaScript(t *testing.T) { if !strings.Contains(failPage, "mein erster ssr-beitrag") { t.Errorf("feed nach fehler nicht mitgerendert:\n%s", excerpt(failPage, "feed")) } + // Die neu gerenderten Interaktions-Knöpfe zeigen zurück in den Feed, nicht + // auf "/" (die noch per JS bediente Startseite). + if strings.Contains(failPage, `name="return_to" value="/#e`) { + t.Errorf("interaktions-knöpfe zeigen nach dem fehler auf \"/\":\n%s", excerpt(failPage, "vote")) + } + if !strings.Contains(failPage, `name="return_to" value="/feed#e`) { + t.Errorf("interaktions-knöpfe ohne feed-rücksprung:\n%s", excerpt(failPage, "vote")) + } // Getippter Text überlebt einen abgelehnten Upload. resp = postMultipartHTML(t, alice, srv.URL+"/entry/create", @@ -1367,6 +1461,10 @@ func TestThreadOhneJavaScript(t *testing.T) { if !strings.Contains(page, fmt.Sprintf(`name="reply_to" value="%d"`, mitte)) { t.Fatalf("antwortformular ohne reply_to:\n%s", excerpt(page, "compose")) } + // Das Antwortformular trägt den Rücksprung in den Thread mit. + if !strings.Contains(page, fmt.Sprintf(`name="return_to" value="/beitrag/%d"`, mitte)) { + t.Errorf("antwortformular ohne return_to:\n%s", excerpt(page, "compose")) + } // Antworten ohne JS -> 303 in den Thread des Elternbeitrags. resp := postFormHTML(t, alice, srv.URL+"/entry/create", @@ -1390,9 +1488,11 @@ func TestThreadOhneJavaScript(t *testing.T) { t.Errorf("anker %q aus dem redirect existiert nicht in der seite", anchor) } - // Fehlerfall: leere Antwort -> Thread neu, nicht der Feed. + // Fehlerfall: leere Antwort -> Thread neu, nicht der Feed. return_to wie + // vom Formular mitgeschickt. resp = postFormHTML(t, alice, srv.URL+"/entry/create", - url.Values{"content": {" "}, "reply_to": {fmt.Sprint(mitte)}}) + url.Values{"content": {" "}, "reply_to": {fmt.Sprint(mitte)}, + "return_to": {fmt.Sprintf("/beitrag/%d", mitte)}}) failPage := body(t, resp) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("leere antwort: erwartet 400, bekam %d", resp.StatusCode) @@ -1403,6 +1503,14 @@ func TestThreadOhneJavaScript(t *testing.T) { if !strings.Contains(failPage, "mittlere antwort") { t.Errorf("thread nach fehler nicht mitgerendert:\n%s", excerpt(failPage, "focus")) } + // Die neu gerenderten Interaktions-Knöpfe zeigen zurück in den Thread, + // nicht auf "/". + if strings.Contains(failPage, `name="return_to" value="/#e`) { + t.Errorf("interaktions-knöpfe zeigen nach dem fehler auf \"/\":\n%s", excerpt(failPage, "vote")) + } + if !strings.Contains(failPage, fmt.Sprintf(`name="return_to" value="/beitrag/%d#e`, mitte)) { + t.Errorf("interaktions-knöpfe ohne thread-rücksprung:\n%s", excerpt(failPage, "vote")) + } // Unbekannter Beitrag -> 404, keine leere Seite. if status, _ := getHTML(t, anon, srv.URL+"/beitrag/999999"); status != http.StatusNotFound { diff --git a/entry.go b/entry.go index 28fde69..e44be30 100644 --- a/entry.go +++ b/entry.go @@ -496,11 +496,20 @@ func handleBump(w http.ResponseWriter, r *http.Request) error { if remaining < 0 { remaining = 0 } - // Bewusst kein Taxonomie-Fehler: die Antwort trägt Nutzdaten - // (retry_after, bump_count), die das Frontend zum Sperren des Buttons - // braucht -- ein reiner Fehlerkörper kann die nicht transportieren. - // Der Cooldown ist auch kein Fehlerfall, sondern ein regulärer - // Ausgang des Bumpens. + // Ohne JavaScript: der Cooldown ist eine eigene Fehlerseite mit der + // Restzeit -- ein stiller Rücksprung wäre von "hat geklappt" nicht zu + // unterscheiden. + if !wantsJSON(r) { + hours := (remaining + 3599) / 3600 + if hours < 1 { + hours = 1 + } + return RateLimited("entry.bump_cooldown", fmt.Sprintf( + "Dieser Beitrag wurde erst kürzlich repostet. Nächster Repost in etwa %d Stunde(n).", hours)) + } + // JSON-Zweig: bewusst kein Taxonomie-Fehler -- die Antwort trägt + // Nutzdaten (retry_after, bump_count), die das Frontend zum Sperren des + // Buttons braucht, und der Cooldown ist ein regulärer Ausgang. w.Header().Set("Retry-After", strconv.FormatInt(remaining, 10)) writeJSON(w, http.StatusTooManyRequests, map[string]any{ "error": apiErrorBody{ @@ -512,6 +521,13 @@ func handleBump(w http.ResponseWriter, r *http.Request) error { }) return nil } + + // Ohne JavaScript zurück an den Beitrag, auf dem repostet wurde -- der + // Anker in return_to hält die Position. + if !wantsJSON(r) { + redirectBack(w, r, threadAnchor(pid)) + return nil + } writeJSON(w, http.StatusOK, map[string]any{ "pid": pid, "bump_count": bumpCount, diff --git a/feedview.go b/feedview.go index 3838e66..2d8272a 100644 --- a/feedview.go +++ b/feedview.go @@ -80,6 +80,31 @@ type entryView struct { DeleteURL string } +// bumpRemaining ist die Restsperre des Reposts in Sekunden (0 = wieder +// möglich). Spiegelt die Bedingung im UPDATE von handleBump; die Vorlage +// braucht den Wert, um den Repost-Knopf zu sperren und die Wartezeit +// anzuzeigen -- das Gegenstück zu setupBump in web/js/feed.js. +func (v entryView) bumpRemaining() int64 { + rem := v.LastBump + v.BumpCount*bumpStep - time.Now().Unix() + if rem < 0 { + return 0 + } + return rem +} + +// BumpLocked meldet, ob der Repost dieses Beitrags gerade im Cooldown steckt. +func (v entryView) BumpLocked() bool { return v.bumpRemaining() > 0 } + +// BumpWaitHours ist die Restsperre auf volle Stunden aufgerundet (mindestens +// 1, solange überhaupt Zeit übrig ist) -- wie die Anzeige im JS-Frontend. +func (v entryView) BumpWaitHours() int64 { + h := (v.bumpRemaining() + 3599) / 3600 + if h < 1 { + return 1 + } + return h +} + // composeForm hält die Eingaben des Beitragsformulars über einen // fehlgeschlagenen Versuch hinweg. Die angehängte Datei fehlt dabei // zwangsläufig: ein lässt sich aus Sicherheitsgründen @@ -97,6 +122,13 @@ type feedView struct { PrevURL string NextURL string + // ReturnTo ist der Rücksprungort dieser Seite (beim GET die Seite selbst). + // Das Beitragsformular trägt ihn als verstecktes Feld, damit er einen + // abgelehnten Post übersteht -- sonst fiele das neu gerenderte Formular + // samt seiner Interaktions-Knöpfe auf den Default "/" zurück (die noch per + // JS bediente Startseite) statt auf "/feed". + ReturnTo string + // CanPost steuert, ob das Beitragsformular erscheint. Ohne Anmeldung gibt // es nichts zu schreiben; über TOR wird das Schreiben abgelehnt (blockTOR), // und ein Formular, das beim Absenden immer scheitert, wäre schlechter als @@ -178,11 +210,13 @@ func buildFeedView(r *http.Request, uid int64, rootsOnly bool) (feedView, error) return feedView{}, Internal(err) } + back := returnTo(r) v := feedView{ - Entries: toEntryViews(entries, tallies, returnTo(r), viewer), - Page: page, - Pages: pages, - IsTOR: isTOR(r), + Entries: toEntryViews(entries, tallies, back, viewer), + Page: page, + Pages: pages, + IsTOR: isTOR(r), + ReturnTo: back, } v.CanPost = viewer != 0 && !v.IsTOR if page > 0 { @@ -203,6 +237,11 @@ type threadView struct { CanPost bool IsTOR bool Compose composeForm + + // ReturnTo wie bei feedView: das Antwortformular trägt ihn versteckt mit, + // damit eine abgelehnte Antwort die Interaktions-Knöpfe nicht auf "/" + // zurückwirft statt auf den Thread. + ReturnTo string } // toEntryViews reichert Beiträge mit Abstimmungsstand, Rücksprungziel und @@ -272,6 +311,7 @@ func buildThreadView(r *http.Request, pid int64) (threadView, error) { Entry: toEntryViews([]Entry{t.Entry}, tallies, back, viewer)[0], Replies: toEntryViews(t.Replies, tallies, back, viewer), IsTOR: isTOR(r), + ReturnTo: back, } // Auf einen gelöschten Beitrag lässt sich nicht antworten -- er hat keinen // Autor mehr und der Platzhalter soll den Thread nur zusammenhalten. diff --git a/static/ads/placeholder.png b/static/ads/placeholder.png deleted file mode 100644 index d5e3945..0000000 Binary files a/static/ads/placeholder.png and /dev/null differ diff --git a/templates/entries.html b/templates/entries.html index e56fa24..98216c2 100644 --- a/templates/entries.html +++ b/templates/entries.html @@ -51,6 +51,14 @@ + {{/* Repost hebt den Beitrag im Feed wieder nach oben. Der Cooldown + sitzt am Beitrag (global) und wächst pro Repost; ist er aktiv, + ist der Knopf gesperrt und die Restzeit steht daneben. */}} +
+ + + {{if .BumpLocked}} · in {{.BumpWaitHours}}h{{end}} +
{{else}} {{/* Ohne Anmeldung nur der Stand -- ein Formular hier würde ausschließlich 401 liefern. */}} diff --git a/templates/feed.html b/templates/feed.html index 5a5a9e9..1bab028 100644 --- a/templates/feed.html +++ b/templates/feed.html @@ -6,6 +6,9 @@ statt der Datei -- ohne JavaScript gibt es keinen zweiten Versuch. */}}
+ {{/* return_to überlebt einen abgelehnten Post: sonst rendert failCompose + den Feed mit Interaktions-Knöpfen, die auf "/" statt "/feed" zeigen. */}} + {{with $.Err}}

{{.Msg}}

{{end}}