● Zion Boggan
repos/Sightline/backend/app/core/storage.py
zionboggan.com ↗
100 lines · python
History for this file →
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