Files

92 lines
4.5 KiB
Python
Raw Permalink Normal View History

"""Offline, verified, atomic(ish) restore for single-volume SQLite SiteForge.
Stop Web and Workers. Destination is replaced as one directory rename; previous
contents are retained in a sibling .pre-restore folder for manual rollback.
On failure the old destination stays unchanged. Use only on the same filesystem.
"""
import argparse
import os
import sqlite3
import stat
import tempfile
import zipfile
from datetime import datetime, timezone
from pathlib import Path
ALLOWED_ROOTS={'media','legacy','builds','archives'}
MAX_FILES=250000
MAX_UNCOMPRESSED=30_000_000_000
def _inspect_zip(z):
infos=z.infolist()
if len(infos)>MAX_FILES:raise ValueError('备份文件数量过大')
if sum(i.file_size for i in infos)>MAX_UNCOMPRESSED:raise ValueError('解压数据总量过大')
names=set()
for info in infos:
p=Path(info.filename)
if (p.is_absolute() or not p.parts or any(part in ('..','.') for part in p.parts)
or '\\' in info.filename or ':' in info.filename or '\x00' in info.filename):
raise ValueError('备份包含不安全路径')
if stat.S_ISLNK(info.external_attr>>16):raise ValueError('不允许符号链接')
if p.parts[0]!='siteforge.sqlite3' and p.parts[0] not in ALLOWED_ROOTS:
raise ValueError('备份包含未知文件范围: '+str(p)[:100])
if p.parts[0]=='siteforge.sqlite3' and len(p.parts)!=1:
raise ValueError('数据库名称不合法')
if not info.is_dir() and info.filename in names:raise ValueError('备份包含重复路径')
names.add(info.filename)
if info.file_size>0 and info.compress_size and info.file_size>5_000_000 and info.file_size/info.compress_size>1000:
raise ValueError('异常压缩比,拒绝解压')
if 'siteforge.sqlite3' not in names:raise ValueError('缺少数据库文件')
return infos
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.is_file():raise FileNotFoundError(archive)
target.parent.mkdir(parents=True,exist_ok=True)
# Preflight the entire archive before changing the previous installation.
with zipfile.ZipFile(archive) as z:
infos=_inspect_zip(z)
with tempfile.TemporaryDirectory(prefix='.siteforge-restore-',dir=target.parent) as tmp:
stage=Path(tmp)/'new';stage.mkdir()
# Never follow symlinks out of the staging directory.
for item in infos:
dest=stage/item.filename
if item.is_dir():dest.mkdir(parents=True,exist_ok=True);continue
dest.parent.mkdir(parents=True,exist_ok=True)
with z.open(item) as src, dest.open('wb') as out:
import shutil
shutil.copyfileobj(src,out,1024*1024)
with sqlite3.connect(stage/'siteforge.sqlite3') as conn:
if conn.execute('PRAGMA integrity_check').fetchone()[0]!='ok':
raise ValueError('数据库完整性校验失败')
if not conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='sites'").fetchone():
raise ValueError('数据库不包含 SiteForge sites 表')
previous=None
if target.exists():
previous=target.with_name(target.name+'.pre-restore-'+datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S'))
suffix=0
while previous.exists():
suffix+=1
previous=target.with_name(target.name+'.pre-restore-'+datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')+'-'+str(suffix))
target.rename(previous)
try:
stage.rename(target)
except BaseException:
if previous is not None and previous.exists() and not target.exists():previous.rename(target)
raise
print('恢复完成: '+str(target))
if previous:print('旧数据保留在: '+str(previous))
return {'target':str(target),'previous':str(previous) if previous else None}
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)