SSR: Repost ohne JS, return_to in Formularen, Werbebanner entfernt

Letzter vollständiger SSR-Stand vor dem Rückbau auf reine SPA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
irrlicht
2026-09-26 21:23:09 +02:00
co-authored by Claude Opus 5.5
parent c66df283d1
commit eccddcebe6
9 changed files with 194 additions and 45 deletions
+111 -3
View File
@@ -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 {
+21 -5
View File
@@ -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,
+41 -1
View File
@@ -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 <input type="file"> 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),
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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

+8
View File
@@ -51,6 +51,14 @@
<button class="ghost{{if eq .Selected "right"}} selected{{end}}"
name="mode" value="right">Rechts</button>
</form>
{{/* 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. */}}
<form method="post" action="/entry/{{.PID}}/bump">
<input type="hidden" name="return_to" value="{{.ReturnTo}}#e{{.PID}}">
<button class="bump" type="submit"{{if .BumpLocked}} disabled{{end}}>Repost ({{.BumpCount}})</button>
{{if .BumpLocked}}<span class="muted"> · in {{.BumpWaitHours}}h</span>{{end}}
</form>
{{else}}
{{/* Ohne Anmeldung nur der Stand -- ein Formular hier würde
ausschließlich 401 liefern. */}}
+3
View File
@@ -6,6 +6,9 @@
statt der Datei -- ohne JavaScript gibt es keinen zweiten Versuch. */}}
<form method="post" action="/entry/create" enctype="multipart/form-data">
<input type="hidden" name="page" value="{{.Page}}">
{{/* return_to überlebt einen abgelehnten Post: sonst rendert failCompose
den Feed mit Interaktions-Knöpfen, die auf "/" statt "/feed" zeigen. */}}
<input type="hidden" name="return_to" value="{{.ReturnTo}}">
{{with $.Err}}<p class="msg">{{.Msg}}</p>{{end}}
<label for="content">Neuer Beitrag</label>
<textarea id="content" name="content" rows="4" maxlength="1000"
+3
View File
@@ -14,6 +14,9 @@
<section id="compose">
<form method="post" action="/entry/create" enctype="multipart/form-data">
<input type="hidden" name="reply_to" value="{{.Entry.PID}}">
{{/* return_to überlebt eine abgelehnte Antwort: sonst zeigen die neu
gerenderten Interaktions-Knöpfe auf "/" statt auf den Thread. */}}
<input type="hidden" name="return_to" value="{{.ReturnTo}}">
{{with $.Err}}<p class="msg">{{.Msg}}</p>{{end}}
<label for="content">Antworten</label>
<textarea id="content" name="content" rows="4" maxlength="1000"
+1 -2
View File
@@ -78,8 +78,7 @@ registerForm.addEventListener("submit", async (e) => {
const feed = createFeed(
document.getElementById("feed"),
document.getElementById("sentinel"),
(page) => `/entry/feed/${page}`,
{ adEvery: 5 }
(page) => `/entry/feed/${page}`
);
// --- Neuer Beitrag ---
+3 -31
View File
@@ -261,35 +261,12 @@ function setupReport(el, pid) {
});
}
// renderAd baut einen In-Feed-Werbeslot: das Banner (selbst gehostet, sonst
// würde die CSP default-src 'self' es blocken), darunter der Pflicht-Hinweis
// "Werbung" und der Link zur Eigenvermarktung. rel="sponsored nofollow" macht
// den bezahlten Charakter für Suchmaschinen kenntlich.
function renderAd() {
const el = document.createElement("aside");
el.className = "ad";
el.innerHTML = `
<a class="ad-banner" href="/werben" rel="sponsored nofollow noopener">
<img src="/static/ads/placeholder.png" alt="Werbung" loading="lazy">
</a>
<div class="ad-meta muted">
<span>Werbung</span>
<a href="/werben">Auf dem Kontrollverlust werben</a>
</div>
`;
return el;
}
// createFeed hängt Infinite-Scroll an feedEl/sentinelEl und lädt Seiten über
// urlFor(page). opts.adEvery (>0) blendet nach je N Beiträgen einen
// Werbebanner ein — nur im Top-Level-Feed, nicht in User-/Thread-Feeds.
// Gibt { reset } zurück, um den Feed neu zu laden.
function createFeed(feedEl, sentinelEl, urlFor, opts = {}) {
const adEvery = opts.adEvery || 0;
// urlFor(page). Gibt { reset } zurück, um den Feed neu zu laden.
function createFeed(feedEl, sentinelEl, urlFor) {
let page = 0;
let loading = false;
let done = false;
let shown = 0; // fortlaufende Beitragszahl über Seitengrenzen hinweg
async function loadMore() {
if (loading || done) return;
@@ -303,11 +280,7 @@ function createFeed(feedEl, sentinelEl, urlFor, opts = {}) {
end.textContent = "YOU REACHED THE END!";
feedEl.appendChild(end);
} else {
entries.forEach((e) => {
feedEl.appendChild(renderEntry(e));
shown++;
if (adEvery && shown % adEvery === 0) feedEl.appendChild(renderAd());
});
entries.forEach((e) => feedEl.appendChild(renderEntry(e)));
page++;
}
loading = false;
@@ -326,7 +299,6 @@ function createFeed(feedEl, sentinelEl, urlFor, opts = {}) {
function reset() {
page = 0;
done = false;
shown = 0;
feedEl.innerHTML = "";
loadMore();
}