diff --git a/system/migrate/migrate.php b/system/migrate/migrate.php new file mode 100644 index 000000000..a8445b8c4 --- /dev/null +++ b/system/migrate/migrate.php @@ -0,0 +1,1538 @@ + MIGRATE_STATE_VERSION, + 'current_stage' => 'auth', + 'completed' => [], + 'stage_dir' => 'grav-2', + ]; + + return $data; +} + +function save_state(array $state): void +{ + $json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RuntimeException('Failed to encode state.'); + } + $tmp = MIGRATE_FLAG . '.tmp'; + if (file_put_contents($tmp, $json) === false) { + throw new RuntimeException('Failed to write state tmp file.'); + } + if (!rename($tmp, MIGRATE_FLAG)) { + @unlink($tmp); + throw new RuntimeException('Failed to replace state file.'); + } + @chmod(MIGRATE_FLAG, 0600); +} + +function redact_state(array $state): array +{ + unset($state['token']); + return $state; +} + +function advance_stage(array &$state, string $stage, array $data = []): void +{ + $state[$stage] = $data + [$stage => true]; + if (!in_array($stage, $state['completed'] ?? [], true)) { + $state['completed'][] = $stage; + } + $idx = array_search($stage, STAGES, true); + $state['current_stage'] = $idx !== false && isset(STAGES[$idx + 1]) + ? STAGES[$idx + 1] + : 'done'; + save_state($state); +} + +// --------------------------------------------------------------------------- +// Auth (against the 1.x user/accounts/*.yaml files) +// --------------------------------------------------------------------------- + +function user_accounts_dir(): string +{ + return MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . 'user' . DIRECTORY_SEPARATOR . 'accounts'; +} + +function parse_minimal_yaml(string $content): array +{ + if (function_exists('yaml_parse')) { + $parsed = @yaml_parse($content); + if (is_array($parsed)) { + return $parsed; + } + } + // Tiny fallback — handles the flat + nested-map subset used by account + // files. Doesn't support arrays or multi-line strings, which is fine here. + $lines = preg_split("/\r\n|\n|\r/", $content) ?: []; + $root = []; + $stack = [['indent' => -1, 'ref' => &$root]]; + foreach ($lines as $line) { + $stripped = rtrim($line); + $t = ltrim($stripped); + if ($t === '' || $t[0] === '#') { + continue; + } + $indent = strlen($stripped) - strlen($t); + $indent = strlen(str_replace("\t", ' ', substr($stripped, 0, $indent))); + + while (count($stack) > 1 && $indent <= $stack[count($stack) - 1]['indent']) { + array_pop($stack); + } + if (!preg_match('/^([A-Za-z0-9_.-]+):\s*(.*)$/', $t, $m)) { + continue; + } + $key = $m[1]; + $val = trim($m[2]); + + $parent = &$stack[count($stack) - 1]['ref']; + if ($val === '' || $val === '~' || strtolower($val) === 'null') { + $parent[$key] = []; + $stack[] = ['indent' => $indent, 'ref' => &$parent[$key]]; + } else { + if ($val === 'true') { + $val = true; + } elseif ($val === 'false') { + $val = false; + } elseif (preg_match('/^-?\d+$/', $val)) { + $val = (int)$val; + } elseif (preg_match('/^"(.*)"$/', $val, $mm) || preg_match("/^'(.*)'$/", $val, $mm)) { + $val = $mm[1]; + } + $parent[$key] = $val; + } + unset($parent); + } + + return $root; +} + +function load_user_account(string $username): ?array +{ + $path = user_accounts_dir() . DIRECTORY_SEPARATOR . $username . '.yaml'; + if (!is_file($path)) { + return null; + } + $raw = @file_get_contents($path); + if ($raw === false) { + return null; + } + $data = parse_minimal_yaml($raw); + $data['_username'] = $username; + return $data; +} + +function account_is_super_admin(array $account): bool +{ + // Grav stores super as access.admin.super: true + $access = $account['access'] ?? null; + if (is_array($access) && isset($access['admin']) && is_array($access['admin'])) { + return !empty($access['admin']['super']); + } + return false; +} + +function verify_credentials(string $username, string $password): array +{ + $account = load_user_account($username); + if (!$account) { + throw new RuntimeException('Unknown user.'); + } + if (($account['state'] ?? 'enabled') !== 'enabled') { + throw new RuntimeException('Account is disabled.'); + } + $hash = (string)($account['hashed_password'] ?? ''); + if ($hash === '' || !password_verify($password, $hash)) { + throw new RuntimeException('Invalid credentials.'); + } + if (!account_is_super_admin($account)) { + throw new RuntimeException('A super-admin account is required to run migration.'); + } + return $account; +} + +// --------------------------------------------------------------------------- +// Session / CSRF +// --------------------------------------------------------------------------- + +function start_wizard_session(): void +{ + if (session_status() !== PHP_SESSION_ACTIVE) { + session_name('GRAV_MIGRATE'); + session_set_cookie_params([ + 'lifetime' => 0, + 'path' => dirname($_SERVER['SCRIPT_NAME'] ?? '/') ?: '/', + 'httponly' => true, + 'samesite' => 'Lax', + ]); + @session_start(); + } +} + +function require_token(array $state): void +{ + start_wizard_session(); + + $supplied = $_GET['token'] ?? $_POST['token'] ?? $_SERVER['HTTP_X_MIGRATE_TOKEN'] ?? null; + $sessionToken = $_SESSION['migrate_token'] ?? null; + + if ($supplied && hash_equals($state['token'], (string)$supplied)) { + $_SESSION['migrate_token'] = $state['token']; + return; + } + if ($sessionToken && hash_equals($state['token'], (string)$sessionToken)) { + return; + } + + throw new RuntimeException('Invalid or missing migration token.'); +} + +function require_authenticated(array $state): void +{ + start_wizard_session(); + if (empty($_SESSION['migrate_user'])) { + throw new RuntimeException('Authentication required.'); + } +} + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +function fmt_bytes(int $n): string +{ + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $i = 0; + $v = (float)$n; + while ($v >= 1024 && $i < count($units) - 1) { + $v /= 1024; + $i++; + } + return sprintf('%.1f %s', $v, $units[$i]); +} + +function disk_free_at(string $path): int +{ + $free = @disk_free_space($path); + return is_numeric($free) ? (int)$free : -1; +} + +function dir_size(string $path): int +{ + if (!is_dir($path)) { + return 0; + } + $total = 0; + $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator( + $path, FilesystemIterator::SKIP_DOTS + )); + foreach ($it as $f) { + if ($f->isFile()) { + $total += $f->getSize(); + } + } + return $total; +} + +/** + * Copy a directory tree. Symlinks are not followed; they're recorded in + * $skipped for operator decision. Returns ['files'=>int,'skipped'=>string[]]. + */ +function fs_copy_tree(string $src, string $dst, array &$skipped = []): array +{ + if (!is_dir($src)) { + return ['files' => 0, 'skipped' => $skipped]; + } + if (!is_dir($dst) && !mkdir($dst, 0775, true) && !is_dir($dst)) { + throw new RuntimeException("Cannot create destination: {$dst}"); + } + + $files = 0; + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($it as $entry) { + /** @var SplFileInfo $entry */ + $rel = substr($entry->getPathname(), strlen($src) + 1); + $target = $dst . DIRECTORY_SEPARATOR . $rel; + + if ($entry->isLink()) { + $skipped[] = $entry->getPathname(); + continue; + } + if ($entry->isDir()) { + if (!is_dir($target) && !@mkdir($target, 0775, true) && !is_dir($target)) { + throw new RuntimeException("Cannot mkdir {$target}"); + } + } elseif ($entry->isFile()) { + $parent = dirname($target); + if (!is_dir($parent) && !@mkdir($parent, 0775, true) && !is_dir($parent)) { + throw new RuntimeException("Cannot mkdir {$parent}"); + } + if (!@copy($entry->getPathname(), $target)) { + throw new RuntimeException("Cannot copy {$entry->getPathname()} → {$target}"); + } + $files++; + } + } + return ['files' => $files, 'skipped' => $skipped]; +} + +function fs_rmtree(string $path): void +{ + if (!file_exists($path) && !is_link($path)) { + return; + } + if (is_link($path) || !is_dir($path)) { + @unlink($path); + return; + } + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($it as $f) { + if ($f->isLink() || $f->isFile()) { + @unlink($f->getPathname()); + } elseif ($f->isDir()) { + @rmdir($f->getPathname()); + } + } + @rmdir($path); +} + +/** + * Read a plugin/theme blueprint YAML and return its parsed map. + */ +function read_blueprint(string $path): ?array +{ + $f = $path . DIRECTORY_SEPARATOR . 'blueprints.yaml'; + if (!is_file($f)) { + return null; + } + $raw = @file_get_contents($f); + if ($raw === false) { + return null; + } + return parse_minimal_yaml($raw); +} + +/** + * Mirror of Grav\Common\GPM\Local\Package::resolveCompatibility() for use + * inside this standalone wizard. Reads blueprint + dep inference. + */ +function resolve_compatibility(?array $blueprint): array +{ + if (!$blueprint) { + return ['grav' => ['1.7'], 'api' => [], 'source' => 'default']; + } + + $compat = $blueprint['compatibility'] ?? null; + if (is_array($compat) && isset($compat['grav']) && is_array($compat['grav'])) { + return [ + 'grav' => array_map('strval', $compat['grav']), + 'api' => isset($compat['api']) && is_array($compat['api']) ? array_map('strval', $compat['api']) : [], + 'source' => 'blueprint', + ]; + } + + $deps = $blueprint['dependencies'] ?? []; + if (is_array($deps)) { + foreach ($deps as $dep) { + if (!is_array($dep) || ($dep['name'] ?? '') !== 'grav') { + continue; + } + $v = (string)($dep['version'] ?? ''); + if (!preg_match('/(\d+\.\d+(?:\.\d+)?)/', $v, $m)) { + continue; + } + if (version_compare($m[1], '2.0', '>=')) { + return ['grav' => ['2.0'], 'api' => [], 'source' => 'inferred']; + } + if (version_compare($m[1], '1.8', '>=')) { + return ['grav' => ['1.8'], 'api' => [], 'source' => 'inferred']; + } + return ['grav' => ['1.7'], 'api' => [], 'source' => 'inferred']; + } + } + + return ['grav' => ['1.7'], 'api' => [], 'source' => 'default']; +} + +function fetch_curated_compat(string $slug): ?array +{ + $url = 'https://getgrav.org/gpm/compatibility/v1/' . urlencode($slug); + $ctx = stream_context_create([ + 'http' => ['timeout' => 3, 'ignore_errors' => true, 'user_agent' => 'grav-migrate/1.0'], + ]); + $body = @file_get_contents($url, false, $ctx); + if ($body === false || $body === '') { + return null; + } + $data = json_decode($body, true); + return is_array($data) ? $data : null; +} + +function classify_plugin(array $compat, ?array $curated): string +{ + $grav = $compat['grav'] ?? []; + $curatedGrav = is_array($curated['grav'] ?? null) ? $curated['grav'] : []; + + if (in_array('2.0', $grav, true) || in_array('2.0', $curatedGrav, true)) { + return 'works'; + } + if ($curated && isset($curated['grav']) && !in_array('2.0', $curatedGrav, true)) { + return 'incompatible'; + } + if (in_array('1.7', $grav, true) || in_array('1.8', $grav, true)) { + return 'needs_update'; + } + return 'unknown'; +} + +function run_subprocess(array $cmd, ?string $cwd = null): array +{ + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $proc = proc_open($cmd, $descriptors, $pipes, $cwd); + if (!is_resource($proc)) { + throw new RuntimeException('Failed to start subprocess: ' . implode(' ', $cmd)); + } + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]) ?: ''; + $stderr = stream_get_contents($pipes[2]) ?: ''; + fclose($pipes[1]); + fclose($pipes[2]); + $code = proc_close($proc); + return ['code' => $code, 'stdout' => $stdout, 'stderr' => $stderr]; +} + +function php_binary(): string +{ + if (defined('PHP_BINARY') && PHP_BINARY) { + return PHP_BINARY; + } + return 'php'; +} + +// --------------------------------------------------------------------------- +// Stage handlers +// --------------------------------------------------------------------------- + +function action_state(array $state): array +{ + return ['state' => redact_state($state)]; +} + +function action_auth(array $state): array +{ + if (in_array('auth', $state['completed'] ?? [], true)) { + return ['already' => true]; + } + $username = trim((string)($_POST['username'] ?? '')); + $password = (string)($_POST['password'] ?? ''); + if ($username === '' || $password === '') { + throw new RuntimeException('Username and password required.'); + } + + $account = verify_credentials($username, $password); + + start_wizard_session(); + $_SESSION['migrate_user'] = $account['_username']; + + advance_stage($state, 'auth', [ + 'authenticated_user' => $account['_username'], + 'authenticated_at' => time(), + ]); + + return ['authenticated_user' => $account['_username']]; +} + +function action_preflight(array $state): array +{ + require_authenticated($state); + + $checks = []; + + $checks[] = [ + 'name' => 'PHP version', + 'pass' => version_compare(PHP_VERSION, MIGRATE_PHP_MIN, '>='), + 'detail' => 'Running ' . PHP_VERSION . ', need ≥ ' . MIGRATE_PHP_MIN, + ]; + + foreach (['zip', 'json', 'mbstring', 'fileinfo'] as $ext) { + $checks[] = [ + 'name' => "PHP ext: {$ext}", + 'pass' => extension_loaded($ext), + 'detail' => extension_loaded($ext) ? 'loaded' : 'missing', + ]; + } + + $writableRoot = is_writable(MIGRATE_WEBROOT); + $checks[] = [ + 'name' => 'Webroot writable', + 'pass' => $writableRoot, + 'detail' => MIGRATE_WEBROOT, + ]; + + $userDir = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . 'user'; + $userSize = dir_size($userDir); + $free = disk_free_at(MIGRATE_WEBROOT); + $needed = max($userSize * 2, 200 * 1024 * 1024); // 2x user/ or 200MB min + $checks[] = [ + 'name' => 'Disk space', + 'pass' => $free < 0 ? true : $free >= $needed, + 'detail' => sprintf( + 'user/ ≈ %s, free ≈ %s, need ≈ %s', + fmt_bytes($userSize), + $free < 0 ? 'unknown' : fmt_bytes($free), + fmt_bytes($needed) + ), + ]; + + $stageDir = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . ($state['stage_dir'] ?? 'grav-2'); + $checks[] = [ + 'name' => 'Stage dir available', + 'pass' => !is_dir($stageDir), + 'detail' => is_dir($stageDir) ? "{$stageDir} already exists" : 'ok', + ]; + + $zipPath = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . ($state['staged_zip'] ?? 'tmp/grav-2.0-staged.zip'); + $checks[] = [ + 'name' => 'Staged 2.0 zip present', + 'pass' => is_file($zipPath), + 'detail' => is_file($zipPath) ? fmt_bytes((int)filesize($zipPath)) : "missing: {$zipPath}", + ]; + + $passed = !in_array(false, array_column($checks, 'pass'), true); + + if ($passed) { + advance_stage($state, 'preflight', ['passed' => true, 'checks' => $checks]); + } + + return ['passed' => $passed, 'checks' => $checks]; +} + +function action_snapshot(array $state): array +{ + require_authenticated($state); + + $backupDir = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . 'backup'; + if (!is_dir($backupDir) && !mkdir($backupDir, 0775, true) && !is_dir($backupDir)) { + throw new RuntimeException('Cannot create backup/ directory.'); + } + + $zipName = 'pre-migration-' . date('YmdHis') . '.zip'; + $zipPath = $backupDir . DIRECTORY_SEPARATOR . $zipName; + + $userDir = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . 'user'; + if (!is_dir($userDir)) { + throw new RuntimeException('user/ directory not found; nothing to back up.'); + } + + $zip = new ZipArchive(); + if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException("Cannot open backup zip for write: {$zipPath}"); + } + + $base = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR; + $fileCount = 0; + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($userDir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($it as $f) { + $abs = $f->getPathname(); + $rel = ltrim(str_replace($base, '', $abs), DIRECTORY_SEPARATOR); + if ($f->isDir()) { + $zip->addEmptyDir($rel); + } elseif ($f->isFile()) { + $zip->addFile($abs, $rel); + $fileCount++; + } + } + $zip->close(); + + $size = (int)@filesize($zipPath); + advance_stage($state, 'snapshot', [ + 'path' => 'backup/' . $zipName, + 'size' => $size, + 'files' => $fileCount, + 'created_at' => time(), + ]); + + return ['path' => 'backup/' . $zipName, 'size' => $size, 'files' => $fileCount]; +} + +function action_stage(array $state): array +{ + require_authenticated($state); + + $zipRel = $state['staged_zip'] ?? 'tmp/grav-2.0-staged.zip'; + $zipPath = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . $zipRel; + if (!is_file($zipPath)) { + throw new RuntimeException("Staged zip missing: {$zipRel}"); + } + + $stageDir = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . ($state['stage_dir'] ?? 'grav-2'); + if (is_dir($stageDir)) { + throw new RuntimeException("Stage directory already exists: {$stageDir}"); + } + if (!mkdir($stageDir, 0775, true)) { + throw new RuntimeException("Cannot create stage directory: {$stageDir}"); + } + + $zip = new ZipArchive(); + $opened = $zip->open($zipPath); + if ($opened !== true) { + throw new RuntimeException("Cannot open staged zip (code {$opened}): {$zipPath}"); + } + + // Detect if zip contents are wrapped in a single top-level directory. + $prefix = ''; + if ($zip->numFiles > 0) { + $first = (string)$zip->getNameIndex(0); + if ($first !== '' && strpos($first, '/') !== false) { + $candidate = substr($first, 0, strpos($first, '/') + 1); + $wrapped = true; + for ($i = 1, $n = min($zip->numFiles, 32); $i < $n; $i++) { + $name = (string)$zip->getNameIndex($i); + if ($name !== '' && strpos($name, $candidate) !== 0) { + $wrapped = false; + break; + } + } + if ($wrapped) { + $prefix = $candidate; + } + } + } + + $extracted = 0; + for ($i = 0, $n = $zip->numFiles; $i < $n; $i++) { + $name = (string)$zip->getNameIndex($i); + if ($name === '') { + continue; + } + $rel = $prefix !== '' && strpos($name, $prefix) === 0 + ? substr($name, strlen($prefix)) + : $name; + if ($rel === '' || $rel === false) { + continue; + } + $dest = $stageDir . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $rel); + if (substr($name, -1) === '/') { + if (!is_dir($dest) && !@mkdir($dest, 0775, true)) { + throw new RuntimeException("Cannot create dir {$dest}"); + } + } else { + $parent = dirname($dest); + if (!is_dir($parent) && !@mkdir($parent, 0775, true)) { + throw new RuntimeException("Cannot create parent {$parent}"); + } + $stream = $zip->getStream($name); + if (!$stream) { + throw new RuntimeException("Cannot stream entry {$name}"); + } + $out = fopen($dest, 'wb'); + if (!$out) { + fclose($stream); + throw new RuntimeException("Cannot open {$dest} for write"); + } + while (!feof($stream)) { + $buf = fread($stream, 1 << 16); + if ($buf === false) { + break; + } + fwrite($out, $buf); + } + fclose($stream); + fclose($out); + $extracted++; + } + } + $zip->close(); + + advance_stage($state, 'stage', [ + 'stage_dir' => $state['stage_dir'] ?? 'grav-2', + 'extracted_files' => $extracted, + 'extracted_at' => time(), + ]); + + return ['stage_dir' => $state['stage_dir'] ?? 'grav-2', 'extracted_files' => $extracted]; +} + +/** + * Copy user-side content (pages, data, accounts, config, themes, media) from + * the source site into the staged grav-2/ tree. Plugins are intentionally + * handled later in evaluate/install stages. + */ +function action_import(array $state): array +{ + require_authenticated($state); + + $stageDir = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . ($state['stage_dir'] ?? 'grav-2'); + if (!is_dir($stageDir)) { + throw new RuntimeException('Stage directory missing — run the stage step first.'); + } + + $srcUser = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . 'user'; + $dstUser = $stageDir . DIRECTORY_SEPARATOR . 'user'; + if (!is_dir($srcUser)) { + throw new RuntimeException('Source user/ directory missing.'); + } + + $subdirs = ['pages', 'data', 'accounts', 'config', 'themes', 'media', 'backups']; + $summary = []; + $skipped = []; + foreach ($subdirs as $sub) { + $src = $srcUser . DIRECTORY_SEPARATOR . $sub; + $dst = $dstUser . DIRECTORY_SEPARATOR . $sub; + if (!is_dir($src)) { + continue; + } + $result = fs_copy_tree($src, $dst, $skipped); + $summary[$sub] = $result['files']; + } + + advance_stage($state, 'import', [ + 'copied' => $summary, + 'skipped_symlinks' => $skipped, + 'imported_at' => time(), + ]); + + return ['copied' => $summary, 'skipped_symlinks' => $skipped]; +} + +/** + * Walk source user/plugins/*, classify each by compatibility, and store the + * inventory in state. Does NOT apply anything; decisions happen in install(). + */ +function action_evaluate(array $state): array +{ + require_authenticated($state); + + $srcPlugins = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . 'user' . DIRECTORY_SEPARATOR . 'plugins'; + if (!is_dir($srcPlugins)) { + advance_stage($state, 'evaluate', ['plugins' => []]); + return ['plugins' => []]; + } + + $plugins = []; + foreach (new DirectoryIterator($srcPlugins) as $d) { + if ($d->isDot() || !$d->isDir()) { + continue; + } + $slug = $d->getFilename(); + $path = $d->getPathname(); + + $bp = read_blueprint($path); + $compat = resolve_compatibility($bp); + $curated = fetch_curated_compat($slug); + $class = classify_plugin($compat, $curated); + + $default = match ($class) { + 'works' => 'import', + 'needs_update' => 'reinstall', + 'incompatible' => 'skip', + default => 'skip', + }; + + $plugins[$slug] = [ + 'slug' => $slug, + 'version' => $bp['version'] ?? null, + 'symlink' => is_link($path), + 'compatibility' => $compat, + 'curated' => $curated, + 'classification' => $class, + 'default_action' => $default, + ]; + } + + ksort($plugins); + + advance_stage($state, 'evaluate', [ + 'plugins' => $plugins, + 'evaluated_at' => time(), + ]); + + return ['plugins' => $plugins]; +} + +/** + * Apply per-plugin decisions (import/reinstall/skip) from the UI into the + * staged tree. Decisions arrive as plugins[slug] = action in POST. + */ +function action_install(array $state): array +{ + require_authenticated($state); + + $decisions = $_POST['plugins'] ?? null; + if (!is_array($decisions)) { + throw new RuntimeException('No plugin decisions submitted.'); + } + + $known = $state['evaluate']['plugins'] ?? []; + if (!$known) { + throw new RuntimeException('Evaluate stage has no plugin inventory.'); + } + + $stageDir = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . ($state['stage_dir'] ?? 'grav-2'); + $srcPlugins = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . 'user' . DIRECTORY_SEPARATOR . 'plugins'; + $dstPlugins = $stageDir . DIRECTORY_SEPARATOR . 'user' . DIRECTORY_SEPARATOR . 'plugins'; + if (!is_dir($dstPlugins) && !mkdir($dstPlugins, 0775, true) && !is_dir($dstPlugins)) { + throw new RuntimeException("Cannot create {$dstPlugins}"); + } + + $gpmBin = $stageDir . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'gpm'; + $results = []; + $skipped = []; + + foreach ($known as $slug => $info) { + $choice = (string)($decisions[$slug] ?? $info['default_action']); + if (!in_array($choice, ['import', 'reinstall', 'skip'], true)) { + $choice = 'skip'; + } + + if ($choice === 'skip') { + $results[$slug] = ['action' => 'skip']; + continue; + } + + if ($choice === 'import') { + if ($info['symlink']) { + $skipped[] = $slug; + $results[$slug] = ['action' => 'import', 'skipped' => 'symlink']; + continue; + } + $src = $srcPlugins . DIRECTORY_SEPARATOR . $slug; + $dst = $dstPlugins . DIRECTORY_SEPARATOR . $slug; + if (is_dir($dst)) { + fs_rmtree($dst); + } + $dummy = []; + $stat = fs_copy_tree($src, $dst, $dummy); + $results[$slug] = ['action' => 'import', 'files' => $stat['files']]; + continue; + } + + // reinstall via the staged 2.0 gpm + if (!is_file($gpmBin)) { + $results[$slug] = ['action' => 'reinstall', 'error' => 'staged bin/gpm not found']; + continue; + } + $proc = run_subprocess([php_binary(), $gpmBin, 'install', $slug, '--yes'], $stageDir); + $results[$slug] = [ + 'action' => 'reinstall', + 'code' => $proc['code'], + 'stderr' => trim((string)$proc['stderr']), + ]; + } + + advance_stage($state, 'install', [ + 'results' => $results, + 'skipped_symlinks' => $skipped, + 'installed_at' => time(), + ]); + + return ['results' => $results, 'skipped_symlinks' => $skipped]; +} + +/** + * Minimal health check on the staged Grav 2.0 site: run `bin/grav list` to + * confirm the install bootstraps without fatal errors. + */ +function action_test(array $state): array +{ + require_authenticated($state); + + $stageDir = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . ($state['stage_dir'] ?? 'grav-2'); + $gravBin = $stageDir . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'grav'; + if (!is_file($gravBin)) { + throw new RuntimeException("Staged grav CLI missing: {$gravBin}"); + } + + $proc = run_subprocess([php_binary(), $gravBin, 'list'], $stageDir); + $ok = $proc['code'] === 0; + + advance_stage($state, 'test', [ + 'ok' => $ok, + 'code' => $proc['code'], + 'stdout_excerpt' => substr((string)$proc['stdout'], 0, 2000), + 'stderr_excerpt' => substr((string)$proc['stderr'], 0, 2000), + 'tested_at' => time(), + ]); + + return [ + 'ok' => $ok, + 'code' => $proc['code'], + 'stdout' => substr((string)$proc['stdout'], 0, 4000), + 'stderr' => substr((string)$proc['stderr'], 0, 4000), + ]; +} + +/** + * Swap: move current webroot contents into an archive directory, then move + * the staged grav-2/ contents up to webroot. Preserves migrate.php, the + * .migrating flag, the backup/ directory, and tmp/. + */ +function action_promote(array $state): array +{ + require_authenticated($state); + + $confirm = (string)($_POST['confirm'] ?? ''); + if ($confirm !== 'PROMOTE') { + throw new RuntimeException('Type PROMOTE to confirm.'); + } + + $stageDir = ($state['stage_dir'] ?? 'grav-2'); + $stagePath = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . $stageDir; + if (!is_dir($stagePath)) { + throw new RuntimeException("Stage directory missing: {$stagePath}"); + } + + $archiveName = 'grav-1x-archive-' . date('YmdHis'); + $archivePath = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . $archiveName; + if (!mkdir($archivePath, 0775)) { + throw new RuntimeException("Cannot create archive dir: {$archivePath}"); + } + + // Entries preserved at webroot and NOT archived. + $preserve = [ + 'migrate.php', '.migrating', $stageDir, $archiveName, + 'backup', 'tmp', + ]; + // Entries from the 1.x site we should archive. Walk top level only. + $movedToArchive = []; + foreach (new DirectoryIterator(MIGRATE_WEBROOT) as $e) { + if ($e->isDot()) { + continue; + } + $name = $e->getFilename(); + if (in_array($name, $preserve, true)) { + continue; + } + // Safety: don't archive other grav-1x-archive-* from previous runs. + if (strpos($name, 'grav-1x-archive-') === 0) { + continue; + } + $src = $e->getPathname(); + $dst = $archivePath . DIRECTORY_SEPARATOR . $name; + if (!@rename($src, $dst)) { + throw new RuntimeException("Failed to archive {$name}"); + } + $movedToArchive[] = $name; + } + + // Move staged contents up to webroot. + $promoted = []; + foreach (new DirectoryIterator($stagePath) as $e) { + if ($e->isDot()) { + continue; + } + $name = $e->getFilename(); + $src = $e->getPathname(); + $dst = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . $name; + + // If 2.0 ships a backup/ or tmp/, merge rather than clobber. + if (is_dir($dst) && in_array($name, ['backup', 'tmp'], true)) { + continue; + } + if (file_exists($dst)) { + // Shouldn't happen for non-preserved names since we archived them, + // but be defensive: move into archive instead of overwriting. + @rename($dst, $archivePath . DIRECTORY_SEPARATOR . $name . '.conflict'); + } + if (!@rename($src, $dst)) { + throw new RuntimeException("Failed to promote {$name} → webroot"); + } + $promoted[] = $name; + } + + // Clean up the now-empty stage dir. + @rmdir($stagePath); + + // Also move the staged zip into the archive for tidiness. + $stagedZip = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . ($state['staged_zip'] ?? 'tmp/grav-2.0-staged.zip'); + if (is_file($stagedZip)) { + @rename($stagedZip, $archivePath . DIRECTORY_SEPARATOR . basename($stagedZip)); + } + + advance_stage($state, 'promote', [ + 'archive' => $archiveName, + 'archived' => $movedToArchive, + 'promoted' => $promoted, + 'promoted_at' => time(), + ]); + + return [ + 'archive' => $archiveName, + 'archived_count' => count($movedToArchive), + 'promoted_count' => count($promoted), + ]; +} + +/** + * Remove migrate.php and .migrating. Archive is left alone — operator deletes + * when satisfied. + */ +function action_cleanup(array $state): array +{ + require_authenticated($state); + + $confirm = (string)($_POST['confirm'] ?? ''); + if ($confirm !== 'REMOVE') { + throw new RuntimeException('Type REMOVE to confirm.'); + } + + advance_stage($state, 'cleanup', ['cleaned_at' => time()]); + + // After advance_stage persists, actually nuke our own surfaces. + $self = __FILE__; + $flag = MIGRATE_FLAG; + + @unlink($flag); + // Leave migrate.php deletion to a shutdown-time unlink so we finish the + // response cleanly. + register_shutdown_function(static function () use ($self) { + @unlink($self); + }); + + return ['removed' => ['.migrating', 'migrate.php (pending shutdown)']]; +} + +// --------------------------------------------------------------------------- +// Request dispatch +// --------------------------------------------------------------------------- + +function send_json($payload, int $code = 200): void +{ + http_response_code($code); + header('Content-Type: application/json; charset=utf-8'); + header('Cache-Control: no-store'); + echo json_encode($payload, JSON_UNESCAPED_SLASHES); + exit; +} + +function handle_request(): void +{ + $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + $action = $_GET['action'] ?? $_POST['action'] ?? null; + + try { + $state = load_state(); + } catch (Throwable $e) { + if ($method === 'POST' || $action) { + send_json(['ok' => false, 'error' => $e->getMessage()], 400); + } + render_error($e->getMessage()); + return; + } + + try { + require_token($state); + } catch (Throwable $e) { + if ($method === 'POST' || $action) { + send_json(['ok' => false, 'error' => $e->getMessage()], 403); + } + render_error($e->getMessage()); + return; + } + + if ($method !== 'POST' && $action !== 'state') { + render_shell($state); + return; + } + + try { + switch ($action) { + case 'state': $res = action_state($state); break; + case 'auth': $res = action_auth($state); break; + case 'preflight': $res = action_preflight($state); break; + case 'snapshot': $res = action_snapshot($state); break; + case 'stage': $res = action_stage($state); break; + case 'import': $res = action_import($state); break; + case 'evaluate': $res = action_evaluate($state); break; + case 'install': $res = action_install($state); break; + case 'test': $res = action_test($state); break; + case 'promote': $res = action_promote($state); break; + case 'cleanup': $res = action_cleanup($state); break; + default: + throw new RuntimeException("Unknown action: " . (string)$action); + } + } catch (Throwable $e) { + send_json(['ok' => false, 'error' => $e->getMessage()], 400); + } + + // Re-load so the response always reflects persisted state. + $fresh = load_state(); + send_json(['ok' => true, 'result' => $res, 'state' => redact_state($fresh)]); +} + +function render_error(string $message): void +{ + http_response_code(400); + header('Content-Type: text/html; charset=utf-8'); + $msg = htmlspecialchars($message, ENT_QUOTES, 'UTF-8'); + ?> +
If you expected this wizard to run, check that .migrating exists at the webroot and that you reached this page through the redirect from the migrate-to-2 plugin.
Running standalone — your Grav 1.x install is not loaded.
+Sign in with a super-admin account from your Grav 1.x site.
+ +Verify the environment can host Grav 2.0.
+ + + +Zip up user/ into backup/ so you can roll back if needed.
+ +Extract the Grav 2.0 release into .
+ +Copy pages, data, accounts, config, themes, and media from your 1.x site into the staged Grav 2.0.
+ +Classify each plugin by its compatibility.grav flag, dependency inference, and the curated getgrav.org list.
+ + + + +| Plugin | +Status | +Grav | +Action | +
|---|---|---|---|
| + | + + | ++ | + + | +
Return to the evaluate step to submit decisions.
+ +Runs bin/grav list inside to confirm the staged install bootstraps.
+ + + + ++ Archives your Grav 1.x site to grav-1x-archive-YmdHis/ and moves the staged 2.0 install up to the webroot. + This cuts over your live site. +
+You can skip this step and keep 2.0 running at indefinitely.
+ + + ++ Removes migrate.php and .migrating from the webroot. + Your archive directory is left alone — delete it manually once you're confident the migration is good. +
+ + +All stages finished. You can remove this file when satisfied.
+