string, 'file' => string, 'name' => string ]. * @return mixed Same shape, with `url`/`file` repointed when the move worked. */ public static function move_to_orders( $stored ) { if ( ! is_array( $stored ) || empty( $stored['file'] ) ) { return $stored; } $src = wp_normalize_path( (string) $stored['file'] ); $tmp_root = self::tmp_root(); if ( ! self::is_inside( $src, $tmp_root ) || ! is_file( $src ) ) { return $stored; } $src = self::resolve( $src ); $tmp_root = self::resolve( $tmp_root ); $relative = ltrim( substr( $src, strlen( trailingslashit( $tmp_root ) ) ), '/' ); $sub = ltrim( str_replace( '\\', '/', dirname( $relative ) ), '.' ); $sub = '' === $sub ? '' : trim( $sub, '/' ) . '/'; $dest_dir = self::order_root() . ( '' === $sub ? '' : '/' . untrailingslashit( $sub ) ); if ( '' === self::order_root() || ! wp_mkdir_p( $dest_dir ) ) { return $stored; } self::guard_tree( self::order_root(), $dest_dir ); // Upload names are already 20 random characters (see the file field's // random_filename()), so a plain existence check is enough. Not // wp_unique_filename(): it scandir()s the whole month folder on every // call, which grows with the shop's order volume and runs at checkout. $filename = basename( $src ); while ( file_exists( trailingslashit( $dest_dir ) . $filename ) ) { $filename = wp_generate_password( 6, false, false ) . '-' . basename( $src ); } $dest = trailingslashit( $dest_dir ) . $filename; if ( ! self::move_file( $src, $dest ) ) { return $stored; } $uploads = wp_get_upload_dir(); $stored['file'] = $dest; $stored['url'] = trailingslashit( $uploads['baseurl'] ) . self::ORDER_DIR . '/' . $sub . $filename; return $stored; } /** * Move a file by copying it and deleting the original. * * Copy + delete rather than rename() so a move across filesystem * boundaries (tmp and uploads on different mounts) needs no fallback. * * Direct filesystem calls rather than WP_Filesystem, for the reason the * file field already gives for its guard files: this runs during checkout * on the front end, where a host needing FTP credentials has no way to ask * for them, and WP_Filesystem() would simply return false. PHP can write * here — it wrote the upload — and every failure path here is non-fatal. * * @param string $src Absolute source path. * @param string $dest Absolute destination path. * @return bool */ private static function move_file( $src, $dest ) { if ( ! is_file( $src ) || ! is_readable( $src ) || ! wp_is_writable( dirname( $dest ) ) ) { return false; } if ( ! copy( $src, $dest ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions -- front-end context, see above. return false; } self::set_permissions( $dest ); wp_delete_file( $src ); return true; } /** * Apply the site's configured file permissions to a path we created. * * Mirrors what core does for media uploads in _wp_handle_upload(). * * @param string $path Absolute path. */ private static function set_permissions( $path ) { if ( ! is_file( $path ) ) { return; } if ( defined( 'FS_CHMOD_FILE' ) ) { $perms = FS_CHMOD_FILE; } else { $stat = stat( dirname( $path ) ); $perms = $stat ? ( $stat['mode'] & 0000666 ) : 0644; } chmod( $path, $perms ); // phpcs:ignore WordPress.WP.AlternativeFunctions -- front-end context, see move_file(). } /* --------------------------------------------------------------- * Deletion. * ------------------------------------------------------------- */ /** * Delete one cart-stage upload. * * Refuses anything outside the cart-stage folder, whatever it was handed — * so a filtered or corrupted `file` value cannot turn a cleanup into an * arbitrary unlink. Files already promoted to the order folder are, by the * same check, untouchable here. * * @param string $path Absolute path. * @return bool Whether the file was removed. */ public static function delete_tmp_file( $path ) { $tmp_root = self::tmp_root(); if ( '' === $tmp_root ) { return false; } $root = self::resolve( $tmp_root ); return '' !== $root && self::delete_inside( (string) $path, $root ); } /** * Delete a file, but only if it really sits inside an already-resolved root. * * Split out of delete_tmp_file() so the sweep resolves the root once per run * instead of once per file. * * @param string $path Absolute path. * @param string $root Resolved root, as returned by resolve(). * @return bool Whether the file was removed. */ private static function delete_inside( $path, $root ) { $real = self::resolve( $path ); if ( '' === $real || 0 !== strpos( $real, trailingslashit( $root ) ) || ! is_file( $real ) ) { return false; } wp_delete_file( $real ); return ! file_exists( $real ); } /* --------------------------------------------------------------- * The daily sweep. * ------------------------------------------------------------- */ /** * How long an unpurchased upload is kept. * * The floor matters: a cart is not abandoned just because the customer * closed the tab, and WooCommerce keeps a logged-in customer's cart across * sessions indefinitely. The default is deliberately long — disk is * cheaper than a customer returning to a cart whose artwork has evaporated * — and the window only has to be shorter than "forever" for the pile-up * to stop being unbounded. * * @return int Seconds. */ public static function retention() { /** * Filter how many days an uploaded file survives in a cart without * being bought. Values below 1 are raised to 1. * * @param int $days Retention in days. */ $days = (int) apply_filters( 'shapeoptions/upload_retention_days', 30 ); return max( 1, $days ) * DAY_IN_SECONDS; } /** * Delete cart-stage uploads older than the retention window, then prune * the year/month folders that emptied. * * The YYYY/MM folder name already says how old its files are, so most * folders never need a per-file stat: a month that started after the * cutoff is skipped outright, and one that ended before it is emptied * without reading mtimes. Only the month straddling the cutoff (and any * folder whose name is not a date) is checked file by file. * * When the run hits MAX_PER_RUN, a follow-up run is queued. */ public static function run_cleanup() { $tmp_root = self::tmp_root(); if ( '' === $tmp_root || ! is_dir( $tmp_root ) ) { return; } $root = self::resolve( $tmp_root ); $cutoff = time() - self::retention(); $budget = self::MAX_PER_RUN; if ( '' === $root ) { return; } foreach ( self::subdirs( $root ) as $year ) { foreach ( self::subdirs( $year ) as $month ) { $age = self::folder_age( basename( $year ), basename( $month ), $cutoff ); if ( 'new' === $age ) { continue; } $budget -= self::sweep_dir( $month, $root, 'old' === $age ? null : $cutoff, $budget ); if ( $budget <= 0 ) { self::schedule_continuation(); return; } self::remove_if_empty( $month, $root ); } self::remove_if_empty( $year, $root ); } // Sites with year/month folders turned off keep files in the root. $budget -= self::sweep_dir( $root, $root, $cutoff, $budget ); if ( $budget <= 0 ) { self::schedule_continuation(); } } /** * Classify a YYYY/MM upload folder against the cutoff. * * A day of slack on both sides absorbs the site timezone: upload_dir() * names folders by local time, the cutoff is a UTC timestamp. * * @param string $year Year folder name. * @param string $month Month folder name. * @param int $cutoff Unix timestamp. * @return string 'new' (nothing can be expired), 'old' (everything is), or * 'boundary' (check each file). */ private static function folder_age( $year, $month, $cutoff ) { if ( ! preg_match( '/^\d{4}$/', $year ) || ! preg_match( '/^\d{2}$/', $month ) ) { return 'boundary'; } $y = (int) $year; $m = (int) $month; if ( $m < 1 || $m > 12 ) { return 'boundary'; } $start = gmmktime( 0, 0, 0, $m, 1, $y ); $end = gmmktime( 0, 0, 0, $m + 1, 1, $y ); if ( $start - DAY_IN_SECONDS >= $cutoff ) { return 'new'; } if ( $end + DAY_IN_SECONDS <= $cutoff ) { return 'old'; } return 'boundary'; } /** * Immediate child directories of a path. * * @param string $dir Absolute path. * @return string[] Absolute paths. */ private static function subdirs( $dir ) { $found = glob( trailingslashit( $dir ) . '*', GLOB_ONLYDIR ); return is_array( $found ) ? $found : array(); } /** * Delete the files in one directory that are older than the cutoff. * * Streams the listing with readdir() rather than glob(), so a folder with * tens of thousands of entries is never held in memory at once. * * @param string $dir Absolute path. * @param string $root Resolved cart-stage root. * @param int|null $cutoff Unix timestamp; files modified before this go. * Null deletes every file without reading mtimes. * @param int $budget Most files to delete here. * @return int Number deleted. */ private static function sweep_dir( $dir, $root, $cutoff, $budget ) { if ( $budget <= 0 || ! is_dir( $dir ) || ! is_readable( $dir ) ) { return 0; } $handle = opendir( $dir ); if ( ! $handle ) { return 0; } $deleted = 0; while ( $deleted < $budget ) { $entry = readdir( $handle ); if ( false === $entry ) { break; } // Skips `.`, `..` and dotfiles, as glob() used to, and the folder's // own guard file (remove_if_empty() takes that with the folder). if ( '.' === $entry[0] || self::INDEX_FILE === $entry ) { continue; } $file = trailingslashit( $dir ) . $entry; if ( ! is_file( $file ) ) { continue; } if ( null !== $cutoff ) { $mtime = is_readable( $file ) ? filemtime( $file ) : false; if ( ! $mtime || $mtime >= $cutoff ) { continue; } } if ( self::delete_inside( $file, $root ) ) { $deleted++; } } closedir( $handle ); return $deleted; } /** * Remove a directory once nothing is left in it. * * @param string $dir Absolute path. * @param string $root Resolved cart-stage root. */ private static function remove_if_empty( $dir, $root ) { $real = self::resolve( $dir ); if ( '' === $real || 0 !== strpos( $real, trailingslashit( $root ) ) ) { return; } $entries = glob( trailingslashit( $dir ) . '*' ); // The guard file alone does not keep a folder alive. $guard = trailingslashit( $dir ) . self::INDEX_FILE; if ( is_array( $entries ) && array( $guard ) === $entries ) { wp_delete_file( $guard ); $entries = array(); } // glob() skips dotfiles, so scandir() confirms the folder is truly empty. if ( is_array( $entries ) && ! $entries && is_dir( $dir ) && wp_is_writable( dirname( $dir ) ) && 2 === count( (array) scandir( $dir ) ) ) { rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions -- front-end context, see move_file(); a locked directory simply stays. } } /* --------------------------------------------------------------- * Directory guards. * ------------------------------------------------------------- */ /** * Drop the standard guard files into the upload folder. * * Anyone browsing a product page can upload here — that is the feature — * and the store owner has to be able to open what was uploaded, so the * files must stay fetchable by URL; the 20-character random filename is * what keeps them unguessable. What the guard files add is the second * layer: no directory listing, nothing in here is ever executed as a * script, and nothing renders inline in the browser (so a crafted file * cannot become stored XSS against the admin who opens it). * * Written at the `shapeoptions` root so both `tmp` and `orders` inherit the * .htaccess; the year/month folders get their own index file from * guard_tree(). * * @param string $dir Absolute path to uploads/shapeoptions. */ public static function protect( $dir ) { if ( ! wp_mkdir_p( $dir ) ) { return; } $htaccess = <<<'HTA' # ShapeOptions — customer uploads. Managed by the plugin; safe to delete. # Files stay downloadable (the store owner needs them); they must never run # as scripts and never render inline in a browser. php_flag engine off php_flag engine off php_flag engine off Require all denied Order deny,allow Deny from all Header set Content-Disposition "attachment" Header set X-Content-Type-Options "nosniff" HTA; $files = array( '.htaccess' => $htaccess . "\n", self::INDEX_FILE => '', ); if ( ! wp_is_writable( $dir ) ) { return; } foreach ( $files as $name => $contents ) { $path = trailingslashit( $dir ) . $name; if ( file_exists( $path ) ) { continue; } file_put_contents( $path, $contents ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- front-end context, see move_file(); a failed write is non-fatal. } } /** * Drop an empty index file into every folder from $path up to $root. * * The files themselves live in year/month subfolders, which the * root-level guard does not cover; without an index file a server with * directory listing on (Apache `Indexes`, nginx `autoindex`) would list * every customer upload in them. * * @param string $root Absolute path of the tmp or orders root. * @param string $path Absolute path of the folder just created under it. */ private static function guard_tree( $root, $path ) { $root = untrailingslashit( wp_normalize_path( (string) $root ) ); $dir = untrailingslashit( wp_normalize_path( (string) $path ) ); if ( '' === $root ) { return; } while ( $dir === $root || 0 === strpos( $dir, $root . '/' ) ) { $index = $dir . '/' . self::INDEX_FILE; if ( is_dir( $dir ) && ! file_exists( $index ) && wp_is_writable( $dir ) ) { file_put_contents( $index, '' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- front-end context, see move_file(); a failed write is non-fatal. } if ( $dir === $root ) { break; } $dir = dirname( $dir ); } } /* --------------------------------------------------------------- * Flood protection. * ------------------------------------------------------------- */ /** * Whether this visitor may store another upload right now. * * Uploading does not require an account, so the only thing standing * between the disk and a scripted loop of add-to-cart requests is a cap. * It is counted per IP in a fixed one-hour window and set generously: a * real customer uploads a handful of files, and the limit exists to turn * "fill the disk" into "fill a little of the disk slowly", not to police * ordinary shopping. An office or campus behind one NAT address shares the * allowance, which is why it is filterable — `__return_zero` switches it * off entirely. * * Only counts uploads that are actually stored; a file rejected for its * type or size never reaches this. * * @return bool */ public static function within_rate_limit() { /** * Filter how many files one IP may upload per hour. 0 or less * disables the check. * * @param int $max Uploads per hour. */ $max = (int) apply_filters( 'shapeoptions/upload_rate_limit', 60 ); if ( $max <= 0 ) { return true; } // REMOTE_ADDR, not WC_Geolocation::get_ip_address(): that trusts the // X-Real-IP / X-Forwarded-For headers, which the client writes itself — // a fresh fake IP per request would get a fresh allowance every time. $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? (string) rest_is_ip_address( sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) ) : ''; /** * Filter the client IP the upload limit is counted against. A site * behind a trusted reverse proxy or CDN, where REMOTE_ADDR is the * proxy, returns the real visitor IP from the proxy's header here. * * @param string $ip Validated REMOTE_ADDR, or '' when unavailable. */ $ip = (string) apply_filters( 'shapeoptions/upload_client_ip', $ip ); // No usable IP means one shared allowance, never no limit at all. if ( '' === $ip ) { $ip = 'unknown'; } $key = 'shapeoptions_upload_rate_' . md5( $ip ); $window = get_transient( $key ); if ( ! is_array( $window ) || empty( $window['reset'] ) || $window['reset'] <= time() ) { $window = array( 'count' => 0, 'reset' => time() + HOUR_IN_SECONDS, ); } if ( $window['count'] >= $max ) { return false; } $window['count']++; set_transient( $key, $window, max( MINUTE_IN_SECONDS, $window['reset'] - time() ) ); return true; } } endif;