"# 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 );
}
}
}