From eccddcebe62a5695b8fc250bffae6383e45f745b Mon Sep 17 00:00:00 2001 From: irrlicht Date: Sat, 26 Sep 2026 21:23:09 +0200 Subject: [PATCH] SSR: Repost ohne JS, return_to in Formularen, Werbebanner entfernt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Letzter vollständiger SSR-Stand vor dem Rückbau auf reine SPA. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q --- endpoints_test.go | 114 ++++++++++++++++++++++++++++++++++++- entry.go | 26 +++++++-- feedview.go | 48 ++++++++++++++-- static/ads/placeholder.png | Bin 9240 -> 0 bytes templates/entries.html | 8 +++ templates/feed.html | 3 + templates/thread.html | 3 + web/js/app.js | 3 +- web/js/feed.js | 34 +---------- 9 files changed, 194 insertions(+), 45 deletions(-) delete mode 100644 static/ads/placeholder.png 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 d5e39451cd6d2deeadc27264972e2e5707610394..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9240 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 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}}