"# 1003 Mortgage Application: private files, never served directly.\n\n\tRequire all denied\n\n\n\tOrder allow,deny\n\tDeny from all\n\nOptions -Indexes\n", 'web.config' => "\n\n", 'index.php' => " $contents ) { if ( ! file_exists( $dir . $name ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents @file_put_contents( $dir . $name, $contents ); } } } /** * @return string[] Allowed extensions. */ public static function allowed_types() { $setting = function_exists( 'get_front_mortgage_application_option' ) ? (string) get_front_mortgage_application_option( 'mortgage_ma_submissions_file_extension', 'mortgage_submissions_use_form_network_settings' ) : ''; $types = array_filter( array_map( 'trim', explode( ',', strtolower( str_replace( '.', '', $setting ) ) ) ) ); $types = $types ? $types : self::DEFAULT_TYPES; // Never accept executable or markup types, whatever the setting says. $types = array_diff( $types, array( 'php', 'phtml', 'phar', 'js', 'html', 'htm', 'svg', 'exe', 'sh', 'bat', 'cgi', 'pl', 'py' ) ); return array_values( apply_filters( 'mapp_allowed_file_types', $types ) ); } /** @return int Max bytes per file: 20 MB, or less if the server's PHP upload limit is lower. */ public static function max_bytes() { $server = function_exists( 'wp_max_upload_size' ) ? (int) wp_max_upload_size() : 0; $limit = 20 * MB_IN_BYTES; return (int) apply_filters( 'mapp_max_file_bytes', $server > 0 ? min( $limit, $server ) : $limit ); } /** @return int Days a signed link stays valid. */ public static function link_days() { $days = Retention::days(); return $days > 0 ? min( $days, 30 ) : 7; } /** @return int Downloads allowed per signed link file (0 = unlimited). */ public static function download_limit() { $limit = function_exists( 'get_front_mortgage_application_option' ) ? (int) get_front_mortgage_application_option( 'mortgage_ma_submissions_download_limit', 'mortgage_submissions_use_form_network_settings' ) : 0; return max( 0, $limit ); } /** * Store an uploaded file for an application. * * @param int $id Application ID. * @param string $tmp_path Uploaded temp file. * @param string $name Original file name. * @param string $doc_type Checklist item the borrower uploaded it for. * @return string|\WP_Error File ID. */ public static function put( $id, $tmp_path, $name, $doc_type = '' ) { if ( ! is_readable( $tmp_path ) ) { return new \WP_Error( 'mapp_file_missing', __( 'The upload did not arrive. Please try again.', '1003-mortgage-application' ) ); } $size = (int) filesize( $tmp_path ); if ( $size <= 0 || $size > self::max_bytes() ) { /* translators: %s: size limit */ return new \WP_Error( 'mapp_file_size', sprintf( __( 'Files must be smaller than %s.', '1003-mortgage-application' ), size_format( self::max_bytes() ) ) ); } $name = sanitize_file_name( wp_basename( (string) $name ) ); $check = wp_check_filetype_and_ext( $tmp_path, $name ); $ext = strtolower( $check['ext'] ? $check['ext'] : pathinfo( $name, PATHINFO_EXTENSION ) ); if ( ! in_array( $ext, self::allowed_types(), true ) || ( false === $check['ext'] && ! in_array( $ext, array( 'heic', 'csv', 'txt' ), true ) ) ) { /* translators: %s: allowed extensions */ return new \WP_Error( 'mapp_file_type', sprintf( __( 'Allowed file types: %s.', '1003-mortgage-application' ), implode( ', ', self::allowed_types() ) ) ); } $mime = $check['type'] ? $check['type'] : 'application/octet-stream'; $used = array_sum( wp_list_pluck( self::files_for( $id ), 'size' ) ); if ( $used + $size > self::max_total_bytes() ) { /* translators: %s: size limit */ return new \WP_Error( 'mapp_file_total', sprintf( __( 'This application already holds %s of documents. Send the rest to your loan officer directly.', '1003-mortgage-application' ), size_format( self::max_total_bytes() ) ) ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $bytes = file_get_contents( $tmp_path ); if ( false === $bytes ) { return new \WP_Error( 'mapp_file_read', __( 'The upload could not be read. Please try again.', '1003-mortgage-application' ) ); } return self::put_bytes( $id, $bytes, $name, $mime, $doc_type ); } /** @return int Max bytes of documents per application or upload record. */ public static function max_total_bytes() { return (int) apply_filters( 'mapp_max_total_bytes', 200 * MB_IN_BYTES ); } /** * Store bytes without the upload checks (type, size). Used for files that * are already ours, such as 2.6 uploads being moved into private storage. * * @param int $id Record ID. * @param string $bytes File contents. * @param string $name File name. * @param string $mime MIME type. * @param string $doc_type Checklist item. * @return string|\WP_Error File ID. */ public static function put_bytes( $id, $bytes, $name, $mime = 'application/octet-stream', $doc_type = '' ) { $name = sanitize_file_name( wp_basename( (string) $name ) ); $size = strlen( $bytes ); $file_id = Crypto::token( 12 ); $dir = self::app_dir( $id ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents if ( false === file_put_contents( $dir . $file_id . '.bin', Crypto::encrypt( $bytes ) ) ) { return new \WP_Error( 'mapp_file_write', __( 'The file could not be saved. Please try again.', '1003-mortgage-application' ) ); } $files = self::files_for( $id ); $files[ $file_id ] = array( 'name' => $name, 'mime' => $mime, 'size' => $size, 'sha256' => hash( 'sha256', $bytes ), 'doc_type' => sanitize_key( $doc_type ), 'uploaded' => time(), 'downloads' => 0, ); update_post_meta( $id, self::META, $files ); return $file_id; } /** * Decrypted bytes, or null. * * @param int $id Application ID. * @param string $file_id File ID. * @return string|null */ public static function read( $id, $file_id ) { $files = self::files_for( $id ); if ( ! isset( $files[ $file_id ] ) || ! preg_match( '/^[A-Za-z0-9_-]+$/', $file_id ) ) { return null; } $path = self::app_dir( $id ) . $file_id . '.bin'; if ( ! is_readable( $path ) ) { return null; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents return Crypto::decrypt( (string) file_get_contents( $path ) ); } /** * Delete a file. * * @param int $id Application ID. * @param string $file_id File ID. */ public static function delete( $id, $file_id ) { $files = self::files_for( $id ); if ( ! isset( $files[ $file_id ] ) ) { return; } $path = self::app_dir( $id ) . $file_id . '.bin'; if ( file_exists( $path ) ) { wp_delete_file( $path ); } unset( $files[ $file_id ] ); update_post_meta( $id, self::META, $files ); } /** * @param int $id Application ID. * @return array File ID => record. */ public static function files_for( $id ) { $files = get_post_meta( $id, self::META, true ); return is_array( $files ) ? $files : array(); } /** * The documents to ask for, from the answers (same rules and names as * the form's checklist). * * @param int $id Application ID. * @param string $lang en | es. * @return array Type => full name. */ public static function checklist( $id, $lang = 'en' ) { $get = function ( $key ) use ( $id ) { return get_post_meta( $id, $key, true ); }; $out = array( 'id', 'bank' ); $work = (string) $get( 'employment_status' ); if ( 'Employed' === $work ) { array_push( $out, 'paystubs', 'w2' ); } elseif ( 'Self-Employed' === $work ) { array_push( $out, 'taxes', 'pnl' ); } elseif ( 'retired' === $work ) { $out[] = 'retirement'; } if ( 'Yes' === $get( 'military' ) ) { $out[] = 'dd214'; } if ( 'Home Purchase' === $get( 'purpose' ) && 'Immediately: Signed a Purchase Agreement' === $get( 'loan_purpose_purchase' ) ) { $out[] = 'contract'; } if ( 'Home Refinance' === $get( 'purpose' ) ) { array_push( $out, 'mortgage', 'insurance' ); } $gifts = $get( 'gifts' ); if ( is_array( $gifts ) && $gifts ) { $out[] = 'gift'; } $names = array( 'id' => array( 'Photo ID (driver license or passport)', 'Identificación con foto (licencia o pasaporte)' ), 'bank' => array( 'Last 2 months of bank statements', 'Estados de cuenta de los últimos 2 meses' ), 'paystubs' => array( 'Pay stubs from the last 30 days', 'Talones de pago de los últimos 30 días' ), 'w2' => array( 'W-2s for the last 2 years', 'Formularios W-2 de los últimos 2 años' ), 'taxes' => array( 'Personal and business tax returns, last 2 years', 'Declaraciones de impuestos personales y del negocio, últimos 2 años' ), 'pnl' => array( 'Year-to-date profit and loss statement', 'Estado de pérdidas y ganancias del año' ), 'retirement' => array( 'Social Security or pension award letters', 'Cartas de beneficios del Seguro Social o pensión' ), 'dd214' => array( 'DD-214 or VA Certificate of Eligibility', 'DD-214 o Certificado de Elegibilidad de VA' ), 'contract' => array( 'Signed purchase agreement', 'Contrato de compra firmado' ), 'mortgage' => array( 'Current mortgage statement', 'Estado de cuenta de la hipoteca actual' ), 'insurance' => array( 'Homeowners insurance declarations page', 'Página de declaraciones del seguro de la casa' ), 'gift' => array( 'Gift letter', 'Carta de donación' ), ); $list = array(); foreach ( $out as $type ) { $list[ $type ] = $names[ $type ][ 'es' === $lang ? 1 : 0 ]; } return $list; } /** * Readable name for a document type. * * @param string $type Doc type. * @return string */ public static function doc_label( $type ) { $labels = array( 'id' => 'Photo ID', 'bank' => 'Bank statements', 'paystubs' => 'Pay stubs', 'w2' => 'W-2s', 'taxes' => 'Tax returns', 'pnl' => 'Profit and loss', 'retirement' => 'Award letters', 'dd214' => 'DD-214 / COE', 'contract' => 'Purchase agreement', 'mortgage' => 'Mortgage statement', 'insurance' => 'Insurance declarations', 'gift' => 'Gift letter', 'imported' => 'Imported from 2.x', ); return isset( $labels[ $type ] ) ? $labels[ $type ] : ( '' !== $type ? $type : '—' ); } /** * Set a flag on a file record. * * @param int $id Application ID. * @param string $file_id File ID. * @param string $key Field. * @param mixed $value Value. */ public static function mark( $id, $file_id, $key, $value ) { $files = self::files_for( $id ); if ( isset( $files[ $file_id ] ) ) { $files[ $file_id ][ $key ] = $value; update_post_meta( $id, self::META, $files ); } } /** * Signed, expiring download link. * * @param int $id Application ID. * @param string $file_id File ID. * @param string $purpose crm | email. * @return string */ public static function signed_url( $id, $file_id, $purpose = 'email' ) { $expires = time() + self::link_days() * DAY_IN_SECONDS; return add_query_arg( array( 'exp' => $expires, 'p' => $purpose, 'sig' => self::signature( $id, $file_id, $expires, $purpose ), ), rest_url( 'mapp/v1/files/' . (int) $id . '/' . rawurlencode( $file_id ) ) ); } /** * @param int $id Application ID. * @param string $file_id File ID. * @param int $expires Expiry timestamp. * @param string $purpose Purpose. * @return string */ public static function signature( $id, $file_id, $expires, $purpose ) { return \Mapp\Security\Secret::hmac( 'files', 'mapp_file|' . (int) $id . '|' . $file_id . '|' . (int) $expires . '|' . $purpose ); } /** * Per-application directory, created and protected on demand. * * @param int $id Application ID. * @return string */ private static function app_dir( $id ) { // The directory name is random (so application IDs cannot be walked) and // saved on the record, so nothing (salt or key rotation) can orphan it. $name = (string) get_post_meta( $id, 'mapp_files_dir', true ); if ( ! preg_match( '/^\d+-[a-f0-9]{16}$/', $name ) ) { $name = (int) $id . '-' . bin2hex( random_bytes( 8 ) ); update_post_meta( $id, 'mapp_files_dir', $name ); } $dir = self::base_dir() . $name . '/'; if ( ! is_dir( $dir ) ) { wp_mkdir_p( $dir ); self::protect( $dir ); } return $dir; } /** * Delete every file for an application (on permanent delete). * * @param int $id Application ID. */ public static function delete_all( $id ) { foreach ( array_keys( self::files_for( $id ) ) as $file_id ) { self::delete( $id, $file_id ); } } }