Skip to content

Instantly share code, notes, and snippets.

@arenagroove
Last active October 24, 2025 14:32
Show Gist options
  • Select an option

  • Save arenagroove/95d0fff37a0dbe400eb04aa5f2f36789 to your computer and use it in GitHub Desktop.

Select an option

Save arenagroove/95d0fff37a0dbe400eb04aa5f2f36789 to your computer and use it in GitHub Desktop.
Toggleable hard lock for WordPress core, plugin, and theme updates. Includes Tools screen, admin bar toggle, REST protection, and WP-CLI.
<?php
/**
* Plugin Name: LR Lock Updates
* Description: Toggleable hard lock for WordPress core, plugin, and theme updates. Includes Tools screen, admin bar toggle, REST protection, and WP-CLI.
* Author: Less Rain
* Author URI: https://www.lessrain.com
* Version: 2.2.0
* Network: true
* Text Domain: lr-lock-updates
* Domain Path: /languages
* Requires PHP: 7.4
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* CONFIGURATION CONSTANTS (add to wp-config.php if needed)
*
* // Emergency unlock (bypasses all lock checks and UI state)
* define('LR_LOCK_EMERGENCY_UNLOCK', true);
*
* // Force default state on new installs (overrides DEFAULT_STATE constant)
* define('LR_LOCK_FORCE_DEFAULT', true); // or false
*/
final class LR_Lock_Updates {
const VERSION = '2.2.0';
const DEFAULT_STATE = false;
const FILTER_PRIORITY = PHP_INT_MAX;
const OPT_ENABLED = 'lr_lock_updates_enabled';
const OPT_PARTS = 'lr_lock_updates_parts';
const OPT_LOG = 'lr_lock_updates_log';
const MAX_LOG_ENTRIES = 50;
const DISPLAYED_LOG_ENTRIES = 10;
private static $instance = null;
public static function instance() {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {
add_action('init', [$this, 'load_textdomain']);
if ($this->is_emergency_unlocked()) {
// Emergency mode: force unlock via filters and skip all protection hooks
add_filter('pre_option_' . self::OPT_ENABLED, '__return_zero', self::FILTER_PRIORITY);
add_filter('pre_site_option_' . self::OPT_ENABLED, '__return_zero', self::FILTER_PRIORITY);
add_action('cli_init', [$this, 'register_cli']);
return;
}
$this->init_hooks();
}
private function __clone() {}
public function __wakeup() {}
public function load_textdomain(): void {
load_muplugin_textdomain('lr-lock-updates', dirname(plugin_basename(__FILE__)) . '/languages');
}
private function init_hooks(): void {
if ($this->is_enabled()) {
$this->apply_protections();
}
add_action('admin_bar_menu', [$this, 'admin_bar_toggle'], 100);
add_action('admin_init', [$this, 'handle_toggle']);
add_action('admin_init', [$this, 'export_log_csv'], 1);
add_action('admin_menu', [$this, 'add_tools_page']);
add_action('network_admin_menu', [$this, 'add_network_tools_page']);
add_action('admin_post_lr_lock_save', [$this, 'handle_save']);
add_filter('admin_footer_text', [$this, 'footer_text']);
add_filter('mu_plugin_action_links', [$this, 'action_links'], 10, 2);
add_action('cli_init', [$this, 'register_cli']);
}
/**
* Check if emergency unlock constant is active.
*
* @return bool
*/
private function is_emergency_unlocked(): bool {
return defined('LR_LOCK_EMERGENCY_UNLOCK') && LR_LOCK_EMERGENCY_UNLOCK === true;
}
/**
* Apply comprehensive update protections based on enabled parts.
*
* @return void
*/
private function apply_protections(): void {
$parts = $this->get_parts();
add_filter('file_mod_allowed', [$this, 'filter_file_mods'], 10, 2);
if (!defined('DISALLOW_FILE_EDIT')) {
define('DISALLOW_FILE_EDIT', true);
}
add_filter('automatic_updater_disabled', '__return_true', self::FILTER_PRIORITY);
if ($parts['core']) {
add_filter('allow_dev_auto_core_updates', '__return_false', self::FILTER_PRIORITY);
add_filter('allow_minor_auto_core_updates', '__return_false', self::FILTER_PRIORITY);
add_filter('allow_major_auto_core_updates', '__return_false', self::FILTER_PRIORITY);
add_filter('auto_update_core', '__return_false', self::FILTER_PRIORITY);
// Return empty update object to prevent core update checks from triggering
add_filter('pre_site_transient_update_core', function () {
return (object) [
'last_checked' => time(),
'version_checked' => get_bloginfo('version'),
'updates' => [],
'translations' => [],
'locale' => get_locale(),
];
}, self::FILTER_PRIORITY);
}
if ($parts['plugins']) {
add_filter('auto_update_plugin', '__return_false', self::FILTER_PRIORITY);
add_filter('pre_site_transient_update_plugins', '__return_null', self::FILTER_PRIORITY);
}
if ($parts['themes']) {
add_filter('auto_update_theme', '__return_false', self::FILTER_PRIORITY);
add_filter('pre_site_transient_update_themes', '__return_null', self::FILTER_PRIORITY);
}
// Clear scheduled update checks for locked parts
add_action('init', function () use ($parts) {
if ($parts['core']) {
wp_clear_scheduled_hook('wp_version_check');
remove_action('wp_maybe_auto_update', 'wp_maybe_auto_update');
}
if ($parts['plugins']) {
wp_clear_scheduled_hook('wp_update_plugins');
}
if ($parts['themes']) {
wp_clear_scheduled_hook('wp_update_themes');
}
}, 1);
add_action('admin_head', [$this, 'hide_update_ui']);
add_action('admin_notices', [$this, 'admin_notice']);
add_action('network_admin_notices', [$this, 'admin_notice']);
add_action('muplugins_loaded', [$this, 'clear_transients']);
add_filter('rest_pre_dispatch', [$this, 'protect_rest_api'], 10, 3);
}
/**
* Block file modifications for update-related contexts.
*
* Note: $context can be null in some WordPress core contexts, so no type hint.
*
* @param bool $allowed Current allowed state.
* @param string|null $context File modification context.
* @return bool
*/
public function filter_file_mods($allowed, $context) {
$blocked = ['update-core', 'plugin', 'theme', 'install'];
return in_array($context, $blocked, true) ? false : $allowed;
}
/**
* Hide update UI elements only for locked parts.
*
* Critical: Do NOT hide generic ".update-plugins" or admin menu counts will
* disappear even when plugins/themes are unlocked.
*
* @return void
*/
public function hide_update_ui(): void {
$p = $this->get_parts();
$rules = [];
if ($p['core']) {
$rules[] = '.update-nag';
$rules[] = '#update-nag';
}
if ($p['plugins']) {
$rules[] = '.plugin-update-tr';
$rules[] = '#menu-plugins .update-plugins';
}
if ($p['themes']) {
$rules[] = '.theme-update';
$rules[] = '#menu-appearance .update-plugins';
}
if (empty($rules)) {
return;
}
// Hardcoded selectors, no user input - no escaping needed
echo '<style>' . implode(',', $rules) . '{display:none !important;}</style>';
}
public function admin_notice(): void {
if (!$this->current_user_can_manage()) {
return;
}
if (!function_exists('get_current_screen')) {
return;
}
$screen = get_current_screen();
if (!$screen) {
return;
}
$allowed = ['dashboard', 'plugins', 'themes', 'update-core', 'tools_page_lr-lock-updates', 'settings_page_lr-lock-updates-network'];
if (!in_array($screen->id, $allowed, true)) {
return;
}
$parts = $this->get_parts();
$locked_parts = array_keys(array_filter($parts));
if (empty($locked_parts)) {
return;
}
$what = implode(', ', $locked_parts);
echo '<div class="notice notice-warning is-dismissible"><p><strong>' . esc_html__('Updates are locked', 'lr-lock-updates') . '</strong> ' .
sprintf(esc_html__('for %s. Use the admin bar toggle or Tools screen to unlock.', 'lr-lock-updates'), esc_html($what)) . '</p></div>';
}
public function clear_transients(): void {
$parts = $this->get_parts();
if ($parts['core']) {
delete_site_transient('update_core');
}
if ($parts['plugins']) {
delete_site_transient('update_plugins');
}
if ($parts['themes']) {
delete_site_transient('update_themes');
}
}
/**
* Protect REST API endpoints for plugin and theme modifications.
*
* @param mixed $result Response to replace the requested version with.
* @param WP_REST_Server $server Server instance.
* @param WP_REST_Request $request Request used to generate the response.
* @return mixed
*/
public function protect_rest_api($result, $server, $request) {
if (!$this->is_enabled()) {
return $result;
}
$route = $request->get_route();
$method = $request->get_method();
$parts = $this->get_parts();
if ($parts['plugins'] && strpos($route, '/wp/v2/plugins') !== false) {
if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
return new WP_Error('lr_lock_active', __('Plugin updates are locked', 'lr-lock-updates'), ['status' => 403]);
}
}
if ($parts['themes'] && strpos($route, '/wp/v2/themes') !== false) {
if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
return new WP_Error('lr_lock_active', __('Theme updates are locked', 'lr-lock-updates'), ['status' => 403]);
}
}
return $result;
}
public function admin_bar_toggle($wp_admin_bar): void {
if (!is_user_logged_in() || !is_admin() || !$this->current_user_can_manage()) {
return;
}
$locked = $this->is_enabled();
$action = $locked ? 'off' : 'on';
$url = wp_nonce_url(add_query_arg('lr_lock_toggle', $action), 'lr_lock_toggle');
$indicator = $locked ? '🔴' : '🟢';
$title = $locked
? $indicator . ' ' . __('Updates locked', 'lr-lock-updates')
: $indicator . ' ' . __('Updates unlocked', 'lr-lock-updates');
$wp_admin_bar->add_node([
'id' => 'lr-lock-updates',
'title' => $title,
'href' => $url,
'meta' => ['title' => __('Click to toggle update lock', 'lr-lock-updates')],
]);
}
public function handle_toggle(): void {
if (!isset($_GET['lr_lock_toggle']) || !$this->current_user_can_manage()) {
return;
}
if (!wp_verify_nonce($_GET['_wpnonce'] ?? '', 'lr_lock_toggle')) {
wp_die(esc_html__('Invalid security token', 'lr-lock-updates'));
}
$new_state = (sanitize_key($_GET['lr_lock_toggle']) === 'on');
$this->set_enabled($new_state);
if (!$new_state) {
$this->trigger_update_checks();
}
wp_safe_redirect(remove_query_arg(['lr_lock_toggle', '_wpnonce']));
exit;
}
public function add_tools_page(): void {
if (is_multisite() || !$this->current_user_can_manage()) {
return;
}
add_management_page(
__('LR Lock Updates', 'lr-lock-updates'),
__('LR Lock Updates', 'lr-lock-updates'),
'manage_options',
'lr-lock-updates',
[$this, 'render_tools_page']
);
}
public function add_network_tools_page(): void {
if (!is_multisite() || !$this->current_user_can_manage()) {
return;
}
add_submenu_page(
'settings.php',
__('LR Lock Updates', 'lr-lock-updates'),
__('LR Lock Updates', 'lr-lock-updates'),
'manage_network_options',
'lr-lock-updates',
[$this, 'render_tools_page']
);
}
public function render_tools_page(): void {
if (!$this->current_user_can_manage()) {
wp_die(esc_html__('Permission denied', 'lr-lock-updates'));
}
$locked = $this->is_enabled();
$parts = $this->get_parts();
echo '<div class="wrap">';
echo '<h1>' . esc_html__('LR Lock Updates', 'lr-lock-updates') . '</h1>';
if (!empty($_GET['saved'])) {
echo '<div class="updated notice is-dismissible"><p>' . esc_html__('Settings saved.', 'lr-lock-updates') . '</p></div>';
}
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
wp_nonce_field('lr_lock_save');
echo '<input type="hidden" name="action" value="lr_lock_save">';
echo '<table class="form-table" role="presentation">';
echo '<tr><th scope="row">' . esc_html__('Lock status', 'lr-lock-updates') . '</th><td>';
echo '<label><input type="checkbox" name="lr_lock_enabled" value="1" ' . checked($locked, true, false) . '> ';
echo esc_html__('Enable lock', 'lr-lock-updates') . '</label>';
echo '<p class="description">' . esc_html__('When enabled, selected update types are blocked and nags are hidden.', 'lr-lock-updates') . '</p>';
echo '</td></tr>';
echo '<tr><th scope="row">' . esc_html__('Lock parts', 'lr-lock-updates') . '</th><td>';
echo '<label><input type="checkbox" name="lr_lock_core" value="1" ' . checked($parts['core'], 1, false) . '> ';
echo esc_html__('Core', 'lr-lock-updates') . '</label><br>';
echo '<label><input type="checkbox" name="lr_lock_plugins" value="1" ' . checked($parts['plugins'], 1, false) . '> ';
echo esc_html__('Plugins', 'lr-lock-updates') . '</label><br>';
echo '<label><input type="checkbox" name="lr_lock_themes" value="1" ' . checked($parts['themes'], 1, false) . '> ';
echo esc_html__('Themes', 'lr-lock-updates') . '</label>';
echo '<p class="description">' . esc_html__('Uncheck any item you want to keep updatable while the lock is enabled.', 'lr-lock-updates') . '</p>';
echo '</td></tr>';
echo '</table>';
submit_button(__('Save changes', 'lr-lock-updates'));
echo '</form>';
$this->render_activity_log();
echo '</div>';
}
private function render_activity_log(): void {
$log = $this->get_option(self::OPT_LOG, []);
if (empty($log)) {
return;
}
echo '<h2>' . esc_html__('Recent Activity', 'lr-lock-updates') . '</h2>';
$export_url = wp_nonce_url(add_query_arg('lr_lock_export_log', '1'), 'lr_lock_export_log');
echo '<p><a href="' . esc_url($export_url) . '" class="button">' . esc_html__('Download CSV', 'lr-lock-updates') . '</a></p>';
echo '<table class="wp-list-table widefat fixed striped"><thead><tr>';
echo '<th>' . esc_html__('Date', 'lr-lock-updates') . '</th>';
echo '<th>' . esc_html__('User', 'lr-lock-updates') . '</th>';
echo '<th>' . esc_html__('Action', 'lr-lock-updates') . '</th>';
echo '</tr></thead><tbody>';
foreach (array_reverse(array_slice($log, -self::DISPLAYED_LOG_ENTRIES)) as $entry) {
$user = get_userdata($entry['user_id']);
$username = $user ? $user->user_login : __('Unknown', 'lr-lock-updates');
echo '<tr>';
echo '<td>' . esc_html(wp_date(get_option('date_format') . ' ' . get_option('time_format'), $entry['time'])) . '</td>';
echo '<td>' . esc_html($username) . '</td>';
echo '<td>' . esc_html($entry['action']) . '</td>';
echo '</tr>';
}
echo '</tbody></table>';
}
public function handle_save(): void {
if (!$this->current_user_can_manage()) {
wp_die(esc_html__('Permission denied', 'lr-lock-updates'));
}
if (!wp_verify_nonce($_POST['_wpnonce'] ?? '', 'lr_lock_save')) {
wp_die(esc_html__('Invalid security token', 'lr-lock-updates'));
}
$enabled = isset($_POST['lr_lock_enabled']);
$this->set_enabled($enabled);
$parts = [
'core' => !empty($_POST['lr_lock_core']) ? 1 : 0,
'plugins' => !empty($_POST['lr_lock_plugins']) ? 1 : 0,
'themes' => !empty($_POST['lr_lock_themes']) ? 1 : 0,
];
$this->set_parts($parts);
if (!$enabled) {
$this->trigger_update_checks();
}
$ref = wp_get_referer();
if (!$ref) {
$ref = is_network_admin()
? network_admin_url('settings.php?page=lr-lock-updates')
: admin_url('tools.php?page=lr-lock-updates');
}
wp_safe_redirect(add_query_arg('saved', 1, $ref));
exit;
}
public function footer_text(string $text): string {
if ($this->is_enabled()) {
$text .= ' | ' . esc_html__('Updates locked by LR Lock Updates.', 'lr-lock-updates');
}
return $text;
}
public function action_links(array $links, string $mu_plugin_file): array {
if (basename($mu_plugin_file) !== basename(__FILE__)) {
return $links;
}
$url = is_multisite()
? network_admin_url('settings.php?page=lr-lock-updates')
: admin_url('tools.php?page=lr-lock-updates');
$links[] = '<a href="' . esc_url($url) . '">' . esc_html__('Settings', 'lr-lock-updates') . '</a>';
return $links;
}
/**
* Get option from site or regular options based on multisite.
*
* @param string $key Option key.
* @param mixed $default Default value.
* @return mixed
*/
private function get_option(string $key, $default = null) {
return is_multisite() ? get_site_option($key, $default) : get_option($key, $default);
}
/**
* Set option in site or regular options based on multisite.
*
* @param string $key Option key.
* @param mixed $value Option value.
* @return bool
*/
private function set_option(string $key, $value): bool {
return is_multisite() ? update_site_option($key, $value) : update_option($key, $value, false);
}
private function current_user_can_manage(): bool {
return current_user_can('manage_options') || current_user_can('manage_network_options');
}
/**
* Check if lock is currently enabled.
*
* Auto-initializes to DEFAULT_STATE on first run, or uses LR_LOCK_FORCE_DEFAULT
* if defined in wp-config.php.
*
* @return bool
*/
public function is_enabled(): bool {
$val = $this->get_option(self::OPT_ENABLED, null);
if ($val === null) {
$initial = defined('LR_LOCK_FORCE_DEFAULT')
? (LR_LOCK_FORCE_DEFAULT ? 1 : 0)
: (self::DEFAULT_STATE ? 1 : 0);
$this->set_option(self::OPT_ENABLED, $initial);
return (bool) $initial;
}
return (bool) $val;
}
public function set_enabled(bool $enabled): void {
$old = $this->is_enabled();
$this->set_option(self::OPT_ENABLED, $enabled ? 1 : 0);
if ($old !== $enabled) {
$this->log_action($enabled ? 'Lock enabled' : 'Lock disabled');
do_action('lr_lock_state_changed', $enabled, $old, get_current_user_id());
}
}
/**
* Get which parts (core/plugins/themes) are locked.
*
* @return array Associative array with 'core', 'plugins', 'themes' keys (1 or 0).
*/
public function get_parts(): array {
$parts = $this->get_option(self::OPT_PARTS, null);
if (!is_array($parts)) {
$parts = ['core' => 1, 'plugins' => 1, 'themes' => 1];
$this->set_option(self::OPT_PARTS, $parts);
}
// Ensure all keys exist with defaults
return [
'core' => (int) !empty($parts['core']),
'plugins' => (int) !empty($parts['plugins']),
'themes' => (int) !empty($parts['themes']),
];
}
public function set_parts(array $parts): void {
$clean = [
'core' => empty($parts['core']) ? 0 : 1,
'plugins' => empty($parts['plugins']) ? 0 : 1,
'themes' => empty($parts['themes']) ? 0 : 1,
];
$this->set_option(self::OPT_PARTS, $clean);
$enabled_parts = array_keys(array_filter($clean));
$this->log_action('Parts updated: ' . (!empty($enabled_parts) ? implode(', ', $enabled_parts) : 'none'));
}
private function log_action(string $action): void {
$log = $this->get_option(self::OPT_LOG, []);
$log[] = ['time' => time(), 'user_id' => get_current_user_id(), 'action' => $action];
$log = array_slice($log, -self::MAX_LOG_ENTRIES);
$this->set_option(self::OPT_LOG, $log);
}
public function export_log_csv(): void {
if (!isset($_GET['lr_lock_export_log']) || !$this->current_user_can_manage()) {
return;
}
if (!wp_verify_nonce($_GET['_wpnonce'] ?? '', 'lr_lock_export_log')) {
wp_die(esc_html__('Invalid security token', 'lr-lock-updates'));
}
$log = $this->get_option(self::OPT_LOG, []);
if (empty($log)) {
wp_die(esc_html__('No log entries to export', 'lr-lock-updates'));
}
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename=lr-lock-activity-' . date('Y-m-d') . '.csv');
header('Pragma: no-cache');
header('Expires: 0');
$output = fopen('php://output', 'w');
fputcsv($output, ['Date', 'User ID', 'Username', 'Action']);
foreach ($log as $entry) {
$user = get_userdata($entry['user_id']);
$username = $user ? $user->user_login : 'Unknown';
fputcsv($output, [wp_date('Y-m-d H:i:s', $entry['time']), $entry['user_id'], $username, $entry['action']]);
}
fclose($output);
exit;
}
public function trigger_update_checks(): void {
if (function_exists('wp_version_check')) {
wp_version_check();
}
if (function_exists('wp_update_plugins')) {
wp_update_plugins();
}
if (function_exists('wp_update_themes')) {
wp_update_themes();
}
}
public function register_cli(): void {
if (!defined('WP_CLI') || !WP_CLI) {
return;
}
WP_CLI::add_command('lr-lock', 'LR_Lock_CLI_Command');
}
}
if (defined('WP_CLI') && WP_CLI) {
class LR_Lock_CLI_Command {
public function status(): void {
$plugin = LR_Lock_Updates::instance();
$locked = $plugin->is_enabled();
WP_CLI::log($locked ? 'Status: Locked' : 'Status: Unlocked');
$parts = $plugin->get_parts();
$enabled = array_keys(array_filter($parts));
WP_CLI::log('Protected: ' . (empty($enabled) ? 'none' : implode(', ', $enabled)));
}
public function enable(): void {
$plugin = LR_Lock_Updates::instance();
$plugin->set_enabled(true);
WP_CLI::success('Lock enabled');
}
public function disable(): void {
$plugin = LR_Lock_Updates::instance();
$plugin->set_enabled(false);
$plugin->trigger_update_checks();
WP_CLI::success('Lock disabled. Update checks triggered.');
}
public function toggle(): void {
$plugin = LR_Lock_Updates::instance();
$new = !$plugin->is_enabled();
$plugin->set_enabled($new);
if (!$new) {
$plugin->trigger_update_checks();
}
WP_CLI::success('Lock ' . ($new ? 'enabled' : 'disabled'));
}
public function parts($args = [], $assoc_args = []): void {
$plugin = LR_Lock_Updates::instance();
if (empty($assoc_args)) {
$parts = $plugin->get_parts();
WP_CLI::log('Parts configuration:');
WP_CLI::log(' Core: ' . ($parts['core'] ? 'locked' : 'unlocked'));
WP_CLI::log(' Plugins: ' . ($parts['plugins'] ? 'locked' : 'unlocked'));
WP_CLI::log(' Themes: ' . ($parts['themes'] ? 'locked' : 'unlocked'));
return;
}
$parts = $plugin->get_parts();
if (isset($assoc_args['core'])) {
$parts['core'] = (int) $assoc_args['core'];
}
if (isset($assoc_args['plugins'])) {
$parts['plugins'] = (int) $assoc_args['plugins'];
}
if (isset($assoc_args['themes'])) {
$parts['themes'] = (int) $assoc_args['themes'];
}
$plugin->set_parts($parts);
WP_CLI::success('Parts updated');
}
}
}
LR_Lock_Updates::instance();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment