/// under a * random 32-character name, so they can't be guessed. Apache is also told to * refuse them (.htaccess); on Nginx the random name is what protects them. * Staff open them through a download link that checks permissions. * * @package BYLTCommerce */ defined( 'ABSPATH' ) || exit; class Bylt_Forms_Files { const DIR = 'bylt-private'; /** Allowed extensions => mime types. */ public static function types() { $t = array( 'pdf' => 'application/pdf', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', ); $t = apply_filters( 'bylt_form_file_types', $t ); return is_array( $t ) ? $t : array(); } /** Largest upload in bytes (5 MB, or the server limit if lower). */ public static function max_bytes() { $max = (int) apply_filters( 'bylt_form_file_max', 5 * MB_IN_BYTES ); return min( $max, (int) wp_max_upload_size() ); } /** "PDF, JPG, PNG or WEBP, up to 5 MB" */ public static function hint() { $ext = array_unique( array_map( 'strtoupper', array_diff( array_keys( self::types() ), array( 'jpeg' ) ) ) ); $last = array_pop( $ext ); /* translators: 1: list of file types, 2: last file type, 3: size like "5 MB" */ return sprintf( __( '%1$s or %2$s, up to %3$s.', 'byltcommerce-toolkit' ), implode( ', ', $ext ), $last, size_format( self::max_bytes() ) ); } public static function accept() { $out = array(); foreach ( array_keys( self::types() ) as $e ) { $out[] = '.' . $e; } return implode( ',', $out ); } public static function base() { $up = wp_upload_dir( null, false ); return trailingslashit( $up['basedir'] ) . self::DIR; } /** Make the folder and its "no browsing, no direct access" files. */ private static function ensure( $dir ) { $base = self::base(); if ( ! is_dir( $dir ) ) { wp_mkdir_p( $dir ); } if ( ! file_exists( $base . '/.htaccess' ) ) { file_put_contents( $base . '/.htaccess', "# BYLTCommerce: private uploads\n\nRequire all denied\n\n\nDeny from all\n\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions } if ( ! file_exists( $base . '/index.php' ) ) { file_put_contents( $base . '/index.php', " self::max_bytes() ) { /* translators: %s: size like "5 MB" */ return new WP_Error( 'size', sprintf( __( 'That file is too big. The limit is %s.', 'byltcommerce-toolkit' ), size_format( self::max_bytes() ) ) ); } if ( UPLOAD_ERR_OK !== $err || empty( $file['tmp_name'] ) || ! self::is_upload( $file['tmp_name'] ) ) { return new WP_Error( 'upload', __( 'The file didn\'t upload. Please try again.', 'byltcommerce-toolkit' ) ); } $types = self::types(); $check = wp_check_filetype_and_ext( $file['tmp_name'], $file['name'], $types ); $ext = $check['ext'] ? strtolower( $check['ext'] ) : ''; if ( ! $ext || ! isset( $types[ $ext ] ) || ( $check['type'] && $types[ $ext ] !== $check['type'] ) ) { return new WP_Error( 'type', self::hint() ); } return array( 'tmp' => $file['tmp_name'], 'name' => sanitize_file_name( wp_basename( $file['name'] ) ), 'ext' => $ext, 'size' => (int) $file['size'], ); } /** Real uploads only (tests can allow local files with the filter). */ private static function is_upload( $tmp ) { return is_uploaded_file( $tmp ) || (bool) apply_filters( 'bylt_form_file_allow_local', false, $tmp ); } /** * Move a checked upload into private storage. * * @param array $info From check(). * @param string $area Sub-folder (forms, certificates). * @return string|WP_Error Path relative to the private folder. */ public static function store( array $info, $area = 'forms' ) { $rel = sanitize_key( $area ) . '/' . gmdate( 'Y/m' ) . '/' . strtolower( wp_generate_password( 32, false ) ) . '.' . $info['ext']; $abs = self::base() . '/' . $rel; self::ensure( dirname( $abs ) ); $ok = is_uploaded_file( $info['tmp'] ) ? self::handle_upload( $info, $abs ) : copy( $info['tmp'], $abs ); if ( ! $ok ) { return new WP_Error( 'store', __( 'The file couldn\'t be saved. Please try again.', 'byltcommerce-toolkit' ) ); } @chmod( $abs, 0640 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions return $rel; } /** * Save a real upload to $abs through WordPress's own upload handler. * * @param array $info From check(). * @param string $abs Target path inside the private folder. */ private static function handle_upload( array $info, $abs ) { if ( ! function_exists( 'wp_handle_upload' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } $dir = dirname( $abs ); $name = basename( $abs ); $to_dir = function ( $u ) use ( $dir ) { $u['path'] = $dir; $u['subdir'] = ''; $u['error'] = false; return $u; }; $file = array( 'name' => $name, 'type' => '', 'tmp_name' => $info['tmp'], 'error' => UPLOAD_ERR_OK, 'size' => $info['size'], ); add_filter( 'upload_dir', $to_dir, 999 ); $done = wp_handle_upload( $file, array( 'test_form' => false, 'mimes' => self::types(), 'unique_filename_callback' => function () use ( $name ) { return $name; }, ) ); remove_filter( 'upload_dir', $to_dir, 999 ); return empty( $done['error'] ) && ! empty( $done['file'] ) && wp_normalize_path( $done['file'] ) === wp_normalize_path( $abs ); } /** Copy a stored file to another area (e.g. an application's certificate). */ public static function copy( $rel, $area ) { $src = self::path( $rel ); if ( ! $src ) { return ''; } $ext = strtolower( pathinfo( $src, PATHINFO_EXTENSION ) ); $new = sanitize_key( $area ) . '/' . gmdate( 'Y/m' ) . '/' . strtolower( wp_generate_password( 32, false ) ) . '.' . $ext; self::ensure( dirname( self::base() . '/' . $new ) ); return copy( $src, self::base() . '/' . $new ) ? $new : ''; } /** Absolute path for a stored file, or '' when it's missing or outside the folder. */ public static function path( $rel ) { $rel = (string) $rel; if ( '' === $rel || false !== strpos( $rel, '..' ) || ! preg_match( '#^[a-z0-9_-]+/\d{4}/\d{2}/[a-z0-9]{32}\.[a-z0-9]{2,5}$#', $rel ) ) { return ''; } $abs = self::base() . '/' . $rel; return is_file( $abs ) ? $abs : ''; } public static function delete( $rel ) { $abs = self::path( $rel ); if ( $abs ) { wp_delete_file( $abs ); } } /** Send a stored file to the browser (after the caller checked permissions). */ public static function serve( $rel, $download_name ) { $abs = self::path( $rel ); if ( ! $abs ) { wp_die( esc_html__( 'File not found.', 'byltcommerce-toolkit' ), '', array( 'response' => 404 ) ); } $ext = strtolower( pathinfo( $abs, PATHINFO_EXTENSION ) ); $types = self::types(); $type = isset( $types[ $ext ] ) ? $types[ $ext ] : 'application/octet-stream'; nocache_headers(); header( 'Content-Type: ' . $type ); header( 'Content-Length: ' . filesize( $abs ) ); header( 'X-Content-Type-Options: nosniff' ); header( 'Content-Disposition: ' . ( 'application/pdf' === $type || 0 === strpos( $type, 'image/' ) ? 'inline' : 'attachment' ) . '; filename="' . str_replace( '"', '', sanitize_file_name( $download_name ) ) . '"' ); readfile( $abs ); // phpcs:ignore WordPress.WP.AlternativeFunctions exit; } }