Compare commits

...

15 Commits

Author SHA1 Message Date
JayashTripathy
87de831559 feat: add Link icon to issue link item and update favicon handling 2025-05-28 19:37:03 +05:30
JayashTripathy
8b7c061d15 Merge remote-tracking branch 'origin/fix-project-joining-date' into chore-link-metadata 2025-05-28 19:21:00 +05:30
sangeethailango
9c83fbfc58 fix: set created_at as read_only_fields 2025-05-28 16:42:24 +05:30
gakshita
0f82be1bdd fix: added project's joining date 2025-05-27 19:27:36 +05:30
sangeethailango
64165695bb fix: return project joining date 2025-05-27 18:59:36 +05:30
JayashTripathy
83128c24a9 chore: added favicon and title of links 2025-05-26 20:31:35 +05:30
sangeethailango
fa63779fb9 fix: remove print statementsg 2025-05-26 17:04:04 +05:30
sangeethailango
aea4c320f2 fix: Handle None 2025-05-26 16:58:58 +05:30
sangeethailango
e7bbedf5d3 chore: type hints 2025-05-26 16:56:55 +05:30
sangeethailango
4ebbe00001 refactor: call find_favicon_url inside fetch_and_encode_favicon function 2025-05-26 16:37:41 +05:30
sangeethailango
cadaf86542 fix: handle exception by returning None 2025-05-26 16:15:49 +05:30
sangeethailango
c75e92c79c fix: remove json.dumps 2025-05-26 16:09:08 +05:30
sangeethailango
4e6958f186 fix: add validation for accessing IP ranges 2025-05-26 16:01:09 +05:30
sangeethailango
9d097d77f7 fix: return meta_data in the response 2025-05-26 15:44:44 +05:30
sriram veeraghanta
efbccead12 feat: added a python bg task to crawl work item links for title and description 2025-05-25 20:57:48 +05:30
7 changed files with 208 additions and 6 deletions

View File

@@ -151,7 +151,8 @@ class ProjectMemberAdminSerializer(BaseSerializer):
class ProjectMemberRoleSerializer(DynamicBaseSerializer):
class Meta:
model = ProjectMember
fields = ("id", "role", "member", "project")
fields = ("id", "role", "member", "project", "created_at")
read_only_fields = ["created_at"]
class ProjectMemberInviteSerializer(BaseSerializer):

View File

@@ -15,6 +15,7 @@ from plane.app.serializers import IssueLinkSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import IssueLink
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.work_item_link_task import crawl_work_item_link_title
from plane.utils.host import base_host
@@ -44,6 +45,9 @@ class IssueLinkViewSet(BaseViewSet):
serializer = IssueLinkSerializer(data=request.data)
if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id)
crawl_work_item_link_title(
serializer.data.get("id"), serializer.data.get("url")
)
issue_activity.delay(
type="link.activity.created",
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
@@ -55,6 +59,10 @@ class IssueLinkViewSet(BaseViewSet):
notification=True,
origin=base_host(request=request, is_app=True),
)
issue_link = self.get_queryset().get(id=serializer.data.get("id"))
serializer = IssueLinkSerializer(issue_link)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -66,9 +74,14 @@ class IssueLinkViewSet(BaseViewSet):
current_instance = json.dumps(
IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder
)
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
crawl_work_item_link_title(
serializer.data.get("id"), serializer.data.get("url")
)
issue_activity.delay(
type="link.activity.updated",
requested_data=requested_data,
@@ -80,6 +93,9 @@ class IssueLinkViewSet(BaseViewSet):
notification=True,
origin=base_host(request=request, is_app=True),
)
issue_link = self.get_queryset().get(id=serializer.data.get("id"))
serializer = IssueLinkSerializer(issue_link)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

View File

@@ -0,0 +1,172 @@
# Third party imports
from celery import shared_task
import requests
from bs4 import BeautifulSoup
from urllib.parse import urlparse, urljoin
import base64
import ipaddress
from typing import Dict, Any
from typing import Optional
from plane.db.models import IssueLink
DEFAULT_FAVICON = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWxpbmstaWNvbiBsdWNpZGUtbGluayI+PHBhdGggZD0iTTEwIDEzYTUgNSAwIDAgMCA3LjU0LjU0bDMtM2E1IDUgMCAwIDAtNy4wNy03LjA3bC0xLjcyIDEuNzEiLz48cGF0aCBkPSJNMTQgMTFhNSA1IDAgMCAwLTcuNTQtLjU0bC0zIDNhNSA1IDAgMCAwIDcuMDcgNy4wN2wxLjcxLTEuNzEiLz48L3N2Zz4=" # noqa: E501
@shared_task
def crawl_work_item_link_title(id: str, url: str) -> None:
meta_data = crawl_work_item_link_title_and_favicon(url)
issue_link = IssueLink.objects.get(id=id)
issue_link.metadata = meta_data
issue_link.save()
def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
"""
Crawls a URL to extract the title and favicon.
Args:
url (str): The URL to crawl
Returns:
str: JSON string containing title and base64-encoded favicon
"""
try:
# Prevent access to private IP ranges
parsed = urlparse(url)
try:
ip = ipaddress.ip_address(parsed.hostname)
if ip.is_private or ip.is_loopback or ip.is_reserved:
raise ValueError("Access to private/internal networks is not allowed")
except ValueError:
# Not an IP address, continue with domain validation
pass
# Set up headers to mimic a real browser
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" # noqa: E501
}
# Fetch the main page
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
# Parse HTML
soup = BeautifulSoup(response.content, "html.parser")
# Extract title
title_tag = soup.find("title")
title = title_tag.get_text().strip() if title_tag else "No title found"
# Fetch and encode favicon
favicon_base64 = fetch_and_encode_favicon(headers, soup, url)
# Prepare result
result = {
"title": title,
"favicon": favicon_base64["favicon_base64"],
"url": url,
"favicon_url": favicon_base64["favicon_url"],
}
return result
except requests.RequestException as e:
return {
"error": f"Request failed: {str(e)}",
"title": None,
"favicon": None,
"url": url,
}
except Exception as e:
return {
"error": f"Unexpected error: {str(e)}",
"title": None,
"favicon": None,
"url": url,
}
def find_favicon_url(soup: BeautifulSoup, base_url: str) -> Optional[str]:
"""
Find the favicon URL from HTML soup.
Args:
soup: BeautifulSoup object
base_url: Base URL for resolving relative paths
Returns:
str: Absolute URL to favicon or None
"""
# Look for various favicon link tags
favicon_selectors = [
'link[rel="icon"]',
'link[rel="shortcut icon"]',
'link[rel="apple-touch-icon"]',
'link[rel="apple-touch-icon-precomposed"]',
]
for selector in favicon_selectors:
favicon_tag = soup.select_one(selector)
if favicon_tag and favicon_tag.get("href"):
return urljoin(base_url, favicon_tag["href"])
# Fallback to /favicon.ico
parsed_url = urlparse(base_url)
fallback_url = f"{parsed_url.scheme}://{parsed_url.netloc}/favicon.ico"
# Check if fallback exists
try:
response = requests.head(fallback_url, timeout=5)
if response.status_code == 200:
return fallback_url
except Exception:
return None
return None
def fetch_and_encode_favicon(
headers: Dict[str, str], soup: BeautifulSoup, url: str
) -> Optional[Dict[str, str]]:
"""
Fetch favicon and encode it as base64.
Args:
favicon_url: URL to the favicon
headers: Request headers
Returns:
str: Base64 encoded favicon with data URI prefix or None
"""
try:
favicon_url = find_favicon_url(soup, url)
if favicon_url is None:
favicon_url = DEFAULT_FAVICON
response = requests.get(favicon_url, headers=headers, timeout=10)
response.raise_for_status()
# Get content type
content_type = response.headers.get("content-type", "image/x-icon")
# Convert to base64
favicon_base64 = base64.b64encode(response.content).decode("utf-8")
# Return as data URI
return {
"favicon_url": favicon_url,
"favicon_base64": f"data:{content_type};base64,{favicon_base64}",
}
except Exception as e:
print(f"Failed to fetch favicon: {e}")
return {
"favicon_url": None,
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
}

View File

@@ -114,6 +114,7 @@ export interface IProjectMembership {
id: string;
member: string;
role: TUserPermissions;
created_at: string;
}
export interface IProjectBulkAddFormData {

View File

@@ -12,6 +12,7 @@ export interface IUserLite {
id: string;
is_bot: boolean;
last_name: string;
joining_date?: string;
}
export interface IUser extends IUserLite {
// only for uploading the cover image

View File

@@ -2,7 +2,7 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { Pencil, Trash2, Copy } from "lucide-react";
import { Pencil, Trash2, Copy, Link } from "lucide-react";
import { EIssueServiceType } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TIssueServiceType } from "@plane/types";
@@ -37,7 +37,9 @@ export const IssueLinkItem: FC<TIssueLinkItem> = observer((props) => {
const linkDetail = getLinkById(linkId);
if (!linkDetail) return <></>;
const Icon = getIconForLink(linkDetail.url);
// const Icon = getIconForLink(linkDetail.url);
const faviconUrl: string | undefined = linkDetail.metadata?.favicon;
const linkTitle: string | undefined = linkDetail.metadata?.title;
const toggleIssueLinkModal = (modalToggle: boolean) => {
toggleIssueLinkModalStore(modalToggle);
@@ -50,15 +52,21 @@ export const IssueLinkItem: FC<TIssueLinkItem> = observer((props) => {
className="group col-span-12 lg:col-span-6 xl:col-span-4 2xl:col-span-3 3xl:col-span-2 flex items-center justify-between gap-3 h-10 flex-shrink-0 px-3 bg-custom-background-90 hover:bg-custom-background-80 border-[0.5px] border-custom-border-200 rounded"
>
<div className="flex items-center gap-2.5 truncate flex-grow">
<Icon className="size-4 flex-shrink-0 stroke-2 text-custom-text-350 group-hover:text-custom-text-100" />
{faviconUrl ? (
<img src={faviconUrl} alt="favicon" className="size-4" />
) : (
<Link className="size-4 text-custom-text-350 group-hover:text-custom-text-100" />
)}
<Tooltip tooltipContent={linkDetail.url} isMobile={isMobile}>
<a
href={linkDetail.url}
target="_blank"
rel="noopener noreferrer"
className="truncate text-sm cursor-pointer flex-grow"
className="truncate text-sm cursor-pointer flex-grow flex items-center gap-3"
>
{linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url}
{linkTitle && linkTitle !== "" && <span className="text-custom-text-400 text-xs">{linkTitle}</span>}
</a>
</Tooltip>
</div>

View File

@@ -127,7 +127,10 @@ export class ProjectMemberStore implements IProjectMemberStore {
const memberDetails: IProjectMemberDetails = {
id: projectMember.id,
role: projectMember.role,
member: this.memberRoot?.memberMap?.[projectMember.member],
member: {
...this.memberRoot?.memberMap?.[projectMember.member],
joining_date: projectMember.created_at,
},
};
return memberDetails;
});