diff --git a/apps/api/plane/settings/storage.py b/apps/api/plane/settings/storage.py index e4a978bd2b1..97b74028465 100644 --- a/apps/api/plane/settings/storage.py +++ b/apps/api/plane/settings/storage.py @@ -63,9 +63,34 @@ def __init__(self, request=None): ) def generate_presigned_post(self, object_name, file_type, file_size, expiration=None): - """Generate a presigned URL to upload an S3 object""" if expiration is None: expiration = self.signed_url_expiration + + # Check if we are using Cloudflare R2 (which doesn't support S3 Presigned POST nicely) + is_r2 = self.aws_s3_endpoint_url and "r2.cloudflarestorage" in self.aws_s3_endpoint_url + if is_r2: + try: + response_url = self.s3_client.generate_presigned_url( + ClientMethod="put_object", + Params={ + "Bucket": self.aws_storage_bucket_name, + "Key": object_name, + "ContentType": file_type, + "ContentLength": file_size, + }, + ExpiresIn=expiration, + ) + return { + "url": response_url, + "fields": { + "_method": "PUT", + "Content-Type": file_type + } + } + except ClientError as e: + print(f"Error generating presigned PUT URL for R2: {e}") + return None + fields = {"Content-Type": file_type} conditions = [ diff --git a/apps/api/plane/tests/unit/settings/test_storage.py b/apps/api/plane/tests/unit/settings/test_storage.py index 00856aeecb6..a6ce6a18cc7 100644 --- a/apps/api/plane/tests/unit/settings/test_storage.py +++ b/apps/api/plane/tests/unit/settings/test_storage.py @@ -116,6 +116,46 @@ def test_generate_presigned_post_uses_custom_expiration(self, mock_boto3): call_kwargs = mock_s3_client.generate_presigned_post.call_args[1] assert call_kwargs["ExpiresIn"] == 60 + @patch.dict( + os.environ, + { + "AWS_ACCESS_KEY_ID": "test-key", + "AWS_SECRET_ACCESS_KEY": "test-secret", + "AWS_S3_BUCKET_NAME": "test-bucket", + "AWS_REGION": "us-east-1", + "AWS_S3_ENDPOINT_URL": "https://test.r2.cloudflarestorage.com", + }, + clear=True, + ) + @patch("plane.settings.storage.boto3") + def test_generate_presigned_post_with_cloudflare_r2(self, mock_boto3): + """Test that Cloudflare R2 endpoints generate presigned PUT URLs instead of POST""" + # Mock the boto3 client and its response + mock_s3_client = Mock() + mock_s3_client.generate_presigned_url.return_value = "https://r2-test-url.com" + mock_boto3.client.return_value = mock_s3_client + + # Create S3Storage instance + storage = S3Storage() + + # Call generate_presigned_post + result = storage.generate_presigned_post("test-object", "image/png", 1024) + + # Assert that the boto3 method generate_presigned_url was called with put_object + mock_s3_client.generate_presigned_url.assert_called_once() + call_kwargs = mock_s3_client.generate_presigned_url.call_args[1] + assert call_kwargs["ClientMethod"] == "put_object" + assert call_kwargs["Params"]["Bucket"] == "test-bucket" + assert call_kwargs["Params"]["Key"] == "test-object" + assert call_kwargs["Params"]["ContentType"] == "image/png" + assert call_kwargs["Params"]["ContentLength"] == 1024 + + # Verify the returned object structure + assert result["url"] == "https://r2-test-url.com" + assert result["fields"]["_method"] == "PUT" + assert result["fields"]["Content-Type"] == "image/png" + + @patch.dict( os.environ, { diff --git a/apps/web/core/services/file-upload.service.ts b/apps/web/core/services/file-upload.service.ts index f8f49396799..32d791ec54f 100644 --- a/apps/web/core/services/file-upload.service.ts +++ b/apps/web/core/services/file-upload.service.ts @@ -5,7 +5,7 @@ */ import type { AxiosRequestConfig } from "axios"; -import axios from "axios"; +import { CancelToken, isCancel } from "axios"; // services import { APIService } from "@/services/api.service"; @@ -21,10 +21,22 @@ export class FileUploadService extends APIService { data: FormData, uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"] ): Promise { - this.cancelSource = axios.CancelToken.source(); - return this.post(url, data, { + this.cancelSource = CancelToken.source(); + + const isPut = data.has("_method") && data.get("_method") === "PUT"; + const requestMethod = isPut ? "put" : "post"; + // For PUT (Cloudflare R2), we send the raw File blob, not FormData. The helper appended it as 'file' + const requestData = isPut ? data.get("file") : data; + + if (isPut && !(requestData instanceof Blob)) { + return Promise.reject(new Error("Invalid or missing file data for upload.")); + } + + const contentType = isPut ? data.get("Content-Type") || "application/octet-stream" : "multipart/form-data"; + + return this[requestMethod](url, requestData, { headers: { - "Content-Type": "multipart/form-data", + "Content-Type": contentType as string, }, cancelToken: this.cancelSource.token, withCredentials: false, @@ -32,7 +44,7 @@ export class FileUploadService extends APIService { }) .then((response) => response?.data) .catch((error) => { - if (axios.isCancel(error)) { + if (isCancel(error)) { console.log(error.message); } else { throw error?.response?.data;