From e265473c8c426abdea7518c75ff28e083fc1c5a2 Mon Sep 17 00:00:00 2001 From: Pavel Kvach Date: Wed, 22 Apr 2026 00:52:36 +0300 Subject: [PATCH] views: Add prevent-cache option to fix stale comment display Add a prevent-cache config option (default: true) to set HTTP no-cache headers on dynamic JSON GET endpoints for comment listings and views. This prevents browsers and proxies from caching stale comment data, ensuring users always see the latest comments. Fixes https://github.com/isso-comments/isso/issues/939 --- CHANGES.rst | 2 + contrib/isso-dev.cfg | 1 + docs/docs/reference/server-config.rst | 25 +++++++++++++ isso/isso.cfg | 4 ++ isso/tests/test_comments.py | 53 ++++++++++++++++++++++++++- isso/utils/__init__.py | 22 +++++++++++ isso/views/comments.py | 28 +++++++++++++- 7 files changed, 133 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index f98982f3e..dbe102fb9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,8 +8,10 @@ New Features ^^^^^^^^^^^^ - Add option to show/hide website field in comment form (`#1111`_, pkvach) +- Add prevent-cache option to fix stale comment display (`#1110`_, pkvach) .. _#1111: https://github.com/isso-comments/isso/pull/1111 +.. _#1110: https://github.com/isso-comments/isso/pull/1110 0.14.0 (2026-03-26) -------------------- diff --git a/contrib/isso-dev.cfg b/contrib/isso-dev.cfg index 760494b4a..d2c1389f3 100644 --- a/contrib/isso-dev.cfg +++ b/contrib/isso-dev.cfg @@ -30,6 +30,7 @@ purge-after = 30d [server] reload = false profile = false +prevent-cache = true [guard] enabled = false diff --git a/docs/docs/reference/server-config.rst b/docs/docs/reference/server-config.rst index be16cdb6b..8cc969cb9 100644 --- a/docs/docs/reference/server-config.rst +++ b/docs/docs/reference/server-config.rst @@ -261,6 +261,31 @@ samesite Default: (empty) +prevent-cache + Add HTTP no-cache headers to dynamic JSON GET endpoints that serve comment + listings and individual comment views. This prevents browsers and proxy + servers from caching stale comment data. + + When enabled, the following HTTP headers are added to responses from the + comment listing (``GET /``) and comment view (``GET /id/``) endpoints: + + - ``Cache-Control: no-cache, no-store, must-revalidate`` + - ``Pragma: no-cache`` + - ``Expires: 0`` + + This ensures that users always see the most up-to-date comments without + having to perform a hard refresh. The setting does not affect XML/Atom + feed endpoints, which use ETag-based caching. + + You may want to disable this if you have an upstream caching layer (such + as a CDN or reverse proxy) that needs different caching behavior and is + configured to override or bypass origin cache headers. In such cases, you + should configure your caching layer appropriately. + + Default: ``true`` + + .. versionadded:: 0.14.1 + .. _configure-smtp: SMTP diff --git a/isso/isso.cfg b/isso/isso.cfg index 85e7eb2de..af06ebb22 100644 --- a/isso/isso.cfg +++ b/isso/isso.cfg @@ -131,6 +131,10 @@ trusted-proxies = # Accepted values: None, Lax, Strict samesite = +# Add no-cache headers to dynamic JSON GET endpoints (comment listing and view). +# This prevents browsers and proxies from caching stale comment data. +# Set to false if you have an upstream caching layer that needs different behavior. +prevent-cache = true [smtp] # Isso can notify you on new comments via SMTP. In the email notification, you diff --git a/isso/tests/test_comments.py b/isso/tests/test_comments.py index 579487f2c..46445dfa9 100644 --- a/isso/tests/test_comments.py +++ b/isso/tests/test_comments.py @@ -11,7 +11,7 @@ from werkzeug.wrappers import Response from isso import Isso, core, config -from isso.utils import http +from isso.utils import http, NO_CACHE_HEADERS from isso.views import comments from fixtures import curl, loads, FakeIP, FakeHost, JSONClient @@ -665,6 +665,57 @@ def testLatestNotEnabled(self): response = self.get("/latest?limit=5") self.assertEqual(response.status_code, 404) + def testFetchAddsNoCacheHeaders(self): + """Test that fetch endpoint adds no-cache headers by default.""" + self.post("/new?uri=%2Fpath%2F", data=json.dumps({"text": "Lorem ipsum ..."})) + r = self.get("/?uri=%2Fpath%2F") + + self.assertEqual(r.status_code, 200) + for header, value in NO_CACHE_HEADERS.items(): + self.assertIn(header, r.headers) + self.assertEqual(r.headers[header], value) + + def testViewAddsNoCacheHeaders(self): + """Test that view endpoint adds no-cache headers by default.""" + self.post("/new?uri=%2Fpath%2F", data=json.dumps({"text": "Lorem ipsum ..."})) + r = self.get("/id/1") + + self.assertEqual(r.status_code, 200) + for header, value in NO_CACHE_HEADERS.items(): + self.assertIn(header, r.headers) + self.assertEqual(r.headers[header], value) + + def testPreventCacheCanBeDisabled(self): + """Test that no-cache headers can be disabled via configuration.""" + # Disable prevent-cache in configuration + self.conf.set("server", "prevent-cache", "false") + + # Recreate app with updated config + class App(Isso, core.Mixin): + pass + + self.app = App(self.conf) + self.app.wsgi_app = FakeIP(self.app.wsgi_app, "192.168.1.1") + self.client = JSONClient(self.app, Response) + + # Create a comment + rv = self.client.post("/new?uri=%2Fpath%2F", data=json.dumps({"text": "Lorem ipsum ..."})) + self.assertEqual(rv.status_code, 201) + + # Test fetch endpoint + r_fetch = self.client.get("/?uri=%2Fpath%2F") + self.assertEqual(r_fetch.status_code, 200) + # None of the no-cache headers should be present + for header in NO_CACHE_HEADERS: + self.assertNotIn(header, r_fetch.headers) + + # Test view endpoint + r_view = self.client.get("/id/1") + self.assertEqual(r_view.status_code, 200) + # None of the no-cache headers should be present + for header in NO_CACHE_HEADERS: + self.assertNotIn(header, r_view.headers) + class TestHostDependent(unittest.TestCase): def setUp(self): diff --git a/isso/utils/__init__.py b/isso/utils/__init__.py index b7f8abc07..85689d808 100644 --- a/isso/utils/__init__.py +++ b/isso/utils/__init__.py @@ -13,6 +13,13 @@ from isso.wsgi import Request +NO_CACHE_HEADERS = { + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", +} + + def anonymize(remote_addr): """ Anonymize IPv4 and IPv6 :param remote_addr: to /24 (zero'd) @@ -34,6 +41,21 @@ def anonymize(remote_addr): return "0.0.0.0" +def set_no_cache_headers(resp): + """ + Set HTTP headers to prevent caching of dynamic content. + + Args: + resp: A Response object to add headers to + + Returns: + The same Response object with no-cache headers added + """ + for key, value in NO_CACHE_HEADERS.items(): + resp.headers[key] = value + return resp + + class Bloomfilter: """A space-efficient probabilistic data structure. False-positive rate: diff --git a/isso/views/comments.py b/isso/views/comments.py index 3864ed865..789296983 100644 --- a/isso/views/comments.py +++ b/isso/views/comments.py @@ -9,6 +9,7 @@ from configparser import NoOptionError from importlib.resources import files from datetime import datetime, timedelta +from functools import wraps from html import escape from io import BytesIO as StringIO from os import path as os_path @@ -24,7 +25,7 @@ from werkzeug.wsgi import get_current_url from isso import utils, local -from isso.utils import http, parse, JSONResponse as JSON, XMLResponse as XML, render_template +from isso.utils import http, parse, JSONResponse as JSON, XMLResponse as XML, render_template, set_no_cache_headers from isso.utils.hash import md5, sha1 from isso.views import requires @@ -83,6 +84,25 @@ def dec(self, env, req, *args, **kwargs): return dec +def no_cache_headers(func): + """Decorator that applies no-cache headers to successful responses. + + Only applies headers when status_code < 400. Error responses (4xx/5xx) + are intentionally excluded, as their caching behavior is typically + controlled by the client or intermediary proxies rather than + the application itself. + """ + + @wraps(func) + def wrapper(self, *args, **kwargs): + resp = func(self, *args, **kwargs) + if self.prevent_cache and isinstance(resp, Response) and resp.status_code < 400: + set_no_cache_headers(resp) + return resp + + return wrapper + + def get_comment_id_from_url(comment_url): """ Extracts the comment ID from a given comment URL. @@ -193,6 +213,10 @@ def __init__(self, isso, hasher): self.trusted_proxies = list(isso.conf.getiter("server", "trusted-proxies")) except NoOptionError: self.trusted_proxies = [] + try: + self.prevent_cache = isso.conf.getboolean("server", "prevent-cache") + except NoOptionError: + self.prevent_cache = True # These configuration records can be read out by client self.public_conf = {} @@ -484,6 +508,7 @@ def create_cookie(self, **kwargs): } """ + @no_cache_headers def view(self, environ, request, id): rv = self.comments.get(id) if rv is None: @@ -938,6 +963,7 @@ def moderate(self, environ, request, id, action, key): """ @requires(str, "uri") + @no_cache_headers def fetch(self, environ, request, uri): args = {"uri": uri, "after": request.args.get("after", 0)}