DocHub/backend/app/local_file_client.py
Tianyang 4c19fad561 refactor: replace Garage/S3 storage with local filesystem storage
Single-node deployment doesn't benefit from S3 abstraction overhead;
LocalFileClient keeps the same interface DocumentService depends on,
backed by a STORAGE_DIR volume mount instead of a separate Garage service.
2026-08-01 16:30:02 +08:00

77 lines
2.6 KiB
Python

import os
from pathlib import Path
from datetime import datetime, timezone
class LocalFileClient:
"""本地文件系统存储客户端"""
def __init__(self, base_dir: str):
self._base_dir = Path(base_dir).resolve()
self._base_dir.mkdir(parents=True, exist_ok=True)
def _resolve_path(self, key: str) -> Path:
"""将 key 转换为文件系统路径,确保不会逃出 base_dir"""
path = (self._base_dir / key).resolve()
if not path.is_relative_to(self._base_dir):
raise ValueError(f"Path traversal attempt: {key}")
return path
def list_objects(self, prefix: str) -> list[dict]:
"""列出指定前缀下的所有对象(文件和目录)"""
prefix_path = self._base_dir / prefix if prefix else self._base_dir
if not prefix_path.exists():
return []
result = []
try:
for entry in prefix_path.iterdir():
rel_path = entry.relative_to(self._base_dir)
key = str(rel_path).replace(os.sep, "/")
if entry.is_dir():
result.append({
"key": key + "/",
"size": None,
"is_dir": True,
"modifiedAt": None,
})
else:
stat = entry.stat()
result.append({
"key": key,
"size": stat.st_size,
"is_dir": False,
"modifiedAt": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
})
except (PermissionError, OSError):
pass
return result
def get_object(self, key: str) -> bytes:
path = self._resolve_path(key)
if not path.exists() or not path.is_file():
raise FileNotFoundError(key)
return path.read_bytes()
def put_object(self, key: str, data: bytes, content_type: str) -> None:
path = self._resolve_path(key)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
def delete_object(self, key: str) -> None:
path = self._resolve_path(key)
if not path.exists():
raise FileNotFoundError(key)
path.unlink()
# 清理空目录(逐层向上)
parent = path.parent
while parent != self._base_dir:
if not any(parent.iterdir()):
parent.rmdir()
parent = parent.parent
else:
break