SLICE-7: images pipeline end-to-end — fetch/host/serve + INV-12 over images (SD-0002 §7.2) #30
@@ -68,3 +68,31 @@ def smtp_from() -> str:
|
|||||||
def smtp_starttls() -> bool:
|
def smtp_starttls() -> bool:
|
||||||
"""STARTTLS on the relay connection (default on; disable only for odd relays)."""
|
"""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"}
|
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
|
nh3>=0.2
|
||||||
python-multipart>=0.0.9
|
python-multipart>=0.0.9
|
||||||
Pillow>=11.0
|
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