| 1 | """Pluggable blob storage. Local filesystem for dev; S3-compatible for production. |
| 2 | Both back ends share one interface so evidence files and raw import blobs are stored |
| 3 | identically regardless of environment. Selected via SIGHTLINE_STORAGE_BACKEND.""" |
| 4 | from __future__ import annotations |
| 5 | |
| 6 | import os |
| 7 | import shutil |
| 8 | from abc import ABC, abstractmethod |
| 9 | from typing import BinaryIO |
| 10 | |
| 11 | from app.core.config import settings |
| 12 | |
| 13 | |
| 14 | class StorageBackend(ABC): |
| 15 | @abstractmethod |
| 16 | def put(self, key: str, data: bytes) -> str: ... |
| 17 | @abstractmethod |
| 18 | def get(self, ref: str) -> bytes: ... |
| 19 | @abstractmethod |
| 20 | def open_stream(self, ref: str) -> BinaryIO: ... |
| 21 | @abstractmethod |
| 22 | def delete(self, ref: str) -> None: ... |
| 23 | |
| 24 | |
| 25 | class LocalStorage(StorageBackend): |
| 26 | """Stores under settings.storage_dir. The returned ref is the absolute path.""" |
| 27 | |
| 28 | def __init__(self, base_dir: str): |
| 29 | self.base = os.path.abspath(base_dir) |
| 30 | os.makedirs(self.base, exist_ok=True) |
| 31 | |
| 32 | def _safe_path(self, key: str) -> str: |
| 33 | clean = key.lstrip("/").replace("..", "_") |
| 34 | path = os.path.join(self.base, clean) |
| 35 | os.makedirs(os.path.dirname(path), exist_ok=True) |
| 36 | return path |
| 37 | |
| 38 | def put(self, key: str, data: bytes) -> str: |
| 39 | path = self._safe_path(key) |
| 40 | with open(path, "wb") as fh: |
| 41 | fh.write(data) |
| 42 | return path |
| 43 | |
| 44 | def get(self, ref: str) -> bytes: |
| 45 | with open(ref, "rb") as fh: |
| 46 | return fh.read() |
| 47 | |
| 48 | def open_stream(self, ref: str) -> BinaryIO: |
| 49 | return open(ref, "rb") |
| 50 | |
| 51 | def delete(self, ref: str) -> None: |
| 52 | try: |
| 53 | os.remove(ref) |
| 54 | except FileNotFoundError: |
| 55 | pass |
| 56 | |
| 57 | |
| 58 | class S3Storage(StorageBackend): |
| 59 | """S3 / S3-compatible (MinIO, R2, Wasabi). Ref format: s3://bucket/key. |
| 60 | boto3 is imported lazily so dev installs don't need it.""" |
| 61 | |
| 62 | def __init__(self, bucket: str, endpoint_url: str | None = None, region: str | None = None): |
| 63 | import boto3 |
| 64 | self.bucket = bucket |
| 65 | self._s3 = boto3.client("s3", endpoint_url=endpoint_url, region_name=region) |
| 66 | |
| 67 | def put(self, key: str, data: bytes) -> str: |
| 68 | clean = key.lstrip("/") |
| 69 | self._s3.put_object(Bucket=self.bucket, Key=clean, Body=data) |
| 70 | return f"s3://{self.bucket}/{clean}" |
| 71 | |
| 72 | def _parse(self, ref: str) -> tuple[str, str]: |
| 73 | assert ref.startswith("s3://") |
| 74 | bucket, _, key = ref[5:].partition("/") |
| 75 | return bucket, key |
| 76 | |
| 77 | def get(self, ref: str) -> bytes: |
| 78 | bucket, key = self._parse(ref) |
| 79 | return self._s3.get_object(Bucket=bucket, Key=key)["Body"].read() |
| 80 | |
| 81 | def open_stream(self, ref: str) -> BinaryIO: |
| 82 | bucket, key = self._parse(ref) |
| 83 | return self._s3.get_object(Bucket=bucket, Key=key)["Body"] |
| 84 | |
| 85 | def delete(self, ref: str) -> None: |
| 86 | bucket, key = self._parse(ref) |
| 87 | self._s3.delete_object(Bucket=bucket, Key=key) |
| 88 | |
| 89 | |
| 90 | _backend: StorageBackend | None = None |
| 91 | |
| 92 | |
| 93 | def get_storage() -> StorageBackend: |
| 94 | global _backend |
| 95 | if _backend is None: |
| 96 | if settings.storage_backend == "s3": |
| 97 | _backend = S3Storage(settings.s3_bucket, settings.s3_endpoint_url, settings.s3_region) |
| 98 | else: |
| 99 | _backend = LocalStorage(settings.storage_dir) |
| 100 | return _backend |