Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
--------------------
Expand Down
1 change: 1 addition & 0 deletions contrib/isso-dev.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ purge-after = 30d
[server]
reload = false
profile = false
prevent-cache = true

[guard]
enabled = false
Expand Down
25 changes: 25 additions & 0 deletions docs/docs/reference/server-config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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/<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
Expand Down
4 changes: 4 additions & 0 deletions isso/isso.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 52 additions & 1 deletion isso/tests/test_comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
22 changes: 22 additions & 0 deletions isso/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:

Expand Down
28 changes: 27 additions & 1 deletion isso/views/comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)}

Expand Down
Loading