diff --git a/surfsense_backend/app/file_storage/backends/azure.py b/surfsense_backend/app/file_storage/backends/azure.py new file mode 100644 index 000000000..ec59525e3 --- /dev/null +++ b/surfsense_backend/app/file_storage/backends/azure.py @@ -0,0 +1,54 @@ +"""Azure Blob Storage backend (the first production target).""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + +from app.file_storage.backends.base import StorageBackend + + +class AzureBlobBackend(StorageBackend): + """Stores objects as blobs in an Azure Blob Storage container.""" + + backend_name = "azure" + + def __init__(self, *, connection_string: str, container: str) -> None: + self._connection_string = connection_string + self._container = container + + def _service(self): + from azure.storage.blob.aio import BlobServiceClient + + return BlobServiceClient.from_connection_string(self._connection_string) + + async def put( + self, key: str, data: bytes, *, content_type: str | None = None + ) -> None: + from azure.storage.blob import ContentSettings + + settings = ContentSettings(content_type=content_type) if content_type else None + async with self._service() as service: + blob = service.get_blob_client(self._container, key) + await blob.upload_blob(data, overwrite=True, content_settings=settings) + + async def open_stream(self, key: str) -> AsyncIterator[bytes]: + async with self._service() as service: + blob = service.get_blob_client(self._container, key) + downloader = await blob.download_blob() + async for chunk in downloader.chunks(): + yield chunk + + async def delete(self, key: str) -> None: + from azure.core.exceptions import ResourceNotFoundError + + async with self._service() as service: + blob = service.get_blob_client(self._container, key) + try: + await blob.delete_blob() + except ResourceNotFoundError: + pass + + async def exists(self, key: str) -> bool: + async with self._service() as service: + blob = service.get_blob_client(self._container, key) + return await blob.exists() diff --git a/surfsense_backend/app/file_storage/backends/local.py b/surfsense_backend/app/file_storage/backends/local.py new file mode 100644 index 000000000..c55bd901a --- /dev/null +++ b/surfsense_backend/app/file_storage/backends/local.py @@ -0,0 +1,64 @@ +"""Local filesystem backend for development (no cloud credentials required).""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from pathlib import Path + +from app.file_storage.backends.base import StorageBackend + +_CHUNK_SIZE = 1024 * 1024 + + +class LocalFileBackend(StorageBackend): + """Stores objects as files under a single root directory.""" + + backend_name = "local" + + def __init__(self, root: str) -> None: + self._root = Path(root).resolve() + + def _path_for(self, key: str) -> Path: + # Resolve and confirm the key stays inside the root to block traversal. + target = (self._root / key).resolve() + if self._root not in target.parents and target != self._root: + raise ValueError("Resolved storage key escapes the storage root") + return target + + async def put( + self, key: str, data: bytes, *, content_type: str | None = None + ) -> None: + path = self._path_for(key) + + def _write() -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + + await asyncio.to_thread(_write) + + async def open_stream(self, key: str) -> AsyncIterator[bytes]: + path = self._path_for(key) + handle = await asyncio.to_thread(path.open, "rb") + try: + while True: + chunk = await asyncio.to_thread(handle.read, _CHUNK_SIZE) + if not chunk: + break + yield chunk + finally: + await asyncio.to_thread(handle.close) + + async def delete(self, key: str) -> None: + path = self._path_for(key) + + def _unlink() -> None: + try: + path.unlink() + except FileNotFoundError: + pass + + await asyncio.to_thread(_unlink) + + async def exists(self, key: str) -> bool: + return await asyncio.to_thread(self._path_for(key).exists)