/ and are never put in * the media library. They are only sent through Paygrab_Downloads after checks. * * @package PAYGRAB */ defined( 'ABSPATH' ) || exit; /** * Store, locate and protect product files. */ class Paygrab_Files { const DIR = 'paygrab-private'; /** * Absolute path of the protected folder (with trailing slash). * * @return string */ public static function base_dir() { $uploads = wp_get_upload_dir(); return trailingslashit( $uploads['basedir'] ) . self::DIR . '/'; } /** * Public URL of the protected folder (used only to test that it is NOT reachable). * * @return string */ public static function base_url() { $uploads = wp_get_upload_dir(); return trailingslashit( $uploads['baseurl'] ) . self::DIR . '/'; } /** * Random sub folder, so file URLs cannot be guessed even if protection fails. * * @return string */ public static function sub_dir() { $sub = (string) get_option( 'paygrab_private_sub' ); if ( ! preg_match( '/^[a-z0-9]{24}$/', $sub ) ) { $sub = strtolower( wp_generate_password( 24, false, false ) ); update_option( 'paygrab_private_sub', $sub, false ); } return $sub; } /** * Create the folder and the files that block direct access. * * @return void */ public static function protect() { $base = self::base_dir(); wp_mkdir_p( $base . self::sub_dir() ); $files = array( '.htaccess' => "# PAYGRAB: block direct access to the files being sold.\n\n\tRequire all denied\n\n\n\tOrder deny,allow\n\tDeny from all\n\n", 'web.config' => "\n\n", 'index.php' => " $body ) { if ( ! file_exists( $base . $name ) ) { file_put_contents( $base . $name, $body ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents } } if ( ! file_exists( $base . self::sub_dir() . '/index.php' ) ) { file_put_contents( $base . self::sub_dir() . '/index.php', $files['index.php'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents } } /** * File types that can be sold: [ extension => MIME type ]. ZIP only by default. * * Sellers put PDFs, images, fonts and anything else into one ZIP. That keeps one rule * for every product, avoids "this file type is not permitted" surprises for formats * WordPress does not know (EPUB, AI, fonts…), and browsers always save it instead of * opening it. * * @return array */ public static function allowed_types() { /** * Filters the file types that can be uploaded as products. * * @param array $types extension (or ext1|ext2) => MIME type. */ return (array) apply_filters( 'paygrab_allowed_file_types', array( 'zip' => 'application/zip' ) ); } /** * Extensions allowed, for messages and the file picker (e.g. ".zip"). * * @return string[] */ public static function allowed_extensions() { $out = array(); foreach ( array_keys( self::allowed_types() ) as $exts ) { foreach ( explode( '|', $exts ) as $ext ) { $out[] = '.' . strtolower( $ext ); } } return array_values( array_unique( $out ) ); } /** * Save an uploaded file ($_FILES entry) into the protected folder. * * @param array $file One $_FILES entry. * @return array|WP_Error { path, name, size, type } on success. */ public static function store_upload( array $file ) { self::protect(); require_once ABSPATH . 'wp-admin/includes/file.php'; $sub = self::sub_dir(); $filter = static function ( $dirs ) use ( $sub ) { $dirs['subdir'] = '/' . self::DIR . '/' . $sub; $dirs['path'] = $dirs['basedir'] . $dirs['subdir']; $dirs['url'] = $dirs['baseurl'] . $dirs['subdir']; return $dirs; }; $ext = strtolower( pathinfo( (string) ( $file['name'] ?? '' ), PATHINFO_EXTENSION ) ); if ( ! in_array( '.' . $ext, self::allowed_extensions(), true ) ) { return new WP_Error( 'paygrab_upload_type', /* translators: %s: allowed extensions, e.g. ".zip" */ sprintf( __( 'Only %s files can be uploaded. Put your files (PDF, images and so on) into one ZIP file and upload that.', 'paygrab' ), implode( ' / ', self::allowed_extensions() ) ) ); } // A file named .zip that is not really a ZIP gets PAYGRAB's message, not WordPress's “not allowed” one. if ( ! empty( $file['tmp_name'] ) && is_file( $file['tmp_name'] ) ) { $check = wp_check_filetype_and_ext( $file['tmp_name'], (string) $file['name'], self::allowed_types() ); if ( empty( $check['type'] ) ) { return new WP_Error( 'paygrab_upload_broken', __( 'This file could not be read as a ZIP file. Make the ZIP file again and upload it.', 'paygrab' ) ); } } add_filter( 'upload_dir', $filter ); $result = wp_handle_upload( $file, array( 'test_form' => false, 'mimes' => self::allowed_types(), ) ); remove_filter( 'upload_dir', $filter ); if ( isset( $result['error'] ) ) { return new WP_Error( 'paygrab_upload', $result['error'] ); } $path = wp_normalize_path( $result['file'] ); $rel = ltrim( substr( $path, strlen( wp_normalize_path( self::base_dir() ) ) ), '/' ); return array( 'path' => $rel, 'name' => sanitize_file_name( wp_basename( $file['name'] ) ), 'size' => (int) filesize( $path ), 'type' => (string) $result['type'], 'time' => time(), ); } /** * Absolute path of a stored file, or '' when it is missing or outside the folder. * * @param string $rel Path relative to the protected folder. * @return string */ public static function path( $rel ) { $rel = (string) $rel; if ( '' === $rel || str_contains( $rel, '..' ) ) { return ''; } $base = realpath( self::base_dir() ); $full = realpath( self::base_dir() . $rel ); if ( ! $base || ! $full || ! str_starts_with( $full, $base . DIRECTORY_SEPARATOR ) || ! is_file( $full ) ) { return ''; } return $full; } /** * Delete a stored file. * * @param string $rel Path relative to the protected folder. * @return void */ public static function delete( $rel ) { $full = self::path( $rel ); if ( $full ) { wp_delete_file( $full ); } } /** * Check whether the folder can be opened from outside. * * @param bool $force Ignore the cached result. * @return array{status:string,server:string,checked:int} status = ok | exposed | unknown. */ public static function check( $force = false ) { $cached = self::last_check(); if ( ! $force && $cached ) { return $cached; } self::protect(); $token = wp_generate_password( 20, false, false ); $name = 'paygrab-check.txt'; file_put_contents( self::base_dir() . $name, $token ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents $response = wp_remote_get( self::base_url() . $name, array( 'timeout' => 8, // A request to this same site (not an outside server): follow WordPress's own filter for local requests. 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core's filter for requests to the site itself. ) ); wp_delete_file( self::base_dir() . $name ); if ( is_wp_error( $response ) ) { $status = 'unknown'; } else { $exposed = 200 === (int) wp_remote_retrieve_response_code( $response ) && str_contains( (string) wp_remote_retrieve_body( $response ), $token ); $status = $exposed ? 'exposed' : 'ok'; } $software = isset( $_SERVER['SERVER_SOFTWARE'] ) ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) ) : ''; $server = str_contains( $software, 'nginx' ) ? 'nginx' : ( str_contains( $software, 'apache' ) || str_contains( $software, 'litespeed' ) ? 'apache' : 'other' ); // Checks run from WP-CLI or the system cron do not know the server; keep what the last check found. $last = self::last_check(); $result = array( 'status' => $status, 'server' => '' === $software && $last ? (string) ( $last['server'] ?? 'other' ) : $server, 'checked' => time(), ); // Kept until the next check (not a transient), so a problem never disappears by itself. update_option( self::CHECK, $result, false ); return $result; } /** Option holding the last result of check(). */ const CHECK = 'paygrab_protect_check'; /** * The last result of check(), without checking. * * @return array{status:string,server:string,checked:int}|null Null when never checked. */ public static function last_check() { $result = get_option( self::CHECK ); return is_array( $result ) && isset( $result['status'] ) ? $result : null; } /** * Check again when the last check is older than 24 hours (run by the hourly job). * * @return void */ public static function recheck() { $last = self::last_check(); if ( $last && (int) ( $last['checked'] ?? 0 ) < time() - DAY_IN_SECONDS ) { self::check( true ); } } /** * nginx rule that blocks the folder. * * @return string */ public static function nginx_rule() { $uploads = wp_get_upload_dir(); $path = wp_parse_url( trailingslashit( $uploads['baseurl'] ) . self::DIR . '/', PHP_URL_PATH ); return "location ^~ {$path} {\n deny all;\n}"; } /** Prefix of the options holding files uploaded from the product form but not saved to a product yet (one option per file). */ const HOLD = 'paygrab_hold_'; /** * Remember a file uploaded from the product form until the product is saved. * * @param array $stored Result of store_upload(). * @return string Token the form sends back when saving. */ public static function hold( array $stored ) { $token = wp_generate_password( 24, false ); add_option( self::HOLD . $token, array( 'file' => $stored, 'user' => get_current_user_id(), 'time' => time(), ), '', false ); return $token; } /** * Take a held file (only the person who uploaded it can). * * @param string $token Token from hold(). * @return array|null Stored file. */ public static function take( $token ) { if ( ! preg_match( '/^[A-Za-z0-9]{24}$/', (string) $token ) ) { return null; } $item = get_option( self::HOLD . $token ); if ( ! is_array( $item ) || (int) $item['user'] !== get_current_user_id() ) { return null; } delete_option( self::HOLD . $token ); return self::path( $item['file']['path'] ) ? $item['file'] : null; } /** * Delete held files older than a day (the form was closed without saving). * * @return void */ public static function clean_held() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $names = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( self::HOLD ) . '%' ) ); foreach ( $names as $name ) { $item = get_option( $name ); if ( is_array( $item ) && time() - (int) $item['time'] < DAY_IN_SECONDS ) { continue; } if ( is_array( $item ) && ! empty( $item['file']['path'] ) && ! self::in_use( $item['file']['path'] ) ) { self::delete( $item['file']['path'] ); } delete_option( $name ); } } /** * Whether a product uses a stored file. * * @param string $rel Path relative to the protected folder. * @param int $exclude Product to ignore. * @return bool */ public static function in_use( $rel, $exclude = 0 ) { global $wpdb; $like = '%' . $wpdb->esc_like( '"' . $rel . '"' ) . '%'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery return (bool) $wpdb->get_var( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_paygrab_file' AND post_id != %d AND meta_value LIKE %s LIMIT 1", (int) $exclude, $like ) ); } }