PawTrace / backend /app /storage /backend.py
Elliott Duke
HomingPet: lost-dog reunification (FastAPI + React) with Render deploy
de1e3fc
Raw
History Blame Contribute Delete
2.57 kB
"""StorageBackend interface + Local FS default (spec §5, §16, A9).
The DB stores a relative ``key`` (path) returned by ``save``; an S3 adapter can drop in behind
the same interface by implementing these four methods.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from pathlib import Path
from ..config import settings
class StorageBackend(ABC):
@abstractmethod
def save(self, key: str, data: bytes) -> str:
"""Persist ``data`` under ``key``; return the stored relative key."""
@abstractmethod
def open(self, key: str) -> bytes:
...
@abstractmethod
def delete(self, key: str) -> None:
...
@abstractmethod
def abs_path(self, key: str) -> str | None:
"""Absolute filesystem path if the backend is local; else None."""
class LocalStorage(StorageBackend):
def __init__(self, root: Path | None = None):
self.root = (root or settings.media_path)
self.root.mkdir(parents=True, exist_ok=True)
def _full(self, key: str) -> Path:
# Prevent path traversal outside the media root.
full = (self.root / key).resolve()
if not str(full).startswith(str(self.root.resolve())):
raise ValueError(f"Refusing to access key outside media root: {key}")
return full
def save(self, key: str, data: bytes) -> str:
full = self._full(key)
full.parent.mkdir(parents=True, exist_ok=True)
full.write_bytes(data)
return key
def open(self, key: str) -> bytes:
return self._full(key).read_bytes()
def delete(self, key: str) -> None:
full = self._full(key)
if full.exists():
full.unlink()
def abs_path(self, key: str) -> str | None:
return str(self._full(key))
class S3Storage(StorageBackend): # pragma: no cover - stub for future (spec §5/A9)
"""Stub. Implement with boto3 when STORAGE_BACKEND=s3 is needed."""
def __init__(self, *_, **__):
raise NotImplementedError("S3 storage backend not implemented yet (see DECISIONS.md).")
def save(self, key: str, data: bytes) -> str: ... # noqa: D401
def open(self, key: str) -> bytes: ...
def delete(self, key: str) -> None: ...
def abs_path(self, key: str) -> str | None: ...
_backend: StorageBackend | None = None
def get_storage() -> StorageBackend:
global _backend
if _backend is None:
if settings.storage_backend == "s3":
_backend = S3Storage()
else:
_backend = LocalStorage()
return _backend