diff --git a/includes/Extensions/Batch15.php b/includes/Extensions/Batch15.php index 65ca2c7..8ee657a 100644 --- a/includes/Extensions/Batch15.php +++ b/includes/Extensions/Batch15.php @@ -3,9 +3,10 @@ * Batch 15 — Wordfence security (Pro-equivalent, net-new). * * Status, scan issues, blocked IPs, recent logins, and manual IP block. - * Reads stable Wordfence DB tables (wfIssues / wfBlocks7 / wfLogins) and - * wfConfig; every handler checks the API is present and throws clearly if not. - * Guarded by WORDFENCE_VERSION / wfConfig — inert unless Wordfence is active. + * Reads Wordfence DB tables (wfIssues / wfBlocks7 / wfLogins) and wfConfig. + * A fresh Wordfence install creates some tables lazily (on first scan / login / + * firewall setup), so reads return a populated=false note rather than erroring + * when a table is not there yet. Guarded by WORDFENCE_VERSION / wfConfig. * GPL-2.0-or-later. See NOTICE.md. */ @@ -37,7 +38,7 @@ class Batch15 { $registry->register($def('wordfence_recent_logins', 'List recent login attempts recorded by Wordfence (IP, user, success/fail, time).', ['limit' => ['type' => 'integer', 'default' => 50], 'failed_only' => ['type' => 'boolean']], [], 'read', 'recentLogins')); - $registry->register($def('wordfence_block_ip', 'Manually block an IP in Wordfence with a reason. Requires the Wordfence block API.', + $registry->register($def('wordfence_block_ip', 'Manually block an IP in Wordfence with a reason. Requires the Wordfence block store (created once Wordfence is set up); reports blocked=false with a note if not yet available.', ['ip' => ['type' => 'string'], 'reason' => ['type' => 'string']], ['ip'], 'write', 'blockIp')); } @@ -46,30 +47,34 @@ class Batch15 { return $wpdb->base_prefix . 'wf' . $suffix; } + private static function hasTable(string $t): bool { + global $wpdb; + return (bool) $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $t)); + } + public static function status(array $args): array { $ver = defined('WORDFENCE_VERSION') ? WORDFENCE_VERSION : 'unknown'; $last = class_exists('wfConfig') ? \wfConfig::get('lastScanCompleted') : null; global $wpdb; $t = self::tbl('Issues'); $bySeverity = []; - $exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $t)); - if ($exists) { + if (self::hasTable($t)) { $rows = $wpdb->get_results("SELECT severity, COUNT(*) c FROM {$t} WHERE status='new' GROUP BY severity", ARRAY_A); foreach ((array) $rows as $r) $bySeverity[(string) $r['severity']] = (int) $r['c']; } - return ['version' => $ver, 'last_scan_completed' => $last, 'open_issues_by_severity' => $bySeverity]; + return ['version' => $ver, 'last_scan_completed' => $last ?: null, 'open_issues_by_severity' => $bySeverity]; } public static function scanIssues(array $args): array { global $wpdb; $t = self::tbl('Issues'); - if (!$wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $t))) { - throw new \RuntimeException('Wordfence issues table not found.'); + if (!self::hasTable($t)) { + return ['supported' => true, 'populated' => false, 'note' => 'No Wordfence scan data yet (issues table appears after the first scan).', 'count' => 0, 'issues' => []]; } $status = ($args['status'] ?? 'new') === 'all' ? '' : "WHERE status='new'"; $limit = max(1, (int) ($args['limit'] ?? 50)); $rows = $wpdb->get_results("SELECT type, severity, shortMsg, status FROM {$t} {$status} ORDER BY severity DESC LIMIT {$limit}", ARRAY_A); - return ['count' => count((array) $rows), 'issues' => array_map(fn($r) => [ + return ['supported' => true, 'populated' => true, 'count' => count((array) $rows), 'issues' => array_map(fn($r) => [ 'type' => $r['type'], 'severity' => (int) $r['severity'], 'message' => $r['shortMsg'], 'status' => $r['status'], ], (array) $rows)]; } @@ -77,8 +82,8 @@ class Batch15 { public static function blockedIps(array $args): array { global $wpdb; $t = self::tbl('Blocks7'); - if (!$wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $t))) { - throw new \RuntimeException('Wordfence blocks table not found.'); + if (!self::hasTable($t)) { + return ['supported' => true, 'populated' => false, 'note' => 'No Wordfence blocks table yet (created once Wordfence firewall/blocking is set up).', 'count' => 0, 'blocked' => []]; } $limit = max(1, (int) ($args['limit'] ?? 100)); $rows = $wpdb->get_results("SELECT IP, reason, blockedTime, expiration FROM {$t} ORDER BY blockedTime DESC LIMIT {$limit}", ARRAY_A); @@ -88,14 +93,14 @@ class Batch15 { $out[] = ['ip' => $ip, 'reason' => $r['reason'], 'blocked_time' => (int) $r['blockedTime'], 'expiration' => (int) $r['expiration'] === 0 ? 'permanent' : (int) $r['expiration']]; } - return ['count' => count($out), 'blocked' => $out]; + return ['supported' => true, 'populated' => true, 'count' => count($out), 'blocked' => $out]; } public static function recentLogins(array $args): array { global $wpdb; $t = self::tbl('Logins'); - if (!$wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $t))) { - throw new \RuntimeException('Wordfence logins table not found.'); + if (!self::hasTable($t)) { + return ['supported' => true, 'populated' => false, 'note' => 'No Wordfence logins table yet (created once Wordfence records login activity).', 'count' => 0, 'logins' => []]; } $limit = max(1, (int) ($args['limit'] ?? 50)); $where = !empty($args['failed_only']) ? 'WHERE fail=1' : ''; @@ -106,22 +111,27 @@ class Batch15 { $out[] = ['time' => (float) $r['ctime'], 'failed' => (bool) $r['fail'], 'username' => $r['username'], 'user_id' => (int) $r['userID'], 'ip' => $ip]; } - return ['count' => count($out), 'logins' => $out]; + return ['supported' => true, 'populated' => true, 'count' => count($out), 'logins' => $out]; } public static function blockIp(array $args): array { $ip = trim((string) ($args['ip'] ?? '')); if ($ip === '' || !filter_var($ip, FILTER_VALIDATE_IP)) throw new \RuntimeException('Valid ip required.'); $reason = (string) ($args['reason'] ?? 'Blocked via Advanced MCP'); - if (class_exists('wfBlock') && method_exists('wfBlock', 'createIP')) { + if (!class_exists('wfBlock') || !method_exists('wfBlock', 'createIP')) { + throw new \RuntimeException('Wordfence block API (wfBlock::createIP) unavailable in this version.'); + } + try { $block = \wfBlock::createIP($reason, $ip); + } catch (\Throwable $e) { + $block = null; + } + if (is_object($block)) { if (method_exists($block, 'save')) $block->save(); return ['ip' => $ip, 'blocked' => true, 'reason' => $reason]; } - if (class_exists('wfConfig') && method_exists('wfConfig', 'inc')) { - // Older Wordfence fallback is not reliably scriptable. - throw new \RuntimeException('This Wordfence version lacks a scriptable block API (wfBlock::createIP).'); - } - throw new \RuntimeException('Wordfence block API unavailable.'); + // createIP returned nothing — Wordfence block store not initialised yet on this install. + return ['ip' => $ip, 'blocked' => false, 'reason' => $reason, + 'note' => 'Wordfence has not initialised its block store yet (needs the firewall/blocking set up). The call is wired correctly and will persist once Wordfence is configured.']; } }