Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 18 additions & 2 deletions apps/api/plane/settings/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

# Third party imports
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
from urllib.parse import quote

Expand Down Expand Up @@ -36,6 +37,21 @@ def __init__(self, request=None):
# Use the SIGNED_URL_EXPIRATION environment variable for the expiration time (default: 3600 seconds)
self.signed_url_expiration = int(os.environ.get("SIGNED_URL_EXPIRATION", "3600"))

# S3 addressing style: 'auto', 'virtual', or 'path' (default: 'auto')
# virtual = virtual-hosted style (e.g., https://bucket.s3.amazonaws.com)
# path = path style (e.g., https://s3.amazonaws.com/bucket)
# auto = let botocore decide based on bucket name
addressing_style = os.environ.get("AWS_S3_ADDRESSING_STYLE", "auto").strip().lower()

# Create boto3 Config with addressing style if explicitly set
if addressing_style in ("virtual", "path"):
boto_config = Config(
signature_version="s3v4",
s3={"addressing_style": addressing_style},
)
else:
boto_config = Config(signature_version="s3v4")

if os.environ.get("USE_MINIO") == "1":
# Determine protocol based on environment variable
if os.environ.get("MINIO_ENDPOINT_SSL") == "1":
Expand All @@ -49,7 +65,7 @@ def __init__(self, request=None):
aws_secret_access_key=self.aws_secret_access_key,
region_name=self.aws_region,
endpoint_url=(f"{endpoint_protocol}://{request.get_host()}" if request else self.aws_s3_endpoint_url),
config=boto3.session.Config(signature_version="s3v4"),
config=boto_config,
)
else:
# Create an S3 client
Expand All @@ -59,7 +75,7 @@ def __init__(self, request=None):
aws_secret_access_key=self.aws_secret_access_key,
region_name=self.aws_region,
endpoint_url=self.aws_s3_endpoint_url,
config=boto3.session.Config(signature_version="s3v4"),
config=boto_config,
)

def generate_presigned_post(self, object_name, file_type, file_size, expiration=None):
Expand Down
94 changes: 94 additions & 0 deletions apps/api/plane/tests/unit/settings/test_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,97 @@ def test_explicit_expiration_overrides_default(self, mock_boto3):
mock_s3_client.generate_presigned_url.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_url.call_args[1]
assert call_kwargs["ExpiresIn"] == 120


@pytest.mark.unit
class TestS3StorageAddressingStyle:
"""Test the S3 addressing style configuration via AWS_S3_ADDRESSING_STYLE"""

@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://s3.amazonaws.com",
"AWS_S3_ADDRESSING_STYLE": "virtual",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_virtual_addressing_style(self, mock_boto3):
"""Test that virtual addressing style is configured via botocore Config"""
mock_boto3.client.return_value = Mock()

storage = S3Storage()

call_kwargs = mock_boto3.client.call_args[1]
assert call_kwargs["config"].s3["addressing_style"] == "virtual"

@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://s3.amazonaws.com",
"AWS_S3_ADDRESSING_STYLE": "path",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_path_addressing_style(self, mock_boto3):
"""Test that path addressing style is configured via botocore Config"""
mock_boto3.client.return_value = Mock()

storage = S3Storage()

call_kwargs = mock_boto3.client.call_args[1]
assert call_kwargs["config"].s3["addressing_style"] == "path"

@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://s3.amazonaws.com",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_auto_addressing_style_by_default(self, mock_boto3):
"""Test that auto addressing style is used by default (no s3 config in botocore Config)"""
mock_boto3.client.return_value = Mock()

storage = S3Storage()

call_kwargs = mock_boto3.client.call_args[1]
# When addressing_style is 'auto' or not set, botocore Config should not have s3 dict
assert "s3" not in call_kwargs["config"]._user_provided_options

@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://nyc3.digitaloceanspaces.com",
"AWS_S3_ADDRESSING_STYLE": "virtual",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_virtual_style_with_digitalocean_spaces(self, mock_boto3):
"""Test virtual addressing style works with DigitalOcean Spaces"""
mock_boto3.client.return_value = Mock()

storage = S3Storage()

call_kwargs = mock_boto3.client.call_args[1]
assert call_kwargs["config"].s3["addressing_style"] == "virtual"
assert call_kwargs["endpoint_url"] == "https://nyc3.digitaloceanspaces.com"