-
Notifications
You must be signed in to change notification settings - Fork 5.2k
feat(api): page webhooks (create/update/delete) and public v1 Pages API #9401
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
clwluvw
wants to merge
20
commits into
makeplane:preview
Choose a base branch
from
clwluvw:page-crud
base: preview
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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 d9f7a74
feat(webhooks): dev-only WEBHOOK_ALLOW_PRIVATE_URLS escape hatch for …
clwluvw ca5c320
fix(api): stop the webhook OpenAPI schema from declaring secret_key o…
clwluvw a8e4155
feat(webhooks): emit page created/deleted webhooks
clwluvw 9943f6d
feat(webhooks): debounce page update webhooks in webhook_activity
clwluvw 4431bec
feat(webhooks): emit page update webhooks on property + content changes
clwluvw 5a1b62b
refactor(webhooks): share dispatch_page_webhook from webhook_task
clwluvw 035cc98
feat(api): add project pages to the public token API
clwluvw 130df21
fix(api): address review feedback on page endpoints
clwluvw 0c636af
fix(api): address second review round on page endpoints
clwluvw 2bdcbbc
fix(api): correct mislabelled page docstrings and stub webhook in arc…
clwluvw 6c56394
perf(webhooks): build the webhook payload once per event, not per sub…
clwluvw 7ccf313
docs(api): clarify what the page list `type` filter actually returns
clwluvw 1d1eb64
fix(api): treat an emptied page as a content change
clwluvw ba63bce
fix(api): close the remaining review findings on page endpoints
clwluvw b1741a5
fix(api): scope the page cycle walk and stop leaking conflicting page…
clwluvw 4d47d24
fix(pages): stop rewriting an intentionally empty page body
clwluvw 05dbaa8
fix(api): require an active project link on the public pages API
clwluvw 66fc89e
feat(api): expose the page webhook toggle on the public token API
clwluvw 990ea45
test: give each api-key test its own throttle bucket
clwluvw File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| ] | ||
|
|
||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| ), | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| ), | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.