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
20 changes: 20 additions & 0 deletions apps/api/plane/api/serializers/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
validate_html_content,
validate_binary_data,
)
from plane.utils.entity_mention_parser import apply_entity_mention_transformation

from .base import BaseSerializer
from .cycle import CycleLiteSerializer, CycleSerializer
Expand Down Expand Up @@ -89,6 +90,10 @@ def validate(self, data):
except Exception:
raise serializers.ValidationError("Invalid HTML passed")

# Transform @issue/PROJ-123 style mentions before sanitization
if data.get("description_html"):
apply_entity_mention_transformation(data, self.context, "description_html")

# Validate description content for security
if data.get("description_html"):
is_valid, error_msg, sanitized_html = validate_html_content(data["description_html"])
Expand Down Expand Up @@ -719,6 +724,18 @@ class Meta:
"edited_at",
]

def validate(self, data):
if "comment_html" in data and data["comment_html"]:
apply_entity_mention_transformation(data, self.context, "comment_html")

if "comment_html" in data and data["comment_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(data["comment_html"])
if not is_valid:
raise serializers.ValidationError({"comment_html": "HTML content is not valid"})
if sanitized_html is not None:
data["comment_html"] = sanitized_html
return data


class IssueCommentSerializer(BaseSerializer):
"""
Expand All @@ -745,6 +762,9 @@ class Meta:
exclude = ["comment_stripped", "comment_json"]

def validate(self, data):
if "comment_html" in data and data["comment_html"]:
apply_entity_mention_transformation(data, self.context, "comment_html")

if "comment_html" in data and data["comment_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(data["comment_html"])
if not is_valid:
Expand Down
12 changes: 10 additions & 2 deletions apps/api/plane/api/views/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -1477,7 +1477,10 @@ def post(self, request, slug, project_id, issue_id):
status=status.HTTP_409_CONFLICT,
)

serializer = IssueCommentCreateSerializer(data=request.data)
serializer = IssueCommentCreateSerializer(
data=request.data,
context={"project_id": project_id},
)
if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id, actor=request.user)
issue_comment = IssueComment.objects.get(pk=serializer.instance.id)
Expand Down Expand Up @@ -1624,7 +1627,12 @@ def patch(self, request, slug, project_id, issue_id, pk):
status=status.HTTP_409_CONFLICT,
)

serializer = IssueCommentCreateSerializer(issue_comment, data=request.data, partial=True)
serializer = IssueCommentCreateSerializer(
issue_comment,
data=request.data,
partial=True,
context={"project_id": project_id},
)
if serializer.is_valid():
serializer.save()
issue_activity.delay(
Expand Down
8 changes: 8 additions & 0 deletions apps/api/plane/app/serializers/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
validate_html_content,
validate_binary_data,
)
from plane.utils.entity_mention_parser import apply_entity_mention_transformation


class IssueFlatSerializer(BaseSerializer):
Expand Down Expand Up @@ -132,6 +133,10 @@ def validate(self, attrs):
):
raise serializers.ValidationError("Start date cannot exceed target date")

# Transform @issue/PROJ-123 style mentions before sanitization
if "description_html" in attrs and attrs["description_html"]:
apply_entity_mention_transformation(attrs, self.context, "description_html")

# Validate description content for security
if "description_html" in attrs and attrs["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(attrs["description_html"])
Expand Down Expand Up @@ -716,6 +721,9 @@ class Meta:
]

def validate(self, attrs):
if "comment_html" in attrs and attrs["comment_html"]:
apply_entity_mention_transformation(attrs, self.context, "comment_html")

if "comment_html" in attrs and attrs["comment_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(attrs["comment_html"])
if not is_valid:
Expand Down
12 changes: 10 additions & 2 deletions apps/api/plane/app/views/issue/comment.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ def create(self, request, slug, project_id, issue_id):
{"error": "You are not allowed to comment on the issue"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = IssueCommentSerializer(data=request.data)
serializer = IssueCommentSerializer(
data=request.data,
context={"project_id": project_id},
)
if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id, actor=request.user)
issue_activity.delay(
Expand Down Expand Up @@ -111,7 +114,12 @@ def partial_update(self, request, slug, project_id, issue_id, pk):
issue_comment = IssueComment.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk)
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
current_instance = json.dumps(IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder)
serializer = IssueCommentSerializer(issue_comment, data=request.data, partial=True)
serializer = IssueCommentSerializer(
issue_comment,
data=request.data,
partial=True,
context={"project_id": project_id},
)
if serializer.is_valid():
if "comment_html" in request.data and request.data["comment_html"] != issue_comment.comment_html:
serializer.save(edited_at=timezone.now())
Expand Down
110 changes: 110 additions & 0 deletions apps/api/plane/tests/unit/utils/test_entity_mention_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

import pytest

from plane.db.models import Issue, Project, State, Workspace
from plane.utils.entity_mention_parser import (
build_mention_component,
transform_entity_mentions_in_html,
transform_entity_mentions_in_text,
)


@pytest.fixture
def workspace(create_user):
return Workspace.objects.create(
name="Test Workspace",
slug="test-workspace",
owner=create_user,
)


@pytest.fixture
def project(workspace, create_user):
return Project.objects.create(
name="Test Project",
identifier="ENG",
workspace=workspace,
created_by=create_user,
)


@pytest.fixture
def state(project):
return State.objects.create(
name="Todo",
project=project,
group="unstarted",
color="#60646C",
)


@pytest.fixture
def issue(workspace, project, state, create_user):
return Issue.objects.create(
name="Referenced Issue",
workspace=workspace,
project=project,
state=state,
created_by=create_user,
)


@pytest.mark.unit
class TestEntityMentionParser:
@pytest.mark.django_db
def test_transform_issue_mention_with_type_prefix(self, workspace, project, issue):
identifier = f"{project.identifier}-{issue.sequence_id}"
html = f"<p>Blocked by @issue/{identifier}</p>"
result = transform_entity_mentions_in_html(html, workspace_slug=workspace.slug)

assert "mention-component" in result
assert f'entity_identifier="{issue.id}"' in result
assert 'entity_name="issue"' in result
assert f'entity_display_name="{identifier}"' in result

@pytest.mark.django_db
def test_transform_issue_mention_without_type_prefix(self, workspace, project, issue):
identifier = f"{project.identifier}-{issue.sequence_id}"
text = f"See @{identifier} for details"
result = transform_entity_mentions_in_text(text, workspace_slug=workspace.slug)

assert "mention-component" in result
assert f'entity_identifier="{issue.id}"' in result

@pytest.mark.django_db
def test_transform_project_mention(self, workspace, project):
text = "Track in @project/ENG"
result = transform_entity_mentions_in_text(text, workspace_slug=workspace.slug)

assert "mention-component" in result
assert f'entity_identifier="{project.id}"' in result
assert 'entity_name="project"' in result
assert 'entity_display_name="ENG"' in result

@pytest.mark.django_db
def test_unknown_mention_is_left_unchanged(self, workspace):
text = "Unknown @issue/ZZZ-999 reference"
result = transform_entity_mentions_in_text(text, workspace_slug=workspace.slug)
assert result == text

@pytest.mark.django_db
def test_existing_mention_component_is_not_replaced(self, workspace, project, issue):
existing = build_mention_component(
entity_name="issue",
entity_identifier=str(issue.id),
entity_display_name=f"{project.identifier}-{issue.sequence_id}",
)
html = f"<p>Already linked {existing}</p>"
result = transform_entity_mentions_in_html(html, workspace_slug=workspace.slug)
assert result.count("mention-component") == 1

def test_build_mention_component_includes_display_name(self):
component = build_mention_component(
entity_name="issue",
entity_identifier="test-id",
entity_display_name="ENG-42",
)
assert 'entity_display_name="ENG-42"' in component
2 changes: 1 addition & 1 deletion apps/api/plane/utils/content_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ def validate_binary_data(data):
"alt",
"title",
},
"mention-component": {"id", "entity_identifier", "entity_name"},
"mention-component": {"id", "entity_identifier", "entity_name", "entity_display_name"},
"th": {
"colspan",
"rowspan",
Expand Down
Loading