PluginProbe
Activity Logs, User Activity Tracking, Multisite Activity Log from Logtivity / 3.3.9
Activity Logs, User Activity Tracking, Multisite Activity Log from Logtivity v3.3.9
3.3.9 3.3.8 trunk 1.0 1.1.0 1.10.0 1.11.0 1.11.1 1.12.0 1.13.0 1.14.0 1.15.0 1.16.0 1.17.0 1.17.1 1.18.0 1.19.0 1.2.0 1.20.0 1.20.1 1.3.0 1.3.1 1.4.0 1.5.0 1.6.0 All 67 releases
logtivity / Loggers / Core / Logtivity_Plugin.php

Logtivity_Plugin.php in Activity Logs, User Activity Tracking, Multisite Activity Log from Logtivity 3.3.9, at Loggers/Core/Logtivity_Plugin.php

416 lines 13.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * @package Logtivity
5 * @contact logtivity.io, [email protected]
6 * @copyright 2024-2026 Logtivity. All rights reserved
7 * @license https://www.gnu.org/licenses/gpl.html GNU/GPL
8 *
9 * This file is part of Logtivity.
10 *
11 * Logtivity is free software: you can redistribute it and/or modify
12 * it under the terms of the GNU General Public License as published by
13 * the Free Software Foundation, either version 2 of the License, or
14 * (at your option) any later version.
15 *
16 * Logtivity is distributed in the hope that it will be useful,
17 * but WITHOUT ANY WARRANTY; without even the implied warranty of
18 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 * GNU General Public License for more details.
20 *
21 * You should have received a copy of the GNU General Public License
22 * along with Logtivity. If not, see <https://www.gnu.org/licenses/>.
23 */
24
25 class Logtivity_Plugin extends Logtivity_Abstract_Logger
26 {
27 /**
28 * Plugin data captured before a destructive file operation — a deletion, or
29 * an update that overwrites the files — keyed by plugin slug. Read back once
30 * the operation completes, when the on-disk copy is gone or already replaced.
31 *
32 * @var array
33 */
34 protected array $capturedPluginData = [];
35
36 /**
37 * @inheritDoc
38 */
39 protected function registerHooks(): void
40 {
41 /*
42 * Track the active plugin lists directly rather than the activated_plugin/
43 * deactivated_plugin actions. Those actions are skipped when a plugin is
44 * (de)activated silently (the $silent argument to activate_plugin() /
45 * deactivate_plugins()), whereas the underlying option is always written.
46 * Diffing the option therefore catches every status change, silent or not.
47 */
48 add_action('update_option_active_plugins', [$this, 'activePluginsUpdated'], 10, 2);
49 add_action('add_option_active_plugins', [$this, 'activePluginsAdded'], 10, 2);
50 add_action('update_site_option_active_sitewide_plugins', [$this, 'networkPluginsUpdated'], 10, 3);
51 add_action('add_site_option_active_sitewide_plugins', [$this, 'networkPluginsAdded'], 10, 2);
52
53 add_action('delete_plugin', [$this, 'savePluginData'], 10, 1);
54 add_action('deleted_plugin', [$this, 'pluginDeleted'], 10, 2);
55
56 add_filter('upgrader_pre_install', [$this, 'savePreUpdateVersion'], 10, 2);
57 add_action('upgrader_process_complete', [$this, 'upgradeProcessComplete'], 10, 2);
58
59 add_filter('editable_extensions', [$this, 'pluginFileModified'], 10, 2);
60 }
61
62 /**
63 * @param string|Plugin_Upgrader $plugin
64 *
65 * @return ?array
66 */
67 protected function getPluginData($plugin): ?array
68 {
69 if (is_string($plugin)) {
70 $slug = $plugin;
71 $pluginData = get_plugin_data(WP_PLUGIN_DIR . '/' . $plugin, false, false);
72
73 } elseif ($plugin instanceof Plugin_Upgrader) {
74 if ($slug = $plugin->plugin_info()) {
75 $pluginData = get_plugin_data($plugin->result['local_destination'] . '/' . $slug, false, false);
76 }
77 }
78
79 if (isset($pluginData)) {
80 $pluginData = array_merge(
81 [
82 'Name' => 'Not Set',
83 'Slug' => $slug ?? 'Not Set',
84 'Version' => 'Not Set',
85 ],
86 $pluginData
87 );
88 }
89
90 return $pluginData ?? null;
91 }
92
93 /**
94 * @param string $state
95 * @param string $slug
96 * @param bool $networkWide
97 *
98 * @return void
99 */
100 public function pluginStateChanged(string $state, string $slug, bool $networkWide): void
101 {
102 if ($plugin = $this->getPluginData($slug)) {
103 Logtivity::log()
104 ->setAction('Plugin ' . $state)
105 ->setContext($plugin['Name'])
106 ->addMeta('Version', $plugin['Version'])
107 ->addMeta('Slug', $plugin['Slug'])
108 ->addMeta('Network Wide', $networkWide ? 'Yes' : 'No')
109 ->send();
110 }
111 }
112
113 /**
114 * The active_plugins option was updated (single site).
115 *
116 * @param mixed $oldValue
117 * @param mixed $value
118 *
119 * @return void
120 */
121 public function activePluginsUpdated($oldValue, $value): void
122 {
123 $this->logPluginListChanges((array)$oldValue, (array)$value, false);
124 }
125
126 /**
127 * The active_plugins option was created (single site, first activation).
128 *
129 * @param string $option
130 * @param mixed $value
131 *
132 * @return void
133 */
134 public function activePluginsAdded(string $option, $value): void
135 {
136 $this->logPluginListChanges([], (array)$value, false);
137 }
138
139 /**
140 * The active_sitewide_plugins network option was updated (multisite).
141 *
142 * The value is keyed by plugin slug, so compare against the keys.
143 *
144 * @param string $option
145 * @param mixed $value
146 * @param mixed $oldValue
147 *
148 * @return void
149 */
150 public function networkPluginsUpdated(string $option, $value, $oldValue): void
151 {
152 $this->logPluginListChanges(
153 array_keys((array)$oldValue),
154 array_keys((array)$value),
155 true
156 );
157 }
158
159 /**
160 * The active_sitewide_plugins network option was created (multisite).
161 *
162 * @param string $option
163 * @param mixed $value
164 *
165 * @return void
166 */
167 public function networkPluginsAdded(string $option, $value): void
168 {
169 $this->logPluginListChanges([], array_keys((array)$value), true);
170 }
171
172 /**
173 * Diff a previous and current list of active plugin slugs and log each
174 * activation/deactivation. A set-based diff means a pure reordering of the
175 * list (e.g. Logtivity moving itself to load first) produces no events.
176 *
177 * @param string[] $previous
178 * @param string[] $current
179 * @param bool $networkWide
180 *
181 * @return void
182 */
183 protected function logPluginListChanges(array $previous, array $current, bool $networkWide): void
184 {
185 foreach (array_diff($current, $previous) as $slug) {
186 $this->pluginStateChanged('Activated', $slug, $networkWide);
187 }
188
189 foreach (array_diff($previous, $current) as $slug) {
190 /*
191 * A plugin whose files no longer exist was deleted, not merely
192 * deactivated. Deleting an active plugin drops it from the active
193 * list too, but that deletion is already reported via the
194 * deleted_plugin hook, so skip the redundant deactivation event.
195 */
196 if ($this->pluginFileExists($slug) == false) {
197 continue;
198 }
199
200 $this->pluginStateChanged('Deactivated', $slug, $networkWide);
201 }
202 }
203
204 /**
205 * Whether the plugin's main file still exists on disk.
206 *
207 * @param string $slug
208 *
209 * @return bool
210 */
211 protected function pluginFileExists(string $slug): bool
212 {
213 return is_readable(WP_PLUGIN_DIR . '/' . $slug);
214 }
215
216 /**
217 * Capture a plugin's data before its files are removed or overwritten, so it
218 * can still be reported once the on-disk copy is gone or replaced. Keyed by
219 * slug and captured at most once per plugin per request.
220 *
221 * @param string $slug
222 *
223 * @return void
224 */
225 protected function capturePluginData(string $slug): void
226 {
227 if (
228 isset($this->capturedPluginData[$slug]) == false
229 && $this->pluginFileExists($slug)
230 ) {
231 if ($pluginData = $this->getPluginData($slug)) {
232 $this->capturedPluginData[$slug] = $pluginData;
233 }
234 }
235 }
236
237 /**
238 * Snapshot the version of every installed plugin. Used when an install does
239 * not name the plugin being written (an upload, including one overwriting an
240 * existing plugin), so the prior version can still be resolved by slug once
241 * the install completes. Runs while the old files are still on disk, and
242 * never overwrites data already captured for a specific plugin.
243 *
244 * @return void
245 */
246 protected function captureAllPluginData(): void
247 {
248 if (function_exists('get_plugins') == false) {
249 require_once ABSPATH . 'wp-admin/includes/plugin.php';
250 }
251
252 foreach (get_plugins() as $slug => $pluginData) {
253 if (isset($this->capturedPluginData[$slug]) == false) {
254 $this->capturedPluginData[$slug] = array_merge(
255 [
256 'Name' => 'Not Set',
257 'Slug' => $slug,
258 'Version' => 'Not Set',
259 ],
260 $pluginData
261 );
262 }
263 }
264 }
265
266 /**
267 * @param string $plugin
268 *
269 * @return void
270 */
271 public function savePluginData(string $plugin): void
272 {
273 $this->capturePluginData($plugin);
274 }
275
276 /**
277 * @param string $pluginFile
278 * @param bool $deleted
279 *
280 * @return void
281 */
282 public function pluginDeleted(string $pluginFile, bool $deleted): void
283 {
284 $plugin = $this->capturedPluginData[$pluginFile] ?? $this->getPluginData($pluginFile);
285
286 if ($plugin) {
287 Logtivity::log()
288 ->setAction('Plugin Deleted')
289 ->setContext($plugin['Name'])
290 ->addMeta('Slug', $plugin['Slug'])
291 ->addMeta('Version', $plugin['Version'])
292 ->addMeta('Deletion Successful', $deleted ? 'Yes' : 'No')
293 ->send();
294 }
295 }
296
297 /**
298 * Capture plugin data before an install/update overwrites the files, so the
299 * old version can be logged once it completes. Fires while the old files are
300 * still on disk. Updates name the plugin in $hookExtra, so only that one is
301 * captured; uploads (including one overwriting an existing plugin) do not,
302 * so every installed plugin is snapshotted and the replaced one resolved by
303 * slug at completion. Registered as a filter, so the value is returned as-is.
304 *
305 * @param bool|WP_Error $response
306 * @param array $hookExtra
307 *
308 * @return bool|WP_Error
309 */
310 public function savePreUpdateVersion($response, array $hookExtra)
311 {
312 if ($slug = $hookExtra['plugin'] ?? null) {
313 $this->capturePluginData($slug);
314 } else {
315 $this->captureAllPluginData();
316 }
317
318 return $response;
319 }
320
321 /**
322 * @param WP_Upgrader $upgrader
323 * @param array $options
324 *
325 * @return void
326 */
327 public function upgradeProcessComplete(WP_Upgrader $upgrader, array $options): void
328 {
329 $action = $options['action'] ?? null;
330
331 if ($upgrader instanceof Plugin_Upgrader) {
332 switch ($action) {
333 case 'install':
334 $this->pluginInstalled('Installed', $upgrader, $options);
335 break;
336
337 case 'update':
338 $this->pluginInstalled('Updated', $upgrader, $options);
339 break;
340 }
341 }
342 }
343
344 /**
345 * @param Plugin_Upgrader $upgrader
346 * @param bool $bulk
347 *
348 * @return string[]|Plugin_Upgrader[]
349 */
350 protected function getPluginList(Plugin_Upgrader $upgrader, array $options): array
351 {
352 if ($options['bulk'] ?? false) {
353 return (array)$options['plugins'] ?? [];
354 }
355
356 return [$upgrader];
357 }
358
359 /**
360 * @param Plugin_Upgrader $upgrader
361 * @param array $options
362 *
363 * @return void
364 */
365 public function pluginInstalled(string $action, Plugin_Upgrader $upgrader, array $options): void
366 {
367 $bulk = $options['bulk'] ?? false;
368 $plugins = $this->getPluginList($upgrader, $options);
369
370 foreach ($plugins as $plugin) {
371 if ($pluginData = $this->getPluginData($plugin)) {
372 $log = Logtivity::log()
373 ->setAction('Plugin ' . $action)
374 ->setContext($pluginData['Name'])
375 ->addMeta('Slug', $pluginData['Slug'])
376 ->addMeta('Version', $pluginData['Version']);
377
378 $oldVersion = $this->capturedPluginData[$pluginData['Slug']]['Version'] ?? null;
379 if ($oldVersion) {
380 $log->addMeta('Old Version', $oldVersion);
381 }
382
383 $log->addMeta('Bulk', $bulk ? 'Yes' : 'No')
384 ->send();
385 }
386 }
387 }
388
389 /**
390 * @param string[] $defaultTypes
391 *
392 * @return array
393 */
394 public function pluginFileModified(array $defaultTypes): array
395 {
396 $action = sanitize_text_field($_POST['action'] ?? null);
397 $plugin = sanitize_text_field($_POST['plugin'] ?? null);
398 $file = sanitize_text_field($_REQUEST['file'] ?? null);
399
400 if ($file && $plugin && $action == 'edit-theme-plugin-file') {
401 $pluginData = $this->getPluginData($plugin);
402
403 Logtivity::log('Plugin File Edited')
404 ->setContext($pluginData['Name'])
405 ->addMeta('File', $file)
406 ->addMeta('Slug', $pluginData['Slug'])
407 ->addMeta('Version', $pluginData['Version'])
408 ->send();
409 }
410
411 return $defaultTypes;
412 }
413 }
414
415 new Logtivity_Plugin();
416