feat(demo): add story 1 — Sorano: Rock and Time
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
namespace Grav\Plugin\MigrateGrav;
|
||||
|
||||
/**
|
||||
* Detects and remediates direct web access to the sensitive `user/` folders
|
||||
* (accounts, config, data, env).
|
||||
*
|
||||
* Older Grav installs ship a site root `.htaccess` that only blocks a fixed
|
||||
* list of file extensions under `user/`. Files stored under `user/data` with
|
||||
* an unlisted extension (certificates, keys, tokens, sqlite databases, logs)
|
||||
* could therefore be downloaded directly over HTTP. Grav 2.0 blocks these
|
||||
* folders outright; this helper brings an existing install up to the same
|
||||
* protection, and warns when the webserver is not Apache and the fix has to
|
||||
* be applied to the server config by hand.
|
||||
*/
|
||||
class HtaccessSecurity
|
||||
{
|
||||
/** Folders under user/ that must never be web-served. */
|
||||
public const SENSITIVE = ['accounts', 'config', 'data', 'env'];
|
||||
|
||||
/** Signature of the folder-block rule in a patched root .htaccess. */
|
||||
private const RULE_SIGNATURE = '^(user)/(accounts|config|data|env)/';
|
||||
|
||||
private string $root;
|
||||
|
||||
public function __construct(string $root)
|
||||
{
|
||||
$this->root = rtrim($root, '/');
|
||||
}
|
||||
|
||||
private function denyHtaccess(): string
|
||||
{
|
||||
return <<<HTACCESS
|
||||
# Deny all direct web access to this folder and everything beneath it.
|
||||
# Grav reads these files server-side; they must never be served over HTTP.
|
||||
# This is a defense-in-depth backup for the rules in the site root .htaccess.
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order allow,deny
|
||||
Deny from all
|
||||
</IfModule>
|
||||
|
||||
HTACCESS;
|
||||
}
|
||||
|
||||
public function serverSoftware(): string
|
||||
{
|
||||
return strtolower((string) ($_SERVER['SERVER_SOFTWARE'] ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* LiteSpeed honours .htaccess too, so it counts as Apache-compatible here.
|
||||
*/
|
||||
public function isApache(): bool
|
||||
{
|
||||
$s = $this->serverSoftware();
|
||||
return $s === '' ? false : (str_contains($s, 'apache') || str_contains($s, 'litespeed'));
|
||||
}
|
||||
|
||||
public function rootHtaccessPath(): string
|
||||
{
|
||||
return $this->root . '/.htaccess';
|
||||
}
|
||||
|
||||
public function hasRootRule(): bool
|
||||
{
|
||||
$f = $this->rootHtaccessPath();
|
||||
return is_file($f) && str_contains((string) @file_get_contents($f), self::RULE_SIGNATURE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Folders that exist but do not yet carry a backup deny-all .htaccess.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function unprotectedDirs(): array
|
||||
{
|
||||
$missing = [];
|
||||
foreach (self::SENSITIVE as $folder) {
|
||||
$dir = $this->root . '/user/' . $folder;
|
||||
if (is_dir($dir) && !is_file($dir . '/.htaccess')) {
|
||||
$missing[] = $folder;
|
||||
}
|
||||
}
|
||||
return $missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{protected: bool, apache: bool, server: string, root_rule: bool, can_autofix: bool, unprotected: string[]}
|
||||
*/
|
||||
public function status(): array
|
||||
{
|
||||
$apache = $this->isApache();
|
||||
$rootRule = $this->hasRootRule();
|
||||
$unprotected = $this->unprotectedDirs();
|
||||
$perDirCovers = $unprotected === [];
|
||||
|
||||
// On Apache the install is protected once either the root rule is in
|
||||
// place or every sensitive folder has its own deny file. On any other
|
||||
// server, .htaccess is ignored entirely, so we cannot self-protect and
|
||||
// must defer to a manual server-config change.
|
||||
$protected = $apache && ($rootRule || $perDirCovers);
|
||||
|
||||
// We can only safely auto-fix when Apache is serving the site and the
|
||||
// root .htaccess (if present) is writable.
|
||||
$rootWritable = !is_file($this->rootHtaccessPath()) || is_writable($this->rootHtaccessPath());
|
||||
$canAutofix = $apache && !$protected && $rootWritable;
|
||||
|
||||
return [
|
||||
'protected' => $protected,
|
||||
'apache' => $apache,
|
||||
'server' => $this->serverSoftware(),
|
||||
'root_rule' => $rootRule,
|
||||
'can_autofix' => $canAutofix,
|
||||
'unprotected' => $unprotected,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch the root .htaccess and drop per-folder deny files.
|
||||
*
|
||||
* @return array{patched: bool, created: string[], errors: string[]}
|
||||
*/
|
||||
public function applyFix(): array
|
||||
{
|
||||
$created = [];
|
||||
$errors = [];
|
||||
|
||||
foreach (self::SENSITIVE as $folder) {
|
||||
$dir = $this->root . '/user/' . $folder;
|
||||
if (!is_dir($dir)) {
|
||||
continue;
|
||||
}
|
||||
$file = $dir . '/.htaccess';
|
||||
if (is_file($file)) {
|
||||
continue;
|
||||
}
|
||||
if (!is_writable($dir)) {
|
||||
$errors[] = "user/$folder is not writable";
|
||||
continue;
|
||||
}
|
||||
if (@file_put_contents($file, $this->denyHtaccess()) !== false) {
|
||||
$created[] = "user/$folder/.htaccess";
|
||||
} else {
|
||||
$errors[] = "could not write user/$folder/.htaccess";
|
||||
}
|
||||
}
|
||||
|
||||
$patched = false;
|
||||
$root = $this->rootHtaccessPath();
|
||||
if (is_file($root)) {
|
||||
if (!is_writable($root)) {
|
||||
$errors[] = '.htaccess is not writable';
|
||||
} elseif (!$this->hasRootRule()) {
|
||||
$contents = (string) @file_get_contents($root);
|
||||
$rule = "# Block all direct access to these sensitive user folders, whatever the file type\n"
|
||||
. "RewriteRule ^(user)/(accounts|config|data|env)/(.*) error [F]\n";
|
||||
$count = 0;
|
||||
$new = preg_replace(
|
||||
'/^(RewriteRule \^\(\\\\\.git\|cache\|bin\|logs\|backup\|webserver-configs\|tests\)\/\(\.\*\) error \[F\]\n)/m',
|
||||
'$1' . $rule,
|
||||
$contents,
|
||||
1,
|
||||
$count
|
||||
);
|
||||
if ($count > 0 && is_string($new) && $new !== $contents) {
|
||||
if (@file_put_contents($root, $new) !== false) {
|
||||
$patched = true;
|
||||
} else {
|
||||
$errors[] = 'could not write patched .htaccess';
|
||||
}
|
||||
} else {
|
||||
$errors[] = 'could not locate the Grav security block in .htaccess to patch';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['patched' => $patched, 'created' => $created, 'errors' => $errors];
|
||||
}
|
||||
|
||||
/**
|
||||
* The rules an operator must add by hand when the site is not on Apache.
|
||||
*/
|
||||
public function manualSnippet(): string
|
||||
{
|
||||
$s = $this->serverSoftware();
|
||||
if (str_contains($s, 'nginx')) {
|
||||
return "location ~* /user/(accounts|config|data|env)/.*$ { return 403; }";
|
||||
}
|
||||
if (str_contains($s, 'iis') || str_contains($s, 'microsoft')) {
|
||||
return '<rule name="user_sensitive_folders" stopProcessing="true">' . "\n"
|
||||
. ' <match url="^user/(accounts|config|data|env)/(.*)" ignoreCase="false" />' . "\n"
|
||||
. ' <action type="Redirect" url="error" redirectType="Permanent" />' . "\n"
|
||||
. '</rule>';
|
||||
}
|
||||
if (str_contains($s, 'caddy')) {
|
||||
return "rewrite /user/(accounts|config|data|env)/.* /403";
|
||||
}
|
||||
if (str_contains($s, 'lighttpd')) {
|
||||
return '$HTTP["url"] =~ "^/user/(accounts|config|data|env)/(.*)" { url.access-deny = ("") }';
|
||||
}
|
||||
return "RewriteRule ^(user)/(accounts|config|data|env)/(.*) error [F]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
<?php
|
||||
namespace Grav\Plugin\MigrateGrav;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Stages the Grav 2.0 release alongside the existing site and drops the
|
||||
* standalone wizard at webroot. Performs no Grav-side bootstrap of 2.0;
|
||||
* the wizard runs in a fresh PHP process started by the user.
|
||||
*
|
||||
* The wizard is owned by THIS plugin (wizard/migrate.php) and copied to
|
||||
* webroot — not extracted from the Grav 2.0 zip. That way we can iterate
|
||||
* on the migration flow without re-releasing Grav.
|
||||
*/
|
||||
class Kickoff
|
||||
{
|
||||
private const MIGRATE_FILE = 'migrate.php';
|
||||
private const FLAG_FILE = '.migrating';
|
||||
private const ZIP_NAME = 'grav-2.0-staged.zip';
|
||||
|
||||
/** @var string */
|
||||
private $webroot;
|
||||
/** @var array */
|
||||
private $config;
|
||||
|
||||
public function __construct(string $webroot, array $config)
|
||||
{
|
||||
$this->webroot = rtrim($webroot, DIRECTORY_SEPARATOR);
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the kickoff. Returns metadata describing the resulting state
|
||||
* (token, paths, next-step URL/CLI hint).
|
||||
*
|
||||
* @param array $context Optional triggering context (admin user, source, etc.)
|
||||
*/
|
||||
public function run(array $context = []): array
|
||||
{
|
||||
$this->assertWebrootWritable();
|
||||
$this->assertNotAlreadyStaged();
|
||||
|
||||
$zipPath = $this->obtainZip();
|
||||
$this->placeWizard();
|
||||
$this->placeStagedZip($zipPath);
|
||||
|
||||
$token = bin2hex(random_bytes(16));
|
||||
$stageDir = $this->config['stage_dir'] ?: 'grav-2';
|
||||
|
||||
$payload = [
|
||||
'token' => $token,
|
||||
'created' => time(),
|
||||
'step' => 'staged',
|
||||
'source' => [
|
||||
'grav_version' => $context['grav_version'] ?? null,
|
||||
'root' => $this->webroot,
|
||||
'admin_user' => $context['admin_user'] ?? null,
|
||||
'trigger' => $context['trigger'] ?? 'cli',
|
||||
],
|
||||
'stage_dir' => $stageDir,
|
||||
'staged_zip' => 'tmp/' . self::ZIP_NAME,
|
||||
'wizard_url' => '/' . self::MIGRATE_FILE . '?token=' . $token,
|
||||
];
|
||||
|
||||
// Forward Grav's system.http.proxy_url / proxy_cert_path into the
|
||||
// flag so the standalone wizard (which runs without Grav loaded)
|
||||
// can apply the same proxy to its own outbound HTTP calls. Empty
|
||||
// values aren't serialized — keeps the flag clean for the common
|
||||
// no-proxy case.
|
||||
$proxyUrl = (string) ($this->config['proxy_url'] ?? '');
|
||||
$proxyCertPath = (string) ($this->config['proxy_cert_path'] ?? '');
|
||||
if ($proxyUrl !== '') {
|
||||
$payload['proxy'] = ['url' => $proxyUrl];
|
||||
if ($proxyCertPath !== '') {
|
||||
$payload['proxy']['cert_path'] = $proxyCertPath;
|
||||
}
|
||||
}
|
||||
|
||||
$this->writeFlag($payload);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function assertWebrootWritable(): void
|
||||
{
|
||||
if (!is_dir($this->webroot) || !is_writable($this->webroot)) {
|
||||
throw new RuntimeException("Webroot is not writable: {$this->webroot}");
|
||||
}
|
||||
|
||||
$tmp = $this->webroot . DIRECTORY_SEPARATOR . 'tmp';
|
||||
if (!is_dir($tmp) && !mkdir($tmp, 0775, true) && !is_dir($tmp)) {
|
||||
throw new RuntimeException("Could not create tmp dir: {$tmp}");
|
||||
}
|
||||
if (!is_writable($tmp)) {
|
||||
throw new RuntimeException("tmp/ is not writable: {$tmp}");
|
||||
}
|
||||
}
|
||||
|
||||
private function assertNotAlreadyStaged(): void
|
||||
{
|
||||
$flag = $this->webroot . DIRECTORY_SEPARATOR . self::FLAG_FILE;
|
||||
if (file_exists($flag)) {
|
||||
throw new RuntimeException(
|
||||
"A migration is already staged ({$flag}). " .
|
||||
"Use Restart Wizard or Reset Migration on the Migrate Grav admin page, " .
|
||||
"or visit /" . self::MIGRATE_FILE . " to resume."
|
||||
);
|
||||
}
|
||||
|
||||
$stage = $this->webroot . DIRECTORY_SEPARATOR . ($this->config['stage_dir'] ?: 'grav-2');
|
||||
if (is_dir($stage)) {
|
||||
throw new RuntimeException(
|
||||
"Stage directory already exists: {$stage}. " .
|
||||
"Use Reset Migration on the Migrate Grav admin page to clear it."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function obtainZip(): string
|
||||
{
|
||||
$local = trim((string)($this->config['source_local_zip'] ?? ''));
|
||||
if ($local !== '') {
|
||||
if (!is_file($local)) {
|
||||
throw new RuntimeException("source_local_zip not found: {$local}");
|
||||
}
|
||||
$this->assertValidZip($local, false);
|
||||
return $local;
|
||||
}
|
||||
|
||||
$url = (string)($this->config['source_url'] ?? '');
|
||||
if ($url === '') {
|
||||
throw new RuntimeException('No source_url configured for Grav 2.0 release.');
|
||||
}
|
||||
|
||||
// Honor the site's GPM channel: if the user runs on the testing
|
||||
// channel (system.gpm.releases: testing) and the configured source_url
|
||||
// is plain (no query string), append `?testing` so the kickoff pulls
|
||||
// the same release the rest of the admin would advertise as available.
|
||||
// If source_url already carries a query string, the user has been
|
||||
// explicit — leave it alone.
|
||||
$channel = (string)($this->config['gpm_channel'] ?? 'stable');
|
||||
if ($channel === 'testing' && !str_contains($url, '?')) {
|
||||
$url .= '?testing';
|
||||
}
|
||||
|
||||
$dest = $this->webroot . DIRECTORY_SEPARATOR . 'tmp' . DIRECTORY_SEPARATOR . self::ZIP_NAME;
|
||||
$this->downloadTo($url, $dest);
|
||||
|
||||
if (!is_file($dest) || filesize($dest) < 1024) {
|
||||
throw new RuntimeException("Downloaded zip looks invalid: {$dest}");
|
||||
}
|
||||
$this->assertValidZip($dest, true);
|
||||
|
||||
return $dest;
|
||||
}
|
||||
|
||||
private function downloadTo(string $url, string $dest): void
|
||||
{
|
||||
// Build a stream context that honors Grav's proxy config. Without
|
||||
// this, sites behind a corporate proxy can't fetch the Grav 2.0 zip
|
||||
// and the kickoff fails with a generic "Failed to open source URL".
|
||||
$ctx = $this->buildHttpContext();
|
||||
$in = $ctx !== null
|
||||
? @fopen($url, 'rb', false, $ctx)
|
||||
: @fopen($url, 'rb');
|
||||
if (!$in) {
|
||||
throw new RuntimeException("Failed to open source URL: {$url}");
|
||||
}
|
||||
$out = @fopen($dest, 'wb');
|
||||
if (!$out) {
|
||||
fclose($in);
|
||||
throw new RuntimeException("Failed to open destination for write: {$dest}");
|
||||
}
|
||||
$ok = false;
|
||||
$written = 0;
|
||||
try {
|
||||
while (!feof($in)) {
|
||||
$chunk = fread($in, 1 << 16);
|
||||
if ($chunk === false) {
|
||||
throw new RuntimeException("Read error during download from {$url}");
|
||||
}
|
||||
if ($chunk === '') {
|
||||
continue;
|
||||
}
|
||||
if (fwrite($out, $chunk) !== strlen($chunk)) {
|
||||
throw new RuntimeException("Write error while saving {$dest} — is the disk full?");
|
||||
}
|
||||
$written += strlen($chunk);
|
||||
}
|
||||
|
||||
// feof() also reports true when the server, a proxy, or a flaky
|
||||
// connection drops mid-transfer, so a clean loop exit does NOT
|
||||
// mean a complete file. Cross-check bytes received against the
|
||||
// response's Content-Length before trusting the download.
|
||||
$meta = stream_get_meta_data($in);
|
||||
if (!empty($meta['timed_out'])) {
|
||||
throw new RuntimeException(
|
||||
"Download timed out after {$written} bytes from {$url}. Try again, " .
|
||||
"or download the release manually and set source_local_zip in the plugin configuration."
|
||||
);
|
||||
}
|
||||
$expected = self::contentLengthFromHeaders($meta['wrapper_data'] ?? []);
|
||||
if ($expected !== null && $written !== $expected) {
|
||||
throw new RuntimeException(
|
||||
"Incomplete download from {$url}: received {$written} of {$expected} bytes. " .
|
||||
"The connection was interrupted — try again, or download the release manually " .
|
||||
"and set source_local_zip in the plugin configuration."
|
||||
);
|
||||
}
|
||||
$ok = true;
|
||||
} finally {
|
||||
fclose($in);
|
||||
fclose($out);
|
||||
if (!$ok) {
|
||||
// Never leave a partial file behind: a later retry must not
|
||||
// be able to stage it, and (on failure paths that don't
|
||||
// throw past obtainZip) neither must the wizard.
|
||||
@unlink($dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective Content-Length from the HTTP wrapper's header list. Across
|
||||
* redirects the wrapper appends every hop's headers to one flat array,
|
||||
* so reset on each new status line and keep the last value seen — that
|
||||
* is the body actually streamed. Returns null when the final response
|
||||
* carried no Content-Length (e.g. chunked encoding); the caller then
|
||||
* relies on the zip integrity check instead.
|
||||
*/
|
||||
private static function contentLengthFromHeaders(array $headers): ?int
|
||||
{
|
||||
$length = null;
|
||||
foreach ($headers as $header) {
|
||||
if (!is_string($header)) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('~^HTTP/~i', $header)) {
|
||||
$length = null;
|
||||
} elseif (preg_match('~^Content-Length:\s*(\d+)\s*$~i', $header, $m)) {
|
||||
$length = (int) $m[1];
|
||||
}
|
||||
}
|
||||
return $length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a zip that isn't a readable archive. The end-of-central-directory
|
||||
* record lives at the TAIL of a zip, so a truncated transfer passes any
|
||||
* size check yet fails to open (libzip: ER_NOZIP 19, ER_INCONS 21, or
|
||||
* ER_TRUNCATED_ZIP 35 on libzip >= 1.10). Catching it here, before the
|
||||
* .migrating flag is written, beats failing later in the wizard's extract
|
||||
* step where the remedy (Reset Migration, re-stage) is less obvious.
|
||||
*/
|
||||
private function assertValidZip(string $path, bool $deleteOnFailure): void
|
||||
{
|
||||
if (!class_exists(\ZipArchive::class)) {
|
||||
return; // no zip extension in this SAPI; the wizard reports it on extract
|
||||
}
|
||||
$zip = new \ZipArchive();
|
||||
$rc = $zip->open($path);
|
||||
if ($rc === true && $zip->numFiles > 0) {
|
||||
$zip->close();
|
||||
return;
|
||||
}
|
||||
if ($rc === true) {
|
||||
$zip->close();
|
||||
}
|
||||
if ($deleteOnFailure) {
|
||||
@unlink($path);
|
||||
}
|
||||
$detail = $rc === true ? 'archive contains no entries' : "ZipArchive error code {$rc}";
|
||||
throw new RuntimeException(
|
||||
"Zip is corrupt or truncated ({$detail}): {$path}. " .
|
||||
($deleteOnFailure
|
||||
? 'The download was likely interrupted — try staging again, or download the release manually and set source_local_zip in the plugin configuration.'
|
||||
: 'Re-download the file configured as source_local_zip and verify it with `unzip -t`.')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stream context for the kickoff's outbound zip download,
|
||||
* threading in proxy config from Grav's system.http.proxy_url /
|
||||
* proxy_cert_path (forwarded into $this->config by migrate-grav.php's
|
||||
* newKickoff() / cli/InitCommand.php). Returns null when no proxy is
|
||||
* configured — the caller then falls back to a bare fopen() so the
|
||||
* common case (no proxy) doesn't pay any context-construction cost.
|
||||
*
|
||||
* @return resource|null
|
||||
*/
|
||||
private function buildHttpContext()
|
||||
{
|
||||
$proxyUrl = (string) ($this->config['proxy_url'] ?? '');
|
||||
if ($proxyUrl === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// PHP's HTTP stream wrapper wants tcp://host:port. Strip any
|
||||
// http:// or https:// scheme the user wrote in system.yaml.
|
||||
$proxyHostPort = preg_replace('~^[a-zA-Z][a-zA-Z0-9+.\-]*://~', '', $proxyUrl);
|
||||
$http = [
|
||||
'timeout' => 60, // zip can be large; be generous
|
||||
'header' => "User-Agent: grav-migrate-kickoff/1.0\r\n",
|
||||
'proxy' => 'tcp://' . $proxyHostPort,
|
||||
'request_fulluri' => true,
|
||||
];
|
||||
$ssl = ['verify_peer' => true, 'verify_peer_name' => true];
|
||||
|
||||
$certPath = (string) ($this->config['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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the plugin's canonical wizard (wizard/migrate.php) to webroot.
|
||||
*
|
||||
* The wizard intentionally lives in this plugin rather than in the Grav
|
||||
* 2.0 release zip, so the migration flow can be iterated without Grav
|
||||
* core releases. Each kickoff overwrites any previous wizard copy.
|
||||
*/
|
||||
private function placeWizard(): void
|
||||
{
|
||||
$src = __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'wizard' . DIRECTORY_SEPARATOR . self::MIGRATE_FILE;
|
||||
if (!is_file($src)) {
|
||||
throw new RuntimeException("Plugin wizard source missing: {$src}");
|
||||
}
|
||||
|
||||
$dest = $this->webroot . DIRECTORY_SEPARATOR . self::MIGRATE_FILE;
|
||||
if (!@copy($src, $dest)) {
|
||||
throw new RuntimeException("Failed to copy wizard to {$dest}");
|
||||
}
|
||||
@chmod($dest, 0644);
|
||||
}
|
||||
|
||||
private function placeStagedZip(string $zipPath): void
|
||||
{
|
||||
$dest = $this->webroot . DIRECTORY_SEPARATOR . 'tmp' . DIRECTORY_SEPARATOR . self::ZIP_NAME;
|
||||
if (realpath($zipPath) === realpath($dest)) {
|
||||
return;
|
||||
}
|
||||
if (!@copy($zipPath, $dest)) {
|
||||
throw new RuntimeException("Failed to copy staged zip to {$dest}");
|
||||
}
|
||||
}
|
||||
|
||||
private function writeFlag(array $payload): void
|
||||
{
|
||||
$flag = $this->webroot . DIRECTORY_SEPARATOR . self::FLAG_FILE;
|
||||
$json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false || file_put_contents($flag, $json) === false) {
|
||||
throw new RuntimeException("Failed to write flag file: {$flag}");
|
||||
}
|
||||
@chmod($flag, 0600);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset migration state. Two modes:
|
||||
*
|
||||
* 'full' — delete .migrating, migrate.php, the staged zip, the stage
|
||||
* directory, and restore .htaccess. Next kickoff starts from
|
||||
* scratch (re-download, re-stage).
|
||||
*
|
||||
* 'restart' — keep .migrating (rewound to step='staged'), keep migrate.php
|
||||
* and the staged zip, restore .htaccess, drop only the stage
|
||||
* directory and any transient run state. Lets the user re-run
|
||||
* the wizard without re-downloading Grav 2.0.
|
||||
*
|
||||
* Safe to call even when nothing is staged.
|
||||
*/
|
||||
public function reset(string $mode = 'full'): array
|
||||
{
|
||||
if (!in_array($mode, ['full', 'restart'], true)) {
|
||||
throw new RuntimeException("Unknown reset mode: {$mode}");
|
||||
}
|
||||
|
||||
$removed = [];
|
||||
$errors = [];
|
||||
$stageDir = trim((string)($this->config['stage_dir'] ?? 'grav-2'), '/');
|
||||
|
||||
// Both modes restore .htaccess and drop the stage directory.
|
||||
$this->restoreHtaccess();
|
||||
|
||||
if ($stageDir !== '') {
|
||||
$stagePath = $this->webroot . DIRECTORY_SEPARATOR . $stageDir;
|
||||
if (is_dir($stagePath)) {
|
||||
if ($this->removeDirectory($stagePath)) {
|
||||
$removed[] = $stageDir . '/';
|
||||
} else {
|
||||
$errors[] = "Could not fully remove {$stageDir}/";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($mode === 'restart') {
|
||||
// Rewrite .migrating with only the kickoff-time keys, step rewound
|
||||
// to 'staged'. Strips wizard-side run state (plugins_themes,
|
||||
// accounts, content, _prev_options, staged_zip_version, etc.) so
|
||||
// the wizard restarts cleanly from the staged release.
|
||||
$existing = $this->readFlag();
|
||||
if ($existing !== null) {
|
||||
$minimal = array_filter([
|
||||
'token' => $existing['token'] ?? null,
|
||||
'created' => $existing['created'] ?? time(),
|
||||
'step' => 'staged',
|
||||
'source' => $existing['source'] ?? null,
|
||||
'stage_dir' => $existing['stage_dir'] ?? ($this->config['stage_dir'] ?: 'grav-2'),
|
||||
'staged_zip' => $existing['staged_zip'] ?? 'tmp/' . self::ZIP_NAME,
|
||||
'wizard_url' => $existing['wizard_url'] ?? null,
|
||||
], static fn($v) => $v !== null);
|
||||
$this->writeFlag($minimal);
|
||||
$removed[] = '.migrating (rewound to staged)';
|
||||
}
|
||||
return ['removed' => $removed, 'errors' => $errors, 'mode' => 'restart'];
|
||||
}
|
||||
|
||||
// mode === 'full'
|
||||
$candidates = [
|
||||
self::FLAG_FILE,
|
||||
self::MIGRATE_FILE,
|
||||
'tmp/' . self::ZIP_NAME,
|
||||
];
|
||||
foreach ($candidates as $rel) {
|
||||
$path = $this->webroot . DIRECTORY_SEPARATOR . $rel;
|
||||
if (is_file($path)) {
|
||||
if (@unlink($path)) {
|
||||
$removed[] = $rel;
|
||||
} else {
|
||||
$errors[] = "Could not remove {$rel}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['removed' => $removed, 'errors' => $errors, 'mode' => 'full'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the .migrating flag file, or null if none is present/corrupt.
|
||||
*/
|
||||
public function readFlag(): ?array
|
||||
{
|
||||
$flag = $this->webroot . DIRECTORY_SEPARATOR . self::FLAG_FILE;
|
||||
if (!is_file($flag)) {
|
||||
return null;
|
||||
}
|
||||
$raw = @file_get_contents($flag);
|
||||
if ($raw === false) {
|
||||
return null;
|
||||
}
|
||||
$data = json_decode($raw, true);
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the wizard's Test step patched .htaccess (with a backup), restore it.
|
||||
* Idempotent: no-op when no backup exists and no marker is present.
|
||||
*/
|
||||
private function restoreHtaccess(): void
|
||||
{
|
||||
$ht = $this->webroot . DIRECTORY_SEPARATOR . '.htaccess';
|
||||
$bk = $ht . '.migrate-grav-backup';
|
||||
if (is_file($bk)) {
|
||||
@copy($bk, $ht);
|
||||
@unlink($bk);
|
||||
return;
|
||||
}
|
||||
if (is_file($ht)) {
|
||||
$cur = (string) @file_get_contents($ht);
|
||||
if (str_contains($cur, '# migrate-grav stage exclusion')) {
|
||||
$stripped = preg_replace(
|
||||
'/^[ \t]*# migrate-grav stage exclusion.*\n[ \t]*(?:RewriteCond|RewriteBase)[^\n]*\n/m',
|
||||
'',
|
||||
$cur
|
||||
);
|
||||
if (is_string($stripped)) @file_put_contents($ht, $stripped);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively delete a directory tree.
|
||||
*
|
||||
* Symlinks are unlinked, never traversed — critical when the wizard's
|
||||
* staged tree contains symlinked plugin clones (a developer convenience
|
||||
* during iteration). Following the symlinks would attempt to delete real
|
||||
* source files outside the staged tree.
|
||||
*/
|
||||
private function removeDirectory(string $path): bool
|
||||
{
|
||||
if (is_link($path)) {
|
||||
return @unlink($path);
|
||||
}
|
||||
if (!is_dir($path)) {
|
||||
return true;
|
||||
}
|
||||
$items = @scandir($path);
|
||||
if ($items === false) {
|
||||
return false;
|
||||
}
|
||||
$ok = true;
|
||||
foreach ($items as $item) {
|
||||
if ($item === '.' || $item === '..') {
|
||||
continue;
|
||||
}
|
||||
$sub = $path . DIRECTORY_SEPARATOR . $item;
|
||||
if (is_link($sub)) {
|
||||
$ok = @unlink($sub) && $ok;
|
||||
} elseif (is_dir($sub)) {
|
||||
$ok = $this->removeDirectory($sub) && $ok;
|
||||
} else {
|
||||
$ok = @unlink($sub) && $ok;
|
||||
}
|
||||
}
|
||||
return @rmdir($path) && $ok;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user