From d52cce150fd4dca83ac244865b60c48ee83b5f47 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Thu, 5 Jun 2025 17:16:28 +0530 Subject: [PATCH 1/4] chore: new asset duplicate endpoint added --- apiserver/plane/app/urls/asset.py | 6 ++ apiserver/plane/app/views/__init__.py | 1 + apiserver/plane/app/views/asset/v2.py | 90 +++++++++++++++++++++++++++ web/core/services/file.service.ts | 19 +++++- 4 files changed, 115 insertions(+), 1 deletion(-) diff --git a/apiserver/plane/app/urls/asset.py b/apiserver/plane/app/urls/asset.py index 77dd3d00efa..c15d1254a15 100644 --- a/apiserver/plane/app/urls/asset.py +++ b/apiserver/plane/app/urls/asset.py @@ -13,6 +13,7 @@ ProjectAssetEndpoint, ProjectBulkAssetEndpoint, AssetCheckEndpoint, + DuplicateAssetEndpoint, ) @@ -89,4 +90,9 @@ AssetCheckEndpoint.as_view(), name="asset-check", ), + path( + "assets/v2/workspaces//duplicate-assets/", + DuplicateAssetEndpoint.as_view(), + name="duplicate-assets", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 55642a53358..7a9ad40da3b 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -107,6 +107,7 @@ ProjectAssetEndpoint, ProjectBulkAssetEndpoint, AssetCheckEndpoint, + DuplicateAssetEndpoint, ) from .issue.base import ( IssueListEndpoint, diff --git a/apiserver/plane/app/views/asset/v2.py b/apiserver/plane/app/views/asset/v2.py index aecba04b8c3..36d81e83f72 100644 --- a/apiserver/plane/app/views/asset/v2.py +++ b/apiserver/plane/app/views/asset/v2.py @@ -718,3 +718,93 @@ def get(self, request, slug, asset_id): id=asset_id, workspace__slug=slug, deleted_at__isnull=True ).exists() return Response({"exists": asset}, status=status.HTTP_200_OK) + + +class DuplicateAssetEndpoint(BaseAPIView): + 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): + project_id = request.data.get("project_id", None) + asset_ids = request.data.get("asset_ids", None) + entity_id = request.data.get("entity_id", None) + entity_type = request.data.get("entity_type", None) + + if not asset_ids: + return Response( + {"error": "asset_ids is required"}, 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 + ) + duplicated_assets = {} + + storage = S3Storage() + original_assets = FileAsset.objects.filter( + workspace=workspace, id__in=asset_ids + ) + for original_asset in original_assets: + 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) + duplicated_assets[str(original_asset.id)] = str(duplicated_asset.id) + + if duplicated_assets: + # Update the is_uploaded field for all newly created assets + FileAsset.objects.filter(id__in=duplicated_assets.values()).update( + is_uploaded=True + ) + + return Response(duplicated_assets, status=status.HTTP_200_OK) diff --git a/web/core/services/file.service.ts b/web/core/services/file.service.ts index cbf7ae59742..a5863ad83f3 100644 --- a/web/core/services/file.service.ts +++ b/web/core/services/file.service.ts @@ -1,6 +1,7 @@ import { AxiosRequestConfig } from "axios"; -// plane types +// plane imports import { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types"; +import { EFileAssetType } from "@plane/types/src/enums"; // helpers import { API_BASE_URL } from "@/helpers/common.helper"; import { generateFileUploadPayload, getAssetIdFromUrl, getFileMetaDataForUpload } from "@/helpers/file.helper"; @@ -258,6 +259,22 @@ export class FileService extends APIService { }); } + async duplicateAssets( + workspaceSlug: string, + data: { + entity_id: string; + entity_type: EFileAssetType; + project_id?: string; + asset_ids: string[]; + } + ): Promise> { + return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/duplicate-assets/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + cancelUpload() { this.cancelSource.cancel("Upload canceled"); } From 0e369f87acc01d65a9e788da28fc87a7138cc828 Mon Sep 17 00:00:00 2001 From: NarayanBavisetti Date: Thu, 5 Jun 2025 18:23:17 +0530 Subject: [PATCH 2/4] chore: change the type in url --- apiserver/plane/app/urls/asset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apiserver/plane/app/urls/asset.py b/apiserver/plane/app/urls/asset.py index c15d1254a15..09553c69364 100644 --- a/apiserver/plane/app/urls/asset.py +++ b/apiserver/plane/app/urls/asset.py @@ -91,7 +91,7 @@ name="asset-check", ), path( - "assets/v2/workspaces//duplicate-assets/", + "assets/v2/workspaces//duplicate-assets/", DuplicateAssetEndpoint.as_view(), name="duplicate-assets", ), From 9704f21c6efcd9502fa98a163f441d0bc1478680 Mon Sep 17 00:00:00 2001 From: NarayanBavisetti Date: Fri, 6 Jun 2025 15:44:58 +0530 Subject: [PATCH 3/4] chore: added rate limiting for image duplication endpoint --- apiserver/plane/app/views/asset/v2.py | 6 +++++- apiserver/plane/settings/common.py | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apiserver/plane/app/views/asset/v2.py b/apiserver/plane/app/views/asset/v2.py index 36d81e83f72..1d75fca24a0 100644 --- a/apiserver/plane/app/views/asset/v2.py +++ b/apiserver/plane/app/views/asset/v2.py @@ -11,6 +11,7 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.permissions import AllowAny +from rest_framework.throttling import ScopedRateThrottle # Module imports from ..base import BaseAPIView @@ -20,7 +21,6 @@ from plane.utils.cache import invalidate_cache_directly from plane.bgtasks.storage_metadata_task import get_asset_object_metadata - class UserAssetsV2Endpoint(BaseAPIView): """This endpoint is used to upload user profile images.""" @@ -721,6 +721,10 @@ def get(self, request, slug, asset_id): class DuplicateAssetEndpoint(BaseAPIView): + + throttle_classes = [ScopedRateThrottle] + throttle_scope = "image_duplicate" + def get_entity_id_field(self, entity_type, entity_id): # Workspace Logo if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO: diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 38d2ac6e0ad..9e1f7d047a0 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -71,6 +71,11 @@ "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework.authentication.SessionAuthentication", ), + "DEFAULT_THROTTLE_CLASSES": ("rest_framework.throttling.AnonRateThrottle",), + "DEFAULT_THROTTLE_RATES": { + "anon": "30/minute", + "image_duplicate": "10/minute", + }, "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), From cdd6a7be102851e922a633f5d0f1969ac0307761 Mon Sep 17 00:00:00 2001 From: NarayanBavisetti Date: Mon, 9 Jun 2025 17:29:28 +0530 Subject: [PATCH 4/4] chore: added rate limiting per asset id --- apiserver/plane/app/throttles/asset.py | 11 +++++ apiserver/plane/app/urls/asset.py | 2 +- apiserver/plane/app/views/asset/v2.py | 67 ++++++++++---------------- apiserver/plane/settings/common.py | 2 +- 4 files changed, 39 insertions(+), 43 deletions(-) create mode 100644 apiserver/plane/app/throttles/asset.py diff --git a/apiserver/plane/app/throttles/asset.py b/apiserver/plane/app/throttles/asset.py new file mode 100644 index 00000000000..48465004938 --- /dev/null +++ b/apiserver/plane/app/throttles/asset.py @@ -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}" diff --git a/apiserver/plane/app/urls/asset.py b/apiserver/plane/app/urls/asset.py index 09553c69364..818d5b3d05d 100644 --- a/apiserver/plane/app/urls/asset.py +++ b/apiserver/plane/app/urls/asset.py @@ -91,7 +91,7 @@ name="asset-check", ), path( - "assets/v2/workspaces//duplicate-assets/", + "assets/v2/workspaces//duplicate-assets//", DuplicateAssetEndpoint.as_view(), name="duplicate-assets", ), diff --git a/apiserver/plane/app/views/asset/v2.py b/apiserver/plane/app/views/asset/v2.py index 1d75fca24a0..873ed8ad877 100644 --- a/apiserver/plane/app/views/asset/v2.py +++ b/apiserver/plane/app/views/asset/v2.py @@ -11,7 +11,6 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.permissions import AllowAny -from rest_framework.throttling import ScopedRateThrottle # Module imports from ..base import BaseAPIView @@ -20,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.app.throttles.asset import AssetRateThrottle class UserAssetsV2Endpoint(BaseAPIView): """This endpoint is used to upload user profile images.""" @@ -722,8 +722,7 @@ def get(self, request, slug, asset_id): class DuplicateAssetEndpoint(BaseAPIView): - throttle_classes = [ScopedRateThrottle] - throttle_scope = "image_duplicate" + throttle_classes = [AssetRateThrottle] def get_entity_id_field(self, entity_type, entity_id): # Workspace Logo @@ -759,17 +758,11 @@ def get_entity_id_field(self, entity_type, entity_id): return {} @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") - def post(self, request, slug): + def post(self, request, slug, asset_id): project_id = request.data.get("project_id", None) - asset_ids = request.data.get("asset_ids", None) entity_id = request.data.get("entity_id", None) entity_type = request.data.get("entity_type", None) - if not asset_ids: - return Response( - {"error": "asset_ids is required"}, status=status.HTTP_400_BAD_REQUEST - ) - workspace = Workspace.objects.get(slug=slug) if project_id: # check if project exists in the workspace @@ -777,38 +770,30 @@ def post(self, request, slug): return Response( {"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND ) - duplicated_assets = {} storage = S3Storage() - original_assets = FileAsset.objects.filter( - workspace=workspace, id__in=asset_ids + original_asset = FileAsset.objects.filter( + workspace=workspace, id=asset_id + ).first() + # for original_asset in original_assets: + 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), ) - for original_asset in original_assets: - 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) - duplicated_assets[str(original_asset.id)] = str(duplicated_asset.id) - - if duplicated_assets: - # Update the is_uploaded field for all newly created assets - FileAsset.objects.filter(id__in=duplicated_assets.values()).update( - is_uploaded=True - ) + storage.copy_object(original_asset.asset, destination_key) + # Update the is_uploaded field for all newly created assets + FileAsset.objects.filter(id=duplicated_asset.id).update(is_uploaded=True) - return Response(duplicated_assets, status=status.HTTP_200_OK) + return Response(duplicated_asset.id, status=status.HTTP_200_OK) diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 9e1f7d047a0..41a91a688b6 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -74,7 +74,7 @@ "DEFAULT_THROTTLE_CLASSES": ("rest_framework.throttling.AnonRateThrottle",), "DEFAULT_THROTTLE_RATES": { "anon": "30/minute", - "image_duplicate": "10/minute", + "asset_id": "5/minute", }, "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),