';
$webroot = __DIR__;
$flagPath = $webroot . '/.migrating';
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$action = (string) ($_POST['action'] ?? '');
$token = (string) ($_GET['token'] ?? $_POST['token'] ?? '');
// CLI is intentionally not supported for the wizard itself. The multi-step
// flow (extract / plugins+themes / accounts / content / promote) needs the
// browser UI for compat review, policy choices, and progress streams.
// `bin/plugin migrate-grav init` is still the right CLI entry point — it
// stages the files and prints the URL to open.
if (PHP_SAPI === 'cli') {
fwrite(STDERR, "The Grav 2.0 migration wizard is browser-only.\n");
if (is_file($flagPath)) {
$f = json_decode((string) file_get_contents($flagPath), true);
if (is_array($f) && !empty($f['wizard_url'])) {
fwrite(STDERR, "Open in your browser: {$f['wizard_url']}\n");
}
} else {
fwrite(STDERR, "Run `bin/plugin migrate-grav init` first to stage the migration.\n");
}
exit(2);
}
// Common validation for any POST action
if ($method === 'POST') {
$flagForAuth = load_flag($flagPath);
$expected = $flagForAuth['token'] ?? '';
if ($expected === '' || $expected !== $token) {
http_response_code(403);
render_error_page('Invalid token', 'Action requires a matching migration token.');
exit(1);
}
switch ($action) {
case 'reset':
wizard_reset($webroot, (string) ($flagForAuth['stage_dir'] ?? 'grav-2'));
header('Location: ' . base_path_from_script(), true, 302);
exit(0);
case 'restart':
// Light reset: keep flag (rewound), keep zip + wizard, drop stage
// dir and restore .htaccess. Send the user back to the wizard
// landing so they can re-run from step 1.
wizard_restart($webroot, $flagPath, $flagForAuth);
redirect_self($token, ['flash' => 'restarted', 'msg' => 'Wizard restarted. The staged Grav 2.0 directory was cleared — re-run from step 1.']);
case 'extract':
// Long-running. Stream progress instead of blocking then redirecting.
stream_extract_page($webroot, $flagForAuth, $token);
exit(0);
case 'plugins_themes':
$reqMode = (string) ($_POST['mode'] ?? MG_MODE_STRICT);
$mode = in_array($reqMode, MG_MODES_ALL, true) ? $reqMode : MG_MODE_STRICT;
// Form's "upgrade_plugins" checkbox is positive-framed (checked
// means run gpm update). Internally we keep the legacy
// skip_update flag so the rest of the pipeline stays untouched.
$options = [
'mode' => $mode,
'policy' => (($_POST['policy'] ?? '') === 'skip') ? 'skip' : 'disable',
'skip_update' => !isset($_POST['upgrade_plugins']),
];
stream_plugins_themes_page($webroot, $flagForAuth, $token, $options);
exit(0);
case 'accounts':
$options = ['migrate_perms' => !isset($_POST['skip_perms'])];
stream_accounts_page($webroot, $flagForAuth, $token, $options);
exit(0);
case 'content':
stream_content_page($webroot, $flagForAuth, $token);
exit(0);
case 'test_continue':
// No long work — just advance state. Reload the step view.
$flagForAuth['step'] = 'test_done';
save_flag($flagPath, $flagForAuth);
redirect_self($token, ['flash' => 'test_done', 'msg' => 'Ready to promote.']);
case 'promote':
stream_promote_page($webroot, $flagForAuth, $token);
exit(0);
case 'rerun_step':
$target = (string) ($_POST['target'] ?? '');
$rewindLabels = [
'extracted' => ['Step 2: Copy & Migrate', 'plugins_done'],
'plugins_done' => ['Step 3: Accounts', 'accounts_done'],
'accounts_done' => ['Step 4: Content', 'content_done'],
];
if (!isset($rewindLabels[$target])) {
http_response_code(400);
render_error_page('Invalid re-run target', 'Unknown rewind target: ' . htmlspecialchars($target));
exit(1);
}
// Must have actually completed the step we're rewinding past.
$required = $rewindLabels[$target][1];
$currentStep = (string) ($flagForAuth['step'] ?? '');
$stepOrder = MG_STEPS;
$currentIdx = array_search($currentStep, $stepOrder, true);
$requiredIdx = array_search($required, $stepOrder, true);
if ($currentIdx === false || $requiredIdx === false || $currentIdx < $requiredIdx) {
http_response_code(409);
render_error_page('Cannot re-run step',
"You must have completed {$rewindLabels[$target][0]} before re-running it. Current step: " . htmlspecialchars($currentStep) . '');
exit(1);
}
mg_rewind_to($webroot, $flagForAuth, $target);
$msg = 'Rewound to "' . $target . '". Adjust options and re-run.';
redirect_self($token, ['flash' => 'rewound', 'msg' => $msg]);
default:
http_response_code(400);
render_error_page('Unknown action', 'The action "' . htmlspecialchars($action) . '" is not recognised.');
exit(1);
}
}
// ── GET: render the wizard ──────────────────────────────────────────────────
$flag = load_flag($flagPath);
if ($flag === null) {
http_response_code(409);
render_error_page('No migration staged', "No .migrating flag present at {$webroot}. Run the kickoff from the Grav admin or CLI first.");
exit(1);
}
$expected = (string) ($flag['token'] ?? '');
if ($expected === '' || $token !== $expected) {
http_response_code(403);
render_error_page('Invalid token', 'This wizard was staged for a specific token. Start it via the link shown after kickoff, or reset to start over.');
exit(1);
}
$step = (string) ($flag['step'] ?? 'staged');
$stageDir = (string) ($flag['stage_dir'] ?? 'grav-2');
$stagedZip = (string) ($flag['staged_zip'] ?? 'tmp/grav-2.0-staged.zip');
$flash = null;
if (isset($_GET['flash'])) {
$flash = ['type' => (string) $_GET['flash'], 'msg' => (string) ($_GET['msg'] ?? '')];
}
render_wizard($flag, $step, $webroot, $stageDir, $stagedZip, $flagPath, $expected, $flash);
// ─────────────────────────────────────────────────────────────────────────────
// State helpers
// ─────────────────────────────────────────────────────────────────────────────
function load_flag(string $path): ?array
{
if (!is_file($path)) return null;
$raw = @file_get_contents($path);
if ($raw === false) return null;
$data = json_decode($raw, true);
return is_array($data) ? $data : null;
}
function save_flag(string $path, array $flag): bool
{
$json = json_encode($flag, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
return $json !== false && file_put_contents($path, $json) !== false;
}
function base_path_from_script(): string
{
$base = dirname($_SERVER['SCRIPT_NAME'] ?? '/');
return $base === '/' || $base === '\\' ? '/' : rtrim($base, '/') . '/';
}
function redirect_self(string $token, array $extra): void
{
$qs = http_build_query(['token' => $token] + $extra);
$self = basename($_SERVER['SCRIPT_NAME'] ?? 'migrate.php');
header('Location: ' . base_path_from_script() . $self . '?' . $qs, true, 302);
exit(0);
}
// ─────────────────────────────────────────────────────────────────────────────
// Step 1: Extract — unzip staged release into stage_dir, strip wrapper prefix
// ─────────────────────────────────────────────────────────────────────────────
function do_extract(string $webroot, array $flag, ?callable $progress = null): array
{
$stageDir = trim((string) ($flag['stage_dir'] ?? 'grav-2'), '/');
$stagedZipRel = (string) ($flag['staged_zip'] ?? 'tmp/grav-2.0-staged.zip');
$zipPath = $webroot . '/' . ltrim($stagedZipRel, '/');
$destPath = $webroot . '/' . $stageDir;
if ($stageDir === '' || str_contains($stageDir, '..')) {
return ['ok' => false, 'msg' => "Invalid stage_dir: {$stageDir}"];
}
if (!is_file($zipPath)) {
return ['ok' => false, 'msg' => "Staged zip missing: {$zipPath}"];
}
$zip = new ZipArchive();
if (($open = $zip->open($zipPath)) !== true) {
$msg = "Could not open zip (code {$open}): {$zipPath}";
// 19 = not a zip, 21 = inconsistent, 35 = truncated (libzip >= 1.10,
// which has no ZipArchive constant yet): the staged download is
// damaged — extraction itself didn't break.
if (in_array($open, [ZipArchive::ER_NOZIP, ZipArchive::ER_INCONS, 35], true)) {
$msg .= ' — the staged zip is truncated or corrupt, usually from an interrupted download. '
. 'Use Reset Migration on the Migrate Grav admin page, then stage again to re-download it.';
}
return ['ok' => false, 'msg' => $msg];
}
$prefix = detect_common_prefix($zip);
$total = $zip->numFiles;
if (!is_dir($destPath) && !@mkdir($destPath, 0755, true) && !is_dir($destPath)) {
$zip->close();
return ['ok' => false, 'msg' => "Could not create stage dir: {$destPath}"];
}
$count = 0;
for ($i = 0; $i < $total; $i++) {
$name = $zip->getNameIndex($i);
if ($name === false) continue;
$relative = $prefix !== '' && str_starts_with($name, $prefix) ? substr($name, strlen($prefix)) : $name;
if ($relative === '' || str_contains($relative, '..')) continue;
$target = $destPath . '/' . $relative;
if (substr($name, -1) === '/') {
if (!is_dir($target)) @mkdir($target, 0755, true);
continue;
}
$parent = dirname($target);
if (!is_dir($parent)) @mkdir($parent, 0755, true);
$stream = $zip->getStream($name);
if ($stream === false) {
$zip->close();
return ['ok' => false, 'msg' => "Could not read zip entry: {$name}"];
}
$out = @fopen($target, 'wb');
if (!$out) {
fclose($stream);
$zip->close();
return ['ok' => false, 'msg' => "Could not write: {$target}"];
}
while (!feof($stream)) {
$chunk = fread($stream, 1 << 16);
if ($chunk === false) break;
fwrite($out, $chunk);
}
fclose($stream);
fclose($out);
mg_apply_zip_mode($zip, $i, $target, $relative);
$count++;
// Throttled progress callback — every 50 files or final tick.
if ($progress && ($count % 50 === 0 || $count === $total)) {
$progress($count, $total, $relative);
}
}
$zip->close();
if ($progress) $progress($count, $total, null);
// Ensure runtime dirs Grav expects exist at the staged root. Source zips
// ship these with .gitkeep, but our test-built zip and some hand-rolled
// builds skip empty dirs — Grav's Problems plugin then flags them red.
foreach (['tmp', 'backup', 'logs', 'cache', 'images', 'assets'] as $runtimeDir) {
$p = $destPath . '/' . $runtimeDir;
if (!is_dir($p)) @mkdir($p, 0755, true);
}
// Normalize the staged layout so later steps don't depend on which package
// variant was used. grav-update ships only system/vendor/bin — no user/,
// no root .htaccess. grav / grav-admin ship both. Create the user/
// skeleton and materialize .htaccess from webserver-configs/ if missing
// so plugins-themes/content/test steps work regardless of source package.
foreach (['user', 'user/plugins', 'user/themes', 'user/accounts', 'user/config', 'user/data', 'user/pages'] as $userDir) {
$p = $destPath . '/' . $userDir;
if (!is_dir($p)) @mkdir($p, 0755, true);
}
$htRoot = $destPath . '/.htaccess';
$htTmpl = $destPath . '/webserver-configs/htaccess.txt';
if (!is_file($htRoot) && is_file($htTmpl)) {
@copy($htTmpl, $htRoot);
}
// Stash the staged Grav version so the UI can display "what version of
// Grav 2.0 are we installing?" without re-reading defines.php on every
// request. Reads `define('GRAV_VERSION', '...')` from the staged tree.
$stagedGravVersion = mg_read_defines_version($destPath . '/system/defines.php');
$flag['step'] = 'extracted';
$flag['extracted'] = [
'at' => time(),
'files' => $count,
'prefix_stripped' => $prefix,
'grav_version' => $stagedGravVersion,
];
save_flag($webroot . '/.migrating', $flag);
$verStr = $stagedGravVersion ? " (Grav v{$stagedGravVersion})" : '';
return ['ok' => true, 'msg' => "Extracted {$count} files into /{$stageDir}/{$verStr}"];
}
/**
* Render a streaming "extracting…" page that shows live progress as the zip
* expands, then redirects to the wizard view once complete. Disables output
* buffering so each flush lands on the browser immediately.
*/
function stream_extract_page(string $webroot, array $flag, string $token): void
{
// Buffering off. Web-server-level gzip buffering may still defeat this;
// the padding bytes at the end of each flush help kick most setups loose.
@ini_set('zlib.output_compression', '0');
@ini_set('output_buffering', '0');
@ini_set('implicit_flush', '1');
while (ob_get_level() > 0) @ob_end_flush();
@ob_implicit_flush(true);
header('Content-Type: text/html; charset=utf-8');
header('X-Accel-Buffering: no'); // nginx
header('Cache-Control: no-cache, no-store, must-revalidate');
page_header('Extracting Grav 2.0…');
echo '
';
echo '
';
echo '
';
echo '
Extracting Grav 2.0…
';
echo '
Unzipping the staged release into /' . htmlspecialchars((string)($flag['stage_dir'] ?? 'grav-2')) . '/. Keep this tab open until the migration advances to the next step.
';
page_footer();
}
/**
* Detect a shared top-level directory prefix (e.g. "grav-admin/") and return it.
* Returns '' when entries don't share a single root folder.
*/
function detect_common_prefix(ZipArchive $zip): string
{
$prefix = null;
for ($i = 0, $n = $zip->numFiles; $i < $n; $i++) {
$name = $zip->getNameIndex($i);
if ($name === false || $name === '') continue;
$slash = strpos($name, '/');
$first = $slash === false ? $name : substr($name, 0, $slash + 1);
if ($prefix === null) {
$prefix = $first;
} elseif ($prefix !== $first) {
return '';
}
}
return $prefix ?? '';
}
/**
* Apply the correct unix file mode to a just-extracted zip entry.
*
* PHP's raw `fwrite()` extract path used here does not carry over the mode
* stored in the zip's central directory (external attributes), so files
* land at whatever umask dictates — usually 0644. For the Grav distribution
* that silently strips the +x bit from bin/grav, bin/gpm, bin/plugin, and
* bin/composer.phar, leaving operators with broken CLI tools post-migration.
*
* Strategy:
* 1. Prefer the mode stored in the zip (OPSYS_UNIX). Real release builds
* ship with 0755 on bin/* so this is usually sufficient.
* 2. Fallback: test-built zips and some hand-rolled builds don't pack
* unix modes. For those, force 0755 on anything sitting directly
* under bin/ since that dir contains only executables in the Grav
* distribution (grav, gpm, plugin, composer.phar, ...).
*/
function mg_apply_zip_mode(ZipArchive $zip, int $index, string $target, string $relative): void
{
$applied = false;
$opsys = null;
$attr = null;
if (@$zip->getExternalAttributesIndex($index, $opsys, $attr)) {
if ($opsys === ZipArchive::OPSYS_UNIX && $attr !== null) {
$mode = ($attr >> 16) & 0xFFFF;
// Only trust zips that actually stored a permission bitset.
if (($mode & 0o777) !== 0) {
@chmod($target, $mode & 0o777);
$applied = true;
}
}
}
if (!$applied && (str_starts_with($relative, 'bin/') && substr_count($relative, '/') === 1)) {
@chmod($target, 0755);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Step 2: Import — copy source user/* into stage_dir/user/
// ─────────────────────────────────────────────────────────────────────────────
/**
* MG_IMPORT_DEFAULT — always-copied top-level user/ entries (site content).
* Plugins and themes are also always copied, but each one is classified by
* 2.0 compatibility first and handled per the user-selected policy.
*/
// ─── Step 2: Plugins & Themes ──────────────────────────────────────────────
function do_plugins_themes(string $webroot, array $flag, array $options, ?callable $progress = null): array
{
$stageDir = trim((string)($flag['stage_dir'] ?? 'grav-2'), '/');
$srcUser = $webroot . '/user';
$dstUser = $webroot . '/' . $stageDir . '/user';
if (!is_dir($srcUser)) {
return ['ok' => false, 'msg' => "Source user/ missing at {$srcUser}"];
}
ensure_dir($dstUser);
$mode = (string) ($options['mode'] ?? MG_MODE_STRICT);
if (!in_array($mode, MG_MODES_ALL, true)) $mode = MG_MODE_STRICT;
$policy = ($options['policy'] ?? 'disable') === 'skip' ? 'skip' : 'disable';
$autoUpdate = empty($options['skip_update']);
$scan = mg_compat_scan_cached($webroot, $flag);
$superseded = mg_collect_superseded($scan); // ['plugins/admin' => 'admin2', ...]
$stageRoot = $webroot . '/' . $stageDir;
// ─── Phase 1: bulk-copy source user/ → staged user/ ──────────────────────
// Everything (plugins, themes, accounts, pages, data, config, env,
// languages, any custom folders, any top-level files) comes across
// verbatim. Dotfiles/dotdirs at user/ root (.git, .DS_Store, editor
// backups) are skipped as filesystem cruft. Symlinks (top-level and
// mid-tree) are preserved so dev clones stay live. Downstream phases
// mutate the staged tree in place.
$copied = 0;
$copySkipped = [];
$copiedEntries = [];
mg_bulk_copy_user($srcUser, $dstUser, $copied, $progress, $copySkipped, $copiedEntries);
// ─── Phase 2: collect symlinked plugin/theme slugs ───────────────────────
// Excluded from the upgrade pass (gpm would unlink the symlink and
// extract a fresh zip in its place) and from the policy pass (dev
// clones manage their own version, leave them alone).
$symlinkedSlugs = ['plugins' => [], 'themes' => []];
foreach (['plugins', 'themes'] as $kind) {
$kindDir = $dstUser . '/' . $kind;
if (!is_dir($kindDir)) continue;
foreach (scandir($kindDir) ?: [] as $slug) {
if ($slug === '.' || $slug === '..' || $slug[0] === '.') continue;
if (is_link($kindDir . '/' . $slug)) $symlinkedSlugs[$kind][] = $slug;
}
}
// ─── Phase 3: neutralize superseded plugins against gpm dep resolution ──
// Two cooperating tricks so gpm leaves admin (et al.) alone:
// (a) Exclude superseded slugs from gpm's positional allowlist so they
// aren't directly listed for update.
// (b) Pin each superseded plugin's blueprints `version:` to gpm's
// reported latest. gpm's getDependencies() removes any installed
// dep where `currentlyInstalledVersion === latestRelease` from the
// dep list — so transitive declarations like
// `dependencies: [{name: admin, version: '>=1.7.4'}]`
// (data-manager, login-ldap, ...) won't drag admin back in.
// Without (b), `bin/gpm update -y` would mark admin as an 'ignore'-type
// transitive dep, then with -y still install it (the install command
// treats -y as auto-yes for ignore-type deps too).
// We don't restore the pinned version — the dir gets deleted in
// Phase 4.5 anyway.
$supersededPluginSlugs = [];
foreach (array_keys($superseded) as $label) {
if (!str_starts_with($label, 'plugins/')) continue;
$supersededPluginSlugs[] = substr($label, strlen('plugins/'));
}
$gpmPluginsIndex = $scan['gpm']['plugins'] ?? [];
foreach ($supersededPluginSlugs as $slug) {
$latest = (string) ($gpmPluginsIndex[$slug]['version'] ?? '');
if ($latest === '') continue;
$bpPath = $stageRoot . '/user/plugins/' . $slug . '/blueprints.yaml';
if (!is_file($bpPath)) continue;
$bpContent = @file_get_contents($bpPath);
if (!is_string($bpContent) || $bpContent === '') continue;
$patched = preg_replace(
'/^version:\s*[\'"]?[0-9A-Za-z.\-]+[\'"]?\s*$/m',
"version: {$latest}",
$bpContent,
1
);
if (is_string($patched) && $patched !== $bpContent) {
@file_put_contents($bpPath, $patched);
}
}
// ─── Phase 4: bring installed plugins/themes up to their 2.0 versions ──
// Shell out to the staged Grav 2.0's `bin/gpm update -p -y` for plugins
// (gpm itself enforces 2.0-compat — only compatible upgrades land).
// Symlinked slugs AND superseded slugs excluded via positional allowlist.
// Themes still use the per-slug zip path. Running BEFORE policy so the
// post-upgrade re-scan reflects what gpm actually did, and policy only
// kicks in for plugins gpm couldn't rescue.
$upgraded = [];
$gpmResult = null;
if ($autoUpdate) {
$gpmExclude = array_values(array_unique(array_merge($symlinkedSlugs['plugins'], $supersededPluginSlugs)));
$gpmResult = mg_gpm_update($stageRoot, 'plugins', $gpmExclude, $progress);
if ($gpmResult['ok']) {
// gpm reports display names, not slugs. Resolve back to slug via
// the scan's blueprint metadata so upgraded[] keys match the
// slug-keyed convention used by the themes path below.
$nameToSlug = [];
foreach (($scan['plugins'] ?? []) as $s => $v) {
$n = (string) ($v['name'] ?? '');
if ($n !== '') $nameToSlug[$n] = $s;
}
$supersededSet = array_flip($supersededPluginSlugs);
foreach ($gpmResult['updated'] as $name => $version) {
$slug = $nameToSlug[$name] ?? '';
// Hide superseded slugs from the upgraded report — even if the
// pinning trick above didn't fully suppress gpm's dep handling,
// the dir gets removed in Phase 4.5 and is replaced by admin2/api.
if ($slug !== '' && isset($supersededSet[$slug])) continue;
if ($slug === '') {
$upgraded["plugins/{$name}"] = ['to' => $version, 'from' => ''];
continue;
}
$from = (string) ($scan['plugins'][$slug]['installed_version'] ?? '');
$upgraded["plugins/{$slug}"] = ['to' => $version, 'from' => $from];
}
}
// Themes — existing per-slug path, skipping symlinked slugs.
$themeSymSet = array_flip($symlinkedSlugs['themes']);
foreach (($scan['themes'] ?? []) as $slug => $verdict) {
if (isset($themeSymSet[$slug])) continue;
$update = $verdict['update'] ?? null;
if (!$update || empty($update['download']) || empty($update['to'])) continue;
$dst = $dstUser . '/themes/' . $slug;
if (!is_dir($dst) || is_link($dst)) continue;
if ($progress) $progress(['phase' => 'start', 'entry' => "update/themes/{$slug}"]);
$res = mg_install_zip_url($webroot, $stageDir, 'themes', $slug, $update['download']);
if ($res['ok']) {
$upgraded["themes/{$slug}"] = ['to' => $update['to'], 'from' => $verdict['installed_version'] ?? ''];
if ($progress) $progress(['phase' => 'done-entry', 'entry' => "update/themes/{$slug}"]);
} else {
if ($progress) $progress(['phase' => 'skip', 'entry' => "update/themes/{$slug}", 'reason' => $res['msg']]);
}
}
}
// ─── Phase 4.5: delete superseded plugin dirs ───────────────────────────
// Now that gpm has had its dep-resolution pass with these dirs in place,
// we can remove them. Their replacements (admin2, api, …) are installed
// in Phase 7 by mg_install_replacements.
$supersedeResult = mg_handle_supersedes($stageRoot, $superseded, $progress);
// ─── Phase 5: re-scan compat on the staged tree (post-upgrade) ──────────
// Verdicts now reflect whatever gpm actually installed — a plugin that
// was 1.7-only on the source but got upgraded to a 2.0-compat release
// reads `compatible` here, so policy won't touch it.
$postScan = mg_rescan_staged($dstUser, $scan);
// ─── Phase 6: apply policy to plugins still incompatible after upgrade ─
// In strict mode, this is the small residual set gpm couldn't rescue.
// In test mode, nothing gets disabled/skipped at all (effective=compat).
$policyResult = mg_apply_plugin_policy($stageRoot, $postScan, $policy, $mode, $progress);
$disabled = array_map(static fn($s) => "plugins/{$s}", $policyResult['disabled']);
$skipped = array_merge(
// mg_handle_supersedes already produces kind/slug-prefixed entries
$supersedeResult['skipped'],
array_map(static fn($s) => "plugins/{$s}", $policyResult['skipped'])
);
// ─── Phase 7: install replacements (admin2, api, etc.) ──────────────────
$replacements = mg_install_replacements($webroot, $stageDir, $scan, $disabled, $skipped, $progress);
// ─── Phase 7.5: carry a customized admin path → admin2 ──────────────────
// 1.7's admin route lives in admin.yaml; admin2 reads admin2.yaml. Only
// runs when admin2 was actually installed (admin → admin2 supersede).
$adminRoute = ['migrated' => false, 'route' => null];
if (array_key_exists('admin2', $replacements['installed'] ?? [])) {
$adminRoute = mg_migrate_admin_route($stageRoot, $progress);
}
// Derive the "actually copied" slug list per kind from the scan, minus
// skipped. (Disabled is a subset of copied — they got the files plus the
// enabled:false flag.)
$copiedByKind = ['plugins' => [], 'themes' => []];
foreach (['plugins', 'themes'] as $kind) {
$skippedSet = [];
foreach ($skipped as $s) {
if (!str_starts_with($s, $kind . '/')) continue;
$bareSlug = explode(' ', substr($s, strlen($kind) + 1), 2)[0];
$skippedSet[$bareSlug] = true;
}
foreach (array_keys($scan[$kind] ?? []) as $slug) {
if (!isset($skippedSet[$slug])) {
$copiedByKind[$kind][] = $slug;
}
}
}
$symlinkCount = count($symlinkedSlugs['plugins']) + count($symlinkedSlugs['themes']);
$flag['step'] = 'plugins_done';
$flag['plugins_themes'] = [
'at' => time(),
'files' => $copied,
'copied' => $copiedByKind,
'copied_entries' => $copiedEntries,
'copy_skipped' => $copySkipped,
'skipped' => $skipped,
'disabled' => $disabled,
'mode' => $mode,
'policy' => $policy,
'auto_update' => $autoUpdate,
'upgraded' => $upgraded,
'symlinked' => $symlinkedSlugs,
'gpm_result' => $gpmResult ? [
'ok' => $gpmResult['ok'],
'msg' => $gpmResult['msg'],
'updated' => $gpmResult['updated'],
] : null,
'replacements' => $replacements,
'force_included' => $policyResult['force_included'] ?? [],
'admin_route' => $adminRoute,
];
save_flag($webroot . '/.migrating', $flag);
$msg = "Copied {$copied} files across user/ (" . count($copiedEntries) . ' top-level entries)';
if ($mode !== MG_MODE_STRICT) $msg .= "; mode: {$mode}";
if (!empty($policyResult['force_included'])) $msg .= '; force-included ' . count($policyResult['force_included']);
if ($upgraded) $msg .= "; updated " . count($upgraded);
if ($symlinkCount) $msg .= "; preserved {$symlinkCount} symlink(s)";
if ($disabled) $msg .= "; disabled " . count($disabled);
if ($skipped) $msg .= "; skipped " . count($skipped);
if (!empty($replacements['installed'])) $msg .= "; installed " . count($replacements['installed']) . " replacement(s)";
if (!empty($adminRoute['migrated'])) $msg .= "; carried admin route {$adminRoute['route']} → admin2";
if ($gpmResult && !$gpmResult['ok']) $msg .= "; gpm: " . $gpmResult['msg'];
return ['ok' => true, 'msg' => $msg . '.'];
}
// ─── Step 3: Accounts ──────────────────────────────────────────────────────
function do_accounts(string $webroot, array $flag, array $options, ?callable $progress = null): array
{
// Accounts were already copied verbatim into staged user/accounts/ by
// Step 2's bulk user/ copy. This step just applies the optional
// admin.* → api.* perm mirror transform on the staged yamls in place.
$stageDir = trim((string)($flag['stage_dir'] ?? 'grav-2'), '/');
$dst = $webroot . '/' . $stageDir . '/user/accounts';
if (!is_dir($dst)) {
$flag['step'] = 'accounts_done';
$flag['accounts'] = ['at' => time(), 'count' => 0, 'migrated_perms' => 0, 'skipped_perms' => true];
save_flag($webroot . '/.migrating', $flag);
return ['ok' => true, 'msg' => 'No user/accounts/ in staged install — nothing to transform.'];
}
$migratePerms = !empty($options['migrate_perms']);
$count = 0;
$mirrored = 0;
$details = [];
foreach (scandir($dst) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') continue;
if ($entry[0] === '.') continue;
$path = $dst . '/' . $entry;
if (!is_file($path)) continue;
if (!preg_match('/\.yaml$/i', $entry)) continue;
if ($progress) $progress(['phase' => 'start', 'entry' => "accounts/{$entry}"]);
if ($migratePerms) {
// Rewrite in place: read, mirror admin.* → api.*, overwrite.
$added = mg_migrate_account_perms($path, $path);
$mirrored += $added;
$details[$entry] = $added;
}
$count++;
if ($progress) $progress(['phase' => 'done-entry', 'entry' => "accounts/{$entry}", 'added' => $details[$entry] ?? 0]);
}
$flag['step'] = 'accounts_done';
$flag['accounts'] = [
'at' => time(),
'count' => $count,
'migrated_perms' => $mirrored,
'skipped_perms' => !$migratePerms,
'details' => $details,
];
save_flag($webroot . '/.migrating', $flag);
$msg = "Processed {$count} account file(s)";
if ($migratePerms) $msg .= "; mirrored {$mirrored} admin.* → api.* permission(s)";
else $msg .= "; permission mirroring skipped";
return ['ok' => true, 'msg' => $msg . '.'];
}
/**
* Copy a single account yaml from $src to $dst, and for every `admin.X`
* permission under `access:` (nested OR dotted-flat), add a matching
* `api.X` with the same value if one isn't already present. Preserves
* existing admin.* entries. Returns the number of api.* keys added.
*/
function mg_migrate_account_perms(string $src, string $dst): int
{
mg_ensure_yaml_available();
$raw = @file_get_contents($src);
if ($raw === false) { @copy($src, $dst); return 0; }
if (class_exists('Symfony\\Component\\Yaml\\Yaml')) {
try {
$data = \Symfony\Component\Yaml\Yaml::parse($raw);
} catch (\Throwable $e) { @copy($src, $dst); return 0; }
} elseif (function_exists('yaml_parse')) {
$data = @yaml_parse($raw);
} else {
// Can't safely edit; just copy.
@copy($src, $dst);
return 0;
}
if (!is_array($data)) { @copy($src, $dst); return 0; }
$added = 0;
if (isset($data['access']) && is_array($data['access'])) {
// Nested form: access: { admin: { super: true, login: true } }
if (isset($data['access']['admin']) && is_array($data['access']['admin'])) {
$apiExisting = (array) ($data['access']['api'] ?? []);
foreach ($data['access']['admin'] as $k => $v) {
if (!array_key_exists($k, $apiExisting)) {
$apiExisting[$k] = $v;
$added++;
}
}
$data['access']['api'] = $apiExisting;
}
// Dotted-flat form: access: { admin.super: true, admin.login: true }
foreach ($data['access'] as $k => $v) {
if (is_string($k) && str_starts_with($k, 'admin.')) {
$apiKey = 'api.' . substr($k, strlen('admin.'));
if (!array_key_exists($apiKey, $data['access'])) {
$data['access'][$apiKey] = $v;
$added++;
}
}
}
}
if (class_exists('Symfony\\Component\\Yaml\\Yaml')) {
$out = \Symfony\Component\Yaml\Yaml::dump($data, 6, 2);
} elseif (function_exists('yaml_emit')) {
$out = yaml_emit($data);
} else {
@copy($src, $dst);
return 0;
}
@file_put_contents($dst, $out);
return $added;
}
/**
* Carry a customized admin path from Grav 1.7 into the staged Grav 2.0.
*
* In 1.7 the admin route lives in `user/config/plugins/admin.yaml` (`route`),
* but admin2 reads its own `user/config/plugins/admin2.yaml` (`route`). The
* bulk user/ copy brings admin.yaml across, yet admin2 never looks at it — so
* a user who changed `/admin` to e.g. `/backend` as a security measure would
* silently lose that after migration. This mirrors a non-default 1.7 route
* into the staged admin2 config, normalized to admin2's `/path` style.
*
* Only a route that differs from the 1.7 default (`/admin`) is carried; the
* default is already admin2's default, so there's nothing to write. Any other
* 1.7 admin settings are intentionally left behind — admin2's config surface
* is just `enabled` + `route`, so nothing else has an equivalent.
*
* Returns ['migrated' => bool, 'route' => string|null].
*/
function mg_migrate_admin_route(string $stageRoot, ?callable $progress = null): array
{
$result = ['migrated' => false, 'route' => null];
$adminCfg = $stageRoot . '/user/config/plugins/admin.yaml';
$admin2Cfg = $stageRoot . '/user/config/plugins/admin2.yaml';
if (!is_file($adminCfg)) return $result;
mg_ensure_yaml_available();
$adminData = mg_yaml_parse_file($adminCfg);
if (!is_array($adminData)) return $result;
$route = $adminData['route'] ?? null;
if (!is_string($route) || trim($route) === '') return $result;
// Normalize to admin2's leading-slash, no-trailing-slash form (`/backend`).
$route = '/' . trim(trim($route), '/');
if ($route === '/' || $route === '/admin') return $result; // default — nothing to carry.
// Merge into any existing staged admin2 config rather than clobbering it.
$admin2Data = is_file($admin2Cfg) ? mg_yaml_parse_file($admin2Cfg) : [];
if (!is_array($admin2Data)) $admin2Data = [];
if (($admin2Data['route'] ?? null) === $route) {
// Already set (idempotent re-run) — report as migrated without rewriting.
return ['migrated' => true, 'route' => $route];
}
$admin2Data['route'] = $route;
if ($progress) $progress(['phase' => 'start', 'entry' => 'config/admin2.yaml (route)']);
$out = mg_yaml_dump($admin2Data);
if ($out === null) return $result;
ensure_dir(dirname($admin2Cfg));
@file_put_contents($admin2Cfg, $out);
if ($progress) $progress(['phase' => 'done-entry', 'entry' => 'config/admin2.yaml (route)', 'route' => $route]);
return ['migrated' => true, 'route' => $route];
}
/**
* Parse a YAML file with whichever backend is available (staged Symfony Yaml
* preferred, ext-yaml fallback). Returns the decoded array or null.
*/
function mg_yaml_parse_file(string $path): ?array
{
$raw = @file_get_contents($path);
if ($raw === false) return null;
if (class_exists('Symfony\\Component\\Yaml\\Yaml')) {
try {
$data = \Symfony\Component\Yaml\Yaml::parse($raw);
} catch (\Throwable $e) { return null; }
} elseif (function_exists('yaml_parse')) {
$data = @yaml_parse($raw);
} else {
return null;
}
return is_array($data) ? $data : null;
}
/**
* Dump an array back to a YAML string with whichever backend is available.
* Returns null when neither backend can serialize.
*/
function mg_yaml_dump(array $data): ?string
{
if (class_exists('Symfony\\Component\\Yaml\\Yaml')) {
return \Symfony\Component\Yaml\Yaml::dump($data, 6, 2);
}
if (function_exists('yaml_emit')) {
return yaml_emit($data);
}
return null;
}
/**
* Ensure Symfony\Component\Yaml\Yaml is loadable. After Step 1 extracts the
* 2.0 release, the staged vendor/ has it. Pulling in autoload is OK — we
* only reach here inside POST handlers where the extra class load is fine.
*/
function mg_ensure_yaml_available(): void
{
if (class_exists('Symfony\\Component\\Yaml\\Yaml')) return;
// Try the staged 2.0 vendor first (always present after extract).
foreach ([
__DIR__ . '/grav-2/vendor/autoload.php',
__DIR__ . '/vendor/autoload.php',
] as $candidate) {
if (is_file($candidate)) {
require_once $candidate;
if (class_exists('Symfony\\Component\\Yaml\\Yaml')) return;
}
}
}
// ─── Step 6: Promote ────────────────────────────────────────────────────────
/**
* Promote the staged Grav 2.0 install to the webroot:
* 1. Create {webroot}/backup-pre-2.0-{YYYYMMDD-HHMMSS}/
* 2. Move every top-level entry at the webroot EXCEPT the stage dir and
* the new backup dir into the backup (this includes migrate.php itself,
* .migrating, .htaccess, system/, vendor/, user/, tmp/, logs/, etc.)
* 3. Move every top-level entry from the stage dir up to the webroot.
* 4. Remove the empty stage dir.
*
* All operations are `rename()` calls on the same filesystem (fast + near-atomic).
* After success the running PHP process is already finished reading migrate.php,
* so even though the file has moved the response still renders; the user is
* redirected to the new webroot.
*/
function do_promote(string $webroot, array $flag, ?callable $progress = null): array
{
$stageDir = trim((string)($flag['stage_dir'] ?? 'grav-2'), '/');
$stagePath = $webroot . '/' . $stageDir;
if ($stageDir === '' || !is_dir($stagePath)) {
return ['ok' => false, 'msg' => "Stage dir missing or invalid: {$stagePath}"];
}
// Version comes from the CURRENT install's defines.php (the one we're
// about to back up — not the staged one).
$currentVersion = mg_read_defines_version($webroot . '/system/defines.php')
?? ($flag['source']['grav_version'] ?? 'unknown');
// Match Grav's backup discovery regex (#(.*)--(\d*).zip#) so the resulting
// file shows up in the admin's Backups list: --.zip with a
// double-dash separator and an unbroken numeric timestamp.
$timestamp = date('YmdHis');
$zipName = 'migration-backup-' . $currentVersion . '--' . $timestamp . '.zip';
// Write the zip INSIDE the stage dir's backup/ so that after promote
// (which moves stage contents up) it naturally lands at
// {newWebroot}/backup/migration-backup-*.zip — no cross-device move.
$zipDir = $stagePath . '/backup';
ensure_dir($zipDir);
$zipPath = $zipDir . '/' . $zipName;
if (is_file($zipPath)) @unlink($zipPath);
// ─── Phase 1: zip the 1.x install ──────────────────────────────────────
$zip = new ZipArchive();
$rc = $zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
if ($rc !== true) {
return ['ok' => false, 'msg' => "Could not create backup zip (ZipArchive code {$rc}): {$zipPath}"];
}
$added = 0;
$err = mg_zip_webroot($zip, $webroot, $stageDir, $added, $progress);
$zip->close();
if ($err !== null) {
@unlink($zipPath);
return ['ok' => false, 'msg' => "Backup failed: {$err}"];
}
if (!is_file($zipPath) || filesize($zipPath) < 1024) {
return ['ok' => false, 'msg' => 'Backup zip looks invalid after close'];
}
// ─── Phase 2: delete everything at webroot except the stage dir ────────
//
// Pre-flight: on Windows, a file held open by another process (VSCode
// file-watcher, Sourcetree's libgit2, a tail -f, a tmux pane with the
// file open in vim) cannot be deleted — unlink() fails. Phase 2 partway
// through would leave the webroot half-destroyed AND the backup zip
// sitting inside grav-2/backup waiting to be hand-extracted. Detect
// locks BEFORE the destructive work so the user can close the offender
// and retry without recovering. macOS/Linux skip this — unlink succeeds
// there regardless of open handles, so the runtime check would be wasted
// I/O and would also produce false negatives (the rename-rename-back
// probe always succeeds when share-delete is the default).
$locked = mg_check_windows_locks($webroot, $stageDir);
if ($locked !== []) {
$sample = array_slice($locked, 0, 10);
$more = count($locked) - count($sample);
$msg = "Cannot safely promote — these files are locked by another process (close any editor, git GUI, or terminal that has the webroot open, then retry):\n - "
. implode("\n - ", $sample)
. ($more > 0 ? "\n …and {$more} more" : '');
return ['ok' => false, 'msg' => $msg, 'locked' => $locked];
}
$deleted = [];
foreach (scandir($webroot) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') continue;
if ($entry === $stageDir) continue;
if ($progress) $progress(['phase' => 'copy', 'entry' => 'clear', 'file' => $entry, 'copied' => $added]);
$path = $webroot . '/' . $entry;
$failedPath = null;
if (is_link($path) || is_file($path)) {
if (!@unlink($path)) {
$rel = ltrim(substr($path, strlen($webroot)), '/\\');
return ['ok' => false, 'msg' => "Could not delete {$rel} (file is locked by another process — close any editor, git GUI, or terminal that has it open, then retry)."];
}
} elseif (is_dir($path)) {
if (!mg_rm_tree($path, $failedPath)) {
$rel = $failedPath !== null
? ltrim(substr($failedPath, strlen($webroot)), '/\\')
: $entry;
return ['ok' => false, 'msg' => "Could not delete {$rel} (file is locked by another process — close any editor, git GUI, or terminal that has it open, then retry)."];
}
}
$deleted[] = $entry;
}
// ─── Phase 3: promote stage contents up ────────────────────────────────
$promoted = [];
foreach (scandir($stagePath) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') continue;
if ($progress) $progress(['phase' => 'copy', 'entry' => 'promote', 'file' => $entry, 'copied' => count($promoted)]);
if (!@rename($stagePath . '/' . $entry, $webroot . '/' . $entry)) {
return ['ok' => false, 'msg' => "Failed to promote {$entry}"];
}
$promoted[] = $entry;
}
@rmdir($stagePath);
// Breadcrumb for the new install.
$summary = [
'migrated_at' => date('c'),
'backup_zip' => 'backup/' . $zipName,
'from_version' => $currentVersion,
'promoted' => $promoted,
];
@file_put_contents(
$webroot . '/.migration-complete',
json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
);
if ($progress) $progress(['phase' => 'done-entry', 'entry' => 'promote', 'copied' => count($promoted)]);
return [
'ok' => true,
'msg' => "Backed up {$added} files to backup/{$zipName}; promoted " . count($promoted) . ' entries to webroot.',
'backup' => $zipName,
];
}
/**
* Read the GRAV_VERSION string out of a Grav install's system/defines.php.
* Returns null if the file is missing or the pattern doesn't match.
*/
function mg_read_defines_version(string $path): ?string
{
if (!is_file($path)) return null;
$raw = @file_get_contents($path);
if ($raw === false) return null;
if (preg_match("/define\\(\\s*['\"]GRAV_VERSION['\"]\\s*,\\s*['\"]([^'\"]+)['\"]/", $raw, $m)) {
return $m[1];
}
return null;
}
/**
* Peek into the staged Grav zip to read GRAV_VERSION from system/defines.php
* without extracting. Used by the wizard's pre-extraction view so users can
* see exactly what version they're about to install. Returns null if the zip
* is unreadable or doesn't contain a recognizable defines.php.
*/
function mg_read_grav_version_from_zip(string $zipPath): ?string
{
if (!is_file($zipPath)) return null;
$zip = new ZipArchive();
if ($zip->open($zipPath) !== true) return null;
$found = null;
for ($i = 0, $n = $zip->numFiles; $i < $n; $i++) {
$name = $zip->getNameIndex($i);
if ($name === false) continue;
// Match system/defines.php at any depth (zip may have a wrapper prefix
// like grav-admin/ or grav-update/).
if (!preg_match('~(?:^|/)system/defines\.php$~', $name)) continue;
$raw = $zip->getFromIndex($i);
if ($raw === false) continue;
if (preg_match("/define\\(\\s*['\"]GRAV_VERSION['\"]\\s*,\\s*['\"]([^'\"]+)['\"]/", $raw, $m)) {
$found = $m[1];
break;
}
}
$zip->close();
return $found;
}
/**
* Get the staged zip's Grav version, caching it in the flag so we only
* crack the zip once. Returns null when the zip is missing or unreadable.
*/
function mg_staged_zip_version(string $webroot, array &$flag): ?string
{
$cached = $flag['staged_zip_version'] ?? null;
if (is_string($cached) && $cached !== '') return $cached;
$stagedZipRel = (string) ($flag['staged_zip'] ?? 'tmp/grav-2.0-staged.zip');
$version = mg_read_grav_version_from_zip($webroot . '/' . ltrim($stagedZipRel, '/'));
if ($version !== null) {
$flag['staged_zip_version'] = $version;
save_flag($webroot . '/.migrating', $flag);
}
return $version;
}
/**
* Add everything at the webroot to the zip EXCEPT the stage dir. Skips
* symlinks (avoids chasing dev-env links into unrelated repos). Streams
* progress per ~200 files for the UI.
*/
function mg_zip_webroot(ZipArchive $zip, string $webroot, string $skipTop, int &$added, ?callable $progress): ?string
{
try {
$it = new RecursiveIteratorIterator(
new RecursiveCallbackFilterIterator(
new RecursiveDirectoryIterator($webroot, RecursiveDirectoryIterator::SKIP_DOTS),
static function ($item, $key, $iterator) use ($webroot, $skipTop) {
$path = $item->getPathname();
// Skip the stage dir at the top level.
if (strpos($path, $webroot . DIRECTORY_SEPARATOR . $skipTop . DIRECTORY_SEPARATOR) === 0
|| $path === $webroot . DIRECTORY_SEPARATOR . $skipTop) {
return false;
}
return true;
}
),
RecursiveIteratorIterator::SELF_FIRST
);
} catch (\Throwable $e) {
return "Could not iterate webroot: " . $e->getMessage();
}
// Follow symlinks when archiving — this is a FULL backup, and in dev
// environments plugins/themes are often symlinked to sibling repos.
// Skipping them here would leave them out of the restore zip while the
// delete phase still removes the link from the webroot (= data loss).
// ZipArchive::addFile() and PHP's is_dir() both follow symlinks by
// default, so we just archive whatever the link resolves to.
foreach ($it as $file) {
$path = $file->getPathname();
$rel = ltrim(substr($path, strlen($webroot)), '/\\');
if ($rel === '') continue;
// Zip spec mandates '/' as the path separator. On Windows,
// SplFileInfo::getPathname() returns native '\\'-separated paths,
// and passing those to ZipArchive::addFile() stores entries like
// `user\plugins\admin\file.php` — which non-strict extractors
// (7-zip on Windows, macOS Archive Utility, Windows Explorer's
// in-place viewer) treat as a flat filename containing literal
// backslashes, dumping every file in the zip's root and rendering
// directory entries as a flat breadcrumb list. Normalize before
// any zip-write so the output is always portable, regardless of
// which OS the wizard ran on.
$rel = str_replace(DIRECTORY_SEPARATOR, '/', $rel);
if ($file->isDir()) {
$zip->addEmptyDir($rel);
} else {
if (!$zip->addFile($path, $rel)) {
return "Could not add to zip: {$rel}";
}
$added++;
if ($progress && $added % 200 === 0) {
$progress(['phase' => 'copy', 'entry' => 'backup', 'file' => $rel, 'copied' => $added]);
}
}
}
return null;
}
/**
* Windows-only pre-flight: scan the webroot (excluding the stage dir) for
* files we won't be able to unlink during Phase 2 because another process
* holds an exclusive handle. On Windows, MoveFileEx (which underlies PHP's
* rename()) refuses when the target file is open without FILE_SHARE_DELETE
* — which is the default for editors (VSCode, Sublime), git GUIs
* (Sourcetree's libgit2 holds .git/index, packed-refs, *.idx, *.pack), and
* even some shells. So a successful rename-to-self-with-suffix-then-back is
* a reliable proxy for "PHP's unlink() will succeed on this path."
*
* On macOS/Linux this is a no-op: unlink() succeeds on open files there
* (the inode just sticks around until the last fd closes), so the check
* would be both wasted I/O and a source of false positives — share-delete
* is implicit, so the rename probe always succeeds, including for files
* that ARE problematic for unrelated reasons. Skipping the probe entirely
* is the right answer.
*
* Returns the list of locked paths relative to the webroot. Empty array =
* safe to proceed. Caller renders the list to the user. Capped at 50 so
* a totally-locked tree doesn't produce a 10MB response body.
*
* Caveat: rename-rename-back is destructive if power fails between the two
* renames (file ends up as `foo.mglocktest`). PHP's rename is atomic on the
* same volume (MoveFileEx with MOVEFILE_REPLACE_EXISTING isn't used here so
* it's a simple rename), and we stay on the same volume by appending to the
* existing path. Risk is low enough to accept against the value of catching
* locks before destroying the webroot.
*/
function mg_check_windows_locks(string $webroot, string $skipTop): array
{
if (PHP_OS_FAMILY !== 'Windows') {
return [];
}
$locked = [];
$cap = 50;
try {
$it = new RecursiveIteratorIterator(
new RecursiveCallbackFilterIterator(
new RecursiveDirectoryIterator($webroot, RecursiveDirectoryIterator::SKIP_DOTS),
static function ($item) use ($webroot, $skipTop) {
$path = $item->getPathname();
if (strpos($path, $webroot . DIRECTORY_SEPARATOR . $skipTop . DIRECTORY_SEPARATOR) === 0
|| $path === $webroot . DIRECTORY_SEPARATOR . $skipTop) {
return false;
}
return true;
}
),
RecursiveIteratorIterator::LEAVES_ONLY
);
} catch (\Throwable $e) {
// Can't iterate — let the destructive phase surface the real error.
return [];
}
foreach ($it as $file) {
// Symlinks: deleting the link doesn't touch the target, so handle
// checks aren't needed. Directories: empty dirs delete cleanly even
// when Windows Explorer has the folder selected, and rmdir() will
// surface any real failure during the destructive phase.
if ($file->isLink() || !$file->isFile()) continue;
$path = $file->getPathname();
$tmp = $path . '.mglocktest';
// If the .mglocktest sibling already exists from a previous crashed
// run, treat it as locked rather than overwriting it.
if (file_exists($tmp)) {
$locked[] = ltrim(substr($path, strlen($webroot)), '/\\');
} elseif (@rename($path, $tmp)) {
// Free — restore. If restore somehow fails, do not leave it in
// limbo: rename failure here is essentially impossible on the
// same volume, but if it happens we report the original path
// as problematic so the user notices.
if (!@rename($tmp, $path)) {
$locked[] = ltrim(substr($path, strlen($webroot)), '/\\') . ' (renamed to .mglocktest — please restore manually)';
}
} else {
$locked[] = ltrim(substr($path, strlen($webroot)), '/\\');
}
if (count($locked) >= $cap) break;
}
return $locked;
}
function mg_rm_tree(string $path, ?string &$failedPath = null): bool
{
// Critical: never traverse INTO symlinks. The wizard's staged tree often
// contains symlinks to plugin source clones (a developer convenience), and
// RecursiveDirectoryIterator follows them by default, which would attempt
// to delete real source files. scandir() returns symlinks as plain entries
// we can identify via is_link() before deciding to recurse or just unlink.
//
// $failedPath is set to the FIRST path that couldn't be removed, so the
// promote-error UI can name exactly which file is locked (typically a
// .git/index or editor-held buffer on Windows) instead of just the
// top-level entry. Subsequent failures in the same tree are not tracked
// — first one wins, since that's the one the user needs to free.
if (is_link($path) || is_file($path)) {
if (@unlink($path)) return true;
if ($failedPath === null) $failedPath = $path;
return false;
}
if (!is_dir($path)) {
return true;
}
$items = @scandir($path);
if ($items === false) {
if ($failedPath === null) $failedPath = $path;
return false;
}
$ok = true;
foreach ($items as $item) {
if ($item === '.' || $item === '..') continue;
$sub = $path . DIRECTORY_SEPARATOR . $item;
if (is_link($sub)) {
if (!@unlink($sub)) {
if ($failedPath === null) $failedPath = $sub;
$ok = false;
}
} elseif (is_dir($sub)) {
$ok = mg_rm_tree($sub, $failedPath) && $ok;
} else {
if (!@unlink($sub)) {
if ($failedPath === null) $failedPath = $sub;
$ok = false;
}
}
}
if (!@rmdir($path)) {
if ($failedPath === null) $failedPath = $path;
return false;
}
return $ok;
}
// ─── Step 5: Test (server-aware sub-path enabler) ──────────────────────────
// MG_HTACCESS_MARKER lives at the top of the file — top-level `const`
// statements run in source order, not hoisted, and these helpers are
// invoked during render before this section would execute.
function mg_server_kind(): string
{
$s = strtolower((string) ($_SERVER['SERVER_SOFTWARE'] ?? ''));
if (str_contains($s, 'apache')) return 'apache';
if (str_contains($s, 'litespeed')) return 'litespeed';
if (str_contains($s, 'nginx')) return 'nginx';
if (str_contains($s, 'caddy')) return 'caddy';
return 'other';
}
/**
* Apache/LiteSpeed only: inject one extra `RewriteCond` into the catch-all
* `RewriteRule .* index.php [L]` so requests under // are NOT
* routed to the parent install's index.php — letting the staged 2.0 serve
* itself for testing. Backs up to .htaccess.migrate-grav-backup. Idempotent.
*/
function mg_patch_htaccess(string $webroot, string $stageDir): array
{
$htpath = $webroot . '/.htaccess';
if (!is_file($htpath)) {
return ['ok' => false, 'msg' => "No .htaccess at {$htpath} — manual config needed."];
}
$current = (string) file_get_contents($htpath);
if (str_contains($current, MG_HTACCESS_MARKER)) {
return ['ok' => true, 'msg' => 'Already patched.'];
}
// Apache .htaccess does NOT support inline `# comment` after a directive
// — must put comments on their own line, otherwise Apache treats the rest
// (including the #) as part of the directive value and 500s.
$patchLines = " " . MG_HTACCESS_MARKER . "\n"
. " RewriteCond %{REQUEST_URI} !/" . $stageDir . "/\n";
// Inject right before any `RewriteRule .* index.php [L]` or [QSA,L] line.
$patched = preg_replace(
'/^(\s*RewriteRule\s+\.\*\s+index\.php\s+\[(?:[^\]]*L[^\]]*)\])/m',
$patchLines . '$1',
$current,
1,
$count
);
if ($patched === null || $count === 0) {
return ['ok' => false, 'msg' => 'Could not find catch-all RewriteRule in .htaccess.'];
}
@copy($htpath, $htpath . '.migrate-grav-backup');
@file_put_contents($htpath, $patched);
return ['ok' => true, 'msg' => 'Patched.'];
}
/**
* The staged Grav's .htaccess ships with `# RewriteBase /` commented out. When
* the staged install lives at a sub-path (e.g. /grav-admin-1749.5/grav-2/),
* its rewrites need RewriteBase set to that sub-path or every page after the
* root throws 500/404. Idempotent: skips if a RewriteBase line already exists.
*/
function mg_patch_staged_htaccess(string $webroot, string $stageDir, string $stageUrlPath): array
{
$htpath = $webroot . '/' . trim($stageDir, '/') . '/.htaccess';
if (!is_file($htpath)) {
return ['ok' => false, 'msg' => "Staged .htaccess missing at {$htpath}"];
}
$current = (string) file_get_contents($htpath);
// Already has an active RewriteBase? Leave it alone.
if (preg_match('/^\s*RewriteBase\s+\S/m', $current)) {
return ['ok' => true, 'msg' => 'Staged .htaccess already has RewriteBase.'];
}
$rewriteBase = '/' . trim($stageUrlPath, '/') . '/';
// Two lines — comment on its own (Apache doesn't allow inline comments).
$lines = MG_HTACCESS_MARKER . "\n"
. "RewriteBase {$rewriteBase}\n";
// Try replacing the commented "# RewriteBase /" template line first.
$patched = preg_replace('/^#\s*RewriteBase\s+\/\s*$/m', $lines, $current, 1, $count);
if ($count === 0) {
// Fall back to inserting before the first RewriteRule.
$patched = preg_replace('/^(\s*RewriteRule\b)/m', $lines . '$1', $current, 1, $count);
}
if ($count === 0) {
return ['ok' => false, 'msg' => 'Could not find a place to insert RewriteBase in staged .htaccess'];
}
@file_put_contents($htpath, $patched);
return ['ok' => true, 'msg' => "Set RewriteBase {$rewriteBase} in staged .htaccess."];
}
function mg_unpatch_htaccess(string $webroot): void
{
$htpath = $webroot . '/.htaccess';
$backup = $htpath . '.migrate-grav-backup';
if (is_file($backup)) {
@copy($backup, $htpath);
@unlink($backup);
return;
}
// No backup but our marker is present → strip the marker line AND the
// injected directive on the line that follows it.
if (is_file($htpath)) {
$cur = (string) file_get_contents($htpath);
if (str_contains($cur, MG_HTACCESS_MARKER)) {
$marker = preg_quote(MG_HTACCESS_MARKER, '/');
$stripped = preg_replace('/^[ \t]*' . $marker . ".*\n[ \t]*(?:RewriteCond|RewriteBase)[^\n]*\n/m", '', $cur);
if (is_string($stripped)) @file_put_contents($htpath, $stripped);
}
}
}
// ─── Step 4: Content ───────────────────────────────────────────────────────
function do_content(string $webroot, array $flag, ?callable $progress = null): array
{
// Content (pages, data, config, env, languages, custom folders, any
// top-level user/* files) was already bulk-copied into the staged
// install during Step 2. This step just summarises what landed in the
// staged user/ and marks the flow complete.
$stageDir = trim((string)($flag['stage_dir'] ?? 'grav-2'), '/');
$dstUser = $webroot . '/' . $stageDir . '/user';
$handled = ['plugins', 'themes', 'accounts'];
$entries = [];
if (is_dir($dstUser)) {
foreach (scandir($dstUser) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') continue;
if ($entry[0] === '.') continue;
if (in_array($entry, $handled, true)) continue;
$entries[] = $entry;
}
}
// Scan for editor-authored Twig usage and flip the 2.0 default-off
// security.twig_content gates back on where the source site relied on
// them. Editor permission stays off — operator opts in deliberately
// after migration.
$twigScan = mg_scan_twig_content($webroot, $dstUser);
// Scan for URL-based image transforms (e.g. `image.jpg?cropResize=300,200`)
// that bypass the media object. Grav 2.0 gates these behind the new
// system.images.url_actions toggle (off by default); 1.7 had no gate, so
// flip it on where the source content relied on it.
$mediaScan = mg_scan_media_url_actions($webroot, $dstUser);
$flag['step'] = 'content_done';
$flag['content'] = [
'at' => time(),
'entries' => $entries,
'twig_scan' => $twigScan,
'media_url_actions' => $mediaScan,
];
save_flag($webroot . '/.migrating', $flag);
$msg = 'Content already migrated in Step 2 — ' . count($entries) . ' top-level entries under staged user/.';
if ($twigScan['process_enabled']) {
$reasons = [];
if ($twigScan['pages_with_twig'] > 0) {
$reasons[] = $twigScan['pages_with_twig'] . ' page(s) set process.twig';
}
if (!empty($twigScan['system_process_twig'])) {
$reasons[] = 'system.yaml pages.process.twig was on';
}
if (!empty($twigScan['frontmatter_twig'])) {
$reasons[] = 'system.yaml pages.frontmatter.process_twig was on';
}
$msg .= ' Enabled security.twig_content.process_enabled (' . implode('; ', $reasons) . ').';
}
if ($twigScan['config_access']) {
$msg .= ' Enabled security.twig_content.config_access (found `config.` usage in ' . count($twigScan['config_pages']) . ' page(s)).';
}
// Twig function/filter seeding.
$safeAdded = array_merge($twigScan['safe_functions_added'] ?? [], $twigScan['safe_filters_added'] ?? []);
if ($safeAdded) {
$msg .= ' Added ' . count($safeAdded) . ' PHP function(s) to system.twig.safe_functions/safe_filters ('
. implode(', ', $safeAdded) . ') so they stay callable in Twig.';
}
$addedFns = $twigScan['sandbox_functions_added'] ?? [];
$addedFls = $twigScan['sandbox_filters_added'] ?? [];
if ($addedFns || $addedFls) {
$bits = [];
if ($addedFns) $bits[] = count($addedFns) . ' function(s)';
if ($addedFls) $bits[] = count($addedFls) . ' filter(s)';
$msg .= ' Widened security.twig_sandbox allowlist (' . implode(', ', $bits) . ') so page content can use them.';
}
$plugin = $twigScan['sandbox_plugin_funcs'] ?? [];
if ($plugin) {
$msg .= ' NOTE: ' . count($plugin) . ' name(s) in content are not PHP functions (' . implode(', ', $plugin)
. ') — these are plugin-provided Twig functions. The providing plugin must register them (ideally via'
. ' the onBuildTwigSandboxPolicy event); otherwise they will still fail after migration.';
}
$fromContent = $twigScan['sandbox_from_content'] ?? [];
if ($fromContent && !empty($twigScan['undefined_functions_was_on'])) {
$msg .= ' Your source site had system.twig.undefined_functions ON, and ' . count($fromContent)
. ' name(s) used in content were not in your safe_functions list — review the additions and remove any you do not trust.';
}
$blockedDanger = $twigScan['sandbox_blocked_dangerous'] ?? [];
if ($blockedDanger) {
$msg .= ' Did NOT add ' . count($blockedDanger) . ' dangerous function(s) Grav 2.0 always refuses ('
. implode(', ', $blockedDanger) . ') — those content usages will not work and must be reworked.';
}
$blocked = $twigScan['sandbox_blocked'] ?? [];
if ($blocked) {
$msg .= ' Did NOT allowlist ' . count($blocked) . ' function(s) the content sandbox blocks by design ('
. implode(', ', $blocked) . ').';
}
$stripped = $twigScan['system_twig_undefined_stripped'] ?? [];
if ($stripped) {
$msg .= ' Removed dead 1.x key(s) from system.yaml: ' . implode(', ', $stripped) . '.';
}
if (!empty($twigScan['system_twig_warning'])) {
$msg .= ' WARNING (system.yaml): ' . $twigScan['system_twig_warning'] . '.';
}
// URL-based image actions (system.images.url_actions).
if ($mediaScan['enabled']) {
$where = [];
if (!empty($mediaScan['page_hits'])) $where[] = count($mediaScan['page_hits']) . ' page(s)';
if (!empty($mediaScan['template_hits'])) $where[] = count($mediaScan['template_hits']) . ' template(s)';
$msg .= ' Enabled system.images.url_actions — found URL-based image transforms that bypass the media object (e.g. `image.jpg?'
. ($mediaScan['actions'][0] ?? 'cropResize') . '=…`) in ' . implode(' and ', $where)
. '. Grav 2.0 disables these by default; without the toggle those images would stop transforming after migration.';
} elseif ($mediaScan['already_on']) {
$msg .= ' system.images.url_actions was already on — left as-is.';
}
if (!empty($mediaScan['oversized'])) {
$msg .= ' NOTE: ' . count($mediaScan['oversized']) . ' of those request the image above the system.images.max_pixels ceiling ('
. number_format($mediaScan['max_pixels']) . 'px) and will still be refused — raise max_pixels or rework them.';
}
if (!empty($mediaScan['warning'])) {
$msg .= ' WARNING (system.yaml images): ' . $mediaScan['warning'] . '.';
}
return ['ok' => true, 'msg' => $msg];
}
/**
* Walk the staged user/pages/ tree and the staged system.yaml looking for
* editor-authored Twig usage that the source site depended on. When any is
* found, flip the matching `security.twig_content.*` gates ON in the staged
* `user/config/security.yaml` so the migrated site keeps working.
*
* Returns a summary used by the wizard's report and stored on .migrating:
* - process_enabled (bool): whether we flipped the gate
* - config_access (bool): whether we flipped config-in-twig access
* - pages_with_twig (int): count of pages with process.twig:true
* - config_pages (array): paths of pages that use {{ config }} in body
* - frontmatter_twig (bool): whether system.pages.frontmatter.process_twig was on
* - system_process_twig (bool): whether system.pages.process.twig was on (1.x global default — dropped in 2.0 admin UI)
* - safe_functions_added (list): PHP functions added to system.twig.safe_functions
* - safe_filters_added (list): PHP functions added to system.twig.safe_filters
* - sandbox_functions_added (list): names added to security.twig_sandbox.allowed_functions
* - sandbox_filters_added (list): names added to security.twig_sandbox.allowed_filters
* - sandbox_plugin_funcs (list): content names that aren't PHP functions (need plugin registration)
* - sandbox_from_content (list): names used in content but not in source safe_functions (escape-hatch category)
* - sandbox_blocked (list): sandbox-denylisted names found but NOT added (blocked by 2.0 design)
* - sandbox_blocked_dangerous (list): isDangerousFunction() names found but NOT added
* - sandbox_token_pages (array): content-derived token => sample page path
* - undefined_functions_was_on (bool): whether 1.x system.twig.undefined_functions was on
* - system_twig_undefined_stripped (list): dead undefined_* keys removed from staged system.yaml
*/
function mg_scan_twig_content(string $webroot, string $dstUser): array
{
mg_ensure_yaml_available();
$yaml = '\\Symfony\\Component\\Yaml\\Yaml';
$result = [
'process_enabled' => false,
'config_access' => false,
'pages_with_twig' => 0,
'config_pages' => [],
'frontmatter_twig' => false,
'system_process_twig' => false,
// Twig function/filter seeding (Grav 2.0).
'sandbox_safe_functions' => [], // existing source system.twig.safe_functions
'sandbox_safe_filters' => [], // existing source system.twig.safe_filters
'safe_functions_added' => [], // PHP functions added to system.twig.safe_functions
'safe_filters_added' => [], // PHP functions added to system.twig.safe_filters
'sandbox_functions_added' => [], // names added to security.twig_sandbox.allowed_functions
'sandbox_filters_added' => [], // names added to security.twig_sandbox.allowed_filters
'sandbox_from_content' => [], // subset discovered in page bodies (escape-hatch category)
'sandbox_plugin_funcs' => [], // content funcs that aren't PHP functions (need plugin registration)
'sandbox_blocked' => [], // sandbox-denylisted names found but NOT added (blocked by design)
'sandbox_blocked_dangerous' => [], // isDangerousFunction() names found but NOT added
'sandbox_token_pages' => [], // token => [sample page paths] for content-derived names
'undefined_functions_was_on' => false,
'system_twig_undefined_stripped' => [],
];
// Pass 1: scan pages.
$pagesDir = $dstUser . '/pages';
// Custom Twig function/filter tokens seen in twig-enabled page bodies,
// mapped to the pages they appear in (for the migration report).
$bodyFuncPages = [];
$bodyFilterPages = [];
if (is_dir($pagesDir)) {
$rii = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($pagesDir, \FilesystemIterator::SKIP_DOTS));
foreach ($rii as $file) {
/** @var \SplFileInfo $file */
if ($file->isDir() || $file->getExtension() !== 'md') continue;
$raw = @file_get_contents($file->getPathname());
if ($raw === false || $raw === '') continue;
[$frontmatter, $body] = mg_split_frontmatter($raw);
if ($frontmatter === '') continue;
try {
$parsed = $yaml::parse($frontmatter);
} catch (\Throwable) {
continue;
}
if (!is_array($parsed)) continue;
$twig = $parsed['process']['twig'] ?? null;
if ($twig !== true && $twig !== 'true' && $twig !== 1 && $twig !== '1') continue;
$result['pages_with_twig']++;
$rel = ltrim(str_replace($pagesDir, '', $file->getPathname()), '/\\');
// Heuristic config-usage scan against the markdown body. False
// positives are safe (preserve current behavior); false negatives
// are also safe (operator can flip the toggle manually after).
if (preg_match('/\{\{[^}]*\bconfig\b|\{%[^%]*\bconfig\b/', $body) === 1) {
$result['config_pages'][] = $rel;
}
// Collect the Twig function/filter names used in this body so we
// can seed the 2.0 sandbox allowlist with the custom ones below.
$tokens = mg_extract_twig_tokens($body);
foreach ($tokens['functions'] as $fn) {
$bodyFuncPages[$fn][] = $rel;
}
foreach ($tokens['filters'] as $fl) {
$bodyFilterPages[$fl][] = $rel;
}
}
}
// Pass 2: scan system.yaml for the frontmatter-Twig opt-in. Operator
// already accepted the risk site-wide there, so honour it.
$systemYaml = $dstUser . '/config/system.yaml';
if (is_file($systemYaml)) {
try {
$sys = $yaml::parseFile($systemYaml);
if (is_array($sys)) {
// Site-wide opt-ins from Grav 1.x. Both were dropped from the
// 2.0 admin UI: pages.process.twig (the per-page default — now
// only settable per page) and pages.frontmatter.process_twig
// (Twig in frontmatter values). Either being true on the
// source site means the operator deliberately enabled
// editor-authored Twig, so re-open the 2.0 gate to match.
// Accept the same truthy set as the per-page scanner so a
// source `twig: "true"` (quoted string) or `twig: 1` is
// promoted to the gate — otherwise the 2.0 site silently
// loses Twig in content after migration.
$sysTwig = $sys['pages']['process']['twig'] ?? null;
if ($sysTwig === true || $sysTwig === 'true' || $sysTwig === 1 || $sysTwig === '1') {
$result['system_process_twig'] = true;
}
$sysFrontTwig = $sys['pages']['frontmatter']['process_twig'] ?? null;
if ($sysFrontTwig === true || $sysFrontTwig === 'true' || $sysFrontTwig === 1 || $sysFrontTwig === '1') {
$result['frontmatter_twig'] = true;
}
// 1.x `system.twig.safe_functions` / `safe_filters` are the
// operator's curated allowlist of custom functions/filters for
// content Twig — the authoritative source for seeding the 2.0
// sandbox below. `undefined_functions` (default ON in 1.x) was
// the escape hatch 2.0 removed (GHSA-9wg2-prc3-vx89); track it
// so the report can warn that undeclared functions in content
// will now hard-fail. All four keys are dead in 2.0 and get
// stripped from the staged system.yaml further down.
$result['sandbox_safe_functions'] = mg_normalize_token_list($sys['twig']['safe_functions'] ?? []);
$result['sandbox_safe_filters'] = mg_normalize_token_list($sys['twig']['safe_filters'] ?? []);
$undef = $sys['twig']['undefined_functions'] ?? null;
// Unset means the 1.x default (true), which is the risky case.
$result['undefined_functions_was_on'] =
$undef === null || $undef === true || $undef === 'true' || $undef === 1 || $undef === '1';
}
} catch (\Throwable) {
// Bad YAML in source — leave defaults.
}
}
// Decide which gates to flip.
if ($result['pages_with_twig'] > 0 || $result['frontmatter_twig'] || !empty($result['system_process_twig'])) {
$result['process_enabled'] = true;
}
if (!empty($result['config_pages'])) {
$result['config_access'] = true;
}
// Nothing found → just drop the two dead keys and leave fresh-install
// defaults in place. Grav 2.0 removed `undefined_functions` /
// `undefined_filters` (the blanket auto-allow); `safe_functions` /
// `safe_filters` are RETAINED (hardened) so they're preserved here and, in
// the process_enabled path below, merged with anything found in content.
if (!$result['process_enabled']) {
if (is_file($systemYaml)) {
$result['system_twig_undefined_stripped'] =
mg_rewrite_system_twig_keys($systemYaml, ['undefined_functions', 'undefined_filters'], [], $result);
}
return $result;
}
// Merge into the staged security.yaml.
$secYaml = $dstUser . '/config/security.yaml';
$current = [];
if (is_file($secYaml)) {
try {
$parsed = $yaml::parseFile($secYaml);
if (is_array($parsed)) $current = $parsed;
} catch (\Throwable) {
// Treat unparseable file as empty — we'll overwrite with valid YAML below.
}
}
$current['twig_content'] = array_merge(
['process_enabled' => false, 'editor_enabled' => false, 'config_access' => false],
$current['twig_content'] ?? [],
[
'process_enabled' => true,
'editor_enabled' => $current['twig_content']['editor_enabled'] ?? false,
'config_access' => $result['config_access'] || (bool) ($current['twig_content']['config_access'] ?? false),
]
);
// Seed the Twig allowlists so the custom functions/filters this site used
// in content keep resolving under Grav 2.0. Two layers are involved:
// - system.twig.safe_functions / safe_filters — registers a raw PHP
// function (e.g. `strtoupper`) so it's callable by name at all.
// - security.twig_sandbox.allowed_functions / allowed_filters — lets
// sandboxed PAGE CONTENT call it. Both are needed for content; a theme
// template only needs the first.
// We scan Twig-enabled page bodies and, for each non-core name used:
// - refuse anything Utils::isDangerousFunction() blocks (reported), and
// anything the sandbox deny-lists by design (reported);
// - real PHP functions → both safe_functions and the sandbox allowlist;
// - everything else (plugin-provided Twig functions) → the sandbox
// allowlist, plus a note that the plugin must register them.
$baseline = mg_read_sandbox_baseline(dirname($dstUser));
$denylist = array_flip(mg_twig_sandbox_denylist());
$coreFnsSet = array_flip($baseline['functions']);
$coreFlsSet = array_flip($baseline['filters']);
$existingSafeFns = array_flip($result['sandbox_safe_functions']);
$existingSafeFls = array_flip($result['sandbox_safe_filters']);
$allowFnAdd = $safeFnAdd = $pluginFns = [];
$contentTokens = []; // name => sample page paths (escape-hatch category)
foreach ($bodyFuncPages as $fn => $pages) {
if (isset($coreFnsSet[$fn])) continue; // already a permitted Twig function
if (mg_is_dangerous_function((string) $fn)) { $result['sandbox_blocked_dangerous'][] = $fn; continue; }
if (isset($denylist[strtolower((string) $fn)])) { $result['sandbox_blocked'][] = $fn; continue; }
$allowFnAdd[$fn] = true;
if (function_exists((string) $fn)) {
$safeFnAdd[$fn] = true; // real PHP function → register it
} else {
$pluginFns[$fn] = true; // plugin-provided Twig function
}
// Escape-hatch category = used in content but not already blessed.
if (!isset($existingSafeFns[$fn])) {
$contentTokens[$fn] = array_values(array_unique($pages));
}
}
$allowFlAdd = $safeFlAdd = [];
foreach ($bodyFilterPages as $fl => $pages) {
if (isset($coreFlsSet[$fl])) continue;
if (mg_is_dangerous_function((string) $fl)) { $result['sandbox_blocked_dangerous'][] = $fl; continue; }
$allowFlAdd[$fl] = true;
if (function_exists((string) $fl)) {
$safeFlAdd[$fl] = true;
} else {
$pluginFns[$fl] = true;
}
if (!isset($existingSafeFls[$fl])) {
$contentTokens[$fl] = array_values(array_unique($pages));
}
}
// Merged safe_* lists for system.yaml = existing (preserved) + new PHP funcs.
$unionSafeFns = array_values(array_unique(array_merge($result['sandbox_safe_functions'], array_keys($safeFnAdd))));
$unionSafeFls = array_values(array_unique(array_merge($result['sandbox_safe_filters'], array_keys($safeFlAdd))));
$result['sandbox_blocked'] = array_values(array_unique($result['sandbox_blocked']));
$result['sandbox_blocked_dangerous'] = array_values(array_unique($result['sandbox_blocked_dangerous']));
$result['sandbox_functions_added'] = array_keys($allowFnAdd);
$result['sandbox_filters_added'] = array_keys($allowFlAdd);
// "added to safe_*" = the genuinely new entries (exclude pre-existing).
$result['safe_functions_added'] = array_values(array_diff(array_keys($safeFnAdd), $result['sandbox_safe_functions']));
$result['safe_filters_added'] = array_values(array_diff(array_keys($safeFlAdd), $result['sandbox_safe_filters']));
$result['sandbox_plugin_funcs'] = array_keys($pluginFns);
$result['sandbox_from_content'] = array_keys($contentTokens);
// One sample page per content-derived token keeps the report payload small.
$result['sandbox_token_pages'] = array_map(static fn($p) => $p[0] ?? null, $contentTokens);
$sandboxComment = '';
if ($allowFnAdd || $allowFlAdd) {
// Write the FULL union (core defaults first, then additions). The
// sandbox lists have no blueprint, so Grav merges them BY INDEX — a
// partial list here would corrupt the core defaults. Keep them whole.
$sandbox = $current['twig_sandbox'] ?? [];
if ($allowFnAdd) {
$sandbox['allowed_functions'] = array_values(array_unique(array_merge($baseline['functions'], array_keys($allowFnAdd))));
}
if ($allowFlAdd) {
$sandbox['allowed_filters'] = array_values(array_unique(array_merge($baseline['filters'], array_keys($allowFlAdd))));
}
$current['twig_sandbox'] = $sandbox;
$sandboxComment =
"#\n"
. "# Widened the Twig sandbox allowlist (security.twig_sandbox) to keep\n"
. "# custom functions/filters from your 1.x content working. These are\n"
. "# the FULL lists (core defaults + additions): the sandbox lists have\n"
. "# no blueprint, so Grav merges them by index — a partial list here\n"
. "# would corrupt the core defaults. Keep them complete.\n"
. "# Raw PHP functions are also added to system.twig.safe_functions so\n"
. "# they're callable at all. Durable fix for plugin-provided functions:\n"
. "# update the plugin to register them via the onBuildTwigSandboxPolicy\n"
. "# event, then delete them from this file.\n";
}
@mkdir(dirname($secYaml), 0775, true);
$dump = "# Generated by migrate-grav: enabled security.twig_content gates to\n"
. "# preserve Twig-in-content behavior from the source 1.x install.\n"
. "# editor_enabled is intentionally left off — grant the\n"
. "# `admin.pages_twig` permission to specific users, or flip\n"
. "# editor_enabled to true in Configuration > Security > Twig in Content.\n"
. $sandboxComment
. "\n"
. $yaml::dump($current, 6, 2);
@file_put_contents($secYaml, $dump);
// Rewrite the staged system.yaml twig block: drop the dead
// `undefined_functions` / `undefined_filters` keys Grav 2.0 removed, and
// (re)write `safe_functions` / `safe_filters` as the merged list of the
// operator's originals plus the raw PHP functions found in content. Empty
// lists are skipped, existing comments/keys are preserved.
if (is_file($systemYaml)) {
$result['system_twig_undefined_stripped'] = mg_rewrite_system_twig_keys(
$systemYaml,
['undefined_functions', 'undefined_filters'],
array_filter(['safe_functions' => $unionSafeFns, 'safe_filters' => $unionSafeFls]),
$result
);
}
// Promote the legacy `system.pages.process.twig` flag into the security
// gate by stripping it from system.yaml. Grav 2.0 defaults the per-page
// `process.twig` flag from `security.twig_content.process_enabled` when
// the legacy key isn't present, so behavior is preserved and the gate
// becomes the single source of truth (visible in the 2.0 admin UI).
//
// Done BEFORE the security.yaml write below so that a strip failure
// never leaves the 2.0 install in a half-applied state. If strip fails,
// we still write security.yaml so the gate is on and behavior is
// preserved (both keys end up true, functionally equivalent), and the
// warning is surfaced so the operator can hand-edit.
if ($result['system_process_twig'] && is_file($systemYaml)) {
$result['system_process_twig_stripped'] = mg_strip_system_pages_process_twig($systemYaml, $result);
}
return $result;
}
/**
* Strip `pages.process.twig` from a staged `system.yaml`. Operates on the
* raw text so unrelated keys, comments, and formatting are preserved.
*
* Limitations (surfaced to the caller via `$result['system_process_twig_warning']`):
* - Flow-style mappings (`process: { twig: true }`) are detected but not
* auto-stripped — removing one key from a flow mapping without a real
* YAML rewrite is fragile, so the operator is asked to remove it by hand.
* - The empty `process:` parent mapping is left in place when twig was its
* only child. Grav 2.0 handles `pages.process: null` as `[]`, so this is
* cosmetic only.
*
* @param array $result Migration result array; the function
* may set `system_process_twig_warning` on it.
* @return bool true when the file was modified, false otherwise.
*/
function mg_strip_system_pages_process_twig(string $systemYaml, array &$result): bool
{
$raw = @file_get_contents($systemYaml);
if ($raw === false) {
$result['system_process_twig_warning'] = 'could not read staged system.yaml';
return false;
}
if ($raw === '') {
return false;
}
// Match `twig: ` block-style line. Accept optional
// quotes around the truthy scalar (e.g. `twig: "true"`) and end-of-
// string in lieu of a trailing newline so the last line of a file with
// no final newline is still caught. We only strip truthy values; an
// explicit `false` means the operator deliberately disabled Twig in
// content and that override must be preserved.
$pattern = '/^([ \t]*)twig:[ \t]+["\']?(?:true|yes|on|1)["\']?\b[^\n]*(?:\r?\n|\z)/m';
$modified = $raw;
if (preg_match_all($pattern, $raw, $matches, PREG_OFFSET_CAPTURE)) {
// Collect (offset, length, indent) per match and process in reverse
// order so earlier offsets remain valid as we mutate $modified.
$entries = [];
foreach ($matches[0] as $i => $m) {
$entries[] = [$m[1], strlen($m[0]), strlen($matches[1][$i][0])];
}
usort($entries, static fn($a, $b) => $b[0] - $a[0]);
foreach ($entries as [$offset, $len, $indent]) {
// Only strip when this exact match sits under `pages: -> process:`.
// Walking the original $raw is safe because offsets came from it.
if (!mg_yaml_match_under_pages_process($raw, $offset, $indent)) {
continue;
}
$modified = substr($modified, 0, $offset) . substr($modified, $offset + $len);
}
}
// Detect flow-style mappings the block-style regex can't reach. If the
// scanner triggered (caller only invokes us when system_process_twig is
// true) but no block-style match was usable, the truthy must live in a
// flow mapping or in a form we can't safely auto-edit. Warn so the
// operator knows to remove it manually.
if ($modified === $raw && preg_match('/process:[ \t]*\{[^}]*\btwig[ \t]*:[ \t]*["\']?(?:true|yes|on|1)["\']?/i', $raw)) {
$result['system_process_twig_warning'] = 'flow-style `process: { twig: true }` detected; please remove the twig key from user/config/system.yaml manually';
return false;
}
if ($modified === $raw) {
return false;
}
// Atomic write: temp file + rename, so an interruption mid-write can't
// truncate the staged system.yaml.
$tmp = $systemYaml . '.tmp.' . getmypid();
if (@file_put_contents($tmp, $modified) === false) {
$result['system_process_twig_warning'] = 'failed to write temp file for system.yaml strip';
return false;
}
if (!@rename($tmp, $systemYaml)) {
@unlink($tmp);
$result['system_process_twig_warning'] = 'failed to rename temp file over system.yaml';
return false;
}
return true;
}
/**
* Walk backwards from $offset in $raw to confirm the line at that offset
* (with the given indent) sits under a `process:` parent whose own parent
* is `pages:`. Used to gate `mg_strip_system_pages_process_twig` so unrelated
* `twig:` keys elsewhere in the file aren't stripped.
*/
function mg_yaml_match_under_pages_process(string $raw, int $offset, int $indent): bool
{
$before = substr($raw, 0, $offset);
$lines = preg_split('/\r?\n/', $before);
if (!is_array($lines)) {
return false;
}
$parentFound = false;
$parentIndent = 0;
for ($i = count($lines) - 1; $i >= 0; $i--) {
$l = $lines[$i];
$trimmed = trim($l);
if ($trimmed === '' || str_starts_with($trimmed, '#')) {
continue;
}
if (!preg_match('/^([ \t]*)/', $l, $m)) {
continue;
}
$thisIndent = strlen($m[1]);
if (!$parentFound) {
if ($thisIndent >= $indent) {
continue;
}
if ($trimmed !== 'process:') {
return false;
}
$parentFound = true;
$parentIndent = $thisIndent;
continue;
}
// Now searching for grandparent.
if ($thisIndent >= $parentIndent) {
continue;
}
return $trimmed === 'pages:';
}
return false;
}
/**
* Normalize a YAML value into a clean list of non-empty string tokens.
* 1.x `safe_functions: { }` (empty map) and `safe_functions: []` both parse
* to an empty array; a populated list parses to a string list. Anything else
* (scalar, null) yields an empty list.
*
* @return list
*/
function mg_normalize_token_list(mixed $val): array
{
if (!is_array($val)) {
return [];
}
$out = [];
foreach ($val as $v) {
if (is_string($v) && $v !== '') {
$out[] = $v;
}
}
return array_values(array_unique($out));
}
/**
* Functions Grav 2.0 deliberately keeps OUT of the default sandbox allowlist
* because they enable SSTI / arbitrary file or code access from editor content
* (see the comments in system/config/security.yaml). migrate never auto-adds
* these even if the source site used them — it reports them instead.
*
* @return list
*/
function mg_twig_sandbox_denylist(): array
{
return [
// Twig core
'include', 'source', 'template_from_string', 'constant',
// Grav extras
'evaluate', 'evaluate_twig', 'svg_image', 'read_file',
'redirect_me', 'http_response_code',
];
}
/**
* Extract the Twig function-call and filter names used inside `{{ … }}` /
* `{% … %}` regions of a page body. Prose parentheses and `|` characters
* outside Twig delimiters are ignored. Method calls (`obj.method()`) and
* piped filters are excluded from the function set, and macros declared in the
* same body are not mistaken for custom functions.
*
* Over-inclusion is safe here — a stray name only widens an allowlist that the
* operator is told to review. Under-inclusion is safe too (operator can add it
* by hand). So the heuristics favour simplicity over a full Twig lexer.
*
* @return array{functions:list,filters:list}
*/
function mg_extract_twig_tokens(string $body): array
{
if ($body === '' || (!str_contains($body, '{{') && !str_contains($body, '{%'))) {
return ['functions' => [], 'filters' => []];
}
// Macro names declared in this body — excluded from the function set.
$macros = [];
if (preg_match_all('/\{%-?\s*macro\s+([a-zA-Z_]\w*)\s*\(/', $body, $mm)) {
foreach ($mm[1] as $n) {
$macros[strtolower($n)] = true;
}
}
if (preg_match_all('/\{%-?\s*from\b[^%]*\bimport\b([^%]*)%\}/', $body, $im)) {
foreach ($im[1] as $clause) {
if (preg_match_all('/[a-zA-Z_]\w*/', $clause, $names)) {
foreach ($names[0] as $n) {
if (strtolower($n) !== 'as') {
$macros[strtolower($n)] = true;
}
}
}
}
}
$functions = [];
$filters = [];
if (preg_match_all('/\{\{.*?\}\}|\{%.*?%\}/s', $body, $regions)) {
foreach ($regions[0] as $region) {
// Function calls: `name(` not preceded by a word char, `.` (method
// call) or `|` (piped filter).
if (preg_match_all('/(? array_keys($functions),
'filters' => array_keys($filters),
];
}
/**
* Read the core default `twig_sandbox.allowed_functions` / `allowed_filters`
* lists from the staged Grav 2.0 install's `system/config/security.yaml`.
* Reading the staged copy (rather than a hardcoded list) keeps the union we
* write correct as core's defaults evolve across 2.0 point releases.
*
* @param string $stageRoot Staged Grav 2.0 root (dirname of the staged user/).
* @return array{functions:list,filters:list}
*/
function mg_read_sandbox_baseline(string $stageRoot): array
{
$out = ['functions' => [], 'filters' => []];
$path = $stageRoot . '/system/config/security.yaml';
if (!is_file($path)) {
return $out;
}
mg_ensure_yaml_available();
$yaml = '\\Symfony\\Component\\Yaml\\Yaml';
try {
$cfg = $yaml::parseFile($path);
} catch (\Throwable) {
return $out;
}
if (is_array($cfg)) {
$out['functions'] = mg_normalize_token_list($cfg['twig_sandbox']['allowed_functions'] ?? []);
$out['filters'] = mg_normalize_token_list($cfg['twig_sandbox']['allowed_filters'] ?? []);
}
return $out;
}
/**
* Is `$name` a PHP function Grav 2.0 refuses to expose as Twig via
* `safe_functions` / `safe_filters`? Defers to the staged Grav 2.0
* `Utils::isDangerousFunction()` for an exact match (it's a pure static method,
* autoloadable once the staged vendor is on the include path), with a
* conservative built-in fallback if that class can't be loaded.
*/
function mg_is_dangerous_function(string $name): bool
{
// mg_ensure_yaml_available() also wires the staged vendor autoload, which
// maps the `Grav\` namespace, so Utils becomes loadable after it runs.
mg_ensure_yaml_available();
if (class_exists('\\Grav\\Common\\Utils')) {
return \Grav\Common\Utils::isDangerousFunction($name);
}
// Fallback: refuse the command/code-execution and obvious filesystem/IO
// offenders. Core still enforces its full list at runtime regardless.
static $bad = [
'exec', 'passthru', 'system', 'shell_exec', 'popen', 'proc_open', 'pcntl_exec',
'assert', 'preg_replace', 'create_function', 'include', 'include_once',
'require', 'require_once', 'eval', 'call_user_func', 'call_user_func_array',
'extract', 'parse_str', 'putenv', 'ini_set', 'mail', 'header', 'unserialize',
'fopen', 'file_put_contents', 'file_get_contents', 'fwrite', 'unlink',
'phpinfo', 'getenv',
];
return in_array(strtolower($name), $bad, true);
}
/**
* Rewrite the `twig:` block of a staged system.yaml in a single atomic pass:
* - strip the keys in `$stripKeys` (e.g. the dead `undefined_functions` /
* `undefined_filters` Grav 2.0 removed), and
* - upsert each `$upsert[] => list` (e.g. the merged
* `safe_functions` / `safe_filters`) as a fresh block-style list inserted
* at the top of the `twig:` block. The old copy of an upserted key is
* removed first so we never leave a duplicate.
*
* Operates on raw text (line-based, block-aware) so unrelated keys, comments,
* and formatting are preserved. A multi-line FLOW value for a key we need to
* remove (unbalanced `{`/`[` on the key line) is left in place and flagged via
* `$result['system_twig_warning']` — we skip its upsert to avoid a duplicate
* key rather than risk a fragile flow rewrite.
*
* @param list $stripKeys Keys to remove outright.
* @param array> $upsert key => full list to (re)write.
* @param array $result May receive `system_twig_warning`.
* @return list Names of the keys actually stripped (excludes upserted keys).
*/
function mg_rewrite_system_twig_keys(string $systemYaml, array $stripKeys, array $upsert, array &$result): array
{
$stripped = [];
$raw = @file_get_contents($systemYaml);
if ($raw === false || $raw === '') {
return $stripped;
}
// Every upserted key is also removed first, then re-inserted fresh.
$removeKeys = array_values(array_unique(array_merge($stripKeys, array_keys($upsert))));
$eol = str_contains($raw, "\r\n") ? "\r\n" : "\n";
$lines = preg_split('/\r?\n/', $raw);
if (!is_array($lines)) {
return $stripped;
}
// Locate the `twig:` mapping (a bare `twig:` line) and its indent.
$twigStart = -1;
$twigIndent = 0;
foreach ($lines as $i => $l) {
if (preg_match('/^([ \t]*)twig:[ \t]*$/', $l, $m)) {
$twigStart = $i;
$twigIndent = strlen($m[1]);
break;
}
}
if ($twigStart === -1) {
return $stripped;
}
// Detect the child indent used inside the twig: block (first child line);
// default to twig indent + 2 spaces.
$childIndentStr = str_repeat(' ', $twigIndent + 2);
for ($j = $twigStart + 1; $j < count($lines); $j++) {
$cl = $lines[$j];
if (trim($cl) === '' || ltrim($cl)[0] === '#') {
continue;
}
$ci = strlen($cl) - strlen(ltrim($cl, " \t"));
if ($ci > $twigIndent) {
$childIndentStr = substr($cl, 0, $ci);
}
break;
}
$itemIndentStr = $childIndentStr . ' ';
// Build the fresh block(s) to insert right after the twig: line. A key
// whose list is empty is skipped (nothing to write).
$blocked = []; // upsert keys we couldn't safely insert (flow conflict)
$insert = [];
foreach ($upsert as $key => $list) {
$list = mg_normalize_token_list($list);
if (!$list) {
continue;
}
$insert[$key] = $list;
}
$out = [];
$n = count($lines);
$i = 0;
while ($i < $n) {
$line = $lines[$i];
if ($i === $twigStart) {
$out[] = $line;
// Inject the upserted lists at the top of the block.
foreach ($insert as $key => $list) {
$out[] = $childIndentStr . $key . ':';
foreach ($list as $item) {
$out[] = $itemIndentStr . '- ' . $item;
}
}
$i++;
continue;
}
if ($i > $twigStart) {
$trimmed = trim($line);
$indent = strlen($line) - strlen(ltrim($line, " \t"));
// A non-blank, non-comment line at or below the twig: indent ends
// the block — copy the remainder verbatim.
if ($trimmed !== '' && $trimmed[0] !== '#' && $indent <= $twigIndent) {
for (; $i < $n; $i++) {
$out[] = $lines[$i];
}
break;
}
if ($trimmed !== '' && $trimmed[0] !== '#'
&& preg_match('/^([ \t]*)([a-zA-Z_]\w*):(.*)$/', $line, $km)) {
$keyIndent = strlen($km[1]);
if ($keyIndent > $twigIndent && in_array($km[2], $removeKeys, true)) {
$rest = $km[3];
$opens = substr_count($rest, '{') + substr_count($rest, '[');
$closes = substr_count($rest, '}') + substr_count($rest, ']');
if ($opens > $closes) {
$result['system_twig_warning'] =
"multi-line flow value for `twig.{$km[2]}` in system.yaml; please remove it by hand";
// If this was an upsert key we just inserted, our fresh
// copy would now duplicate it — drop our insertion.
if (isset($insert[$km[2]])) {
$blocked[$km[2]] = true;
}
$out[] = $line;
$i++;
continue;
}
if (in_array($km[2], $stripKeys, true)) {
$stripped[] = $km[2];
}
$i++;
// Drop deeper-indented continuation lines (list items, etc.).
while ($i < $n) {
$child = $lines[$i];
if (trim($child) === '') {
break;
}
$cIndent = strlen($child) - strlen(ltrim($child, " \t"));
if ($cIndent > $keyIndent) {
$i++;
continue;
}
break;
}
continue;
}
}
}
$out[] = $line;
$i++;
}
// If a flow conflict blocked an upsert, strip the duplicate fresh block we
// injected at the top so the file stays valid (the old flow copy remains).
if ($blocked) {
$filtered = [];
$skip = 0;
foreach ($out as $idx => $ol) {
if ($skip > 0) { $skip--; continue; }
if (preg_match('/^([ \t]*)([a-zA-Z_]\w*):\s*$/', $ol, $bm)
&& isset($blocked[$bm[2]]) && strlen($bm[1]) === strlen($childIndentStr)) {
// Skip this header and its item lines.
for ($k = $idx + 1; $k < count($out); $k++) {
if (preg_match('/^\s*- /', $out[$k])) { $skip++; } else { break; }
}
continue;
}
$filtered[] = $ol;
}
$out = $filtered;
}
if (!$stripped && !$insert) {
return $stripped;
}
// Nothing actually changed (e.g. only-flow-blocked upserts, no strips).
$modified = implode($eol, $out);
if ($modified === $raw) {
return $stripped;
}
// Atomic write: temp file + rename.
$tmp = $systemYaml . '.tmp.' . getmypid();
if (@file_put_contents($tmp, $modified) === false) {
$result['system_twig_warning'] = 'failed to write temp file for system.yaml twig rewrite';
return [];
}
if (!@rename($tmp, $systemYaml)) {
@unlink($tmp);
$result['system_twig_warning'] = 'failed to rename temp file over system.yaml';
return [];
}
return $stripped;
}
/**
* The query-string image actions Grav serves through the URL fallback handler
* (`Grav::fallbackUrl`) when `system.images.url_actions` is on. Mirrors
* `ImageMedium::$magic_actions` in Grav core — keep in sync. A request like
* `/blog/post/photo.jpg?cropResize=300,200` only does anything when the query
* key matches one of these EXACTLY (Grav does a strict `in_array`), which is
* why the scanner matches them case-sensitively.
*
* @return list
*/
function mg_image_url_action_names(): array
{
return [
'resize', 'forceResize', 'cropResize', 'crop', 'zoomCrop',
'negate', 'brightness', 'contrast', 'grayscale', 'emboss',
'smooth', 'sharp', 'edge', 'colorize', 'sepia', 'enableProgressive',
'rotate', 'flip', 'fixOrientation', 'gaussianBlur', 'format', 'create', 'fill', 'merge',
];
}
/**
* Width/height argument positions for the resize-family actions. Mirrors
* `ImageMedium::$magic_resize_actions`; Grav reads the output width/height from
* the last two positions (so `crop`'s w,h are positions 2,3). Used to flag
* request-derived resizes that exceed the `system.images.max_pixels` ceiling —
* those are refused even with `url_actions` on, so they need a heads-up.
*
* @return array>
*/
function mg_image_url_action_resize_args(): array
{
return [
'resize' => [0, 1],
'forceResize' => [0, 1],
'cropResize' => [0, 1],
'crop' => [0, 1, 2, 3],
'zoomCrop' => [0, 1],
];
}
/**
* Scan migrated content for URL-based image transforms that bypass Grav's
* media object, and turn on `system.images.url_actions` in the staged
* `system.yaml` when any are found.
*
* Background: in Grav 1.7 a request like `image.jpg?cropResize=300,200` always
* applied the transform — there was no gate. Grav 2.0 added
* `system.images.url_actions` (default OFF) because those actions run with
* request-supplied arguments from an unauthenticated visitor. The normal,
* developer-controlled path — Twig/Markdown media methods such as
* `page.media['x'].cropResize(300,200)` or a co-located Markdown image
* `` whose file IS the page's media — is
* UNAFFECTED by the toggle: Grav resolves it through the media object at render
* time and emits a hashed cache URL with no query string. Only references Grav
* can't resolve to page media (absolute/rooted paths, `theme://`/`image://`
* stream paths, files that aren't co-located, and anything hand-written in a
* theme template) keep their literal `?action=` URL and hit the url_actions
* handler. Those are what break after migration unless the toggle is on.
*
* Detection therefore matches the URL query form (`…ext?action=…`) — which the
* media object never emits — and, for Markdown-processed page content, only
* counts a reference when it does NOT resolve to a co-located media file:
*
* - Page bodies are run through Grav's Markdown Excerpt processor, which
* swaps a reference for the media-object output ONLY when the referenced
* file exists as that page's own media. A bare/relative path that resolves
* to an existing file in the page folder is the safe media-object case and
* is skipped; everything else (absolute path, stream scheme, http(s) URL,
* or a missing file) keeps its literal query and is counted.
* - Theme templates are NOT Excerpt-processed — they render raw — so any
* literal `?action=` image URL there is always a direct request and is
* counted. (Twig media method chains use `.action(` syntax, not the query
* form, so they never match.)
*
* False positives only re-enable behaviour the 1.7 site already had (and are
* listed in the report for review); false negatives are recoverable by flipping
* the toggle by hand. Both are safe, matching the rest of this step.
*
* @return array{
* needed:bool, already_on:bool, enabled:bool,
* page_hits:array>, template_hits:array>,
* actions:list, oversized:list, max_pixels:int, warning:string
* }
*/
function mg_scan_media_url_actions(string $webroot, string $dstUser): array
{
$result = [
'needed' => false,
'already_on' => false,
'enabled' => false,
'page_hits' => [], // page-rel path => list of action names (direct refs)
'template_hits' => [], // theme template-rel path => list of action names
'actions' => [], // union of action names found in direct refs
'oversized' => [], // refs whose requested w*h exceeds max_pixels
'max_pixels' => 25000000,
'warning' => '',
];
$actionSet = array_flip(mg_image_url_action_names());
$resizeArgs = mg_image_url_action_resize_args();
// Raster image types ImageMedium can transform (svg is vector, gif animated
// but still served by the same handler — include it).
$re = '~([^\s"\'`()<>\[\]]+?\.(?:jpe?g|png|gif|webp|avif|bmp))\?([^\s"\'`()<>]+)~i';
$systemYaml = $dstUser . '/config/system.yaml';
// Read the request-derived resize ceiling so the report can flag refs that
// will still be refused even after the toggle is on. Default mirrors core.
if (is_file($systemYaml)) {
mg_ensure_yaml_available();
try {
$sys = ('\\Symfony\\Component\\Yaml\\Yaml')::parseFile($systemYaml);
if (is_array($sys)) {
$mp = $sys['images']['max_pixels'] ?? null;
if (is_numeric($mp)) {
$result['max_pixels'] = (int) $mp;
}
}
} catch (\Throwable) {
// Bad YAML — keep the default ceiling.
}
}
$maxPixels = $result['max_pixels'];
// Collect query-string image actions from a blob of text. Returns the
// matched action names; appends oversized notes keyed by $label.
$collect = function (string $text, string $label) use ($re, $actionSet, $resizeArgs, $maxPixels, &$result): array {
if (!str_contains($text, '?')) {
return [];
}
$found = [];
if (!preg_match_all($re, $text, $matches, PREG_SET_ORDER)) {
return [];
}
foreach ($matches as $m) {
// External and protocol-relative references (`https://cdn/…`,
// `//host/…`) are served by the remote host, never by
// `Grav::fallbackUrl()`, so the url_actions toggle cannot affect
// them. Skip them so a third-party `?format=webp` / `?crop=…` on a
// CDN URL doesn't masquerade as a Grav image action and turn the
// toggle on for nothing.
if (mg_media_ref_is_external($m[1])) {
continue;
}
// HTML attributes encode the separator as & — normalise first.
$query = str_replace('&', '&', $m[2]);
foreach (explode('&', $query) as $pair) {
if ($pair === '') continue;
[$key, $val] = array_pad(explode('=', $pair, 2), 2, '');
if (!isset($actionSet[$key])) continue; // strict, case-sensitive
$found[$key] = true;
// Flag request-derived resizes above the pixel ceiling — Grav
// refuses these even with url_actions on (RAM-exhaustion guard).
if ($maxPixels > 0 && isset($resizeArgs[$key])) {
$args = explode(',', $val);
$pos = $resizeArgs[$key];
$w = $args[$pos[count($pos) - 2]] ?? null;
$h = $args[$pos[count($pos) - 1]] ?? null;
if (is_numeric($w) && is_numeric($h) && (int) $w > 0 && (int) $h > 0
&& ((int) $w * (int) $h) > $maxPixels) {
$result['oversized'][] = $label . ': ' . $key . '=' . $val;
}
}
}
}
return array_keys($found);
};
$pagesRoot = realpath($dstUser . '/pages') ?: '';
// Pass 1: page content (Markdown-Excerpt-processed). Skip references that
// resolve to a co-located media file — those go through the media object.
if ($pagesRoot !== '' && is_dir($pagesRoot)) {
$rii = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($pagesRoot, \FilesystemIterator::SKIP_DOTS));
foreach ($rii as $file) {
/** @var \SplFileInfo $file */
if ($file->isDir() || $file->getExtension() !== 'md') continue;
$raw = @file_get_contents($file->getPathname());
if ($raw === false || $raw === '' || !str_contains($raw, '?')) continue;
$pageDir = dirname($file->getPathname());
$rel = ltrim(str_replace($pagesRoot, '', $file->getPathname()), '/\\');
$hits = [];
if (preg_match_all($re, $raw, $matches, PREG_SET_ORDER)) {
foreach ($matches as $m) {
// Only the references that bypass the media object count.
if (mg_media_ref_resolves_to_page_media($m[1], $pageDir, $pagesRoot)) {
continue;
}
$acts = $collect($m[0], $rel);
foreach ($acts as $a) $hits[$a] = true;
}
}
if ($hits) {
$result['page_hits'][$rel] = array_keys($hits);
}
}
}
// Pass 2: theme templates (rendered raw, never Excerpt-processed). Any
// literal action URL here is a direct request.
$themesRoot = realpath($dstUser . '/themes') ?: '';
if ($themesRoot !== '' && is_dir($themesRoot)) {
$rii = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($themesRoot, \FilesystemIterator::SKIP_DOTS));
foreach ($rii as $file) {
/** @var \SplFileInfo $file */
if ($file->isDir()) continue;
$ext = strtolower($file->getExtension());
if (!in_array($ext, ['twig', 'html', 'htm'], true)) continue;
$raw = @file_get_contents($file->getPathname());
if ($raw === false || $raw === '' || !str_contains($raw, '?')) continue;
$rel = ltrim(str_replace($themesRoot, '', $file->getPathname()), '/\\');
$acts = $collect($raw, $rel);
if ($acts) {
$result['template_hits'][$rel] = $acts;
}
}
}
// Union of action names found across all direct refs (for the report).
$allActions = [];
foreach ($result['page_hits'] as $acts) foreach ($acts as $a) $allActions[$a] = true;
foreach ($result['template_hits'] as $acts) foreach ($acts as $a) $allActions[$a] = true;
$result['actions'] = array_keys($allActions);
$result['oversized'] = array_values(array_unique($result['oversized']));
$result['needed'] = !empty($result['page_hits']) || !empty($result['template_hits']);
if (!$result['needed']) {
return $result;
}
// Flip system.images.url_actions on in the staged system.yaml.
if (is_file($systemYaml)) {
mg_set_system_images_url_actions($systemYaml, $result);
} else {
$result['warning'] = 'staged system.yaml missing — set images.url_actions: true by hand';
}
return $result;
}
/**
* Decide whether an image reference written in Markdown will be resolved by
* Grav's Excerpt processor to the page's media object (and thus does NOT depend
* on `system.images.url_actions`).
*
* Returns true only for the unambiguous co-located case: a bare or relative
* path that resolves to an existing file inside the page's own folder (which is
* where Grav looks up `$page->media()`). Absolute/rooted paths, stream schemes
* (`theme://`, `image://`, …), http(s) URLs, and references to files that don't
* exist on disk all return false — Grav keeps their literal query string, so
* they ride the url_actions handler and must be counted. Resolving absolute
* Grav routes (e.g. `/blog/post` → `01.blog/post`) back to a folder is left out
* deliberately: over-counting only re-enables 1.7 behaviour and is reported for
* review, whereas a fragile route resolver could silently miss the cases that
* actually break.
*/
function mg_media_ref_resolves_to_page_media(string $path, string $pageDir, string $pagesRoot): bool
{
// Any scheme (http://, https://, theme://, image://, user://, …) means the
// reference isn't a plain page-folder media lookup we can confirm on disk.
if (preg_match('~^[a-zA-Z][a-zA-Z0-9+.\-]*://~', $path)) {
return false;
}
// Absolute/rooted paths aren't resolved against the page folder.
if ($path === '' || $path[0] === '/' || $path[0] === '\\') {
return false;
}
// Relative to the page folder — realpath collapses ./ and ../ and returns
// false when the file doesn't exist, which is exactly the test we want.
$real = realpath($pageDir . '/' . $path);
if ($real === false || !is_file($real)) {
return false;
}
// Stay inside the pages tree (a ../ ladder must not climb out of it).
$rootReal = realpath($pagesRoot);
return $rootReal !== false && str_starts_with($real, $rootReal . DIRECTORY_SEPARATOR);
}
/**
* Is a media reference pointed at a remote host rather than this site?
*
* `Grav::fallbackUrl()` only serves media requested from the local site, so an
* absolute URL (`https://cdn.example.com/x.jpg?…`) or a protocol-relative one
* (`//cdn.example.com/x.jpg?…`) is fetched by the browser straight from that
* host and never touches the url_actions handler. Their query string is just
* the remote service's own API (e.g. an image CDN's `?format=webp&w=200`), so a
* collision with a Grav magic-action name is a false positive and must not turn
* the toggle on. Grav schemes (`image://`, `theme://`, `user://`) are NOT
* treated as external: those resolve to local files and keep their literal
* query, so they still ride the handler and must be counted.
*
* Same-site absolute URLs (`https://this-site/page/x.jpg?resize=…`) would hit
* the handler, but the staged migration has no reliable site hostname to match
* against, so they are skipped here and surfaced via the documented
* "enable manually" caveat rather than guessed at.
*/
function mg_media_ref_is_external(string $path): bool
{
// Protocol-relative: //host/path
if (str_starts_with($path, '//')) {
return true;
}
// http(s):// (and any other transport scheme). Grav stream wrappers
// (image://, theme://, user://, …) are local and deliberately excluded.
return (bool) preg_match('~^(?:https?|ftp|ftps|wss?)://~i', $path);
}
/**
* Set `images.url_actions: true` in a staged `system.yaml`, operating on raw
* text so unrelated keys, comments, and formatting are preserved. Handles three
* shapes: the key already present (value flipped to true; an already-true value
* is a no-op recorded as `already_on`), an `images:` block without the key (key
* inserted as the first child), and no `images:` block at all (a fresh
* block-style entry appended). Flow-style `images: { … }` is detected and left
* for the operator with a warning rather than risking a fragile rewrite.
*
* Sets on $result: `enabled` (bool), `already_on` (bool), `warning` (string).
*/
function mg_set_system_images_url_actions(string $systemYaml, array &$result): void
{
$raw = @file_get_contents($systemYaml);
if ($raw === false) {
$result['warning'] = 'could not read staged system.yaml';
return;
}
$eol = str_contains($raw, "\r\n") ? "\r\n" : "\n";
$lines = preg_split('/\r?\n/', $raw);
if (!is_array($lines)) {
$result['warning'] = 'could not parse staged system.yaml';
return;
}
// Strip an unquoted trailing comment and surrounding whitespace.
$valueOf = static function (string $rest): string {
$rest = preg_replace('/\s+#.*$/', '', $rest) ?? $rest;
return trim($rest);
};
$isTruthy = static fn(string $v): bool => in_array(strtolower($v), ['true', '1', 'on', 'yes'], true);
// Locate a top-level (zero-indent) `images:` block.
$imagesStart = -1;
foreach ($lines as $i => $l) {
if (preg_match('/^images:[ \t]*(.*)$/', $l, $m)) {
$rest = $valueOf($m[1]);
if ($rest !== '' && $rest[0] === '{') {
$result['warning'] = 'flow-style `images: { … }` in system.yaml; please add `url_actions: true` by hand';
return;
}
$imagesStart = $i;
break;
}
}
// No images: block — append a fresh one.
if ($imagesStart === -1) {
$prefix = '';
if ($raw !== '') {
// Close an unterminated last line, then a blank separator line.
if (!str_ends_with($raw, "\n") && !str_ends_with($raw, "\r")) $prefix .= $eol;
$prefix .= $eol;
}
$block = $prefix . 'images:' . $eol . ' url_actions: true' . $eol;
if (mg_atomic_append($systemYaml, $block, $result)) {
$result['enabled'] = true;
}
return;
}
// Walk the images: children looking for an existing url_actions: key.
$childIndent = ' ';
$n = count($lines);
for ($j = $imagesStart + 1; $j < $n; $j++) {
$line = $lines[$j];
$trimmed = trim($line);
if ($trimmed === '' || $trimmed[0] === '#') continue;
$indent = strlen($line) - strlen(ltrim($line, " \t"));
if ($indent === 0) break; // left the images: block
$childIndent = substr($line, 0, $indent);
if (preg_match('/^([ \t]+)url_actions:[ \t]*(.*)$/', $line, $km)) {
if ($isTruthy($valueOf($km[2]))) {
$result['already_on'] = true; // nothing to do
return;
}
$lines[$j] = $km[1] . 'url_actions: true';
if (mg_atomic_write($systemYaml, implode($eol, $lines), $result)) {
$result['enabled'] = true;
}
return;
}
}
// images: block exists but no url_actions key — insert as first child.
array_splice($lines, $imagesStart + 1, 0, [$childIndent . 'url_actions: true']);
if (mg_atomic_write($systemYaml, implode($eol, $lines), $result)) {
$result['enabled'] = true;
}
}
/**
* Atomic full-file write (temp + rename). On failure sets `$result['warning']`
* and returns false.
*/
function mg_atomic_write(string $path, string $contents, array &$result): bool
{
$tmp = $path . '.tmp.' . getmypid();
if (@file_put_contents($tmp, $contents) === false) {
$result['warning'] = 'failed to write temp file for system.yaml images rewrite';
return false;
}
if (!@rename($tmp, $path)) {
@unlink($tmp);
$result['warning'] = 'failed to rename temp file over system.yaml';
return false;
}
return true;
}
/**
* Append to a file by reading, concatenating, and atomically rewriting so the
* write stays crash-safe. On failure sets `$result['warning']`, returns false.
*/
function mg_atomic_append(string $path, string $append, array &$result): bool
{
$raw = @file_get_contents($path);
if ($raw === false) {
$result['warning'] = 'could not read staged system.yaml for append';
return false;
}
return mg_atomic_write($path, $raw . $append, $result);
}
/**
* Split a Grav page's raw file content into ['', ''].
* Frontmatter is the block between the leading `---` and the next `---` line.
* Returns ['', $raw] when no frontmatter is present.
*
* @return array{0:string,1:string}
*/
function mg_split_frontmatter(string $raw): array
{
if (!str_starts_with($raw, '---')) {
return ['', $raw];
}
$rest = substr($raw, 3);
// Skip optional CR/LF after the opening fence.
$rest = preg_replace('/^\r?\n/', '', $rest, 1) ?? $rest;
$end = preg_match('/\r?\n---\r?\n/', $rest, $m, PREG_OFFSET_CAPTURE);
if (!$end) {
return ['', $raw];
}
$fmEnd = $m[0][1];
$bodyStart = $fmEnd + strlen($m[0][0]);
return [substr($rest, 0, $fmEnd), substr($rest, $bodyStart)];
}
/**
* Walk the scan and return ["plugins/admin" => "admin2", ...] for every
* source plugin/theme whose `replaced_by` target is something we'll actually
* be able to install (either listed in GPM or has a github_repo in the
* curated registry). The main copy step uses this to skip the OLD plugin
* entirely instead of copying-then-disabling it.
*/
function mg_collect_superseded(array $scan): array
{
$out = [];
$gpmPlugins = $scan['gpm']['plugins'] ?? [];
$gpmThemes = $scan['gpm']['themes'] ?? [];
foreach (['plugins', 'themes'] as $kind) {
foreach (($scan[$kind] ?? []) as $slug => $v) {
$repl = $v['replaced_by'] ?? null;
if (!$repl) continue;
$inGpm = isset($gpmPlugins[$repl]) || isset($gpmThemes[$repl]);
$entry = mg_lookup_registry_entry($repl);
$hasRepo = is_array($entry) && !empty($entry['github_repo']);
if ($inGpm || $hasRepo) {
$out["{$kind}/{$slug}"] = $repl;
}
}
}
return $out;
}
// ─────────────────────────────────────────────────────────────────────────────
// Auto-install replacements from GitHub
// ─────────────────────────────────────────────────────────────────────────────
/**
* For each disabled/skipped plugin with a curated `replaced_by` that itself
* has a `github_repo`, download + install the replacement into the staged
* user/plugins// dir. Returns ['installed' => [slugs], 'failed' => [[slug, reason]]].
*/
function mg_install_replacements(string $webroot, string $stageDir, array $scan, array $disabled, array $skipped, ?callable $progress): array
{
$installed = [];
$failed = [];
$candidates = [];
// Step 1: replacements derived from `replaced_by` on disabled/skipped plugins.
foreach (array_merge($disabled, $skipped) as $label) {
if (!str_starts_with($label, 'plugins/')) continue;
$slug = substr($label, strlen('plugins/'));
$slug = explode(' ', $slug, 2)[0];
$verdict = $scan['plugins'][$slug] ?? null;
if (!is_array($verdict)) continue;
$repl = $verdict['replaced_by'] ?? null;
if (!$repl) continue;
$candidates[$repl] = true;
}
// Step 2: transitively include any `requires:` from each candidate's
// registry entry. Admin 2.0 requires the API plugin — same fetch path.
$queue = array_keys($candidates);
while ($queue) {
$slug = array_shift($queue);
$entry = mg_lookup_registry_entry($slug);
if (!is_array($entry) || empty($entry['requires'])) continue;
foreach ((array) $entry['requires'] as $req) {
if (!isset($candidates[$req])) {
$candidates[$req] = true;
$queue[] = $req;
}
}
}
$gpmPlugins = $scan['gpm']['plugins'] ?? null;
foreach (array_keys($candidates) as $replSlug) {
$replEntry = $scan['plugins'][$replSlug] ?? mg_lookup_registry_entry($replSlug);
if ($progress) $progress(['phase' => 'start', 'entry' => "replacement/{$replSlug}"]);
$res = null;
// Prefer GPM when the slug is published there — same path the auto-update
// flow uses, and the same path real GPM uses (catalog → GitHub zip URL).
if (is_array($gpmPlugins) && isset($gpmPlugins[$replSlug]['download'])) {
$gpmEntry = $gpmPlugins[$replSlug];
$res = mg_install_zip_url($webroot, $stageDir, 'plugins', $replSlug, (string) $gpmEntry['download']);
if ($res['ok']) {
$res['version'] = (string) ($gpmEntry['version'] ?? '?');
$res['source'] = 'gpm';
}
}
// Fall back to direct GitHub fetch via curated github_repo when not in GPM.
if (!$res || !$res['ok']) {
if (is_array($replEntry) && !empty($replEntry['github_repo'])) {
$res = mg_fetch_and_install_github($webroot, $stageDir, $replSlug, $replEntry['github_repo']);
} elseif (!$res) {
$res = ['ok' => false, 'msg' => 'not in GPM and no github_repo in registry'];
}
}
if ($res['ok']) {
$installed[$replSlug] = ['version' => $res['version'] ?? '?', 'source' => $res['source'] ?? '?'];
if ($progress) $progress(['phase' => 'done-entry', 'entry' => "replacement/{$replSlug}"]);
} else {
$failed[] = [$replSlug, $res['msg']];
if ($progress) $progress(['phase' => 'skip', 'entry' => "replacement/{$replSlug}", 'reason' => $res['msg']]);
}
}
return ['installed' => $installed, 'failed' => $failed];
}
/**
* The compat scan only includes plugins/themes that exist in the source install.
* A replacement (e.g. admin-next) won't be in the source — look it up directly
* from the curated registry.
*/
function mg_lookup_registry_entry(string $slug): ?array
{
static $all = null;
if ($all === null) $all = mg_apply_baseline_registry(mg_fetch_curated());
return $all['plugins'][$slug] ?? $all['themes'][$slug] ?? null;
}
/**
/**
* Proxy configuration source-of-truth for the wizard. Read once from the
* `.migrating` flag (populated by Kickoff at staging time from Grav's
* system.http.proxy_url / proxy_cert_path), with an env-var fallback for
* standalone / CLI invocations where Kickoff didn't run.
*
* Returns ['url' => string, 'cert_path' => string]. Empty 'url' = no proxy.
*
* Cached for the request — proxy config doesn't change mid-wizard, and
* cracking open the 2-3 MB flag file repeatedly on every HTTP call adds up.
*/
function mg_proxy_config(): array
{
static $cached = null;
if ($cached !== null) return $cached;
$flagPath = __DIR__ . '/.migrating';
if (is_file($flagPath)) {
$f = json_decode((string) @file_get_contents($flagPath), true);
if (is_array($f) && !empty($f['proxy']) && is_array($f['proxy'])) {
$url = (string) ($f['proxy']['url'] ?? '');
$certPath = (string) ($f['proxy']['cert_path'] ?? '');
if ($url !== '') {
return $cached = ['url' => $url, 'cert_path' => $certPath];
}
}
}
// Env-var fallback. POSIX env vars are case-sensitive; both forms are
// common in the wild (curl honors lowercase; many CI runners set
// uppercase). HTTPS_PROXY wins over HTTP_PROXY when both are set, since
// most outbound calls here are HTTPS.
$url = getenv('HTTPS_PROXY') ?: getenv('https_proxy')
?: getenv('HTTP_PROXY') ?: getenv('http_proxy')
?: getenv('ALL_PROXY') ?: getenv('all_proxy')
?: '';
return $cached = ['url' => (string) $url, 'cert_path' => ''];
}
/**
* Build a stream context for an outbound HTTP call, threading in the
* proxy config from mg_proxy_config() automatically. Every outbound call
* in this wizard MUST go through here — direct stream_context_create()
* silently bypasses proxy settings and breaks for users behind a corporate
* proxy / air-gapped network.
*
* $http accepts the same shape as the 'http' key in stream_context_create
* (timeout, header, method, ignore_errors, …). $ssl likewise; defaults
* enable peer verification.
*
* Proxy URL handling:
* - Strip any scheme prefix from the configured proxy URL; PHP's stream
* wrapper expects 'tcp://host:port'.
* - Set request_fulluri so HTTP requests through the proxy include the
* full URL in the request line (proxy requirement). HTTPS requests use
* CONNECT implicitly and ignore this flag.
* - Credentials embedded in the proxy URL (http://user:pass@host:port)
* are preserved as-is; PHP's HTTP wrapper emits Proxy-Authorization
* from them. Grav 1.7 doesn't expose separate proxy_username /
* proxy_password keys, so URL-embedded creds are the only auth path.
* - proxy_cert_path: if it's a file, set as 'cafile'; if a dir, 'capath'.
* Matches Grav 1.7's documented behavior.
*/
function mg_http_context(array $http = [], array $ssl = []): mixed
{
$ssl = $ssl + ['verify_peer' => true, 'verify_peer_name' => true];
$proxy = mg_proxy_config();
if ($proxy['url'] !== '') {
$proxyHostPort = preg_replace('~^[a-zA-Z][a-zA-Z0-9+.\-]*://~', '', $proxy['url']);
$http['proxy'] = 'tcp://' . $proxyHostPort;
$http['request_fulluri'] = true;
$certPath = $proxy['cert_path'];
if ($certPath !== '') {
if (is_file($certPath)) $ssl['cafile'] = $certPath;
elseif (is_dir($certPath)) $ssl['capath'] = $certPath;
}
}
return stream_context_create(['http' => $http, 'ssl' => $ssl]);
}
/**
* Download a zip of the default branch of / from GitHub and
* extract its single top-level directory's contents into
* {webroot}/{stageDir}/user/plugins/{slug}/.
*/
function mg_fetch_and_install_github(string $webroot, string $stageDir, string $slug, string $repo): array
{
$tmp = $webroot . '/tmp';
ensure_dir($tmp);
$zipPath = $tmp . '/replace-' . preg_replace('/[^a-z0-9\-]/i', '_', $slug) . '.zip';
// Try latest release first (proper tagged version). If none exists (no
// releases published yet), fall back to the default branch HEAD and
// record version as "1.0.0" — sentinel for "pre-release install".
$resolved = mg_github_resolve_download($repo);
$ctx = mg_http_context([
'timeout' => 20,
'header' => "User-Agent: grav-migrate-wizard/1.0\r\nAccept: application/vnd.github+json\r\n",
'ignore_errors' => false,
]);
$bytes = @file_get_contents($resolved['zipball'], false, $ctx);
if ($bytes === false || strlen($bytes) < 1024) {
return ['ok' => false, 'msg' => "Could not download {$repo} ({$resolved['source']})"];
}
@file_put_contents($zipPath, $bytes);
$dest = $webroot . '/' . $stageDir . '/user/plugins/' . $slug;
// Remove any previous stub or partial install
if (is_dir($dest)) remove_dir($dest);
ensure_dir($dest);
$zip = new ZipArchive();
if (($rc = $zip->open($zipPath)) !== true) {
@unlink($zipPath);
return ['ok' => false, 'msg' => "Bad zip (code {$rc})"];
}
$prefix = detect_common_prefix($zip);
for ($i = 0, $n = $zip->numFiles; $i < $n; $i++) {
$name = $zip->getNameIndex($i);
if ($name === false) continue;
$rel = $prefix !== '' && str_starts_with($name, $prefix) ? substr($name, strlen($prefix)) : $name;
if ($rel === '' || str_contains($rel, '..')) continue;
$target = $dest . '/' . $rel;
if (substr($name, -1) === '/') { ensure_dir($target); continue; }
ensure_dir(dirname($target));
$stream = $zip->getStream($name);
if ($stream === false) continue;
$out = @fopen($target, 'wb');
if ($out) {
while (!feof($stream)) { $c = fread($stream, 1 << 16); if ($c === false) break; fwrite($out, $c); }
fclose($out);
}
fclose($stream);
}
$zip->close();
@unlink($zipPath);
// Ensure the replacement is enabled (clears any inherited disable).
$configFile = $webroot . '/' . $stageDir . '/user/config/plugins/' . $slug . '.yaml';
if (is_file($configFile)) {
$raw = (string) @file_get_contents($configFile);
if (preg_match('/^enabled:\s*(false|0)/m', $raw)) {
@file_put_contents($configFile, preg_replace('/^enabled:\s*(false|0)/m', 'enabled: true', $raw, 1));
}
}
return ['ok' => true, 'msg' => 'installed', 'version' => $resolved['version'], 'source' => $resolved['source']];
}
/**
* Generic "download a plugin/theme zip and extract it into stage_dir/user/{kind}/{slug}/".
* Used by the auto-update path (GPM-listed release zips) — same logic as the
* GitHub-replacement path but parameterized over any source zip URL.
*/
function mg_install_zip_url(string $webroot, string $stageDir, string $kind, string $slug, string $url): array
{
$tmp = $webroot . '/tmp';
ensure_dir($tmp);
$safeKind = preg_replace('/[^a-z0-9]/i', '', $kind) ?: 'plugins';
$safeSlug = preg_replace('/[^a-z0-9\-]/i', '_', $slug);
$zipPath = $tmp . '/update-' . $safeKind . '-' . $safeSlug . '.zip';
$ctx = mg_http_context([
'timeout' => 25,
'header' => "User-Agent: grav-migrate-wizard/1.0\r\n",
'ignore_errors' => false,
]);
$bytes = @file_get_contents($url, false, $ctx);
if ($bytes === false || strlen($bytes) < 1024) {
return ['ok' => false, 'msg' => "Could not download {$slug} from {$url}"];
}
@file_put_contents($zipPath, $bytes);
$dest = $webroot . '/' . $stageDir . '/user/' . $kind . '/' . $slug;
if (is_dir($dest)) remove_dir($dest);
ensure_dir($dest);
$zip = new ZipArchive();
if (($rc = $zip->open($zipPath)) !== true) {
@unlink($zipPath);
return ['ok' => false, 'msg' => "Bad zip for {$slug} (code {$rc})"];
}
$prefix = detect_common_prefix($zip);
for ($i = 0, $n = $zip->numFiles; $i < $n; $i++) {
$name = $zip->getNameIndex($i);
if ($name === false) continue;
$rel = $prefix !== '' && str_starts_with($name, $prefix) ? substr($name, strlen($prefix)) : $name;
if ($rel === '' || str_contains($rel, '..')) continue;
$target = $dest . '/' . $rel;
if (substr($name, -1) === '/') { ensure_dir($target); continue; }
ensure_dir(dirname($target));
$stream = $zip->getStream($name);
if ($stream === false) continue;
$out = @fopen($target, 'wb');
if ($out) {
while (!feof($stream)) { $c = fread($stream, 1 << 16); if ($c === false) break; fwrite($out, $c); }
fclose($out);
}
fclose($stream);
}
$zip->close();
@unlink($zipPath);
return ['ok' => true, 'msg' => 'installed'];
}
/**
* Ask GitHub for the latest tagged release of /. Three-tier:
* 1. /releases/latest — full releases only (excludes pre-releases)
* 2. /releases — full list, picks newest non-draft (incl. betas)
* 3. default-branch HEAD — last-resort sentinel marked version "1.0.0"
*
* Tier 2 is what catches plugins like admin2/api during the 2.0 beta line:
* they only ship pre-release tags, /releases/latest silently 404s for those,
* and without this tier the fallback would install untagged HEAD — which is
* exactly the surprise we want to avoid for replacement installs.
*
* Returns:
* ['zipball' => URL, 'version' => string, 'source' => 'release'|'default-branch']
*/
function mg_github_resolve_download(string $repo): array
{
$ctx = mg_http_context([
'timeout' => 6,
'header' => "User-Agent: grav-migrate-wizard/1.0\r\nAccept: application/vnd.github+json\r\n",
'ignore_errors' => true,
]);
// Tier 1: /releases/latest (excludes pre-releases by GitHub's design).
$raw = @file_get_contents("https://api.github.com/repos/{$repo}/releases/latest", false, $ctx);
if ($raw !== false) {
$data = json_decode($raw, true);
if (is_array($data) && !empty($data['zipball_url'])) {
$tag = (string) ($data['tag_name'] ?? '1.0.0');
return [
'zipball' => $data['zipball_url'],
'version' => ltrim($tag, 'v'),
'source' => 'release',
];
}
}
// Tier 2: /releases — newest non-draft entry, pre-releases included.
// GitHub orders the list by created_at desc by default, so the first
// non-draft release is the newest tag.
$raw = @file_get_contents("https://api.github.com/repos/{$repo}/releases?per_page=10", false, $ctx);
if ($raw !== false) {
$list = json_decode($raw, true);
if (is_array($list)) {
foreach ($list as $rel) {
if (!is_array($rel) || !empty($rel['draft'])) continue;
if (empty($rel['zipball_url'])) continue;
$tag = (string) ($rel['tag_name'] ?? '1.0.0');
return [
'zipball' => $rel['zipball_url'],
'version' => ltrim($tag, 'v'),
'source' => 'release',
];
}
}
}
// Tier 3: default-branch HEAD.
return [
'zipball' => "https://api.github.com/repos/{$repo}/zipball",
'version' => '1.0.0',
'source' => 'default-branch',
];
}
/**
* Locate a CLI php binary suitable for running scripts as argv[1].
*
* PHP_BINARY is unreliable from a SAPI context — it can be empty, or it can
* point at php-fpm (which is a daemon, not a script runner). We try, in
* order:
* 1. Sibling /bin/php next to PHP_BINARY's grandparent dir — handles the
* Homebrew layout where PHP_BINARY is `…/sbin/php-fpm` and the matching
* CLI lives at `…/bin/php`.
* 2. PHP_BINARY itself if it's executable and doesn't look like FPM/CGI.
* 3. Common system locations: /usr/local/bin/php, /opt/homebrew/bin/php,
* /usr/bin/php.
* 4. Bare "php" — proc_open's argv form does PATH lookup when the program
* name has no slash, so this is the last-resort fallback.
*
* Returns null only if every option fails the executable check (rare).
*/
function mg_find_php_cli(): ?string
{
$bin = (defined('PHP_BINARY') && is_string(PHP_BINARY)) ? PHP_BINARY : '';
if ($bin !== '' && is_executable($bin)) {
$base = basename($bin);
// Sibling bin/php — works for Homebrew sbin/php-fpm → bin/php
$sibling = dirname(dirname($bin)) . '/bin/php';
if (is_executable($sibling) && basename($sibling) === 'php') {
return $sibling;
}
// PHP_BINARY itself, if it's plain `php` (not -fpm, -cgi, etc.)
if ($base === 'php' || preg_match('/^php-?[0-9]/', $base)) {
return $bin;
}
}
foreach (['/usr/local/bin/php', '/opt/homebrew/bin/php', '/usr/bin/php'] as $candidate) {
if (is_executable($candidate)) return $candidate;
}
// Rely on PATH — proc_open argv form resolves bare names against PATH.
return 'php';
}
/**
* Run the staged Grav 2.0's `bin/gpm update` to bring installed plugins
* (or themes) up to their latest compatible versions on the 2.0 channel.
*
* $kind is 'plugins' or 'themes'.
* $excludeSlugs is the list of slugs to leave alone — typically symlinked
* slugs that we don't want gpm to overwrite (gpm replaces plugin dirs
* wholesale; running update on a symlinked dir would unlink the symlink
* and extract a fresh zip in its place, silently breaking dev wiring).
* If non-empty, we enumerate the rest as positional args (gpm interprets
* positional args as an allowlist).
*
* Streams gpm's stdout line-by-line into $progress so the wizard UI can
* render a moving status (gpm prints one or two lines per package).
*
* Returns ['ok', 'msg', 'updated' => [slug => version], 'skipped' => [...],
* 'output' => string].
*/
function mg_gpm_update(string $stageRoot, string $kind, array $excludeSlugs, ?callable $progress): array
{
if (!in_array($kind, ['plugins', 'themes'], true)) {
return ['ok' => false, 'msg' => "invalid kind: {$kind}", 'updated' => [], 'skipped' => [], 'output' => ''];
}
// Defensive — gpm update of 50+ packages routinely runs longer than the
// default 30s execution limit, even though mg_stream_setup already
// disables it for streaming pages.
@set_time_limit(0);
// popen / proc_open availability — some shared hosts disable them.
$disabled = array_map('trim', explode(',', (string) ini_get('disable_functions')));
if (in_array('proc_open', $disabled, true) || !function_exists('proc_open')) {
return ['ok' => false, 'msg' => 'PHP proc_open() is disabled — cannot invoke staged bin/gpm update', 'updated' => [], 'skipped' => [], 'output' => ''];
}
$bin = $stageRoot . '/bin/gpm';
if (!is_file($bin)) {
return ['ok' => false, 'msg' => "Staged bin/gpm not found at {$bin}", 'updated' => [], 'skipped' => [], 'output' => ''];
}
$kindFlag = $kind === 'themes' ? '-t' : '-p';
// If we have exclusions, build an allowlist of (installed slugs) − (excluded).
$allowSlugs = [];
if (!empty($excludeSlugs)) {
$excludeSet = array_flip($excludeSlugs);
$dir = $stageRoot . '/user/' . $kind;
if (is_dir($dir)) {
foreach (scandir($dir) ?: [] as $slug) {
if ($slug === '.' || $slug === '..' || $slug[0] === '.') continue;
if (isset($excludeSet[$slug])) continue;
if (!is_dir($dir . '/' . $slug)) continue;
$allowSlugs[] = $slug;
}
}
if (empty($allowSlugs)) {
return ['ok' => true, 'msg' => "No {$kind} to update (all installed are symlinked or excluded).", 'updated' => [], 'skipped' => $excludeSlugs, 'output' => ''];
}
}
// Resolve a CLI php binary. PHP_BINARY isn't reliable here — it can be
// empty (some FPM/SAPI builds), or it can point at php-fpm itself
// (Homebrew layouts) which can't run a script as argv[1]. Prefer in
// order: a sibling /bin/php next to PHP_BINARY (Homebrew sbin/php-fpm
// → bin/php), then PHP_BINARY if it looks CLI, then common system
// paths, then bare "php" (PATH lookup).
$phpBin = mg_find_php_cli();
if ($phpBin === null) {
return ['ok' => false, 'msg' => 'Could not locate a CLI php binary to run bin/gpm update', 'updated' => [], 'skipped' => [], 'output' => ''];
}
$argv = [$phpBin, $bin, 'update', $kindFlag, '-y'];
foreach ($allowSlugs as $s) $argv[] = $s;
$desc = [
0 => ['file', '/dev/null', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$proc = @proc_open($argv, $desc, $pipes, $stageRoot);
if (!is_resource($proc)) {
return ['ok' => false, 'msg' => "proc_open failed for {$phpBin} bin/gpm update", 'updated' => [], 'skipped' => [], 'output' => ''];
}
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
// gpm's output (per package) looks like:
// Preparing to install Login OAuth2 [v2.2.6]
// |- Downloading package... 100%
// |- Checking destination... ok
// |- Installing package... ok
// '- Success!
// We track the current package name + version from "Preparing to install"
// and record it in $updated when "Success!" arrives. Sub-steps drive a
// generic "log" tick so the wizard UI moves while gpm is doing its thing.
$updated = [];
$output = '';
$buffers = ['', ''];
$curName = '';
$curVer = '';
while (true) {
$r = [$pipes[1], $pipes[2]];
$w = $e = null;
if (@stream_select($r, $w, $e, 1) === false) break;
foreach ($r as $stream) {
$idx = ($stream === $pipes[1]) ? 0 : 1;
$data = @fread($stream, 4096);
if ($data === false || $data === '') continue;
$buffers[$idx] .= $data;
while (($nl = strpos($buffers[$idx], "\n")) !== false) {
$line = rtrim(substr($buffers[$idx], 0, $nl), "\r");
$buffers[$idx] = substr($buffers[$idx], $nl + 1);
if ($line === '') continue;
$output .= $line . "\n";
// Strip ANSI colour escapes.
$clean = preg_replace('/\e\[[0-9;]*m/', '', $line);
if (preg_match('/^Preparing to install\s+(.+?)\s+\[v?([^\]]+)\]\s*$/', $clean, $m)) {
$curName = trim($m[1]);
$curVer = trim($m[2]);
if ($progress) $progress(['phase' => 'start', 'entry' => "gpm/{$kind}/{$curName}", 'reason' => "v{$curVer}"]);
} elseif (preg_match("/^\s*'-\s*Success!\s*$/", $clean)) {
if ($curName !== '') {
$updated[$curName] = $curVer;
if ($progress) $progress(['phase' => 'done-entry', 'entry' => "gpm/{$kind}/{$curName}", 'reason' => "v{$curVer}"]);
}
$curName = '';
$curVer = '';
} elseif (preg_match('/^\s*\|-\s*(Downloading package|Checking destination|Installing package)/', $clean, $m)) {
if ($progress && $curName !== '') {
$progress(['phase' => 'log', 'entry' => "gpm/{$kind}/{$curName}", 'reason' => rtrim($m[1], '.')]);
}
} elseif ($progress) {
// Anything else (errors, "Nothing to update", header lines)
// — surface as a log tick so the wizard UI keeps moving.
$progress(['phase' => 'log', 'entry' => "gpm/{$kind}", 'reason' => substr($clean, 0, 200)]);
}
}
}
$status = @proc_get_status($proc);
if (is_array($status) && !$status['running'] && feof($pipes[1]) && feof($pipes[2])) break;
}
foreach ($buffers as $rem) if ($rem !== '') $output .= $rem . "\n";
@fclose($pipes[1]);
@fclose($pipes[2]);
$code = proc_close($proc);
return [
'ok' => $code === 0,
'msg' => $code === 0
? sprintf('gpm update %s: %d package(s) touched', $kind, count($updated))
: "gpm update {$kind} exited {$code}",
'updated' => $updated,
'skipped' => $excludeSlugs,
'output' => $output,
];
}
/**
* Copy the entire source user/ tree into the staged install verbatim.
* Top-level dotfiles/dotdirs (.git, .DS_Store, editor backups) are skipped
* as filesystem cruft. Symlinks (top-level and mid-tree) are preserved as
* symlinks so dev environments with linked plugin/theme clones keep their
* wiring in the staged tree. Downstream phases
* mutate the staged tree in place (plugin policy, auto-updates, account
* perm mirror). Appends skipped entries to $copySkipped with a reason
* tag, and appends successfully-copied top-level names to $copiedEntries.
*/
function mg_bulk_copy_user(string $srcUser, string $dstUser, int &$copied, ?callable $progress, array &$copySkipped, array &$copiedEntries): void
{
ensure_dir($dstUser);
foreach (scandir($srcUser) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') continue;
if ($entry[0] === '.') {
$copySkipped[] = "{$entry} (dotfile/dotdir)";
continue;
}
$src = $srcUser . '/' . $entry;
$dst = $dstUser . '/' . $entry;
if (is_link($src)) {
// Preserve top-level symlinks (e.g. user/plugins itself being a
// symlink — unusual but possible in shared multi-site setups).
// copy_tree handles deeper symlinks the same way.
$target = @readlink($src);
if (is_string($target) && $target !== '' && @symlink($target, $dst)) {
$copiedEntries[] = $entry;
if ($progress) $progress(['phase' => 'done-entry', 'entry' => "user/{$entry}", 'reason' => 'symlink preserved', 'copied' => $copied]);
} else {
$copySkipped[] = "{$entry} (symlink, could not preserve)";
if ($progress) $progress(['phase' => 'skip', 'entry' => "user/{$entry}", 'reason' => 'symlink (could not preserve)', 'copied' => $copied]);
}
continue;
}
if ($progress) $progress(['phase' => 'start', 'entry' => "user/{$entry}", 'copied' => $copied]);
if (is_dir($src)) {
copy_tree($src, $dst, static function (string $rel) use (&$copied, $entry, $progress) {
$copied++;
if ($progress && $copied % 200 === 0) {
$progress(['phase' => 'copy', 'entry' => "user/{$entry}", 'file' => $rel, 'copied' => $copied]);
}
});
$copiedEntries[] = $entry;
} elseif (is_file($src)) {
ensure_dir(dirname($dst));
@copy($src, $dst);
$copied++;
$copiedEntries[] = $entry;
}
if ($progress) $progress(['phase' => 'done-entry', 'entry' => "user/{$entry}", 'copied' => $copied]);
}
}
/**
* Apply the chosen compat policy to the already-copied staged user/plugins/.
* - superseded (admin → admin2, etc.): remove the old slug dir; the
* replacement is installed separately via mg_install_replacements.
* - skip policy + incompatible: remove the slug dir entirely.
* - disable policy + incompatible: write user/config/plugins/.yaml
* with enabled: false so Grav loads the config but ignores the plugin.
* Themes are NOT touched here — they're always kept, and Grav 2.0's Twig 3
* compat layer handles most existing themes at runtime. Returns
* ['skipped' => [...], 'disabled' => [...]] (bare slug names; caller
* prefixes with "plugins/" for summary).
*/
/**
* Apply the chosen compat policy to staged plugins, using POST-UPGRADE
* verdicts (mg_rescan_staged result). Symlinked dirs are left alone —
* dev environments choose their own versions and we don't second-guess
* them. Supersedes are handled in their own phase before this runs.
*
* Returns ['skipped', 'disabled', 'force_included'].
*/
function mg_apply_plugin_policy(string $stageRoot, array $postScan, string $policy, string $mode, ?callable $progress): array
{
$skipped = [];
$disabled = [];
$forceIncluded = []; // slugs that strict would have flagged as incompatible
// but the chosen mode promoted to compatible
$pluginDir = $stageRoot . '/user/plugins';
if (!is_dir($pluginDir)) {
return ['skipped' => $skipped, 'disabled' => $disabled, 'force_included' => $forceIncluded];
}
$verdicts = $postScan['plugins'] ?? [];
foreach (scandir($pluginDir) ?: [] as $slug) {
if ($slug === '.' || $slug === '..') continue;
if ($slug[0] === '.') continue;
$slugDir = $pluginDir . '/' . $slug;
// Symlinked plugins: dev clones, leave alone.
if (is_link($slugDir)) continue;
if (!is_dir($slugDir)) continue;
$label = "plugins/{$slug}";
$rawVerdict = $verdicts[$slug] ?? ['status' => 'unknown'];
$rawStatus = (string) ($rawVerdict['status'] ?? 'unknown');
$effective = mg_effective_status($rawVerdict, $mode);
// Track plugins that strict mode would mark incompatible but the
// chosen mode is force-including. Used for the test-step report.
if ($effective === 'compatible' && $rawStatus !== 'compatible' && $rawStatus !== 'needs_update') {
$forceIncluded[] = [
'slug' => $slug,
'reason' => (string) ($rawVerdict['reason'] ?? 'inferred 1.7-only'),
'source' => (string) ($rawVerdict['source'] ?? 'default'),
];
}
if ($effective === 'compatible' || $effective === 'needs_update') continue;
if ($policy === 'skip') {
remove_dir($slugDir);
$skipped[] = $slug;
if ($progress) $progress(['phase' => 'skip', 'entry' => $label, 'reason' => 'incompatible (skip policy)']);
} elseif ($policy === 'disable') {
mg_write_plugin_disable($stageRoot, $slug);
$disabled[] = $slug;
if ($progress) $progress(['phase' => 'done-entry', 'entry' => $label, 'reason' => 'incompatible (disabled)']);
}
}
return ['skipped' => $skipped, 'disabled' => $disabled, 'force_included' => $forceIncluded];
}
/**
* Re-scan compatibility on the STAGED tree after the gpm upgrade pass has
* run. Returns a fresh ['plugins' => [slug => verdict], 'themes' => [...]]
* map, where verdicts reflect the post-upgrade blueprint state — so a
* plugin that was 1.7-only on disk but got upgraded to a 2.0-compatible
* release reads as `compatible` here.
*
* Reuses the source scan's cached curated registry + gpm index so we
* don't pay another network round-trip.
*/
function mg_rescan_staged(string $stagedUserDir, array $sourceScan): array
{
$curated = $sourceScan['curated'] ?? null;
$gpm = $sourceScan['gpm'] ?? ['plugins' => null, 'themes' => null];
return [
'plugins' => mg_scan_category($stagedUserDir . '/plugins', 'plugins', $curated, $gpm['plugins'] ?? null),
'themes' => mg_scan_category($stagedUserDir . '/themes', 'themes', $curated, $gpm['themes'] ?? null),
];
}
/**
* Remove staged dirs for slugs the curated registry has marked as
* superseded by a different package (admin → admin2, etc.). Runs AFTER
* the gpm update pass — keeping the old slug on disk during gpm prevents
* its dependents (data-manager etc. which declare `admin` as a dep) from
* triggering a missing-dep reinstall. The slug is also excluded from
* gpm's update allowlist so gpm leaves it alone version-wise. The actual
* replacement is installed later by mg_install_replacements. Idempotent.
*/
function mg_handle_supersedes(string $stageRoot, array $superseded, ?callable $progress): array
{
$skipped = [];
foreach ($superseded as $label => $replSlug) {
$parts = explode('/', $label, 2);
if (count($parts) !== 2) continue;
[$kind, $slug] = $parts;
if (!in_array($kind, ['plugins', 'themes'], true)) continue;
$path = $stageRoot . '/user/' . $kind . '/' . $slug;
if (!file_exists($path) && !is_link($path)) continue;
if (is_link($path)) {
@unlink($path);
} else {
remove_dir($path);
}
// Already kind/slug-prefixed (e.g. "plugins/admin (superseded by admin2)")
// so the caller can merge straight into the summary skipped[] list.
$skipped[] = $label . " (superseded by {$replSlug})";
if ($progress) $progress(['phase' => 'skip', 'entry' => $label, 'reason' => "superseded by {$replSlug}"]);
}
return ['skipped' => $skipped];
}
function mg_write_plugin_disable(string $stageRoot, string $slug): void
{
$dir = $stageRoot . '/user/config/plugins';
ensure_dir($dir);
$file = $dir . '/' . $slug . '.yaml';
// Preserve any existing config from the imported 1.x yaml; just flip enabled.
$existing = is_file($file) ? @file_get_contents($file) : '';
if (is_string($existing) && $existing !== '' && preg_match('/^enabled:\s*(true|false|0|1)\s*$/m', $existing)) {
$patched = preg_replace('/^enabled:\s*(true|false|0|1)\s*$/m', 'enabled: false', $existing, 1);
@file_put_contents($file, $patched);
return;
}
@file_put_contents($file, "enabled: false\n" . ($existing ?: ''));
}
// ─────────────────────────────────────────────────────────────────────────────
// Compatibility scan
// ─────────────────────────────────────────────────────────────────────────────
/**
* Run (or reuse cached) compatibility scan for plugins + themes in the source
* install. Caches the curated registry + verdicts in .migrating.compat_scan
* so the UI renders consistently with what do_import() will actually do.
*/
function mg_compat_scan_cached(string $webroot, array &$flag): array
{
$cached = $flag['compat_scan'] ?? null;
if (is_array($cached) && (time() - (int)($cached['at'] ?? 0)) < MG_COMPAT_TTL) {
return $cached;
}
$remoteCurated = mg_fetch_curated();
// Always merge the baseline (admin → admin2 etc.) under the remote
// response so the canonical core supersedes apply even if the remote
// registry is offline or has been pruned. The 'source' field below still
// reflects whether the remote actually responded.
$curated = mg_apply_baseline_registry($remoteCurated);
// Use the staged Grav 2.0 version (and current PHP) so the catalog
// returns each plugin's newest release that's actually compatible with
// the migration target. Falls back to '2.0.0' when the zip hasn't been
// cracked yet — still a 2.x query, just less specific.
$stagedGrav = mg_staged_zip_version($webroot, $flag) ?? '';
$php = PHP_VERSION;
$gpm = [
'plugins' => mg_fetch_gpm_index('plugins', $stagedGrav, $php),
'themes' => mg_fetch_gpm_index('themes', $stagedGrav, $php),
];
$scan = [
'at' => time(),
'source' => $remoteCurated !== null ? 'remote' : 'offline',
'gpm' => $gpm, // Cached so install paths can prefer GPM URLs over github_repo fallback.
'curated' => $curated, // Cached so mg_rescan_staged() can re-verdict the post-upgrade staged tree without a second fetch.
'plugins' => mg_scan_category($webroot . '/user/plugins', 'plugins', $curated, $gpm['plugins']),
'themes' => mg_scan_category($webroot . '/user/themes', 'themes', $curated, $gpm['themes']),
];
// Resolve install source/version info for every pending replacement
// (replaced_by target + its transitive `requires:`) so the UI shows
// matching info to what the install path will actually do.
$scan['github_versions'] = mg_resolve_pending_versions($scan, $curated, $gpm);
// No separate "pending update" pre-flight: pending updates are now
// derived from the same v= + php= GPM query that
// mg_fetch_gpm_index() already ran. mg_resolve_update() (called by
// mg_scan_category) writes the result into $verdict['update'], and the
// UI badge falls back to it. Querying the source 1.7 install's bin/gpm
// would filter the catalog with v=1.7.x and miss the 2.0-line upgrades
// that are the whole point of the migration.
$flag['compat_scan'] = $scan;
save_flag($webroot . '/.migrating', $flag);
return $scan;
}
/**
* Walk the scan's pending replacements (replaced_by + requires transitively)
* and call GitHub's releases API for each repo. Returns a slug → [version,
* source] map. Cached inside the compat scan so we only hit GitHub once per
* 15-minute TTL.
*/
function mg_resolve_pending_versions(array $scan, ?array $curated, array $gpm = []): array
{
if ($curated === null) return [];
$queue = [];
foreach (['plugins', 'themes'] as $kind) {
foreach ($scan[$kind] ?? [] as $v) {
$repl = $v['replaced_by'] ?? null;
if ($repl) $queue[$repl] = true;
}
}
// Transitively include requires
$stack = array_keys($queue);
while ($stack) {
$slug = array_shift($stack);
$entry = $curated['plugins'][$slug] ?? $curated['themes'][$slug] ?? null;
if (!is_array($entry) || empty($entry['requires'])) continue;
foreach ((array) $entry['requires'] as $req) {
if (!isset($queue[$req])) {
$queue[$req] = true;
$stack[] = $req;
}
}
}
$out = [];
foreach (array_keys($queue) as $slug) {
// Prefer GPM (matches what mg_install_replacements actually does).
$gpmEntry = $gpm['plugins'][$slug] ?? $gpm['themes'][$slug] ?? null;
if (is_array($gpmEntry) && !empty($gpmEntry['version'])) {
$out[$slug] = [
'version' => (string) $gpmEntry['version'],
'source' => 'gpm',
];
continue;
}
// Fall back to GitHub via curated github_repo.
$entry = $curated['plugins'][$slug] ?? $curated['themes'][$slug] ?? null;
if (!is_array($entry) || empty($entry['github_repo'])) continue;
$info = mg_github_resolve_download($entry['github_repo']);
$out[$slug] = ['version' => $info['version'], 'source' => $info['source']];
}
return $out;
}
function mg_scan_category(string $dir, string $kind, ?array $curated, ?array $gpmIndex = null): array
{
$out = [];
if (!is_dir($dir)) return $out;
foreach (scandir($dir) ?: [] as $slug) {
if ($slug === '.' || $slug === '..') continue;
$path = $dir . '/' . $slug;
if (!is_dir($path)) continue;
$bp = mg_read_blueprint($path . '/blueprints.yaml');
$installedVersion = (string) ($bp['version'] ?? '');
$verdict = mg_resolve_compat($slug, $installedVersion, $bp, $curated[$kind] ?? []);
$verdict['installed_version'] = $installedVersion;
// Display name from the blueprint — used when matching gpm output back
// to slug ("Preparing to install Login OAuth2 [v2.2.6]" → login-oauth2).
$verdict['name'] = (string) ($bp['name'] ?? '');
// Annotate with GPM-available update if applicable.
$verdict['update'] = mg_resolve_update($slug, $installedVersion, $verdict, $gpmIndex);
$out[$slug] = $verdict;
}
return $out;
}
/**
* Decide whether the GPM-listed latest version is an "update worth taking"
* during 2.0 migration. Returns ['to' => version, 'download' => url] or null.
*
* Trust model: mg_fetch_gpm_index() pins the catalog query to v= + php=, so any version returned here has already been
* filtered by getgrav.org against the destination's dependency + blueprint
* compatibility constraints. We accept the suggested upgrade unless the
* curated registry explicitly forbids it.
*
* Rules:
* - Only consider plugins/themes with an entry in the GPM index.
* - Skip when latest <= installed (semver compare).
* - Skip when the curated registry explicitly marks the slug as
* 2.0-incompatible (hard block — overrides the GPM filter).
* - When curated supplies minimum_version, skip if GPM latest is below it.
* - Otherwise: trust the GPM filter and offer the update. This is what lets
* us surface major-line upgrades (e.g. page-toc 3.x → 4.x) for plugins
* whose locally-installed blueprint is the legacy 1.x line and has no
* explicit 2.0 compat marker.
*/
function mg_resolve_update(string $slug, string $installedVersion, array $verdict, ?array $gpmIndex): ?array
{
if (!$gpmIndex || !isset($gpmIndex[$slug])) return null;
$entry = $gpmIndex[$slug];
$latest = (string) ($entry['version'] ?? '');
$url = (string) ($entry['download'] ?? '');
if ($latest === '' || $url === '') return null;
if ($installedVersion !== '' && version_compare($latest, $installedVersion, '<=')) return null;
// Hard block: curated registry says this slug isn't 2.0-compatible at all.
// Even if GPM returned a newer version, the registry overrides — typically
// means the maintainer has marked it deprecated or replaced_by another slug.
if (($verdict['source'] ?? '') === 'curated' && ($verdict['status'] ?? '') === 'incompatible') {
return null;
}
// Curated minimum_version still wins when set.
$min = (string) ($verdict['min_version'] ?? '');
if ($min !== '' && version_compare($latest, $min, '<')) return null;
return ['to' => $latest, 'download' => $url];
}
/**
* Fetch the GPM plugins.json or themes.json index and reshape to slug → entry.
* Returns null on any network/parse failure (callers fall back gracefully).
*/
/**
* Fetch a GPM index (plugins.json | themes.json) filtered for the target
* Grav + PHP version. v= and php= match the params Grav core sends from
* AbstractPackageCollection — required for the catalog to return the right
* "latest" per plugin when the plugin has multiple major lines (1.x vs 2.x).
*
* @param string $kind 'plugins' | 'themes'
* @param string $gravVersion Target Grav version (e.g. '2.0.0' or the staged
* zip's version). Empty falls back to '2.0.0' so
* migration always queries the 2.x catalog.
* @param string $phpVersion Target PHP version. Defaults to PHP_VERSION since
* the wizard runs in the same process the staged
* install will run under.
*/
function mg_fetch_gpm_index(string $kind, string $gravVersion = '', string $phpVersion = ''): ?array
{
$base = $kind === 'themes' ? MG_GPM_THEMES_BASE : MG_GPM_PLUGINS_BASE;
$url = $base . '?' . http_build_query([
'v' => $gravVersion !== '' ? $gravVersion : '2.0.0',
'php' => $phpVersion !== '' ? $phpVersion : PHP_VERSION,
'testing' => 1,
]);
$ctx = mg_http_context([
'timeout' => 6,
'ignore_errors' => true,
'header' => "User-Agent: grav-migrate-wizard/1.0\r\n",
]);
$raw = @file_get_contents($url, false, $ctx);
if ($raw === false) return null;
$data = json_decode($raw, true);
if (!is_array($data)) return null;
// The endpoint returns slug-keyed entries already, but the download URL
// ships under `zipball_url` — normalize to the `download` field that
// mg_resolve_update() and mg_install_replacements() both consume, so
// upgrade detection and GPM-preferred replacement installs both work.
foreach ($data as $slug => $entry) {
if (!is_array($entry)) continue;
if (!isset($entry['download']) && !empty($entry['zipball_url'])) {
$data[$slug]['download'] = (string) $entry['zipball_url'];
}
}
return $data;
}
/**
* Fetch the curated compat registry from getgrav.org. Returns null on any
* network/parse failure — callers should run the result through
* mg_apply_baseline_registry() so the canonical core supersedes still apply
* even when the remote registry is unreachable or has been pruned.
*/
function mg_fetch_curated(): ?array
{
$ctx = mg_http_context([
'timeout' => 4,
'ignore_errors' => true,
'header' => "User-Agent: grav-migrate-wizard/1.0\r\n",
]);
$raw = @file_get_contents(MG_COMPAT_URL, false, $ctx);
if ($raw === false) return null;
$data = json_decode($raw, true);
return is_array($data) ? $data : null;
}
/**
* Hardcoded fallback registry for the canonical 2.0 supersedes — admin →
* admin2 (which itself requires api). Merged under the remote registry so
* remote entries always win per-slug; this only fills holes for slugs the
* remote response is silent on. Without this, a wiped or unreachable
* registry produces a migration that copies admin forward as "incompatible"
* and never installs admin2/api.
*/
function mg_baseline_registry(): array
{
return [
'plugins' => [
'admin' => [
'grav' => ['1.7'],
'replaced_by' => 'admin2',
'notes' => 'Replaced by Admin 2.0',
],
'admin2' => [
'grav' => ['2.0'],
'github_repo' => 'getgrav/grav-plugin-admin2',
'requires' => ['api'],
],
'api' => [
'grav' => ['2.0'],
'github_repo' => 'getgrav/grav-plugin-api',
],
],
'themes' => [],
];
}
/**
* Merge mg_baseline_registry() under $remote: any slug $remote already
* defines wins; missing slugs get the baseline entry. Always returns a
* non-null array so install-side lookups have something to work with even
* when the network fetch failed (the offline signal is preserved separately
* by the caller, not on this return value).
*/
function mg_apply_baseline_registry(?array $remote): array
{
$baseline = mg_baseline_registry();
if (!is_array($remote)) return $baseline;
foreach (['plugins', 'themes'] as $kind) {
if (!isset($remote[$kind]) || !is_array($remote[$kind])) {
$remote[$kind] = [];
}
foreach ($baseline[$kind] as $slug => $entry) {
if (!isset($remote[$kind][$slug])) {
$remote[$kind][$slug] = $entry;
}
}
}
return $remote;
}
/**
* Resolve one plugin/theme's compat against (in priority):
* 1. Curated registry entry (authoritative)
* 2. Blueprint `compatibility.grav`
* 3. Inference from blueprint `dependencies.grav` constraint
* 4. Default: ['1.7'] only
*
* Returns: ['status' => 'compatible|incompatible|needs_update|unknown',
* 'reason' => string, 'source' => 'curated|blueprint|inferred|default',
* 'replaced_by' => ?string, 'min_version' => ?string]
*/
function mg_resolve_compat(string $slug, string $installedVersion, array $bp, array $curatedKind): array
{
// 1. Curated registry
if (isset($curatedKind[$slug]) && is_array($curatedKind[$slug])) {
$entry = $curatedKind[$slug];
$gravs = (array) ($entry['grav'] ?? []);
$min = (string) ($entry['minimum_version'] ?? '');
$repl = $entry['replaced_by'] ?? null;
if (!in_array(MG_COMPAT_TARGET, $gravs, true)) {
return [
'status' => 'incompatible',
'reason' => $repl ? "Deprecated on 2.0 — use {$repl}" : ($entry['notes'] ?? '1.x-only'),
'source' => 'curated',
'replaced_by' => $repl,
'min_version' => $min ?: null,
];
}
if ($min !== '' && $installedVersion !== '' && version_compare($installedVersion, $min, '<')) {
return [
'status' => 'needs_update',
'reason' => "Requires v{$min}+ for 2.0 (installed {$installedVersion})",
'source' => 'curated',
'replaced_by' => null,
'min_version' => $min,
];
}
return [
'status' => 'compatible',
'reason' => $entry['notes'] ?? 'Curated 2.0-compatible',
'source' => 'curated',
'replaced_by' => null,
'min_version' => $min ?: null,
];
}
// 2. Blueprint explicit compatibility
$bpCompat = $bp['compatibility']['grav'] ?? null;
if (is_array($bpCompat)) {
$ok = in_array(MG_COMPAT_TARGET, array_map('strval', $bpCompat), true);
return [
'status' => $ok ? 'compatible' : 'incompatible',
'reason' => $ok ? 'Blueprint declares 2.0 support' : 'Blueprint lists only ' . implode(',', $bpCompat),
'source' => 'blueprint',
'replaced_by' => null,
'min_version' => null,
];
}
// 3. Infer from dependencies.grav
$inferred = mg_infer_compat_from_deps($bp['dependencies'] ?? []);
if (in_array(MG_COMPAT_TARGET, $inferred, true)) {
return [
'status' => 'compatible',
'reason' => "Inferred from dependencies.grav >= " . MG_COMPAT_TARGET,
'source' => 'inferred',
'replaced_by' => null,
'min_version' => null,
];
}
// 4. Default
return [
'status' => 'incompatible',
'reason' => 'Assumed 1.7-only (no explicit 2.0 compatibility)',
'source' => 'default',
'replaced_by' => null,
'min_version' => null,
];
}
/**
* Map a raw compat verdict (from mg_resolve_compat) to an effective status
* given the user's selected mode. Pure function — same input always yields
* same output. The raw scan is mode-agnostic so it can be cached once and
* reinterpreted as the user toggles modes during a re-run.
*
* Rules:
* strict — return verdict status as-is (current default behavior)
* permissive — promote default-inferred incompatibles to compatible, but
* respect curated explicit 1.x-only entries and supersedes
* test — promote everything to compatible, EXCEPT supersedes (those
* still get removed so the replacement plugin can be installed
* cleanly — installing both old + new would conflict)
*
* needs_update preserved across modes (it's a real signal — the user probably
* wants to know they're carrying forward an outdated version).
*/
function mg_effective_status(array $verdict, string $mode): string
{
$raw = (string) ($verdict['status'] ?? 'incompatible');
if ($raw === 'compatible' || $raw === 'needs_update') return $raw;
// Supersedes are always honored — Phase 4 will install the replacement.
if (!empty($verdict['replaced_by'])) return 'incompatible';
if ($mode === MG_MODE_TEST) return 'compatible';
if ($mode === MG_MODE_PERMISSIVE) {
// Promote inferred/default incompatibles. Leave curated explicit
// 1.x-only entries alone — those have a real reason behind them.
$source = (string) ($verdict['source'] ?? 'default');
return $source === 'curated' ? 'incompatible' : 'compatible';
}
return 'incompatible';
}
/**
* Port of Grav core's Local/Package::inferCompatibility for standalone use.
*/
function mg_infer_compat_from_deps(array $dependencies): array
{
foreach ($dependencies as $dep) {
if (!is_array($dep) || ($dep['name'] ?? '') !== 'grav') continue;
$version = (string) ($dep['version'] ?? '');
if (!preg_match('/(\d+\.\d+(?:\.\d+)?)/', $version, $m)) continue;
if (version_compare($m[1], '2.0', '>=')) return ['2.0'];
if (version_compare($m[1], '1.8', '>=')) return ['1.8'];
return ['1.7'];
}
return ['1.7'];
}
/**
* Read a plugin/theme blueprints.yaml into the small array structure we need.
* Uses ext-yaml when available; falls back to a narrow hand parser that
* only understands: version, slug, compatibility.grav, dependencies (list
* of {name, version} maps). Sufficient for all core Grav blueprints.
*/
function mg_read_blueprint(string $path): array
{
if (!is_file($path)) return [];
if (function_exists('yaml_parse_file')) {
$data = @yaml_parse_file($path);
if (is_array($data)) return $data;
}
$raw = @file_get_contents($path);
if ($raw === false) return [];
$out = [];
if (preg_match('/^version:\s*["\']?([\w.\-+]+)["\']?/m', $raw, $m)) $out['version'] = $m[1];
if (preg_match('/^slug:\s*["\']?([\w.\-]+)["\']?/m', $raw, $m)) $out['slug'] = $m[1];
// compatibility.grav: ['1.7','2.0'] — one-line flow style
if (preg_match('/^compatibility:\s*\n(?:\s+\S.*\n)*?\s+grav:\s*\[([^\]]+)\]/m', $raw, $m)) {
$out['compatibility']['grav'] = array_map(static fn($s) => trim($s, " \t\"'"), explode(',', $m[1]));
}
// dependencies: - { name: grav, version: '>=1.7.0' }
if (preg_match_all('/-\s*\{\s*name:\s*([\w.\-]+)\s*,\s*version:\s*["\']?([^"\'}\s]+)["\']?\s*\}/m', $raw, $ms, PREG_SET_ORDER)) {
$out['dependencies'] = [];
foreach ($ms as $dep) {
$out['dependencies'][] = ['name' => $dep[1], 'version' => $dep[2]];
}
}
return $out;
}
/**
* Recursively copy a directory tree. Invokes $onFile for each file copied
* with the relative path under $src. Returns null on success, or an error
* string on the first failure.
*/
function copy_tree(string $src, string $dst, callable $onFile, string $prefix = ''): ?string
{
if (!is_dir($dst) && !@mkdir($dst, 0755, true) && !is_dir($dst)) {
return "Could not create {$dst}";
}
$dh = @opendir($src);
if ($dh === false) return "Could not open {$src}";
while (($entry = readdir($dh)) !== false) {
if ($entry === '.' || $entry === '..') continue;
$srcPath = $src . '/' . $entry;
$dstPath = $dst . '/' . $entry;
$rel = $prefix === '' ? $entry : $prefix . '/' . $entry;
if (is_link($srcPath)) {
// Preserve the symlink as-is. Common in dev environments where
// user/plugins/ or user/themes/ point to a sibling
// working clone — the staged tree should keep the same wiring so
// those clones remain live during testing. The downstream gpm
// update phase detects symlinked slugs and skips updating them
// (otherwise gpm would unlink the symlink and overwrite with a
// fresh zip).
$target = @readlink($srcPath);
if (is_string($target) && $target !== '') {
@symlink($target, $dstPath);
}
continue;
}
if (is_dir($srcPath)) {
$err = copy_tree($srcPath, $dstPath, $onFile, $rel);
if ($err !== null) { closedir($dh); return $err; }
} else {
if (!@copy($srcPath, $dstPath)) {
closedir($dh);
return "Could not copy {$rel}";
}
$onFile($rel);
}
}
closedir($dh);
return null;
}
function ensure_dir(string $path): void
{
if (!is_dir($path)) @mkdir($path, 0755, true);
}
/**
* Is a PHP function unavailable to us — either listed in disable_functions or
* genuinely absent? Mirrors the disable_functions parse used by mg_gpm_update;
* function_exists() alone is unreliable for disabled (vs. nonexistent) funcs
* across PHP versions, so we check both.
*/
function mg_fn_disabled(string $fn): bool
{
static $disabled = null;
if ($disabled === null) {
$disabled = array_map('trim', explode(',', (string) ini_get('disable_functions')));
}
return in_array($fn, $disabled, true) || !function_exists($fn);
}
function mg_stream_setup(string $title, string $subtitle): void
{
// Streaming steps are inherently long-running — bulk copies, downloads,
// and gpm subprocess invocations routinely exceed PHP's default 30s
// max_execution_time. Disable the limit and ignore user abort so a
// user accidentally navigating away doesn't strand the migration in a
// half-applied state.
@set_time_limit(0);
@ignore_user_abort(true);
@ini_set('zlib.output_compression', '0');
@ini_set('output_buffering', '0');
@ini_set('implicit_flush', '1');
while (ob_get_level() > 0) @ob_end_flush();
@ob_implicit_flush(true);
header('Content-Type: text/html; charset=utf-8');
header('X-Accel-Buffering: no');
header('Cache-Control: no-cache, no-store, must-revalidate');
page_header($title);
echo '
';
page_footer();
}
function stream_plugins_themes_page(string $webroot, array $flag, string $token, array $options): void
{
mg_stream_setup('Migrating user/ …', 'Bulk-copying your entire user/ directory (pages, data, config, languages, accounts, plugins, themes, and any custom folders) from your live site into the staged Grav 2.0, then applying plugin transforms: incompatible plugins follow your chosen policy, 2.0-compatible plugins are updated to latest, and replacements (admin2, api, etc.) are installed. Themes are kept as-is — Grav 2.0\'s default Twig 3 compatibility mode aims to keep existing themes rendering.');
$result = do_plugins_themes($webroot, $flag, $options, mg_stream_progress_cb());
mg_stream_finish($result, $token, 'plugins_done');
}
function stream_accounts_page(string $webroot, array $flag, string $token, array $options): void
{
mg_stream_setup('Processing accounts…', 'Account yamls were copied during Step 2' . (empty($options['migrate_perms']) ? '; confirming accounts step and moving on.' : '. Mirroring admin.* permissions to api.* in place so the same users keep full access on Admin 2.0.'));
$result = do_accounts($webroot, $flag, $options, mg_stream_progress_cb());
mg_stream_finish($result, $token, 'accounts_done');
}
function stream_content_page(string $webroot, array $flag, string $token): void
{
mg_stream_setup('Content confirmation…', 'All user/ content (pages, data, config, languages, custom folders) was bulk-copied during Step 2. Marking content complete and advancing to testing.');
$result = do_content($webroot, $flag, mg_stream_progress_cb());
mg_stream_finish($result, $token, 'content_done');
}
function stream_promote_page(string $webroot, array $flag, string $token): void
{
mg_stream_setup('Promoting Grav 2.0 to live…', 'Moving the existing install into a timestamped backup and swapping the staged Grav 2.0 into the webroot. Keep this tab open until it completes.');
$result = do_promote($webroot, $flag, mg_stream_progress_cb());
if ($result['ok']) {
// After promote, .migrating / migrate.php are inside the backup dir —
// the wizard's state is effectively gone, so redirect to the new
// live webroot instead of trying to re-render a wizard page.
$base = base_path_from_script();
echo '
Migration complete. ' . htmlspecialchars($result['msg']) . ' — redirecting to the new install…
';
echo '';
echo '';
} else {
// Render the failure message with newlines preserved so a multi-line
// lock list (one path per row) reads cleanly. nl2br only inserts
// tags; the underlying text is still htmlspecialchars'd.
$msg = nl2br(htmlspecialchars((string) $result['msg']));
echo '
Promote failed. ' . $msg . '
';
// Two recovery hints, scoped to where Phase 2 left things:
// - "locked" populated → Phase 2 aborted via the pre-flight check.
// The webroot is INTACT. The backup zip was created in Phase 1
// and is sitting in the stage's backup/ dir, but the user
// doesn't need to touch it — they free the locks and click
// Promote again.
// - locked absent → Phase 1 or Phase 3 failure, or a Phase 2
// failure that snuck past the pre-flight (race window between
// the probe and the actual unlink, or non-Windows). The webroot
// may be partially destroyed; the backup zip is the only safe
// way back.
$stageDir = trim((string)($flag['stage_dir'] ?? 'grav-2'), '/');
$backupGlob = $webroot . '/' . $stageDir . '/backup/migration-backup-*.zip';
$backups = glob($backupGlob) ?: [];
$latestZip = $backups !== [] ? basename(end($backups)) : null;
if (!empty($result['locked'])) {
echo '
What to do. The destructive phase did not start — your 1.x site is unchanged. Close the listed editor/git GUI/terminal, then re-open this wizard and click Promote again. No backup recovery is needed.
';
} else {
echo '
If your webroot looks empty or partial: a backup zip was created in Phase 1 before the failure. Extract it back over your webroot to restore your 1.x site, then you can retry the wizard from scratch.';
if ($latestZip !== null) {
echo '
Windows: right-click the zip in File Explorer → Extract All… and pick the webroot. 7-Zip and WinRAR also work.
';
echo '
macOS / Linux:unzip /path/to/' . htmlspecialchars((string) ($latestZip ?? 'migration-backup-*.zip')) . ' -d /path/to/webroot, or double-click in Finder to extract via Archive Utility.
';
echo '
If the zip extracts as flat files with \\ or · in their names, the backup was written by an older wizard build on Windows with a separator bug. Run the standalone repair script first — it writes a corrected zip alongside the original: php user/plugins/migrate-grav/wizard/mg-repair-backup.php /path/to/backup.zip
';
echo '
';
}
}
echo '';
page_footer();
}
// ─────────────────────────────────────────────────────────────────────────────
// Reset
// ─────────────────────────────────────────────────────────────────────────────
function wizard_reset(string $webroot, string $stageDir): void
{
@unlink($webroot . '/.migrating');
@unlink($webroot . '/migrate.php');
@unlink($webroot . '/tmp/grav-2.0-staged.zip');
// Restore parent .htaccess if the Test step patched it.
mg_unpatch_htaccess($webroot);
$stagePath = $webroot . '/' . trim($stageDir, '/');
if ($stageDir !== '' && is_dir($stagePath)) {
remove_dir($stagePath);
}
}
/**
* Light reset: clear the stage dir + .htaccess patch, keep the staged zip and
* the wizard, and rewrite .migrating with only the kickoff-time keys (step
* rewound to 'staged'). Wizard run state (plugins_themes/accounts/content
* choices, _prev_options, etc.) is dropped so the rerun starts clean. Lets the
* user re-run the wizard without re-downloading Grav 2.0.
*/
function wizard_restart(string $webroot, string $flagPath, array $flag): void
{
mg_unpatch_htaccess($webroot);
$stageDir = trim((string) ($flag['stage_dir'] ?? 'grav-2'), '/');
$stagePath = $webroot . '/' . $stageDir;
if ($stageDir !== '' && is_dir($stagePath)) {
remove_dir($stagePath);
}
$minimal = array_filter([
'token' => $flag['token'] ?? '',
'created' => $flag['created'] ?? time(),
'step' => 'staged',
'source' => $flag['source'] ?? null,
'stage_dir' => $flag['stage_dir'] ?? 'grav-2',
'staged_zip' => $flag['staged_zip'] ?? 'tmp/grav-2.0-staged.zip',
'wizard_url' => $flag['wizard_url'] ?? null,
], static fn($v) => $v !== null && $v !== '');
save_flag($flagPath, $minimal);
}
function remove_dir(string $path): void
{
// Symlinks are unlinked, never traversed — staged trees can contain
// symlinked plugin clones (developer convenience). Following the symlinks
// would attempt to delete real source files outside the staged tree.
if (is_link($path)) { @unlink($path); return; }
if (!is_dir($path)) { return; }
$items = @scandir($path);
if ($items === false) { return; }
foreach ($items as $item) {
if ($item === '.' || $item === '..') continue;
$sub = $path . DIRECTORY_SEPARATOR . $item;
if (is_link($sub)) {
@unlink($sub);
} elseif (is_dir($sub)) {
remove_dir($sub);
} else {
@unlink($sub);
}
}
@rmdir($path);
}
/**
* Rewind the wizard to a previous step so subsequent steps can be re-run with
* different options. Wipes the staged files for everything downstream of the
* target so the re-run starts clean. Source user/ on the live 1.x install is
* never touched.
*
* 'extracted' — re-run plugins_themes (Step 2). Wipes staged user/
* entirely; bulk-copy will rebuild it.
* 'plugins_done' — re-run accounts (Step 3). Re-copies source
* user/accounts/ over the staged copy so previously-
* applied perm mirrors are undone.
* 'accounts_done' — re-run content (Step 4). Summary-only step; just
* clears the prior summary blob.
*
* Previous step options are stashed under flag['_prev_options'][target] so
* the prerequisite step's form can pre-fill from the last run.
*
* NOTE: re-running 'extracted' is destructive to any hand-edits made in the
* staged install during Test (Step 5). Documented in the UI confirmation.
*/
function mg_rewind_to(string $webroot, array &$flag, string $target): void
{
if (!in_array($target, ['extracted', 'plugins_done', 'accounts_done'], true)) {
throw new InvalidArgumentException("Unknown rewind target: {$target}");
}
$stageDir = trim((string)($flag['stage_dir'] ?? 'grav-2'), '/');
$stagedUser = $webroot . '/' . $stageDir . '/user';
$stash = $flag['_prev_options'] ?? [];
switch ($target) {
case 'extracted':
if (is_dir($stagedUser)) {
mg_rm_tree($stagedUser);
}
if (isset($flag['plugins_themes'])) {
$stash['plugins_themes'] = [
'mode' => $flag['plugins_themes']['mode'] ?? MG_MODE_STRICT,
'policy' => $flag['plugins_themes']['policy'] ?? 'disable',
'auto_update' => $flag['plugins_themes']['auto_update'] ?? true,
];
}
unset($flag['plugins_themes'], $flag['accounts'], $flag['content']);
unset($flag['compat_scan']);
break;
case 'plugins_done':
$srcAccounts = $webroot . '/user/accounts';
$dstAccounts = $stagedUser . '/accounts';
if (is_dir($dstAccounts)) {
mg_rm_tree($dstAccounts);
}
if (is_dir($srcAccounts)) {
copy_tree($srcAccounts, $dstAccounts, static function () {});
}
if (isset($flag['accounts'])) {
$stash['accounts'] = [
'migrate_perms' => empty($flag['accounts']['skipped_perms']),
];
}
unset($flag['accounts'], $flag['content']);
break;
case 'accounts_done':
unset($flag['content']);
break;
}
if ($stash) {
$flag['_prev_options'] = $stash;
}
$flag['step'] = $target;
$flag['rerun_count'][$target] = ($flag['rerun_count'][$target] ?? 0) + 1;
save_flag($webroot . '/.migrating', $flag);
}
// ─────────────────────────────────────────────────────────────────────────────
// Renderers
// ─────────────────────────────────────────────────────────────────────────────
function render_error_page(string $title, string $body): void
{
page_header($title);
echo '
';
echo '
' . htmlspecialchars($title) . ' ' . $body . '
';
echo '
';
page_footer();
}
function render_wizard(array $flag, string $step, string $webroot, string $stageDir, string $stagedZip, string $flagPath, string $token, ?array $flash): void
{
$stageDirAbs = $webroot . '/' . ltrim($stageDir, '/');
// Each row: [label, ok, severity?, hint?]. severity defaults to 'fail'
// (red, blocks the Extract button). 'warn' rows render yellow and DON'T
// block — they flag host conditions that make the long streaming steps
// (bulk copy, gpm update of 50+ packages) liable to die mid-run.
$maxExec = (int) ini_get('max_execution_time');
$stlOff = mg_fn_disabled('set_time_limit');
// A bounded max_execution_time only bites if we can't lift it. With
// set_time_limit available the wizard raises it to 0; disabled, the host
// limit stands and a long step 500s. Surface the actual number so the
// remedy (raise it in the host's PHP / php-fpm config) is concrete.
$stlHint = 'set_time_limit() is blocked by disable_functions, so the wizard cannot lift PHP\'s execution limit'
. ($maxExec > 0 ? ' (currently ' . $maxExec . 's)' : '')
. '. Long steps — the bulk copy and the gpm update of all plugins/themes — can exceed it and fail with a silent HTTP 500. '
. 'Remedy: raise max_execution_time (and your php-fpm request_terminate_timeout / proxy read timeout) for this site, '
. 'then reload this page.';
$preflight = [
['webroot writable', is_writable($webroot)],
['staged zip present', is_file($webroot . '/' . ltrim($stagedZip, '/'))],
['staged zip readable', is_readable($webroot . '/' . ltrim($stagedZip, '/'))],
['stage dir writable / absent', !is_dir($stageDirAbs) || is_writable($stageDirAbs)],
['PHP version >= 8.3', version_compare(PHP_VERSION, '8.3.0', '>=')],
['zip extension loaded', extension_loaded('zip')],
['set_time_limit() available', !$stlOff, 'warn', $stlHint],
['ignore_user_abort() available', !mg_fn_disabled('ignore_user_abort'), 'warn',
'ignore_user_abort() is blocked by disable_functions. If the connection drops or the proxy times out mid-step, '
. 'the migration can abort half-applied. Keep this tab open and the connection alive; if a step fails, use Reset Migration to start clean.'],
['proc_open() available', !mg_fn_disabled('proc_open'), 'warn',
'proc_open() is blocked by disable_functions. The plugin/theme update step shells out to the staged bin/gpm '
. 'and will be skipped — your plugins/themes get copied as-is without being upgraded to their 2.0 releases.'],
];
// Only hard 'fail' rows gate the Extract button; warnings never block.
$preflightOk = !array_filter($preflight, static fn($c) => !$c[1] && (($c[2] ?? 'fail') === 'fail'));
page_header('Grav 2.0 Migration Wizard');
echo '
';
// Cache the staged-zip Grav version into flag once (cheap zip peek the
// first time, free after) so hero + staged-state row + Step 1 can all
// show the same version pre-extraction.
if (empty($flag['extracted']['grav_version']) && empty($flag['staged_zip_version'])) {
mg_staged_zip_version($webroot, $flag);
}
// Hero
$stagedGravVersion = (string) ($flag['extracted']['grav_version'] ?? $flag['staged_zip_version'] ?? '');
$heroTitle = $stagedGravVersion !== ''
? 'Grav 2.0 Migration Wizard v' . htmlspecialchars($stagedGravVersion) . ''
: 'Grav 2.0 Migration Wizard';
echo '
';
echo '
' . MG_ROCKET_SVG . '
';
echo '
' . $heroTitle . '
';
echo '
Standalone wizard for staging and (eventually) importing your site into a fresh Grav 2.0 at /' . htmlspecialchars($stageDir) . '/. No Grav 1.x code is loaded.
';
echo '
';
render_stepper($step);
if ($flash) {
$successKeys = ['extracted', 'imported', 'ok', 'plugins_done', 'accounts_done', 'content_done', 'test_done', 'restarted'];
$type = in_array($flash['type'], $successKeys, true)
? 'ok'
: ($flash['type'] === 'error' ? 'error' : 'warn');
$msg = htmlspecialchars($flash['msg'] ?: ucfirst($flash['type']));
// Map flash type → the just-completed step's flag key, so we can
// expand the rich breakdown inline beneath the success message.
$detailsMap = [
'plugins_done' => 'plugins_themes',
'accounts_done' => 'accounts',
'content_done' => 'content',
];
$detailsKey = $detailsMap[$flash['type']] ?? null;
echo '
';
echo '
' . $msg . '
';
if ($detailsKey && isset($flag[$detailsKey])) {
echo 'Show what was copied / skipped / disabled';
echo '
';
mg_render_pt_details($pt);
mg_render_rerun_form($token, 'extracted', 'Re-run Step 2 with different options', 'This will wipe the staged user/ and re-bulk-copy from your live 1.x install. Steps 3 and 4 will need to be re-run too.');
echo '
';
mg_render_accounts_details($ac);
mg_render_rerun_form($token, 'plugins_done', 'Re-run Step 3 with different options', 'Re-copies user/accounts/ from your live 1.x install and clears Step 4. Plugins/themes (Step 2) are not affected.');
echo '
';
mg_render_content_details($c);
mg_render_rerun_form($token, 'accounts_done', 'Re-run Step 4', 'Step 4 is a summary-only step; re-running just refreshes the entry list.');
echo '
';
}
echo '
';
// Restart / Reset card
echo '
Restart or Reset
';
echo '
Your original site at ' . htmlspecialchars($webroot) . ' is not touched by either action.
';
echo '
Restart Wizard clears the staged Grav 2.0 directory and your wizard progress, but keeps the downloaded release zip and your migration token so you can re-run from step 1 without re-downloading.
';
echo '
Reset Migration abandons the migration entirely — deletes .migrating, migrate.php, the staged zip, and the staged Grav 2.0 directory. Starting over re-downloads Grav 2.0.
The Grav ' . ($stagedVersion ? 'v' . htmlspecialchars($stagedVersion) . '' : '2.0') . ' zip is ready at ' . htmlspecialchars($flag['staged_zip'] ?? '') . '. Once all pre-flight checks pass, extract it into /' . htmlspecialchars($stageDir) . '/.
' . $txt;
// Hints are authored copy (with intentional markup), not
// user data — emit as-is, only shown when the check isn't OK.
if (!$ok && $hint !== '') echo '' . $hint . '';
echo '
This is the heavy-lifting step: your entire user/ directory (pages, data, config, languages, accounts, plugins, themes, and any custom folders) is copied into the staged install, then plugin transforms are applied based on your choices below. Steps 3 and 4 just run light transforms on the already-copied data.
';
echo '
Review 2.0 compatibility. Plugins: incompatible ones follow the policy you choose. Themes: always kept as-is — Grav 2.0 ships Twig 3 compatibility mode (enabled by default), which lets most existing themes (including custom ones) render without changes. Verify the staged site in Step 5 before promoting to live.
';
if ($scan['source'] === 'offline') {
echo '
Could not reach the curated compatibility registry. Falling back to blueprint + dependency inference only — verdicts may be less accurate.
';
}
// Upgrade preview — counts how many rows have a candidate upgrade
// resolved via mg_resolve_update() (i.e. GPM, with v=,
// is offering a newer version than what's installed).
$countUpdates = static fn(array $bucket): int => count(array_filter(
$bucket,
static fn($v) => is_array($v) && !empty($v['update'])
));
$pendingPlugins = $countUpdates($scan['plugins'] ?? []);
$pendingThemes = $countUpdates($scan['themes'] ?? []);
if ($pendingPlugins + $pendingThemes > 0) {
$parts = [];
if ($pendingPlugins > 0) $parts[] = "{$pendingPlugins} plugin update" . ($pendingPlugins === 1 ? '' : 's');
if ($pendingThemes > 0) $parts[] = "{$pendingThemes} theme update" . ($pendingThemes === 1 ? '' : 's');
$msg = implode(' and ', $parts) . ' available on GPM.';
echo '
'
. $msg . ' With upgrade plugins during migration on (default), Grav 2.0's GPM picks up these versions during Copy & Migrate — only ones gpm confirms are 2.0-compatible actually get installed. Rows in the table below tagged “↑ will upgrade to vX.Y.Z” are the candidates.'
. '
';
}
render_compat_breakdown($scan);
// Pre-fill from previous run if user rewound to here.
$prev = $flag['_prev_options']['plugins_themes'] ?? null;
$prevMode = $prev['mode'] ?? MG_MODE_STRICT;
$prevPolicy = $prev['policy'] ?? 'disable';
$prevAutoUpd = $prev['auto_update'] ?? true;
$rerunCount = (int)($flag['rerun_count']['extracted'] ?? 0);
if ($prev !== null) {
echo '
Re-running this step (#' . ($rerunCount + 1) . '). The staged user/ was wiped — any hand-edits made while testing have been lost. Source user/ on the live 1.x install is unchanged.
Account yamls were copied verbatim into the staged install during Step 2 (' . $nAccounts . ' yaml(s) detected in staged user/accounts/). This step just applies the optional permission transform.
';
echo '
Grav 2.0 (with Admin 2.0) uses api.* permission names going forward. Your existing admin.* permissions can be mirrored in place so the same users keep full access on both Admin 1.x and 2.0 while you transition.
All content under user/ (pages, data, config, languages, custom folders, top-level files) was bulk-copied into the staged install during Step 2. Confirm below to continue to testing.
';
break;
case 'content_done':
$webroot = dirname($stageDirAbs);
$serverKind = mg_server_kind();
$stageUrl = base_path_from_script() . rawurlencode($stageDir) . '/';
// Auto-patch on Apache/LiteSpeed (idempotent). For nginx/Caddy,
// skip auto-patch and show manual config. The parent rule
// exclusion is sufficient; the staged install does NOT need a
// RewriteBase tweak (it works as-is when the parent stops
// intercepting).
$patchResult = null;
if (in_array($serverKind, ['apache', 'litespeed'], true)) {
$patchResult = mg_patch_htaccess($webroot, $stageDir);
}
echo '
Step 5: Test the staged install
';
echo '
Open the staged Grav 2.0 in a new tab and verify pages, admin login, plugins, and theme behavior. Your live 1.x install at this URL is unchanged.
';
// Test/Permissive-mode report card: list what was force-included
// so the user knows which plugins to specifically smoke-test.
$pt = $flag['plugins_themes'] ?? [];
$ptMode = (string) ($pt['mode'] ?? MG_MODE_STRICT);
$forced = $pt['force_included'] ?? [];
if ($ptMode !== MG_MODE_STRICT && !empty($forced)) {
$modeWord = $ptMode === MG_MODE_TEST ? 'Test' : 'Permissive';
echo '
';
echo '' . count($forced) . ' plugin(s) force-included by ' . $modeWord . ' mode. ';
echo 'These would have been disabled/skipped in Strict mode. Smoke-test these specifically — fatal errors here are expected and informative. ';
echo 'Force-included list
Detected ' . htmlspecialchars(ucfirst($serverKind)) . '. Auto-patching only works for Apache/LiteSpeed. Apply the manual config below before testing, or your test traffic will hit the 1.x install.
Add this block above your existing Grav location. The PHP handler must be nested inside the prefix block — sibling regex locations are never consulted once an ^~ prefix match wins, so a sibling location ~ \\.php$ would silently break PHP execution under the stage path:
Zips the existing 1.x install into backup/' . htmlspecialchars($backupPreview) . '
';
echo '
Deletes the 1.x files from the webroot
';
echo '
Moves the staged Grav 2.0 from /' . htmlspecialchars($stageDir) . '/ up into the webroot root
';
echo '
Removes the empty /' . htmlspecialchars($stageDir) . '/ dir
';
echo '
Writes a breadcrumb at .migration-complete with the backup path
';
echo '';
echo '
Point of no return. Your live URL will start serving Grav 2.0 immediately after this step. To revert, you\'d extract the backup zip back into place. Make sure Step 5 testing went well first.
';
echo '
Close anything that has the webroot open first. Code editors (VS Code, Sublime, PhpStorm), git GUIs (Sourcetree, GitHub Desktop, GitKraken), open terminals cd\'d into the install, and tail/log viewers can hold file handles that block the delete phase. On Windows this is the #1 cause of promote failure — open files cannot be unlinked. macOS and Linux are forgiving here, but closing them first is still recommended.
';
echo '';
echo '
';
break;
case 'promoted':
echo '
Migration complete
';
echo '
Grav 2.0 is live. Log into the admin to verify your content and plugins.
';
echo '
';
break;
}
}
/**
* Render the rich body for a completed plugins-themes step (used by both the
* post-action flash callout and the persistent staged-state card).
*/
function mg_render_pt_details(array $pt): void
{
$mode = (string) ($pt['mode'] ?? MG_MODE_STRICT);
if ($mode !== MG_MODE_STRICT) {
echo '
';
// Render pending replacements first under their own subhead so the
// user sees the "incoming" rows up top.
$versions = $scan['github_versions'] ?? [];
if ($pending) {
echo '