Repository navigation
Fix activation rollback and other licensing bugs - #26
Merged
mzakash merged 11 commits intoOct 14, 2026
Merged
Conversation
validate_release() runs after the activation is created. When it threw, the catch block called activation()->get() and activation()->delete() without an id, so both requests went to v1/public/activations/ and the new activation stayed on the license, counting against its activation limit. Delete the activation this attempt created, by its id; a 404 means it's already gone. If the delete fails, keep only its id after clearing the options, so Deactivate License can remove it later, and say so in the error.
license_form_submit() passed $_POST['activation_id'] to License::deactivate(), so the form decided which activation got deleted. Call deactivate() without an id: it already falls back to the activation id stored in the settings.
The error was added, but the function went on. With deactivated_redirect set, the page redirected and the error never showed; without it, the page showed "This site was successfully deactivated." next to the error. Return after adding it, like the activation branch does.
In activation mode the form printed the stored license key as the password field's value, it always printed the activation id in a hidden field, and ?debug added two fields with the license and activation ids. Only users with manage_options see the page, but the key still ended up in the HTML. Leave the key field empty and reuse the stored key when it's submitted empty, so "Click Activate License to reconnect it." still works after an activation is removed. Drop the hidden field and the debug fields: deactivation no longer reads a posted id, and nothing reads a posted license_id.
The updater caches the version info for three hours, and WordPress keeps it in the update_plugins (or update_themes) site transient. Both hold the package link, and neither was cleared on activation or deactivation, so a deactivated site kept the link and a newly activated one waited for the next update check. Add Updater::clear_cache() and call it from activate() and deactivate().
redirect() printed esc_url( $url ) inside the <script>. esc_url() turns & into an HTML entity, and a script doesn't decode entities, so every query arg after the first one ended up in the URL fragment: a redirect to admin.php?page=my-plugin&tab=license kept only page in the query and put tab in the fragment. Print the URL as a JSON string from esc_url_raw() instead.
Contributor
|
Thanks a lot @themihaicircea for the quick PR, will look into it. |
activate() cleared every option before it knew whether the attempt could be undone, so a timeout or a release mismatch wiped a key that was already saved. Snapshot the options first and restore them when the attempt fails. Only when the new activation can't be deleted, keep just its id so Deactivate License can remove it. validate() also sent an empty key to the API, which hit the licenses collection. Fail early with "Please enter a license key." instead. Add Settings::set_options() and tests for the rollback, the empty key, and clearing the update caches.
When the last activation couldn't be undone, only its id is stored, and the form said the license was activated. Say that the attempt failed and to click Deactivate License instead. Show "Leave blank to use the saved key" in the empty key field when a key is stored, and wp_unslash() the posted key before sanitizing it. Add form tests: the saved key is never printed, deactivation ignores a posted activation id, and the redirect keeps its query args.
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.
As requested in support ticket 1554756, here are the fixes from the patch I sent there, one commit per fix, on top of v1.2.1.
What changes
1. Delete the new activation when
activate()failsvalidate_release()runs after the activation is created, and fails, for example, with a key for another product. The catch block then calledget()anddelete()without an id, so both requests went tov1/public/activations/and the activation stayed on the license, counting against its limit. It's now deleted by its id. If that request fails, only the id is kept after the options are cleared, so Deactivate License can remove it, and the error says so. A 404 counts as already removed.2. Deactivate the stored activation, not a posted id
license_form_submit()passed$_POST['activation_id']todeactivate(). It now callsdeactivate()without an id, which already falls back to the stored one.3. Return on a deactivation error
With
deactivated_redirectset, the page redirected and the error never showed. Without it, the error showed next to "This site was successfully deactivated." It now returns after adding the error, as activation already does.4. Keep the license key and ids out of the license form
The form printed the key as the field's value, the activation id in a hidden field, and with
?debugthe license id and activation id. The field is now empty, and an empty field reuses the stored key, so "Click Activate License to reconnect it." still works. The hidden and debug fields are gone: deactivation no longer reads a posted id, and nothing reads a postedlicense_id. If you rely on?debugto read the ids, I can bring them back as plain text.5. Clear cached update data when the license changes
The 3-hour version info cache and the
update_plugins(orupdate_themes) transient both hold the package link, and neither was cleared on activation or deactivation: a deactivated site kept the link, and a newly activated one waited for the next update check. The newUpdater::clear_cache()deletes both and runs fromactivate()anddeactivate(). Deleting the whole transient is what core'swp_clean_plugins_cache()andwp_clean_themes_cache()do too, and WordPress rebuilds it on the next admin page load.6. Keep query args in the settings redirect
redirect()printedesc_url()inside a<script>, which turns&into&, so every arg after the first ended up in the fragment:admin.php?page=my-plugin&tab=licenselanded onadmin.php?page=my-plugin&tab=license. It now prints a JSON string fromesc_url_raw().Testing
phpcs.xml: no findings in the three changed files, on every commit.pre_http_requeststub answering the licensing API, I ran 20 cases on this branch and on v1.2.1. On v1.2.1 they reproduce each bug above; on this branch they all pass, with no PHP notices. They cover:wp_update_plugins()deactivated_redirect?debug, while activated and after the activation was removedURLI haven't added unit tests, since the suite runs in wp-env. Happy to add some to
tests/unitif you'd like.