#!/usr/bin/env python3 """Guarded local SMF 1.1 -> 2.1.8 upgrade and MKPortal bridge installation.""" from pathlib import Path import argparse,subprocess,sys,json,hashlib,shutil,os,re,tarfile,datetime HERE=Path(__file__).resolve().parent def digest(p):return hashlib.sha256(p.read_bytes()).hexdigest() def copy(p,q): q.parent.mkdir(parents=True,exist_ok=True);st=q.stat() if q.exists() else p.stat();shutil.copy2(p,q) try:os.chown(q,st.st_uid,st.st_gid);os.chmod(q,st.st_mode & 0o777) except PermissionError:pass def php_call(php,root,action,*args): z=subprocess.run([php,str(HERE/'database.php'),str(root),action,*map(str,args)],capture_output=True,text=True) if z.returncode: logfile=globals().get('PRIVATE_HELPER_LOG') try: if logfile is not None:logfile.write_text(z.stderr);os.chmod(logfile,0o600) except OSError:pass raise RuntimeError('Database helper failed; see the private migration log.' if action!='preflight' else 'Database preflight failed. Check PHP extensions and forum Settings.php.') return z.stdout def settings_patch(path,root,language): text=path.read_text();text=re.sub(r'^.*legacy-php84\.php.*\n','',text,flags=re.M);text=text.replace('?>','') for key,value in {'boarddir':str(root/'forum'),'sourcedir':str(root/'forum/Sources'),'cachedir':str(root/'forum/cache'),'language':language}.items(): pattern=r'\$'+key+r'\s*=\s*[^;]+;';line='$'+key+' = '+"'"+value.replace('\\','\\\\').replace("'","\\'")+"';" text,n=re.subn(pattern,lambda m:line,text) if not n:text+='\n'+line+'\n' if '$db_type' not in text:text+="\n$db_type = 'mysql';\n" path.write_text(text) def rollback(root,backup,php): # Validate the complete backup before any destructive restore step. for rel in ['database.sql','forum/Settings.php','new-files.json','before.json']: if not (backup/rel).is_file():raise RuntimeError('Incomplete backup: '+rel) # Restore using current credentials before restoring original Settings.php. php_call(php,root,'restore',backup/'database.sql') failed=root/'forum' if failed.exists():shutil.rmtree(failed) os.replace(backup/'forum',failed) for p in (backup/'portal-files').rglob('*'): if p.is_file():copy(p,root/p.relative_to(backup/'portal-files')) for rel in json.loads((backup/'new-files.json').read_text()): p=root/rel if p.exists():p.unlink() if (backup/'portal-cache').exists(): shutil.rmtree(root/'mkportal/cache',ignore_errors=True);os.replace(backup/'portal-cache',root/'mkportal/cache') (root/'mkportal/.smf21-migration').unlink(missing_ok=True) print('Rollback complete:',backup) def main(): ap=argparse.ArgumentParser();ap.add_argument('--target',required=True);ap.add_argument('--php',default='php');ap.add_argument('--apply',action='store_true');ap.add_argument('--backup-root',default='/var/www/virtual/yeni1/mkportal-smf21-backups');ap.add_argument('--rollback');a=ap.parse_args();root=Path(a.target).resolve() if not (root/'forum/Settings.php').is_file() or not (root/'mkportal/conf_mk.php').is_file():raise RuntimeError('Target is not this MKPortal installation.') if a.rollback:rollback(root,Path(a.rollback).resolve(),a.php);return checks=json.loads((HERE/'manifest.json').read_text()) for rel,hashes in checks['files'].items(): p=root/rel if hashes['old'] is None: if p.exists():raise RuntimeError('New bridge path already exists: '+rel) elif not p.is_file() or digest(p)!=hashes['old']:raise RuntimeError('Target changed since the supplied backup: '+rel) for name,h in checks['upstream'].items(): if digest(HERE/'upstream'/name)!=h:raise RuntimeError('Package hash mismatch: '+name) for rel,hashes in checks['files'].items(): if digest(HERE/'files'/rel)!=hashes['new']:raise RuntimeError('Payload hash mismatch: '+rel) probe=subprocess.run([a.php,'-r',"foreach(['mysqli','mbstring','gd','openssl','fileinfo','session'] as $x) if(!extension_loaded($x)){fwrite(STDERR,'Missing PHP extension: '.$x.PHP_EOL);exit(1);} if(version_compare(PHP_VERSION,'8.1','<'))exit(1);"],capture_output=True,text=True) if probe.returncode:raise RuntimeError(probe.stderr.strip() or 'PHP 8.1+ is required.') info=json.loads(php_call(a.php,root,'preflight')) if not info['version'].startswith('1.1.'):raise RuntimeError('This migration expects a SMF 1.1.x database. Found '+info['version']) print('Ready: SMF',info['version'],'-> 2.1.8; forum data and MKPortal records will be preserved.') print('Old forum themes/mods will be archived; the upgraded forum uses the standard theme.') if not a.apply:print('Dry run only. Add --apply to create backups and perform the migration.');return base=Path(a.backup_root).resolve() if base==root or root in base.parents:raise RuntimeError('Backup must be outside the public site directory.') stamp=datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=3))).strftime('%Y%m%d%H%M%S') backup=base/('mkportal-'+stamp);backup.mkdir(parents=True,mode=0o700);os.chmod(backup,0o700) globals()['PRIVATE_HELPER_LOG']=backup/'helper-error.log' before=json.loads(php_call(a.php,root,'snapshot'));(backup/'before.json').write_text(json.dumps(before,indent=2));os.chmod(backup/'before.json',0o600) php_call(a.php,root,'dump',backup/'database.sql') new=[] for rel,h in checks['files'].items(): if (root/rel).exists():copy(root/rel,backup/'portal-files'/rel) else:new.append(rel) copy(root/'mkportal/conf_mk.php',backup/'portal-files/mkportal/conf_mk.php') (backup/'new-files.json').write_text(json.dumps(new)) # Build a clean forum outside DocumentRoot; uploaded files and credentials are retained. stage=backup/'new-forum';stage.mkdir() for archive in checks['upstream']: with tarfile.open(HERE/'upstream'/archive) as tf: for member in tf.getmembers(): q=Path(member.name) if q.is_absolute() or '..' in q.parts or member.issym() or member.islnk():raise RuntimeError('Unsafe archive member.') tf.extractall(stage,filter='data') if sys.version_info>=(3,12) else tf.extractall(stage) for name in ['attachments','avatars','custom_avatar','Smileys']: if (root/'forum'/name).is_dir():shutil.copytree(root/'forum'/name,stage/name,dirs_exist_ok=True,copy_function=shutil.copy2) for p in (root/'forum').glob('agreement*.txt'):copy(p,stage/p.name) copy(root/'forum/Settings.php',stage/'Settings.php');settings_patch(stage/'Settings.php',root,'english') # The supplied 1.1 database already has the newer PM table names. sql=stage/'upgrade_1-1.sql';t=sql.read_text() for old,newname in [('instant_messages','personal_messages'),('im_recipients','pm_recipients')]: if not info['old_pm'][old]:t=t.replace('RENAME TABLE {$db_prefix}'+old+'\nTO {$db_prefix}'+newname+';','') sql.write_text(t) sql=stage/'upgrade_2-0_mysql.sql';t=sql.read_text().replace("UPDATE {$db_prefix}settings\n\t\t\tSET variable = 'visual_verification_type'", "UPDATE IGNORE {$db_prefix}settings\n\t\t\tSET variable = 'visual_verification_type'");sql.write_text(t) # Keep Apache's existing identity/perms on the new tree. st=(root/'forum').stat() for p in [stage,*stage.rglob('*')]: try:os.chown(p,st.st_uid,st.st_gid) except PermissionError:pass if p.is_dir():os.chmod(p,0o775) else:os.chmod(p,0o664) os.chmod(stage/'Settings.php',0o640) (root/'mkportal/.smf21-migration').write_text('migration in progress\n') for rel in ['index.php','mkportal/admin/index.php']: p=root/rel; prefix="\n";p.write_text(prefix+p.read_text()) p=stage/'index.php';p.write_text("\n"+p.read_text()) switched=False try: os.replace(root/'forum',backup/'forum') try:os.replace(stage,root/'forum') except Exception: os.replace(backup/'forum',root/'forum');raise switched=True php_call(a.php,root,'prepare') logfile=backup/'upgrade.log' with logfile.open('w') as log: os.chmod(logfile,0o600) z=subprocess.run([a.php,str(root/'forum/upgrade.php'),'--path='+str(root/'forum'),'--language=english','--rebuild-settings'],cwd=root/'forum',stdout=log,stderr=subprocess.STDOUT) # Upgrader can exit zero after a database error. Check DB version and signatures too. php_call(a.php,root,'configure') after=json.loads(php_call(a.php,root,'snapshot'));(backup/'after.json').write_text(json.dumps(after,indent=2));os.chmod(backup/'after.json',0o600) if after!=before:raise RuntimeError('Data verification failed; automatic rollback will restore the original installation.') with tarfile.open(HERE/'upstream/smf_2-1-8_upgrade.tar.gz') as tf: member=next(m for m in tf.getmembers() if m.name.lstrip('./')=='index.php');(root/'forum/index.php').write_bytes(tf.extractfile(member).read()) for rel in checks['files']:copy(HERE/'files'/rel,root/rel) conf=root/'mkportal/conf_mk.php';text=conf.read_text();text=re.sub(r'\$MK_BOARD\s*=\s*[^;]+;',"$MK_BOARD = 'SMF21';",text);conf.write_text(text) settings_patch(root/'forum/Settings.php',root,'turkish') if (root/'mkportal/cache').exists():os.replace(root/'mkportal/cache',backup/'portal-cache') (root/'mkportal/cache').mkdir(mode=0o775,exist_ok=True) try:os.chown(root/'mkportal/cache',st.st_uid,st.st_gid) except PermissionError:pass # Retain upgrader code privately; never leave a runnable upgrader on the web. (backup/'upgrade-files').mkdir() for p in (root/'forum').glob('upgrade*'): if p.is_file():os.replace(p,backup/'upgrade-files'/p.name) (root/'mkportal/.smf21-migration').unlink(missing_ok=True) print('SUCCESS: SMF 2.1.8 + MKPortal SMF21 bridge installed. Verified record counts, IDs, member credentials and all portal records.') print('Backup and private log:',backup) except Exception: if switched: rollback(root,backup,a.php) if (backup/'portal-cache').exists(): shutil.rmtree(root/'mkportal/cache',ignore_errors=True);os.replace(backup/'portal-cache',root/'mkportal/cache') else: for p in (backup/'portal-files').rglob('*'): if p.is_file():copy(p,root/p.relative_to(backup/'portal-files')) (root/'mkportal/.smf21-migration').unlink(missing_ok=True) raise if __name__=='__main__': try:main() except Exception as e:print('ERROR:',e,file=sys.stderr);sys.exit(1)