feat(platform): objectstore port — local + GCS adapters, config (SD-0002 §6.2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -8,3 +8,4 @@ import-linter>=2.0
|
||||
nh3>=0.2
|
||||
python-multipart>=0.0.9
|
||||
Pillow>=11.0
|
||||
google-cloud-storage>=2.18
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user