['post'], 'fields' => [ 'id' => 1, 'title' => 1, 'url' => 1, 'categories' => 1, 'category_urls' => 1, 'tags' => 1, 'tag_urls' => 1, 'topics' => 0, 'topic_urls' => 0, 'published_date' => 1, ], 'export' => [ // 0 = single file (no split). Default 10000 rows per file. 'split_rows' => 10000, ], 'filters' => [ 'date_from' => '', 'date_to' => '', 'id_min' => 0, 'id_max' => 0, 'category_ids' => [], 'topic_taxonomy' => '', 'topic_ids' => [], ], 'link_check' => [ 'enabled' => 1, 'max_items' => 50, 'batch_posts' => 10, 'batch_urls' => 25, 'include_internal' => 0, 'include_external' => 1, 'max_links' => 500, 'allow_private_targets' => 0, 'timeout' => 8, 'user_agent' => 'LeExport/' . (defined('LEEXPORT_VERSION') ? LEEXPORT_VERSION : 'dev'), ], ]; } function leexport_get_settings(): array { $defaults = leexport_default_settings(); $stored = get_option(leexport_settings_key(), []); if (!is_array($stored)) { $stored = []; } // Shallow merge, then per-group merge where needed. $settings = array_merge($defaults, $stored); if (!isset($settings['fields']) || !is_array($settings['fields'])) { $settings['fields'] = $defaults['fields']; } else { $settings['fields'] = array_merge($defaults['fields'], $settings['fields']); } if (!isset($settings['export']) || !is_array($settings['export'])) { $settings['export'] = $defaults['export']; } else { $settings['export'] = array_merge($defaults['export'], $settings['export']); } if (!isset($settings['filters']) || !is_array($settings['filters'])) { $settings['filters'] = $defaults['filters']; } else { $settings['filters'] = array_merge($defaults['filters'], $settings['filters']); if (!isset($settings['filters']['category_ids']) || !is_array($settings['filters']['category_ids'])) { $settings['filters']['category_ids'] = []; } if (!isset($settings['filters']['topic_ids']) || !is_array($settings['filters']['topic_ids'])) { $settings['filters']['topic_ids'] = []; } if (!isset($settings['filters']['topic_taxonomy'])) { $settings['filters']['topic_taxonomy'] = ''; } } if (!isset($settings['link_check']) || !is_array($settings['link_check'])) { $settings['link_check'] = $defaults['link_check']; } else { $settings['link_check'] = array_merge($defaults['link_check'], $settings['link_check']); } if (!isset($settings['post_types']) || !is_array($settings['post_types']) || $settings['post_types'] === []) { $settings['post_types'] = $defaults['post_types']; } return $settings; } function leexport_update_settings(array $settings): bool { return update_option(leexport_settings_key(), $settings, false); } function leexport_bool_int($value): int { return empty($value) ? 0 : 1; } function leexport_sanitize_int($value, int $min = 0, ?int $max = null): int { $v = is_numeric($value) ? (int)$value : 0; if ($v < $min) { $v = $min; } if ($max !== null && $v > $max) { $v = $max; } return $v; } /** * Checks whether an URL is safe for outbound HTTP checks. * * Blocks localhost/private/reserved targets to reduce SSRF risk. */ function leexport_is_safe_remote_url(string $url, bool $allow_private_targets = false): bool { $url = trim($url); if ($url === '' || !wp_http_validate_url($url)) { return false; } if ($allow_private_targets) { return true; } $host = wp_parse_url($url, PHP_URL_HOST); if (!is_string($host) || $host === '') { return false; } // Block obvious local targets quickly. if (strtolower($host) === 'localhost') { return false; } $is_public_ip = static function (string $ip): bool { return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false; }; // IP literal host. if (filter_var($host, FILTER_VALIDATE_IP)) { return $is_public_ip($host); } // Resolve host and ensure all resolved IPs are public. $resolved_ips = []; $ipv4 = gethostbynamel($host); if (is_array($ipv4)) { $resolved_ips = array_merge($resolved_ips, $ipv4); } if (function_exists('dns_get_record')) { $aaaa = @dns_get_record($host, DNS_AAAA); if (is_array($aaaa)) { foreach ($aaaa as $rec) { if (!empty($rec['ipv6']) && is_string($rec['ipv6'])) { $resolved_ips[] = $rec['ipv6']; } } } } $resolved_ips = array_values(array_unique(array_filter($resolved_ips, static fn($ip) => is_string($ip) && $ip !== ''))); if ($resolved_ips === []) { // If DNS cannot be resolved, treat as unsafe for scanner. return false; } foreach ($resolved_ips as $ip) { if (!$is_public_ip($ip)) { return false; } } return true; } /** * Max rows per export file. 0 disables splitting (single file). */ function leexport_split_rows_limit(array $settings): int { $export = isset($settings['export']) && is_array($settings['export']) ? $settings['export'] : []; $limit = leexport_sanitize_int($export['split_rows'] ?? 10000, 0, 100000); return $limit; } /** * Sanitize Y-m-d date string for export filters. */ function leexport_sanitize_date_ymd($value): string { $value = trim((string)$value); if ($value === '') { return ''; } $dt = DateTime::createFromFormat('Y-m-d', $value); if ($dt instanceof DateTime && $dt->format('Y-m-d') === $value) { return $value; } return ''; } /** * Common taxonomy slugs used for “专题” on Chinese WordPress sites. * * @return string[] */ function leexport_common_topic_taxonomy_slugs(): array { return ['topic', 'topics', 'zhuanti', 'special', 'post_topic', 'series', 'special_topic']; } /** * @return array taxonomy slug => label */ function leexport_get_topic_taxonomy_options(): array { $options = []; $skip = ['category', 'post_tag', 'post_format', 'nav_menu', 'link_category']; foreach (leexport_common_topic_taxonomy_slugs() as $slug) { if (!taxonomy_exists($slug)) { continue; } $tax = get_taxonomy($slug); if ($tax) { $options[$slug] = (string)($tax->labels->name ?? $slug); } } foreach (get_taxonomies(['public' => true], 'objects') as $tax) { if (in_array($tax->name, $skip, true) || isset($options[$tax->name])) { continue; } $label = (string)($tax->labels->singular_name ?? $tax->label ?? $tax->name); $name = (string)$tax->name; if ( strpos($label, '专题') !== false || stripos($name, 'topic') !== false || stripos($name, 'zhuanti') !== false || stripos($name, 'special') !== false ) { $options[$tax->name] = $label; } } return $options; } /** * Resolve which taxonomy powers topic filter/export columns. */ function leexport_resolve_topic_taxonomy(array $settings): string { $filters = isset($settings['filters']) && is_array($settings['filters']) ? $settings['filters'] : []; $chosen = sanitize_key((string)($filters['topic_taxonomy'] ?? '')); $options = leexport_get_topic_taxonomy_options(); if ($chosen !== '' && isset($options[$chosen]) && taxonomy_exists($chosen)) { return $chosen; } if ($options !== []) { return (string)array_key_first($options); } return ''; } /** * @param array $raw_ids * @return int[] */ function leexport_sanitize_term_ids_for_taxonomy(array $raw_ids, string $taxonomy): array { if ($taxonomy === '' || !taxonomy_exists($taxonomy)) { return []; } $ids = []; foreach ($raw_ids as $id) { $tid = (int)$id; if ($tid <= 0) { continue; } $term = get_term($tid, $taxonomy); if ($term instanceof WP_Term && !is_wp_error($term)) { $ids[] = $tid; } } return array_values(array_unique($ids)); } /** * @param array $raw filters array from form * @return array{date_from:string,date_to:string,id_min:int,id_max:int,category_ids:int[],topic_taxonomy:string,topic_ids:int[]} */ function leexport_sanitize_filters(array $raw): array { $date_from = leexport_sanitize_date_ymd($raw['date_from'] ?? ''); $date_to = leexport_sanitize_date_ymd($raw['date_to'] ?? ''); if ($date_from !== '' && $date_to !== '' && $date_from > $date_to) { $tmp = $date_from; $date_from = $date_to; $date_to = $tmp; } $id_min = leexport_sanitize_int($raw['id_min'] ?? 0, 0); $id_max = leexport_sanitize_int($raw['id_max'] ?? 0, 0); if ($id_min > 0 && $id_max > 0 && $id_min > $id_max) { $tmp = $id_min; $id_min = $id_max; $id_max = $tmp; } $category_ids = leexport_sanitize_term_ids_for_taxonomy( isset($raw['category_ids']) && is_array($raw['category_ids']) ? $raw['category_ids'] : [], 'category' ); $topic_taxonomy = sanitize_key((string)($raw['topic_taxonomy'] ?? '')); $topic_options = leexport_get_topic_taxonomy_options(); if ($topic_taxonomy !== '' && !isset($topic_options[$topic_taxonomy])) { $topic_taxonomy = ''; } $topic_tax_for_ids = $topic_taxonomy !== '' ? $topic_taxonomy : (leexport_resolve_topic_taxonomy(['filters' => ['topic_taxonomy' => '']])); $topic_ids = leexport_sanitize_term_ids_for_taxonomy( isset($raw['topic_ids']) && is_array($raw['topic_ids']) ? $raw['topic_ids'] : [], $topic_tax_for_ids ); return [ 'date_from' => $date_from, 'date_to' => $date_to, 'id_min' => $id_min, 'id_max' => $id_max, 'category_ids' => $category_ids, 'topic_taxonomy' => $topic_taxonomy, 'topic_ids' => $topic_ids, ]; } /** * Build WP_Query / get_posts args for export (published posts only). * * @param array $settings leexport_get_settings() * @param array $overrides Extra query arguments. */ function leexport_build_export_query_args(array $settings, array $overrides = []): array { $post_types = isset($settings['post_types']) && is_array($settings['post_types']) ? array_values($settings['post_types']) : ['post']; $filters = isset($settings['filters']) && is_array($settings['filters']) ? $settings['filters'] : leexport_default_settings()['filters']; $args = [ 'post_type' => $post_types, 'post_status' => 'publish', 'orderby' => 'date', 'order' => 'DESC', 'ignore_sticky_posts' => true, 'leexport_filtered' => true, ]; $date_from = leexport_sanitize_date_ymd($filters['date_from'] ?? ''); $date_to = leexport_sanitize_date_ymd($filters['date_to'] ?? ''); if ($date_from !== '' || $date_to !== '') { $date_query = [ 'inclusive' => true, 'column' => 'post_date', ]; if ($date_from !== '') { $date_query['after'] = $date_from; } if ($date_to !== '') { $date_query['before'] = $date_to; } $args['date_query'] = [$date_query]; } $id_min = leexport_sanitize_int($filters['id_min'] ?? 0, 0); $id_max = leexport_sanitize_int($filters['id_max'] ?? 0, 0); if ($id_min > 0) { $args['leexport_id_min'] = $id_min; } if ($id_max > 0) { $args['leexport_id_max'] = $id_max; } $tax_query = []; $category_ids = isset($filters['category_ids']) && is_array($filters['category_ids']) ? $filters['category_ids'] : []; $category_ids = array_values(array_filter(array_map('intval', $category_ids), static fn($id) => $id > 0)); if ($category_ids !== []) { $tax_query[] = [ 'taxonomy' => 'category', 'field' => 'term_id', 'terms' => $category_ids, 'operator' => 'IN', ]; } $topic_ids = isset($filters['topic_ids']) && is_array($filters['topic_ids']) ? $filters['topic_ids'] : []; $topic_ids = array_values(array_filter(array_map('intval', $topic_ids), static fn($id) => $id > 0)); $topic_taxonomy = leexport_resolve_topic_taxonomy($settings); if ($topic_ids !== [] && $topic_taxonomy !== '') { $tax_query[] = [ 'taxonomy' => $topic_taxonomy, 'field' => 'term_id', 'terms' => $topic_ids, 'operator' => 'IN', ]; } if (count($tax_query) === 1) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Required for category/topic export filters. $args['tax_query'] = $tax_query; } elseif (count($tax_query) > 1) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Required for category/topic export filters. $args['tax_query'] = array_merge(['relation' => 'AND'], $tax_query); } return array_merge($args, $overrides); } /** * @param WP_Query $query */ function leexport_posts_where_id_range(string $where, $query): string { if (!($query instanceof WP_Query) || !$query->get('leexport_filtered')) { return $where; } global $wpdb; $id_min = (int)$query->get('leexport_id_min'); $id_max = (int)$query->get('leexport_id_max'); if ($id_min > 0) { $where .= $wpdb->prepare(" AND {$wpdb->posts}.ID >= %d", $id_min); } if ($id_max > 0) { $where .= $wpdb->prepare(" AND {$wpdb->posts}.ID <= %d", $id_max); } return $where; } add_filter('posts_where', 'leexport_posts_where_id_range', 10, 2);