From 70375dbc46b303793ec8635678b34b57216a4cf8 Mon Sep 17 00:00:00 2001 From: hugoesscala Date: Thu, 16 Apr 2026 11:13:37 -0600 Subject: [PATCH 1/2] feat(storage): support Cloudflare R2 presigned PUT uploads --- apps/api/plane/settings/storage.py | 26 ++++++++++++- .../plane/tests/unit/settings/test_storage.py | 39 +++++++++++++++++++ apps/web/core/services/file-upload.service.ts | 11 +++++- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/apps/api/plane/settings/storage.py b/apps/api/plane/settings/storage.py index e4a978bd2b1..0c080b9d0ee 100644 --- a/apps/api/plane/settings/storage.py +++ b/apps/api/plane/settings/storage.py @@ -63,9 +63,33 @@ 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, + }, + 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..bf1efa09d2c 100644 --- a/apps/api/plane/tests/unit/settings/test_storage.py +++ b/apps/api/plane/tests/unit/settings/test_storage.py @@ -116,6 +116,45 @@ 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" + + # 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..cd2f2855c4a 100644 --- a/apps/web/core/services/file-upload.service.ts +++ b/apps/web/core/services/file-upload.service.ts @@ -22,9 +22,16 @@ export class FileUploadService extends APIService { uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"] ): Promise { this.cancelSource = axios.CancelToken.source(); - return this.post(url, data, { + + 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; + 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, From 6d90a1f8173877c9c7cebbc73e770e1488b1cfc1 Mon Sep 17 00:00:00 2001 From: hugoesscala Date: Mon, 20 Apr 2026 15:36:38 -0600 Subject: [PATCH 2/2] fix(storage): enforce file size and validate binary blob for R2 uploads --- apps/api/plane/settings/storage.py | 1 + .../api/plane/tests/unit/settings/test_storage.py | 1 + apps/web/core/services/file-upload.service.ts | 15 ++++++++++----- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/api/plane/settings/storage.py b/apps/api/plane/settings/storage.py index 0c080b9d0ee..97b74028465 100644 --- a/apps/api/plane/settings/storage.py +++ b/apps/api/plane/settings/storage.py @@ -76,6 +76,7 @@ def generate_presigned_post(self, object_name, file_type, file_size, expiration= "Bucket": self.aws_storage_bucket_name, "Key": object_name, "ContentType": file_type, + "ContentLength": file_size, }, ExpiresIn=expiration, ) diff --git a/apps/api/plane/tests/unit/settings/test_storage.py b/apps/api/plane/tests/unit/settings/test_storage.py index bf1efa09d2c..a6ce6a18cc7 100644 --- a/apps/api/plane/tests/unit/settings/test_storage.py +++ b/apps/api/plane/tests/unit/settings/test_storage.py @@ -148,6 +148,7 @@ def test_generate_presigned_post_with_cloudflare_r2(self, mock_boto3): 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" diff --git a/apps/web/core/services/file-upload.service.ts b/apps/web/core/services/file-upload.service.ts index cd2f2855c4a..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,13 +21,18 @@ export class FileUploadService extends APIService { data: FormData, uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"] ): Promise { - this.cancelSource = axios.CancelToken.source(); - + 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; - const contentType = isPut ? (data.get("Content-Type") || "application/octet-stream") : "multipart/form-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: { @@ -39,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;