├── CHANGELOG.md ├── ProcessExportProfile.module ├── README.md ├── config.php └── site-skel ├── assets └── index.php ├── install ├── files │ └── README.txt └── finish.php ├── modules └── README.txt └── templates └── README.txt /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # ProcessExportProfile changelog 2 | 3 | ## v5.0.0 4 | 5 | - Added support for updating an existing profile. When an existing profile 6 | is present, it will let you know and give you an "Update" button you can 7 | click to re-export the entire profile in 1 click, using all the settings 8 | from the existing profile. Requires using the "server directory" option. 9 | 10 | - Added support for previewing an existing profile. It shows you what the 11 | profile looks like from the ProcessWire installer (screenshot, etc.) 12 | 13 | - Fixed issue with the ZIP profiles where they could omit status files like 14 | /site/ready.php and /site/init.php. 15 | 16 | - Various other minor fixes and updates. 17 | 18 | ## v4.0.1 19 | 20 | - Added the ability to support a finish.php file that is called after the 21 | installer finishes. Use this to make any final adjustments to the profile 22 | following installation. See the README file for more details on this. 23 | Requires ProcessWire 3.0.191+. 24 | 25 | ## Earlier versions 26 | 27 | - See the commit log at: 28 | -------------------------------------------------------------------------------- /ProcessExportProfile.module: -------------------------------------------------------------------------------- 1 | 'Export Site Profile', 21 | 'summary' => 'Create a site profile that can be used for a fresh install of ProcessWire.', 22 | 'version' => 501, 23 | 'icon' => 'truck', 24 | 'page' => array( 25 | 'name' => 'export-site-profile', 26 | 'parent' => 'setup', 27 | ), 28 | 'requires' => 'ProcessWire>=3.0.200' 29 | ); 30 | } 31 | 32 | /** 33 | * Path that profile DB dump is exported to 34 | * 35 | * @var string 36 | * 37 | */ 38 | protected $exportPath; 39 | 40 | /** 41 | * URL that profile DB dump is exported to 42 | * 43 | * @var string 44 | * 45 | */ 46 | protected $exportURL; 47 | 48 | /** 49 | * Translated labels 50 | * 51 | * @var array 52 | * 53 | */ 54 | protected $labels = array(); 55 | 56 | /** 57 | * Disk path to /site/config.php 58 | * 59 | * @var string 60 | * 61 | */ 62 | protected $siteConfigFile = ''; 63 | 64 | /** 65 | * Disk path to /wire/config.php 66 | * 67 | * @var string 68 | * 69 | */ 70 | protected $wireConfigFile = ''; 71 | 72 | /** 73 | * Disk path to /site/modules/ProcessExportProfile/config.php 74 | * 75 | * @var string 76 | * 77 | */ 78 | protected $defaultConfigFile = ''; 79 | 80 | /** 81 | * Optional files 82 | * 83 | * @var string[] 84 | * 85 | */ 86 | protected $optionals = [ 'ready.php', 'init.php', 'finished.php', 'README.md' ]; 87 | 88 | /** 89 | * Initialize the profile exporter 90 | * 91 | */ 92 | public function init() { 93 | 94 | if(!$this->wire()->user->isSuperuser()) { 95 | throw new WirePermissionException("This module requires superuser access"); 96 | } 97 | 98 | if(version_compare(PHP_VERSION, '8.1.0', '<')) ini_set('auto_detect_line_endings', true); 99 | $dir = 'backups/export-profile/'; 100 | 101 | $config = $this->wire()->config; 102 | 103 | $this->exportPath = $config->paths->assets . $dir; 104 | $this->exportURL = $config->urls->assets . $dir; 105 | $this->siteConfigFile = dirname(rtrim($config->paths->templates, '/')) . '/config.php'; 106 | $this->wireConfigFile = $config->paths->wire . 'config.php'; 107 | $this->defaultConfigFile = dirname(__FILE__) . '/config.php'; 108 | 109 | $this->labels['usageNotes'] = 110 | $this->_('To use this site profile, copy/extract it to the root directory of a new uninstalled copy of ProcessWire in [dir].') . ' ' . 111 | $this->_('When the ProcessWire installer runs, it will detect this profile as an installation option.') . ' ' . 112 | $this->_('When you are done with the files here, you should remove them to save space (available after clicking Continue).'); 113 | 114 | $this->labels['remove'] = $this->_('Remove'); 115 | $this->labels['update'] = $this->_('Update'); 116 | $this->labels['continue'] = $this->_('Continue'); 117 | $this->labels['download'] = $this->_('Download'); 118 | $this->labels['exportSuccess'] = $this->_('Your site profile has been exported!'); 119 | $this->labels['updateSuccess'] = $this->_('Your site profile has been updated!'); 120 | 121 | parent::init(); 122 | } 123 | 124 | /** 125 | * Ensure that everything is where we need it to be 126 | * 127 | * Returns false if not. 128 | * 129 | */ 130 | protected function setup() { 131 | 132 | if(!is_dir($this->exportPath) && !wireMkdir($this->exportPath, true)) { 133 | $this->error(sprintf( 134 | $this->_('Before continuing, please create this directory and ensure that it is writable: %s'), 135 | $this->exportURL 136 | )); 137 | return false; 138 | } 139 | 140 | if(!is_writable($this->exportPath)) { 141 | $this->error(sprintf( 142 | $this->_('Before continuing, please make this directory writable and remove any files already in it: %s'), 143 | $this->exportURL 144 | )); 145 | return false; 146 | } 147 | 148 | return true; 149 | } 150 | 151 | /** 152 | * Build the initial form used by the profile exporter 153 | * 154 | */ 155 | protected function buildForm() { 156 | 157 | $modules = $this->wire()->modules; 158 | 159 | /** @var InputfieldForm $form */ 160 | $form = $modules->get('InputfieldForm'); 161 | 162 | $info = self::getModuleInfo(); 163 | $form->description = $info['summary']; 164 | 165 | /** @var InputfieldName $f */ 166 | $f = $modules->get('InputfieldName'); 167 | $f->attr('name', 'profile_name'); 168 | $f->label = $this->_('Name'); 169 | $f->description = $this->_('Alphanumeric short name for the profile.'); 170 | $f->notes = $this->_('Example: my-profile'); 171 | $f->columnWidth = 50; 172 | $f->required = true; 173 | $form->add($f); 174 | 175 | /** @var InputfieldText $f */ 176 | $f = $modules->get('InputfieldText'); 177 | $f->attr('name', 'profile_title'); 178 | $f->label = $this->_('Title'); 179 | $f->columnWidth = 50; 180 | $f->description = $this->_('Human-readable title of the profile.'); 181 | $f->notes = $this->_('Example: My Profile'); 182 | $form->add($f); 183 | 184 | /** @var InputfieldTextarea $f */ 185 | $f = $modules->get('InputfieldTextarea'); 186 | $f->attr('name', 'profile_summary'); 187 | $f->label = $this->_('Summary'); 188 | $f->description = $this->_('A short description of this profile (1-sentence recommended).'); 189 | $f->attr('rows', 3); 190 | $form->add($f); 191 | 192 | /** @var InputfieldFile $f */ 193 | $f = $modules->get("InputfieldFile"); 194 | $f->name = 'screenshot'; 195 | $f->label = $this->_('Screenshot Image'); 196 | $f->icon = 'camera'; 197 | $f->description = $this->_('At least 760 pixels wide and any height.'); 198 | $f->extensions = 'jpg jpeg gif png'; 199 | $f->maxFiles = 1; 200 | $f->overwrite = false; 201 | $f->required = true; 202 | $f->destinationPath = $this->exportPath; 203 | $form->add($f); 204 | 205 | /** @var InputfieldRadios $f */ 206 | $f = $modules->get('InputfieldRadios'); 207 | $f->attr('name', 'profile_zip'); 208 | $f->label = $this->_('Profile Destination'); 209 | $f->icon = 'file-zip-o'; 210 | $f->addOption(0, $this->_('Server directory')); 211 | $f->addOption(1, $this->_('ZIP file/download')); 212 | $f->attr('value', 0); 213 | $f->description = 214 | $this->_('If exporting to a ZIP file, you will be given the option to download it after the export is completed.') . ' ' . // Profile destination description 1 215 | $this->_('If exporting to a server directory, you will copy the files off the server using your preferred transfer tool (FTP, etc.).') . ' ' . // Profile destination description 2 216 | $this->_('If you want a profile you can re-export or update with out entering the info on this screen again, choose the Server directory option.'); 217 | $f->notes = 218 | $this->_('If your site is exceptionally large, the ZIP file option may take significantly longer or may not be possible.'); // Profile destination notes 219 | $form->add($f); 220 | 221 | $properties = $this->findConfigDifferences(); 222 | if(count($properties)) { 223 | /** @var InputfieldCheckboxes $f */ 224 | $f = $modules->get('InputfieldCheckboxes'); 225 | $f->attr('name', 'profile_config'); 226 | $f->icon = 'sliders'; 227 | $f->label = $this->_('Config Properties'); 228 | $f->description = 229 | $this->_('The following config properties were found to be unique to this site.') . ' ' . 230 | $this->_('Check the box for each of the properties that you want to be included in the exported profile /site/config.php file.'); 231 | $f->table = true; 232 | $f->thead = $this->_('Property') . '|' . $this->_('Value'); 233 | foreach($properties as $property => $value) { 234 | $value = str_replace('|', ' ', $value); 235 | $value = str_replace(array("\r", "\n"), " ", $value); 236 | $value = preg_replace('/^\$config->[^=]+=\s*/', '', $value); 237 | $value = rtrim($value, '; '); 238 | $f->addOption($property, "$property|$value"); 239 | } 240 | $f->attr('value', array_keys($properties)); 241 | $form->add($f); 242 | } 243 | 244 | /** @var InputfieldSubmit $f */ 245 | $f = $modules->get('InputfieldSubmit'); 246 | $f->attr('name', 'submit_export'); 247 | $f->attr('value', $this->_('Start Export')); 248 | $f->icon = 'angle-right'; 249 | $form->add($f); 250 | 251 | return $form; 252 | } 253 | 254 | /** 255 | * Present the instructions and initial info collection form 256 | * 257 | * @return string 258 | * 259 | */ 260 | public function ___execute() { 261 | 262 | if(!$this->setup()) return ''; 263 | 264 | $input = $this->wire()->input; 265 | 266 | if($input->post('submit_copy')) { 267 | return $this->processCopy(); 268 | } 269 | 270 | $profileInfo = $this->getExistingProfile(); 271 | 272 | if($profileInfo) { 273 | return $this->handleExistingProfile($profileInfo); 274 | } 275 | 276 | $this->checkInstalledModules(); 277 | 278 | $form = $this->buildForm(); 279 | 280 | if($input->post('submit_export')) { 281 | $out = $this->processExport($form); 282 | if($out) return $out; 283 | } 284 | 285 | $form->appendMarkup .= 286 | "

" . 287 | $this->_('After clicking the button, we will begin the database export. Be patient!') . ' ' . 288 | $this->_('Depending on how large your site is, this may take some time.') . 289 | "

"; 290 | 291 | return $form->render(); 292 | } 293 | 294 | /** 295 | * Render or process for exisrting profile 296 | * 297 | * @param array $profileInfo 298 | * @return string 299 | * 300 | */ 301 | protected function handleExistingProfile(array $profileInfo) { 302 | 303 | $input = $this->wire()->input; 304 | $modules = $this->wire()->modules; 305 | $sanitizer = $this->wire()->sanitizer; 306 | $installDir = "$profileInfo[name]/install/"; 307 | $zipFile = $this->exportPath . "$profileInfo[name].zip"; 308 | $name = $sanitizer->entities($profileInfo['name']); 309 | $title = $name; 310 | 311 | if(!file_exists($zipFile)) $zipFile = ''; 312 | 313 | if($input->post('submit_remove')) { 314 | return $this->processRemove(); 315 | 316 | } else if($input->post('submit_update') && !$zipFile) { 317 | return $this->processUpdate($profileInfo); 318 | } 319 | 320 | $form = $modules->get('InputfieldForm'); /** @var InputfieldForm $form */ 321 | $infoFile = $this->exportPath . $installDir . 'info.php'; 322 | $infoTemplate = ['title' => $profileInfo['name'], 'summary' => '', 'screenshot' => '']; 323 | 324 | if(is_file($infoFile)) { 325 | define('PROCESSWIRE_INSTALL', 1); 326 | include($infoFile); 327 | /** @var array $info */ 328 | foreach(array_keys($infoTemplate) as $key) { 329 | $info[$key] = empty($info[$key]) ? $infoTemplate[$key] : $sanitizer->entities($info[$key]); 330 | } 331 | if($input->get('screenshot')) { 332 | wireSendFile($this->exportPath . $installDir . $info['screenshot']); 333 | } 334 | /** @var InputfieldMarkup $f */ 335 | $f = $modules->get('InputfieldMarkup'); 336 | $f->label = $this->_('Installer preview of site profile'); 337 | $f->val( 338 | "

" . 341 | ($info['summary'] ? "

" . $sanitizer->entities($info['summary']) . "

" : "

No summary

") . 342 | ($info['screenshot'] ? "

" : "

No screenshot

") 343 | ); 344 | $form->add($f); 345 | $title = $info['title']; 346 | } 347 | 348 | /** @var InputfieldSubmit $f */ 349 | $f = $modules->get('InputfieldSubmit'); 350 | $f->attr('name', 'submit_remove'); 351 | $f->val($this->labels['remove']); 352 | $f->setSecondary(); 353 | $f->icon = 'trash-o'; 354 | $form->add($f); 355 | 356 | if($zipFile) { 357 | /** @var InputfieldButton $f */ 358 | $f = $modules->get('InputfieldButton'); 359 | $f->href = "./download/$name.zip"; 360 | $f->val($this->labels['download']); 361 | $f->icon = 'cloud-download'; 362 | $form->add($f); 363 | } else { 364 | /** @var InputfieldSubmit $f */ 365 | $f = $modules->get('InputfieldSubmit'); 366 | $f->attr('name', 'submit_update'); 367 | $f->val($this->labels['update']); 368 | $f->icon = 'refresh'; 369 | $form->add($f); 370 | } 371 | 372 | if($zipFile) { 373 | $headline = sprintf($this->_('An ZIP installation profile named "%s" exists'), $title); 374 | $location = "$this->exportURL$name.zip"; 375 | $notes = $this->_('Before exporting a new profile, please remove the existing profile by using the button at the bottom of this page.'); 376 | } else { 377 | $headline = sprintf($this->_('An installation profile named "%s" exists'), $title); 378 | $location = "$this->exportURL$name/"; 379 | $notes = 380 | $this->_('Before continuing, please decide whether you would like to remove the existing profile or update it.') . ' ' . 381 | $this->_('Or if you expect to update it later, you can leave it as-is.'); 382 | } 383 | 384 | $out = 385 | "

$headline

" . 386 | "

$location (" . wireRelativeTimeStr($profileInfo['time']) . ")

" . 387 | "

$notes

"; 388 | 389 | return $out . $form->render(); 390 | } 391 | 392 | /** 393 | * Check for certain modules and warn users when necessary 394 | * 395 | */ 396 | protected function checkInstalledModules() { 397 | $modules = $this->wire()->modules; 398 | 399 | $moduleNames = array( 400 | 'SessionHandlerDB', 401 | 'SystemNotifications', 402 | 'ImageSizerEngineIMagick', 403 | ); 404 | 405 | foreach($moduleNames as $key => $name) { 406 | if(!$modules->isInstalled($name)) unset($moduleNames[$key]); 407 | } 408 | 409 | if(count($moduleNames)) { 410 | $this->warning( 411 | $this->_('We recommend uninstalling the following module(s) before exporting a public/shareable profile:') . ' ' . 412 | implode(', ', $moduleNames) . '. ' . 413 | $this->_('(If the profile is for personal use, it is fine to leave them installed)') 414 | ); 415 | } 416 | } 417 | 418 | /** 419 | * Process the initial info collection form and begin export 420 | * 421 | * @param InputfieldForm $form 422 | * @return bool 423 | * @throws WireException 424 | * 425 | */ 426 | protected function processExport($form) { 427 | 428 | $modules = $this->wire()->modules; 429 | $files = $this->wire()->files; 430 | $input = $this->wire()->input; 431 | 432 | // process form 433 | $form->processInput($input->post); 434 | 435 | if(count($form->getErrors())) return false; 436 | 437 | $dir = 'site-' . $form->getChildByName('profile_name')->val(); 438 | $title = $form->getChildByName('profile_title')->val(); 439 | $summary = $form->getChildByName('profile_summary')->val(); 440 | $useZIP = (int) $form->getChildByName('profile_zip')->val(); 441 | $path = $this->exportPath . "$dir/"; 442 | 443 | // setup skeleton directory 444 | if(!$files->mkdir($path)) { 445 | throw new WireException("Unable to create: $path"); 446 | } 447 | 448 | if(!$files->copy(__DIR__ . '/site-skel/', $path)) { 449 | throw new WireException("Unable to setup skeleton site directory: $path"); 450 | } 451 | 452 | /** @var Inputfield $f */ 453 | $f = $form->getChildByName('profile_config'); 454 | 455 | /** @var array $properties */ 456 | $properties = $f ? $f->attr('value') : array(); 457 | $this->writeConfigFile($path . 'config.php', $properties); 458 | 459 | // save screenshot 460 | $screenshot = ''; 461 | foreach($form->getChildByName('screenshot')->val() as $pagefile) { 462 | if($files->rename($this->exportPath . $pagefile->basename, $path . 'install/' . $pagefile->basename)) { 463 | $screenshot = $pagefile->basename; 464 | } 465 | } 466 | 467 | $this->writeInfoFile($path, $title, $summary, $screenshot); 468 | 469 | // export database 470 | $dumpFile = $this->mysqldump("{$path}install/"); 471 | 472 | if($dumpFile) { 473 | $this->message(sprintf($this->_('Exported database to: %s'), $this->pathLabel($dumpFile))); 474 | } else { 475 | $this->error("Error creating SQL dump file in {$path}install/"); 476 | $this->wire()->session->redirect('./'); 477 | } 478 | 479 | $this->message($this->_('Database has been exported.')); 480 | 481 | // present screen for next step 482 | 483 | /** @var InputfieldForm $form */ 484 | $form = $modules->get('InputfieldForm'); 485 | 486 | /** @var InputfieldHidden $f */ 487 | $f = $modules->get('InputfieldHidden'); 488 | $f->attr('name', 'profile_dir'); 489 | $f->attr('value', $dir); 490 | $form->add($f); 491 | 492 | /** @var InputfieldHidden $f */ 493 | $f = $modules->get('InputfieldHidden'); 494 | $f->attr('name', 'profile_zip'); 495 | $f->attr('value', $useZIP ? 1 : 0); 496 | $form->add($f); 497 | 498 | /** @var InputfieldSubmit $f */ 499 | $f = $modules->get('InputfieldSubmit'); 500 | $f->attr('name', 'submit_copy'); 501 | $f->attr('value', $this->labels['continue']); 502 | $f->icon = 'angle-right'; 503 | $form->add($f); 504 | 505 | return 506 | "

" . 507 | $this->_('The next step will copy/archive all of your site files.') . 508 | "

" . 509 | "

" . 510 | $this->_('It will not make any changes to your current site.') . ' ' . 511 | $this->_('If your site has a lot of files this could take awhile, please be patient.') . 512 | "

" . 513 | $form->render(); 514 | } 515 | 516 | /** 517 | * Write the install/info.php file 518 | * 519 | * @param string $path 520 | * @param string $title 521 | * @param string $summary 522 | * @param string $screenshot 523 | * 524 | */ 525 | protected function writeInfoFile($path, $title, $summary, $screenshot) { 526 | // write install/info.php file 527 | $fp = fopen($path . 'install/info.php', "w"); 528 | $title = str_replace('"', ' ', $title); 529 | $summary = str_replace('"', ' ', $summary); 530 | $screenshot = str_replace('"', ' ', $screenshot); 531 | fwrite($fp, 532 | '<' . '?php namespace ProcessWire;' . "\n" . 533 | 'if(!defined("PROCESSWIRE_INSTALL")) die();' . "\n" . 534 | '$info = [' . 535 | "\n\t'title' => \"$title\", " . 536 | "\n\t'summary' => \"$summary\", " . 537 | "\n\t'screenshot' => \"$screenshot\"" . 538 | "\n];\n"); 539 | fclose($fp); 540 | } 541 | 542 | /** 543 | * Update existing profile 544 | * 545 | * @return string 546 | * 547 | */ 548 | public function processUpdate(array $profileInfo) { 549 | 550 | $session = $this->wire()->session; 551 | 552 | if(!$profileInfo) { 553 | $this->error($this->_('No existing profile found')); 554 | $session->location('./'); 555 | } 556 | 557 | $path = $this->exportPath . "$profileInfo[name]/"; 558 | $installPath = $path . "install/"; 559 | 560 | // export database 561 | $sqlFile = $installPath . 'install.sql'; 562 | if(is_file($sqlFile)) unlink($sqlFile); 563 | $sqlFile = $this->mysqldump("{$path}install/"); 564 | 565 | if(!$sqlFile) { 566 | $this->error("Error creating SQL dump file in $installPath"); 567 | $session->location('./'); 568 | } 569 | 570 | $this->message($this->_('Updated database export file')); 571 | 572 | $out = $this->processCopy($profileInfo['name']); 573 | 574 | $this->message($this->_('Updated existing site profile')); 575 | 576 | return $out; 577 | } 578 | 579 | /** 580 | * Remove existing profile 581 | * 582 | * @return string 583 | * 584 | */ 585 | protected function processRemove() { 586 | if($this->exportPath && wireRmdir($this->exportPath, true)) { 587 | $this->message($this->_('Removed existing profile') . " - $this->exportURL"); 588 | $this->wire()->session->location('./'); 589 | return ''; 590 | } else { 591 | $this->error($this->_('Error removing existing site profile') . " - $this->exportURL"); 592 | return $this->button('./'); 593 | } 594 | } 595 | 596 | /** 597 | * Copy current site file assets into site profile path 598 | * 599 | * @param string $path Profile path to copy into 600 | * @param bool $update 601 | * @throws WireException 602 | * 603 | */ 604 | public function copyFileAssets($path, $update = false) { 605 | 606 | $files = $this->wire()->files; 607 | $paths = $this->wire()->config->paths; 608 | 609 | // paths and files to clean in destination when update===true 610 | $cleanFiles = array(); 611 | 612 | $copyOptions = array( 613 | 'copyEmptyDirs' => false, 614 | 'recursive' => true, 615 | 'hidden' => false 616 | ); 617 | 618 | $copyPaths = array( 619 | $paths->templates => $path . 'templates/', 620 | $paths->siteModules => $path . 'modules/', 621 | $paths->files => $path . 'install/files/' 622 | ); 623 | 624 | if($paths->classes && is_dir($paths->classes)) { 625 | $dstPath = $path . 'classes/'; 626 | $copyPaths[$paths->classes] = $dstPath; 627 | } 628 | 629 | $optionalSrcFiles = []; 630 | 631 | foreach($this->optionals as $basename) { 632 | $optionalSrcFiles[] = $paths->site . $basename; 633 | } 634 | 635 | $statusFiles = $this->wire()->config->statusFiles; 636 | 637 | if(is_array($statusFiles)) { 638 | // custom status files in /site/ (3.0.142+) 639 | foreach($statusFiles as $basename) { 640 | if(empty($basename)) continue; 641 | $basename = basename($basename); 642 | $srcFile = $paths->site . $basename; 643 | $dstFile = $path . $basename; 644 | $cleanFiles[$dstFile] = $dstFile; 645 | if(in_array($srcFile, $optionalSrcFiles)) continue; 646 | if(is_file($srcFile)) $optionalSrcFiles[] = $srcFile; 647 | } 648 | } 649 | 650 | foreach($optionalSrcFiles as $srcFile) { 651 | $dstFile = $path . basename($srcFile); 652 | $cleanFiles[$dstFile] = $dstFile; 653 | if(is_file($srcFile)) $copyPaths[$srcFile] = $dstFile; 654 | } 655 | 656 | // update mode: clean up existing profile files where present 657 | // this is necessary in case a file in the existing profile 658 | // is no longer in the live files 659 | 660 | if($update && is_dir($path)) { 661 | foreach($copyPaths as $dstPath) { 662 | if(!is_dir($dstPath)) continue; 663 | $this->warning("rmdir: $dstPath"); 664 | $files->rmdir($dstPath, true); 665 | } 666 | foreach($cleanFiles as $dstFile) { 667 | if(!is_file($dstFile)) continue; 668 | $this->warning("unlink: $dstFile"); 669 | $files->unlink($dstFile); 670 | } 671 | } 672 | 673 | foreach($copyPaths as $srcPath => $dstPath) { 674 | $srcLabel = $this->pathLabel($srcPath); 675 | $dstLabel = $this->pathLabel($dstPath); 676 | if($files->copy($srcPath, $dstPath, $copyOptions)) { 677 | $this->message("Copied $srcLabel => $dstLabel", Notice::debug); 678 | } else { 679 | $this->error("Error copying $srcLabel => $dstLabel"); 680 | } 681 | } 682 | 683 | // don't include this module in exported profile 684 | $files->rmdir($path . 'modules/ProcessExportProfile/', true); 685 | } 686 | 687 | /** 688 | * Copy file assets into site profile 689 | * 690 | * @param string $dir Profile directory name if updating existing profile 691 | * @returns string 692 | * 693 | */ 694 | public function processCopy($dir = '') { 695 | 696 | $sanitizer = $this->wire()->sanitizer; 697 | $input = $this->wire()->input; 698 | 699 | set_time_limit(3600); 700 | 701 | if(empty($dir)) { 702 | $update = false; 703 | $dir = $sanitizer->name($input->post('profile_dir')); 704 | if(empty($dir)) { 705 | $this->error('No profile name specified'); 706 | $this->wire()->session->location('./'); 707 | } else if($input->post('profile_zip')) { 708 | return $this->processCopyZIP($dir); 709 | } 710 | } else { 711 | $update = true; 712 | } 713 | 714 | $path = $this->exportPath . $dir . '/'; 715 | $pathLabel = $sanitizer->entities($this->pathLabel($path)); // relative to root 716 | 717 | $this->copyFileAssets($path, $update); 718 | 719 | $this->headline($update ? $this->labels['updateSuccess'] : $this->labels['exportSuccess']); 720 | 721 | return 722 | "
$pathLabel
" . 723 | "

" . 724 | $this->_('Copy the entire contents of the directory above to another location using your preferred file transfer tool (FTP, SCP, rsync, etc.).') . 725 | "

" . 726 | "

" . 727 | str_replace('[dir]', "/$dir/", $this->labels['usageNotes']) . 728 | "

" . 729 | $this->button('./'); 730 | } 731 | 732 | /** 733 | * ZIP file assets into site profile (alternative to copy) 734 | * 735 | * @param string $dir 736 | * @return string 737 | * 738 | */ 739 | protected function processCopyZIP($dir) { 740 | 741 | $config = $this->wire()->config; 742 | $zipfile = $this->exportPath . "$dir.zip"; 743 | 744 | // site skeleton 745 | $result = wireZipFile($zipfile, $this->exportPath . $dir . '/'); 746 | $errors = $result['errors']; 747 | 748 | // templates and modules... 749 | $files = array( 750 | $config->paths->templates, 751 | $config->paths->siteModules 752 | ); 753 | 754 | foreach($this->optionals as $name) { 755 | $file = $config->paths->site . $name; 756 | if(is_file($file)) $files[] = $file; 757 | } 758 | 759 | $statusFiles = $config->statusFiles; 760 | if(!is_array($statusFiles)) $statusFiles = []; 761 | 762 | foreach($statusFiles as $basename) { 763 | if(empty($basename)) continue; 764 | $basename = basename($basename); 765 | $file = $config->paths->site . $basename; 766 | if(!in_array($file, $files) && is_file($file)) $files[] = $file; 767 | } 768 | 769 | // ...and classes, if used 770 | if($config->paths->classes && is_dir($config->paths->classes)) { 771 | $files[] = $config->paths->classes; 772 | } 773 | 774 | $options = array( 775 | 'dir' => $dir, 776 | 'exclude' => array("$dir/modules/ProcessExportProfile") 777 | ); 778 | 779 | $result = wireZipFile($zipfile, $files, $options); 780 | $errors = array_merge($errors, $result['errors']); 781 | $cnt = count($result['files']); 782 | $this->message(sprintf($this->_('Added %d template, class and module files to ZIP'), $cnt)); 783 | 784 | // file assets 785 | $options = array( 786 | 'allowEmptyDirs' => false, 787 | 'dir' => "$dir/install/", 788 | ); 789 | 790 | $result = wireZipFile($zipfile, $config->paths->files, $options); 791 | $errors = array_merge($errors, $result['errors']); 792 | $cnt = count($result['files']); 793 | $this->message(sprintf($this->_('Added %d asset files to ZIP'), $cnt)); 794 | 795 | foreach($errors as $error) { 796 | $this->error("ZIP add failed: $error"); 797 | } 798 | 799 | if(is_file($zipfile)) { 800 | $this->headline($this->labels['success']); 801 | $out = 802 | "

$this->exportURL$dir.zip

" . 803 | "

" . str_replace('[dir]', "/$dir/", $this->labels['usageNotes']) . "

" . 804 | $this->button("./download/$dir.zip", sprintf($this->labels['download'], "$dir.zip"), 'cloud-download') . 805 | $this->button("./"); 806 | 807 | } else { 808 | $this->error($this->_('ZIP file creation failed. Try saving to server directory instead.')); 809 | $out = "

" . $this->button('./') . "

"; 810 | } 811 | 812 | return $out; 813 | } 814 | 815 | /** 816 | * Download site profile 817 | * 818 | */ 819 | public function ___executeDownload() { 820 | $file = $this->wire()->sanitizer->pageName($this->wire()->input->urlSegment2); 821 | while(strpos($file, '..') !== false) $file = str_replace('..', '.', $file); 822 | if(empty($file)) throw new WireException("No file specified"); 823 | $file = basename($file); 824 | $file = basename($file, '.zip') . '.zip'; 825 | $pathname = $this->exportPath . $file; 826 | if(!is_file($pathname)) throw new WireException("Invalid file: $pathname"); 827 | wireSendFile($pathname); 828 | } 829 | 830 | /** 831 | * Load the given config file return array of all values indexed by config property 832 | * 833 | * @param string $file 834 | * @return array 835 | * @throws WireException 836 | * 837 | */ 838 | protected function loadConfigFile($file) { 839 | 840 | $ignoreProperties = array( 841 | 'dbName', 842 | 'dbUser', 843 | 'dbPass', 844 | 'dbPath', 845 | 'dbPort', 846 | 'dbHost', 847 | 'dbSocket', 848 | 'dbEngine', 849 | 'dbCharset', 850 | 'dbReader', 851 | 'adminEmail', 852 | 'userAuthSalt', 853 | 'chmodDir', 854 | 'chmodFile', 855 | 'timezone', 856 | 'httpHosts', 857 | 'httpHost', 858 | 'installed', 859 | 'tableSalt', 860 | 'debug', 861 | 'uploadUnzipCommand', 862 | 'wireMail', 863 | ); 864 | 865 | $config = array(); 866 | $fp = fopen($file, "r"); 867 | if(!$fp) throw new WireException("Unable to open: $file"); 868 | 869 | while(!feof($fp)) { 870 | $_line = fgets($fp); // unmodified line 871 | $line = $this->cleanConfigLine($_line); // cleaned line, no comments 872 | if(strpos($line, '$config->') === 0) { 873 | $property = trim(substr($line, 0, strpos($line, '=')-1)); // $config->property 874 | $property = trim(substr($property, strpos($property, '>')+1)); // property 875 | if(in_array($property, $ignoreProperties)) continue; 876 | while(substr(rtrim($line), -1) != ';' && !feof($fp)) { 877 | $line = fgets($fp); 878 | $_line .= $line; 879 | $line = $this->cleanConfigLine($line); 880 | } 881 | $config[$property] = $_line; 882 | } 883 | } 884 | 885 | fclose($fp); 886 | 887 | return $config; 888 | } 889 | 890 | /** 891 | * Given a config line, return a trimmed and comment-less version of the line 892 | * 893 | * @param string $line 894 | * @return string 895 | * 896 | */ 897 | protected function cleanConfigLine($line) { 898 | $line = trim($line); 899 | if(preg_match('{^(.+?[,;])\s*((?://|/\*).*)$}', $line, $matches)) { 900 | $line = $matches[1]; 901 | //$comment = $matches[2]; 902 | } 903 | return $line; 904 | } 905 | 906 | /** 907 | * Return an array of property => value of what's unique in /site/config.php 908 | * 909 | * @return array 910 | * 911 | */ 912 | protected function findConfigDifferences() { 913 | 914 | $siteConfig = $this->loadConfigFile($this->siteConfigFile); 915 | $wireConfig = $this->loadConfigFile($this->wireConfigFile); 916 | $differences = array(); 917 | 918 | foreach($siteConfig as $property => $line) { 919 | 920 | if(!isset($wireConfig[$property])) { 921 | // property is one added by site profile and is not present in wire config 922 | $differences[$property] = $line; 923 | continue; 924 | } 925 | 926 | // setup a comparison that ignores whitespace differences 927 | $test1 = preg_replace('/\s+/s', '', $line); 928 | $test2 = preg_replace('/\s+/s', '', $wireConfig[$property]); 929 | 930 | if($test1 != $test2) { 931 | // values are different 932 | $differences[$property] = $line; 933 | } 934 | } 935 | 936 | return $differences; 937 | } 938 | 939 | /** 940 | * Create a new config.php file based on the site and default config file 941 | * 942 | * The $properties array contains a list of properties from /site/config.php 943 | * that should override the ones from default. 944 | * 945 | * @param string $file 946 | * @param array $properties 947 | * @throws WireException 948 | * 949 | */ 950 | protected function writeConfigFile($file, array $properties) { 951 | 952 | $differences = $this->findConfigDifferences(); 953 | $fp = fopen($this->defaultConfigFile, "r"); 954 | if(!$fp) throw new WireException("Unable to open $this->defaultConfigFile for reading"); 955 | $lines = array(); 956 | 957 | while(!feof($fp)) { 958 | $_line = fgets($fp); 959 | $line = $this->cleanConfigLine($_line); 960 | 961 | if(strpos($line, '$config->') !== 0) { 962 | // line that is not part of a config property (probably a comment or whitespace line) 963 | $lines[] = $_line; 964 | continue; 965 | } 966 | 967 | $property = trim(substr($line, 0, strpos($line, '=')-1)); // $config->property 968 | $property = trim(substr($property, strpos($property, '>')+1)); // property 969 | 970 | while(substr(rtrim($line), -1) != ';' && !feof($fp)) { 971 | // retrieve any other lines associated with this property 972 | $line = fgets($fp); 973 | $_line .= $line; 974 | $line = $this->cleanConfigLine($line); 975 | } 976 | 977 | if(in_array($property, $properties)) { 978 | // this is a property where we should use the new value 979 | $_line = $differences[$property]; 980 | unset($differences[$property]); 981 | } 982 | 983 | $lines[] = $_line; 984 | } 985 | fclose($fp); 986 | 987 | // populate custom properties 988 | foreach($lines as $key => $line) { 989 | if(strpos($line, '/*{ProcessExportProfile}*/') === false) continue; 990 | $line = ''; 991 | foreach($differences as $property => $_line) { 992 | if(!in_array($property, $properties)) continue; 993 | $line .= $_line; 994 | } 995 | $lines[$key] = $line; 996 | } 997 | 998 | if(!file_put_contents($file, $lines)) { 999 | throw new WireException("Unable to write to $file"); 1000 | } 1001 | 1002 | $this->message(sprintf( 1003 | $this->_('Profile config file has been written: %s'), 1004 | $this->pathLabel($file) 1005 | )); 1006 | } 1007 | 1008 | 1009 | /** 1010 | * Create a mysql dump file 1011 | * 1012 | * @param string $path Path where dump file should be created 1013 | * @param bool $fullDump Perform a full dump of everything (default: false) 1014 | * @return string|bool Returns the created file on success or false on error 1015 | * 1016 | */ 1017 | public function mysqldump($path, $fullDump = false) { 1018 | 1019 | $config = $this->wire()->config; 1020 | $backup = new WireDatabaseBackup($path); 1021 | $backup->setDatabase($this->database); 1022 | $backup->setDatabaseConfig($config); 1023 | $options = array( 1024 | 'findReplaceCreateTable' => array() 1025 | ); 1026 | 1027 | // the installer dynamically replaces "utf8" and "MyISAM" with user's selected 1028 | // charset and engine, so we make sure our DB dump references the defaults 1029 | if($config->dbEngine == 'InnoDB') { 1030 | $options['findReplaceCreateTable']['DEFAULT CHARSET=utf8mb4'] = 'DEFAULT CHARSET=utf8'; 1031 | } 1032 | 1033 | if($config->dbCharset == 'utf8mb4') { 1034 | $options['findReplaceCreateTable']['ENGINE=InnoDB'] = 'ENGINE=MyISAM'; 1035 | } 1036 | 1037 | if($fullDump) { 1038 | 1039 | $options['filename'] = 'install-full.sql'; 1040 | 1041 | } else { 1042 | 1043 | // exclude this page 1044 | $skipPageIDs = array($this->wire()->page->id); 1045 | 1046 | // exclude users 1047 | foreach($this->wire()->pages->find("template=user, include=all") as $u) { 1048 | if(in_array($u->id, array($config->guestUserPageID, $config->superUserPageID))) continue; 1049 | $skipPageIDs[] = $u->id; 1050 | } 1051 | $skipPageIDs = implode(',', $skipPageIDs); 1052 | 1053 | $options['filename'] = 'install.sql'; 1054 | 1055 | // no create or data 1056 | $options['excludeTables'] = array( // old PW20 tables 1057 | 'pages_drafts', 1058 | 'pages_roles', 1059 | 'permissions', 1060 | 'roles', 1061 | 'roles_permissions', 1062 | 'users', 1063 | 'users_roles', 1064 | 'user', 1065 | 'role', 1066 | 'permission', 1067 | ); 1068 | 1069 | // create, but no data 1070 | $options['excludeExportTables'] = array( 1071 | 'field_roles', 1072 | 'field_permissions', 1073 | 'field_email', 1074 | 'field_pass', 1075 | 'caches', 1076 | 'session_login_throttle', 1077 | 'page_path_history', 1078 | ); 1079 | 1080 | $options['whereSQL'] = array( 1081 | '/^(pages_access|pages_parents)$/' => array( 1082 | "pages_id NOT IN($skipPageIDs)", 1083 | ), 1084 | '/^(' . implode('|', $this->getFieldTablesWithPagesID()) . ')$/' => array( 1085 | "pages_id NOT IN($skipPageIDs)", 1086 | ), 1087 | 'pages' => array( 1088 | "id NOT IN($skipPageIDs)", 1089 | ), 1090 | 'pages_parents' => array( 1091 | "parents_id NOT IN($skipPageIDs)", 1092 | "pages_id NOT IN($skipPageIDs)", 1093 | ), 1094 | 'modules' => array( 1095 | "id!=" . $this->wire()->modules->getModuleID($this), 1096 | ), 1097 | ); 1098 | 1099 | $options['extraSQL'] = array( 1100 | "UPDATE pages SET created_users_id=$config->superUserPageID, " . 1101 | "modified_users_id=$config->superUserPageID, created=NOW(), modified=NOW();", 1102 | ); 1103 | 1104 | } 1105 | 1106 | $result = $backup->backup($options); 1107 | foreach($backup->errors() as $error) $this->error($error); 1108 | 1109 | return $result; 1110 | } 1111 | 1112 | /** 1113 | * Return an array of field_* table names that have a pages_id column 1114 | * 1115 | * @return array of strings 1116 | * 1117 | */ 1118 | protected function getFieldTablesWithPagesID() { 1119 | $tables = array(); 1120 | $database = $this->wire()->database; 1121 | $db = $database->escapeTable($this->wire()->config->dbName); 1122 | $sql = 1123 | "SELECT DISTINCT TABLE_NAME " . 1124 | "FROM INFORMATION_SCHEMA.COLUMNS " . 1125 | "WHERE TABLE_SCHEMA='$db' " . 1126 | "AND COLUMN_NAME IN('pages_id')"; 1127 | $query = $database->prepare($sql); 1128 | $query->execute(); 1129 | while($row = $query->fetch(\PDO::FETCH_NUM)) { 1130 | $tables[] = $row[0]; 1131 | } 1132 | $query->closeCursor(); 1133 | return $tables; 1134 | } 1135 | 1136 | /** 1137 | * Checks if a profile already exists and array of info if so, false if not 1138 | * 1139 | * @return array|false 1140 | * 1141 | */ 1142 | protected function getExistingProfile() { 1143 | 1144 | $name = ''; 1145 | $time = 0; 1146 | 1147 | if(!is_dir($this->exportPath)) return false; 1148 | 1149 | foreach(new \DirectoryIterator($this->exportPath) as $file) { 1150 | if($file->isDot()) continue; 1151 | if($file->isDir() && strpos($file->getBasename(), 'site-') === 0) { 1152 | $name = $file->getBasename(); 1153 | $time = $file->getMTime(); 1154 | break; 1155 | } 1156 | } 1157 | 1158 | if($name) return array( 1159 | 'name' => $this->wire()->sanitizer->name($name), 1160 | 'time' => $time, 1161 | ); 1162 | 1163 | return false; 1164 | } 1165 | 1166 | /** 1167 | * Render a button 1168 | * 1169 | * @param string $href 1170 | * @param string $label 1171 | * @param string $icon 1172 | * @param string $class 1173 | * @return string 1174 | * 1175 | */ 1176 | protected function button($href, $label = '', $icon = 'angle-right', $class = '') { 1177 | if(empty($label)) $label = $this->labels['continue']; 1178 | /** @var InputfieldButton $btn */ 1179 | $btn = $this->wire()->modules->get('InputfieldButton'); 1180 | $btn->href = $href; 1181 | $btn->icon = $icon; 1182 | if($class) $btn->addClass($class); 1183 | $btn->value = $label; 1184 | return $btn->render(); 1185 | } 1186 | 1187 | /** 1188 | * @param string $path 1189 | * return string 1190 | * 1191 | */ 1192 | protected function pathLabel($path) { 1193 | return str_replace($this->wire()->config->paths->root, '/', $path); 1194 | } 1195 | 1196 | /** 1197 | * Uninstall ProcessExportProfile 1198 | * 1199 | */ 1200 | public function ___uninstall() { 1201 | parent::___uninstall(); 1202 | if($this->exportPath && is_dir($this->exportPath)) wireRmdir($this->exportPath); 1203 | } 1204 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Profile Exporter for ProcessWire 3.x 2 | 3 | This module exports an installable site profile of your site. It essentially enables 4 | you take any site and create files that the ProcessWire installer will recognize 5 | when installing a new copy of ProcessWire. This is the tool that is used to export 6 | all of the ProcessWire core site profiles. 7 | 8 | ## About site profiles 9 | 10 | Site profiles exported by this tool include the following: 11 | 12 | - The site database (but no caches and users) 13 | - Files associated with pages (/site/assets/files/) 14 | - Configuration file (/site/config.php), but no DB connection info 15 | - Template files and related assets (/site/templates/*) 16 | - Installed site modules (/site/modules/), except for this one 17 | - Installed custom Page classes (/site/classes/), when used 18 | - System files in /site/ (ready.php, init.php, finished.php, etc.) 19 | 20 | Site profiles do not include users or files not associated with pages (i.e. cache 21 | files, log files, etc.). Essentially, a site profile is meant to be a version of 22 | a site you can share with others, that contains no confidential data like 23 | user or password information. 24 | 25 | ## How to use 26 | 27 | Usage instructions are provided directly in the module when using it. After 28 | installing, go to "Setup > Export Site Profile" and it will guide you through the 29 | rest. 30 | 31 | ### Using a custom install/finish.php file 32 | 33 | ProcessWire 3.0.191+ supports a /site/install/finish.php file in the site profile 34 | which is a plain PHP file that has access to most site API variables and can 35 | perform any finishing touches on the site. It is called after the installer finishes 36 | but before it deletes installer assets and displays the final status messages to the user. 37 | 38 | After exporting a site profile, feel free to modify the default install/finish.php 39 | file to suit your needs. 40 | 41 | --- 42 | 43 | Copyright 2024 by Ryan Cramer -------------------------------------------------------------------------------- /config.php: -------------------------------------------------------------------------------- 1 | debug = false; 38 | 39 | /*{ProcessExportProfile}*/ 40 | 41 | 42 | /*** INSTALLER CONFIG ********************************************************************/ -------------------------------------------------------------------------------- /site-skel/assets/index.php: -------------------------------------------------------------------------------- 1 | ok('Finished installing site profile'); 31 | -------------------------------------------------------------------------------- /site-skel/modules/README.txt: -------------------------------------------------------------------------------- 1 | This file is here to ensure Git adds the dir to the repo. You may delete this file. 2 | -------------------------------------------------------------------------------- /site-skel/templates/README.txt: -------------------------------------------------------------------------------- 1 | This file is here to ensure Git adds the dir to the repo. You may delete this file. 2 | --------------------------------------------------------------------------------