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: