django-heckle 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_heckle-0.1.0/.github/dependabot.yml +13 -0
- django_heckle-0.1.0/.github/workflows/ci.yml +38 -0
- django_heckle-0.1.0/.github/workflows/release.yml +23 -0
- django_heckle-0.1.0/.gitignore +8 -0
- django_heckle-0.1.0/CHANGELOG.md +11 -0
- django_heckle-0.1.0/LICENSE +28 -0
- django_heckle-0.1.0/PKG-INFO +153 -0
- django_heckle-0.1.0/README.md +122 -0
- django_heckle-0.1.0/pyproject.toml +64 -0
- django_heckle-0.1.0/src/heckle/__init__.py +1 -0
- django_heckle-0.1.0/src/heckle/admin.py +39 -0
- django_heckle-0.1.0/src/heckle/antispam.py +68 -0
- django_heckle-0.1.0/src/heckle/apps.py +8 -0
- django_heckle-0.1.0/src/heckle/avatars.py +169 -0
- django_heckle-0.1.0/src/heckle/conf.py +40 -0
- django_heckle-0.1.0/src/heckle/forms.py +104 -0
- django_heckle-0.1.0/src/heckle/migrations/0001_initial.py +42 -0
- django_heckle-0.1.0/src/heckle/migrations/__init__.py +0 -0
- django_heckle-0.1.0/src/heckle/models.py +73 -0
- django_heckle-0.1.0/src/heckle/moderation.py +81 -0
- django_heckle-0.1.0/src/heckle/rendering.py +24 -0
- django_heckle-0.1.0/src/heckle/signals.py +6 -0
- django_heckle-0.1.0/src/heckle/static/heckle/heckle.css +46 -0
- django_heckle-0.1.0/src/heckle/templates/heckle/_comment.html +6 -0
- django_heckle-0.1.0/src/heckle/templates/heckle/_fields.html +14 -0
- django_heckle-0.1.0/src/heckle/templates/heckle/_form.html +5 -0
- django_heckle-0.1.0/src/heckle/templates/heckle/_privacy.html +1 -0
- django_heckle-0.1.0/src/heckle/templates/heckle/comments.html +8 -0
- django_heckle-0.1.0/src/heckle/templates/heckle/email/moderate.txt +9 -0
- django_heckle-0.1.0/src/heckle/templates/heckle/moderate.html +21 -0
- django_heckle-0.1.0/src/heckle/templates/heckle/page.html +18 -0
- django_heckle-0.1.0/src/heckle/templates/heckle/posted.html +13 -0
- django_heckle-0.1.0/src/heckle/templatetags/__init__.py +0 -0
- django_heckle-0.1.0/src/heckle/templatetags/heckle_tags.py +63 -0
- django_heckle-0.1.0/src/heckle/urls.py +10 -0
- django_heckle-0.1.0/src/heckle/views.py +86 -0
- django_heckle-0.1.0/src/heckle/wagtail_hooks.py +29 -0
- django_heckle-0.1.0/tests/__init__.py +0 -0
- django_heckle-0.1.0/tests/conftest.py +43 -0
- django_heckle-0.1.0/tests/models.py +15 -0
- django_heckle-0.1.0/tests/settings.py +52 -0
- django_heckle-0.1.0/tests/templates/article.html +3 -0
- django_heckle-0.1.0/tests/test_avatars_and_admin.py +43 -0
- django_heckle-0.1.0/tests/test_comments.py +173 -0
- django_heckle-0.1.0/tests/urls.py +10 -0
- django_heckle-0.1.0/tests/views.py +9 -0
- django_heckle-0.1.0/uv.lock +969 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
push:
|
|
6
|
+
branches: [main]
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
strategy:
|
|
15
|
+
fail-fast: false
|
|
16
|
+
matrix:
|
|
17
|
+
python: ["3.12", "3.13", "3.14"]
|
|
18
|
+
django: ["5.2", "6.0", "6.1"]
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v7
|
|
21
|
+
- uses: astral-sh/setup-uv@v7
|
|
22
|
+
with:
|
|
23
|
+
python-version: ${{ matrix.python }}
|
|
24
|
+
- run: uv sync --group dev
|
|
25
|
+
- run: uv pip install "django~=${{ matrix.django }}.0"
|
|
26
|
+
- run: uv run --no-sync ruff check .
|
|
27
|
+
- run: uv run --no-sync ruff format --check .
|
|
28
|
+
- run: uv run --no-sync pytest
|
|
29
|
+
- name: No model changes without a migration
|
|
30
|
+
run: uv run --no-sync django-admin makemigrations heckle --check --dry-run --settings=tests.settings --pythonpath=.
|
|
31
|
+
|
|
32
|
+
build:
|
|
33
|
+
runs-on: ubuntu-latest
|
|
34
|
+
steps:
|
|
35
|
+
- uses: actions/checkout@v7
|
|
36
|
+
- uses: astral-sh/setup-uv@v7
|
|
37
|
+
- run: uv build
|
|
38
|
+
- run: uvx twine check --strict dist/*
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Publishes a release to PyPI when a tag such as v0.2.0 is pushed, by trusted
|
|
2
|
+
# publishing (no token is stored): on PyPI, the project's publisher is this
|
|
3
|
+
# repository, this workflow (release.yml) and the environment "pypi".
|
|
4
|
+
name: Release
|
|
5
|
+
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
tags: ["v*"]
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
publish:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
environment: pypi
|
|
14
|
+
permissions:
|
|
15
|
+
contents: read
|
|
16
|
+
id-token: write
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v7
|
|
19
|
+
- uses: astral-sh/setup-uv@v7
|
|
20
|
+
- name: The tag matches the version in pyproject.toml
|
|
21
|
+
run: test "v$(uv version --short)" = "$GITHUB_REF_NAME"
|
|
22
|
+
- run: uv build
|
|
23
|
+
- run: uv publish --trusted-publishing always
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (11 November 2026)
|
|
4
|
+
|
|
5
|
+
The first release.
|
|
6
|
+
|
|
7
|
+
- Comments on any object whose `comments_open` attribute is true, without an account, in Markdown cleaned of anything harmful.
|
|
8
|
+
- Pre-moderation from a signed link in the moderators' email (which gives the commenter's email address), and in Django's and Wagtail's admins; comments by staff approved at once and marked *Author*.
|
|
9
|
+
- Replies, one level deep by default (`HECKLE_MAX_DEPTH`).
|
|
10
|
+
- A honeypot field, a signed timestamp, a rate limit, a signed target, optional Cloudflare Turnstile, and a limit on links.
|
|
11
|
+
- Pictures for commenters drawn from a keyed hash of their email address, in four styles: Lissajous figure, atom, snowflake and emission spectrum.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Christian Hill
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: django-heckle
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Moderated comments for Django sites: anyone may comment, without an account; every comment waits for approval; bots are kept out.
|
|
5
|
+
Project-URL: Homepage, https://github.com/xnx/django-heckle
|
|
6
|
+
Project-URL: Changelog, https://github.com/xnx/django-heckle/blob/main/CHANGELOG.md
|
|
7
|
+
Author: Christian Hill
|
|
8
|
+
License-Expression: BSD-3-Clause
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: blog,comments,django,moderation,wagtail
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Web Environment
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Framework :: Django :: 5.2
|
|
15
|
+
Classifier: Framework :: Django :: 6.0
|
|
16
|
+
Classifier: Framework :: Django :: 6.1
|
|
17
|
+
Classifier: Framework :: Wagtail
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Message Boards
|
|
24
|
+
Requires-Python: >=3.12
|
|
25
|
+
Requires-Dist: django>=5.2
|
|
26
|
+
Requires-Dist: markdown>=3.5
|
|
27
|
+
Requires-Dist: nh3>=0.2.14
|
|
28
|
+
Provides-Extra: wagtail
|
|
29
|
+
Requires-Dist: wagtail>=7.0; extra == 'wagtail'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# django-heckle
|
|
33
|
+
|
|
34
|
+
The django-heckle package adds comments to the pages of a Django site. Anyone can comment, without an account; each comment waits for a moderator's approval, which is given from a link in an email. Bots and spam are kept out by a honeypot field, a signed timestamp, a rate limit and, optionally, Cloudflare Turnstile, with no tracking by a third party. Each commenter gets a picture drawn from a hash of their email address, in one of four scientific styles (a Lissajous figure, an atom, a snowflake or an emission spectrum). The name comes from *heckling*: interrupting a speaker with comments from the audience.
|
|
35
|
+
|
|
36
|
+
The package needs Python 3.12 or later and Django 5.2 or later, and works with or without Wagtail. It runs on the blog of https://scipython.com.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install django-heckle # or "django-heckle[wagtail]"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Add the app to `INSTALLED_APPS`, include its URLs and migrate:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
INSTALLED_APPS = [
|
|
48
|
+
...,
|
|
49
|
+
"heckle",
|
|
50
|
+
]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
urlpatterns = [
|
|
55
|
+
...,
|
|
56
|
+
path("comments/", include("heckle.urls")),
|
|
57
|
+
]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The site must be able to send email (Django's `MAILERS`, or `EMAIL_BACKEND` before Django 6.1, and `DEFAULT_FROM_EMAIL`), and should have a shared cache (such as Redis or the database cache) if it runs several processes, since the rate limit is kept in the cache.
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
Comments can be attached to an instance of any model, but only one whose `comments_open` attribute is true takes them, so a model opts in with a field such as
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
comments_open = models.BooleanField(default=True)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
A page's template shows its approved comments and the form for a new one with
|
|
71
|
+
|
|
72
|
+
```django
|
|
73
|
+
{% load heckle_tags %}
|
|
74
|
+
{% heckle_comments post %}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
and `{% heckle_count post %}` gives the number of approved comments (on an index of posts, for example). Each comment links to itself (`#comment-<id>`), and the section to `#comments`. If `comments_open` is false, the approved comments are still shown, with the form replaced by "Comments are closed."
|
|
78
|
+
|
|
79
|
+
## Moderation
|
|
80
|
+
|
|
81
|
+
A new comment is *pending*, and the moderators (`HECKLE_MODERATORS`, or the site's `MANAGERS`) are emailed its text, the commenter's name and email address (if given, also as the email's Reply-To), the page, and a link at which to approve it, reject it or mark it as spam. The link is signed, so it works without logging in, and it expires after `HECKLE_MODERATION_LINK_DAYS`. Opening it changes nothing until a button is pressed, which makes it safe from mail scanners that follow links.
|
|
82
|
+
|
|
83
|
+
Comments are also moderated in Django's admin and, on a Wagtail site, in Wagtail's admin under *Comments*. A comment posted by a logged-in member of staff (`is_staff`) is approved at once and marked *Author*.
|
|
84
|
+
|
|
85
|
+
A reply is a comment with a parent. Replies go one level deep by default (replies to comments, but not to replies); `HECKLE_MAX_DEPTH` allows more. Only an approved comment can be replied to.
|
|
86
|
+
|
|
87
|
+
## Defences against spam
|
|
88
|
+
|
|
89
|
+
- A *honeypot*: a field hidden from people, which bots fill in.
|
|
90
|
+
- A signed timestamp: a form sent back sooner than `HECKLE_MIN_SECONDS` after it was shown, or later than `HECKLE_MAX_FORM_AGE`, is refused.
|
|
91
|
+
- A rate limit of `HECKLE_RATE_LIMIT_PER_HOUR` comments per hour from each IP address (stored only as a hash).
|
|
92
|
+
- A signed target: the form names the object it belongs to in a signed token, so it cannot be pointed at another.
|
|
93
|
+
- [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/), if both of its keys are set.
|
|
94
|
+
- A comment with more than `HECKLE_MAX_LINKS` links is kept as spam, and nobody is emailed.
|
|
95
|
+
|
|
96
|
+
Comments are written in Markdown, of which only paragraphs, emphasis, links, lists, quotations and code are kept: the HTML is cleaned with [nh3](https://github.com/messense/nh3), and links are marked `rel="nofollow ugc noopener"`, which tells search engines not to credit them.
|
|
97
|
+
|
|
98
|
+
Staff are exempt from the rate limit and from Turnstile. The client's IP address is taken from `REMOTE_ADDR`, so a site behind a proxy must set it from the proxy's header (with middleware or the server's configuration).
|
|
99
|
+
|
|
100
|
+
## Avatars
|
|
101
|
+
|
|
102
|
+
The picture beside each comment is drawn from an HMAC (a hash keyed with the site's `SECRET_KEY`) of the commenter's email address, or of their name if they gave none. The same commenter always gets the same picture but, unlike a [Gravatar](https://gravatar.com)'s hash, the key reveals nothing about the address. `HECKLE_AVATAR` chooses the style:
|
|
103
|
+
|
|
104
|
+
- `"lissajous"`: a *Lissajous figure*, the curve traced by two oscillations at right angles, as on an oscilloscope;
|
|
105
|
+
- `"atom"`: a nucleus with one, two or three electron orbits;
|
|
106
|
+
- `"snowflake"`: a six-fold dendrite;
|
|
107
|
+
- `"spectrum"`: the bright lines of an emission spectrum, a commenter's "fingerprint".
|
|
108
|
+
|
|
109
|
+
It can also be the dotted path of a function that takes the key (32 bytes) and returns an SVG image, or `None` for no pictures.
|
|
110
|
+
|
|
111
|
+
## Settings
|
|
112
|
+
|
|
113
|
+
| Setting | Default | Meaning |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| `HECKLE_MODERATORS` | `[]` | The addresses emailed about each comment awaiting approval; the site's `MANAGERS` if empty |
|
|
116
|
+
| `HECKLE_FROM_EMAIL` | `""` | The address those emails are sent from; `DEFAULT_FROM_EMAIL` if empty |
|
|
117
|
+
| `HECKLE_MODERATION_LINK_DAYS` | `14` | How long the link in a moderator's email works, in days |
|
|
118
|
+
| `HECKLE_MAX_DEPTH` | `1` | How deep replies may go (1: replies to comments, but not to replies) |
|
|
119
|
+
| `HECKLE_MAX_LENGTH` | `5000` | The longest comment, in characters |
|
|
120
|
+
| `HECKLE_MAX_LINKS` | `3` | The most links a comment may have before it is kept as spam |
|
|
121
|
+
| `HECKLE_RATE_LIMIT_PER_HOUR` | `5` | Comments that each IP address may post per hour |
|
|
122
|
+
| `HECKLE_MIN_SECONDS` | `3` | The shortest time, in seconds, between showing a form and its being sent back |
|
|
123
|
+
| `HECKLE_MAX_FORM_AGE` | `86400` | The longest time, in seconds, for which a form can be sent back |
|
|
124
|
+
| `HECKLE_TURNSTILE_SITE_KEY`, `HECKLE_TURNSTILE_SECRET_KEY` | `""` | Cloudflare Turnstile's keys; it is used if both are set |
|
|
125
|
+
| `HECKLE_AVATAR` | `"lissajous"` | The style of picture (see "Avatars") |
|
|
126
|
+
|
|
127
|
+
## Templates and styles
|
|
128
|
+
|
|
129
|
+
The comments are drawn by `heckle/comments.html`, which includes `heckle/_comment.html` for each comment and `heckle/_form.html` for the form; the note under the form about what happens to an email address is `heckle/_privacy.html`. Any of these can be overridden. The pages of the app's own (a comment that could not be posted, and moderation) extend `heckle/page.html`, which by default is a page of its own. A site overrides it to put them inside its own pages:
|
|
130
|
+
|
|
131
|
+
```django
|
|
132
|
+
{% extends "base.html" %}
|
|
133
|
+
{% block title %}{% block heckle_title %}{% endblock %}{% endblock %}
|
|
134
|
+
{% block content %}{% block heckle_content %}{% endblock %}{% endblock %}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The app has its own stylesheet, with no dependence on a CSS framework. Its colours follow the page's text colour and `color-scheme` (light or dark), unless the site sets these custom properties on `.heckle`: `--heckle-ink`, `--heckle-muted`, `--heckle-line`, `--heckle-surface`, `--heckle-input`, `--heckle-accent` and `--heckle-accent-on`.
|
|
138
|
+
|
|
139
|
+
## Signals
|
|
140
|
+
|
|
141
|
+
`heckle.signals.comment_posted` is sent with `comment` and `request` when a comment has been posted (whatever its status), and `comment_moderated` with `comment` when a moderator has changed its status from the emailed link.
|
|
142
|
+
|
|
143
|
+
## Development
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
uv sync --group dev
|
|
147
|
+
uv run pytest
|
|
148
|
+
uv run ruff check . && uv run ruff format --check .
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Licence
|
|
152
|
+
|
|
153
|
+
BSD 3-Clause; see `LICENSE`.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# django-heckle
|
|
2
|
+
|
|
3
|
+
The django-heckle package adds comments to the pages of a Django site. Anyone can comment, without an account; each comment waits for a moderator's approval, which is given from a link in an email. Bots and spam are kept out by a honeypot field, a signed timestamp, a rate limit and, optionally, Cloudflare Turnstile, with no tracking by a third party. Each commenter gets a picture drawn from a hash of their email address, in one of four scientific styles (a Lissajous figure, an atom, a snowflake or an emission spectrum). The name comes from *heckling*: interrupting a speaker with comments from the audience.
|
|
4
|
+
|
|
5
|
+
The package needs Python 3.12 or later and Django 5.2 or later, and works with or without Wagtail. It runs on the blog of https://scipython.com.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install django-heckle # or "django-heckle[wagtail]"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Add the app to `INSTALLED_APPS`, include its URLs and migrate:
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
INSTALLED_APPS = [
|
|
17
|
+
...,
|
|
18
|
+
"heckle",
|
|
19
|
+
]
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
urlpatterns = [
|
|
24
|
+
...,
|
|
25
|
+
path("comments/", include("heckle.urls")),
|
|
26
|
+
]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The site must be able to send email (Django's `MAILERS`, or `EMAIL_BACKEND` before Django 6.1, and `DEFAULT_FROM_EMAIL`), and should have a shared cache (such as Redis or the database cache) if it runs several processes, since the rate limit is kept in the cache.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
Comments can be attached to an instance of any model, but only one whose `comments_open` attribute is true takes them, so a model opts in with a field such as
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
comments_open = models.BooleanField(default=True)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
A page's template shows its approved comments and the form for a new one with
|
|
40
|
+
|
|
41
|
+
```django
|
|
42
|
+
{% load heckle_tags %}
|
|
43
|
+
{% heckle_comments post %}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
and `{% heckle_count post %}` gives the number of approved comments (on an index of posts, for example). Each comment links to itself (`#comment-<id>`), and the section to `#comments`. If `comments_open` is false, the approved comments are still shown, with the form replaced by "Comments are closed."
|
|
47
|
+
|
|
48
|
+
## Moderation
|
|
49
|
+
|
|
50
|
+
A new comment is *pending*, and the moderators (`HECKLE_MODERATORS`, or the site's `MANAGERS`) are emailed its text, the commenter's name and email address (if given, also as the email's Reply-To), the page, and a link at which to approve it, reject it or mark it as spam. The link is signed, so it works without logging in, and it expires after `HECKLE_MODERATION_LINK_DAYS`. Opening it changes nothing until a button is pressed, which makes it safe from mail scanners that follow links.
|
|
51
|
+
|
|
52
|
+
Comments are also moderated in Django's admin and, on a Wagtail site, in Wagtail's admin under *Comments*. A comment posted by a logged-in member of staff (`is_staff`) is approved at once and marked *Author*.
|
|
53
|
+
|
|
54
|
+
A reply is a comment with a parent. Replies go one level deep by default (replies to comments, but not to replies); `HECKLE_MAX_DEPTH` allows more. Only an approved comment can be replied to.
|
|
55
|
+
|
|
56
|
+
## Defences against spam
|
|
57
|
+
|
|
58
|
+
- A *honeypot*: a field hidden from people, which bots fill in.
|
|
59
|
+
- A signed timestamp: a form sent back sooner than `HECKLE_MIN_SECONDS` after it was shown, or later than `HECKLE_MAX_FORM_AGE`, is refused.
|
|
60
|
+
- A rate limit of `HECKLE_RATE_LIMIT_PER_HOUR` comments per hour from each IP address (stored only as a hash).
|
|
61
|
+
- A signed target: the form names the object it belongs to in a signed token, so it cannot be pointed at another.
|
|
62
|
+
- [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/), if both of its keys are set.
|
|
63
|
+
- A comment with more than `HECKLE_MAX_LINKS` links is kept as spam, and nobody is emailed.
|
|
64
|
+
|
|
65
|
+
Comments are written in Markdown, of which only paragraphs, emphasis, links, lists, quotations and code are kept: the HTML is cleaned with [nh3](https://github.com/messense/nh3), and links are marked `rel="nofollow ugc noopener"`, which tells search engines not to credit them.
|
|
66
|
+
|
|
67
|
+
Staff are exempt from the rate limit and from Turnstile. The client's IP address is taken from `REMOTE_ADDR`, so a site behind a proxy must set it from the proxy's header (with middleware or the server's configuration).
|
|
68
|
+
|
|
69
|
+
## Avatars
|
|
70
|
+
|
|
71
|
+
The picture beside each comment is drawn from an HMAC (a hash keyed with the site's `SECRET_KEY`) of the commenter's email address, or of their name if they gave none. The same commenter always gets the same picture but, unlike a [Gravatar](https://gravatar.com)'s hash, the key reveals nothing about the address. `HECKLE_AVATAR` chooses the style:
|
|
72
|
+
|
|
73
|
+
- `"lissajous"`: a *Lissajous figure*, the curve traced by two oscillations at right angles, as on an oscilloscope;
|
|
74
|
+
- `"atom"`: a nucleus with one, two or three electron orbits;
|
|
75
|
+
- `"snowflake"`: a six-fold dendrite;
|
|
76
|
+
- `"spectrum"`: the bright lines of an emission spectrum, a commenter's "fingerprint".
|
|
77
|
+
|
|
78
|
+
It can also be the dotted path of a function that takes the key (32 bytes) and returns an SVG image, or `None` for no pictures.
|
|
79
|
+
|
|
80
|
+
## Settings
|
|
81
|
+
|
|
82
|
+
| Setting | Default | Meaning |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `HECKLE_MODERATORS` | `[]` | The addresses emailed about each comment awaiting approval; the site's `MANAGERS` if empty |
|
|
85
|
+
| `HECKLE_FROM_EMAIL` | `""` | The address those emails are sent from; `DEFAULT_FROM_EMAIL` if empty |
|
|
86
|
+
| `HECKLE_MODERATION_LINK_DAYS` | `14` | How long the link in a moderator's email works, in days |
|
|
87
|
+
| `HECKLE_MAX_DEPTH` | `1` | How deep replies may go (1: replies to comments, but not to replies) |
|
|
88
|
+
| `HECKLE_MAX_LENGTH` | `5000` | The longest comment, in characters |
|
|
89
|
+
| `HECKLE_MAX_LINKS` | `3` | The most links a comment may have before it is kept as spam |
|
|
90
|
+
| `HECKLE_RATE_LIMIT_PER_HOUR` | `5` | Comments that each IP address may post per hour |
|
|
91
|
+
| `HECKLE_MIN_SECONDS` | `3` | The shortest time, in seconds, between showing a form and its being sent back |
|
|
92
|
+
| `HECKLE_MAX_FORM_AGE` | `86400` | The longest time, in seconds, for which a form can be sent back |
|
|
93
|
+
| `HECKLE_TURNSTILE_SITE_KEY`, `HECKLE_TURNSTILE_SECRET_KEY` | `""` | Cloudflare Turnstile's keys; it is used if both are set |
|
|
94
|
+
| `HECKLE_AVATAR` | `"lissajous"` | The style of picture (see "Avatars") |
|
|
95
|
+
|
|
96
|
+
## Templates and styles
|
|
97
|
+
|
|
98
|
+
The comments are drawn by `heckle/comments.html`, which includes `heckle/_comment.html` for each comment and `heckle/_form.html` for the form; the note under the form about what happens to an email address is `heckle/_privacy.html`. Any of these can be overridden. The pages of the app's own (a comment that could not be posted, and moderation) extend `heckle/page.html`, which by default is a page of its own. A site overrides it to put them inside its own pages:
|
|
99
|
+
|
|
100
|
+
```django
|
|
101
|
+
{% extends "base.html" %}
|
|
102
|
+
{% block title %}{% block heckle_title %}{% endblock %}{% endblock %}
|
|
103
|
+
{% block content %}{% block heckle_content %}{% endblock %}{% endblock %}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The app has its own stylesheet, with no dependence on a CSS framework. Its colours follow the page's text colour and `color-scheme` (light or dark), unless the site sets these custom properties on `.heckle`: `--heckle-ink`, `--heckle-muted`, `--heckle-line`, `--heckle-surface`, `--heckle-input`, `--heckle-accent` and `--heckle-accent-on`.
|
|
107
|
+
|
|
108
|
+
## Signals
|
|
109
|
+
|
|
110
|
+
`heckle.signals.comment_posted` is sent with `comment` and `request` when a comment has been posted (whatever its status), and `comment_moderated` with `comment` when a moderator has changed its status from the emailed link.
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
uv sync --group dev
|
|
116
|
+
uv run pytest
|
|
117
|
+
uv run ruff check . && uv run ruff format --check .
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Licence
|
|
121
|
+
|
|
122
|
+
BSD 3-Clause; see `LICENSE`.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "django-heckle"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Moderated comments for Django sites: anyone may comment, without an account; every comment waits for approval; bots are kept out."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "BSD-3-Clause"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "Christian Hill" }]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
keywords = ["django", "wagtail", "comments", "moderation", "blog"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Environment :: Web Environment",
|
|
14
|
+
"Framework :: Django",
|
|
15
|
+
"Framework :: Django :: 5.2",
|
|
16
|
+
"Framework :: Django :: 6.0",
|
|
17
|
+
"Framework :: Django :: 6.1",
|
|
18
|
+
"Framework :: Wagtail",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Message Boards",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"django>=5.2",
|
|
28
|
+
"markdown>=3.5",
|
|
29
|
+
"nh3>=0.2.14",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
wagtail = ["wagtail>=7.0"]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/xnx/django-heckle"
|
|
37
|
+
Changelog = "https://github.com/xnx/django-heckle/blob/main/CHANGELOG.md"
|
|
38
|
+
|
|
39
|
+
[dependency-groups]
|
|
40
|
+
dev = ["pytest", "pytest-django", "ruff"]
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["hatchling>=1.27"]
|
|
44
|
+
build-backend = "hatchling.build"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["src/heckle"]
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
51
|
+
pythonpath = ["."]
|
|
52
|
+
testpaths = ["tests"]
|
|
53
|
+
# A warning raised in the package's own code, such as of a deprecation, fails.
|
|
54
|
+
filterwarnings = ["error:::heckle"]
|
|
55
|
+
|
|
56
|
+
[tool.ruff]
|
|
57
|
+
line-length = 88
|
|
58
|
+
target-version = "py312"
|
|
59
|
+
src = ["src", "."]
|
|
60
|
+
extend-exclude = ["*/migrations/*"]
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
select = ["E", "F", "I", "B", "UP", "DJ"]
|
|
64
|
+
ignore = ["E501"] # line length is left to `ruff format`
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Moderated comments for Django sites."""
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Moderation in Django's admin. (On a Wagtail site, wagtail_hooks.py adds comments
|
|
2
|
+
to Wagtail's admin too.)"""
|
|
3
|
+
|
|
4
|
+
from django.contrib import admin
|
|
5
|
+
|
|
6
|
+
from .models import Comment
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@admin.register(Comment)
|
|
10
|
+
class CommentAdmin(admin.ModelAdmin):
|
|
11
|
+
list_display = ["__str__", "name", "email", "target", "status", "created"]
|
|
12
|
+
list_filter = ["status"]
|
|
13
|
+
search_fields = ["name", "email", "body"]
|
|
14
|
+
readonly_fields = [
|
|
15
|
+
"content_type",
|
|
16
|
+
"object_pk",
|
|
17
|
+
"parent",
|
|
18
|
+
"user",
|
|
19
|
+
"by_staff",
|
|
20
|
+
"created",
|
|
21
|
+
]
|
|
22
|
+
actions = ["approve", "reject", "mark_as_spam"]
|
|
23
|
+
|
|
24
|
+
def _set(self, queryset, status):
|
|
25
|
+
for comment in queryset:
|
|
26
|
+
comment.status = status
|
|
27
|
+
comment.save(update_fields=["status"])
|
|
28
|
+
|
|
29
|
+
@admin.action(description="Approve")
|
|
30
|
+
def approve(self, request, queryset):
|
|
31
|
+
self._set(queryset, Comment.Status.APPROVED)
|
|
32
|
+
|
|
33
|
+
@admin.action(description="Reject")
|
|
34
|
+
def reject(self, request, queryset):
|
|
35
|
+
self._set(queryset, Comment.Status.REJECTED)
|
|
36
|
+
|
|
37
|
+
@admin.action(description="Mark as spam")
|
|
38
|
+
def mark_as_spam(self, request, queryset):
|
|
39
|
+
self._set(queryset, Comment.Status.SPAM)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Keeping bots out: a signed timestamp, a limit per visitor, and Cloudflare
|
|
2
|
+
Turnstile. (The honeypot is a form field: see forms.py.)"""
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import urllib.request
|
|
9
|
+
|
|
10
|
+
from django.core import signing
|
|
11
|
+
from django.core.cache import cache
|
|
12
|
+
|
|
13
|
+
from .conf import setting
|
|
14
|
+
|
|
15
|
+
TURNSTILE_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def client_ip(request):
|
|
19
|
+
"""The visitor's address: the first in X-Forwarded-For (set by the proxy in
|
|
20
|
+
front of the site), else the connection's."""
|
|
21
|
+
forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
|
|
22
|
+
return forwarded.split(",")[0].strip() or request.META.get("REMOTE_ADDR", "")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def issued():
|
|
26
|
+
"""A signed timestamp for a new form."""
|
|
27
|
+
return signing.dumps(time.time(), salt="heckle.issued")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def form_age(token):
|
|
31
|
+
"""The seconds since the form was issued, or None if the token is forged or
|
|
32
|
+
too old."""
|
|
33
|
+
try:
|
|
34
|
+
when = signing.loads(
|
|
35
|
+
token, salt="heckle.issued", max_age=setting("MAX_FORM_AGE")
|
|
36
|
+
)
|
|
37
|
+
except signing.BadSignature:
|
|
38
|
+
return None
|
|
39
|
+
return time.time() - float(when)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def rate_limited(ip):
|
|
43
|
+
"""Counts this comment from the address; True once over the limit for the
|
|
44
|
+
hour. The address itself is not kept, only a hash of it, in the cache."""
|
|
45
|
+
key = "heckle:" + hashlib.sha256(ip.encode()).hexdigest()
|
|
46
|
+
cache.add(key, 0, timeout=60 * 60)
|
|
47
|
+
try:
|
|
48
|
+
count = cache.incr(key)
|
|
49
|
+
except ValueError:
|
|
50
|
+
count = 1
|
|
51
|
+
return count > setting("RATE_LIMIT_PER_HOUR")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def turnstile_enabled():
|
|
55
|
+
return bool(setting("TURNSTILE_SITE_KEY") and setting("TURNSTILE_SECRET_KEY"))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def turnstile_passed(token, ip):
|
|
59
|
+
"""Whether Cloudflare accepts the widget's token. If Cloudflare cannot be
|
|
60
|
+
reached, the comment is refused (the visitor can try again)."""
|
|
61
|
+
data = urllib.parse.urlencode(
|
|
62
|
+
{"secret": setting("TURNSTILE_SECRET_KEY"), "response": token, "remoteip": ip}
|
|
63
|
+
).encode()
|
|
64
|
+
try:
|
|
65
|
+
with urllib.request.urlopen(TURNSTILE_URL, data=data, timeout=5) as response:
|
|
66
|
+
return bool(json.load(response).get("success"))
|
|
67
|
+
except (OSError, ValueError):
|
|
68
|
+
return False
|