feat(comments): v2 — HTMX, Turnstile, reactions, design refresh #44

Merged
mark merged 5 commits from feature/comments-v2 into main 2026-03-04 00:04:43 +00:00
23 changed files with 695 additions and 76 deletions
Showing only changes of commit d0a550fee6 - Show all commits

View File

@@ -303,12 +303,15 @@ class ArticlePage(SeoMixin, Page):
def get_context(self, request, *args, **kwargs): def get_context(self, request, *args, **kwargs):
ctx = super().get_context(request, *args, **kwargs) ctx = super().get_context(request, *args, **kwargs)
ctx["related_articles"] = self.get_related_articles() ctx["related_articles"] = self.get_related_articles()
from django.conf import settings
from apps.comments.models import Comment from apps.comments.models import Comment
approved_replies = Comment.objects.filter(is_approved=True).select_related("parent") approved_replies = Comment.objects.filter(is_approved=True).select_related("parent")
ctx["approved_comments"] = self.comments.filter(is_approved=True, parent__isnull=True).prefetch_related( ctx["approved_comments"] = self.comments.filter(is_approved=True, parent__isnull=True).prefetch_related(
Prefetch("replies", queryset=approved_replies) Prefetch("replies", queryset=approved_replies)
) )
ctx["turnstile_site_key"] = getattr(settings, "TURNSTILE_SITE_KEY", "")
return ctx return ctx

View File

@@ -5,7 +5,7 @@ from datetime import timedelta
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils import timezone from django.utils import timezone
from apps.comments.models import Comment from apps.comments.models import Comment, CommentReaction
class Command(BaseCommand): class Command(BaseCommand):
@@ -29,3 +29,10 @@ class Command(BaseCommand):
.update(author_email="", ip_address=None) .update(author_email="", ip_address=None)
) )
self.stdout.write(self.style.SUCCESS(f"Purged personal data for {purged} comment(s).")) self.stdout.write(self.style.SUCCESS(f"Purged personal data for {purged} comment(s)."))
reactions_purged = (
CommentReaction.objects.filter(created_at__lt=cutoff)
.exclude(session_key="")
.update(session_key="")
)
self.stdout.write(self.style.SUCCESS(f"Purged session keys for {reactions_purged} reaction(s)."))

View File

@@ -0,0 +1,27 @@
# Generated by Django 5.2.11 on 2026-03-03 22:49
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('comments', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='CommentReaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('reaction_type', models.CharField(choices=[('heart', '❤️'), ('plus_one', '👍')], max_length=20)),
('session_key', models.CharField(max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reactions', to='comments.comment')),
],
options={
'constraints': [models.UniqueConstraint(fields=('comment', 'reaction_type', 'session_key'), name='unique_comment_reaction_per_session')],
},
),
]

View File

@@ -23,3 +23,21 @@ class Comment(models.Model):
def __str__(self) -> str: def __str__(self) -> str:
return f"Comment by {self.author_name}" return f"Comment by {self.author_name}"
class CommentReaction(models.Model):
comment = models.ForeignKey(Comment, on_delete=models.CASCADE, related_name="reactions")
reaction_type = models.CharField(max_length=20, choices=[("heart", "❤️"), ("plus_one", "👍")])
session_key = models.CharField(max_length=64)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["comment", "reaction_type", "session_key"],
name="unique_comment_reaction_per_session",
)
]
def __str__(self) -> str:
return f"{self.reaction_type} on comment {self.comment_id}"

View File

@@ -0,0 +1,271 @@
"""Tests for Comments v2: HTMX, Turnstile, reactions, polling, CSP."""
from __future__ import annotations
from datetime import timedelta
from unittest.mock import patch
import pytest
from django.core.cache import cache
from django.core.management import call_command
from django.test import override_settings
from django.utils import timezone
from apps.blog.models import ArticleIndexPage, ArticlePage
from apps.blog.tests.factories import AuthorFactory
from apps.comments.models import Comment, CommentReaction
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
def _article(home_page):
"""Create a published article with comments enabled."""
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
article = ArticlePage(
title="Test Article",
slug="test-article",
author=author,
summary="summary",
body=[("rich_text", "<p>body</p>")],
)
index.add_child(instance=article)
article.save_revision().publish()
return article
@pytest.fixture
def approved_comment(_article):
return Comment.objects.create(
article=_article,
author_name="Alice",
author_email="alice@example.com",
body="Great article!",
is_approved=True,
)
def _post_comment(client, article, extra=None, htmx=False):
cache.clear()
payload = {
"article_id": article.id,
"author_name": "Test",
"author_email": "test@example.com",
"body": "Hello world",
"honeypot": "",
}
if extra:
payload.update(extra)
headers = {}
if htmx:
headers["HTTP_HX_REQUEST"] = "true"
return client.post("/comments/post/", payload, **headers)
# ── HTMX Response Contracts ──────────────────────────────────────────────────
@pytest.mark.django_db
def test_htmx_post_returns_partial_on_success(client, _article):
"""HTMX POST with Turnstile disabled returns moderation notice partial."""
resp = _post_comment(client, _article, htmx=True)
assert resp.status_code == 200
assert b"awaiting moderation" in resp.content
assert "HX-Request" in resp["Vary"]
@pytest.mark.django_db
@override_settings(TURNSTILE_SECRET_KEY="test-secret")
def test_htmx_post_returns_comment_partial_when_turnstile_passes(client, _article):
"""HTMX POST with successful Turnstile returns comment partial for append."""
with patch("apps.comments.views._verify_turnstile", return_value=True):
resp = _post_comment(client, _article, extra={"cf-turnstile-response": "tok"}, htmx=True)
assert resp.status_code == 200
assert b"Hello world" in resp.content
assert b"comment-" in resp.content
comment = Comment.objects.get()
assert comment.is_approved is True
@pytest.mark.django_db
def test_htmx_post_returns_form_with_errors_on_invalid(client, _article):
"""HTMX POST with invalid data returns form partial with HTTP 422."""
cache.clear()
resp = client.post(
"/comments/post/",
{"article_id": _article.id, "author_name": "T", "author_email": "t@t.com", "body": " ", "honeypot": ""},
HTTP_HX_REQUEST="true",
)
assert resp.status_code == 422
assert "HX-Request" in resp["Vary"]
assert Comment.objects.count() == 0
@pytest.mark.django_db
def test_non_htmx_post_still_redirects(client, _article):
"""Non-HTMX POST continues to redirect (progressive enhancement)."""
resp = _post_comment(client, _article)
assert resp.status_code == 302
assert resp["Location"].endswith("?commented=1")
# ── Turnstile Integration ────────────────────────────────────────────────────
@pytest.mark.django_db
@override_settings(TURNSTILE_SECRET_KEY="test-secret")
def test_turnstile_failure_keeps_comment_unapproved(client, _article):
"""When Turnstile verification fails, comment stays unapproved."""
with patch("apps.comments.views._verify_turnstile", return_value=False):
_post_comment(client, _article, extra={"cf-turnstile-response": "bad-tok"})
comment = Comment.objects.get()
assert comment.is_approved is False
@pytest.mark.django_db
def test_turnstile_disabled_keeps_comment_unapproved(client, _article):
"""When TURNSTILE_SECRET_KEY is empty, comment stays unapproved."""
_post_comment(client, _article)
comment = Comment.objects.get()
assert comment.is_approved is False
@pytest.mark.django_db
@override_settings(TURNSTILE_SECRET_KEY="test-secret", TURNSTILE_EXPECTED_HOSTNAME="nohypeai.com")
def test_turnstile_hostname_mismatch_rejects(client, _article):
"""Turnstile hostname mismatch keeps comment unapproved."""
mock_resp = type("R", (), {"json": lambda self: {"success": True, "hostname": "evil.com"}})()
with patch("apps.comments.views.http_requests.post", return_value=mock_resp):
_post_comment(client, _article, extra={"cf-turnstile-response": "tok"})
comment = Comment.objects.get()
assert comment.is_approved is False
@pytest.mark.django_db
@override_settings(TURNSTILE_SECRET_KEY="test-secret")
def test_turnstile_timeout_fails_closed(client, _article):
"""Network error during Turnstile verification fails closed."""
with patch("apps.comments.views.http_requests.post", side_effect=Exception("timeout")):
_post_comment(client, _article, extra={"cf-turnstile-response": "tok"})
comment = Comment.objects.get()
assert comment.is_approved is False
# ── Polling ───────────────────────────────────────────────────────────────────
@pytest.mark.django_db
def test_comment_poll_returns_new_comments(_article, client, approved_comment):
"""Poll endpoint returns only comments after the given ID."""
resp = client.get(f"/comments/poll/{_article.id}/?after_id=0")
assert resp.status_code == 200
assert b"Alice" in resp.content
resp2 = client.get(f"/comments/poll/{_article.id}/?after_id={approved_comment.id}")
assert resp2.status_code == 200
assert b"Alice" not in resp2.content
@pytest.mark.django_db
def test_comment_poll_no_duplicates(_article, client, approved_comment):
"""Polling with current latest ID returns empty."""
resp = client.get(f"/comments/poll/{_article.id}/?after_id={approved_comment.id}")
assert b"comment-" not in resp.content
# ── Reactions ─────────────────────────────────────────────────────────────────
@pytest.mark.django_db
def test_react_creates_reaction(client, approved_comment):
cache.clear()
resp = client.post(
f"/comments/{approved_comment.id}/react/",
{"reaction_type": "heart"},
HTTP_HX_REQUEST="true",
)
assert resp.status_code == 200
assert CommentReaction.objects.count() == 1
@pytest.mark.django_db
def test_react_toggle_removes_reaction(client, approved_comment):
"""Second reaction of same type removes it (toggle)."""
cache.clear()
client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "heart"})
client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "heart"})
assert CommentReaction.objects.count() == 0
@pytest.mark.django_db
def test_react_different_types_coexist(client, approved_comment):
cache.clear()
client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "heart"})
client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "plus_one"})
assert CommentReaction.objects.count() == 2
@pytest.mark.django_db
def test_react_invalid_type_returns_400(client, approved_comment):
cache.clear()
resp = client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "invalid"})
assert resp.status_code == 400
@pytest.mark.django_db
def test_react_on_unapproved_comment_returns_404(client, _article):
cache.clear()
comment = Comment.objects.create(
article=_article, author_name="B", author_email="b@b.com", body="x", is_approved=False,
)
resp = client.post(f"/comments/{comment.id}/react/", {"reaction_type": "heart"})
assert resp.status_code == 404
@pytest.mark.django_db
@override_settings(REACTION_RATE_LIMIT_PER_MINUTE=2)
def test_react_rate_limit(client, approved_comment):
cache.clear()
for _ in range(2):
client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "heart"})
resp = client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "plus_one"})
assert resp.status_code == 429
# ── CSP ───────────────────────────────────────────────────────────────────────
@pytest.mark.django_db
def test_csp_allows_turnstile(client, _article):
"""CSP header includes Cloudflare Turnstile domains."""
resp = client.get(_article.url)
csp = resp.get("Content-Security-Policy", "")
assert "challenges.cloudflare.com" in csp
assert "frame-src" in csp
# ── Purge Command Extension ──────────────────────────────────────────────────
@pytest.mark.django_db
def test_purge_clears_reaction_session_keys(home_page):
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
article = ArticlePage(title="A", slug="a", author=author, summary="s", body=[("rich_text", "<p>b</p>")])
index.add_child(instance=article)
article.save_revision().publish()
comment = Comment.objects.create(
article=article, author_name="X", author_email="x@x.com", body="y", is_approved=True,
)
reaction = CommentReaction.objects.create(
comment=comment, reaction_type="heart", session_key="abc123",
)
CommentReaction.objects.filter(pk=reaction.pk).update(created_at=timezone.now() - timedelta(days=800))
call_command("purge_old_comment_data")
reaction.refresh_from_db()
assert reaction.session_key == ""

View File

@@ -1,7 +1,9 @@
from django.urls import path from django.urls import path
from apps.comments.views import CommentCreateView from apps.comments.views import CommentCreateView, comment_poll, comment_react
urlpatterns = [ urlpatterns = [
path("post/", CommentCreateView.as_view(), name="comment_post"), path("post/", CommentCreateView.as_view(), name="comment_post"),
path("poll/<int:article_id>/", comment_poll, name="comment_poll"),
path("<int:comment_id>/react/", comment_react, name="comment_react"),
] ]

View File

@@ -1,16 +1,25 @@
from __future__ import annotations from __future__ import annotations
import logging
from urllib.parse import urlencode
import requests as http_requests
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.http import HttpResponse from django.db import IntegrityError
from django.db.models import F, Prefetch
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.views import View from django.views import View
from django.views.decorators.http import require_GET, require_POST
from apps.blog.models import ArticlePage from apps.blog.models import ArticlePage
from apps.comments.forms import CommentForm from apps.comments.forms import CommentForm
from apps.comments.models import Comment from apps.comments.models import Comment, CommentReaction
logger = logging.getLogger(__name__)
def client_ip_from_request(request) -> str: def client_ip_from_request(request) -> str:
@@ -22,11 +31,53 @@ def client_ip_from_request(request) -> str:
return remote_addr return remote_addr
def _is_htmx(request) -> bool:
return request.headers.get("HX-Request") == "true"
def _add_vary_header(response):
response["Vary"] = "HX-Request"
return response
def _verify_turnstile(token: str, ip: str) -> bool:
secret = getattr(settings, "TURNSTILE_SECRET_KEY", "")
if not secret:
return False
try:
resp = http_requests.post(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
data={"secret": secret, "response": token, "remoteip": ip},
timeout=5,
)
result = resp.json()
if not result.get("success"):
return False
expected_hostname = getattr(settings, "TURNSTILE_EXPECTED_HOSTNAME", "")
if expected_hostname and result.get("hostname") != expected_hostname:
logger.warning("Turnstile hostname mismatch: %s", result.get("hostname"))
return False
return True
except Exception:
logger.exception("Turnstile verification failed")
return False
def _turnstile_enabled() -> bool:
return bool(getattr(settings, "TURNSTILE_SECRET_KEY", ""))
class CommentCreateView(View): class CommentCreateView(View):
def _render_article_with_errors(self, request, article, form): def _render_article_with_errors(self, request, article, form):
if _is_htmx(request):
ctx = {"comment_form": form, "page": article}
ctx["turnstile_site_key"] = getattr(settings, "TURNSTILE_SITE_KEY", "")
resp = render(request, "comments/_comment_form.html", ctx, status=422)
return _add_vary_header(resp)
context = article.get_context(request) context = article.get_context(request)
context["page"] = article context["page"] = article
context["comment_form"] = form context["comment_form"] = form
context["turnstile_site_key"] = getattr(settings, "TURNSTILE_SITE_KEY", "")
return render(request, "blog/article_page.html", context, status=200) return render(request, "blog/article_page.html", context, status=200)
def post(self, request): def post(self, request):
@@ -45,9 +96,21 @@ class CommentCreateView(View):
if form.is_valid(): if form.is_valid():
if form.cleaned_data.get("honeypot"): if form.cleaned_data.get("honeypot"):
if _is_htmx(request):
return _add_vary_header(
render(request, "comments/_comment_success.html", {"message": "Comment posted!"})
)
return redirect(f"{article.url}?commented=1") return redirect(f"{article.url}?commented=1")
# Turnstile verification
turnstile_ok = False
if _turnstile_enabled():
token = request.POST.get("cf-turnstile-response", "")
turnstile_ok = _verify_turnstile(token, ip)
comment = form.save(commit=False) comment = form.save(commit=False)
comment.article = article comment.article = article
comment.is_approved = turnstile_ok
parent_id = form.cleaned_data.get("parent_id") parent_id = form.cleaned_data.get("parent_id")
if parent_id: if parent_id:
comment.parent = Comment.objects.filter(pk=parent_id, article=article).first() comment.parent = Comment.objects.filter(pk=parent_id, article=article).first()
@@ -58,7 +121,97 @@ class CommentCreateView(View):
form.add_error(None, "Reply depth exceeds the allowed limit") form.add_error(None, "Reply depth exceeds the allowed limit")
return self._render_article_with_errors(request, article, form) return self._render_article_with_errors(request, article, form)
comment.save() comment.save()
messages.success(request, "Your comment is awaiting moderation")
if _is_htmx(request):
if comment.is_approved:
resp = render(request, "comments/_comment.html", {
"comment": comment, "page": article,
"turnstile_site_key": getattr(settings, "TURNSTILE_SITE_KEY", ""),
})
else:
resp = render(request, "comments/_comment_success.html", {
"message": "Your comment has been posted and is awaiting moderation.",
})
return _add_vary_header(resp)
messages.success(
request,
"Comment posted!" if comment.is_approved else "Your comment is awaiting moderation",
)
return redirect(f"{article.url}?commented=1") return redirect(f"{article.url}?commented=1")
return self._render_article_with_errors(request, article, form) return self._render_article_with_errors(request, article, form)
@require_GET
def comment_poll(request, article_id):
"""Return comments newer than after_id for HTMX polling."""
article = get_object_or_404(ArticlePage, pk=article_id)
after_id = request.GET.get("after_id", "0")
try:
after_id = int(after_id)
except (ValueError, TypeError):
after_id = 0
approved_replies = Comment.objects.filter(is_approved=True).select_related("parent")
comments = (
article.comments.filter(is_approved=True, parent__isnull=True, id__gt=after_id)
.prefetch_related(Prefetch("replies", queryset=approved_replies))
.order_by("created_at", "id")
)
resp = render(request, "comments/_comment_list_inner.html", {
"approved_comments": comments,
"page": article,
"turnstile_site_key": getattr(settings, "TURNSTILE_SITE_KEY", ""),
})
return _add_vary_header(resp)
@require_POST
def comment_react(request, comment_id):
"""Toggle a reaction on a comment."""
ip = client_ip_from_request(request)
key = f"reaction-rate:{ip}"
count = cache.get(key, 0)
rate_limit = getattr(settings, "REACTION_RATE_LIMIT_PER_MINUTE", 20)
if count >= rate_limit:
return HttpResponse(status=429)
cache.set(key, count + 1, timeout=60)
comment = get_object_or_404(Comment, pk=comment_id, is_approved=True)
reaction_type = request.POST.get("reaction_type", "heart")
if reaction_type not in ("heart", "plus_one"):
return HttpResponse(status=400)
if not request.session.session_key:
request.session.create()
session_key = request.session.session_key
try:
existing = CommentReaction.objects.filter(
comment=comment, reaction_type=reaction_type, session_key=session_key
).first()
if existing:
existing.delete()
else:
CommentReaction.objects.create(
comment=comment, reaction_type=reaction_type, session_key=session_key
)
except IntegrityError:
pass
counts = {}
for rt in ("heart", "plus_one"):
counts[rt] = comment.reactions.filter(reaction_type=rt).count()
user_reacted = set(
comment.reactions.filter(session_key=session_key).values_list("reaction_type", flat=True)
)
if _is_htmx(request):
resp = render(request, "comments/_reactions.html", {
"comment": comment, "counts": counts, "user_reacted": user_reacted,
})
return _add_vary_header(resp)
return JsonResponse({"counts": counts, "user_reacted": list(user_reacted)})

View File

@@ -41,6 +41,34 @@ class ApproveCommentBulkAction(SnippetBulkAction):
) % {"count": num_parent_objects} ) % {"count": num_parent_objects}
class UnapproveCommentBulkAction(SnippetBulkAction):
display_name = _("Unapprove")
action_type = "unapprove"
aria_label = _("Unapprove selected comments")
template_name = "comments/confirm_bulk_unapprove.html"
action_priority = 30
models = [Comment]
def check_perm(self, snippet):
if getattr(self, "can_change_items", None) is None:
self.can_change_items = self.request.user.has_perm(get_permission_name("change", self.model))
return self.can_change_items
@classmethod
def execute_action(cls, objects, **kwargs):
updated = kwargs["self"].model.objects.filter(pk__in=[obj.pk for obj in objects], is_approved=True).update(
is_approved=False
)
return updated, 0
def get_success_message(self, num_parent_objects, num_child_objects):
return ngettext(
"%(count)d comment unapproved.",
"%(count)d comments unapproved.",
num_parent_objects,
) % {"count": num_parent_objects}
class CommentViewSet(SnippetViewSet): class CommentViewSet(SnippetViewSet):
model = Comment model = Comment
queryset = Comment.objects.all() queryset = Comment.objects.all()
@@ -70,3 +98,4 @@ class CommentViewSet(SnippetViewSet):
register_snippet(CommentViewSet) register_snippet(CommentViewSet)
hooks.register("register_bulk_action", ApproveCommentBulkAction) hooks.register("register_bulk_action", ApproveCommentBulkAction)
hooks.register("register_bulk_action", UnapproveCommentBulkAction)

View File

@@ -1,3 +1,4 @@
from django.conf import settings as django_settings
from wagtail.models import Site from wagtail.models import Site
from apps.core.models import SiteSettings from apps.core.models import SiteSettings
@@ -6,4 +7,7 @@ from apps.core.models import SiteSettings
def site_settings(request): def site_settings(request):
site = Site.find_for_request(request) site = Site.find_for_request(request)
settings_obj = SiteSettings.for_site(site) if site else None settings_obj = SiteSettings.for_site(site) if site else None
return {"site_settings": settings_obj} return {
"site_settings": settings_obj,
"turnstile_site_key": getattr(django_settings, "TURNSTILE_SITE_KEY", ""),
}

View File

@@ -28,11 +28,12 @@ class SecurityHeadersMiddleware:
return response return response
response["Content-Security-Policy"] = ( response["Content-Security-Policy"] = (
f"default-src 'self'; " f"default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}'; " f"script-src 'self' 'nonce-{nonce}' https://challenges.cloudflare.com; "
"style-src 'self' https://fonts.googleapis.com; " "style-src 'self' https://fonts.googleapis.com; "
"img-src 'self' data: blob:; " "img-src 'self' data: blob:; "
"font-src 'self' https://fonts.gstatic.com; " "font-src 'self' https://fonts.gstatic.com; "
"connect-src 'self'; " "connect-src 'self' https://challenges.cloudflare.com; "
"frame-src https://challenges.cloudflare.com; "
"object-src 'none'; " "object-src 'none'; "
"base-uri 'self'; " "base-uri 'self'; "
"frame-ancestors 'self'" "frame-ancestors 'self'"

View File

@@ -48,6 +48,7 @@ INSTALLED_APPS = [
"wagtailseo", "wagtailseo",
"tailwind", "tailwind",
"theme", "theme",
"django_htmx",
"apps.core", "apps.core",
"apps.blog", "apps.blog",
"apps.authors", "apps.authors",
@@ -66,6 +67,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_htmx.middleware.HtmxMiddleware",
"wagtail.contrib.redirects.middleware.RedirectMiddleware", "wagtail.contrib.redirects.middleware.RedirectMiddleware",
"apps.core.middleware.ConsentMiddleware", "apps.core.middleware.ConsentMiddleware",
] ]
@@ -154,6 +156,11 @@ STORAGES = {
TAILWIND_APP_NAME = "theme" TAILWIND_APP_NAME = "theme"
# Cloudflare Turnstile (comment spam protection)
TURNSTILE_SITE_KEY = os.getenv("TURNSTILE_SITE_KEY", "")
TURNSTILE_SECRET_KEY = os.getenv("TURNSTILE_SECRET_KEY", "")
TURNSTILE_EXPECTED_HOSTNAME = os.getenv("TURNSTILE_EXPECTED_HOSTNAME", "")
WAGTAILSEARCH_BACKENDS = { WAGTAILSEARCH_BACKENDS = {
"default": { "default": {
"BACKEND": "wagtail.search.backends.database", "BACKEND": "wagtail.search.backends.database",

View File

@@ -10,6 +10,8 @@ python-dotenv~=1.0.0
dj-database-url~=2.2.0 dj-database-url~=2.2.0
django-tailwind~=3.8.0 django-tailwind~=3.8.0
django-csp~=3.8.0 django-csp~=3.8.0
django-htmx~=1.21.0
requests~=2.32.0
pytest~=8.3.0 pytest~=8.3.0
pytest-django~=4.9.0 pytest-django~=4.9.0
pytest-cov~=5.0.0 pytest-cov~=5.0.0

1
static/js/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -18,8 +18,10 @@
<script src="{% static 'js/theme.js' %}" defer></script> <script src="{% static 'js/theme.js' %}" defer></script>
<script src="{% static 'js/prism.js' %}" defer></script> <script src="{% static 'js/prism.js' %}" defer></script>
<script src="{% static 'js/newsletter.js' %}" defer></script> <script src="{% static 'js/newsletter.js' %}" defer></script>
<script src="{% static 'js/htmx.min.js' %}" nonce="{{ request.csp_nonce|default:'' }}" defer></script>
{% if turnstile_site_key %}<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer nonce="{{ request.csp_nonce|default:'' }}"></script>{% endif %}
</head> </head>
<body class="bg-brand-light dark:bg-brand-dark text-brand-dark dark:text-brand-light antialiased min-h-screen flex flex-col relative"> <body class="bg-brand-light dark:bg-brand-dark text-brand-dark dark:text-brand-light antialiased min-h-screen flex flex-col relative" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<div class="fixed inset-0 bg-grid-pattern pointer-events-none z-[-1]"></div> <div class="fixed inset-0 bg-grid-pattern pointer-events-none z-[-1]"></div>
{% include 'components/nav.html' %} {% include 'components/nav.html' %}
{% include 'components/cookie_banner.html' %} {% include 'components/cookie_banner.html' %}

View File

@@ -140,51 +140,15 @@
<!-- Comments --> <!-- Comments -->
{% if page.comments_enabled %} {% if page.comments_enabled %}
<section class="mt-16 pt-12 border-t border-zinc-200 dark:border-zinc-800"> <section class="mt-16 pt-12 border-t border-zinc-200 dark:border-zinc-800">
<div class="h-1 w-24 bg-gradient-to-r from-brand-cyan to-brand-pink mb-6"></div>
<h2 class="font-display font-bold text-3xl mb-8">Comments</h2> <h2 class="font-display font-bold text-3xl mb-8">Comments</h2>
{% if approved_comments %} {% if approved_comments %}
<div class="space-y-8 mb-12"> {% include "comments/_comment_list.html" %}
{% for comment in approved_comments %}
<article id="comment-{{ comment.id }}" class="bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-6">
<div class="flex items-center gap-3 mb-3">
<div class="w-8 h-8 bg-gradient-to-tr from-brand-cyan to-brand-pink shrink-0"></div>
<div>
<div class="font-display font-bold text-sm">{{ comment.author_name }}</div>
<div class="font-mono text-xs text-zinc-500">{{ comment.created_at|date:"M j, Y" }}</div>
</div>
</div>
<p class="text-zinc-700 dark:text-zinc-300 text-sm leading-relaxed">{{ comment.body }}</p>
{% for reply in comment.replies.all %}
<article id="comment-{{ reply.id }}" class="mt-6 ml-8 bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 p-4">
<div class="flex items-center gap-3 mb-2">
<div class="w-6 h-6 bg-gradient-to-tr from-brand-pink to-brand-cyan shrink-0"></div>
<div>
<div class="font-display font-bold text-sm">{{ reply.author_name }}</div>
<div class="font-mono text-xs text-zinc-500">{{ reply.created_at|date:"M j, Y" }}</div>
</div>
</div>
<p class="text-zinc-700 dark:text-zinc-300 text-sm leading-relaxed">{{ reply.body }}</p>
</article>
{% endfor %}
<form method="post" action="{% url 'comment_post' %}" class="mt-4 pt-4 border-t border-zinc-100 dark:border-zinc-800">
{% csrf_token %}
<input type="hidden" name="article_id" value="{{ page.id }}" />
<input type="hidden" name="parent_id" value="{{ comment.id }}" />
<div class="flex gap-3 mb-3">
<input type="text" name="author_name" required placeholder="Your name"
class="flex-1 bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
<input type="email" name="author_email" required placeholder="your@email.com"
class="flex-1 bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
</div>
<textarea name="body" required placeholder="Write a reply..." rows="2"
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors mb-3 resize-none"></textarea>
<input type="text" name="honeypot" hidden /> <button type="submit" class="px-4 py-2 bg-zinc-200 dark:bg-zinc-800 font-display font-bold text-sm hover:bg-brand-pink hover:text-white transition-colors">Reply</button>
</form>
</article>
{% endfor %}
</div>
{% else %} {% else %}
<p class="font-mono text-sm text-zinc-500 mb-12">No comments yet. Be the first to comment.</p> <div class="mb-12 p-8 bg-grid-pattern text-center">
<p class="font-mono text-sm text-zinc-500">No comments yet. Be the first to comment.</p>
</div>
{% endif %} {% endif %}
{% if comment_form and comment_form.errors %} {% if comment_form and comment_form.errors %}
@@ -194,32 +158,7 @@
</div> </div>
{% endif %} {% endif %}
<div class="bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-6"> {% include "comments/_comment_form.html" %}
<h3 class="font-display font-bold text-xl mb-6">Post a Comment</h3>
<form method="post" action="{% url 'comment_post' %}" data-comment-form class="space-y-4">
{% csrf_token %}
<input type="hidden" name="article_id" value="{{ page.id }}" />
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block font-mono text-xs text-zinc-500 mb-1 uppercase tracking-wider">Name *</label>
<input type="text" name="author_name" value="{% if comment_form %}{{ comment_form.author_name.value|default:'' }}{% endif %}" required
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
</div>
<div>
<label class="block font-mono text-xs text-zinc-500 mb-1 uppercase tracking-wider">Email *</label>
<input type="email" name="author_email" value="{% if comment_form %}{{ comment_form.author_email.value|default:'' }}{% endif %}" required
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
</div>
</div>
<div>
<label class="block font-mono text-xs text-zinc-500 mb-1 uppercase tracking-wider">Comment *</label>
<textarea name="body" required rows="5"
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors resize-none">{% if comment_form %}{{ comment_form.body.value|default:'' }}{% endif %}</textarea>
</div>
<input type="text" name="honeypot" hidden />
<button type="submit" class="px-6 py-3 bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark font-display font-bold hover:-translate-y-1 hover:shadow-solid-dark dark:hover:shadow-solid-light transition-all">Post comment</button>
</form>
</div>
</section> </section>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,24 @@
<article id="comment-{{ comment.id }}" class="bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-6 hover:shadow-solid-dark dark:hover:shadow-solid-light transition-all duration-300">
<div class="flex items-center gap-3 mb-3">
<div class="w-10 h-10 bg-gradient-to-tr from-brand-cyan to-brand-pink shrink-0 shadow-neon-cyan"></div>
<div>
<div class="font-display font-bold text-sm hover:text-brand-cyan transition-colors">{{ comment.author_name }}</div>
<div class="font-mono text-xs text-zinc-500">{{ comment.created_at|date:"M j, Y" }}</div>
</div>
</div>
<p class="text-zinc-700 dark:text-zinc-300 text-sm leading-relaxed">{{ comment.body }}</p>
{% include "comments/_reactions.html" with comment=comment counts=comment.reaction_counts user_reacted=comment.user_reacted %}
{% for reply in comment.replies.all %}
<article id="comment-{{ reply.id }}" class="mt-6 ml-8 bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 border-l-2 border-l-brand-cyan p-4">
<div class="flex items-center gap-3 mb-2">
<div class="w-7 h-7 bg-gradient-to-tr from-brand-pink to-brand-cyan shrink-0"></div>
<div>
<div class="font-display font-bold text-sm">{{ reply.author_name }}</div>
<div class="font-mono text-xs text-zinc-500">{{ reply.created_at|date:"M j, Y" }}</div>
</div>
</div>
<p class="text-zinc-700 dark:text-zinc-300 text-sm leading-relaxed">{{ reply.body }}</p>
</article>
{% endfor %}
{% include "comments/_reply_form.html" with page=page comment=comment %}
</article>

View File

@@ -0,0 +1,32 @@
{% load static %}
<div id="comment-form-container" class="bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-6">
<h3 class="font-display font-bold text-xl mb-6">Post a Comment</h3>
<form method="post" action="{% url 'comment_post' %}" data-comment-form class="space-y-4"
hx-post="{% url 'comment_post' %}" hx-target="#comments-list" hx-swap="beforeend"
hx-on::after-request="if(event.detail.successful) { this.reset(); document.getElementById('comment-success')?.remove(); this.insertAdjacentHTML('beforebegin', '<div id=&quot;comment-success&quot; class=&quot;mb-4 p-3 font-mono text-sm bg-brand-cyan/10 text-brand-cyan border border-brand-cyan/20&quot;>Comment posted!</div>'); }">
{% csrf_token %}
<input type="hidden" name="article_id" value="{{ page.id }}" />
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block font-mono text-xs text-zinc-500 mb-1 uppercase tracking-wider">Name *</label>
<input type="text" name="author_name" value="{% if comment_form %}{{ comment_form.author_name.value|default:'' }}{% endif %}" required
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink focus:shadow-neon-pink transition-colors" />
</div>
<div>
<label class="block font-mono text-xs text-zinc-500 mb-1 uppercase tracking-wider">Email *</label>
<input type="email" name="author_email" value="{% if comment_form %}{{ comment_form.author_email.value|default:'' }}{% endif %}" required
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink focus:shadow-neon-pink transition-colors" />
</div>
</div>
<div>
<label class="block font-mono text-xs text-zinc-500 mb-1 uppercase tracking-wider">Comment *</label>
<textarea name="body" required rows="5"
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink focus:shadow-neon-pink transition-colors resize-none">{% if comment_form %}{{ comment_form.body.value|default:'' }}{% endif %}</textarea>
</div>
<input type="text" name="honeypot" hidden />
{% if turnstile_site_key %}
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="auto"></div>
{% endif %}
<button type="submit" class="px-6 py-3 bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark font-display font-bold hover:-translate-y-1 hover:shadow-solid-dark dark:hover:shadow-solid-light transition-all">Post comment</button>
</form>
</div>

View File

@@ -0,0 +1,6 @@
<div id="comments-list" class="space-y-8 mb-12"
hx-get="{% url 'comment_poll' article_id=page.id %}" hx-trigger="every 30s" hx-swap="innerHTML">
{% for comment in approved_comments %}
{% include "comments/_comment.html" with comment=comment page=page %}
{% endfor %}
</div>

View File

@@ -0,0 +1,3 @@
{% for comment in approved_comments %}
{% include "comments/_comment.html" with comment=comment page=page %}
{% endfor %}

View File

@@ -0,0 +1,3 @@
<div id="comment-notice" class="mb-4 p-3 font-mono text-sm bg-brand-cyan/10 text-brand-cyan border border-brand-cyan/20">
{{ message|default:"Your comment has been posted and is awaiting moderation." }}
</div>

View File

@@ -0,0 +1,12 @@
<div class="flex gap-3 mt-3 items-center" id="reactions-{{ comment.id }}">
<button hx-post="{% url 'comment_react' comment.id %}" hx-target="#reactions-{{ comment.id }}" hx-swap="outerHTML"
hx-vals='{"reaction_type": "heart"}' class="flex items-center gap-1 font-mono text-xs {% if 'heart' in user_reacted %}text-brand-pink{% else %}text-zinc-400 hover:text-brand-pink{% endif %} transition-colors hover:scale-110 transition-transform">
<svg class="w-4 h-4" fill="{% if 'heart' in user_reacted %}currentColor{% else %}none{% endif %}" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" /></svg>
<span>{{ counts.heart|default:"0" }}</span>
</button>
<button hx-post="{% url 'comment_react' comment.id %}" hx-target="#reactions-{{ comment.id }}" hx-swap="outerHTML"
hx-vals='{"reaction_type": "plus_one"}' class="flex items-center gap-1 font-mono text-xs {% if 'plus_one' in user_reacted %}text-brand-cyan{% else %}text-zinc-400 hover:text-brand-cyan{% endif %} transition-colors hover:scale-110 transition-transform">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" d="M6.633 10.25c.806 0 1.533-.446 2.031-1.08a9.041 9.041 0 0 1 2.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 0 0 .322-1.672V2.75a.75.75 0 0 1 .75-.75 2.25 2.25 0 0 1 2.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282m0 0h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 0 1-2.649 7.521c-.388.482-.987.729-1.605.729H13.48c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 0 0-1.423-.23H5.904m10.598-9.75H14.25M5.904 18.5c.083.205.173.405.27.602.197.4-.078.898-.523.898h-.908c-.889 0-1.713-.518-1.972-1.368a12 12 0 0 1-.521-3.507c0-1.553.295-3.036.831-4.398C3.387 9.953 4.167 9.5 5 9.5h1.053c.472 0 .745.556.5.96a8.958 8.958 0 0 0-1.302 4.665c0 1.194.232 2.333.654 3.375Z" /></svg>
<span>{{ counts.plus_one|default:"0" }}</span>
</button>
</div>

View File

@@ -0,0 +1,20 @@
{% load static %}
<form method="post" action="{% url 'comment_post' %}" class="mt-4 pt-4 border-t border-zinc-100 dark:border-zinc-800"
hx-post="{% url 'comment_post' %}" hx-target="#comments-list" hx-swap="beforeend" hx-on::after-request="if(event.detail.successful) this.reset()">
{% csrf_token %}
<input type="hidden" name="article_id" value="{{ page.id }}" />
<input type="hidden" name="parent_id" value="{{ comment.id }}" />
<div class="flex gap-3 mb-3">
<input type="text" name="author_name" required placeholder="Your name"
class="flex-1 bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink focus:shadow-neon-pink transition-colors" />
<input type="email" name="author_email" required placeholder="your@email.com"
class="flex-1 bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink focus:shadow-neon-pink transition-colors" />
</div>
<textarea name="body" required placeholder="Write a reply..." rows="2"
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink focus:shadow-neon-pink transition-colors mb-3 resize-none"></textarea>
<input type="text" name="honeypot" hidden />
{% if turnstile_site_key %}
<div class="cf-turnstile mb-3" data-sitekey="{{ turnstile_site_key }}" data-theme="auto" data-size="compact"></div>
{% endif %}
<button type="submit" class="px-4 py-2 bg-zinc-200 dark:bg-zinc-800 font-display font-bold text-sm hover:bg-brand-pink hover:text-white transition-colors">Reply</button>
</form>

View File

@@ -0,0 +1,53 @@
{% extends 'wagtailadmin/bulk_actions/confirmation/base.html' %}
{% load i18n wagtailusers_tags wagtailadmin_tags %}
{% block titletag %}
{% if items|length == 1 %}
{% blocktrans trimmed with snippet_type_name=model_opts.verbose_name %}Unapprove {{ snippet_type_name }}{% endblocktrans %} - {{ items.0.item }}
{% else %}
{% blocktrans trimmed with count=items|length|intcomma %}Unapprove {{ count }} comments{% endblocktrans %}
{% endif %}
{% endblock %}
{% block header %}
{% trans "Unapprove" as unapprove_str %}
{% if items|length == 1 %}
{% include "wagtailadmin/shared/header.html" with title=unapprove_str subtitle=items.0.item icon=header_icon only %}
{% else %}
{% include "wagtailadmin/shared/header.html" with title=unapprove_str subtitle=model_opts.verbose_name_plural|capfirst icon=header_icon only %}
{% endif %}
{% endblock header %}
{% block items_with_access %}
{% if items %}
{% if items|length == 1 %}
<p>{% blocktrans trimmed with snippet_type_name=model_opts.verbose_name %}Unapprove this {{ snippet_type_name }}?{% endblocktrans %}</p>
{% else %}
<p>{% blocktrans trimmed with count=items|length|intcomma %}Unapprove {{ count }} selected comments?{% endblocktrans %}</p>
<ul>
{% for snippet in items %}
<li><a href="{{ snippet.edit_url }}" target="_blank" rel="noreferrer">{{ snippet.item }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% endif %}
{% endblock items_with_access %}
{% block items_with_no_access %}
{% if items_with_no_access|length == 1 %}
{% trans "You don't have permission to unapprove this comment" as no_access_msg %}
{% else %}
{% trans "You don't have permission to unapprove these comments" as no_access_msg %}
{% endif %}
{% include 'wagtailsnippets/bulk_actions/list_items_with_no_access.html' with items=items_with_no_access no_access_msg=no_access_msg %}
{% endblock items_with_no_access %}
{% block form_section %}
{% if items %}
{% trans "Yes, unapprove" as action_button_text %}
{% trans "No, go back" as no_action_button_text %}
{% include 'wagtailadmin/bulk_actions/confirmation/form.html' %}
{% else %}
{% include 'wagtailadmin/bulk_actions/confirmation/go_back.html' %}
{% endif %}
{% endblock form_section %}