*/ public static function defaults(): array { return [ 'scan_mode' => 'full', // v2.3.0 — email summary on audit completion (off by default). 'notifications' => [ 'email_on_complete' => 0, 'email_recipient' => '', // empty = use site admin_email ], 'scan_limits' => [ 'max_files' => 5000, // 0 = skip scanning uploads directory in FS collector (recommended on shared hosting). 'fs_include_uploads' => 0, 'max_seconds' => 20, 'max_seconds_per_batch' => 5, // Sensible non-zero defaults so a fresh install does not appear // to "freeze" on large sites. Every value is user-editable in // Settings → Scan Limits, and 0 means "no limit" and is honored. 'full_scan_max_pages' => 1000, // How many discovered pages the audit analyzes. The default // keeps the first scan snappy; the site owner can raise it to // any value (0 = analyze every page discovered). 'audit_analysis_max_pages' => 50, 'sample_scan_pages' => 10, // v3.3.40 — lowered to 200 so the build-inventory phase // terminates in ~5-7 minutes for typical sites. Raise it in // Settings → Scan Limits for a deeper crawl (0 = no limit). 'crawl_max_urls' => 200, 'crawl_queue_cap' => 50000, 'crawl_per_batch' => 2, 'crawl_delay_ms' => 1500, 'broken_links_per_page' => 15, 'internal_links_extract_limit' => 200, 'exclude_global_nav_links' => 1, ], 'data_sharing' => [ 'summaries_only' => 1, 'allow_snippets' => 0, 'max_snippet_chars' => 1200, ], 'logs' => [ // Preferred access-log copy path (for crawler behavior analysis). 'access_path' => '', // Optional PHP/debug log path fallback. 'custom_path' => '', 'max_lines' => 500, ], 'ui' => [ // v1.0.90: advanced tools menu visible by default so the Crawl // tool is reachable out of the box. The toggle can still be // turned off in Settings to declutter the menu. 'show_advanced_tools_menu' => 1, // v3.0.0: locale override. 'auto' = follow WordPress site // locale (is_rtl() → Arabic; otherwise English). 'ar' / 'en' // force the plugin admin UI to that language regardless of // what WordPress is set to. Used by Utils\I18n::tr(). 'admin_locale' => 'auto', ], ]; } /** * @return array */ public static function get(): array { $settings = get_option(self::OPTION_KEY); if (!is_array($settings)) { return self::defaults(); } $merged = self::merge_recursive(self::defaults(), $settings); return self::normalize_settings($merged); } /** * @param array $new */ public static function update(array $new): bool { $merged = self::merge_recursive(self::defaults(), $new); $normalized = self::normalize_settings($merged); return update_option(self::OPTION_KEY, $normalized, false); } /** * @param array $settings * @return array */ private static function normalize_settings(array $settings): array { if (!isset($settings['scan_mode']) || !in_array((string) $settings['scan_mode'], ['full', 'sample'], true)) { $settings['scan_mode'] = 'full'; } if (!isset($settings['scan_limits']) || !is_array($settings['scan_limits'])) { $settings['scan_limits'] = self::defaults()['scan_limits']; } $settings['scan_limits'] = self::normalize_scan_limits($settings['scan_limits']); if (!isset($settings['data_sharing']) || !is_array($settings['data_sharing'])) { $settings['data_sharing'] = self::defaults()['data_sharing']; } $settings['data_sharing'] = self::merge_recursive(self::defaults()['data_sharing'], $settings['data_sharing']); $settings['data_sharing']['summaries_only'] = !empty($settings['data_sharing']['summaries_only']) ? 1 : 0; $settings['data_sharing']['allow_snippets'] = !empty($settings['data_sharing']['allow_snippets']) ? 1 : 0; $settings['data_sharing']['max_snippet_chars'] = max(200, min(1200, (int) ($settings['data_sharing']['max_snippet_chars'] ?? 1200))); if (!isset($settings['logs']) || !is_array($settings['logs'])) { $settings['logs'] = self::defaults()['logs']; } $settings['logs'] = self::merge_recursive(self::defaults()['logs'], $settings['logs']); $settings['logs']['access_path'] = isset($settings['logs']['access_path']) ? (string) $settings['logs']['access_path'] : ''; $settings['logs']['custom_path'] = isset($settings['logs']['custom_path']) ? (string) $settings['logs']['custom_path'] : ''; $settings['logs']['max_lines'] = max(50, min(5000, (int) ($settings['logs']['max_lines'] ?? 500))); // v1.0.58: ui section if (!isset($settings['ui']) || !is_array($settings['ui'])) { $settings['ui'] = self::defaults()['ui']; } $settings['ui'] = self::merge_recursive(self::defaults()['ui'], $settings['ui']); $settings['ui']['show_advanced_tools_menu'] = !empty($settings['ui']['show_advanced_tools_menu']) ? 1 : 0; $settings = FeatureGate::apply_settings_limits($settings); return $settings; } /** * @param array $limits * @return array */ private static function normalize_scan_limits(array $limits): array { $defaults = self::defaults()['scan_limits']; $limits = self::merge_recursive($defaults, $limits); // 0 means "no limit" for every page-count field and is preserved as 0 // (honored by the crawler and the auditor). The non-zero defaults keep // a fresh install snappy; the site owner can set any value, including 0 // for a full-site scan. Depth is never gated by edition. $full_scan_max_pages = isset($limits['full_scan_max_pages']) ? (int) $limits['full_scan_max_pages'] : 1000; $audit_analysis_max_pages = isset($limits['audit_analysis_max_pages']) ? (int) $limits['audit_analysis_max_pages'] : 50; $crawl_max_urls = isset($limits['crawl_max_urls']) ? (int) $limits['crawl_max_urls'] : 200; return [ 'max_files' => max(200, min(50000, (int) ($limits['max_files'] ?? 5000))), 'fs_include_uploads' => !empty($limits['fs_include_uploads']) ? 1 : 0, 'max_seconds' => max(5, min(600, (int) ($limits['max_seconds'] ?? 20))), 'max_seconds_per_batch' => max(2, min(30, (int) ($limits['max_seconds_per_batch'] ?? 5))), 'full_scan_max_pages' => max(0, min(500000, $full_scan_max_pages)), 'audit_analysis_max_pages' => max(0, min(500000, $audit_analysis_max_pages)), 'sample_scan_pages' => max(3, min(1000, (int) ($limits['sample_scan_pages'] ?? ($limits['sample_pages'] ?? 10)))), 'crawl_max_urls' => max(0, min(500000, $crawl_max_urls)), 'crawl_queue_cap' => max(500, min(200000, (int) ($limits['crawl_queue_cap'] ?? 50000))), 'crawl_per_batch' => max(1, min(20, (int) ($limits['crawl_per_batch'] ?? 2))), 'crawl_delay_ms' => max(500, min(30000, (int) ($limits['crawl_delay_ms'] ?? 1500))), 'broken_links_per_page' => max(5, min(100, (int) ($limits['broken_links_per_page'] ?? 15))), 'internal_links_extract_limit' => max(20, min(5000, (int) ($limits['internal_links_extract_limit'] ?? 200))), 'exclude_global_nav_links' => !empty($limits['exclude_global_nav_links']) ? 1 : 0, ]; } public static function is_safe_mode(): bool { $s = self::get(); return !empty($s['safe_mode']); } /** * Advanced tools menu visibility (the Crawl tool). * * v1.0.58: now reads from a setting too, so non-developers can flip it from * the Settings UI without writing PHP filter code. */ public static function is_advanced_tools_enabled(): bool { $s = self::get(); $from_setting = !empty($s['ui']['show_advanced_tools_menu']); /** * Filter override (still respected for developers who want to force it). * * @param bool $enabled * @param array $settings */ return (bool) apply_filters('aetos_seo_enable_advanced_tools_menu', $from_setting, $s); } public static function ensure_upload_dirs(): void { $upload = wp_upload_dir(null, false); $base = trailingslashit($upload['basedir']) . 'aetos-seo'; $dirs = [ $base, $base . '/reports', $base . '/cache', $base . '/logs', ]; foreach ($dirs as $dir) { if (!is_dir($dir)) { wp_mkdir_p($dir); } } foreach ($dirs as $dir) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Checking writability of plugin-owned upload subdir before placing index/htaccess guards; WP_Filesystem here would require admin credentials during a regular activation. if (!is_dir($dir) || !is_writable($dir)) { continue; } $index_html = $dir . '/index.html'; if (!file_exists($index_html)) { @file_put_contents($index_html, ''); } $index_php = $dir . '/index.php'; if (!file_exists($index_php)) { @file_put_contents($index_php, "\nRequire all denied\n\n\nDeny from all\n\n"); } $web_config = $dir . '/web.config'; if (!file_exists($web_config)) { @file_put_contents($web_config, "\n\n \n \n \n \n \n\n"); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Hardening permissions on plugin-owned upload subdir to 0700 right after creation; WP_Filesystem does not expose chmod for this use. @chmod($dir, 0700); } } public static function delete_upload_dirs(): void { $upload = wp_upload_dir(null, false); $base = trailingslashit($upload['basedir']) . 'aetos-seo'; self::rmdir_recursive($base); } private static function rmdir_recursive(string $dir): void { if (!is_dir($dir)) { return; } $items = scandir($dir); if (!is_array($items)) { return; } foreach ($items as $item) { if ($item === '.' || $item === '..') { continue; } $path = $dir . DIRECTORY_SEPARATOR . $item; if (is_dir($path)) { self::rmdir_recursive($path); } else { wp_delete_file($path); } } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Removing empty plugin-owned upload subdir; no FS credentials available in destructor context. @rmdir($dir); } /** * Merge recursively with scalar overwrite. * * @param array $base * @param array $override * @return array */ private static function merge_recursive(array $base, array $override): array { foreach ($override as $k => $v) { if (is_array($v) && isset($base[$k]) && is_array($base[$k])) { /** @var array $baseK */ $baseK = $base[$k]; /** @var array $v */ $base[$k] = self::merge_recursive($baseK, $v); } else { $base[$k] = $v; } } return $base; } }