Harden for launch: limits, CSP, moderation, admin 2FA (Phase 5)
Some checks failed
ci / test (push) Failing after 1m6s
ci / deploy (push) Failing after 0s

- Rate limits in the shared cache for comments, issues, uploads and
  invites; a limited HTMX reply keeps the draft.
- Content-Security-Policy (self only, no inline script or style, bucket
  origin for images and frames) and Permissions-Policy on every page.
  htmx configured not to inject styles or eval.
- Moderation: deactivating a user ends their sessions and keeps their
  content; deleted accounts show as "ghost"; reserved usernames; the
  admin won't delete an org's last owner.
- Admin requires a TOTP code (django-otp), with add_admin_totp to enrol.
- Email provider chosen by environment through django-anymail.
- Playwright browser tests: axe WCAG 2.1 AA on every page in light and
  dark, no CSP violations, keyboard walkthrough, theme toggle. Explicit
  Canvas/CanvasText on :root fixes dark-mode contrast detection.
- bin/start creates the data dir if missing; docs/operations.md runbook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alec K2XAP 2026-10-27 10:51:06 -04:00
parent f6b7374be7
commit 604c2bbf91
41 changed files with 1033 additions and 57 deletions

View file

@ -7,3 +7,4 @@ __pycache__
.pytest_cache
.ruff_cache
docs
tests

View file

@ -17,6 +17,8 @@ jobs:
- run: uv sync --locked
- run: uv run ruff check .
- run: uv run ruff format --check .
# Browser for the accessibility, CSP and keyboard tests in tests/browser.
- run: uv run playwright install --with-deps chromium
- run: uv run pytest
deploy:

View file

@ -23,20 +23,12 @@ Then open http://localhost:8000. Data lives in `data/` (uv) or the `data` volume
Checks:
```sh
uv run playwright install chromium # once, for the real-browser tests
uv run ruff check . && uv run ruff format --check . && uv run pytest
```
`uv run pytest -m "not browser"` skips the browser tests for a quicker run.
## Production
Fly.io, one machine with a volume at `/data`. `bin/start` restores the database from B2 if the volume is empty, runs migrations, and starts gunicorn under Litestream.
Secrets (`fly secrets set`):
| Name | Purpose |
|---|---|
| `DJANGO_SECRET_KEY` | Django secret key |
| `LITESTREAM_BUCKET`, `LITESTREAM_ENDPOINT`, `LITESTREAM_ACCESS_KEY_ID`, `LITESTREAM_SECRET_ACCESS_KEY` | B2 backup bucket |
| `BUCKET_NAME`, `AWS_*` | Tigris uploads; set by `fly storage create` |
| `SENTRY_DSN` | Optional error reporting |
Restore drill: create a fresh volume, deploy, and confirm `bin/start` logs a restore before migrating.
Fly.io, one machine with a volume at `/data`; Litestream backs the database up to B2 and uploads go to Tigris. Deploying, secrets, administrators, email, moderation and the restore drill are in [docs/operations.md](docs/operations.md).

View file

@ -5,7 +5,8 @@ set -eu
# The volume is mounted owned by root. Take ownership for the app user, then rerun as it.
if [ "$(id -u)" = 0 ]; then
chown -R app:app "${INTENT_DATA_DIR:?}"
mkdir -p "${INTENT_DATA_DIR:?}"
chown -R app:app "$INTENT_DATA_DIR"
exec setpriv --reuid=app --regid=app --init-groups env HOME=/home/app "$0" "$@"
fi

View file

@ -17,6 +17,8 @@ INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django_otp",
"django_otp.plugins.otp_totp",
"allauth",
"allauth.account",
"allauth.socialaccount",
@ -39,10 +41,12 @@ MIDDLEWARE = [
# First, so health checks skip host validation and the HTTPS redirect.
"intent.core.middleware.HealthCheckMiddleware",
"django.middleware.security.SecurityMiddleware",
"intent.core.middleware.SecurityHeadersMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django_otp.middleware.OTPMiddleware",
"allauth.account.middleware.AccountMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
@ -101,6 +105,11 @@ AUTHENTICATION_BACKENDS = [
"allauth.account.auth_backends.AuthenticationBackend",
]
# The admin asks for a code from an authenticator app as well as the password.
# Add a device with `manage.py add_admin_totp <username>`. Off only in local development.
ADMIN_REQUIRE_OTP = True
OTP_TOTP_ISSUER = "Intent admin"
LOGIN_URL = "account_login"
LOGIN_REDIRECT_URL = "home"
@ -124,8 +133,16 @@ SOCIALACCOUNT_PROVIDERS = {
"github": {"SCOPE": ["read:user", "user:email"]},
}
# Provider is TBD (see concept.md); console output until then.
# Provider is TBD (see concept.md); console output until then. Any Anymail provider works
# without code changes: set EMAIL_BACKEND to its backend and pass its settings as ANYMAIL_*
# variables, e.g. EMAIL_BACKEND=anymail.backends.postmark.EmailBackend with
# ANYMAIL_POSTMARK_SERVER_TOKEN=... (see docs/operations.md).
EMAIL_BACKEND = os.environ.get("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend")
ANYMAIL = {
key.removeprefix("ANYMAIL_"): value
for key, value in os.environ.items()
if key.startswith("ANYMAIL_")
}
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "Intent <intent@localhost>")
AUTH_PASSWORD_VALIDATORS = [
@ -153,6 +170,8 @@ if os.environ.get("BUCKET_NAME"):
"bucket_name": os.environ["BUCKET_NAME"],
"endpoint_url": os.environ.get("AWS_ENDPOINT_URL_S3"),
"region_name": os.environ.get("AWS_REGION", "auto"),
# Bucket in the hostname, matching CSP_BUCKET_ORIGIN below.
"addressing_style": "virtual",
"default_acl": None,
"querystring_auth": True,
"querystring_expire": 300,
@ -165,6 +184,14 @@ else:
"OPTIONS": {"location": DATA_DIR / "media"},
}
# The bucket's origin, so pages may show images and frame prototypes from signed URLs.
# Tigris signed URLs are virtual-hosted: https://<bucket>.fly.storage.tigris.dev.
CSP_BUCKET_ORIGIN = os.environ.get("CSP_BUCKET_ORIGIN", "")
if not CSP_BUCKET_ORIGIN and os.environ.get("BUCKET_NAME"):
_endpoint = os.environ.get("AWS_ENDPOINT_URL_S3", "https://fly.storage.tigris.dev")
_scheme, _, _host = _endpoint.partition("://")
CSP_BUCKET_ORIGIN = f"{_scheme}://{os.environ['BUCKET_NAME']}.{_host.rstrip('/')}"
STORAGES = {
"default": _default_storage,
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},

View file

@ -1,3 +1,5 @@
import os
from .base import * # noqa: F403
DEBUG = True
@ -7,3 +9,6 @@ ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]"]
DATA_DIR.mkdir(exist_ok=True) # noqa: F405
# Avoid needing `createcachetable` for local development.
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
# Plain password admin locally, unless you want to try the real thing.
ADMIN_REQUIRE_OTP = os.environ.get("ADMIN_REQUIRE_OTP") == "1"

View file

@ -1,5 +1,6 @@
import allauth.urls
from allauth.account import urls as allauth_account_urls
from django.conf import settings
from django.contrib import admin
from django.urls import URLResolver, include, path
@ -22,6 +23,13 @@ account_urlpatterns = [
for p in allauth.urls.urlpatterns
]
if settings.ADMIN_REQUIRE_OTP:
from django_otp.admin import OTPAdminSite
# Same site instance and URL names; it now also requires a verified OTP device.
admin.site.__class__ = OTPAdminSite
admin.site.login_template = OTPAdminSite.login_template
urlpatterns = [
path("", views.home, name="home"),
path("admin/", admin.site.urls),

129
docs/operations.md Normal file
View file

@ -0,0 +1,129 @@
# Operating Intent
How to deploy, configure and look after a production Intent. For what the app does, see
[concept.md](concept.md); for how it's built, [plan.md](plan.md).
## Shape
One Fly.io machine with a volume at `/data` holding the SQLite database. Litestream streams
the database to a Backblaze B2 bucket continuously. Uploaded files go to a private Tigris
bucket. `bin/start` runs as root only long enough to take ownership of the volume, then as
the `app` user it:
1. restores the database from B2 if the volume has none (a fresh volume),
2. runs migrations and creates the cache table,
3. starts gunicorn under `litestream replicate`.
## Secrets and settings
Set with `fly secrets set NAME=value`. Non-secret settings live in `fly.toml`.
| Name | Required | Purpose |
|---|---|---|
| `DJANGO_SECRET_KEY` | yes | Signs sessions and tokens. Long and random. |
| `DJANGO_ALLOWED_HOSTS` | yes (`fly.toml`) | Comma-separated hostnames. Also sets the trusted CSRF origins. |
| `EMAIL_BACKEND` | yes | Production refuses to start with the console backend. See Email. |
| `ANYMAIL_*` | with Anymail | The provider's settings, e.g. `ANYMAIL_POSTMARK_SERVER_TOKEN`. |
| `DEFAULT_FROM_EMAIL` | yes | e.g. `Intent <intent@example.org>`. Must be a sender the provider allows. |
| `LITESTREAM_BUCKET`, `LITESTREAM_ENDPOINT`, `LITESTREAM_ACCESS_KEY_ID`, `LITESTREAM_SECRET_ACCESS_KEY` | yes | B2 backup bucket and an application key limited to it. |
| `BUCKET_NAME`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_ENDPOINT_URL_S3`, `AWS_REGION` | yes | Tigris uploads. `fly storage create` sets these. |
| `CSP_BUCKET_ORIGIN` | no | Only if signed URLs aren't `https://<bucket>.fly.storage.tigris.dev`. |
| `SENTRY_DSN` | no | Error reporting. |
## First deploy
1. Replace the `CHANGEME` values in `fly.toml` (app name, region, host).
2. `fly launch --no-deploy`, then `fly volumes create intent_data --size 1`.
3. `fly storage create` for the Tigris bucket.
4. Set the secrets above.
5. `fly deploy`. Check `fly logs` for "running without backups" (Litestream not configured) or a restore message.
6. `fly ssh console`, then create the first administrator (next section).
## Administrators
Administrators are separate from regular users: they sign in only at `/admin/`, with a
password and a code from an authenticator app, and can't join organizations.
```sh
fly ssh console
cd /app
python manage.py createsuperuser
python manage.py add_admin_totp <username>
```
The second command prints an `otpauth://` link. Add it to an authenticator app (most can scan
it if you paste it into any QR generator you trust, or enter the secret by hand). If someone
loses their device, run it again with `--replace`.
Only superusers can make other accounts staff or give out permissions.
In local development the admin uses a password only. To try the real flow, run with
`ADMIN_REQUIRE_OTP=1`.
## Organizations and sign-in providers
- **Organizations** are created in the admin (Organizations → Add), naming the first owner. They invite everyone else.
- **GitHub and Discord sign-in:** in the admin, Social applications → Add, with the provider's client ID and secret.
- **A Forgejo instance:** register an OAuth2 application on the instance with the redirect URL `https://<host>/accounts/oidc/<provider_id>/login/callback/`, then add a Social application with provider "OpenID Connect", the same `provider_id`, the client ID and secret, and under Settings `{"server_url": "https://<forgejo-host>"}`. No deploy is needed.
## Email
Sign-in codes and email invitations need working email. Any provider
[Anymail](https://anymail.dev) supports works without code changes. For example, Postmark:
```sh
fly secrets set \
EMAIL_BACKEND=anymail.backends.postmark.EmailBackend \
ANYMAIL_POSTMARK_SERVER_TOKEN=... \
DEFAULT_FROM_EMAIL="Intent <intent@example.org>"
```
Then send a test and sign in with an emailed code:
```sh
fly ssh console -C "python /app/manage.py sendtestemail you@example.org"
```
## Moderation
- **Delete a comment:** the author, or an owner of the project, from the comment itself. It leaves a "Comment deleted" marker.
- **Close an issue:** any project collaborator or owner.
- **Block a person:** admin → Users → select → "Deactivate". They're signed out everywhere and can't sign in; what they wrote stays under their name. "Reactivate" undoes it.
- **Delete an account:** admin → the user → Delete. What they wrote stays, shown as written by "ghost". The admin won't delete an organization's last owner; make someone else an owner first.
## Limits
Per person: 30 comments or new threads per 10 minutes, 20 new issues per hour, 30 file uploads
per hour, 30 invitations per hour (and 3 per address per organization). allauth limits
sign-in attempts and code requests itself. Limits live in the database cache and reset when
their window ends. Change them in `intent/core/throttle.py`.
## Backups and the restore drill
Litestream keeps B2 within about a second of the database. Fly volume snapshots (daily by
default; check with `fly volumes snapshots list <volume-id>`) are a second, independent copy.
Run the restore drill before launch and after any change to `bin/start` or `litestream.yml`:
1. Note a recent change in the app, e.g. a comment you just posted.
2. Create a new volume and a new machine with it, from the same image:
`fly volumes create intent_data --size 1 --region <region>` then
`fly machine clone <machine-id> --attach-volume <new-volume-id>:/data`.
3. In `fly logs` for the new machine, confirm a Litestream restore happens before "Applying migrations" (or "No migrations to apply").
4. `fly ssh console --machine <new-machine-id>` and check the change from step 1 is there:
`python manage.py shell -c "from intent.comments.models import Comment; print(Comment.objects.latest('created_at').body)"`.
5. Destroy the clone and its volume. Two machines must never write to the same B2 path for long.
To recover for real, stop the machine, destroy its volume, create a new one, and start the
machine: `bin/start` restores from B2 on an empty volume.
## Tests
```sh
uv run playwright install chromium # once per machine
uv run pytest # everything, including real-browser tests
uv run pytest -m "not browser" # fast run without the browser
```
The browser tests run axe (WCAG 2.1 A and AA) on every page in light and dark mode, check
there are no CSP violations or script errors, and walk the main flow by keyboard.

View file

@ -201,6 +201,16 @@ Each phase ends deployable. Rough sizes assume one person, part-time; adjust fre
- Backup restore drill repeated against the real volume.
- **Exit:** launch checklist (below) complete.
Done in code; what remains needs production (see the checklist and `docs/operations.md`):
- **Email:** `django-anymail` installed. The provider is chosen entirely by environment (`EMAIL_BACKEND` plus `ANYMAIL_*` variables), so picking one needs no code change. Still TBD.
- **Moderation:** deactivating a user in the admin blocks sign-in and ends their sessions; their content stays. Deleted accounts' content shows as "ghost" (authors are `SET_NULL`; no placeholder user row), and `ghost` and other official-looking usernames are reserved. The admin refuses to delete an organization's last owner, and bulk delete is off for users so that check always applies.
- **Rate limits:** one fixed-window helper (`intent/core/throttle.py`) in the shared database cache: comments and new threads 30 per 10 minutes, issues 20 per hour, uploads 30 per hour, invites as before. A limited HTMX reply keeps the draft.
- **CSP and headers:** every page gets `default-src 'self'`, no inline script or style, `object-src 'none'`, `base-uri 'none'`, `frame-ancestors 'self'`, and the bucket origin for images and frames when one is configured. No `form-action`, because sign-in with an external provider posts a form that redirects off-site. htmx is configured not to inject styles or eval. Also `Permissions-Policy`. File responses keep their own stricter sandbox CSP.
- **Admin second factor:** `django-otp` TOTP on the admin site, with `manage.py add_admin_totp <user>` to enrol. Off by default only in local development.
- **Browser tests** (`tests/browser`, marked `browser`): axe WCAG 2.1 A/AA on every page in light and dark, signed in and out; no CSP violations or script errors on any page including the admin; a keyboard-only walkthrough (skip link, line comment, HTMX reply with focus); the theme toggle. axe found that the page had no explicit background, so tools assumed white in dark mode; `:root` now sets `Canvas`/`CanvasText`. CI installs Chromium.
- **Restore drill:** written up in `docs/operations.md`; needs the real volume and B2.
## 6. Launch checklist
- [ ] Email provider configured; magic link and invite emails tested in production
@ -208,11 +218,13 @@ Each phase ends deployable. Rough sizes assume one person, part-time; adjust fre
- [ ] Fly volume snapshots enabled as a second backup
- [ ] Sentry receiving errors from production
- [ ] At least one Forgejo OIDC provider registered and tested
- [ ] axe passes on feature, issue, project and org pages, light and dark
- [ ] Keyboard-only walkthrough of the example flow
- [x] axe passes on feature, issue, project and org pages, light and dark (`tests/browser`; recheck against production once deployed)
- [ ] Keyboard-only walkthrough of the example flow (the comment and reply path is automated; do the full flow by hand once)
- [ ] Privacy check: private project content not reachable via file URLs, feeds, or admin leaks
- [ ] `EMAIL_BACKEND` set in production (prod settings refuse the console backend)
- [ ] Container confirmed running as the `app` user on the Fly volume
- [ ] First administrator created with `add_admin_totp`, and admin sign-in tested with a code
- [ ] HSTS raised to a year once the domain is settled (`SECURE_HSTS_SECONDS` in prod settings)
## 7. Hooks for V1.1

View file

@ -1,12 +1,47 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.contrib.sessions.models import Session
from django.db.models import Count, F, Q
from intent.orgs.models import Organization, Role
from .models import User
def orgs_left_without_owner(users) -> list[Organization]:
"""Organizations whose only owners are among `users`."""
ids = [u.pk for u in users]
return list(
Organization.objects.annotate(
owners=Count("memberships", filter=Q(memberships__role=Role.OWNER)),
leaving=Count(
"memberships",
filter=Q(memberships__role=Role.OWNER, memberships__user__in=ids),
),
).filter(owners__gt=0, owners=F("leaving"))
)
def end_sessions(users) -> None:
"""Sign users out everywhere. Sessions aren't indexed by user, so scan them; there are few.
Django already treats inactive users as signed out; this also clears their session data.
"""
ids = {str(u.pk) for u in users}
for session in Session.objects.all():
if session.get_decoded().get("_auth_user_id") in ids:
session.delete()
@admin.register(User)
class IntentUserAdmin(UserAdmin):
"""Moderation lives here: deactivate to block someone and keep their content; delete to
remove the account, after which their content shows as written by "ghost"."""
search_fields = ["username", "email"]
list_display = ["username", "email", "is_active", "is_staff", "date_joined", "last_login"]
list_filter = ["is_active", "is_staff", "is_superuser"]
actions = ["deactivate", "reactivate"]
def get_readonly_fields(self, request, obj=None):
# Only superusers may hand out staff or superuser status, or permissions.
@ -14,3 +49,32 @@ class IntentUserAdmin(UserAdmin):
if not request.user.is_superuser:
fields += ["is_staff", "is_superuser", "groups", "user_permissions"]
return fields
def save_model(self, request, obj, form, change):
super().save_model(request, obj, form, change)
if change and "is_active" in form.changed_data and not obj.is_active:
end_sessions([obj])
@admin.action(description="Deactivate: block sign-in and end sessions, keep content")
def deactivate(self, request, queryset):
users = list(queryset.exclude(pk=request.user.pk))
User.objects.filter(pk__in=[u.pk for u in users]).update(is_active=False)
end_sessions(users)
self.message_user(request, f"Deactivated {len(users)} account(s).")
@admin.action(description="Reactivate")
def reactivate(self, request, queryset):
count = queryset.update(is_active=True)
self.message_user(request, f"Reactivated {count} account(s).")
def has_delete_permission(self, request, obj=None):
# An org's last owner can't be deleted; there would be no one left to run it.
if obj is not None and orgs_left_without_owner([obj]):
return False
return super().has_delete_permission(request, obj)
def get_actions(self, request):
# Delete one account at a time, so the last-owner check above always applies.
actions = super().get_actions(request)
actions.pop("delete_selected", None)
return actions

View file

View file

@ -0,0 +1,31 @@
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from django_otp.plugins.otp_totp.models import TOTPDevice
class Command(BaseCommand):
help = (
"Give a staff account an authenticator-app (TOTP) device for the admin's second "
"factor, and print the otpauth:// link to add to the app."
)
def add_arguments(self, parser):
parser.add_argument("username")
parser.add_argument(
"--replace", action="store_true", help="Remove the account's existing devices first."
)
def handle(self, username, replace, **options):
user = get_user_model().objects.filter(username=username).first()
if user is None:
raise CommandError(f"No user called {username}.")
if not user.is_staff:
raise CommandError(f"{username} isn't staff; only the admin uses this.")
devices = TOTPDevice.objects.filter(user=user)
if devices.exists() and not replace:
raise CommandError(f"{username} already has a device. Use --replace to reset it.")
devices.delete()
device = TOTPDevice.objects.create(user=user, name="Authenticator app", confirmed=True)
self.stdout.write("Add this to an authenticator app (most can scan it as a QR code):")
self.stdout.write(device.config_url)
self.stdout.write("Then sign in at /admin/ with the password and the current code.")

View file

@ -1,5 +1,17 @@
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
# "ghost" stands in for deleted accounts; the rest would look official.
RESERVED_USERNAMES = {
"admin", "administrator", "ghost", "intent", "root", "staff", "support", "system",
} # fmt: skip
def validate_not_reserved(value):
if value.lower() in RESERVED_USERNAMES:
raise ValidationError("That username is reserved.")
# Handles are used in URLs and mentions, and must never look like an email address,
# so a single "username or email" field can tell them apart.
username_validators = [
@ -7,4 +19,5 @@ username_validators = [
r"^[A-Za-z0-9][A-Za-z0-9_-]{0,38}$",
"Use 1–39 letters, numbers, hyphens or underscores, starting with a letter or number.",
),
validate_not_reserved,
]

View file

@ -10,6 +10,8 @@ from intent.access import (
can_resolve_thread,
project_access,
)
from intent.core import throttle as limits
from intent.core.throttle import throttle
from .models import Comment, CommentEdit, CommentThread, ThreadKind
@ -32,6 +34,7 @@ def clean_body(body: str) -> str:
def _require_contribute(by, project):
if not project_access(by, project).can_contribute:
raise PermissionDenied
throttle(f"comments:{by.pk}", *limits.COMMENTS)
def start_feature_thread(

View file

@ -14,3 +14,49 @@ class HealthCheckMiddleware:
cursor.execute("SELECT 1")
return HttpResponse("ok", content_type="text/plain")
return self.get_response(request)
# Everything is served from our own origin; nothing inline. Uploaded images are shown from
# the bucket (signed URLs) and HTML prototypes are framed from it, so those origins are added
# when a bucket is configured. No form-action: sign-in with GitHub, Discord or Forgejo posts a
# form that redirects to the provider, and browsers apply form-action to that redirect.
def _content_security_policy() -> str:
from django.conf import settings
bucket = settings.CSP_BUCKET_ORIGIN
extra = f" {bucket}" if bucket else ""
return "; ".join(
[
"default-src 'self'",
"script-src 'self'",
"style-src 'self'",
f"img-src 'self' data:{extra}",
f"frame-src 'self'{extra}",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'self'",
]
)
PERMISSIONS_POLICY = (
"camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()"
)
class SecurityHeadersMiddleware:
"""CSP and Permissions-Policy on every response that doesn't set its own.
Django's SecurityMiddleware covers HSTS, nosniff, Referrer-Policy and COOP; the file view
sets a stricter sandbox CSP of its own, which this leaves alone.
"""
def __init__(self, get_response):
self.get_response = get_response
self.csp = _content_security_policy()
def __call__(self, request):
response = self.get_response(request)
response.headers.setdefault("Content-Security-Policy", self.csp)
response.headers.setdefault("Permissions-Policy", PERMISSIONS_POLICY)
return response

31
intent/core/throttle.py Normal file
View file

@ -0,0 +1,31 @@
"""Fixed-window rate limits kept in the shared cache (the database cache in production).
Services call `throttle()` before doing work; going over a limit raises a ValidationError
with a user-facing message, which views already show like any other rule violation.
"""
from django.core.cache import cache
from django.core.exceptions import ValidationError
TOO_MANY_MESSAGE = "You're doing that too often. Wait a few minutes and try again."
# (events, seconds). Generous for people; tight enough to stop a script flooding a project.
COMMENTS = (30, 10 * 60) # Comments and new threads, per user.
ISSUES = (20, 60 * 60) # New issues, per user.
UPLOADS = (30, 60 * 60) # File uploads and new versions, per user.
INVITES_PER_SENDER = (30, 60 * 60)
INVITES_PER_RECIPIENT = (3, 60 * 60) # Per org, so one address isn't flooded with email.
def throttle(key: str, limit: int, seconds: int, message: str = TOO_MANY_MESSAGE) -> None:
"""Count an event under `key`; raise once more than `limit` happen within `seconds`."""
key = f"intent:throttle:{key}"
if cache.add(key, 1, timeout=seconds):
return
try:
count = cache.incr(key)
except ValueError: # Expired between add and incr.
cache.add(key, 1, timeout=seconds)
return
if count > limit:
raise ValidationError(message)

View file

@ -5,6 +5,8 @@ from pathlib import PurePath
from django.core.exceptions import ValidationError
from django.db import transaction
from intent.core import throttle as limits
from intent.core.throttle import throttle
from intent.features.services import commit_file_change
from .models import ALLOWED_TYPES, MAX_BYTES, FeatureFile, FeatureFileVersion
@ -12,6 +14,10 @@ from .models import ALLOWED_TYPES, MAX_BYTES, FeatureFile, FeatureFileVersion
ALLOWED_LIST = ", ".join(sorted({ext.lstrip(".") for ext in ALLOWED_TYPES}))
def _throttle_uploads(by) -> None:
throttle(f"uploads:{by.pk}", *limits.UPLOADS)
def check_upload(upload) -> tuple[str, str]:
"""Validate an upload. Returns its display name and content type."""
name = PurePath(upload.name or "").name.strip()[:200]
@ -48,6 +54,7 @@ def add_file(feature, by, *, kind, upload, note, issues=()) -> FeatureFile:
files_change=f"Added {kind} “{name}”",
issues=issues,
)
_throttle_uploads(by)
file = FeatureFile.objects.create(
feature=feature, kind=kind, name=name, added_in_revision=revision.revision
)
@ -68,6 +75,7 @@ def replace_file(file, by, *, upload, note, issues=()) -> FeatureFileVersion:
files_change=f"Replaced “{file.name}” with version {file.current_version + 1}",
issues=issues,
)
_throttle_uploads(by)
file.current_version += 1
file.name = name
file.save(update_fields=["current_version", "name", "updated_at"])

View file

@ -12,12 +12,15 @@ from intent.access import (
project_access,
)
from intent.comments.models import CommentThread, ThreadKind
from intent.core import throttle as limits
from intent.core.throttle import throttle
from intent.markdown import source_lines
from intent.projects.models import Project
from .models import EventKind, Issue, IssueEvent, IssueStatus
MAX_BODY = 50_000
GHOST = "ghost" # Shown for content whose author's account was deleted.
def _clean_text(title, body):
@ -40,6 +43,7 @@ def create_issue(project, by, *, title, body="", features=()) -> Issue:
raise PermissionDenied
title, body = _clean_text(title, body)
_check_features(project, features)
throttle(f"issues:{by.pk}", *limits.ISSUES)
with transaction.atomic():
Project.objects.filter(pk=project.pk).update(next_issue_number=F("next_issue_number") + 1)
project.refresh_from_db(fields=["next_issue_number"])
@ -176,7 +180,7 @@ def promotion_text(thread) -> tuple[str, str]:
quoted = source_lines(snapshot.description, thread.line_start, thread.line_end)
parts.append("\n".join(f"> {line}".rstrip() for line in quoted.splitlines()))
for c in comments:
name = c.author.username if c.author else "deleted user"
name = c.author.username if c.author else GHOST
quoted = "\n".join(f"> {line}".rstrip() for line in c.body.splitlines())
parts.append(f"**{name}** wrote:\n\n{quoted}")
return title, "\n\n".join(parts)

View file

@ -5,44 +5,26 @@ Missing permissions raise PermissionDenied.
"""
from allauth.account.models import EmailAddress
from django.core.cache import cache
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from intent.access import org_access
from intent.core import throttle as limits
from intent.core.throttle import throttle
from .models import InviteStatus, Organization, OrgInvite, OrgMembership, Role
LAST_OWNER_MESSAGE = "An organization needs at least one owner. Make someone else an owner first."
TOO_MANY_INVITES_MESSAGE = "Too many invitations sent recently. Try again later."
# Each invite can send an email, so cap how many one person sends and how often one
# address is hit, with room for genuine use.
INVITES_PER_SENDER = (30, 60 * 60)
INVITES_PER_RECIPIENT = (3, 60 * 60)
def _require_owner(by, org):
if not org_access(by, org).can_manage_members:
raise PermissionDenied
def _throttle(key: str, limit: int, seconds: int) -> None:
"""Count an event under `key`; raise once more than `limit` happen within `seconds`."""
key = f"intent:throttle:{key}"
if cache.add(key, 1, timeout=seconds):
return
try:
count = cache.incr(key)
except ValueError: # Expired between add and incr.
cache.add(key, 1, timeout=seconds)
return
if count > limit:
raise ValidationError(TOO_MANY_INVITES_MESSAGE)
def _is_last_owner(membership) -> bool:
return (
membership.role == Role.OWNER
@ -77,8 +59,11 @@ def invite(org, by, *, user=None, email="", role=Role.COLLABORATOR) -> tuple[Org
email = email.strip().lower()
if bool(user) == bool(email):
raise ValueError("Invite exactly one of user or email")
_throttle(f"invites-by:{by.pk}", *INVITES_PER_SENDER)
_throttle(f"invites-to:{org.pk}:{user.pk if user else email}", *INVITES_PER_RECIPIENT)
recipient = user.pk if user else email
throttle(f"invites-by:{by.pk}", *limits.INVITES_PER_SENDER, TOO_MANY_INVITES_MESSAGE)
throttle(
f"invites-to:{org.pk}:{recipient}", *limits.INVITES_PER_RECIPIENT, TOO_MANY_INVITES_MESSAGE
)
with transaction.atomic():
if user:

View file

@ -6,6 +6,8 @@ requires-python = ">=3.13"
dependencies = [
"django>=5.2,<5.3",
"django-allauth[socialaccount]>=65.21",
"django-anymail==15.2",
"django-otp==1.7.5",
"django-storages[s3]>=1.14.6",
"gunicorn>=26.2.0",
"markdown-it-py>=4.2.0",
@ -17,6 +19,7 @@ dependencies = [
[dependency-groups]
dev = [
"playwright==1.64.1",
"pytest>=9.1.1",
"pytest-django>=4.14.0",
"ruff>=0.17.3",
@ -33,3 +36,6 @@ select = ["E", "F", "I", "UP", "B", "DJ"]
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "config.settings.test"
testpaths = ["tests"]
markers = [
"browser: real-browser tests (Playwright + Chromium); skip with -m 'not browser'",
]

View file

@ -6,6 +6,10 @@
:root {
color-scheme: light dark;
/* The same system colours the browser would use, made explicit so tools (and embeds)
that can't see the canvas don't assume black on white. */
background-color: Canvas;
color: CanvasText;
}
:root[data-theme="light"] {

View file

@ -8,6 +8,8 @@
{# Blocking on purpose: applies the saved theme before first paint. #}
<script src="{% static 'js/theme.js' %}"></script>
<link rel="stylesheet" href="{% static 'css/intent.css' %}">
{# No injected <style> and no eval, so htmx works under the page's CSP. #}
<meta name="htmx-config" content='{"includeIndicatorStyles": false, "allowEval": false, "allowScriptTags": false}'>
{# Enhancements only: every form and link works without these. #}
<script src="{% static 'js/htmx.min.js' %}" defer></script>
<script src="{% static 'js/intent.js' %}" defer></script>

View file

@ -2,7 +2,7 @@
One comment. Needs `c` (prepared by intent.comments.presenters), `thread`, `project`, `focus`.
Forms post normally; with HTMX they swap the whole thread (`thread.dom_id`) in place.
{% endcomment %}
{% with by=c.author.username|default:"deleted user" comment_id="comment-"|add:c.pk %}
{% with by=c.author.username|default:"ghost" comment_id="comment-"|add:c.pk %}
<article id="{{ comment_id }}" class="comment" tabindex="-1"{% if focus == comment_id %} data-autofocus{% endif %}>
<header>
<strong>{{ by }}</strong>

View file

@ -6,7 +6,7 @@
{% if error %}<p role="alert"><strong>Error:</strong> {{ error }}</p>{% endif %}
<details{% if not thread.is_resolved or error or focus %} open{% endif %}>
<summary>
{% if thread.pin_number %}Pin {{ thread.pin_number }}: {% endif %}Thread by {{ thread.author.username|default:"deleted user" }},
{% if thread.pin_number %}Pin {{ thread.pin_number }}: {% endif %}Thread by {{ thread.author.username|default:"ghost" }},
{{ thread.live_count }} comment{{ thread.live_count|pluralize }}{% if thread.promoted_to %}, became issue {{ thread.promoted_to.key }}{% elif thread.is_resolved %}, resolved{% endif %}
</summary>

View file

@ -4,7 +4,7 @@
{% block project_content %}
<p><a href="{{ back }}">Back to the comment</a></p>
<h1>Edits to {{ comment.author.username|default:"deleted user" }}’s comment</h1>
<h1>Edits to {{ comment.author.username|default:"ghost" }}’s comment</h1>
{% if comment.is_deleted %}
<p>This comment was deleted, along with its history.</p>

View file

@ -23,7 +23,7 @@
<dt>Last change</dt>
<dd>
<time datetime="{{ latest.created_at|date:'c' }}">{{ latest.created_at|date:"j M Y" }}</time>
by {{ latest.author.username|default:"unknown" }}: {{ latest.note }}
by {{ latest.author.username|default:"ghost" }}: {{ latest.note }}
</dd>
{% endif %}
</dl>

View file

@ -35,7 +35,7 @@
{% endif %}
</th>
<td><time datetime="{{ rev.created_at|date:'c' }}">{{ rev.created_at|date:"j M Y, H:i" }}</time></td>
<td>{{ rev.author.username|default:"unknown" }}</td>
<td>{{ rev.author.username|default:"ghost" }}</td>
<td>{{ rev.note }}</td>
<td>{% for issue in rev.issues.all %}<a href="{% url 'issues:detail' org_slug=project.org.slug project_slug=project.slug number=issue.number %}">{{ issue.key }}</a>{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</td>
<td>{{ row.changes|join:", " }}</td>

View file

@ -18,7 +18,7 @@
<dt>Change</dt>
<dd>
<time datetime="{{ snapshot.created_at|date:'c' }}">{{ snapshot.created_at|date:"j M Y" }}</time>
by {{ snapshot.author.username|default:"unknown" }}: {{ snapshot.note }}
by {{ snapshot.author.username|default:"ghost" }}: {{ snapshot.note }}
</dd>
</dl>
<section aria-labelledby="description-heading">

View file

@ -9,7 +9,7 @@
<h1>{{ version.original_name }} <span class="muted">version {{ version.version }}</span></h1>
<p>
{{ file.get_kind_display }} for {{ feature.key }}.
Uploaded by {{ version.uploaded_by.username|default:"deleted user" }}
Uploaded by {{ version.uploaded_by.username|default:"ghost" }}
on <time datetime="{{ version.created_at|date:'c' }}">{{ version.created_at|date:"j M Y" }}</time>,
{{ version.size|filesizeformat }}.
<a href="{{ raw_url }}"{% if not version.opens_inline %} download{% endif %}>{% if version.opens_inline %}Open{% else %}Download{% endif %}<span class="visually-hidden"> {{ version.original_name }}</span></a>
@ -25,7 +25,7 @@
<img src="{{ raw_url }}" alt="{{ version.original_name }}, version {{ version.version }}.{% if pins %} Pinned comments are listed below the image.{% endif %}">
<svg class="pins" role="group" aria-label="Pins" data-pins>
{% for t in pins %}
<a href="#thread-{{ t.pk }}" aria-label="Pin {{ t.pin_number }}, thread by {{ t.author.username|default:'deleted user' }}">
<a href="#thread-{{ t.pk }}" aria-label="Pin {{ t.pin_number }}, thread by {{ t.author.username|default:'ghost' }}">
<circle cx="{{ t.x_pct }}%" cy="{{ t.y_pct }}%" r="12"></circle>
<text x="{{ t.x_pct }}%" y="{{ t.y_pct }}%">{{ t.pin_number }}</text>
</a>
@ -101,7 +101,7 @@
{% if v.version == version.version %}{{ v.version }} (shown){% else %}<a href="{{ file_url }}?version={{ v.version }}">{{ v.version }}</a>{% endif %}
</th>
<td>{{ v.original_name }}</td>
<td><time datetime="{{ v.created_at|date:'c' }}">{{ v.created_at|date:"j M Y" }}</time> by {{ v.uploaded_by.username|default:"deleted user" }}</td>
<td><time datetime="{{ v.created_at|date:'c' }}">{{ v.created_at|date:"j M Y" }}</time> by {{ v.uploaded_by.username|default:"ghost" }}</td>
<td><a href="{% url 'features:history' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}">{{ v.created_in_revision }}</a></td>
<td>{{ v.size|filesizeformat }}</td>
</tr>

View file

@ -1,6 +1,6 @@
{% comment %}One timeline event. Needs `e`, `project`.{% endcomment %}
<p class="event muted">
<strong>{{ e.actor.username|default:"deleted user" }}</strong>
<strong>{{ e.actor.username|default:"ghost" }}</strong>
{% if e.kind == "status" %}
{% if e.status == "accepted" %}accepted this issue
{% elif e.status == "rejected" %}rejected this issue

View file

@ -6,7 +6,7 @@
<h1><span class="muted">{{ issue.key }}</span> {{ issue.title }}</h1>
<p>
<strong>{{ issue.get_status_display }}</strong>{% if issue.duplicate_of %} duplicate of <a href="{% url 'issues:detail' org_slug=project.org.slug project_slug=project.slug number=issue.duplicate_of.number %}">{{ issue.duplicate_of.key }} {{ issue.duplicate_of.title }}</a>{% endif %}.
Opened by {{ issue.author.username|default:"deleted user" }}
Opened by {{ issue.author.username|default:"ghost" }}
on <time datetime="{{ issue.created_at|date:'c' }}">{{ issue.created_at|date:"j M Y" }}</time>.
{% if promoted_url %}Started as <a href="{{ promoted_url }}">a comment thread on {{ promoted_from.feature.key }}</a>.{% endif %}
</p>

View file

@ -34,7 +34,7 @@
<th scope="row"><a href="{% url 'issues:detail' org_slug=project.org.slug project_slug=project.slug number=issue.number %}">{{ issue.key }} {{ issue.title }}</a></th>
<td>{{ issue.get_status_display }}</td>
<td>{% for f in issue.features.all %}{{ f.key }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</td>
<td>{{ issue.author.username|default:"deleted user" }}</td>
<td>{{ issue.author.username|default:"ghost" }}</td>
<td><time datetime="{{ issue.updated_at|date:'c' }}">{{ issue.updated_at|date:"j M Y" }}</time></td>
</tr>
{% endfor %}

View file

100
tests/browser/conftest.py Normal file
View file

@ -0,0 +1,100 @@
"""Real-browser tests: Playwright drives Chromium against Django's live server.
Needs the browser once per machine: `uv run playwright install chromium`. Without it these
tests skip, so the rest of the suite still runs anywhere.
"""
import os
from pathlib import Path
import pytest
from django.conf import settings
# Playwright's sync API runs an event loop; the ORM calls in fixtures are still synchronous.
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
from playwright.sync_api import Error as PlaywrightError # noqa: E402
from playwright.sync_api import sync_playwright # noqa: E402
AXE = (Path(__file__).parent.parent / "vendor" / "axe.min.js").read_text()
@pytest.fixture(scope="session")
def browser():
with sync_playwright() as p:
try:
browser = p.chromium.launch()
except PlaywrightError as exc:
pytest.skip(f"Chromium isn't installed (uv run playwright install chromium): {exc}")
yield browser
browser.close()
@pytest.fixture
def page(browser, live_server):
"""A fresh page. `page.problems` collects script errors and CSP violations.
The CSP is enforced, except on `axe_page`, where axe has to be injected inline.
"""
context = browser.new_context(base_url=live_server.url)
page = context.new_page()
page.problems = []
page.on("pageerror", lambda e: page.problems.append(f"script error: {e}"))
page.on(
"console",
lambda m: (
page.problems.append(f"console: {m.text}")
if m.type == "error" and "Content Security Policy" in m.text
else None
),
)
yield page
context.close()
@pytest.fixture
def axe_page(browser, live_server):
context = browser.new_context(base_url=live_server.url, bypass_csp=True)
page = context.new_page()
yield page
context.close()
def share_session(client, page, live_server, user):
client.force_login(user)
page.context.add_cookies(
[
{
"name": settings.SESSION_COOKIE_NAME,
"value": client.cookies[settings.SESSION_COOKIE_NAME].value,
"url": live_server.url,
}
]
)
@pytest.fixture
def sign_in(client, page, live_server):
"""Sign the browser in as `user` by sharing a test-client session."""
def as_user(user):
share_session(client, page, live_server, user)
return as_user
def axe_violations(page, scheme: str) -> list[str]:
"""Run axe on the current page with WCAG 2.1 A and AA rules."""
page.emulate_media(color_scheme=scheme)
page.add_script_tag(content=AXE)
result = page.evaluate(
"""async () => {
const r = await axe.run(document, {
runOnly: {type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]},
});
return r.violations.map(v =>
`${v.id} (${v.impact}): ${v.help} — ${v.nodes.slice(0, 3)
.map(n => n.target.join(" ")).join(", ")}`);
}"""
)
return result

143
tests/browser/test_smoke.py Normal file
View file

@ -0,0 +1,143 @@
"""Accessibility (axe, light and dark), CSP and keyboard checks in a real browser."""
import pytest
from django.conf import settings
from django.urls import reverse
from intent.comments.services import start_feature_thread
from ..test_a11y import _pages, populated # noqa: F401 (fixture)
from .conftest import axe_violations, share_session
pytestmark = [pytest.mark.browser, pytest.mark.django_db(transaction=True)]
def _signed_out_pages(org, project, feature):
pk = {"org_slug": org.slug, "project_slug": project.slug}
return [
reverse("home"),
reverse("account_login"),
reverse("account_signup"),
reverse("orgs:detail", kwargs={"org_slug": org.slug}),
reverse("projects:home", kwargs=pk),
reverse("features:detail", kwargs={**pk, "feature_key": feature.key}),
reverse("issues:detail", kwargs={**pk, "number": 1}),
]
@pytest.mark.parametrize("scheme", ["light", "dark"])
def test_axe_signed_in(axe_page, client, live_server, owner, org, project, populated, scheme): # noqa: F811
share_session(client, axe_page, live_server, owner)
failures = {}
for path in _pages(org, project, populated):
axe_page.goto(path)
if violations := axe_violations(axe_page, scheme):
failures[path] = violations
assert not failures, "\n".join(f"{p}:\n " + "\n ".join(v) for p, v in failures.items())
@pytest.mark.parametrize("scheme", ["light", "dark"])
def test_axe_signed_out(axe_page, org, project, populated, scheme): # noqa: F811
project.visibility, project.open_contribution = "public", True
project.save()
failures = {}
for path in _signed_out_pages(org, project, populated):
axe_page.goto(path)
if violations := axe_violations(axe_page, scheme):
failures[path] = violations
assert not failures, "\n".join(f"{p}:\n " + "\n ".join(v) for p, v in failures.items())
def test_no_csp_violations_or_script_errors(page, sign_in, owner, org, project, populated): # noqa: F811
sign_in(owner)
for path in _pages(org, project, populated):
page.goto(path)
page.wait_for_load_state("networkidle")
assert not page.problems, "\n".join(page.problems)
def _wait_for_focus(page, selector, timeout_ms=3000):
# Not wait_for_function: it evaluates a string, which the page's CSP rightly forbids.
for _ in range(timeout_ms // 50):
if page.evaluate("(s) => document.activeElement.matches(s)", selector):
return
page.wait_for_timeout(50)
raise AssertionError(f"Focus never reached {selector}")
def _tab_to(page, selector, limit=80):
"""Press Tab until `selector` has focus, like a keyboard user would."""
for _ in range(limit):
page.keyboard.press("Tab")
if page.evaluate("(s) => document.activeElement.matches(s)", selector):
return
raise AssertionError(f"Couldn't reach {selector} with Tab")
def test_keyboard_only_line_comment_and_reply(page, sign_in, owner, open_project):
from intent.features.services import create_feature
feature = create_feature(open_project, owner, title="Sign in", description="One\n\nTwo\n")
thread = start_feature_thread(feature, owner, body="First")
sign_in(owner)
page.goto(
reverse(
"features:detail",
kwargs={"org_slug": "catbriar", "project_slug": "intent", "feature_key": feature.key},
)
)
# The first Tab reaches the skip link, which moves focus into the main content.
page.keyboard.press("Tab")
assert page.evaluate("document.activeElement.className") == "skip-link"
page.keyboard.press("Enter")
assert page.evaluate("document.activeElement.id") == "main"
# Open the comment form under the second paragraph and post, all from the keyboard.
_tab_to(page, "#L3-3 summary")
page.keyboard.press("Enter")
page.keyboard.press("Tab")
assert page.evaluate("document.activeElement.id") == "body-L3-3"
page.keyboard.type("About two")
page.keyboard.press("Tab")
page.keyboard.press("Enter")
page.wait_for_selector("#L3-3 >> text=About two")
# Reply to the general thread with HTMX; focus lands on the new comment.
_tab_to(page, f"#reply-{thread.pk}")
page.keyboard.type("Keyboard reply")
page.keyboard.press("Tab")
page.keyboard.press("Enter")
page.wait_for_selector("text=Keyboard reply")
_wait_for_focus(page, ".comment")
assert "Keyboard reply" in page.evaluate("document.activeElement.textContent")
assert not page.problems, "\n".join(page.problems)
def test_theme_choice_applies_and_persists(page):
page.goto(reverse("home"))
page.select_option("#theme-select", "dark")
assert page.evaluate("document.documentElement.dataset.theme") == "dark"
page.reload()
assert page.evaluate("document.documentElement.dataset.theme") == "dark"
assert page.evaluate("getComputedStyle(document.documentElement).colorScheme") == "dark"
page.select_option("#theme-select", "system")
assert page.evaluate("document.documentElement.dataset.theme") is None
def test_admin_pages_work_under_csp(page, client, live_server, verified_admin, populated): # noqa: F811
verified_admin() # Signs `client` in with a verified device; the browser borrows it.
cookie = client.cookies[settings.SESSION_COOKIE_NAME].value
page.context.add_cookies(
[{"name": settings.SESSION_COOKIE_NAME, "value": cookie, "url": live_server.url}]
)
for name in [
"admin:index",
"admin:accounts_user_changelist",
"admin:orgs_organization_add",
"admin:issues_issue_changelist",
]:
page.goto(reverse(name))
page.wait_for_load_state("networkidle")
assert "/login/" not in page.url, name
assert not page.problems, "\n".join(page.problems)

View file

@ -79,3 +79,23 @@ def open_project(project):
def outsider(make_user):
"""A signed-in user with no role in the org."""
return make_user("uma")
@pytest.fixture
def verified_admin(client, make_user):
"""Sign `client` in as a staff user who has passed the admin's second factor."""
from django_otp import DEVICE_ID_SESSION_KEY
from django_otp.plugins.otp_totp.models import TOTPDevice
def sign_in(user=None):
user = user or get_user_model().objects.create_superuser(
"sam", "sam@example.com", "a-long-admin-password"
)
device = TOTPDevice.objects.create(user=user, name="Phone", confirmed=True)
client.force_login(user)
session = client.session
session[DEVICE_ID_SESSION_KEY] = device.persistent_id
session.save()
return user
return sign_in

View file

@ -66,17 +66,26 @@ def test_signed_in_pages_without_passwords_render(client, owner):
assert client.get("/accounts/password/set/").status_code == 404
def test_staff_cannot_grant_staff_without_superuser(client, make_user):
def test_staff_cannot_grant_staff_without_superuser(client, make_user, verified_admin):
from django.contrib.auth.models import Permission
staff = make_user("stan", is_staff=True)
staff.user_permissions.add(*Permission.objects.filter(content_type__app_label="accounts"))
client.force_login(staff)
verified_admin(staff)
target = make_user("tina")
page = client.get(reverse("admin:accounts_user_change", args=[target.pk])).content.decode()
response = client.get(reverse("admin:accounts_user_change", args=[target.pk]))
page = response.content.decode()
assert response.status_code == 200 and 'name="email"' in page
assert 'name="is_superuser"' not in page and 'name="is_staff"' not in page
def test_admin_pages_for_issues_and_threads_render(admin_client, db):
for name in ["admin:issues_issue_changelist", "admin:comments_commentthread_changelist"]:
assert admin_client.get(reverse(name)).status_code == 200
def test_admin_changelists_render(client, verified_admin):
verified_admin()
for name in [
"admin:accounts_user_changelist",
"admin:issues_issue_changelist",
"admin:comments_commentthread_changelist",
"admin:files_featurefile_changelist",
"admin:implementations_implementation_changelist",
]:
assert client.get(reverse(name)).status_code == 200, name

188
tests/test_hardening.py Normal file
View file

@ -0,0 +1,188 @@
"""Phase 5: rate limits, security headers, moderation, the admin's second factor, email."""
import io
import pytest
from django.core.exceptions import ValidationError
from django.core.management import CommandError, call_command
from django.urls import reverse
from django_otp.oath import totp
from django_otp.plugins.otp_totp.models import TOTPDevice
from intent.accounts.admin import IntentUserAdmin, orgs_left_without_owner
from intent.accounts.models import User
from intent.comments import services as comment_services
from intent.core import throttle as limits
from intent.features.services import create_feature
from intent.issues.services import create_issue
def test_comment_rate_limit(open_project, owner, outsider, monkeypatch):
monkeypatch.setattr(limits, "COMMENTS", (2, 60))
feature = create_feature(open_project, owner, title="F")
thread = comment_services.start_feature_thread(feature, outsider, body="1")
comment_services.reply(thread, outsider, body="2")
with pytest.raises(ValidationError, match="too often"):
comment_services.reply(thread, outsider, body="3")
comment_services.reply(thread, owner, body="Others aren't affected")
def test_issue_rate_limit(open_project, outsider, monkeypatch):
monkeypatch.setattr(limits, "ISSUES", (1, 60))
create_issue(open_project, outsider, title="One")
with pytest.raises(ValidationError, match="too often"):
create_issue(open_project, outsider, title="Two")
def test_rate_limited_reply_keeps_the_draft(client, open_project, owner, outsider, monkeypatch):
monkeypatch.setattr(limits, "COMMENTS", (1, 60))
feature = create_feature(open_project, owner, title="F")
thread = comment_services.start_feature_thread(feature, outsider, body="1")
client.force_login(outsider)
url = reverse(
"comments:reply",
kwargs={"org_slug": "catbriar", "project_slug": "intent", "thread_id": thread.pk},
)
response = client.post(url, {"body": "My long reply"}, headers={"HX-Request": "true"})
assert b"too often" in response.content and b"My long reply" in response.content
def test_security_headers(client, db):
response = client.get(reverse("home"))
csp = response["Content-Security-Policy"]
assert "script-src 'self'" in csp and "'unsafe-inline'" not in csp
assert "object-src 'none'" in csp and "frame-ancestors 'self'" in csp
assert "camera=()" in response["Permissions-Policy"]
assert response["X-Content-Type-Options"] == "nosniff"
assert response["Referrer-Policy"] == "same-origin"
def test_bucket_origin_is_allowed_for_images_and_frames(settings, client, db):
from intent.core.middleware import _content_security_policy
settings.CSP_BUCKET_ORIGIN = "https://intent-files.fly.storage.tigris.dev"
csp = _content_security_policy()
assert "img-src 'self' data: https://intent-files.fly.storage.tigris.dev" in csp
assert "frame-src 'self' https://intent-files.fly.storage.tigris.dev" in csp
# Moderation
def test_deactivated_user_is_signed_out_and_content_stays(client, open_project, owner, outsider):
issue = create_issue(open_project, outsider, title="Mine")
client.force_login(outsider)
assert client.get(reverse("invites:list")).status_code == 200
outsider.is_active = False
outsider.save()
assert client.get(reverse("invites:list")).status_code == 302 # Treated as signed out.
issue.refresh_from_db()
assert issue.author == outsider
def test_deleted_users_content_shows_as_ghost(client, open_project, owner, outsider):
issue = create_issue(open_project, outsider, title="Orphan")
outsider.delete()
client.force_login(owner)
page = client.get(
reverse(
"issues:detail",
kwargs={"org_slug": "catbriar", "project_slug": "intent", "number": issue.number},
)
).content.decode()
assert "Opened by ghost" in page
def test_ghost_and_official_names_are_reserved(client, db):
response = client.post(
reverse("account_signup"), {"email": "g@example.com", "username": "Ghost"}
)
assert b"reserved" in response.content
assert not User.objects.filter(username__iexact="ghost").exists()
def test_last_owner_cannot_be_deleted_in_admin(rf, owner, org, make_user):
admin_user = User.objects.create_superuser("sam", "sam@example.com", "pw-long-enough-1")
request = rf.get("/")
request.user = admin_user
from django.contrib import admin
model_admin = IntentUserAdmin(User, admin.site)
assert [o.slug for o in orgs_left_without_owner([owner])] == ["catbriar"]
assert not model_admin.has_delete_permission(request, owner)
assert "delete_selected" not in model_admin.get_actions(request)
second = make_user("otto")
org.memberships.create(user=second, role="owner")
assert model_admin.has_delete_permission(request, owner)
def test_deactivate_action_ends_sessions(client, outsider, rf):
from django.contrib.sessions.models import Session
client.force_login(outsider)
assert Session.objects.count() == 1
admin_user = User.objects.create_superuser("sam", "sam@example.com", "pw-long-enough-1")
request = rf.post("/")
request.user = admin_user
request._messages = type("M", (), {"add": lambda *a, **k: None})()
IntentUserAdmin(User, None).deactivate(request, User.objects.filter(pk=outsider.pk))
outsider.refresh_from_db()
assert not outsider.is_active and Session.objects.count() == 0
# Admin second factor
def test_admin_needs_a_verified_device(client, db):
staff = User.objects.create_superuser("sam", "sam@example.com", "pw-long-enough-1")
client.force_login(staff) # Password alone, no device.
response = client.get(reverse("admin:index"))
assert response.status_code == 302 and "/admin/login/" in response["Location"]
def test_admin_login_with_password_and_code(client, db):
staff = User.objects.create_superuser("sam", "sam@example.com", "pw-long-enough-1")
device = TOTPDevice.objects.create(user=staff, name="Phone", confirmed=True)
login = reverse("admin:login")
bad = client.post(
login, {"username": "sam", "password": "pw-long-enough-1", "otp_token": "000000"}
)
assert bad.status_code == 200 and not client.get(reverse("admin:index")).status_code == 200
code = f"{totp(device.bin_key, device.step, device.t0, device.digits):06d}"
client.post(
login,
{"username": "sam", "password": "pw-long-enough-1", "otp_device": device.persistent_id,
"otp_token": code},
) # fmt: skip
assert client.get(reverse("admin:index")).status_code == 200
def test_add_admin_totp_command(db, make_user):
staff = User.objects.create_superuser("sam", "sam@example.com", "pw-long-enough-1")
out = io.StringIO()
call_command("add_admin_totp", "sam", stdout=out)
assert "otpauth://totp/" in out.getvalue()
assert TOTPDevice.objects.filter(user=staff, confirmed=True).count() == 1
with pytest.raises(CommandError, match="already"):
call_command("add_admin_totp", "sam", stdout=io.StringIO())
call_command("add_admin_totp", "sam", "--replace", stdout=io.StringIO())
assert TOTPDevice.objects.filter(user=staff).count() == 1
with pytest.raises(CommandError, match="isn't staff"):
call_command("add_admin_totp", make_user("ruth").username, stdout=io.StringIO())
# Email
def test_anymail_settings_come_from_the_environment(monkeypatch):
import importlib
import config.settings.base as base
monkeypatch.setenv("ANYMAIL_POSTMARK_SERVER_TOKEN", "token-123")
reloaded = importlib.reload(base)
try:
assert reloaded.ANYMAIL == {"POSTMARK_SERVER_TOKEN": "token-123"}
finally:
monkeypatch.delenv("ANYMAIL_POSTMARK_SERVER_TOKEN")
importlib.reload(base)

12
tests/vendor/axe.min.js vendored Normal file

File diff suppressed because one or more lines are too long

130
uv.lock generated
View file

@ -341,6 +341,33 @@ socialaccount = [
{ name = "requests" },
]
[[package]]
name = "django-anymail"
version = "15.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "idna" },
{ name = "requests" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/f9/7fb704db13b2de283d51f1524861e37a87d1e4c345068c90dd578047404f/django_anymail-15.2.tar.gz", hash = "sha256:65b26b1c99e6a09051b71b012ac4dfdd75a33aeb057315944dd9fb872cae57cf", size = 110533 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/5a/fe84e7afa8297f86b95b0120e3e635d045719d217fbca321cf56d363286e/django_anymail-15.2-py3-none-any.whl", hash = "sha256:866ed547e9d3110210e6fad343a2311eead55dbd4d544195fb628d06ae25d737", size = 150759 },
]
[[package]]
name = "django-otp"
version = "1.7.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d0/e9/db4349d7a866e6d5153b31fa740b17f5893723715edf7d1673615d9f462c/django_otp-1.7.5.tar.gz", hash = "sha256:7f554d21dcd035550c3b8e3f154b1f422fee71fdc75c8a3bf96e42a2ea6dafcc", size = 79637 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/bc/bda771669006e7e112bed31a330636ff0b3c3407b053c10c492368c35f6f/django_otp-1.7.5-py3-none-any.whl", hash = "sha256:475d4111e07d61616996f098d08d29884da6397273a11f53c1b8e8c75d27e1dc", size = 73805 },
]
[[package]]
name = "django-storages"
version = "1.14.6"
@ -358,6 +385,63 @@ s3 = [
{ name = "boto3" },
]
[[package]]
name = "greenlet"
version = "3.5.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/a1/e720a38852366c589e1a46cf570b886507ad2cf591050c203365638baab0/greenlet-3.5.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f96f0e30b5a95c7631b12bfe214cbc90ec8fe8cfa36920596c10514a65743519", size = 294627 },
{ url = "https://files.pythonhosted.org/packages/eb/c3/58187858df41354a11e6a55b421e7af9059798abdab3a384cc51b8567c38/greenlet-3.5.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c75116c9de79949de23006e2d9b35ee82874c594fcf5c0311b439acaa14b8441", size = 614356 },
{ url = "https://files.pythonhosted.org/packages/ce/b9/3a7e67d5f05c9760b1ad411fa52264bd69cc08e22a2ebfb4018b90628ced/greenlet-3.5.6-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cad5782f93f7f738b62c6527b6f32a60694d924029f299a8b524758cfa53d815", size = 626756 },
{ url = "https://files.pythonhosted.org/packages/c6/7c/40400455f5b5a65bb83e94fde66d1be9e5ec518638113f8083ace746c309/greenlet-3.5.6-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a93ee7c6e8fd0f8a83525a51bd777be57ee17787e91d805bd8d6faf9dcada18e", size = 632632 },
{ url = "https://files.pythonhosted.org/packages/85/cb/ab0c123c514ed4e94c0dc9ee2e86362633e6b998cfc05de7fc9ac2eb9690/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f98e8215e172f567ce80eeaed9107fb4d32b6c44f26983d9b8334658136a205a", size = 623779 },
{ url = "https://files.pythonhosted.org/packages/f9/67/1f35cff30a6c51c3f23b63d4afcc7313ab4f97490ba3676fa78178984b27/greenlet-3.5.6-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7f731ebac68ea06d628658295cb2d217b10186329fcf9a3b6a149045059bf92e", size = 434933 },
{ url = "https://files.pythonhosted.org/packages/a5/26/fda8a5a06e7073333ccb038133c5893b9e0c4fe29d5992a17e83c241bc6e/greenlet-3.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df19e2d0b1620039af5102563fbd96e8938c7f5c3f5828528d641d9fc585525e", size = 1584930 },
{ url = "https://files.pythonhosted.org/packages/2f/37/50f8813163148d6234e08b23dcad6a9e37f01d148c8ec976e4c44ea2d918/greenlet-3.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06c0e933290fba8ffe53ead4ae1b8044b0e9754b75cebf381aa2bc3e50d82fac", size = 1647590 },
{ url = "https://files.pythonhosted.org/packages/86/da/b7669b09586365654083a62bd0724cf06cb74bd5085a15cdd161271f992f/greenlet-3.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:5b602b4201b965a8354d74e232364a66ff243dd142e350d035f46169bb36e13d", size = 324086 },
{ url = "https://files.pythonhosted.org/packages/e5/5d/c9663cfe84a2a9e0aa96f066f5b0594c227ea4c647511e087e2e11d4ac0a/greenlet-3.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:876077e7ebb8c84ed068e2b23d4c62ebb010d60df84b9591af1be2f39010ffb2", size = 308211 },
{ url = "https://files.pythonhosted.org/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221 },
{ url = "https://files.pythonhosted.org/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992 },
{ url = "https://files.pythonhosted.org/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428 },
{ url = "https://files.pythonhosted.org/packages/fd/21/631bb45fafde1dca782152377c0676d182ec924820064047f533a3627b28/greenlet-3.5.6-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd0b83bed3405b586a3133629f1d1a5bc7bfd64822a3b7ab342bdc68e6dbc61b", size = 677688 },
{ url = "https://files.pythonhosted.org/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773 },
{ url = "https://files.pythonhosted.org/packages/40/30/2b0a73e68e1e18e30b601d0d183cfdfc2beca4de5a6843c630f0fc9fb90c/greenlet-3.5.6-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:fdacf26402389bdd89857ad3c045a26fe8f3314f9a8b28226f82f88463a65b77", size = 480475 },
{ url = "https://files.pythonhosted.org/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900 },
{ url = "https://files.pythonhosted.org/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740 },
{ url = "https://files.pythonhosted.org/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473 },
{ url = "https://files.pythonhosted.org/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095 },
{ url = "https://files.pythonhosted.org/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352 },
{ url = "https://files.pythonhosted.org/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671 },
{ url = "https://files.pythonhosted.org/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489 },
{ url = "https://files.pythonhosted.org/packages/71/76/3c11c21e0716b1f1dc7c1a4b3d690abb1d3b448c69a9d32049fecb64010a/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:61a61b4a95a4f97922c3a6f5606d3e360851584bd47e500a5161373c53810e3d", size = 681303 },
{ url = "https://files.pythonhosted.org/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608 },
{ url = "https://files.pythonhosted.org/packages/3f/26/3ae402202452cd5941bbbd483e5a74297e2397e7aa3182c2a5e3ab7d5666/greenlet-3.5.6-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:fe3170a69fe039b18ad18171e66faa9a75f6fe9d78f968fd9b54e09fbd714d81", size = 510112 },
{ url = "https://files.pythonhosted.org/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479 },
{ url = "https://files.pythonhosted.org/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758 },
{ url = "https://files.pythonhosted.org/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574 },
{ url = "https://files.pythonhosted.org/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926 },
{ url = "https://files.pythonhosted.org/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910 },
{ url = "https://files.pythonhosted.org/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415 },
{ url = "https://files.pythonhosted.org/packages/f5/41/b3114c97c10e796010f00a30f51c81470072bca4b53e396ccca87484fcf7/greenlet-3.5.6-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9133d68624b1f2e89ec2f554d56aea8a5b0d7168cd9320200ba58d4d794845a4", size = 681337 },
{ url = "https://files.pythonhosted.org/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927 },
{ url = "https://files.pythonhosted.org/packages/48/1b/d41861c2fa00968e39e467a495ca8db9ce9b6310a5d9b57561b3d0dc48fa/greenlet-3.5.6-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:5adcbbfe78bdc242c71740a02e0991cc1b2f34d33c8bb15ca45eee8fd1140942", size = 487339 },
{ url = "https://files.pythonhosted.org/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236 },
{ url = "https://files.pythonhosted.org/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526 },
{ url = "https://files.pythonhosted.org/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739 },
{ url = "https://files.pythonhosted.org/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715 },
{ url = "https://files.pythonhosted.org/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244 },
{ url = "https://files.pythonhosted.org/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578 },
{ url = "https://files.pythonhosted.org/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203 },
{ url = "https://files.pythonhosted.org/packages/f2/d1/039c353d5593a97a89699e989324c9bc86af499e6c6152fe0180f5742204/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63aff70fe5aac59c72215f42ec39fcb59ff46774fa966e717f8ecb6ee2273577", size = 686023 },
{ url = "https://files.pythonhosted.org/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253 },
{ url = "https://files.pythonhosted.org/packages/8a/62/97ceb8e0b2ea96046cdf8e95b042715020ebb12d83ea0690db80a8f03d23/greenlet-3.5.6-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:520648db8fb92eef7b3e6013f5a6f901cdf0d6685f639c2f7a245879f865bef7", size = 517058 },
{ url = "https://files.pythonhosted.org/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988 },
{ url = "https://files.pythonhosted.org/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383 },
{ url = "https://files.pythonhosted.org/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845 },
{ url = "https://files.pythonhosted.org/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070 },
]
[[package]]
name = "gunicorn"
version = "26.2.0"
@ -392,6 +476,8 @@ source = { virtual = "." }
dependencies = [
{ name = "django" },
{ name = "django-allauth", extra = ["socialaccount"] },
{ name = "django-anymail" },
{ name = "django-otp" },
{ name = "django-storages", extra = ["s3"] },
{ name = "gunicorn" },
{ name = "markdown-it-py" },
@ -403,6 +489,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "playwright" },
{ name = "pytest" },
{ name = "pytest-django" },
{ name = "ruff" },
@ -412,6 +499,8 @@ dev = [
requires-dist = [
{ name = "django", specifier = ">=5.2,<5.3" },
{ name = "django-allauth", extras = ["socialaccount"], specifier = ">=65.21" },
{ name = "django-anymail", specifier = "==15.2" },
{ name = "django-otp", specifier = "==1.7.5" },
{ name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" },
{ name = "gunicorn", specifier = ">=26.2.0" },
{ name = "markdown-it-py", specifier = ">=4.2.0" },
@ -423,6 +512,7 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "playwright", specifier = "==1.64.1" },
{ name = "pytest", specifier = ">=9.1.1" },
{ name = "pytest-django", specifier = ">=4.14.0" },
{ name = "ruff", specifier = ">=0.17.3" },
@ -510,6 +600,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956 },
]
[[package]]
name = "playwright"
version = "1.64.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet" },
{ name = "pyee" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/c8/cc677856d89a0c077673b40ab915ed5597bf1347b205cabe70121c431ca9/playwright-1.64.1-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:29453ff76e25c17d0b45658cacd6b92e9eec46e8db21e134fb3b7143df50f013", size = 44508971 },
{ url = "https://files.pythonhosted.org/packages/72/4f/572bbbe60a5787ca61a84bad934c3ce19c08c2bc9c0e9781bf0d99cad1a6/playwright-1.64.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:59f7b041284ce8d1b72098ae50ca48a2d83692eec25154ab5ba79d4b6b4fe8a9", size = 43155232 },
{ url = "https://files.pythonhosted.org/packages/44/fa/468f313a56d33a6420705147c8d47bef558f087856edf38c5b13eef8aa6c/playwright-1.64.1-py3-none-macosx_11_0_universal2.whl", hash = "sha256:6a6f99adad1ae40e6ee62efe89785db861ce7ddd57651521cb31894183cc4ae3", size = 44508977 },
{ url = "https://files.pythonhosted.org/packages/15/b9/e481a34572933b5cf179d5637c98da488c732ee339dff5b73a0624d2e42b/playwright-1.64.1-py3-none-manylinux1_x86_64.whl", hash = "sha256:577b8d88778c245cfbd145ae17cf53044f084b1b46c55c69081d4069d56057e2", size = 48471931 },
{ url = "https://files.pythonhosted.org/packages/0c/11/545f25e47ec54e1e8a2892ef9994fc2b298f909ab37b88578e3a1f87ac2e/playwright-1.64.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a29fda44725e11b0b625c4bff6458a1f6dbee97b88c9788d12e18a8344458c2d", size = 48150994 },
{ url = "https://files.pythonhosted.org/packages/33/87/8ff7af99387ee72ae9b46a66ece7a0c0aa63f73320ecf6dd3dd087fa840a/playwright-1.64.1-py3-none-win32.whl", hash = "sha256:d3c48b31da426c2f3562d0919cb9ea4e5415f6dbd29d7211fa4d66cca24fbd2f", size = 38801828 },
{ url = "https://files.pythonhosted.org/packages/96/0a/64db6851aa1852a4f76be28eea71d4e2bf9f3ce493ee2e66939426729046/playwright-1.64.1-py3-none-win_amd64.whl", hash = "sha256:5e240a79464434c9260957b9ef59a5808e02e96e3fcabdc717fd7485260b9730", size = 38801836 },
{ url = "https://files.pythonhosted.org/packages/c5/4b/d7ddde9467a5fcfe6a0d3c954732332e98a4f4a9025fd42c8d091c16e5f9/playwright-1.64.1-py3-none-win_arm64.whl", hash = "sha256:983ccd085453ea5a7588193657fc349a6ffbf7bf7ba663fd58dc81e4aa3af9d9", size = 34693875 },
]
[[package]]
name = "pluggy"
version = "1.7.0"
@ -528,6 +637,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/ae/315bbeee126a769cbde43cf4908acb4b40408d8bf8bbf334dd7368d8dadf/pycparser-3.12-py3-none-any.whl", hash = "sha256:a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e", size = 62600 },
]
[[package]]
name = "pyee"
version = "14.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/f1/fdedc2c75c3e31a330659c85e5793bb18b3397981fbf0844c6dee5b18926/pyee-14.0.0.tar.gz", hash = "sha256:76dd0f4314ecd27f02dc73589dea7fd3853f9b6176d8ef9b122860657e3602de", size = 98760 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/12/5347938b1f9a6453f0dbdfcc3e2388a1320ef9b9ec17fbefbc4ab647ea98/pyee-14.0.0-py3-none-any.whl", hash = "sha256:3ac2d3229a9677f7de2c33d7f52fe25b638a46b19c413fea2edc8c6d0a644e4d", size = 15553 },
]
[[package]]
name = "pygments"
version = "2.21.0"
@ -688,6 +809,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070 },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571 },
]
[[package]]
name = "tzdata"
version = "2026.5"