Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c28fc10
feat(api): manage workspace webhooks via the public token API
clwluvw Jul 11, 2026
d9f7a74
feat(webhooks): dev-only WEBHOOK_ALLOW_PRIVATE_URLS escape hatch for …
clwluvw Jul 23, 2026
ca5c320
fix(api): stop the webhook OpenAPI schema from declaring secret_key o…
clwluvw Jul 25, 2026
a8e4155
feat(webhooks): emit page created/deleted webhooks
clwluvw Jul 11, 2026
9943f6d
feat(webhooks): debounce page update webhooks in webhook_activity
clwluvw Jul 23, 2026
4431bec
feat(webhooks): emit page update webhooks on property + content changes
clwluvw Jul 23, 2026
5a1b62b
refactor(webhooks): share dispatch_page_webhook from webhook_task
clwluvw Jul 23, 2026
035cc98
feat(api): add project pages to the public token API
clwluvw Jul 23, 2026
130df21
fix(api): address review feedback on page endpoints
clwluvw Jul 25, 2026
0c636af
fix(api): address second review round on page endpoints
clwluvw Jul 25, 2026
2bdcbbc
fix(api): correct mislabelled page docstrings and stub webhook in arc…
clwluvw Jul 25, 2026
6c56394
perf(webhooks): build the webhook payload once per event, not per sub…
clwluvw Jul 25, 2026
7ccf313
docs(api): clarify what the page list `type` filter actually returns
clwluvw Jul 25, 2026
1d1eb64
fix(api): treat an emptied page as a content change
clwluvw Jul 25, 2026
ba63bce
fix(api): close the remaining review findings on page endpoints
clwluvw Jul 25, 2026
b1741a5
fix(api): scope the page cycle walk and stop leaking conflicting page…
clwluvw Jul 25, 2026
4d47d24
fix(pages): stop rewriting an intentionally empty page body
clwluvw Jul 25, 2026
05dbaa8
fix(api): require an active project link on the public pages API
clwluvw Jul 25, 2026
66fc89e
feat(api): expose the page webhook toggle on the public token API
clwluvw Jul 27, 2026
990ea45
test: give each api-key test its own throttle bucket
clwluvw Jul 27, 2026
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
2 changes: 2 additions & 0 deletions apps/api/plane/api/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,5 @@
from .invite import WorkspaceInviteSerializer
from .member import ProjectMemberSerializer
from .sticky import StickySerializer
from .webhook import WebhookSerializer, WebhookLiteSerializer
from .page import PageSerializer, PageAPISerializer
112 changes: 112 additions & 0 deletions apps/api/plane/api/serializers/page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# 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
from plane.utils.content_validator import validate_html_content


class PageSerializer(BaseSerializer):
"""Serializer used to build the webhook payload for page events.

Read-only projection of a :class:`~plane.db.models.Page`. ``description_binary``
is intentionally excluded — it is a ``BinaryField`` (the Yjs document) that has
no JSON representation and is not part of the public webhook contract.
"""

class Meta:
model = Page
fields = [
"id",
"name",
"description_html",
"description_stripped",
"owned_by",
"access",
"color",
"parent",
"archived_at",
"is_locked",
"is_global",
"view_props",
"logo_props",
"external_id",
"external_source",
"workspace",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
read_only_fields = fields


class PageAPISerializer(BaseSerializer):
"""Read/write serializer for pages in the public token (v1) API.

Third-party integrations exchange page content as ``description_html``,
sanitized on write with the same ``validate_html_content`` sanitizer the
internal API uses. The Yjs ``description_binary`` / ``description_json``
fields are intentionally excluded from the public contract — they are the
live-collaboration document state and have no stable JSON representation.

``parent`` is writable, and DRF resolves relations through the model's default
manager, which knows nothing about who is asking. It is therefore validated
against the caller's access-scoped queryset by
``PageAPIEndpoint._invalid_parent_response`` — the single place that owns page
visibility — before any save. Any new page write path must call it too.
"""

class Meta:
model = Page
fields = [
"id",
"name",
"description_html",
"access",
"color",
"is_locked",
"archived_at",
"view_props",
"logo_props",
"external_id",
"external_source",
"owned_by",
"parent",
"sort_order",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
read_only_fields = [
"id",
"is_locked",
"archived_at",
"owned_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
Comment thread
clwluvw marked this conversation as resolved.

def validate_description_html(self, value):
"""Sanitize page HTML with the same sanitizer the internal API uses.

Mirrors ``PageBinaryUpdateSerializer.validate_description_html`` so
API-authored content is held to the identical nh3 allow-list.
"""
if not value:
return value

is_valid, error_message, sanitized_html = validate_html_content(value)
if not is_valid:
raise serializers.ValidationError(error_message)

# Return sanitized HTML if available, otherwise return original
return sanitized_html if sanitized_html is not None else value
91 changes: 91 additions & 0 deletions apps/api/plane/api/serializers/webhook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# 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 Webhook
from plane.db.models.webhook import validate_domain, validate_schema
from plane.utils.webhook import validate_webhook_url


class WebhookSerializer(BaseSerializer):
"""Public token-API serializer for workspace webhooks.

Mirrors the internal app-API webhook serializer: enforces the schema/domain
validators plus the shared SSRF/disallowed-domain guard, and keeps
``secret_key`` server-generated (read-only) so it is returned on create but
never accepted as input.
"""

url = serializers.URLField(validators=[validate_schema, validate_domain])

def _validate_webhook_url(self, url):
"""Validate a webhook URL against SSRF and disallowed-domain rules.

Thin adapter binding the serializer's request context to the shared
``validate_webhook_url`` guard, mirroring the internal app-API
serializer so the SSRF/URL checks cannot drift between the two.
"""
validate_webhook_url(url, self.context.get("request"))

def create(self, validated_data):
url = validated_data.get("url", None)
self._validate_webhook_url(url)
return Webhook.objects.create(**validated_data)

def update(self, instance, validated_data):
url = validated_data.get("url", None)
if url:
self._validate_webhook_url(url)
return super().update(instance, validated_data)

class Meta:
model = Webhook
fields = [
"id",
"url",
"is_active",
"secret_key",
"project",
"issue",
"module",
"cycle",
"issue_comment",
# Every entity toggle on the model belongs here. DRF drops unknown
# keys silently, so a toggle missing from this list is not a
# validation error the caller can see — it is a webhook that simply
# never subscribes, since delivery is gated on the column
# (``webhooks.filter(page=True)`` in ``webhook_activity``).
"page",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"workspace",
"secret_key",
"created_at",
"updated_at",
]


class WebhookLiteSerializer(BaseSerializer):
"""Read-side webhook shape — everything ``WebhookSerializer`` exposes except
the signing ``secret_key``.

The secret is surfaced exactly once, in the create response; list, retrieve
and update responses use this serializer so it is never returned on a read.
Deriving the field list from ``WebhookSerializer`` (minus the secret) makes
that exclusion structural rather than a hand-maintained parallel list, and
because the runtime response and the published OpenAPI schema are both
generated from this one class they cannot contradict each other.
"""

class Meta:
model = Webhook
fields = [field for field in WebhookSerializer.Meta.fields if field != "secret_key"]
read_only_fields = fields
4 changes: 4 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,8 @@
from .work_item import urlpatterns as work_item_patterns
from .invite import urlpatterns as invite_patterns
from .sticky import urlpatterns as sticky_patterns
from .webhook import urlpatterns as webhook_patterns
from .page import urlpatterns as page_patterns

urlpatterns = [
*asset_patterns,
Expand All @@ -22,10 +24,12 @@
*label_patterns,
*member_patterns,
*module_patterns,
*page_patterns,
*project_patterns,
*state_patterns,
*user_patterns,
*work_item_patterns,
*invite_patterns,
*sticky_patterns,
*webhook_patterns,
]
35 changes: 35 additions & 0 deletions apps/api/plane/api/urls/page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# 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.page import (
PageListCreateAPIEndpoint,
PageDetailAPIEndpoint,
PageArchiveAPIEndpoint,
PageLockAPIEndpoint,
)

urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/",
PageListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="page-list-create",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/",
PageDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="page-detail",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/archive/",
PageArchiveAPIEndpoint.as_view(http_method_names=["post", "delete"]),
name="page-archive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/lock/",
PageLockAPIEndpoint.as_view(http_method_names=["post", "delete"]),
name="page-lock",
),
]
20 changes: 20 additions & 0 deletions apps/api/plane/api/urls/webhook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# 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 WebhookAPIEndpoint, WebhookDetailAPIEndpoint

urlpatterns = [
path(
"workspaces/<str:slug>/webhooks/",
WebhookAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="webhooks",
),
path(
"workspaces/<str:slug>/webhooks/<uuid:pk>/",
WebhookDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="webhook-detail",
),
]
9 changes: 9 additions & 0 deletions apps/api/plane/api/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,12 @@
from .invite import WorkspaceInvitationsViewset

from .sticky import StickyViewSet

from .webhook import WebhookAPIEndpoint, WebhookDetailAPIEndpoint

from .page import (
PageListCreateAPIEndpoint,
PageDetailAPIEndpoint,
PageArchiveAPIEndpoint,
PageLockAPIEndpoint,
)
Loading