array( 'plugin_version' => RFCC_PLUGIN_VERSION, 'db_version' => defined('RFCC_DB_VERSION') ? RFCC_DB_VERSION : '1.0', 'export_date' => current_time('mysql'), 'site_url' => get_site_url(), 'wordpress_version' => get_bloginfo('version') ), 'plugin_options' => self::export_plugin_options(), 'routes' => self::export_routes(), 'tea_stops' => self::export_tea_stops(), 'route_stats' => self::export_route_stats() ); // Only include GPX files if explicitly requested (they add ~36 MB to export) if ($include_gpx_files) { // Use WordPress's own admin memory allowance (WP_MAX_MEMORY_LIMIT, // filterable via 'admin_memory_limit') - the GPX files are zipped, // so a full export stays well inside it. wp_raise_memory_limit('admin'); $export_data['gpx_files'] = self::export_gpx_files(); } return $export_data; } /** * Export plugin options * * IMPORTANT: This method automatically exports ALL rfcc_* options from the database. * This means any new settings added in future versions will be automatically included * in exports without requiring code changes. * * Current settings include (but not limited to): * - Club groups, climbing profiles, activity types * - Route shortcuts and filter configurations * - Map provider settings and display options * - Permission and access control settings * - All other rfcc_* prefixed options * * @return array Plugin options (all rfcc_* settings) */ public static function export_plugin_options() { global $wpdb; // Get all options starting with rfcc_ // Excludes: transients, migration/import backups (internal use only), and // rfcc_club_groups/rfcc_climbing_profiles - legacy pre-Definition-Groups // option keys that nothing reads anymore (see includes/definition-groups.php // - Club Groups/Climbing Profiles are owned by Definition Groups now, // exported via rfcc_activity_definition_groups, which this blanket LIKE // query already picks up like any other current rfcc_* option). Exporting // the dead keys too would just carry stale, increasingly-wrong data since // nothing writes to them once a group is edited through the new UI. $options = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE 'rfcc_%' AND option_name NOT LIKE '_transient_%' AND option_name NOT LIKE 'rfcc_migration_backup_%' AND option_name NOT LIKE 'rfcc_pre_prod_import_backup_%' AND option_name NOT IN ('rfcc_club_groups', 'rfcc_climbing_profiles') ORDER BY option_name", ARRAY_A ); $exported_options = array(); foreach ($options as $option) { $exported_options[$option['option_name']] = maybe_unserialize($option['option_value']); } // Don't carry group overrides that reference routes which no longer // exist here - on the target site those IDs can belong to other routes. if (isset($exported_options['rfcc_manual_route_group_overrides']) && function_exists('rfcc_prune_manual_group_overrides')) { $exported_options['rfcc_manual_route_group_overrides'] = rfcc_prune_manual_group_overrides($exported_options['rfcc_manual_route_group_overrides']); } return $exported_options; } /** * Export routes data * * @return array Routes data */ public static function export_routes() { $routes = get_posts(array( 'post_type' => 'rfcc_route', 'post_status' => array('publish', 'draft', 'private'), 'numberposts' => -1, 'orderby' => 'ID', 'order' => 'ASC' )); $exported_routes = array(); foreach ($routes as $route) { // Get all post meta $meta = get_post_meta($route->ID); $processed_meta = array(); // Process meta values foreach ($meta as $key => $values) { if (count($values) === 1) { $processed_meta[$key] = maybe_unserialize($values[0]); } else { $processed_meta[$key] = array_map('maybe_unserialize', $values); } } $exported_routes[] = array( 'post_data' => array( 'ID' => $route->ID, 'post_title' => $route->post_title, 'post_content' => $route->post_content, 'post_excerpt' => $route->post_excerpt, 'post_status' => $route->post_status, 'post_date' => $route->post_date, 'post_date_gmt' => $route->post_date_gmt, 'post_modified' => $route->post_modified, 'post_modified_gmt' => $route->post_modified_gmt, 'post_name' => $route->post_name, 'guid' => $route->guid ), 'meta_data' => $processed_meta, 'taxonomies' => wp_get_object_terms($route->ID, get_object_taxonomies('rfcc_route'), array('fields' => 'all')) ); } return $exported_routes; } /** * Export tea stops data * * @return array Tea stops data */ public static function export_tea_stops() { $tea_stops = get_posts(array( 'post_type' => 'rfcc_teastop', 'post_status' => array('publish', 'draft', 'private'), 'numberposts' => -1, 'orderby' => 'ID', 'order' => 'ASC' )); $exported_tea_stops = array(); foreach ($tea_stops as $tea_stop) { // Get all post meta $meta = get_post_meta($tea_stop->ID); $processed_meta = array(); // Process meta values foreach ($meta as $key => $values) { if (count($values) === 1) { $processed_meta[$key] = maybe_unserialize($values[0]); } else { $processed_meta[$key] = array_map('maybe_unserialize', $values); } } $exported_tea_stops[] = array( 'post_data' => array( 'ID' => $tea_stop->ID, 'post_title' => $tea_stop->post_title, 'post_content' => $tea_stop->post_content, 'post_excerpt' => $tea_stop->post_excerpt, 'post_status' => $tea_stop->post_status, 'post_date' => $tea_stop->post_date, 'post_date_gmt' => $tea_stop->post_date_gmt, 'post_modified' => $tea_stop->post_modified, 'post_modified_gmt' => $tea_stop->post_modified_gmt, 'post_name' => $tea_stop->post_name, 'guid' => $tea_stop->guid ), 'meta_data' => $processed_meta, 'taxonomies' => wp_get_object_terms($tea_stop->ID, get_object_taxonomies('rfcc_teastop'), array('fields' => 'all')) ); } return $exported_tea_stops; } /** * Export route stats * * @return array Route stats data */ public static function export_route_stats() { global $wpdb; $table_name = $wpdb->prefix . 'rfcc_route_stats'; // Check if table exists if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ) !== $table_name ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table name is safely constructed from $wpdb->prefix return array(); } return $wpdb->get_results( "SELECT * FROM `" . esc_sql( $table_name ) . "` ORDER BY id", ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table name is safely constructed from $wpdb->prefix } /** * Export GPX files as a compressed ZIP archive * * Exports all GPX files from /wp-content/uploads/rfcc-gpx/ directory * as a ZIP archive encoded as base64. This is much more efficient than * storing individual files - 27 MB of GPX files compress to ~3-5 MB. * * @return array|false ZIP data with base64 content, or false on failure */ public static function export_gpx_files() { $upload_dir = wp_upload_dir(); $gpx_dir = $upload_dir['basedir'] . '/rfcc-gpx'; // Check if directory exists if (!is_dir($gpx_dir)) { return false; } // Get all .gpx files $files = glob($gpx_dir . '/*.gpx'); if (empty($files)) { return false; } // Check if ZipArchive is available if (!class_exists('ZipArchive')) { return self::export_gpx_files_uncompressed(); } // Temporary ZIP file - ZipArchive doesn't need a .zip extension, so // the wp_tempnam() file itself is used (and deleted below). $temp_zip = self::create_temp_file('rfcc_gpx_'); if (!$temp_zip) { return false; } $zip = new ZipArchive(); if ($zip->open($temp_zip, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { wp_delete_file($temp_zip); return false; } // Add all GPX files to ZIP $file_count = 0; foreach ($files as $file_path) { $filename = basename($file_path); if ($zip->addFile($file_path, $filename)) { $file_count++; } else { } } $zip->close(); // Read the ZIP file $zip_content = file_get_contents($temp_zip); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading temp ZIP file $zip_size = filesize($temp_zip); // Clean up temp file wp_delete_file($temp_zip); if ($zip_content === false) { return false; } // base64: the ZIP is binary and has to travel inside the JSON export // file (JSON strings can't hold raw bytes). Not obfuscation. $encoded = base64_encode($zip_content); return array( 'type' => 'zip', 'content' => $encoded, 'file_count' => $file_count, 'compressed_size' => $zip_size, 'compression_ratio' => round((1 - $zip_size / array_sum(array_map('filesize', $files))) * 100, 1) ); } /** * Fallback: Export GPX files uncompressed (legacy format) * Used if ZipArchive is not available * * @return array GPX files with filename => base64_content */ private static function export_gpx_files_uncompressed() { $upload_dir = wp_upload_dir(); $gpx_dir = $upload_dir['basedir'] . '/rfcc-gpx'; $gpx_files = array(); $files = glob($gpx_dir . '/*.gpx'); foreach ($files as $file_path) { $filename = basename($file_path); $content = file_get_contents($file_path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading local GPX files if ($content !== false) { $gpx_files[$filename] = array( 'filename' => $filename, 'content' => base64_encode($content), // Binary-safe transport inside JSON, not obfuscation. 'size' => filesize($file_path), 'modified' => filemtime($file_path) ); } } return array( 'type' => 'individual', 'files' => $gpx_files ); } /** * Export club groups * * @deprecated No longer needed - club groups are exported with all plugin_options * @return array Club groups data */ public static function export_club_groups() { // Kept for backward compatibility with old import files return function_exists('rfcc_get_club_groups_unsorted') ? rfcc_get_club_groups_unsorted() : array(); } /** * Import all plugin data * * @param array $import_data Data to import * @param array $options Import options * @return array Import results */ public static function import_all_data($import_data, $options = array()) { global $wpdb; $default_options = array( 'overwrite_existing' => false, 'import_routes' => true, 'import_tea_stops' => true, 'import_options' => true, 'import_stats' => true, 'import_club_groups' => true, // Legacy - for old export files 'import_gpx_files' => true, 'create_backup' => true ); $options = wp_parse_args($options, $default_options); $results = array( 'success' => false, 'messages' => array(), 'errors' => array(), 'imported_counts' => array( 'routes' => 0, 'tea_stops' => 0, 'options' => 0, 'stats' => 0, 'club_groups' => 0, 'gpx_files' => 0, 'group_overrides' => 0 ) ); try { // Per-post side effects (cron scheduling, marker recalculation, // shutdown cache rebuilds) check this and stand down: the import // brings that data with it and clears caches once at the end. if (!defined('WP_IMPORTING')) { define('WP_IMPORTING', true); } // Validate import data rfcc_import_status_step(__('Checking the import file', 'route-finder')); if (!self::validate_import_data($import_data)) { throw new Exception(__('Invalid import data format', 'route-finder')); } // Create backup if requested if ($options['create_backup']) { rfcc_import_status_step(__('Creating a backup of this site', 'route-finder')); $backup_file = self::create_backup(); if ($backup_file) { /* translators: %s: backup filename */ $results['messages'][] = sprintf(__('Backup created: %s', 'route-finder'), $backup_file); } else { throw new Exception(__('Could not create a backup before importing. Nothing was imported.', 'route-finder')); } } // Start transaction-like behavior $wpdb->query('START TRANSACTION'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching // Import plugin options - rfcc_manual_route_group_overrides is pulled // out and handled separately below, after routes are imported, since // it references routes by ID (needs the route ID map) and needs a // real merge rather than the generic array-options fallback (see // import_manual_route_group_overrides() for why). $manual_group_overrides_import = null; if ($options['import_options'] && isset($import_data['plugin_options'])) { rfcc_import_status_step(__('Importing settings', 'route-finder')); $plugin_options_to_import = $import_data['plugin_options']; if (isset($plugin_options_to_import['rfcc_manual_route_group_overrides'])) { $manual_group_overrides_import = $plugin_options_to_import['rfcc_manual_route_group_overrides']; unset($plugin_options_to_import['rfcc_manual_route_group_overrides']); } $results['imported_counts']['options'] = self::import_plugin_options( $plugin_options_to_import, $options['overwrite_existing'] ); } // Import club groups if ($options['import_club_groups'] && isset($import_data['club_groups'])) { $results['imported_counts']['club_groups'] = self::import_club_groups( $import_data['club_groups'], $options['overwrite_existing'] ); } // IMPORTANT: Import tea stops BEFORE routes to create ID mapping $teastop_id_map = array(); if ($options['import_tea_stops'] && isset($import_data['tea_stops'])) { rfcc_import_status_step(__('Importing destinations', 'route-finder')); $teastop_result = self::import_tea_stops( $import_data['tea_stops'], $options['overwrite_existing'] ); $results['imported_counts']['tea_stops'] = $teastop_result['count']; $teastop_id_map = $teastop_result['id_map']; } // Import routes (with tea stop ID remapping) $route_id_map = array(); if ($options['import_routes'] && isset($import_data['routes'])) { rfcc_import_status_step(__('Importing routes', 'route-finder')); $route_result = self::import_routes( $import_data['routes'], $options['overwrite_existing'], $teastop_id_map ); $results['imported_counts']['routes'] = $route_result['count']; $route_id_map = $route_result['id_map']; } // Now that routes have been imported and the route ID map exists, // merge in the manual route group overrides pulled out above. if ($manual_group_overrides_import !== null) { rfcc_import_status_step(__('Importing route groups', 'route-finder')); $results['imported_counts']['group_overrides'] = self::import_manual_route_group_overrides( $manual_group_overrides_import, $route_id_map, $options['overwrite_existing'] ); } // Import route stats if ($options['import_stats'] && isset($import_data['route_stats'])) { rfcc_import_status_step(__('Importing route stats', 'route-finder')); $results['imported_counts']['stats'] = self::import_route_stats( $import_data['route_stats'], $options['overwrite_existing'] ); } // Import GPX files if ($options['import_gpx_files'] && isset($import_data['gpx_files'])) { rfcc_import_status_step(__('Copying GPX files', 'route-finder')); $results['imported_counts']['gpx_files'] = self::import_gpx_files( $import_data['gpx_files'], $options['overwrite_existing'] ); $results['messages'][] = __('GPX files imported to /wp-content/uploads/rfcc-gpx/', 'route-finder'); } rfcc_import_status_step(__('Saving', 'route-finder')); $wpdb->query('COMMIT'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $results['success'] = true; $results['messages'][] = __('Import completed successfully', 'route-finder'); // Clear caches rfcc_import_status_step(__('Clearing caches', 'route-finder')); self::clear_all_caches(); } catch (Throwable $e) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching /* translators: %s: error message */ $results['errors'][] = sprintf(__('Import failed: %s', 'route-finder'), $e->getMessage()); } return $results; } /** * Validate import data structure * * @param array $data Import data * @return bool Valid or not */ public static function validate_import_data($data) { if (!is_array($data)) { return false; } // Check required structure $required_keys = array('export_info'); foreach ($required_keys as $key) { if (!isset($data[$key])) { return false; } } // Validate export info if (!isset($data['export_info']['plugin_version']) || !isset($data['export_info']['export_date'])) { return false; } return true; } /** * Import plugin options with smart merge * * Merge Strategy: * - If option doesn't exist: CREATE it * - If option exists: MERGE or OVERWRITE based on $overwrite flag * - Smart merge: Only update if existing option is empty/default * * @param array $options Options to import * @param bool $overwrite Whether to use full overwrite mode (legacy behavior) * @return int Number of options imported */ public static function import_plugin_options($options, $overwrite = false) { $stats = array( 'created' => 0, 'updated' => 0, 'skipped' => 0 ); if (!is_array($options)) { return 0; } foreach ($options as $option_name => $option_value) { // Only ever write this plugin's own options - a backup file is // just JSON an admin uploads, and could come from an untrusted // source; without this, a crafted file could use this importer // to overwrite arbitrary WordPress options (default_role, // users_can_register, etc.), not just this plugin's settings. // The export side already only ever writes rfcc_* keys (see // export_plugin_options() above), so this doesn't affect any // legitimate export/import round-trip. if (!self::is_importable_option_name($option_name)) { $stats['skipped']++; continue; } $existing_value = get_option($option_name, null); $option_exists = ($existing_value !== null); // Never change an option's shape (a list/settings array replaced // by a string, or the reverse) - the code reading it expects the // shape it already has. if ($option_exists && !empty($existing_value) && is_array($existing_value) !== is_array($option_value)) { $stats['skipped']++; continue; } // update_option() below also runs each option's registered // sanitize callback (sanitize_option()). $option_value = self::sanitize_imported_value($option_value); if (!$option_exists) { // CASE 1: Option doesn't exist - CREATE IT update_option($option_name, $option_value); $stats['created']++; } elseif ($overwrite) { // CASE 2: Full overwrite mode - REPLACE IT update_option($option_name, $option_value); $stats['updated']++; } else { // CASE 3: Smart merge mode - UPDATE IF EMPTY $should_update = false; // Check if existing value is empty/default if ($existing_value === '' || $existing_value === false || $existing_value === null) { $should_update = true; } elseif (is_array($existing_value) && empty($existing_value)) { // Empty array $should_update = true; } elseif (is_array($existing_value) && is_array($option_value)) { // For array options (like activity_types, club_groups, etc.), // ALWAYS merge to get new items from backup // This ensures new activity types, club groups, etc. are added $merged = self::merge_array_options($existing_value, $option_value); if ($merged !== $existing_value) { update_option($option_name, $merged); $stats['updated']++; continue; // Skip the should_update check below } } if ($should_update && !empty($option_value)) { update_option($option_name, $option_value); $stats['updated']++; } else { $stats['skipped']++; } } } return $stats['created'] + $stats['updated']; } /** * Whether an option from an import file may be written. * * Only this plugin's own rfcc_* settings. Internal bookkeeping options * (backups, and the uninstall "delete all data" switch, which must only * ever be turned on deliberately in the settings screen) are never * imported. * * @param mixed $option_name Option name from the import file. * @return bool */ private static function is_importable_option_name($option_name) { if (!is_string($option_name) || !preg_match('/^rfcc_[a-z0-9_]{1,186}$/', $option_name)) { return false; } if ('rfcc_enable_uninstall_cleanup' === $option_name) { return false; } foreach (array('rfcc_migration_backup_', 'rfcc_pre_prod_import_backup_', 'rfcc_cpt_migration_backup_', 'rfcc_gpx_migration_backup_') as $prefix) { if (strpos($option_name, $prefix) === 0) { return false; } } return true; } /** * Sanitize a value from an import file before it is stored. * * JSON only produces scalars, null and arrays; anything else is dropped. * Strings that contain markup are run through wp_kses_post() unless the * current user may post unfiltered HTML (the same rule core applies to * post content), so an import can't add scripts that the importing user * couldn't have typed into the settings screens. Strings without '<' * are left untouched so URLs, templates and keys round-trip exactly. * * @param mixed $value Imported value. * @return mixed */ private static function sanitize_imported_value($value) { if (is_array($value)) { $clean = array(); foreach ($value as $key => $item) { $clean[$key] = self::sanitize_imported_value($item); } return $clean; } if (is_string($value)) { if (strpos($value, '<') !== false && !current_user_can('unfiltered_html')) { return wp_kses_post($value); } return $value; } if (is_int($value) || is_float($value) || is_bool($value) || null === $value) { return $value; } return null; } /** * Whether a post meta key from an import file may be written. * * Keeps every key the plugin, ACF and the theme store on routes and * destinations (so backups round-trip), but never WordPress-internal * bookkeeping keys (edit locks, trash state, attachment/template data). * * @param mixed $meta_key Meta key from the import file. * @return bool */ private static function is_importable_meta_key($meta_key) { if (!is_string($meta_key) || !preg_match('/^[A-Za-z0-9_-]{1,255}$/', $meta_key)) { return false; } if (strpos($meta_key, '_wp_') === 0) { return false; } return !in_array($meta_key, array('_edit_lock', '_edit_last', '_thumbnail_id', '_encloseme', '_pingme'), true); } /** * Validate and normalise one imported route/destination item. * * @param mixed $item Item from the import file. * @return array|null Array with 'post_data' and 'meta_data', or null if unusable. */ private static function prepare_imported_post_item($item) { if (!is_array($item) || !isset($item['post_data']) || !is_array($item['post_data'])) { return null; } $post_data = $item['post_data']; $status = isset($post_data['post_status']) && is_string($post_data['post_status']) ? $post_data['post_status'] : 'draft'; if (!in_array($status, array('publish', 'draft', 'pending', 'private'), true)) { $status = 'draft'; } $clean_post = array( 'post_title' => isset($post_data['post_title']) && is_scalar($post_data['post_title']) ? (string) $post_data['post_title'] : '', 'post_content' => isset($post_data['post_content']) && is_scalar($post_data['post_content']) ? (string) $post_data['post_content'] : '', 'post_excerpt' => isset($post_data['post_excerpt']) && is_scalar($post_data['post_excerpt']) ? (string) $post_data['post_excerpt'] : '', 'post_status' => $status, 'post_name' => isset($post_data['post_name']) && is_scalar($post_data['post_name']) ? sanitize_title((string) $post_data['post_name']) : '', ); if (isset($post_data['ID']) && is_numeric($post_data['ID'])) { $clean_post['ID'] = (int) $post_data['ID']; } $clean_meta = array(); if (isset($item['meta_data']) && is_array($item['meta_data'])) { foreach ($item['meta_data'] as $meta_key => $meta_value) { if (self::is_importable_meta_key($meta_key)) { $clean_meta[$meta_key] = self::sanitize_imported_value($meta_value); } } } return array( 'post_data' => $clean_post, 'meta_data' => $clean_meta, ); } /** * Merge array options intelligently * * For indexed arrays (like activity_types, club_groups): * - Add items from backup that don't exist in live * - Keep items from live that don't exist in backup * - Identify by 'name' field if present * * @param array $existing Existing option value * @param array $imported Imported option value * @return array Merged array */ private static function merge_array_options($existing, $imported) { // If not both arrays, just return imported if (!is_array($existing) || !is_array($imported)) { return $imported; } // Check if this is an indexed array of associative arrays (like activity_types) if (isset($imported[0]) && is_array($imported[0]) && isset($imported[0]['name'])) { // Merge by 'name' field $merged = $existing; $existing_names = array(); // Build index of existing names foreach ($existing as $item) { if (isset($item['name'])) { $existing_names[$item['name']] = true; } } // Add items from import that don't exist in live foreach ($imported as $item) { if (isset($item['name']) && !isset($existing_names[$item['name']])) { $merged[] = $item; } } return $merged; } else { // For associative arrays or simple arrays, prefer imported value return $imported; } } /** * Remap tea stop IDs in route meta data * * @param array $meta_data Route meta data * @param array $id_map Mapping of old ID -> new ID * @return array Updated meta data with remapped IDs */ private static function remap_teastop_ids($meta_data, $id_map) { // Fields that contain tea stop IDs $teastop_fields = array('main_stop', 'additional_teastops'); foreach ($teastop_fields as $field) { if (!isset($meta_data[$field]) || empty($meta_data[$field])) { continue; } $json = $meta_data[$field]; $data = is_string($json) ? json_decode($json, true) : null; if (!is_array($data)) { continue; } $remapped = array(); foreach ($data as $item) { if (is_array($item)) { // Format: {"id": 123, "km": 10} if (isset($item['id']) && isset($id_map[$item['id']])) { $old_id = $item['id']; $item['id'] = $id_map[$old_id]; } $remapped[] = $item; } else { // Format: [123, 456, 789] $old_id = intval($item); if (isset($id_map[$old_id])) { $remapped[] = $id_map[$old_id]; } else { $remapped[] = $old_id; } } } $meta_data[$field] = wp_json_encode($remapped); } return $meta_data; } /** * Import routes with smart merge strategy * * Merge Strategy: * - If route doesn't exist (by unique_id): CREATE it * - If route exists: MERGE data (only update fields that have values in import) * - If route exists in live site but not in backup: LEAVE IT ALONE * * @param array $routes Routes to import * @param bool $overwrite Whether to use full overwrite mode (legacy behavior) * @param array $teastop_id_map Optional mapping of old tea stop IDs to new IDs * @return int Number of routes processed (created or updated) */ public static function import_routes($routes, $overwrite = false, $teastop_id_map = array()) { $stats = array( 'created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0 ); // Track old (source site) route ID -> new/matched (target site) route ID - // needed by callers that remap route ID references elsewhere (e.g. manual // route group overrides), the same way $teastop_id_map lets this function // remap teastop references in main_stop/additional_teastops above. $id_map = array(); if (!is_array($routes)) { return array('count' => 0, 'id_map' => $id_map); } $total_routes = count($routes); $route_num = 0; foreach ($routes as $route_data) { $route_num++; if ($route_num === 1 || $route_num % 25 === 0) { /* translators: 1: route number, 2: total routes */ rfcc_import_status_step(sprintf(__('Importing routes (%1$d of %2$d)', 'route-finder'), $route_num, $total_routes)); } try { $route_data = self::prepare_imported_post_item($route_data); if (null === $route_data) { $stats['errors']++; continue; } $post_data = $route_data['post_data']; $meta_data = $route_data['meta_data']; // Remap tea stop IDs in meta data if mapping provided if (!empty($teastop_id_map)) { $meta_data = self::remap_teastop_ids($meta_data, $teastop_id_map); } // Check if route exists by unique_id $existing_route = null; $unique_id = isset($meta_data['unique_id']) && is_scalar($meta_data['unique_id']) ? (string) $meta_data['unique_id'] : null; if ($unique_id) { $existing_posts = get_posts(array( 'post_type' => 'rfcc_route', 'meta_key' => 'unique_id', 'meta_value' => $unique_id, 'post_status' => 'any', 'numberposts' => 1 )); if (!empty($existing_posts)) { $existing_route = $existing_posts[0]; } } // CASE 1: Route doesn't exist - CREATE IT if (!$existing_route) { $post_insert_data = array( 'post_type' => 'rfcc_route', 'post_title' => $post_data['post_title'], 'post_content' => $post_data['post_content'], 'post_excerpt' => $post_data['post_excerpt'], 'post_status' => $post_data['post_status'], 'post_name' => $post_data['post_name'] ); $post_id = wp_insert_post($post_insert_data); if (!is_wp_error($post_id)) { // Import all meta data for new route (including ACF internal fields). // wp_slash(): update_post_meta() unslashes, which would strip the // backslashes out of JSON escapes (e.g. \u00f4 in town names). foreach ($meta_data as $meta_key => $meta_value) { update_post_meta($post_id, $meta_key, wp_slash($meta_value)); } if (isset($post_data['ID'])) { $id_map[$post_data['ID']] = $post_id; } $stats['created']++; } else { $stats['errors']++; } // CASE 2: Route exists - MERGE OR OVERWRITE } else { $post_id = $existing_route->ID; if (isset($post_data['ID'])) { $id_map[$post_data['ID']] = $post_id; } if ($overwrite) { // LEGACY MODE: Full overwrite $post_insert_data = array( 'ID' => $post_id, 'post_title' => $post_data['post_title'], 'post_content' => $post_data['post_content'], 'post_excerpt' => $post_data['post_excerpt'], 'post_status' => $post_data['post_status'], 'post_name' => $post_data['post_name'] ); wp_update_post($post_insert_data); // Overwrite all meta (including ACF internal fields) foreach ($meta_data as $meta_key => $meta_value) { update_post_meta($post_id, $meta_key, wp_slash($meta_value)); } $stats['updated']++; } else { // SMART MERGE MODE: Only update missing or empty fields $needs_update = false; $updated_fields = array(); // Check and merge post fields $post_update_data = array('ID' => $post_id); // Merge post_content if existing is empty and import has content if (empty($existing_route->post_content) && !empty($post_data['post_content'])) { $post_update_data['post_content'] = $post_data['post_content']; $updated_fields[] = 'post_content'; $needs_update = true; } // Merge post_excerpt if existing is empty and import has content if (empty($existing_route->post_excerpt) && !empty($post_data['post_excerpt'])) { $post_update_data['post_excerpt'] = $post_data['post_excerpt']; $updated_fields[] = 'post_excerpt'; $needs_update = true; } // Update post if needed if (count($post_update_data) > 1) { wp_update_post($post_update_data); } // Merge meta data - only update if field is missing or empty foreach ($meta_data as $meta_key => $meta_value) { // Handle ACF internal fields separately $is_acf_internal = (strpos($meta_key, '_') === 0); if (!$is_acf_internal) { $existing_value = get_post_meta($post_id, $meta_key, true); // Update if: field doesn't exist OR field is empty AND import has a value $should_update = false; if ($existing_value === '' || $existing_value === false || $existing_value === null) { // Field is empty/missing if (!empty($meta_value) || $meta_value === '0' || $meta_value === 0) { $should_update = true; } } elseif (is_array($existing_value) && empty($existing_value)) { // Field is empty array if (!empty($meta_value)) { $should_update = true; } } if ($should_update) { update_post_meta($post_id, $meta_key, wp_slash($meta_value)); $updated_fields[] = $meta_key; $needs_update = true; // Also update corresponding ACF internal field (e.g., _main_stop) $acf_internal_key = '_' . $meta_key; if (isset($meta_data[$acf_internal_key])) { update_post_meta($post_id, $acf_internal_key, wp_slash($meta_data[$acf_internal_key])); } } } } if ($needs_update) { $stats['updated']++; } else { $stats['skipped']++; } } } } catch (Throwable $e) { $stats['errors']++; } } // Log summary return array( 'count' => $stats['created'] + $stats['updated'], 'id_map' => $id_map ); } /** * Import manual route group overrides (rfcc_manual_route_group_overrides) - * handled as its own step rather than through the generic import_plugin_options() * merge, for two reasons: * * 1. Each entry references routes by ID (route_id or route_ids), which almost * never match between the source and target site - this must run AFTER * import_routes() so the route ID map it now returns is available to * translate those references to the target site's actual route IDs. * 2. This option is an associative array keyed by generated string IDs (e.g. * "manual_group_1780762351"), not the 'name'-keyed shape * import_plugin_options()'s generic array merge knows how to combine - * its fallback for anything else is to return the imported value outright, * which silently replaced the target site's entire manual group * configuration on every import. This does a real union merge instead: * every existing override is kept, and only genuinely new overrides * (after remapping) get added. * * @param array $imported_overrides The imported rfcc_manual_route_group_overrides value. * @param array $route_id_map Source route ID -> target route ID, from import_routes(). * @param bool $overwrite Whether to use full overwrite mode (legacy behavior). * @return int Number of override entries added (or, in overwrite mode, now present). */ private static function import_manual_route_group_overrides($imported_overrides, $route_id_map, $overwrite = false) { if (!is_array($imported_overrides) || empty($imported_overrides)) { return 0; } // Remap route ID references to the target site's IDs before doing // anything else with these entries. A reference with no mapping (the // route wasn't in this import) is dropped, never kept as-is: the raw // source ID usually belongs to a different route on this site. foreach ($imported_overrides as $key => $override) { if (!is_array($override)) { unset($imported_overrides[$key]); continue; } if (isset($override['route_ids'])) { $ids = array(); foreach ((array) $override['route_ids'] as $rid) { if (is_scalar($rid) && isset($route_id_map[$rid])) { $ids[] = absint($route_id_map[$rid]); } } if (empty($ids)) { unset($imported_overrides[$key]); continue; } $imported_overrides[$key]['route_ids'] = $ids; } elseif (isset($override['route_id'])) { if (!is_scalar($override['route_id']) || !isset($route_id_map[$override['route_id']])) { unset($imported_overrides[$key]); continue; } $imported_overrides[$key]['route_id'] = absint($route_id_map[$override['route_id']]); } } if (function_exists('rfcc_prune_manual_group_overrides')) { $imported_overrides = rfcc_prune_manual_group_overrides($imported_overrides); } $imported_overrides = self::sanitize_imported_value($imported_overrides); if ($overwrite) { update_option('rfcc_manual_route_group_overrides', $imported_overrides); return count($imported_overrides); } if (empty($imported_overrides)) { return 0; } $existing = get_option('rfcc_manual_route_group_overrides', array()); if (!is_array($existing)) { $existing = array(); } $merged = $existing; $added = 0; foreach ($imported_overrides as $key => $override) { // Skip anything whose (remapped) content already exists, so merging // the same backup into the same site twice doesn't pile up duplicates. $already_present = false; foreach ($existing as $existing_override) { if ($existing_override == $override) { // phpcs:ignore Universal.Operators.StrictComparisons -- deliberate loose compare: array key order can differ harmlessly between the two sites' exports $already_present = true; break; } } if ($already_present) { continue; } // Avoid clobbering an existing entry that happens to reuse the same // generated key (e.g. both sites created a manual_group override in // the same second). $use_key = isset($merged[$key]) ? $key . '_imported_' . uniqid() : $key; $merged[$use_key] = $override; $added++; } if ($added > 0) { update_option('rfcc_manual_route_group_overrides', $merged); } return $added; } /** * Import tea stops with smart merge strategy * * Merge Strategy: * - If tea stop doesn't exist (by title): CREATE it * - If tea stop exists: MERGE data (only update fields that have values in import) * - If tea stop exists in live site but not in backup: LEAVE IT ALONE * * @param array $tea_stops Tea stops to import * @param bool $overwrite Whether to use full overwrite mode (legacy behavior) * @return int Number of tea stops processed (created or updated) */ public static function import_tea_stops($tea_stops, $overwrite = false) { $stats = array( 'created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0 ); // Track old ID -> new ID mapping for route references $id_map = array(); if (!is_array($tea_stops)) { return array('count' => 0, 'id_map' => $id_map); } $total_stops = count($tea_stops); $stop_num = 0; foreach ($tea_stops as $tea_stop_data) { $stop_num++; if ($stop_num === 1 || $stop_num % 25 === 0) { /* translators: 1: destination number, 2: total destinations */ rfcc_import_status_step(sprintf(__('Importing destinations (%1$d of %2$d)', 'route-finder'), $stop_num, $total_stops)); } try { $tea_stop_data = self::prepare_imported_post_item($tea_stop_data); if (null === $tea_stop_data) { $stats['errors']++; continue; } $post_data = $tea_stop_data['post_data']; $meta_data = $tea_stop_data['meta_data']; // Check if tea stop exists by title $existing_query = new WP_Query( array( 'post_type' => 'rfcc_teastop', 'title' => $post_data['post_title'], 'post_status' => 'all', 'posts_per_page' => 1, 'no_found_rows' => true, 'ignore_sticky_posts' => true, 'update_post_term_cache' => false, 'update_post_meta_cache' => false, ) ); $existing_tea_stop = $existing_query->have_posts() ? $existing_query->posts[0] : null; // CASE 1: Tea stop doesn't exist - CREATE IT if (!$existing_tea_stop) { $post_insert_data = array( 'post_type' => 'rfcc_teastop', 'post_title' => $post_data['post_title'], 'post_content' => $post_data['post_content'], 'post_excerpt' => $post_data['post_excerpt'], 'post_status' => $post_data['post_status'], 'post_name' => $post_data['post_name'] ); $post_id = wp_insert_post($post_insert_data); if (!is_wp_error($post_id)) { // Import all meta data for new tea stop (including ACF internal fields) foreach ($meta_data as $meta_key => $meta_value) { update_post_meta($post_id, $meta_key, wp_slash($meta_value)); } // Map old ID to new ID if (isset($post_data['ID'])) { $id_map[$post_data['ID']] = $post_id; } $stats['created']++; } else { $stats['errors']++; } // CASE 2: Tea stop exists - MERGE OR OVERWRITE } else { $post_id = $existing_tea_stop->ID; // Map old ID to existing ID if (isset($post_data['ID'])) { $id_map[$post_data['ID']] = $post_id; } if ($overwrite) { // LEGACY MODE: Full overwrite $post_insert_data = array( 'ID' => $post_id, 'post_title' => $post_data['post_title'], 'post_content' => $post_data['post_content'], 'post_excerpt' => $post_data['post_excerpt'], 'post_status' => $post_data['post_status'], 'post_name' => $post_data['post_name'] ); wp_update_post($post_insert_data); // Overwrite all meta (including ACF internal fields) foreach ($meta_data as $meta_key => $meta_value) { update_post_meta($post_id, $meta_key, wp_slash($meta_value)); } $stats['updated']++; } else { // SMART MERGE MODE: Only update missing or empty fields $needs_update = false; $updated_fields = array(); // Merge post fields $post_update_data = array('ID' => $post_id); if (empty($existing_tea_stop->post_content) && !empty($post_data['post_content'])) { $post_update_data['post_content'] = $post_data['post_content']; $updated_fields[] = 'post_content'; $needs_update = true; } if (empty($existing_tea_stop->post_excerpt) && !empty($post_data['post_excerpt'])) { $post_update_data['post_excerpt'] = $post_data['post_excerpt']; $updated_fields[] = 'post_excerpt'; $needs_update = true; } if (count($post_update_data) > 1) { wp_update_post($post_update_data); } // Merge meta data - only update if field is missing or empty foreach ($meta_data as $meta_key => $meta_value) { // Handle ACF internal fields separately $is_acf_internal = (strpos($meta_key, '_') === 0); if (!$is_acf_internal) { $existing_value = get_post_meta($post_id, $meta_key, true); $should_update = false; if ($existing_value === '' || $existing_value === false || $existing_value === null) { if (!empty($meta_value) || $meta_value === '0' || $meta_value === 0) { $should_update = true; } } elseif (is_array($existing_value) && empty($existing_value)) { if (!empty($meta_value)) { $should_update = true; } } if ($should_update) { update_post_meta($post_id, $meta_key, wp_slash($meta_value)); $updated_fields[] = $meta_key; $needs_update = true; // Also update corresponding ACF internal field $acf_internal_key = '_' . $meta_key; if (isset($meta_data[$acf_internal_key])) { update_post_meta($post_id, $acf_internal_key, wp_slash($meta_data[$acf_internal_key])); } } } } if ($needs_update) { $stats['updated']++; } else { $stats['skipped']++; } } } } catch (Throwable $e) { $stats['errors']++; } } // Log summary return array( 'count' => $stats['created'] + $stats['updated'], 'id_map' => $id_map ); } /** * Import route stats * * @param array $stats Route stats to import * @param bool $overwrite Whether to overwrite existing * @return int Number of stats imported */ public static function import_route_stats($stats, $overwrite = false) { global $wpdb; $table_name = $wpdb->prefix . 'rfcc_route_stats'; $count = 0; if (!is_array($stats) || empty($stats)) { return 0; } // The stats table is optional (no longer created on fresh installs). if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table_name))) !== $table_name) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-off existence check during an admin import return 0; } foreach ($stats as $stat) { if (!is_array($stat) || !isset($stat['route_id'], $stat['views'], $stat['downloads'], $stat['last_updated']) || !is_numeric($stat['route_id'])) { continue; } $stat = array( 'route_id' => absint($stat['route_id']), 'views' => absint($stat['views']), 'downloads' => absint($stat['downloads']), 'last_updated' => is_scalar($stat['last_updated']) ? sanitize_text_field((string) $stat['last_updated']) : '', ); // Check if stat exists $existing = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table name is safely constructed from $wpdb->prefix "SELECT id FROM `" . esc_sql( $table_name ) . "` WHERE route_id = %d", $stat['route_id'] ) ); if ($existing && !$overwrite) { continue; } if ($existing && $overwrite) { $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $table_name, array( 'views' => $stat['views'], 'downloads' => $stat['downloads'], 'last_updated' => $stat['last_updated'] ), array('route_id' => $stat['route_id']), array('%d', '%d', '%s'), array('%d') ); } else { $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $table_name, array( 'route_id' => $stat['route_id'], 'views' => $stat['views'], 'downloads' => $stat['downloads'], 'last_updated' => $stat['last_updated'] ), array('%d', '%d', '%d', '%s') ); } $count++; } return $count; } /** * Import GPX files * * Handles both new ZIP format and legacy individual files format. * Restores GPX files to /wp-content/uploads/rfcc-gpx/ directory. * * @param array $gpx_data GPX files data (either ZIP or individual files) * @param bool $overwrite Whether to overwrite existing files * @return int Number of files imported */ public static function import_gpx_files($gpx_data, $overwrite = false) { if (empty($gpx_data)) { return 0; } $upload_dir = wp_upload_dir(); $gpx_dir = $upload_dir['basedir'] . '/rfcc-gpx'; // Create directory if it doesn't exist if (!is_dir($gpx_dir)) { wp_mkdir_p($gpx_dir); } // Check format type if (isset($gpx_data['type'])) { if ($gpx_data['type'] === 'zip') { return self::import_gpx_files_from_zip($gpx_data, $gpx_dir, $overwrite); } elseif ($gpx_data['type'] === 'individual') { return self::import_gpx_files_individual($gpx_data['files'], $gpx_dir, $overwrite); } } // Legacy format (no type field) - assume individual files return self::import_gpx_files_individual($gpx_data, $gpx_dir, $overwrite); } /** * Import GPX files from ZIP archive * * @param array $zip_data ZIP data with base64 content * @param string $gpx_dir Target directory * @param bool $overwrite Whether to overwrite existing files * @return int Number of files imported */ private static function import_gpx_files_from_zip($zip_data, $gpx_dir, $overwrite) { if (!isset($zip_data['content']) || !is_string($zip_data['content'])) { return 0; } // Check if ZipArchive is available if (!class_exists('ZipArchive')) { return 0; } $filesystem = self::get_filesystem(); if (!$filesystem) { return 0; } // The ZIP travels base64-encoded inside the JSON export (binary-safe // transport, see export_gpx_files()) - not obfuscation. $zip_content = base64_decode($zip_data['content'], true); if ($zip_content === false) { return 0; } // Temporary ZIP file (ZipArchive doesn't need a .zip extension). $temp_zip = self::create_temp_file('rfcc_import_'); if (!$temp_zip || !$filesystem->put_contents($temp_zip, $zip_content, 0600)) { if ($temp_zip) { wp_delete_file($temp_zip); } return 0; } $zip = new ZipArchive(); if ($zip->open($temp_zip) !== true) { wp_delete_file($temp_zip); return 0; } $count = 0; // Extract each file for ($i = 0; $i < $zip->numFiles; $i++) { $filename = self::sanitize_gpx_import_filename($zip->getNameIndex($i)); if ('' === $filename) { continue; // Not a plain *.gpx file name - never written. } $file_path = $gpx_dir . '/' . $filename; // Skip if file exists and not overwriting if (file_exists($file_path) && !$overwrite) { continue; } // Extract file content $content = $zip->getFromIndex($i); if ($content !== false && self::is_gpx_content($content)) { if ($filesystem->put_contents($file_path, $content, self::FILE_MODE)) { $count++; } } } $zip->close(); wp_delete_file($temp_zip); return $count; } /** * Import individual GPX files (legacy format) * * @param array $gpx_files Individual GPX files data * @param string $gpx_dir Target directory * @param bool $overwrite Whether to overwrite existing files * @return int Number of files imported */ private static function import_gpx_files_individual($gpx_files, $gpx_dir, $overwrite) { $count = 0; if (!is_array($gpx_files)) { return 0; } $filesystem = self::get_filesystem(); if (!$filesystem) { return 0; } foreach ($gpx_files as $gpx_data) { if (!is_array($gpx_data) || !isset($gpx_data['filename']) || !isset($gpx_data['content']) || !is_string($gpx_data['content'])) { continue; } // Only a plain "*.gpx" file name is accepted: basename() strips // any directory traversal ("../../x.gpx"), and anything that // isn't a GPX file name (x.php, .htaccess, x.php.gpx) is skipped. $filename = self::sanitize_gpx_import_filename($gpx_data['filename']); if ('' === $filename) { continue; } $file_path = $gpx_dir . '/' . $filename; // Skip if file exists and not overwriting if (file_exists($file_path) && !$overwrite) { continue; } // Binary-safe transport inside the JSON export - not obfuscation. $content = base64_decode($gpx_data['content'], true); if ($content === false || !self::is_gpx_content($content)) { continue; } if ($filesystem->put_contents($file_path, $content, self::FILE_MODE)) { $count++; } } return $count; } /** * Reduce an imported file name to a safe GPX file name. * * @param mixed $filename File name from the import (ZIP entry or JSON). * @return string The bare file name, or '' if it isn't an acceptable * *.gpx name (wrong extension, hidden file, or an * executable-looking inner extension such as x.php.gpx, * which some server configurations would still run). */ private static function sanitize_gpx_import_filename($filename) { if (!is_string($filename) || '' === $filename) { return ''; } $filename = basename(str_replace('\\', '/', $filename)); if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,200}\.gpx$/i', $filename)) { return ''; } $parts = explode('.', strtolower($filename)); array_pop($parts); // The final "gpx". array_shift($parts); // The base name itself. $blocked = array('php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'phar', 'pht', 'phps', 'shtml', 'cgi', 'pl', 'py', 'asp', 'aspx', 'jsp', 'sh', 'htaccess', 'html', 'htm', 'svg', 'js'); if (array_intersect($parts, $blocked)) { return ''; } return $filename; } /** * Whether file content is a GPX document. * * @param string $content File content. * @return bool */ private static function is_gpx_content($content) { return is_string($content) && stripos($content, 'method) { return $wp_filesystem; } if ('direct' === get_filesystem_method() && WP_Filesystem() && $wp_filesystem instanceof WP_Filesystem_Base) { return $wp_filesystem; } require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-base.php'; require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-direct.php'; return new WP_Filesystem_Direct(null); } /** * Create an empty temporary file (removed by the caller). * * @param string $prefix File name prefix. * @return string|false Path, or false on failure. */ private static function create_temp_file($prefix) { if (!function_exists('wp_tempnam')) { require_once ABSPATH . 'wp-admin/includes/file.php'; } $path = wp_tempnam($prefix); return ($path && file_exists($path)) ? $path : false; } /** * Import club groups * * @param array $club_groups Club groups to import * @param bool $overwrite Whether to overwrite existing * @return int Number of club groups imported */ public static function import_club_groups($club_groups, $overwrite = false) { // Only reachable from an old-format export file's top-level "club_groups" // key (export_all_data() hasn't produced that shape for a long time - // export_club_groups() is dead on the write side, kept only so this // function has something to read if such a file is ever uploaded). // Definition Groups (rfcc_activity_definition_groups) are the // authoritative source now, so this routes into the untagged/"Default" // group's own club_groups list instead of the dead rfcc_club_groups // option nothing reads anymore - creating that group if none exists yet. if (!function_exists('rfcc_get_definition_groups') || !function_exists('rfcc_generate_definition_group_id')) { return 0; } $groups = rfcc_get_definition_groups(); $default_index = null; foreach ($groups as $i => $group) { if (empty($group['activity_types'])) { $default_index = $i; break; } } if ($default_index === null) { $groups[] = array( 'id' => rfcc_generate_definition_group_id('grp'), 'name' => __('Default', 'route-finder'), 'activity_types' => array(), 'club_groups' => array(), 'climbing_profiles' => array(), 'difficulty_scale' => null, 'tags' => array(), 'distance_range' => array('min' => null, 'max' => null), 'ascent_range' => array('min' => null, 'max' => null), ); $default_index = count($groups) - 1; } if (!$overwrite && !empty($groups[$default_index]['club_groups'])) { return 0; } $processed = array(); foreach ((array) $club_groups as $club_group) { if (!is_array($club_group) || empty($club_group['name'])) { continue; } $club_group = self::sanitize_imported_value($club_group); if (empty($club_group['key'])) { $club_group['key'] = rfcc_generate_definition_group_id('cg'); } $processed[] = $club_group; } $groups[$default_index]['club_groups'] = $processed; update_option('rfcc_activity_definition_groups', $groups); return count($processed); } /** * Create backup before import * * The backup holds every plugin setting (including API keys/secrets) and * all route meta, so it is written to a directory that denies web access * (index.php + .htaccess, see rfcc_secure_import_backup_dir()) under a * random, unguessable file name - the .htaccess rule doesn't apply on * servers such as nginx, so the name must not be predictable either. * Admins download backups through rfcc_get_import_backup_download_url(). * * @param bool $include_gpx_files Whether to include GPX files in backup (default: false) * @return string|false Backup filename, or false if it couldn't be written. */ public static function create_backup($include_gpx_files = false) { $backup_path = rfcc_secure_import_backup_dir(); if (!$backup_path) { return false; } // Don't include GPX files in automatic backups by default (too large) $backup_data = self::export_all_data($include_gpx_files); $backup_filename = 'rfcc-backup-' . wp_date('Y-m-d-H-i-s') . '-' . wp_generate_password(24, false) . '.json'; $filesystem = self::get_filesystem(); if (!$filesystem || !$filesystem->put_contents($backup_path . $backup_filename, wp_json_encode($backup_data, JSON_PRETTY_PRINT), self::FILE_MODE)) { return false; } return $backup_filename; } /** * Public accessor for the uploads-directory filesystem helper, for the * backup directory functions below. * * @return WP_Filesystem_Base|null */ public static function filesystem() { return self::get_filesystem(); } /** * Clear all caches after import */ public static function clear_all_caches() { global $wpdb; // Clear WordPress object cache wp_cache_flush(); // Clear plugin transients $wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_rfcc_%'"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_rfcc_%'"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching // Clear any other plugin-specific caches delete_option('rfcc_route_ranges_cache'); delete_transient('rfcc_all_route_groups'); do_action('rfcc_clear_all_caches'); } } /** * Path of the import-backup directory (with trailing slash). * * @return string */ function rfcc_get_import_backup_dir() { $upload_dir = wp_upload_dir(); return trailingslashit($upload_dir['basedir']) . 'rfcc-backups/'; } /** * Make sure the import-backup directory exists and can't be browsed or * served: adds index.php and a deny-all .htaccess, and renames any backup * left over from older versions (predictable timestamp-only names) to a * random name. * * @return string|false Directory path with trailing slash, or false on failure. */ function rfcc_secure_import_backup_dir() { $dir = rfcc_get_import_backup_dir(); if (!is_dir($dir) && !wp_mkdir_p($dir)) { return false; } $filesystem = RFCC_Export_Import_Manager::filesystem(); if (!$filesystem) { return false; } if (!file_exists($dir . 'index.php')) { $filesystem->put_contents($dir . 'index.php', "put_contents( $dir . '.htaccess', "# Route Finder import backups - never served directly.\n" . "\n\tRequire all denied\n\n" . "\n\tOrder deny,allow\n\tDeny from all\n\n", RFCC_Export_Import_Manager::FILE_MODE ); } // Backups written by older versions used a guessable timestamp-only name. foreach ((array) glob($dir . 'rfcc-backup-*.json') as $legacy) { $name = basename($legacy); if (preg_match('/^rfcc-backup-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.json$/', $name)) { $filesystem->move($legacy, $dir . substr($name, 0, -5) . '-' . wp_generate_password(24, false) . '.json'); } } return $dir; } /** * Harden an existing backup directory left by an older version as soon as * an administrator visits wp-admin (cheap: two file_exists() calls once done). */ function rfcc_maybe_secure_import_backup_dir() { if (!current_user_can('manage_options')) { return; } $dir = rfcc_get_import_backup_dir(); if (is_dir($dir) && (!file_exists($dir . '.htaccess') || !file_exists($dir . 'index.php'))) { rfcc_secure_import_backup_dir(); } } add_action('admin_init', 'rfcc_maybe_secure_import_backup_dir'); /** * Whether a file name is one of this plugin's import backups. * * @param string $filename Bare file name. * @return bool */ function rfcc_is_import_backup_filename($filename) { return is_string($filename) && (bool) preg_match('/^rfcc-[A-Za-z0-9_-]+\.json$/', $filename); } /** * Capability- and nonce-protected download URL for an import backup. * * @param string $filename Bare backup file name (as listed in the backup directory). * @return string */ function rfcc_get_import_backup_download_url($filename) { return wp_nonce_url( add_query_arg( array( 'action' => 'rfcc_download_import_backup', 'file' => rawurlencode($filename), // add_query_arg() doesn't encode values. ), admin_url('admin-post.php') ), 'rfcc_download_import_backup_' . $filename ); } /** * admin-post handler: stream an import backup to an administrator. */ function rfcc_download_import_backup() { if (!current_user_can('manage_options')) { wp_die(esc_html__('You do not have permission to download backups.', 'route-finder'), '', array('response' => 403)); } $filename = isset($_GET['file']) ? sanitize_file_name(wp_unslash($_GET['file'])) : ''; check_admin_referer('rfcc_download_import_backup_' . $filename); $path = rfcc_get_import_backup_dir() . $filename; if (!rfcc_is_import_backup_filename($filename) || !is_file($path)) { wp_die(esc_html__('Backup not found.', 'route-finder'), '', array('response' => 404)); } $filesystem = RFCC_Export_Import_Manager::filesystem(); $content = $filesystem ? $filesystem->get_contents($path) : false; if (false === $content) { wp_die(esc_html__('Backup could not be read.', 'route-finder'), '', array('response' => 500)); } while (ob_get_level() > 0) { ob_end_clean(); } nocache_headers(); header('Content-Type: application/json; charset=utf-8'); header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Content-Length: ' . strlen($content)); header('X-Content-Type-Options: nosniff'); // Raw JSON file download (attachment + nosniff), not HTML - escaping it // would corrupt the file. file_put_contents('php://output', $content); exit; } add_action('admin_post_rfcc_download_import_backup', 'rfcc_download_import_backup'); /** * Handle export/import requests BEFORE page rendering * * NOTE: This handler is DISABLED for the routes-setup page (tab=export-import) * because routes-setup.php has a superior implementation with selective export. * This handler is kept for backward compatibility with the separate * 'rfcc-export-import' admin menu page (if ever re-enabled). */ // Use priority 1 to run before other plugins that might add output add_action('admin_init', 'rfcc_handle_export_import_actions', 1); function rfcc_handle_export_import_actions() { global $plugin_page; // Only run on the standalone export/import page (routes-setup.php's // 'rfcc-setup' page has its own selective export implementation). if ('rfcc-export-import' !== $plugin_page) { return; } // Handle export request if (isset($_POST['rfcc_export'])) { // Verify nonce if (!isset($_POST['_wpnonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])), 'rfcc_export_import')) { wp_die(esc_html__('Security check failed', 'route-finder')); } // CRITICAL: Remove all other admin_init hooks to prevent them from adding output // This must be done before the export handler runs remove_all_actions('admin_notices'); remove_all_actions('all_admin_notices'); // Call export handler (it will exit on success) rfcc_handle_export(); // Function above calls exit() on success, so this only executes on failure wp_die(esc_html__('Export failed unexpectedly', 'route-finder')); } // Import is handled in the page function to display results } /** * Admin page for export/import */ add_action('admin_menu', 'rfcc_add_export_import_menu'); function rfcc_add_export_import_menu() { add_submenu_page( 'edit.php?post_type=rfcc_route', __('Export/Import', 'route-finder'), __('Export/Import', 'route-finder'), 'manage_options', 'rfcc-export-import', 'rfcc_export_import_page' ); } /** * Export/import admin page */ function rfcc_export_import_page() { // Handle import form submission (export is handled in admin_init hook) if (isset($_POST['rfcc_import']) && isset($_FILES['import_file'])) { check_admin_referer('rfcc_export_import'); $result = rfcc_handle_import(); } ?>

tags */ echo wp_kses(sprintf(__('Export progress is being logged. Check %s for detailed information.', 'route-finder'), 'wp-content/debug.log'), array('code' => array())); ?>

  • rfcc_*'), array('code' => array())); ?>








-
-


Backup Management

Automatic backups are stored in /wp-content/uploads/rfcc-backups/

Available Backups:'; echo '
    '; foreach ($backups as $backup) { $filename = basename($backup); $size = size_format(filesize($backup)); $date = wp_date('Y-m-d H:i:s', filemtime($backup)); echo '
  • ' . esc_html($filename) . ' (' . esc_html($size) . ') - ' . esc_html($date) . ' ' . esc_html__('Download', 'route-finder') . '
  • '; } echo '
'; } else { echo '

No backups found.

'; } } else { echo '

Backup directory not yet created.

'; } ?>
0) { ob_end_clean(); } // Set headers for file download - order matters! header('Content-Type: application/json; charset=utf-8', true); header('Content-Disposition: attachment; filename="' . $filename . '"', true); header('Content-Length: ' . strlen($json_content), true); header('Content-Transfer-Encoding: binary', true); header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0', true); header('Cache-Control: post-check=0, pre-check=0', false); header('Pragma: no-cache', true); header('Expires: 0', true); // Additional headers to prevent caching and ensure download header('Connection: close', true); // Disable WordPress shutdown hooks that might add output remove_all_actions('shutdown'); // Free the source array before sending - only the encoded string is needed now. unset($export_data); // Send the file body. This is a JSON file download (Content-Type: // application/json, Content-Disposition: attachment), not HTML, so it // is written to the output stream as-is: HTML-escaping it would // corrupt the JSON. Its content was produced by wp_json_encode() above. file_put_contents('php://output', $json_content); flush(); exit; } catch (Throwable $e) { // Clean output buffer on error if (ob_get_level() > 0) { ob_end_clean(); } wp_die('Export failed: ' . esc_html($e->getMessage())); } } /** * Handle import request */ function rfcc_handle_import() { // Both import forms (this file's page and routes-setup.php's tab) submit // the 'rfcc_export_import' nonce. check_admin_referer('rfcc_export_import'); if (!current_user_can('manage_options')) { wp_die('Insufficient permissions'); } $import_options = array( 'import_routes' => isset($_POST['import_routes']), 'import_tea_stops' => isset($_POST['import_tea_stops']), 'import_options' => isset($_POST['import_options']), 'import_stats' => isset($_POST['import_stats']), 'import_club_groups' => isset($_POST['import_club_groups']), 'import_gpx_files' => isset($_POST['import_gpx_files']), 'overwrite_existing' => isset($_POST['overwrite_existing']), 'create_backup' => isset($_POST['create_backup']) ); // Validate file upload if (!isset($_FILES['import_file']['error']) || UPLOAD_ERR_OK !== absint($_FILES['import_file']['error'])) { return array( 'success' => false, 'errors' => array('File upload failed'), 'messages' => array() ); } // Read and decode JSON file $import_file_path = isset($_FILES['import_file']['tmp_name']) ? sanitize_text_field(wp_unslash($_FILES['import_file']['tmp_name'])) : ''; if ('' === $import_file_path || !is_uploaded_file($import_file_path)) { return array( 'success' => false, 'errors' => array('File upload failed'), 'messages' => array() ); } rfcc_import_status_begin(); rfcc_import_status_step(__('Reading the import file', 'route-finder')); $json_content = file_get_contents($import_file_path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading temp upload file // The decoded structure is validated by import_all_data() (validate_import_data()) // and every value is sanitized for its own type where it is written: // sanitize_imported_value() for options/meta/club groups, // prepare_imported_post_item() for post fields, absint()/sanitize_text_field() // for stats and route-group overrides, sanitize_gpx_import_filename() + // is_gpx_content() for GPX files. A blanket text sanitizer is not applied // here because it would strip the route descriptions' HTML. $import_data = json_decode((string) $json_content, true); unset($json_content); if (json_last_error() !== JSON_ERROR_NONE || !is_array($import_data)) { rfcc_import_status_end(); return array( 'success' => false, 'errors' => array('Invalid JSON file'), 'messages' => array() ); } // Perform import $result = RFCC_Export_Import_Manager::import_all_data($import_data, $import_options); rfcc_import_status_end(); return $result; } /* * Import progress tracking. An import runs inside one page request, so a * server time or memory limit can kill it part-way and leave a blank page with * no message. The current step is written to a small status file (outside the * database, so the import's transaction can't roll it back); if the request * dies, the next visit to an Export/Import screen says where it stopped. */ /** * Path of the import status file (in the protected backup directory; its name * deliberately doesn't match rfcc-*.json, so it is never listed as a backup). * * @return string|false */ function rfcc_import_status_file() { $dir = rfcc_secure_import_backup_dir(); return $dir ? $dir . 'import-status.json' : false; } /** * @return array|null Current status, or null when there is none. */ function rfcc_import_status_read() { $file = rfcc_import_status_file(); if (!$file || !is_file($file)) { return null; } $data = json_decode((string) file_get_contents($file), true); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- small local status file return is_array($data) ? $data : null; } /** * @param array $status Status to store. */ function rfcc_import_status_write($status) { $file = rfcc_import_status_file(); $filesystem = RFCC_Export_Import_Manager::filesystem(); if ($file && $filesystem) { $filesystem->put_contents($file, wp_json_encode($status), RFCC_Export_Import_Manager::FILE_MODE); } } /** * Start tracking an import: raise this request's limits and watch for it * being cut short. */ function rfcc_import_status_begin() { global $rfcc_import_status; // Record the server's own limits (what a failure message should quote), // then ask for more; hosts may cap or ignore either. $rfcc_import_status = array( 'state' => 'running', 'started' => time(), 'step' => '', 'time_limit' => (int) ini_get('max_execution_time'), 'memory' => (string) ini_get('memory_limit'), ); wp_raise_memory_limit('admin'); if (function_exists('set_time_limit')) { set_time_limit(300); } rfcc_import_status_write($rfcc_import_status); register_shutdown_function('rfcc_import_status_shutdown'); } /** * Record the step the import is on. * * @param string $step Human-readable step. */ function rfcc_import_status_step($step) { global $rfcc_import_status; if (!is_array($rfcc_import_status) || $rfcc_import_status['state'] !== 'running') { return; } $rfcc_import_status['step'] = $step; $rfcc_import_status['updated'] = time(); rfcc_import_status_write($rfcc_import_status); } /** * The import returned normally (successfully or with a reported error). */ function rfcc_import_status_end() { global $rfcc_import_status; if (is_array($rfcc_import_status)) { $rfcc_import_status['state'] = 'done'; } $file = rfcc_import_status_file(); if ($file && is_file($file)) { wp_delete_file($file); } } /** * Shutdown: if the import never returned, a fatal error (usually the time or * memory limit) ended the request. Roll back the open transaction, record why, * and say so on the page. */ function rfcc_import_status_shutdown() { global $rfcc_import_status, $wpdb; if (!is_array($rfcc_import_status) || $rfcc_import_status['state'] !== 'running') { return; } $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- abandon the interrupted import's transaction $error = error_get_last(); $rfcc_import_status['state'] = 'failed'; $rfcc_import_status['error'] = ($error && in_array($error['type'], array(E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR), true)) ? $error['message'] : ''; $rfcc_import_status['ended'] = time(); rfcc_import_status_write($rfcc_import_status); rfcc_render_import_status_notice(); } /** * Show (once) why the last import didn't finish. */ function rfcc_render_import_status_notice() { if (!current_user_can('manage_options')) { return; } $status = rfcc_import_status_read(); if (!$status || empty($status['started'])) { return; } global $rfcc_import_status; $is_this_request = is_array($rfcc_import_status) && $rfcc_import_status['state'] === 'failed'; $state = isset($status['state']) ? $status['state'] : ''; // Still marked running: either it really is running in another tab, or // the server killed it without letting PHP finish (no shutdown). if ($state === 'running' && !$is_this_request) { $last = isset($status['updated']) ? (int) $status['updated'] : (int) $status['started']; if (time() - $last < 10 * MINUTE_IN_SECONDS) { echo '

' . esc_html(sprintf( /* translators: 1: time since the import started, 2: current step */ __('An import started %1$s ago may still be running (last step: %2$s). Wait a few minutes before importing again.', 'route-finder'), human_time_diff((int) $status['started']), $status['step'] !== '' ? $status['step'] : __('starting', 'route-finder') )) . '

'; return; } } echo '

' . esc_html__('The last import did not finish.', 'route-finder') . ' '; echo esc_html(sprintf( /* translators: %s: step the import was on */ __('It stopped while: %s.', 'route-finder'), $status['step'] !== '' ? $status['step'] : __('starting', 'route-finder') )) . '

'; if (!empty($status['error'])) { echo '

' . esc_html(sprintf( /* translators: %s: PHP error message */ __('Error: %s', 'route-finder'), $status['error'] )) . '

'; } echo '

' . esc_html(sprintf( /* translators: 1: PHP time limit in seconds, 2: PHP memory limit */ __('The server ended the request, usually because of a time limit (PHP: %1$ss) or memory limit (%2$s). Database changes from that import should have been rolled back; GPX files already copied were kept. Try again without "GPX files", or import GPX files on their own, or ask your host to raise the limits.', 'route-finder'), isset($status['time_limit']) ? (int) $status['time_limit'] : 0, isset($status['memory']) ? $status['memory'] : '?' )) . '

'; if (!$is_this_request) { $file = rfcc_import_status_file(); if ($file && is_file($file)) { wp_delete_file($file); } } }