8cea7dee20
- SiteForge Enterprise V8.10.0-dev.1 upstream source (210 tests) - Fix: v8_workflows._mapped_input now reads both 类型/分类 columns so WXR imports keep post/product types instead of collapsing to page - Docs: trade-pro theme port spec (R3) and implementation plan Co-Authored-By: Claude Code <noreply@anthropic.com>
92 lines
4.5 KiB
Python
92 lines
4.5 KiB
Python
"""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)
|