"""Offline restore of SiteForge backups. STOP API and worker containers first! Usage: python scripts/restore_backup.py /path/to/backup.zip --confirmed-offline The application must be stopped to prevent WAL writes during restore. """ import argparse import os import shutil import sqlite3 import stat import tempfile import zipfile from pathlib import Path def restore(path, target, confirmed=False): if not confirmed:raise RuntimeError('停止 API 与 worker 后,使用 --confirmed-offline 才能恢复') target=Path(target).resolve();archive=Path(path).resolve() if not archive.exists():raise FileNotFoundError(archive) with zipfile.ZipFile(archive) as z: infos=z.infolist() if len(infos)>250000:raise ValueError('备份文件数量过大') if sum(i.file_size for i in infos)>30_000_000_000:raise ValueError('解压数据总量过大') for item in infos: p=Path(item.filename) if p.is_absolute() or any(x in ('..','.') for x in p.parts) or not p.parts: raise ValueError('备份包含不安全路径') if stat.S_ISLNK(item.external_attr>>16):raise ValueError('不允许符号链接') if 'siteforge.sqlite3' not in z.namelist():raise ValueError('缺少数据库文件') with tempfile.TemporaryDirectory(dir=target.parent) as tmp: z.extractall(tmp) restored=Path(tmp)/'siteforge.sqlite3' with sqlite3.connect(restored) as db: if db.execute('PRAGMA integrity_check').fetchone()[0]!='ok': raise ValueError('备份数据库完整性校验失败') target.mkdir(parents=True,exist_ok=True) # Keep a copy of the old DB outside the restored data volume. old_db=target/'siteforge.sqlite3' if old_db.exists(): dest=target.parent/'siteforge-pre-restore.sqlite3' shutil.copy2(old_db,dest) for special in ('siteforge.sqlite3-wal','siteforge.sqlite3-shm'): (target/special).unlink(missing_ok=True) for src in Path(tmp).rglob('*'): if src.is_file(): dest=target/src.relative_to(tmp) dest.parent.mkdir(parents=True,exist_ok=True) shutil.copy2(src,dest) print('恢复完成。请检查文件权限后再启动 SiteForge。') if __name__=='__main__': parser=argparse.ArgumentParser() parser.add_argument('archive') parser.add_argument('--data',default=os.getenv('SITEFORGE_DATA_DIR','/data')) parser.add_argument('--confirmed-offline',action='store_true') args=parser.parse_args() restore(args.archive,args.data,args.confirmed_offline)