release: v1.2.2 #8645
This commit is contained in:
commit
2a978e3ac0
29 changed files with 183 additions and 69 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "admin",
|
"name": "admin",
|
||||||
"description": "Admin UI for Plane",
|
"description": "Admin UI for Plane",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "plane-api",
|
"name": "plane-api",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "API server powering Plane's backend"
|
"description": "API server powering Plane's backend"
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ class APITokenSerializer(BaseSerializer):
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"workspace",
|
"workspace",
|
||||||
"user",
|
"user",
|
||||||
|
"is_active",
|
||||||
|
"last_used",
|
||||||
|
"user_type",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -603,7 +603,7 @@ class ProjectAssetEndpoint(BaseAPIView):
|
||||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||||
def patch(self, request, slug, project_id, pk):
|
def patch(self, request, slug, project_id, pk):
|
||||||
# get the asset id
|
# get the asset id
|
||||||
asset = FileAsset.objects.get(id=pk)
|
asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id)
|
||||||
# get the storage metadata
|
# get the storage metadata
|
||||||
asset.is_uploaded = True
|
asset.is_uploaded = True
|
||||||
# get the storage metadata
|
# get the storage metadata
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,14 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||||
|
|
||||||
@allow_permission([ROLE.ADMIN], creator=True, model=FileAsset)
|
@allow_permission([ROLE.ADMIN], creator=True, model=FileAsset)
|
||||||
def delete(self, request, slug, project_id, issue_id, pk):
|
def delete(self, request, slug, project_id, issue_id, pk):
|
||||||
issue_attachment = FileAsset.objects.get(pk=pk)
|
issue_attachment = FileAsset.objects.filter(
|
||||||
|
pk=pk, workspace__slug=slug, project_id=project_id, issue_id=issue_id
|
||||||
|
).first()
|
||||||
|
if not issue_attachment:
|
||||||
|
return Response(
|
||||||
|
{"error": "Issue attachment not found."},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
issue_attachment.asset.delete(save=False)
|
issue_attachment.asset.delete(save=False)
|
||||||
issue_attachment.delete()
|
issue_attachment.delete()
|
||||||
issue_activity.delay(
|
issue_activity.delay(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
|
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
# See the LICENSE file for details.
|
||||||
|
|
||||||
# Python imports
|
# Python imports
|
||||||
import logging
|
import logging
|
||||||
|
import socket
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
|
|
@ -20,6 +24,48 @@ logger = logging.getLogger("plane.worker")
|
||||||
DEFAULT_FAVICON = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWxpbmstaWNvbiBsdWNpZGUtbGluayI+PHBhdGggZD0iTTEwIDEzYTUgNSAwIDAgMCA3LjU0LjU0bDMtM2E1IDUgMCAwIDAtNy4wNy03LjA3bC0xLjcyIDEuNzEiLz48cGF0aCBkPSJNMTQgMTFhNSA1IDAgMCAwLTcuNTQtLjU0bC0zIDNhNSA1IDAgMCAwIDcuMDcgNy4wN2wxLjcxLTEuNzEiLz48L3N2Zz4=" # noqa: E501
|
DEFAULT_FAVICON = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWxpbmstaWNvbiBsdWNpZGUtbGluayI+PHBhdGggZD0iTTEwIDEzYTUgNSAwIDAgMCA3LjU0LjU0bDMtM2E1IDUgMCAwIDAtNy4wNy03LjA3bC0xLjcyIDEuNzEiLz48cGF0aCBkPSJNMTQgMTFhNSA1IDAgMCAwLTcuNTQtLjU0bC0zIDNhNSA1IDAgMCAwIDcuMDcgNy4wN2wxLjcxLTEuNzEiLz48L3N2Zz4=" # noqa: E501
|
||||||
|
|
||||||
|
|
||||||
|
def validate_url_ip(url: str) -> None:
|
||||||
|
"""
|
||||||
|
Validate that a URL doesn't point to a private/internal IP address.
|
||||||
|
Resolves hostnames to IPs before checking.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The URL to validate
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the URL points to a private/internal IP
|
||||||
|
"""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
hostname = parsed.hostname
|
||||||
|
|
||||||
|
if not hostname:
|
||||||
|
raise ValueError("Invalid URL: No hostname found")
|
||||||
|
|
||||||
|
# Only allow HTTP and HTTPS to prevent file://, gopher://, etc.
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
raise ValueError("Invalid URL scheme. Only HTTP and HTTPS are allowed")
|
||||||
|
|
||||||
|
# Resolve hostname to IP addresses — this catches domain names that
|
||||||
|
# point to internal IPs (e.g. attacker.com -> 169.254.169.254)
|
||||||
|
|
||||||
|
try:
|
||||||
|
addr_info = socket.getaddrinfo(hostname, None)
|
||||||
|
except socket.gaierror:
|
||||||
|
raise ValueError("Hostname could not be resolved")
|
||||||
|
|
||||||
|
if not addr_info:
|
||||||
|
raise ValueError("No IP addresses found for the hostname")
|
||||||
|
|
||||||
|
# Check every resolved IP against blocked ranges to prevent SSRF
|
||||||
|
for addr in addr_info:
|
||||||
|
ip = ipaddress.ip_address(addr[4][0])
|
||||||
|
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
|
||||||
|
raise ValueError("Access to private/internal networks is not allowed")
|
||||||
|
|
||||||
|
|
||||||
|
MAX_REDIRECTS = 5
|
||||||
|
|
||||||
|
|
||||||
def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
|
def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Crawls a URL to extract the title and favicon.
|
Crawls a URL to extract the title and favicon.
|
||||||
|
|
@ -31,17 +77,6 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
|
||||||
str: JSON string containing title and base64-encoded favicon
|
str: JSON string containing title and base64-encoded favicon
|
||||||
"""
|
"""
|
||||||
try:
|
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
|
# Set up headers to mimic a real browser
|
||||||
headers = {
|
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
|
"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
|
||||||
|
|
@ -49,9 +84,28 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
|
||||||
|
|
||||||
soup = None
|
soup = None
|
||||||
title = None
|
title = None
|
||||||
|
final_url = url
|
||||||
|
|
||||||
|
validate_url_ip(final_url)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(url, headers=headers, timeout=1)
|
# Manually follow redirects to validate each URL before requesting
|
||||||
|
redirect_count = 0
|
||||||
|
response = requests.get(final_url, headers=headers, timeout=1, allow_redirects=False)
|
||||||
|
|
||||||
|
while response.is_redirect and redirect_count < MAX_REDIRECTS:
|
||||||
|
redirect_url = response.headers.get("Location")
|
||||||
|
if not redirect_url:
|
||||||
|
break
|
||||||
|
# Resolve relative redirects against current URL
|
||||||
|
final_url = urljoin(final_url, redirect_url)
|
||||||
|
# Validate the redirect target BEFORE making the request
|
||||||
|
validate_url_ip(final_url)
|
||||||
|
redirect_count += 1
|
||||||
|
response = requests.get(final_url, headers=headers, timeout=1, allow_redirects=False)
|
||||||
|
|
||||||
|
if redirect_count >= MAX_REDIRECTS:
|
||||||
|
logger.warning(f"Too many redirects for URL: {url}")
|
||||||
|
|
||||||
soup = BeautifulSoup(response.content, "html.parser")
|
soup = BeautifulSoup(response.content, "html.parser")
|
||||||
title_tag = soup.find("title")
|
title_tag = soup.find("title")
|
||||||
|
|
@ -60,8 +114,8 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
logger.warning(f"Failed to fetch HTML for title: {str(e)}")
|
logger.warning(f"Failed to fetch HTML for title: {str(e)}")
|
||||||
|
|
||||||
# Fetch and encode favicon
|
# Fetch and encode favicon using final URL (after redirects)
|
||||||
favicon_base64 = fetch_and_encode_favicon(headers, soup, url)
|
favicon_base64 = fetch_and_encode_favicon(headers, soup, final_url)
|
||||||
|
|
||||||
# Prepare result
|
# Prepare result
|
||||||
result = {
|
result = {
|
||||||
|
|
@ -107,7 +161,9 @@ def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[s
|
||||||
for selector in favicon_selectors:
|
for selector in favicon_selectors:
|
||||||
favicon_tag = soup.select_one(selector)
|
favicon_tag = soup.select_one(selector)
|
||||||
if favicon_tag and favicon_tag.get("href"):
|
if favicon_tag and favicon_tag.get("href"):
|
||||||
return urljoin(base_url, favicon_tag["href"])
|
favicon_href = urljoin(base_url, favicon_tag["href"])
|
||||||
|
validate_url_ip(favicon_href)
|
||||||
|
return favicon_href
|
||||||
|
|
||||||
# Fallback to /favicon.ico
|
# Fallback to /favicon.ico
|
||||||
parsed_url = urlparse(base_url)
|
parsed_url = urlparse(base_url)
|
||||||
|
|
@ -115,7 +171,9 @@ def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[s
|
||||||
|
|
||||||
# Check if fallback exists
|
# Check if fallback exists
|
||||||
try:
|
try:
|
||||||
response = requests.head(fallback_url, timeout=2)
|
validate_url_ip(fallback_url)
|
||||||
|
response = requests.head(fallback_url, timeout=2, allow_redirects=False)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return fallback_url
|
return fallback_url
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
|
|
@ -146,6 +204,8 @@ def fetch_and_encode_favicon(
|
||||||
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
|
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validate_url_ip(favicon_url)
|
||||||
|
|
||||||
response = requests.get(favicon_url, headers=headers, timeout=1)
|
response = requests.get(favicon_url, headers=headers, timeout=1)
|
||||||
|
|
||||||
# Get content type
|
# Get content type
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,11 @@ class ProjectMembersEndpoint(BaseAPIView):
|
||||||
|
|
||||||
def get(self, request, anchor):
|
def get(self, request, anchor):
|
||||||
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
|
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
|
||||||
|
if not deploy_board:
|
||||||
|
return Response(
|
||||||
|
{"error": "Invalid anchor"},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
members = ProjectMember.objects.filter(
|
members = ProjectMember.objects.filter(
|
||||||
project=deploy_board.project,
|
project=deploy_board.project,
|
||||||
|
|
@ -71,10 +76,7 @@ class ProjectMembersEndpoint(BaseAPIView):
|
||||||
).values(
|
).values(
|
||||||
"id",
|
"id",
|
||||||
"member",
|
"member",
|
||||||
"member__first_name",
|
|
||||||
"member__last_name",
|
|
||||||
"member__display_name",
|
"member__display_name",
|
||||||
"project",
|
"member__avatar",
|
||||||
"workspace",
|
|
||||||
)
|
)
|
||||||
return Response(members, status=status.HTTP_200_OK)
|
return Response(members, status=status.HTTP_200_OK)
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ class TestApiTokenEndpoint:
|
||||||
"""Test retrieving a specific API token"""
|
"""Test retrieving a specific API token"""
|
||||||
# Arrange
|
# Arrange
|
||||||
session_client.force_authenticate(user=create_user)
|
session_client.force_authenticate(user=create_user)
|
||||||
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
|
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
response = session_client.get(url)
|
response = session_client.get(url)
|
||||||
|
|
@ -155,7 +155,7 @@ class TestApiTokenEndpoint:
|
||||||
# Arrange
|
# Arrange
|
||||||
session_client.force_authenticate(user=create_user)
|
session_client.force_authenticate(user=create_user)
|
||||||
fake_pk = uuid4()
|
fake_pk = uuid4()
|
||||||
url = reverse("api-tokens", kwargs={"pk": fake_pk})
|
url = reverse("api-tokens-details", kwargs={"pk": fake_pk})
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
response = session_client.get(url)
|
response = session_client.get(url)
|
||||||
|
|
@ -174,7 +174,7 @@ class TestApiTokenEndpoint:
|
||||||
other_user = User.objects.create(email=unique_email, username=unique_username)
|
other_user = User.objects.create(email=unique_email, username=unique_username)
|
||||||
other_token = APIToken.objects.create(label="Other Token", user=other_user, user_type=0)
|
other_token = APIToken.objects.create(label="Other Token", user=other_user, user_type=0)
|
||||||
session_client.force_authenticate(user=create_user)
|
session_client.force_authenticate(user=create_user)
|
||||||
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
|
url = reverse("api-tokens-details", kwargs={"pk": other_token.pk})
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
response = session_client.get(url)
|
response = session_client.get(url)
|
||||||
|
|
@ -188,7 +188,7 @@ class TestApiTokenEndpoint:
|
||||||
"""Test successful API token deletion"""
|
"""Test successful API token deletion"""
|
||||||
# Arrange
|
# Arrange
|
||||||
session_client.force_authenticate(user=create_user)
|
session_client.force_authenticate(user=create_user)
|
||||||
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
|
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
response = session_client.delete(url)
|
response = session_client.delete(url)
|
||||||
|
|
@ -203,7 +203,7 @@ class TestApiTokenEndpoint:
|
||||||
# Arrange
|
# Arrange
|
||||||
session_client.force_authenticate(user=create_user)
|
session_client.force_authenticate(user=create_user)
|
||||||
fake_pk = uuid4()
|
fake_pk = uuid4()
|
||||||
url = reverse("api-tokens", kwargs={"pk": fake_pk})
|
url = reverse("api-tokens-details", kwargs={"pk": fake_pk})
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
response = session_client.delete(url)
|
response = session_client.delete(url)
|
||||||
|
|
@ -222,7 +222,7 @@ class TestApiTokenEndpoint:
|
||||||
other_user = User.objects.create(email=unique_email, username=unique_username)
|
other_user = User.objects.create(email=unique_email, username=unique_username)
|
||||||
other_token = APIToken.objects.create(label="Other Token", user=other_user, user_type=0)
|
other_token = APIToken.objects.create(label="Other Token", user=other_user, user_type=0)
|
||||||
session_client.force_authenticate(user=create_user)
|
session_client.force_authenticate(user=create_user)
|
||||||
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
|
url = reverse("api-tokens-details", kwargs={"pk": other_token.pk})
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
response = session_client.delete(url)
|
response = session_client.delete(url)
|
||||||
|
|
@ -238,7 +238,7 @@ class TestApiTokenEndpoint:
|
||||||
# Arrange
|
# Arrange
|
||||||
service_token = APIToken.objects.create(label="Service Token", user=create_user, user_type=0, is_service=True)
|
service_token = APIToken.objects.create(label="Service Token", user=create_user, user_type=0, is_service=True)
|
||||||
session_client.force_authenticate(user=create_user)
|
session_client.force_authenticate(user=create_user)
|
||||||
url = reverse("api-tokens", kwargs={"pk": service_token.pk})
|
url = reverse("api-tokens-details", kwargs={"pk": service_token.pk})
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
response = session_client.delete(url)
|
response = session_client.delete(url)
|
||||||
|
|
@ -254,7 +254,7 @@ class TestApiTokenEndpoint:
|
||||||
"""Test successful API token update"""
|
"""Test successful API token update"""
|
||||||
# Arrange
|
# Arrange
|
||||||
session_client.force_authenticate(user=create_user)
|
session_client.force_authenticate(user=create_user)
|
||||||
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
|
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||||
update_data = {
|
update_data = {
|
||||||
"label": "Updated Token Label",
|
"label": "Updated Token Label",
|
||||||
"description": "Updated description",
|
"description": "Updated description",
|
||||||
|
|
@ -278,7 +278,7 @@ class TestApiTokenEndpoint:
|
||||||
"""Test partial API token update"""
|
"""Test partial API token update"""
|
||||||
# Arrange
|
# Arrange
|
||||||
session_client.force_authenticate(user=create_user)
|
session_client.force_authenticate(user=create_user)
|
||||||
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
|
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||||
original_description = create_api_token_for_user.description
|
original_description = create_api_token_for_user.description
|
||||||
update_data = {"label": "Only Label Updated"}
|
update_data = {"label": "Only Label Updated"}
|
||||||
|
|
||||||
|
|
@ -296,7 +296,7 @@ class TestApiTokenEndpoint:
|
||||||
# Arrange
|
# Arrange
|
||||||
session_client.force_authenticate(user=create_user)
|
session_client.force_authenticate(user=create_user)
|
||||||
fake_pk = uuid4()
|
fake_pk = uuid4()
|
||||||
url = reverse("api-tokens", kwargs={"pk": fake_pk})
|
url = reverse("api-tokens-details", kwargs={"pk": fake_pk})
|
||||||
update_data = {"label": "New Label"}
|
update_data = {"label": "New Label"}
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
|
|
@ -316,7 +316,7 @@ class TestApiTokenEndpoint:
|
||||||
other_user = User.objects.create(email=unique_email, username=unique_username)
|
other_user = User.objects.create(email=unique_email, username=unique_username)
|
||||||
other_token = APIToken.objects.create(label="Other Token", user=other_user, user_type=0)
|
other_token = APIToken.objects.create(label="Other Token", user=other_user, user_type=0)
|
||||||
session_client.force_authenticate(user=create_user)
|
session_client.force_authenticate(user=create_user)
|
||||||
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
|
url = reverse("api-tokens-details", kwargs={"pk": other_token.pk})
|
||||||
update_data = {"label": "Hacked Label"}
|
update_data = {"label": "Hacked Label"}
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
|
|
@ -329,6 +329,56 @@ class TestApiTokenEndpoint:
|
||||||
other_token.refresh_from_db()
|
other_token.refresh_from_db()
|
||||||
assert other_token.label == "Other Token"
|
assert other_token.label == "Other Token"
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_patch_cannot_modify_token(self, session_client, create_user, create_api_token_for_user):
|
||||||
|
"""Test that token value cannot be modified via PATCH"""
|
||||||
|
# Arrange
|
||||||
|
session_client.force_authenticate(user=create_user)
|
||||||
|
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||||
|
original_token = create_api_token_for_user.token
|
||||||
|
update_data = {"token": "plane_api_malicious_token_value"}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
response = session_client.patch(url, update_data, format="json")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
create_api_token_for_user.refresh_from_db()
|
||||||
|
assert create_api_token_for_user.token == original_token
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_patch_cannot_modify_user_type(self, session_client, create_user, create_api_token_for_user):
|
||||||
|
"""Test that user_type cannot be modified via PATCH"""
|
||||||
|
# Arrange
|
||||||
|
session_client.force_authenticate(user=create_user)
|
||||||
|
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
|
||||||
|
update_data = {"user_type": 1}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
response = session_client.patch(url, update_data, format="json")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
create_api_token_for_user.refresh_from_db()
|
||||||
|
assert create_api_token_for_user.user_type == 0
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_patch_cannot_modify_service_token(self, session_client, create_user):
|
||||||
|
"""Test that service tokens cannot be modified through user token endpoint"""
|
||||||
|
# Arrange
|
||||||
|
service_token = APIToken.objects.create(label="Service Token", user=create_user, user_type=0, is_service=True)
|
||||||
|
session_client.force_authenticate(user=create_user)
|
||||||
|
url = reverse("api-tokens-details", kwargs={"pk": service_token.pk})
|
||||||
|
update_data = {"label": "Hacked Service Token"}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
response = session_client.patch(url, update_data, format="json")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
|
service_token.refresh_from_db()
|
||||||
|
assert service_token.label == "Service Token"
|
||||||
|
|
||||||
# Authentication tests
|
# Authentication tests
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_all_endpoints_require_authentication(self, api_client):
|
def test_all_endpoints_require_authentication(self, api_client):
|
||||||
|
|
@ -337,9 +387,9 @@ class TestApiTokenEndpoint:
|
||||||
endpoints = [
|
endpoints = [
|
||||||
(reverse("api-tokens"), "get"),
|
(reverse("api-tokens"), "get"),
|
||||||
(reverse("api-tokens"), "post"),
|
(reverse("api-tokens"), "post"),
|
||||||
(reverse("api-tokens", kwargs={"pk": uuid4()}), "get"),
|
(reverse("api-tokens-details", kwargs={"pk": uuid4()}), "get"),
|
||||||
(reverse("api-tokens", kwargs={"pk": uuid4()}), "patch"),
|
(reverse("api-tokens-details", kwargs={"pk": uuid4()}), "patch"),
|
||||||
(reverse("api-tokens", kwargs={"pk": uuid4()}), "delete"),
|
(reverse("api-tokens-details", kwargs={"pk": uuid4()}), "delete"),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Act & Assert
|
# Act & Assert
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# base requirements
|
# base requirements
|
||||||
|
|
||||||
# django
|
# django
|
||||||
Django==4.2.27
|
Django==4.2.28
|
||||||
# rest framework
|
# rest framework
|
||||||
djangorestframework==3.15.2
|
djangorestframework==3.15.2
|
||||||
# postgres
|
# postgres
|
||||||
|
|
@ -51,7 +51,7 @@ beautifulsoup4==4.12.3
|
||||||
# analytics
|
# analytics
|
||||||
posthog==3.5.0
|
posthog==3.5.0
|
||||||
# crypto
|
# crypto
|
||||||
cryptography==44.0.1
|
cryptography==46.0.5
|
||||||
# html validator
|
# html validator
|
||||||
lxml==6.0.0
|
lxml==6.0.0
|
||||||
# s3
|
# s3
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "live",
|
"name": "live",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"description": "A realtime collaborative server powers Plane's rich text editor",
|
"description": "A realtime collaborative server powers Plane's rich text editor",
|
||||||
"main": "./dist/start.mjs",
|
"main": "./dist/start.mjs",
|
||||||
|
|
|
||||||
6
apps/space/core/types/member.d.ts
vendored
6
apps/space/core/types/member.d.ts
vendored
|
|
@ -1,10 +1,6 @@
|
||||||
export type TPublicMember = {
|
export type TPublicMember = {
|
||||||
id: string;
|
id: string;
|
||||||
member: string;
|
member: string;
|
||||||
member__avatar: string;
|
|
||||||
member__first_name: string;
|
|
||||||
member__last_name: string;
|
|
||||||
member__display_name: string;
|
member__display_name: string;
|
||||||
project: string;
|
member__avatar: string;
|
||||||
workspace: string;
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "space",
|
"name": "space",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"name": "plane",
|
"name": "plane",
|
||||||
"description": "Open-source project management that unlocks customer value",
|
"description": "Open-source project management that unlocks customer value",
|
||||||
"repository": "https://github.com/makeplane/plane.git",
|
"repository": "https://github.com/makeplane/plane.git",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@plane/codemods",
|
"name": "@plane/codemods",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@plane/constants",
|
"name": "@plane/constants",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@plane/editor",
|
"name": "@plane/editor",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"description": "Core Editor that powers Plane",
|
"description": "Core Editor that powers Plane",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@plane/hooks",
|
"name": "@plane/hooks",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"description": "React hooks that are shared across multiple apps internally",
|
"description": "React hooks that are shared across multiple apps internally",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@plane/i18n",
|
"name": "@plane/i18n",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"description": "I18n shared across multiple apps internally",
|
"description": "I18n shared across multiple apps internally",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@plane/logger",
|
"name": "@plane/logger",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"description": "Logger shared across multiple apps internally",
|
"description": "Logger shared across multiple apps internally",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@plane/propel",
|
"name": "@plane/propel",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@plane/services",
|
"name": "@plane/services",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@plane/shared-state",
|
"name": "@plane/shared-state",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"description": "Shared state shared across multiple apps internally",
|
"description": "Shared state shared across multiple apps internally",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@plane/tailwind-config",
|
"name": "@plane/tailwind-config",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"description": "common tailwind configuration across monorepo",
|
"description": "common tailwind configuration across monorepo",
|
||||||
"main": "tailwind.config.js",
|
"main": "tailwind.config.js",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@plane/types",
|
"name": "@plane/types",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -196,12 +196,8 @@ export type TProfileViews = "assigned" | "created" | "subscribed";
|
||||||
export type TPublicMember = {
|
export type TPublicMember = {
|
||||||
id: string;
|
id: string;
|
||||||
member: string;
|
member: string;
|
||||||
member__avatar: string;
|
|
||||||
member__first_name: string;
|
|
||||||
member__last_name: string;
|
|
||||||
member__display_name: string;
|
member__display_name: string;
|
||||||
project: string;
|
member__avatar: string;
|
||||||
workspace: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// export interface ICurrentUser {
|
// export interface ICurrentUser {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@plane/typescript-config",
|
"name": "@plane/typescript-config",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"files": [
|
"files": [
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"name": "@plane/ui",
|
"name": "@plane/ui",
|
||||||
"description": "UI components shared across multiple apps internally",
|
"description": "UI components shared across multiple apps internally",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@plane/utils",
|
"name": "@plane/utils",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"description": "Helper functions shared across multiple apps internally",
|
"description": "Helper functions shared across multiple apps internally",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue