WP_REST_Server::CREATABLE, // POST 'callback' => [ $this, 'extract_zip' ], 'permission_callback' => [ $this, 'can_extract' ], 'args' => [ 'attachment_id' => [ 'required' => true, 'type' => 'integer', 'minimum' => 1, 'sanitize_callback' => 'absint', ], ], ] ); } /** * The creative runs same-origin with scripts, so extracting it needs the same trust as unfiltered HTML. * * @param WP_REST_Request $request The extract request. * * @return bool */ public function can_extract( WP_REST_Request $request ): bool { $attachment_id = absint( $request->get_param( 'attachment_id' ) ); return current_user_can( 'upload_files' ) && current_user_can( 'unfiltered_html' ) && ( ! $attachment_id || current_user_can( 'edit_post', $attachment_id ) ); } /** * Handles the extraction process. * * @param WP_REST_Request $request * * @return WP_REST_Response|WP_Error */ public function extract_zip( WP_REST_Request $request ): WP_Error|WP_REST_Response { $attachment_id = absint( $request->get_param( 'attachment_id' ) ); if ( empty( $attachment_id ) ) { return new WP_Error( 'missing_id', __( 'Attachment ID is missing.', 'adpresso' ), [ 'status' => 400 ] ); } $file_path = get_attached_file( $attachment_id ); if ( ! $file_path || ! file_exists( $file_path ) ) { return new WP_Error( 'file_not_found', __( 'ZIP file not found on server.', 'adpresso' ), [ 'status' => 404 ] ); } if ( strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) ) !== 'zip' ) { return new WP_Error( 'not_a_zip', __( 'The selected file is not a ZIP archive.', 'adpresso' ), [ 'status' => 400 ] ); } if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } WP_Filesystem(); global $wp_filesystem; $upload_dir = wp_upload_dir(); if ( ! empty( $upload_dir['error'] ) ) { return new WP_Error( 'upload_error', $upload_dir['error'], [ 'status' => 500 ] ); } // Random suffix: the public folder name must not be guessable. $relative_base_dir = defined( 'AdPresso\Constants::HTML5_DIR' ) ? Constants::HTML5_DIR : 'adpresso-html5'; $unique_dir_name = 'ad_' . $attachment_id . '_' . time() . '_' . strtolower( wp_generate_password( 12, false, false ) ); $relative_path = trailingslashit( $relative_base_dir ) . $unique_dir_name; $absolute_dir = trailingslashit( $upload_dir['basedir'] ) . $relative_path; // Extracted and filtered outside the uploads folder, so no disallowed file is ever publicly reachable. $temp_dir = trailingslashit( get_temp_dir() ) . 'adpresso-html5-' . wp_generate_password( 20, false, false ); if ( ! $wp_filesystem->mkdir( $temp_dir, FS_CHMOD_DIR ) ) { return new WP_Error( 'mkdir_failed', __( 'Could not create target directory.', 'adpresso' ), [ 'status' => 500 ] ); } $result = unzip_file( $file_path, $temp_dir ); if ( true !== $result ) { $wp_filesystem->rmdir( $temp_dir, true ); $error_msg = is_wp_error( $result ) ? $result->get_error_message() : __( 'Failed to extract ZIP.', 'adpresso' ); return new \WP_Error( 'extract_failed', $error_msg, [ 'status' => 500 ] ); } self::remove_disallowed_files( $temp_dir ); if ( ! self::move_dir( $temp_dir, $absolute_dir ) ) { $wp_filesystem->rmdir( $temp_dir, true ); return new WP_Error( 'move_failed', __( 'Could not move the extracted files into the uploads folder.', 'adpresso' ), [ 'status' => 500 ] ); } return new WP_REST_Response( [ 'directory' => $relative_path ], 200 ); } /** * Moves the filtered folder into uploads; copies when a rename across filesystems fails. * * @param string $from Filtered temp folder. * @param string $to Target folder (must not exist yet). * * @return bool */ private static function move_dir( string $from, string $to ): bool { global $wp_filesystem; if ( ! wp_mkdir_p( dirname( $to ) ) ) { return false; } if ( $wp_filesystem->move( $from, $to ) ) { return true; } if ( ! $wp_filesystem->mkdir( $to, FS_CHMOD_DIR ) ) { return false; } if ( true !== copy_dir( $from, $to ) ) { $wp_filesystem->rmdir( $to, true ); return false; } $wp_filesystem->rmdir( $from, true ); return true; } /** * Keeps only creative file types in the public uploads folder: no PHP, no .htaccess, no links. * * @param string $dir Extracted directory. * * @return string[] Removed paths. */ public static function remove_disallowed_files( string $dir ): array { if ( ! is_dir( $dir ) ) { return []; } $removed = []; $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::CHILD_FIRST ); foreach ( $iterator as $entry ) { $path = $entry->getPathname(); if ( $entry->isLink() ) { unlink( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- inside our own fresh extraction dir. $removed[] = $path; } elseif ( $entry->isFile() ) { $name = $entry->getFilename(); $extension = strtolower( $entry->getExtension() ); // Also "x.php.png": some server setups run any file with a .php part. $executable = (bool) preg_match( '/\.(php\d?|phtml|phar|pht|phps|cgi|pl|py|sh|asp|aspx|jsp)(\.|$)/i', $name ); if ( str_starts_with( $name, '.' ) || $executable || ! in_array( $extension, self::ALLOWED_EXTENSIONS, true ) ) { wp_delete_file( $path ); $removed[] = $path; } } } return $removed; } }