render_double_submit_guard(); // A POST body larger than post_max_size makes PHP discard $_POST and // $_FILES entirely, so the form looks un-submitted. Detect that here and // explain it, rather than falling through to a misleading empty form. $method = isset( $_SERVER['REQUEST_METHOD'] ) && is_string( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; $length = isset( $_SERVER['CONTENT_LENGTH'] ) && is_numeric( $_SERVER['CONTENT_LENGTH'] ) ? (int) $_SERVER['CONTENT_LENGTH'] : 0; $post_max = UploadError::ini_size( 'post_max_size' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- a body over post_max leaves $_POST empty (no nonce possible); this only shows a message. if ( array() === $_POST && UploadError::post_max_exceeded( $method, $length, UploadError::bytes( $post_max ) ) ) { $this->render_upload_form( UploadError::post_max_message( $post_max ) ); return; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- step routing only; each handler verifies its own nonce. $step = isset( $_POST['webcal_legacy_step'] ) && is_string( $_POST['webcal_legacy_step'] ) ? sanitize_text_field( wp_unslash( $_POST['webcal_legacy_step'] ) ) : ''; if ( 'upload' === $step ) { $this->handle_upload(); return; } if ( 'serverfile' === $step ) { $this->handle_server_file(); return; } if ( 'run' === $step ) { $this->handle_run(); return; } if ( 'rollback' === $step ) { $this->handle_rollback(); return; } $this->render_upload_form(); } /** * Disable a migration action button and relabel it on submit, so a slow * upload or import cannot be triggered twice. Uses document-level event * delegation, so it works for whichever form is currently rendered. */ private function render_double_submit_guard(): void { ?>

' . esc_html( $error ) . '

'; } ?>

__( 'Analyzing…', 'agenticdaisy-calendar' ) ) ); ?>
render_server_file_form(); $this->render_previous_runs(); } /** * "Import a file already on the server" — the no-upload workaround for * files larger than the PHP upload limit. */ private function render_server_file_form(): void { $files = $this->scan_server_files(); ?>

__( 'Analyzing…', 'agenticdaisy-calendar' ) ) ); ?>
resolve_server_file( $posted ); if ( null === $path ) { $this->render_upload_form( __( 'That file could not be found in the uploads folder. Place a .sql file in wp-content/uploads and try again.', 'agenticdaisy-calendar' ) ); return; } set_transient( self::TRANSIENT . get_current_user_id(), $path, HOUR_IN_SECONDS ); $this->render_mapping_form( $path ); } /** * List `.sql` files sitting in the uploads directory. * * @return array realpath => display name. */ public function scan_server_files(): array { $uploads = wp_upload_dir(); $base = $uploads['basedir']; if ( '' === $base || ! is_dir( $base ) ) { return array(); } $found = array(); foreach ( (array) glob( trailingslashit( $base ) . '*.sql' ) as $path ) { if ( is_string( $path ) && is_file( $path ) ) { $real = realpath( $path ); if ( false !== $real ) { $found[ $real ] = basename( $real ); } } } return $found; } /** * Validate a chosen server path: it must resolve inside the uploads * directory and be an existing `.sql` file. Returns the safe realpath, or * null. The posted value is never trusted directly — this guards against * path traversal / reading arbitrary server files. * * @param string $posted The user-supplied path. * @return string|null */ public function resolve_server_file( string $posted ): ?string { if ( '' === trim( $posted ) ) { return null; } $uploads = wp_upload_dir(); $base = realpath( $uploads['basedir'] ); $real = realpath( $posted ); if ( false === $base || false === $real ) { return null; } if ( $real !== $base && 0 !== strpos( $real, $base . DIRECTORY_SEPARATOR ) ) { return null; } if ( ! is_file( $real ) ) { return null; } if ( 'sql' !== strtolower( (string) pathinfo( $real, PATHINFO_EXTENSION ) ) ) { return null; } return $real; } private function render_previous_runs(): void { $runs = ( new LegacyMigrationImporter( $this->pdo, $this->table_prefix ) )->list_runs(); if ( array() === $runs ) { return; } ?>

"return confirm('" . esc_js( __( 'This permanently removes every event this migration imported. Continue?', 'agenticdaisy-calendar' ) ) . "');" ) ); ?>
pdo, $this->table_prefix ) )->rollback( $import_id ); echo '

' . esc_html( sprintf( /* translators: %d: number of removed events */ __( 'Removed %d migrated events.', 'agenticdaisy-calendar' ), $removed ) ) . '

'; $this->render_upload_form(); } private function handle_upload(): void { check_admin_referer( 'webcal_legacy_migration' ); if ( ! current_user_can( 'webcal_can_import' ) ) { wp_die( esc_html__( 'You do not have permission to import.', 'agenticdaisy-calendar' ) ); } if ( ! function_exists( 'wp_handle_upload' ) ) { /** @psalm-suppress MissingFile -- WP-core file resolved via ABSPATH at runtime. */ require_once ABSPATH . 'wp-admin/includes/file.php'; } /** @psalm-suppress TypeDoesNotContainType -- defensive guard for PHPStan Level 9 */ // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above. if ( ! isset( $_FILES['webcal_legacy_file'] ) || ! is_array( $_FILES['webcal_legacy_file'] ) ) { $this->render_upload_form( __( 'Please select a file to upload.', 'agenticdaisy-calendar' ) ); return; } // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified above; wp_handle_upload() sanitizes. $raw = $_FILES['webcal_legacy_file']; /** * @psalm-suppress RedundantCast -- defensive coercion for PHPStan Level 9 * @psalm-suppress RedundantCastGivenDocblockType -- same as above */ $file = array( 'name' => is_scalar( $raw['name'] ?? null ) ? (string) $raw['name'] : '', 'type' => is_scalar( $raw['type'] ?? null ) ? (string) $raw['type'] : '', 'tmp_name' => is_scalar( $raw['tmp_name'] ?? null ) ? (string) $raw['tmp_name'] : '', 'error' => is_numeric( $raw['error'] ?? null ) ? (int) $raw['error'] : UPLOAD_ERR_NO_FILE, 'size' => is_numeric( $raw['size'] ?? null ) ? (int) $raw['size'] : 0, ); // Surface a rich message (with the current limits and workarounds) for a // rejected upload, instead of wp_handle_upload()'s terse default. if ( UPLOAD_ERR_OK !== $file['error'] ) { $this->render_upload_form( UploadError::for_code( $file['error'], UploadError::ini_size( 'upload_max_filesize' ), UploadError::ini_size( 'post_max_size' ) ) ); return; } $upload = wp_handle_upload( $file, array( 'test_form' => false, 'mimes' => array( 'sql' => 'text/plain' ), ) ); if ( isset( $upload['error'] ) || empty( $upload['file'] ) ) { $this->render_upload_form( is_string( $upload['error'] ?? null ) ? $upload['error'] : __( 'Upload failed.', 'agenticdaisy-calendar' ) ); return; } $staged = $this->stage_upload( $upload['file'] ); if ( null === $staged ) { wp_delete_file( $upload['file'] ); $this->render_upload_form( __( 'The uploaded file could not be stored for the migration.', 'agenticdaisy-calendar' ) ); return; } set_transient( self::TRANSIENT . get_current_user_id(), $staged, self::STAGE_TTL ); // An abandoned or failed migration leaves the file; this removes it. wp_schedule_single_event( time() + self::STAGE_TTL + MINUTE_IN_SECONDS, self::CLEANUP_HOOK ); $this->render_mapping_form( $staged ); } /** * Move an uploaded dump out of the public uploads folder's dated * directories into a private staging directory, under a random name. * * wp_handle_upload() leaves the file web-readable under its original * name, and a 1.9 dump carries users' emails and password hashes. The * staging directory denies web access where the server honours * .htaccess, and the 128-bit name keeps it unguessable where it does not. * * @param string $uploaded Path wp_handle_upload() returned. * @return string|null The staged path, or null when it could not be moved. */ public function stage_upload( string $uploaded ): ?string { $dir = self::staging_dir(); if ( null === $dir ) { return null; } $target = $dir . '/' . bin2hex( random_bytes( 16 ) ) . '.sql'; // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- a same-filesystem move within uploads; WP_Filesystem would need credentials here. if ( ! @rename( $uploaded, $target ) ) { return null; } return $target; } /** * Delete staged dumps older than their TTL. * * Runs from the one-off cleanup event and on every visit to the tab. */ public static function purge_stale_staged_files(): void { $uploads = wp_upload_dir(); $dir = trailingslashit( $uploads['basedir'] ) . self::STAGING_DIR; if ( ! is_dir( $dir ) ) { return; } foreach ( (array) glob( $dir . '/*.sql' ) as $path ) { if ( is_string( $path ) && is_file( $path ) && filemtime( $path ) < time() - self::STAGE_TTL ) { wp_delete_file( $path ); } } } /** * The staging directory, created with deny files if missing. */ private static function staging_dir(): ?string { $uploads = wp_upload_dir(); $dir = trailingslashit( $uploads['basedir'] ) . self::STAGING_DIR; if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { return null; } // phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- tiny guard files in our own directory. if ( ! file_exists( $dir . '/.htaccess' ) ) { file_put_contents( $dir . '/.htaccess', "Require all denied\nDeny from all\n" ); } if ( ! file_exists( $dir . '/index.php' ) ) { file_put_contents( $dir . '/index.php', "parse_file( $path ); $wp_users = array_map( static fn( \WP_User $u ): string => $u->user_login, get_users( array( 'fields' => 'all' ) ) ); $legacy_users = array_map( static fn( array $row ): array => $row, $dump->rows( 'webcal_user' ) ); ?>

count( 'webcal_entry' ), esc_html( $dump->metadata['version'] ?? '1.9.x' ) ); ?>

warnings as $warning ) : ?>

timezone_advisory( $dump, wp_timezone_string() ); ?>





__( 'Running migration…', 'agenticdaisy-calendar' ) ) ); ?>
render_upload_form( __( 'The uploaded file expired. Please upload it again.', 'agenticdaisy-calendar' ) ); return; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above. $raw_map = isset( $_POST['webcal_user_map'] ) && is_array( $_POST['webcal_user_map'] ) ? wp_unslash( $_POST['webcal_user_map'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized per element below. $user_map = array(); foreach ( $raw_map as $legacy => $wp_login ) { if ( is_scalar( $wp_login ) ) { $user_map[ sanitize_text_field( (string) $legacy ) ] = sanitize_text_field( (string) $wp_login ); } } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above. $include = isset( $_POST['webcal_include'] ) && is_array( $_POST['webcal_include'] ) ? array_map( 'boolval', wp_unslash( $_POST['webcal_include'] ) ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- coerced to booleans. $dump = ( new LegacyDumpParser() )->parse_file( $path ); list( $user_map, $created, $notes, $created_ids ) = $this->resolve_user_map( $user_map, $dump ); $importer = new LegacyMigrationImporter( $this->pdo, $this->table_prefix ); try { $report = $importer->import( $dump, wp_timezone(), $user_map, array( 'include_categories' => $include['categories'] ?? false, 'include_reminders' => $include['reminders'] ?? false, 'include_layers' => $include['layers'] ?? false, 'include_remote_cals' => $include['remote_cals'] ?? false, 'include_site_extras' => $include['site_extras'] ?? false, ) ); } catch ( \Throwable $e ) { // The importer rolls its transaction back on failure, so no calendar // data was written — but undo the WordPress accounts we created for // this run (a separate connection the transaction cannot cover), and // keep the staged file so the user can adjust and retry. $this->delete_created_users( $created_ids ); $this->render_upload_form( sprintf( /* translators: %s: error detail */ __( 'The migration could not be completed and no data was imported (any accounts created for it were removed): %s', 'agenticdaisy-calendar' ), $e->getMessage() ) ); return; } wp_delete_file( $path ); delete_transient( self::TRANSIENT . get_current_user_id() ); // Imported remote subscriptions should not sit stale until the next // hourly cron tick; queue one near-immediate sync run. if ( (int) $report['remote'] > 0 ) { wp_schedule_single_event( time() + MINUTE_IN_SECONDS, 'webcal_sync_remote_calendars' ); } foreach ( $notes as $note ) { echo '

' . esc_html( $note ) . '

'; } if ( $created > 0 ) { echo '

' . esc_html( sprintf( /* translators: %d: number of WordPress users created */ __( 'Created %d WordPress users; each was sent a password-setup email.', 'agenticdaisy-calendar' ), $created ) ) . '

'; } ?>

rows( 'webcal_user_pref' ) as $row ) { if ( 'TIMEZONE' === ( $row['cal_setting'] ?? '' ) && '' !== (string) ( $row['cal_value'] ?? '' ) ) { $zones[ (string) $row['cal_value'] ] = true; } } $server_tz = (string) ( $dump->metadata['server_tz'] ?? '' ); if ( '' !== $server_tz ) { $zones[ $server_tz ] = true; } unset( $zones[ $site_tz ] ); if ( array() === $zones ) { return ''; } return sprintf( /* translators: 1: legacy timezone list, 2: site timezone */ __( 'The legacy calendar used time zone(s) %1$s, but this site is set to %2$s. Imported events will show in the site time zone. If that is not intended, set the site time zone (Settings → General) BEFORE importing — it cannot be corrected afterwards.', 'agenticdaisy-calendar' ), implode( ', ', array_keys( $zones ) ), '' !== $site_tz ? $site_tz : 'UTC' ); } /** * Resolves '__create__' mapping entries by creating WordPress accounts. * * The new account uses the (sanitized) legacy login and the legacy email, * gets the site's default role and a password-setup email — legacy * password hashes are never migrated. If the login already exists the * legacy user is mapped onto it and a note is emitted instead. * * @param array $user_map Raw mapping from the form. * @return array{0: array, 1: int, 2: list, 3: list} Resolved map, created count, notes, and the ids of accounts created (so a failed import can undo them). */ public function resolve_user_map( array $user_map, \WebCalendar\WP\Migration\LegacyDump $dump ): array { $created = 0; $created_ids = array(); $notes = array(); $legacy = array(); foreach ( $dump->rows( 'webcal_user' ) as $row ) { $legacy[ (string) ( $row['cal_login'] ?? '' ) ] = $row; } foreach ( $user_map as $legacy_login => $target ) { if ( '__create__' !== $target ) { continue; } $login = sanitize_user( $legacy_login, true ); if ( '' === $login ) { $user_map[ $legacy_login ] = ''; $notes[] = sprintf( /* translators: %s: legacy login */ __( 'Could not create a WordPress user for "%s"; their data was skipped.', 'agenticdaisy-calendar' ), $legacy_login ); continue; } if ( false !== username_exists( $login ) ) { $user_map[ $legacy_login ] = $login; $notes[] = sprintf( /* translators: 1: legacy login, 2: existing WP login */ __( 'A WordPress user named "%2$s" already exists; "%1$s" was mapped to it instead of creating a new account.', 'agenticdaisy-calendar' ), $legacy_login, $login ); continue; } $row = $legacy[ $legacy_login ] ?? array(); $default_role = get_option( 'default_role', 'subscriber' ); $user_id = wp_insert_user( array( 'user_login' => $login, 'user_email' => sanitize_email( (string) ( $row['cal_email'] ?? '' ) ), 'user_pass' => wp_generate_password( 24 ), 'first_name' => (string) ( $row['cal_firstname'] ?? '' ), 'last_name' => (string) ( $row['cal_lastname'] ?? '' ), 'role' => is_string( $default_role ) ? $default_role : 'subscriber', ) ); if ( is_wp_error( $user_id ) ) { $user_map[ $legacy_login ] = ''; $notes[] = sprintf( /* translators: 1: legacy login, 2: error message */ __( 'Could not create a WordPress user for "%1$s" (%2$s); their data was skipped.', 'agenticdaisy-calendar' ), $legacy_login, $user_id->get_error_message() ); continue; } wp_new_user_notification( $user_id, null, 'user' ); $user_map[ $legacy_login ] = $login; $created_ids[] = (int) $user_id; ++$created; } return array( $user_map, $created, $notes, $created_ids ); } /** * Delete WordPress accounts created for a migration run that then failed — * the importer's transaction reverts the calendar data, but wp_insert_user() * writes through a separate connection the transaction cannot cover. * * @param list $ids Account ids created during this run. */ public function delete_created_users( array $ids ): void { if ( array() === $ids ) { return; } if ( ! function_exists( 'wp_delete_user' ) ) { /** @psalm-suppress MissingFile -- WP-core file resolved via ABSPATH at runtime. */ require_once ABSPATH . 'wp-admin/includes/user.php'; } foreach ( $ids as $id ) { wp_delete_user( $id ); } } }