diff --git a/AGENTS.md b/AGENTS.md index 45530fe..0b4a27c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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=` (single package) | | Backend behavior | `uv run python manage.py test` from `apps/backend/`, or `make dev-backend-test` | | Backend lint | `uv run ruff check 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//` (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 diff --git a/apps/backend/articles/serializers/articles.py b/apps/backend/articles/serializers/articles.py index a1fabc6..baf0269 100644 --- a/apps/backend/articles/serializers/articles.py +++ b/apps/backend/articles/serializers/articles.py @@ -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 ( @@ -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 @@ -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): diff --git a/apps/backend/articles/tests/test_views.py b/apps/backend/articles/tests/test_views.py index dabde9c..e2fb8a6 100644 --- a/apps/backend/articles/tests/test_views.py +++ b/apps/backend/articles/tests/test_views.py @@ -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( @@ -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=( + '

Safe

' + 'link' + ), + ) + + 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("

Safe

", 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) @@ -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) diff --git a/apps/backend/articles/views/articles.py b/apps/backend/articles/views/articles.py index 6459feb..76aec0d 100644 --- a/apps/backend/articles/views/articles.py +++ b/apps/backend/articles/views/articles.py @@ -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 @@ -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 diff --git a/apps/backend/comments/permissions.py b/apps/backend/comments/permissions.py index c59fb06..db542b7 100644 --- a/apps/backend/comments/permissions.py +++ b/apps/backend/comments/permissions.py @@ -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 - diff --git a/apps/backend/comments/tests/test_views.py b/apps/backend/comments/tests/test_views.py index bd9db47..2a3002c 100644 --- a/apps/backend/comments/tests/test_views.py +++ b/apps/backend/comments/tests/test_views.py @@ -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") diff --git a/apps/backend/comments/views.py b/apps/backend/comments/views.py index 5177d88..706d3ed 100644 --- a/apps/backend/comments/views.py +++ b/apps/backend/comments/views.py @@ -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 @@ -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") diff --git a/apps/backend/core/tests/test_html.py b/apps/backend/core/tests/test_html.py new file mode 100644 index 0000000..adbbc42 --- /dev/null +++ b/apps/backend/core/tests/test_html.py @@ -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 = ( + '

Guide

Use redstone.

' + '
const x = 1;
' + 'Guide' + 'Reference' + ) + + sanitized = sanitize_published_html(html) + + self.assertIn("

Guide

", sanitized) + self.assertIn("redstone", 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 = ( + '' + '' + 'unsafe' + ) + + sanitized = sanitize_published_html(html) + + self.assertNotIn("script", sanitized) + self.assertNotIn("data:", sanitized) + self.assertNotIn("onerror", sanitized) + self.assertNotIn("javascript:", sanitized) diff --git a/apps/backend/core/utils/html.py b/apps/backend/core/utils/html.py new file mode 100644 index 0000000..e597387 --- /dev/null +++ b/apps/backend/core/utils/html.py @@ -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) diff --git a/apps/backend/openapi/v1.yaml b/apps/backend/openapi/v1.yaml index 4cfabe5..6436939 100644 --- a/apps/backend/openapi/v1.yaml +++ b/apps/backend/openapi/v1.yaml @@ -160,6 +160,7 @@ paths: - article_publications security: - cookieAuth: [] + - {} responses: '200': content: @@ -231,6 +232,7 @@ paths: - article_publications security: - cookieAuth: [] + - {} responses: '200': content: @@ -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 diff --git a/apps/backend/posts/permissions.py b/apps/backend/posts/permissions.py index af4d654..3674e99 100644 --- a/apps/backend/posts/permissions.py +++ b/apps/backend/posts/permissions.py @@ -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: diff --git a/apps/backend/posts/tests/test_permissions.py b/apps/backend/posts/tests/test_permissions.py index 66ada44..e2d800c 100644 --- a/apps/backend/posts/tests/test_permissions.py +++ b/apps/backend/posts/tests/test_permissions.py @@ -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): diff --git a/apps/backend/posts/tests/test_views.py b/apps/backend/posts/tests/test_views.py index 08c420e..bb3981d 100644 --- a/apps/backend/posts/tests/test_views.py +++ b/apps/backend/posts/tests/test_views.py @@ -239,12 +239,27 @@ def test_other_user_cannot_destroy_post(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertTrue(CommunityPost.objects.filter(id=post.id).exists()) - def test_anonymous_users_cannot_access_posts(self): + def test_anonymous_users_can_read_posts(self): + post = create_community_post(author=self.author, body="Hello community") + + list_response = self.get_json(reverse("community_post-list")) + detail_response = self.get_json(reverse("community_post-detail", args=[post.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_posts(self): post = create_community_post(author=self.author, body="Hello community") responses = [ - self.get_json(reverse("community_post-list")), - self.get_json(reverse("community_post-detail", args=[post.id])), self.post_json(reverse("community_post-list"), {"body": "Hello"}), self.patch_json(reverse("community_post-detail", args=[post.id]), {"body": "After"}), self.delete_json(reverse("community_post-detail", args=[post.id])), diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index d587038..266890d 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "drf-spectacular==0.30.0", "drf-std-response==0.1.0", "environs==15.1.0", + "nh3==0.3.6", "pillow==12.3.0", "psycopg[binary]==3.3.4", "requests==2.34.2", diff --git a/apps/frontend/README.md b/apps/frontend/README.md index afa3f4d..60411d2 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -32,6 +32,10 @@ The application supports English at `/` and Simplified Chinese at `/zh`. Translations live in `i18n/locales`; keep both locale files structurally in sync when adding interface copy. +Authentication uses Django's same-origin session cookie. The Nuxt session +plugin resolves the current user during SSR, while login and logout bootstrap +and submit the required CSRF token in the browser. + ## Production Build the application for production: diff --git a/apps/frontend/app/api/articles.ts b/apps/frontend/app/api/articles.ts new file mode 100644 index 0000000..a3e2a4c --- /dev/null +++ b/apps/frontend/app/api/articles.ts @@ -0,0 +1,32 @@ +import type { components } from "~/api/generated/v1"; + +import type { ApiClient } from "./client"; +import { unwrapApiResponse } from "./errors"; + +export type ArticlePublication = components["schemas"]["ArticlePublication"]; +export type ArticlePublicationPage = + components["schemas"]["PaginatedArticlePublicationList"]; + +export async function listArticlePublications( + api: ApiClient, + page = 1 +): Promise { + const response = unwrapApiResponse( + await api.GET("/v1/article_publications/", { + params: { query: { page } }, + }) + ); + return response.data; +} + +export async function getArticlePublication( + api: ApiClient, + id: string +): Promise { + const response = unwrapApiResponse( + await api.GET("/v1/article_publications/{id}/", { + params: { path: { id } }, + }) + ); + return response.data; +} diff --git a/apps/frontend/app/api/community-posts.ts b/apps/frontend/app/api/community-posts.ts new file mode 100644 index 0000000..c223df3 --- /dev/null +++ b/apps/frontend/app/api/community-posts.ts @@ -0,0 +1,32 @@ +import type { components } from "~/api/generated/v1"; + +import type { ApiClient } from "./client"; +import { unwrapApiResponse } from "./errors"; + +export type CommunityPost = components["schemas"]["CommunityPostRead"]; +export type CommunityPostPage = + components["schemas"]["PaginatedCommunityPostReadList"]; + +export async function listCommunityPosts( + api: ApiClient, + page = 1 +): Promise { + const response = unwrapApiResponse( + await api.GET("/v1/community_posts/", { + params: { query: { page } }, + }) + ); + return response.data; +} + +export async function getCommunityPost( + api: ApiClient, + id: string +): Promise { + const response = unwrapApiResponse( + await api.GET("/v1/community_posts/{id}/", { + params: { path: { id } }, + }) + ); + return response.data; +} diff --git a/apps/frontend/app/api/generated/v1.d.ts b/apps/frontend/app/api/generated/v1.d.ts index 704045e..642775d 100644 --- a/apps/frontend/app/api/generated/v1.d.ts +++ b/apps/frontend/app/api/generated/v1.d.ts @@ -1046,10 +1046,6 @@ export interface components { readonly version: number; /** @description The title of the article publication version */ readonly title: string; - /** - * Article in html - * @description The article in HTML format - */ readonly html: string; /** * Published at diff --git a/apps/frontend/app/api/session.ts b/apps/frontend/app/api/session.ts new file mode 100644 index 0000000..a414601 --- /dev/null +++ b/apps/frontend/app/api/session.ts @@ -0,0 +1,36 @@ +import type { components } from "~/api/generated/v1"; + +import type { ApiClient } from "./client"; +import { unwrapApiResponse } from "./errors"; + +export type AuthUser = components["schemas"]["UserRetrieve"]; +export type LoginCredentials = components["schemas"]["UserLoginRequest"]; + +export async function fetchCurrentUser(api: ApiClient): Promise { + const response = unwrapApiResponse(await api.GET("/v1/profiles/me/")); + return response.data; +} + +export async function loginSession( + api: ApiClient, + credentials: LoginCredentials, + csrfToken: string +): Promise { + unwrapApiResponse( + await api.POST("/v1/sessions/login/", { + body: credentials, + params: { header: { "X-CSRFToken": csrfToken } }, + }) + ); +} + +export async function logoutSession( + api: ApiClient, + csrfToken: string +): Promise { + unwrapApiResponse( + await api.POST("/v1/sessions/logout/", { + params: { header: { "X-CSRFToken": csrfToken } }, + }) + ); +} diff --git a/apps/frontend/app/components/AppHeader.vue b/apps/frontend/app/components/AppHeader.vue index ab09629..e4d66bd 100644 --- a/apps/frontend/app/components/AppHeader.vue +++ b/apps/frontend/app/components/AppHeader.vue @@ -1,5 +1,21 @@ diff --git a/apps/frontend/app/components/articles/ArticleBody.vue b/apps/frontend/app/components/articles/ArticleBody.vue new file mode 100644 index 0000000..84bd69d --- /dev/null +++ b/apps/frontend/app/components/articles/ArticleBody.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/apps/frontend/app/components/articles/ArticleCard.vue b/apps/frontend/app/components/articles/ArticleCard.vue new file mode 100644 index 0000000..f3586d9 --- /dev/null +++ b/apps/frontend/app/components/articles/ArticleCard.vue @@ -0,0 +1,60 @@ + + + diff --git a/apps/frontend/app/components/articles/ArticleList.vue b/apps/frontend/app/components/articles/ArticleList.vue new file mode 100644 index 0000000..693b46a --- /dev/null +++ b/apps/frontend/app/components/articles/ArticleList.vue @@ -0,0 +1,17 @@ + + + diff --git a/apps/frontend/app/components/articles/ArticlePagination.vue b/apps/frontend/app/components/articles/ArticlePagination.vue new file mode 100644 index 0000000..95ed883 --- /dev/null +++ b/apps/frontend/app/components/articles/ArticlePagination.vue @@ -0,0 +1,52 @@ + + + diff --git a/apps/frontend/app/components/auth/LoginForm.vue b/apps/frontend/app/components/auth/LoginForm.vue new file mode 100644 index 0000000..7498cc0 --- /dev/null +++ b/apps/frontend/app/components/auth/LoginForm.vue @@ -0,0 +1,74 @@ + + + diff --git a/apps/frontend/app/components/auth/UserMenu.vue b/apps/frontend/app/components/auth/UserMenu.vue new file mode 100644 index 0000000..f6a7c84 --- /dev/null +++ b/apps/frontend/app/components/auth/UserMenu.vue @@ -0,0 +1,35 @@ + + + diff --git a/apps/frontend/app/components/community/CommunityPagination.vue b/apps/frontend/app/components/community/CommunityPagination.vue new file mode 100644 index 0000000..1cbf5c1 --- /dev/null +++ b/apps/frontend/app/components/community/CommunityPagination.vue @@ -0,0 +1,52 @@ + + + diff --git a/apps/frontend/app/components/community/CommunityPostCard.vue b/apps/frontend/app/components/community/CommunityPostCard.vue new file mode 100644 index 0000000..fcac7db --- /dev/null +++ b/apps/frontend/app/components/community/CommunityPostCard.vue @@ -0,0 +1,72 @@ + + + diff --git a/apps/frontend/app/components/community/CommunityPostList.vue b/apps/frontend/app/components/community/CommunityPostList.vue new file mode 100644 index 0000000..ecd1430 --- /dev/null +++ b/apps/frontend/app/components/community/CommunityPostList.vue @@ -0,0 +1,17 @@ + + + diff --git a/apps/frontend/app/components/home/LatestContent.vue b/apps/frontend/app/components/home/LatestContent.vue new file mode 100644 index 0000000..facd1e1 --- /dev/null +++ b/apps/frontend/app/components/home/LatestContent.vue @@ -0,0 +1,118 @@ + + + diff --git a/apps/frontend/app/components/ui/BaseButton.vue b/apps/frontend/app/components/ui/BaseButton.vue new file mode 100644 index 0000000..082e762 --- /dev/null +++ b/apps/frontend/app/components/ui/BaseButton.vue @@ -0,0 +1,59 @@ + + + diff --git a/apps/frontend/app/components/ui/BaseInput.vue b/apps/frontend/app/components/ui/BaseInput.vue new file mode 100644 index 0000000..7a45bb2 --- /dev/null +++ b/apps/frontend/app/components/ui/BaseInput.vue @@ -0,0 +1,33 @@ + + + diff --git a/apps/frontend/app/components/ui/EmptyState.vue b/apps/frontend/app/components/ui/EmptyState.vue new file mode 100644 index 0000000..a9990e2 --- /dev/null +++ b/apps/frontend/app/components/ui/EmptyState.vue @@ -0,0 +1,28 @@ + + + diff --git a/apps/frontend/app/components/ui/FormError.vue b/apps/frontend/app/components/ui/FormError.vue new file mode 100644 index 0000000..4db4471 --- /dev/null +++ b/apps/frontend/app/components/ui/FormError.vue @@ -0,0 +1,14 @@ + + + diff --git a/apps/frontend/app/components/ui/FormField.vue b/apps/frontend/app/components/ui/FormField.vue new file mode 100644 index 0000000..bec9351 --- /dev/null +++ b/apps/frontend/app/components/ui/FormField.vue @@ -0,0 +1,46 @@ + + + diff --git a/apps/frontend/app/components/ui/LoadingSkeleton.vue b/apps/frontend/app/components/ui/LoadingSkeleton.vue new file mode 100644 index 0000000..3377578 --- /dev/null +++ b/apps/frontend/app/components/ui/LoadingSkeleton.vue @@ -0,0 +1,29 @@ + + + diff --git a/apps/frontend/app/components/ui/README.md b/apps/frontend/app/components/ui/README.md new file mode 100644 index 0000000..ed5df73 --- /dev/null +++ b/apps/frontend/app/components/ui/README.md @@ -0,0 +1,37 @@ +# UI foundations + +These components are the smallest shared presentation layer for the Nuxt app. +They contain visual and accessibility behavior, but no product or API logic. + +Nuxt auto-imports this directory with the `Ui` prefix: + +| Component | Responsibility | +| --- | --- | +| `UiBaseButton` | Button variants, sizes, disabled and loading states | +| `UiBaseInput` | Native input styling, `v-model`, invalid and disabled states | +| `UiFormField` | Label, description, error and `aria-describedby` wiring | +| `UiFormError` | Form-level alert message | +| `UiUserAvatar` | Avatar image, initials fallback and size variants | +| `UiLoadingSkeleton` | Content-shaped loading placeholder | +| `UiEmptyState` | Empty result title, description, icon and action slots | + +Use translated strings at the call site. Shared UI components must not own +feature-specific i18n keys. + +```vue + + + +``` + +Prefer these components when their existing contract fits. Extend a contract +only when at least one real feature needs the new behavior; do not add product +state, API requests, or feature-specific layout to this directory. diff --git a/apps/frontend/app/components/ui/UserAvatar.vue b/apps/frontend/app/components/ui/UserAvatar.vue new file mode 100644 index 0000000..f671a97 --- /dev/null +++ b/apps/frontend/app/components/ui/UserAvatar.vue @@ -0,0 +1,41 @@ + + + diff --git a/apps/frontend/app/composables/useArticles.ts b/apps/frontend/app/composables/useArticles.ts new file mode 100644 index 0000000..18d8ee9 --- /dev/null +++ b/apps/frontend/app/composables/useArticles.ts @@ -0,0 +1,42 @@ +import type { MaybeRefOrGetter } from "vue"; + +import { getArticlePublication, listArticlePublications } from "~/api/articles"; +import { ApiResponseError } from "~/api/errors"; + +interface ArticleListOptions { + key?: string; +} + +export function useArticleList( + page: MaybeRefOrGetter, + options: ArticleListOptions = {} +) { + const api = useApi(); + const resolvedPage = computed(() => toValue(page)); + + return useAsyncData( + options.key ?? "article-publication-list", + () => listArticlePublications(api, resolvedPage.value), + { watch: [resolvedPage] } + ); +} + +export function useArticlePublication(id: MaybeRefOrGetter) { + const api = useApi(); + const resolvedId = computed(() => toValue(id)); + + return useAsyncData( + "article-publication-detail", + async () => { + try { + return await getArticlePublication(api, resolvedId.value); + } catch (error) { + if (error instanceof ApiResponseError && error.status === 404) { + throw createError({ statusCode: 404, statusMessage: "Not Found" }); + } + throw error; + } + }, + { watch: [resolvedId] } + ); +} diff --git a/apps/frontend/app/composables/useAuthSession.ts b/apps/frontend/app/composables/useAuthSession.ts new file mode 100644 index 0000000..ac0afc2 --- /dev/null +++ b/apps/frontend/app/composables/useAuthSession.ts @@ -0,0 +1,71 @@ +import { ApiResponseError } from "~/api/errors"; +import { fetchCurrentUser, loginSession, logoutSession } from "~/api/session"; +import type { LoginCredentials } from "~/api/session"; + +function isUnauthenticated(error: unknown): boolean { + return ( + error instanceof ApiResponseError && + (error.status === 401 || error.status === 403) + ); +} + +export function useAuthSession() { + const api = useApi(); + const store = useAuthStore(); + const { isAuthenticated, status, user } = storeToRefs(store); + + async function refresh(): Promise { + store.setLoading(); + try { + store.setAuthenticated(await fetchCurrentUser(api)); + } catch (error) { + if (isUnauthenticated(error)) { + store.setAnonymous(); + return; + } + + store.setError(); + } + } + + async function initialize(): Promise { + if (store.status !== "idle") { + return; + } + await refresh(); + } + + async function login(credentials: LoginCredentials): Promise { + const csrfToken = await ensureCsrfToken(); + await loginSession(api, credentials, csrfToken); + await refresh(); + + if (!store.isAuthenticated) { + throw new Error("The session was created without an authenticated user."); + } + } + + async function logout(): Promise { + try { + const csrfToken = await ensureCsrfToken(); + await logoutSession(api, csrfToken); + store.setAnonymous(); + } catch (error) { + if (isUnauthenticated(error)) { + store.setAnonymous(); + return; + } + throw error; + } + } + + return { + initialize, + isAuthenticated: readonly(isAuthenticated), + login, + logout, + refresh, + status: readonly(status), + user: readonly(user), + }; +} diff --git a/apps/frontend/app/composables/useCommunityPosts.ts b/apps/frontend/app/composables/useCommunityPosts.ts new file mode 100644 index 0000000..b267519 --- /dev/null +++ b/apps/frontend/app/composables/useCommunityPosts.ts @@ -0,0 +1,42 @@ +import type { MaybeRefOrGetter } from "vue"; + +import { getCommunityPost, listCommunityPosts } from "~/api/community-posts"; +import { ApiResponseError } from "~/api/errors"; + +interface CommunityPostListOptions { + key?: string; +} + +export function useCommunityPostList( + page: MaybeRefOrGetter, + options: CommunityPostListOptions = {} +) { + const api = useApi(); + const resolvedPage = computed(() => toValue(page)); + + return useAsyncData( + options.key ?? "community-post-list", + () => listCommunityPosts(api, resolvedPage.value), + { watch: [resolvedPage] } + ); +} + +export function useCommunityPost(id: MaybeRefOrGetter) { + const api = useApi(); + const resolvedId = computed(() => toValue(id)); + + return useAsyncData( + "community-post-detail", + async () => { + try { + return await getCommunityPost(api, resolvedId.value); + } catch (error) { + if (error instanceof ApiResponseError && error.status === 404) { + throw createError({ statusCode: 404, statusMessage: "Not Found" }); + } + throw error; + } + }, + { watch: [resolvedId] } + ); +} diff --git a/apps/frontend/app/middleware/auth.ts b/apps/frontend/app/middleware/auth.ts new file mode 100644 index 0000000..2c58a1a --- /dev/null +++ b/apps/frontend/app/middleware/auth.ts @@ -0,0 +1,14 @@ +export default defineNuxtRouteMiddleware((to) => { + const store = useAuthStore(); + if (store.status !== "anonymous") { + return; + } + + const localePath = useLocalePath(); + return navigateTo( + localePath({ + name: "login", + query: { redirect: to.fullPath }, + }) + ); +}); diff --git a/apps/frontend/app/middleware/guest.ts b/apps/frontend/app/middleware/guest.ts new file mode 100644 index 0000000..60a1463 --- /dev/null +++ b/apps/frontend/app/middleware/guest.ts @@ -0,0 +1,8 @@ +export default defineNuxtRouteMiddleware(() => { + const store = useAuthStore(); + if (!store.isAuthenticated) { + return; + } + + return navigateTo(useLocalePath()("index")); +}); diff --git a/apps/frontend/app/pages/articles/[id].vue b/apps/frontend/app/pages/articles/[id].vue new file mode 100644 index 0000000..20fdce2 --- /dev/null +++ b/apps/frontend/app/pages/articles/[id].vue @@ -0,0 +1,112 @@ + + + diff --git a/apps/frontend/app/pages/articles/index.vue b/apps/frontend/app/pages/articles/index.vue new file mode 100644 index 0000000..095cd85 --- /dev/null +++ b/apps/frontend/app/pages/articles/index.vue @@ -0,0 +1,59 @@ + + + diff --git a/apps/frontend/app/pages/community/[id].vue b/apps/frontend/app/pages/community/[id].vue new file mode 100644 index 0000000..f15c821 --- /dev/null +++ b/apps/frontend/app/pages/community/[id].vue @@ -0,0 +1,96 @@ + + + diff --git a/apps/frontend/app/pages/community/index.vue b/apps/frontend/app/pages/community/index.vue new file mode 100644 index 0000000..f4a6153 --- /dev/null +++ b/apps/frontend/app/pages/community/index.vue @@ -0,0 +1,59 @@ + + + diff --git a/apps/frontend/app/pages/index.vue b/apps/frontend/app/pages/index.vue index dd430c8..7a20dac 100644 --- a/apps/frontend/app/pages/index.vue +++ b/apps/frontend/app/pages/index.vue @@ -1,5 +1,6 @@ diff --git a/apps/frontend/app/pages/login.vue b/apps/frontend/app/pages/login.vue new file mode 100644 index 0000000..d5f554f --- /dev/null +++ b/apps/frontend/app/pages/login.vue @@ -0,0 +1,46 @@ + + + diff --git a/apps/frontend/app/plugins/session.ts b/apps/frontend/app/plugins/session.ts new file mode 100644 index 0000000..0e4ade2 --- /dev/null +++ b/apps/frontend/app/plugins/session.ts @@ -0,0 +1,4 @@ +export default defineNuxtPlugin(async () => { + const { initialize } = useAuthSession(); + await initialize(); +}); diff --git a/apps/frontend/app/stores/auth.ts b/apps/frontend/app/stores/auth.ts new file mode 100644 index 0000000..0af2aa1 --- /dev/null +++ b/apps/frontend/app/stores/auth.ts @@ -0,0 +1,48 @@ +import { defineStore } from "pinia"; +import { computed, shallowRef } from "vue"; + +import type { AuthUser } from "~/api/session"; + +export type AuthStatus = + | "idle" + | "loading" + | "authenticated" + | "anonymous" + | "error"; + +export const useAuthStore = defineStore("auth", () => { + const status = shallowRef("idle"); + const user = shallowRef(); + + const isAuthenticated = computed( + () => status.value === "authenticated" && user.value !== undefined + ); + + function setLoading(): void { + status.value = "loading"; + } + + function setAuthenticated(nextUser: AuthUser): void { + user.value = nextUser; + status.value = "authenticated"; + } + + function setAnonymous(): void { + user.value = undefined; + status.value = "anonymous"; + } + + function setError(): void { + status.value = "error"; + } + + return { + isAuthenticated, + setAnonymous, + setAuthenticated, + setError, + setLoading, + status, + user, + }; +}); diff --git a/apps/frontend/app/utils/community-posts.ts b/apps/frontend/app/utils/community-posts.ts new file mode 100644 index 0000000..49f5104 --- /dev/null +++ b/apps/frontend/app/utils/community-posts.ts @@ -0,0 +1,12 @@ +import type { CommunityPost } from "~/api/community-posts"; + +const MENTION_PATTERN = /\{\{mention:(\d+)\}\}/g; + +export function resolveCommunityPostBody( + post: Pick +): string { + return post.body.replace(MENTION_PATTERN, (token, indexValue: string) => { + const mention = post.mention_users[Number(indexValue)]; + return mention ? `@${mention.username}` : token; + }); +} diff --git a/apps/frontend/app/utils/content.ts b/apps/frontend/app/utils/content.ts new file mode 100644 index 0000000..6b6ae10 --- /dev/null +++ b/apps/frontend/app/utils/content.ts @@ -0,0 +1,29 @@ +export function parsePageNumber(value: unknown): number { + const candidate = Array.isArray(value) ? value[0] : value; + const page = typeof candidate === "string" ? Number(candidate) : candidate; + return typeof page === "number" && Number.isInteger(page) && page > 0 + ? page + : 1; +} + +export function formatContentDate(value: string, locale: string): string { + return new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeZone: "UTC", + }).format(new Date(value)); +} + +export function getContentExcerpt(value: string, maximumLength = 220): string { + const normalized = value.replace(/\s+/g, " ").trim(); + if (normalized.length <= maximumLength) { + return normalized; + } + return `${normalized.slice(0, maximumLength).trimEnd()}…`; +} + +export function isUuid(value: unknown): value is string { + return ( + typeof value === "string" && + /^[\da-f]{8}-(?:[\da-f]{4}-){3}[\da-f]{12}$/i.test(value) + ); +} diff --git a/apps/frontend/app/utils/navigation.ts b/apps/frontend/app/utils/navigation.ts new file mode 100644 index 0000000..aa5e0f5 --- /dev/null +++ b/apps/frontend/app/utils/navigation.ts @@ -0,0 +1,11 @@ +export function resolveSafeRedirect( + candidate: unknown, + fallback: string +): string { + const path = Array.isArray(candidate) ? candidate[0] : candidate; + return typeof path === "string" && + path.startsWith("/") && + !path.startsWith("//") + ? path + : fallback; +} diff --git a/apps/frontend/app/utils/ui.ts b/apps/frontend/app/utils/ui.ts new file mode 100644 index 0000000..62dcf7b --- /dev/null +++ b/apps/frontend/app/utils/ui.ts @@ -0,0 +1,8 @@ +export function getAvatarInitial(name: string): string { + return name.trim().slice(0, 1).toUpperCase() || "?"; +} + +export function getSkeletonRowIds(rows: number): number[] { + const count = Number.isFinite(rows) ? Math.max(1, Math.trunc(rows)) : 1; + return Array.from({ length: count }, (_, index) => index + 1); +} diff --git a/apps/frontend/i18n/locales/en.json b/apps/frontend/i18n/locales/en.json index 26b8978..ac07e5f 100644 --- a/apps/frontend/i18n/locales/en.json +++ b/apps/frontend/i18n/locales/en.json @@ -2,6 +2,109 @@ "accessibility": { "skipToContent": "Skip to main content" }, + "auth": { + "login": { + "description": "Sign in with your AlienCommons account to continue.", + "email": "Email", + "eyebrow": "Welcome back", + "invalidCredentials": "The email or password is incorrect.", + "metaTitle": "Sign in", + "navigation": "Sign in", + "password": "Password", + "submit": "Sign in", + "submitting": "Signing in…", + "title": "Sign in to AlienCommons", + "unavailable": "Sign-in is temporarily unavailable. Please try again." + }, + "logout": { + "submit": "Sign out", + "submitting": "Signing out…", + "unavailable": "Sign-out failed. Please try again." + }, + "session": { + "loading": "Loading account" + }, + "userMenu": { + "avatarAlt": "{username}'s avatar" + } + }, + "articles": { + "card": { + "label": "Published guide" + }, + "comments": "{count} comments", + "detail": { + "back": "Back to articles", + "label": "Published article", + "metaDescription": "Read {title} on AlienCommons." + }, + "dislikes": "{count} dislikes", + "empty": { + "description": "The first community article has not been published yet.", + "title": "No articles yet" + }, + "emptyBody": { + "description": "This publication does not currently contain a readable article body.", + "title": "Article body unavailable" + }, + "error": { + "description": "We could not load published articles. Please try again.", + "retry": "Try again", + "title": "Articles are unavailable" + }, + "eyebrow": "Knowledge base", + "likes": "{count} likes", + "list": { + "description": "Browse reviewed guides and lasting resources published by the Technical Minecraft community.", + "metaTitle": "Published articles", + "title": "Published articles" + }, + "loading": "Loading published articles", + "pagination": { + "label": "Article pages", + "next": "Next", + "previous": "Previous", + "status": "Page {current} of {total}" + }, + "read": "Read article", + "readNamed": "Read {title}", + "untitled": "Untitled article" + }, + "community": { + "authorAvatar": "{username}'s avatar", + "comments": "{count} comments", + "deletedUser": "Deleted user", + "detail": { + "back": "Back to community", + "metaTitle": "Post by {username}" + }, + "dislikes": "{count} dislikes", + "empty": { + "description": "The first community post has not been published yet.", + "title": "No posts yet" + }, + "error": { + "description": "We could not load community posts. Please try again.", + "retry": "Try again", + "title": "Community posts are unavailable" + }, + "eyebrow": "Community", + "likes": "{count} likes", + "list": { + "description": "Ideas, discoveries, and conversations from the Technical Minecraft community.", + "metaTitle": "Community posts", + "title": "Community posts" + }, + "loading": "Loading community posts", + "pagination": { + "label": "Community post pages", + "next": "Next", + "previous": "Previous", + "status": "Page {current} of {total}" + }, + "readPost": "Read post", + "readPostBy": "Read post by {username}" + }, "error": { "genericDescription": "Something prevented this page from loading. You can return home and try again.", "genericTitle": "Something went wrong", @@ -16,10 +119,18 @@ "home": { "description": "A shared place to publish knowledge, exchange ideas, and build lasting resources for the Technical Minecraft community.", "eyebrow": "Technical Minecraft, together", + "exploreArticles": "Browse articles", + "exploreCommunity": "Explore the community", + "latestArticlesEyebrow": "Latest knowledge", + "latestArticlesTitle": "Recently published articles", + "latestPostsEyebrow": "Latest activity", + "latestPostsTitle": "From the community", "metaTitle": "Technical Minecraft community", "statusDescription": "The Nuxt application shell, localized routing, API foundation, and server-side rendering are ready for the first product features.", "statusTitle": "The foundation is ready", - "title": "A home for knowledge built block by block." + "title": "A home for knowledge built block by block.", + "viewAllArticles": "View all articles", + "viewAllPosts": "View all posts" }, "locale": { "chinese": "Switch to Simplified Chinese", @@ -27,6 +138,8 @@ "label": "Language" }, "navigation": { + "articles": "Articles", + "community": "Community", "home": "Home", "primary": "Primary navigation" } diff --git a/apps/frontend/i18n/locales/zh.json b/apps/frontend/i18n/locales/zh.json index db2a9cf..e174359 100644 --- a/apps/frontend/i18n/locales/zh.json +++ b/apps/frontend/i18n/locales/zh.json @@ -2,6 +2,109 @@ "accessibility": { "skipToContent": "跳到主要内容" }, + "auth": { + "login": { + "description": "使用你的 AlienCommons 账户登录以继续。", + "email": "电子邮箱", + "eyebrow": "欢迎回来", + "invalidCredentials": "邮箱或密码不正确。", + "metaTitle": "登录", + "navigation": "登录", + "password": "密码", + "submit": "登录", + "submitting": "正在登录…", + "title": "登录 AlienCommons", + "unavailable": "暂时无法登录,请稍后重试。" + }, + "logout": { + "submit": "退出登录", + "submitting": "正在退出…", + "unavailable": "退出登录失败,请重试。" + }, + "session": { + "loading": "正在加载账户" + }, + "userMenu": { + "avatarAlt": "{username} 的头像" + } + }, + "articles": { + "card": { + "label": "已发布指南" + }, + "comments": "{count} 条评论", + "detail": { + "back": "返回文章列表", + "label": "已发布文章", + "metaDescription": "在 AlienCommons 阅读《{title}》。" + }, + "dislikes": "{count} 次反对", + "empty": { + "description": "社区的第一篇文章还没有发布。", + "title": "还没有文章" + }, + "emptyBody": { + "description": "这个发布版本暂时没有可阅读的文章正文。", + "title": "文章正文不可用" + }, + "error": { + "description": "暂时无法加载已发布文章,请重试。", + "retry": "重试", + "title": "文章暂不可用" + }, + "eyebrow": "知识库", + "likes": "{count} 次赞同", + "list": { + "description": "浏览由技术型 Minecraft 社区发布并经过审核的指南和长期资源。", + "metaTitle": "已发布文章", + "title": "已发布文章" + }, + "loading": "正在加载已发布文章", + "pagination": { + "label": "文章分页", + "next": "下一页", + "previous": "上一页", + "status": "第 {current} 页,共 {total} 页" + }, + "read": "阅读文章", + "readNamed": "阅读《{title}》", + "untitled": "无标题文章" + }, + "community": { + "authorAvatar": "{username} 的头像", + "comments": "{count} 条评论", + "deletedUser": "已注销用户", + "detail": { + "back": "返回社区", + "metaTitle": "{username} 发布的帖子" + }, + "dislikes": "{count} 次反对", + "empty": { + "description": "社区的第一篇帖子还没有发布。", + "title": "还没有帖子" + }, + "error": { + "description": "暂时无法加载社区帖子,请重试。", + "retry": "重试", + "title": "社区帖子暂不可用" + }, + "eyebrow": "社区", + "likes": "{count} 次赞同", + "list": { + "description": "浏览技术型 Minecraft 社区分享的想法、发现与讨论。", + "metaTitle": "社区帖子", + "title": "社区帖子" + }, + "loading": "正在加载社区帖子", + "pagination": { + "label": "社区帖子分页", + "next": "下一页", + "previous": "上一页", + "status": "第 {current} 页,共 {total} 页" + }, + "readPost": "阅读帖子", + "readPostBy": "阅读 {username} 发布的帖子" + }, "error": { "genericDescription": "页面加载时遇到了问题。你可以返回首页后重试。", "genericTitle": "出现了一些问题", @@ -16,10 +119,18 @@ "home": { "description": "一个为技术型 Minecraft 社区发布知识、交流想法并共同沉淀长期资源的共享空间。", "eyebrow": "一起探索技术型 Minecraft", + "exploreArticles": "浏览文章", + "exploreCommunity": "探索社区", + "latestArticlesEyebrow": "最新知识", + "latestArticlesTitle": "最近发布的文章", + "latestPostsEyebrow": "最新动态", + "latestPostsTitle": "来自社区", "metaTitle": "技术型 Minecraft 社区", "statusDescription": "Nuxt 应用外壳、本地化路由、API 地基和服务端渲染已经就绪,可以开始构建第一个产品功能。", "statusTitle": "项目地基已经就绪", - "title": "一砖一瓦,共建知识家园。" + "title": "一砖一瓦,共建知识家园。", + "viewAllArticles": "查看全部文章", + "viewAllPosts": "查看全部帖子" }, "locale": { "chinese": "切换到简体中文", @@ -27,6 +138,8 @@ "label": "语言" }, "navigation": { + "articles": "文章", + "community": "社区", "home": "首页", "primary": "主导航" } diff --git a/apps/frontend/test/articles-api.test.ts b/apps/frontend/test/articles-api.test.ts new file mode 100644 index 0000000..b4ebfab --- /dev/null +++ b/apps/frontend/test/articles-api.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + getArticlePublication, + listArticlePublications, +} from "../app/api/articles"; +import { createApiClient } from "../app/api/client"; + +const publicationId = "00000000-0000-0000-0000-000000000001"; + +function envelope(data: unknown): Response { + return new Response(JSON.stringify({ data }), { + headers: { "content-type": "application/json" }, + status: 200, + }); +} + +function publication() { + return { + article: "00000000-0000-0000-0000-000000000002", + comment_count: 2, + created_at: "2026-01-02T00:00:00Z", + dislike_count: 0, + html: "

Safe article

", + id: publicationId, + latest_version: undefined, + like_count: 4, + my_reaction: undefined, + publication_at: "2026-01-02T00:00:00Z", + published_at: "2026-01-02T00:00:00Z", + title: "Redstone guide", + updated_at: "2026-01-02T00:00:00Z", + versions: [], + }; +} + +describe("article publications API", () => { + it("lists the requested page", async () => { + const requests: Request[] = []; + const api = createApiClient({ + baseUrl: "https://example.test/api", + fetch: async (request) => { + requests.push(request); + return envelope({ + count: 1, + current_page: 3, + page_size: 20, + results: [publication()], + total_pages: 3, + }); + }, + }); + + const result = await listArticlePublications(api, 3); + + expect(requests[0]?.url).toBe( + "https://example.test/api/v1/article_publications/?page=3" + ); + expect(result.results[0]?.title).toBe("Redstone guide"); + }); + + it("retrieves a publication by id", async () => { + const requests: Request[] = []; + const api = createApiClient({ + baseUrl: "https://example.test/api", + fetch: async (request) => { + requests.push(request); + return envelope(publication()); + }, + }); + + const result = await getArticlePublication(api, publicationId); + + expect(requests[0]?.url).toBe( + `https://example.test/api/v1/article_publications/${publicationId}/` + ); + expect(result.html).toBe("

Safe article

"); + }); +}); diff --git a/apps/frontend/test/auth-store.test.ts b/apps/frontend/test/auth-store.test.ts new file mode 100644 index 0000000..83d429f --- /dev/null +++ b/apps/frontend/test/auth-store.test.ts @@ -0,0 +1,43 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import type { AuthUser } from "../app/api/session"; +import { useAuthStore } from "../app/stores/auth"; + +const user: AuthUser = { + avatar: "https://example.test/avatar.png", + date_joined: "2026-01-01T00:00:00Z", + email: "player@example.test", + id: "00000000-0000-0000-0000-000000000001", + is_moderator: false, + signature: "", + username: "Player", +}; + +describe("auth store", () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it("moves from loading to an authenticated user", () => { + const store = useAuthStore(); + + store.setLoading(); + store.setAuthenticated(user); + + expect(store.status).toBe("authenticated"); + expect(store.isAuthenticated).toBe(true); + expect(store.user).toEqual(user); + }); + + it("clears user data when the session becomes anonymous", () => { + const store = useAuthStore(); + store.setAuthenticated(user); + + store.setAnonymous(); + + expect(store.status).toBe("anonymous"); + expect(store.isAuthenticated).toBe(false); + expect(store.user).toBeUndefined(); + }); +}); diff --git a/apps/frontend/test/community-posts-api.test.ts b/apps/frontend/test/community-posts-api.test.ts new file mode 100644 index 0000000..0c15372 --- /dev/null +++ b/apps/frontend/test/community-posts-api.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + getCommunityPost, + listCommunityPosts, +} from "../app/api/community-posts"; +import { createApiClient } from "../app/api/client"; + +const postId = "00000000-0000-0000-0000-000000000001"; + +function envelope(data: unknown): Response { + return new Response(JSON.stringify({ data }), { + headers: { "content-type": "application/json" }, + status: 200, + }); +} + +function post() { + return { + author: { + id: "00000000-0000-0000-0000-000000000002", + signature: "", + username: "Builder", + }, + author_username: "Builder", + body: "Hello community", + comment_count: 0, + created_at: "2026-01-02T00:00:00Z", + dislike_count: 0, + id: postId, + like_count: 1, + mention_users: [], + mentions: [], + render_body: "Hello community", + updated_at: "2026-01-02T00:00:00Z", + }; +} + +describe("community posts API", () => { + it("lists the requested page", async () => { + const requests: Request[] = []; + const api = createApiClient({ + baseUrl: "https://example.test/api", + fetch: async (request) => { + requests.push(request); + return envelope({ + count: 1, + current_page: 2, + page_size: 20, + results: [post()], + total_pages: 2, + }); + }, + }); + + const result = await listCommunityPosts(api, 2); + + expect(requests[0]?.url).toBe( + "https://example.test/api/v1/community_posts/?page=2" + ); + expect(result.results[0]?.id).toBe(postId); + }); + + it("retrieves a post by id", async () => { + const requests: Request[] = []; + const api = createApiClient({ + baseUrl: "https://example.test/api", + fetch: async (request) => { + requests.push(request); + return envelope(post()); + }, + }); + + const result = await getCommunityPost(api, postId); + + expect(requests[0]?.url).toBe( + `https://example.test/api/v1/community_posts/${postId}/` + ); + expect(result.author_username).toBe("Builder"); + }); +}); diff --git a/apps/frontend/test/community-posts-utils.test.ts b/apps/frontend/test/community-posts-utils.test.ts new file mode 100644 index 0000000..4a62bd2 --- /dev/null +++ b/apps/frontend/test/community-posts-utils.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + formatContentDate, + getContentExcerpt, + isUuid, + parsePageNumber, +} from "../app/utils/content"; +import { resolveCommunityPostBody } from "../app/utils/community-posts"; + +describe("community post utilities", () => { + it.each([ + ["3", 3], + [["2", "4"], 2], + ["0", 1], + ["invalid", 1], + [undefined, 1], + ])("normalizes page value %j", (value, expected) => { + expect(parsePageNumber(value)).toBe(expected); + }); + + it("formats dates in a deterministic UTC timezone", () => { + expect(formatContentDate("2026-01-02T23:30:00-08:00", "en-US")).toBe( + "Jan 3, 2026" + ); + }); + + it("resolves mention tokens as safe plain text", () => { + expect( + resolveCommunityPostBody({ + body: "Hello {{mention:0}} and {{mention:2}}", + mention_users: [{ user_id: "user-id", username: "Builder" }], + }) + ).toBe("Hello @Builder and {{mention:2}}"); + }); + + it("truncates normalized excerpts", () => { + expect(getContentExcerpt(" one\n two three ", 7)).toBe("one two…"); + }); + + it("recognizes UUID route parameters", () => { + expect(isUuid("00000000-0000-0000-0000-000000000001")).toBe(true); + expect(isUuid("not-a-uuid")).toBe(false); + }); +}); diff --git a/apps/frontend/test/navigation.test.ts b/apps/frontend/test/navigation.test.ts new file mode 100644 index 0000000..d1cf03f --- /dev/null +++ b/apps/frontend/test/navigation.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveSafeRedirect } from "../app/utils/navigation"; + +describe("resolveSafeRedirect", () => { + it("accepts a same-origin application path", () => { + expect(resolveSafeRedirect("/zh/articles/42", "/zh")).toBe( + "/zh/articles/42" + ); + }); + + it.each(["//malicious.test", "https://malicious.test", undefined])( + "rejects unsafe redirect %s", + (candidate) => { + expect(resolveSafeRedirect(candidate, "/zh")).toBe("/zh"); + } + ); +}); diff --git a/apps/frontend/test/session-api.test.ts b/apps/frontend/test/session-api.test.ts new file mode 100644 index 0000000..6932c11 --- /dev/null +++ b/apps/frontend/test/session-api.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { createApiClient } from "../app/api/client"; +import { + fetchCurrentUser, + loginSession, + logoutSession, +} from "../app/api/session"; + +const emptyResponseData: unknown = JSON.parse("null"); + +function envelope(data: unknown): Response { + return new Response(JSON.stringify({ data }), { + headers: { "content-type": "application/json" }, + status: 200, + }); +} + +describe("session API", () => { + it("retrieves the current user", async () => { + const api = createApiClient({ + baseUrl: "https://example.test/api", + fetch: async () => + envelope({ + avatar: "https://example.test/avatar.png", + date_joined: "2026-01-01T00:00:00Z", + email: "player@example.test", + id: "00000000-0000-0000-0000-000000000001", + is_moderator: false, + signature: "", + username: "Player", + }), + }); + + await expect(fetchCurrentUser(api)).resolves.toMatchObject({ + username: "Player", + }); + }); + + it("sends credentials and CSRF when logging in", async () => { + const requests: Request[] = []; + const api = createApiClient({ + baseUrl: "https://example.test/api", + fetch: async (request) => { + requests.push(request); + return envelope(emptyResponseData); + }, + }); + + await loginSession( + api, + { email: "player@example.test", password: "secret" }, + "csrf-token" + ); + + expect(requests[0]?.url).toBe( + "https://example.test/api/v1/sessions/login/" + ); + expect(requests[0]?.headers.get("x-csrftoken")).toBe("csrf-token"); + await expect(requests[0]?.json()).resolves.toEqual({ + email: "player@example.test", + password: "secret", + }); + }); + + it("sends CSRF when logging out", async () => { + const requests: Request[] = []; + const api = createApiClient({ + baseUrl: "https://example.test/api", + fetch: async (request) => { + requests.push(request); + return envelope(emptyResponseData); + }, + }); + + await logoutSession(api, "csrf-token"); + + expect(requests[0]?.url).toBe( + "https://example.test/api/v1/sessions/logout/" + ); + expect(requests[0]?.headers.get("x-csrftoken")).toBe("csrf-token"); + }); +}); diff --git a/apps/frontend/test/ui.test.ts b/apps/frontend/test/ui.test.ts new file mode 100644 index 0000000..876db77 --- /dev/null +++ b/apps/frontend/test/ui.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { getAvatarInitial, getSkeletonRowIds } from "../app/utils/ui"; + +describe("UI utilities", () => { + it("normalizes avatar initials", () => { + expect(getAvatarInitial(" player")).toBe("P"); + expect(getAvatarInitial(" ")).toBe("?"); + }); + + it("creates stable skeleton row identifiers", () => { + expect(getSkeletonRowIds(3)).toEqual([1, 2, 3]); + expect(getSkeletonRowIds(0)).toEqual([1]); + expect(getSkeletonRowIds(Number.POSITIVE_INFINITY)).toEqual([1]); + }); +}); diff --git a/docs/contributors/docs/en/development/frontend-internationalization.md b/docs/contributors/docs/en/development/frontend-internationalization.md new file mode 100644 index 0000000..67c3548 --- /dev/null +++ b/docs/contributors/docs/en/development/frontend-internationalization.md @@ -0,0 +1,206 @@ +# Frontend Internationalization + +The AlienCommons frontend officially supports English and Simplified Chinese. It uses the official `@nuxtjs/i18n` module on top of Vue I18n, so routing, message lookup, language switching, server-side rendering, and localized SEO all share one configuration. + +This page describes the frontend interface localization system. User-generated articles, community posts, comments, and profile content are not translated automatically. + +## Language and Route Strategy + +The i18n configuration lives in `apps/frontend/nuxt.config.ts`. English is the default locale and Simplified Chinese is the secondary locale: + +```ts +i18n: { + defaultLocale: "en", + locales: [ + { + code: "en", + file: "en.json", + language: "en-US", + name: "English", + }, + { + code: "zh", + file: "zh.json", + language: "zh-Hans", + name: "简体中文", + }, + ], + strategy: "prefix_except_default", +} +``` + +The `prefix_except_default` strategy keeps English URLs unprefixed and adds `/zh` to Chinese URLs: + +| Page file | English URL | Chinese URL | +| --- | --- | --- | +| `app/pages/index.vue` | `/` | `/zh` | +| `app/pages/login.vue` | `/login` | `/zh/login` | + +Do not create separate English and Chinese Vue page files. Nuxt I18n generates both localized routes from the same page component. + +## Browser Language Detection + +Language detection runs only when a visitor enters at the root URL. It does not repeatedly redirect visitors while they navigate: + +```ts +detectBrowserLanguage: { + cookieKey: "aliencommons_locale", + fallbackLocale: "en", + redirectOn: "root", + useCookie: true, +} +``` + +The selected locale is remembered in the `aliencommons_locale` cookie. English is used when the browser language cannot be matched. + +## Translation Files + +Interface messages are stored in two JSON files: + +```text +apps/frontend/i18n/locales/ +├── en.json # English +└── zh.json # Simplified Chinese +``` + +Both files must have the same key structure. Group messages by feature instead of by component type: + +```json +{ + "auth": { + "login": { + "title": "Sign in to AlienCommons", + "email": "Email", + "password": "Password" + } + } +} +``` + +The matching Chinese file uses the same keys: + +```json +{ + "auth": { + "login": { + "title": "登录 AlienCommons", + "email": "电子邮箱", + "password": "密码" + } + } +} +``` + +Keep keys semantic and stable. A key such as `auth.login.submit` communicates where and why a message is used; a key such as `blueButtonText` couples translation data to presentation. + +## Using Messages in Components + +Templates can use the injected `$t` function: + +```vue +

{{ $t("auth.login.title") }}

+``` + +Use `useI18n()` when a translated value is needed in `