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;