All plugins

JCORE Siirto

Migrate a WordPress site to another installation: themes, plugins, media, files and database, using only administrator accounts.

This file covers the repository and the development workflow. What the plugin does, and how to use it, is in readme.txt.

Requirements

  • PHP 8.2+
  • WordPress 6.7+
  • Node 22+ and pnpm
  • Composer and WP-CLI (WP-CLI is only needed for the translation targets)

Getting started

pnpm install
composer install
pnpm build

Then run a throwaway WordPress with the plugin mounted:

pnpm playground

That serves WordPress Playground on http://localhost:8829 from .wp/blueprint.json, logged in as admin / password. Plugin Check is installed alongside it.

Scripts

CommandWhat it does
pnpm buildBuild src/ into build/.
pnpm startSame, in watch mode.
pnpm checkEverything CI lints: ESLint, Stylelint and PHPCS.
pnpm lint:js / lint:css / lint:phpOne linter at a time.
pnpm formatFormat src/ with wp-scripts format.
composer lint:fixFix what PHPCBF can fix.
pnpm i18nRegenerate the POT, the MO files and the JS translation JSON.
pnpm playgroundServe the plugin in WordPress Playground.

A Makefile wraps the same scripts (make ci is the entry point the shared publish workflow calls); it is a shim, not a second build system.

Layout

jcore-siirto.php      Plugin header, constants, autoloader, bootstrap
uninstall.php         Removes stored data on delete
includes/             Classes; Jcore\Siirto\Foo_Bar lives in includes/class-foo-bar.php
  class-crypto.php    Key format, request signatures, response encryption
  class-roots.php     Which directories may be read/written, and what is always excluded
  class-replacer.php  URL/path rewriting that walks serialized data without unserialize()
  source/             Keys and the signed endpoints a target reads from
  target/             Job state, client, file/database importers, finalize/rollback
  rest/               Admin REST routes for the React screen
src/admin/            The Tools → Site migration screen
languages/            .po sources; .pot, .mo and .json are generated

How a migration works

The target (the site being overwritten) pulls from the source. Pulling means the endpoints exposed to the network are read-only, and the decision to overwrite a site is made by an administrator of that site.

  1. On the source, an administrator re-enters their password and creates a migration key (siirto1.<base64url>): the source URL, a key ID and a 256-bit secret. It is shown once.
  2. On the target, an administrator pastes the key. The target calls hello and shows what the source has.
  3. The target copies the selected file roots into wp-content/jcore-siirto-<random>/ and the database into tables named jt<tag>_<table>, rewriting URLs, paths and prefix-dependent keys (wp_user_roles, wp_capabilities…) as rows arrive. The browser drives this with short step requests, so no long request or WP-Cron is needed and closing the tab pauses it.
  4. Nothing live has changed yet. The administrator types the site's host name and their password to finalize: staged top-level entries are rename()d into place (what they replace is moved aside) and the tables are swapped with one atomic RENAME TABLE (the replaced ones become jo<tag>_<table>). The source is told to revoke the key.
  5. Until Clean up, everything replaced is kept and Roll back restores it.

The plugin's own tables (*_jcore_siirto_*) and options are never exported or replaced, so job state survives the swap and the source's key never ends up on the target.

Source endpoints

POST /jcore-siirto/v1/source/{hello|list|read|schema|rows|finish}, called with ?rest_route= so permalinks don't matter. These routes are only registered while an unexpired key exists.

ActionScopeReturns
helloanyVersions, URLs, paths, table prefix, roots, table list
listfilesOne page of a directory (names, sizes, mtimes)
readfilesFramed file contents or byte ranges, up to 4 MB per call
schemadatabaseSHOW CREATE TABLE, columns, primary key
rowsdatabaseA batch of rows (keyset paging on single-column keys)
finishanyRevokes the key

Admin routes

RoutePurpose
GET /sendSource checks, keys, audit log
POST /send/keysCreate a key (password required)
DELETE /send/keys/{id}Revoke a key
GET /receiveTarget checks, current job, audit log
POST /receive/connectValidate a key against its source
POST /receive/startChoose what to copy and start
POST /receive/stepDo a few seconds of copying
POST /receive/resumeRetry after a failure
POST /receive/finalizeReplace this site (password + typed host name)
POST /receive/rollbackUndo finalize (password)
POST /receive/cleanupCancel, or delete what a finished job left behind

All admin routes require manage_options and are disabled on multisite.

Security model

Anyone holding a valid key can read the whole source site, and finalizing overwrites the whole target, so both ends are locked down:

  • Keys are random 256-bit secrets, valid 1–72 hours, scoped to files and/or database, stored encrypted with a key derived from the wp-config.php salts, revoked when the migration finishes, and locked after 10 bad signatures. Creating one needs the administrator's password; the site admin email is notified when a key is first used.
  • Binding: the first target to present a valid signature binds the key to its random client ID; a copy of the key used anywhere else is refused.
  • Requests are signed with HMAC-SHA256 over the route, key ID, client ID, a strictly increasing sequence number, a timestamp (±5 min) and the body hash. Replays, reordering, changed bodies and redirected actions all fail. Redirects are not followed.
  • Responses are encrypted and authenticated with XChaCha20-Poly1305 bound to the request (key ID, sequence, route), so they can't be read, altered or swapped in transit, even through a TLS-terminating proxy.
  • HTTPS is required on the source. define( 'JCORE_SIIRTO_ALLOW_HTTP', true ); lifts this for local development only.
  • Paths: every path is relative to a fixed root, validated on both sides (no .., control characters, backslashes or absolute paths), and symlinks are never followed. wp-config.php, .htaccess, core files, caches, backups, logs (debug.log), host-specific drop-ins and this plugin's own directory are never transferred.
  • Executable files (.php, .phtml, .phar, .htaccess, .user.ini, double extensions such as x.php.jpg…) in uploads and other wp-content files are skipped by default and listed in the report: in those places they are usually backdoors.
  • Database: only tables with the source's prefix are exported, never this plugin's. Serialized data is rewritten by a tokenizer, never unserialize()d, so imported data can't instantiate objects. Identifiers go through %i, values through esc_sql() or hex literals.
  • Finalize, rollback and key creation require the user's password (5 tries per 15 minutes); finalize also needs the host name typed in.
  • Audit log of key creation, use, refusals, and every migration step on both sites, with IP addresses, kept 90 days.

Known limits: multisite is not supported; URL replacement is a plain string match (so old.com would also match inside old.com.au); binary (BLOB) columns are copied as-is without URL rewriting; the staging directory's deny rules only work on servers that read .htaccess, so its name has 128 random bits.

Releasing

Pushing to main runs .github/workflows/release.yml:

  1. check – lint, build, then run Plugin Check against the tree minus .distignore. Also runs on pull requests.
  2. release – foonver reads the conventional commits since the last tag, bumps the version, syncs it into jcore-siirto.php, readme.txt and package.json, writes the == Changelog == section of readme.txt and pushes the tag.
  3. publish – the reusable workflow in jcore-update builds the zip, attaches it to a GitHub release, registers the version with update.jcore.fi, pushes to the dist repository and posts to Slack.

Every commit that reaches main produces a release, so work on branches and merge through pull requests. Commit messages must follow Conventional Commits.

.distignore is the single list of what does not ship.

License

GPL-2.0-or-later. See LICENSE.