/ 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 ) );
}
}