diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0e581b..2261a6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,3 +28,7 @@ jobs: composer install --no-interaction --prefer-dist --no-progress - name: Lint executable run: php -l bin/mybb + - name: Verify the separately resolved core dependency + run: | + php -r 'require "vendor/autoload.php"; if (MyBB\Tooling\CoreRuntime::PACKAGE !== "mybb-tooling-core" || is_dir("packages/mybb-tooling")) exit(1);' + php bin/mybb --help diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..34a78a5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +/dist/ +/composer.local.* diff --git a/README.md b/README.md index 47ba86b..eb640af 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ # mybb-cli -The standalone repository includes `packages/mybb-tooling`, a source snapshot of the shared package. In the coordinated workspace, `src/` is canonical; `php tools/sync-shared-package.php` refreshes the snapshot before release, and CI verifies it matches. - -For local development or a standalone checkout, use `composer install` in this project; its shared tooling dependency is included under `packages/mybb-tooling/`. The coordinated workspace builds `mybb-cli.phar` together with the installer using `php -d phar.readonly=0 tools/build-phars.php VERSION [OUTPUT_DIR]` from the workspace root. Each PHAR embeds the shared library, has PHP's internal SHA-256 archive signature, and is accompanied by a SHA-256 sidecar for transfer-integrity checks. The sidecar is not an authenticity signature; only distribute a release after attaching artifacts to the project's trusted, provenance-verified release channel. V1 host support is PHP 8.2–8.4 on Ubuntu 24.04 x86_64, macOS 15 Intel, and macOS 15 Apple Silicon; Windows support is not claimed. +The shared library is a normal Composer dependency, `mybbresources/mybb-tooling >=0.1.0 <1.0.0`, resolved from its public Git repository. Run `composer install` in a standalone checkout. Composer owns the ignored `vendor/` directory; no library source snapshot is tracked here. In the coordinated core checkout, `php tools/install-component-dependencies.php` uses a temporary path repository pointing directly to canonical `src/`. The coordinated workspace builds `mybb-cli.phar` together with the installer using `php -d phar.readonly=0 tools/build-phars.php VERSION [OUTPUT_DIR]` from the workspace root. Each PHAR embeds the shared library, has PHP's internal SHA-256 archive signature, and is accompanied by a SHA-256 sidecar for transfer-integrity checks. The sidecar is not an authenticity signature; only distribute a release after attaching artifacts to the project's trusted, provenance-verified release channel. V1 host support is PHP 8.2–8.4 on Ubuntu 24.04 x86_64, macOS 15 Intel, and macOS 15 Apple Silicon; Windows support is not claimed. WP-CLI-style command line interface prototype for an installed MyBB site. It discovers the site root, validates the installation, and exposes safe administrative tasks without requiring routine manual edits or database queries. @@ -29,7 +27,7 @@ Exit codes are `0` for success, `1` for operation failure, `2` for invalid usage `plugin install` and `plugin update` first validate and preview the signed package without changing site files or creating package state. Review the returned file map, then use the returned `next_arguments` with `--yes` to apply that exact release and site. If the plan reports destination conflicts, those files remain untouched unless you deliberately repeat with both `--force --yes`; the output lists existing files that the forced update may replace. Catalog releases are fetched and verified again on the apply invocation, and immutable signed release metadata binds the package digest and version. -The CLI updates itself through its signed `mybb-toolchain` package, which owns the installed executable and shared library. Preview with `mybb plugin update mybb-toolchain [VERSION]`, review the file plan, then apply the returned command with `--yes`. The package manager stages, backs up, and transactionally replaces the running CLI files; the current PHP process finishes using its already-loaded code, and later invocations use the updated code. No separate self-updater or independent trust path is required. The catalog must publish a `mybb-toolchain` release, or an operator can supply a locally built signed manifest and archive through the standard package update form. +The CLI updates itself through its signed `mybb-tooling-core` package, which owns the installed executable and shared library. Preview with `mybb plugin update mybb-toolchain [VERSION]`, review the file plan, then apply the returned command with `--yes`. The package manager stages, backs up, and transactionally replaces the running CLI files; the current PHP process finishes using its already-loaded code, and later invocations use the updated code. No separate self-updater or independent trust path is required. The catalog must publish a `mybb-toolchain` release, or an operator can supply a locally built signed manifest and archive through the standard package update form. `plugin activate SLUG_OR_CODENAME` and `plugin deactivate SLUG_OR_CODENAME` first display a confirmation plan. Apply with `--yes`. Managed plugin files must still match their ownership-ledger digest; a directly installed plugin can be targeted by its top-level PHP codename. The confirmed operation loads MyBB's `global.php` runtime and invokes the standard plugin compatibility and lifecycle callbacks, which may change database-backed settings, templates, or plugin data. It then updates MyBB's plugin cache and the managed package's activation snapshot. Deactivation does not uninstall the plugin or delete data. Review plugin callbacks before confirming because lifecycle callbacks execute PHP code. @@ -39,4 +37,6 @@ Plugin activation and deactivation always load MyBB's runtime after explicit con **Current limits:** Database diagnostics and backups support MyBB's `mysqli` driver only; SQLite, PostgreSQL, and other adapters fail with a clear unsupported-driver error. `db check`, `db backup`, signed package update, transactional self-update of the installed CLI/toolchain, and plugin lifecycle have passed against a disposable MyBB 1.8.41 + MariaDB 11.4 site. Database restore is not implemented. Plugin search/info and install/update can use the catalog; local manifest and ZIP installs are also supported. External third-party callback behavior is not verified because no upstream plugin is included. The current latest-version fixture resolved to 1.8.41, the same as the supported floor; repeat the matrix against the next supported patch after it is released. -The distribution reference map pins production key `catalog-2026`. The signed toolchain alone owns the installed central trust ring; Admin CP reads that ring. A public pin does not authenticate a PHAR download or complete publication. See [release readiness](https://github.com/mybbresources/mybb-tooling-core/blob/main/RELEASING.md) for the fingerprint, rotation process, private candidate verification, and remaining release gates. +The distribution reference map pins production key `catalog-2026`. The signed core package alone owns the installed central trust ring; Admin CP reads that ring. A public pin does not authenticate a PHAR download or complete publication. See [release readiness](https://github.com/mybbresources/mybb-tooling-core/blob/main/RELEASING.md) for the fingerprint, rotation process, private candidate verification, and remaining release gates. + +Installed site CLI packages require the separately installed `mybb-tooling-core >=0.1.0 <1.0.0`; core integrity is checked before site commands. Composer installations and portable PHARs can also bootstrap or repair a site. See the [core dependency and legacy migration contract](https://github.com/mybbresources/mybb-tooling-core/blob/main/docs/SHARED-CONTRACTS.md#separate-core-dependency-manifest-schema-v2) for `--migrate-toolchain` and `core recover-migration --journal=PATH`. diff --git a/bin/mybb b/bin/mybb index e9308e4..d6383f0 100755 --- a/bin/mybb +++ b/bin/mybb @@ -9,6 +9,7 @@ use MyBB\Tooling\DatabaseTools; use MyBB\Tooling\PluginInventory; const EXIT_OK=0, EXIT_USAGE=2, EXIT_ERROR=1, EXIT_RETRYABLE=3; +const MYBB_CLI_CORE_REQUIREMENT='>=0.1.0 <1.0.0'; function out(array $data,bool $json): void { echo $json?json_encode($data,JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)."\n":MyBB\Tooling\TextTable::render($data); } function safeCliMessage(string $message):string{$clean=preg_replace('/[\x00-\x1f\x7f-\x9f]/u',' ',trim($message))??'Operation failed.';return substr($clean,0,500);} function stableOperationError(Throwable $e):string{$message=strtolower($e->getMessage());if(str_starts_with($message,'incompatible '))return 'incompatible_release';if(str_contains($message,'signing key has been revoked'))return 'signing_key_revoked';if(str_contains($message,'signing key is not trusted'))return 'untrusted_signing_key';if(str_contains($message,'manifest signature is invalid'))return 'invalid_signature';if(str_contains($message,'package size or sha-256'))return 'package_integrity_failure';if(str_contains($message,'unsupported manifest schema'))return 'unsupported_manifest_schema';return 'operation_failed';} @@ -83,11 +84,18 @@ function resetMyBBPassword(string $root,int $uid,string $password): array { return ['status'=>'password_reset','uid'=>$uid,'username'=>(string)$user['username'],'active_sessions_revoked'=>true,'password_value_returned'=>false,'plugin_output_suppressed'=>trim($hookOutput)!=='']; } finally {if(is_string($originalDirectory))@chdir($originalDirectory);} } -function args(array $argv): array { $o=['json'=>false,'dry'=>false,'force'=>false,'yes'=>false];$p=[];foreach(array_slice($argv,1) as $a){if($a==='--json'||$a==='--format=json')$o['json']=true;elseif($a==='--format=table')$o['json']=false;elseif($a==='--dry-run')$o['dry']=true;elseif($a==='--force')$o['force']=true;elseif($a==='--yes')$o['yes']=true;elseif(str_starts_with($a,'--path='))$o['path']=substr($a,7);elseif(str_starts_with($a,'--config='))$o['config']=substr($a,9);elseif(str_starts_with($a,'--catalog='))$o['catalog']=rtrim(substr($a,10),'/');elseif(str_starts_with($a,'--output='))$o['output']=substr($a,9);elseif(str_starts_with($a,'--password-file='))$o['password_file']=substr($a,16);elseif(str_starts_with($a,'--'))throw new InvalidArgumentException('Unknown option '.$a);else $p[]=$a;}return [$p,$o]; } +function args(array $argv): array { $o=['json'=>false,'dry'=>false,'force'=>false,'yes'=>false];$p=[];foreach(array_slice($argv,1) as $a){if($a==='--json'||$a==='--format=json')$o['json']=true;elseif($a==='--format=table')$o['json']=false;elseif($a==='--dry-run')$o['dry']=true;elseif($a==='--force')$o['force']=true;elseif($a==='--yes')$o['yes']=true;elseif($a==='--migrate-toolchain')$o['migrate_legacy']=true;elseif(str_starts_with($a,'--path='))$o['path']=substr($a,7);elseif(str_starts_with($a,'--config='))$o['config']=substr($a,9);elseif(str_starts_with($a,'--catalog='))$o['catalog']=rtrim(substr($a,10),'/');elseif(str_starts_with($a,'--output='))$o['output']=substr($a,9);elseif(str_starts_with($a,'--journal='))$o['journal']=substr($a,10);elseif(str_starts_with($a,'--password-file='))$o['password_file']=substr($a,16);elseif(str_starts_with($a,'--'))throw new InvalidArgumentException('Unknown option '.$a);else $p[]=$a;}return [$p,$o]; } try { - if(in_array('--help',$argv,true)||in_array('-h',$argv,true)){fwrite(STDOUT,"Usage: mybb COMMAND [ARGS] [--path=SITE|--config=SITE/inc/config.php] [--format=table|json]\nCommands: core status|check, plugin list|search|info|install|update|remove|activate|deactivate, user password-reset UID --password-file=PATH --yes, cache clear, config validate, db check, db backup --output=PATH [--dry-run]\n");exit(0);}[$p,$o]=args($argv); $cmd=array_shift($p)??'help';if($cmd==='help'){fwrite(STDOUT,"Usage: mybb COMMAND [ARGS] [--path=SITE|--config=SITE/inc/config.php] [--format=table|json]\nCommands: core status|check, plugin list|search|info|install|update|remove|activate|deactivate, user password-reset UID --password-file=PATH --yes, cache clear, config validate, db check, db backup --output=PATH [--dry-run]\n");exit(0);} $root=siteRoot($o['path']??null,$o['config']??null); $state=$root.'/inc/plugins/data/mybb-tooling'; $keyFile=$root.'/inc/plugins/mybb-tooling/config/trusted-keys.json';if(!is_file($keyFile))$keyFile=dirname(__DIR__).'/config/trusted-keys.json';$keys=json_decode((string)@file_get_contents($keyFile),true)?:[]; + if(in_array('--help',$argv,true)||in_array('-h',$argv,true)){fwrite(STDOUT,"Usage: mybb COMMAND [ARGS] [--path=SITE|--config=SITE/inc/config.php] [--format=table|json]\nCommands: core status|check|recover-migration, plugin list|search|info|install|update|remove|activate|deactivate, user password-reset UID --password-file=PATH --yes, cache clear, config validate, db check, db backup --output=PATH [--dry-run]\n");exit(0);}[$p,$o]=args($argv); $cmd=array_shift($p)??'help';if(($o['migrate_legacy']??false)&&!($cmd==='plugin'&&in_array($p[0]??'', ['install','update'],true)))throw new InvalidArgumentException('--migrate-toolchain applies only to installing the separate core package.');if($cmd==='help'){fwrite(STDOUT,"Usage: mybb COMMAND [ARGS] [--path=SITE|--config=SITE/inc/config.php] [--format=table|json]\nCommands: core status|check|recover-migration, plugin list|search|info|install|update|remove|activate|deactivate, user password-reset UID --password-file=PATH --yes, cache clear, config validate, db check, db backup --output=PATH [--dry-run]\n");exit(0);} $root=siteRoot($o['path']??null,$o['config']??null);$installedBinary=realpath($root.'/inc/plugins/data/mybb-tooling/bin/mybb');if($installedBinary!==false&&realpath(__FILE__)===$installedBinary){if(!class_exists('MyBB\\Tooling\\CoreRuntime'))throw new RuntimeException('Install or update the separate MyBB Tooling Core package first.');\MyBB\Tooling\CoreRuntime::assertInstalled($root,MYBB_CLI_CORE_REQUIREMENT);} $state=$root.'/inc/plugins/data/mybb-tooling'; $keyFile=$root.'/inc/plugins/mybb-tooling/config/trusted-keys.json';if(!is_file($keyFile))$keyFile=dirname(__DIR__).'/config/trusted-keys.json';$keys=json_decode((string)@file_get_contents($keyFile),true)?:[]; $pm=new PackageManager($root,$state,$keys); - if($cmd==='core'&&($p[0]??'')==='status') $result=['status'=>'ok','site_root'=>$root,'mybb_version'=>MyBB\Tooling\Compatibility::detectMybbVersion($root),'php'=>PHP_VERSION,'ledger'=>$state.'/installed.json']; + if($cmd==='core'&&($p[0]??'')==='recover-migration'){ + if(empty($o['journal']))throw new InvalidArgumentException('Use core recover-migration --journal=TRANSACTION/core-migration.json [--dry-run|--yes].'); + $preview=$pm->recoverLegacyMigration($o['journal'],true); + if(($preview['status']??'')==='rollback_incomplete')$result=$preview; + elseif($o['dry']||!$o['yes'])$result=['status'=>$o['dry']?'dry_run':'confirmation_required','confirmation_required'=>true,'plan'=>$preview,'next_arguments'=>['mybb','core','recover-migration','--path='.$root,'--journal='.$o['journal'],'--yes']]; + else $result=$pm->recoverLegacyMigration($o['journal']); + } + elseif($cmd==='core'&&($p[0]??'')==='status') $result=['status'=>'ok','site_root'=>$root,'mybb_version'=>MyBB\Tooling\Compatibility::detectMybbVersion($root),'php'=>PHP_VERSION,'ledger'=>$state.'/installed.json']; elseif($cmd==='core'&&($p[0]??'')==='check') $result=['site_root'=>$root,'php'=>PHP_VERSION,'config_readable'=>is_readable($root.'/inc/config.php'),'plugins_dir_writable'=>is_writable($root.'/inc/plugins'),'zip'=>extension_loaded('zip'),'sodium'=>extension_loaded('sodium')]; elseif($cmd==='plugin'&&($p[0]??'')==='list') { $ledger=is_file($state.'/installed.json')?json_decode((string)file_get_contents($state.'/installed.json'),true,512,JSON_THROW_ON_ERROR):[]; if(!is_array($ledger))throw new RuntimeException('Invalid installed plugin ledger'); $result=\MyBB\Tooling\PluginInventory::inspect($root,$ledger); } elseif($cmd==='plugin'&&in_array($p[0]??'', ['search','info'],true)){$catalog=new CatalogClient($o['catalog']??'https://plugins.mybbresources.com');$result=($p[0]==='search')?$catalog->list($p[1]??''):$catalog->plugin($p[1]??throw new InvalidArgumentException('Usage: plugin info SLUG'));} @@ -97,16 +105,16 @@ try { if(is_file($p[1])&&isset($p[2])&&is_file($p[2])){$manifestPath=$p[1];$zip=$p[2];$manifest=json_decode((string)file_get_contents($manifestPath),true,512,JSON_THROW_ON_ERROR);$sourceDescription='local manifest '.$manifestPath;$confirmationArgs=['mybb','plugin',$operation,$manifestPath,$zip];} else {$catalog=new CatalogClient($o['catalog']??'https://plugins.mybbresources.com');$slug=$p[1];$details=$catalog->plugin($slug);$releases=$details['releases']??[];if(!$releases)throw new RuntimeException('Plugin has no published compatible releases');$wanted=$p[2]??null;$release=null;foreach($releases as $candidate)if(!$wanted||($candidate['version']??'')===$wanted){$release=$candidate;break;}if(!$release)throw new RuntimeException('Requested published version was not found');$manifest=$release['manifest']??throw new RuntimeException('Catalog release has no manifest');$temporaryZip=$catalog->download($manifest);$zip=$temporaryZip;$sourceDescription='catalog release '.$slug.' '.($manifest['version']??'');$confirmationArgs=['mybb','plugin',$operation,$slug,(string)$manifest['version']];} try { - $plan=$pm->install($zip,$manifest,$o['force'],true); + $plan=$pm->install($zip,$manifest,$o['force'],true,(bool)($o['migrate_legacy']??false)); if($o['dry']){$result=$plan;} elseif(!$o['yes']) { $conflicts=$plan['conflicts']??[]; $overwrite=array_values(array_map(static fn(array $file):string=>$file['destination'],array_filter($plan['files']??[],static fn(array $file):bool=>($file['existing_sha256']??null)!==null))); - $nextArgs=$confirmationArgs; $nextArgs[]='--path='.$root; if(isset($o['catalog']))$nextArgs[]='--catalog='.$o['catalog']; if($o['force']||$conflicts)$nextArgs[]='--force'; if($o['json'])$nextArgs[]='--json'; $nextArgs[]='--yes'; + $nextArgs=$confirmationArgs;if($o['migrate_legacy']??false)$nextArgs[]='--migrate-toolchain'; $nextArgs[]='--path='.$root; if(isset($o['catalog']))$nextArgs[]='--catalog='.$o['catalog']; if($o['force']||$conflicts)$nextArgs[]='--force'; if($o['json'])$nextArgs[]='--json'; $nextArgs[]='--yes'; $result=['status'=>$conflicts&&!$o['force']?'conflicts':'confirmation_required','confirmation_required'=>true,'operation'=>$operation,'source'=>$sourceDescription,'slug'=>$plan['slug']??$manifest['slug'],'version'=>$plan['version']??$manifest['version'],'plan'=>$plan,'existing_files_to_replace'=>$overwrite,'next_step'=>$conflicts&&!$o['force']?'Review the conflict list; next_arguments explicitly select force and confirmation.':'Review the plan and run next_arguments to apply this exact release.','next_arguments'=>$nextArgs]; } elseif(($plan['conflicts']??[])&&!$o['force']) throw new RuntimeException('Modified destination files conflict with this release; review them with --dry-run, then use --force --yes only if you intend to replace those files.'); - else $result=$pm->install($zip,$manifest,$o['force'],false); + else $result=$pm->install($zip,$manifest,$o['force'],false,(bool)($o['migrate_legacy']??false)); } finally {if($temporaryZip!==null)@unlink($temporaryZip);} } elseif($cmd==='plugin'&&($p[0]??'')==='remove') { if(empty($p[1])) throw new InvalidArgumentException('Usage: plugin remove SLUG [--yes]'); $plan=$pm->remove($p[1],$o['force'],true); if($o['dry']||!$o['yes'])$result=['status'=>$o['dry']?'dry_run':'confirmation_required','confirmation_required'=>true,'files_to_remove'=>$plan['removed'],'modified_files_to_preserve'=>$plan['preserved'],'missing_files'=>$plan['missing'],'plugin_data'=>'preserved','activation_check'=>'MyBB runtime activation will be checked again before confirmed removal','next_command'=>'mybb plugin remove '.$p[1].' --yes']; else {assertManagedPluginInactiveForRemoval($root,$state,$p[1]);$result=$pm->remove($p[1],$o['force'],false);} } @@ -140,6 +148,6 @@ try { } elseif($cmd==='db'&&($p[0]??'')==='check') $result=(new DatabaseTools($root))->check(); elseif($cmd==='db'&&($p[0]??'')==='backup') { $dbTools=new DatabaseTools($root);$result=$o['dry']?$dbTools->previewBackup((string)($o['output']??'')):$dbTools->backup((string)($o['output']??'')); } - else throw new InvalidArgumentException('Commands: core status|check, plugin list|search|info|install|update|remove|activate|deactivate, user password-reset, cache clear, config validate, db check|backup'); - if($cmd==='plugin'&&($p[0]??'')==='list'&&!$o['json']){echo "SLUG\tVERSION\tACTIVE\tMANAGED\tFILE\n";foreach($result['plugins'] as $plugin)echo $plugin['slug']."\t".($plugin['version']??'unknown')."\t".($plugin['active']===null?'unknown':($plugin['active']?'yes':'no'))."\t".($plugin['managed']?'yes':'no')."\t".$plugin['file']."\n";echo "activation_cache: ".$result['activation_cache']."\n";}else out($result,$o['json']);if($cmd==='core'&&($p[0]??'')==='check'){foreach($result as $key=>$value)if(is_bool($value)&&!$value)exit(EXIT_ERROR);}exit(EXIT_OK); + else throw new InvalidArgumentException('Commands: core status|check|recover-migration, plugin list|search|info|install|update|remove|activate|deactivate, user password-reset, cache clear, config validate, db check|backup'); + if($cmd==='plugin'&&($p[0]??'')==='list'&&!$o['json']){echo "SLUG\tVERSION\tACTIVE\tMANAGED\tFILE\n";foreach($result['plugins'] as $plugin)echo $plugin['slug']."\t".($plugin['version']??'unknown')."\t".($plugin['active']===null?'unknown':($plugin['active']?'yes':'no'))."\t".($plugin['managed']?'yes':'no')."\t".$plugin['file']."\n";echo "activation_cache: ".$result['activation_cache']."\n";}else out($result,$o['json']);if($cmd==='core'&&($p[0]??'')==='check'){foreach($result as $key=>$value)if(is_bool($value)&&!$value)exit(EXIT_ERROR);}if(($result['status']??null)==='rollback_incomplete')exit(EXIT_ERROR);exit(EXIT_OK); } catch(InvalidArgumentException $e){$message=safeCliMessage($e->getMessage());if($o['json']??false)echo json_encode(['error'=>['code'=>'usage','message'=>$message,'retryable'=>false,'request_id'=>bin2hex(random_bytes(6))]],JSON_UNESCAPED_SLASHES)."\n";else fwrite(STDERR,$message."\n");exit(EXIT_USAGE);} catch(CatalogException $e){$message=safeCliMessage($e->getMessage());if($o['json']??false)echo json_encode(['error'=>['code'=>$e->errorCode,'message'=>$message,'retryable'=>$e->retryable,'request_id'=>$e->requestId??bin2hex(random_bytes(8))]],JSON_UNESCAPED_SLASHES)."\n";else fwrite(STDERR,$e->errorCode.': '.$message."\n");exit($e->retryable?EXIT_RETRYABLE:EXIT_ERROR);} catch(Throwable $e){$message=safeCliMessage($e->getMessage());$code=stableOperationError($e);if($o['json']??false)echo json_encode(['error'=>['code'=>$code,'message'=>$message,'retryable'=>false,'request_id'=>bin2hex(random_bytes(8))]],JSON_UNESCAPED_SLASHES)."\n";else fwrite(STDERR,$code.': '.$message."\n");exit(EXIT_ERROR);} diff --git a/composer.json b/composer.json index 691700f..d58aee0 100644 --- a/composer.json +++ b/composer.json @@ -1 +1,19 @@ -{"name":"mybbresources/mybb-cli","type":"project","license":"MIT","require":{"php":">=8.2","ext-curl":"*","mybbresources/mybb-tooling":"0.1.0"},"repositories":[{"type":"path","url":"packages/mybb-tooling","options":{"symlink":true,"versions":{"mybbresources/mybb-tooling":"0.1.0"}}}],"bin":["bin/mybb"]} +{ + "name": "mybbresources/mybb-cli", + "type": "project", + "license": "MIT", + "require": { + "php": ">=8.2", + "ext-curl": "*", + "mybbresources/mybb-tooling": ">=0.1.0 <1.0.0" + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/mybbresources/mybb-tooling-core" + } + ], + "bin": [ + "bin/mybb" + ] +} diff --git a/composer.lock b/composer.lock index 5d9be0e..f93780c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,15 +4,21 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "812a3b3b2e85f299806b7a022bbe21c5", + "content-hash": "d918eaeeae6334a033ebf6daff31fc64", "packages": [ { "name": "mybbresources/mybb-tooling", - "version": "0.1.0", + "version": "v0.1.0", + "source": { + "type": "git", + "url": "https://github.com/mybbresources/mybb-tooling-core.git", + "reference": "0b06e9ffce2a2fbc75196343b6fef76dde128da4" + }, "dist": { - "type": "path", - "url": "packages/mybb-tooling", - "reference": "b9ca831d5534666280b935c3132be1b2778b73da" + "type": "zip", + "url": "https://api.github.com/repos/mybbresources/mybb-tooling-core/zipball/0b06e9ffce2a2fbc75196343b6fef76dde128da4", + "reference": "0b06e9ffce2a2fbc75196343b6fef76dde128da4", + "shasum": "" }, "require": { "ext-json": "*", @@ -26,14 +32,20 @@ "MyBB\\Tooling\\": "src/" } }, + "scripts": { + "test": [ + "php tests/run.php" + ] + }, "license": [ "MIT" ], "description": "Shared secure package and catalog contracts for MyBB tooling", - "transport-options": { - "symlink": true, - "relative": true - } + "support": { + "source": "https://github.com/mybbresources/mybb-tooling-core/tree/v0.1.0", + "issues": "https://github.com/mybbresources/mybb-tooling-core/issues" + }, + "time": "2026-11-01T10:36:10+00:00" } ], "packages-dev": [], diff --git a/packages/mybb-tooling/LICENSE b/packages/mybb-tooling/LICENSE deleted file mode 100644 index f91ef74..0000000 --- a/packages/mybb-tooling/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 MyBBResources - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/mybb-tooling/composer.json b/packages/mybb-tooling/composer.json deleted file mode 100644 index 5c93a42..0000000 --- a/packages/mybb-tooling/composer.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "mybbresources/mybb-tooling", - "description": "Shared secure package and catalog contracts for MyBB tooling", - "type": "library", - "license": "MIT", - "require": { - "php": ">=8.2", - "ext-json": "*", - "ext-sodium": "*", - "ext-zip": "*" - }, - "autoload": { - "psr-4": { - "MyBB\\Tooling\\": "src/" - } - } -} diff --git a/packages/mybb-tooling/src/CatalogClient.php b/packages/mybb-tooling/src/CatalogClient.php deleted file mode 100644 index 625365b..0000000 --- a/packages/mybb-tooling/src/CatalogClient.php +++ /dev/null @@ -1,198 +0,0 @@ -origin = rtrim($origin, '/'); - } - - public function list(string $query = '', ?string $cursor = null): array - { - $url = $this->origin . '/api/v1/plugins?limit=50'; - if ($query !== '') $url .= '&q=' . rawurlencode($query); - if ($cursor !== null) $url .= '&cursor=' . rawurlencode($cursor); - return $this->json($url); - } - - public function plugin(string $slug): array - { - return $this->json($this->origin . '/api/v1/plugins/' . rawurlencode($slug)); - } - - public function release(string $slug, string $version): array - { - return $this->json($this->origin . '/api/v1/plugins/' . rawurlencode($slug) . '/releases/' . rawurlencode($version)); - } - - public function download(array $manifest): string - { - $url = $manifest['package']['url'] ?? null; - $expectedSize = $manifest['package']['size'] ?? null; - if (!is_string($url) || !str_starts_with($url, $this->origin . '/api/v1/')) { - throw new CatalogException('invalid_package_url', 'Catalog returned a package URL outside the configured origin.', false); - } - if (!is_int($expectedSize) || $expectedSize < 1 || $expectedSize > self::MAX_PACKAGE_BYTES) { - throw new CatalogException('package_integrity_failure', 'Catalog returned an invalid or oversized package declaration.', false); - } - - $tmp = tempnam(sys_get_temp_dir(), 'mybb-catalog-'); - if ($tmp === false) throw new CatalogException('local_io_error', 'Could not create a private package download file.', false); - $fp = @fopen($tmp, 'wb'); - if ($fp === false) { - @unlink($tmp); - throw new CatalogException('local_io_error', 'Could not open a private package download file.', false); - } - $ch = curl_init($url); - if ($ch === false) { - fclose($fp); - @unlink($tmp); - throw new CatalogException('catalog_transport_error', 'Could not initialize the HTTPS package request.', false); - } - - $downloaded = 0; - $limitExceeded = false; - curl_setopt_array($ch, [ - CURLOPT_FILE => $fp, - CURLOPT_FOLLOWLOCATION => false, - CURLOPT_CONNECTTIMEOUT => 8, - CURLOPT_TIMEOUT => 90, - CURLOPT_SSL_VERIFYPEER => true, - CURLOPT_SSL_VERIFYHOST => 2, - CURLOPT_USERAGENT => 'mybb-cli/0.1', - CURLOPT_NOPROGRESS => false, - CURLOPT_PROGRESSFUNCTION => static function ($resource, $downloadTotal, $current) use (&$downloaded, &$limitExceeded, $expectedSize): int { - $downloaded = (int)$current; - if ($downloaded > $expectedSize || $downloaded > self::MAX_PACKAGE_BYTES) { - $limitExceeded = true; - return 1; - } - return 0; - }, - ]); - $ok = curl_exec($ch); - $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); - $curlError = curl_error($ch); - $curlErrno = curl_errno($ch); - curl_close($ch); - fclose($fp); - - if ($ok === false) { - @unlink($tmp); - throw self::classifyTransportFailure($curlErrno, $curlError, $limitExceeded); - } - if ($status < 200 || $status >= 300) { - @unlink($tmp); - throw self::classifyHttpFailure($status, [], 'Package download was rejected by the catalog.'); - } - $actualSize = filesize($tmp); - if (!is_int($actualSize) || $actualSize !== $expectedSize) { - @unlink($tmp); - throw new CatalogException('package_integrity_failure', 'Downloaded package size does not match its signed manifest.', false, $status); - } - return $tmp; - } - - private function json(string $url): array - { - $body = ''; - $tooLarge = false; - $headers = []; - $ch = curl_init($url); - if ($ch === false) throw new CatalogException('catalog_transport_error', 'Could not initialize the HTTPS catalog request.', false); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => false, - CURLOPT_WRITEFUNCTION => static function ($resource, string $chunk) use (&$body, &$tooLarge): int { - if (strlen($body) + strlen($chunk) > self::MAX_JSON_BYTES) { - $tooLarge = true; - return 0; - } - $body .= $chunk; - return strlen($chunk); - }, - CURLOPT_HEADERFUNCTION => static function ($resource, string $line) use (&$headers): int { - $length = strlen($line); - $separator = strpos($line, ':'); - if ($separator !== false) $headers[strtolower(trim(substr($line, 0, $separator)))] = trim(substr($line, $separator + 1)); - return $length; - }, - CURLOPT_FOLLOWLOCATION => false, - CURLOPT_CONNECTTIMEOUT => 8, - CURLOPT_TIMEOUT => 20, - CURLOPT_SSL_VERIFYPEER => true, - CURLOPT_SSL_VERIFYHOST => 2, - CURLOPT_USERAGENT => 'mybb-cli/0.1', - CURLOPT_HTTPHEADER => ['Accept: application/json'], - ]); - $ok = curl_exec($ch); - $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); - $curlError = curl_error($ch); - $curlErrno = curl_errno($ch); - curl_close($ch); - - if ($ok === false) throw self::classifyTransportFailure($curlErrno, $curlError, $tooLarge); - $decoded = json_decode($body, true); - if ($status < 200 || $status >= 300) { - $error = is_array($decoded['error'] ?? null) ? $decoded['error'] : []; - throw self::classifyHttpFailure($status, $error, 'Catalog request failed.', $headers['x-request-id'] ?? null); - } - if (!is_array($decoded) || !isset($decoded['data']) || !is_array($decoded['data'])) { - throw new CatalogException('invalid_api_response', 'Catalog returned an invalid API v1 response.', false, $status, $headers['x-request-id'] ?? null); - } - return $decoded['data']; - } - - /** Build stable errors from the shared JSON error envelope and HTTP status. */ - public static function classifyHttpFailure(int $status, array $error = [], string $fallbackMessage = 'Catalog request failed.', ?string $headerRequestId = null): CatalogException - { - $defaults = match ($status) { - 401, 403 => ['authorization_failed', false], - 404 => ['not_found', false], - 409 => ['conflict', false], - 410 => ['release_revoked', false], - 422 => ['validation_error', false], - 429 => ['rate_limited', true], - default => $status >= 500 ? ['catalog_server_error', true] : ['catalog_http_error', false], - }; - $code = $error['code'] ?? $defaults[0]; - if (!is_string($code) || !preg_match('/^[a-z][a-z0-9_]{0,63}$/', $code)) $code = $defaults[0]; - $message = $error['message'] ?? $fallbackMessage; - if (!is_string($message) || trim($message) === '') $message = $fallbackMessage; - $retryable = is_bool($error['retryable'] ?? null) ? $error['retryable'] : $defaults[1]; - $requestId = $error['request_id'] ?? $headerRequestId; - if (!is_string($requestId) || !preg_match('/^[A-Za-z0-9._:-]{1,128}$/', $requestId)) $requestId = null; - return new CatalogException($code, self::safeMessage($message), $retryable, $status, $requestId); - } - - /** Classify transport failures without exposing raw libcurl text to normal CLI output. */ - public static function classifyTransportFailure(int $curlErrno, string $curlMessage, bool $sizeLimitExceeded = false): CatalogException - { - if ($sizeLimitExceeded) return new CatalogException('response_too_large', 'Catalog response exceeds the allowed byte limit.', false); - $transient = [CURLE_COULDNT_RESOLVE_HOST, CURLE_COULDNT_CONNECT, CURLE_OPERATION_TIMEDOUT, CURLE_RECV_ERROR, CURLE_SEND_ERROR, CURLE_GOT_NOTHING, CURLE_PARTIAL_FILE]; - if (in_array($curlErrno, $transient, true)) return new CatalogException('catalog_unavailable', 'Catalog could not be reached; retry when the network or service is available.', true); - // The peer-verification errno was renamed between libcurl versions; include its legacy - // numeric value and the portable PHP constants available on supported curl builds. - $tlsErrors = [CURLE_SSL_CONNECT_ERROR, CURLE_SSL_CERTPROBLEM, CURLE_SSL_CACERT, CURLE_SSL_CACERT_BADFILE, CURLE_SSL_PINNEDPUBKEYNOTMATCH, 51]; - if (in_array($curlErrno, $tlsErrors, true) || str_contains(strtolower($curlMessage), 'certificate') || str_contains(strtolower($curlMessage), 'ssl')) return new CatalogException('catalog_tls_error', 'Catalog TLS verification failed; the response was not trusted.', false); - return new CatalogException('catalog_transport_error', 'The HTTPS catalog request failed before a valid response was received.', false); - } - - private static function safeMessage(string $message): string - { - $message = preg_replace('/[\x00-\x1f\x7f-\x9f]/u', ' ', trim($message)) ?? 'Catalog request failed.'; - return substr($message, 0, 500); - } -} diff --git a/packages/mybb-tooling/src/CatalogException.php b/packages/mybb-tooling/src/CatalogException.php deleted file mode 100644 index ab3a2c3..0000000 --- a/packages/mybb-tooling/src/CatalogException.php +++ /dev/null @@ -1,25 +0,0 @@ -errorCode.'] '.($this->retryable ? 'Retryable: ' : '').$this->getMessage(); - if ($this->requestId !== null) $message .= ' (request ID: '.$this->requestId.')'; - return $message; - } -} diff --git a/packages/mybb-tooling/src/CatalogPagination.php b/packages/mybb-tooling/src/CatalogPagination.php deleted file mode 100644 index c31eb1a..0000000 --- a/packages/mybb-tooling/src/CatalogPagination.php +++ /dev/null @@ -1,33 +0,0 @@ - 50) { - throw new \InvalidArgumentException('Catalog page size must be between 1 and 50.'); - } - - $query = ['limit' => $limit]; - if ($search !== '') $query['q'] = $search; - if ($cursor !== '') $query['cursor'] = $cursor; - return $baseUrl . '/plugins?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986); - } - - public static function adminNextPageUrl(string $search, string $cursor): string - { - if ($cursor === '' || strlen($cursor) > 512) { - throw new \InvalidArgumentException('Catalog pagination cursor is invalid.'); - } - $query = ['module' => 'config-mybbresources_repository', 'cursor' => $cursor]; - if ($search !== '') $query['q'] = $search; - return 'index.php?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986); - } -} diff --git a/packages/mybb-tooling/src/Compatibility.php b/packages/mybb-tooling/src/Compatibility.php deleted file mode 100644 index 9cd5cac..0000000 --- a/packages/mybb-tooling/src/Compatibility.php +++ /dev/null @@ -1,17 +0,0 @@ -$mybbVersion,'php'=>$phpVersion] as $component=>$actual){$range=(string)($requirements[$component]??'');if($range===''||!self::satisfies($actual,$range))throw new RuntimeException("Incompatible {$component} version {$actual}; release requires ".($range?:'a declared range'));} - } - public static function satisfies(string $actual,string $range):bool { - self::validateRange($range);$constraints=preg_split('/\s+/',trim($range)); - foreach($constraints as $constraint){if(!preg_match('/^(>=|<=|>|<|=|\^|~)?(\d+(?:\.\d+){0,2})(?:\.(x|\*))?$/i',$constraint,$m))throw new RuntimeException('Unsupported compatibility range expression: '.$constraint);$op=$m[1]?:'=';$target=$m[2];if(isset($m[3])&&$m[3]!==''){if($op!=='=')throw new RuntimeException('Wildcard compatibility ranges only support exact prefix matching');$actualParts=explode('.',$actual);foreach(explode('.',$target) as $index=>$part)if(!isset($actualParts[$index])||$actualParts[$index]!==$part)return false;continue;}$cmp=version_compare($actual,$target);$parts=array_map('intval',explode('.',$target));$upper=null;if($op==='^'){$upper=$parts[0]>0?($parts[0]+1).'.0.0':(($parts[1]??0)>0?'0.'.($parts[1]+1).'.0':'0.0.'.(($parts[2]??0)+1));}elseif($op==='~'){$upper=count($parts)>=2?$parts[0].'.'.($parts[1]+1).'.0':($parts[0]+1).'.0.0';}$ok=match($op){'>='=>$cmp>=0,'<='=>$cmp<=0,'>'=>$cmp>0,'<'=>$cmp<0,'='=>$cmp===0,'^'=>$cmp>=0&&version_compare($actual,(string)$upper,'<'),'~'=>$cmp>=0&&version_compare($actual,(string)$upper,'<'),default=>false};if(!$ok)return false;}return true; - } - public static function validateRange(string $range):void{$constraints=preg_split('/\s+/',trim($range));if(!$constraints||$constraints===[''])throw new RuntimeException('Compatibility range cannot be empty');foreach($constraints as $constraint){if(!preg_match('/^(>=|<=|>|<|=|\^|~)?(\d+(?:\.\d+){0,2})(?:\.(x|\*))?$/i',$constraint,$m))throw new RuntimeException('Unsupported compatibility range expression: '.$constraint);if(isset($m[3])&&$m[3]!==''&&isset($m[1])&&$m[1]!==''&&$m[1]!=='=')throw new RuntimeException('Wildcard compatibility ranges only support exact prefix matching');}} - public static function detectMybbVersion(string $root):string { - $file=$root.'/inc/class_core.php';if(!is_file($file))throw new RuntimeException('Cannot determine MyBB version: inc/class_core.php is missing');$source=(string)file_get_contents($file);if(preg_match('/(?:\$version|MYBB_VERSION)\s*=\s*[\'"]([0-9]+\.[0-9]+\.[0-9]+)[\'"]/i',$source,$m))return $m[1];throw new RuntimeException('Cannot safely determine the installed MyBB version'); - } -} diff --git a/packages/mybb-tooling/src/DatabaseTools.php b/packages/mybb-tooling/src/DatabaseTools.php deleted file mode 100644 index 6b6fdaf..0000000 --- a/packages/mybb-tooling/src/DatabaseTools.php +++ /dev/null @@ -1,177 +0,0 @@ -connect(); - try { - $tables = count($this->mybbTables($db, $prefix)); - } catch (Throwable) { - throw new RuntimeException('Database connection succeeded, but MyBB tables could not be listed.'); - } finally { - $db->close(); - } - if ($tables === 0) throw new RuntimeException('Database connection succeeded, but no tables matching this MyBB site prefix were found.'); - return ['status' => 'connected', 'driver' => 'mysqli', 'tables' => $tables]; - } - - /** Write a restrictive, atomic SQL dump outside the public MyBB document root. */ - public function backup(string $output): array - { - $target = $this->resolveOutput($output); - [$db, $prefix] = $this->connect(); - $temp = dirname($target) . DIRECTORY_SEPARATOR . '.' . basename($target) . '.' . bin2hex(random_bytes(8)) . '.tmp'; - $stream = @fopen($temp, 'x'); - if ($stream === false) { - $db->close(); - throw new RuntimeException('Cannot create a temporary backup in the selected directory.'); - } - @chmod($temp, 0600); - try { - $this->write($stream, "-- MyBB database backup\n-- Created: " . gmdate(DATE_ATOM) . "\nSET NAMES utf8mb4;\nSET FOREIGN_KEY_CHECKS=0;\n\n"); - foreach ($this->mybbTables($db, $prefix) as $table) { - $quoted = '`' . str_replace('`', '``', $table) . '`'; - $create = $db->query('SHOW CREATE TABLE ' . $quoted)->fetch_row(); - if (!is_array($create) || !isset($create[1])) throw new RuntimeException('Could not read a table definition.'); - $this->write($stream, 'DROP TABLE IF EXISTS ' . $quoted . ";\n" . $create[1] . ";\n"); - $data = $db->query('SELECT * FROM ' . $quoted, MYSQLI_USE_RESULT); - $fields = $data->fetch_fields(); - $columns = implode(', ', array_map(static fn($field): string => '`' . str_replace('`', '``', $field->name) . '`', $fields)); - while ($record = $data->fetch_row()) { - $values = array_map(static function ($value, $field) use ($db): string { - if ($value === null) return 'NULL'; - $binaryTypes = [MYSQLI_TYPE_TINY_BLOB, MYSQLI_TYPE_MEDIUM_BLOB, MYSQLI_TYPE_LONG_BLOB, MYSQLI_TYPE_BLOB, MYSQLI_TYPE_BIT]; - if (in_array($field->type, $binaryTypes, true)) return "X'" . bin2hex((string)$value) . "'"; - return "'" . $db->real_escape_string((string)$value) . "'"; - }, $record, $fields); - $this->write($stream, 'INSERT INTO ' . $quoted . ' (' . $columns . ') VALUES (' . implode(', ', $values) . ");\n"); - } - $data->free(); - $this->write($stream, "\n"); - } - $this->write($stream, "SET FOREIGN_KEY_CHECKS=1;\n"); - if (!fflush($stream)) throw new RuntimeException('Could not flush backup data.'); - fclose($stream); - $stream = null; - // link() publishes atomically and fails if another process created the target meanwhile. - if (!@link($temp, $target)) throw new RuntimeException('Could not publish the completed backup file without overwriting an existing path.'); - if (!@unlink($temp)) throw new RuntimeException('Backup was published, but the temporary hard link could not be removed: ' . $temp); - @chmod($target, 0600); - return ['status' => 'backed_up', 'path' => $target, 'bytes' => filesize($target), 'sha256' => hash_file('sha256', $target)]; - } catch (Throwable $e) { - if (is_resource($stream)) fclose($stream); - @unlink($temp); - if ($e instanceof RuntimeException) throw $e; - throw new RuntimeException('Database backup failed; the incomplete temporary file was removed.'); - } finally { - $db->close(); - } - } - - /** Validate the destination without loading credentials or opening a database connection. */ - public function previewBackup(string $output): array - { - return ['status' => 'dry_run', 'would_create' => $this->resolveOutput($output), 'database_connection_attempted' => false]; - } - - /** @return array{mysqli} */ - private function connect(): array - { - $root = realpath($this->siteRoot); - if (!$root || !is_file($root . '/inc/config.php')) throw new RuntimeException('Validated MyBB configuration is unavailable.'); - try { - $config = (static function (string $file): array { - $config = []; - require $file; - return is_array($config) ? $config : []; - })($root . '/inc/config.php'); - } catch (Throwable) { - throw new RuntimeException('Could not read MyBB database settings from inc/config.php.'); - } - $database = $config['database'] ?? []; - if (strtolower((string)($database['type'] ?? '')) !== 'mysqli') { - throw new RuntimeException('Database tools currently support MyBB mysqli sites only.'); - } - if (!extension_loaded('mysqli')) throw new RuntimeException('The PHP mysqli extension is required for this site.'); - // MyBB supports separate read/write connection credentials. Diagnostics and dumps use the writer. - $settings = array_replace($database, is_array($database['write'] ?? null) ? $database['write'] : []); - foreach (['database', 'table_prefix'] as $required) { - if (!isset($settings[$required]) || !is_string($settings[$required])) throw new RuntimeException('MyBB database settings are incomplete.'); - } - try { - mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); - $hostname = (string)($settings['hostname'] ?? 'localhost'); - $port = (int)($settings['port'] ?? 3306); - if (preg_match('/^\[([0-9a-fA-F:]+)\]:(\d+)$/', $hostname, $match)) { - $hostname = $match[1]; - if (!isset($settings['port'])) $port = (int)$match[2]; - } elseif (substr_count($hostname, ':') === 1 && preg_match('/^([^:]+):(\d+)$/', $hostname, $match)) { - $hostname = $match[1]; - if (!isset($settings['port'])) $port = (int)$match[2]; - } - $db = new mysqli( - $hostname, - (string)($settings['username'] ?? ''), - (string)($settings['password'] ?? ''), - (string)$settings['database'], - $port, - (string)($settings['socket'] ?? '') ?: null - ); - $db->set_charset('utf8mb4'); - return [$db, (string)($database['table_prefix'] ?? '')]; - } catch (Throwable) { - throw new RuntimeException('Database connection failed. Check MyBB database settings and server availability; credentials were not included.'); - } - } - - /** @return list only tables owned by this MyBB installation */ - private function mybbTables(mysqli $db, string $prefix): array - { - $result = $db->query('SHOW TABLES'); - $tables = []; - while ($row = $result->fetch_row()) { - $name = (string)$row[0]; - if ($prefix === '' || str_starts_with($name, $prefix)) $tables[] = $name; - } - $result->free(); - return $tables; - } - - private function resolveOutput(string $output): string - { - if ($output === '') throw new RuntimeException('Usage: db backup --output=PATH'); - $path = str_starts_with($output, DIRECTORY_SEPARATOR) ? $output : getcwd() . DIRECTORY_SEPARATOR . $output; - $parent = realpath(dirname($path)); - $root = realpath($this->siteRoot); - if ($parent === false || $root === false || !is_dir($parent) || is_link($path)) throw new RuntimeException('Backup directory must already exist and the output cannot be a symlink.'); - $resolved = $parent . DIRECTORY_SEPARATOR . basename($path); - if (file_exists($resolved)) throw new RuntimeException('Backup output already exists; choose a new path.'); - if ($resolved === $root || str_starts_with($resolved, rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR)) { - throw new RuntimeException('Database backups must be written outside the public MyBB site directory.'); - } - if (!is_writable($parent)) throw new RuntimeException('Backup directory is not writable.'); - return $resolved; - } - - /** @param resource $stream */ - private function write($stream, string $data): void - { - $offset = 0; - while ($offset < strlen($data)) { - $written = fwrite($stream, substr($data, $offset)); - if ($written === false || $written === 0) throw new RuntimeException('Could not write the database backup.'); - $offset += $written; - } - } -} diff --git a/packages/mybb-tooling/src/Manifest.php b/packages/mybb-tooling/src/Manifest.php deleted file mode 100644 index 4afb8e9..0000000 --- a/packages/mybb-tooling/src/Manifest.php +++ /dev/null @@ -1,98 +0,0 @@ -getExternalAttributesIndex($index,$opsys,$attributes)||$opsys!==\ZipArchive::OPSYS_UNIX)return; - $type=($attributes>>16)&0170000;if($type===0)return; - if($type===0120000)throw new RuntimeException('Archive symlinks are forbidden'); - if(($directory&&$type===0040000)||(!$directory&&$type===0100000))return; - throw new RuntimeException('Archive contains an unsupported special file type'); - } - - public static function canonical(array $value): string - { - $sort = static function (&$v) use (&$sort): void { if (is_array($v)) { if (!array_is_list($v)) ksort($v, SORT_STRING); foreach ($v as &$child) $sort($child); } }; - $sort($value); - return json_encode($value, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR); - } - - public static function unsignedPayload(array $manifest): string - { - unset($manifest['package']['signature']); - return self::canonical($manifest); - } - - public static function verifySignature(array $manifest, array $trustedKeys, int $now = 0): void - { - $package = $manifest['package']; $key = $trustedKeys[$package['key_id']] ?? null; - if (!$key) throw new RuntimeException('Signing key is not trusted'); - if (isset($key['expires_at']) && $key['expires_at'] < ($now ?: time())) throw new RuntimeException('Signing key has expired'); - if (($key['revoked'] ?? false) === true) throw new RuntimeException('Signing key has been revoked'); - if (isset($key['not_before']) && $key['not_before'] > ($now ?: time())) throw new RuntimeException('Signing key is not active yet'); - $public = base64_decode((string)$key['public_key'], true); $signature = base64_decode((string)$package['signature'], true); - if ($public === false || strlen($public) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES || $signature === false || strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES) throw new RuntimeException('Malformed signing key or signature'); - if (!sodium_crypto_sign_verify_detached($signature, self::unsignedPayload($manifest), $public)) throw new RuntimeException('Manifest signature is invalid'); - } -} diff --git a/packages/mybb-tooling/src/OfficialMyBBArchive.php b/packages/mybb-tooling/src/OfficialMyBBArchive.php deleted file mode 100644 index a8de8e7..0000000 --- a/packages/mybb-tooling/src/OfficialMyBBArchive.php +++ /dev/null @@ -1,48 +0,0 @@ -isDir() && !$entry->isLink()) { - $consider($entry->getPathname()); - } - } - - if (count($candidates) !== 1) { - throw new RuntimeException('Official archive does not contain exactly one recognizable MyBB root.'); - } - return $candidates[0]; - } -} diff --git a/packages/mybb-tooling/src/OfficialMyBBRelease.php b/packages/mybb-tooling/src/OfficialMyBBRelease.php deleted file mode 100644 index 7e6f762..0000000 --- a/packages/mybb-tooling/src/OfficialMyBBRelease.php +++ /dev/null @@ -1,12 +0,0 @@ -\s* <',$releasePage);$text=html_entity_decode(strip_tags($separatedTags??$releasePage),ENT_QUOTES|ENT_HTML5,'UTF-8');if(!preg_match('/\bMyBB\s+'.preg_quote($version,'/').'(?![0-9.])/i',$text))throw new RuntimeException('Official version page did not identify the requested MyBB release');$start=stripos($text,'Full Package');$end=$start===false?false:stripos($text,'Changed Files',$start);if($start===false||$end===false)throw new RuntimeException('Official version page did not contain an unambiguous full-package section');$section=substr($text,$start,$end-$start);if(!preg_match('/\bsha\s*-?\s*512\s*:\s*([a-f0-9]{128})\b/i',$section,$m))throw new RuntimeException('Official version page did not publish a full-package SHA-512 checksum');return strtolower($m[1]);} -} diff --git a/packages/mybb-tooling/src/OfficialMyBBUpgrade.php b/packages/mybb-tooling/src/OfficialMyBBUpgrade.php deleted file mode 100644 index 13c9be0..0000000 --- a/packages/mybb-tooling/src/OfficialMyBBUpgrade.php +++ /dev/null @@ -1,267 +0,0 @@ -isFile() || $item->isLink()) continue; - $relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($item->getPathname(), strlen($release) + 1)); - Manifest::validateRelativePath($relative); - if (self::preservePath($relative)) continue; - $destination = self::safePath($site, $relative, false); - if (file_exists($destination) && !is_file($destination)) throw new RuntimeException('Upgrade destination is not a regular file: ' . $relative); - $actions[] = [ - 'path' => $relative, - 'action' => is_file($destination) ? 'replace' : 'add', - 'old_sha256' => is_file($destination) ? hash_file('sha256', $destination) : null, - 'old_mode' => is_file($destination) ? (fileperms($destination) & 0777) : null, - 'new_sha256' => hash_file('sha256', $item->getPathname()), - 'bytes' => filesize($item->getPathname()), - ]; - } - usort($actions, static fn(array $a, array $b): int => strcmp($a['path'], $b['path'])); - if (!$actions) throw new RuntimeException('The staged release contains no applicable MyBB core files.'); - return $actions; - } - - public static function validateBackupDirectory(string $siteRoot, string $requested): string - { - $site = realpath($siteRoot); - if ($site === false || $requested === '') throw new RuntimeException('Upgrade requires --backup-dir outside the MyBB site.'); - $path = str_starts_with($requested, DIRECTORY_SEPARATOR) ? $requested : getcwd() . DIRECTORY_SEPARATOR . $requested; - if (str_ends_with($path, DIRECTORY_SEPARATOR)) throw new RuntimeException('Backup directory must be a new directory path, not a trailing slash.'); - $parent = realpath(dirname($path)); - if ($parent === false || !is_dir($parent) || !is_writable($parent) || is_link($path)) throw new RuntimeException('Backup directory parent must exist, be writable, and not use a symlink target.'); - $resolved = $parent . DIRECTORY_SEPARATOR . basename($path); - if (file_exists($resolved)) throw new RuntimeException('Backup directory already exists; choose a new path.'); - if ($resolved === $site || str_starts_with($resolved, rtrim($site, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR)) throw new RuntimeException('Upgrade backup directory must be outside the public MyBB site directory.'); - return $resolved; - } - - public static function apply(string $siteRoot, string $releaseRoot, string $backupDirectory, array $verification = []): array - { - $site = realpath($siteRoot); - if ($site === false) throw new RuntimeException('Cannot resolve MyBB site root.'); - $backup = self::validateBackupDirectory($site, $backupDirectory); - $plan = self::plan($site, $releaseRoot); - $release = realpath($releaseRoot); - if ($release === false || !mkdir($backup, 0700)) throw new RuntimeException('Could not create the timestamped upgrade backup directory.'); - $journalPath = $backup . '/transaction.json'; - $journal = array_merge(['schema_version'=>1,'kind'=>'mybb-core-upgrade','site'=>$site,'release'=> $release,'status'=>'backing_up','started_at'=>gmdate(DATE_ATOM),'backup_directory'=>$backup,'files'=>$plan],$verification); - foreach($journal['files'] as &$file){$file['status']='planned';$file['backup_status']=$file['old_sha256']===null?'not_required':'pending';}unset($file); - self::persist($journalPath, $journal); - try { - // Complete and verify every required backup before replacing any live file. - foreach ($plan as $index => $action) { - if ($action['old_sha256'] === null) continue; - $backupPath = $backup . '/files/' . str_replace('/', DIRECTORY_SEPARATOR, $action['path']); - $backupParent=dirname(substr($backupPath,strlen($backup)+1));self::ensureDirectory($backup,$backupParent==='.'?'files':$backupParent); - $source = self::safePath($site, $action['path'], false); - if (!hash_equals($action['old_sha256'], (string)hash_file('sha256', $source)) || !copy($source, $backupPath) || !hash_equals($action['old_sha256'], (string)hash_file('sha256', $backupPath))) { - throw new RuntimeException('Could not create or verify backup for ' . $action['path']); - } - @chmod($backupPath, 0600); - $journal['files'][$index]['backup_status']='backed_up'; - self::persist($journalPath, $journal); - } - $journal['status'] = 'applying'; - foreach($journal['files'] as &$file)unset($file['backup_status']);unset($file); - self::persist($journalPath, $journal); - foreach ($plan as $index => $action) { - self::ensureDirectory($site, dirname($action['path'])); - $destination = self::safePath($site, $action['path'], true); - if ($action['old_sha256'] !== null && (!is_file($destination) || !hash_equals($action['old_sha256'], (string)hash_file('sha256', $destination)))) { - throw new RuntimeException('Site file changed after preflight; refusing to overwrite ' . $action['path']); - } - if ($action['old_sha256'] === null && file_exists($destination)) throw new RuntimeException('New core file collided with an existing path: ' . $action['path']); - $source = $release . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $action['path']); - if (!hash_equals($action['new_sha256'], (string)hash_file('sha256', $source))) throw new RuntimeException('Staged release file changed after validation: ' . $action['path']); - $tmp = dirname($destination) . DIRECTORY_SEPARATOR . '.' . basename($destination) . '.mybb-' . bin2hex(random_bytes(8)) . '.tmp'; - self::copyVerifiedExclusive($source, $tmp, $action['new_sha256']); - @chmod($tmp, fileperms($source) & 0777); - if ($action['old_sha256'] === null) { - if (!@link($tmp, $destination)) { @unlink($tmp); throw new RuntimeException('New core file collided with an existing path: ' . $action['path']); } - @unlink($tmp); - } elseif (!@rename($tmp, $destination)) { @unlink($tmp); throw new RuntimeException('Could not atomically replace upgrade file: ' . $action['path']); } - $journal['files'][$index]['status'] = 'applied'; - self::persist($journalPath, $journal); - } - $journal['status'] = 'complete'; - $journal['completed_at'] = gmdate(DATE_ATOM); - self::persist($journalPath, $journal); - return ['status'=>'upgraded_files','site'=>$site,'backup_directory'=>$backup,'journal'=>$journalPath,'files'=>count($plan),'replaced'=>count(array_filter($plan,static fn(array $f):bool=>$f['action']==='replace')),'added'=>count(array_filter($plan,static fn(array $f):bool=>$f['action']==='add')),'database_upgrade'=>'pending_official_web_upgrader']; - } catch (Throwable $e) { - try { $recovery = self::rollback($journalPath); } - catch (Throwable $rollbackError) { throw new RuntimeException('Upgrade failed; recovery data is preserved at ' . $backup . ' and rollback needs attention: ' . $rollbackError->getMessage(), 0, $e); } - if ($recovery['status'] === 'rollback_incomplete') throw new RuntimeException('Upgrade failed; some changed files were preserved for review. Recovery data: ' . $backup, 0, $e); - throw new RuntimeException('Upgrade failed; the previous file set was restored. Recovery data: ' . $backup, 0, $e); - } - } - - /** Continue a journaled upgrade only when the verified release and every live path still match the transaction. */ - public static function resume(string $siteRoot, string $releaseRoot, string $journalPath, bool $dryRun = false): array - { - $site=realpath($siteRoot);$release=realpath($releaseRoot);$journalReal=is_link($journalPath)?false:realpath($journalPath); - if($site===false||$release===false||$journalReal===false||!is_file($journalReal))throw new RuntimeException('Upgrade resume requires the site, verified release, and regular transaction journal.'); - $journal=json_decode((string)file_get_contents($journalReal),true,512,JSON_THROW_ON_ERROR); - if(!is_array($journal)||($journal['schema_version']??null)!==1||($journal['kind']??null)!=='mybb-core-upgrade'||($journal['site']??null)!==$site||!is_array($journal['files']??null))throw new RuntimeException('Upgrade transaction journal is invalid or belongs to another site.'); - if(!in_array(($journal['status']??''),['backing_up','applying'],true))throw new RuntimeException('Only an interrupted backing-up or applying upgrade can be resumed.'); - $backup=realpath((string)($journal['backup_directory']??'')); - if($backup===false||dirname($journalReal)!==$backup||$backup===$site||str_starts_with($backup,rtrim($site,DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR))throw new RuntimeException('Upgrade recovery paths do not match the transaction journal.'); - $releaseMap=self::releaseFileHashes($release);$paths=[];$pendingBackups=[];$pendingFiles=[]; - if(!$journal['files'])throw new RuntimeException('Upgrade transaction journal has no validated file plan.'); - foreach($journal['files'] as $index=>$file){ - if(!is_array($file)||!is_string($file['path']??null)||!is_string($file['new_sha256']??null)||!preg_match('/^[a-f0-9]{64}$/',$file['new_sha256'])||!in_array(($file['action']??''),['add','replace'],true)||!in_array(($file['status']??''),['planned','applied'],true))throw new RuntimeException('Upgrade journal contains an invalid file plan.'); - $path=$file['path'];Manifest::validateRelativePath($path);$key=strtolower($path);if(isset($paths[$key]))throw new RuntimeException('Upgrade journal contains duplicate normalized file paths.');$paths[$key]=true; - $old=$file['old_sha256']??null;if($old!==null&&(!is_string($old)||!preg_match('/^[a-f0-9]{64}$/',$old)))throw new RuntimeException('Upgrade journal contains an invalid original digest.'); - if(($old===null)!==($file['action']==='add'))throw new RuntimeException('Upgrade journal action does not match its original digest.'); - if(!isset($releaseMap[$path])||!hash_equals($file['new_sha256'],$releaseMap[$path]))throw new RuntimeException('Verified release does not match the interrupted upgrade plan: '.$path); - $destination=self::inspectionPath($site,$path);if(file_exists($destination)&&!is_file($destination))throw new RuntimeException('An upgrade path is no longer a regular file: '.$path);$current=is_file($destination)?(string)hash_file('sha256',$destination):null; - if($old===null){if($current!==null&&!hash_equals($file['new_sha256'],$current))throw new RuntimeException('A new core path was changed after interruption; refusing to overwrite '.$path);} - elseif($current===null||(!hash_equals($old,$current)&&!hash_equals($file['new_sha256'],$current)))throw new RuntimeException('A core path was changed after interruption; refusing to overwrite '.$path); - $backupFile=self::inspectionPath($backup,'files/'.$path); - if($old!==null){ - if(file_exists($backupFile)&&!is_file($backupFile))throw new RuntimeException('An upgrade backup path is not a regular file: '.$path); - if(is_file($backupFile)){if(!hash_equals($old,(string)hash_file('sha256',$backupFile)))throw new RuntimeException('An upgrade backup failed digest verification: '.$path);} - elseif(($journal['status']??'')==='applying'||$current===null||!hash_equals($old,$current))throw new RuntimeException('A required original-file backup is missing: '.$path); - else $pendingBackups[]=$path; - } - if($current===null||!hash_equals($file['new_sha256'],$current))$pendingFiles[]=$path; - } - if(count($releaseMap)!==count($paths))throw new RuntimeException('Verified release file set differs from the interrupted upgrade plan.'); - $result=['site'=>$site,'backup_directory'=>$backup,'journal'=>$journalReal,'pending_backups'=>$pendingBackups,'pending_files'=>$pendingFiles,'release_files'=>count($paths)]; - if($dryRun)return ['status'=>'dry_run']+$result; - - try{ - if(($journal['status']??'')==='backing_up'){ - foreach($journal['files'] as $index=>$file){if(($file['old_sha256']??null)===null)continue;$backupFile=self::inspectionPath($backup,'files/'.$file['path']);if(is_file($backupFile))continue; - $source=self::inspectionPath($site,$file['path']);if(!is_file($source)||!hash_equals($file['old_sha256'],(string)hash_file('sha256',$source)))throw new RuntimeException('Site file changed before its original backup could be completed: '.$file['path']); - $backupParent=dirname('files/'.$file['path']);self::ensureDirectory($backup,$backupParent==='.'?'files':$backupParent);if(!copy($source,$backupFile)||!hash_equals($file['old_sha256'],(string)hash_file('sha256',$backupFile)))throw new RuntimeException('Could not create or verify the missing original backup: '.$file['path']);@chmod($backupFile,0600);$journal['files'][$index]['backup_status']='backed_up';self::persist($journalReal,$journal); - } - $journal['status']='applying';foreach($journal['files'] as &$file)unset($file['backup_status']);unset($file);self::persist($journalReal,$journal); - } - foreach($journal['files'] as $index=>$file){ - $path=$file['path'];self::ensureDirectory($site,dirname($path));$destination=self::safePath($site,$path,true);$current=is_file($destination)?(string)hash_file('sha256',$destination):null; - if($current!==null&&hash_equals($file['new_sha256'],$current)){$journal['files'][$index]['status']='applied';self::persist($journalReal,$journal);continue;} - $old=$file['old_sha256']??null;if($old===null){if(file_exists($destination))throw new RuntimeException('New core file collided during resume: '.$path);}elseif($current===null||!hash_equals($old,$current))throw new RuntimeException('Site file changed during resume; refusing to overwrite '.$path); - $source=$release.DIRECTORY_SEPARATOR.str_replace('/',DIRECTORY_SEPARATOR,$path);if(!is_file($source)||!hash_equals($file['new_sha256'],(string)hash_file('sha256',$source)))throw new RuntimeException('Verified release file changed during resume: '.$path); - $tmp=dirname($destination).DIRECTORY_SEPARATOR.'.'.basename($destination).'.mybb-resume-'.bin2hex(random_bytes(8)).'.tmp';self::copyVerifiedExclusive($source,$tmp,$file['new_sha256']);@chmod($tmp,fileperms($source)&0777); - if($old===null){if(!@link($tmp,$destination)){@unlink($tmp);throw new RuntimeException('New core file collided during resume: '.$path);}@unlink($tmp);}elseif(!@rename($tmp,$destination)){@unlink($tmp);throw new RuntimeException('Could not atomically replace upgrade file during resume: '.$path);} - $journal['files'][$index]['status']='applied';self::persist($journalReal,$journal); - } - $journal['status']='complete';$journal['completed_at']=gmdate(DATE_ATOM);self::persist($journalReal,$journal); - return ['status'=>'upgraded_files','site'=>$site,'backup_directory'=>$backup,'journal'=>$journalReal,'files'=>count($journal['files']),'replaced'=>count(array_filter($journal['files'],static fn(array $f):bool=>$f['action']==='replace')),'added'=>count(array_filter($journal['files'],static fn(array $f):bool=>$f['action']==='add')),'database_upgrade'=>'pending_official_web_upgrader']; - }catch(Throwable $e){ - try{$recovery=self::rollback($journalReal);}catch(Throwable $rollbackError){throw new RuntimeException('Upgrade resume failed; recovery data is preserved at '.$backup.' and rollback needs attention: '.$rollbackError->getMessage(),0,$e);} - if($recovery['status']==='rollback_incomplete')throw new RuntimeException('Upgrade resume failed; changed files were preserved for review. Recovery data: '.$backup,0,$e); - throw new RuntimeException('Upgrade resume failed; the previous file set was restored. Recovery data: '.$backup,0,$e); - } - } - - public static function rollback(string $journalPath): array - { - $journal = json_decode((string)@file_get_contents($journalPath), true, 512, JSON_THROW_ON_ERROR); - if (!is_array($journal) || ($journal['schema_version'] ?? null) !== 1 || ($journal['kind'] ?? null) !== 'mybb-core-upgrade' || !is_array($journal['files'] ?? null)) throw new RuntimeException('Upgrade transaction journal is invalid.'); - if (($journal['status'] ?? '') === 'complete') throw new RuntimeException('Upgrade is committed; use a new upgrade rather than rolling back a completed transaction.'); - $site = realpath((string)($journal['site'] ?? '')); - $backup = realpath((string)($journal['backup_directory'] ?? dirname($journalPath))); - if ($site === false || $backup === false || dirname(realpath($journalPath) ?: '') !== $backup) throw new RuntimeException('Upgrade recovery paths do not match the transaction journal.'); - $restored=[];$removed=[];$preserved=[]; - foreach (array_reverse($journal['files']) as $file) { - $relative=(string)($file['path']??''); - try { Manifest::validateRelativePath($relative); $destination=self::safePath($site,$relative,true); } - catch (Throwable) { $preserved[]=$relative?:'(invalid path)'; continue; } - if (!file_exists($destination)) { - if ($file['old_sha256']===null) continue; - $backupFile=self::inspectionPath($backup,'files/'.$relative); - if (!is_file($backupFile)||!hash_equals((string)$file['old_sha256'],(string)hash_file('sha256',$backupFile))) { $preserved[]=$relative; continue; } - self::ensureDirectory($site,dirname($relative)); - $tmp=$destination.'.rollback-'.bin2hex(random_bytes(6)).'.tmp'; - if (self::tryCopyVerifiedExclusive($backupFile,$tmp,(string)$file['old_sha256'])) { @chmod($tmp,(int)($file['old_mode']??0644)); if(@rename($tmp,$destination))$restored[]=$relative;else{$preserved[]=$relative;@unlink($tmp);} } - else { $preserved[]=$relative; } - continue; - } - $current=(string)@hash_file('sha256',$destination); - if (hash_equals((string)$file['new_sha256'],$current)) { - if ($file['old_sha256']===null) { if (@unlink($destination)) $removed[]=$relative; else $preserved[]=$relative; continue; } - $backupFile=self::inspectionPath($backup,'files/'.$relative); - if (!is_file($backupFile)||!hash_equals((string)$file['old_sha256'],(string)hash_file('sha256',$backupFile))) { $preserved[]=$relative; continue; } - self::ensureDirectory($site,dirname($relative)); - $tmp=$destination.'.rollback-'.bin2hex(random_bytes(6)).'.tmp'; - if (self::tryCopyVerifiedExclusive($backupFile,$tmp,(string)$file['old_sha256'])) { @chmod($tmp,(int)($file['old_mode']??0644)); if(@rename($tmp,$destination))$restored[]=$relative;else{$preserved[]=$relative;@unlink($tmp);} } - else { $preserved[]=$relative; } - continue; - } - if ($file['old_sha256']!==null && hash_equals((string)$file['old_sha256'],$current)) continue; - $preserved[]=$relative; - } - $journal['status']=$preserved?'rollback_incomplete':'rolled_back';$journal['rollback_restored']=$restored;$journal['rollback_removed']=$removed;$journal['rollback_preserved']=$preserved;$journal['recovered_at']=gmdate(DATE_ATOM);self::persist($journalPath,$journal); - return ['status'=>$journal['status'],'restored'=>$restored,'removed'=>$removed,'preserved'=>$preserved,'journal'=>$journalPath]; - } - - private static function preservePath(string $path): bool - { - return $path==='inc/config.php'||$path==='inc/settings.php'||str_starts_with($path,'uploads/')||$path==='uploads'||str_starts_with($path,'cache/')||$path==='cache'||str_starts_with($path,'inc/plugins/')||$path==='inc/plugins'||str_starts_with($path,'admin/backups/')||$path==='admin/backups'; - } - - private static function safePath(string $root,string $relative,bool $allowMissing): string - { - Manifest::validateRelativePath($relative);$cursor=$root;$parts=explode('/',$relative); - foreach($parts as $index=>$part){$cursor.=DIRECTORY_SEPARATOR.$part;if(is_link($cursor))throw new RuntimeException('Upgrade path traverses a symlink: '.$relative);if($index$part){$cursor.=DIRECTORY_SEPARATOR.$part;if($missing)continue;if(is_link($cursor))throw new RuntimeException('Upgrade path traverses a symlink: '.$relative);if($index */ - private static function releaseFileHashes(string $release): array - { - $files=[];foreach(new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($release,\FilesystemIterator::SKIP_DOTS)) as $item){if(!$item->isFile()||$item->isLink())continue;$path=str_replace(DIRECTORY_SEPARATOR,'/',substr($item->getPathname(),strlen($release)+1));Manifest::validateRelativePath($path);if(self::preservePath($path))continue;if(isset($files[$path]))throw new RuntimeException('Verified release contains duplicate normalized paths.');$files[$path]=(string)hash_file('sha256',$item->getPathname());}return $files; - } - - private static function ensureDirectory(string $root,string $relative): void - { - if ($relative==='.'||$relative==='') return; - $cursor=$root; - foreach(explode('/',$relative) as $part){$cursor.=DIRECTORY_SEPARATOR.$part;if(is_link($cursor))throw new RuntimeException('Upgrade directory is a symlink: '.$relative);if(!is_dir($cursor)&&!@mkdir($cursor,0755))throw new RuntimeException('Could not create upgrade directory: '.$relative);$real=realpath($cursor);if($real===false||!str_starts_with($real,rtrim($root,DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR))throw new RuntimeException('Upgrade directory resolves outside its root: '.$relative);} - } - - private static function persist(string $path,array $journal): void - { - $tmp=$path.'.'.bin2hex(random_bytes(6)).'.tmp';$json=json_encode($journal,JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR);if(file_put_contents($tmp,$json,LOCK_EX)!==strlen($json))throw new RuntimeException('Could not persist upgrade transaction journal');@chmod($tmp,0600);if(!rename($tmp,$path)){@unlink($tmp);throw new RuntimeException('Could not publish upgrade transaction journal');} - } - - private static function copyVerifiedExclusive(string $source,string $destination,string $expectedHash): void - { - if (!self::tryCopyVerifiedExclusive($source,$destination,$expectedHash)) throw new RuntimeException('Could not stage and verify upgrade file: ' . basename($destination)); - } - - private static function tryCopyVerifiedExclusive(string $source,string $destination,string $expectedHash): bool - { - $input=@fopen($source,'rb');if($input===false)return false;$output=@fopen($destination,'xb');if($output===false){fclose($input);return false;} - $copied=stream_copy_to_stream($input,$output);$flushed=fflush($output);fclose($input);fclose($output); - if($copied===false||!$flushed||!is_file($destination)||!hash_equals($expectedHash,(string)hash_file('sha256',$destination))){@unlink($destination);return false;} - return true; - } -} diff --git a/packages/mybb-tooling/src/PackageManager.php b/packages/mybb-tooling/src/PackageManager.php deleted file mode 100644 index 96493ac..0000000 --- a/packages/mybb-tooling/src/PackageManager.php +++ /dev/null @@ -1,129 +0,0 @@ -resolveRoot(false);return $dryRun?$this->installUnlocked($zipPath,$manifest,$force,true,false):$this->withLock(fn() => $this->installUnlocked($zipPath,$manifest,$force,false,false)); } - - public function installBootstrap(string $zipPath,array $manifest,?callable $onStateWrite=null,?callable $onTransactionCreated=null): array - { $this->resolveRoot(true);return $this->withLock(fn() => $this->installUnlocked($zipPath,$manifest,false,false,true,$onStateWrite,$onTransactionCreated)); } - - private function installUnlocked(string $zipPath, array $manifest, bool $force, bool $dryRun,bool $bootstrap,?callable $onStateWrite=null,?callable $onTransactionCreated=null): array - { - Manifest::validate($manifest); Manifest::verifySignature($manifest, $this->trustedKeys); - if (filesize($zipPath) !== $manifest['package']['size'] || !hash_equals($manifest['package']['sha256'], hash_file('sha256', $zipPath))) throw new RuntimeException('Package size or SHA-256 does not match manifest'); - $root = $this->resolveRoot($bootstrap); $records = $this->readLedger(!$dryRun); $owned = []; $newDestinations=[];$conflicts=[]; - Compatibility::assert($manifest['requirements'],Compatibility::detectMybbVersion($root),PHP_VERSION); - $archive = new ZipArchive(); if ($archive->open($zipPath) !== true) throw new RuntimeException('Cannot open plugin archive'); - try { - if ($archive->numFiles > $this->maxEntries) throw new RuntimeException('Archive contains too many entries'); - $declared = []; foreach ($manifest['files'] as $f) $declared[$f['source']] = $f; - $seen = []; $total = 0; - for ($i=0; $i<$archive->numFiles; $i++) { - $stat = $archive->statIndex($i); $name = $stat['name']; - $directoryEntry=str_ends_with($name, '/');$checkedName=$directoryEntry?substr($name,0,-1):$name;Manifest::validateRelativePath($checkedName); - Manifest::validateArchiveEntryType($archive,$i,$directoryEntry); - if ($directoryEntry) continue; - if (isset($seen[$name])) throw new RuntimeException('Duplicate archive entry'); $seen[$name] = true; - $total += (int)$stat['size']; if ($total > $this->maxExpandedBytes) throw new RuntimeException('Expanded archive exceeds size limit'); - if (!isset($declared[$name])) throw new RuntimeException("Undeclared archive file: {$name}"); - } - if (array_diff_key($declared, $seen)) throw new RuntimeException('Archive is missing manifest-declared files'); - foreach ($manifest['files'] as $file) { - $newDestinations[$file['destination']]=true; - $destination = $this->safeDestination($root, $file['destination']); - if(file_exists($destination)&&!is_file($destination))throw new RuntimeException("Destination is not a regular file: {$file['destination']}"); - $current = is_file($destination) ? hash_file('sha256', $destination) : null; - $prior = $records[$manifest['slug']]['files'][$file['destination']]['sha256'] ?? null; - if ($current !== null && $current !== $prior && !$force) { - if (!$dryRun) throw new RuntimeException("Destination exists or was modified: {$file['destination']}"); - $conflicts[]=$file['destination']; - } - $owned[] = ['source'=>$file['source'],'destination'=>$file['destination'],'sha256'=>hash('sha256', $archive->getFromName($file['source'])),'existing_sha256'=>$current]; - } - $removeOld=[];$preserveOld=[]; - foreach(($records[$manifest['slug']]['files']??[]) as $path=>$oldFile)if(!isset($newDestinations[$path])){$dest=$this->safeDestination($root,$path);if(!is_file($dest))continue;if(hash_file('sha256',$dest)!==$oldFile['sha256']&&!$force)$preserveOld[$path]=$oldFile;else $removeOld[]=$path;} - if ($dryRun) return ['status'=>$conflicts?'conflicts':'dry_run','slug'=>$manifest['slug'],'version'=>$manifest['version'],'files'=>$owned,'conflicts'=>$conflicts,'obsolete_files_to_remove'=>$removeOld,'modified_obsolete_files_to_preserve'=>array_keys($preserveOld)]; - return $this->apply($archive, $manifest, $owned, $root, $records,$removeOld,$preserveOld,$onStateWrite,$onTransactionCreated); - } finally { $archive->close(); } - } - - public function remove(string $slug, bool $force = false, bool $dryRun = false): array - { $this->resolveRoot();return $dryRun?$this->removeUnlocked($slug,$force,true):$this->withLock(fn() => $this->removeUnlocked($slug,$force,false)); } - - /** Persist the latest cache-confirmed activation state for an installed package. */ - public function setActivationState(string $slug, bool $active): array - { $this->resolveRoot();return $this->withLock(function()use($slug,$active){$ledger=$this->readLedger();if(!isset($ledger[$slug]))throw new RuntimeException('Plugin is not tracked as installed');$ledger[$slug]['active']=$active;$this->writeLedger($ledger);return ['slug'=>$slug,'active'=>$active];}); } - - private function removeUnlocked(string $slug, bool $force, bool $dryRun): array - { - $root=$this->resolveRoot(); $ledger=$this->readLedger(!$dryRun); $record=$ledger[$slug]??null; if (!$record) throw new RuntimeException('Plugin is not tracked as installed'); - if(($record['active']??false)===true)throw new RuntimeException('Plugin is recorded as active; deactivate it before uninstalling.'); - // The local activation snapshot can be unavailable on database-backed MyBB caches. - // Refuse a positively active result here; callers must verify unknown state against - // MyBB's runtime before applying removal. - foreach(PluginInventory::inspect($root,$ledger)['plugins'] as $plugin)if(($plugin['managed']??false)&&($plugin['slug']??null)===$slug&&($plugin['active']??null)===true)throw new RuntimeException('Plugin is active in MyBB; deactivate it before uninstalling.'); - $remove=[]; $modified=[]; $missing=[]; - foreach ($record['files'] as $path=>$file) { $dest=$this->safeDestination($root,$path); if (!is_file($dest)) { $missing[]=$path;continue; } if (hash_file('sha256',$dest)!==$file['sha256']) { if($force)$remove[]=$path;else{$modified[]=$path;continue;} } else $remove[]=$path; } - if (!$remove && $modified && !$missing) return ['status'=>'preserved_modified_files','removed'=>[],'preserved'=>$modified]; - if ($dryRun) return ['status'=>'dry_run','removed'=>$remove,'preserved'=>$modified,'missing'=>$missing]; - $backup=$this->newTransaction('remove-'.$slug); $removed=[]; - try { - foreach ($remove as $path) { $dest=$this->safeDestination($root,$path); $backupFile=$backup.'/backup/'.$path; if (!is_dir(dirname($backupFile))) mkdir(dirname($backupFile),0700,true); if (!rename($dest,$backupFile)) throw new RuntimeException("Could not stage removal: {$path}"); $removed[]=$path; } - foreach(array_merge($removed,$missing) as $path)unset($ledger[$slug]['files'][$path]); - $preserved=$force?[]:$modified; - if(empty($ledger[$slug]['files']))unset($ledger[$slug]);else $ledger[$slug]['preserved_modified_files']=$preserved; - $this->writeLedger($ledger); - return ['status'=>$preserved?'removed_with_preserved_files':'removed','removed'=>$removed,'preserved'=>$preserved,'backup'=>$backup]; - } catch (\Throwable $e) { foreach (array_reverse($removed) as $path) { $src=$backup.'/backup/'.$path; $dst=$this->safeDestination($root,$path); if (is_file($src)) { @mkdir(dirname($dst),0755,true); @rename($src,$dst); } } throw new RuntimeException('Removal failed; rollback attempted; recovery at '.$backup,0,$e); } - } - - private function apply(ZipArchive $zip, array $m, array $files, string $root, array $ledger,array $removeOld,array $preserveOld,?callable $onStateWrite=null,?callable $onTransactionCreated=null): array - { - $txn=$this->newTransaction($m['slug'],$onTransactionCreated); $written=[]; $oldLedger=$ledger; - try { - foreach ($files as $f) { - $dest=$this->safeDestination($root,$f['destination']); $temp=$txn.'/stage/'.hash('sha256',$f['destination']); - if (!is_dir(dirname($temp))) mkdir(dirname($temp),0700,true); - $contents=$zip->getFromName($f['source']); if ($contents===false) throw new RuntimeException('Cannot read archive entry'); file_put_contents($temp,$contents,LOCK_EX); - if (hash_file('sha256',$temp)!==$f['sha256']) throw new RuntimeException('Staged file digest mismatch'); - if (is_file($dest)) { $backup=$txn.'/backup/'.$f['destination']; @mkdir(dirname($backup),0700,true); if (!copy($dest,$backup)) throw new RuntimeException("Could not back up {$f['destination']}"); } - $written[]=['destination'=>$f['destination'],'backup'=>is_file($dest)]; - @mkdir(dirname($dest),0755,true); if (!rename($temp,$dest)) throw new RuntimeException("Could not install {$f['destination']}"); - } - foreach($removeOld as $path){$dest=$this->safeDestination($root,$path);if(!is_file($dest))continue;$backup=$txn.'/backup/'.$path;@mkdir(dirname($backup),0700,true);if(!rename($dest,$backup))throw new RuntimeException("Could not stage obsolete owned file {$path}");$written[]=['destination'=>$path,'backup'=>true];} - $ledger[$m['slug']]=['version'=>$m['version'],'package_sha256'=>$m['package']['sha256'],'installed_at'=>gmdate(DATE_ATOM),'active'=>(bool)($ledger[$m['slug']]['active']??false),'files'=>[]]; - foreach ($files as $f) $ledger[$m['slug']]['files'][$f['destination']]=['sha256'=>$f['sha256'],'role'=>$this->roleFor($m,$f['source'])]; - foreach($preserveOld as $path=>$oldFile)$ledger[$m['slug']]['files'][$path]=$oldFile; - $this->writeLedger($ledger,$onStateWrite); file_put_contents($txn.'/COMMITTED',gmdate(DATE_ATOM)); - return ['status'=>'installed','slug'=>$m['slug'],'version'=>$m['version'],'files'=>array_column($files,'destination'),'obsolete_files_removed'=>$removeOld,'modified_obsolete_files_preserved'=>array_keys($preserveOld),'transaction'=>$txn]; - } catch (\Throwable $e) { - $failures=[]; foreach (array_reverse($written) as $f) { try{$dest=$this->safeDestination($root,$f['destination']);if($f['backup']){$backup=$txn.'/backup/'.$f['destination'];@mkdir(dirname($dest),0755,true);if(!@copy($backup,$dest))$failures[]=$f['destination'];}elseif(is_file($dest)&&!@unlink($dest))$failures[]=$f['destination'];}catch(\Throwable){$failures[]=$f['destination'];} } - try{$this->writeLedger($oldLedger,$onStateWrite);}catch(\Throwable){$failures[]='installed.json';} - file_put_contents($txn.'/FAILED',json_encode(['error'=>$e->getMessage(),'rollback_failures'=>$failures])); - throw new RuntimeException('Install failed; rollback '.($failures?'incomplete for '.implode(', ',$failures):'completed').'; recovery data: '.$txn,0,$e); - } - } - - private function roleFor(array $m,string $source): string { foreach($m['files'] as $f) if($f['source']===$source) return $f['role']; return 'other'; } - private function resolveRoot(bool $bootstrap=false): string { $root=realpath($this->siteRoot); if(!$root || !is_dir($root))throw new RuntimeException('Path is not a validated MyBB site');if(is_file($root.'/inc/config.php'))return $root;if($bootstrap&&is_file($root.'/inc/config.default.php')&&is_file($root.'/inc/class_core.php')&&is_file($root.'/global.php')&&is_file($root.'/install/index.php'))return $root;throw new RuntimeException('Path is not a configured MyBB site (inc/config.php missing)'); } - private function safeDestination(string $root,string $relative): string { Manifest::validateRelativePath($relative); $path=$root.DIRECTORY_SEPARATOR.str_replace('/',DIRECTORY_SEPARATOR,$relative); if(is_link($path))throw new RuntimeException('Destination is a symlink and cannot be managed'); $cursor=$root; foreach(explode('/',dirname($relative)) as $part) { if($part==='.') continue; $cursor.=DIRECTORY_SEPARATOR.$part; if(is_link($cursor)) { $real=realpath($cursor); if(!$real || !str_starts_with($real,$root.DIRECTORY_SEPARATOR)) throw new RuntimeException('Destination traverses a symlink outside site root'); } } return $path; } - private function withLock(callable $work): mixed { $this->prepareStateRoot();$h=fopen($this->stateRoot.'/manager.lock','c');if(!$h||!flock($h,LOCK_EX))throw new RuntimeException('Cannot lock package state');try{return $work();}finally{flock($h,LOCK_UN);fclose($h);} } - private function prepareStateRoot(bool $create=true):void { - $root=realpath($this->siteRoot);if(!$root)throw new RuntimeException('Cannot resolve validated MyBB site root');$lexicalRoot=rtrim($this->siteRoot,DIRECTORY_SEPARATOR);$statePath=rtrim($this->stateRoot,DIRECTORY_SEPARATOR); - if(str_starts_with($statePath,$lexicalRoot.DIRECTORY_SEPARATOR)){$relative=substr($statePath,strlen($lexicalRoot)+1);$cursor=$root;foreach(explode(DIRECTORY_SEPARATOR,$relative) as $part){if($part===''||$part==='.'||$part==='..')throw new RuntimeException('Invalid package state path');$cursor.=DIRECTORY_SEPARATOR.$part;if(is_link($cursor))throw new RuntimeException('Package state path cannot traverse symlinks');if(file_exists($cursor)){if(!is_dir($cursor))throw new RuntimeException('Package state path component is not a directory');}elseif($create){if(!mkdir($cursor,0700)&&!is_dir($cursor))throw new RuntimeException('Cannot create package state directory');}else{return;}if(is_link($cursor))throw new RuntimeException('Package state path cannot traverse symlinks');}} - else{if(is_link($this->stateRoot))throw new RuntimeException('Package state directory cannot be a symlink');if(file_exists($this->stateRoot)&&!is_dir($this->stateRoot))throw new RuntimeException('Package state path component is not a directory');if(!is_dir($this->stateRoot)&&$create&&!mkdir($this->stateRoot,0700,true)&&!is_dir($this->stateRoot))throw new RuntimeException('Cannot create package state directory');} - } - private function stateFile(): string { if(is_link($this->stateRoot))throw new RuntimeException('Package state directory cannot be a symlink');if(!is_dir($this->stateRoot) && !mkdir($this->stateRoot,0700,true) && !is_dir($this->stateRoot)) throw new RuntimeException('Cannot create state directory');@chmod($this->stateRoot,0700);$deny=$this->stateRoot.'/.htaccess';if(!is_file($deny))@file_put_contents($deny,"Require all denied\n");$index=$this->stateRoot.'/index.html';if(!is_file($index))@file_put_contents($index,'');return $this->stateRoot.'/installed.json'; } - private function readLedger(bool $createIfMissing=true): array { if($createIfMissing){$p=$this->stateFile();}else{$this->prepareStateRoot(false);$p=$this->stateRoot.'/installed.json';if(is_link($p))throw new RuntimeException('Installed plugin ledger cannot be a symlink');}if(!is_file($p)) return []; $v=json_decode((string)file_get_contents($p),true,512,JSON_THROW_ON_ERROR); if(!is_array($v)) throw new RuntimeException('Invalid installed ledger'); return $v; } - private function writeLedger(array $v,?callable $onWrite=null): void { $p=$this->stateFile();$bytes=json_encode($v,JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR);if($onWrite!==null)$onWrite($p,hash('sha256',$bytes));$tmp=$p.'.'.bin2hex(random_bytes(6)).'.tmp';if(file_put_contents($tmp,$bytes,LOCK_EX)!==strlen($bytes))throw new RuntimeException('Could not write installed ledger');chmod($tmp,0600);if(!rename($tmp,$p)) throw new RuntimeException('Could not atomically update installed ledger'); } - private function newTransaction(string $name,?callable $onCreated=null): string { $dir=$this->stateRoot.'/transactions/'.preg_replace('/[^a-z0-9-]/i','-', $name).'-'.gmdate('YmdHis').'-'.bin2hex(random_bytes(3));if($onCreated!==null)$onCreated($dir);if(!mkdir($dir,0700,true)) throw new RuntimeException('Cannot create transaction directory'); return $dir; } -} diff --git a/packages/mybb-tooling/src/PluginInventory.php b/packages/mybb-tooling/src/PluginInventory.php deleted file mode 100644 index e99c72d..0000000 --- a/packages/mybb-tooling/src/PluginInventory.php +++ /dev/null @@ -1,70 +0,0 @@ -,activation_cache:string} */ - public static function inspect(string $siteRoot, array $ledger): array - { - $root = realpath($siteRoot); - if ($root === false) throw new \RuntimeException('Cannot resolve MyBB site root.'); - $active = self::readActivePlugins($root . '/cache/plugins.php'); - $plugins = []; - $seen = []; - foreach (glob($root . '/inc/plugins/*.php') ?: [] as $filePath) { - if (is_link($filePath) || !is_file($filePath)) continue; - $file = basename($filePath); - if ($file === 'index.html') continue; - $name = substr($file, 0, -4); - $record = null; - foreach ($ledger as $slug => $entry) { - foreach (($entry['files'] ?? []) as $ownedPath => $metadata) { - if ($ownedPath === 'inc/plugins/' . $file && ($metadata['role'] ?? '') === 'plugin') { - $record = ['slug' => (string)$slug, 'version' => isset($entry['version']) ? (string)$entry['version'] : null]; - break 2; - } - } - } - $slug = $record['slug'] ?? $name; - if (isset($seen[$slug])) continue; - $seen[$slug] = true; - $plugins[] = ['slug' => $slug, 'file' => $file, 'version' => $record['version'] ?? null, - 'active' => $active === null ? null : in_array($name, $active, true), 'managed' => $record !== null]; - } - usort($plugins, static fn(array $a, array $b): int => strcmp($a['slug'], $b['slug'])); - return ['plugins' => $plugins, 'activation_cache' => $active === null ? 'unavailable' : 'available']; - } - - /** A genuinely absent initial cache is safe to initialize on an explicit activation; malformed caches remain unavailable. */ - public static function activationCacheIsMissing(string $siteRoot): bool - { - $root = realpath($siteRoot); - if ($root === false) return false; - $cacheDirectory = $root . '/cache'; - if (is_link($cacheDirectory) || !is_dir($cacheDirectory)) return false; - $path = $cacheDirectory . '/plugins.php'; - return !file_exists($path) && !is_link($path); - } - - /** @return list|null null means unavailable or not safely parseable. */ - private static function readActivePlugins(string $path): ?array - { - if (!is_file($path) || is_link($path) || !is_readable($path)) return null; - $source = file_get_contents($path); - if (!is_string($source) || strlen($source) > 2_000_000) return null; - // MyBB writes this generated cache using var_export. Only accept a literal string list; - // never include/eval the PHP cache file because it is derived from site-controlled data. - if (!preg_match("/'active'\\s*=>\\s*array\\s*\\((.*?)\\)\\s*,/s", $source, $match)) return null; - $body = $match[1]; - if (preg_match('/[^\s,0-9=>()\'"\\\\A-Za-z_.-]/', $body)) return null; - preg_match_all("/=>\\s*'([a-zA-Z0-9_-]+)'|=>\\s*\"([a-zA-Z0-9_-]+)\"/", $body, $items, PREG_SET_ORDER); - $plugins = []; - foreach ($items as $item) { - $value = $item[1] !== '' ? $item[1] : ($item[2] ?? ''); - if ($value !== '') $plugins[] = $value; - } - return $plugins; - } -} diff --git a/packages/mybb-tooling/src/PluginLifecycle.php b/packages/mybb-tooling/src/PluginLifecycle.php deleted file mode 100644 index 52c4152..0000000 --- a/packages/mybb-tooling/src/PluginLifecycle.php +++ /dev/null @@ -1,66 +0,0 @@ -read('plugins'); - if(!is_array($cacheData)||!is_array($cacheData['active']??null))return null; - return isset($cacheData['active'][$codename]); - } - - /** Refuse file removal unless each managed plugin entry is confirmed inactive. */ - public static function assertInactive(array $record, object $cache, string $guidance = 'deactivate it before uninstalling.'): void - { - $codenames=[]; - foreach(($record['files']??[]) as $path=>$file)if(is_array($file)&&($file['role']??'')==='plugin'&&preg_match('#^inc/plugins/([A-Za-z0-9_]+)\.php$#',(string)$path,$match))$codenames[]=$match[1]; - if(!$codenames)return; - if(($record['active']??false)===true)throw new RuntimeException('Plugin is recorded as active; '.$guidance); - foreach($codenames as $codename){$active=self::activeState($cache,$codename);if($active===null)throw new RuntimeException('MyBB activation state is unavailable; '.$guidance);if($active)throw new RuntimeException('Plugin is active in MyBB; '.$guidance);} - } - - public static function setState(string $siteRoot, string $codename, bool $activate, object $cache, object $plugins): array - { - if (!preg_match('/^[A-Za-z0-9_]+$/', $codename)) throw new RuntimeException('Plugin file name is not a valid MyBB plugin codename.'); - $root = realpath($siteRoot); - if ($root === false) throw new RuntimeException('Cannot resolve MyBB site root.'); - $file = $root . '/inc/plugins/' . $codename . '.php'; - if (is_link($file) || !is_file($file)) throw new RuntimeException('Managed plugin file is missing or unsafe.'); - - $cacheData = $cache->read('plugins'); - if (!is_array($cacheData)) $cacheData = []; - $active = $cacheData['active'] ?? []; - if (!is_array($active)) throw new RuntimeException('MyBB plugin activation cache is unavailable or malformed.'); - $isActive = isset($active[$codename]); - if ($isActive === $activate) return ['status'=>'unchanged','active'=>$isActive,'installed_now'=>false]; - - $action = $activate ? 'activate' : 'deactivate'; - $plugins->run_hooks('admin_config_plugins_' . $action); - require_once $file; - $installedNow = false; - if ($activate) { - if ($plugins->is_compatible($codename) === false) throw new RuntimeException('Plugin is incompatible with this MyBB version.'); - $installedFunction = $codename . '_is_installed'; - $installed = !function_exists($installedFunction) || $installedFunction() === true; - $installFunction = $codename . '_install'; - if (!$installed && function_exists($installFunction)) { call_user_func($installFunction); $installedNow = true; } - $activateFunction = $codename . '_activate'; - if (function_exists($activateFunction)) call_user_func($activateFunction); - $active[$codename] = $codename; - } else { - $deactivateFunction = $codename . '_deactivate'; - if (function_exists($deactivateFunction)) call_user_func($deactivateFunction); - unset($active[$codename]); - } - $cacheData['active'] = $active; - $cache->update('plugins', $cacheData); - $plugins->run_hooks('admin_config_plugins_' . $action . '_commit'); - return ['status'=>$action . 'd','active'=>$activate,'installed_now'=>$installedNow]; - } -} diff --git a/packages/mybb-tooling/src/RepositoryMetadataCache.php b/packages/mybb-tooling/src/RepositoryMetadataCache.php deleted file mode 100644 index cc03e67..0000000 --- a/packages/mybb-tooling/src/RepositoryMetadataCache.php +++ /dev/null @@ -1,49 +0,0 @@ - $entry) { - if (!is_array($entry) || !is_int($entry['fetched_at'] ?? null) || $entry['fetched_at'] < $now - self::MAX_AGE_SECONDS) { - unset($entries[$entryKey]); - } - } - $entries[$key] = ['fetched_at' => $now, 'expires_at' => $now + self::TTL_SECONDS, 'data' => $data]; - uasort($entries, static fn(array $a, array $b): int => ($b['fetched_at'] ?? 0) <=> ($a['fetched_at'] ?? 0)); - $entries = array_slice($entries, 0, self::MAX_ENTRIES, true); - while ($entries && strlen(serialize($entries)) > self::MAX_BYTES) { - array_pop($entries); - } - return $entries; - } -} diff --git a/packages/mybb-tooling/src/TextTable.php b/packages/mybb-tooling/src/TextTable.php deleted file mode 100644 index 0c8ca05..0000000 --- a/packages/mybb-tooling/src/TextTable.php +++ /dev/null @@ -1,48 +0,0 @@ - $value) $rows[] = [self::clean((string)$key), self::clean(self::value($value))]; - $widths = [0, 0]; - foreach ($rows as $row) foreach ([0, 1] as $column) $widths[$column] = max($widths[$column], self::width($row[$column])); - - $border = '+' . str_repeat('-', $widths[0] + 2) . '+' . str_repeat('-', $widths[1] + 2) . "+\n"; - $output = $border; - foreach ($rows as $index => $row) { - $output .= '| ' . $row[0] . str_repeat(' ', $widths[0] - self::width($row[0])) - . ' | ' . $row[1] . str_repeat(' ', $widths[1] - self::width($row[1])) . " |\n"; - if ($index === 0) $output .= $border; - } - return $output . $border; - } - - private static function value(mixed $value): string - { - if ($value === null) return 'null'; - if (is_bool($value)) return $value ? 'yes' : 'no'; - if (is_scalar($value)) return (string)$value; - return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE) ?: '[unavailable]'; - } - - private static function clean(string $value): string - { - $clean = preg_replace('/[\x00-\x1f\x7f]/', ' ', $value) ?? ''; - return str_replace('|', '\\|', $clean); - } - - private static function width(string $value): int - { - if (function_exists('mb_strwidth')) { - $width = mb_strwidth($value, 'UTF-8'); - if (is_int($width)) return $width; - } - return strlen($value); - } -}