intent/intent/files/services.py
Alec Perkins 604c2bbf91
Some checks failed
ci / test (push) Failing after 1m6s
ci / deploy (push) Failing after 0s
Harden for launch: limits, CSP, moderation, admin 2FA (Phase 5)
- 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>
2026-10-27 10:51:06 -04:00

111 lines
4 KiB
Python

"""File rules. Every add, replace or remove is also a feature revision with a change note."""
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
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]
content_type = ALLOWED_TYPES.get(PurePath(name).suffix.lower())
if not name or content_type is None:
raise ValidationError(f"That type of file isn't allowed. Use one of: {ALLOWED_LIST}.")
if upload.size > MAX_BYTES:
raise ValidationError(f"Files can be up to {MAX_BYTES // (1024 * 1024)} MB.")
if upload.size == 0:
raise ValidationError("That file is empty.")
return name, content_type
def _add_version(file, by, upload, name, content_type, revision) -> FeatureFileVersion:
return FeatureFileVersion.objects.create(
file=file,
version=file.current_version,
blob=upload,
original_name=name,
content_type=content_type,
size=upload.size,
created_in_revision=revision.revision,
uploaded_by=by,
)
def add_file(feature, by, *, kind, upload, note, issues=()) -> FeatureFile:
name, content_type = check_upload(upload)
with transaction.atomic():
revision = commit_file_change(
feature,
by,
note=note,
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
)
_add_version(file, by, upload, name, content_type, revision)
return file
def replace_file(file, by, *, upload, note, issues=()) -> FeatureFileVersion:
"""A new version of the file. Earlier versions, and threads on them, are kept."""
name, content_type = check_upload(upload)
if file.is_removed:
raise ValidationError("This file was removed. Add it again as a new file.")
with transaction.atomic():
revision = commit_file_change(
file.feature,
by,
note=note,
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"])
return _add_version(file, by, upload, name, content_type, revision)
def remove_file(file, by, *, note) -> None:
if file.is_removed:
raise ValidationError("This file was already removed.")
with transaction.atomic():
revision = commit_file_change(
file.feature, by, note=note, files_change=f"Removed “{file.name}”"
)
file.removed_in_revision = revision.revision
file.save(update_fields=["removed_in_revision", "updated_at"])
def files_at(feature, revision: int) -> list[FeatureFileVersion]:
"""The version of each file that was current at a feature revision."""
versions = (
FeatureFileVersion.objects.filter(
file__feature=feature,
file__added_in_revision__lte=revision,
created_in_revision__lte=revision,
)
.exclude(file__removed_in_revision__lte=revision)
.select_related("file")
.order_by("file__kind", "file__name", "file_id", "-version")
)
latest = {}
for version in versions:
latest.setdefault(version.file_id, version)
return list(latest.values())