| 1 |
<?php |
| 2 |
|
| 3 |
namespace MasterAddons\Inc\Classes; |
| 4 |
|
| 5 |
if (!defined('ABSPATH')) { |
| 6 |
exit; |
| 7 |
} |
| 8 |
|
| 9 |
// phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.WP.AlternativeFunctions.file_system_operations_unlink -- Removing our own cache tree under uploads; WP_Filesystem credential prompts are not acceptable during a background upgrade routine. |
| 10 |
|
| 11 |
/** |
| 12 |
* Removes the local template-library mirror from uploads/master_addons. |
| 13 |
* |
| 14 |
* Client sites read the library from the remote API now, so the mirror is dead |
| 15 |
* weight — on small hosting plans it was filling the disk and failing writes. |
| 16 |
* This runs once per site, in the background, and only for the directories that |
| 17 |
* mirrored remote data. |
| 18 |
* |
| 19 |
* IT MUST NOT RUN ON el.master-addons.com. That site is the SOURCE of the |
| 20 |
* library: the same directories there hold published kits, purchased-kit |
| 21 |
* payloads and generated zips. Three independent guards keep it out: |
| 22 |
* |
| 23 |
* 1. the master-addons-site-importer plugin (which only exists on the |
| 24 |
* library server) sets jltma_remove_local_template_cache to false; |
| 25 |
* 2. JLTMA_IMPORTER being defined is treated as the same signal, so the |
| 26 |
* guard holds even if the filter is registered late; |
| 27 |
* 3. any site can opt out with |
| 28 |
* add_filter('jltma_remove_local_template_cache', '__return_false'). |
| 29 |
*/ |
| 30 |
class Local_Cache_Cleanup |
| 31 |
{ |
| 32 |
const DONE_OPTION = 'jltma_local_cache_removed'; |
| 33 |
const EVENT = 'jltma_remove_local_cache'; |
| 34 |
|
| 35 |
/** |
| 36 |
* Bumped when the cleanup itself changes, so sites that already ran an |
| 37 |
* older pass run the new one once. Version 2 removed the empty directory |
| 38 |
* tree the library cache used to recreate on every load; version 3 takes |
| 39 |
* assets_cache and the master_addons root with it. |
| 40 |
*/ |
| 41 |
const VERSION = 3; |
| 42 |
|
| 43 |
/** |
| 44 |
* Everything this plugin used to keep under uploads/master_addons. |
| 45 |
* |
| 46 |
* The template and kit directories are mirrors of the remote library and |
| 47 |
* are refetched from the API on demand. assets_cache holds bundled CSS and |
| 48 |
* JS for this site's own pages, which is regenerated -- into post meta now |
| 49 |
* that the directory is not created -- so it goes as well: the plugin |
| 50 |
* leaves no folders on a client site. |
| 51 |
*/ |
| 52 |
const MIRROR_DIRS = [ |
| 53 |
'templates-library', |
| 54 |
'templates_kits', |
| 55 |
'purchased_kits', |
| 56 |
'templates_kit', |
| 57 |
'assets_cache', |
| 58 |
]; |
| 59 |
|
| 60 |
private static $instance = null; |
| 61 |
|
| 62 |
public static function get_instance() |
| 63 |
{ |
| 64 |
if (null === self::$instance) { |
| 65 |
self::$instance = new self(); |
| 66 |
} |
| 67 |
return self::$instance; |
| 68 |
} |
| 69 |
|
| 70 |
private function __construct() |
| 71 |
{ |
| 72 |
add_action('admin_init', [$this, 'maybe_schedule']); |
| 73 |
add_action(self::EVENT, [$this, 'run']); |
| 74 |
} |
| 75 |
|
| 76 |
/** |
| 77 |
* Delete the mirror straight away, without waiting for cron. |
| 78 |
* |
| 79 |
* Called from the plugin activation hook: deactivating and reactivating is |
| 80 |
* what a site owner does when they want the plugin to sort itself out, and |
| 81 |
* telling them to wait a minute for WP-Cron -- which only fires on the next |
| 82 |
* page view, and not at all when DISABLE_WP_CRON is set -- is not an |
| 83 |
* answer. Deleting a few thousand files takes well under a second once the |
| 84 |
* first pass is done, and on activation there is no page render to hold up. |
| 85 |
*/ |
| 86 |
public static function purge_now() |
| 87 |
{ |
| 88 |
return self::get_instance()->run(); |
| 89 |
} |
| 90 |
|
| 91 |
/** |
| 92 |
* Is this site allowed to delete its local mirror? |
| 93 |
*/ |
| 94 |
public static function allowed() |
| 95 |
{ |
| 96 |
// The library server ships master-addons-site-importer; its uploads |
| 97 |
// tree is the real library, not a mirror of one. |
| 98 |
if (defined('JLTMA_IMPORTER')) { |
| 99 |
return false; |
| 100 |
} |
| 101 |
|
| 102 |
return (bool) apply_filters('jltma_remove_local_template_cache', true); |
| 103 |
} |
| 104 |
|
| 105 |
/** |
| 106 |
* Has this site run the current version of the cleanup? |
| 107 |
*/ |
| 108 |
private static function needs_run() |
| 109 |
{ |
| 110 |
$done = get_option(self::DONE_OPTION); |
| 111 |
if (!$done) { |
| 112 |
return true; |
| 113 |
} |
| 114 |
$version = is_array($done) && isset($done['version']) ? (int) $done['version'] : 1; |
| 115 |
if ($version < self::VERSION) { |
| 116 |
return true; |
| 117 |
} |
| 118 |
|
| 119 |
// A finished run is not a promise the directories stay gone: any code |
| 120 |
// path that still creates one puts it back, and a one-shot cleanup |
| 121 |
// would then never look again. Four is_dir() calls per admin request is |
| 122 |
// cheap enough to keep checking. |
| 123 |
return self::has_leftovers(); |
| 124 |
} |
| 125 |
|
| 126 |
/** |
| 127 |
* Is any mirror directory still on disk? |
| 128 |
*/ |
| 129 |
private static function has_leftovers() |
| 130 |
{ |
| 131 |
$upload_dir = wp_upload_dir(null, false); |
| 132 |
if (empty($upload_dir['basedir'])) { |
| 133 |
return false; |
| 134 |
} |
| 135 |
|
| 136 |
$root = trailingslashit($upload_dir['basedir']) . 'master_addons'; |
| 137 |
foreach (self::MIRROR_DIRS as $dir) { |
| 138 |
if (is_dir($root . '/' . $dir)) { |
| 139 |
return true; |
| 140 |
} |
| 141 |
} |
| 142 |
|
| 143 |
return false; |
| 144 |
} |
| 145 |
|
| 146 |
/** |
| 147 |
* Queue the cleanup once. Deleting a few thousand cached files is not |
| 148 |
* something to do inside an admin page load, so it goes to cron. |
| 149 |
*/ |
| 150 |
public function maybe_schedule() |
| 151 |
{ |
| 152 |
if (!self::allowed() || !self::needs_run()) { |
| 153 |
return; |
| 154 |
} |
| 155 |
|
| 156 |
// Cron is not guaranteed: DISABLE_WP_CRON, a broken loopback request or |
| 157 |
// a site nobody visits on the front end all leave the event pending |
| 158 |
// forever, and the mirror with it. If an earlier attempt is already |
| 159 |
// overdue, stop waiting for it and delete inline. |
| 160 |
$scheduled = wp_next_scheduled(self::EVENT); |
| 161 |
if ($scheduled) { |
| 162 |
if ($scheduled < time() - 5 * MINUTE_IN_SECONDS) { |
| 163 |
wp_unschedule_event($scheduled, self::EVENT); |
| 164 |
$this->run(); |
| 165 |
} |
| 166 |
return; |
| 167 |
} |
| 168 |
|
| 169 |
wp_schedule_single_event(time() + MINUTE_IN_SECONDS, self::EVENT); |
| 170 |
} |
| 171 |
|
| 172 |
/** |
| 173 |
* Delete the mirror directories, then mark the site done so this never |
| 174 |
* runs twice. |
| 175 |
* |
| 176 |
* @return array dir => files removed |
| 177 |
*/ |
| 178 |
public function run() |
| 179 |
{ |
| 180 |
if (!self::allowed()) { |
| 181 |
return []; |
| 182 |
} |
| 183 |
|
| 184 |
$upload_dir = wp_upload_dir(null, false); |
| 185 |
$basedir = !empty($upload_dir['basedir']) ? $upload_dir['basedir'] : ''; |
| 186 |
$removed = []; |
| 187 |
|
| 188 |
if ($basedir) { |
| 189 |
$root = trailingslashit($basedir) . 'master_addons'; |
| 190 |
foreach (self::MIRROR_DIRS as $dir) { |
| 191 |
$removed[$dir] = $this->delete_tree($root . '/' . $dir); |
| 192 |
} |
| 193 |
|
| 194 |
// The mirror directories are gone, but master_addons itself may now |
| 195 |
// hold nothing but the index.php/.htaccess stubs the cache used to |
| 196 |
// drop in. A client should not be left with an empty tree, so the |
| 197 |
// root goes too -- unless a directory this plugin did not create is |
| 198 |
// still sitting in it. |
| 199 |
$removed['root'] = $this->remove_if_empty($root) ? 1 : 0; |
| 200 |
} |
| 201 |
|
| 202 |
$this->clear_mirror_cron(); |
| 203 |
|
| 204 |
update_option(self::DONE_OPTION, [ |
| 205 |
'time' => time(), |
| 206 |
'version' => self::VERSION, |
| 207 |
'removed' => $removed, |
| 208 |
], false); |
| 209 |
|
| 210 |
return $removed; |
| 211 |
} |
| 212 |
|
| 213 |
/** |
| 214 |
* Drop the scheduled events whose only job is to refill the mirror. |
| 215 |
* |
| 216 |
* update_templates_cache() and update_template_kits_cache() exist to walk |
| 217 |
* the remote library and write it to disk. With local caching off they have |
| 218 |
* nothing to write, so leaving them scheduled just wakes the site up to do |
| 219 |
* nothing -- and any future path that forgets its gate would refill the |
| 220 |
* tree from a cron nobody is watching. |
| 221 |
*/ |
| 222 |
private function clear_mirror_cron() |
| 223 |
{ |
| 224 |
foreach (['jltma_templates_cache_update', 'jltma_template_kits_cache_update'] as $hook) { |
| 225 |
if (function_exists('wp_unschedule_hook')) { |
| 226 |
wp_unschedule_hook($hook); |
| 227 |
continue; |
| 228 |
} |
| 229 |
while ($timestamp = wp_next_scheduled($hook)) { |
| 230 |
wp_unschedule_event($timestamp, $hook); |
| 231 |
} |
| 232 |
} |
| 233 |
} |
| 234 |
|
| 235 |
/** |
| 236 |
* Recursively delete a directory. Returns the number of files removed. |
| 237 |
*/ |
| 238 |
private function delete_tree($dir) |
| 239 |
{ |
| 240 |
if (!is_dir($dir)) { |
| 241 |
return 0; |
| 242 |
} |
| 243 |
|
| 244 |
// Never step outside uploads, whatever the caller passed in. |
| 245 |
$upload_dir = wp_upload_dir(null, false); |
| 246 |
$basedir = !empty($upload_dir['basedir']) ? realpath($upload_dir['basedir']) : ''; |
| 247 |
$real = realpath($dir); |
| 248 |
if (!$basedir || !$real || 0 !== strpos($real, $basedir)) { |
| 249 |
return 0; |
| 250 |
} |
| 251 |
|
| 252 |
$files = 0; |
| 253 |
$items = new \RecursiveIteratorIterator( |
| 254 |
new \RecursiveDirectoryIterator($real, \FilesystemIterator::SKIP_DOTS), |
| 255 |
\RecursiveIteratorIterator::CHILD_FIRST |
| 256 |
); |
| 257 |
|
| 258 |
foreach ($items as $item) { |
| 259 |
if ($item->isDir()) { |
| 260 |
@rmdir($item->getPathname()); |
| 261 |
} else { |
| 262 |
if (@unlink($item->getPathname())) { |
| 263 |
$files++; |
| 264 |
} |
| 265 |
} |
| 266 |
} |
| 267 |
|
| 268 |
@rmdir($real); |
| 269 |
|
| 270 |
return $files; |
| 271 |
} |
| 272 |
|
| 273 |
/** |
| 274 |
* Delete a directory that holds nothing of the site's own. |
| 275 |
* |
| 276 |
* The placeholder files the cache wrote (index.php, .htaccess) do not count |
| 277 |
* as content -- they only existed to protect the cache tree that is now |
| 278 |
* gone. Anything else, a real directory above all, keeps the root alive. |
| 279 |
* |
| 280 |
* @return bool whether the directory was removed. |
| 281 |
*/ |
| 282 |
private function remove_if_empty($dir) |
| 283 |
{ |
| 284 |
if (!is_dir($dir)) { |
| 285 |
return false; |
| 286 |
} |
| 287 |
|
| 288 |
// index.php/.htaccess were written to protect a tree that is gone; |
| 289 |
// .DS_Store and Thumbs.db are the operating system's litter. None of |
| 290 |
// them is a reason to keep the folder standing. |
| 291 |
$stubs = ['index.php', 'index.html', '.htaccess', '.DS_Store', 'Thumbs.db']; |
| 292 |
$entries = @scandir($dir); |
| 293 |
if (false === $entries) { |
| 294 |
return false; |
| 295 |
} |
| 296 |
|
| 297 |
$found = []; |
| 298 |
foreach ($entries as $entry) { |
| 299 |
if ('.' === $entry || '..' === $entry) { |
| 300 |
continue; |
| 301 |
} |
| 302 |
if (is_dir($dir . '/' . $entry) || !in_array($entry, $stubs, true)) { |
| 303 |
return false; |
| 304 |
} |
| 305 |
$found[] = $dir . '/' . $entry; |
| 306 |
} |
| 307 |
|
| 308 |
foreach ($found as $file) { |
| 309 |
@unlink($file); |
| 310 |
} |
| 311 |
|
| 312 |
return (bool) @rmdir($dir); |
| 313 |
} |
| 314 |
} |
| 315 |
|