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 ) . '
';
}
?>
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();
?>
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;
}
?>
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() ); ?>
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 );
}
}
}