Skip to content
Merged
Show file tree
Hide file tree
Changes from 44 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
d52cce1
chore: new asset duplicate endpoint added
aaryan610 Jun 5, 2025
0e369f8
chore: change the type in url
NarayanBavisetti Jun 5, 2025
9704f21
chore: added rate limiting for image duplication endpoint
NarayanBavisetti Jun 6, 2025
cdd6a7b
chore: added rate limiting per asset id
NarayanBavisetti Jun 9, 2025
3cb427f
Merge branch 'preview' into chore/asset-duplicate-endpoint
NarayanBavisetti Jun 13, 2025
8605789
Merge branch 'preview' into chore/asset-duplicate-endpoint
NarayanBavisetti Jun 23, 2025
f8df5c0
Merge branch 'preview' of github.com:makeplane/plane into chore/asset…
NarayanBavisetti Jul 16, 2025
c54aa7a
chore: added throttle class
NarayanBavisetti Jul 16, 2025
96f95ac
Merge branch 'preview' into chore/asset-duplicate-endpoint
NarayanBavisetti Oct 6, 2025
8ad1529
Merge branch 'preview' into chore/asset-duplicate-endpoint
iam-vipin Oct 13, 2025
992dac3
Merge branch 'preview' of github.com:makeplane/plane into chore/asset…
NarayanBavisetti Oct 14, 2025
9fc0fa7
chore: added validations for entity
NarayanBavisetti Oct 14, 2025
10bca48
Merge branch 'chore/asset-duplicate-endpoint' of github.com:makeplane…
NarayanBavisetti Oct 14, 2025
fe68d0f
Merge branch 'preview' of github.com:makeplane/plane into chore/asset…
NarayanBavisetti Oct 14, 2025
155857c
chore: added extra validations
NarayanBavisetti Oct 14, 2025
7b116b7
chore: removed the comment
NarayanBavisetti Oct 14, 2025
4a06133
chore: reverted the frontend code
NarayanBavisetti Oct 14, 2025
5670144
chore: added the response key
NarayanBavisetti Oct 14, 2025
94e8763
feat: handle image duplication for web
iam-vipin Oct 29, 2025
66abe90
feat: custom image duplication update
iam-vipin Oct 29, 2025
8fdef1b
fix: remove paste logic for image
iam-vipin Oct 29, 2025
60c28b0
fix : remove entity validation
iam-vipin Oct 29, 2025
6a2ab08
refactor: remove entity id for duplication
iam-vipin Oct 30, 2025
203ef14
feat: handle duplication in utils
iam-vipin Oct 31, 2025
ee9a19a
feat: add asset duplication registry
iam-vipin Oct 31, 2025
76921fb
chore: update the set attribute method
iam-vipin Oct 31, 2025
919d49d
Merge branch 'preview' into chore/asset-duplicate-endpoint
iam-vipin Nov 10, 2025
f08bbfc
fix: add ref for api check
iam-vipin Nov 10, 2025
2d4318b
chore :remove logs
iam-vipin Nov 12, 2025
2b0f0ef
chore : add entity types types
iam-vipin Nov 12, 2025
ff28596
refactor: rename duplication success status value
iam-vipin Nov 13, 2025
60bb7e0
chore: update attribute to enums
iam-vipin Nov 13, 2025
eb588d6
chore: update variable name
iam-vipin Nov 13, 2025
2bed639
Merge branch 'preview' into chore/asset-duplicate-endpoint
iam-vipin Nov 13, 2025
1a88225
chore: set uploading state
iam-vipin Nov 13, 2025
426d5f3
chore : update enum name
iam-vipin Nov 13, 2025
334074d
chore : update replace command
iam-vipin Nov 13, 2025
098a753
iam-vipin Nov 13, 2025
c0ebe35
chore: fix retry UI
iam-vipin Nov 13, 2025
af96742
chore: remove default logic
iam-vipin Nov 14, 2025
548e060
Merge branch 'preview' into chore/asset-duplicate-endpoint
iam-vipin Nov 20, 2025
bb43f2f
refactor: optimize imports in custom image extension files and improv…
iam-vipin Nov 20, 2025
9de753f
fix:type error
iam-vipin Nov 20, 2025
0b40e6d
Update packages/editor/src/core/extensions/custom-image/components/no…
iam-vipin Nov 20, 2025
d420ce7
fix: enhance asset duplication handler to ignore HTTP sources
iam-vipin Nov 20, 2025
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
6 changes: 6 additions & 0 deletions apps/api/plane/app/urls/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
DuplicateAssetEndpoint,
WorkspaceAssetDownloadEndpoint,
ProjectAssetDownloadEndpoint,
)
Expand Down Expand Up @@ -91,6 +92,11 @@
AssetCheckEndpoint.as_view(),
name="asset-check",
),
path(
"assets/v2/workspaces/<str:slug>/duplicate-assets/<uuid:asset_id>/",
DuplicateAssetEndpoint.as_view(),
name="duplicate-assets",
),
path(
"assets/v2/workspaces/<str:slug>/download/<uuid:asset_id>/",
WorkspaceAssetDownloadEndpoint.as_view(),
Expand Down
1 change: 1 addition & 0 deletions apps/api/plane/app/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
DuplicateAssetEndpoint,
WorkspaceAssetDownloadEndpoint,
ProjectAssetDownloadEndpoint,
)
Expand Down
171 changes: 153 additions & 18 deletions apps/api/plane/app/views/asset/v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from plane.app.permissions import allow_permission, ROLE
from plane.utils.cache import invalidate_cache_directly
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.throttles.asset import AssetRateThrottle


class UserAssetsV2Endpoint(BaseAPIView):
Expand All @@ -44,7 +45,9 @@ def entity_asset_save(self, asset_id, entity_type, asset, request):
# Save the new avatar
user.avatar_asset_id = asset_id
user.save()
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
invalidate_cache_directly(
path="/api/users/me/", url_params=False, user=True, request=request
)
invalidate_cache_directly(
path="/api/users/me/settings/",
url_params=False,
Expand All @@ -62,7 +65,9 @@ def entity_asset_save(self, asset_id, entity_type, asset, request):
# Save the new cover image
user.cover_image_asset_id = asset_id
user.save()
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
invalidate_cache_directly(
path="/api/users/me/", url_params=False, user=True, request=request
)
invalidate_cache_directly(
path="/api/users/me/settings/",
url_params=False,
Expand All @@ -78,7 +83,9 @@ def entity_asset_delete(self, entity_type, asset, request):
user = User.objects.get(id=asset.user_id)
user.avatar_asset_id = None
user.save()
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
invalidate_cache_directly(
path="/api/users/me/", url_params=False, user=True, request=request
)
invalidate_cache_directly(
path="/api/users/me/settings/",
url_params=False,
Expand All @@ -91,7 +98,9 @@ def entity_asset_delete(self, entity_type, asset, request):
user = User.objects.get(id=asset.user_id)
user.cover_image_asset_id = None
user.save()
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
invalidate_cache_directly(
path="/api/users/me/", url_params=False, user=True, request=request
)
invalidate_cache_directly(
path="/api/users/me/settings/",
url_params=False,
Expand Down Expand Up @@ -151,7 +160,9 @@ def post(self, request):
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
presigned_url = storage.generate_presigned_post(
object_name=asset_key, file_type=type, file_size=size_limit
)
# Return the presigned URL
return Response(
{
Expand Down Expand Up @@ -188,7 +199,9 @@ def delete(self, request, asset_id):
asset.is_deleted = True
asset.deleted_at = timezone.now()
# get the entity and save the asset id for the request field
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
self.entity_asset_delete(
entity_type=asset.entity_type, asset=asset, request=request
)
asset.save(update_fields=["is_deleted", "deleted_at"])
return Response(status=status.HTTP_204_NO_CONTENT)

Expand Down Expand Up @@ -252,14 +265,18 @@ def entity_asset_save(self, asset_id, entity_type, asset, request):
workspace.logo = ""
workspace.logo_asset_id = asset_id
workspace.save()
invalidate_cache_directly(path="/api/workspaces/", url_params=False, user=False, request=request)
invalidate_cache_directly(
path="/api/workspaces/", url_params=False, user=False, request=request
)
invalidate_cache_directly(
path="/api/users/me/workspaces/",
url_params=False,
user=True,
request=request,
)
invalidate_cache_directly(path="/api/instances/", url_params=False, user=False, request=request)
invalidate_cache_directly(
path="/api/instances/", url_params=False, user=False, request=request
)
return

# Project Cover
Expand All @@ -286,14 +303,18 @@ def entity_asset_delete(self, entity_type, asset, request):
return
workspace.logo_asset_id = None
workspace.save()
invalidate_cache_directly(path="/api/workspaces/", url_params=False, user=False, request=request)
invalidate_cache_directly(
path="/api/workspaces/", url_params=False, user=False, request=request
)
invalidate_cache_directly(
path="/api/users/me/workspaces/",
url_params=False,
user=True,
request=request,
)
invalidate_cache_directly(path="/api/instances/", url_params=False, user=False, request=request)
invalidate_cache_directly(
path="/api/instances/", url_params=False, user=False, request=request
)
return
# Project Cover
elif entity_type == FileAsset.EntityTypeContext.PROJECT_COVER:
Expand Down Expand Up @@ -354,13 +375,17 @@ def post(self, request, slug):
workspace=workspace,
created_by=request.user,
entity_type=entity_type,
**self.get_entity_id_field(entity_type=entity_type, entity_id=entity_identifier),
**self.get_entity_id_field(
entity_type=entity_type, entity_id=entity_identifier
),
)

# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
presigned_url = storage.generate_presigned_post(
object_name=asset_key, file_type=type, file_size=size_limit
)
# Return the presigned URL
return Response(
{
Expand Down Expand Up @@ -397,7 +422,9 @@ def delete(self, request, slug, asset_id):
asset.is_deleted = True
asset.deleted_at = timezone.now()
# get the entity and save the asset id for the request field
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
self.entity_asset_delete(
entity_type=asset.entity_type, asset=asset, request=request
)
asset.save(update_fields=["is_deleted", "deleted_at"])
return Response(status=status.HTTP_204_NO_CONTENT)

Expand Down Expand Up @@ -560,7 +587,9 @@ def post(self, request, slug, project_id):
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
presigned_url = storage.generate_presigned_post(
object_name=asset_key, file_type=type, file_size=size_limit
)
# Return the presigned URL
return Response(
{
Expand Down Expand Up @@ -590,7 +619,9 @@ def patch(self, request, slug, project_id, pk):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def delete(self, request, slug, project_id, pk):
# Get the asset
asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id)
asset = FileAsset.objects.get(
id=pk, workspace__slug=slug, project_id=project_id
)
# Check deleted assets
asset.is_deleted = True
asset.deleted_at = timezone.now()
Expand All @@ -601,7 +632,9 @@ def delete(self, request, slug, project_id, pk):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, pk):
# get the asset id
asset = FileAsset.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
asset = FileAsset.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)

# Check if the asset is uploaded
if not asset.is_uploaded:
Expand Down Expand Up @@ -634,7 +667,9 @@ def post(self, request, slug, project_id, entity_id):

# Check if the asset ids are provided
if not asset_ids:
return Response({"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST
)

# get the asset id
assets = FileAsset.objects.filter(id__in=asset_ids, workspace__slug=slug)
Expand Down Expand Up @@ -688,10 +723,110 @@ class AssetCheckEndpoint(BaseAPIView):

@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def get(self, request, slug, asset_id):
asset = FileAsset.all_objects.filter(id=asset_id, workspace__slug=slug, deleted_at__isnull=True).exists()
asset = FileAsset.all_objects.filter(
id=asset_id, workspace__slug=slug, deleted_at__isnull=True
).exists()
return Response({"exists": asset}, status=status.HTTP_200_OK)


class DuplicateAssetEndpoint(BaseAPIView):

throttle_classes = [AssetRateThrottle]

def get_entity_id_field(self, entity_type, entity_id):
# Workspace Logo
if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO:
return {"workspace_id": entity_id}

# Project Cover
if entity_type == FileAsset.EntityTypeContext.PROJECT_COVER:
return {"project_id": entity_id}

# User Avatar and Cover
if entity_type in [
FileAsset.EntityTypeContext.USER_AVATAR,
FileAsset.EntityTypeContext.USER_COVER,
]:
return {"user_id": entity_id}

# Issue Attachment and Description
if entity_type in [
FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
FileAsset.EntityTypeContext.ISSUE_DESCRIPTION,
]:
return {"issue_id": entity_id}

# Page Description
if entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION:
return {"page_id": entity_id}

# Comment Description
if entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION:
return {"comment_id": entity_id}

return {}

@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug, asset_id):
Comment thread
NarayanBavisetti marked this conversation as resolved.
project_id = request.data.get("project_id", None)
entity_id = request.data.get("entity_id", None)
entity_type = request.data.get("entity_type", None)

Comment thread
NarayanBavisetti marked this conversation as resolved.

if (
not entity_type
or entity_type not in FileAsset.EntityTypeContext.values
):
return Response(
{"error": "Invalid entity type or entity id"},
status=status.HTTP_400_BAD_REQUEST,
)

workspace = Workspace.objects.get(slug=slug)
if project_id:
# check if project exists in the workspace
if not Project.objects.filter(id=project_id, workspace=workspace).exists():
return Response(
{"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND
)

storage = S3Storage(request=request)
original_asset = FileAsset.objects.filter(
workspace=workspace, id=asset_id, is_uploaded=True
).first()
Comment thread
NarayanBavisetti marked this conversation as resolved.

if not original_asset:
return Response(
{"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND
)
Comment thread
NarayanBavisetti marked this conversation as resolved.

destination_key = (
f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
)
duplicated_asset = FileAsset.objects.create(
attributes={
"name": original_asset.attributes.get("name"),
"type": original_asset.attributes.get("type"),
"size": original_asset.attributes.get("size"),
},
asset=destination_key,
size=original_asset.size,
workspace=workspace,
created_by_id=request.user.id,
entity_type=entity_type,
project_id=project_id if project_id else None,
storage_metadata=original_asset.storage_metadata,
**self.get_entity_id_field(entity_type=entity_type, entity_id=entity_id),
)
storage.copy_object(original_asset.asset, destination_key)
Comment thread
NarayanBavisetti marked this conversation as resolved.
# Update the is_uploaded field for all newly created assets
FileAsset.objects.filter(id=duplicated_asset.id).update(is_uploaded=True)

return Response(
{"asset_id": str(duplicated_asset.id)}, status=status.HTTP_200_OK
)


class WorkspaceAssetDownloadEndpoint(BaseAPIView):
"""Endpoint to generate a download link for an asset with content-disposition=attachment."""

Expand Down
9 changes: 8 additions & 1 deletion apps/api/plane/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,14 @@

# Rest Framework settings
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework.authentication.SessionAuthentication",),
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework.authentication.SessionAuthentication",
),
"DEFAULT_THROTTLE_CLASSES": ("rest_framework.throttling.AnonRateThrottle",),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify impact on existing anonymous endpoints

Adding AnonRateThrottle globally will throttle all unauthenticated requests at 30/minute. While DEFAULT_PERMISSION_CLASSES requires authentication for most endpoints (line 80), verify that any existing public/anonymous endpoints can tolerate this rate limit.


🏁 Script executed:

#!/bin/bash
# Description: Find endpoints that allow anonymous access by searching for permission_classes overrides

# Search for AllowAny, IsAuthenticatedOrReadOnly, and custom permission classes
rg -n -C3 --type=py 'permission_classes\s*=.*\b(AllowAny|IsAuthenticatedOrReadOnly)\b' -g '!**/test/**' -g '!**/tests/**'

# Also check for views that explicitly allow anonymous access
rg -n -C3 --type=py 'authentication_classes\s*=\s*\[\s*\]' -g '!**/test/**' -g '!**/tests/**'

Length of output: 15033



🏁 Script executed:

#!/bin/bash
# Find AllowAny endpoints and check if they have custom throttle_classes in the same view

# Get all files with AllowAny permission
files=$(rg -l --type=py 'permission_classes.*AllowAny' -g '!**/test/**' -g '!**/tests/**')

echo "=== AllowAny endpoints without custom throttle_classes ==="
for file in $files; do
    # Check if file has both AllowAny and throttle_classes on same view
    if rg -U --type=py '(?s)class\s+\w+.*?permission_classes.*?AllowAny.*?^\s*$' "$file" | grep -q 'throttle_classes'; then
        continue
    else
        # Print the class name and surrounding context
        rg -n -B2 -A5 --type=py 'permission_classes.*AllowAny' "$file"
        echo "---"
    fi
done | head -100

Length of output: 2929


🏁 Script executed:

#!/bin/bash
# Find the DEFAULT_THROTTLE_RATES setting to see the actual rate limit

echo "=== DEFAULT_THROTTLE_RATES configuration ==="
rg -n -A10 'DEFAULT_THROTTLE_RATES' apps/api/plane/settings/common.py

echo -e "\n=== DEFAULT_THROTTLE_CLASSES context ==="
rg -n -B5 -A5 'DEFAULT_THROTTLE_CLASSES' apps/api/plane/settings/common.py

Length of output: 1131


🏁 Script executed:

#!/bin/bash
# Check the DEFAULT_THROTTLE_RATES configuration

echo "=== Finding DEFAULT_THROTTLE_RATES ==="
rg -n -B3 -A8 "DEFAULT_THROTTLE_RATES" apps/api/plane/settings/common.py

echo -e "\n=== Also check if there's a 'anon' rate defined ==="
rg -n "'anon'" apps/api/plane/settings/common.py

Length of output: 800


Add custom throttle_classes to critical endpoints that lack protection

Adding DEFAULT_THROTTLE_CLASSES with AnonRateThrottle at 30/minute will throttle several critical endpoints. The following endpoints must override the throttle to maintain functionality:

  • CSRFTokenEndpoint (authentication/views/common.py:25) – CSRF token generation will fail after 30 requests/minute
  • WorkspaceJoinEndpoint (app/views/workspace/invite.py:134) – workspace invite acceptance limited to 30/minute
  • ProjectJoinEndpoint (app/views/project/invite.py:180) – project invite acceptance limited to 30/minute
  • MagicGenerateSpaceEndpoint (authentication/views/space/magic.py:28) – magic link generation now throttled (other magic endpoints already have custom throttle)

These should either override throttle_classes = [] to disable throttling, or use a less restrictive custom throttle. At minimum, verify the 30/minute rate is acceptable for these flows.

🤖 Prompt for AI Agents
In apps/api/plane/settings/common.py around line 75, adding
DEFAULT_THROTTLE_CLASSES with AnonRateThrottle at 30/min will throttle critical
endpoints; update the listed endpoints to explicitly override or relax
throttling: open authentication/views/common.py (CSRFTokenEndpoint) and set
throttle_classes = [] (or a permissive custom throttle), modify
app/views/workspace/invite.py (WorkspaceJoinEndpoint) and
app/views/project/invite.py (ProjectJoinEndpoint) to set throttle_classes = []
or a less restrictive throttle, and update authentication/views/space/magic.py
(MagicGenerateSpaceEndpoint) to similarly override throttling; ensure each
change is limited to those endpoint classes only and include brief comments
explaining why throttling is disabled/relaxed.

"DEFAULT_THROTTLE_RATES": {
"anon": "30/minute",
"asset_id": "5/minute",
},
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
"DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",),
Expand Down
11 changes: 11 additions & 0 deletions apps/api/plane/throttles/asset.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from rest_framework.throttling import SimpleRateThrottle


class AssetRateThrottle(SimpleRateThrottle):
scope = "asset_id"

def get_cache_key(self, request, view):
asset_id = view.kwargs.get("asset_id")
if not asset_id:
return None
return f"throttle_asset_{asset_id}"
4 changes: 4 additions & 0 deletions apps/space/helpers/editor.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ export const getEditorFileHandlers = (args: TArgs): TFileHandler => {
await sitesFileService.restoreNewAsset(anchor, src);
}
},
duplicate: async (assetId: string) =>
// Duplication is not supported for sites/space app
// Return the same assetId as a fallback
assetId,
validation: {
maxFileSize: MAX_FILE_SIZE,
},
Expand Down
Loading
Loading