4e3a4b4602
带 Web 操作界面的备份系统,支持: - 数据源:MySQL 数据库(mysqldump)、服务器目录(tar.gz) - 存储目标:本地目录、S3 兼容对象存储(MinIO/Ceph/AWS S3) - 触发方式:手动 + Cron 定时调度(APScheduler) - 备份还原:MySQL 库、目录 - JWT 登录认证 + Fernet 字段加密 - 保留策略:按数量 + 按天数双重清理 技术栈:FastAPI + SQLAlchemy 2 + APScheduler + aioboto3; 前端 Vite + React 18 + TypeScript + Ant Design 5 + Zustand。 Docker Compose 一键起。 Co-Authored-By: Claude <noreply@anthropic.com>
99 lines
3.1 KiB
Python
99 lines
3.1 KiB
Python
"""本地文件系统存储。"""
|
|
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
|