Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,24 @@ Run the **smallest** check that covers your change. If a check cannot be run, sa
| Any Node package (`apps/frontend`, `apps/alienmark`, `packages/alienmark`) | `pnpm run check` (full workspace) or `pnpm turbo run check --filter=<package>` (single package) |
| Backend behavior | `uv run python manage.py test` from `apps/backend/`, or `make dev-backend-test` |
| Backend lint | `uv run ruff check <apps> manage.py` from `apps/backend/` |
| API contract | Regenerate `apps/backend/openapi/v1.yaml`, then run `pnpm --filter frontend api:generate` and commit both generated artifacts |
| Docs site | Run both strict Zensical builds from `docs/<name>/` (default English config, then `zensical.zh.toml`) |
| Unused-code audit (advisory) | `pnpm run knip` |

### API contract synchronization

When backend permissions, serializers, views, response schemas, or routes change the public API contract:

```bash
cd apps/backend
DJANGO_SETTINGS_MODULE=backend.settings.test uv run --project ../.. --package aliencommons-backend python manage.py spectacular --file openapi/v1.yaml --validate --fail-on-warn
cd ../..
pnpm --filter frontend api:generate
pnpm --filter frontend api:check
```

Commit both `apps/backend/openapi/v1.yaml` and `apps/frontend/app/api/generated/v1.d.ts` when they change. CI regenerates these files and fails if either committed artifact is stale.

CI mirrors these in `.github/workflows/ci.yml`. If your change alters app names, settings modules, build commands, or verification steps, update the workflow too.

## Working rules
Expand Down
8 changes: 7 additions & 1 deletion apps/backend/articles/serializers/articles.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from PIL import Image
from rest_framework import serializers

from core.utils.html import sanitize_published_html
from core.validators import FileSizeValidator, FileTypeValidator

from ..models import (
Expand Down Expand Up @@ -167,6 +168,11 @@ class ArticlePublicationVersionSerializer(serializers.ModelSerializer):
"""
Serializer for immutable article publication versions.
"""
html = serializers.SerializerMethodField()

@extend_schema_field(serializers.CharField())
def get_html(self, obj):
return sanitize_published_html(obj.html)

class Meta:
model = ArticlePublicationVersion
Expand Down Expand Up @@ -250,7 +256,7 @@ def get_title(self, obj):
@extend_schema_field(serializers.CharField(allow_null=True))
def get_html(self, obj):
latest_version = self._get_latest_version(obj)
return latest_version.html if latest_version else None
return sanitize_published_html(latest_version.html) if latest_version else None

@extend_schema_field(serializers.DateTimeField(allow_null=True))
def get_publication_at(self, obj):
Expand Down
39 changes: 38 additions & 1 deletion apps/backend/articles/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,6 @@ def test_publication_list_only_returns_published_articles(self):
unpublished_article.status = Article.ArticleStatus.UNPUBLISHED
unpublished_article.save(update_fields=["status"])

self.authenticate(self.viewer)
response = self.get_json(reverse("article_publication-list"))

self.assert_success_response(
Expand All @@ -395,6 +394,33 @@ def test_publication_list_only_returns_published_articles(self):
self.assertEqual(visible_result["latest_version"]["version"], 2)
self.assertEqual(len(visible_result["versions"]), 2)

def test_publication_detail_is_public_and_sanitizes_html(self):
article = create_article(author=self.author, title="Safe publication")
publication = create_article_publication(
article,
html=(
'<h1>Safe</h1><script>alert("xss")</script>'
'<a href="javascript:alert(1)" onclick="alert(1)">link</a>'
),
)

response = self.get_json(
reverse("article_publication-detail", args=[publication.id])
)

self.assert_success_response(
response,
status_code=status.HTTP_200_OK,
code="retrieved",
)
serialized = response.data["data"]
self.assertIn("<h1>Safe</h1>", serialized["html"])
self.assertNotIn("script", serialized["html"])
self.assertNotIn("javascript:", serialized["html"])
self.assertNotIn("onclick", serialized["html"])
self.assertEqual(serialized["html"], serialized["latest_version"]["html"])
self.assertEqual(serialized["html"], serialized["versions"][0]["html"])

def test_publication_detail_returns_404_after_article_is_unpublished(self):
article = create_article(author=self.author)
publication = create_article_publication(article)
Expand All @@ -405,3 +431,14 @@ def test_publication_detail_returns_404_after_article_is_unpublished(self):
response = self.get_json(reverse("article_publication-detail", args=[publication.id]))

self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

def test_publication_endpoint_has_no_edit_operation(self):
article = create_article(author=self.author)
publication = create_article_publication(article)

response = self.patch_json(
reverse("article_publication-detail", args=[publication.id]),
{"title": "Not editable"},
)

self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
4 changes: 2 additions & 2 deletions apps/backend/articles/views/articles.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from drf_std_response import EnvelopeMixin
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet

from core.utils.permissions import is_moderator
Expand Down Expand Up @@ -260,7 +260,7 @@ def trash(self, request, pk=None):
class ArticlePublicationViewSet(EnvelopeMixin, ReadOnlyModelViewSet):
queryset = ArticlePublication.objects.select_related("article").prefetch_related("versions")
serializer_class = ArticlePublicationSerializer
permission_classes = [IsAuthenticated]
permission_classes = [AllowAny]

def get_queryset(self):
from comments.querysets import with_article_publication_comment_count
Expand Down
9 changes: 6 additions & 3 deletions apps/backend/comments/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@

class CommentPermission(permissions.BasePermission):
"""
Authenticated users can read and create comments.
Anyone can read comments; authenticated users can create them.
Authors can edit and soft-delete their own comments.
"""

def has_permission(self, request, view):
return request.user.is_authenticated
return (
request.method in permissions.SAFE_METHODS
or request.user.is_authenticated
)

def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
return obj.author_id == request.user.id

65 changes: 65 additions & 0 deletions apps/backend/comments/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,71 @@ def test_other_user_cannot_delete_comment(self):
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertTrue(Comment.objects.filter(id=comment.id).exists())

def test_anonymous_users_can_read_comments(self):
comment = create_comment(self.author, self.published, body="Public comment")

list_response = self.get_json(
reverse("comment-list"),
{"article_publication": str(self.published.id)},
)
detail_response = self.get_json(reverse("comment-detail", args=[comment.id]))

self.assert_success_response(
list_response,
status_code=status.HTTP_200_OK,
code="listed",
)
self.assert_success_response(
detail_response,
status_code=status.HTTP_200_OK,
code="retrieved",
)

def test_anonymous_users_cannot_write_comments(self):
comment = create_comment(self.author, self.published, body="Public comment")

responses = [
self.post_json(
reverse("comment-list"),
{
"article_publication": str(self.published.id),
"body": "Anonymous comment",
},
),
self.patch_json(
reverse("comment-detail", args=[comment.id]),
{"body": "Anonymous edit"},
),
self.delete_json(reverse("comment-detail", args=[comment.id])),
]

for response in responses:
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

def test_anonymous_users_cannot_read_comments_on_deleted_posts(self):
post = create_community_post(author=self.author, body="Deleted post")
comment = Comment.objects.create(
author=self.author,
target=post.content_target,
body="Hidden comment",
)
post.is_deleted = True
post.save(update_fields=["is_deleted", "updated_at"])

list_response = self.get_json(reverse("comment-list"))
detail_response = self.get_json(reverse("comment-detail", args=[comment.id]))

self.assert_success_response(
list_response,
status_code=status.HTTP_200_OK,
code="listed",
)
self.assertNotIn(
str(comment.id),
{item["id"] for item in list_response.data["data"]["results"]},
)
self.assertEqual(detail_response.status_code, status.HTTP_404_NOT_FOUND)

def test_list_filters_comments_by_article_publication(self):
top_level = create_comment(self.author, self.published, body="Top level")
reply = create_comment(self.other_user, self.published, reply_to=top_level, body="Reply")
Expand Down
17 changes: 17 additions & 0 deletions apps/backend/comments/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from rest_framework import status
from rest_framework.viewsets import ModelViewSet

from articles.models import Article

from .models import Comment
from .permissions import CommentPermission
from .serializers import CommentReadSerializer, CommentWriteSerializer
Expand Down Expand Up @@ -48,6 +50,21 @@ def get_queryset(self):
),
)
)
if self.request.user.is_anonymous:
queryset = queryset.filter(
Q(target__community_post__is_deleted=False)
| Q(
target__article_publication__article__status=(
Article.ArticleStatus.PUBLISHED
)
)
| Q(parent__target__community_post__is_deleted=False)
| Q(
parent__target__article_publication__article__status=(
Article.ArticleStatus.PUBLISHED
)
)
)
article_publication_id = self.request.query_params.get("article_publication")
community_post_id = self.request.query_params.get("community_post")
parent_id = self.request.query_params.get("parent")
Expand Down
36 changes: 36 additions & 0 deletions apps/backend/core/tests/test_html.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from django.test import SimpleTestCase

from core.utils.html import sanitize_published_html


class PublishedHtmlSanitizerTests(SimpleTestCase):
def test_preserves_alienmark_markup(self):
html = (
'<h2>Guide</h2><p>Use <strong>redstone</strong>.</p>'
'<pre><code class="language-ts">const x = 1;</code></pre>'
'<img src="/media/article_images/guide.webp" alt="Guide">'
'<a href="https://example.com">Reference</a>'
)

sanitized = sanitize_published_html(html)

self.assertIn("<h2>Guide</h2>", sanitized)
self.assertIn("<strong>redstone</strong>", sanitized)
self.assertIn('class="language-ts"', sanitized)
self.assertIn('src="/media/article_images/guide.webp"', sanitized)
self.assertIn('href="https://example.com"', sanitized)
self.assertIn('rel="noopener noreferrer"', sanitized)

def test_removes_executable_markup_and_unsafe_urls(self):
html = (
'<script>alert("xss")</script>'
'<img src="data:image/svg+xml,unsafe" onerror="alert(1)">'
'<a href="javascript:alert(1)">unsafe</a>'
)

sanitized = sanitize_published_html(html)

self.assertNotIn("script", sanitized)
self.assertNotIn("data:", sanitized)
self.assertNotIn("onerror", sanitized)
self.assertNotIn("javascript:", sanitized)
36 changes: 36 additions & 0 deletions apps/backend/core/utils/html.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import nh3

PUBLISHED_HTML_CLEANER = nh3.Cleaner(
tags={
"a",
"blockquote",
"code",
"em",
"h1",
"h2",
"h3",
"h4",
"hr",
"img",
"li",
"ol",
"p",
"pre",
"strong",
"ul",
},
clean_content_tags={"script", "style"},
attributes={
"a": {"href"},
"code": {"class"},
"img": {"alt", "src"},
"ol": {"start"},
},
link_rel="noopener noreferrer",
url_schemes={"http", "https", "mailto"},
)


def sanitize_published_html(value: str) -> str:
"""Return the safe HTML subset supported by the article renderer."""
return PUBLISHED_HTML_CLEANER.clean(value)
4 changes: 2 additions & 2 deletions apps/backend/openapi/v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ paths:
- article_publications
security:
- cookieAuth: []
- {}
responses:
'200':
content:
Expand Down Expand Up @@ -231,6 +232,7 @@ paths:
- article_publications
security:
- cookieAuth: []
- {}
responses:
'200':
content:
Expand Down Expand Up @@ -7367,8 +7369,6 @@ components:
html:
type: string
readOnly: true
title: Article in html
description: The article in HTML format
publication_at:
type: string
format: date-time
Expand Down
7 changes: 5 additions & 2 deletions apps/backend/posts/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@

class CommunityPostPermission(permissions.BasePermission):
"""
Authenticated users can read and create community posts.
Anyone can read community posts; authenticated users can create them.
Authors can edit and soft-delete their own community posts.
"""

def has_permission(self, request, view):
return request.user.is_authenticated
return (
request.method in permissions.SAFE_METHODS
or request.user.is_authenticated
)

def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
Expand Down
7 changes: 6 additions & 1 deletion apps/backend/posts/tests/test_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,14 @@ def request(self, method, user):
request.user = user
return request

def test_anonymous_users_do_not_have_general_permission(self):
def test_anonymous_users_have_safe_method_permission(self):
request = self.request("get", AnonymousUser())

self.assertTrue(self.permission.has_permission(request, None))

def test_anonymous_users_do_not_have_unsafe_method_permission(self):
request = self.request("post", AnonymousUser())

self.assertFalse(self.permission.has_permission(request, None))

def test_authenticated_users_have_general_permission(self):
Expand Down
Loading