PluginProbe
Activity Logs, User Activity Tracking, Multisite Activity Log from Logtivity / 3.3.10
Activity Logs, User Activity Tracking, Multisite Activity Log from Logtivity v3.3.10
3.3.10 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 All 68 releases
logtivity / Loggers / Core / Logtivity_Plugin.php

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

432 lines 13.8 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 $slug = $plugin->plugin_info();
75 if ($slug) {
76 $pluginData = get_plugin_data($plugin->result['local_destination'] . '/' . $slug, false, false);
77 }
78 }
79
80 if (isset($pluginData)) {
81 $pluginData = array_merge(
82 [
83 'Name' => 'Not Set',
84 'Slug' => $slug ?? 'Not Set',
85 'Version' => 'Not Set',
86 ],
87 $pluginData
88 );
89 }
90
91 return $pluginData ?? null;
92 }
93
94 /**
95 * @param string $state
96 * @param string $slug
97 * @param bool $networkWide
98 *
99 * @return void
100 */
101 public function pluginStateChanged(string $state, string $slug, bool $networkWide): void
102 {
103 $plugin = $this->getPluginData($slug);
104 if ($plugin) {
105 Logtivity::log()
106 ->setAction('Plugin ' . $state)
107 ->setContext($plugin['Name'])
108 ->addMeta('Version', $plugin['Version'])
109 ->addMeta('Slug', $plugin['Slug'])
110 ->addMeta('Network Wide', $networkWide ? 'Yes' : 'No')
111 ->addInventory()
112 ->send();
113 }
114 }
115
116 /**
117 * The active_plugins option was updated (single site).
118 *
119 * @param mixed $oldValue
120 * @param mixed $value
121 *
122 * @return void
123 */
124 public function activePluginsUpdated($oldValue, $value): void
125 {
126 $this->logPluginListChanges((array)$oldValue, (array)$value, false);
127 }
128
129 /**
130 * The active_plugins option was created (single site, first activation).
131 *
132 * @param string $option
133 * @param mixed $value
134 *
135 * @return void
136 */
137 public function activePluginsAdded(string $option, $value): void
138 {
139 $this->logPluginListChanges([], (array)$value, false);
140 }
141
142 /**
143 * The active_sitewide_plugins network option was updated (multisite).
144 *
145 * The value is keyed by plugin slug, so compare against the keys.
146 *
147 * @param string $option
148 * @param mixed $value
149 * @param mixed $oldValue
150 *
151 * @return void
152 */
153 public function networkPluginsUpdated(string $option, $value, $oldValue): void
154 {
155 $this->logPluginListChanges(
156 array_keys((array)$oldValue),
157 array_keys((array)$value),
158 true
159 );
160 }
161
162 /**
163 * The active_sitewide_plugins network option was created (multisite).
164 *
165 * @param string $option
166 * @param mixed $value
167 *
168 * @return void
169 */
170 public function networkPluginsAdded(string $option, $value): void
171 {
172 $this->logPluginListChanges([], array_keys((array)$value), true);
173 }
174
175 /**
176 * Diff a previous and current list of active plugin slugs and log each
177 * activation/deactivation. A set-based diff means a pure reordering of the
178 * list (e.g. Logtivity moving itself to load first) produces no events.
179 *
180 * @param string[] $previous
181 * @param string[] $current
182 * @param bool $networkWide
183 *
184 * @return void
185 */
186 protected function logPluginListChanges(array $previous, array $current, bool $networkWide): void
187 {
188 foreach (array_diff($current, $previous) as $slug) {
189 $this->pluginStateChanged('Activated', $slug, $networkWide);
190 }
191
192 foreach (array_diff($previous, $current) as $slug) {
193 /*
194 * A plugin whose files no longer exist was deleted, not merely
195 * deactivated. Deleting an active plugin drops it from the active
196 * list too, but that deletion is already reported via the
197 * deleted_plugin hook, so skip the redundant deactivation event.
198 */
199 if ($this->pluginFileExists($slug) == false) {
200 continue;
201 }
202
203 $this->pluginStateChanged('Deactivated', $slug, $networkWide);
204 }
205 }
206
207 /**
208 * Whether the plugin's main file still exists on disk.
209 *
210 * @param string $slug
211 *
212 * @return bool
213 */
214 protected function pluginFileExists(string $slug): bool
215 {
216 return is_readable(WP_PLUGIN_DIR . '/' . $slug);
217 }
218
219 /**
220 * Capture a plugin's data before its files are removed or overwritten, so it
221 * can still be reported once the on-disk copy is gone or replaced. Keyed by
222 * slug and captured at most once per plugin per request.
223 *
224 * @param string $slug
225 *
226 * @return void
227 */
228 protected function capturePluginData(string $slug): void
229 {
230 if (
231 isset($this->capturedPluginData[$slug]) == false
232 && $this->pluginFileExists($slug)
233 ) {
234 $pluginData = $this->getPluginData($slug);
235 if ($pluginData) {
236 $this->capturedPluginData[$slug] = $pluginData;
237 }
238 }
239 }
240
241 /**
242 * Snapshot the version of every installed plugin. Used when an install does
243 * not name the plugin being written (an upload, including one overwriting an
244 * existing plugin), so the prior version can still be resolved by slug once
245 * the install completes. Runs while the old files are still on disk, and
246 * never overwrites data already captured for a specific plugin.
247 *
248 * @return void
249 */
250 protected function captureAllPluginData(): void
251 {
252 if (function_exists('get_plugins') == false) {
253 require_once ABSPATH . 'wp-admin/includes/plugin.php';
254 }
255
256 foreach (get_plugins() as $slug => $pluginData) {
257 if (isset($this->capturedPluginData[$slug]) == false) {
258 $this->capturedPluginData[$slug] = array_merge(
259 [
260 'Name' => 'Not Set',
261 'Slug' => $slug,
262 'Version' => 'Not Set',
263 ],
264 $pluginData
265 );
266 }
267 }
268 }
269
270 /**
271 * @param string $plugin
272 *
273 * @return void
274 */
275 public function savePluginData(string $plugin): void
276 {
277 $this->capturePluginData($plugin);
278 }
279
280 /**
281 * @param string $pluginFile
282 * @param bool $deleted
283 *
284 * @return void
285 */
286 public function pluginDeleted(string $pluginFile, bool $deleted): void
287 {
288 $plugin = $this->capturedPluginData[$pluginFile] ?? $this->getPluginData($pluginFile);
289
290 if ($plugin) {
291 Logtivity::log()
292 ->setAction('Plugin Deleted')
293 ->setContext($plugin['Name'])
294 ->addMeta('Slug', $plugin['Slug'])
295 ->addMeta('Version', $plugin['Version'])
296 ->addMeta('Deletion Successful', $deleted ? 'Yes' : 'No')
297 ->addInventory()
298 ->send();
299 }
300 }
301
302 /**
303 * Capture plugin data before an install/update overwrites the files, so the
304 * old version can be logged once it completes. Fires while the old files are
305 * still on disk. Updates name the plugin in $hookExtra, so only that one is
306 * captured; uploads (including one overwriting an existing plugin) do not,
307 * so every installed plugin is snapshotted and the replaced one resolved by
308 * slug at completion. Registered as a filter, so the value is returned as-is.
309 *
310 * @param bool|WP_Error $response
311 * @param array $hookExtra
312 *
313 * @return bool|WP_Error
314 */
315 public function savePreUpdateVersion($response, array $hookExtra)
316 {
317 $slug = $hookExtra['plugin'] ?? null;
318 if ($slug) {
319 $this->capturePluginData($slug);
320 } else {
321 $this->captureAllPluginData();
322 }
323
324 return $response;
325 }
326
327 /**
328 * @param WP_Upgrader $upgrader
329 * @param array $options
330 *
331 * @return void
332 */
333 public function upgradeProcessComplete(WP_Upgrader $upgrader, array $options): void
334 {
335 $action = $options['action'] ?? null;
336
337 if ($upgrader instanceof Plugin_Upgrader) {
338 switch ($action) {
339 case 'install':
340 $this->pluginInstalled('Installed', $upgrader, $options);
341 break;
342
343 case 'update':
344 $this->pluginInstalled('Updated', $upgrader, $options);
345 break;
346 }
347 }
348 }
349
350 /**
351 * @param Plugin_Upgrader $upgrader
352 * @param bool $bulk
353 *
354 * @return string[]|Plugin_Upgrader[]
355 */
356 protected function getPluginList(Plugin_Upgrader $upgrader, array $options): array
357 {
358 if ($options['bulk'] ?? false) {
359 return (array)$options['plugins'] ?? [];
360 }
361
362 return [$upgrader];
363 }
364
365 /**
366 * @param Plugin_Upgrader $upgrader
367 * @param array $options
368 *
369 * @return void
370 */
371 public function pluginInstalled(string $action, Plugin_Upgrader $upgrader, array $options): void
372 {
373 $bulk = $options['bulk'] ?? false;
374 $plugins = $this->getPluginList($upgrader, $options);
375
376 foreach ($plugins as $plugin) {
377 $pluginData = $this->getPluginData($plugin);
378 if ($pluginData) {
379 $log = Logtivity::log()
380 ->setAction('Plugin ' . $action)
381 ->setContext($pluginData['Name'])
382 ->addMeta('Slug', $pluginData['Slug'])
383 ->addMeta('Version', $pluginData['Version']);
384
385 $oldVersion = $this->capturedPluginData[$pluginData['Slug']]['Version'] ?? null;
386 if ($oldVersion) {
387 $log->addMeta('Old Version', $oldVersion);
388 }
389
390 $log->addMeta('Bulk', $bulk ? 'Yes' : 'No')
391 ->addInventory()
392 ->send();
393 }
394 }
395 }
396
397 /**
398 * @param string[] $defaultTypes
399 *
400 * @return array
401 */
402 public function pluginFileModified(array $defaultTypes): array
403 {
404 // phpcs:disable WordPress.Security.NonceVerification.Missing -- Passive logger reads core plugin-editor request after WordPress validates it.
405 $action = isset($_POST['action'])
406 ? sanitize_text_field(wp_unslash($_POST['action']))
407 : null;
408 $plugin = isset($_POST['plugin'])
409 ? sanitize_text_field(wp_unslash($_POST['plugin']))
410 : null;
411 $file = isset($_REQUEST['file'])
412 ? sanitize_text_field(wp_unslash($_REQUEST['file']))
413 : null;
414 // phpcs:enable WordPress.Security.NonceVerification.Missing
415
416 if ($file && $plugin && $action == 'edit-theme-plugin-file') {
417 $pluginData = $this->getPluginData($plugin);
418
419 Logtivity::log('Plugin File Edited')
420 ->setContext($pluginData['Name'])
421 ->addMeta('File', $file)
422 ->addMeta('Slug', $pluginData['Slug'])
423 ->addMeta('Version', $pluginData['Version'])
424 ->send();
425 }
426
427 return $defaultTypes;
428 }
429 }
430
431 new Logtivity_Plugin();
432