From db82f9e3906acd1cc842583ba1c934d7b10e4dfa Mon Sep 17 00:00:00 2001 From: Jimmy <2489219080@qq.com> Date: Sun, 3 May 2026 19:48:43 +0800 Subject: [PATCH 1/4] feat: add cf r2 backup --- Dockerfile | 1 + api/app.py | 3 + api/system.py | 72 ++ services/backup_service.py | 657 ++++++++++++++++++ services/config.py | 87 +++ .../components/backup-settings-card.tsx | 430 ++++++++++++ web/src/app/settings/page.tsx | 14 + web/src/app/settings/store.ts | 211 +++++- web/src/lib/api.ts | 104 +++ 9 files changed, 1576 insertions(+), 3 deletions(-) create mode 100644 services/backup_service.py create mode 100644 web/src/app/settings/components/backup-settings-card.tsx diff --git a/Dockerfile b/Dockerfile index 254cecd71..5a608d0c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,6 +33,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ git \ libpq-dev \ gcc \ + openssl \ && rm -rf /var/lib/apt/lists/* RUN pip install --no-cache-dir uv diff --git a/api/app.py b/api/app.py index 8596c8ae5..2d10a8e1d 100644 --- a/api/app.py +++ b/api/app.py @@ -10,6 +10,7 @@ from api import accounts, ai, image_tasks, register, system from api.support import resolve_web_asset, start_limited_account_watcher +from services.backup_service import backup_service from services.config import config @@ -20,12 +21,14 @@ def create_app() -> FastAPI: async def lifespan(_: FastAPI): stop_event = Event() thread = start_limited_account_watcher(stop_event) + backup_service.start() config.cleanup_old_images() try: yield finally: stop_event.set() thread.join(timeout=1) + backup_service.stop() app = FastAPI(title="chatgpt2api", version=app_version, lifespan=lifespan) app.add_middleware( diff --git a/api/system.py b/api/system.py index 74d0cba10..aa44f5282 100644 --- a/api/system.py +++ b/api/system.py @@ -1,10 +1,14 @@ from __future__ import annotations +from urllib.parse import quote + from fastapi import APIRouter, Header, HTTPException, Request from fastapi.concurrency import run_in_threadpool +from fastapi.responses import Response from pydantic import BaseModel, ConfigDict from api.support import require_admin, require_identity, resolve_image_base_url +from services.backup_service import BackupError, backup_service from services.config import config from services.image_service import delete_images, get_thumbnail_response, list_images from services.log_service import log_service @@ -26,6 +30,10 @@ class ImageDeleteRequest(BaseModel): all_matching: bool = False +class BackupDeleteRequest(BaseModel): + key: str = "" + + def create_router(app_version: str) -> APIRouter: router = APIRouter() @@ -90,4 +98,68 @@ async def get_storage_info(authorization: str | None = Header(default=None)): "health": storage.health_check(), } + @router.post("/api/backup/test") + async def test_backup_connection(authorization: str | None = Header(default=None)): + require_admin(authorization) + try: + return {"result": await run_in_threadpool(backup_service.test_connection)} + except BackupError as exc: + raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc + + @router.get("/api/backups") + async def get_backups(authorization: str | None = Header(default=None)): + require_admin(authorization) + try: + return { + "items": await run_in_threadpool(backup_service.list_backups), + "state": backup_service.get_status(), + "settings": backup_service.get_settings(), + } + except BackupError as exc: + raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc + + @router.post("/api/backups/run") + async def run_backup_endpoint(authorization: str | None = Header(default=None)): + require_admin(authorization) + try: + return {"result": await run_in_threadpool(backup_service.run_backup)} + except BackupError as exc: + raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc + + @router.post("/api/backups/delete") + async def delete_backup_endpoint(body: BackupDeleteRequest, authorization: str | None = Header(default=None)): + require_admin(authorization) + try: + await run_in_threadpool(backup_service.delete_backup, body.key) + return {"ok": True} + except BackupError as exc: + raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc + + @router.get("/api/backups/detail") + async def get_backup_detail(key: str = "", authorization: str | None = Header(default=None)): + require_admin(authorization) + try: + return {"item": await run_in_threadpool(backup_service.get_backup_detail, key)} + except BackupError as exc: + raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc + + @router.get("/api/backups/download") + async def download_backup_endpoint(key: str = "", authorization: str | None = Header(default=None)): + require_admin(authorization) + try: + item = await run_in_threadpool(backup_service.download_backup, key) + except BackupError as exc: + raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc + filename = str(item.get("name") or "backup.bin") + quoted = quote(filename) + headers = { + "Content-Disposition": f"attachment; filename*=UTF-8''{quoted}", + "Content-Length": str(int(item.get("size") or 0)), + } + return Response( + content=bytes(item.get("payload") or b""), + media_type=str(item.get("content_type") or "application/octet-stream"), + headers=headers, + ) + return router diff --git a/services/backup_service.py b/services/backup_service.py new file mode 100644 index 000000000..4cf91c884 --- /dev/null +++ b/services/backup_service.py @@ -0,0 +1,657 @@ +from __future__ import annotations + +import hashlib +import hmac +import io +import json +import os +import random +import subprocess +import tarfile +import threading +from datetime import UTC, datetime +from pathlib import Path +from urllib.parse import quote, urlencode + +from curl_cffi import requests + +from services.config import BASE_DIR, CONFIG_FILE, DATA_DIR, config, load_backup_state, save_backup_state + + +def _utc_now() -> datetime: + return datetime.now(UTC) + + +def _iso_now() -> str: + return _utc_now().replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _clean(value: object) -> str: + return str(value or "").strip() + + +def _sha256_hex(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _hmac_sha256(key: bytes, message: str) -> bytes: + return hmac.new(key, message.encode("utf-8"), hashlib.sha256).digest() + + +def _openssl_encrypt(data: bytes, passphrase: str) -> bytes: + env = dict(os.environ) + env["CHATGPT2API_BACKUP_PASSPHRASE"] = passphrase + try: + result = subprocess.run( + [ + "openssl", + "enc", + "-aes-256-cbc", + "-pbkdf2", + "-salt", + "-md", + "sha256", + "-pass", + "env:CHATGPT2API_BACKUP_PASSPHRASE", + ], + input=data, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + env=env, + ) + except FileNotFoundError as exc: + raise BackupError("当前环境缺少 openssl,无法执行加密备份") from exc + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or b"").decode("utf-8", errors="replace").strip() + raise BackupError(f"加密备份失败:{detail or 'openssl 执行失败'}") from exc + return result.stdout + + +def _openssl_decrypt(data: bytes, passphrase: str) -> bytes: + env = dict(os.environ) + env["CHATGPT2API_BACKUP_PASSPHRASE"] = passphrase + try: + result = subprocess.run( + [ + "openssl", + "enc", + "-d", + "-aes-256-cbc", + "-pbkdf2", + "-md", + "sha256", + "-pass", + "env:CHATGPT2API_BACKUP_PASSPHRASE", + ], + input=data, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + env=env, + ) + except FileNotFoundError as exc: + raise BackupError("当前环境缺少 openssl,无法解密备份内容") from exc + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or b"").decode("utf-8", errors="replace").strip() + raise BackupError(f"解密备份失败:{detail or 'openssl 执行失败'}") from exc + return result.stdout + + +def _guess_content_type(name: str) -> str: + if name.endswith(".json"): + return "application/json" + if name.endswith(".jsonl"): + return "application/x-ndjson" + if name.endswith(".tar.gz"): + return "application/gzip" + if name.endswith(".gz"): + return "application/gzip" + return "application/octet-stream" + + +def _json_bytes(value: object) -> bytes: + return json.dumps(value, ensure_ascii=False, indent=2).encode("utf-8") + + +def _count_items(value: object) -> int: + if isinstance(value, list): + return len(value) + if isinstance(value, dict): + return len(value) + return 0 + + +class BackupError(RuntimeError): + pass + + +class CloudflareR2Client: + def __init__(self, settings: dict[str, object]) -> None: + self.account_id = _clean(settings.get("account_id")) + self.access_key_id = _clean(settings.get("access_key_id")) + self.secret_access_key = _clean(settings.get("secret_access_key")) + self.bucket = _clean(settings.get("bucket")) + self.prefix = _clean(settings.get("prefix")) or "backups" + self.session = requests.Session(impersonate="chrome", verify=True) + + def validate(self) -> None: + missing = [] + if not self.account_id: + missing.append("Account ID") + if not self.access_key_id: + missing.append("Access Key ID") + if not self.secret_access_key: + missing.append("Secret Access Key") + if not self.bucket: + missing.append("Bucket") + if missing: + raise BackupError(f"R2 配置不完整:缺少 {'、'.join(missing)}") + + @property + def endpoint(self) -> str: + return f"https://{self.account_id}.r2.cloudflarestorage.com" + + def _aws_v4_headers( + self, + method: str, + path: str, + *, + query: dict[str, str] | None = None, + body: bytes = b"", + extra_headers: dict[str, str] | None = None, + ) -> tuple[str, dict[str, str]]: + now = _utc_now() + amz_date = now.strftime("%Y%m%dT%H%M%SZ") + date_stamp = now.strftime("%Y%m%d") + encoded_query = urlencode(sorted((query or {}).items())) + payload_hash = _sha256_hex(body) + host = f"{self.account_id}.r2.cloudflarestorage.com" + headers = { + "host": host, + "x-amz-content-sha256": payload_hash, + "x-amz-date": amz_date, + } + if extra_headers: + for key, value in extra_headers.items(): + headers[key.lower()] = value.strip() + sorted_items = sorted((key.lower(), " ".join(str(value).strip().split())) for key, value in headers.items()) + canonical_headers = "".join(f"{key}:{value}\n" for key, value in sorted_items) + signed_headers = ";".join(key for key, _ in sorted_items) + canonical_request = "\n".join([ + method.upper(), + path, + encoded_query, + canonical_headers, + signed_headers, + payload_hash, + ]) + credential_scope = f"{date_stamp}/auto/s3/aws4_request" + string_to_sign = "\n".join([ + "AWS4-HMAC-SHA256", + amz_date, + credential_scope, + _sha256_hex(canonical_request.encode("utf-8")), + ]) + k_date = _hmac_sha256(("AWS4" + self.secret_access_key).encode("utf-8"), date_stamp) + k_region = hmac.new(k_date, b"auto", hashlib.sha256).digest() + k_service = hmac.new(k_region, b"s3", hashlib.sha256).digest() + k_signing = hmac.new(k_service, b"aws4_request", hashlib.sha256).digest() + signature = hmac.new(k_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest() + authorization = ( + "AWS4-HMAC-SHA256 " + f"Credential={self.access_key_id}/{credential_scope}, " + f"SignedHeaders={signed_headers}, " + f"Signature={signature}" + ) + request_headers = {key: value for key, value in headers.items()} + request_headers["authorization"] = authorization + return encoded_query, request_headers + + def _request( + self, + method: str, + key: str = "", + *, + query: dict[str, str] | None = None, + body: bytes = b"", + extra_headers: dict[str, str] | None = None, + timeout: float = 60.0, + ): + object_path = f"/{self.bucket}" + if key: + object_path += f"/{quote(key.lstrip('/'), safe='/')}" + encoded_query, headers = self._aws_v4_headers(method, object_path, query=query, body=body, extra_headers=extra_headers) + url = f"{self.endpoint}{object_path}" + if encoded_query: + url += f"?{encoded_query}" + response = self.session.request(method.upper(), url, headers=headers, data=body, timeout=timeout) + return response + + def test_connection(self) -> dict[str, object]: + self.validate() + response = self._request("GET", query={"list-type": "2", "max-keys": "1"}, timeout=30.0) + if response.status_code >= 400: + raise BackupError(f"连接 R2 失败:HTTP {response.status_code}") + return {"ok": True, "status": int(response.status_code)} + + def upload_bytes(self, key: str, payload: bytes, *, content_type: str, metadata: dict[str, str] | None = None) -> dict[str, object]: + headers = {"content-type": content_type} + if metadata: + for item_key, item_value in metadata.items(): + headers[f"x-amz-meta-{item_key}"] = str(item_value) + response = self._request("PUT", key, body=payload, extra_headers=headers) + if response.status_code >= 400: + raise BackupError(f"上传备份失败:HTTP {response.status_code}") + return {"key": key, "etag": str(response.headers.get("etag") or "").strip('"')} + + def delete_object(self, key: str) -> None: + response = self._request("DELETE", key, timeout=30.0) + if response.status_code >= 400 and response.status_code != 404: + raise BackupError(f"删除备份失败:HTTP {response.status_code}") + + def download_bytes(self, key: str) -> bytes: + response = self._request("GET", key, timeout=60.0) + if response.status_code >= 400: + raise BackupError(f"读取备份失败:HTTP {response.status_code}") + return bytes(response.content or b"") + + def list_objects(self) -> list[dict[str, object]]: + items: list[dict[str, object]] = [] + continuation = "" + while True: + query = {"list-type": "2", "prefix": f"{self.prefix.rstrip('/')}/", "max-keys": "1000"} + if continuation: + query["continuation-token"] = continuation + response = self._request("GET", query=query, timeout=30.0) + if response.status_code >= 400: + raise BackupError(f"获取备份列表失败:HTTP {response.status_code}") + text = response.text + for block in text.split("")[1:]: + key = _clean(block.split("", 1)[1].split("", 1)[0]) if "" in block else "" + if not key: + continue + size_text = _clean(block.split("", 1)[1].split("", 1)[0]) if "" in block else "0" + updated = _clean(block.split("", 1)[1].split("", 1)[0]) if "" in block else "" + items.append({ + "key": key, + "size": int(size_text or 0), + "updated_at": updated, + }) + truncated = "true" in text + if not truncated: + break + if "" not in text: + break + continuation = _clean(text.split("", 1)[1].split("", 1)[0]) + if not continuation: + break + items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True) + return items + + def close(self) -> None: + self.session.close() + + +class BackupService: + def __init__(self) -> None: + self._lock = threading.RLock() + self._stop_event = threading.Event() + self._thread: threading.Thread | None = None + self._running = False + + def start(self) -> None: + with self._lock: + if self._thread and self._thread.is_alive(): + return + self._stop_event.clear() + self._thread = threading.Thread(target=self._run, daemon=True, name="r2-backup-scheduler") + self._thread.start() + + def stop(self) -> None: + with self._lock: + self._stop_event.set() + thread = self._thread + self._thread = None + if thread and thread.is_alive(): + thread.join(timeout=2) + + def _run(self) -> None: + while not self._stop_event.is_set(): + try: + self.run_scheduled_backup_if_needed() + except Exception: + pass + self._stop_event.wait(30) + + def run_scheduled_backup_if_needed(self) -> None: + settings = config.get_backup_settings() + if not settings.get("enabled"): + return + state = self.get_status() + if state.get("running"): + return + interval_minutes = int(settings.get("interval_minutes") or 360) + last_finished_raw = _clean(state.get("last_finished_at")) + if last_finished_raw: + try: + last_finished = datetime.fromisoformat(last_finished_raw.replace("Z", "+00:00")) + elapsed = (_utc_now() - last_finished.astimezone(UTC)).total_seconds() + if elapsed < interval_minutes * 60: + return + except Exception: + pass + self.run_backup(trigger="schedule") + + def get_status(self) -> dict[str, object]: + return { + **load_backup_state(), + "running": self._running, + } + + def is_configured(self) -> bool: + settings = config.get_backup_settings() + return all([ + _clean(settings.get("account_id")), + _clean(settings.get("access_key_id")), + _clean(settings.get("secret_access_key")), + _clean(settings.get("bucket")), + ]) + + def get_settings(self) -> dict[str, object]: + settings = dict(config.get_backup_settings()) + settings["secret_access_key"] = "********" if _clean(settings.get("secret_access_key")) else "" + settings["passphrase"] = "********" if _clean(settings.get("passphrase")) else "" + return settings + + def update_settings(self, payload: dict[str, object]) -> dict[str, object]: + current = config.get_backup_settings() + merged = dict(current) + merged.update(dict(payload or {})) + if "include" in payload and isinstance(payload.get("include"), dict): + include = dict(current.get("include") or {}) + include.update(payload.get("include") or {}) + merged["include"] = include + if payload.get("secret_access_key") == "********": + merged["secret_access_key"] = current.get("secret_access_key") + if payload.get("passphrase") == "********": + merged["passphrase"] = current.get("passphrase") + updated = config.update({"backup": merged}) + return dict(updated.get("backup") or {}) + + def test_connection(self) -> dict[str, object]: + client = CloudflareR2Client(config.get_backup_settings()) + try: + return client.test_connection() + finally: + client.close() + + def list_backups(self) -> list[dict[str, object]]: + if not self.is_configured(): + return [] + client = CloudflareR2Client(config.get_backup_settings()) + try: + items = client.list_objects() + finally: + client.close() + parsed: list[dict[str, object]] = [] + for item in items: + key = _clean(item.get("key")) + name = key.rsplit("/", 1)[-1] + encrypted = name.endswith(".enc") + parsed.append({ + "key": key, + "name": name, + "size": int(item.get("size") or 0), + "updated_at": item.get("updated_at"), + "encrypted": encrypted, + }) + return parsed + + def delete_backup(self, key: str) -> None: + candidate = _clean(key) + if not candidate: + raise BackupError("备份对象 key 不能为空") + client = CloudflareR2Client(config.get_backup_settings()) + try: + client.delete_object(candidate) + finally: + client.close() + + def download_backup(self, key: str) -> dict[str, object]: + candidate = _clean(key) + if not candidate: + raise BackupError("备份对象 key 不能为空") + client = CloudflareR2Client(config.get_backup_settings()) + try: + payload = client.download_bytes(candidate) + finally: + client.close() + name = candidate.rsplit("/", 1)[-1] or "backup.bin" + return { + "key": candidate, + "name": name, + "content_type": _guess_content_type(name), + "payload": payload, + "size": len(payload), + } + + def get_backup_detail(self, key: str) -> dict[str, object]: + candidate = _clean(key) + if not candidate: + raise BackupError("备份对象 key 不能为空") + client = CloudflareR2Client(config.get_backup_settings()) + try: + payload = client.download_bytes(candidate) + finally: + client.close() + detail = self._decode_backup_payload(candidate, payload) + detail["key"] = candidate + detail["name"] = candidate.rsplit("/", 1)[-1] + detail["encrypted"] = candidate.endswith(".enc") + return detail + + def run_backup(self, *, trigger: str = "manual") -> dict[str, object]: + with self._lock: + current = self.get_status() + if self._running: + raise BackupError("当前已有备份任务正在执行") + started_at = _iso_now() + self._running = True + save_backup_state({ + "last_started_at": started_at, + "last_finished_at": current.get("last_finished_at"), + "last_status": "idle", + "last_error": None, + "last_object_key": current.get("last_object_key"), + }) + try: + result = self._run_backup_once(trigger=trigger) + save_backup_state({ + "last_started_at": started_at, + "last_finished_at": _iso_now(), + "last_status": "success", + "last_error": None, + "last_object_key": result["key"], + }) + return result + except Exception as exc: + save_backup_state({ + "last_started_at": started_at, + "last_finished_at": _iso_now(), + "last_status": "error", + "last_error": str(exc) or exc.__class__.__name__, + "last_object_key": current.get("last_object_key"), + }) + raise + finally: + self._running = False + + def _run_backup_once(self, *, trigger: str) -> dict[str, object]: + settings = config.get_backup_settings() + client = CloudflareR2Client(settings) + client.validate() + payload_raw = self._build_backup_archive(settings, trigger=trigger) + encrypted = bool(settings.get("encrypt")) + if encrypted: + passphrase = _clean(settings.get("passphrase")) + if not passphrase: + raise BackupError("已启用备份加密,但未设置加密口令") + payload = _openssl_encrypt(payload_raw, passphrase) + suffix = ".tar.gz.enc" + else: + payload = payload_raw + suffix = ".tar.gz" + timestamp = _utc_now().strftime("%Y%m%dT%H%M%SZ") + random_tag = f"{random.randint(0, 0xFFFF):04x}" + object_key = f"{client.prefix.rstrip('/')}/backup-{timestamp}-{random_tag}{suffix}" + metadata = { + "created-at": _iso_now(), + "encrypted": "true" if encrypted else "false", + "trigger": trigger, + } + try: + result = client.upload_bytes(object_key, payload, content_type="application/octet-stream", metadata=metadata) + self._apply_rotation(client, int(settings.get("rotation_keep") or 0)) + return { + "key": result["key"], + "size": len(payload), + "encrypted": encrypted, + } + finally: + client.close() + + def _decode_backup_payload(self, key: str, payload: bytes) -> dict[str, object]: + decoded = payload + if key.endswith(".enc"): + passphrase = _clean(config.get_backup_settings().get("passphrase")) + if not passphrase: + raise BackupError("当前未配置加密口令,无法查看已加密备份") + decoded = _openssl_decrypt(decoded, passphrase) + return self._decode_archive_detail(decoded) + + def _apply_rotation(self, client: CloudflareR2Client, keep: int) -> None: + if keep <= 0: + return + items = client.list_objects() + if len(items) <= keep: + return + for item in items[keep:]: + key = _clean(item.get("key")) + if key: + client.delete_object(key) + + def _decode_archive_detail(self, payload: bytes) -> dict[str, object]: + files: list[dict[str, object]] = [] + snapshots: list[dict[str, object]] = [] + metadata: dict[str, object] = {} + try: + with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as archive: + members = [member for member in archive.getmembers() if member.isfile()] + for member in members: + extracted = archive.extractfile(member) + if extracted is None: + continue + raw = extracted.read() + name = member.name + if name == "backup-metadata.json": + try: + parsed = json.loads(raw.decode("utf-8")) + if isinstance(parsed, dict): + metadata = parsed + except Exception: + metadata = {} + continue + if name.startswith("snapshots/") and name.endswith(".json"): + count = 0 + try: + parsed_snapshot = json.loads(raw.decode("utf-8")) + count = _count_items(parsed_snapshot) + except Exception: + count = 0 + snapshots.append({ + "name": name.removeprefix("snapshots/").removesuffix(".json"), + "count": count, + }) + continue + files.append({ + "name": name, + "exists": True, + "content_type": _guess_content_type(name), + "size": len(raw), + "sha256": _sha256_hex(raw), + }) + except tarfile.TarError as exc: + raise BackupError("解析备份压缩包失败,备份可能已损坏") from exc + files.sort(key=lambda item: str(item.get("name") or "")) + snapshots.sort(key=lambda item: str(item.get("name") or "")) + return { + "created_at": metadata.get("created_at"), + "trigger": metadata.get("trigger"), + "app_version": metadata.get("app_version"), + "storage_backend": metadata.get("storage_backend"), + "files": files, + "snapshots": snapshots, + } + + def _build_backup_archive(self, settings: dict[str, object], *, trigger: str) -> bytes: + include = settings.get("include") if isinstance(settings.get("include"), dict) else {} + metadata = { + "version": 2, + "created_at": _iso_now(), + "trigger": trigger, + "app_version": config.app_version, + "storage_backend": config.get_storage_backend().get_backend_info(), + } + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:gz") as archive: + self._add_bytes_to_archive(archive, "backup-metadata.json", _json_bytes(metadata)) + if include.get("config"): + self._add_file_to_archive(archive, CONFIG_FILE, "config.json") + if include.get("register"): + self._add_file_to_archive(archive, DATA_DIR / "register.json", "data/register.json") + if include.get("cpa"): + self._add_file_to_archive(archive, DATA_DIR / "cpa_config.json", "data/cpa_config.json") + if include.get("sub2api"): + self._add_file_to_archive(archive, DATA_DIR / "sub2api_config.json", "data/sub2api_config.json") + if include.get("logs"): + self._add_file_to_archive(archive, DATA_DIR / "logs.jsonl", "data/logs.jsonl") + if include.get("image_tasks"): + self._add_file_to_archive(archive, DATA_DIR / "image_tasks.json", "data/image_tasks.json") + if include.get("accounts_snapshot"): + self._add_bytes_to_archive( + archive, + "snapshots/accounts.json", + _json_bytes(config.get_storage_backend().load_accounts()), + ) + if include.get("auth_keys_snapshot"): + self._add_bytes_to_archive( + archive, + "snapshots/auth_keys.json", + _json_bytes(config.get_storage_backend().load_auth_keys()), + ) + if include.get("images"): + self._add_directory_to_archive(archive, config.images_dir, "data/images") + return buffer.getvalue() + + def _add_bytes_to_archive(self, archive: tarfile.TarFile, name: str, payload: bytes) -> None: + info = tarfile.TarInfo(name=name) + info.size = len(payload) + info.mtime = int(_utc_now().timestamp()) + archive.addfile(info, io.BytesIO(payload)) + + def _add_file_to_archive(self, archive: tarfile.TarFile, source: Path, arcname: str) -> None: + if not source.exists() or not source.is_file(): + return + archive.add(source, arcname=arcname) + + def _add_directory_to_archive(self, archive: tarfile.TarFile, source_dir: Path, arcname_root: str) -> None: + if not source_dir.exists() or not source_dir.is_dir(): + return + for path in sorted(source_dir.rglob("*")): + if path.is_file(): + relative = path.relative_to(source_dir).as_posix() + archive.add(path, arcname=f"{arcname_root}/{relative}") + + +backup_service = BackupService() diff --git a/services/config.py b/services/config.py index 4ead1eee3..7c8aca8c3 100644 --- a/services/config.py +++ b/services/config.py @@ -13,6 +13,76 @@ DATA_DIR = BASE_DIR / "data" CONFIG_FILE = BASE_DIR / "config.json" VERSION_FILE = BASE_DIR / "VERSION" +BACKUP_STATE_FILE = DATA_DIR / "backup_state.json" + +DEFAULT_BACKUP_INCLUDE = { + "config": True, + "register": True, + "cpa": True, + "sub2api": True, + "logs": True, + "image_tasks": True, + "accounts_snapshot": True, + "auth_keys_snapshot": True, + "images": False, +} + + +def _normalize_bool(value: object, default: bool = False) -> bool: + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"1", "true", "yes", "on"}: + return True + if lowered in {"0", "false", "no", "off"}: + return False + if value is None: + return default + return bool(value) + + +def _normalize_positive_int(value: object, default: int, minimum: int = 0) -> int: + try: + normalized = int(value) + except (TypeError, ValueError): + normalized = default + return max(minimum, normalized) + + +def _normalize_backup_include(value: object) -> dict[str, bool]: + source = value if isinstance(value, dict) else {} + normalized = dict(DEFAULT_BACKUP_INCLUDE) + for key in normalized: + normalized[key] = _normalize_bool(source.get(key), normalized[key]) + return normalized + + +def _normalize_backup_settings(value: object) -> dict[str, object]: + source = value if isinstance(value, dict) else {} + return { + "enabled": _normalize_bool(source.get("enabled"), False), + "provider": "cloudflare_r2", + "account_id": str(source.get("account_id") or "").strip(), + "access_key_id": str(source.get("access_key_id") or "").strip(), + "secret_access_key": str(source.get("secret_access_key") or "").strip(), + "bucket": str(source.get("bucket") or "").strip(), + "prefix": str(source.get("prefix") or "backups").strip().strip("/") or "backups", + "interval_minutes": _normalize_positive_int(source.get("interval_minutes"), 360, 1), + "rotation_keep": _normalize_positive_int(source.get("rotation_keep"), 10, 0), + "encrypt": _normalize_bool(source.get("encrypt"), False), + "passphrase": str(source.get("passphrase") or "").strip(), + "include": _normalize_backup_include(source.get("include")), + } + + +def _normalize_backup_state(value: object) -> dict[str, object]: + source = value if isinstance(value, dict) else {} + return { + "last_started_at": str(source.get("last_started_at") or "").strip() or None, + "last_finished_at": str(source.get("last_finished_at") or "").strip() or None, + "last_status": str(source.get("last_status") or "idle").strip() or "idle", + "last_error": str(source.get("last_error") or "").strip() or None, + "last_object_key": str(source.get("last_object_key") or "").strip() or None, + } @dataclass(frozen=True) @@ -201,6 +271,7 @@ def get(self) -> dict[str, object]: data["log_levels"] = self.log_levels data["sensitive_words"] = self.sensitive_words data["ai_review"] = self.ai_review + data["backup"] = self.get_backup_settings() data.pop("auth-key", None) return data @@ -210,10 +281,16 @@ def get_proxy_settings(self) -> str: def update(self, data: dict[str, object]) -> dict[str, object]: next_data = dict(self.data) next_data.update(dict(data or {})) + if "backup" in next_data: + next_data["backup"] = _normalize_backup_settings(next_data.get("backup")) + next_data.pop("backup_state", None) self.data = next_data self._save() return self.get() + def get_backup_settings(self) -> dict[str, object]: + return _normalize_backup_settings(self.data.get("backup")) + def get_storage_backend(self) -> StorageBackend: """获取存储后端实例(单例)""" if self._storage_backend is None: @@ -222,4 +299,14 @@ def get_storage_backend(self) -> StorageBackend: return self._storage_backend +def load_backup_state() -> dict[str, object]: + return _normalize_backup_state(_read_json_object(BACKUP_STATE_FILE, name="backup_state.json")) + + +def save_backup_state(state: dict[str, object]) -> dict[str, object]: + normalized = _normalize_backup_state(state) + BACKUP_STATE_FILE.write_text(json.dumps(normalized, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return normalized + + config = ConfigStore(CONFIG_FILE) diff --git a/web/src/app/settings/components/backup-settings-card.tsx b/web/src/app/settings/components/backup-settings-card.tsx new file mode 100644 index 000000000..067e10fcc --- /dev/null +++ b/web/src/app/settings/components/backup-settings-card.tsx @@ -0,0 +1,430 @@ +"use client"; + +import { CloudUpload, Download, Eye, LoaderCircle, Play, RefreshCcw, Shield, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import webConfig from "@/constants/common-env"; +import { fetchBackupDetail, getBackupDownloadUrl, type BackupDetail, type BackupInclude } from "@/lib/api"; +import { getStoredAuthKey } from "@/store/auth"; +import { useSettingsStore } from "../store"; + +function formatDateTime(value?: string | null) { + if (!value) { + return "—"; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return new Intl.DateTimeFormat("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(date); +} + +function formatBytes(value: number) { + if (!Number.isFinite(value) || value <= 0) { + return "0 B"; + } + const units = ["B", "KB", "MB", "GB"]; + let size = value; + let index = 0; + while (size >= 1024 && index < units.length - 1) { + size /= 1024; + index += 1; + } + return `${size >= 10 || index === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[index]}`; +} + +const includeLabels: Array<{ key: keyof BackupInclude; label: string }> = [ + { key: "config", label: "系统配置" }, + { key: "register", label: "注册配置" }, + { key: "cpa", label: "CPA 配置" }, + { key: "sub2api", label: "Sub2API 配置" }, + { key: "logs", label: "调度与调用日志" }, + { key: "image_tasks", label: "图片任务记录" }, + { key: "accounts_snapshot", label: "账号快照" }, + { key: "auth_keys_snapshot", label: "用户密钥快照" }, + { key: "images", label: "图片文件目录" }, +]; + +export function BackupSettingsCard() { + const [detailOpen, setDetailOpen] = useState(false); + const [detailLoading, setDetailLoading] = useState(false); + const [detail, setDetail] = useState(null); + const config = useSettingsStore((state) => state.config); + const backups = useSettingsStore((state) => state.backups); + const backupState = useSettingsStore((state) => state.backupState); + const isLoadingConfig = useSettingsStore((state) => state.isLoadingConfig); + const isSavingConfig = useSettingsStore((state) => state.isSavingConfig); + const isLoadingBackups = useSettingsStore((state) => state.isLoadingBackups); + const isRunningBackup = useSettingsStore((state) => state.isRunningBackup); + const deletingBackupKey = useSettingsStore((state) => state.deletingBackupKey); + const isTestingBackup = useSettingsStore((state) => state.isTestingBackup); + const saveConfig = useSettingsStore((state) => state.saveConfig); + const loadBackups = useSettingsStore((state) => state.loadBackups); + const runBackup = useSettingsStore((state) => state.runBackup); + const removeBackup = useSettingsStore((state) => state.removeBackup); + const testBackup = useSettingsStore((state) => state.testBackup); + const setBackupField = useSettingsStore((state) => state.setBackupField); + const setBackupInclude = useSettingsStore((state) => state.setBackupInclude); + + if (isLoadingConfig) { + return ( + + + + + + ); + } + + const backup = config?.backup; + if (!backup) { + return null; + } + + const handleOpenDetail = async (key: string) => { + setDetailLoading(true); + setDetailOpen(true); + try { + const data = await fetchBackupDetail(key); + setDetail(data.item); + } catch (error) { + setDetail(null); + toast.error(error instanceof Error ? error.message : "读取备份详情失败"); + } finally { + setDetailLoading(false); + } + }; + + const handleDownload = async (key: string, name: string) => { + try { + const authKey = await getStoredAuthKey(); + if (!authKey) { + toast.error("当前登录态已失效,请重新登录后再下载"); + return; + } + const response = await fetch(`${webConfig.apiUrl.replace(/\/$/, "")}${getBackupDownloadUrl(key)}`, { + headers: { + Authorization: `Bearer ${authKey}`, + }, + }); + if (!response.ok) { + let message = "下载备份失败"; + try { + const data = await response.json() as { detail?: { error?: string }; error?: string; message?: string }; + message = data.detail?.error || data.error || data.message || message; + } catch { + message = response.status === 401 ? "登录已失效,请重新登录后再试" : message; + } + throw new Error(message); + } + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = name || "backup.bin"; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + window.URL.revokeObjectURL(url); + toast.success("备份下载已开始"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "下载备份失败"); + } + }; + + return ( + <> + + +
+
+
+ +
+
+

R2 备份管理

+

将关键数据定时备份到 Cloudflare R2,支持可选加密、轮替、手动执行与历史清理。

+
+
+
+ + {backupState?.running ? "备份中" : backupState?.last_status === "success" ? "最近成功" : backupState?.last_status === "error" ? "最近失败" : "未执行"} + +
+
+ +
+ 账号与用户密钥会从当前存储后端导出逻辑快照,不依赖底层是 `json`、`sqlite`、`postgres` 还是 `git`。图片目录默认不备份,避免备份体积过大。 +
+ +
+ + + +
+ + setBackupField("account_id", event.target.value)} className="h-10 rounded-xl border-stone-200 bg-white" /> +
+
+ + setBackupField("bucket", event.target.value)} className="h-10 rounded-xl border-stone-200 bg-white" /> +
+ +
+ + setBackupField("access_key_id", event.target.value)} className="h-10 rounded-xl border-stone-200 bg-white" /> +
+
+ + setBackupField("secret_access_key", event.target.value)} className="h-10 rounded-xl border-stone-200 bg-white" /> +
+ +
+ + setBackupField("prefix", event.target.value)} placeholder="backups" className="h-10 rounded-xl border-stone-200 bg-white" /> +

R2 内对象前缀,例如 `backups/prod`。

+
+
+ + setBackupField("interval_minutes", event.target.value)} placeholder="360" className="h-10 rounded-xl border-stone-200 bg-white" /> +

单位分钟,服务启动后会按此间隔自动轮询执行。

+
+ +
+ + setBackupField("rotation_keep", event.target.value)} placeholder="10" className="h-10 rounded-xl border-stone-200 bg-white" /> +

成功上传后自动删除更旧的备份。填 `0` 表示不自动轮替。

+
+
+ + setBackupField("passphrase", event.target.value)} placeholder={backup.encrypt ? "启用加密后必填" : "留空"} className="h-10 rounded-xl border-stone-200 bg-white" /> +

仅在启用加密时使用。请妥善保管,否则无法解密备份内容。

+
+
+ +
+
+
备份内容
+

按组件勾选需要进入备份包的数据。

+
+
+ {includeLabels.map((item) => ( + + ))} +
+
+ +
+
+
最近开始
+
{formatDateTime(backupState?.last_started_at)}
+
+
+
最近完成
+
{formatDateTime(backupState?.last_finished_at)}
+
+
+
最近对象
+
{backupState?.last_object_key || "—"}
+
+ {backupState?.last_error ? ( +
+
最近错误
+
{backupState.last_error}
+
+ ) : null} +
+ +
+ + + + +
+ +
+
+
+

历史备份

+

支持查看对象信息并直接删除远端备份。

+
+
+ + {isLoadingBackups ? ( +
+ +
+ ) : backups.length === 0 ? ( +
+ 暂无远端备份记录。保存配置并执行一次手动备份后会出现在这里。 +
+ ) : ( +
+ {backups.map((item) => { + const isDeleting = deletingBackupKey === item.key; + return ( +
+
+
+
{item.name}
+ {item.encrypted ? 已加密 : null} +
+
+ 大小 {formatBytes(item.size)} + 更新时间 {formatDateTime(item.updated_at)} + 对象 key {item.key} +
+
+ +
+ + + +
+
+ ); + })} +
+ )} +
+
+
+ + + + + 备份详情 + +
+ {detailLoading ? ( +
+ +
+ ) : !detail ? ( +
+ 暂时无法读取备份详情;如果这是加密备份,请确认当前已填写正确的加密口令并先保存配置。 +
+ ) : ( + <> +
+
+
对象名称
+
{detail.name}
+
+
+
创建时间
+
{formatDateTime(detail.created_at)}
+
+
+
触发方式
+
{detail.trigger || "—"}
+
+
+
应用版本
+
{detail.app_version || "—"}
+
+
+
存储后端
+
{JSON.stringify(detail.storage_backend || {}, null, 2)}
+
+
+ +
+

文件内容

+
+ {detail.files.map((item) => ( +
+
{item.name}
+
+ {item.exists ? "已包含" : "缺失"} + 大小 {formatBytes(item.size)} + {item.content_type || "application/octet-stream"} + SHA256 {item.sha256 || "—"} +
+
+ ))} +
+
+ +
+

快照内容

+
+ {detail.snapshots.map((item) => ( +
+
{item.name}
+
记录数 {item.count}
+
+ ))} +
+
+ + )} +
+
+
+ + ); +} diff --git a/web/src/app/settings/page.tsx b/web/src/app/settings/page.tsx index 3e4c0ea6b..154a7ece4 100644 --- a/web/src/app/settings/page.tsx +++ b/web/src/app/settings/page.tsx @@ -5,6 +5,7 @@ import { LoaderCircle } from "lucide-react"; import { useAuthGuard } from "@/lib/use-auth-guard"; +import { BackupSettingsCard } from "./components/backup-settings-card"; import { ConfigCard } from "./components/config-card"; import { CPAPoolDialog } from "./components/cpa-pool-dialog"; import { CPAPoolsCard } from "./components/cpa-pools-card"; @@ -18,7 +19,9 @@ function SettingsDataController() { const didLoadRef = useRef(false); const initialize = useSettingsStore((state) => state.initialize); const loadPools = useSettingsStore((state) => state.loadPools); + const loadBackups = useSettingsStore((state) => state.loadBackups); const pools = useSettingsStore((state) => state.pools); + const backupState = useSettingsStore((state) => state.backupState); useEffect(() => { if (didLoadRef.current) { @@ -43,6 +46,16 @@ function SettingsDataController() { return () => window.clearInterval(timer); }, [loadPools, pools]); + useEffect(() => { + if (!backupState?.running) { + return; + } + const timer = window.setInterval(() => { + void loadBackups(true); + }, 3000); + return () => window.clearInterval(timer); + }, [backupState?.running, loadBackups]); + return null; } @@ -53,6 +66,7 @@ function SettingsPageContent() {
+ diff --git a/web/src/app/settings/store.ts b/web/src/app/settings/store.ts index ae759ac19..2f1ba91f0 100644 --- a/web/src/app/settings/store.ts +++ b/web/src/app/settings/store.ts @@ -5,18 +5,25 @@ import { toast } from "sonner"; import { createCPAPool, + deleteBackup, deleteCPAPool, fetchCPAPoolFiles, fetchCPAPools, + fetchBackups, fetchRegisterConfig, resetRegister as resetRegisterApi, fetchSettingsConfig, + runBackupNow, startRegister, startCPAImport, stopRegister, + testBackupConnection, updateCPAPool, updateRegisterConfig, updateSettingsConfig, + type BackupItem, + type BackupSettings, + type BackupState, type CPAPool, type CPARemoteFile, type RegisterConfig, @@ -28,6 +35,32 @@ export const PAGE_SIZE_OPTIONS = ["50", "100", "200"] as const; export type PageSizeOption = (typeof PAGE_SIZE_OPTIONS)[number]; function normalizeConfig(config: SettingsConfig): SettingsConfig { + const backup = typeof config.backup === "object" && config.backup + ? config.backup as BackupSettings + : { + enabled: false, + provider: "cloudflare_r2", + account_id: "", + access_key_id: "", + secret_access_key: "", + bucket: "", + prefix: "backups", + interval_minutes: 360, + rotation_keep: 10, + encrypt: false, + passphrase: "", + include: { + config: true, + register: true, + cpa: true, + sub2api: true, + logs: true, + image_tasks: true, + accounts_snapshot: true, + auth_keys_snapshot: true, + images: false, + }, + }; return { ...config, refresh_account_interval_minute: Number(config.refresh_account_interval_minute || 5), @@ -46,6 +79,30 @@ function normalizeConfig(config: SettingsConfig): SettingsConfig { model: String(config.ai_review?.model || ""), prompt: String(config.ai_review?.prompt || ""), }, + backup: { + ...backup, + enabled: Boolean(backup.enabled), + account_id: String(backup.account_id || ""), + access_key_id: String(backup.access_key_id || ""), + secret_access_key: String(backup.secret_access_key || ""), + bucket: String(backup.bucket || ""), + prefix: String(backup.prefix || "backups"), + interval_minutes: Number(backup.interval_minutes || 360), + rotation_keep: Number(backup.rotation_keep || 10), + encrypt: Boolean(backup.encrypt), + passphrase: String(backup.passphrase || ""), + include: { + config: Boolean(backup.include?.config ?? true), + register: Boolean(backup.include?.register ?? true), + cpa: Boolean(backup.include?.cpa ?? true), + sub2api: Boolean(backup.include?.sub2api ?? true), + logs: Boolean(backup.include?.logs ?? true), + image_tasks: Boolean(backup.include?.image_tasks ?? true), + accounts_snapshot: Boolean(backup.include?.accounts_snapshot ?? true), + auth_keys_snapshot: Boolean(backup.include?.auth_keys_snapshot ?? true), + images: Boolean(backup.include?.images ?? false), + }, + }, }; } @@ -70,6 +127,12 @@ type SettingsStore = { config: SettingsConfig | null; isLoadingConfig: boolean; isSavingConfig: boolean; + backups: BackupItem[]; + backupState: BackupState | null; + isLoadingBackups: boolean; + isRunningBackup: boolean; + deletingBackupKey: string | null; + isTestingBackup: boolean; registerConfig: RegisterConfig | null; isLoadingRegister: boolean; @@ -99,7 +162,11 @@ type SettingsStore = { initialize: () => Promise; loadConfig: () => Promise; - saveConfig: () => Promise; + saveConfig: () => Promise; + loadBackups: (silent?: boolean) => Promise; + runBackup: () => Promise; + removeBackup: (key: string) => Promise; + testBackup: () => Promise; setRefreshAccountIntervalMinute: (value: string) => void; setImageRetentionDays: (value: string) => void; setImagePollTimeoutSecs: (value: string) => void; @@ -110,6 +177,8 @@ type SettingsStore = { setBaseUrl: (value: string) => void; setSensitiveWordsText: (value: string) => void; setAIReviewField: (key: "enabled" | "base_url" | "api_key" | "model" | "prompt", value: string | boolean) => void; + setBackupField: (key: keyof BackupSettings, value: string | boolean) => void; + setBackupInclude: (key: keyof BackupSettings["include"], value: boolean) => void; loadRegister: (silent?: boolean) => Promise; setRegisterConfig: (config: RegisterConfig) => void; @@ -153,6 +222,12 @@ export const useSettingsStore = create((set, get) => ({ config: null, isLoadingConfig: true, isSavingConfig: false, + backups: [], + backupState: null, + isLoadingBackups: true, + isRunningBackup: false, + deletingBackupKey: null, + isTestingBackup: false, registerConfig: null, isLoadingRegister: true, @@ -182,14 +257,27 @@ export const useSettingsStore = create((set, get) => ({ initialize: async () => { await Promise.allSettled([get().loadConfig(), get().loadPools()]); + const backup = get().config?.backup; + const isConfigured = Boolean( + String(backup?.account_id || "").trim() + && String(backup?.access_key_id || "").trim() + && String(backup?.secret_access_key || "").trim() + && String(backup?.bucket || "").trim(), + ); + if (isConfigured) { + await get().loadBackups(); + } else { + set({ backups: [], isLoadingBackups: false }); + } }, loadConfig: async () => { set({ isLoadingConfig: true }); try { const data = await fetchSettingsConfig(); + const normalized = normalizeConfig(data.config); set({ - config: normalizeConfig(data.config), + config: normalized, }); } catch (error) { toast.error(error instanceof Error ? error.message : "加载系统配置失败"); @@ -201,7 +289,7 @@ export const useSettingsStore = create((set, get) => ({ saveConfig: async () => { const { config } = get(); if (!config) { - return; + return false; } set({ isSavingConfig: true }); @@ -223,13 +311,26 @@ export const useSettingsStore = create((set, get) => ({ model: String(config.ai_review?.model || "").trim(), prompt: String(config.ai_review?.prompt || "").trim(), }, + backup: { + ...(config.backup as BackupSettings), + account_id: String(config.backup?.account_id || "").trim(), + access_key_id: String(config.backup?.access_key_id || "").trim(), + secret_access_key: String(config.backup?.secret_access_key || "").trim(), + bucket: String(config.backup?.bucket || "").trim(), + prefix: String(config.backup?.prefix || "backups").trim(), + interval_minutes: Math.max(1, Number(config.backup?.interval_minutes) || 360), + rotation_keep: Math.max(0, Number(config.backup?.rotation_keep) || 0), + passphrase: String(config.backup?.passphrase || "").trim(), + }, }); set({ config: normalizeConfig(data.config), }); toast.success("配置已保存"); + return true; } catch (error) { toast.error(error instanceof Error ? error.message : "保存系统配置失败"); + return false; } finally { set({ isSavingConfig: false }); } @@ -311,6 +412,110 @@ export const useSettingsStore = create((set, get) => ({ set((state) => state.config ? { config: { ...state.config, ai_review: { ...(state.config.ai_review || {}), [key]: value } } } : {}); }, + setBackupField: (key, value) => { + set((state) => { + if (!state.config?.backup) { + return {}; + } + return { + config: { + ...state.config, + backup: { + ...state.config.backup, + [key]: value, + }, + }, + }; + }); + }, + + setBackupInclude: (key, value) => { + set((state) => { + if (!state.config?.backup) { + return {}; + } + return { + config: { + ...state.config, + backup: { + ...state.config.backup, + include: { + ...state.config.backup.include, + [key]: value, + }, + }, + }, + }; + }); + }, + + loadBackups: async (silent = false) => { + if (!silent) { + set({ isLoadingBackups: true }); + } + try { + const data = await fetchBackups(); + set({ + backups: data.items, + backupState: data.state, + }); + } catch (error) { + if (!silent) { + toast.error(error instanceof Error ? error.message : "加载备份列表失败"); + } + } finally { + if (!silent) { + set({ isLoadingBackups: false }); + } + } + }, + + runBackup: async () => { + set({ isRunningBackup: true }); + try { + const saved = await get().saveConfig(); + if (!saved) { + return; + } + const data = await runBackupNow(); + toast.success(`备份已完成:${data.result.key}`); + await get().loadBackups(true); + } catch (error) { + toast.error(error instanceof Error ? error.message : "执行备份失败"); + } finally { + set({ isRunningBackup: false }); + } + }, + + removeBackup: async (key) => { + set({ deletingBackupKey: key }); + try { + await deleteBackup(key); + toast.success("备份已删除"); + await get().loadBackups(true); + } catch (error) { + toast.error(error instanceof Error ? error.message : "删除备份失败"); + } finally { + set({ deletingBackupKey: null }); + } + }, + + testBackup: async () => { + set({ isTestingBackup: true }); + try { + const saved = await get().saveConfig(); + if (!saved) { + return; + } + const data = await testBackupConnection(); + toast.success(`R2 连接正常(HTTP ${data.result.status})`); + } catch (error) { + toast.error(error instanceof Error ? error.message : "测试备份连接失败"); + } finally { + set({ isTestingBackup: false }); + } + }, + loadRegister: async (silent = false) => { if (!silent) set({ isLoadingRegister: true }); try { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 42f5530c2..89483aea6 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -67,9 +67,76 @@ export type SettingsConfig = { auto_remove_invalid_accounts?: boolean; auto_remove_rate_limited_accounts?: boolean; log_levels?: string[]; + backup?: BackupSettings; + backup_state?: BackupState; [key: string]: unknown; }; +export type BackupInclude = { + config: boolean; + register: boolean; + cpa: boolean; + sub2api: boolean; + logs: boolean; + image_tasks: boolean; + accounts_snapshot: boolean; + auth_keys_snapshot: boolean; + images: boolean; +}; + +export type BackupSettings = { + enabled: boolean; + provider: "cloudflare_r2" | string; + account_id: string; + access_key_id: string; + secret_access_key: string; + bucket: string; + prefix: string; + interval_minutes: number | string; + rotation_keep: number | string; + encrypt: boolean; + passphrase: string; + include: BackupInclude; +}; + +export type BackupState = { + running: boolean; + last_started_at?: string | null; + last_finished_at?: string | null; + last_status?: string; + last_error?: string | null; + last_object_key?: string | null; +}; + +export type BackupItem = { + key: string; + name: string; + size: number; + updated_at?: string | null; + encrypted: boolean; +}; + +export type BackupDetail = { + key: string; + name: string; + encrypted: boolean; + created_at?: string | null; + trigger?: string | null; + app_version?: string | null; + storage_backend?: Record | null; + files: Array<{ + name: string; + exists: boolean; + content_type?: string; + size: number; + sha256?: string; + }>; + snapshots: Array<{ + name: string; + count: number; + }>; +}; + export type ManagedImage = { path?: string; name: string; @@ -321,6 +388,43 @@ export async function updateSettingsConfig(settings: SettingsConfig) { }); } +export async function testBackupConnection() { + return httpRequest<{ result: { ok: boolean; status: number } }>("/api/backup/test", { + method: "POST", + body: {}, + }); +} + +export async function fetchBackups() { + return httpRequest<{ items: BackupItem[]; state: BackupState; settings: BackupSettings }>("/api/backups"); +} + +export async function runBackupNow() { + return httpRequest<{ result: { key: string; size: number; encrypted: boolean } }>("/api/backups/run", { + method: "POST", + body: {}, + }); +} + +export async function deleteBackup(key: string) { + return httpRequest<{ ok: boolean }>("/api/backups/delete", { + method: "POST", + body: { key }, + }); +} + +export async function fetchBackupDetail(key: string) { + const params = new URLSearchParams(); + params.set("key", key); + return httpRequest<{ item: BackupDetail }>(`/api/backups/detail?${params.toString()}`); +} + +export function getBackupDownloadUrl(key: string) { + const params = new URLSearchParams(); + params.set("key", key); + return `/api/backups/download?${params.toString()}`; +} + export async function fetchManagedImages(filters: { start_date?: string; end_date?: string }) { const params = new URLSearchParams(); if (filters.start_date) params.set("start_date", filters.start_date); From 9f604db7a4f04a36abe4b0bf2038e02bfac494b1 Mon Sep 17 00:00:00 2001 From: Jimmy <2489219080@qq.com> Date: Sun, 3 May 2026 20:10:52 +0800 Subject: [PATCH 2/4] feat: download with auto decrypt --- services/backup_service.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/services/backup_service.py b/services/backup_service.py index 4cf91c884..b26dbd282 100644 --- a/services/backup_service.py +++ b/services/backup_service.py @@ -428,6 +428,13 @@ def download_backup(self, key: str) -> dict[str, object]: finally: client.close() name = candidate.rsplit("/", 1)[-1] or "backup.bin" + if candidate.endswith(".enc"): + passphrase = _clean(config.get_backup_settings().get("passphrase")) + if not passphrase: + raise BackupError("当前未配置加密口令,无法下载并解密已加密备份") + payload = _openssl_decrypt(payload, passphrase) + if name.endswith(".enc"): + name = name[:-4] or "backup.tar.gz" return { "key": candidate, "name": name, From a1595ba41b8f6d78f0dc4d9fed00037096bac434 Mon Sep 17 00:00:00 2001 From: Jimmy <2489219080@qq.com> Date: Sun, 3 May 2026 20:18:55 +0800 Subject: [PATCH 3/4] fix: modified encrypted download name in web --- .../components/backup-settings-card.tsx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/web/src/app/settings/components/backup-settings-card.tsx b/web/src/app/settings/components/backup-settings-card.tsx index 067e10fcc..322ac6615 100644 --- a/web/src/app/settings/components/backup-settings-card.tsx +++ b/web/src/app/settings/components/backup-settings-card.tsx @@ -46,6 +46,23 @@ function formatBytes(value: number) { return `${size >= 10 || index === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[index]}`; } +function getFilenameFromContentDisposition(value: string | null) { + const header = String(value || "").trim(); + if (!header) { + return ""; + } + const utf8Match = header.match(/filename\*\s*=\s*UTF-8''([^;]+)/i); + if (utf8Match?.[1]) { + try { + return decodeURIComponent(utf8Match[1]); + } catch { + return utf8Match[1]; + } + } + const plainMatch = header.match(/filename\s*=\s*"?([^";]+)"?/i); + return plainMatch?.[1] || ""; +} + const includeLabels: Array<{ key: keyof BackupInclude; label: string }> = [ { key: "config", label: "系统配置" }, { key: "register", label: "注册配置" }, @@ -130,11 +147,12 @@ export function BackupSettingsCard() { } throw new Error(message); } + const downloadName = getFilenameFromContentDisposition(response.headers.get("Content-Disposition")) || name || "backup.bin"; const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; - anchor.download = name || "backup.bin"; + anchor.download = downloadName; document.body.append(anchor); anchor.click(); anchor.remove(); From cc169e1fc6b237c93654c8d2e358b6ac2a22b35a Mon Sep 17 00:00:00 2001 From: Jimmy <2489219080@qq.com> Date: Sun, 3 May 2026 21:08:46 +0800 Subject: [PATCH 4/4] add default config in config.json --- config.json | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/config.json b/config.json index e9d7d24b0..1f94481ba 100644 --- a/config.json +++ b/config.json @@ -22,5 +22,29 @@ "api_key": "", "model": "gpt-5.4-mini", "prompt": "判断用户请求是否允许。只回答 ALLOW 或 REJECT。" + }, + "backup": { + "enabled": false, + "provider": "cloudflare_r2", + "account_id": "", + "access_key_id": "", + "secret_access_key": "", + "bucket": "", + "prefix": "backups", + "interval_minutes": 1440, + "rotation_keep": 10, + "encrypt": false, + "passphrase": "", + "include": { + "config": true, + "register": true, + "cpa": true, + "sub2api": true, + "logs": true, + "image_tasks": true, + "accounts_snapshot": true, + "auth_keys_snapshot": true, + "images": false + } } -} +} \ No newline at end of file