feat(demo): add story 1 — Sorano: Rock and Time

This commit is contained in:
2026-06-20 21:19:57 +02:00
parent 42ed59a6b3
commit 8f87155c1d
5508 changed files with 1595740 additions and 124 deletions
+3
View File
@@ -0,0 +1,3 @@
.DS_Store
.idea/
*.swp
+115
View File
@@ -0,0 +1,115 @@
# v1.0.0-rc.6
## 06-16-2026
1. [](#bugfix)
* **A truncated Grav 2.0 download can no longer be staged.** PHP's HTTP stream reports end-of-file when the server, a proxy, or a flaky connection drops mid-transfer, so the kickoff's download loop exited cleanly on a partial file, and the only validation afterward was a 1KB minimum-size check — a release zip cut off at any point past that sailed through and got marked as staged. The wizard then failed at the extract step with the unhelpful `Could not open zip (code 35)` (libzip's "truncated zip archive"). The kickoff now cross-checks the bytes received against the response's `Content-Length`, verifies the file actually opens as a zip archive (with at least one entry) before the `.migrating` flag is written, deletes the partial file on any failure, and reports what went wrong — including how many bytes arrived versus how many were expected — along with the remedy (retry, or download the release manually and point `source_local_zip` at it).
2. [](#improved)
* The wizard's extract step now recognizes the libzip error codes that mean the staged zip is damaged (19 not-a-zip, 21 inconsistent, 35 truncated) and explains that the download was interrupted and how to recover (Reset Migration on the admin page, then stage again), instead of printing only the bare numeric code.
* A configured `source_local_zip` is validated as a readable archive before staging, so a bad manual download is caught at kickoff with a pointer to re-download and verify it with `unzip -t` (the file itself is left in place, since the user supplied it).
* Disk-full or quota errors while saving the download are now detected at write time instead of silently truncating the file.
# v1.0.0-rc.5
## 06-10-2026
1. [](#new)
* A customized admin path now carries across the migration. Grav 1.7 stores the admin route in `admin.yaml`, but Admin 2.0 reads its own `admin2.yaml`, so a site that changed `/admin` to e.g. `/backend` as a security measure would otherwise revert to the default after migrating. When the source route differs from the `/admin` default, the migration now writes it into the staged `admin2.yaml` (normalized to Admin 2.0's `/path` form), merging into any existing admin2 config rather than overwriting it. Only the route is carried — it's the one 1.7 admin setting Admin 2.0 has an equivalent for. [#6]
* The migrate page now checks whether the `user/data`, `user/accounts`, `user/config` and `user/env` folders are reachable over the web and, on Apache, offers a one-click fix that blocks them; on other webservers it shows the exact rule to add by hand.
* Migration now scans content for URL-based image transforms that bypass Grav's media object (e.g. `image.jpg?cropResize=300,200`) and turns on Grav 2.0's new `system.images.url_actions` toggle in the staged `system.yaml` when it finds any. Grav 1.7 applied these query-string actions with no gate; 2.0 disables them by default because they run with arguments an unauthenticated visitor controls. The normal developer-driven path is unaffected and deliberately left alone: a Twig/Markdown media call like `page.media['x'].cropResize(300,200)`, or a Markdown image whose file is the page's own media (`![](x.jpg?cropResize=300,200)`), is resolved through the media object into a hashed cache URL with no query string and never touches the toggle. 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 need the toggle, so those are what flip it on. External and protocol-relative URLs (`https://cdn.example.com/x.jpg?format=webp`, `//host/x.jpg?…`) are skipped, since they're served by the remote host and a CDN's own query string can't be a Grav image action. The report lists the pages and templates involved, and flags any transform that requests an image above the `system.images.max_pixels` ceiling (still refused even with the toggle on) so you can raise the limit or rework it.
# v1.0.0-rc.4
## 06-03-2026
1. [](#new)
* Migration now scans the source site for Twig-in-content usage (both per-page `process: twig: true` and the site-wide `system.yaml` opt-ins) and turns on Grav 2.0's new `security.twig_content` gate in the migrated install, so those pages keep rendering after promote. If any Twig-enabled page also reads site config inside Twig, the `config` access toggle is enabled too.
* Migration now scans Twig-enabled page content for the function and filter calls it uses and re-enables them for Grav 2.0, which tightened Twig security. Raw PHP functions (e.g. `strtoupper`) are added to both `system.twig.safe_functions`/`safe_filters` (so they're callable at all) and `security.twig_sandbox.allowed_functions`/`allowed_filters` (so sandboxed page content may call them); your existing `safe_functions` entries are preserved and merged in. Plugin-provided Twig functions (e.g. `unite_gallery`) are added to the sandbox allowlist and called out in the report — the providing plugin still needs to register them (ideally via the `onBuildTwigSandboxPolicy` event). Functions Grav 2.0 refuses outright — `Utils::isDangerousFunction()` (`system`, `exec`, `preg_replace`, …) and the sandbox's by-design exclusions (`constant`, `read_file`, `evaluate`, …) — are never added and are listed in the report instead. The sandbox lists are written in full (core defaults plus additions) because Grav merges them by index.
2. [](#improved)
* Step 1 pre-flight now warns when PHP's `set_time_limit`, `ignore_user_abort`, or `proc_open` are blocked by `disable_functions` (common on managed hosts such as RunCloud). With `set_time_limit` disabled the wizard cannot lift PHP's execution limit, so the long Step 2 copy or the GPM update of many plugins can exceed `max_execution_time` and die with a silent HTTP 500. The warning prints the current `max_execution_time` and the remedy (raise the host's `max_execution_time` / php-fpm `request_terminate_timeout` / proxy read timeout). The checks are advisory only and do not block the migration. [#5]
* Migration now strips the dead `twig.undefined_functions` / `undefined_filters` keys from the staged `system.yaml` — Grav 2.0 removed the blanket undefined-function escape hatch (an unlisted function/filter is now a hard error). The retained `safe_functions` / `safe_filters` keys are preserved and merged with anything found in content. Block-style values are rewritten cleanly; a multi-line flow value the rewrite can't safely touch is flagged for you to finish by hand.
* After turning on Grav 2.0's Twig in content security gate, the migration now also removes the matching legacy flag from the staged `system.yaml` so the migrated install has one setting in one place instead of two doing the same job. An explicit opt-out (`pages.process.twig: false`) is preserved.
* Removing that legacy flag handles more `system.yaml` shapes than before: quoted values, files without a trailing newline, Windows line endings, and unrelated `twig:` keys under sibling blocks (which a previous version could accidentally strip too). The `system.yaml` rewrite is now atomic so an interrupted migration cannot corrupt the staged config. When the legacy flag is written in flow-style (e.g. `process: { twig: true }`) the migration cannot safely auto-remove it without losing comments, and the wizard now flags this so you can finish the cleanup by hand.
* The source-site scanner now recognises the same quoted truthy values the per-page scanner already does, so a 1.x install with `pages.process.twig: "true"` correctly turns on the 2.0 security gate after migration.
3. [](#bugfix)
* **Backup zip created on Windows is now extractable.** `mg_zip_webroot` was passing `SplFileInfo::getPathname()` output straight into `ZipArchive::addFile($abs, $rel)` — on Windows that meant entry names were stored with native `\\` separators instead of the `/` the zip spec requires. Every standards-tolerant extractor (7-Zip, Windows Explorer's in-place viewer, macOS Archive Utility) treated the backslashes as literal filename characters, dumping every file in the zip's root with names like `user\plugins\admin\file.php` and rendering directory entries as a flat breadcrumb list. Now normalized to `/` regardless of OS. A standalone repair script `wizard/mg-repair-backup.php` ships in this release for users whose pre-rc.3 Windows backup zips are still on disk — it rewrites the entry names so the zip extracts correctly with any tool.
# v1.0.0-rc.3
## 05-13-2026
1. [](#improved)
* Pre-promote callout now warns to close any editor, git GUI (Sourcetree, GitHub Desktop, GitKraken), and terminal that has the webroot open — on Windows these processes hold file handles that block the Phase 2 delete pass.
* Promote step on Windows now runs a pre-flight scan for locked files BEFORE deleting anything, so the wizard reports the specific paths (e.g. `user/plugins/foo/.git/index`) the user needs to free, rather than half-destroying the webroot and failing midway. macOS and Linux skip the scan — `unlink()` succeeds on open files there.
* Promote failure callout now names the specific file that couldn't be deleted (e.g. `user/plugins/foo/.git/objects/pack/pack-abc.idx`) instead of just the top-level entry, so it's obvious which editor or git GUI to close.
* Promote failure callout now includes recovery instructions for the backup zip, including a Windows-specific warning that File Explorer's in-place zip viewer renders nested paths as a flat breadcrumb list (`system·src·Grav·…`) and that you must use **Right-click → Extract All…** rather than dragging entries out.
* README has a new **Recovering from a failed promote** section documenting the three-phase rollback model and platform-specific extraction commands.
2. [](#bugfix)
* nginx config snippet shown in Step 5 (Test) is now actually functional. The previous version put the PHP `location ~ \.php$` block as a sibling of the `location ^~ /grav-2/` prefix — but nginx never evaluates sibling regex locations once an `^~` prefix match wins, so PHP under the stage path was served as a static download. The snippet now nests the PHP block *inside* the prefix block and adds `fastcgi_split_path_info` and `fastcgi_index` to match Grav's documented nginx template. [#3]
* Outbound HTTP from the migration wizard now honors Grav's `system.http.proxy_url` and `system.http.proxy_cert_path` settings (and the standard `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` env vars as fallback). Previously, every HTTP call — the Grav 2.0 zip download, GPM catalog queries, GitHub release lookups, plugin/theme replacement zips, the curated compat registry — built its own stream context with no proxy support and silently failed for sites behind a corporate proxy. Kickoff now forwards the site's proxy config into the `.migrating` flag at staging time, and the standalone wizard reads it via a new `mg_http_context()` helper. [#2]
# v1.0.0-rc.2
## 05-06-2026
1. [](#improved)
* Step 2 compatibility breakdown now has a dedicated **Will be upgraded** bucket for plugins whose installed version reads as 1.7-only but for which GPM has a newer 2.0-compatible release. Previously these were rendered under **Incompatible** even though Phase 4's `gpm update` will land the new version — misleading because the user's skip/disable policy doesn't apply to them.
2. [](#bugfix)
* Replacement installs (admin2 + api) are now guaranteed even when the curated compatibility registry is offline or has been pruned of those entries — a hardcoded baseline maps `admin → admin2` (with `requires: [api]`) and is merged under the remote response so any remote entry still wins per slug.
* GPM upgrade detection no longer silently fails: `getgrav.org/downloads` returns the install URL under `zipball_url`, but the wizard was reading `download`. Normalized inside `mg_fetch_gpm_index` so every plugin with a newer 2.0-compatible release on GPM now lands in the **Will be upgraded** bucket and gets installed via GPM during the upgrade pass (instead of silently falling through to the GitHub fallback path).
# v1.0.0-rc.1
## 05-04-2026
1. [](#new)
* Two reset modes — **Restart Wizard** keeps the downloaded Grav 2.0 zip and lets you re-run from step 1, **Reset Migration** wipes everything and starts over.
2. [](#improved)
* Plugin upgrade lookups now ask GPM for the release that fits Grav 2.0 specifically, so suggested upgrades reflect what actually works on the destination.
* Plugin upgrades during migration are offered for any plugin with a newer 2.0-compatible release on GPM, not only those in the curated compatibility registry.
* Replacement installs (admin2, api, etc.) now fall back to the newest tagged GitHub release — including beta tags — when a plugin isn't on GPM yet.
* Plugin updates during Copy & Migrate now run through Grav 2.0's own `bin/gpm`, matching how a regular admin update behaves.
* Compatibility breakdown table groups rows by status with per-bucket counts (Compatible / Needs update / Incompatible / Will be installed) and color-coded labels for where each verdict came from.
* Symlinked plugins and themes are preserved through the migration, so developer setups with linked plugin clones don't get clobbered.
* Long-running steps (bulk copy, plugin upgrade) no longer time out on shared hosts with low `max_execution_time`.
* The "already staged" error when starting a new migration now points at the Restart/Reset buttons instead of asking you to delete files by hand.
3. [](#bugfix)
* Recursive delete during reset no longer follows symlinks — protects real files outside the staged tree.
* Plugin upgrade pass no longer clobbers plugins that are about to be replaced (admin → admin2, etc.).
* Compatibility policy (skip/disable) now applies *after* the upgrade pass, so freshly upgraded 2.0-compatible plugins aren't then disabled.
* CLI php detection handles hosts where `PHP_BINARY` points at `php-fpm` or `php-cgi`.
# v1.0.0-beta.5
## 04-25-2026
2. [](#bugfix)
* Use 'latest' URL to always get the latest version of Grav 2.0 beta
* Allow being run in Grav 1.7.49+
# v1.0.0-beta.4
## 04-21-2026
1. [](#improved)
* Default source URL now points at the released Grav 2.0 beta `grav-update` package (`https://getgrav.org/download/core/grav-update/2.0.0-beta.1?testing`) instead of a local dev zip. The update package ships system/vendor/bin only (no baseline `user/` pages) — this avoids polluting migrated sites with default home/typography pages that the full install package would otherwise drop on top of the source content.
* Staging flow reworked around a single bulk copy: Step 2 now copies the entire source `user/` directory verbatim into the staged install (including any custom folders beyond plugins/themes/accounts), then applies plugin compat policy, auto-updates, and replacement installs in place. Step 3 becomes a transform-only step that rewrites `admin.*``api.*` on the already-copied account yamls. Step 4 is a confirmation/summary of what landed in staged `user/`.
* Staged layout is now package-agnostic. After extract, `user/`, `user/{plugins,themes,accounts,config,data,pages}/`, and a root `.htaccess` (materialized from `webserver-configs/htaccess.txt`) are created when missing, so downstream steps work whether the source zip is `grav-update`, `grav`, or `grav-admin`.
* Theme handling and messaging: themes are always kept as-is (skip policy no longer removes them); incompatible themes render as ⚠ "Kept — Twig 3 compatibility enabled (verify before promoting)" rather than a scary ✗. Step 2 intro and stream subtitles explain the Twig 3 compat layer and that custom/unmarked themes are expected to work through it.
* Top-level `user/` dotfiles (`.git`, `.DS_Store`, editor backups) and symlinks are explicitly excluded from the bulk copy and recorded in the step summary.
2. [](#bugfix)
* `do_plugins_themes` and `do_content` no longer abort with "Source or staged user/ missing" when the source package is `grav-update` (which ships no `user/`). Extract normalizes the skeleton first.
* `mg_patch_staged_htaccess` (used by the Test step to set `RewriteBase` for sub-path testing) no longer fails on `grav-update`-based stages — the extract step materializes `.htaccess` from the zip's `webserver-configs/htaccess.txt` template when missing.
# v1.0.0-beta.3
## 04-20-2026
1. [](#bugfix)
* Use beta release URL of Grav 2.0
# v1.0.0-beta.2
## 04-16-2026
1. [](#bugfix)
* Preserve executable bits on `bin/*` during staged zip extract. The raw `fwrite()`-based extractor dropped the mode stored in the zip's central directory, landing `bin/grav`, `bin/gpm`, `bin/plugin`, and `bin/composer.phar` at `0644` post-migration and breaking CLI tooling on the fresh 2.0 install. Extract now honors the zip's unix mode when present, with a safety-net `chmod 0755` for anything directly under `bin/` so test-built zips (which omit mode metadata) also work.
# v1.0.0-beta.1
## 04-15-2026
1. [](#new)
* Initial scaffold: kickoff plugin for staging Grav 2.0 alongside an existing 1.7/1.8 site.
* CLI: `bin/plugin migrate-grav init` and `bin/plugin migrate-grav status`.
* Admin page with single-click staging that redirects to the standalone wizard.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Trilby Media, LLC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+172
View File
@@ -0,0 +1,172 @@
# Migrate to Grav 2.0
Stages a fresh Grav 2.0 install alongside your existing Grav 1.7 or 1.8 site
and hands off to a standalone migration wizard. The plugin itself does **not**
perform the migration — it exists solely to download Grav 2.0, drop a
self-contained `migrate.php` at your webroot, and get out of the way so the
wizard can run in a fresh PHP process with no 1.x code loaded.
## Why a standalone handoff?
In-place upgrades from 1.x → 2.0 are not safe: the vendor stacks differ, file
locks and opcache pinning can corrupt mid-upgrade state, and any failure
leaves an unbootable site. This plugin's job is to make the *handoff*
boring: download, drop, redirect. Everything risky happens later, in a
process that has no relationship to your running 1.x install.
## Requirements
- Grav 1.7.50+ or 1.8.x
- Write access to your webroot and `tmp/` directory
- PHP 7.3.6+ (for the kickoff itself; the 2.0 wizard requires PHP 8.3+)
## Installation
```bash
bin/gpm install migrate-grav
```
## Usage
### From the admin
Click **Migrate to Grav 2.0** in the sidebar. Press the staging button. Your
browser will be redirected to `/migrate.php` and you'll be running the wizard
outside of Grav 1.x.
### From the CLI
```bash
bin/plugin migrate-grav init
```
Then follow the printed instructions to start the wizard in a fresh PHP
process (either `php migrate.php` or by visiting the URL).
### Status
```bash
bin/plugin migrate-grav status
```
## Configuration
`user/config/plugins/migrate-grav.yaml`:
```yaml
enabled: true
source_url: 'https://getgrav.org/download/core/grav-update/2.0.0-beta.1?testing'
source_local_zip: '' # absolute path to a local 2.0 zip (dev only)
stage_dir: 'grav-2'
require_super_admin: true
```
## Twig in content
Grav 2.0 changed how editor-authored Twig (Twig inside page content) is secured: the
`security.twig_content` gate is off by default, a sandbox restricts what content Twig can
do, and the blanket `undefined_functions` escape hatch was removed — an unlisted Twig
function or filter is now a hard error. The `safe_functions` / `safe_filters` allow-lists
are retained (and hardened: command/code-execution functions can never be enabled). The
migration tries to preserve your 1.x behavior:
- It turns the `security.twig_content` gate back on when your source site used Twig in
content (per-page `process: twig: true` or the site-wide `system.yaml` opt-ins).
- It scans your Twig-enabled page content for the functions/filters it calls. **Raw PHP
functions** (e.g. `strtoupper`) are added to `system.twig.safe_functions` /
`safe_filters` (so they're callable at all) **and** to the
`security.twig_sandbox.allowed_functions` / `allowed_filters` lists (so sandboxed content
may call them). Your existing `safe_functions` entries are preserved and merged in.
- **Plugin-provided Twig functions** (e.g. `unite_gallery`) are added to the sandbox
allow-list, but the providing plugin must still register them — ideally via the
`onBuildTwigSandboxPolicy` event. These are listed in the migration report.
- Functions Grav 2.0 refuses — `Utils::isDangerousFunction()` (`system`, `exec`,
`preg_replace`, …) and the sandbox's by-design exclusions (`constant`, `read_file`,
`evaluate`, …) — are never added; the report lists them so you know those usages need
reworking.
**What it can't detect automatically:** custom **object methods and properties** used in
content Twig (for example a plugin object's `{{ thing.render() }}`) can't be found by a
static scan, because the object's class isn't known until runtime. Grav 2.0 already
allowlists the common page, media, config, and user classes, so most content keeps working.
If something still renders as raw Twig (or shows a sandbox placeholder) after migration,
check `logs/security.log`, then either add the class/method to
`security.twig_sandbox.allowed_methods` by hand or — better — update the providing plugin to
a 2.0 version that registers its safe Twig members via the `onBuildTwigSandboxPolicy` event.
The allowlists written to `user/config/security.yaml` are the **full** lists (core defaults
plus your additions) on purpose: Grav merges these lists by index, so a partial override
would corrupt the core defaults. If you prune an entry, leave the rest intact.
## URL-based image actions
Grav 1.7 applied image transforms straight from the query string —
`image.jpg?cropResize=300,200` resized on the fly with no gate. Grav 2.0 moved that behind
the new `system.images.url_actions` toggle (**off by default**), because those actions run
with arguments an unauthenticated visitor controls. The normal, developer-driven path is
unaffected: a Twig/Markdown media call like `page.media['x'].cropResize(300,200)`, or a
Markdown image whose file is the page's own media (`![](x.jpg?cropResize=300,200)`), is
resolved through the media object at render time into a hashed cache URL with no query
string — it never touches this toggle.
The migration scans your content for the query-string form that *does* bypass the media
object — absolute or rooted paths, `theme://`/`image://` stream paths, references to files
that aren't the page's media, and anything hand-written in a theme template — and turns
`system.images.url_actions` on in the staged `user/config/system.yaml` when it finds any, so
those images keep transforming after migration. Co-located Markdown media references (the
common case) are recognised and left alone, so the toggle is not flipped on needlessly.
External and protocol-relative URLs (`https://cdn.example.com/x.jpg?…`, `//host/x.jpg?…`)
are skipped too — those are served by the remote host, so a CDN's own `?format=webp` query
can't be mistaken for a Grav image action.
If a flagged transform requests an image larger than `system.images.max_pixels`
(25,000,000px by default), Grav still refuses it even with the toggle on — the report calls
those out so you can raise the ceiling or rework them.
## Aborting
If you want to start over before launching the wizard, remove:
- `.migrating` at your webroot
- The staged subdirectory (default: `grav-2/`)
- `tmp/grav-2.0-staged.zip`
Your existing Grav 1.x site is untouched.
## Recovering from a failed promote
The promote step is the only point where the wizard touches your live webroot. It runs in three phases:
1. **Phase 1 — backup zip.** Every file in your live 1.x install (except the staged `grav-2/`) is zipped to `grav-2/backup/migration-backup-<version>--<timestamp>.zip`. After promote this lands at `backup/<…>.zip` next to Grav's other backups.
2. **Phase 2 — delete.** Top-level entries at the webroot are removed.
3. **Phase 3 — promote.** Contents of `grav-2/` are renamed up to the webroot.
If Phase 2 or Phase 3 fails partway through, your live webroot may be partially destroyed. The backup zip from Phase 1 is your recovery artifact.
**Before you retry, identify and free the lock.** The most common failure (especially on Windows, where open files can't be deleted) is a code editor, git GUI, or terminal holding a file handle on something inside your webroot — `.git/index`, `.git/objects/pack/*.idx`, a `.log` being tailed. The wizard will now report the specific path that failed; close whatever has it open.
**To restore from the backup zip:**
- **Windows:** in File Explorer, right-click the zip → **Extract All…** and pick your webroot. The Extract All wizard reconstructs the directory tree correctly. 7-Zip and WinRAR also work fine.
- **macOS:** double-click in Finder (Archive Utility extracts a proper tree), or `unzip migration-backup-*.zip -d /path/to/webroot` from Terminal.
- **Linux:** `unzip migration-backup-*.zip -d /path/to/webroot`.
Once the webroot is restored, follow the **Aborting** steps above to clear the wizard state, then re-run the wizard from the admin.
### "The zip extracts as flat files with `·` or `\` in their names"
If you ran the wizard on **Windows** with a version **prior to 1.0.0-rc.3**, the backup zip it created has a separator bug — entry names use `\` (Windows path separator) instead of `/` (zip spec). Every standards-tolerant extractor (7-Zip, Archive Utility, Windows Explorer's in-place viewer) treats the backslashes as literal filename characters and dumps every file in the zip's root with names like `user\plugins\admin\file.php` (or, in some viewers, `user·plugins·admin·file.php`).
To repair such a zip, copy `user/plugins/migrate-grav/wizard/mg-repair-backup.php` from this plugin to any directory and run:
```
php mg-repair-backup.php migration-backup-1.7.x--20260507111032.zip
```
It writes `migration-backup-1.7.x--20260507111032.fixed.zip` next to the original with all entry names normalized to forward slashes. Extract the fixed zip with any tool and the directory tree will be correct.
The script is self-contained — no Grav, no Composer, no plugin context. It just needs PHP 8.1+ with the `zip` extension. Backup-zip writes from 1.0.0-rc.3 onward no longer have this bug regardless of OS.
## License
MIT
@@ -0,0 +1,8 @@
---
title: Migrate to Grav 2.0
template: migrate-grav
expires: 0
access:
admin.super: true
---
@@ -0,0 +1,281 @@
{% extends 'partials/base.html.twig' %}
{# Inlined to avoid a Twig namespace lookup; sized via .mg-rocket / .fa-rocket
compatible CSS so it drops in wherever the FA rocket icon was. #}
{% set rocket_svg %}<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="mg-rocket"><path d="M9.752 6.193c.599.6 1.73.437 2.528-.362s.96-1.932.362-2.531c-.599-.6-1.73-.438-2.528.361-.798.8-.96 1.933-.362 2.532"/><path d="M15.811 3.312c-.363 1.534-1.334 3.626-3.64 6.218l-.24 2.408a2.56 2.56 0 0 1-.732 1.526L8.817 15.85a.51.51 0 0 1-.867-.434l.27-1.899c.04-.28-.013-.593-.131-.956a9 9 0 0 0-.249-.657l-.082-.202c-.815-.197-1.578-.662-2.191-1.277-.614-.615-1.079-1.379-1.275-2.195l-.203-.083a10 10 0 0 0-.655-.248c-.363-.119-.675-.172-.955-.132l-1.896.27A.51.51 0 0 1 .15 7.17l2.382-2.386c.41-.41.947-.67 1.524-.734h.006l2.4-.238C9.005 1.55 11.087.582 12.623.208c.89-.217 1.59-.232 2.08-.188.244.023.435.06.57.093q.1.026.16.045c.184.06.279.13.351.295l.029.073a3.5 3.5 0 0 1 .157.721c.055.485.051 1.178-.159 2.065m-4.828 7.475.04-.04-.107 1.081a1.54 1.54 0 0 1-.44.913l-1.298 1.3.054-.38c.072-.506-.034-.993-.172-1.418a9 9 0 0 0-.164-.45c.738-.065 1.462-.38 2.087-1.006M5.205 5c-.625.626-.94 1.351-1.004 2.09a9 9 0 0 0-.45-.164c-.424-.138-.91-.244-1.416-.172l-.38.054 1.3-1.3c.245-.246.566-.401.91-.44l1.08-.107zm9.406-3.961c-.38-.034-.967-.027-1.746.163-1.558.38-3.917 1.496-6.937 4.521-.62.62-.799 1.34-.687 2.051.107.676.483 1.362 1.048 1.928.564.565 1.25.941 1.924 1.049.71.112 1.429-.067 2.048-.688 3.079-3.083 4.192-5.444 4.556-6.987.183-.771.18-1.345.138-1.713a3 3 0 0 0-.045-.283 3 3 0 0 0-.3-.041Z"/><path d="M7.009 12.139a7.6 7.6 0 0 1-1.804-1.352A7.6 7.6 0 0 1 3.794 8.86c-1.102.992-1.965 5.054-1.839 5.18.125.126 3.936-.896 5.054-1.902Z"/></svg>{% endset %}
{% block titlebar %}
<div class="button-bar">
<a class="btn" href="https://github.com/getgrav/grav-plugin-migrate-grav" target="_blank" rel="noopener">
<i class="fa fa-question-circle"></i> Help
</a>
</div>
<h1>{{ rocket_svg|raw }} Migrate to Grav 2.0</h1>
{% endblock %}
{% block content %}
<style>
.mg-page { padding: 24px 28px; max-width: 920px; margin: 0 auto; }
.mg-rocket { width: 1em; height: 1em; vertical-align: -0.125em; }
h1 .mg-rocket { width: 0.95em; height: 0.95em; margin-right: 6px; }
.mg-hero-icon .mg-rocket { width: 42px; height: 42px; color: #fff; }
.mg-hero {
position: relative;
overflow: hidden;
margin: 0 0 28px;
padding: 28px 32px;
border-radius: 8px;
background: linear-gradient(135deg, #5b3ea8 0%, #7b2ff7 55%, #ff4d8f 100%);
color: #fff;
box-shadow: 0 6px 24px -8px rgba(91, 62, 168, 0.55), 0 2px 4px rgba(0, 0, 0, 0.08);
}
.mg-hero::before {
content: "";
position: absolute;
top: -80px; right: -80px;
width: 280px; height: 280px;
background: radial-gradient(circle, rgba(255,255,255,0.18) 0%, rgba(255,255,255,0) 70%);
pointer-events: none;
}
.mg-hero-inner { position: relative; display: flex; align-items: center; gap: 24px; flex-wrap: wrap; }
.mg-hero-icon {
flex: 0 0 auto;
width: 80px; height: 80px;
border-radius: 14px;
background: rgba(255,255,255,0.14);
border: 1px solid rgba(255,255,255,0.22);
display: flex; align-items: center; justify-content: center;
font-size: 36px;
}
.mg-hero-text { flex: 1 1 320px; min-width: 0; }
.mg-hero-text h2 { margin: 0 0 6px; color: #fff; font-size: 22px; font-weight: 700; letter-spacing: -0.3px; }
.mg-hero-text p { margin: 0; color: rgba(255,255,255,0.92); font-size: 14px; line-height: 1.55; max-width: 560px; }
.mg-card {
background: #fff;
border: 1px solid #e6e8ef;
border-radius: 6px;
padding: 20px 24px;
margin: 0 0 20px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
.mg-card h3 { margin: 0 0 12px; font-size: 15px; font-weight: 600; color: #333; letter-spacing: 0.2px; }
.mg-card p { margin: 0 0 10px; font-size: 14px; line-height: 1.6; color: #555; }
.mg-card p:last-child { margin-bottom: 0; }
.mg-callout {
display: flex; gap: 12px; align-items: flex-start;
padding: 14px 18px;
margin: 0 0 20px;
background: #fffaf0;
border-left: 4px solid #f7a600;
border-radius: 4px;
font-size: 13.5px;
line-height: 1.55;
color: #5a4a1f;
}
.mg-callout .fa { color: #f7a600; font-size: 16px; margin-top: 2px; flex: 0 0 auto; }
.mg-callout-active { background: #eef4ff; border-left-color: #3b82f6; color: #1e3a70; }
.mg-callout-active .fa { color: #3b82f6; }
.mg-callout-danger { background: #fff1f1; border-left-color: #c62828; color: #6e1f1f; align-items: center; }
.mg-callout-danger .fa { color: #c62828; }
.mg-callout-ok { background: #effaf1; border-left-color: #2e9e54; color: #1d5e34; }
.mg-callout-ok .fa { color: #2e9e54; }
.mg-callout .mg-callout-body { flex: 1 1 320px; }
.mg-callout form { margin: 0; flex: 0 0 auto; }
.mg-snippet {
display: block;
margin: 8px 0 0;
padding: 10px 12px;
background: #1f1f2b;
color: #e9e9f0;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px;
white-space: pre-wrap;
word-break: break-word;
}
.mg-action {
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
padding: 18px 24px;
margin: 0 0 24px;
background: linear-gradient(to right, #f7f7fb, #fff);
border: 1px solid #e6e8ef;
border-radius: 6px;
}
.mg-action-active { background: linear-gradient(to right, #eef4ff, #fff); border-color: #d5e1f6; }
.mg-action .mg-action-label {
flex: 1 1 260px;
font-size: 14px;
color: #555;
}
.mg-action .mg-action-label strong { color: #333; }
.mg-action-buttons { display: flex; gap: 8px; flex-wrap: wrap; flex: 0 0 auto; }
.mg-btn {
display: inline-flex; align-items: center; gap: 8px;
padding: 10px 18px; margin: 0;
font-weight: 600; font-size: 13px;
border-radius: 4px; border: none; cursor: pointer;
text-decoration: none;
transition: background 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
line-height: 1;
}
.mg-btn-primary { background: #5b3ea8; color: #fff !important; box-shadow: 0 2px 6px rgba(91, 62, 168, 0.25); }
.mg-btn-primary:hover { background: #4a328b; color: #fff !important; transform: translateY(-1px); box-shadow: 0 4px 10px rgba(91, 62, 168, 0.35); }
.mg-btn-secondary { background: #fff; color: #5b3ea8 !important; border: 1px solid #d8d2ec; }
.mg-btn-secondary:hover { background: #f5f2fb; color: #4a328b !important; }
.mg-btn-danger { background: #fff; color: #c62828 !important; border: 1px solid #e5c2c2; }
.mg-btn-danger:hover { background: #fff1f1; color: #b72020 !important; }
.mg-steps { counter-reset: step; padding: 0; margin: 0; list-style: none; }
.mg-steps li {
position: relative;
padding: 8px 0 8px 44px;
font-size: 14px;
line-height: 1.5;
color: #444;
counter-increment: step;
}
.mg-steps li::before {
content: counter(step);
position: absolute;
left: 0; top: 8px;
width: 28px; height: 28px;
border-radius: 50%;
background: #eef0f8;
color: #5b3ea8;
font-weight: 700;
font-size: 13px;
display: flex; align-items: center; justify-content: center;
}
.mg-steps code { background: #f4f4f8; padding: 1px 6px; border-radius: 3px; font-size: 12.5px; color: #333; }
.mg-cli {
background: #1f1f2b;
color: #e9e9f0;
padding: 14px 18px;
border-radius: 5px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
display: flex; align-items: center; gap: 10px;
}
.mg-cli .mg-prompt { color: #8ad0ff; user-select: none; }
</style>
<div class="mg-page">
{% if migrate_grav_security and not migrate_grav_security.protected %}
<div class="mg-callout mg-callout-danger">
<i class="fa fa-shield"></i>
<div class="mg-callout-body">
<strong>Security: the <code>user/data</code>, <code>user/accounts</code>, <code>user/config</code> and <code>user/env</code> folders may be downloadable over the web.</strong>
{% if migrate_grav_security.apache %}
<p style="margin:6px 0 0;">On older installs these folders are only protected by file extension, so certificates, keys, tokens and databases stored under <code>user/data</code> can be fetched directly. Grav 2.0 blocks them outright — apply the same fix to this site now.</p>
{% if migrate_grav_security.can_autofix %}
<form method="post" action="{{ base_url_relative }}/task:migrateGravSecureHtaccess" style="margin-top:10px;">
<input type="hidden" name="admin-nonce" value="{{ admin.getNonce('admin-form') }}" />
<button type="submit" class="mg-btn mg-btn-danger">
<i class="fa fa-lock"></i> Secure these folders now
</button>
</form>
{% else %}
<p style="margin:6px 0 0;">The site root <code>.htaccess</code> is not writable, so this cannot be fixed automatically. Add this rule to it inside the <code>&lt;IfModule mod_rewrite.c&gt;</code> block:</p>
<code class="mg-snippet">{{ migrate_grav_security.snippet }}</code>
{% endif %}
{% else %}
<p style="margin:6px 0 0;">This site is served by <code>{{ migrate_grav_security.server ?: 'a non-Apache webserver' }}</code>, which ignores <code>.htaccess</code>. Grav cannot fix this for you — add the equivalent rule to your webserver config and reload it:</p>
<code class="mg-snippet">{{ migrate_grav_security.snippet }}</code>
{% endif %}
</div>
</div>
{% elseif migrate_grav_security and migrate_grav_security.protected %}
<div class="mg-callout mg-callout-ok">
<i class="fa fa-shield"></i>
<div class="mg-callout-body">
<strong>The sensitive <code>user/</code> folders are protected from direct web access.</strong>
</div>
</div>
{% endif %}
<div class="mg-hero">
<div class="mg-hero-inner">
<div class="mg-hero-icon">{{ rocket_svg|raw }}</div>
<div class="mg-hero-text">
<h2>Ready to move to Grav 2.0</h2>
<p>Stage a fresh Grav 2.0 install alongside this site and hand off to a standalone migration wizard. Your current site stays untouched until you explicitly promote the new install.</p>
</div>
</div>
</div>
{% if migrate_grav_state %}
<div class="mg-callout mg-callout-active">
<i class="fa fa-refresh fa-spin"></i>
<div>
<strong>A migration is already staged.</strong> Resume it in a fresh PHP process, restart the wizard from step 1, or reset to start over from scratch. The staged files live at <code>/{{ migrate_grav_state.stage_dir }}/</code> and <code>/{{ migrate_grav_state.staged_zip }}</code>.
</div>
</div>
<div class="mg-action mg-action-active">
<div class="mg-action-label">
<strong>Staged {{ migrate_grav_state.created|date('Y-m-d H:i') }}</strong> from Grav <code>{{ migrate_grav_state.source.grav_version }}</code> by <code>{{ migrate_grav_state.source.trigger }}</code>.
</div>
<div class="mg-action-buttons">
<a class="mg-btn mg-btn-primary" href="{{ base_url_simple }}{{ migrate_grav_state.wizard_url }}" target="_blank" rel="noopener">
<i class="fa fa-arrow-right"></i> Continue migration
</a>
<form method="post" action="{{ base_url_relative }}/task:migrateGravRestart" style="margin:0" onsubmit="return confirm('Restart the wizard? This clears the staged Grav 2.0 directory and any wizard progress, but keeps the downloaded release zip and the migration token so you can re-run from step 1 without re-downloading. Your original site is untouched.');">
<input type="hidden" name="admin-nonce" value="{{ admin.getNonce('admin-form') }}" />
<button type="submit" class="mg-btn mg-btn-secondary">
<i class="fa fa-undo"></i> Restart Wizard
</button>
</form>
<form method="post" action="{{ base_url_relative }}/task:migrateGravReset" style="margin:0" onsubmit="return confirm('Reset the migration completely? This deletes .migrating, migrate.php, the staged zip, and the staged Grav 2.0 directory. Your original site is untouched. The next start will re-download Grav 2.0.');">
<input type="hidden" name="admin-nonce" value="{{ admin.getNonce('admin-form') }}" />
<button type="submit" class="mg-btn mg-btn-danger">
<i class="fa fa-trash"></i> Reset Migration
</button>
</form>
</div>
</div>
{% else %}
<div class="mg-callout">
<i class="fa fa-info-circle"></i>
<div>
<strong>Nothing is changed in your current site</strong> until you complete and explicitly promote the new install. You can abandon the migration at any time by clicking Reset on this page.
</div>
</div>
<div class="mg-action">
<div class="mg-action-label">
<strong>Stage Grav 2.0 in <code>/{{ config.plugins['migrate-grav'].stage_dir }}/</code></strong> and open the migration wizard in a fresh PHP process.
</div>
<form method="post" action="{{ base_url_relative }}/task:migrateGravInit" style="margin:0">
<input type="hidden" name="admin-nonce" value="{{ admin.getNonce('admin-form') }}" />
<button type="submit" class="mg-btn mg-btn-primary">
<i class="fa fa-play"></i> Stage &amp; start wizard
</button>
</form>
</div>
{% endif %}
<div class="mg-card">
<h3>What this does</h3>
<ol class="mg-steps">
<li>Downloads the latest Grav 2.0 release.</li>
<li>Drops a standalone <code>migrate.php</code> file at your webroot.</li>
<li>Saves the 2.0 release zip to <code>tmp/</code> for the wizard to extract.</li>
<li>Writes a single-use token to <code>.migrating</code>.</li>
<li>Redirects you to <code>/migrate.php</code> — your browser leaves Grav 1.x entirely from this point on.</li>
</ol>
</div>
<div class="mg-card">
<h3>Prefer the CLI?</h3>
<p>Run the same kickoff without the admin UI:</p>
<div class="mg-cli">
<span class="mg-prompt">$</span> bin/plugin migrate-grav init
</div>
</div>
</div>
{% endblock %}
+63
View File
@@ -0,0 +1,63 @@
name: Migrate Grav
type: plugin
slug: migrate-grav
version: 1.0.0-rc.6
testing: true
description: "Stages a new major Grav release alongside an existing site and hands off to a standalone migration wizard."
icon: rocket
author:
name: Team Grav
email: devs@getgrav.org
url: https://getgrav.org
homepage: https://github.com/getgrav/grav-plugin-migrate-grav
keywords: migrate, upgrade, migration
bugs: https://github.com/getgrav/grav-plugin-migrate-grav/issues
docs: https://github.com/getgrav/grav-plugin-migrate-grav/blob/main/README.md
license: MIT
dependencies:
- { name: grav, version: '>=1.7.49' }
compatibility:
grav: ['1.7', '1.8']
form:
validation: strict
fields:
enabled:
type: toggle
label: PLUGIN_ADMIN.PLUGIN_STATUS
highlight: 1
default: 1
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
validate:
type: bool
source_url:
type: text
label: 2.0 release URL
default: 'https://getgrav.org/download/core/grav-update/latest?testing'
help: URL of the Grav 2.0 update zip (system files, no baseline user/ content). Avoids polluting the migrated site with default home/typography pages. The wizard fetches admin2, api, and any 2.0-compatible plugin updates from GPM separately. Override only for testing.
source_local_zip:
type: text
label: Local 2.0 zip (dev only)
default: ''
help: Absolute path to a local grav-2.0.zip. When set, overrides source_url.
stage_dir:
type: text
label: Stage subdirectory
default: 'grav-2'
help: Subdirectory inside the webroot where the new Grav 2.0 install will be staged.
require_super_admin:
type: toggle
label: Require super admin to trigger
highlight: 1
default: 1
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
validate:
type: bool
@@ -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]";
}
}
+519
View File
@@ -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;
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace Grav\Plugin\Console;
use Grav\Common\Grav;
use Grav\Console\ConsoleCommand;
use Grav\Plugin\MigrateGrav\Kickoff;
use RuntimeException;
use Symfony\Component\Console\Input\InputOption;
/**
* Stages a Grav 2.0 install and writes a migration handoff token. Does NOT
* execute the wizard itself — the user starts a fresh PHP process so no
* 1.7/1.8 code remains loaded once migration begins.
*
* Usage: bin/plugin migrate-grav init [--source-url=URL] [--source-zip=PATH]
*/
class InitCommand extends ConsoleCommand
{
protected function configure(): void
{
$this
->setName('init')
->setDescription('Stage Grav 2.0 alongside this site and prepare the migration wizard.')
->addOption(
'source-url',
null,
InputOption::VALUE_REQUIRED,
'URL of the Grav 2.0 release zip (overrides plugin config).'
)
->addOption(
'source-zip',
null,
InputOption::VALUE_REQUIRED,
'Local path to a Grav 2.0 zip (overrides URL — for development).'
);
}
protected function serve(): int
{
$grav = Grav::instance();
$config = (array) $grav['config']->get('plugins.migrate-grav', []);
if (!($config['enabled'] ?? false)) {
$this->output->writeln('<red>Plugin migrate-grav is not enabled.</red>');
return 1;
}
$sourceUrl = $this->input->getOption('source-url');
$sourceZip = $this->input->getOption('source-zip');
if ($sourceUrl) {
$config['source_url'] = $sourceUrl;
}
if ($sourceZip) {
$config['source_local_zip'] = $sourceZip;
}
// Forward the site's GPM channel so Kickoff can append `?testing` to
// the source URL when the user has opted into the testing channel.
$config['gpm_channel'] = (string) $grav['config']->get('system.gpm.releases', 'stable');
// Forward Grav's proxy config so the zip download (Kickoff) and the
// standalone wizard's outbound HTTP (via .migrating flag) both honor
// system.http.proxy_url / proxy_cert_path.
$config['proxy_url'] = (string) $grav['config']->get('system.http.proxy_url', '');
$config['proxy_cert_path'] = (string) $grav['config']->get('system.http.proxy_cert_path', '');
require_once dirname(__DIR__) . '/classes/Kickoff.php';
$webroot = defined('GRAV_WEBROOT') ? GRAV_WEBROOT : GRAV_ROOT;
$kickoff = new Kickoff($webroot, $config);
try {
$payload = $kickoff->run([
'grav_version' => GRAV_VERSION,
'admin_user' => null,
'trigger' => 'cli',
]);
} catch (RuntimeException $e) {
$this->output->writeln('<red>Kickoff failed:</red> ' . $e->getMessage());
return 1;
}
$this->output->writeln('');
$this->output->writeln('<green>Grav 2.0 staged successfully.</green>');
$this->output->writeln('');
$this->output->writeln(' Token: ' . $payload['token']);
$this->output->writeln(' Stage dir: ' . $payload['stage_dir']);
$this->output->writeln(' Staged zip: ' . $payload['staged_zip']);
$this->output->writeln(' Wizard URL: ' . $payload['wizard_url']);
$this->output->writeln('');
$this->output->writeln('<yellow>Next step — open the wizard in your browser:</yellow>');
$this->output->writeln('');
$this->output->writeln(' ' . $payload['wizard_url']);
$this->output->writeln('');
$this->output->writeln('<cyan>Important:</cyan> do NOT continue inside this Grav 1.x process — the');
$this->output->writeln('wizard runs standalone to avoid file locks and library conflicts.');
return 0;
}
}
@@ -0,0 +1,47 @@
<?php
namespace Grav\Plugin\Console;
use Grav\Console\ConsoleCommand;
/**
* Reports the current state of any in-progress migration.
*
* Usage: bin/plugin migrate-grav status
*/
class StatusCommand extends ConsoleCommand
{
protected function configure(): void
{
$this
->setName('status')
->setDescription('Show the state of any in-progress Grav 2.0 migration.');
}
protected function serve(): int
{
$webroot = defined('GRAV_WEBROOT') ? GRAV_WEBROOT : GRAV_ROOT;
$flag = $webroot . DIRECTORY_SEPARATOR . '.migrating';
if (!is_file($flag)) {
$this->output->writeln('<cyan>No migration in progress.</cyan>');
return 0;
}
$raw = file_get_contents($flag);
$data = $raw !== false ? json_decode($raw, true) : null;
if (!is_array($data)) {
$this->output->writeln('<red>Found .migrating but contents are not valid JSON.</red>');
return 1;
}
$this->output->writeln('<green>Migration staged.</green>');
$this->output->writeln(' Created: ' . date('c', (int)($data['created'] ?? 0)));
$this->output->writeln(' Token: ' . ($data['token'] ?? '(none)'));
$this->output->writeln(' Stage dir: ' . ($data['stage_dir'] ?? '(none)'));
$this->output->writeln(' Wizard URL: ' . ($data['wizard_url'] ?? '(none)'));
$this->output->writeln(' Source: ' . json_encode($data['source'] ?? [], JSON_UNESCAPED_SLASHES));
return 0;
}
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-rocket-takeoff" viewBox="0 0 16 16">
<path d="M9.752 6.193c.599.6 1.73.437 2.528-.362s.96-1.932.362-2.531c-.599-.6-1.73-.438-2.528.361-.798.8-.96 1.933-.362 2.532"/>
<path d="M15.811 3.312c-.363 1.534-1.334 3.626-3.64 6.218l-.24 2.408a2.56 2.56 0 0 1-.732 1.526L8.817 15.85a.51.51 0 0 1-.867-.434l.27-1.899c.04-.28-.013-.593-.131-.956a9 9 0 0 0-.249-.657l-.082-.202c-.815-.197-1.578-.662-2.191-1.277-.614-.615-1.079-1.379-1.275-2.195l-.203-.083a10 10 0 0 0-.655-.248c-.363-.119-.675-.172-.955-.132l-1.896.27A.51.51 0 0 1 .15 7.17l2.382-2.386c.41-.41.947-.67 1.524-.734h.006l2.4-.238C9.005 1.55 11.087.582 12.623.208c.89-.217 1.59-.232 2.08-.188.244.023.435.06.57.093q.1.026.16.045c.184.06.279.13.351.295l.029.073a3.5 3.5 0 0 1 .157.721c.055.485.051 1.178-.159 2.065m-4.828 7.475.04-.04-.107 1.081a1.54 1.54 0 0 1-.44.913l-1.298 1.3.054-.38c.072-.506-.034-.993-.172-1.418a9 9 0 0 0-.164-.45c.738-.065 1.462-.38 2.087-1.006M5.205 5c-.625.626-.94 1.351-1.004 2.09a9 9 0 0 0-.45-.164c-.424-.138-.91-.244-1.416-.172l-.38.054 1.3-1.3c.245-.246.566-.401.91-.44l1.08-.107zm9.406-3.961c-.38-.034-.967-.027-1.746.163-1.558.38-3.917 1.496-6.937 4.521-.62.62-.799 1.34-.687 2.051.107.676.483 1.362 1.048 1.928.564.565 1.25.941 1.924 1.049.71.112 1.429-.067 2.048-.688 3.079-3.083 4.192-5.444 4.556-6.987.183-.771.18-1.345.138-1.713a3 3 0 0 0-.045-.283 3 3 0 0 0-.3-.041Z"/>
<path d="M7.009 12.139a7.6 7.6 0 0 1-1.804-1.352A7.6 7.6 0 0 1 3.794 8.86c-1.102.992-1.965 5.054-1.839 5.18.125.126 3.936-.896 5.054-1.902Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+215
View File
@@ -0,0 +1,215 @@
<?php
namespace Grav\Plugin;
use Grav\Common\Plugin;
use Grav\Plugin\MigrateGrav\HtaccessSecurity;
use Grav\Plugin\MigrateGrav\Kickoff;
use RocketTheme\Toolbox\Event\Event;
use RuntimeException;
class MigrateGravPlugin extends Plugin
{
public static function getSubscribedEvents(): array
{
return [
'onPluginsInitialized' => ['onPluginsInitialized', 0],
];
}
public function onPluginsInitialized(): void
{
if (!$this->isAdmin()) {
return;
}
$this->enable([
'onAdminMenu' => ['onAdminMenu', 0],
'onAdminTwigTemplatePaths' => ['onAdminTwigTemplatePaths', 0],
'onAdminTaskExecute' => ['onAdminTaskExecute', 0],
'onTwigSiteVariables' => ['onTwigSiteVariables', 0],
]);
}
public function onAdminMenu(): void
{
$this->grav['twig']->plugins_hooked_nav['Migrate to Grav 2.0'] = [
'route' => 'migrate-grav',
'icon' => 'fa-rocket',
];
}
public function onAdminTwigTemplatePaths(Event $event): void
{
$paths = $event['paths'];
$paths[] = __DIR__ . '/admin/templates';
$event['paths'] = $paths;
}
public function onAdminTaskExecute(Event $event): void
{
$task = $event['method'] ?? null;
$controller = $event['controller'] ?? null;
$authorized = !$controller
|| !method_exists($controller, 'isAuthorizedFunction')
|| $controller->isAuthorizedFunction('admin.super');
if ($task === 'taskMigrateGravInit') {
if (!$authorized) {
$this->grav['admin']->setMessage('Super admin required to start migration.', 'error');
return;
}
try {
$payload = $this->runKickoff('admin');
} catch (RuntimeException $e) {
$this->grav['admin']->setMessage('Migration kickoff failed: ' . $e->getMessage(), 'error');
return;
}
$this->grav->redirect($payload['wizard_url'], 302);
return;
}
if ($task === 'taskMigrateGravSecureHtaccess') {
if (!$authorized) {
$this->grav['admin']->setMessage('Super admin required to secure the webserver config.', 'error');
return;
}
$result = $this->newHtaccessSecurity()->applyFix();
if ($result['errors']) {
$this->grav['admin']->setMessage(
'Could not fully secure the user/ folders: ' . implode('; ', $result['errors']),
'error'
);
} else {
$done = [];
if ($result['patched']) {
$done[] = 'added the folder block to .htaccess';
}
if ($result['created']) {
$done[] = 'created ' . implode(', ', $result['created']);
}
$this->grav['admin']->setMessage(
$done ? 'Secured the sensitive user/ folders: ' . implode('; ', $done) . '.'
: 'The sensitive user/ folders were already protected.',
'info'
);
}
$this->grav->redirect($this->grav['admin']->getAdminRoute('/migrate-grav'), 302);
return;
}
if ($task === 'taskMigrateGravReset' || $task === 'taskMigrateGravRestart') {
$isRestart = $task === 'taskMigrateGravRestart';
$verb = $isRestart ? 'restart wizard' : 'reset migration';
if (!$authorized) {
$this->grav['admin']->setMessage("Super admin required to {$verb}.", 'error');
return;
}
$result = $this->runReset($isRestart ? 'restart' : 'full');
if ($result['errors']) {
$label = $isRestart ? 'Restart' : 'Reset';
$this->grav['admin']->setMessage("{$label} incomplete: " . implode('; ', $result['errors']), 'error');
} else {
if (!$result['removed']) {
$msg = $isRestart ? 'Nothing to restart — no migration is staged.' : 'Nothing to reset.';
} else {
$msg = ($isRestart ? 'Wizard restarted. ' : 'Migration reset. ')
. 'Removed: ' . implode(', ', $result['removed']);
}
$this->grav['admin']->setMessage($msg, 'info');
}
// Restart preserves .migrating, so send the user back into the
// wizard at the staged step. Full reset has nothing to resume —
// return to the migrate-grav admin page.
if ($isRestart && !$result['errors']) {
$state = $this->newKickoff()->readFlag();
if (!empty($state['wizard_url'])) {
$this->grav->redirect($state['wizard_url'], 302);
return;
}
}
// Pass the Route object directly — Grav::redirect() handles
// Route instances via toString(true), which already includes the
// install base and admin route (no doubling, no manual stitching).
$this->grav->redirect($this->grav['admin']->getAdminRoute('/migrate-grav'), 302);
return;
}
}
/**
* Shared kickoff entry point used by both admin and CLI.
*/
public function runKickoff(string $trigger, ?string $adminUser = null): array
{
return $this->newKickoff()->run([
'grav_version' => GRAV_VERSION,
'admin_user' => $adminUser,
'trigger' => $trigger,
]);
}
/**
* Shared reset entry point used by admin and CLI.
*
* @param string $mode 'full' nukes everything; 'restart' keeps the staged
* zip + flag and rewinds the wizard to step='staged'.
*/
public function runReset(string $mode = 'full'): array
{
return $this->newKickoff()->reset($mode);
}
/**
* Expose the current .migrating state to admin twig templates, so the
* migrate-grav page can switch between "Start" and "Continue/Reset" UI
* without round-tripping through an AJAX call.
*/
public function onTwigSiteVariables(): void
{
// Only attach the flag state on the migrate-grav admin page. Checking
// the request URI is more reliable than poking admin internals.
$path = (string) $this->grav['uri']->path();
if (!str_ends_with(rtrim($path, '/'), '/migrate-grav')) {
return;
}
$state = $this->newKickoff()->readFlag();
$this->grav['twig']->twig_vars['migrate_grav_state'] = $state;
$security = $this->newHtaccessSecurity();
$status = $security->status();
$status['snippet'] = $status['protected'] ? '' : $security->manualSnippet();
$this->grav['twig']->twig_vars['migrate_grav_security'] = $status;
}
private function newHtaccessSecurity(): HtaccessSecurity
{
require_once __DIR__ . '/classes/HtaccessSecurity.php';
$webroot = defined('GRAV_WEBROOT') ? GRAV_WEBROOT : GRAV_ROOT;
return new HtaccessSecurity($webroot);
}
private function newKickoff(): Kickoff
{
require_once __DIR__ . '/classes/Kickoff.php';
$config = (array) $this->config->get('plugins.migrate-grav', []);
$webroot = defined('GRAV_WEBROOT') ? GRAV_WEBROOT : GRAV_ROOT;
// Forward the site's GPM channel so Kickoff can match the release
// channel the rest of the admin uses (?testing vs stable).
$config['gpm_channel'] = (string) $this->grav['config']->get('system.gpm.releases', 'stable');
// Forward Grav's proxy config so both the Kickoff's own zip download
// AND the standalone wizard (via the .migrating flag) honor it.
// Sites behind a corporate proxy were silently breaking on every
// outbound call (GPM catalog, GitHub release lookups, the 2.0 zip).
$config['proxy_url'] = (string) $this->grav['config']->get('system.http.proxy_url', '');
$config['proxy_cert_path'] = (string) $this->grav['config']->get('system.http.proxy_cert_path', '');
return new Kickoff($webroot, $config);
}
}
+8
View File
@@ -0,0 +1,8 @@
enabled: true
# Base URL for the Grav 2.0 release zip. The kickoff appends `?testing` at
# runtime when the site's `system.gpm.releases` is set to `testing` — match
# whatever channel the rest of the Grav admin UI is using to find updates.
source_url: 'https://getgrav.org/download/core/grav-update/latest'
source_local_zip: ''
stage_dir: 'grav-2'
require_super_admin: true
@@ -0,0 +1,151 @@
<?php
/**
* Grav 2.0 migration backup-zip repair tool — standalone.
*
* Use this if you ran the migration wizard on Windows with a version prior
* to 1.0.0-rc.3, your promote failed partway, and now the backup zip won't
* extract correctly — every file ends up in the zip's root with literal
* backslashes in its name, no directory tree.
*
* Cause: PHP's ZipArchive on Windows stored entry names with native '\\'
* separators, but the zip spec requires '/' as the path separator. Non-
* strict extractors (7-zip on Windows, macOS Archive Utility, Windows
* Explorer's in-place viewer) treat the backslashes as literal filename
* characters instead of path separators.
*
* This script reads the broken zip, normalizes every entry name's
* separators to '/', and writes a fresh zip alongside it. The fixed zip
* can then be extracted with any standard tool (Right-click → Extract All
* on Windows, double-click on macOS, `unzip` on Linux).
*
* Usage (Windows / macOS / Linux, anywhere PHP 8.1+ is installed):
*
* php mg-repair-backup.php <broken-zip>
* → writes <broken-zip>.fixed.zip next to the original
*
* php mg-repair-backup.php <broken-zip> <output-zip>
* → writes to a specific path
*
* Standalone — no Grav, no Composer autoloader, no plugin context needed.
*/
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(403);
echo "This script is CLI-only. Run it from a terminal.";
exit(1);
}
if (!extension_loaded('zip')) {
fwrite(STDERR, "PHP zip extension is required. Install php-zip and try again.\n");
exit(2);
}
$src = $argv[1] ?? null;
$dst = $argv[2] ?? null;
if ($src === null) {
fwrite(STDERR, "usage: php " . basename(__FILE__) . " <broken-zip> [<output-zip>]\n");
exit(2);
}
if (!is_file($src)) {
fwrite(STDERR, "Input zip not found: {$src}\n");
exit(2);
}
if ($dst === null) {
// Default: write next to the source with .fixed.zip suffix, so the
// user can compare or roll back if anything looks off.
if (substr($src, -4) === '.zip') {
$dst = substr($src, 0, -4) . '.fixed.zip';
} else {
$dst = $src . '.fixed.zip';
}
}
if (realpath($src) !== false && realpath(dirname($dst)) !== false
&& realpath($src) === realpath($dst)) {
fwrite(STDERR, "Refusing to write output to the same path as input.\n");
exit(2);
}
$in = new ZipArchive();
$rc = $in->open($src);
if ($rc !== true) {
fwrite(STDERR, "Could not open input zip (ZipArchive code {$rc}): {$src}\n");
exit(2);
}
@unlink($dst);
$out = new ZipArchive();
$rc = $out->open($dst, ZipArchive::CREATE | ZipArchive::OVERWRITE);
if ($rc !== true) {
$in->close();
fwrite(STDERR, "Could not create output zip (ZipArchive code {$rc}): {$dst}\n");
exit(2);
}
$total = $in->numFiles;
$normalized = 0;
$copied = 0;
$failed = [];
for ($i = 0; $i < $total; $i++) {
$name = $in->getNameIndex($i);
if ($name === false) {
$failed[] = "(index {$i}: getNameIndex failed)";
continue;
}
$fixed = str_replace('\\', '/', $name);
if ($fixed !== $name) $normalized++;
$isDir = substr($fixed, -1) === '/';
if ($isDir) {
$out->addEmptyDir(rtrim($fixed, '/'));
$copied++;
continue;
}
$bytes = $in->getFromIndex($i);
if ($bytes === false) {
$failed[] = $name;
continue;
}
if (!$out->addFromString($fixed, $bytes)) {
$failed[] = $name;
continue;
}
$copied++;
}
$in->close();
$closeOk = $out->close();
echo "── repair summary ──\n";
echo "input: {$src}\n";
echo "output: {$dst}\n";
echo "entries scanned: {$total}\n";
echo "entries fixed: {$normalized} (had backslashes)\n";
echo "entries copied: {$copied}\n";
echo "close ok: " . ($closeOk ? 'yes' : 'NO') . "\n";
if ($failed !== []) {
echo "FAILED entries: " . count($failed) . "\n";
foreach ($failed as $f) echo " - {$f}\n";
}
if ($normalized === 0 && $failed === []) {
echo "\nNote: input zip already had all forward-slashed entry names — it\n";
echo " was already valid. If you're still seeing flat extraction,\n";
echo " the problem is with your extractor, not the zip.\n";
}
echo "\nNext step: extract {$dst} as you normally would.\n";
echo " Windows: right-click → Extract All…\n";
echo " macOS: double-click, or unzip in Terminal\n";
echo " Linux: unzip " . escapeshellarg($dst) . "\n";
exit($failed !== [] || !$closeOk ? 1 : 0);
File diff suppressed because it is too large Load Diff