diff --git a/backend/app/platform/config.py b/backend/app/platform/config.py index 70251e7..c44b98c 100644 --- a/backend/app/platform/config.py +++ b/backend/app/platform/config.py @@ -68,3 +68,31 @@ def smtp_from() -> str: def smtp_starttls() -> bool: """STARTTLS on the relay connection (default on; disable only for odd relays).""" return os.environ.get("ECOMM_SMTP_STARTTLS", "1").strip().lower() not in {"0", "false", "no", "off"} + + +def objectstore_kind() -> str: + """Which objectstore adapter to build: 'local' (dev/tests) or 'gcs' (deployed).""" + return os.environ.get("ECOMM_OBJECTSTORE_KIND") or "local" + + +def objectstore_bucket() -> str: + """The GCS bucket name for the media objects (gcs adapter; deployment overlay).""" + return os.environ.get("ECOMM_OBJECTSTORE_BUCKET", "") + + +def objectstore_local_dir() -> str: + """Base directory for the local-disk objectstore adapter (dev/tests).""" + return os.environ.get("ECOMM_OBJECTSTORE_DIR") or "/tmp/ecomm-objectstore" + + +def public_base_url() -> str: + """Absolute origin for hosted image URLs in export (e.g. APP_URL); '' → relative.""" + return (os.environ.get("APP_URL") or "").rstrip("/") + + +def image_fetch_allow_private() -> bool: + """Allow image fetch from private/loopback hosts (dev/E2E fixture host only). + Default off — the SSRF guard rejects private ranges in deployed envs.""" + return os.environ.get("ECOMM_IMAGE_FETCH_ALLOW_PRIVATE", "").strip().lower() in { + "1", "true", "yes", "on", + } diff --git a/backend/app/platform/objectstore.py b/backend/app/platform/objectstore.py new file mode 100644 index 0000000..2a7ef6f --- /dev/null +++ b/backend/app/platform/objectstore.py @@ -0,0 +1,103 @@ +"""platform/objectstore — the media-blob port (SD-0002 §6.2, §6.3.1). + +A tiny put/get/delete port with two adapters, mirroring the SD-0001 mailer +pattern: local-disk (dev/tests) and GCS (deployed). Owns no semantics — keys +and content types come from the caller (the image-fetch phase). Objects are +written once, never mutated (immutable, image-id-addressed). +""" +from __future__ import annotations + +import os +from pathlib import Path +from typing import Protocol + +from app.platform import config + + +class ObjectNotFound(Exception): + """Raised by get() when the key has no object.""" + + +class ObjectStore(Protocol): + def put(self, key: str, data: bytes, content_type: str) -> None: ... + def get(self, key: str) -> bytes: ... + def delete(self, key: str) -> None: ... + + +def build_objectstore(kind: str) -> ObjectStore: + """Select the objectstore adapter by configured kind (config.objectstore_kind, INV-8).""" + if kind == "local": + return LocalObjectStore(config.objectstore_local_dir()) + if kind == "gcs": + return GcsObjectStore(config.objectstore_bucket()) + raise ValueError(f"unknown objectstore kind: {kind!r}") + + +def _safe_segments(key: str) -> tuple[str, ...]: + parts = tuple(p for p in key.split("/") if p) + if not parts or any(p in {".", ".."} for p in parts): + raise ValueError(f"unsafe objectstore key: {key!r}") + return parts + + +class LocalObjectStore: + """Disk-backed adapter under a base dir; key path-segments become subdirs.""" + + def __init__(self, base_dir: str) -> None: + self._base = Path(base_dir) + + def _path(self, key: str) -> Path: + return self._base.joinpath(*_safe_segments(key)) + + def put(self, key: str, data: bytes, content_type: str) -> None: + path = self._path(key) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + + def get(self, key: str) -> bytes: + path = self._path(key) + try: + return path.read_bytes() + except FileNotFoundError as exc: + raise ObjectNotFound(key) from exc + + def delete(self, key: str) -> None: + try: + os.remove(self._path(key)) + except FileNotFoundError: + pass + + +class GcsObjectStore: + """GCS adapter; auth via the VM service account ADC (no secret bytes). + + google-cloud-storage is imported lazily so dev/test runs (local adapter) + don't require the package at import time. + """ + + def __init__(self, bucket_name: str) -> None: + if not bucket_name: + raise ValueError("gcs objectstore requires ECOMM_OBJECTSTORE_BUCKET") + from google.cloud import storage # lazy + + self._bucket = storage.Client().bucket(bucket_name) + + def put(self, key: str, data: bytes, content_type: str) -> None: + _safe_segments(key) + self._bucket.blob(key).upload_from_string(data, content_type=content_type) + + def get(self, key: str) -> bytes: + from google.cloud.exceptions import NotFound # lazy + + try: + return self._bucket.blob(key).download_as_bytes() + except NotFound as exc: + raise ObjectNotFound(key) from exc + + def delete(self, key: str) -> None: + from google.cloud.exceptions import NotFound # lazy + + try: + self._bucket.blob(key).delete() + except NotFound: + pass diff --git a/backend/requirements.txt b/backend/requirements.txt index e0cc306..8aaa1da 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,3 +8,4 @@ import-linter>=2.0 nh3>=0.2 python-multipart>=0.0.9 Pillow>=11.0 +google-cloud-storage>=2.18 diff --git a/backend/tests/test_platform_objectstore.py b/backend/tests/test_platform_objectstore.py new file mode 100644 index 0000000..0968570 --- /dev/null +++ b/backend/tests/test_platform_objectstore.py @@ -0,0 +1,44 @@ +"""platform/objectstore — local-disk adapter round-trip (SD-0002 §6.2/§6.3.1).""" +import pytest + +from app.platform import objectstore + + +def test_local_put_get_round_trip(tmp_path): + store = objectstore.LocalObjectStore(str(tmp_path)) + key = "storefronts/1/product-images/9/original" + store.put(key, b"\x89PNG-bytes", "image/png") + assert store.get(key) == b"\x89PNG-bytes" + + +def test_local_get_missing_raises_not_found(tmp_path): + store = objectstore.LocalObjectStore(str(tmp_path)) + with pytest.raises(objectstore.ObjectNotFound): + store.get("nope/missing") + + +def test_local_delete_is_idempotent(tmp_path): + store = objectstore.LocalObjectStore(str(tmp_path)) + store.put("a/b", b"x", "text/plain") + store.delete("a/b") + store.delete("a/b") # no error second time + with pytest.raises(objectstore.ObjectNotFound): + store.get("a/b") + + +def test_local_keys_cannot_escape_base_dir(tmp_path): + store = objectstore.LocalObjectStore(str(tmp_path)) + with pytest.raises(ValueError): + store.put("../escape", b"x", "text/plain") + + +def test_build_objectstore_local(tmp_path, monkeypatch): + monkeypatch.setenv("ECOMM_OBJECTSTORE_KIND", "local") + monkeypatch.setenv("ECOMM_OBJECTSTORE_DIR", str(tmp_path)) + store = objectstore.build_objectstore("local") + assert isinstance(store, objectstore.LocalObjectStore) + + +def test_build_objectstore_unknown_kind_raises(): + with pytest.raises(ValueError): + objectstore.build_objectstore("s3")