Repository navigation
🚀 chore(release): WP Bones 3.0.0 - #131
Merged
Merged
Conversation
…eck on init Every activation and every update used to run every migration and every seeder, and an update ran them from upgrader_post_install: in the request doing the update, with the old classes still loaded, and never for a ZIP replaced through Upload Plugin or for files copied by FTP, git or Composer. A runOnce seeder ran only while its table was empty, so data added in a later release never arrived (#40). - Migrator: the files in database/migrations/ run once per site, in file name order, recorded by name in an option. A version stored per site is the cheap check on init that something may have changed; a pending migration that sorts before one that ran still runs, with a warning. One request at a time (INSERT IGNORE lock), stop at the first failure without recording it, tell the administrators, retry only from the admin. - Migration moves to WPBones\Database\Migration and its constructor runs nothing; the 2.x name stays as a deprecated subclass. insert(), truncate(), isEmpty() for seed data. - Seeders do not run any more: seed data is a migration. The class stays as a no-op so that a 2.x seeder file still loads during the update to 3.0. - Activation read the plugin header nowhere: the slug was empty there and the options delta wrote to an options row with no name. It now loads the header first.
…table it is given php bones migrate runs the pending migrations on this site through the plugin's own runner, the way to run one added during development before a version bump. migrate:status lists every migration, whether it ran, in which batch and version, and flags one that sorts before a migration that already ran. Both exit 1 without WordPress above the plugin. The migrate:create stub ignored the table name and always created my_plugin_products; it now creates the table it was asked for, on the 3.0 base class.
…t dbDelta could not tidy The first live run of the 2.1 → 3.0 update stopped at a 2.x migration: dbDelta() re-applying a schema written on one line tried ALTER COLUMN `id` SET DEFAULT '' and MySQL refused. 2.x ran that migration again on every activation and update and never saw the error; 3.0 applies it once more and counted it as a failure, which blocked every migration after it. create() now decides for itself: it throws when the table, or a column the schema declares (read the way dbDelta reads them, one per line), is missing afterwards, and logs anything else dbDelta left behind as tolerated. The Migrator skips exactly those errors, one occurrence each, and the DESCRIBE probe dbDelta makes of a table it is about to create.
3.0 runs no seeders, so a plugin coming from 2.x needs its database folder converted: - every database/migrations/*.php moves to the 3.0 base class name; - every database/seeders/*.php becomes a migration that sorts after the latest migration, in the order the seeders ran, and the seeder file is deleted. The body of run() becomes up(), read with PHP's tokenizer so that comments and strings stay as they are; insert(), truncate() and count() gain the table the seeder implied, $this->tablename keeps working, and a seeder with $runOnce seeds only an empty table, as before; - a seeder it cannot convert (no run(), other methods, no table) is kept and listed for review, and so is one that ran on every update and will now run once. Running it again changes nothing. Migration gains count() and query(), the last two helpers the 2.x Seeder had.
…Error A deployed plugin has no namespace file, and getNamespace() died on it. The conversion belongs in the plugin's sources: say so and exit 1, changing nothing.
…ry for the notice A plugin moved to 3.0 without migrate:to-v3 now hears about its seeders when it is activated, not only after an update. The admin notice reads the failure only when the stored version is behind the plugin's, which a failure always leaves it: the steady state costs no query on admin pages.
The lock and the ledger:
- the lock row carries `time|token`: unlock() deletes only its own, touch() refreshes it before
each migration, and an abandoned lock is taken over after an hour (WP_Upgrader's default)
instead of ten minutes, so a long `php bones migrate` is not run twice;
- log() reports a ledger write the database refused, and the run stops there without moving
the version, instead of running that migration again on the next update;
- a run started by a page load asks again under the lock: another request may have finished,
or failed, while it waited, and a public page must not rerun what just failed;
- a cache older than the database (persistent object cache after an import) is dropped when
refresh() sees it disagree, instead of costing a lock on every request.
Failures:
- the failure option stays, empty and autoloaded, so checking it costs no query; it is checked
whatever the version, so a migration added without a bump that fails is retried from the admin
and shown; a failure of 1.1.0 no longer holds back 1.1.1, which may fix it;
- insert(), truncate(), query() and count() throw when wpdb answers false, which it also does
without recording an error (a value too long for its column).
The rest:
- activation finishes an update made while the plugin was inactive: plugin/updated.php runs;
- create() no longer counts CONSTRAINT, FOREIGN KEY and CHECK lines as columns;
- the automatic run is skipped under the bones CLI, so `php bones migrate` lists what it runs;
- OptionRepository::forget() for an uninstall.php that drops the tables;
- migrate:to-v3 reads the seeder's settings past its comments, keeps a namespace declaration,
supports $this->wpdb, `$this -> insert(` and `$this?->insert(`, and flags truncate('name')
under $usePrefix = false, which 2.x prefixed anyway.
…rt without losing a seeder From Copilot's second pass on #119: - setVersion() reports a write the database refused (a false from update_option() is checked against the row), and only the request that stored the new version finishes the update, so plugin/updated.php runs once; the next request retries the write. - migrate:to-v3 deletes a seeder only once its migration is written and passes php -l; a migration that does not parse is removed and the seeder kept, listed for review. A migration file it cannot rewrite is reported instead of counted as updated.
…0 run too From Codex's review of v3 (gpt-6-sol): - The update's own work (the options delta, plugin/updated.php) ran after the version was stored and the lock released: a failure there was never retried, and another request could go on before it ended. Migrator::migrate() now takes it as a callback, runs it under the lock once the migrations went through and before the version moves; when it throws, the failure is recorded and the next run finishes the update again. - The first 3.0 run of a site updated from 2.x has no stored version, so plugin/updated.php was skipped as if it were an installation, and that update's work was lost. It now runs with $previousVersion null; only a first activation, which is an installation, skips it. Proven live: migrations-live-smoke.sh, the 2.1.0 → 3.0 update through the real upgrader now finishes with plugin/updated.php on the first 3.0 request (33 checks green).
…failure From Codex's second round on v3: when setVersion() failed after the update's work ran, the failure had already been cleared, so every public request found the update due and ran plugin/updated.php again. The version is now stored before the failure is cleared, and a refused write is recorded like any failure: retried by an administrator, at most every RETRY_AFTER.
Breaking, for 3.0 (audit S2, S3, S9): - a page of config/routes.php or of pages/ without a capability asks for manage_options; it asked for read (2.1.2), and before that for nothing; - a menu of config/menus.php without a capability asks for manage_options, and its items with it; it asked for read; - a REST route without a permission_callback refuses every request with WordPress's rest_forbidden (401 or 403), and the notice says how to make it public; up to 2.x it was public. A page, menu or route meant for every logged-in user, or for everyone, says so: 'capability' => 'read', capability() returning 'read', or 'permission_callback' => '__return_true'.
The pages of config/routes.php and pages/, the menus of config/menus.php and the REST routes of api/ that declare no capability or permission_callback, one line each with the file (and the line, for a route), read from the tokens. Nothing is rewritten: who may open a page is the author's call.
- the REST folder is the one config/api.php names (api.custom.path), not always api/; a path that is not a literal is reported; - a config/routes.php or config/menus.php that does not return a literal array is a review item, never "nothing to change"; - only a key of the options argument counts as a permission_callback, not the string anywhere in the call; options that are not a literal array are reported.
…tom.path The REST folder is read from the custom block's own path key, and only when its value is one whole string literal: another path key earlier in config/api.php, or a concatenation, made the report scan the wrong folder.
The report said "Nothing to change" where something did: - a config entry whose key is a constant or an interpolation made the next entries' keys count for the previous one: such a file is now unreadable, as one that returns a variable is; - the first return in the file was taken, an ABSPATH guard's included: only the file's own top-level return counts, and only one; - 'capability' => null or '' counted as declared (the providers fall back to manage_options), and so did 'permission_callback' => null; - a Route imported under another name, written in lower case, or nested in another call's arguments was not read; an attribute in the arguments made a declared route look undeclared; - a pages/ capability() was found by a regex on the raw text: in a comment it counted, a protected one or one with required arguments counted, an implicitly public one did not. Read from the tokens now. Tests: the menu spy kept one capability per slug, so the first item hid the menu's own (a hard-coded 'read' in add_menu_page() passed); it keeps both, and the post-type branch has a test. The REST status stub returns 418, so a hard-coded 401 would fail.
Audit S6/S7. Up to 2.x compiled Blade views (<plugin>/.cache, .bladec) and log files (<plugin>/storage/logs) were written inside the plugin folder, created 0777, where the web server serves them as text; and "errorlog", the default, also wrote a daily file there, against its own documentation. - Support\Storage::path(plugin, folder): uploads/wpbones/<plugin folder>/ <folder>, made with wp_mkdir_p, an index.php at every level and a deny-all .htaccess at the top (left alone once it exists); the temp folder when uploads cannot be written. Named after the plugin's folder: the slug is read from the header on init, after the logger is made. - Blade is made when a Blade view first renders, compiles there, as .php. - Logs: "errorlog" is error_log() only; "single" and "daily" default to Storage, with a hash keyed by AUTH_SALT in the file name (not wp_hash(), which is pluggable and not loaded yet at boot); a plugin.logging.path of the plugin's own keeps its folder and names.
- no temporary-folder fallback: a shared /tmp would let other local users read the logs and plant compiled views. When uploads cannot be written, Storage::path() answers null: a Blade view throws a RuntimeException that says why, a single/daily log goes to error_log() only; - a deny rule that cannot be written means no folder, as Apache would serve what it holds; - the test no longer removes a shared /tmp/wpbones; - View::render(true) closes its buffer when the view throws.
- an .htaccess is accepted only if it denies: one that a short write left empty or partial (a full disk) is removed, and an existing one that denies nothing means no folder; - View::render(true) restores the buffer level it found, so a view that throws inside a Blade @section or @Push no longer leaves render()'s own buffer open.
- a folder another request made first is used (wp_mkdir_p() answers false when it loses the race), and the .htaccess is written aside and renamed, so no request reads it half written; - `Require all denied` alone (Apache 2.4) is accepted as the rule; - the log name is keyed with AUTH_SALT only (or the salt WordPress keeps in the options): ABSPATH differed between a web request and WP-CLI, and between the releases of an atomic deploy; - Blade runs with throwOnError: BladeOne's showError() closed a buffer it had not opened, the page's own; - Storage::delete($plugin), for an uninstall.php; - tests: the log key with a real AUTH_SALT, in a process of its own (the suite never defined one, so dropping the salt passed).
Breaking, for 3.0 (audit S4, S5): - every request to a WP Bones admin page (menu item, route page, pages/ class) that is not a GET or a HEAD carries the plugin's nonce, in the _wpbones_nonce field $plugin->csrfField() prints; Routing\Csrf checks it on load, before any load callback, and again before the page renders, and answers wp_nonce_ays(). A route that takes requests from elsewhere says 'csrf' => false; a pages/ class, a public csrf() returning false. Up to 2.x store()/update()/destroy() and the load callbacks ran for a form posted from another site. - a logged Ajax action of a provider without $nonceHash (or $nonceKey) refuses every request, with a notice; up to 2.x its nonce check returned true. useHTTPPost() returns unslashed values. - Request::verifyNonce() reads a missing _wpnonce as a failed check, not an undefined index.
… open Ajax providers The views whose POST forms do not print $plugin->csrfField(), and the direct children of WordPressAjaxServiceProvider with logged actions and no $nonceHash (a child of the plugin's own base class may inherit one, so it is not listed).
- a pages/ csrf() is the opt-out only when declared, public and callable with no arguments: a csrf($token) of the page's own threw during menu setup; - migrate:to-v3 checks each POST form on its own (one form with the field hid another without it), and the pages/ classes that print their own forms.
…ings A pages/ class that opts out with a public csrf() returning false is not listed, and in a view only its HTML counts: a POST form printed as an example inside a PHP string (htmlentities(), as the Options boilerplate does) is not one a browser submits. A pages/ class's returned strings still count.
- the nonce may come in the query string or, for a fetch() with a JSON body
(no $_POST), in an X-WPBones-Nonce header; a logged Ajax action reads it
from $_REQUEST, as check_ajax_referer() does;
- WordPress's own forms that post to the page they are on, each with a nonce
of its own, go through: Screen Options and the filesystem credentials form;
- migrate:to-v3 finds the framework's Ajax provider under the name a use
statement gives it (make:ajax and the boilerplates import it as
ServiceProvider), and lists every stripslashes()/wp_unslash() in a file
that calls useHTTPPost(), which now unslashes (Scotty decodes JSON after
stripslashes: a quote would come back null);
- make:ajax writes $nonceHash = '{ClassName}' (it wrote '', which 3.0 refuses);
- the opt-out is described as what it is, for a page that checks a nonce of
its own: wp-admin asks for a logged-in browser first, so it is no door for
requests from elsewhere;
- route and pages/ load callbacks hang on the same hook name as the guard;
- tests: every menu item is guarded, a pages/ POST without the nonce is
refused (removing either guard used to pass).
The Options boilerplate writes action="#<?php echo $mid ?>": the > that closes the PHP ended the tag, so its five POST forms were never listed. The tag is matched over the whole file now, with PHP blocks inside it, and kept when it starts in the markup (a view's HTML, a pages/ class's strings).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bumps
WPBONES_COMMAND_LINE_VERSIONto 3.0.0, and bringsv3to master as a fast-forward: master (v2.1.3) is an ancestor of this branch.WP Bones 3.0 changes what the framework does when a plugin says nothing:
migrate:to-v3converts the database folder and lists the rest (🐛 fix(bones): migrate:to-v3 reads a form tag with PHP inside it #130).The release notes and the upgrade guide (
/docs/migrating-to-v3) describe each change and how to keep the 2.x behaviour.Tests on this branch
composer test: 350 tests and 897 assertions (224 unit, 126 console), against 205 and 533 at v2.1.3.migrations(33)csrf(12)v3-access(18)storage(11)activationconsoleviewqbversionbones-safety(11)bones-robustness(17)route-capabilityfails the 3 checks that assume the 2.x defaults.eloquentfails on SQLite only, because it runs the published 2.1.3 Database ZIP, whose seeders 3.0 no longer runs. It runs again on the 3.0 ZIP after the cascade.v3copied over theirvendor/: 12 smokes pass. The ReactJS and TypeScript Jest runs failed only becausenode_moduleswas missing.