400 ] ); } if ( isset( self::$protected[ $resolved['path'] ] ) ) { return new WP_Error( 'target_in_use', __( 'That directory is in use.', 'templately' ), [ 'status' => 409 ] ); } if ( $context->is_dry_run() ) { $measured = Scanner::measure_dir( $resolved['path'] ); return TaskResult::empty()->add( (int) $measured['files'], (int) $measured['bytes'] ); } return self::delete_tree( $resolved['path'], $keep_root ); } /** * Remove a single file. * * @param string $path Absolute path, or one relative to the uploads root. * @param Context $context Honours `dry_run`. * @return TaskResult|WP_Error */ public static function delete_file( string $path, Context $context ) { $base = realpath( Scanner::get_base_dir() ); if ( false === $base ) { return new WP_Error( 'uploads_base_missing', __( 'The Templately uploads directory does not exist.', 'templately' ), [ 'status' => 404 ] ); } $base = rtrim( $base, '/\\' ); $real = realpath( $path ); if ( false === $real ) { // Already gone. Not an error — a task that raced another sweep, or a // record whose file was removed by hand, should not report a failure. return TaskResult::empty(); } // Same strict containment as directories: the trailing separator is what // stops `/uploads/templately-evil/x.log` passing as ours. if ( 0 !== strpos( $real, $base . DIRECTORY_SEPARATOR ) ) { return new WP_Error( 'target_outside_base', __( 'That path is outside the Templately uploads directory.', 'templately' ), [ 'status' => 400 ] ); } if ( self::is_guard_file( $real ) ) { return new WP_Error( 'refuse_guard_file', __( 'That file protects the uploads directory and cannot be removed.', 'templately' ), [ 'status' => 400 ] ); } if ( isset( self::$protected[ $real ] ) ) { return new WP_Error( 'target_in_use', __( 'That file is in use.', 'templately' ), [ 'status' => 409 ] ); } if ( is_dir( $real ) && ! is_link( $real ) ) { return new WP_Error( 'target_is_a_directory', __( 'That path is a directory.', 'templately' ), [ 'status' => 400 ] ); } $size = (int) @filesize( $real ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( $context->is_dry_run() ) { return TaskResult::empty()->add( 1, $size ); } if ( ! @unlink( $real ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged return TaskResult::empty()->fail( sprintf( 'Could not remove %s', basename( $real ) ) ); } return TaskResult::empty()->add( 1, $size ); } /** * Whether a path is one of the directory's protective files. */ public static function is_guard_file( string $path ): bool { return in_array( basename( $path ), self::GUARD_FILES, true ); } /** * The recursive removal itself. PRIVATE — this is the capability the whole * class exists to keep out of task authors' hands. * * Relocated verbatim from the developer-only uploads scanner, which is why * its symlink and failure-tolerance behaviour is unchanged: children are * visited CHILD_FIRST so directories are empty by the time they are removed, * a symlink is unlinked as a link and never followed, and a file that cannot * be removed is simply not counted rather than aborting a partial delete. * * Guard files are skipped here too, not only in `delete_file()` — a * directory delete must never take the root's guards with it. */ private static function delete_tree( string $path, bool $keep_root = false ): TaskResult { $result = TaskResult::empty(); if ( ! is_dir( $path ) ) { return $result; } $iterator = Scanner::make_recursive_iterator( $path, RecursiveIteratorIterator::CHILD_FIRST ); if ( null === $iterator ) { return $result->fail( sprintf( 'Could not read %s', basename( $path ) ) ); } foreach ( $iterator as $item ) { /** @var SplFileInfo $item */ $item_path = $item->getPathname(); // isLink() FIRST: a symlink to a directory answers isDir() === true, // and we must remove the LINK, never walk into its target. if ( $item->isLink() ) { @unlink( $item_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged continue; } if ( $item->isDir() ) { if ( @rmdir( $item_path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged $result->add_dirs( 1 ); } continue; } if ( $keep_root && self::is_guard_file( $item_path ) ) { // Emptying a directory we are keeping must not strip the guards // that keep it private. continue; } $size = Scanner::safe_size( $item ); if ( @unlink( $item_path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged $result->add( 1, $size ); } } if ( ! $keep_root && @rmdir( $path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged $result->add_dirs( 1 ); } Scanner::bust_cache(); return $result; } }