feat(demo): add story 1 — Sorano: Rock and Time
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
.DS_Store
|
||||
.idea/
|
||||
*.swp
|
||||
@@ -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 (``), 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.
|
||||
@@ -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.
|
||||
@@ -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 (``), 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><IfModule mod_rewrite.c></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 & 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 %}
|
||||
@@ -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]";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user