"""本地文件系统存储。""" import asyncio import hashlib import os import shutil from datetime import datetime from pathlib import Path from typing import AsyncIterator import aiofiles from app.core.storage.base import BaseStorage, StorageObject from app.utils.logging import get_logger log = get_logger(__name__) class LocalStorage(BaseStorage): type = "local" def __init__(self, config: dict): super().__init__(config) self.root: str = os.path.abspath(config["path"]) Path(self.root).mkdir(parents=True, exist_ok=True) def _resolve(self, key: str) -> Path: # 防 path traversal:禁止 .. if ".." in key.split("/"): raise ValueError(f"Invalid key (path traversal): {key}") return Path(self.root) / key async def test_connection(self) -> tuple[bool, str]: try: Path(self.root).mkdir(parents=True, exist_ok=True) test_file = Path(self.root) / ".backup_system_test" async with aiofiles.open(test_file, "w") as f: await f.write("ok") # aiofiles 不暴露 os 命名空间,用线程池跑同步删除 await asyncio.to_thread(os.remove, test_file) return True, f"OK: writable {self.root}" except Exception as e: return False, f"FAIL: {e}" async def upload_stream( self, source: AsyncIterator[bytes], key: str, content_type: str = "application/octet-stream", ) -> tuple[int, str]: path = self._resolve(key) path.parent.mkdir(parents=True, exist_ok=True) sha = hashlib.sha256() size = 0 async with aiofiles.open(path, "wb") as f: async for chunk in source: await f.write(chunk) sha.update(chunk) size += len(chunk) return size, sha.hexdigest() async def download_stream(self, key: str) -> AsyncIterator[bytes]: path = self._resolve(key) if not path.exists(): raise FileNotFoundError(f"Not found: {key}") async with aiofiles.open(path, "rb") as f: while True: chunk = await f.read(64 * 1024) if not chunk: break yield chunk async def delete(self, key: str) -> None: path = self._resolve(key) try: path.unlink() except FileNotFoundError: pass async def exists(self, key: str) -> bool: return self._resolve(key).exists() async def list(self, prefix: str = "") -> list[StorageObject]: base = Path(self.root) / prefix if prefix else Path(self.root) if not base.exists(): return [] out: list[StorageObject] = [] for p in base.rglob("*"): if p.is_file(): rel = p.relative_to(Path(self.root)).as_posix() st = p.stat() out.append( StorageObject( key=rel, size=st.st_size, last_modified=datetime.fromtimestamp(st.st_mtime).isoformat(), ) ) return out