'New Application ({first_name} {last_name})', 'mortgage_application_user_mail_subject' => 'Your Application Receipt', 'mortgage_application_user_mail_message' => 'Thank you very much for submitting your application. We look forward to guiding you through your home loan process and will follow up with you shortly! {all_fields}', 'mortgage_application_reminder_mail_subject' => 'Incomplete Application', 'mortgage_application_reminder_mail_message' => 'To complete your mortgage application please click the following link: {edit_url}', 'mortgage_ma_submissions_subject' => '{name} Uploaded Files', 'mortgage_ma_submissions_client_subject' => 'File Upload Receipt', 'mortgage_application_submision_client_message' => 'Your files have been uploaded successfully!', ); /** * Once: email texts still at their 2.6 default get the 3.0 default. * Anything a site edited is left alone. */ public static function email_defaults() { if ( get_option( 'mapp_email_defaults_30' ) || ! function_exists( 'mapp_default_option' ) ) { return; } $norm = function ( $text ) { return preg_replace( '/\s+/', ' ', trim( wp_strip_all_tags( (string) $text ) ) ); }; foreach ( self::OLD_EMAIL_DEFAULTS as $option => $old ) { $current = get_option( $option, false ); if ( false !== $current && $norm( $current ) === $norm( $old ) ) { update_option( $option, mapp_default_option( $option ) ); } if ( is_multisite() && is_main_site() ) { $network = get_site_option( $option, false ); if ( false !== $network && $norm( $network ) === $norm( $old ) ) { update_site_option( $option, mapp_default_option( $option ) ); } } } update_option( 'mapp_email_defaults_30', time(), false ); } /** @return array */ public static function state() { $state = get_option( self::OPTION, array() ); return wp_parse_args( is_array( $state ) ? $state : array(), array( 'settings' => false, 'files' => false, 'ssn' => false, 'exports' => false, 'imported' => 0, 'ssn_done' => 0, 'errors' => array(), ) ); } /** @return bool */ public static function done() { $s = self::state(); return $s['settings'] && $s['files'] && ! empty( $s['exports'] ) && $s['ssn']; } /** Run the next batch if there is work left. */ public static function maybe_run() { if ( self::done() ) { return; } // A real mutex: two admin requests (a page load plus a heartbeat) must // never run the SSN re-encryption at the same time. if ( ! \Mapp\Security\Lock::acquire( 'upgrade_' . get_current_blog_id(), 5 * MINUTE_IN_SECONDS ) ) { return; } wp_cache_delete( self::OPTION, 'options' ); $state = self::state(); if ( $state['settings'] && $state['files'] && ! empty( $state['exports'] ) && $state['ssn'] ) { \Mapp\Security\Lock::release( 'upgrade_' . get_current_blog_id() ); return; } try { if ( ! $state['settings'] ) { self::settings(); $state['settings'] = true; flush_rewrite_rules( false ); } if ( ! $state['files'] ) { $state['files'] = self::import_files( $state ); } if ( empty( $state['exports'] ) ) { $state['exports'] = self::remove_public_exports( $state ); } if ( ! $state['ssn'] ) { $state['ssn'] = self::reencrypt_ssns( $state ); } } catch ( \Throwable $e ) { $state['errors'][] = gmdate( 'c' ) . ' ' . $e->getMessage(); $state['errors'] = array_slice( $state['errors'], -10 ); } update_option( self::OPTION, $state, false ); \Mapp\Security\Lock::release( 'upgrade_' . get_current_blog_id() ); } /** Step 1: settings defaults that keep 2.6 integrations working. */ public static function settings() { if ( false === get_option( 'mapp_webhook_format', false ) ) { $hooks = function_exists( 'get_front_mortgage_application_option' ) ? trim( (string) get_front_mortgage_application_option( 'mortgage_application_webhooks', 'mortgage_application_use_network_settings' ) ) : ''; update_option( 'mapp_webhook_format', '' !== $hooks ? 'form' : 'json', false ); } } /** * Legacy upload folders that may hold public files. * * @return string[] */ public static function legacy_dirs() { $uploads = wp_upload_dir( null, false ); $blog = get_current_blog_id(); return array_filter( array( trailingslashit( $uploads['basedir'] ) . '1003-mortgage-application/' . $blog . '/', MAPP_MORTGAGE_APP_BASE_PATH . 'uploads/' . $blog . '/', ), 'is_dir' ); } /** * Step 2: import public 2.6 zips into the private store. * * @param array $state State (imported count updated). * @return bool Whether every legacy file is handled. */ public static function import_files( &$state ) { $remaining = 0; $handled = 0; foreach ( self::legacy_dirs() as $dir ) { Store::protect( $dir ); foreach ( (array) glob( $dir . '*' ) as $path ) { if ( ! is_file( $path ) || in_array( basename( $path ), array( '.htaccess', 'web.config', 'index.php' ), true ) ) { continue; } if ( $handled >= self::BATCH ) { ++$remaining; continue; } ++$handled; if ( preg_match( '/^(\d{9,11})uploadedfiles\.zip$/', basename( $path ), $m ) ) { $moved = self::import_zip( $path, (int) $m[1] ); } elseif ( filesize( $path ) <= 16 ) { // 2.6's encryptFile() truncated each upload to its 16-byte IV: // nothing recoverable is left in these. $moved = true; } else { // Anything else that still has content is kept, privately. $moved = self::keep_whole( $path, (int) filemtime( $path ) ); } // Only delete the public copy once the private one exists. if ( $moved ) { wp_delete_file( $path ); ++$state['imported']; } else { $state['errors'][] = gmdate( 'c' ) . ' could not move ' . basename( $path ) . '; left in place behind deny-all rules'; } } } return 0 === $remaining; } /** * @param string $path Zip path. * @param int $time Upload time from the file name. */ /** * Move a 2.6 zip's files into the private store. Falls back to storing * the zip itself when it cannot be opened or an entry cannot be stored. * * @param string $path Zip path. * @param int $time Upload time from the file name. * @return bool Whether everything in the zip is now in private storage. */ private static function import_zip( $path, $time ) { $record = self::record_for( $time ); if ( ! class_exists( 'ZipArchive' ) ) { return self::keep_whole( $path, $time, $record ); } $zip = new \ZipArchive(); if ( true !== $zip->open( $path ) ) { return self::keep_whole( $path, $time, $record ); } $stored = array(); $ok = true; for ( $i = 0; $i < $zip->numFiles; $i++ ) { $name = (string) $zip->getNameIndex( $i ); if ( '/' === substr( $name, -1 ) ) { continue; } $bytes = $zip->getFromIndex( $i ); if ( false === $bytes ) { $ok = false; break; } if ( '' === $bytes ) { continue; } $check = wp_check_filetype( $name ); $file_id = Store::put_bytes( $record, $bytes, wp_basename( $name ), $check['type'] ? $check['type'] : 'application/octet-stream', 'imported' ); if ( is_wp_error( $file_id ) ) { $ok = false; break; } $stored[] = $file_id; } $zip->close(); if ( ! $ok ) { // Undo the partial import and keep the original zip, encrypted, as one file. foreach ( $stored as $file_id ) { Store::delete( $record, $file_id ); } return self::keep_whole( $path, $time, $record ); } update_post_meta( $record, 'mapp_imported_from_26', time() ); return true; } /** * Store a legacy file unchanged (encrypted) on an upload record. * * @param string $path File. * @param int $time Its time. * @param int|null $record Record, or null to find one by time. * @return bool */ private static function keep_whole( $path, $time, $record = null ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $bytes = file_get_contents( $path ); if ( false === $bytes ) { return false; } $record = $record ? $record : self::record_for( $time ); $check = wp_check_filetype( basename( $path ) ); $file_id = Store::put_bytes( $record, $bytes, basename( $path ), $check['type'] ? $check['type'] : 'application/octet-stream', 'imported' ); if ( is_wp_error( $file_id ) ) { return false; } update_post_meta( $record, 'mapp_imported_from_26', time() ); return true; } /** * The 2.6 upload record created at (about) this time, or a new one. * * @param int $time Unix time. * @return int */ private static function record_for( $time ) { $found = get_posts( array( 'post_type' => Store::UPLOAD_POST_TYPE, 'post_status' => 'any', 'posts_per_page' => 1, 'fields' => 'ids', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => array( array( 'key' => 'file_uploads_date', 'value' => array( $time - 30, $time + 30 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC', ), ), ) ); if ( $found ) { return (int) $found[0]; } $id = wp_insert_post( array( 'post_type' => Store::UPLOAD_POST_TYPE, 'post_status' => 'publish', 'post_title' => sprintf( 'Imported 2.6 upload (%s)', gmdate( 'm-d-Y H:i', $time ) ), ) ); update_post_meta( $id, 'file_uploads_date', $time ); return (int) $id; } /** * Step 2b: 2.6 built exports (CSV / FNM / MISMO, SSNs included) inside the * public uploads month folders and never removed them. Delete those * leftovers: applications.zip files that hold only export files, and the * csv / fannie / mismo work folders. * * @param array $state State. * @return bool Done. */ public static function remove_public_exports( &$state ) { $uploads = wp_upload_dir( null, false ); $base = trailingslashit( $uploads['basedir'] ); $removed = 0; foreach ( (array) glob( $base . '[0-9][0-9][0-9][0-9]/[0-9][0-9]/', GLOB_ONLYDIR ) as $month ) { $zip_path = $month . 'applications.zip'; if ( is_file( $zip_path ) && self::is_export_zip( $zip_path ) ) { wp_delete_file( $zip_path ); ++$removed; } foreach ( array( 'csv', 'fannie', 'mismo' ) as $folder ) { $dir = $month . $folder . '/'; if ( ! is_dir( $dir ) ) { continue; } $files = (array) glob( $dir . '*' ); $ours = array_filter( $files, function ( $f ) { return is_file( $f ) && preg_match( '/\.(csv|fnm|xml)$/i', $f ); } ); if ( count( $ours ) === count( $files ) ) { foreach ( $ours as $f ) { wp_delete_file( $f ); ++$removed; } @rmdir( $dir ); // phpcs:ignore } } } $state['exports_removed'] = $removed; return true; } /** * @param string $path Zip. * @return bool Whether every entry is a CSV / FNM / XML export file. */ private static function is_export_zip( $path ) { if ( ! class_exists( 'ZipArchive' ) ) { return false; } $zip = new \ZipArchive(); if ( true !== $zip->open( $path ) ) { return false; } $ok = $zip->numFiles > 0; for ( $i = 0; $i < $zip->numFiles; $i++ ) { if ( ! preg_match( '/\.(csv|fnm|xml)$/i', (string) $zip->getNameIndex( $i ) ) ) { $ok = false; break; } } $zip->close(); return $ok; } /** * Step 3: re-encrypt 2.x SSNs. * * @param array $state State (count updated). * @return bool Whether every SSN is in the 3.0 format. */ public static function reencrypt_ssns( &$state ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $rows = $wpdb->get_results( $wpdb->prepare( "SELECT pm.meta_id, pm.post_id, pm.meta_key, pm.meta_value FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id AND p.post_type = 'mortgage_application' WHERE pm.meta_key IN ('ss_number','co_ss_number') AND pm.meta_value <> '' AND pm.meta_value NOT LIKE %s LIMIT %d", $wpdb->esc_like( Crypto::PREFIX ) . '%', self::BATCH ) ); foreach ( (array) $rows as $row ) { $plain = Crypto::read_ssn( $row->meta_value ); if ( '' === $plain ) { // Unreadable (e.g. 1.x key tied to an old server, or a double-encrypted // value from the 2.6 edit-link bug): keep it, but out of this loop. update_post_meta( $row->post_id, 'mapp_' . $row->meta_key . '_unreadable', $row->meta_value ); delete_post_meta( $row->post_id, $row->meta_key ); continue; } update_post_meta( $row->post_id, $row->meta_key, Crypto::encrypt( $plain ) ); ++$state['ssn_done']; } return count( (array) $rows ) < self::BATCH; } }