diff --git a/src/app/core/config.py b/src/app/core/config.py index 077f4779..3812f318 100644 --- a/src/app/core/config.py +++ b/src/app/core/config.py @@ -68,6 +68,11 @@ def sqlachmey_uri(cls, v: str) -> str: S3_ENDPOINT_URL: str = os.environ["S3_ENDPOINT_URL"] S3_PROXY_URL: str = os.environ.get("S3_PROXY_URL", "") S3_URL_EXPIRATION: int = int(os.environ.get("S3_URL_EXPIRATION") or 24 * 3600) + # Comma-separated browser origins allowed to fetch bucket objects cross-origin (the frontend + # platform URLs). Applied as a CORS policy at bucket creation so the frontend can fetch() + # presigned image URLs (e.g. the "download all" buttons). Deployments MUST set this to the + # real frontend origins; the localhost default only serves local dev. + S3_CORS_ORIGINS: str = os.environ.get("S3_CORS_ORIGINS", "http://localhost:5173") # Sequence handling: three windows gate sequence behaviour, each on its own timescale. # All three are tuned against the cameras' frame interval (time between two frames of the diff --git a/src/app/services/storage.py b/src/app/services/storage.py index 45bc746e..2d6b4f1e 100644 --- a/src/app/services/storage.py +++ b/src/app/services/storage.py @@ -143,11 +143,35 @@ def create_bucket(self, bucket_name: str) -> bool: else {"CreateBucketConfiguration": {"LocationConstraint": self._s3.meta.region_name}} ) self._s3.create_bucket(Bucket=bucket_name, **config_) + self._put_bucket_cors(bucket_name) return True except ClientError as e: logger.warning(e) return False + def _put_bucket_cors(self, bucket_name: str) -> None: + """Apply the CORS policy so browsers can fetch() presigned URLs cross-origin. + + Allows the frontend origins (settings.S3_CORS_ORIGINS) to GET bucket objects, which the + platform's "download all" buttons rely on (native fetch triggers CORS, unlike a plain + or ). + """ + origins = [origin.strip() for origin in settings.S3_CORS_ORIGINS.split(",") if origin.strip()] + self._s3.put_bucket_cors( + Bucket=bucket_name, + CORSConfiguration={ + "CORSRules": [ + { + "AllowedOrigins": origins, + "AllowedMethods": ["GET", "HEAD"], + "AllowedHeaders": ["*"], + "ExposeHeaders": ["Content-Length", "Content-Type"], + "MaxAgeSeconds": 3000, + } + ] + }, + ) + def get_bucket(self, bucket_name: str) -> S3Bucket: """Get an existing bucket in S3 storage""" return S3Bucket(self._s3, bucket_name, self.proxy_url) diff --git a/src/tests/services/test_storage.py b/src/tests/services/test_storage.py index 04c0f7fc..ca8aa131 100644 --- a/src/tests/services/test_storage.py +++ b/src/tests/services/test_storage.py @@ -53,6 +53,12 @@ async def test_s3_service(region, endpoint_url, access_key, secret_key, proxy_ur # Create random bucket bucket_name = "dummy-bucket" service.create_bucket(bucket_name) + # The CORS policy is applied at creation so the frontend can fetch() presigned URLs + cors_rules = service._s3.get_bucket_cors(Bucket=bucket_name)["CORSRules"] + assert cors_rules[0]["AllowedMethods"] == ["GET", "HEAD"] + assert cors_rules[0]["AllowedOrigins"] == [ + origin.strip() for origin in settings.S3_CORS_ORIGINS.split(",") if origin.strip() + ] # Delete the bucket await service.delete_bucket(bucket_name) else: