mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-04 20:05:16 +02:00
feat(file-storage): add local and azure blob backends
This commit is contained in:
parent
f3ebb14e46
commit
74fcad6496
2 changed files with 118 additions and 0 deletions
54
surfsense_backend/app/file_storage/backends/azure.py
Normal file
54
surfsense_backend/app/file_storage/backends/azure.py
Normal file
|
|
@ -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()
|
||||
64
surfsense_backend/app/file_storage/backends/local.py
Normal file
64
surfsense_backend/app/file_storage/backends/local.py
Normal file
|
|
@ -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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue