All Pages. add_filter('page_row_actions', [$this, 'page_duplicate_action'], 10, 2); add_action('admin_post_ekmt_duplicate_page', [$this, 'handle_duplicate_page']); add_action('admin_notices', [$this, 'duplicate_page_notice']); } public function admin_menu() { add_menu_page( 'Ahmad Developer Toolkit', 'Ahmad Toolkit', 'upload_files', 'ahmad-developer-toolkit', [$this, 'render'], 'dashicons-format-image', 58 ); } public function assets($hook) { $screen = function_exists('get_current_screen') ? get_current_screen() : null; if ($hook === 'toplevel_page_ahmad-developer-toolkit') { wp_enqueue_style('ekmt-admin', plugin_dir_url(__FILE__) . 'assets/admin.css', [], self::VERSION); wp_enqueue_script('ekmt-admin', plugin_dir_url(__FILE__) . 'assets/admin.js', ['jquery'], self::VERSION, true); wp_enqueue_script('ekmt-seo-audit', plugin_dir_url(__FILE__) . 'assets/seo-audit.js', ['jquery', 'ekmt-admin'], self::VERSION, true); wp_localize_script('ekmt-admin', 'EKMT', [ 'ajax' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce(self::NONCE), 'batch' => 15, 'home' => home_url('/'), ]); } $is_page_admin = $screen && ($screen->post_type === 'page' || $screen->id === 'edit-page'); if ($is_page_admin && in_array($hook, ['edit.php', 'post.php', 'post-new.php'], true)) { wp_enqueue_style('ekmt-notes', plugin_dir_url(__FILE__) . 'assets/notes.css', [], self::VERSION); wp_enqueue_script('ekmt-notes', plugin_dir_url(__FILE__) . 'assets/notes.js', ['jquery'], self::VERSION, true); wp_localize_script('ekmt-notes', 'EKMT_NOTES', [ 'ajax' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce(self::NONCE), ]); } } public function render() { if (!current_user_can('upload_files')) return; ?>
AHMAD • SITE WORKSPACE

Ahmad Developer Toolkit

One practical workspace for media, SEO, forms, site utilities, tracking, and maintenance.

Ready v
AHMAD • DEVELOPER WORKSPACE Media tools, audits, forms, tracking, and site utilities in one place.

Built for practical WordPress development and SEO work.

TOOL 01

Image Renamer

Give media files clean, meaningful names while keeping extensions, generated sizes and common WordPress references in sync.

TOOL 02

WebP Converter

Turn JPG, JPEG and PNG uploads into lightweight WebP files with safe batch processing.

TOOL 03

Image Compressor

Reduce JPG, PNG and WebP file sizes while automatically keeping a backup of the current original.

Compression qualityRecommended range: 75–8582
Smaller fileHigher quality
TOOL 04

Oversized Image Detector

One-click scan for heavy Media Library originals. Every image that is 1 MB or larger is shown below for review.

TOOL 05

Missing ALT Text Scanner

Find Media Library images with empty ALT text and fix each one directly here without reloading the page.

site_widgets_settings(); $seo_default_phone = trim((string) ($widget_phone['phone'] ?? '')); ?>
TOOL 06

Advanced SEO Website Audit + History

Run a strict sitewide SEO diagnosis with advanced content-quality checks, technical checks, prioritized fixes, and saved scan history so you can see exactly what improved or got worse over time.

Deep rendered-content SEO audit. The score is earned from verified SEO setup and page quality; unfinished/new pages do not receive a high score just for existing. It does not pretend to measure rankings, backlinks, Search Console traffic, competitor difficulty, or real-user Core Web Vitals.
SEO MONITORING

SEO Scan History

Every completed audit is saved locally in WordPress so you can see whether SEO health is improving or getting worse over time.

Run an SEO audit to start building history.
indexnow_key(); $ix_key_url = $this->indexnow_key_location(); ?>
FREE TOOL

IndexNow — Submit Only the Page You Choose

No bulk site submission and no automatic submission. Send only the exact page you want from this screen or click IndexNow beside a published Page/Post in WordPress.

IndexNowReadyNo account/API setup required
Submission ModeManual OnlyNothing is sent unless you click IndexNow
Verification Key…Open key file
URL INDEXING TRACKER

Request & Index Status

Tracks the exact request time, IndexNow response, first confirmed indexing time, and elapsed time. In free/no-API mode, IndexNow does not report the exact moment a search engine indexes a URL, so the plugin never invents that status.

Enter a URL above and click View Status.
PAGE-BY-PAGE TRACKING

IndexNow Requests

Each row is a page you intentionally submitted. After verifying the page in a search engine, click Confirm Indexed; the toolkit records the confirmation time and calculates how long it took from your IndexNow request.

No IndexNow requests yet. Go to Pages or Posts and click IndexNow on the exact URL you want.
TOOL 07

Phone Number Scanner

Enter the official number, then scan every published WordPress Page exactly as visitors see it. It checks visible page content plus phone buttons/tel: links and identifies whether each location is in the Header, Footer, or a specific page.

Formatting is ignored. For example, (815) 474-3974, 815-474-3974 and +1 815 474 3974 are treated as the same number.
TOOL 08

Email Address Scanner

Enter the official email, then scan every published WordPress Page exactly as visitors see it. It checks visible content, Header, Footer, email buttons and mailto: links and shows every correct or outdated email location.

Capitalization is ignored. INFO@EXAMPLE.COM and info@example.com are treated as the same address. A button's visible email and its mailto: target are checked separately.
TOOL 09

Form & Booking Health

Scan every published Page as a visitor sees it, find contact and booking forms, check their structure, and safely test eligible contact forms without touching payment or real booking workflows.

Safe testing. Automatic submission is disabled for booking, payment, CAPTCHA, file-upload, and external forms. A passed submission confirms the website accepted the request; inbox delivery should still be verified separately.
TOOL 10

Toolkit Forms

Ten ready-made lead forms with simple shortcodes. Every submission is saved in WordPress and sent as a professional responsive HTML email through wp_mail(), so the website's SMTP setup is reused automatically.

Works with SMTP Mailer

Toolkit Forms use WordPress wp_mail(). Configure the built-in SMTP Mailer tab if this website does not already have a mail transport. Use only one SMTP/mail-delivery plugin at a time.

UNIVERSAL CONNECTOR

Custom HTML Forms

Connect almost any same-site custom HTML lead form to Toolkit Forms without changing its design. Add one attribute to the form, keep normal name="" attributes on the fields, and the toolkit handles the submission, lead inbox, HTML email, SMTP delivery, email log, and form conversion count.

1Add the connectordata-ahmad-form="1"
2Name your fieldsname="phone"
3DoneThe form submits through Ahmad Toolkit.
data-ahmad-title="Free Quote"Optional form name shown in the email and Lead Inbox.
data-ahmad-subject="New quote request"Optional custom email subject.
data-ahmad-success="Thanks! We'll contact you soon."Optional success message.
data-ahmad-reset="no"Optional: keep entered values after a successful submission.
data-label="Service Needed"Optional on any field to control its label in the email.
Copy-ready exampleUse any styling/classes you want. The connector does not redesign the form.
Safe by default.

The destination email cannot be changed from the public form. The connector accepts standard lead/contact fields only and intentionally refuses password, payment-card, and file-upload forms.

lead_form_definitions() as $form_id => $form) : ?>
lead_form_definitions()), true) + 1, 2, '0', STR_PAD_LEFT)); ?>

[ahmad_toolkit_form id=""]
LEAD INBOX

Recent Form Entries

Submissions are saved here even if the mail transport reports a sending problem.

smtp_settings(); $smtp_dashboard = $this->smtp_dashboard_data(); ?>
Emails Last Week
Failed Last Week
Sent
Opened
Emails OverviewLast 7 days
Sent EmailsFailed
ConnectionsYour mail provider
Primary
SMTP Connection
smtp_password_available() ? $smtp_dashboard['connection']['status'] : 'disabled'; $smtp_connection_label = $smtp_connection_state === 'connected' ? 'Connected' : ($smtp_connection_state === 'failed' ? 'Failed' : ($smtp_connection_state === 'disabled' ? 'Disabled' : 'Needs Test')); ?>
Enter your own mail provider settings. SMTP is disabled until you enable and save it.
EMAIL DELIVERY

SMTP Mailer

Connect your own SMTP account, save the settings, and send a test email. Leave this disabled if another plugin already manages email delivery.

SenderThe default name is this website's name, but you can change it.
Email Tracking & LogsKeep local delivery history and optionally measure HTML email opens.
Send Test EmailAfter saving the password, send a real message to confirm end-to-end delivery.
Email LogLatest outbound WordPress emails. Message bodies and SMTP passwords are never stored.
No emails logged yet

New sent and failed messages will appear here when Email Log is enabled.

DateToSubjectStatusOpened
—
site_widgets_settings(); ?>
TOOL 14

Website Contact Widgets

Create one floating contact button that expands into the contact methods you actually configure, plus a professional announcement bar and popup builder for forms, offers, and quick CTAs.

FLOATING CONTACT

Floating Contact Widget

Only enabled channels with valid details will appear on the website.

site_widget_channels() as $key => $channel) : $enabled_key=$key.'_enabled'; $value_key=$key; ?>
Phone / SMS / WhatsApp values are normalized automatically. Facebook can use a Messenger username or full m.me URL. Instagram opens the profile you provide because Instagram does not offer a universal web DM link for every account.
ANNOUNCEMENT

Announcement Bar Generator

Perfect for “Free Estimates”, “Same Day Service”, temporary notices, or offers.

POPUP BUILDER

Professional Popup Generator

Show a contact form or CTA popup automatically after a delay, scroll, or exit intent.

POPUP PREVIEW
TRUST BADGE BUILDER

Professional Trust Badges

Create Licensed, Insured, Warranty, Free Estimates, Trusted Business, locally owned, or custom trust badges and place them anywhere with one shortcode.

trust_badge_defaults() as $i => $badge) : $n=$i+1; ?>
[ahmad_toolkit_trust_badges]
Add [ahmad_toolkit_trust_badges] to Elementor Shortcode widget, Gutenberg Shortcode block, sidebar, footer, or any shortcode-ready area.
Open Website
tracking_totals(); $track_settings = $this->tracking_settings(); $total_leads = array_sum($track); ?>
CONVERSION TOOL

Call & Button Tracking

Track first-party website interactions without adding another analytics platform. Phone, WhatsApp, SMS, email and direction links are detected sitewide. Toolkit Forms and MetForm submissions count after a successful stored submission.

Calls
Forms
WhatsApp
SMS
Email
Directions
Total Leads / Actions
What counts: actual link clicks for call/SMS/email/WhatsApp/maps and successful Toolkit Form or MetForm submissions.
login_settings(); $login_slug = $login_settings['slug']; ?>
DEVELOPER TOOLS

Login URL & Page Utilities

Choose a custom WordPress login slug and duplicate any Page into a safe Draft directly from Pages → All Pages.

Login URL Customizer

Example: /secure-login/ instead of sharing the standard login address.

Duplicate Page

No setup needed. A Duplicate Page link is added below every page title.

Example PageEdit  |  Quick Edit  |  Trash  |  View  |  Duplicate Page
Copies into Draft

Content, featured image, page template, Elementor data and normal custom fields are copied. The duplicate is never published automatically.

Open All Pages
custom_code_settings(); ?>
PRIVATE SITE TOOL

Header / Footer Code

Add trusted snippets such as analytics, verification tags, pixels, chat scripts, or custom HTML without editing the theme.

Only paste code you trust. This tool intentionally allows raw HTML, CSS and JavaScript and is restricted to administrators with the unfiltered_html capability.
maintenance_mode(); $maint_settings = $this->maintenance_settings(); $maint_samples = $this->maintenance_samples(); ?>
TOOL 14

Maintenance & Hosting Notice Generator

Create a clean professional visitor page in one click. Choose a maintenance page or a simple hosting-expired notice, then customize the title, description, and icon. Logged-in admins can still browse the real website normally.

Live status maintenance_mode_label($maint_mode)); ?>

maintenance_mode_help($maint_mode)); ?>

10 READY SAMPLES

Maintenance & hosting notice templates

Click a sample to instantly load its title, description, icon, and page type.

TOOL 14

Unused Image Cleaner

Run the heavy gallery scan in the background. You can leave this plugin screen and keep working elsewhere in WordPress while it continues.

Background Scan
0Images scanned
0Unused found
0 BPotential savings
Protected cleanup flow

Nothing is permanently deleted during scanning. High-confidence candidates move to quarantine immediately when you choose them, so you can check the live site and restore anything you want.

admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('ekmt_front_forms'), ]); $widgets = $this->site_widgets_settings(); if (!empty($widgets['floating_enabled']) || !empty($widgets['announcement_enabled']) || !empty($widgets['popup_enabled']) || !empty($widgets['trust_enabled'])) { wp_enqueue_style('ekmt-site-widgets', plugin_dir_url(__FILE__) . 'assets/widgets.css', [], self::VERSION); wp_enqueue_script('ekmt-site-widgets', plugin_dir_url(__FILE__) . 'assets/widgets.js', [], self::VERSION, true); } $tracking = $this->tracking_settings(); if (!empty($tracking['enabled'])) { wp_enqueue_script('ekmt-conversion-tracking', plugin_dir_url(__FILE__) . 'assets/tracking.js', [], self::VERSION, true); wp_localize_script('ekmt-conversion-tracking', 'EKMT_TRACK', [ 'ajax' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('ekmt_track_event'), ]); } } public function register_lead_entry_type() { register_post_type(self::LEAD_POST_TYPE, [ 'labels' => ['name' => 'Toolkit Form Entries', 'singular_name' => 'Toolkit Form Entry'], 'public' => false, 'show_ui' => false, 'show_in_menu' => false, 'show_in_rest' => false, 'supports' => ['title'], 'capability_type' => 'post', 'map_meta_cap' => true, ]); } private function smtp_defaults() { return [ 'configuration_version' => 1, 'enabled' => 0, 'from_email' => '', 'force_from_email' => 1, 'from_name' => sanitize_text_field((string) get_bloginfo('name')), 'force_from_name' => 1, 'host' => '', 'encryption' => 'tls', 'port' => 587, 'auto_tls' => 1, 'auth' => 1, 'username' => '', 'password' => '', 'log_emails' => 0, 'track_opens' => 0, ]; } private function smtp_settings() { $saved = get_option(self::SMTP_SETTINGS_OPTION, []); $defaults = $this->smtp_defaults(); // Legacy private settings must never activate a shared mailbox on upgrade. if (!is_array($saved) || (int) ($saved['configuration_version'] ?? 0) !== 1) return $defaults; return array_merge($defaults, array_intersect_key($saved, $defaults)); } private function smtp_connection_status() { $saved = get_option(self::SMTP_CONNECTION_OPTION, []); $saved = is_array($saved) ? $saved : []; $status = sanitize_key((string) ($saved['status'] ?? 'unverified')); if (!in_array($status, ['connected', 'failed', 'unverified'], true)) $status = 'unverified'; return [ 'status' => $status, 'checked_at' => absint($saved['checked_at'] ?? 0), 'message' => sanitize_text_field((string) ($saved['message'] ?? '')), ]; } private function smtp_set_connection_status($status, $message = '') { $status = in_array($status, ['connected', 'failed', 'unverified'], true) ? $status : 'unverified'; update_option(self::SMTP_CONNECTION_OPTION, [ 'status' => $status, 'checked_at' => time(), 'message' => sanitize_text_field((string) $message), ], false); } private function smtp_logs() { $logs = get_option(self::SMTP_LOG_OPTION, []); if (!is_array($logs)) return []; $clean = []; foreach ($logs as $row) { if (!is_array($row)) continue; $clean[] = [ 'id' => sanitize_key((string) ($row['id'] ?? '')), 'to' => sanitize_text_field((string) ($row['to'] ?? '')), 'subject' => sanitize_text_field((string) ($row['subject'] ?? '')), 'status' => in_array(($row['status'] ?? ''), ['sent', 'failed'], true) ? $row['status'] : 'sent', 'created_at' => absint($row['created_at'] ?? 0), 'opened_at' => absint($row['opened_at'] ?? 0), 'opens' => absint($row['opens'] ?? 0), 'open_token' => sanitize_key((string) ($row['open_token'] ?? '')), 'error' => sanitize_text_field((string) ($row['error'] ?? '')), ]; } return array_slice($clean, 0, self::SMTP_LOG_LIMIT); } private function smtp_save_logs($logs) { $logs = is_array($logs) ? array_values($logs) : []; update_option(self::SMTP_LOG_OPTION, array_slice($logs, 0, self::SMTP_LOG_LIMIT), false); } private function smtp_recipient_label($to) { $list = is_array($to) ? $to : preg_split('/\s*,\s*/', (string) $to); $clean = []; foreach ((array) $list as $item) { $item = sanitize_text_field((string) $item); if ($item === '') continue; $clean[] = $item; if (count($clean) >= 3) break; } return implode(', ', $clean); } private function smtp_message_is_html($atts) { $headers = $atts['headers'] ?? []; if (is_string($headers)) $headers = preg_split('/\r?\n/', $headers); foreach ((array) $headers as $header) { if (stripos((string) $header, 'content-type:') !== false && stripos((string) $header, 'text/html') !== false) return true; } $message = (string) ($atts['message'] ?? ''); return stripos($message, 'smtp_settings(); if (empty($settings['log_emails'])) { $this->smtp_pending_mail = []; return $atts; } $token = ''; if (!empty($settings['track_opens']) && $this->smtp_message_is_html($atts)) { try { $token = bin2hex(random_bytes(16)); } catch (Throwable $e) { $token = wp_generate_password(32, false, false); $token = preg_replace('/[^a-zA-Z0-9]/', '', $token); } if ($token !== '') { $pixel_url = add_query_arg('adt_mail_open', rawurlencode($token), home_url('/')); $pixel = ''; $message = (string) ($atts['message'] ?? ''); if (stripos($message, '') !== false) { $message = preg_replace('/<\/body>/i', $pixel . '', $message, 1); } else { $message .= $pixel; } $atts['message'] = $message; } } $this->smtp_pending_mail = [ 'id' => sanitize_key(wp_generate_uuid4()), 'to' => $this->smtp_recipient_label($atts['to'] ?? ''), 'subject' => sanitize_text_field((string) ($atts['subject'] ?? '(no subject)')), 'open_token' => sanitize_key($token), ]; return $atts; } private function smtp_append_log($status, $error = '', $mail_data = []) { $settings = $this->smtp_settings(); if (empty($settings['log_emails'])) return; $pending = is_array($this->smtp_pending_mail) ? $this->smtp_pending_mail : []; $to = (string) ($pending['to'] ?? ''); $subject = (string) ($pending['subject'] ?? ''); if ($to === '' && is_array($mail_data)) $to = $this->smtp_recipient_label($mail_data['to'] ?? ''); if ($subject === '' && is_array($mail_data)) $subject = sanitize_text_field((string) ($mail_data['subject'] ?? '(no subject)')); $logs = $this->smtp_logs(); array_unshift($logs, [ 'id' => sanitize_key((string) ($pending['id'] ?? wp_generate_uuid4())), 'to' => $to, 'subject' => $subject !== '' ? $subject : '(no subject)', 'status' => $status === 'failed' ? 'failed' : 'sent', 'created_at' => time(), 'opened_at' => 0, 'opens' => 0, 'open_token' => sanitize_key((string) ($pending['open_token'] ?? '')), 'error' => sanitize_text_field((string) $error), ]); $this->smtp_save_logs($logs); $this->smtp_pending_mail = []; } public function capture_smtp_mail_success($mail_data) { $settings = $this->smtp_settings(); if (!empty($settings['enabled'])) $this->smtp_set_connection_status('connected', 'WordPress handed the email to the configured SMTP transport successfully.'); $this->smtp_append_log('sent', '', is_array($mail_data) ? $mail_data : []); } public function smtp_handle_open_pixel() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public tracking pixel cannot use a logged-in nonce; the random token is the capability. if (empty($_GET['adt_mail_open'])) return; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- See note above. $token = sanitize_key(wp_unslash($_GET['adt_mail_open'])); if (!preg_match('/^[a-zA-Z0-9]{20,64}$/', $token)) return; $logs = $this->smtp_logs(); $changed = false; foreach ($logs as &$row) { if (!empty($row['open_token']) && hash_equals((string) $row['open_token'], $token)) { $row['opens'] = absint($row['opens'] ?? 0) + 1; if (empty($row['opened_at'])) $row['opened_at'] = time(); $changed = true; break; } } unset($row); if ($changed) $this->smtp_save_logs($logs); nocache_headers(); header('Content-Type: image/gif'); header('Content-Length: 43'); echo base64_decode('R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Binary GIF response. exit; } private function smtp_dashboard_data() { $logs = $this->smtp_logs(); $cutoff = time() - (7 * DAY_IN_SECONDS); $emails_last_week = 0; $failed_last_week = 0; $sent_total = 0; $opened_total = 0; $days = []; $site_now = current_datetime(); for ($i = 6; $i >= 0; $i--) { $day = $site_now->modify('-' . $i . ' days'); $key = $day->format('Y-m-d'); $days[$key] = [ 'label' => $day->format('D'), 'sent' => 0, 'failed' => 0, ]; } foreach ($logs as $row) { $created = absint($row['created_at'] ?? 0); $status = (string) ($row['status'] ?? 'sent'); if ($status === 'sent') $sent_total++; if (!empty($row['opened_at'])) $opened_total++; if ($created >= $cutoff) { if ($status === 'sent') $emails_last_week++; elseif ($status === 'failed') $failed_last_week++; } if ($created > 0) { $day_key = wp_date('Y-m-d', $created); if (isset($days[$day_key])) { if ($status === 'failed') $days[$day_key]['failed']++; else $days[$day_key]['sent']++; } } } $max = 1; foreach ($days as $day) $max = max($max, (int) $day['sent'], (int) $day['failed']); foreach ($days as $key => $day) { $days[$key]['sent_pct'] = min(100, round(((int) $day['sent'] / $max) * 100)); $days[$key]['failed_pct'] = min(100, round(((int) $day['failed'] / $max) * 100)); } return [ 'emails_last_week' => $emails_last_week, 'failed_last_week' => $failed_last_week, 'sent_total' => $sent_total, 'opened_total' => $opened_total, 'days' => $days, 'logs' => array_slice($logs, 0, 50), 'connection' => $this->smtp_connection_status(), ]; } public function ajax_clear_smtp_logs() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('manage_options')) wp_send_json_error(['message' => 'Only an administrator can clear email logs.'], 403); delete_option(self::SMTP_LOG_OPTION); wp_send_json_success(['message' => 'Email log cleared.']); } private function smtp_crypto_key() { return hash('sha256', wp_salt('auth') . '|ahmad-developer-toolkit|smtp', true); } private function smtp_encrypt_password($password) { $password = (string) $password; if ($password === '') return ''; if (!function_exists('openssl_encrypt')) { return new WP_Error('smtp_crypto_unavailable', 'OpenSSL is required to securely store an SMTP password. You can instead define AHMAD_TOOLKIT_SMTP_PASSWORD in wp-config.php.'); } try { $iv = random_bytes(12); } catch (Throwable $e) { return new WP_Error('smtp_crypto_random', 'Secure random bytes are unavailable on this server.'); } $tag = ''; $cipher = openssl_encrypt($password, 'aes-256-gcm', $this->smtp_crypto_key(), OPENSSL_RAW_DATA, $iv, $tag, 'adt-smtp-v1'); if ($cipher === false || strlen($tag) !== 16) { return new WP_Error('smtp_crypto_failed', 'The SMTP password could not be encrypted on this server.'); } return 'gcm1:' . base64_encode($iv . $tag . $cipher); } private function smtp_decrypt_password($stored = '') { if (defined('AHMAD_TOOLKIT_SMTP_PASSWORD')) return (string) AHMAD_TOOLKIT_SMTP_PASSWORD; $stored = (string) $stored; if ($stored === '' || strpos($stored, 'gcm1:') !== 0 || !function_exists('openssl_decrypt')) return ''; $raw = base64_decode(substr($stored, 5), true); if ($raw === false || strlen($raw) < 29) return ''; $iv = substr($raw, 0, 12); $tag = substr($raw, 12, 16); $cipher = substr($raw, 28); $plain = openssl_decrypt($cipher, 'aes-256-gcm', $this->smtp_crypto_key(), OPENSSL_RAW_DATA, $iv, $tag, 'adt-smtp-v1'); return $plain === false ? '' : (string) $plain; } private function smtp_password_available() { if (defined('AHMAD_TOOLKIT_SMTP_PASSWORD') && (string) AHMAD_TOOLKIT_SMTP_PASSWORD !== '') return true; $settings = $this->smtp_settings(); return $this->smtp_decrypt_password($settings['password']) !== ''; } public function configure_smtp_mailer($phpmailer) { $settings = $this->smtp_settings(); if (empty($settings['enabled']) || empty($settings['host'])) return; $smtp_password = $this->smtp_decrypt_password($settings['password']); if ($smtp_password === '') return; $phpmailer->isSMTP(); $phpmailer->Host = $settings['host']; $phpmailer->Port = (int) $settings['port']; $phpmailer->SMTPAuth = !empty($settings['auth']); $phpmailer->SMTPAutoTLS = !empty($settings['auto_tls']); $phpmailer->Timeout = 20; if ($settings['encryption'] === 'ssl') $phpmailer->SMTPSecure = 'ssl'; elseif ($settings['encryption'] === 'tls') $phpmailer->SMTPSecure = 'tls'; else $phpmailer->SMTPSecure = ''; if (!empty($settings['auth'])) { $phpmailer->Username = $settings['username']; $phpmailer->Password = $smtp_password; } } public function smtp_mail_from($email) { $settings = $this->smtp_settings(); if (empty($settings['enabled']) || !is_email($settings['from_email'])) return $email; if (!empty($settings['force_from_email'])) return $settings['from_email']; $host = (string) wp_parse_url(network_home_url('/'), PHP_URL_HOST); $default = $host ? 'wordpress@' . preg_replace('/^www\\./i', '', $host) : ''; return ($default && strtolower((string) $email) === strtolower($default)) ? $settings['from_email'] : $email; } public function smtp_mail_from_name($name) { $settings = $this->smtp_settings(); if (empty($settings['enabled']) || $settings['from_name'] === '') return $name; if (!empty($settings['force_from_name'])) return $settings['from_name']; return ((string) $name === 'WordPress') ? $settings['from_name'] : $name; } public function capture_smtp_mail_error($error) { $message = ''; $mail_data = []; if ($error instanceof WP_Error) { $message = $error->get_error_message(); $this->smtp_last_error = $message; $data = $error->get_error_data(); if (is_array($data) && !empty($data['to'])) $mail_data = $data; } $settings = $this->smtp_settings(); if (!empty($settings['enabled'])) $this->smtp_set_connection_status('failed', $message !== '' ? $message : 'The last SMTP send attempt failed.'); $this->smtp_append_log('failed', $message, $mail_data); } public function ajax_save_smtp_settings() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('manage_options')) wp_send_json_error(['message' => 'Only an administrator can change SMTP settings.'], 403); $old = $this->smtp_settings(); $data = $this->smtp_defaults(); $data['enabled'] = !empty($_POST['enabled']) ? 1 : 0; $data['from_name'] = sanitize_text_field(wp_unslash($_POST['from_name'] ?? '')); if ($data['from_name'] === '') $data['from_name'] = sanitize_text_field((string) get_bloginfo('name')); $data['from_email'] = sanitize_email(wp_unslash($_POST['from_email'] ?? '')); $data['host'] = sanitize_text_field(wp_unslash($_POST['host'] ?? '')); $data['username'] = sanitize_text_field(wp_unslash($_POST['username'] ?? '')); $data['port'] = absint($_POST['port'] ?? 587); $encryption = sanitize_key(wp_unslash($_POST['encryption'] ?? 'tls')); if (!in_array($encryption, ['tls', 'ssl'], true)) wp_send_json_error(['message' => 'Choose TLS or SSL encryption.'], 422); $data['encryption'] = $encryption; $data['log_emails'] = !empty($_POST['log_emails']) ? 1 : 0; $data['track_opens'] = $data['log_emails'] && !empty($_POST['track_opens']) ? 1 : 0; $data['password'] = $old['password']; // Never reuse one account's saved password with a different server or username. if ($data['host'] !== $old['host'] || $data['username'] !== $old['username']) $data['password'] = ''; $password_input = isset($_POST['password']) && is_string($_POST['password']) ? wp_unslash($_POST['password']) : ''; if ($password_input !== '' && !defined('AHMAD_TOOLKIT_SMTP_PASSWORD')) { $encrypted = $this->smtp_encrypt_password($password_input); if (is_wp_error($encrypted)) wp_send_json_error(['message' => $encrypted->get_error_message()], 500); $data['password'] = $encrypted; } if ($data['enabled']) { if (!is_email($data['from_email']) || $data['username'] === '') wp_send_json_error(['message' => 'Enter your sender email and SMTP username.'], 422); if ($data['host'] === '' || !preg_match('/^[a-zA-Z0-9.-]+$/D', $data['host'])) wp_send_json_error(['message' => 'Enter a valid SMTP hostname without a URL scheme or path.'], 422); if ($data['port'] < 1 || $data['port'] > 65535) wp_send_json_error(['message' => 'Enter a port between 1 and 65535.'], 422); if ($this->smtp_decrypt_password($data['password']) === '') wp_send_json_error(['message' => 'Enter your SMTP password before enabling SMTP.'], 422); } update_option(self::SMTP_SETTINGS_OPTION, $data, false); $this->smtp_set_connection_status($data['enabled'] ? 'unverified' : 'disabled', $data['enabled'] ? 'Settings saved. Send a test email to verify delivery.' : 'SMTP Mailer is disabled.'); wp_send_json_success([ 'message' => $data['enabled'] ? 'SMTP settings saved. Send a test email to verify delivery.' : 'SMTP Mailer is disabled. WordPress will use its existing mail transport.', 'enabled' => (bool) $data['enabled'], 'password_saved' => $this->smtp_password_available(), ]); } public function ajax_send_smtp_test() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('manage_options')) wp_send_json_error(['message' => 'Only an administrator can send an SMTP test email.'], 403); $email = sanitize_email(wp_unslash($_POST['email'] ?? '')); if (!$email || !is_email($email)) wp_send_json_error(['message' => 'Enter a valid test email address.'], 422); $settings = $this->smtp_settings(); if (empty($settings['enabled'])) wp_send_json_error(['message' => 'Enable and save SMTP Mailer before sending a test.'], 422); if (empty($settings['host'])) wp_send_json_error(['message' => 'SMTP Host is missing. Save the SMTP settings first.'], 422); if (!$this->smtp_password_available()) wp_send_json_error(['message' => 'Enter and save the SMTP password first.'], 422); $this->smtp_last_error = ''; $site_name = wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES); $subject = '[' . $site_name . '] SMTP test email'; $message = '' . '
' . '' . '' . '' . '
' . esc_html($site_name) . '
SMTP Mailer is working
This test email was sent through the SMTP server configured on ' . esc_html((string) wp_parse_url(home_url('/'), PHP_URL_HOST)) . '.
'; $sent = wp_mail($email, $subject, $message, ['Content-Type: text/html; charset=UTF-8']); if (!$sent) { $detail = $this->smtp_last_error ? ' ' . sanitize_text_field($this->smtp_last_error) : ''; wp_send_json_error(['message' => 'WordPress could not send the test email.' . $detail], 500); } wp_send_json_success(['message' => 'Test email sent successfully to ' . $email . '. Check the inbox and spam folder.']); } private function custom_code_settings() { $saved = get_option(self::CUSTOM_CODE_OPTION, []); $saved = is_array($saved) ? $saved : []; return [ 'enabled' => !empty($saved['enabled']) ? 1 : 0, 'header_code' => isset($saved['header_code']) && is_string($saved['header_code']) ? $saved['header_code'] : '', 'footer_code' => isset($saved['footer_code']) && is_string($saved['footer_code']) ? $saved['footer_code'] : '', ]; } public function ajax_save_custom_code() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('unfiltered_html')) { wp_send_json_error(['message' => 'This feature requires permission to save unfiltered HTML.'], 403); } $enabled = !empty($_POST['enabled']) ? 1 : 0; $header_code = isset($_POST['header_code']) ? wp_unslash($_POST['header_code']) : ''; $footer_code = isset($_POST['footer_code']) ? wp_unslash($_POST['footer_code']) : ''; if (!is_string($header_code)) $header_code = ''; if (!is_string($footer_code)) $footer_code = ''; update_option(self::CUSTOM_CODE_OPTION, [ 'enabled' => $enabled, 'header_code' => $header_code, 'footer_code' => $footer_code, ], false); wp_send_json_success(['message' => $enabled ? 'Header / Footer Code saved and enabled.' : 'Header / Footer Code saved and disabled.']); } public function render_custom_header_code() { $settings = $this->custom_code_settings(); if (empty($settings['enabled']) || trim($settings['header_code']) === '') return; echo "\n" . $settings['header_code'] . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Intentional trusted administrator code-injection feature. } public function render_custom_footer_code() { $settings = $this->custom_code_settings(); if (empty($settings['enabled']) || trim($settings['footer_code']) === '') return; echo "\n" . $settings['footer_code'] . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Intentional trusted administrator code-injection feature. } private function forms_recipient() { return sanitize_email((string) get_option(self::FORMS_RECIPIENT_OPTION, '')); } private function tracking_settings() { $saved = get_option(self::TRACKING_SETTINGS_OPTION, []); return wp_parse_args(is_array($saved) ? $saved : [], ['enabled' => 1]); } private function tracking_totals() { $defaults = ['calls'=>0, 'forms'=>0, 'whatsapp'=>0, 'sms'=>0, 'email'=>0, 'directions'=>0]; $saved = get_option(self::TRACKING_OPTION, []); $saved = wp_parse_args(is_array($saved) ? $saved : [], $defaults); foreach ($defaults as $key => $zero) $saved[$key] = max(0, absint($saved[$key] ?? 0)); return $saved; } private function increment_tracking($event) { $allowed = ['calls','forms','whatsapp','sms','email','directions']; if (!in_array($event, $allowed, true)) return false; $settings = $this->tracking_settings(); if (empty($settings['enabled'])) return false; $totals = $this->tracking_totals(); $totals[$event] = absint($totals[$event]) + 1; update_option(self::TRACKING_OPTION, $totals, false); return true; } /** * MetForm fires this hook after the submitted entry has been stored. * Arguments are optional because MetForm versions have varied in what they * pass to the action. We only need the successful event itself. */ public function track_metform_submission($form_id = 0, $form_data = [], $handler = null) { $this->increment_tracking('forms'); } private function seo_string_length($value) { $value = (string) $value; return function_exists('mb_strlen') ? mb_strlen($value, 'UTF-8') : strlen($value); } private function seo_word_count($value) { $text = html_entity_decode(wp_strip_all_tags((string) $value), ENT_QUOTES, 'UTF-8'); if (preg_match_all('/[\p{L}\p{N}][\p{L}\p{N}\'\x{2019}-]*/u', $text, $m)) return count($m[0]); return 0; } private function seo_normalize_url($url) { $parts = wp_parse_url((string) $url); if (!$parts || empty($parts['host'])) return untrailingslashit((string) $url); $scheme = strtolower((string) ($parts['scheme'] ?? 'https')); $host = strtolower((string) $parts['host']); $port = isset($parts['port']) ? ':' . absint($parts['port']) : ''; $path = isset($parts['path']) ? '/' . ltrim((string) $parts['path'], '/') : '/'; $path = $path === '/' ? '/' : untrailingslashit($path); return $scheme . '://' . $host . $port . $path; } private function seo_detect_plugin() { if (defined('RANK_MATH_VERSION') || class_exists('RankMath\\Helper')) return 'Rank Math'; if (defined('WPSEO_VERSION') || class_exists('WPSEO_Options')) return 'Yoast SEO'; if (defined('AIOSEO_VERSION') || class_exists('AIOSEO\\Plugin\\AIOSEO')) return 'All in One SEO'; if (defined('SEOPRESS_VERSION')) return 'SEOPress'; return 'Not detected'; } private function seo_auditable_post_types() { $types = get_post_types(['public' => true], 'names'); unset($types['attachment']); return array_values(array_filter($types, function($type) { return !in_array($type, ['nav_menu_item', 'wp_block', 'wp_template', 'wp_template_part', 'wp_navigation'], true); })); } private function seo_is_generic_text($text) { $text = strtolower(trim(wp_strip_all_tags((string) $text))); $text = preg_replace('/\s+/', ' ', $text); if ($text === '') return true; $site_name = strtolower(trim((string) get_bloginfo('name'))); if ($site_name !== '' && trim($text, " -|–—:") === $site_name) return true; if (strpos($text, 'just another wordpress site') !== false) return true; if (preg_match('/^welcome(?:\s+to\b|[!.]?$)/i', $text)) return true; $generic = ['home', 'welcome', 'welcome!', 'untitled', 'sample page', 'page', 'homepage', 'new page']; return in_array($text, $generic, true); } private function seo_is_utility_page($page_id) { $slug = strtolower((string) get_post_field('post_name', $page_id)); $title = strtolower((string) get_the_title($page_id)); $haystack = $slug . ' ' . $title; foreach (['contact', 'privacy', 'terms', 'thank', 'cart', 'checkout', 'account', 'login', 'cookie', 'accessibility'] as $word) { if (strpos($haystack, $word) !== false) return true; } return false; } private function seo_site_checks() { $seo_plugin = $this->seo_detect_plugin(); $https = is_ssl() || stripos(home_url('/'), 'https://') === 0; $pretty = trim((string) get_option('permalink_structure')) !== ''; $visible = (string) get_option('blog_public', '1') === '1'; $site_title = trim((string) get_option('blogname', '')); $tagline = trim((string) get_option('blogdescription', '')); $default_tagline = strtolower($tagline) === 'just another wordpress site'; $sitemap = false; if ($seo_plugin !== 'Not detected') { $sitemap = true; } elseif (function_exists('wp_sitemaps_get_server')) { try { $server = wp_sitemaps_get_server(); $sitemap = $server && method_exists($server, 'sitemaps_enabled') ? (bool) $server->sitemaps_enabled() : true; } catch (Throwable $e) { $sitemap = false; } } $robots_status = 'warning'; $robots_detail = 'robots.txt could not be verified.'; $robots_response = wp_remote_get(home_url('/robots.txt'), ['timeout' => 4, 'redirection' => 2, 'user-agent' => '11-Keys-SEO-Audit/' . self::VERSION]); if (!is_wp_error($robots_response)) { $robots_code = (int) wp_remote_retrieve_response_code($robots_response); $robots_body = (string) wp_remote_retrieve_body($robots_response); if ($robots_code >= 200 && $robots_code < 300) { if (preg_match('/^\s*Disallow\s*:\s*\/\s*$/mi', $robots_body)) { $robots_status = 'critical'; $robots_detail = 'robots.txt contains a sitewide “Disallow: /” rule.'; } else { $robots_status = 'pass'; $robots_detail = 'robots.txt is reachable and no exact sitewide Disallow: / rule was detected.'; } } else { $robots_detail = 'robots.txt returned HTTP ' . $robots_code . '.'; } } $sitemap_reachable = false; $sitemap_url = ''; if ($seo_plugin === 'All in One SEO') $sitemap_candidates = [home_url('/sitemap.xml'), home_url('/sitemap_index.xml')]; elseif ($seo_plugin !== 'Not detected') $sitemap_candidates = [home_url('/sitemap_index.xml'), home_url('/wp-sitemap.xml')]; else $sitemap_candidates = [home_url('/wp-sitemap.xml'), home_url('/sitemap_index.xml')]; foreach ($sitemap_candidates as $candidate) { $resp = wp_remote_get($candidate, ['timeout' => 4, 'redirection' => 2, 'user-agent' => '11-Keys-SEO-Audit/' . self::VERSION]); if (is_wp_error($resp)) continue; $code = (int) wp_remote_retrieve_response_code($resp); $body = strtolower(substr((string) wp_remote_retrieve_body($resp), 0, 12000)); if ($code >= 200 && $code < 300 && (strpos($body, ' $visible ? 'pass' : 'critical', 'label' => 'Search engine visibility', 'detail' => $visible ? 'Search engines are allowed to index the site.' : 'WordPress is set to discourage search engines.', 'score_weight' => 40], ['status' => $https ? 'pass' : 'warning', 'label' => 'HTTPS', 'detail' => $https ? 'The website is using HTTPS.' : 'The website URL is not using HTTPS.', 'score_weight' => 20], ['status' => $pretty ? 'pass' : 'warning', 'label' => 'Permalinks', 'detail' => $pretty ? 'Pretty permalinks are enabled.' : 'Plain permalink structure is enabled.', 'score_weight' => 10], ['status' => $sitemap ? 'pass' : 'warning', 'label' => 'XML sitemap provider', 'detail' => $sitemap ? 'A WordPress/SEO sitemap provider is available.' : 'No sitemap provider was detected locally.', 'score_weight' => 8], ['status' => $sitemap_reachable ? 'pass' : 'warning', 'label' => 'Sitemap reachability', 'detail' => $sitemap_reachable ? 'A public XML sitemap responded successfully: ' . $sitemap_url : 'Common sitemap URLs did not return a verifiable XML sitemap.', 'score_weight' => 10], ['status' => $robots_status, 'label' => 'robots.txt', 'detail' => $robots_detail, 'score_weight' => 8], ['status' => $site_title !== '' ? 'pass' : 'warning', 'label' => 'Site title', 'detail' => $site_title !== '' ? 'A WordPress site title is configured.' : 'The WordPress site title is empty.', 'score_weight' => 5], ['status' => !$default_tagline ? 'pass' : 'warning', 'label' => 'Default WordPress tagline', 'detail' => $default_tagline ? 'The default “Just another WordPress site” tagline is still configured.' : ($tagline !== '' ? 'The default WordPress tagline is not in use.' : 'No default WordPress tagline is being used.'), 'score_weight' => 10], ['status' => $seo_plugin === 'Not detected' ? 'info' : 'pass', 'label' => 'SEO plugin', 'detail' => $seo_plugin === 'Not detected' ? 'No major SEO plugin was detected. An SEO plugin is not required if equivalent output is handled another way.' : $seo_plugin . ' detected.', 'score_weight' => 0], ]; } private function seo_content_shingles($text, $limit = 90) { $text = html_entity_decode(wp_strip_all_tags((string) $text), ENT_QUOTES, 'UTF-8'); $text = strtolower(preg_replace('/[^\p{L}\p{N}]+/u', ' ', $text)); $words = preg_split('/\s+/u', trim($text), -1, PREG_SPLIT_NO_EMPTY); if (!is_array($words) || count($words) < 12) return []; $stop = array_flip(['the','and','for','with','that','this','from','your','you','our','are','was','were','have','has','had','will','can','not','but','all','any','its','into','about','more','their','they','them','then','than','when','where','what','who','why','how','a','an','of','to','in','on','at','by','or','as','is','it','be','we','i']); $clean = []; foreach ($words as $w) { if (isset($stop[$w]) || strlen($w) < 3) continue; $clean[] = $w; } if (count($clean) < 9) return []; $hashes = []; $max = min(count($clean) - 2, 450); for ($i = 0; $i < $max; $i++) { $shingle = $clean[$i] . ' ' . $clean[$i+1] . ' ' . $clean[$i+2]; $hashes[substr(md5($shingle), 0, 10)] = true; } $keys = array_keys($hashes); sort($keys, SORT_STRING); if (count($keys) > $limit) { $step = max(1, (int) floor(count($keys) / $limit)); $sample = []; for ($i = 0; $i < count($keys) && count($sample) < $limit; $i += $step) $sample[] = $keys[$i]; $keys = $sample; } return array_values($keys); } private function seo_history_items() { $history = get_option(self::SEO_HISTORY_OPTION, []); return is_array($history) ? array_values($history) : []; } public function ajax_get_seo_history() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); wp_send_json_success(['items' => $this->seo_history_items()]); } public function ajax_clear_seo_history() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); delete_option(self::SEO_HISTORY_OPTION); wp_send_json_success(['message' => 'SEO scan history cleared.']); } private function seo_clean_history_payload($payload) { $summary = isset($payload['summary']) && is_array($payload['summary']) ? $payload['summary'] : []; $categories = isset($payload['categories']) && is_array($payload['categories']) ? $payload['categories'] : []; $issues = isset($payload['issues']) && is_array($payload['issues']) ? $payload['issues'] : []; $pages = isset($payload['pages']) && is_array($payload['pages']) ? $payload['pages'] : []; $clean_categories = []; foreach (array_slice($categories, 0, 20, true) as $name => $score) { $clean_categories[sanitize_text_field((string) $name)] = max(0, min(100, absint($score))); } $clean_issues = []; foreach (array_slice($issues, 0, 80, true) as $code => $item) { if (!is_array($item)) continue; $key = sanitize_key((string) $code); if ($key === '') continue; $clean_issues[$key] = [ 'label' => sanitize_text_field((string) ($item['label'] ?? $key)), 'severity' => in_array(($item['severity'] ?? ''), ['critical','warning','info'], true) ? $item['severity'] : 'warning', 'count' => max(0, absint($item['count'] ?? 0)), ]; } $clean_pages = []; foreach (array_slice($pages, 0, 250, true) as $url => $item) { if (!is_array($item)) continue; $safe_url = esc_url_raw((string) $url); if ($safe_url === '') continue; $clean_pages[$safe_url] = [ 'title' => sanitize_text_field((string) ($item['title'] ?? '')), 'score' => max(0, min(100, absint($item['score'] ?? 0))), 'critical' => max(0, absint($item['critical'] ?? 0)), 'warnings' => max(0, absint($item['warnings'] ?? 0)), ]; } return [ 'id' => sanitize_key((string) ($payload['id'] ?? uniqid('seo_', false))), 'timestamp' => time(), 'date' => wp_date('M j, Y g:i a'), 'summary' => [ 'score' => max(0, min(100, absint($summary['score'] ?? 0))), 'improvement' => max(0, min(100, absint($summary['improvement'] ?? 0))), 'pages' => max(0, absint($summary['pages'] ?? 0)), 'critical' => max(0, absint($summary['critical'] ?? 0)), 'warnings' => max(0, absint($summary['warnings'] ?? 0)), 'avgPage' => max(0, min(100, absint($summary['avgPage'] ?? 0))), 'technical' => max(0, min(100, absint($summary['technical'] ?? 0))), 'label' => sanitize_text_field((string) ($summary['label'] ?? '')), ], 'categories' => $clean_categories, 'issues' => $clean_issues, 'pages' => $clean_pages, ]; } public function ajax_save_seo_history() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON is decoded and every supported field is sanitized in seo_clean_history_payload(). $raw = wp_unslash((string) ($_POST['payload'] ?? '')); if ($raw === '' || strlen($raw) > 800000) wp_send_json_error(['message' => 'SEO history payload is missing or too large.'], 400); $payload = json_decode($raw, true); if (!is_array($payload)) wp_send_json_error(['message' => 'SEO history payload is invalid.'], 400); $item = $this->seo_clean_history_payload($payload); $history = $this->seo_history_items(); $previous = !empty($history) ? end($history) : null; $history[] = $item; if (count($history) > 20) $history = array_slice($history, -20); update_option(self::SEO_HISTORY_OPTION, array_values($history), false); wp_send_json_success(['item' => $item, 'previous' => $previous, 'items' => array_values($history)]); } private function seo_fetch_page_html($page_id) { $url = get_permalink($page_id); if (!$url) return new WP_Error('no_url', 'Could not determine the public page URL.'); $response = wp_remote_get($url, [ 'timeout' => 15, 'redirection' => 4, 'user-agent' => '11-Keys-SEO-Audit/' . self::VERSION . '; ' . home_url('/'), 'headers' => ['Cache-Control' => 'no-cache'], ]); if (is_wp_error($response)) return $response; return [ 'url' => $url, 'status' => (int) wp_remote_retrieve_response_code($response), 'html' => (string) wp_remote_retrieve_body($response), ]; } private function seo_xpath_first_attr($xpath, $query, $attr = '') { $nodes = $xpath->query($query); if (!$nodes || !$nodes->length) return ''; $node = $nodes->item(0); if ($attr !== '' && $node instanceof DOMElement) return trim((string) $node->getAttribute($attr)); return trim((string) $node->textContent); } private function seo_audit_page($page_id, $official_phone = '') { $title_wp = get_the_title($page_id); $edit_url = get_edit_post_link($page_id, 'raw') ?: ''; $fetched = $this->seo_fetch_page_html($page_id); if (is_wp_error($fetched)) { return [ 'id' => (int) $page_id, 'page_title' => $title_wp, 'url' => get_permalink($page_id) ?: '', 'edit_url' => $edit_url, 'score' => 0, 'base_score' => 0, 'score_breakdown' => [], 'critical' => 1, 'warnings' => 0, 'passed' => 0, 'checks' => 1, 'title' => '', 'meta' => '', 'h1' => '', 'issues' => [[ 'severity' => 'critical', 'code' => 'fetch_error', 'label' => 'Page could not be fetched', 'detail' => $fetched->get_error_message(), ]], 'stats' => ['images'=>0,'missing_alt'=>0,'words'=>0,'internal_links'=>0,'external_links'=>0,'wrong_phones'=>0], 'wrong_numbers' => [], ]; } $html = $fetched['html']; $url = $fetched['url']; $status = absint($fetched['status']); $issues = []; $checks = 0; $passed = 0; $add_check = function($ok, $severity, $code, $label, $detail = '') use (&$issues, &$checks, &$passed) { $checks++; if ($ok) { $passed++; return; } $issues[] = ['severity'=>$severity, 'code'=>$code, 'label'=>$label, 'detail'=>$detail]; }; $seo_title = ''; $meta_desc = ''; $canonical = ''; $robots = ''; $lang = ''; $viewport = ''; $h1s = []; $h2_count = 0; $heading_levels = []; $empty_headings = 0; $heading_skips = 0; $image_total = 0; $missing_alt = []; $empty_alt = 0; $missing_dimensions = 0; $generic_image_names = 0; $internal_links = 0; $external_links = 0; $empty_links = 0; $generic_anchor_links = 0; $nofollow_internal = 0; $schema_count = 0; $invalid_schema = 0; $schema_types = []; $og_title = ''; $og_image = ''; $twitter_card = ''; $body_text = ''; if (class_exists('DOMDocument')) { $doc = new DOMDocument(); $previous = libxml_use_internal_errors(true); $loaded = $doc->loadHTML('' . $html, LIBXML_HTML_NODEFDTD); libxml_clear_errors(); libxml_use_internal_errors($previous); if ($loaded) { $xpath = new DOMXPath($doc); $seo_title = $this->seo_xpath_first_attr($xpath, '//title'); $meta_desc = $this->seo_xpath_first_attr($xpath, '//meta[translate(@name,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="description"]', 'content'); $canonical = $this->seo_xpath_first_attr($xpath, '//link[contains(concat(" ", normalize-space(translate(@rel,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")), " "), " canonical ")]', 'href'); $robots = strtolower($this->seo_xpath_first_attr($xpath, '//meta[translate(@name,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="robots"]', 'content')); $viewport = $this->seo_xpath_first_attr($xpath, '//meta[translate(@name,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="viewport"]', 'content'); $lang = $this->seo_xpath_first_attr($xpath, '//html', 'lang'); $og_title = $this->seo_xpath_first_attr($xpath, '//meta[translate(@property,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="og:title"]', 'content'); $og_image = $this->seo_xpath_first_attr($xpath, '//meta[translate(@property,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="og:image"]', 'content'); $twitter_card = $this->seo_xpath_first_attr($xpath, '//meta[translate(@name,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="twitter:card"]', 'content'); $h1nodes = $xpath->query('//h1'); if ($h1nodes) foreach ($h1nodes as $node) { $txt = trim(preg_replace('/\s+/', ' ', (string) $node->textContent)); if ($txt !== '') $h1s[] = $txt; } $h2nodes = $xpath->query('//h2'); $h2_count = $h2nodes ? (int) $h2nodes->length : 0; $heading_nodes = $xpath->query('//h1|//h2|//h3|//h4|//h5|//h6'); if ($heading_nodes) { $previous_level = 0; foreach ($heading_nodes as $heading_node) { $level = (int) substr(strtolower((string) $heading_node->nodeName), 1); $txt = trim(preg_replace('/\s+/', ' ', (string) $heading_node->textContent)); if ($txt === '') $empty_headings++; if ($previous_level > 0 && $level > ($previous_level + 1)) $heading_skips++; $heading_levels[] = $level; $previous_level = $level; } } $imgs = $xpath->query('//img'); if ($imgs) { foreach ($imgs as $img) { $image_total++; if (!($img instanceof DOMElement)) continue; $role = strtolower(trim((string) $img->getAttribute('role'))); $hidden = strtolower(trim((string) $img->getAttribute('aria-hidden'))); $src = trim((string) $img->getAttribute('src')); $filename = $src ? basename((string) wp_parse_url($src, PHP_URL_PATH)) : ''; if ($filename !== '' && preg_match('/^(?:img|image|dsc|pxl|photo|screenshot|untitled)[-_ ]?\d+/i', pathinfo($filename, PATHINFO_FILENAME))) $generic_image_names++; if (!$img->hasAttribute('width') || !$img->hasAttribute('height')) $missing_dimensions++; if ($role === 'presentation' || $hidden === 'true') continue; $has_alt = $img->hasAttribute('alt'); $alt_value = $has_alt ? trim((string) $img->getAttribute('alt')) : ''; if ($has_alt && $alt_value === '') $empty_alt++; if (!$has_alt && count($missing_alt) < 8) { $missing_alt[] = $filename !== '' ? $filename : 'Image without src'; } elseif (!$has_alt) { $missing_alt[] = ''; } } } $home_host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST)); $links = $xpath->query('//a[@href and not(ancestor::header) and not(ancestor::footer) and not(ancestor::nav)]'); if ($links) { foreach ($links as $link) { if (!($link instanceof DOMElement)) continue; $href = trim(html_entity_decode((string) $link->getAttribute('href'), ENT_QUOTES, 'UTF-8')); if ($href === '' || $href === '#' || stripos($href, 'javascript:void') === 0) { $empty_links++; continue; } if (preg_match('~^(tel:|mailto:|sms:|javascript:)~i', $href) || strpos($href, '#') === 0) continue; $host = strtolower((string) wp_parse_url($href, PHP_URL_HOST)); $is_internal = ($host === '' || $host === $home_host); if ($is_internal) $internal_links++; else $external_links++; $anchor_text = strtolower(trim(preg_replace('/\s+/', ' ', (string) $link->textContent))); if (in_array($anchor_text, ['click here','read more','learn more','more','here','this link'], true)) $generic_anchor_links++; $rel = strtolower(trim((string) $link->getAttribute('rel'))); if ($is_internal && preg_match('/(?:^|\s)nofollow(?:\s|$)/', $rel)) $nofollow_internal++; } } $schemas = $xpath->query('//script[contains(translate(@type,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz"),"application/ld+json")]'); $schema_count = $schemas ? (int) $schemas->length : 0; if ($schemas) { foreach ($schemas as $schema_node) { $json = trim((string) $schema_node->textContent); if ($json === '') continue; $decoded = json_decode($json, true); if (json_last_error() !== JSON_ERROR_NONE) { $invalid_schema++; continue; } $stack = [$decoded]; while ($stack) { $current = array_pop($stack); if (!is_array($current)) continue; if (isset($current['@type'])) { $types = is_array($current['@type']) ? $current['@type'] : [$current['@type']]; foreach ($types as $type) if (is_string($type) && $type !== '') $schema_types[$type] = true; } if (isset($current['@graph']) && is_array($current['@graph'])) foreach ($current['@graph'] as $graph_item) $stack[] = $graph_item; } } } $texts = $xpath->query('//body//text()[normalize-space() != "" and not(ancestor::script) and not(ancestor::style) and not(ancestor::noscript) and not(ancestor::svg) and not(ancestor::header) and not(ancestor::footer) and not(ancestor::nav)]'); $chunks = []; if ($texts) foreach ($texts as $text_node) $chunks[] = (string) $text_node->nodeValue; $body_text = implode(' ', $chunks); } } else { if (preg_match('~]*>(.*?)~is', $html, $m)) $seo_title = trim(wp_strip_all_tags($m[1])); if (preg_match('~]+name=["\']description["\'][^>]+content=["\']([^"\']*)["\']~i', $html, $m)) $meta_desc = trim($m[1]); if (preg_match('~]+rel=["\'][^"\']*canonical[^"\']*["\'][^>]+href=["\']([^"\']+)["\']~i', $html, $m)) $canonical = trim($m[1]); if (preg_match_all('~]*>(.*?)~is', $html, $m)) foreach ($m[1] as $h) $h1s[] = trim(wp_strip_all_tags($h)); $h2_count = preg_match_all('~]*>~i', $html, $imgs); if ($imgs && !empty($imgs[0])) foreach ($imgs[0] as $tag) if (!preg_match('~\balt\s*=\s*["\'][^"\']+["\']~i', $tag)) $missing_alt[] = 'Image missing ALT'; $schema_count = preg_match_all('~application/ld\+json~i', $html, $m); $body_text = wp_strip_all_tags($html); } $word_count = $this->seo_word_count($body_text); $title_len = $this->seo_string_length($seo_title); $meta_len = $this->seo_string_length($meta_desc); $h1_count = count($h1s); $missing_alt_count = count($missing_alt); $is_utility = $this->seo_is_utility_page($page_id); $generic_title = $this->seo_is_generic_text(preg_replace('/\s*[\|\-–—:]\s*' . preg_quote((string) get_bloginfo('name'), '/') . '\s*$/iu', '', $seo_title)); $generic_h1 = $h1_count > 0 ? $this->seo_is_generic_text($h1s[0]) : false; $placeholder_content = (bool) preg_match('/\b(lorem ipsum|dummy text|sample content|sample page|placeholder text|coming soon)\b/i', $body_text); $content_shingles = $this->seo_content_shingles($body_text); $content_hash = $word_count >= 80 ? md5(strtolower(trim(preg_replace('/\s+/', ' ', wp_strip_all_tags($body_text))))) : ''; $url_path = (string) wp_parse_url($url, PHP_URL_PATH); $url_length = strlen($url_path); $url_has_underscore = strpos($url_path, '_') !== false; $title_tokens = preg_split('/[^\p{L}\p{N}]+/u', strtolower(wp_strip_all_tags($seo_title)), -1, PREG_SPLIT_NO_EMPTY); $h1_tokens = preg_split('/[^\p{L}\p{N}]+/u', strtolower(wp_strip_all_tags($h1s[0] ?? '')), -1, PREG_SPLIT_NO_EMPTY); $stop_words = ['the','and','for','with','from','this','that','your','our','you','are','of','to','in','on','at','by','or','a','an']; $title_tokens = array_values(array_diff(array_unique(array_filter((array) $title_tokens, fn($v) => strlen($v) >= 3)), $stop_words)); $h1_tokens = array_values(array_diff(array_unique(array_filter((array) $h1_tokens, fn($v) => strlen($v) >= 3)), $stop_words)); $title_h1_overlap = count(array_intersect($title_tokens, $h1_tokens)); $wrong_numbers = []; if ($official_phone !== '' && $this->canonical_phone_digits($official_phone) !== '') { $regions = $this->phone_page_regions($html); $phone_items = []; foreach (['header','footer','content'] as $region) { foreach ($this->phone_region_occurrences($regions[$region] ?? '', $official_phone, $region, $page_id, $title_wp, $url) as $item) { if (empty($item['match'])) $phone_items[$item['key']] = $item; } } foreach ($phone_items as $item) { $display = trim((string) ($item['number'] ?? '')); if ($display !== '' && !in_array($display, $wrong_numbers, true)) $wrong_numbers[] = $display; } } $add_check($status >= 200 && $status < 300, 'critical', 'http_status', 'Page is not returning a clean 2xx response', 'HTTP status: ' . $status); $add_check($seo_title !== '', 'critical', 'missing_title', 'Missing meta title', 'No rendered tag was found.'); if ($seo_title !== '') { $add_check(!$generic_title, 'warning', 'generic_title', 'Meta title is too generic', 'Rendered title: ' . $seo_title); $add_check($title_len >= 30 && $title_len <= 65, 'warning', 'title_length', 'Meta title length needs review', $title_len . ' characters. Practical range: about 30–65.'); } $add_check($meta_desc !== '', 'critical', 'missing_meta', 'Missing meta description', 'No rendered meta description was found.'); if ($meta_desc !== '') $add_check($meta_len >= 110 && $meta_len <= 165, 'warning', 'meta_length', 'Meta description length needs review', $meta_len . ' characters.'); $add_check($h1_count === 1, 'critical', $h1_count === 0 ? 'missing_h1' : 'multiple_h1', $h1_count === 0 ? 'Missing H1 heading' : 'Multiple H1 headings', $h1_count . ' H1 heading(s) found.'); if ($h1_count > 0) $add_check(!$generic_h1, 'warning', 'generic_h1', 'Primary H1 is too generic', 'H1: ' . ($h1s[0] ?? '')); $add_check($canonical !== '', 'warning', 'missing_canonical', 'Missing canonical tag', 'No rendered canonical URL was found.'); if ($canonical !== '') $add_check($this->seo_normalize_url($canonical) === $this->seo_normalize_url($url), 'critical', 'canonical_mismatch', 'Canonical points to a different URL', $canonical); $add_check(strpos($robots, 'noindex') === false, 'critical', 'noindex', 'Page is marked noindex', $robots !== '' ? 'Robots: ' . $robots : 'A noindex directive was detected.'); $add_check(!$placeholder_content, 'critical', 'placeholder_content', 'Placeholder/demo content detected', 'The rendered content contains placeholder/demo wording.'); $alt_ratio = $image_total > 0 ? ($missing_alt_count / max(1, $image_total)) : 0; $add_check($missing_alt_count === 0, $alt_ratio > .5 ? 'critical' : 'warning', 'missing_alt', 'Images missing ALT attributes', $missing_alt_count . ' of ' . $image_total . ' image(s) are missing an ALT attribute.'); $content_min = $is_utility ? 60 : 250; $add_check($word_count >= $content_min, $word_count < ($is_utility ? 30 : 120) ? 'critical' : 'warning', 'thin_content', 'Content depth is weak', $word_count . ' meaningful rendered words found' . ($is_utility ? ' (utility-page threshold applied).' : '.')); if ($word_count >= 250) $add_check($h2_count > 0, 'warning', 'missing_h2', 'No H2 subheading found', 'This page has ' . $word_count . ' meaningful words but no H2 heading structure.'); $front_id = absint(get_option('page_on_front')); if ($page_id !== $front_id && !$is_utility) { $add_check($internal_links >= 2, 'warning', 'weak_internal_links', 'Weak contextual internal linking', $internal_links . ' meaningful internal content link(s) found. Header/footer/navigation links are excluded.'); } elseif ($page_id !== $front_id) { $add_check($internal_links >= 1, 'warning', 'no_internal_links', 'No meaningful internal links found', $internal_links . ' content link(s) found. Header/footer/navigation links are excluded.'); } $add_check(strpos($robots, 'nofollow') === false, 'warning', 'nofollow', 'Page is marked nofollow', $robots !== '' ? 'Robots: ' . $robots : 'A nofollow directive was detected.'); $add_check($schema_count > 0, 'info', 'missing_schema', 'No JSON-LD schema detected', 'No application/ld+json block was found. This is an enhancement opportunity, not automatically a ranking problem.'); $add_check($og_title !== '' && $og_image !== '', 'info', 'missing_og', 'Open Graph tags incomplete', 'OG title/image are incomplete. This mainly affects social sharing previews.'); $add_check($viewport !== '', 'warning', 'missing_viewport', 'Viewport meta tag missing', 'Mobile viewport tag was not found.'); $add_check($lang !== '', 'warning', 'missing_lang', 'HTML language attribute missing', 'The rendered <html> element does not declare a language.'); $add_check($empty_links === 0, 'warning', 'empty_links', 'Empty or placeholder links found', $empty_links . ' content link(s) use an empty/#/javascript placeholder.'); $add_check($empty_headings === 0, 'warning', 'empty_headings', 'Empty heading elements found', $empty_headings . ' empty H1–H6 heading element(s) found.'); $add_check($heading_skips === 0, 'warning', 'heading_hierarchy', 'Heading hierarchy skips levels', $heading_skips . ' heading transition(s) skip a level, such as H2 directly to H4.'); if ($seo_title !== '' && $h1_count > 0 && count($title_tokens) >= 1 && count($h1_tokens) >= 1) { $add_check($title_h1_overlap > 0, 'warning', 'title_h1_mismatch', 'SEO title and H1 appear poorly aligned', 'The rendered title and primary H1 share no meaningful topic words. Review whether they describe the same page intent.'); } $add_check($url_length <= 95 && !$url_has_underscore, 'info', 'url_quality', 'URL structure could be cleaner', 'Path length: ' . $url_length . ' characters' . ($url_has_underscore ? '; underscore detected.' : '.')); $add_check($generic_anchor_links === 0, 'info', 'generic_anchor_text', 'Generic anchor text found', $generic_anchor_links . ' contextual link(s) use text such as “click here” or “read more”.'); $add_check($nofollow_internal === 0, 'warning', 'nofollow_internal', 'Internal contextual links use nofollow', $nofollow_internal . ' internal content link(s) have a nofollow attribute.'); if ($image_total > 0) { $add_check($missing_dimensions <= max(1, (int) floor($image_total * .35)), 'info', 'image_dimensions', 'Many images are missing width/height attributes', $missing_dimensions . ' of ' . $image_total . ' image(s) lack explicit width and/or height attributes.'); $add_check($generic_image_names <= max(2, (int) floor($image_total * .50)), 'info', 'generic_image_filenames', 'Many image filenames are generic', $generic_image_names . ' of ' . $image_total . ' image(s) use filenames such as IMG_1234 or image-123.'); } $add_check($invalid_schema === 0, 'warning', 'invalid_schema', 'Invalid JSON-LD schema detected', $invalid_schema . ' JSON-LD block(s) could not be parsed as valid JSON.'); $add_check($twitter_card !== '' || ($og_title !== '' && $og_image !== ''), 'info', 'social_meta', 'Social sharing metadata is limited', 'No Twitter card was detected and Open Graph coverage may be incomplete.'); $mixed = 0; if (stripos(home_url('/'), 'https://') === 0) { $host = preg_quote((string) wp_parse_url(home_url('/'), PHP_URL_HOST), '~'); $mixed = preg_match_all("~http://" . $host . "[^\\s\"']*~i", $html, $mm); $add_check($mixed === 0, 'warning', 'mixed_content', 'Internal HTTP URLs found on HTTPS site', absint($mixed) . ' insecure internal reference(s) found.'); } if ($official_phone !== '' && $this->canonical_phone_digits($official_phone) !== '') { $add_check(count($wrong_numbers) === 0, 'critical', 'wrong_phone', 'Wrong phone number found', count($wrong_numbers) . ' different mismatched phone value(s): ' . implode(', ', array_slice($wrong_numbers, 0, 4))); } // Earned-points scoring: pages do not start at 100. A new/unfinished page // must actually demonstrate solid SEO setup to receive a high score. $breakdown = [ 'Technical' => ['earned' => 0, 'max' => 10], 'Metadata' => ['earned' => 0, 'max' => 25], 'Headings' => ['earned' => 0, 'max' => 15], 'Indexing' => ['earned' => 0, 'max' => 15], 'Content' => ['earned' => 0, 'max' => 15], 'Internal Linking' => ['earned' => 0, 'max' => 8], 'Images' => ['earned' => 0, 'max' => 7], 'Structured Data' => ['earned' => 0, 'max' => 3], 'Social' => ['earned' => 0, 'max' => 2], ]; // Technical 10 if ($status >= 200 && $status < 300) $breakdown['Technical']['earned'] += 5; if ($viewport !== '') $breakdown['Technical']['earned'] += 1; if ($lang !== '') $breakdown['Technical']['earned'] += 1; if ($empty_links === 0) $breakdown['Technical']['earned'] += 1.5; if ($mixed === 0) $breakdown['Technical']['earned'] += 1.5; // Metadata 25 if ($seo_title !== '') { $breakdown['Metadata']['earned'] += 5; if ($title_len >= 30 && $title_len <= 65) $breakdown['Metadata']['earned'] += 5; elseif ($title_len >= 20 && $title_len <= 75) $breakdown['Metadata']['earned'] += 2.5; if (!$generic_title) $breakdown['Metadata']['earned'] += 5; } if ($meta_desc !== '') { $breakdown['Metadata']['earned'] += 5; if ($meta_len >= 110 && $meta_len <= 165) $breakdown['Metadata']['earned'] += 5; elseif ($meta_len >= 70 && $meta_len <= 190) $breakdown['Metadata']['earned'] += 2.5; } // Headings 15 if ($h1_count === 1) $breakdown['Headings']['earned'] += 10; elseif ($h1_count > 1) $breakdown['Headings']['earned'] += 4; if ($h1_count > 0 && !$generic_h1) $breakdown['Headings']['earned'] += 3; if ($word_count < 250 || $h2_count > 0) $breakdown['Headings']['earned'] += 2; // Indexing 15 if (strpos($robots, 'noindex') === false) $breakdown['Indexing']['earned'] += 7; if ($canonical !== '') $breakdown['Indexing']['earned'] += 3; if ($canonical !== '' && $this->seo_normalize_url($canonical) === $this->seo_normalize_url($url)) $breakdown['Indexing']['earned'] += 5; // Content 15 if ($placeholder_content) { $breakdown['Content']['earned'] = min(2, $word_count > 0 ? 2 : 0); } elseif ($is_utility) { if ($word_count >= 120) $breakdown['Content']['earned'] = 15; elseif ($word_count >= 60) $breakdown['Content']['earned'] = 10; elseif ($word_count >= 30) $breakdown['Content']['earned'] = 5; } else { if ($word_count >= 600) $breakdown['Content']['earned'] = 15; elseif ($word_count >= 400) $breakdown['Content']['earned'] = 13; elseif ($word_count >= 250) $breakdown['Content']['earned'] = 9; elseif ($word_count >= 120) $breakdown['Content']['earned'] = 4; } // Internal linking 8 (context/content links only) if ($page_id === $front_id) { if ($internal_links >= 3) $breakdown['Internal Linking']['earned'] = 8; elseif ($internal_links >= 1) $breakdown['Internal Linking']['earned'] = 5; } elseif ($is_utility) { $breakdown['Internal Linking']['earned'] = $internal_links >= 1 ? 8 : 0; } else { if ($internal_links >= 3) $breakdown['Internal Linking']['earned'] = 8; elseif ($internal_links === 2) $breakdown['Internal Linking']['earned'] = 6; elseif ($internal_links === 1) $breakdown['Internal Linking']['earned'] = 3; } // Images 7 if ($image_total === 0 || $missing_alt_count === 0) $breakdown['Images']['earned'] = 7; elseif ($alt_ratio <= .20) $breakdown['Images']['earned'] = 5; elseif ($alt_ratio <= .50) $breakdown['Images']['earned'] = 3; // Enhancements if ($schema_count > 0) $breakdown['Structured Data']['earned'] = 3; if ($og_title !== '' && $og_image !== '') $breakdown['Social']['earned'] = 2; $earned_points = 0; $max_points = 0; foreach ($breakdown as $bucket) { $earned_points += (float) $bucket['earned']; $max_points += (float) $bucket['max']; } $score = $max_points > 0 ? (int) round(($earned_points / $max_points) * 100) : 0; // Critical-readiness caps prevent a serious indexing/content problem // from being hidden by otherwise clean theme markup. if (!($status >= 200 && $status < 300)) $score = min($score, 35); if (strpos($robots, 'noindex') !== false) $score = min($score, 50); if ($canonical !== '' && $this->seo_normalize_url($canonical) !== $this->seo_normalize_url($url)) $score = min($score, 60); if ($placeholder_content) $score = min($score, 55); if ($seo_title === '') $score = min($score, 55); if ($h1_count === 0) $score = min($score, 65); if (!empty($wrong_numbers)) $score = max(0, $score - 6); if ($invalid_schema > 0) $score = max(0, $score - 3); if ($heading_skips > 1) $score = max(0, $score - 2); if ($empty_headings > 1) $score = max(0, $score - 2); if ($title_h1_overlap === 0 && $seo_title !== '' && $h1_count > 0) $score = max(0, $score - 3); usort($issues, function($a, $b) { $rank = ['critical'=>0, 'warning'=>1, 'info'=>2]; return ($rank[$a['severity']] ?? 9) <=> ($rank[$b['severity']] ?? 9); }); $critical = count(array_filter($issues, fn($i) => ($i['severity'] ?? '') === 'critical')); $warnings = count(array_filter($issues, fn($i) => ($i['severity'] ?? '') === 'warning')); return [ 'id' => (int) $page_id, 'page_title' => $title_wp, 'url' => $url, 'edit_url' => $edit_url, 'status' => $status, 'score' => $score, 'base_score' => $score, 'score_breakdown' => $breakdown, 'is_utility' => $is_utility, 'placeholder_content' => $placeholder_content, 'critical' => $critical, 'warnings' => $warnings, 'passed' => $passed, 'checks' => $checks, 'title' => $seo_title, 'title_length' => $title_len, 'meta' => $meta_desc, 'meta_length' => $meta_len, 'h1' => $h1s[0] ?? '', 'h1_count' => $h1_count, 'h2_count' => $h2_count, 'canonical' => $canonical, 'issues' => $issues, 'stats' => [ 'images' => $image_total, 'missing_alt' => $missing_alt_count, 'words' => $word_count, 'internal_links' => $internal_links, 'external_links' => $external_links, 'schema' => $schema_count, 'invalid_schema' => $invalid_schema, 'empty_headings' => $empty_headings, 'heading_skips' => $heading_skips, 'generic_anchors' => $generic_anchor_links, 'nofollow_internal' => $nofollow_internal, 'missing_dimensions' => $missing_dimensions, 'generic_image_names' => $generic_image_names, 'empty_alt' => $empty_alt, 'wrong_phones' => count($wrong_numbers), ], 'schema_types' => array_values(array_keys($schema_types)), 'content_hash' => $content_hash, 'content_shingles' => $content_shingles, 'wrong_numbers' => $wrong_numbers, ]; } public function ajax_seo_audit_batch() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); if ($this->maintenance_mode() !== 'off') { wp_send_json_error(['message' => 'Disable the Maintenance / Hosting Notice page before running the SEO audit so the scanner can read the real public pages.']); } $page = max(1, absint($_POST['page'] ?? 1)); $limit = min(5, max(1, absint($_POST['limit'] ?? 4))); $official_phone = sanitize_text_field(wp_unslash($_POST['official_phone'] ?? '')); $query = new WP_Query([ 'post_type' => $this->seo_auditable_post_types(), 'post_status' => 'publish', 'posts_per_page' => $limit, 'paged' => $page, 'orderby' => 'ID', 'order' => 'ASC', 'fields' => 'ids', 'no_found_rows' => false, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ]); $items = []; foreach ($query->posts as $page_id) $items[] = $this->seo_audit_page(absint($page_id), $official_phone); wp_reset_postdata(); wp_send_json_success([ 'items' => $items, 'page' => $page, 'total' => absint($query->found_posts), 'total_pages' => absint($query->max_num_pages), 'has_more' => $page < absint($query->max_num_pages), 'site_checks' => $page === 1 ? $this->seo_site_checks() : [], ]); } private function login_settings() { $saved = get_option(self::LOGIN_SETTINGS_OPTION, []); return wp_parse_args(is_array($saved) ? $saved : [], ['slug'=>'', 'hide_default'=>0]); } private function custom_login_url($query = []) { $settings = $this->login_settings(); if (empty($settings['slug'])) return ''; $url = home_url('/' . trim($settings['slug'], '/') . '/'); return $query ? add_query_arg($query, $url) : $url; } private function site_widget_channels() { return [ 'phone' => ['label' => 'Phone', 'placeholder' => '(815) 474-3974'], 'sms' => ['label' => 'SMS', 'placeholder' => '(815) 474-3974'], 'whatsapp' => ['label' => 'WhatsApp', 'placeholder' => '+1 815 474 3974'], 'facebook' => ['label' => 'Facebook Messenger', 'placeholder' => 'username or https://m.me/username'], 'email' => ['label' => 'Email', 'placeholder' => 'hello@example.com'], 'map' => ['label' => 'Google Maps', 'placeholder' => 'https://maps.google.com/...'], 'instagram' => ['label' => 'Instagram', 'placeholder' => '@username or profile URL'], 'form' => ['label' => 'Contact Form', 'placeholder' => 'Choose a form'], ]; } private function trust_badge_defaults() { return [ ['icon'=>'license','title'=>'Licensed Professionals','text'=>'Qualified & dependable'], ['icon'=>'shield','title'=>'Fully Insured','text'=>'Protection you can trust'], ['icon'=>'warranty','title'=>'Workmanship Warranty','text'=>'Quality backed by our work'], ['icon'=>'estimate','title'=>'Free Estimates','text'=>'Clear, no-obligation quotes'], ['icon'=>'badge','title'=>'Trusted Business','text'=>'Professional local service'], ['icon'=>'local','title'=>'Locally Owned','text'=>'Proudly serving our community'], ]; } private function trust_badge_icon_choices() { return [ 'license'=>'License / Certificate', 'shield'=>'Shield / Insured', 'warranty'=>'Warranty / Check', 'estimate'=>'Free Estimate', 'badge'=>'Trust Badge', 'local'=>'Locally Owned', 'star'=>'Star / Rated', 'clock'=>'Fast Service', ]; } private function svg_allowed_html() { return [ 'svg' => [ 'viewbox' => true, 'aria-hidden' => true, 'focusable' => true, 'role' => true, 'xmlns' => true, ], 'path' => [ 'd' => true, 'fill' => true, 'fill-rule' => true, 'clip-rule' => true, ], ]; } /** * Initialize WordPress' filesystem abstraction for media file operations. * * @return WP_Filesystem_Base|WP_Error */ private function wp_filesystem_instance() { global $wp_filesystem; if ($wp_filesystem instanceof WP_Filesystem_Base) { return $wp_filesystem; } require_once ABSPATH . 'wp-admin/includes/file.php'; if (!WP_Filesystem()) { return new WP_Error('filesystem_unavailable', 'WordPress could not initialize filesystem access.'); } if (!($wp_filesystem instanceof WP_Filesystem_Base)) { return new WP_Error('filesystem_unavailable', 'WordPress filesystem access is unavailable.'); } return $wp_filesystem; } private function filesystem_move($from, $to, $overwrite = false) { $fs = $this->wp_filesystem_instance(); if (is_wp_error($fs)) return false; return (bool) $fs->move($from, $to, (bool) $overwrite); } private function filesystem_copy($from, $to, $overwrite = false) { $fs = $this->wp_filesystem_instance(); if (is_wp_error($fs)) return false; return (bool) $fs->copy($from, $to, (bool) $overwrite, FS_CHMOD_FILE); } private function filesystem_delete_file($file) { if (!$file || !file_exists($file)) return true; wp_delete_file($file); return !file_exists($file); } private function trust_badge_icon_svg($key) { $icons = [ 'license'=>'<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 2h10a2 2 0 0 1 2 2v16l-7-3-7 3V4a2 2 0 0 1 2-2Zm1 5v2h8V7H8Zm0 4v2h6v-2H8Z"/></svg>', 'shield'=>'<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 20 5v6c0 5.2-3.4 9.1-8 11-4.6-1.9-8-5.8-8-11V5l8-3Zm3.7 6.3-4.8 4.8-2.6-2.6-1.4 1.4 4 4 6.2-6.2-1.4-1.4Z"/></svg>', 'warranty'=>'<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 2 2.3 2.4 3.3-.5.5 3.3L20.5 9 19 12l1.5 3-2.4 1.8-.5 3.3-3.3-.5L12 22l-2.3-2.4-3.3.5-.5-3.3L3.5 15 5 12 3.5 9l2.4-1.8.5-3.3 3.3.5L12 2Zm3.6 7.6-4.7 4.7-2.5-2.5L7 13.2l3.9 3.9 6.1-6.1-1.4-1.4Z"/></svg>', 'estimate'=>'<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 2h9l4 4v16H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm8 1.8V7h3.2L14 3.8ZM8 11v2h8v-2H8Zm0 4v2h6v-2H8Z"/></svg>', 'badge'=>'<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2a7 7 0 0 1 5.7 11.1L20 22l-4-2-4 2-4-2-4 2 2.3-8.9A7 7 0 1 1 12 2Zm0 4a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z"/></svg>', 'local'=>'<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2a7 7 0 0 1 7 7c0 4.9-7 13-7 13S5 13.9 5 9a7 7 0 0 1 7-7Zm0 4a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z"/></svg>', 'star'=>'<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 2.5 3 6.1 6.7 1-4.9 4.7 1.2 6.7-6-3.2-6 3.2 1.2-6.7-4.9-4.7 6.7-1 3-6.1Z"/></svg>', 'clock'=>'<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20Zm1 5h-2v6l5 3 1-1.7-4-2.3V7Z"/></svg>', ]; return $icons[$key] ?? $icons['shield']; } public function shortcode_trust_badges($atts = []) { $s = $this->site_widgets_settings(); if (empty($s['trust_enabled'])) return ''; wp_enqueue_style('ekmt-site-widgets', plugin_dir_url(__FILE__) . 'assets/widgets.css', [], self::VERSION); $items = []; for ($i=1; $i<=6; $i++) { if (empty($s['trust_'.$i.'_enabled'])) continue; $title = trim((string)($s['trust_'.$i.'_title'] ?? '')); if ($title === '') continue; $items[] = [ 'icon' => sanitize_key((string)($s['trust_'.$i.'_icon'] ?? 'shield')), 'title' => $title, 'text' => trim((string)($s['trust_'.$i.'_text'] ?? '')), ]; } if (!$items) return ''; $style = in_array($s['trust_style'], ['cards','minimal','strip'], true) ? $s['trust_style'] : 'cards'; $columns = in_array((string)$s['trust_columns'], ['3','4','6'], true) ? (string)$s['trust_columns'] : '3'; $css = '--ekmt-trust-accent:' . esc_attr($s['trust_accent']) . ';--ekmt-trust-bg:' . esc_attr($s['trust_bg']) . ';--ekmt-trust-color:' . esc_attr($s['trust_color']) . ';--ekmt-trust-cols:' . esc_attr($columns) . ';'; ob_start(); echo '<div class="ekmt-trust-badges ekmt-trust-' . esc_attr($style) . '" style="' . esc_attr($css) . '">'; foreach ($items as $item) { echo '<div class="ekmt-trust-badge"><span class="ekmt-trust-icon">' . wp_kses($this->trust_badge_icon_svg($item['icon']), $this->svg_allowed_html()) . '</span><span class="ekmt-trust-copy"><strong>' . esc_html($item['title']) . '</strong>'; if ($item['text'] !== '') echo '<small>' . esc_html($item['text']) . '</small>'; echo '</span></div>'; } echo '</div>'; return ob_get_clean(); } private function site_widgets_settings() { $defaults = [ 'floating_enabled' => 0, 'floating_label' => 'Contact Us', 'floating_position' => 'right', 'phone_enabled' => 0, 'phone' => '', 'sms_enabled' => 0, 'sms' => '', 'whatsapp_enabled' => 0, 'whatsapp' => '', 'facebook_enabled' => 0, 'facebook' => '', 'email_enabled' => 0, 'email' => '', 'map_enabled' => 0, 'map' => '', 'instagram_enabled' => 0, 'instagram' => '', 'form_enabled' => 0, 'form' => '', 'announcement_enabled' => 0, 'announcement_text' => 'Free estimates available — call today.', 'announcement_button_text' => 'Contact Us', 'announcement_button_url' => '', 'announcement_position' => 'top', 'announcement_dismissible' => '1', 'announcement_bg' => '#111827', 'announcement_color' => '#ffffff', 'announcement_button_bg' => '#1667ff', 'trust_enabled' => 1, 'trust_style' => 'cards', 'trust_columns' => '3', 'trust_accent' => '#1667ff', 'trust_bg' => '#ffffff', 'trust_color' => '#161616', 'trust_1_enabled'=>1, 'trust_1_icon'=>'license', 'trust_1_title'=>'Licensed Professionals', 'trust_1_text'=>'Qualified & dependable', 'trust_2_enabled'=>1, 'trust_2_icon'=>'shield', 'trust_2_title'=>'Fully Insured', 'trust_2_text'=>'Protection you can trust', 'trust_3_enabled'=>1, 'trust_3_icon'=>'warranty', 'trust_3_title'=>'Workmanship Warranty', 'trust_3_text'=>'Quality backed by our work', 'trust_4_enabled'=>1, 'trust_4_icon'=>'estimate', 'trust_4_title'=>'Free Estimates', 'trust_4_text'=>'Clear, no-obligation quotes', 'trust_5_enabled'=>0, 'trust_5_icon'=>'badge', 'trust_5_title'=>'Trusted Business', 'trust_5_text'=>'Professional local service', 'trust_6_enabled'=>0, 'trust_6_icon'=>'local', 'trust_6_title'=>'Locally Owned', 'trust_6_text'=>'Proudly serving our community', 'popup_enabled' => 0, 'popup_type' => 'form', 'popup_title' => 'How can we help?', 'popup_description' => 'Send us your details and our team will get back to you.', 'popup_form' => 'contact', 'popup_button_text' => 'Contact Us', 'popup_button_url' => '', 'popup_trigger' => 'delay', 'popup_delay' => 5, 'popup_scroll' => 50, 'popup_frequency' => 'session', 'popup_theme' => 'light', 'popup_position' => 'center', ]; $saved = get_option(self::SITE_WIDGETS_OPTION, []); return wp_parse_args(is_array($saved) ? $saved : [], $defaults); } private function normalize_contact_number($value) { $value = trim((string) $value); $digits = preg_replace('/\D+/', '', $value); return $digits ?: ''; } private function contact_channel_url($key, $value) { $value = trim((string) $value); if ($value === '') return ''; if (in_array($key, ['phone','sms','whatsapp'], true)) { $digits = $this->normalize_contact_number($value); if (!$digits) return ''; if ($key === 'phone') return 'tel:+' . $digits; if ($key === 'sms') return 'sms:+' . $digits; return 'https://wa.me/' . $digits; } if ($key === 'email') return is_email($value) ? 'mailto:' . sanitize_email($value) : ''; if ($key === 'facebook') { if (preg_match('#^https?://#i', $value)) return esc_url_raw($value); $user = ltrim($value, '@/ '); return $user ? 'https://m.me/' . rawurlencode($user) : ''; } if ($key === 'instagram') { if (preg_match('#^https?://#i', $value)) return esc_url_raw($value); $user = ltrim($value, '@/ '); return $user ? 'https://instagram.com/' . rawurlencode($user) : ''; } if ($key === 'map') return esc_url_raw($value); return ''; } private function contact_icon_svg($key) { $icons = [ 'phone' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6.6 10.8c1.7 3.4 3.2 4.9 6.6 6.6l2.2-2.2c.3-.3.7-.4 1.1-.2 1.2.4 2.5.6 3.8.6.6 0 1 .4 1 1V20c0 .6-.4 1-1 1C10.8 21 3 13.2 3 3.7c0-.6.4-1 1-1h3.4c.6 0 1 .4 1 1 0 1.3.2 2.6.6 3.8.1.4 0 .8-.2 1.1l-2.2 2.2Z"/></svg>', 'sms' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 4h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H8l-5 4v-4H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Zm3 5h10v2H7V9Zm0 4h7v2H7v-2Z"/></svg>', 'whatsapp' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2a9.7 9.7 0 0 0-8.3 14.8L2.4 22l5.3-1.4A9.9 9.9 0 1 0 12 2Zm0 2a7.8 7.8 0 0 1 6.7 11.8 7.8 7.8 0 0 1-9.9 2.8l-.7-.4-3.1.8.8-3-.4-.7A7.8 7.8 0 0 1 12 4Zm-3.2 3.6c-.2 0-.4 0-.6.2-.2.2-.8.8-.8 1.9s.8 2.2.9 2.4c.1.2 1.6 2.5 4 3.4 2 .8 2.4.7 2.8.6.4-.1 1.4-.6 1.6-1.1.2-.5.2-1 .2-1.1-.1-.1-.2-.2-.5-.4l-1.7-.8c-.2-.1-.4-.1-.6.2l-.8 1c-.1.2-.3.2-.5.1-.3-.1-1.1-.4-2-1.3-.7-.7-1.2-1.4-1.4-1.7-.1-.2 0-.4.1-.5l.6-.7.3-.5c.1-.2 0-.4 0-.5l-.8-1.8c-.2-.5-.4-.5-.8-.5Z"/></svg>', 'facebook' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13.5 22v-8h2.7l.4-3h-3.1V9.1c0-.9.2-1.5 1.6-1.5h1.7V5c-.3 0-1.3-.1-2.5-.1-2.5 0-4.2 1.5-4.2 4.3V11H7.3v3h2.8v8h3.4Z"/></svg>', 'email' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 5h18a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2Zm0 2 9 6 9-6H3Zm18 10V9.4l-9 6-9-6V17h18Z"/></svg>', 'map' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2a7 7 0 0 1 7 7c0 5-7 13-7 13S5 14 5 9a7 7 0 0 1 7-7Zm0 4a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z"/></svg>', 'instagram' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 2h10a5 5 0 0 1 5 5v10a5 5 0 0 1-5 5H7a5 5 0 0 1-5-5V7a5 5 0 0 1 5-5Zm0 2a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3H7Zm11.2 1.5a1.3 1.3 0 1 1 0 2.6 1.3 1.3 0 0 1 0-2.6ZM12 7a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm0 2a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z"/></svg>', 'form' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 3h16a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Zm2 4v2h12V7H6Zm0 4v2h8v-2H6Zm0 4v2h10v-2H6Z"/></svg>', ]; return $icons[$key] ?? $icons['form']; } private function maintenance_mode() { $mode = sanitize_key((string) get_option(self::MAINTENANCE_MODE_OPTION, 'off')); return in_array($mode, ['off', 'maintenance', 'expired'], true) ? $mode : 'off'; } private function maintenance_mode_label($mode) { if ($mode === 'maintenance') return 'Maintenance page is active'; if ($mode === 'expired') return 'Hosting notice page is active'; return 'Live site is normal'; } private function maintenance_mode_help($mode) { if ($mode === 'maintenance') return 'Visitors see the maintenance page. Logged-in admins can still browse the real website.'; if ($mode === 'expired') return 'Visitors see the hosting-expired notice page until you turn it off.'; return 'Nothing is overriding your public website right now.'; } private function maintenance_defaults($mode = 'maintenance') { if ($mode === 'expired') { return [ 'title' => 'Hosting is expired please renew your hosting.', 'description' => 'This website is temporarily unavailable. Please renew the hosting service and then bring the website back online.', 'icon' => 'warning', ]; } return [ 'title' => 'We’ll be back shortly.', 'description' => 'We are making a few updates right now. Please check back again soon.', 'icon' => 'tools', ]; } private function maintenance_settings($mode = '') { $saved = get_option(self::MAINTENANCE_SETTINGS_OPTION, []); $saved = is_array($saved) ? $saved : []; $base_mode = $mode ?: $this->maintenance_mode(); if ($base_mode === 'off') $base_mode = 'maintenance'; $defaults = $this->maintenance_defaults($base_mode); return [ 'title' => sanitize_text_field((string) ($saved['title'] ?? $defaults['title'])), 'description' => sanitize_textarea_field((string) ($saved['description'] ?? $defaults['description'])), 'icon' => sanitize_key((string) ($saved['icon'] ?? $defaults['icon'])), ]; } private function maintenance_icon_choices() { return [ 'warning' => 'Warning', 'tools' => 'Tools', 'clock' => 'Clock', 'info' => 'Info', 'pause' => 'Pause', 'server' => 'Server', ]; } private function maintenance_icon_symbol($icon) { $map = [ 'warning' => '⚠', 'tools' => '🛠', 'clock' => '⏳', 'info' => 'ℹ', 'pause' => '⏸', 'server' => '🖥', ]; return $map[$icon] ?? '⚠'; } private function maintenance_samples() { return [ ['mode' => 'maintenance', 'title' => 'We’re working on the website right now.', 'description' => 'Please check back again shortly. We are making improvements behind the scenes.', 'icon' => 'tools'], ['mode' => 'maintenance', 'title' => 'Scheduled maintenance in progress.', 'description' => 'The website is temporarily unavailable while we complete scheduled updates.', 'icon' => 'clock'], ['mode' => 'maintenance', 'title' => 'Quick updates are in progress.', 'description' => 'We are refreshing this website right now. Thank you for your patience.', 'icon' => 'info'], ['mode' => 'maintenance', 'title' => 'Website maintenance mode is active.', 'description' => 'We are updating content and functionality. Please come back again soon.', 'icon' => 'pause'], ['mode' => 'maintenance', 'title' => 'We’ll be back shortly.', 'description' => 'A few final website updates are being completed. The site will return shortly.', 'icon' => 'tools'], ['mode' => 'expired', 'title' => 'Hosting is expired please renew your hosting.', 'description' => 'This website is temporarily unavailable because the hosting service needs to be renewed.', 'icon' => 'warning'], ['mode' => 'expired', 'title' => 'Hosting renewal is required.', 'description' => 'The website is temporarily offline. Please renew the hosting plan to restore access.', 'icon' => 'server'], ['mode' => 'expired', 'title' => 'This website is temporarily unavailable.', 'description' => 'The hosting account has expired. Please renew it and try again.', 'icon' => 'warning'], ['mode' => 'expired', 'title' => 'Website service is paused.', 'description' => 'Hosting service needs attention before this website can go live again.', 'icon' => 'pause'], ['mode' => 'expired', 'title' => 'Temporary hosting notice.', 'description' => 'The website is offline until hosting renewal is completed.', 'icon' => 'info'], ]; } private function lead_form_definitions() { $common = [ 'name' => ['label' => 'Full Name', 'type' => 'text', 'required' => true, 'autocomplete' => 'name'], 'email' => ['label' => 'Email Address', 'type' => 'email', 'required' => true, 'autocomplete' => 'email'], 'phone' => ['label' => 'Phone Number', 'type' => 'tel', 'required' => true, 'autocomplete' => 'tel'], ]; return [ 'contact' => [ 'title' => 'Simple Contact Form', 'button' => 'Contact Us', 'description' => 'A clean professional contact form for general inquiries and lead capture.', 'fields' => $common + [ 'message' => ['label' => 'How can we help?', 'type' => 'textarea', 'required' => true], ], ], 'quote' => [ 'title' => 'Request a Quote', 'button' => 'Request Quote', 'description' => 'A more detailed professional quote form with address and project details.', 'fields' => $common + [ 'address' => ['label' => 'Address', 'type' => 'text', 'required' => true, 'autocomplete' => 'street-address'], 'city_state_zip' => ['label' => 'City / State / ZIP', 'type' => 'text', 'required' => false], 'service' => ['label' => 'Service Needed', 'type' => 'text', 'required' => false], 'project_details' => ['label' => 'Project Details', 'type' => 'textarea', 'required' => true], ], ], 'consultation' => [ 'title' => 'Book a Consultation', 'button' => 'Request Consultation', 'description' => 'Request a preferred consultation date and time without reserving a live calendar slot.', 'fields' => $common + [ 'preferred_date' => ['label' => 'Preferred Date', 'type' => 'date', 'required' => false], 'preferred_time' => ['label' => 'Preferred Time', 'type' => 'time', 'required' => false], 'message' => ['label' => 'What would you like to discuss?', 'type' => 'textarea', 'required' => false], ], ], 'service' => [ 'title' => 'Service Request', 'button' => 'Send Service Request', 'description' => 'A practical service request form for local-service websites.', 'fields' => $common + [ 'service' => ['label' => 'Service Needed', 'type' => 'text', 'required' => true], 'address' => ['label' => 'Service Address', 'type' => 'text', 'required' => false, 'autocomplete' => 'street-address'], 'message' => ['label' => 'Request Details', 'type' => 'textarea', 'required' => true], ], ], 'callback' => [ 'title' => 'Call Back Request', 'button' => 'Request a Call Back', 'description' => 'Short form focused on phone leads.', 'fields' => [ 'name' => $common['name'], 'phone' => $common['phone'], 'best_time' => ['label' => 'Best Time to Call', 'type' => 'text', 'required' => false], 'message' => ['label' => 'What can we help with?', 'type' => 'textarea', 'required' => false], ], ], 'appointment' => [ 'title' => 'Appointment Request', 'button' => 'Request Appointment', 'description' => 'Collect appointment preferences without directly booking a slot.', 'fields' => $common + [ 'service' => ['label' => 'Service', 'type' => 'text', 'required' => false], 'preferred_date' => ['label' => 'Preferred Date', 'type' => 'date', 'required' => true], 'preferred_time' => ['label' => 'Preferred Time', 'type' => 'time', 'required' => false], ], ], 'estimate' => [ 'title' => 'Get an Estimate', 'button' => 'Request Estimate', 'description' => 'A concise estimate-request form.', 'fields' => $common + [ 'service' => ['label' => 'Service / Project Type', 'type' => 'text', 'required' => true], 'project_details' => ['label' => 'Project Details', 'type' => 'textarea', 'required' => true], ], ], 'inquiry' => [ 'title' => 'General Inquiry', 'button' => 'Send Inquiry', 'description' => 'Useful for general questions and non-sales messages.', 'fields' => $common + [ 'subject' => ['label' => 'Subject', 'type' => 'text', 'required' => false], 'message' => ['label' => 'Message', 'type' => 'textarea', 'required' => true], ], ], 'project' => [ 'title' => 'Project Information', 'button' => 'Send Project Details', 'description' => 'Capture project type, location, timeline, and details.', 'fields' => $common + [ 'project_type' => ['label' => 'Project Type', 'type' => 'text', 'required' => true], 'project_location' => ['label' => 'Project Location', 'type' => 'text', 'required' => false], 'timeline' => ['label' => 'Preferred Timeline', 'type' => 'text', 'required' => false], 'project_details' => ['label' => 'Project Details', 'type' => 'textarea', 'required' => true], ], ], 'quick' => [ 'title' => 'Quick Contact', 'button' => 'Contact Us', 'description' => 'Minimal lead form for sidebars, landing pages, and compact sections.', 'fields' => [ 'name' => $common['name'], 'phone' => $common['phone'], 'email' => ['label' => 'Email Address', 'type' => 'email', 'required' => false, 'autocomplete' => 'email'], 'message' => ['label' => 'Message', 'type' => 'textarea', 'required' => false], ], ], ]; } public function shortcode_lead_form($atts = []) { $atts = shortcode_atts(['id' => 'contact', 'title' => '', 'button' => ''], $atts, 'ahmad_toolkit_form'); $form_id = sanitize_key($atts['id']); $forms = $this->lead_form_definitions(); if (empty($forms[$form_id])) return ''; $form = $forms[$form_id]; $title = sanitize_text_field($atts['title']) ?: $form['title']; $button = sanitize_text_field($atts['button']) ?: $form['button']; $description = $form['description'] ?? ''; $uid = 'ekmtlf-' . $form_id . '-' . wp_rand(1000, 999999); ob_start(); ?> <div class="ekmt-lead-form-shell" data-ekmt-form-shell> <form class="ekmt-lead-form professional-form" data-ekmt-lead-form method="post" novalidate> <div class="ekmt-lead-form-head"> <span class="ekmt-lead-form-badge">Toolkit Form</span> <h3><?php echo esc_html($title); ?></h3> <?php if ($description) : ?><p><?php echo esc_html($description); ?></p><?php endif; ?> </div> <div class="ekmt-lead-form-grid"> <?php foreach ($form['fields'] as $name => $field) : $type = $field['type'] ?? 'text'; $required = !empty($field['required']); $input_id = $uid . '-' . sanitize_html_class($name); ?> <div class="ekmt-lead-field <?php echo esc_attr($type === 'textarea' ? 'is-full' : ''); ?>"> <label for="<?php echo esc_attr($input_id); ?>"><?php echo esc_html($field['label']); ?><?php if ($required) echo ' <span>*</span>'; ?></label> <?php if ($type === 'textarea') : ?> <textarea id="<?php echo esc_attr($input_id); ?>" name="<?php echo esc_attr($name); ?>" rows="5" placeholder="<?php echo esc_attr($field['label']); ?>"<?php if ($required) : ?> required<?php endif; ?>></textarea> <?php else : ?> <input id="<?php echo esc_attr($input_id); ?>" type="<?php echo esc_attr($type); ?>" name="<?php echo esc_attr($name); ?>" placeholder="<?php echo esc_attr($field['label']); ?>"<?php if ($required) : ?> required<?php endif; ?><?php if (!empty($field['autocomplete'])) : ?> autocomplete="<?php echo esc_attr($field['autocomplete']); ?>"<?php endif; ?>> <?php endif; ?> </div> <?php endforeach; ?> </div> <div class="ekmt-lead-hp" aria-hidden="true"><label>Leave this field empty<input type="text" name="company_website" tabindex="-1" autocomplete="off"></label></div> <input type="hidden" name="form_id" value="<?php echo esc_attr($form_id); ?>"> <input type="hidden" name="page_url" value="<?php echo esc_url(get_permalink()); ?>"> <input type="hidden" name="started_at" value="<?php echo esc_attr((string) time()); ?>"> <button type="submit" class="ekmt-lead-submit"><?php echo esc_html($button); ?></button> <div class="ekmt-lead-response" role="status" aria-live="polite"></div> </form> </div> <?php return ob_get_clean(); } private function lead_entry_title($form_title, $name, $email, $phone) { $who = $name ?: ($email ?: ($phone ?: 'Website visitor')); return wp_strip_all_tags($form_title . ' — ' . $who); } private function save_lead_entry($form_id, $form, $values, $page_url, $mail_sent) { $post_id = wp_insert_post([ 'post_type' => self::LEAD_POST_TYPE, 'post_status' => 'publish', 'post_title' => $this->lead_entry_title($form['title'], $values['name'] ?? '', $values['email'] ?? '', $values['phone'] ?? ''), ], true); if (is_wp_error($post_id)) return $post_id; update_post_meta($post_id, '_ekmt_form_id', $form_id); update_post_meta($post_id, '_ekmt_form_title', $form['title']); update_post_meta($post_id, '_ekmt_form_values', $values); update_post_meta($post_id, '_ekmt_page_url', esc_url_raw($page_url)); update_post_meta($post_id, '_ekmt_mail_sent', $mail_sent ? '1' : '0'); update_post_meta($post_id, '_ekmt_recipient', $this->forms_recipient()); return $post_id; } private function lead_email_value_html($value, $field_type = 'text') { $value = trim((string) $value); if ($value === '') return '<span style="color:#94a3b8;">—</span>'; if ($field_type === 'email' && is_email($value)) { return '<a href="mailto:' . esc_attr($value) . '" style="color:#2563eb;text-decoration:none;font-weight:600;">' . esc_html($value) . '</a>'; } if ($field_type === 'tel' || $field_type === 'phone') { $tel = preg_replace('/[^0-9+]/', '', $value); return '<a href="tel:' . esc_attr($tel) . '" style="color:#2563eb;text-decoration:none;font-weight:600;">' . esc_html($value) . '</a>'; } return nl2br(esc_html($value)); } private function build_lead_email_html($form, $values, $page_url) { $site_name = wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES); $site_url = home_url('/'); $date = wp_date('F j, Y g:i a'); $lead_name = trim((string) ($values['name'] ?? '')); $lead_email = sanitize_email((string) ($values['email'] ?? '')); $headline = 'New ' . $form['title'] . ' Submission'; $preview = $lead_name !== '' ? $lead_name . ' sent a new website enquiry.' : 'A new website enquiry has been received.'; $rows = ''; foreach ($form['fields'] as $name => $field) { $label = (string) ($field['label'] ?? ucwords(str_replace('_', ' ', $name))); $type = (string) ($field['type'] ?? 'text'); $value = $values[$name] ?? ''; // Internal helper / anti-spam fields should never appear in lead emails. if (strpos((string) $name, 'ahmad_') === 0 || strpos((string) $name, 'ekmt_') === 0) { continue; } // Only show actual customer-entered information. if (is_array($value)) { $has_value = (bool) array_filter($value, static function ($item) { return trim((string) $item) !== ''; }); if (!$has_value) { continue; } } elseif (trim((string) $value) === '') { continue; } $rows .= '<tr><td style="width:34%;padding:13px 16px;border-bottom:1px solid #e8edf3;color:#64748b;font-size:13px;line-height:1.45;vertical-align:top;font-weight:600;">' . esc_html($label) . '</td><td style="padding:13px 16px;border-bottom:1px solid #e8edf3;color:#172033;font-size:14px;line-height:1.55;vertical-align:top;">' . $this->lead_email_value_html($value, $type) . '</td></tr>'; } $page_link = $page_url ? '<a href="' . esc_url($page_url) . '" style="color:#2563eb;text-decoration:none;">View submission page</a>' : '<span style="color:#94a3b8;">Not available</span>'; $reply_button = ''; if ($lead_email && is_email($lead_email)) { $button_label = $lead_name !== '' ? 'Reply to ' . $lead_name : 'Reply to Lead'; $reply_button = '<a href="mailto:' . esc_attr($lead_email) . '" style="display:inline-block;background:#111827;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:8px;font-size:14px;font-weight:700;">' . esc_html($button_label) . '</a>'; } $html = '<!doctype html><html><body style="margin:0;padding:0;background:#f3f6fa;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#172033;">' . '<div style="display:none;max-height:0;overflow:hidden;opacity:0;">' . esc_html($preview) . '</div>' . '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;background:#f3f6fa;margin:0;padding:0;"><tr><td align="center" style="padding:32px 14px;">' . '<table role="presentation" width="640" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:640px;background:#ffffff;border:1px solid #e2e8f0;border-radius:14px;overflow:hidden;">' . '<tr><td style="padding:28px 30px;background:#111827;"><div style="font-size:12px;line-height:1.4;letter-spacing:.08em;text-transform:uppercase;color:#93c5fd;font-weight:700;">' . esc_html($site_name) . '</div><div style="margin-top:8px;font-size:25px;line-height:1.25;color:#ffffff;font-weight:800;">' . esc_html($headline) . '</div><div style="margin-top:8px;font-size:14px;line-height:1.5;color:#cbd5e1;">A new lead was submitted through your website.</div></td></tr>' . '<tr><td style="padding:24px 30px 10px;"><table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;background:#f8fafc;border:1px solid #e8edf3;border-radius:10px;"><tr>' . '<td style="padding:15px 16px;width:50%;vertical-align:top;"><div style="font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#94a3b8;font-weight:700;">Form</div><div style="margin-top:4px;font-size:14px;color:#172033;font-weight:700;">' . esc_html($form['title']) . '</div></td>' . '<td style="padding:15px 16px;width:50%;vertical-align:top;"><div style="font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#94a3b8;font-weight:700;">Received</div><div style="margin-top:4px;font-size:14px;color:#172033;font-weight:700;">' . esc_html($date) . '</div></td></tr><tr>' . '<td style="padding:0 16px 15px;width:50%;vertical-align:top;"><div style="font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#94a3b8;font-weight:700;">Website</div><div style="margin-top:4px;font-size:13px;"><a href="' . esc_url($site_url) . '" style="color:#2563eb;text-decoration:none;">' . esc_html(wp_parse_url($site_url, PHP_URL_HOST) ?: $site_url) . '</a></div></td>' . '<td style="padding:0 16px 15px;width:50%;vertical-align:top;"><div style="font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#94a3b8;font-weight:700;">Page</div><div style="margin-top:4px;font-size:13px;">' . $page_link . '</div></td></tr></table></td></tr>' . '<tr><td style="padding:14px 30px 4px;"><div style="font-size:16px;font-weight:800;color:#172033;">Lead details</div></td></tr>' . '<tr><td style="padding:10px 30px 24px;"><table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;border:1px solid #e8edf3;border-radius:10px;border-collapse:separate;border-spacing:0;overflow:hidden;">' . $rows . '</table></td></tr>'; if ($reply_button !== '') $html .= '<tr><td style="padding:0 30px 28px;">' . $reply_button . '</td></tr>'; $html .= '</table></td></tr></table></body></html>'; return $html; } private function custom_form_field_label($label, $name) { $label = sanitize_text_field((string) $label); if ($label !== '') return substr($label, 0, 120); $fallback = ucwords(str_replace(['_', '-'], ' ', sanitize_key((string) $name))); return $fallback !== '' ? $fallback : 'Field'; } private function custom_form_field_type($type) { $type = sanitize_key((string) $type); $allowed = ['text', 'email', 'tel', 'phone', 'textarea', 'select', 'radio', 'checkbox', 'url', 'number', 'date', 'time']; return in_array($type, $allowed, true) ? $type : 'text'; } private function custom_form_sanitize_value($value, $type) { if (is_array($value)) { $clean = []; foreach ($value as $item) { if (!is_scalar($item)) continue; $item = sanitize_text_field((string) $item); if ($item !== '') $clean[] = $item; } return implode(', ', array_slice($clean, 0, 30)); } $value = (string) $value; if ($type === 'email') return sanitize_email($value); if ($type === 'textarea') return sanitize_textarea_field($value); if ($type === 'url') return esc_url_raw($value); return sanitize_text_field($value); } private function custom_form_sensitive_field($name, $label) { $haystack = strtolower((string) $name . ' ' . (string) $label); $blocked = ['password', 'passwd', 'passcode', 'card number', 'card_number', 'credit card', 'cvv', 'cvc', 'security code', 'social security', 'ssn']; foreach ($blocked as $term) { if (strpos($haystack, $term) !== false) return true; } return false; } public function ajax_submit_custom_html_form() { if (!check_ajax_referer('ekmt_front_forms', 'nonce', false)) { wp_send_json_error(['message' => 'Security check expired. Refresh the page and try again.'], 403); } $honeypot = sanitize_text_field(wp_unslash($_POST['honeypot'] ?? '')); if ($honeypot !== '') { wp_send_json_success(['message' => 'Thank you. Your request has been received.']); } $started_at = absint($_POST['started_at'] ?? 0); if ($started_at && (time() - $started_at) < 2) { wp_send_json_error(['message' => 'Please wait a moment and try again.'], 429); } $recipient = $this->forms_recipient(); if (!$recipient || !is_email($recipient)) { wp_send_json_error(['message' => 'This form is not configured yet. Please contact the website owner.'], 500); } $raw_fields_json = isset($_POST['fields']) ? wp_unslash($_POST['fields']) : ''; if (!is_string($raw_fields_json) || $raw_fields_json === '' || strlen($raw_fields_json) > 100000) { wp_send_json_error(['message' => 'The form data could not be processed.'], 400); } $submitted_fields = json_decode($raw_fields_json, true); if (!is_array($submitted_fields) || count($submitted_fields) < 1 || count($submitted_fields) > 60) { wp_send_json_error(['message' => 'The form fields could not be processed.'], 400); } $form_title = sanitize_text_field(wp_unslash($_POST['form_title'] ?? 'Custom HTML Form')); $form_title = $form_title !== '' ? substr($form_title, 0, 100) : 'Custom HTML Form'; $custom_subject = sanitize_text_field(wp_unslash($_POST['subject'] ?? '')); $custom_subject = substr($custom_subject, 0, 160); $success_message = sanitize_text_field(wp_unslash($_POST['success_message'] ?? '')); $success_message = $success_message !== '' ? substr($success_message, 0, 220) : 'Thank you! Your request has been received.'; $page_url = esc_url_raw(wp_unslash($_POST['page_url'] ?? '')); $ip = sanitize_text_field(wp_unslash((string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown'))); $rate_key = 'ekmt_cf_' . substr(hash('sha256', $ip . '|' . $form_title), 0, 24); $rate = (int) get_transient($rate_key); if ($rate >= 8) { wp_send_json_error(['message' => 'Too many submissions. Please wait a few minutes and try again.'], 429); } set_transient($rate_key, $rate + 1, 10 * MINUTE_IN_SECONDS); $values = []; $field_definitions = []; $used_keys = []; $field_index = 0; foreach ($submitted_fields as $submitted) { if (!is_array($submitted)) continue; $raw_name = sanitize_text_field((string) ($submitted['name'] ?? '')); $label = $this->custom_form_field_label($submitted['label'] ?? '', $raw_name); $type = $this->custom_form_field_type($submitted['type'] ?? 'text'); $required = !empty($submitted['required']); if ($this->custom_form_sensitive_field($raw_name, $label)) { wp_send_json_error(['message' => 'Password and payment/security fields are not supported by the custom HTML form connector.'], 422); } $key = sanitize_key($raw_name); if ($key === '') { $field_index++; $key = 'field_' . $field_index; } $base_key = $key; $suffix = 2; while (isset($used_keys[$key])) { $key = $base_key . '_' . $suffix; $suffix++; } $used_keys[$key] = true; $raw_value = $submitted['value'] ?? ''; $value = $this->custom_form_sanitize_value($raw_value, $type); if ($required && trim((string) $value) === '') { wp_send_json_error(['message' => $label . ' is required.'], 422); } if ($type === 'email' && $value !== '' && !is_email($value)) { wp_send_json_error(['message' => 'Please enter a valid email address.'], 422); } $values[$key] = $value; $field_definitions[$key] = [ 'label' => $label, 'type' => $type, 'required' => $required, ]; } if (!$field_definitions) { wp_send_json_error(['message' => 'Add at least one named field to the form.'], 422); } $form = [ 'title' => $form_title, 'button' => 'Submit', 'description' => 'Custom HTML form connected through Ahmad Developer Toolkit.', 'fields' => $field_definitions, ]; $site_name = wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES); $subject = $custom_subject !== '' ? '[' . $site_name . '] ' . $custom_subject : '[' . $site_name . '] New ' . $form_title . ' submission'; $message = $this->build_lead_email_html($form, $values, $page_url); $headers = ['Content-Type: text/html; charset=UTF-8']; $reply_email = ''; foreach ($field_definitions as $key => $field) { if (($field['type'] ?? '') === 'email' && !empty($values[$key]) && is_email($values[$key])) { $reply_email = $values[$key]; break; } } if ($reply_email !== '') $headers[] = 'Reply-To: ' . $reply_email; $mail_sent = wp_mail($recipient, $subject, $message, $headers); // Map common field names into the standard lead-card positions where possible. if (!isset($values['name'])) { foreach (['full_name', 'fullname', 'customer_name', 'your_name'] as $candidate) { if (!empty($values[$candidate])) { $values['name'] = $values[$candidate]; break; } } } if (!isset($values['phone'])) { foreach (['telephone', 'mobile', 'phone_number', 'contact_number'] as $candidate) { if (!empty($values[$candidate])) { $values['phone'] = $values[$candidate]; break; } } } if (!isset($values['email'])) { foreach ($field_definitions as $key => $field) { if (($field['type'] ?? '') === 'email' && !empty($values[$key])) { $values['email'] = $values[$key]; break; } } } $entry_id = $this->save_lead_entry('custom_html', $form, $values, $page_url, (bool) $mail_sent); if (is_wp_error($entry_id)) { wp_send_json_error(['message' => 'The form could not save your submission. Please try again.'], 500); } $this->increment_tracking('forms'); wp_send_json_success([ 'message' => $success_message, 'mail_sent' => (bool) $mail_sent, 'entry_id' => (int) $entry_id, ]); } public function ajax_submit_lead_form() { if (!check_ajax_referer('ekmt_front_forms', 'nonce', false)) wp_send_json_error(['message' => 'Security check expired. Refresh the page and try again.'], 403); if (!empty($_POST['company_website'])) wp_send_json_success(['message' => 'Thank you. Your message has been received.']); $started_at = absint($_POST['started_at'] ?? 0); if ($started_at && (time() - $started_at) < 2) wp_send_json_error(['message' => 'Please wait a moment and try again.'], 429); $form_id = sanitize_key(wp_unslash($_POST['form_id'] ?? '')); $forms = $this->lead_form_definitions(); if (!$form_id || empty($forms[$form_id])) wp_send_json_error(['message' => 'This form is not available.'], 400); $form = $forms[$form_id]; $recipient = $this->forms_recipient(); if (!$recipient || !is_email($recipient)) wp_send_json_error(['message' => 'This form is not configured yet. Please contact the website owner.'], 500); $ip = sanitize_text_field(wp_unslash((string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown'))); $rate_key = 'ekmt_lf_' . substr(hash('sha256', $ip . '|' . $form_id), 0, 24); $rate = (int) get_transient($rate_key); if ($rate >= 8) wp_send_json_error(['message' => 'Too many submissions. Please wait a few minutes and try again.'], 429); set_transient($rate_key, $rate + 1, 10 * MINUTE_IN_SECONDS); $values = []; foreach ($form['fields'] as $name => $field) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized immediately below according to the configured field type. $raw = wp_unslash($_POST[$name] ?? ''); $type = $field['type'] ?? 'text'; if ($type === 'email') $value = sanitize_email($raw); elseif ($type === 'textarea') $value = sanitize_textarea_field($raw); else $value = sanitize_text_field($raw); if (!empty($field['required']) && trim((string) $value) === '') { wp_send_json_error(['message' => $field['label'] . ' is required.'], 422); } if ($type === 'email' && $value !== '' && !is_email($value)) { wp_send_json_error(['message' => 'Please enter a valid email address.'], 422); } $values[$name] = $value; } $page_url = esc_url_raw(wp_unslash($_POST['page_url'] ?? '')); $site_name = wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES); $subject = '[' . $site_name . '] New ' . $form['title'] . ' submission'; $message = $this->build_lead_email_html($form, $values, $page_url); $headers = ['Content-Type: text/html; charset=UTF-8']; if (!empty($values['email']) && is_email($values['email'])) $headers[] = 'Reply-To: ' . $values['email']; $mail_sent = wp_mail($recipient, $subject, $message, $headers); $entry_id = $this->save_lead_entry($form_id, $form, $values, $page_url, (bool) $mail_sent); if (is_wp_error($entry_id)) { wp_send_json_error(['message' => 'The form could not save your submission. Please try again.'], 500); } $this->increment_tracking('forms'); wp_send_json_success([ 'message' => 'Thank you! Your request has been received.', 'mail_sent' => (bool) $mail_sent, ]); } public function ajax_save_forms_recipient() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); if (!current_user_can('manage_options')) wp_send_json_error(['message' => 'Only an administrator can change the destination email.'], 403); $email = sanitize_email(wp_unslash($_POST['email'] ?? '')); if (!$email || !is_email($email)) wp_send_json_error(['message' => 'Enter a valid destination email.'], 422); update_option(self::FORMS_RECIPIENT_OPTION, $email, false); wp_send_json_success(['message' => 'Submission email saved.', 'email' => $email]); } public function ajax_send_forms_test_email() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); if (!current_user_can('manage_options')) wp_send_json_error(['message' => 'Only an administrator can send the setup test.'], 403); $email = $this->forms_recipient(); if (!$email || !is_email($email)) wp_send_json_error(['message' => 'Save a valid destination email first.'], 422); $site_name = wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES); $subject = '[' . $site_name . '] Toolkit Forms test email'; $message = '<!doctype html><html><body style="margin:0;padding:0;background:#f3f6fa;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;">' . '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;background:#f3f6fa;"><tr><td align="center" style="padding:32px 14px;">' . '<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;background:#ffffff;border:1px solid #e2e8f0;border-radius:14px;overflow:hidden;">' . '<tr><td style="padding:26px 30px;background:#111827;color:#ffffff;"><div style="font-size:12px;color:#93c5fd;font-weight:700;text-transform:uppercase;letter-spacing:.08em;">' . esc_html($site_name) . '</div><div style="margin-top:8px;font-size:24px;font-weight:800;">Email template test</div></td></tr>' . '<tr><td style="padding:28px 30px;color:#334155;font-size:14px;line-height:1.7;"><p style="margin:0 0 12px;">Your Ahmad Toolkit Forms HTML email template is working.</p><p style="margin:0;">This message was sent through WordPress <code>wp_mail()</code>. If SMTP Mailer is enabled, it was delivered using your configured SMTP server.</p></td></tr>' . '<tr><td style="padding:16px 30px;background:#f8fafc;border-top:1px solid #e8edf3;color:#64748b;font-size:12px;">Ahmad Developer Toolkit</td></tr>' . '</table></td></tr></table></body></html>'; $sent = wp_mail($email, $subject, $message, ['Content-Type: text/html; charset=UTF-8']); if (!$sent) wp_send_json_error(['message' => 'WordPress reported that the test email could not be handed off for sending. Check the site mail/SMTP configuration.']); wp_send_json_success(['message' => 'Test email handed to WordPress mail successfully. Check ' . $email . ' to confirm delivery.']); } public function ajax_get_form_entries() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); $q = new WP_Query([ 'post_type' => self::LEAD_POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => 30, 'orderby' => 'date', 'order' => 'DESC', 'no_found_rows' => true, ]); $items = []; foreach ($q->posts as $post) { $values = get_post_meta($post->ID, '_ekmt_form_values', true); if (!is_array($values)) $values = []; $items[] = [ 'id' => (int) $post->ID, 'title' => get_post_meta($post->ID, '_ekmt_form_title', true) ?: 'Website Form', 'date' => get_the_date('M j, Y g:i a', $post), 'name' => (string) ($values['name'] ?? ''), 'email' => (string) ($values['email'] ?? ''), 'phone' => (string) ($values['phone'] ?? ''), 'values' => $values, 'page_url' => (string) get_post_meta($post->ID, '_ekmt_page_url', true), 'mail_sent' => get_post_meta($post->ID, '_ekmt_mail_sent', true) === '1', ]; } wp_send_json_success(['items' => $items]); } public function ajax_delete_form_entry() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); $id = absint($_POST['id'] ?? 0); if (!$id || get_post_type($id) !== self::LEAD_POST_TYPE) wp_send_json_error(['message' => 'Entry not found.'], 404); if (!current_user_can('delete_post', $id) && !current_user_can('manage_options')) wp_send_json_error(['message' => 'Permission denied.'], 403); $deleted = wp_delete_post($id, true); if (!$deleted) wp_send_json_error(['message' => 'Could not delete this entry.']); wp_send_json_success(['message' => 'Entry deleted.']); } public function ajax_support() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); wp_send_json_success([ 'webp' => wp_image_editor_supports(['mime_type' => 'image/webp']), ]); } public function ajax_get_images() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); $page = max(1, absint($_POST['page'] ?? 1)); $mode = sanitize_key(wp_unslash($_POST['mode'] ?? 'rename')); $per_page = 30; $mimes = ['image/jpeg', 'image/png', 'image/webp']; if ($mode === 'webp') $mimes = ['image/jpeg', 'image/png']; $q = new WP_Query([ 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_mime_type' => $mimes, 'posts_per_page' => $per_page, 'paged' => $page, 'orderby' => 'ID', 'order' => 'DESC', 'fields' => 'ids', 'no_found_rows' => false, ]); $items = []; foreach ($q->posts as $id) { $file = get_attached_file($id); if (!$file || !file_exists($file)) continue; $thumb = wp_get_attachment_image_url($id, 'medium') ?: wp_get_attachment_url($id); $items[] = [ 'id' => $id, 'filename' => basename($file), 'name' => pathinfo($file, PATHINFO_FILENAME), 'ext' => strtolower(pathinfo($file, PATHINFO_EXTENSION)), 'mime' => get_post_mime_type($id), 'thumb' => $thumb, 'size' => size_format(filesize($file), 1), 'dimensions' => $this->dimensions($file), ]; } wp_send_json_success([ 'items' => $items, 'has_more' => $page < (int)$q->max_num_pages, 'page' => $page, 'total' => (int)$q->found_posts, ]); } private function dimensions($file) { $d = @getimagesize($file); return $d ? $d[0] . '×' . $d[1] : '—'; } public function ajax_scan_oversized() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); global $wpdb; $cursor = absint($_POST['cursor'] ?? 0); $limit = max(10, min(80, absint($_POST['limit'] ?? 50))); $include_total = !empty($_POST['include_total']); $min_bytes = 1024 * 1024; // Fixed: 1 MB or larger. $image_mime_like = 'image/%'; if ($cursor > 0) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status = %s AND post_mime_type LIKE %s AND ID < %d ORDER BY ID DESC LIMIT %d", 'attachment', 'inherit', $image_mime_like, $cursor, $limit ) ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status = %s AND post_mime_type LIKE %s ORDER BY ID DESC LIMIT %d", 'attachment', 'inherit', $image_mime_like, $limit ) ); } $ids = array_map('intval', (array) $ids); $total = null; if ($include_total) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data. $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status = %s AND post_mime_type LIKE %s", 'attachment', 'inherit', $image_mime_like ) ); } $items = []; foreach ($ids as $id) { if (get_post_meta($id, self::QUARANTINE_META, true)) continue; $file = get_attached_file($id); if (!$file || !file_exists($file)) continue; $bytes = (int) filesize($file); if ($bytes < $min_bytes) continue; $meta = wp_get_attachment_metadata($id); $width = !empty($meta['width']) ? (int) $meta['width'] : 0; $height = !empty($meta['height']) ? (int) $meta['height'] : 0; if (!$width || !$height) { $size = @getimagesize($file); if ($size) { $width = (int) $size[0]; $height = (int) $size[1]; } } $items[] = [ 'id' => $id, 'filename' => basename($file), 'thumb' => wp_get_attachment_image_url($id, 'medium') ?: wp_get_attachment_url($id), 'dimensions' => ($width && $height) ? ($width . '×' . $height) : '—', 'width' => $width, 'height' => $height, 'size' => size_format($bytes, 1), 'bytes' => $bytes, 'reason' => '1 MB+ original', 'edit_url' => get_edit_post_link($id, 'raw'), ]; } wp_send_json_success([ 'items' => $items, 'scanned' => count($ids), 'found' => count($items), 'has_more' => count($ids) === $limit, 'next_cursor' => $ids ? min($ids) : 0, 'total' => $total, ]); } public function ajax_scan_missing_alt() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); global $wpdb; $cursor = absint($_POST['cursor'] ?? 0); $limit = max(10, min(60, absint($_POST['limit'] ?? 40))); $include_total = !empty($_POST['include_total']); $image_mime_like = 'image/%'; if ($cursor > 0) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = %s WHERE p.post_type = %s AND p.post_status = %s AND p.post_mime_type LIKE %s AND (pm.meta_id IS NULL OR TRIM(pm.meta_value) = '') AND p.ID < %d ORDER BY p.ID DESC LIMIT %d", '_wp_attachment_image_alt', 'attachment', 'inherit', $image_mime_like, $cursor, $limit ) ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = %s WHERE p.post_type = %s AND p.post_status = %s AND p.post_mime_type LIKE %s AND (pm.meta_id IS NULL OR TRIM(pm.meta_value) = '') ORDER BY p.ID DESC LIMIT %d", '_wp_attachment_image_alt', 'attachment', 'inherit', $image_mime_like, $limit ) ); } $ids = array_map('intval', (array) $ids); $total = null; if ($include_total) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data. $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = %s WHERE p.post_type = %s AND p.post_status = %s AND p.post_mime_type LIKE %s AND (pm.meta_id IS NULL OR TRIM(pm.meta_value) = '')", '_wp_attachment_image_alt', 'attachment', 'inherit', $image_mime_like ) ); } $items = []; foreach ($ids as $id) { if (get_post_meta($id, self::QUARANTINE_META, true)) continue; $file = get_attached_file($id); if (!$file || !file_exists($file)) continue; $items[] = [ 'id' => $id, 'filename' => basename($file), 'title' => get_the_title($id), 'thumb' => wp_get_attachment_image_url($id, 'medium') ?: wp_get_attachment_url($id), 'dimensions' => $this->dimensions($file), 'size' => size_format(filesize($file), 1), ]; } wp_send_json_success([ 'items' => $items, 'scanned' => count($ids), 'found' => count($items), 'has_more' => count($ids) === $limit, 'next_cursor' => $ids ? min($ids) : 0, 'total' => $total, ]); } public function ajax_save_alt() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); $id = absint($_POST['id'] ?? 0); $alt = sanitize_text_field(wp_unslash($_POST['alt'] ?? '')); if (!$id || get_post_type($id) !== 'attachment' || !wp_attachment_is_image($id)) { wp_send_json_error(['message' => 'Invalid image.']); } if (!current_user_can('edit_post', $id)) { wp_send_json_error(['message' => 'You cannot edit this image.'], 403); } update_post_meta($id, '_wp_attachment_image_alt', $alt); wp_send_json_success([ 'message' => $alt === '' ? 'ALT text cleared.' : 'ALT text saved.', 'alt' => $alt, ]); } private function normalize_phone_digits($number) { return preg_replace('/\D+/', '', html_entity_decode((string) $number, ENT_QUOTES, 'UTF-8')); } private function canonical_phone_digits($number) { $digits = $this->normalize_phone_digits($number); // North-American country code is optional for comparison. This means // +1 (815) 474-3974 and (815) 474-3974 are the same phone number. if (strlen($digits) === 11 && strpos($digits, '1') === 0) { $digits = substr($digits, 1); } return $digits; } private function phone_is_match($number, $official) { $a = $this->canonical_phone_digits($number); $b = $this->canonical_phone_digits($official); return $a !== '' && $b !== '' && hash_equals($b, $a); } private function phone_candidate_valid($raw, $official) { $digits = $this->normalize_phone_digits($raw); $canonical = $this->canonical_phone_digits($raw); $official_canonical = $this->canonical_phone_digits($official); if ($canonical === '' || $official_canonical === '') return false; // When the official number is a normal US/Canada 10-digit number, only // accept 10 digits or 11 digits beginning with 1. This avoids treating // dates, IDs, ZIP codes and tracking numbers as phones. if (strlen($official_canonical) === 10) { return strlen($digits) === 10 || (strlen($digits) === 11 && strpos($digits, '1') === 0); } $len = strlen($digits); return $len >= 7 && $len <= 15; } private function phone_text_candidates($text, $official) { $text = html_entity_decode((string) $text, ENT_QUOTES, 'UTF-8'); if ($text === '') return []; $pattern = '~(?<!\d)(?:\+?1[\s.\-]?)?(?:\(\s*\d{3}\s*\)|\d{3})[\s.\-]*\d{3}[\s.\-]*\d{4}(?!\d)~'; if (!preg_match_all($pattern, $text, $matches)) return []; $out = []; foreach ($matches[0] as $raw) { $raw = trim((string) $raw); if ($this->phone_candidate_valid($raw, $official)) $out[] = $raw; } return $out; } private function phone_short_context($text) { $text = trim(preg_replace('/\s+/', ' ', wp_strip_all_tags(html_entity_decode((string) $text, ENT_QUOTES, 'UTF-8')))); if (strlen($text) > 180) $text = substr($text, 0, 177) . '...'; return $text; } private function phone_region_occurrences($region_html, $official, $region, $page_id, $page_title, $page_url) { $region_html = (string) $region_html; if ($region_html === '') return []; // DOM is standard on most WordPress hosts. If a minimal PHP build does // not include it, use the lightweight text fallback instead of failing. if (!class_exists('DOMDocument')) { $plain = wp_strip_all_tags($region_html); $items = []; foreach ($this->phone_text_candidates($plain, $official) as $raw) { $canonical = $this->canonical_phone_digits($raw); $kind = 'Content text'; $region_label = $region === 'header' ? 'Header' : ($region === 'footer' ? 'Footer' : $page_title); $key = ($region === 'content' ? ('page-' . $page_id) : $region) . '|content text|' . $canonical; if (!isset($items[$key])) { $items[$key] = [ 'key' => $key, 'number' => $raw, 'digits' => $canonical, 'match' => $this->phone_is_match($raw, $official), 'kind' => $kind, 'region' => $region, 'location' => $region_label . ' — ' . $kind, 'context' => $this->phone_short_context($plain), 'count' => 0, 'page_id' => $page_id, 'page_title' => $page_title, 'page_url' => $page_url, 'edit_url' => get_edit_post_link($page_id, 'raw') ?: '', ]; } $items[$key]['count']++; } return array_values($items); } $doc = new DOMDocument(); $previous = libxml_use_internal_errors(true); $loaded = $doc->loadHTML('<?xml encoding="utf-8" ?><div id="ekmt-phone-region">' . $region_html . '</div>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); libxml_clear_errors(); libxml_use_internal_errors($previous); if (!$loaded) return []; $xpath = new DOMXPath($doc); $root_nodes = $xpath->query('//*[@id="ekmt-phone-region"]'); $root = ($root_nodes && $root_nodes->length) ? $root_nodes->item(0) : null; if (!$root) return []; $items = []; $add = function($raw, $kind, $context = '') use (&$items, $official, $region, $page_id, $page_title, $page_url) { if (!$this->phone_candidate_valid($raw, $official)) return; $canonical = $this->canonical_phone_digits($raw); $region_label = $region === 'header' ? 'Header' : ($region === 'footer' ? 'Footer' : $page_title); $location = $region_label . ' — ' . $kind; $dedupe = ($region === 'content' ? ('page-' . $page_id) : $region) . '|' . strtolower($kind) . '|' . $canonical; if (!isset($items[$dedupe])) { $items[$dedupe] = [ 'key' => $dedupe, 'number' => trim((string) $raw), 'digits' => $canonical, 'match' => $this->phone_is_match($raw, $official), 'kind' => $kind, 'region' => $region, 'location' => $location, 'context' => $this->phone_short_context($context), 'count' => 0, 'page_id' => $page_id, 'page_title' => $page_title, 'page_url' => $page_url, 'edit_url' => get_edit_post_link($page_id, 'raw') ?: '', ]; } $items[$dedupe]['count']++; }; // Phone links/buttons. The href is checked separately from the visible // label so a correct-looking button with the wrong tel: target is caught. $links = $xpath->query('.//a[@href]', $root); if ($links) { foreach ($links as $link) { $href = html_entity_decode((string) $link->getAttribute('href'), ENT_QUOTES, 'UTF-8'); if (stripos(trim($href), 'tel:') !== 0) continue; $target = rawurldecode(trim(substr(trim($href), 4))); $target = preg_split('/[?;,]/', $target)[0] ?? $target; $class = strtolower((string) $link->getAttribute('class')); $role = strtolower((string) $link->getAttribute('role')); $is_button = strpos($class, 'button') !== false || preg_match('/(^|[\s_-])btn([\s_-]|$)/', $class) || $role === 'button'; $kind = $is_button ? 'Phone button' : 'Phone link'; $label = trim((string) $link->textContent); $add($target, $kind, $label ? ('Label: ' . $label . ' · Link: ' . $href) : ('Link: ' . $href)); foreach ($this->phone_text_candidates($label, $official) as $visible_number) { if ($this->canonical_phone_digits($visible_number) !== $this->canonical_phone_digits($target)) { $add($visible_number, $is_button ? 'Button text' : 'Link text', 'Visible label: ' . $label); } } } } // Less common onclick="...tel:..." buttons. $click_nodes = $xpath->query('.//*[@onclick]', $root); if ($click_nodes) { foreach ($click_nodes as $node) { $onclick = html_entity_decode((string) $node->getAttribute('onclick'), ENT_QUOTES, 'UTF-8'); if (!preg_match('~tel:\s*([^\"\'\s)]+)~i', $onclick, $m)) continue; $add(rawurldecode($m[1]), 'Phone button', 'onclick phone action · ' . trim((string) $node->textContent)); } } // Visible page text, excluding scripts/styles and phone-link labels so a // single clickable number is not counted twice. $texts = $xpath->query('.//text()[normalize-space() != "" and not(ancestor::script) and not(ancestor::style) and not(ancestor::noscript) and not(ancestor::a[starts-with(translate(@href,"TEL","tel"),"tel:")])]', $root); if ($texts) { foreach ($texts as $text_node) { $text = (string) $text_node->nodeValue; foreach ($this->phone_text_candidates($text, $official) as $raw) { $context = $text; if ($text_node->parentNode) $context = (string) $text_node->parentNode->textContent; $add($raw, 'Content text', $context); } } } return array_values($items); } private function phone_page_regions($html) { if (!class_exists('DOMDocument')) { $html = (string) $html; $header = ''; $footer = ''; if (preg_match_all('~<header\b[^>]*>.*?</header>~is', $html, $m)) $header = implode("\n", $m[0]); if (preg_match_all('~<footer\b[^>]*>.*?</footer>~is', $html, $m)) $footer = implode("\n", $m[0]); $content = $html; if ($header !== '') $content = str_replace(explode("\n", $header), '', $content); if ($footer !== '') $content = str_replace(explode("\n", $footer), '', $content); return ['header' => $header, 'footer' => $footer, 'content' => $content]; } $doc = new DOMDocument(); $previous = libxml_use_internal_errors(true); $loaded = $doc->loadHTML('<?xml encoding="utf-8" ?>' . (string) $html, LIBXML_HTML_NODEFDTD); libxml_clear_errors(); libxml_use_internal_errors($previous); if (!$loaded) return ['header' => '', 'footer' => '', 'content' => (string) $html]; $xpath = new DOMXPath($doc); $save_nodes = function($query) use ($xpath, $doc) { $htmls = []; $nodes = $xpath->query($query); if ($nodes) { foreach ($nodes as $node) $htmls[] = $doc->saveHTML($node); } return $htmls; }; $headers = $save_nodes('//header'); if (!$headers) { $headers = $save_nodes('//*[contains(concat(" ", normalize-space(@class), " "), " elementor-location-header ") or contains(concat(" ", normalize-space(@class), " "), " site-header ") or @id="masthead" or @id="header"]'); } $footers = $save_nodes('//footer'); if (!$footers) { $footers = $save_nodes('//*[contains(concat(" ", normalize-space(@class), " "), " elementor-location-footer ") or contains(concat(" ", normalize-space(@class), " "), " site-footer ") or @id="colophon" or @id="footer"]'); } // Work with a second DOM so header/footer can be removed before content // scanning, preventing sitewide numbers from being counted on every page. $content_doc = new DOMDocument(); $previous = libxml_use_internal_errors(true); $content_doc->loadHTML('<?xml encoding="utf-8" ?>' . (string) $html, LIBXML_HTML_NODEFDTD); libxml_clear_errors(); libxml_use_internal_errors($previous); $content_xpath = new DOMXPath($content_doc); $remove_query = '//header | //footer | //*[contains(concat(" ", normalize-space(@class), " "), " elementor-location-header ") or contains(concat(" ", normalize-space(@class), " "), " elementor-location-footer ") or contains(concat(" ", normalize-space(@class), " "), " site-header ") or contains(concat(" ", normalize-space(@class), " "), " site-footer ") or @id="masthead" or @id="colophon" or @id="header" or @id="footer"]'; $remove = $content_xpath->query($remove_query); $to_remove = []; if ($remove) foreach ($remove as $node) $to_remove[] = $node; foreach ($to_remove as $node) { if ($node->parentNode) $node->parentNode->removeChild($node); } $content_html = ''; $mains = $content_xpath->query('//main'); if ($mains && $mains->length) { foreach ($mains as $main) $content_html .= $content_doc->saveHTML($main); } else { $bodies = $content_xpath->query('//body'); if ($bodies && $bodies->length) $content_html = $content_doc->saveHTML($bodies->item(0)); else $content_html = $content_doc->saveHTML(); } return [ 'header' => implode("\n", $headers), 'footer' => implode("\n", $footers), 'content' => $content_html, ]; } public function ajax_scan_phone_numbers() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); $official = sanitize_text_field(wp_unslash($_POST['official'] ?? '')); $cursor = absint($_POST['cursor'] ?? 0); $limit = max(1, min(6, absint($_POST['limit'] ?? 3))); $official_digits = $this->canonical_phone_digits($official); if (!$official || strlen($official_digits) < 7 || strlen($official_digits) > 15) { wp_send_json_error(['message' => 'Enter a valid official phone number first.']); } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data. $page_ids = $wpdb->get_col($wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID > %d AND post_type = 'page' AND post_status = 'publish' ORDER BY ID ASC LIMIT %d", $cursor, $limit )); $items = []; $errors = []; foreach ($page_ids as $page_id_raw) { $page_id = absint($page_id_raw); $title = trim((string) get_the_title($page_id)); if ($title === '') $title = 'Page #' . $page_id; $url = get_permalink($page_id); if (!$url) { $errors[] = $title . ': no public URL found.'; continue; } $response = wp_remote_get($url, [ 'timeout' => 15, 'redirection' => 5, 'user-agent' => 'Ahmad Developer Toolkit Phone Scanner/' . self::VERSION . '; ' . home_url('/'), 'headers' => ['Cache-Control' => 'no-cache'], ]); if (is_wp_error($response)) { $errors[] = $title . ': ' . $response->get_error_message(); continue; } $code = (int) wp_remote_retrieve_response_code($response); if ($code < 200 || $code >= 400) { $errors[] = $title . ': HTTP ' . $code; continue; } $html = (string) wp_remote_retrieve_body($response); $regions = $this->phone_page_regions($html); foreach (['header', 'content', 'footer'] as $region) { foreach ($this->phone_region_occurrences($regions[$region] ?? '', $official, $region, $page_id, $title, $url) as $item) { $items[] = $item; } } } $next_cursor = $page_ids ? absint(end($page_ids)) : $cursor; $page_counts = wp_count_posts('page'); $total_pages = isset($page_counts->publish) ? (int) $page_counts->publish : 0; wp_send_json_success([ 'items' => $items, 'errors' => $errors, 'scanned' => count($page_ids), 'has_more' => count($page_ids) === $limit, 'next_cursor' => $next_cursor, 'total_pages' => $total_pages, ]); } private function canonical_email_address($email) { $email = html_entity_decode(rawurldecode((string) $email), ENT_QUOTES, 'UTF-8'); $email = trim($email); if (stripos($email, 'mailto:') === 0) $email = substr($email, 7); $email = preg_split('/[?&#]/', $email)[0] ?? $email; $email = trim($email, " \t\n\r\0\x0B<>\"'"); return strtolower($email); } private function email_is_match($email, $official) { $a = $this->canonical_email_address($email); $b = $this->canonical_email_address($official); return $a !== '' && $b !== '' && hash_equals($b, $a); } private function email_text_candidates($text) { $text = html_entity_decode((string) $text, ENT_QUOTES, 'UTF-8'); if ($text === '') return []; if (!preg_match_all('/(?<![A-Z0-9._%+\-])([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,63})(?![A-Z0-9._%+\-])/i', $text, $m)) return []; $out = []; foreach ($m[1] as $email) { $email = trim((string) $email); if (is_email($email)) $out[] = $email; } return array_values(array_unique($out)); } private function email_region_occurrences($region_html, $official, $region, $page_id, $page_title, $page_url) { $region_html = (string) $region_html; if ($region_html === '') return []; $items = []; $add = function($raw, $kind, $context = '') use (&$items, $official, $region, $page_id, $page_title, $page_url) { $canonical = $this->canonical_email_address($raw); if (!is_email($canonical)) return; $region_label = $region === 'header' ? 'Header' : ($region === 'footer' ? 'Footer' : $page_title); $dedupe = ($region === 'content' ? ('page-' . $page_id) : $region) . '|' . strtolower($kind) . '|' . $canonical; if (!isset($items[$dedupe])) { $items[$dedupe] = [ 'key' => $dedupe, 'email' => trim((string) $raw), 'canonical' => $canonical, 'match' => $this->email_is_match($raw, $official), 'kind' => $kind, 'region' => $region, 'location' => $region_label . ' — ' . $kind, 'context' => $this->phone_short_context($context), 'count' => 0, 'page_id' => $page_id, 'page_title' => $page_title, 'page_url' => $page_url, 'edit_url' => get_edit_post_link($page_id, 'raw') ?: '', ]; } $items[$dedupe]['count']++; }; if (!class_exists('DOMDocument')) { foreach ($this->email_text_candidates(wp_strip_all_tags($region_html)) as $email) { $add($email, 'Content email', wp_strip_all_tags($region_html)); } if (preg_match_all('/href\s*=\s*(["\'])mailto:([^"\']+)\1/i', $region_html, $matches)) { foreach ($matches[2] as $target) { $target = preg_split('/[?]/', rawurldecode((string) $target))[0] ?? $target; foreach (preg_split('/[,;]/', $target) as $addr) $add(trim($addr), 'Email link', 'mailto:' . trim($addr)); } } return array_values($items); } $doc = new DOMDocument(); $previous = libxml_use_internal_errors(true); $loaded = $doc->loadHTML('<?xml encoding="utf-8" ?><div id="ekmt-email-region">' . $region_html . '</div>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); libxml_clear_errors(); libxml_use_internal_errors($previous); if (!$loaded) return []; $xpath = new DOMXPath($doc); $roots = $xpath->query('//*[@id="ekmt-email-region"]'); $root = ($roots && $roots->length) ? $roots->item(0) : null; if (!$root) return []; $links = $xpath->query('.//a[@href]', $root); if ($links) { foreach ($links as $link) { $href = html_entity_decode((string) $link->getAttribute('href'), ENT_QUOTES, 'UTF-8'); if (stripos(trim($href), 'mailto:') !== 0) continue; $target = rawurldecode(substr(trim($href), 7)); $target = preg_split('/[?]/', $target)[0] ?? $target; $class = strtolower((string) $link->getAttribute('class')); $role = strtolower((string) $link->getAttribute('role')); $is_button = strpos($class, 'button') !== false || preg_match('/(^|[\s_-])btn([\s_-]|$)/', $class) || $role === 'button'; $kind = $is_button ? 'Email button' : 'Email link'; $label = trim((string) $link->textContent); foreach (preg_split('/[,;]/', $target) as $addr) { $addr = trim($addr); if ($addr !== '') $add($addr, $kind, $label ? ('Label: ' . $label . ' · Link: ' . $href) : ('Link: ' . $href)); } foreach ($this->email_text_candidates($label) as $visible) { $add($visible, $is_button ? 'Button text' : 'Link text', 'Visible text: ' . $label); } } } $text_nodes = $xpath->query('.//text()[not(ancestor::script) and not(ancestor::style) and not(ancestor::noscript)]', $root); if ($text_nodes) { foreach ($text_nodes as $node) { $skip = false; $parent = $node->parentNode; while ($parent && $parent !== $root) { if ($parent instanceof DOMElement && strtolower($parent->tagName) === 'a') { $href = (string) $parent->getAttribute('href'); if (stripos(trim($href), 'mailto:') === 0) { $skip = true; break; } } $parent = $parent->parentNode; } if ($skip) continue; $text = trim((string) $node->nodeValue); if ($text === '') continue; foreach ($this->email_text_candidates($text) as $email) { $context = $node->parentNode ? (string) $node->parentNode->textContent : $text; $add($email, 'Content email', $context); } } } return array_values($items); } public function ajax_scan_email_addresses() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); $official_raw = sanitize_email(wp_unslash($_POST['official'] ?? '')); $official = $this->canonical_email_address($official_raw); $cursor = absint($_POST['cursor'] ?? 0); $limit = max(1, min(6, absint($_POST['limit'] ?? 3))); if (!$official || !is_email($official)) wp_send_json_error(['message' => 'Enter a valid official email address first.']); global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data. $page_ids = $wpdb->get_col($wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID > %d AND post_type = 'page' AND post_status = 'publish' ORDER BY ID ASC LIMIT %d", $cursor, $limit )); $items = []; $errors = []; foreach ($page_ids as $page_id_raw) { $page_id = absint($page_id_raw); $title = trim((string) get_the_title($page_id)); if ($title === '') $title = 'Page #' . $page_id; $url = get_permalink($page_id); if (!$url) { $errors[] = $title . ': no public URL found.'; continue; } $response = wp_remote_get($url, [ 'timeout' => 15, 'redirection' => 5, 'user-agent' => 'Ahmad Developer Toolkit Email Scanner/' . self::VERSION . '; ' . home_url('/'), 'headers' => ['Cache-Control' => 'no-cache'], ]); if (is_wp_error($response)) { $errors[] = $title . ': ' . $response->get_error_message(); continue; } $code = (int) wp_remote_retrieve_response_code($response); if ($code < 200 || $code >= 400) { $errors[] = $title . ': HTTP ' . $code; continue; } $html = (string) wp_remote_retrieve_body($response); $regions = $this->phone_page_regions($html); foreach (['header', 'content', 'footer'] as $region) { foreach ($this->email_region_occurrences($regions[$region] ?? '', $official, $region, $page_id, $title, $url) as $item) $items[] = $item; } } $next_cursor = $page_ids ? absint(end($page_ids)) : $cursor; $page_counts = wp_count_posts('page'); $total_pages = isset($page_counts->publish) ? (int) $page_counts->publish : 0; wp_send_json_success([ 'items' => $items, 'errors' => $errors, 'scanned' => count($page_ids), 'has_more' => count($page_ids) === $limit, 'next_cursor' => $next_cursor, 'total_pages' => $total_pages, ]); } private function form_fetch_page($page_id) { $page_id = absint($page_id); $url = get_permalink($page_id); if (!$url) return new WP_Error('no_url', 'No public URL found for this page.'); $response = wp_remote_get($url, [ 'timeout' => 18, 'redirection' => 5, 'user-agent' => 'Toolkit Form Health/' . self::VERSION . '; ' . home_url('/'), 'headers' => ['Cache-Control' => 'no-cache'], ]); if (is_wp_error($response)) return $response; $code = (int) wp_remote_retrieve_response_code($response); if ($code < 200 || $code >= 400) return new WP_Error('http_error', 'HTTP ' . $code); return ['url' => $url, 'html' => (string) wp_remote_retrieve_body($response), 'response' => $response]; } private function form_absolute_url($action, $page_url) { $action = html_entity_decode(trim((string) $action), ENT_QUOTES, 'UTF-8'); if ($action === '' || $action === '#') return $page_url; if (preg_match('~^javascript:~i', $action)) return ''; if (preg_match('~^https?://~i', $action)) return $action; if (strpos($action, '//') === 0) { $scheme = wp_parse_url($page_url, PHP_URL_SCHEME) ?: 'https'; return $scheme . ':' . $action; } if (strpos($action, '/') === 0) { $parts = wp_parse_url($page_url); if (!$parts || empty($parts['host'])) return ''; $scheme = $parts['scheme'] ?? 'https'; $port = isset($parts['port']) ? ':' . (int) $parts['port'] : ''; return $scheme . '://' . $parts['host'] . $port . $action; } return trailingslashit($page_url) . ltrim($action, './'); } private function form_same_origin($url) { $site_host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST)); $target_host = strtolower((string) wp_parse_url($url, PHP_URL_HOST)); return $site_host !== '' && $target_host !== '' && $site_host === $target_host; } private function form_detect_type(DOMElement $form) { $class = strtolower($form->getAttribute('class')); $id = strtolower($form->getAttribute('id')); $action = strtolower($form->getAttribute('action')); $html = strtolower($form->ownerDocument->saveHTML($form)); $hay = $class . ' ' . $id . ' ' . $action . ' ' . $html; if (strpos($hay, 'wpcf7') !== false || strpos($hay, '_wpcf7') !== false) return 'Contact Form 7'; if (strpos($hay, 'wpforms') !== false) return 'WPForms'; if (strpos($hay, 'elementor-form') !== false || strpos($hay, 'elementor_form') !== false) return 'Elementor Form'; if (strpos($hay, 'gform_') !== false || strpos($hay, 'gravityforms') !== false) return 'Gravity Forms'; if (strpos($hay, 'fluentform') !== false || strpos($hay, 'frm-fluent-form') !== false) return 'Fluent Forms'; if (strpos($hay, 'formidable') !== false || preg_match('/(^|[\\s_-])frm([\\s_-]|$)/', $hay)) return 'Formidable Forms'; if (strpos($hay, 'ninja-forms') !== false || strpos($hay, 'nf-form') !== false) return 'Ninja Forms'; if (strpos($hay, 'woocommerce') !== false) return 'WooCommerce Form'; return 'HTML / Custom Form'; } private function form_region(DOMElement $form) { $node = $form; while ($node && $node instanceof DOMElement) { $tag = strtolower($node->tagName); $class = strtolower($node->getAttribute('class')); $id = strtolower($node->getAttribute('id')); if ($tag === 'header' || strpos($class, 'elementor-location-header') !== false || strpos($class, 'site-header') !== false || in_array($id, ['header','masthead'], true)) return 'header'; if ($tag === 'footer' || strpos($class, 'elementor-location-footer') !== false || strpos($class, 'site-footer') !== false || in_array($id, ['footer','colophon'], true)) return 'footer'; $node = $node->parentNode; } return 'content'; } private function form_analyse_element(DOMElement $form, $page_id, $page_title, $page_url, $index) { $type = $this->form_detect_type($form); $region = $this->form_region($form); $method = strtoupper(trim($form->getAttribute('method')) ?: 'GET'); $action_raw = trim($form->getAttribute('action')); $action = $this->form_absolute_url($action_raw, $page_url); $form_html = (string) $form->ownerDocument->saveHTML($form); $lower = strtolower($form_html . ' ' . $type . ' ' . $form->getAttribute('class') . ' ' . $form->getAttribute('id')); $booking = (bool) preg_match('/book|booking|appointment|reservation|schedule|calendar|preferred[ _-]?(date|time)|time[ _-]?slot|amelia|bookly|latepoint|simplybook|acuity|setmore/', $lower); $payment = (bool) preg_match('/payment|checkout|stripe|paypal|credit[\\s_-]*card|card[\\s_-]*number|woocommerce-checkout|authorize\\.net/', $lower); $captcha = (bool) preg_match('/recaptcha|hcaptcha|turnstile|captcha/', $lower); $file_upload = stripos($lower, 'type="file"') !== false || stripos($lower, "type='file'") !== false; $xpath = new DOMXPath($form->ownerDocument); $fields = $xpath->query('.//input[@name] | .//textarea[@name] | .//select[@name]', $form); $submit = $xpath->query('.//button[@type="submit"] | .//input[@type="submit"] | .//button[not(@type)]', $form); $field_count = $fields ? $fields->length : 0; $required_count = 0; if ($fields) { foreach ($fields as $field) { if ($field instanceof DOMElement && ($field->hasAttribute('required') || strtolower($field->getAttribute('aria-required')) === 'true')) $required_count++; } } $has_submit = $submit && $submit->length > 0; $same_origin = $action !== '' && $this->form_same_origin($action); $issues = []; if (!$has_submit) $issues[] = 'No submit button detected'; if ($field_count === 0) $issues[] = 'No named form fields detected'; if ($action === '') $issues[] = 'Form action is not testable'; if ($booking) $issues[] = 'Booking workflow — manual live test protected'; if ($payment) $issues[] = 'Payment workflow — automatic submission disabled'; if ($captcha) $issues[] = 'CAPTCHA detected — manual/browser test required'; if ($file_upload) $issues[] = 'File upload detected — automatic submission disabled'; if ($action && !$same_origin) $issues[] = 'External form action — automatic submission disabled'; if ($method !== 'POST') $issues[] = 'GET form — structure checked only'; $safe_test = $has_submit && $field_count > 0 && $method === 'POST' && $same_origin && !$booking && !$payment && !$captcha && !$file_upload; $status = (!$has_submit || $field_count === 0) ? 'broken' : ($safe_test ? 'ready' : 'review'); $label = trim($form->getAttribute('aria-label')); if ($label === '') $label = trim($form->getAttribute('name')); if ($label === '') $label = trim($form->getAttribute('id')); if ($label === '') $label = $booking ? 'Booking form' : ($payment ? 'Payment form' : $type); $fingerprint_bits = [$type, $region, $method, $action, $label, (string) $field_count]; $key = ($region === 'content' ? ('page-' . $page_id . '-' . $index) : $region) . '|' . md5(implode('|', $fingerprint_bits)); $location = $region === 'header' ? 'Header' : ($region === 'footer' ? 'Footer' : $page_title); return [ 'key' => $key, 'page_id' => (int) $page_id, 'page_title' => $page_title, 'page_url' => $page_url, 'edit_url' => get_edit_post_link($page_id, 'raw') ?: '', 'form_index' => (int) $index, 'label' => $label, 'type' => $type, 'region' => $region, 'location' => $location, 'method' => $method, 'action' => $action, 'fields' => (int) $field_count, 'required' => (int) $required_count, 'has_submit' => (bool) $has_submit, 'booking' => (bool) $booking, 'payment' => (bool) $payment, 'captcha' => (bool) $captcha, 'file_upload' => (bool) $file_upload, 'safe_test' => (bool) $safe_test, 'status' => $status, 'issues' => $issues, ]; } private function form_parse_page_regex($html, $page_id, $page_title, $page_url) { $items = []; if (!preg_match_all('~<form\b([^>]*)>(.*?)</form>~is', (string) $html, $matches, PREG_SET_ORDER)) return $items; $attr = function($attrs, $name) { if (preg_match("~\\b" . preg_quote($name, '~') . "\\s*=\\s*([\"'])(.*?)\\1~is", $attrs, $m)) return html_entity_decode($m[2], ENT_QUOTES, 'UTF-8'); if (preg_match('~\b' . preg_quote($name, '~') . '\s*=\s*([^\s>]+)~i', $attrs, $m)) return html_entity_decode(trim($m[1], "\"'"), ENT_QUOTES, 'UTF-8'); return ''; }; foreach ($matches as $index => $m) { $attrs = (string) ($m[1] ?? ''); $body = (string) ($m[2] ?? ''); $all = $attrs . ' ' . $body; $class = strtolower($attr($attrs, 'class')); $id = strtolower($attr($attrs, 'id')); $action_raw = $attr($attrs, 'action'); $action = $this->form_absolute_url($action_raw, $page_url); $method = strtoupper($attr($attrs, 'method') ?: 'GET'); $lower = strtolower($all); $type = 'HTML / Custom Form'; if (strpos($lower, 'wpcf7') !== false || strpos($lower, '_wpcf7') !== false) $type = 'Contact Form 7'; elseif (strpos($lower, 'wpforms') !== false) $type = 'WPForms'; elseif (strpos($lower, 'elementor-form') !== false || strpos($lower, 'elementor_form') !== false) $type = 'Elementor Form'; elseif (strpos($lower, 'gform_') !== false || strpos($lower, 'gravityforms') !== false) $type = 'Gravity Forms'; elseif (strpos($lower, 'fluentform') !== false || strpos($lower, 'frm-fluent-form') !== false) $type = 'Fluent Forms'; elseif (strpos($lower, 'formidable') !== false) $type = 'Formidable Forms'; $booking = (bool) preg_match('/book|booking|appointment|reservation|schedule|calendar|preferred[ _-]?(date|time)|time[ _-]?slot|amelia|bookly|latepoint|simplybook|acuity|setmore/', $lower); $payment = (bool) preg_match('/payment|checkout|stripe|paypal|credit[\s_-]*card|card[\s_-]*number|woocommerce-checkout|authorize\.net/', $lower); $captcha = (bool) preg_match('/recaptcha|hcaptcha|turnstile|captcha/', $lower); $file_upload = (bool) preg_match('~<input\b[^>]*type\s*=\s*(["\']?)file\1~i', $all); preg_match_all('~<(?:input|textarea|select)\b[^>]*\bname\s*=~i', $body, $fm); $field_count = count($fm[0]); preg_match_all('~<(?:input|textarea|select)\b[^>]*(?:\brequired\b|aria-required\s*=\s*(["\']?)true\1)~i', $body, $rm); $required_count = count($rm[0]); $has_submit = (bool) preg_match('~<(?:button\b[^>]*(?:type\s*=\s*(["\']?)submit\1)?|input\b[^>]*type\s*=\s*(["\']?)submit\2)~i', $body); $same_origin = $action !== '' && $this->form_same_origin($action); $issues = ['Limited scanner mode: PHP DOM extension is unavailable']; if (!$has_submit) $issues[] = 'No submit button detected'; if ($field_count === 0) $issues[] = 'No named form fields detected'; if ($booking) $issues[] = 'Booking workflow — manual live test protected'; if ($payment) $issues[] = 'Payment workflow — automatic submission disabled'; if ($captcha) $issues[] = 'CAPTCHA detected — manual/browser test required'; if ($file_upload) $issues[] = 'File upload detected — automatic submission disabled'; if ($action && !$same_origin) $issues[] = 'External form action — automatic submission disabled'; $label = $attr($attrs, 'aria-label') ?: ($attr($attrs, 'name') ?: ($id ?: ($booking ? 'Booking form' : $type))); $status = (!$has_submit || $field_count === 0) ? 'broken' : 'review'; $items[] = [ 'key' => 'page-' . (int)$page_id . '-' . (int)$index . '|' . md5($type . '|' . $action . '|' . $label), 'page_id'=>(int)$page_id,'page_title'=>$page_title,'page_url'=>$page_url,'edit_url'=>get_edit_post_link($page_id,'raw') ?: '', 'form_index'=>(int)$index,'label'=>$label,'type'=>$type,'region'=>'content','location'=>$page_title,'method'=>$method,'action'=>$action, 'fields'=>(int)$field_count,'required'=>(int)$required_count,'has_submit'=>(bool)$has_submit,'booking'=>(bool)$booking,'payment'=>(bool)$payment, 'captcha'=>(bool)$captcha,'file_upload'=>(bool)$file_upload,'safe_test'=>false,'status'=>$status,'issues'=>$issues, ]; } return $items; } private function form_parse_page($html, $page_id, $page_title, $page_url) { if (!class_exists('DOMDocument')) return $this->form_parse_page_regex($html, $page_id, $page_title, $page_url); $doc = new DOMDocument(); $previous = libxml_use_internal_errors(true); $loaded = $doc->loadHTML('<?xml encoding="utf-8" ?>' . (string) $html, LIBXML_HTML_NODEFDTD); libxml_clear_errors(); libxml_use_internal_errors($previous); if (!$loaded) return new WP_Error('parse_failed', 'Could not parse rendered page HTML.'); $forms = $doc->getElementsByTagName('form'); $items = []; $i = 0; foreach ($forms as $form) { if (!$form instanceof DOMElement) continue; $items[] = $this->form_analyse_element($form, $page_id, $page_title, $page_url, $i); $i++; } // Booking systems are frequently embedded as iframes and do not expose a // normal HTML form to WordPress. Surface those as protected manual checks. $iframes = $doc->getElementsByTagName('iframe'); foreach ($iframes as $iframe) { if (!$iframe instanceof DOMElement) continue; $src = trim($iframe->getAttribute('src')); $hay = strtolower($src . ' ' . $iframe->getAttribute('class') . ' ' . $iframe->getAttribute('title')); if (!preg_match('/calendly|acuity|squarespacescheduling|setmore|simplybook|booksy|booking|appointment|reservation|schedule/', $hay)) continue; $region = 'content'; $node = $iframe; while ($node && $node instanceof DOMElement) { if (strtolower($node->tagName) === 'header') { $region = 'header'; break; } if (strtolower($node->tagName) === 'footer') { $region = 'footer'; break; } $node = $node->parentNode; } $location = $region === 'header' ? 'Header' : ($region === 'footer' ? 'Footer' : $page_title); $key = ($region === 'content' ? ('page-' . $page_id . '-iframe-' . $i) : $region) . '|booking-widget|' . md5($src); $items[] = [ 'key' => $key, 'page_id' => (int) $page_id, 'page_title' => $page_title, 'page_url' => $page_url, 'edit_url' => get_edit_post_link($page_id, 'raw') ?: '', 'form_index' => -1, 'label' => 'Embedded booking widget', 'type' => 'Booking Widget', 'region' => $region, 'location' => $location, 'method' => 'EMBED', 'action' => $src, 'fields' => 0, 'required' => 0, 'has_submit' => true, 'booking' => true, 'payment' => false, 'captcha' => false, 'file_upload' => false, 'safe_test' => false, 'status' => 'review', 'issues' => ['Embedded booking workflow — open page for manual live test'], ]; $i++; } $xpath = new DOMXPath($doc); $widgets = $xpath->query('//*[@data-url]'); if ($widgets) { foreach ($widgets as $widget) { if (!$widget instanceof DOMElement) continue; $src = trim($widget->getAttribute('data-url')); $hay = strtolower($src . ' ' . $widget->getAttribute('class') . ' ' . $widget->getAttribute('id')); if (!preg_match('/calendly|acuity|squarespacescheduling|setmore|simplybook|booksy|booking|appointment|reservation|schedule/', $hay)) continue; $key = 'page-' . (int)$page_id . '-booking-data|' . md5($src); $items[] = [ 'key'=>$key,'page_id'=>(int)$page_id,'page_title'=>$page_title,'page_url'=>$page_url,'edit_url'=>get_edit_post_link($page_id,'raw') ?: '', 'form_index'=>-1,'label'=>'Embedded booking widget','type'=>'Booking Widget','region'=>'content','location'=>$page_title,'method'=>'EMBED','action'=>$src, 'fields'=>0,'required'=>0,'has_submit'=>true,'booking'=>true,'payment'=>false,'captcha'=>false,'file_upload'=>false,'safe_test'=>false,'status'=>'review', 'issues'=>['Booking widget loaded on the public page — open it for a manual end-to-end availability/submission test'], ]; } } return $items; } public function ajax_scan_forms() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); global $wpdb; $cursor = absint($_POST['cursor'] ?? 0); $limit = max(1, min(6, absint($_POST['limit'] ?? 3))); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data. $page_ids = $wpdb->get_col($wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID > %d AND post_type='page' AND post_status='publish' ORDER BY ID ASC LIMIT %d", $cursor, $limit )); $items = []; $errors = []; foreach ($page_ids as $raw_id) { $page_id = absint($raw_id); $title = trim((string) get_the_title($page_id)); if ($title === '') $title = 'Page #' . $page_id; $fetched = $this->form_fetch_page($page_id); if (is_wp_error($fetched)) { $errors[] = $title . ': ' . $fetched->get_error_message(); continue; } $parsed = $this->form_parse_page($fetched['html'], $page_id, $title, $fetched['url']); if (is_wp_error($parsed)) { $errors[] = $title . ': ' . $parsed->get_error_message(); continue; } foreach ($parsed as $item) $items[] = $item; } $next_cursor = $page_ids ? absint(end($page_ids)) : $cursor; $page_counts = wp_count_posts('page'); $total_pages = isset($page_counts->publish) ? (int) $page_counts->publish : 0; wp_send_json_success([ 'items' => $items, 'errors' => $errors, 'scanned' => count($page_ids), 'has_more' => count($page_ids) === $limit, 'next_cursor' => $next_cursor, 'total_pages' => $total_pages, ]); } private function form_value_for_field(DOMElement $field, $test_email) { $tag = strtolower($field->tagName); $name = strtolower($field->getAttribute('name')); $type = strtolower($field->getAttribute('type')); if ($tag === 'textarea') return 'Ahmad Developer Toolkit automated form health test. Please ignore this test submission.'; if ($tag === 'select') { foreach ($field->getElementsByTagName('option') as $option) { if (!$option instanceof DOMElement) continue; $value = $option->getAttribute('value'); if ($value !== '' && !$option->hasAttribute('disabled')) return $value; } return ''; } if ($type === 'email' || strpos($name, 'email') !== false) return $test_email; if ($type === 'tel' || strpos($name, 'phone') !== false || strpos($name, 'tel') !== false) return '(555) 555-0111'; if ($type === 'url' || strpos($name, 'website') !== false || strpos($name, 'url') !== false) return home_url('/'); if ($type === 'number') return $field->getAttribute('min') !== '' ? $field->getAttribute('min') : '1'; if ($type === 'date') return gmdate('Y-m-d', time() + 7 * DAY_IN_SECONDS); if ($type === 'time') return '10:00'; if (strpos($name, 'first') !== false) return 'Eleven'; if (strpos($name, 'last') !== false) return 'Keys'; if (strpos($name, 'name') !== false) return 'Ahmad Toolkit Test'; if (strpos($name, 'subject') !== false) return 'Toolkit Form Health Test'; if (strpos($name, 'message') !== false || strpos($name, 'comment') !== false) return 'Ahmad Developer Toolkit automated form health test. Please ignore this test submission.'; $value = $field->getAttribute('value'); if ($value !== '') return $value; return 'Ahmad Toolkit Test'; } private function form_build_submission(DOMElement $form, $test_email) { $xpath = new DOMXPath($form->ownerDocument); $fields = $xpath->query('.//input[@name] | .//textarea[@name] | .//select[@name]', $form); $body = []; if (!$fields) return $body; foreach ($fields as $field) { if (!$field instanceof DOMElement || $field->hasAttribute('disabled')) continue; $name = trim($field->getAttribute('name')); if ($name === '') continue; $tag = strtolower($field->tagName); $type = strtolower($field->getAttribute('type')); if (in_array($type, ['file','reset','button','image'], true)) continue; if ($type === 'submit') { if ($field->getAttribute('value') !== '') $body[$name] = $field->getAttribute('value'); continue; } if (in_array($type, ['checkbox','radio'], true)) { if (!$field->hasAttribute('checked') && !$field->hasAttribute('required')) continue; $body[$name] = $field->getAttribute('value') !== '' ? $field->getAttribute('value') : '1'; continue; } if ($type === 'hidden') { $body[$name] = $field->getAttribute('value'); continue; } $body[$name] = $this->form_value_for_field($field, $test_email); } return $body; } public function ajax_test_form() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); $page_id = absint($_POST['page_id'] ?? 0); $form_index = intval($_POST['form_index'] ?? -1); $test_email = sanitize_email(wp_unslash($_POST['test_email'] ?? '')); if (!$test_email) $test_email = sanitize_email((string) get_option('admin_email')); if (!$page_id || $form_index < 0 || get_post_type($page_id) !== 'page' || get_post_status($page_id) !== 'publish') { wp_send_json_error(['message' => 'This form can no longer be tested from the saved scan result.']); } $title = trim((string) get_the_title($page_id)); $fetched = $this->form_fetch_page($page_id); if (is_wp_error($fetched)) wp_send_json_error(['message' => $fetched->get_error_message()]); if (!class_exists('DOMDocument')) wp_send_json_error(['message' => 'PHP DOM extension is required.']); $doc = new DOMDocument(); $previous = libxml_use_internal_errors(true); $loaded = $doc->loadHTML('<?xml encoding="utf-8" ?>' . $fetched['html'], LIBXML_HTML_NODEFDTD); libxml_clear_errors(); libxml_use_internal_errors($previous); if (!$loaded) wp_send_json_error(['message' => 'Could not parse the current page.']); $forms = $doc->getElementsByTagName('form'); if ($form_index >= $forms->length) wp_send_json_error(['message' => 'The form changed since the scan. Scan again and retry.']); $form = $forms->item($form_index); if (!$form instanceof DOMElement) wp_send_json_error(['message' => 'Could not locate this form.']); $analysis = $this->form_analyse_element($form, $page_id, $title ?: ('Page #' . $page_id), $fetched['url'], $form_index); if (empty($analysis['safe_test'])) { wp_send_json_error(['message' => 'Automatic submission is protected for this form. Open the page and test it manually.']); } $body = $this->form_build_submission($form, $test_email); if (!$body) wp_send_json_error(['message' => 'No safe fields could be prepared for this form.']); $action = $analysis['action']; if (!$action || !$this->form_same_origin($action)) wp_send_json_error(['message' => 'Only same-site forms can be automatically tested.']); $request_action = $action; if (($analysis['type'] ?? '') === 'Contact Form 7' && !empty($body['_wpcf7'])) { $request_action = rest_url('contact-form-7/v1/contact-forms/' . absint($body['_wpcf7']) . '/feedback'); } elseif (($analysis['type'] ?? '') === 'Elementor Form') { // Elementor Pro forms normally submit through admin-ajax.php. Using // the same endpoint makes the health test far more representative // than POSTing the rendered Page URL. $request_action = admin_url('admin-ajax.php'); $body['action'] = 'elementor_pro_forms_send_form'; if (empty($body['referrer'])) $body['referrer'] = $fetched['url']; } $response = wp_remote_post($request_action, [ 'timeout' => 22, 'redirection' => 0, 'user-agent' => 'Toolkit Form Health Test/' . self::VERSION . '; ' . home_url('/'), 'headers' => [ 'Referer' => $fetched['url'], 'Cache-Control' => 'no-cache', 'X-11-Keys-Test' => '1', ], 'cookies' => wp_remote_retrieve_cookies($fetched['response']), 'body' => $body, ]); if (is_wp_error($response)) wp_send_json_error(['message' => 'Test request failed: ' . $response->get_error_message()]); $code = (int) wp_remote_retrieve_response_code($response); $raw_body = (string) wp_remote_retrieve_body($response); $json_body = json_decode($raw_body, true); if (($analysis['type'] ?? '') === 'Contact Form 7' && is_array($json_body)) { $cf7_status = sanitize_key((string) ($json_body['status'] ?? '')); $cf7_message = sanitize_text_field((string) ($json_body['message'] ?? '')); if (in_array($cf7_status, ['mail_sent','sent'], true)) { wp_send_json_success(['status'=>'passed','message'=>$cf7_message ?: 'Contact Form 7 accepted the test and reported mail sent. Verify inbox delivery separately.','http_code'=>$code]); } if ($cf7_status) { wp_send_json_error(['message'=>($cf7_message ?: 'Contact Form 7 returned ' . $cf7_status . '.') . ' Status: ' . $cf7_status . '.']); } } if (($analysis['type'] ?? '') === 'Elementor Form' && is_array($json_body) && array_key_exists('success', $json_body)) { $message = ''; if (isset($json_body['data']['message'])) $message = sanitize_text_field((string)$json_body['data']['message']); elseif (isset($json_body['data']) && is_string($json_body['data'])) $message = sanitize_text_field($json_body['data']); if (!empty($json_body['success'])) { wp_send_json_success(['status'=>'passed','message'=>$message ?: 'Elementor accepted the test submission. Verify the notification inbox separately.','http_code'=>$code]); } wp_send_json_error(['message'=>$message ?: 'Elementor rejected the test submission.']); } $resp_body = strtolower(wp_strip_all_tags($raw_body)); $location = (string) wp_remote_retrieve_header($response, 'location'); if ($code >= 300 && $code < 400) { wp_send_json_success([ 'status' => 'passed', 'message' => 'Submission was accepted and redirected' . ($location ? ' to ' . $location : '') . '.', 'http_code' => $code, ]); } if ($code < 200 || $code >= 400) { wp_send_json_error(['message' => 'Form returned HTTP ' . $code . ' during the test.']); } if (preg_match('/thank you|thanks for|successfully|submission received|message has been sent|we have received|your message was sent/', $resp_body)) { wp_send_json_success(['status' => 'passed', 'message' => 'Test submission accepted and a success response was detected.', 'http_code' => $code]); } if (preg_match('/validation error|invalid field|please fill|required field|captcha|spam detected|nonce|forbidden|submission failed|error occurred/', $resp_body)) { wp_send_json_error(['message' => 'The test reached the form but the response contained a validation/error message.']); } wp_send_json_success([ 'status' => 'unverified', 'message' => 'The server accepted the test request (HTTP ' . $code . '), but no clear success message or redirect was detected. Check the test email/entry or run a manual browser test.', 'http_code' => $code, ]); } public function ajax_rename() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); $id = absint($_POST['id'] ?? 0); $new = sanitize_file_name(wp_unslash($_POST['new_name'] ?? '')); $new = pathinfo($new, PATHINFO_FILENAME); if (!$id || !$new) wp_send_json_error(['message' => 'Missing image or filename.']); if (!current_user_can('edit_post', $id)) wp_send_json_error(['message' => 'You cannot edit this image.'], 403); $result = $this->rename_attachment($id, $new); if (is_wp_error($result)) wp_send_json_error(['message' => $result->get_error_message()]); wp_send_json_success($result); } private function rename_attachment($id, $new_base) { $old_file = get_attached_file($id); if (!$old_file || !file_exists($old_file)) return new WP_Error('missing', 'Image file does not exist.'); $dir = dirname($old_file); $ext = strtolower(pathinfo($old_file, PATHINFO_EXTENSION)); $new_base = sanitize_title($new_base); if (!$new_base) return new WP_Error('invalid', 'Invalid filename.'); $candidate = wp_unique_filename($dir, $new_base . '.' . $ext); $new_file = trailingslashit($dir) . $candidate; if ($new_file === $old_file) return new WP_Error('same', 'New filename is the same as the current filename.'); $old_url = wp_get_attachment_url($id); $old_meta = wp_get_attachment_metadata($id); $old_files = $this->metadata_files($old_file, $old_meta); if (!$this->filesystem_move($old_file, $new_file, false)) return new WP_Error('rename_failed', 'WordPress could not rename the original image.'); update_attached_file($id, $new_file); // Regenerate metadata/thumbnails under the new base name, then remove old generated sizes. require_once ABSPATH . 'wp-admin/includes/image.php'; $new_meta = wp_generate_attachment_metadata($id, $new_file); if (is_wp_error($new_meta) || empty($new_meta)) { $this->filesystem_move($new_file, $old_file, true); update_attached_file($id, $old_file); return new WP_Error('metadata_failed', 'Could not regenerate image metadata. Rename rolled back.'); } wp_update_attachment_metadata($id, $new_meta); foreach ($old_files as $old_generated) { if ($old_generated !== $old_file && file_exists($old_generated)) $this->filesystem_delete_file($old_generated); } $new_url = wp_get_attachment_url($id); $this->replace_references($old_url, $new_url); $this->replace_references(basename($old_file), basename($new_file)); clean_post_cache($id); $this->clear_elementor_cache(); return ['message' => 'Renamed successfully.', 'filename' => basename($new_file), 'url' => $new_url]; } public function ajax_convert_webp() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); $id = absint($_POST['id'] ?? 0); $delete_old = !empty($_POST['delete_old']); if (!$id) wp_send_json_error(['message' => 'Missing image.']); if (!current_user_can('edit_post', $id)) wp_send_json_error(['message' => 'You cannot edit this image.'], 403); $result = $this->convert_to_webp($id, $delete_old); if (is_wp_error($result)) wp_send_json_error(['message' => $result->get_error_message()]); wp_send_json_success($result); } private function convert_to_webp($id, $delete_old = false) { if (!wp_image_editor_supports(['mime_type' => 'image/webp'])) { return new WP_Error('no_webp', 'This server image library does not support WebP output.'); } $old_file = get_attached_file($id); if (!$old_file || !file_exists($old_file)) return new WP_Error('missing', 'Image file does not exist.'); $mime = get_post_mime_type($id); if (!in_array($mime, ['image/jpeg', 'image/png'], true)) return new WP_Error('mime', 'Only JPG/JPEG and PNG can be converted here.'); $dir = dirname($old_file); $base = pathinfo($old_file, PATHINFO_FILENAME); $webp_name = wp_unique_filename($dir, $base . '.webp'); $new_file = trailingslashit($dir) . $webp_name; $editor = wp_get_image_editor($old_file); if (is_wp_error($editor)) return $editor; $editor->set_quality(82); $saved = $editor->save($new_file, 'image/webp'); if (is_wp_error($saved)) return $saved; $old_url = wp_get_attachment_url($id); $old_meta = wp_get_attachment_metadata($id); $old_files = $this->metadata_files($old_file, $old_meta); update_attached_file($id, $new_file); wp_update_post(['ID' => $id, 'post_mime_type' => 'image/webp']); require_once ABSPATH . 'wp-admin/includes/image.php'; $new_meta = wp_generate_attachment_metadata($id, $new_file); if (empty($new_meta) || is_wp_error($new_meta)) { update_attached_file($id, $old_file); wp_update_post(['ID' => $id, 'post_mime_type' => $mime]); $this->filesystem_delete_file($new_file); return new WP_Error('metadata_failed', 'WebP was created but metadata failed. Conversion rolled back.'); } wp_update_attachment_metadata($id, $new_meta); $new_url = wp_get_attachment_url($id); $this->replace_references($old_url, $new_url); $this->replace_references(basename($old_file), basename($new_file)); if ($delete_old) { foreach ($old_files as $f) if (file_exists($f) && $f !== $new_file) $this->filesystem_delete_file($f); } clean_post_cache($id); $this->clear_elementor_cache(); return [ 'message' => 'Converted to WebP.', 'filename' => basename($new_file), 'size' => file_exists($new_file) ? size_format(filesize($new_file), 1) : '', 'url' => $new_url, ]; } public function ajax_compress() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); $id = absint($_POST['id'] ?? 0); $quality = max(40, min(95, absint($_POST['quality'] ?? 82))); if (!$id) wp_send_json_error(['message' => 'Missing image.']); if (!current_user_can('edit_post', $id)) wp_send_json_error(['message' => 'You cannot edit this image.'], 403); $result = $this->compress_attachment($id, $quality); if (is_wp_error($result)) wp_send_json_error(['message' => $result->get_error_message()]); wp_send_json_success($result); } private function compress_attachment($id, $quality) { $file = get_attached_file($id); if (!$file || !file_exists($file)) return new WP_Error('missing', 'Image file does not exist.'); $mime = get_post_mime_type($id); if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) { return new WP_Error('mime', 'Unsupported image type.'); } $before = (int) filesize($file); if ($before <= 0) return new WP_Error('empty', 'Image file is empty.'); $dir = dirname($file); $base = pathinfo($file, PATHINFO_FILENAME); $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); if (!$ext) { $ext = $mime === 'image/png' ? 'png' : ($mime === 'image/webp' ? 'webp' : 'jpg'); } // Keep a normal image extension. Some GD/Imagick adapters fail when the // temporary path ends in a custom non-image extension. $tmp_name = wp_unique_filename($dir, '.' . $base . '-ekmt-compressed.' . $ext); $tmp = trailingslashit($dir) . $tmp_name; $editor = wp_get_image_editor($file); if (is_wp_error($editor)) return $editor; $quality_set = $editor->set_quality($quality); if (is_wp_error($quality_set)) return $quality_set; $saved = $editor->save($tmp, $mime); if (is_wp_error($saved)) { $this->filesystem_delete_file($tmp); return new WP_Error('compress_save', 'Image editor could not create the compressed file: ' . $saved->get_error_message()); } if (!file_exists($tmp) || filesize($tmp) <= 0) { $this->filesystem_delete_file($tmp); return new WP_Error('compress_failed', 'Compressed file could not be created.'); } clearstatcache(true, $tmp); $candidate = (int) filesize($tmp); if ($candidate >= $before) { $this->filesystem_delete_file($tmp); return [ 'message' => 'Already optimized — this quality setting would not make the image smaller.', 'before' => size_format($before, 1), 'after' => size_format($before, 1), 'saved' => '0 B', ]; } // Create the backup only when we know there is a smaller replacement. $backup = $this->backup_file($file, $id); if (is_wp_error($backup)) { $this->filesystem_delete_file($tmp); return $backup; } $old_meta = wp_get_attachment_metadata($id); $old_files = $this->metadata_files($file, $old_meta); if (!$this->filesystem_move($tmp, $file, true)) { $this->filesystem_delete_file($tmp); return new WP_Error('replace_failed', 'WordPress could not replace the original image with the compressed version.'); } clearstatcache(true, $file); // Regenerate WordPress image sizes from the newly compressed master. // Do not delete old sizes first; that way a metadata failure does not // instantly remove thumbnails that the live site may still use. require_once ABSPATH . 'wp-admin/includes/image.php'; $new_meta = wp_generate_attachment_metadata($id, $file); if (is_wp_error($new_meta) || empty($new_meta)) { // Restore the original master from the backup. $this->filesystem_copy($backup, $file, true); clearstatcache(true, $file); return new WP_Error('metadata_failed', 'The master image compressed, but WordPress could not regenerate its image sizes. The original master was restored.'); } wp_update_attachment_metadata($id, $new_meta); // Remove only old generated files that are no longer part of the current metadata. $new_files = $this->metadata_files($file, $new_meta); $keep = array_map('wp_normalize_path', $new_files); foreach ($old_files as $old_generated) { if ($old_generated === $file || !file_exists($old_generated)) continue; if (!in_array(wp_normalize_path($old_generated), $keep, true)) $this->filesystem_delete_file($old_generated); } clean_post_cache($id); $this->clear_elementor_cache(); clearstatcache(true, $file); $after = (int) filesize($file); return [ 'message' => 'Compressed successfully.', 'before' => size_format($before, 1), 'after' => size_format($after, 1), 'saved' => size_format(max(0, $before - $after), 1), ]; } private function backup_file($file, $id) { $uploads = wp_upload_dir(); if (!empty($uploads['error'])) return new WP_Error('uploads', $uploads['error']); $dir = trailingslashit($uploads['basedir']) . self::BACKUP_DIR . '/' . $id; if (!wp_mkdir_p($dir)) return new WP_Error('backup_dir', 'Could not create backup directory.'); $dest = trailingslashit($dir) . gmdate('Ymd-His') . '-' . basename($file); if (!$this->filesystem_copy($file, $dest, false)) return new WP_Error('backup', 'WordPress could not create the image backup.'); return $dest; } private function metadata_files($original, $meta) { $files = [$original]; $dir = trailingslashit(dirname($original)); if (!empty($meta['sizes']) && is_array($meta['sizes'])) { foreach ($meta['sizes'] as $size) if (!empty($size['file'])) $files[] = $dir . $size['file']; } if (!empty($meta['original_image'])) $files[] = $dir . $meta['original_image']; return array_values(array_unique($files)); } private function replace_references($old, $new) { if (!$old || !$new || $old === $new) return; global $wpdb; // Post content. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Admin-triggered reference replacement intentionally performs a fresh direct content update. $wpdb->query($wpdb->prepare( "UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, %s, %s) WHERE post_content LIKE %s", $old, $new, '%' . $wpdb->esc_like($old) . '%' )); // Post meta including Elementor JSON. REPLACE preserves serialized string length only when not serialized; // Elementor data is JSON. Skip PHP-serialized values for safety. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-triggered reference replacement must search current metadata values. $rows = $wpdb->get_results($wpdb->prepare( "SELECT meta_id, meta_value FROM {$wpdb->postmeta} WHERE meta_value LIKE %s", '%' . $wpdb->esc_like($old) . '%' )); foreach ($rows as $row) { if (is_serialized($row->meta_value)) continue; $updated = str_replace($old, $new, $row->meta_value); if ($updated !== $row->meta_value) { update_metadata_by_mid('post', (int) $row->meta_id, $updated); } } } public function ajax_start_unused_scan() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); global $wpdb; $token = wp_generate_password(32, false, false); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data. $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} p WHERE p.post_type = %s AND p.post_status = %s AND p.post_mime_type LIKE %s AND NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} qm WHERE qm.post_id = p.ID AND qm.meta_key = %s )", 'attachment', 'inherit', 'image/%', self::QUARANTINE_META ) ); $state = [ 'token' => $token, 'status' => 'running', 'cursor' => 0, 'total' => $total, 'scanned' => 0, 'unused' => 0, 'unused_bytes' => 0, 'results' => [], 'started' => time(), 'updated' => time(), 'error' => '', ]; update_option(self::SCAN_STATE_OPTION, $state, false); if (!wp_next_scheduled('ekmt_unused_scan_cron', [$token])) { wp_schedule_single_event(time() + 30, 'ekmt_unused_scan_cron', [$token]); } $this->dispatch_unused_scan_worker($token); wp_send_json_success([ 'status' => 'running', 'total' => $total, 'scanned' => 0, 'unused' => 0, 'unused_bytes' => 0, ]); } public function ajax_unused_scan_status() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); $offset = max(0, absint($_POST['offset'] ?? 0)); $state = get_option(self::SCAN_STATE_OPTION, []); if (!is_array($state) || empty($state['token'])) { wp_send_json_success([ 'exists' => false, 'status' => 'idle', 'items' => [], 'next_offset' => 0, 'total_results' => 0, ]); } // If a host dropped the loopback worker, touching the status endpoint // nudges it back to life without restarting the scan. if (($state['status'] ?? '') === 'running' && (time() - (int) ($state['updated'] ?? 0)) > 12) { $this->dispatch_unused_scan_worker((string) $state['token']); } $results = !empty($state['results']) && is_array($state['results']) ? $state['results'] : []; $slice = array_slice($results, $offset, 40); $items = []; foreach ($slice as $entry) { $id = absint($entry['id'] ?? 0); if (!$id || get_post_meta($id, self::QUARANTINE_META, true)) continue; $file = get_attached_file($id); if (!$file || !file_exists($file)) continue; $items[] = $this->cleaner_item($id, (int) ($entry['bytes'] ?? 0), [ 'checked' => [ 'Page/post content', 'Elementor, WooCommerce, ACF & post meta', 'Theme/customizer/widget options', 'Taxonomy & user meta', 'Attachment IDs, URLs & generated image sizes', ], ]); } $next_offset = min(count($results), $offset + count($slice)); wp_send_json_success([ 'exists' => true, 'status' => $state['status'] ?? 'idle', 'total' => (int) ($state['total'] ?? 0), 'scanned' => (int) ($state['scanned'] ?? 0), 'unused' => (int) ($state['unused'] ?? 0), 'unused_bytes' => (int) ($state['unused_bytes'] ?? 0), 'error' => (string) ($state['error'] ?? ''), 'items' => $items, 'next_offset' => $next_offset, 'total_results' => count($results), 'updated' => (int) ($state['updated'] ?? 0), ]); } public function ajax_background_unused_worker() { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Loopback worker is authenticated by a high-entropy, server-stored scan token. $token = sanitize_text_field(wp_unslash($_POST['token'] ?? '')); if (!$token) wp_send_json_error(['message' => 'Missing scan token.'], 400); $state = get_option(self::SCAN_STATE_OPTION, []); if (!is_array($state) || empty($state['token']) || !hash_equals((string) $state['token'], $token)) { wp_send_json_error(['message' => 'Invalid or expired scan token.'], 403); } $this->process_background_unused_scan($token); wp_send_json_success(['message' => 'Background batch finished.']); } public function cron_unused_scan_worker($token) { $token = sanitize_text_field((string) $token); if (!$token) return; $this->process_background_unused_scan($token); } private function dispatch_unused_scan_worker($token) { $url = admin_url('admin-ajax.php'); wp_remote_post($url, [ 'timeout' => 0.01, 'blocking' => false, 'body' => [ 'action' => 'ekmt_background_unused_worker', 'token' => $token, ], ]); } private function process_background_unused_scan($token) { global $wpdb; $lock = 'ekmt_scan_lock_' . md5($token); $existing = get_option($lock, 0); if ($existing && (time() - (int) $existing) < 45) return; if ($existing) delete_option($lock); if (!add_option($lock, time(), '', false)) return; $continue_scan = false; try { $state = get_option(self::SCAN_STATE_OPTION, []); if (!is_array($state) || ($state['status'] ?? '') !== 'running' || empty($state['token']) || !hash_equals((string) $state['token'], (string) $token)) { return; } $cursor = absint($state['cursor'] ?? 0); $limit = 15; $image_mime_like = 'image/%'; if ($cursor > 0) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p WHERE p.post_type = %s AND p.post_status = %s AND p.post_mime_type LIKE %s AND NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} qm WHERE qm.post_id = p.ID AND qm.meta_key = %s ) AND p.ID < %d ORDER BY p.ID DESC LIMIT %d", 'attachment', 'inherit', $image_mime_like, self::QUARANTINE_META, $cursor, $limit ) ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p WHERE p.post_type = %s AND p.post_status = %s AND p.post_mime_type LIKE %s AND NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} qm WHERE qm.post_id = p.ID AND qm.meta_key = %s ) ORDER BY p.ID DESC LIMIT %d", 'attachment', 'inherit', $image_mime_like, self::QUARANTINE_META, $limit ) ); } $ids = array_map('intval', (array) $ids); if (!$ids) { $state['status'] = 'completed'; $state['updated'] = time(); update_option(self::SCAN_STATE_OPTION, $state, false); wp_clear_scheduled_hook('ekmt_unused_scan_cron', [$token]); return; } $usage_map = $this->detect_image_usage_batch($ids); if (is_wp_error($usage_map)) { $state['status'] = 'failed'; $state['error'] = $usage_map->get_error_message(); $state['updated'] = time(); update_option(self::SCAN_STATE_OPTION, $state, false); return; } foreach ($ids as $id) { $file = get_attached_file($id); if (!$file || !file_exists($file)) continue; $usage = $usage_map[$id] ?? ['used' => true]; if (!empty($usage['used'])) continue; $bytes = $this->attachment_total_bytes($id); $state['results'][] = ['id' => (int) $id, 'bytes' => (int) $bytes]; $state['unused'] = (int) ($state['unused'] ?? 0) + 1; $state['unused_bytes'] = (int) ($state['unused_bytes'] ?? 0) + $bytes; } $state['scanned'] = (int) ($state['scanned'] ?? 0) + count($ids); $state['cursor'] = min($ids); $state['updated'] = time(); if (count($ids) < $limit) { $state['status'] = 'completed'; wp_clear_scheduled_hook('ekmt_unused_scan_cron', [$token]); } update_option(self::SCAN_STATE_OPTION, $state, false); if (($state['status'] ?? '') === 'running') { // Schedule a fallback and dispatch the next loopback only after // the batch lock is released in finally below. $continue_scan = true; if (!wp_next_scheduled('ekmt_unused_scan_cron', [$token])) { wp_schedule_single_event(time() + 30, 'ekmt_unused_scan_cron', [$token]); } } } catch (Throwable $e) { $state = get_option(self::SCAN_STATE_OPTION, []); if (is_array($state) && ($state['token'] ?? '') === $token) { $state['status'] = 'failed'; $state['error'] = 'Background scan stopped: ' . $e->getMessage(); $state['updated'] = time(); update_option(self::SCAN_STATE_OPTION, $state, false); } } finally { delete_option($lock); } if ($continue_scan) { // Continue independently of whichever WordPress admin page the user visits. $this->dispatch_unused_scan_worker($token); } } /** * Scan Media Library images in small cursor-based batches. * * v1.1.1 uses one bulk reference pass per storage area for the whole batch instead * of repeating several full-table LIKE searches for every image. This is dramatically * faster on Elementor/WooCommerce sites and avoids common max_execution_time failures. */ public function ajax_scan_unused() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); global $wpdb; $cursor = absint($_POST['cursor'] ?? 0); $limit = max(5, min(25, absint($_POST['limit'] ?? 20))); $include_total = !empty($_POST['include_total']); $image_mime_like = 'image/%'; if ($cursor > 0) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p WHERE p.post_type = %s AND p.post_status = %s AND p.post_mime_type LIKE %s AND NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} qm WHERE qm.post_id = p.ID AND qm.meta_key = %s ) AND p.ID < %d ORDER BY p.ID DESC LIMIT %d", 'attachment', 'inherit', $image_mime_like, self::QUARANTINE_META, $cursor, $limit ) ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p WHERE p.post_type = %s AND p.post_status = %s AND p.post_mime_type LIKE %s AND NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} qm WHERE qm.post_id = p.ID AND qm.meta_key = %s ) ORDER BY p.ID DESC LIMIT %d", 'attachment', 'inherit', $image_mime_like, self::QUARANTINE_META, $limit ) ); } $ids = array_map('intval', (array) $ids); $total = null; if ($include_total) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data. $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} p WHERE p.post_type = %s AND p.post_status = %s AND p.post_mime_type LIKE %s AND NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} qm WHERE qm.post_id = p.ID AND qm.meta_key = %s )", 'attachment', 'inherit', $image_mime_like, self::QUARANTINE_META ) ); } if (!$ids) { wp_send_json_success([ 'items' => [], 'has_more' => false, 'next_cursor' => 0, 'total' => $total, 'scanned' => 0, 'unused' => 0, 'unused_bytes' => 0, 'batch_size' => $limit, ]); } $usage_map = $this->detect_image_usage_batch($ids); if (is_wp_error($usage_map)) { wp_send_json_error(['message' => $usage_map->get_error_message()], 500); } $items = []; $unused_bytes = 0; foreach ($ids as $id) { $file = get_attached_file($id); if (!$file || !file_exists($file)) continue; $usage = $usage_map[$id] ?? ['used' => true, 'reasons' => ['Could not complete the fast safety check.'], 'checked' => []]; if (!empty($usage['used'])) continue; $bytes = $this->attachment_total_bytes($id); $unused_bytes += $bytes; $items[] = $this->cleaner_item($id, $bytes, $usage); } $next_cursor = min($ids); wp_send_json_success([ 'items' => $items, 'has_more' => count($ids) === $limit, 'next_cursor' => $next_cursor, 'total' => $total, 'scanned' => count($ids), 'unused' => count($items), 'unused_bytes' => $unused_bytes, 'batch_size' => $limit, ]); } /** * Fast bulk detector used only for the gallery scan. * The quarantine action still calls detect_image_usage() for a slower, detailed, * per-image second check immediately before any file is moved. */ private function detect_image_usage_batch($ids) { global $wpdb; $ids = array_values(array_unique(array_filter(array_map('absint', (array) $ids)))); $checked = [ 'Page/post content', 'Elementor, WooCommerce, ACF & post meta', 'Theme/widget/plugin options', 'Taxonomy & user meta', 'Attachment IDs, URLs & generated image filenames', ]; $results = []; $infos = []; $sql_terms = []; foreach ($ids as $id) { $results[$id] = ['used' => false, 'reasons' => [], 'checked' => $checked]; $file = get_attached_file($id); if (!$file || !file_exists($file)) { $results[$id]['used'] = true; $results[$id]['reasons'][] = 'Original file is missing, so it is protected from cleanup.'; continue; } $meta = wp_get_attachment_metadata($id); $files = $this->metadata_files($file, $meta); $uploads = wp_upload_dir(); $file_needles = []; $relative_stem = ''; foreach ($files as $candidate) { $name = basename($candidate); if ($name) { $file_needles[] = $name; $sql_terms[] = $name; } if (empty($uploads['error']) && !empty($uploads['basedir']) && !empty($uploads['baseurl'])) { $norm_base = trailingslashit(wp_normalize_path($uploads['basedir'])); $norm_file = wp_normalize_path($candidate); if (strpos($norm_file, $norm_base) === 0) { $relative = ltrim(substr($norm_file, strlen($norm_base)), '/'); if ($relative) { $file_needles[] = $relative; $sql_terms[] = $relative; $file_needles[] = trailingslashit($uploads['baseurl']) . str_replace('%2F', '/', rawurlencode($relative)); } } } } if (!empty($meta['file']) && is_string($meta['file'])) { $relative_original = ltrim($meta['file'], '/'); if ($relative_original) { $relative_stem = preg_replace('/\.[^.\/]+$/', '', $relative_original); if ($relative_stem) $sql_terms[] = $relative_stem; } } $sql_terms[] = basename($file); $url = wp_get_attachment_url($id); if ($url) $file_needles[] = $url; $infos[$id] = [ 'file_needles' => array_values(array_unique(array_filter($file_needles, 'strlen'))), 'relative_stem' => $relative_stem, 'id' => $id, ]; } if (!$infos) return $results; $sql_terms = array_values(array_unique(array_filter($sql_terms, function($value) { return is_string($value) && strlen($value) >= 4; }))); $regex_parts = []; foreach ($sql_terms as $term) { // Keep the SQL regular expression bounded and escape regex metacharacters. $term = substr($term, 0, 512); if ($term !== '') $regex_parts[] = preg_quote($term, '/'); } $id_alt = implode('|', array_map('intval', array_keys($infos))); if ($id_alt !== '') { $regex_parts[] = '((wp-image-|attachment[_-])(' . $id_alt . ')([^0-9]|$)|"(id|image|attachment_id)"[[:space:]]*:[[:space:]]*"?(' . $id_alt . ')"?([^0-9]|$)|i:(' . $id_alt . ');|(^|,)[[:space:]]*(' . $id_alt . ')[[:space:]]*(,|$))'; } $reference_regex = $regex_parts ? '(' . implode('|', $regex_parts) . ')' : ''; if ($reference_regex === '') return $results; // Gutenberg/classic content and builder shortcodes. $wpdb->last_error = ''; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_type, post_title, post_content, post_excerpt FROM {$wpdb->posts} WHERE post_status NOT IN (%s, %s) AND (post_content REGEXP %s OR post_excerpt REGEXP %s)", 'trash', 'auto-draft', $reference_regex, $reference_regex ) ); if ($wpdb->last_error) return new WP_Error('scan_db', 'Fast scan database error while checking page content.'); foreach ((array) $rows as $row) { $this->map_batch_reference_text( $results, $infos, (string) $row->post_content . "\n" . (string) $row->post_excerpt, 'Content: ' . ($row->post_title ?: ('#' . $row->ID)) . ' (' . $row->post_type . ')', (int) $row->ID ); } // Elementor JSON, WooCommerce galleries, ACF and other post meta. $wpdb->last_error = ''; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, meta_key, meta_value FROM {$wpdb->postmeta} WHERE meta_value REGEXP %s", $reference_regex ) ); if ($wpdb->last_error) return new WP_Error('scan_db', 'Fast scan database error while checking post meta.'); foreach ((array) $rows as $row) { $this->map_batch_reference_text( $results, $infos, (string) $row->meta_value, 'Post meta: ' . $row->meta_key . ' on #' . $row->post_id, (int) $row->post_id ); } // Customizer/theme mods, widgets, logos and plugin settings. $transient_prefix = $wpdb->esc_like('_transient_') . '%'; $wpdb->last_error = ''; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name NOT LIKE %s AND option_value REGEXP %s", $transient_prefix, $reference_regex ) ); if ($wpdb->last_error) return new WP_Error('scan_db', 'Fast scan database error while checking theme and plugin options.'); foreach ((array) $rows as $row) { $this->map_batch_reference_text( $results, $infos, (string) $row->option_value, 'Option/theme setting: ' . $row->option_name, 0 ); } if (!empty($wpdb->termmeta)) { $wpdb->last_error = ''; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT term_id, meta_key, meta_value FROM {$wpdb->termmeta} WHERE meta_value REGEXP %s", $reference_regex ) ); if ($wpdb->last_error) return new WP_Error('scan_db', 'Fast scan database error while checking taxonomy meta.'); foreach ((array) $rows as $row) { $this->map_batch_reference_text( $results, $infos, (string) $row->meta_value, 'Term meta: ' . $row->meta_key . ' on term #' . $row->term_id, 0 ); } } if (!empty($wpdb->usermeta)) { $wpdb->last_error = ''; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only diagnostic scan requires fresh cross-table data. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT user_id, meta_key, meta_value FROM {$wpdb->usermeta} WHERE meta_value REGEXP %s", $reference_regex ) ); if ($wpdb->last_error) return new WP_Error('scan_db', 'Fast scan database error while checking user meta.'); foreach ((array) $rows as $row) { $this->map_batch_reference_text( $results, $infos, (string) $row->meta_value, 'User meta: ' . $row->meta_key . ' on user #' . $row->user_id, 0 ); } } foreach ($infos as $id => $info) { if (!empty($results[$id]['used'])) continue; if ((bool) apply_filters('ekmt_attachment_is_used', false, $id, $info['file_needles'])) { $results[$id]['used'] = true; $results[$id]['reasons'][] = 'Protected by a custom usage filter.'; } } return $results; } private function map_batch_reference_text(&$results, $infos, $text, $reason, $owner_post_id = 0) { if ($text === '') return; foreach ($infos as $id => $info) { if (!empty($results[$id]['used'])) continue; // Ignore an attachment's own post/meta row; its internal metadata is not site usage. if ($owner_post_id && (int) $owner_post_id === (int) $id) continue; if ($this->fast_text_references_attachment($text, $id, $info)) { $results[$id]['used'] = true; $results[$id]['reasons'][] = $reason; } } } private function fast_text_references_attachment($text, $id, $info) { foreach ((array) ($info['file_needles'] ?? []) as $needle) { if ($needle !== '' && stripos($text, $needle) !== false) return true; } // Relative stem catches original URLs and generated sizes such as -300x300 or -scaled. if (!empty($info['relative_stem']) && stripos($text, $info['relative_stem']) !== false) return true; $id = (int) $id; $trimmed = trim($text, " \t\n\r\0\x0B\"'"); if ($trimmed === (string) $id) return true; $patterns = [ '/wp-image-' . $id . '(?:[^0-9]|$)/i', '/attachment[_-]' . $id . '(?:[^0-9]|$)/i', '/["\'](?:id|image|attachment_id)["\']\s*:\s*["\']?' . $id . '["\']?(?:[^0-9]|$)/i', '/i:' . $id . ';/i', '/(?:^|,)\s*' . $id . '\s*(?:,|$)/', ]; foreach ($patterns as $pattern) if (preg_match($pattern, $text)) return true; return false; } private function cleaner_item($id, $bytes = null, $usage = []) { $file = get_attached_file($id); $bytes = $bytes === null ? $this->attachment_total_bytes($id) : $bytes; return [ 'id' => (int) $id, 'filename' => $file ? basename($file) : get_the_title($id), 'thumb' => wp_get_attachment_image_url($id, 'medium') ?: wp_get_attachment_url($id), 'size' => size_format($bytes, 1), 'bytes' => (int) $bytes, 'dimensions' => ($file && file_exists($file)) ? $this->dimensions($file) : '—', 'uploaded' => get_the_date('M j, Y', $id), 'confidence' => 'High confidence', 'checked' => !empty($usage['checked']) ? $usage['checked'] : [ 'Page/post content', 'Elementor & post meta', 'Theme/widget options', 'Taxonomy/user meta', 'Attachment IDs & generated sizes' ], ]; } /** * Detect references across common WordPress storage locations. * This intentionally over-matches some ID patterns to reduce deletion risk. */ private function detect_image_usage($id) { $id = absint($id); if (!$id) { return ['used' => true, 'reasons' => ['Invalid attachment ID.'], 'checked' => []]; } $map = $this->detect_image_usage_batch([$id]); if (is_wp_error($map)) { return [ 'used' => true, 'reasons' => ['The safety scan could not complete, so this image was protected from cleanup.'], 'checked' => [], ]; } return $map[$id] ?? [ 'used' => true, 'reasons' => ['The safety scan returned no result, so this image was protected from cleanup.'], 'checked' => [], ]; } private function like_group_sql($columns, $needles, &$args) { global $wpdb; $args = []; $parts = []; $needles = array_values(array_unique(array_filter($needles, 'strlen'))); foreach ($columns as $column) { foreach ($needles as $needle) { $parts[] = "{$column} LIKE %s"; $args[] = '%' . $wpdb->esc_like($needle) . '%'; } } return implode(' OR ', $parts); } private function attachment_total_bytes($id) { $file = get_attached_file($id); if (!$file) return 0; $meta = wp_get_attachment_metadata($id); $total = 0; foreach ($this->metadata_files($file, $meta) as $candidate) { if (file_exists($candidate) && is_file($candidate)) $total += (int) filesize($candidate); } return $total; } public function ajax_quarantine() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); $id = absint($_POST['id'] ?? 0); if (!$id || !current_user_can('delete_post', $id)) { wp_send_json_error(['message' => 'You cannot remove this attachment.'], 403); } if (get_post_meta($id, self::QUARANTINE_META, true)) { wp_send_json_error(['message' => 'Image is already quarantined.']); } // v1.3: quarantine is intentionally immediate. The expensive reference // analysis already happened during the unused-media scan. Quarantine is // reversible, so we do not repeat the full scan here. $result = $this->quarantine_attachment($id); if (is_wp_error($result)) wp_send_json_error(['message' => $result->get_error_message()]); $this->remove_quarantined_from_scan_state($id, (int) ($result['bytes'] ?? 0)); wp_send_json_success($result); } private function remove_quarantined_from_scan_state($id, $bytes = 0) { $state = get_option(self::SCAN_STATE_OPTION, []); if (!is_array($state) || empty($state['results']) || !is_array($state['results'])) return; $found = false; $new = []; foreach ($state['results'] as $entry) { if ((int) ($entry['id'] ?? 0) === (int) $id) { $found = true; if (!$bytes) $bytes = (int) ($entry['bytes'] ?? 0); continue; } $new[] = $entry; } if (!$found) return; $state['results'] = $new; $state['unused'] = max(0, (int) ($state['unused'] ?? 0) - 1); $state['unused_bytes'] = max(0, (int) ($state['unused_bytes'] ?? 0) - (int) $bytes); $state['updated'] = time(); update_option(self::SCAN_STATE_OPTION, $state, false); } private function quarantine_attachment($id) { $uploads = wp_upload_dir(); if (!empty($uploads['error'])) return new WP_Error('uploads', $uploads['error']); $basedir = wp_normalize_path($uploads['basedir']); $file = get_attached_file($id); if (!$file || !file_exists($file)) return new WP_Error('missing', 'Original image file does not exist.'); $meta = wp_get_attachment_metadata($id); $files = $this->metadata_files($file, $meta); $manifest = []; $moved = []; $bytes = 0; $qroot = trailingslashit($basedir) . self::QUARANTINE_DIR . '/' . $id; foreach ($files as $src) { if (!file_exists($src) || !is_file($src)) continue; $norm = wp_normalize_path($src); $prefix = trailingslashit($basedir); if (strpos($norm, $prefix) !== 0) { $this->rollback_quarantine_moves($moved); return new WP_Error('outside_uploads', 'A related file is outside the WordPress uploads directory, so Safe Mode stopped.'); } $relative = ltrim(substr($norm, strlen($prefix)), '/'); $dest = trailingslashit($qroot) . $relative; if (!wp_mkdir_p(dirname($dest))) { $this->rollback_quarantine_moves($moved); return new WP_Error('quarantine_dir', 'Could not create the quarantine directory.'); } if (!$this->move_file($src, $dest)) { $this->rollback_quarantine_moves($moved); return new WP_Error('quarantine_move', 'Could not move all image files to quarantine. Nothing was intentionally deleted.'); } $size = file_exists($dest) ? (int) filesize($dest) : 0; $bytes += $size; $moved[] = ['from' => $dest, 'to' => $src]; $manifest[] = ['relative' => $relative, 'bytes' => $size]; } if (!$manifest) return new WP_Error('nothing_moved', 'No image files were available to quarantine.'); update_post_meta($id, self::QUARANTINE_META, gmdate('Y-m-d H:i:s')); update_post_meta($id, self::QUARANTINE_FILES_META, $manifest); update_post_meta($id, self::QUARANTINE_BYTES_META, $bytes); clean_post_cache($id); $this->clear_elementor_cache(); return [ 'message' => 'Moved to quarantine. Test the website before permanent deletion.', 'id' => $id, 'size' => size_format($bytes, 1), 'bytes' => $bytes, ]; } private function rollback_quarantine_moves($moves) { foreach (array_reverse($moves) as $move) { if (!empty($move['from']) && !empty($move['to']) && file_exists($move['from'])) { if (!is_dir(dirname($move['to']))) wp_mkdir_p(dirname($move['to'])); $this->move_file($move['from'], $move['to']); } } } private function move_file($from, $to) { return $this->filesystem_move($from, $to, false); } public function ajax_get_quarantine() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); $q = new WP_Query([ 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_mime_type' => 'image', 'posts_per_page' => 200, 'orderby' => 'ID', 'order' => 'DESC', 'fields' => 'ids', 'no_found_rows' => true, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Admin-only quarantine list needs the quarantine marker. 'meta_query' => [[ 'key' => self::QUARANTINE_META, 'compare' => 'EXISTS', ]], ]); $uploads = wp_upload_dir(); $items = []; foreach ($q->posts as $id) { $manifest = get_post_meta($id, self::QUARANTINE_FILES_META, true); $bytes = (int) get_post_meta($id, self::QUARANTINE_BYTES_META, true); $thumb = ''; if (is_array($manifest) && $manifest && empty($uploads['error'])) { $first = reset($manifest); if (!empty($first['relative'])) { $thumb = trailingslashit($uploads['baseurl']) . self::QUARANTINE_DIR . '/' . $id . '/' . ltrim($first['relative'], '/'); } } $original = get_attached_file($id); $items[] = [ 'id' => (int) $id, 'filename' => $original ? basename($original) : get_the_title($id), 'thumb' => $thumb, 'size' => size_format($bytes, 1), 'bytes' => $bytes, 'quarantined' => get_post_meta($id, self::QUARANTINE_META, true), ]; } wp_send_json_success(['items' => $items]); } public function ajax_restore_quarantine() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); $id = absint($_POST['id'] ?? 0); if (!$id || !current_user_can('delete_post', $id)) wp_send_json_error(['message' => 'You cannot restore this attachment.'], 403); $result = $this->restore_quarantine($id); if (is_wp_error($result)) wp_send_json_error(['message' => $result->get_error_message()]); wp_send_json_success($result); } private function restore_quarantine($id) { $manifest = get_post_meta($id, self::QUARANTINE_FILES_META, true); if (!is_array($manifest) || !$manifest) return new WP_Error('not_quarantined', 'No quarantine manifest was found.'); $uploads = wp_upload_dir(); if (!empty($uploads['error'])) return new WP_Error('uploads', $uploads['error']); $basedir = wp_normalize_path($uploads['basedir']); $qroot = trailingslashit($basedir) . self::QUARANTINE_DIR . '/' . $id; // Refuse to overwrite any file that appeared after quarantine. foreach ($manifest as $entry) { if (empty($entry['relative'])) continue; $dest = trailingslashit($basedir) . ltrim($entry['relative'], '/'); if (file_exists($dest)) return new WP_Error('restore_conflict', 'Restore stopped because a file now exists at the original path: ' . basename($dest)); } $restored = []; foreach ($manifest as $entry) { if (empty($entry['relative'])) continue; $src = trailingslashit($qroot) . ltrim($entry['relative'], '/'); $dest = trailingslashit($basedir) . ltrim($entry['relative'], '/'); if (!file_exists($src)) continue; if (!wp_mkdir_p(dirname($dest)) || !$this->move_file($src, $dest)) { // Best-effort rollback back into quarantine. foreach (array_reverse($restored) as $move) $this->move_file($move['from'], $move['to']); return new WP_Error('restore_failed', 'Could not restore every quarantined file.'); } $restored[] = ['from' => $dest, 'to' => $src]; } if (!$restored) return new WP_Error('missing_quarantine', 'Quarantine files are missing.'); delete_post_meta($id, self::QUARANTINE_META); delete_post_meta($id, self::QUARANTINE_FILES_META); delete_post_meta($id, self::QUARANTINE_BYTES_META); $this->remove_empty_dirs($qroot); clean_post_cache($id); $this->clear_elementor_cache(); return ['message' => 'Image restored to its original upload path.', 'id' => $id]; } public function ajax_delete_quarantine() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); $id = absint($_POST['id'] ?? 0); if (!$id || !current_user_can('delete_post', $id)) wp_send_json_error(['message' => 'You cannot delete this attachment.'], 403); if (!get_post_meta($id, self::QUARANTINE_META, true)) wp_send_json_error(['message' => 'Only quarantined images can be permanently deleted.']); $uploads = wp_upload_dir(); $qroot = empty($uploads['error']) ? trailingslashit($uploads['basedir']) . self::QUARANTINE_DIR . '/' . $id : ''; $deleted = wp_delete_attachment($id, true); if (!$deleted) wp_send_json_error(['message' => 'WordPress could not delete the attachment record. Quarantine files were kept.']); if ($qroot) $this->delete_tree($qroot); wp_send_json_success(['message' => 'Permanently deleted from WordPress and quarantine.', 'id' => $id]); } private function delete_tree($dir) { if (!$dir || !is_dir($dir)) return; $fs = $this->wp_filesystem_instance(); if (is_wp_error($fs)) return; $fs->delete($dir, true, 'd'); } private function remove_empty_dirs($dir) { if (!$dir || !is_dir($dir)) return; $fs = $this->wp_filesystem_instance(); if (is_wp_error($fs)) return; $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST ); foreach ($iterator as $file) { if ($file->isDir()) { $fs->rmdir($file->getPathname(), false); } } $fs->rmdir($dir, false); } public function ajax_track_event() { if (!check_ajax_referer('ekmt_track_event', 'nonce', false)) wp_send_json_error(['message'=>'Invalid tracking request.'], 403); $event = sanitize_key(wp_unslash((string) ($_POST['event'] ?? ''))); if (!in_array($event, ['calls','whatsapp','sms','email','directions'], true)) wp_send_json_error(['message'=>'Invalid event.'], 400); $ip = sanitize_text_field(wp_unslash((string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown'))); $path = sanitize_text_field(wp_unslash($_POST['path'] ?? '')); $rate_key = 'ekmt_track_' . substr(hash('sha256', $ip . '|' . $event . '|' . $path), 0, 24); if (get_transient($rate_key)) wp_send_json_success(['counted'=>false]); set_transient($rate_key, 1, 2); $counted = $this->increment_tracking($event); wp_send_json_success(['counted'=>$counted]); } public function ajax_reset_tracking() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); if (!current_user_can('manage_options')) wp_send_json_error(['message'=>'Only an administrator can reset tracking.'], 403); $empty = ['calls'=>0,'forms'=>0,'whatsapp'=>0,'sms'=>0,'email'=>0,'directions'=>0]; update_option(self::TRACKING_OPTION, $empty, false); wp_send_json_success(['message'=>'All conversion counters were reset.', 'totals'=>$empty]); } public function ajax_save_tracking_settings() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); if (!current_user_can('manage_options')) wp_send_json_error(['message'=>'Only an administrator can change tracking settings.'], 403); $enabled = !empty($_POST['enabled']) ? 1 : 0; update_option(self::TRACKING_SETTINGS_OPTION, ['enabled'=>$enabled], false); wp_send_json_success(['message'=>$enabled ? 'Conversion tracking is enabled.' : 'Conversion tracking is disabled.', 'enabled'=>$enabled]); } public function ajax_save_login_settings() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); if (!current_user_can('manage_options')) wp_send_json_error(['message'=>'Only an administrator can change the login URL.'], 403); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Converted to a safe slug with sanitize_title() on the next line. $raw = trim((string) wp_unslash($_POST['slug'] ?? '')); $slug = sanitize_title($raw); $hide = !empty($_POST['hide_default']) ? 1 : 0; if ($raw !== '' && (strlen($slug) < 3 || in_array($slug, ['wp-admin','wp-login','login','admin','dashboard','feed','wp-json'], true))) { wp_send_json_error(['message'=>'Choose a different login slug with at least 3 characters.'], 422); } if ($hide && $slug === '') wp_send_json_error(['message'=>'Set a custom login slug before hiding wp-login.php.'], 422); update_option(self::LOGIN_SETTINGS_OPTION, ['slug'=>$slug,'hide_default'=>$hide], false); delete_option('rewrite_rules'); wp_send_json_success([ 'message'=>$slug ? 'Custom login URL saved. Test it in a private/incognito window before relying on it.' : 'Custom login URL disabled.', 'slug'=>$slug, 'url'=>$slug ? home_url('/' . $slug . '/') : '', 'hide_default'=>$hide, ]); } public function register_custom_login_rewrite() { $settings = $this->login_settings(); if (empty($settings['slug'])) return; add_rewrite_rule('^' . preg_quote($settings['slug'], '#') . '/?$', 'index.php?ekmt_login=1', 'top'); } public function custom_login_query_vars($vars) { $vars[] = 'ekmt_login'; return $vars; } public function maybe_render_custom_login() { $settings = $this->login_settings(); if (empty($settings['slug'])) return; $is_custom = ((string) get_query_var('ekmt_login') === '1'); $request_uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : ''; if (!$is_custom && $request_uri !== '') { $request_path = trim(sanitize_text_field((string) wp_parse_url($request_uri, PHP_URL_PATH)), '/'); $home_path = trim((string) wp_parse_url(home_url('/'), PHP_URL_PATH), '/'); if ($home_path && strpos($request_path, $home_path . '/') === 0) $request_path = substr($request_path, strlen($home_path) + 1); $is_custom = ($request_path === trim($settings['slug'], '/')); } if (!$is_custom) return; if (!defined('EKMT_CUSTOM_LOGIN_ENTRY')) define('EKMT_CUSTOM_LOGIN_ENTRY', true); // wp-login.php is normally executed as a top-level WordPress entry file. // Because our custom slug loads it from this callback scope, initialize the // variables core login templates may expect before including the file. // This prevents PHP 8+ undefined-variable warnings on login/register views. $user_login = ''; $error = ''; $errors = new WP_Error(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only compatibility with core wp-login.php request parameters. $redirect_to = isset($_REQUEST['redirect_to']) ? esc_url_raw(wp_unslash($_REQUEST['redirect_to'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only compatibility with core wp-login.php request parameters. $interim_login = isset($_REQUEST['interim-login']); global $pagenow; $pagenow = 'wp-login.php'; require ABSPATH . 'wp-login.php'; exit; } private function replace_wp_login_url($url) { $settings = $this->login_settings(); if (empty($settings['slug']) || strpos($url, 'wp-login.php') === false) return $url; $parts = wp_parse_url($url); $query = []; if (!empty($parts['query'])) parse_str($parts['query'], $query); return $this->custom_login_url($query); } public function filter_login_site_url($url, $path, $scheme, $blog_id) { return $this->replace_wp_login_url($url); } public function filter_login_network_site_url($url, $path, $scheme) { return $this->replace_wp_login_url($url); } public function maybe_block_default_login() { if (defined('EKMT_CUSTOM_LOGIN_ENTRY')) return; $settings = $this->login_settings(); if (empty($settings['slug']) || empty($settings['hide_default'])) return; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only core login routing parameter. $action = sanitize_key(wp_unslash((string) ($_REQUEST['action'] ?? 'login'))); if ($action === 'postpass') return; status_header(404); nocache_headers(); wp_die('Login page not found.', 'Not Found', ['response'=>404]); } public function page_duplicate_action($actions, $post) { if (!$post instanceof WP_Post || $post->post_type !== 'page' || !current_user_can('edit_page', $post->ID)) return $actions; $url = wp_nonce_url(admin_url('admin-post.php?action=ekmt_duplicate_page&post=' . absint($post->ID)), 'ekmt_duplicate_page_' . $post->ID); $actions['ekmt_duplicate_page'] = '<a href="' . esc_url($url) . '" aria-label="Duplicate this page as a draft">Duplicate Page</a>'; return $actions; } public function handle_duplicate_page() { $post_id = absint(wp_unslash($_GET['post'] ?? 0)); if (!$post_id || !current_user_can('edit_page', $post_id)) wp_die('You cannot duplicate this page.', 'Permission denied', ['response'=>403]); check_admin_referer('ekmt_duplicate_page_' . $post_id); $post = get_post($post_id); if (!$post || $post->post_type !== 'page') wp_die('Page not found.', 'Error', ['response'=>404]); $new_id = wp_insert_post([ 'post_type'=>'page', 'post_status'=>'draft', 'post_title'=>$post->post_title . ' — Copy', 'post_content'=>$post->post_content, 'post_excerpt'=>$post->post_excerpt, 'post_author'=>get_current_user_id(), 'post_parent'=>$post->post_parent, 'menu_order'=>$post->menu_order, 'comment_status'=>$post->comment_status, 'ping_status'=>$post->ping_status, ], true); if (is_wp_error($new_id)) wp_die(esc_html($new_id->get_error_message()), 'Duplicate failed', ['response'=>500]); $taxonomies = get_object_taxonomies('page'); foreach ($taxonomies as $taxonomy) { $terms = wp_get_object_terms($post_id, $taxonomy, ['fields'=>'ids']); if (!is_wp_error($terms)) wp_set_object_terms($new_id, $terms, $taxonomy, false); } $skip = ['_edit_lock','_edit_last','_wp_old_slug']; $meta = get_post_meta($post_id); foreach ($meta as $key => $values) { if (in_array($key, $skip, true)) continue; foreach ($values as $value) add_post_meta($new_id, $key, maybe_unserialize($value)); } clean_post_cache($new_id); wp_safe_redirect(add_query_arg(['post_type'=>'page','ekmt_duplicated'=>$new_id], admin_url('edit.php'))); exit; } public function duplicate_page_notice() { $screen = function_exists('get_current_screen') ? get_current_screen() : null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin notice query arg created after a nonce-protected action. if (empty($_GET['ekmt_duplicated']) || !$screen || $screen->id !== 'edit-page') return; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin notice query arg created after a nonce-protected action. $id = absint(wp_unslash($_GET['ekmt_duplicated'])); if (!$id) return; echo '<div class="notice notice-success is-dismissible"><p><strong>Ahmad Developer Toolkit:</strong> Page duplicated successfully as a Draft. <a href="' . esc_url(get_edit_post_link($id)) . '">Edit duplicate</a></p></div>'; } public function ajax_save_site_widgets() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); if (!current_user_can('manage_options')) wp_send_json_error(['message' => 'You do not have permission to manage website widgets.'], 403); $old = $this->site_widgets_settings(); $channels = $this->site_widget_channels(); $data = [ 'floating_enabled' => !empty($_POST['floating_enabled']) ? 1 : 0, 'floating_label' => sanitize_text_field(wp_unslash($_POST['floating_label'] ?? 'Contact Us')), 'floating_position' => (($floating_position = sanitize_key(wp_unslash($_POST['floating_position'] ?? 'right'))) && in_array($floating_position, ['left','right'], true)) ? $floating_position : 'right', 'announcement_enabled' => !empty($_POST['announcement_enabled']) ? 1 : 0, 'announcement_text' => sanitize_text_field(wp_unslash($_POST['announcement_text'] ?? '')), 'announcement_button_text' => sanitize_text_field(wp_unslash($_POST['announcement_button_text'] ?? '')), 'announcement_button_url' => esc_url_raw(wp_unslash($_POST['announcement_button_url'] ?? '')), 'announcement_position' => (($announcement_position = sanitize_key(wp_unslash($_POST['announcement_position'] ?? 'top'))) && in_array($announcement_position, ['top','bottom'], true)) ? $announcement_position : 'top', 'announcement_dismissible' => sanitize_key(wp_unslash($_POST['announcement_dismissible'] ?? '1')) === '0' ? '0' : '1', 'announcement_bg' => sanitize_hex_color(wp_unslash($_POST['announcement_bg'] ?? '')) ?: '#111827', 'announcement_color' => sanitize_hex_color(wp_unslash($_POST['announcement_color'] ?? '')) ?: '#ffffff', 'announcement_button_bg' => sanitize_hex_color(wp_unslash($_POST['announcement_button_bg'] ?? '')) ?: '#1667ff', 'trust_enabled' => !empty($_POST['trust_enabled']) ? 1 : 0, 'trust_style' => (($trust_style = sanitize_key(wp_unslash($_POST['trust_style'] ?? 'cards'))) && in_array($trust_style, ['cards','minimal','strip'], true)) ? $trust_style : 'cards', 'trust_columns' => in_array(sanitize_key(wp_unslash((string)($_POST['trust_columns'] ?? '3'))), ['3','4','6'], true) ? sanitize_key(wp_unslash((string)($_POST['trust_columns'] ?? '3'))) : '3', 'trust_accent' => sanitize_hex_color(wp_unslash($_POST['trust_accent'] ?? '')) ?: '#1667ff', 'trust_bg' => sanitize_hex_color(wp_unslash($_POST['trust_bg'] ?? '')) ?: '#ffffff', 'trust_color' => sanitize_hex_color(wp_unslash($_POST['trust_color'] ?? '')) ?: '#161616', 'popup_enabled' => !empty($_POST['popup_enabled']) ? 1 : 0, 'popup_type' => (($popup_type = sanitize_key(wp_unslash($_POST['popup_type'] ?? 'form'))) && in_array($popup_type, ['form','cta'], true)) ? $popup_type : 'form', 'popup_title' => sanitize_text_field(wp_unslash($_POST['popup_title'] ?? '')), 'popup_description' => sanitize_textarea_field(wp_unslash($_POST['popup_description'] ?? '')), 'popup_form' => sanitize_key(wp_unslash($_POST['popup_form'] ?? 'contact')), 'popup_button_text' => sanitize_text_field(wp_unslash($_POST['popup_button_text'] ?? '')), 'popup_button_url' => esc_url_raw(wp_unslash($_POST['popup_button_url'] ?? '')), 'popup_trigger' => (($popup_trigger = sanitize_key(wp_unslash($_POST['popup_trigger'] ?? 'delay'))) && in_array($popup_trigger, ['delay','scroll','exit'], true)) ? $popup_trigger : 'delay', 'popup_delay' => max(1, min(120, absint($_POST['popup_delay'] ?? 5))), 'popup_scroll' => max(10, min(95, absint($_POST['popup_scroll'] ?? 50))), 'popup_frequency' => (($popup_frequency = sanitize_key(wp_unslash($_POST['popup_frequency'] ?? 'session'))) && in_array($popup_frequency, ['session','always'], true)) ? $popup_frequency : 'session', 'popup_theme' => (($popup_theme = sanitize_key(wp_unslash($_POST['popup_theme'] ?? 'light'))) && in_array($popup_theme, ['light','dark'], true)) ? $popup_theme : 'light', 'popup_position' => (($popup_position = sanitize_key(wp_unslash($_POST['popup_position'] ?? 'center'))) && in_array($popup_position, ['center','bottom-right'], true)) ? $popup_position : 'center', ]; $valid_trust_icons = array_keys($this->trust_badge_icon_choices()); for ($i=1; $i<=6; $i++) { $data['trust_'.$i.'_enabled'] = !empty($_POST['trust_'.$i.'_enabled']) ? 1 : 0; $icon = sanitize_key(wp_unslash($_POST['trust_'.$i.'_icon'] ?? 'shield')); $data['trust_'.$i.'_icon'] = in_array($icon, $valid_trust_icons, true) ? $icon : 'shield'; $data['trust_'.$i.'_title'] = sanitize_text_field(wp_unslash($_POST['trust_'.$i.'_title'] ?? '')); $data['trust_'.$i.'_text'] = sanitize_text_field(wp_unslash($_POST['trust_'.$i.'_text'] ?? '')); } foreach ($channels as $key => $channel) { $data[$key . '_enabled'] = !empty($_POST[$key . '_enabled']) ? 1 : 0; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized immediately below according to the channel type. $raw = wp_unslash($_POST[$key] ?? ''); if ($key === 'email') $data[$key] = sanitize_email($raw); elseif ($key === 'form') $data[$key] = sanitize_key($raw); else $data[$key] = sanitize_text_field($raw); } if ($data['email_enabled'] && $data['email'] && !is_email($data['email'])) wp_send_json_error(['message' => 'Please enter a valid email address for the floating widget.'], 422); if ($data['form_enabled'] && $data['form'] && !array_key_exists($data['form'], $this->lead_form_definitions())) wp_send_json_error(['message' => 'Please choose a valid Toolkit Form.'], 422); if ($data['popup_enabled'] && $data['popup_type'] === 'form' && !array_key_exists($data['popup_form'], $this->lead_form_definitions())) wp_send_json_error(['message' => 'Please choose a valid form for the popup.'], 422); update_option(self::SITE_WIDGETS_OPTION, $data, false); wp_send_json_success(['message' => 'Website widgets saved. Open the website in a new tab to see the live result.']); } public function render_site_widgets() { if (is_admin()) return; $s = $this->site_widgets_settings(); if (empty($s['floating_enabled']) && empty($s['announcement_enabled']) && empty($s['popup_enabled'])) return; if (!empty($s['announcement_enabled']) && trim($s['announcement_text']) !== '') { $style = '--ekmt-ann-bg:' . esc_attr($s['announcement_bg']) . ';--ekmt-ann-color:' . esc_attr($s['announcement_color']) . ';--ekmt-ann-btn:' . esc_attr($s['announcement_button_bg']) . ';'; echo '<div class="ekmt-announcement ekmt-announcement-' . esc_attr($s['announcement_position']) . '" style="' . esc_attr($style) . '" data-ekmt-announcement><div class="ekmt-announcement-inner"><span class="ekmt-announcement-text">' . esc_html($s['announcement_text']) . '</span>'; if (!empty($s['announcement_button_text']) && !empty($s['announcement_button_url'])) echo '<a class="ekmt-announcement-button" href="' . esc_url($s['announcement_button_url']) . '">' . esc_html($s['announcement_button_text']) . '</a>'; if ($s['announcement_dismissible'] === '1') echo '<button type="button" class="ekmt-announcement-close" aria-label="Close announcement" data-ekmt-ann-close>×</button>'; echo '</div></div>'; } if (!empty($s['floating_enabled'])) { $items = []; foreach ($this->site_widget_channels() as $key => $channel) { if (empty($s[$key . '_enabled']) || empty($s[$key])) continue; if ($key === 'form') { if (array_key_exists($s[$key], $this->lead_form_definitions())) $items[] = ['key'=>$key,'label'=>$channel['label'],'url'=>'']; continue; } $url = $this->contact_channel_url($key, $s[$key]); if ($url) $items[] = ['key'=>$key,'label'=>$channel['label'],'url'=>$url]; } if ($items) { echo '<div class="ekmt-floating-contact ekmt-floating-' . esc_attr($s['floating_position']) . '" data-ekmt-floating><div class="ekmt-floating-menu" data-ekmt-floating-menu>'; foreach ($items as $item) { if ($item['key'] === 'form') echo '<button type="button" class="ekmt-floating-item ekmt-floating-form-open" data-ekmt-open-form><span class="ekmt-floating-icon">' . wp_kses($this->contact_icon_svg('form'), $this->svg_allowed_html()) . '</span><span>' . esc_html($item['label']) . '</span></button>'; else echo '<a class="ekmt-floating-item" href="' . esc_url($item['url']) . '"' . (in_array($item['key'], ['facebook','map','instagram','whatsapp'], true) ? ' target="_blank" rel="noopener"' : '') . '><span class="ekmt-floating-icon">' . wp_kses($this->contact_icon_svg($item['key']), $this->svg_allowed_html()) . '</span><span>' . esc_html($item['label']) . '</span></a>'; } echo '</div><button type="button" class="ekmt-floating-launcher" data-ekmt-floating-toggle aria-expanded="false"><span class="ekmt-floating-launcher-icon">' . wp_kses($this->contact_icon_svg('sms'), $this->svg_allowed_html()) . '</span><span class="ekmt-floating-launcher-label">' . esc_html($s['floating_label'] ?: 'Contact Us') . '</span></button></div>'; if (!empty($s['form_enabled']) && !empty($s['form']) && array_key_exists($s['form'], $this->lead_form_definitions())) { echo '<div class="ekmt-floating-modal" data-ekmt-floating-modal aria-hidden="true"><div class="ekmt-floating-modal-backdrop" data-ekmt-close-form></div><div class="ekmt-floating-modal-card"><button type="button" class="ekmt-floating-modal-close" data-ekmt-close-form aria-label="Close">×</button>' . do_shortcode('[ahmad_toolkit_form id="' . esc_attr($s['form']) . '"]') . '</div></div>'; } } } if (!empty($s['popup_enabled'])) { $popup_type = in_array($s['popup_type'], ['form','cta'], true) ? $s['popup_type'] : 'form'; $trigger = in_array($s['popup_trigger'], ['delay','scroll','exit'], true) ? $s['popup_trigger'] : 'delay'; $frequency = $s['popup_frequency'] === 'always' ? 'always' : 'session'; $theme = $s['popup_theme'] === 'dark' ? 'dark' : 'light'; $position = $s['popup_position'] === 'bottom-right' ? 'bottom-right' : 'center'; echo '<div class="ekmt-site-popup ekmt-site-popup-' . esc_attr($theme) . ' ekmt-site-popup-' . esc_attr($position) . '" data-ekmt-site-popup data-trigger="' . esc_attr($trigger) . '" data-delay="' . esc_attr((int) $s['popup_delay']) . '" data-scroll="' . esc_attr((int) $s['popup_scroll']) . '" data-frequency="' . esc_attr($frequency) . '" aria-hidden="true">'; echo '<div class="ekmt-site-popup-backdrop" data-ekmt-popup-close></div><div class="ekmt-site-popup-card" role="dialog" aria-modal="true" aria-label="Website popup"><button type="button" class="ekmt-site-popup-close" data-ekmt-popup-close aria-label="Close">×</button>'; echo '<div class="ekmt-site-popup-copy"><span class="ekmt-site-popup-kicker">' . ($popup_type === 'form' ? 'CONTACT US' : 'NOTICE') . '</span>'; if (!empty($s['popup_title'])) echo '<h2>' . esc_html($s['popup_title']) . '</h2>'; if (!empty($s['popup_description'])) echo '<p>' . nl2br(esc_html($s['popup_description'])) . '</p>'; echo '</div>'; if ($popup_type === 'form' && !empty($s['popup_form']) && array_key_exists($s['popup_form'], $this->lead_form_definitions())) { echo '<div class="ekmt-site-popup-form">' . do_shortcode('[ahmad_toolkit_form id="' . esc_attr($s['popup_form']) . '" title=""]') . '</div>'; } elseif (!empty($s['popup_button_text']) && !empty($s['popup_button_url'])) { echo '<a class="ekmt-site-popup-cta" href="' . esc_url($s['popup_button_url']) . '">' . esc_html($s['popup_button_text']) . '</a>'; } echo '</div></div>'; } } public function ajax_save_maintenance_mode() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); if (!current_user_can('manage_options')) wp_send_json_error(['message' => 'You do not have permission to manage live maintenance mode.'], 403); $mode = sanitize_key(wp_unslash((string) ($_POST['mode'] ?? 'off'))); if (!in_array($mode, ['off', 'maintenance', 'expired'], true)) wp_send_json_error(['message' => 'Invalid mode selected.'], 400); if ($mode === 'off') { update_option(self::MAINTENANCE_MODE_OPTION, 'off', false); wp_send_json_success([ 'mode' => 'off', 'label' => $this->maintenance_mode_label('off'), 'help' => $this->maintenance_mode_help('off'), 'message' => 'The live override page has been disabled.', ]); } $defaults = $this->maintenance_defaults($mode); $title = sanitize_text_field(wp_unslash((string) ($_POST['title'] ?? ''))); $description = sanitize_textarea_field(wp_unslash((string) ($_POST['description'] ?? ''))); $icon = sanitize_key(wp_unslash((string) ($_POST['icon'] ?? $defaults['icon']))); if (!array_key_exists($icon, $this->maintenance_icon_choices())) $icon = $defaults['icon']; $settings = [ 'title' => $title !== '' ? $title : $defaults['title'], 'description' => $description !== '' ? $description : $defaults['description'], 'icon' => $icon, ]; update_option(self::MAINTENANCE_SETTINGS_OPTION, $settings, false); update_option(self::MAINTENANCE_MODE_OPTION, $mode, false); wp_send_json_success([ 'mode' => $mode, 'label' => $this->maintenance_mode_label($mode), 'help' => $this->maintenance_mode_help($mode), 'message' => $mode === 'maintenance' ? 'The maintenance page is now active for visitors.' : 'The hosting notice page is now active for visitors.', 'settings' => $settings, 'symbol' => $this->maintenance_icon_symbol($settings['icon']), ]); } public function maybe_render_maintenance_page() { if (is_admin() || wp_doing_ajax()) return; if ((defined('REST_REQUEST') && REST_REQUEST) || (defined('XMLRPC_REQUEST') && XMLRPC_REQUEST)) return; $mode = $this->maintenance_mode(); if ($mode === 'off') return; if (is_user_logged_in() && current_user_can('manage_options')) return; if ($mode === 'maintenance') { status_header(503); header('Retry-After: 3600'); } else { status_header(200); } nocache_headers(); header('X-Robots-Tag: noindex, nofollow', true); $site = wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES); $settings = $this->maintenance_settings($mode); $title = $settings['title']; $description = $settings['description']; $symbol = $this->maintenance_icon_symbol($settings['icon']); $badge = $mode === 'expired' ? 'Hosting Notice' : 'Maintenance Mode'; $html_lang = get_bloginfo('language'); $html_dir = is_rtl() ? 'rtl' : 'ltr'; wp_enqueue_style( 'ahmad-developer-toolkit-maintenance', plugin_dir_url(__FILE__) . 'assets/maintenance.css', [], self::VERSION ); echo '<!doctype html><html lang="' . esc_attr($html_lang) . '" dir="' . esc_attr($html_dir) . '"><head><meta charset="' . esc_attr(get_bloginfo('charset')) . '"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex,nofollow"><title>' . esc_html($site) . ''; wp_print_styles(['ahmad-developer-toolkit-maintenance']); echo '
' . esc_html($symbol) . '
' . esc_html($badge) . '

' . esc_html($title) . '

' . esc_html($description) . '

' . esc_html($site) . '
'; exit; } public function page_note_columns($columns) { $new = []; foreach ($columns as $key => $label) { $new[$key] = $label; if ($key === 'title') $new['ekmt_admin_note'] = 'Toolkit Note'; } if (!isset($new['ekmt_admin_note'])) $new['ekmt_admin_note'] = 'Toolkit Note'; return $new; } public function page_note_column_content($column, $post_id) { if ($column !== 'ekmt_admin_note') return; $note = trim((string) get_post_meta($post_id, self::NOTE_META, true)); if ($note !== '') { $snippet = wp_html_excerpt($note, 62, '…'); echo ''; } else { echo ''; } } public function add_page_note_metabox() { add_meta_box( 'ekmt-page-note', 'Toolkit Admin Note', [$this, 'render_page_note_metabox'], 'page', 'side', 'default' ); } public function render_page_note_metabox($post) { $note = (string) get_post_meta($post->ID, self::NOTE_META, true); wp_nonce_field('ekmt_save_page_note', 'ekmt_page_note_nonce'); echo '

Internal only — never shown on the public page.

'; echo ''; if ($note !== '') echo '

Note added

'; } public function save_page_note($post_id) { if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return; if (wp_is_post_revision($post_id)) return; if (empty($_POST['ekmt_page_note_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['ekmt_page_note_nonce'])), 'ekmt_save_page_note')) return; if (!current_user_can('edit_post', $post_id)) return; $note = sanitize_textarea_field(wp_unslash($_POST['ekmt_admin_note'] ?? '')); if ($note === '') delete_post_meta($post_id, self::NOTE_META); else update_post_meta($post_id, self::NOTE_META, $note); } public function render_page_note_modal() { $screen = function_exists('get_current_screen') ? get_current_screen() : null; if (!$screen || $screen->post_type !== 'page') return; ?> 'Permission denied.'], 403); $id = absint($_POST['id'] ?? 0); if (!$id || get_post_type($id) !== 'page' || !current_user_can('edit_post', $id)) { wp_send_json_error(['message' => 'You cannot edit this page note.'], 403); } wp_send_json_success([ 'note' => (string) get_post_meta($id, self::NOTE_META, true), 'title' => get_the_title($id), ]); } public function ajax_save_admin_note() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); $id = absint($_POST['id'] ?? 0); if (!$id || get_post_type($id) !== 'page' || !current_user_can('edit_post', $id)) { wp_send_json_error(['message' => 'You cannot edit this page note.'], 403); } $note = sanitize_textarea_field(wp_unslash($_POST['note'] ?? '')); if ($note === '') delete_post_meta($id, self::NOTE_META); else update_post_meta($id, self::NOTE_META, $note); wp_send_json_success([ 'message' => $note === '' ? 'Note removed.' : 'Note saved.', 'note' => $note, 'snippet' => $note === '' ? '' : wp_html_excerpt($note, 62, '…'), ]); } public function add_privacy_policy_content() { if (!function_exists('wp_add_privacy_policy_content')) return; $content = '

Ahmad Developer Toolkit stores data locally on this WordPress website when site administrators use certain tools. If the optional SMTP Mailer is enabled, SMTP connection settings are stored locally and the SMTP password is encrypted before database storage unless it is supplied through the AHMAD_TOOLKIT_SMTP_PASSWORD configuration constant.

'; $content .= '

Toolkit Forms can store submitted names, email addresses, phone numbers, messages, selected services, and technical submission metadata as private WordPress entries. The plugin also stores aggregate local click/conversion counters, SEO scan history, administrator page notes, and IndexNow request history.

'; $content .= '

If SMTP Email Log is enabled, the plugin stores the recipient, subject, sent/failed status, timestamp, and a short error summary for recent outbound emails. Message bodies are not stored. Optional Open Tracking adds a unique local image URL to HTML emails and stores only the open count and first-open time; it does not store the recipient's IP address or user agent.

'; $content .= '

The plugin does not send analytics or feature-usage telemetry to the developer. When an administrator explicitly submits a URL through the IndexNow tool, the selected URL, site host, generated IndexNow key, and key-location URL are sent to the IndexNow service for search-engine notification.

'; wp_add_privacy_policy_content('Ahmad Developer Toolkit', wp_kses_post($content)); } /* ------------------------------------------------------------------------- * Free Instant Indexing / IndexNow * ---------------------------------------------------------------------- */ public function indexnow_enforce_manual_mode() { if (get_option('ekmt_indexnow_manual_mode_v308') === '1') return; $settings = get_option(self::INDEXNOW_SETTINGS_OPTION, []); if (!is_array($settings)) $settings = []; $settings['auto_submit'] = 0; update_option(self::INDEXNOW_SETTINGS_OPTION, $settings, false); wp_clear_scheduled_hook('ekmt_indexnow_submit_event'); update_option('ekmt_indexnow_manual_mode_v308', '1', false); } private function indexnow_tracking_all() { $items = get_option(self::INDEXNOW_TRACKING_OPTION, []); return is_array($items) ? $items : []; } private function indexnow_tracking_key($url) { return md5(untrailingslashit((string)$url)); } private function indexnow_tracking_for($url) { $url = $this->indexnow_normalize_local_url($url); if ($url === '') return null; $all = $this->indexnow_tracking_all(); $key = $this->indexnow_tracking_key($url); return isset($all[$key]) && is_array($all[$key]) ? $all[$key] : null; } private function indexnow_update_tracking($url, array $changes) { $url = $this->indexnow_normalize_local_url($url); if ($url === '') return; $all = $this->indexnow_tracking_all(); $key = $this->indexnow_tracking_key($url); $current = isset($all[$key]) && is_array($all[$key]) ? $all[$key] : []; $current = wp_parse_args($current, [ 'url' => $url, 'first_submitted_at' => 0, 'last_submitted_at' => 0, 'last_status' => '', 'last_code' => 0, 'last_message' => '', 'last_source' => '', 'confirmed_indexed_at' => 0, 'confirmed_by' => '', ]); foreach ($changes as $k => $v) $current[$k] = $v; $current['url'] = $url; $all[$key] = $current; if (count($all) > 500) { uasort($all, function($a, $b){ return absint($b['last_submitted_at'] ?? 0) <=> absint($a['last_submitted_at'] ?? 0); }); $all = array_slice($all, 0, 500, true); } update_option(self::INDEXNOW_TRACKING_OPTION, $all, false); } public function indexnow_row_action($actions, $post) { if (!$post instanceof WP_Post || $post->post_status !== 'publish') return $actions; $obj = get_post_type_object($post->post_type); if (!$obj || empty($obj->public) || $post->post_type === 'attachment') return $actions; if (!current_user_can('edit_post', $post->ID)) return $actions; $url = wp_nonce_url( admin_url('admin-post.php?action=ekmt_indexnow_submit_post&post=' . absint($post->ID)), 'ekmt_indexnow_submit_post_' . absint($post->ID) ); $track = $this->indexnow_tracking_for(get_permalink($post->ID)); $label = !empty($track['last_submitted_at']) ? 'IndexNow Again' : 'IndexNow'; $actions['ekmt_indexnow'] = '' . esc_html($label) . ''; return $actions; } public function handle_indexnow_submit_post() { $post_id = absint(wp_unslash($_GET['post'] ?? 0)); if (!$post_id || !current_user_can('edit_post', $post_id)) wp_die('You cannot submit this URL.', 'Permission denied', ['response'=>403]); check_admin_referer('ekmt_indexnow_submit_post_' . $post_id); $post = get_post($post_id); if (!$post || $post->post_status !== 'publish') wp_die('Only a published public URL can be submitted.', 'IndexNow', ['response'=>400]); $obj = get_post_type_object($post->post_type); if (!$obj || empty($obj->public) || $post->post_type === 'attachment') wp_die('This post type is not publicly indexable.', 'IndexNow', ['response'=>400]); $url = get_permalink($post_id); $result = $this->indexnow_submit_urls([$url], 'Page list'); $redirect = wp_get_referer(); if (!$redirect) $redirect = admin_url('edit.php?post_type=' . urlencode($post->post_type)); if (is_wp_error($result)) { $redirect = add_query_arg(['ekmt_ix'=>'error','ekmt_ix_msg'=>rawurlencode($result->get_error_message())], $redirect); } else { $redirect = add_query_arg(['ekmt_ix'=>'sent','ekmt_ix_post'=>$post_id], $redirect); } wp_safe_redirect($redirect); exit; } public function indexnow_admin_notice() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin notice query arg created after a nonce-protected IndexNow action. if (empty($_GET['ekmt_ix'])) return; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin notice query arg created after a nonce-protected IndexNow action. $type = sanitize_key(wp_unslash($_GET['ekmt_ix'])); if ($type === 'sent') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin notice query arg created after a nonce-protected IndexNow action. $post_id = absint(wp_unslash($_GET['ekmt_ix_post'] ?? 0)); $title = $post_id ? get_the_title($post_id) : ''; echo '

Toolkit IndexNow: Request accepted for ' . esc_html($title ?: 'the selected URL') . '.

'; } elseif ($type === 'error') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin notice query arg created after a nonce-protected IndexNow action. $raw_message = isset($_GET['ekmt_ix_msg']) ? sanitize_text_field(wp_unslash($_GET['ekmt_ix_msg'])) : 'IndexNow request failed.'; $message = sanitize_text_field(rawurldecode($raw_message)); echo '

Toolkit IndexNow: ' . esc_html($message) . '

'; } } private function indexnow_settings() { $saved = get_option(self::INDEXNOW_SETTINGS_OPTION, []); if (!is_array($saved)) $saved = []; return wp_parse_args($saved, [ 'auto_submit' => 0, ]); } private function indexnow_key() { $settings = $this->indexnow_settings(); $key = strtolower(preg_replace('/[^a-zA-Z0-9\-]/', '', (string)($settings['key'] ?? ''))); if (strlen($key) < 8) { try { $key = bin2hex(random_bytes(16)); } catch (Exception $e) { $key = strtolower(wp_generate_password(32, false, false)); } $settings['key'] = $key; update_option(self::INDEXNOW_SETTINGS_OPTION, $settings, false); } return $key; } private function indexnow_key_location() { return home_url('/' . $this->indexnow_key() . '.txt'); } public function indexnow_maybe_serve_key_file() { $request_path = wp_parse_url(sanitize_text_field(wp_unslash((string)($_SERVER['REQUEST_URI'] ?? ''))), PHP_URL_PATH); $key_path = wp_parse_url($this->indexnow_key_location(), PHP_URL_PATH); if (!is_string($request_path) || !is_string($key_path)) return; if (untrailingslashit(rawurldecode($request_path)) !== untrailingslashit(rawurldecode($key_path))) return; status_header(200); nocache_headers(); header('Content-Type: text/plain; charset=utf-8'); echo esc_html($this->indexnow_key()); exit; } private function indexnow_normalize_local_url($url) { $url = esc_url_raw(trim((string)$url)); if ($url === '') return ''; $scheme = strtolower((string)wp_parse_url($url, PHP_URL_SCHEME)); if (!in_array($scheme, ['http','https'], true)) return ''; $url_host = strtolower((string)wp_parse_url($url, PHP_URL_HOST)); $home_host = strtolower((string)wp_parse_url(home_url('/'), PHP_URL_HOST)); $url_host = preg_replace('/^www\./', '', $url_host); $home_host = preg_replace('/^www\./', '', $home_host); if ($url_host === '' || $home_host === '' || $url_host !== $home_host) return ''; return $url; } private function indexnow_history() { $history = get_option(self::INDEXNOW_HISTORY_OPTION, []); return is_array($history) ? $history : []; } private function indexnow_add_history($url, $source, $ok, $code, $message) { $history = $this->indexnow_history(); array_unshift($history, [ 'time' => time(), 'url' => esc_url_raw($url), 'source' => sanitize_text_field($source), 'status' => $ok ? 'accepted' : 'failed', 'code' => absint($code), 'message' => sanitize_text_field($message), ]); if (count($history) > 200) $history = array_slice($history, 0, 200); update_option(self::INDEXNOW_HISTORY_OPTION, $history, false); $now = time(); $track = $this->indexnow_tracking_for($url); $first = absint($track['first_submitted_at'] ?? 0); $this->indexnow_update_tracking($url, [ 'first_submitted_at' => $first ?: $now, 'last_submitted_at' => $now, 'last_status' => $ok ? 'accepted' : 'failed', 'last_code' => absint($code), 'last_message' => sanitize_text_field($message), 'last_source' => sanitize_text_field($source), ]); } private function indexnow_submit_urls(array $urls, $source = 'Manual') { $clean = []; foreach ($urls as $url) { $url = $this->indexnow_normalize_local_url($url); if ($url !== '') $clean[$url] = true; if (count($clean) >= 10000) break; } $urls = array_keys($clean); if (!$urls) return new WP_Error('indexnow_no_urls', 'No valid URLs from this website were supplied.'); $payload = [ 'host' => (string)wp_parse_url(home_url('/'), PHP_URL_HOST), 'key' => $this->indexnow_key(), 'keyLocation' => $this->indexnow_key_location(), 'urlList' => array_values($urls), ]; $response = wp_remote_post('https://api.indexnow.org/indexnow', [ 'timeout' => 18, 'redirection' => 3, 'headers' => [ 'Content-Type' => 'application/json; charset=utf-8', 'Accept' => 'application/json, text/plain, */*', ], 'user-agent' => '11KeysMediaToolkit/' . self::VERSION . '; ' . home_url('/'), 'body' => wp_json_encode($payload), ]); if (is_wp_error($response)) { $message = $response->get_error_message(); foreach ($urls as $url) $this->indexnow_add_history($url, $source, false, 0, $message); return $response; } $code = (int)wp_remote_retrieve_response_code($response); $ok = in_array($code, [200, 202], true); $messages = [ 200 => 'IndexNow accepted the URL notification.', 202 => 'IndexNow accepted the notification and may still be validating the key.', 400 => 'IndexNow rejected the request as invalid.', 403 => 'IndexNow could not validate the key for this website.', 422 => 'One or more URLs do not belong to the submitted host or failed validation.', 429 => 'IndexNow rate limit reached. Try again later.', ]; $message = $messages[$code] ?? ('IndexNow returned HTTP ' . $code . '.'); foreach ($urls as $url) $this->indexnow_add_history($url, $source, $ok, $code, $message); if (!$ok) return new WP_Error('indexnow_http_' . $code, $message, ['status'=>$code]); return [ 'count' => count($urls), 'code' => $code, 'message' => $message, ]; } private function indexnow_last_submission_for($url) { $needle = untrailingslashit((string)$url); foreach ($this->indexnow_history() as $item) { if (untrailingslashit((string)($item['url'] ?? '')) === $needle) return $item; } return null; } private function indexnow_check_page($url) { $url = $this->indexnow_normalize_local_url($url); if ($url === '') return new WP_Error('indexnow_bad_url', 'Enter a valid URL from this website.'); $response = wp_safe_remote_get($url, [ 'timeout' => 15, 'redirection' => 5, 'user-agent' => '11KeysIndexabilityChecker/' . self::VERSION, ]); if (is_wp_error($response)) return $response; $code = (int)wp_remote_retrieve_response_code($response); $html = (string)wp_remote_retrieve_body($response); $xrobots = strtolower((string)wp_remote_retrieve_header($response, 'x-robots-tag')); $noindex = strpos($xrobots, 'noindex') !== false; $meta_robots = ''; if (preg_match('/]+name=["\']robots["\'][^>]+content=["\']([^"\']*)["\'][^>]*>/i', $html, $m) || preg_match('/]+content=["\']([^"\']*)["\'][^>]+name=["\']robots["\'][^>]*>/i', $html, $m)) { $meta_robots = strtolower(trim($m[1])); if (strpos($meta_robots, 'noindex') !== false) $noindex = true; } $canonical = ''; if (preg_match('/]+rel=["\'][^"\']*canonical[^"\']*["\'][^>]+href=["\']([^"\']+)["\']/i', $html, $m) || preg_match('/]+href=["\']([^"\']+)["\'][^>]+rel=["\'][^"\']*canonical[^"\']*["\']/i', $html, $m)) { $canonical = esc_url_raw(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8')); } $global_block = (string)get_option('blog_public', '1') === '0'; $indexable = $code >= 200 && $code < 300 && !$noindex && !$global_block; $reasons = []; if ($code < 200 || $code >= 300) $reasons[] = 'HTTP status is ' . $code; if ($global_block) $reasons[] = 'WordPress is set to discourage search engines'; if ($noindex) $reasons[] = 'A noindex directive was detected'; if (!$reasons) $reasons[] = 'No obvious noindex block was detected'; $last = $this->indexnow_last_submission_for($url); return [ 'url' => $url, 'http_code' => $code, 'indexable' => $indexable, 'reason' => implode('. ', $reasons) . '.', 'canonical' => $canonical, 'canonical_matches' => $canonical === '' ? null : (untrailingslashit($canonical) === untrailingslashit($url)), 'meta_robots' => $meta_robots, 'x_robots' => $xrobots, 'last_submission' => $last, 'tracking' => $this->indexnow_tracking_for($url), 'google_search' => 'https://www.google.com/search?q=' . rawurlencode('site:' . $url), 'bing_search' => 'https://www.bing.com/search?q=' . rawurlencode('url:' . $url), ]; } public function ajax_indexnow_submit() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); $url = $this->indexnow_normalize_local_url(esc_url_raw(wp_unslash($_POST['url'] ?? ''))); if ($url === '') wp_send_json_error(['message'=>'Enter a valid URL from this website.'], 400); $result = $this->indexnow_submit_urls([$url], 'Manual'); if (is_wp_error($result)) wp_send_json_error(['message'=>$result->get_error_message()], 400); wp_send_json_success($result + ['url'=>$url]); } public function ajax_indexnow_check() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); $result = $this->indexnow_check_page(esc_url_raw(wp_unslash($_POST['url'] ?? ''))); if (is_wp_error($result)) wp_send_json_error(['message'=>$result->get_error_message()], 400); wp_send_json_success($result); } public function ajax_indexnow_history() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); $rows = []; $seen = []; foreach ($this->indexnow_history() as $item) { $url = $this->indexnow_normalize_local_url($item['url'] ?? ''); if ($url === '') continue; $key = $this->indexnow_tracking_key($url); if (isset($seen[$key])) continue; $seen[$key] = true; $item['tracking'] = $this->indexnow_tracking_for($url); $rows[] = $item; } wp_send_json_success(['items'=>$rows]); } public function ajax_indexnow_mark_indexed() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); $url = $this->indexnow_normalize_local_url(esc_url_raw(wp_unslash($_POST['url'] ?? ''))); if ($url === '') wp_send_json_error(['message'=>'Enter a valid URL from this website.'], 400); $track = $this->indexnow_tracking_for($url); if (!$track || empty($track['first_submitted_at'])) wp_send_json_error(['message'=>'Submit this URL to IndexNow before recording indexing confirmation.'], 400); if (empty($track['confirmed_indexed_at'])) { $this->indexnow_update_tracking($url, [ 'confirmed_indexed_at' => time(), 'confirmed_by' => 'Manual search verification', ]); } wp_send_json_success(['message'=>'Indexed status confirmed and timing recorded.','tracking'=>$this->indexnow_tracking_for($url)]); } public function ajax_indexnow_unmark_indexed() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); $url = $this->indexnow_normalize_local_url(esc_url_raw(wp_unslash($_POST['url'] ?? ''))); if ($url === '') wp_send_json_error(['message'=>'Enter a valid URL from this website.'], 400); $this->indexnow_update_tracking($url, ['confirmed_indexed_at'=>0,'confirmed_by'=>'']); wp_send_json_success(['message'=>'Index confirmation removed.']); } public function ajax_indexnow_clear_history() { check_ajax_referer(self::NONCE, 'nonce'); if (!current_user_can('upload_files')) wp_send_json_error(['message' => 'Permission denied.'], 403); if (!current_user_can('manage_options')) wp_send_json_error(['message'=>'Only an administrator can clear IndexNow history.'], 403); delete_option(self::INDEXNOW_HISTORY_OPTION); delete_option(self::INDEXNOW_TRACKING_OPTION); wp_send_json_success(['message'=>'IndexNow request and confirmation history cleared.']); } private function clear_elementor_cache() { if (did_action('elementor/loaded') && class_exists('\\Elementor\\Plugin')) { try { $plugin = \Elementor\Plugin::$instance; if (isset($plugin->files_manager)) $plugin->files_manager->clear_cache(); } catch (Throwable $e) {} } } } new Eleven_Keys_Media_Toolkit();