commentail 0.1.0__py3-none-any.whl
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.
- commentail/__init__.py +1 -0
- commentail/admin_urls.py +28 -0
- commentail/admin_views.py +660 -0
- commentail/admission.py +201 -0
- commentail/apps.py +19 -0
- commentail/assets.py +21 -0
- commentail/avatars.py +80 -0
- commentail/checks.py +8 -0
- commentail/config.py +193 -0
- commentail/detection.py +89 -0
- commentail/drafts.py +95 -0
- commentail/forms.py +138 -0
- commentail/group_permissions.py +110 -0
- commentail/limits.py +96 -0
- commentail/locale/ru/LC_MESSAGES/django.mo +0 -0
- commentail/locale/ru/LC_MESSAGES/django.po +825 -0
- commentail/logging.py +24 -0
- commentail/management/__init__.py +0 -0
- commentail/management/commands/__init__.py +0 -0
- commentail/management/commands/process_commentail.py +24 -0
- commentail/middleware.py +27 -0
- commentail/migrations/0001_initial.py +408 -0
- commentail/migrations/0002_sitegrouppermission_hide_sitegrouppermission_view.py +24 -0
- commentail/migrations/0003_actiontoken_captchachallenge_guestgrant.py +104 -0
- commentail/migrations/0004_submissionrecord.py +55 -0
- commentail/migrations/0005_rateevent.py +48 -0
- commentail/migrations/0006_alter_comment_guest_name_and_more.py +24 -0
- commentail/migrations/0007_actiontoken_nonce_replysubscription_mailjob.py +108 -0
- commentail/migrations/0008_mailjob_revision.py +25 -0
- commentail/migrations/0009_formticket.py +39 -0
- commentail/migrations/0010_captchachallenge_backend_and_more.py +25 -0
- commentail/migrations/0011_discussion_content_type_discussion_object_id_and_more.py +68 -0
- commentail/migrations/0012_formticket_draft.py +21 -0
- commentail/migrations/0013_sitecommentsettings_email_enabled.py +21 -0
- commentail/migrations/__init__.py +0 -0
- commentail/models.py +344 -0
- commentail/objects.py +266 -0
- commentail/outbox.py +526 -0
- commentail/page_integration.py +137 -0
- commentail/panels.py +15 -0
- commentail/permissions.py +31 -0
- commentail/policies.py +80 -0
- commentail/public.py +719 -0
- commentail/rules_csv.py +164 -0
- commentail/security.py +290 -0
- commentail/services.py +487 -0
- commentail/signals.py +27 -0
- commentail/static/commentail/admin.css +62 -0
- commentail/static/commentail/admin.js +27 -0
- commentail/static/commentail/commentail.css +198 -0
- commentail/static/commentail/commentail.js +322 -0
- commentail/structured.py +95 -0
- commentail/templates/commentail/action.html +23 -0
- commentail/templates/commentail/admin/detail.html +5 -0
- commentail/templates/commentail/admin/form.html +13 -0
- commentail/templates/commentail/admin/group_permission_row.html +4 -0
- commentail/templates/commentail/admin/group_permissions.html +13 -0
- commentail/templates/commentail/admin/list.html +9 -0
- commentail/templates/commentail/admin/mail.html +9 -0
- commentail/templates/commentail/admin/objects.html +8 -0
- commentail/templates/commentail/admin/page_panel.html +1 -0
- commentail/templates/commentail/admin/rules.html +25 -0
- commentail/templates/commentail/admin/rules_import.html +24 -0
- commentail/templates/commentail/base.html +23 -0
- commentail/templates/commentail/block.html +1 -0
- commentail/templates/commentail/captcha.html +1 -0
- commentail/templates/commentail/comment.html +1 -0
- commentail/templates/commentail/edit-form.html +13 -0
- commentail/templates/commentail/emails/confirm.txt +7 -0
- commentail/templates/commentail/emails/confirm_subject.txt +1 -0
- commentail/templates/commentail/emails/edit.txt +5 -0
- commentail/templates/commentail/emails/edit_subject.txt +1 -0
- commentail/templates/commentail/emails/published.txt +3 -0
- commentail/templates/commentail/emails/published_subject.txt +1 -0
- commentail/templates/commentail/emails/rejected.txt +5 -0
- commentail/templates/commentail/emails/rejected_subject.txt +1 -0
- commentail/templates/commentail/emails/reply.txt +6 -0
- commentail/templates/commentail/emails/reply_subject.txt +1 -0
- commentail/templates/commentail/form.html +1 -0
- commentail/templates/commentail/replies.html +1 -0
- commentail/templatetags/__init__.py +0 -0
- commentail/templatetags/commentail_tags.py +80 -0
- commentail/text.py +7 -0
- commentail/urls.py +15 -0
- commentail/views.py +159 -0
- commentail/wagtail_hooks.py +44 -0
- commentail-0.1.0.dist-info/METADATA +201 -0
- commentail-0.1.0.dist-info/RECORD +91 -0
- commentail-0.1.0.dist-info/WHEEL +5 -0
- commentail-0.1.0.dist-info/licenses/LICENSE +21 -0
- commentail-0.1.0.dist-info/top_level.txt +1 -0
commentail/admission.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Request authorization boundary for the future server-rendered form adapter.
|
|
2
|
+
|
|
3
|
+
Do not bypass this boundary with direct calls to trusted domain services.
|
|
4
|
+
Views must still enforce POST and CSRF through Django middleware/decorators.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import secrets
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
|
|
10
|
+
from django.conf import settings
|
|
11
|
+
from django.core.mail import get_connection
|
|
12
|
+
from django.core.mail.backends.smtp import EmailBackend
|
|
13
|
+
from django.db import transaction
|
|
14
|
+
from django.db.models import F
|
|
15
|
+
from django.http import HttpRequest
|
|
16
|
+
from django.utils import timezone
|
|
17
|
+
from wagtail.models import Site
|
|
18
|
+
|
|
19
|
+
from .config import technical_settings
|
|
20
|
+
from .limits import client_ip, consume
|
|
21
|
+
from .models import Access, Comment, Discussion, FormTicket, Mode, SubmissionRecord
|
|
22
|
+
from .policies import editorial_settings, email_enabled
|
|
23
|
+
from .security import digest, remembered_guest, verify_captcha
|
|
24
|
+
from .services import DomainError, _text, confirm_email, submit_once
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def session_digest(request: HttpRequest) -> str:
|
|
28
|
+
if not request.session.session_key:
|
|
29
|
+
request.session.create()
|
|
30
|
+
return digest(request.session.session_key)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def can_view(request: HttpRequest, discussion: Discussion) -> bool:
|
|
34
|
+
if not discussion.page_id:
|
|
35
|
+
from .objects import ObjectAccessError, checked_site, target_for
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
return (
|
|
39
|
+
checked_site(target_for(discussion), request).pk == discussion.site_id
|
|
40
|
+
)
|
|
41
|
+
except ObjectAccessError:
|
|
42
|
+
return False
|
|
43
|
+
page = discussion.page
|
|
44
|
+
return page.live and all(
|
|
45
|
+
restriction.accept_request(request)
|
|
46
|
+
for restriction in page.get_view_restrictions()
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def smtp_configured() -> bool:
|
|
51
|
+
return (
|
|
52
|
+
isinstance(get_connection(), EmailBackend)
|
|
53
|
+
and settings.is_overridden("EMAIL_HOST")
|
|
54
|
+
and bool(settings.EMAIL_HOST)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def issue_form(request: HttpRequest, discussion: Discussion) -> str:
|
|
59
|
+
if not can_view(request, discussion):
|
|
60
|
+
raise DomainError("Page access is required")
|
|
61
|
+
secret = secrets.token_urlsafe(32)
|
|
62
|
+
FormTicket.objects.create(
|
|
63
|
+
digest=digest(secret),
|
|
64
|
+
discussion=discussion,
|
|
65
|
+
session_digest=session_digest(request),
|
|
66
|
+
expires_at=timezone.now()
|
|
67
|
+
+ timedelta(seconds=technical_settings(discussion.site)["FORM_TICKET_SECONDS"]),
|
|
68
|
+
)
|
|
69
|
+
return secret
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def accept_request(
|
|
73
|
+
request: HttpRequest,
|
|
74
|
+
discussion_id: int,
|
|
75
|
+
key: str,
|
|
76
|
+
*,
|
|
77
|
+
text: str,
|
|
78
|
+
guest_name: str = "",
|
|
79
|
+
guest_email: str = "",
|
|
80
|
+
reply_to: int | None = None,
|
|
81
|
+
challenge_id: str = "",
|
|
82
|
+
answer: str = "",
|
|
83
|
+
subscribe: bool = False,
|
|
84
|
+
) -> Comment:
|
|
85
|
+
"""Validate access on every attempt, including already-accepted form replays.
|
|
86
|
+
|
|
87
|
+
Incorrect CAPTCHA attempts commit before DomainError is raised, so the
|
|
88
|
+
attempts counter cannot be reset by rolling back a rejected submission.
|
|
89
|
+
A successful CAPTCHA and accepted comment share a transaction.
|
|
90
|
+
"""
|
|
91
|
+
site = Site.find_for_request(request)
|
|
92
|
+
if site is None:
|
|
93
|
+
raise DomainError("Site cannot be determined")
|
|
94
|
+
failure = None
|
|
95
|
+
result = None
|
|
96
|
+
with transaction.atomic():
|
|
97
|
+
# Lock site-wide quotas before any reads in this admission transaction.
|
|
98
|
+
if not Site.objects.filter(pk=site.pk).update(port=F("port")):
|
|
99
|
+
raise DomainError("Site does not exist")
|
|
100
|
+
discussion = (
|
|
101
|
+
Discussion.objects.select_related("site", "page")
|
|
102
|
+
.filter(pk=discussion_id, site=site)
|
|
103
|
+
.first()
|
|
104
|
+
)
|
|
105
|
+
if discussion is None or not can_view(request, discussion):
|
|
106
|
+
raise DomainError("Page access is required")
|
|
107
|
+
options = editorial_settings(discussion)
|
|
108
|
+
if options.mode != Mode.OPEN:
|
|
109
|
+
raise DomainError("Discussion is closed")
|
|
110
|
+
ticket = FormTicket.objects.filter(
|
|
111
|
+
digest=digest(key),
|
|
112
|
+
discussion=discussion,
|
|
113
|
+
session_digest=session_digest(request),
|
|
114
|
+
expires_at__gt=timezone.now(),
|
|
115
|
+
).first()
|
|
116
|
+
if ticket is None:
|
|
117
|
+
raise DomainError("Invalid or expired form")
|
|
118
|
+
user = request.user if request.user.is_authenticated else None
|
|
119
|
+
if user is not None and not user.is_active:
|
|
120
|
+
raise DomainError("Account cannot comment")
|
|
121
|
+
if (
|
|
122
|
+
options.access == Access.NONE
|
|
123
|
+
or (user is None and options.access == Access.REGISTERED)
|
|
124
|
+
or (user is not None and options.access == Access.GUESTS)
|
|
125
|
+
):
|
|
126
|
+
raise DomainError("Author cannot comment")
|
|
127
|
+
mail_available = email_enabled(site.pk) and smtp_configured()
|
|
128
|
+
subscribe = mail_available and bool(
|
|
129
|
+
guest_email.strip() if user is None else getattr(user, "email", "")
|
|
130
|
+
)
|
|
131
|
+
if user is None and not mail_available:
|
|
132
|
+
raise DomainError("Guest comments require configured SMTP")
|
|
133
|
+
actor = (
|
|
134
|
+
"user:" + str(user.pk)
|
|
135
|
+
if user is not None
|
|
136
|
+
else "guest:" + guest_email.strip().casefold()
|
|
137
|
+
)
|
|
138
|
+
if SubmissionRecord.objects.filter(
|
|
139
|
+
discussion=discussion, actor_digest=digest(actor), key_digest=digest(key)
|
|
140
|
+
).exists():
|
|
141
|
+
return submit_once(
|
|
142
|
+
discussion.pk,
|
|
143
|
+
key,
|
|
144
|
+
text=text,
|
|
145
|
+
user=user,
|
|
146
|
+
guest_name=guest_name,
|
|
147
|
+
guest_email=guest_email,
|
|
148
|
+
reply_to=reply_to,
|
|
149
|
+
subscribe=subscribe,
|
|
150
|
+
language=getattr(request, "LANGUAGE_CODE", settings.LANGUAGE_CODE),
|
|
151
|
+
)
|
|
152
|
+
_text(text, options)
|
|
153
|
+
grant = (
|
|
154
|
+
remembered_guest(
|
|
155
|
+
request.COOKIES.get(f"commentail_guest_{site.pk}", ""), site
|
|
156
|
+
)
|
|
157
|
+
if user is None
|
|
158
|
+
else None
|
|
159
|
+
)
|
|
160
|
+
remembered = (
|
|
161
|
+
grant is not None
|
|
162
|
+
and grant.email.strip().casefold() == guest_email.strip().casefold()
|
|
163
|
+
)
|
|
164
|
+
if (
|
|
165
|
+
user is None
|
|
166
|
+
and not remembered
|
|
167
|
+
and not verify_captcha(request, site, challenge_id, answer)
|
|
168
|
+
):
|
|
169
|
+
failure = "Incorrect or expired CAPTCHA"
|
|
170
|
+
else:
|
|
171
|
+
consume(site, "comment", actor, client_ip(request, site))
|
|
172
|
+
checks = technical_settings(site)["GUEST_REQUEST_CHECKS"]
|
|
173
|
+
suspicious = user is None and (
|
|
174
|
+
(
|
|
175
|
+
"missing_user_agent" in checks
|
|
176
|
+
and not request.META.get("HTTP_USER_AGENT", "").strip()
|
|
177
|
+
)
|
|
178
|
+
or (
|
|
179
|
+
"cross_site" in checks
|
|
180
|
+
and request.META.get("HTTP_SEC_FETCH_SITE") == "cross-site"
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
result = submit_once(
|
|
184
|
+
discussion.pk,
|
|
185
|
+
key,
|
|
186
|
+
text=text,
|
|
187
|
+
user=user,
|
|
188
|
+
guest_name=guest_name,
|
|
189
|
+
guest_email=guest_email,
|
|
190
|
+
reply_to=reply_to,
|
|
191
|
+
subscribe=subscribe,
|
|
192
|
+
suspicious=suspicious,
|
|
193
|
+
language=getattr(request, "LANGUAGE_CODE", settings.LANGUAGE_CODE),
|
|
194
|
+
)
|
|
195
|
+
if user is None and remembered:
|
|
196
|
+
result = confirm_email(result.pk)
|
|
197
|
+
if failure:
|
|
198
|
+
raise DomainError(failure)
|
|
199
|
+
if result is None:
|
|
200
|
+
raise DomainError("Submission was not accepted")
|
|
201
|
+
return result
|
commentail/apps.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from importlib import import_module
|
|
2
|
+
|
|
3
|
+
from django.apps import AppConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CommentailConfig(AppConfig):
|
|
7
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
8
|
+
name = "commentail"
|
|
9
|
+
|
|
10
|
+
def ready(self) -> None:
|
|
11
|
+
import_module("commentail.checks")
|
|
12
|
+
import_module("commentail.signals")
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
from .logging import CapabilityFilter
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("django.server")
|
|
18
|
+
if not any(isinstance(item, CapabilityFilter) for item in logger.filters):
|
|
19
|
+
logger.addFilter(CapabilityFilter())
|
commentail/assets.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Content-versioned package resources shared by public tags and admin forms."""
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from hashlib import sha256
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from django.templatetags.static import static
|
|
9
|
+
|
|
10
|
+
Asset = Literal["admin.css", "admin.js", "commentail.css", "commentail.js"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@lru_cache(maxsize=4)
|
|
14
|
+
def asset_url(name: Asset) -> str:
|
|
15
|
+
if name not in ("admin.css", "admin.js", "commentail.css", "commentail.js"):
|
|
16
|
+
raise ValueError("Unknown Commentail asset")
|
|
17
|
+
version = sha256(
|
|
18
|
+
files("commentail").joinpath("static", "commentail", name).read_bytes()
|
|
19
|
+
).hexdigest()[:12]
|
|
20
|
+
url = static("commentail/" + name)
|
|
21
|
+
return url + ("&" if "?" in url else "?") + "v=" + version
|
commentail/avatars.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Deterministic initials/gradient and a safe read-only avatar callback."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from django.contrib.auth.models import AbstractBaseUser
|
|
7
|
+
from django.db.models import Model
|
|
8
|
+
from django.http import HttpRequest
|
|
9
|
+
from django.utils.module_loading import import_string
|
|
10
|
+
from wagtail.models import Site
|
|
11
|
+
|
|
12
|
+
from .config import technical_settings
|
|
13
|
+
from .models import Comment
|
|
14
|
+
from .objects import target_for
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("commentail")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class CommentContext:
|
|
21
|
+
id: int
|
|
22
|
+
display_name: str
|
|
23
|
+
user: AbstractBaseUser | None
|
|
24
|
+
site: Site
|
|
25
|
+
target: Model
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def default_avatar(name: str) -> tuple[str, str]:
|
|
29
|
+
words = name.strip().split()
|
|
30
|
+
initials = (
|
|
31
|
+
(words[0][:2] if len(words) == 1 else words[0][0] + words[-1][0]).upper()
|
|
32
|
+
if words
|
|
33
|
+
else ""
|
|
34
|
+
)
|
|
35
|
+
if not initials:
|
|
36
|
+
return "?", "linear-gradient(135deg, hsl(220 85% 56%), hsl(265 85% 60%))"
|
|
37
|
+
hashed = 2166136261
|
|
38
|
+
for letter in initials:
|
|
39
|
+
hashed = ((hashed ^ ord(letter)) * 16777619) & 0xFFFFFFFF
|
|
40
|
+
hue = hashed % 360
|
|
41
|
+
second = (hue + 45 + ((hashed >> 8) % 90)) % 360
|
|
42
|
+
gradient = f"linear-gradient(135deg, hsl({hue} {78 + hashed % 12}% {48 + ((hashed >> 12) % 8)}%), hsl({second} {80 + ((hashed >> 4) % 12)}% {50 + ((hashed >> 20) % 8)}%))"
|
|
43
|
+
return initials, gradient
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def author_name(comment: Comment, request: HttpRequest) -> str:
|
|
47
|
+
path = technical_settings(comment.discussion.site)["GET_AUTHOR_NAME"]
|
|
48
|
+
if path and comment.user_id:
|
|
49
|
+
try:
|
|
50
|
+
value = import_string(path)(comment.user, request)
|
|
51
|
+
if isinstance(value, str) and value.strip():
|
|
52
|
+
return value.strip()
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
logger.exception(
|
|
55
|
+
"Author name handler failed (%s)", type(exc).__name__, exc_info=False
|
|
56
|
+
)
|
|
57
|
+
return comment.display_name
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def avatar_url(comment: Comment, request: HttpRequest, name: str) -> str | None:
|
|
61
|
+
path = technical_settings(comment.discussion.site)["GET_AVATAR"]
|
|
62
|
+
if not path:
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
value = import_string(path)(
|
|
66
|
+
CommentContext(
|
|
67
|
+
comment.pk,
|
|
68
|
+
name,
|
|
69
|
+
comment.user,
|
|
70
|
+
comment.discussion.site,
|
|
71
|
+
target_for(comment.discussion),
|
|
72
|
+
),
|
|
73
|
+
request,
|
|
74
|
+
)
|
|
75
|
+
return value if isinstance(value, str) and value else None
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
logger.exception(
|
|
78
|
+
"Avatar handler failed (%s)", type(exc).__name__, exc_info=False
|
|
79
|
+
)
|
|
80
|
+
return None
|
commentail/checks.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from django.core.checks import Error, register
|
|
2
|
+
|
|
3
|
+
from .config import configuration_errors
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@register()
|
|
7
|
+
def check_settings(app_configs: object, **kwargs: object) -> list[Error]:
|
|
8
|
+
return [Error(message, id="commentail.E001") for message in configuration_errors()]
|
commentail/config.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Technical settings inherit library defaults, default, then Site.hostname."""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
from typing import Literal, TypedDict
|
|
5
|
+
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
8
|
+
from wagtail.models import Site
|
|
9
|
+
|
|
10
|
+
StructuredDataMode = Literal["both", "json_ld", "microdata", "off"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TechnicalSettings(TypedDict):
|
|
14
|
+
STRUCTURED_DATA: StructuredDataMode
|
|
15
|
+
OBJECT_ADAPTERS: dict[str, str]
|
|
16
|
+
RULES_CSV_MAX_BYTES: int
|
|
17
|
+
RULES_CSV_MAX_ROWS: int
|
|
18
|
+
FORM_TICKET_SECONDS: int
|
|
19
|
+
EDIT_WINDOW_SECONDS: int
|
|
20
|
+
EMAIL_CONFIRM_SECONDS: int
|
|
21
|
+
GUEST_GRANT_SECONDS: int
|
|
22
|
+
CAPTCHA_SECONDS: int
|
|
23
|
+
CAPTCHA_ATTEMPTS: int
|
|
24
|
+
AUTHOR_MIN_INTERVAL: int
|
|
25
|
+
AUTHOR_LIMIT_PER_HOUR: int
|
|
26
|
+
IP_LIMIT_PER_HOUR: int
|
|
27
|
+
RESEND_MIN_INTERVAL: int
|
|
28
|
+
RESEND_LIMIT_PER_HOUR: int
|
|
29
|
+
RESEND_IP_LIMIT_PER_HOUR: int
|
|
30
|
+
CAPTCHA_IP_LIMIT_PER_MINUTE: int
|
|
31
|
+
CAPTCHA_IP_LIMIT_PER_HOUR: int
|
|
32
|
+
TRUSTED_PROXIES: list[str]
|
|
33
|
+
GUEST_REQUEST_CHECKS: list[str]
|
|
34
|
+
MAIL_RETENTION_SECONDS: int
|
|
35
|
+
MAIL_LEASE_SECONDS: int
|
|
36
|
+
MAIL_RETRY_SECONDS: list[int]
|
|
37
|
+
ACTION_LINK_SECONDS: int
|
|
38
|
+
CAPTCHA_BACKEND: str | None
|
|
39
|
+
GET_AVATAR: str | None
|
|
40
|
+
GET_AUTHOR_NAME: str | None
|
|
41
|
+
GET_COMMENT_TEMPLATES: str | None
|
|
42
|
+
CAN_SEND_GUEST_EMAIL: str | None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
DEFAULTS: TechnicalSettings = {
|
|
46
|
+
"STRUCTURED_DATA": "both",
|
|
47
|
+
"OBJECT_ADAPTERS": {},
|
|
48
|
+
"RULES_CSV_MAX_BYTES": 5242880,
|
|
49
|
+
"RULES_CSV_MAX_ROWS": 20000,
|
|
50
|
+
"FORM_TICKET_SECONDS": 3600,
|
|
51
|
+
"EDIT_WINDOW_SECONDS": 900,
|
|
52
|
+
"EMAIL_CONFIRM_SECONDS": 86400,
|
|
53
|
+
"GUEST_GRANT_SECONDS": 2592000,
|
|
54
|
+
"CAPTCHA_SECONDS": 300,
|
|
55
|
+
"CAPTCHA_ATTEMPTS": 5,
|
|
56
|
+
"AUTHOR_MIN_INTERVAL": 30,
|
|
57
|
+
"AUTHOR_LIMIT_PER_HOUR": 20,
|
|
58
|
+
"IP_LIMIT_PER_HOUR": 100,
|
|
59
|
+
"RESEND_MIN_INTERVAL": 60,
|
|
60
|
+
"RESEND_LIMIT_PER_HOUR": 5,
|
|
61
|
+
"RESEND_IP_LIMIT_PER_HOUR": 20,
|
|
62
|
+
"CAPTCHA_IP_LIMIT_PER_MINUTE": 10,
|
|
63
|
+
"CAPTCHA_IP_LIMIT_PER_HOUR": 100,
|
|
64
|
+
"TRUSTED_PROXIES": [],
|
|
65
|
+
"GUEST_REQUEST_CHECKS": ["missing_user_agent", "cross_site"],
|
|
66
|
+
"MAIL_RETENTION_SECONDS": 604800,
|
|
67
|
+
"MAIL_LEASE_SECONDS": 300,
|
|
68
|
+
"MAIL_RETRY_SECONDS": [60, 300, 900],
|
|
69
|
+
"ACTION_LINK_SECONDS": 2592000,
|
|
70
|
+
"CAN_SEND_GUEST_EMAIL": None,
|
|
71
|
+
"GET_COMMENT_TEMPLATES": None,
|
|
72
|
+
"GET_AVATAR": None,
|
|
73
|
+
"CAPTCHA_BACKEND": None,
|
|
74
|
+
"GET_AUTHOR_NAME": None,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def configuration_errors() -> list[str]:
|
|
79
|
+
config = getattr(settings, "COMMENTAIL", {})
|
|
80
|
+
if not isinstance(config, dict):
|
|
81
|
+
return ["COMMENTAIL must be a dictionary"]
|
|
82
|
+
errors: list[str] = []
|
|
83
|
+
for hostname, values in config.items():
|
|
84
|
+
if (
|
|
85
|
+
not isinstance(hostname, str)
|
|
86
|
+
or not hostname
|
|
87
|
+
or not isinstance(values, dict)
|
|
88
|
+
):
|
|
89
|
+
errors.append(
|
|
90
|
+
"COMMENTAIL sections must have string names and dictionary values"
|
|
91
|
+
)
|
|
92
|
+
continue
|
|
93
|
+
for key, value in values.items():
|
|
94
|
+
if key not in DEFAULTS:
|
|
95
|
+
errors.append(f"Unknown COMMENTAIL key: {key}")
|
|
96
|
+
elif key == "STRUCTURED_DATA":
|
|
97
|
+
if not isinstance(value, str) or value not in (
|
|
98
|
+
"both", "json_ld", "microdata", "off"
|
|
99
|
+
):
|
|
100
|
+
errors.append("STRUCTURED_DATA must be both, json_ld, microdata or off")
|
|
101
|
+
elif key == "OBJECT_ADAPTERS":
|
|
102
|
+
from .objects import registry_errors
|
|
103
|
+
|
|
104
|
+
if hostname != "default":
|
|
105
|
+
errors.append(
|
|
106
|
+
"OBJECT_ADAPTERS is project-wide; configure it in default"
|
|
107
|
+
)
|
|
108
|
+
errors.extend(registry_errors(value))
|
|
109
|
+
elif key.startswith("RULES_CSV_") and hostname != "default":
|
|
110
|
+
errors.append(f"{key} is project-wide; configure it in default")
|
|
111
|
+
elif key == "CAPTCHA_BACKEND":
|
|
112
|
+
if value is not None:
|
|
113
|
+
from django.utils.module_loading import import_string
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
backend = (
|
|
117
|
+
import_string(value) if isinstance(value, str) else None
|
|
118
|
+
)
|
|
119
|
+
if (
|
|
120
|
+
not isinstance(backend, type)
|
|
121
|
+
or len(value) > 255
|
|
122
|
+
or not all(
|
|
123
|
+
callable(getattr(backend, method, None))
|
|
124
|
+
for method in ("create", "verify")
|
|
125
|
+
)
|
|
126
|
+
):
|
|
127
|
+
errors.append(
|
|
128
|
+
"CAPTCHA_BACKEND must be an import path to a class with create and verify methods"
|
|
129
|
+
)
|
|
130
|
+
except (ImportError, AttributeError):
|
|
131
|
+
errors.append("CAPTCHA_BACKEND cannot be imported")
|
|
132
|
+
elif key in {
|
|
133
|
+
"CAN_SEND_GUEST_EMAIL",
|
|
134
|
+
"GET_COMMENT_TEMPLATES",
|
|
135
|
+
"GET_AVATAR",
|
|
136
|
+
"GET_AUTHOR_NAME",
|
|
137
|
+
}:
|
|
138
|
+
if value is not None:
|
|
139
|
+
from django.utils.module_loading import import_string
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
if not isinstance(value, str) or not callable(
|
|
143
|
+
import_string(value)
|
|
144
|
+
):
|
|
145
|
+
errors.append(f"{key} must be an import path to a callable")
|
|
146
|
+
except (ImportError, AttributeError):
|
|
147
|
+
errors.append(f"{key} cannot be imported")
|
|
148
|
+
elif key == "GUEST_REQUEST_CHECKS":
|
|
149
|
+
if (
|
|
150
|
+
not isinstance(value, list)
|
|
151
|
+
or not all(
|
|
152
|
+
isinstance(item, str)
|
|
153
|
+
and item in {"missing_user_agent", "cross_site"}
|
|
154
|
+
for item in value
|
|
155
|
+
)
|
|
156
|
+
or len(value) != len(set(value))
|
|
157
|
+
):
|
|
158
|
+
errors.append(
|
|
159
|
+
"GUEST_REQUEST_CHECKS must contain unique known signal names: missing_user_agent, cross_site"
|
|
160
|
+
)
|
|
161
|
+
elif key == "MAIL_RETRY_SECONDS":
|
|
162
|
+
if not isinstance(value, list) or not all(
|
|
163
|
+
type(item) is int and item > 0 for item in value
|
|
164
|
+
):
|
|
165
|
+
errors.append(
|
|
166
|
+
"MAIL_RETRY_SECONDS must contain positive integer delays"
|
|
167
|
+
)
|
|
168
|
+
elif key == "TRUSTED_PROXIES":
|
|
169
|
+
if not isinstance(value, list) or not all(
|
|
170
|
+
isinstance(item, str) for item in value
|
|
171
|
+
):
|
|
172
|
+
errors.append("TRUSTED_PROXIES must be a list of network strings")
|
|
173
|
+
else:
|
|
174
|
+
for item in value:
|
|
175
|
+
try:
|
|
176
|
+
ipaddress.ip_network(item)
|
|
177
|
+
except ValueError:
|
|
178
|
+
errors.append("Invalid TRUSTED_PROXIES network")
|
|
179
|
+
elif type(value) is not int or value <= 0:
|
|
180
|
+
errors.append(f"{key} must be a positive integer")
|
|
181
|
+
return errors
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def technical_settings(site: Site | None) -> TechnicalSettings:
|
|
185
|
+
errors = configuration_errors()
|
|
186
|
+
if errors:
|
|
187
|
+
raise ImproperlyConfigured("; ".join(errors))
|
|
188
|
+
config = getattr(settings, "COMMENTAIL", {})
|
|
189
|
+
return {
|
|
190
|
+
**DEFAULTS,
|
|
191
|
+
**config.get("default", {}),
|
|
192
|
+
**(config.get(site.hostname, {}) if site else {}),
|
|
193
|
+
}
|
commentail/detection.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
2
|
+
from django.http import HttpRequest
|
|
3
|
+
from django.template import Context, NodeList, TemplateDoesNotExist, TemplateSyntaxError
|
|
4
|
+
from django.template.loader import get_template
|
|
5
|
+
from django.template.loader_tags import ExtendsNode, IncludeNode
|
|
6
|
+
from django.utils.module_loading import import_string
|
|
7
|
+
from wagtail.models import Page, Site
|
|
8
|
+
|
|
9
|
+
from .config import configuration_errors, technical_settings
|
|
10
|
+
from .templatetags.commentail_tags import CommentailNode
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DetectionError(ValueError):
|
|
14
|
+
"""The library cannot establish the effective template chain."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def effective_settings(site: Site | None) -> dict[str, object]:
|
|
18
|
+
if errors := configuration_errors():
|
|
19
|
+
raise ImproperlyConfigured("; ".join(errors))
|
|
20
|
+
if site:
|
|
21
|
+
return dict(technical_settings(site))
|
|
22
|
+
from django.conf import settings
|
|
23
|
+
|
|
24
|
+
return dict(getattr(settings, "COMMENTAIL", {}).get("default", {}))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def has_comments(page: Page, request: HttpRequest) -> bool:
|
|
28
|
+
"""Inspect compiled nodes without rendering user templates or evaluating conditions.
|
|
29
|
+
|
|
30
|
+
A configured resolver must enumerate unresolved dynamic dependencies. Template
|
|
31
|
+
nodes are version-sensitive internals covered by integration tests.
|
|
32
|
+
"""
|
|
33
|
+
config = effective_settings(Site.find_for_request(request))
|
|
34
|
+
resolver_path = config.get("GET_COMMENT_TEMPLATES")
|
|
35
|
+
resolver = import_string(str(resolver_path)) if resolver_path else None
|
|
36
|
+
try:
|
|
37
|
+
names = resolver(page, request) if resolver else [page.get_template(request)]
|
|
38
|
+
except Exception as exc:
|
|
39
|
+
# Do not expose handler exception payloads in editorial messages.
|
|
40
|
+
raise DetectionError("Comment template handler failed") from exc
|
|
41
|
+
if not isinstance(names, (list, tuple)) or not all(
|
|
42
|
+
isinstance(n, str) for n in names
|
|
43
|
+
):
|
|
44
|
+
raise DetectionError("GET_COMMENT_TEMPLATES must return template names")
|
|
45
|
+
seen: set[str] = set()
|
|
46
|
+
|
|
47
|
+
def load(name: str) -> bool:
|
|
48
|
+
if name in seen:
|
|
49
|
+
return False
|
|
50
|
+
seen.add(name)
|
|
51
|
+
compiled = get_template(name).template
|
|
52
|
+
return scan(compiled.nodelist)
|
|
53
|
+
|
|
54
|
+
def dependency(expression: object) -> bool:
|
|
55
|
+
if getattr(expression, "is_var", True):
|
|
56
|
+
if resolver:
|
|
57
|
+
return False # Resolver enumerates this dependency in names.
|
|
58
|
+
raise DetectionError(
|
|
59
|
+
"Dynamic template dependency requires GET_COMMENT_TEMPLATES"
|
|
60
|
+
)
|
|
61
|
+
value = expression.resolve(Context())
|
|
62
|
+
candidates = [value] if isinstance(value, str) else list(value)
|
|
63
|
+
for name in candidates:
|
|
64
|
+
try:
|
|
65
|
+
return load(name)
|
|
66
|
+
except TemplateDoesNotExist:
|
|
67
|
+
continue
|
|
68
|
+
raise DetectionError("Included template not found")
|
|
69
|
+
|
|
70
|
+
def scan(nodes: NodeList) -> bool:
|
|
71
|
+
for node in nodes:
|
|
72
|
+
if isinstance(node, CommentailNode):
|
|
73
|
+
return True
|
|
74
|
+
if isinstance(node, ExtendsNode) and dependency(node.parent_name):
|
|
75
|
+
return True
|
|
76
|
+
if isinstance(node, IncludeNode) and dependency(node.template):
|
|
77
|
+
return True
|
|
78
|
+
# Presence controls configuration, even in overridden blocks or
|
|
79
|
+
# false conditions. Rendering is deliberately not simulated.
|
|
80
|
+
for attr in getattr(node, "child_nodelists", ()):
|
|
81
|
+
children = getattr(node, attr, None)
|
|
82
|
+
if isinstance(children, NodeList) and scan(children):
|
|
83
|
+
return True
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
return any(load(name) for name in names)
|
|
88
|
+
except (TemplateDoesNotExist, TemplateSyntaxError) as exc:
|
|
89
|
+
raise DetectionError(str(exc)) from exc
|