PluginProbe
phpinfo() WP – Site Health, PHP Compatibility & Server Audit / 7.2.6
phpinfo() WP – Site Health, PHP Compatibility & Server Audit v7.2.6
7.2.7 7.2.6 7.2.5 7.2.4 7.2.3 7.2.0 7.2.1 7.2.2 7.1.0 7.0.3 7.0.4 7.0.5 trunk 6.0 7.0.0 7.0.1 7.0.2
phpinfo-wp / includes / class-alerts.php

class-alerts.php in phpinfo() WP – Site Health, PHP Compatibility & Server Audit 7.2.6, at includes/class-alerts.php

310 lines 12.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 defined('ABSPATH') or die('Unauthorized Access');
3
4 class Phpinfo_WP_Alerts {
5
6 const OPT = 'phpinfowp_alert_settings';
7
8 private static function _pro(): bool { return Phpinfo_WP_License::is_valid(); }
9
10 public static function get_settings(): array {
11 return wp_parse_args(get_option(self::OPT, []), [
12 'enabled' => false,
13 'emails' => get_option('admin_email', ''),
14 'eol_warning' => true,
15 'config_change' => true,
16 'opcache_low' => false,
17 'opcache_thresh' => 80,
18 'weekly_digest' => false,
19 'ssl_expiry' => true,
20 'ssl_thresh' => 30,
21 'webhook_url' => '',
22 'webhook_type' => 'slack',
23 ]);
24 }
25
26 public static function save_settings(array $s): void {
27 $is_unlim = Phpinfo_WP_License::is_unlimited();
28 $url = $is_unlim ? esc_url_raw(trim($s['webhook_url'] ?? '')) : '';
29 $type = ($is_unlim && in_array($s['webhook_type'] ?? '', ['slack', 'discord', 'generic'], true)) ? $s['webhook_type'] : 'slack';
30 update_option(self::OPT, [
31 'enabled' => !empty($s['enabled']),
32 'emails' => sanitize_textarea_field($s['emails'] ?? ''),
33 'eol_warning' => !empty($s['eol_warning']),
34 'config_change' => !empty($s['config_change']),
35 'opcache_low' => !empty($s['opcache_low']),
36 'opcache_thresh' => max(0, min(100, (int)($s['opcache_thresh'] ?? 80))),
37 'weekly_digest' => $is_unlim && !empty($s['weekly_digest']),
38 'ssl_expiry' => !empty($s['ssl_expiry']),
39 'ssl_thresh' => max(1, (int)($s['ssl_thresh'] ?? 30)),
40 'webhook_url' => (strpos($url, 'https://') === 0) ? $url : '',
41 'webhook_type' => $type,
42 ], false);
43 }
44
45 private static function recipients(): array {
46 $s = self::get_settings();
47 $raw = $s['emails'] ?? '';
48 $list = array_filter(array_map('trim', preg_split('/[\r\n,;]+/', $raw)));
49 return array_values(array_filter($list, 'is_email'));
50 }
51
52 private static function send(string $subject, string $body): bool {
53 $sent = false;
54
55 $to = self::recipients();
56 if ($to) {
57 $site = get_bloginfo('name') . ' (' . get_site_url() . ')';
58 $full = "Alert from phpinfo() WP Pro\nSite: {$site}\n\n{$body}\n\n---\n"
59 . "Manage alerts: " . admin_url('admin.php?page=phpinfowp-alerts');
60 $headers = [
61 'Content-Type: text/plain; charset=UTF-8',
62 'From: phpinfo() WP <' . get_option('admin_email') . '>',
63 ];
64 $sent = wp_mail($to, "[phpinfo() WP] {$subject}", $full, $headers);
65 }
66
67 self::send_webhook($subject, $body);
68 return $sent;
69 }
70
71 public static function send_webhook(string $subject, string $body): bool {
72 $s = self::get_settings();
73 $url = $s['webhook_url'] ?? '';
74 if (!$url) return false;
75
76 $site = get_bloginfo('name') . ' (' . get_site_url() . ')';
77 $text = "*[phpinfo() WP] {$subject}*\nSite: {$site}\n\n{$body}";
78
79 switch ($s['webhook_type']) {
80 case 'discord':
81 $payload = ['content' => substr($text, 0, 1900)];
82 break;
83 case 'slack':
84 $payload = ['text' => $text];
85 break;
86 default:
87 $payload = ['site' => $site, 'subject' => $subject, 'body' => $body, 'at' => time()];
88 break;
89 }
90
91 $resp = wp_remote_post($url, [
92 'timeout' => 10,
93 'headers' => ['Content-Type' => 'application/json'],
94 'body' => wp_json_encode($payload),
95 ]);
96 if (is_wp_error($resp)) return false;
97 $code = wp_remote_retrieve_response_code($resp);
98 return $code >= 200 && $code < 300;
99 }
100
101 public static function maybe_send_eol(): void {
102 if (!self::_pro()) return;
103 $s = self::get_settings();
104 if (!$s['enabled'] || !$s['eol_warning']) return;
105
106 $status = Phpinfo_WP_EOL::status();
107 if (!in_array($status['status'], ['warning', 'eol'], true)) return;
108
109 // Throttle: don't send more than once per 7 days
110 if (get_transient('phpinfowp_alert_eol_sent')) return;
111 set_transient('phpinfowp_alert_eol_sent', 1, 7 * DAY_IN_SECONDS);
112
113 $minor = $status['minor'];
114 $eol = $status['eol'];
115 $days = $status['days'];
116
117 if ($status['status'] === 'eol') {
118 $subject = "ACTION REQUIRED: PHP {$minor} is past end-of-life";
119 $body = "PHP {$minor} reached end-of-life on {$eol}.\n"
120 . "No security updates are being issued for this version.\n"
121 . "Upgrade to PHP 8.2 or newer immediately.";
122 } else {
123 $subject = "Warning: PHP {$minor} reaches end-of-life in {$days} days";
124 $body = "PHP {$minor} will reach end-of-life on {$eol} ({$days} days from now).\n"
125 . "Plan your PHP upgrade before that date to avoid running unsupported software.";
126 }
127
128 self::send($subject, $body);
129 }
130
131 public static function maybe_send_config_change(array $diff): void {
132 if (!self::_pro()) return;
133 $s = self::get_settings();
134 if (!$s['enabled'] || !$s['config_change'] || !$diff) return;
135
136 $lines = ["The following PHP configuration changes were detected:\n"];
137 foreach ($diff as $item) {
138 $key = $item['key'];
139 if ($item['type'] === 'changed') {
140 $lines[] = " CHANGED {$key}: \"{$item['old']}\"\"{$item['new']}\"";
141 } elseif ($item['type'] === 'added') {
142 $lines[] = " ADDED {$key}: \"{$item['new']}\"";
143 } else {
144 $lines[] = " REMOVED {$key} (was \"{$item['old']}\")";
145 }
146 }
147
148 $count = count($diff);
149 $subject = "{$count} PHP config change" . ($count > 1 ? 's' : '') . " detected";
150 self::send($subject, implode("\n", $lines));
151 }
152
153 public static function maybe_send_opcache_low(): void {
154 if (!self::_pro()) return;
155 $s = self::get_settings();
156 if (!$s['enabled'] || !$s['opcache_low']) return;
157
158 $status = Phpinfo_WP_OPcache::status();
159 if (!$status || $status['hit_rate'] === null) return;
160 if ($status['hit_rate'] >= (float) $s['opcache_thresh']) return;
161
162 if (get_transient('phpinfowp_alert_opcache_sent')) return;
163 set_transient('phpinfowp_alert_opcache_sent', 1, DAY_IN_SECONDS);
164
165 $rate = $status['hit_rate'];
166 $thresh = $s['opcache_thresh'];
167 $subject = "OPcache hit rate is low: {$rate}% (threshold: {$thresh}%)";
168 $body = "OPcache hit rate has dropped to {$rate}%, below your threshold of {$thresh}%.\n"
169 . "This may indicate OPcache memory is too small or is restarting frequently.\n"
170 . "Current OPcache memory used: " . Phpinfo_WP_OPcache::format_bytes($status['memory_used']) . " / "
171 . Phpinfo_WP_OPcache::format_bytes($status['memory_total']);
172
173 self::send($subject, $body);
174 }
175
176 public static function maybe_send_ssl(): void {
177 if (!self::_pro()) return;
178 $s = self::get_settings();
179 if (!$s['enabled'] || !$s['ssl_expiry']) return;
180 if (get_transient('phpinfowp_alert_ssl_sent')) return;
181
182 $results = Phpinfo_WP_SSL::check_all();
183 $problems = array_filter($results, function ($r) use ($s) {
184 return empty($r['error'])
185 && isset($r['days'])
186 && $r['days'] <= (int)$s['ssl_thresh'];
187 });
188
189 if (!$problems) return;
190 set_transient('phpinfowp_alert_ssl_sent', 1, DAY_IN_SECONDS);
191
192 $lines = [];
193 foreach ($problems as $cert) {
194 $lines[] = $cert['days'] < 0
195 ? " EXPIRED {$cert['host']} (expired {$cert['expiry']})"
196 : " EXPIRING {$cert['host']}{$cert['days']} days left (expires {$cert['expiry']})";
197 }
198
199 $count = count($problems);
200 $subject = "SSL certificate" . ($count > 1 ? 's' : '') . " expiring soon";
201 self::send($subject, implode("\n", $lines));
202 }
203
204 public static function send_weekly_digest(): void {
205 if (!self::_pro()) return;
206 if (!Phpinfo_WP_License::is_unlimited()) return;
207 $s = self::get_settings();
208 if (!$s['enabled'] || !$s['weekly_digest']) return;
209
210 $site = get_bloginfo('name') . ' (' . get_site_url() . ')';
211 $eol = Phpinfo_WP_EOL::status();
212 $grader = Phpinfo_WP_Config_Grader::run();
213 $mem_used = size_format(memory_get_usage(true));
214 $mem_limit = ini_get('memory_limit');
215
216 $lines = [];
217 $lines[] = "Weekly server health digest for: {$site}";
218 $lines[] = str_repeat('', 60);
219 $lines[] = '';
220
221 // PHP
222 $lines[] = "PHP VERSION";
223 $lines[] = " PHP " . PHP_VERSION;
224 if ($eol['status'] === 'eol') {
225 $lines[] = " ⚠ End of life — upgrade immediately";
226 } elseif ($eol['status'] === 'warning') {
227 $lines[] = " ⚠ EOL in {$eol['days']} days ({$eol['eol']})";
228 } else {
229 $lines[] = " ✓ Supported until {$eol['eol']}";
230 }
231
232 // Memory
233 $lines[] = '';
234 $lines[] = "MEMORY";
235 $lines[] = " Used: {$mem_used} / Limit: {$mem_limit}";
236
237 // Config grade
238 $lines[] = '';
239 $lines[] = "CONFIG GRADE";
240 $lines[] = " Score: {$grader['score']}/100 (Grade: {$grader['grade']})";
241 $failing = array_filter($grader['checks'], function ($c) {
242 return $c['status'] === 'fail';
243 });
244 foreach (array_slice($failing, 0, 5) as $f) {
245 $lines[] = "{$f['key']}: currently {$f['value']} (recommended: {$f['good']})";
246 }
247
248 // OPcache
249 $oc = Phpinfo_WP_OPcache::status();
250 if ($oc && $oc['enabled']) {
251 $lines[] = '';
252 $lines[] = "OPCACHE";
253 $lines[] = " Hit rate: " . ($oc['hit_rate'] ?? 'N/A') . "%";
254 $lines[] = " Memory: " . Phpinfo_WP_OPcache::format_bytes($oc['memory_used'])
255 . " / " . Phpinfo_WP_OPcache::format_bytes($oc['memory_total']);
256 }
257
258 // SSL
259 $ssl_results = Phpinfo_WP_SSL::check_all();
260 if ($ssl_results) {
261 $lines[] = '';
262 $lines[] = "SSL CERTIFICATES";
263 foreach ($ssl_results as $cert) {
264 if (!empty($cert['error'])) {
265 $lines[] = "{$cert['host']}: {$cert['error']}";
266 } elseif ($cert['days'] < 0) {
267 $lines[] = "{$cert['host']}: EXPIRED {$cert['expiry']}";
268 } else {
269 $icon = $cert['days'] < 30 ? '' : '';
270 $lines[] = " {$icon} {$cert['host']}: {$cert['days']} days left (expires {$cert['expiry']})";
271 }
272 }
273 }
274
275 // Snapshots — any changes this week
276 $latest = Phpinfo_WP_Snapshots::get_latest();
277 $prev = $latest ? Phpinfo_WP_Snapshots::get_previous_to($latest->id) : null;
278 if ($latest && $prev) {
279 $diff = Phpinfo_WP_Snapshots::diff($prev->snapshot_data, $latest->snapshot_data);
280 if ($diff) {
281 $lines[] = '';
282 $lines[] = "CONFIG CHANGES SINCE LAST SNAPSHOT";
283 foreach (array_slice($diff, 0, 10) as $item) {
284 $lines[] = " " . strtoupper($item['type']) . " {$item['key']}: "
285 . ($item['old'] ?? '') . "" . ($item['new'] ?? '');
286 }
287 }
288 }
289
290 $lines[] = '';
291 $lines[] = str_repeat('', 60);
292 $lines[] = "View full dashboard: " . admin_url('admin.php?page=phpinfo-wp');
293
294 self::send("Weekly health digest — " . get_bloginfo('name'), implode("\n", $lines));
295 }
296
297 // Weekly cron — runs all alert checks + auto-snapshot
298 public static function cron_weekly(): void {
299 if (!self::_pro()) return;
300 $diff = Phpinfo_WP_Snapshots::auto_snapshot();
301 self::maybe_send_config_change($diff);
302 Phpinfo_WP_Snapshots::prune(30);
303
304 self::maybe_send_eol();
305 self::maybe_send_opcache_low();
306 self::maybe_send_ssl();
307 self::send_weekly_digest();
308 }
309 }
310