Wstęp#

To kolejny wpis w serii o systemach komentarzy dla Hugo. W poprzednim postawiłem samą aplikację remark42 na VPS obok istniejącego caddy-docker-proxy: Self-hosting remark42 obok istniejącego Caddy na VPS . Backend odpowiada „pong", baza BoltDB przyjmuje zapisy. Ale to jeszcze nie są komentarze pod wpisami. Żeby pojawiły się na stronie, trzeba wpiąć widget: kawałek kodu JavaScript, który powoduje powstanie iframe z listą wątków i formularzem.

Mój setup ma jednak jeden szczegół, który przy tym wpięciu wraca jak bumerang. Blog i instancja remark42 stoją na różnych domenach. Z perspektywy przeglądarki iframe od remark42 osadzony na stronie to klasyczny „third-party context". Ta sama kategoria, do której trafiają reklamowe pixels, przyciski „Zaloguj przez Facebook", tracking scripts.

W ostatnich pięciu latach przeglądarki systematycznie ograniczają, co iframe w takim third-party context może robić z cookies. Safari zaczął w 2020 (Intelligent Tracking Prevention, ITP). Firefox dołączył w 2022 (Total Cookie Protection). Chrome, od którego zależy duża część ruchu na typowym blogu technicznym, wdrożył zmiany w 2024 (Tracking Protection oparta o mechanizm CHIPS: Cookies Having Independent Partitioned State). Wszystkie trzy sprowadzają się do tej samej mechaniki: cookie ustawione przez remark42 w iframe osadzonym w example.com ląduje w osobnej „szufladce" storage, przypisanej do konkretnej pary (top-level strona, domena iframe). To samo cookie ustawione przez remark42 odwiedzone bezpośrednio ląduje w innej szufladce. Iframe swojego cookie po prostu nie widzi.

W praktyce po pierwszym deployu wyglądało to tak: widget się załadował, formularz „Sign in as Anonymous" zadziałał, avatar pojawił się nad polem komentarza. Ale kiedy nacisnąłem „Send", backend odpowiedział 401 Not authorized. To nie był problem CORS, brakującego XSRF-tokenu ani zbyt wąskiego ALLOWED_HOSTS. Wszystkie te podejrzenia sprawdziłem osobno i wykluczyłem. Wina leżała po stronie tej partycjonowanej szufladki cookies, do której iframe nie miał dostępu: sesja została nawiązana w jednym kontekście przeglądarki, a widget próbował z niej korzystać w innym.

Ten wpis rozkłada problem na czynniki i pokazuje wyjścia. Najpierw wpięcie widgetu od strony Hugo (partial, wywołanie w single.html, wpis w hugo.toml). Potem mechanika logowania krok po kroku: dlaczego widget wyświetla mnie jako zalogowanego, ale POST idzie bez autoryzacji. Przechodzę przez warianty rozwiązania i opisuję to, na co ostatecznie się zdecydowałem: przełączenie remark42 z cookie-sesji na sesję opartą o JWT w nagłówku, przez dwie flagi w .env.

Wdrożenie widgetu w Hugo#

Systemem komentarzy w tym blogu steruje jeden parametr w hugo.toml. Partial layouts/partials/comments.html sprawdza jego wartość i renderuje odpowiedni widget. remark42 wchodzi jako czwarty wariant do tej samej struktury. Poniżej pokazuję trzy pliki, które trzeba przygotować lub zmodyfikować.

1. Partial remark42.html#

Tworzymy layouts/partials/comments/remark42.html. Ładujemy z niego embed.js z instancji remark42 i konfigurujemy widget przez globalny obiekt remark_config. Locale bierzemy z języka aktywnej wersji strony, bo blog jest dwujęzyczny i PL i EN mają osobne wątki komentarzy:

{{- $remark := .Site.Params.remark42 -}}
{{- $lang := .Site.Language.Lang | default "en" -}}

<div id="remark42"></div>
<script>
  var remark_config = {
    host: {{ $remark.host }},
    site_id: {{ $remark.siteId }},
    url: {{ .Permalink }},
    components: ['embed'],
    theme: 'dark',
    locale: {{ $lang }}
  };
  (function(c) {
    for (var i = 0; i < c.length; i++) {
      var d = document, s = d.createElement('script');
      s.src = remark_config.host + '/web/' + c[i] + '.js';
      s.defer = true;
      (d.head || d.body).appendChild(s);
    }
  })(remark_config.components);
</script>

Skrypt ładuje się z atrybutem defer, żeby nie blokował parsera HTML. Pole url: {{ .Permalink }} przypisuje wątek komentarzy do konkretnego adresu strony. Bez tego widget wziąłby location.href, co przy różnicach w trailing slashach albo prefiksie języka (/posts/ kontra /pl/posts/) potrafi rozjechać wątek między dwoma URL-ami prowadzącymi do tego samego wpisu.

2. Wpięcie w layout wpisu#

Komentarze renderują się przez layouts/_default/single.html. Na dole template’u wpisu wystarczy wywołanie dispatchera:

{{ partial "comments.html" . }}

3. Konfiguracja hugo.toml#

Parametr commentSystem mówi dispatcherowi który widget renderować. Dane połączenia z instancją remark42 idą do osobnej sekcji [params.remark42]:

[params]
  commentSystem = "remark42"

[params.remark42]
  host = "https://remark42.example.com"
  siteId = "blog"

Po tych trzech zmianach hugo server renderuje na dole wpisów kontener #remark42 i wciąga skrypt widgetu. Tyle po stronie Hugo. Reszta problemu, którą opisuję poniżej, żyje w przeglądarce.

Sesja przy cross-domain: problem i rozwiązanie#

Objaw opisany we Wstępie, 401 Not authorized po kliknięciu „Send", nie ujawnia się od razu. Popup logowania działa, po wpisaniu nicku widget pokazuje avatar. Rozjazd wynika z dwóch różnych kontekstów przeglądarki, w których żyje sesja. Popup logowania to okno, w którym remark42 jest top-level, i cookie sesji ląduje w partycji przypisanej do jego domeny. Iframe widgetu żyje w partycji przypisanej do domeny bloga i tego cookie nie widzi. Fakt zalogowania dociera do iframe przez window.postMessage z popup, więc UI się aktualizuje, ale POST komentarza wychodzi już z iframe, bez cookie sesji, i backend odpowiada 401.

Rozwiązaniem jest przestawienie remark42 na sesję w nagłówku zamiast w cookie. Dwie flagi w .env:

AUTH_SAME_SITE=none
AUTH_SEND_JWT_HEADER=true

Z AUTH_SEND_JWT_HEADER=true backend zwraca JWT w nagłówku X-JWT zamiast w Set-Cookie. Widget zapisuje token w localStorage swojego iframe’u i dołącza go jako nagłówek X-JWT do kolejnych requestów. Nagłówki HTTP nie podlegają partycjonowaniu cookies, więc problem znika. Dwa ograniczenia warte odnotowania: remark42 musi być w wersji co najmniej 1.15.0 (starsze mają błąd #1877, gdzie token nie trafia do widgetu), i opcja ta nie działa z OAuth. Logowanie przez GitHub albo Google wymaga redirect flow opartego na cookies. Jeśli OAuth będzie potrzebny, jedyną ścieżką jest subdomena bloga wskazująca na VPS z remark42.

Admin skonfigurowany w poprzednim wpisie przez ADMIN_SHARED_ID działa w tym trybie bez zmian. Token z attrs.admin: true trafia do localStorage widgetu tak samo jak token zwykłego użytkownika, więc moderacyjne UI (kasowanie, pinowanie, blokada) pojawia się w widget na tych samych zasadach.

Podsumowanie#

Wpięcie widgetu remark42 w Hugo od strony template’ów to trzy pliki: partial remark42.html, wywołanie partiala w single.html i sekcja w hugo.toml. To prosta i szybka część. Rzeczywisty ciężar tego wpisu leży w tym, co z tym setupem robi przeglądarka.

Phase-out third-party cookies sprawia, że każdy self-hosted system komentarzy osadzany cross-domain w 2026 stoi przed architektonicznym wyborem:

  • Subdomenowa infrastruktura (np. comments.example.com). Sesja jest same-site z perspektywy przeglądarki. Koszt to zmiany w DNS, TLS i konfiguracji reverse-proxy. Jedyna ścieżka, jeśli potrzebujesz OAuth.
  • Header-based auth. Sesja przenosi się z cookies do nagłówka JWT i localStorage widgetu. Bez OAuth, ale bez zmian w topologii. Wystarczą dwie flagi w .env.
  • Brak sesji w ogóle. Filozofia, którą reprezentuje Cusdis: każdy komentarz to formularz do zatwierdzenia przez admina, żaden użytkownik nie istnieje między requestami. Cross-domain przestaje mieć znaczenie, bo nie ma sesji do zarządzania.

remark42 daje pierwsze dwie opcje. Jeśli zaczynasz nowy setup i wystarczy Ci anonymous auth, header-based auth jest najtańszym wejściem: dwie flagi w .env i widget działa cross-domain bez zaglądania w topologię hostingu.

Źródła i dalsze materiały#