Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/api/plane/api/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,4 @@
from .invite import WorkspaceInviteSerializer
from .member import ProjectMemberSerializer
from .sticky import StickySerializer
from .page import PageAPISerializer
50 changes: 50 additions & 0 deletions apps/api/plane/api/serializers/page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

# Third party imports
from rest_framework import serializers

# Module imports
from .base import BaseSerializer
from plane.db.models import Page


class PageAPISerializer(BaseSerializer):
"""
Serializer for Page model exposed via the public v1 API.
Provides read/write access to core page fields.
"""

# name is required when creating a page via the API
name = serializers.CharField(required=True, allow_blank=False)

class Meta:
model = Page
fields = [
"id",
"name",
"description_html",
"description_stripped",
"owned_by",
"access",
"color",
"parent",
"is_locked",
"is_global",
"archived_at",
"workspace",
"created_at",
"updated_at",
"created_by",
"updated_by",
"view_props",
"logo_props",
]
read_only_fields = [
"workspace",
"owned_by",
"created_by",
"updated_by",
"archived_at",
]
Comment on lines +33 to +50
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

is_locked is too permissive on this PATCH surface.

ProjectPagePermission allows members to PATCH public pages, and PageDetailAPIEndpoint.patch() only blocks edits when the page is already locked. Leaving is_locked writable here lets one member lock another user's public page and freeze future edits through this API. Please make is_locked read-only here or move lock toggles behind a stricter owner/admin-only flow.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/plane/api/serializers/page.py` around lines 33 - 50, The serializer
currently exposes "is_locked" as writable which lets non-owners lock other
users' public pages; update the page serializer in
apps/api/plane/api/serializers/page.py (the serializer that defines
read_only_fields for pages) to make "is_locked" read-only by adding "is_locked"
to the read_only_fields list (or otherwise remove it from the writable fields),
ensuring lock state can only be changed via owner/admin-only flows (e.g., the
PageDetailAPIEndpoint or a dedicated lock/unlock endpoint).

2 changes: 2 additions & 0 deletions apps/api/plane/api/urls/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .work_item import urlpatterns as work_item_patterns
from .invite import urlpatterns as invite_patterns
from .sticky import urlpatterns as sticky_patterns
from .page import urlpatterns as page_patterns

urlpatterns = [
*asset_patterns,
Expand All @@ -28,4 +29,5 @@
*work_item_patterns,
*invite_patterns,
*sticky_patterns,
*page_patterns,
]
23 changes: 23 additions & 0 deletions apps/api/plane/api/urls/page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

from django.urls import path

from plane.api.views import (
PageListCreateAPIEndpoint,
PageDetailAPIEndpoint,
)

urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/",
PageListCreateAPIEndpoint.as_view(),
name="pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/",
PageDetailAPIEndpoint.as_view(),
name="page-detail",
),
]
1 change: 1 addition & 0 deletions apps/api/plane/api/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,4 @@
from .invite import WorkspaceInvitationsViewset

from .sticky import StickyViewSet
from .page import PageListCreateAPIEndpoint, PageDetailAPIEndpoint
207 changes: 207 additions & 0 deletions apps/api/plane/api/views/page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
# Django imports
from django.db.models import Exists, OuterRef, Q

# Third party imports
from rest_framework import status
from rest_framework.response import Response

# drf-spectacular imports
from drf_spectacular.utils import extend_schema

# Module imports
from plane.api.serializers import PageAPISerializer
from plane.app.permissions import ProjectPagePermission
from plane.db.models import Page, ProjectPage, UserFavorite, Project
from .base import BaseAPIView


class PageListCreateAPIEndpoint(BaseAPIView):
"""
GET /api/v1/workspaces/{slug}/projects/{project_id}/pages/
List all pages in a project visible to the current user.

POST /api/v1/workspaces/{slug}/projects/{project_id}/pages/
Create a new page in the specified project.
"""

permission_classes = [ProjectPagePermission]
serializer_class = PageAPISerializer

def get_queryset(self, slug, project_id):
subquery = UserFavorite.objects.filter(
user=self.request.user,
entity_type="page",
entity_identifier=OuterRef("pk"),
workspace__slug=slug,
)
return (
Page.objects.filter(workspace__slug=slug)
.filter(
projects__project_projectmember__member=self.request.user,
projects__project_projectmember__is_active=True,
projects__archived_at__isnull=True,
)
.filter(parent__isnull=True)
.filter(Q(owned_by=self.request.user) | Q(access=0))
Comment on lines +44 to +45
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

GET /pages/ currently hides child pages.

The parent__isnull=True filter on Line 48 makes this route return only root pages, even though the endpoint/docs describe a project pages listing. Nested pages become undiscoverable unless the caller already knows their IDs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/plane/api/views/page.py` around lines 48 - 49, The query currently
filters out nested pages by calling .filter(parent__isnull=True); remove that
filter so the view's queryset no longer restricts results to root pages (keep
the permission filters that use Q(owned_by=self.request.user) | Q(access=0)
intact) so GET /pages/ returns all pages within the scope instead of only
top-level ones; if you need project scoping, ensure a project filter (e.g.,
parent__project or project field) is applied elsewhere rather than
parent__isnull.

.filter(
Exists(
ProjectPage.objects.filter(
page_id=OuterRef("id"),
project_id=project_id,
)
)
)
.prefetch_related("projects")
.select_related("workspace", "owned_by")
.annotate(is_favorite=Exists(subquery))
.order_by("-created_at")
)

@extend_schema(
responses={200: PageAPISerializer(many=True)},
summary="List project pages",
description="Returns all pages in a project visible to the current user.",
tags=["Pages"],
)
def get(self, request, slug, project_id):
pages = self.get_queryset(slug, project_id)
serializer = PageAPISerializer(pages, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)

@extend_schema(
request=PageAPISerializer,
responses={201: PageAPISerializer},
summary="Create a page",
description="Creates a new page in the specified project.",
tags=["Pages"],
)
def post(self, request, slug, project_id):
try:
project = Project.objects.get(pk=project_id, workspace__slug=slug)
except Project.DoesNotExist:
return Response(
{"error": "Project not found."},
status=status.HTTP_404_NOT_FOUND,
)
Comment on lines +78 to +85
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

POST allows page creation in archived projects.

The project lookup on line 80 doesn't verify that the project isn't archived. Users could create pages in archived projects, which contradicts the archive semantics.

🛡️ Proposed fix to reject archived projects
     def post(self, request, slug, project_id):
         try:
-            project = Project.objects.get(pk=project_id, workspace__slug=slug)
+            project = Project.objects.get(
+                pk=project_id,
+                workspace__slug=slug,
+                archived_at__isnull=True,
+            )
         except Project.DoesNotExist:
             return Response(
                 {"error": "Project not found."},
                 status=status.HTTP_404_NOT_FOUND,
             )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/plane/api/views/page.py` around lines 78 - 85, The POST handler
currently fetches Project via Project.objects.get(pk=project_id,
workspace__slug=slug) but doesn't prevent creating pages on archived projects;
after retrieving the Project in post, verify the archive flag (e.g.,
project.archived or project.is_archived) or change the query to exclude archived
projects (e.g., add archived=False) and return an appropriate Response (403
Forbidden with an error message like "Project is archived") when the project is
archived so page creation is rejected.


serializer = PageAPISerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

page = Page.objects.create(
name=serializer.validated_data.get("name", ""),
description_html=serializer.validated_data.get("description_html", ""),
description_stripped=serializer.validated_data.get("description_stripped", ""),
description=serializer.validated_data.get("description", {}),
access=serializer.validated_data.get("access", 0),
color=serializer.validated_data.get("color", ""),
view_props=serializer.validated_data.get("view_props", {}),
logo_props=serializer.validated_data.get("logo_props", {}),
Comment on lines +91 to +99
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's find and examine the PageAPISerializer
fd -t f "serializer" apps/api/plane/api | grep -i page | head -20

Repository: makeplane/plane

Length of output: 41


🏁 Script executed:

# Also check the Page model directly
fd -t f page.py apps/api/plane/db/models/ -x cat {}

Repository: makeplane/plane

Length of output: 7324


🏁 Script executed:

# Let's search for PageAPISerializer definition
rg "class PageAPISerializer" -A 30 apps/api/

Repository: makeplane/plane

Length of output: 2081


🏁 Script executed:

# Get the page.py view file and check lines 95-103
rg "class.*PageAPI" apps/api/plane/api/views/page.py -A 100 | head -120

Repository: makeplane/plane

Length of output: 4573


🏁 Script executed:

# Alternative: just get the file and read around line 95
cat -n apps/api/plane/api/views/page.py | sed -n '90,110p'

Repository: makeplane/plane

Length of output: 1237


Remove description parameter from Page.objects.create() call.

Line 99 passes description=serializer.validated_data.get("description", {}) to Page.objects.create(), but the Page model has no "description" field. The PageAPISerializer also does not validate a "description" field. This will raise a FieldError at runtime instead of returning a 201 response.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/plane/api/views/page.py` around lines 95 - 103, The
Page.objects.create(...) call is passing a non-existent field "description"
which will raise a FieldError; remove the
description=serializer.validated_data.get("description", {}) entry from the
Page.objects.create invocation (located where Page.objects.create is called in
page view) so the create call only includes valid model fields (name,
description_html, description_stripped, access, color, view_props, logo_props,
etc.).

# Explicitly clear binary so the collab server reads description_html on next load
description_binary=None,
owned_by=request.user,
workspace_id=project.workspace_id,
)
ProjectPage.objects.create(
workspace_id=page.workspace_id,
project_id=project_id,
page_id=page.id,
created_by=request.user,
updated_by=request.user,
)
Comment on lines +91 to +111
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Create the page and its project link atomically.

Page and ProjectPage are both required for a successful POST, but they are written independently here. If the second insert fails, this leaves behind an orphaned Page that the project-scoped API cannot reach anymore.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/plane/api/views/page.py` around lines 95 - 113, The Page and
ProjectPage creations must be done atomically to avoid orphan Pages if the
ProjectPage insert fails; wrap the Page.objects.create and
ProjectPage.objects.create calls inside a single transaction (use
django.db.transaction.atomic) so both are committed or both rolled back, and
keep references to the same page instance (created by Page.objects.create) when
creating the ProjectPage; update the view function that calls
Page.objects.create and ProjectPage.objects.create to use transaction.atomic()
and ensure any exceptions propagate so the transaction will roll back.

return Response(PageAPISerializer(page).data, status=status.HTTP_201_CREATED)


class PageDetailAPIEndpoint(BaseAPIView):
"""
GET /api/v1/workspaces/{slug}/projects/{project_id}/pages/{page_id}/
PATCH /api/v1/workspaces/{slug}/projects/{project_id}/pages/{page_id}/
DELETE /api/v1/workspaces/{slug}/projects/{project_id}/pages/{page_id}/
"""

permission_classes = [ProjectPagePermission]
serializer_class = PageAPISerializer

def get_page(self, slug, project_id, page_id):
return (
Page.objects.filter(
workspace__slug=slug,
projects__id=project_id,
pk=page_id,
)
.filter(Q(owned_by=self.request.user) | Q(access=0))
.first()
)
Comment on lines +125 to +134
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Detail operations can access pages in archived projects.

Unlike get_queryset() which filters by projects__archived_at__isnull=True (line 42), get_page() omits this check. This allows GET, PATCH, and DELETE operations on pages belonging to archived projects, which is inconsistent with the list endpoint behavior.

🛡️ Proposed fix to add archived project filter
     def get_page(self, slug, project_id, page_id):
         return (
             Page.objects.filter(
                 workspace__slug=slug,
                 projects__id=project_id,
                 pk=page_id,
             )
+            .filter(projects__archived_at__isnull=True)
             .filter(Q(owned_by=self.request.user) | Q(access=0))
             .first()
         )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/plane/api/views/page.py` around lines 125 - 134, get_page currently
returns pages without excluding those in archived projects, unlike get_queryset
which filters with projects__archived_at__isnull=True; update the Page query in
get_page to include the same archived-project filter (add
projects__archived_at__isnull=True) so GET/PATCH/DELETE respect archived status,
keeping the existing Q(owned_by=...) | Q(access=0) logic and other filters in
get_page.


@extend_schema(
responses={200: PageAPISerializer},
summary="Get a page",
description="Retrieve a single page by ID.",
tags=["Pages"],
)
def get(self, request, slug, project_id, page_id):
page = self.get_page(slug, project_id, page_id)
if not page:
return Response(
{"error": "Page not found."},
status=status.HTTP_404_NOT_FOUND,
)
return Response(PageAPISerializer(page).data)

@extend_schema(
request=PageAPISerializer,
responses={200: PageAPISerializer},
summary="Update a page",
description=(
"Partially update a page. Cannot update locked pages. "
"When description_html is updated, description_binary is reset so the "
"collaborative editor reloads the content from the new HTML on next open."
),
tags=["Pages"],
)
def patch(self, request, slug, project_id, page_id):
page = self.get_page(slug, project_id, page_id)
if not page:
return Response(
{"error": "Page not found."},
status=status.HTTP_404_NOT_FOUND,
)
if page.is_locked:
return Response(
{"error": "Page is locked and cannot be modified."},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = PageAPISerializer(page, data=request.data, partial=True)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

# If the caller is updating description_html, reset description_binary so
# the Tiptap/Yjs collab server picks up the new HTML on next document load
# instead of serving stale binary state.
save_kwargs = {"updated_by": request.user}
if "description_html" in request.data:
save_kwargs["description_binary"] = None

serializer.save(**save_kwargs)
return Response(serializer.data)

@extend_schema(
responses={204: None},
summary="Delete a page",
description="Delete a page. Only the page owner can perform this action.",
tags=["Pages"],
)
def delete(self, request, slug, project_id, page_id):
page = self.get_page(slug, project_id, page_id)
if not page:
return Response(
{"error": "Page not found."},
status=status.HTTP_404_NOT_FOUND,
)
if page.owned_by != request.user:
return Response(
{"error": "Only the page owner can delete this page."},
status=status.HTTP_403_FORBIDDEN,
)
page.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
Loading