403 ] ); } } public static function origin(): string { $origin = defined( 'SMOOTH_SANDBOX_PLUGINPEER_ORIGIN' ) ? SMOOTH_SANDBOX_PLUGINPEER_ORIGIN : 'https://pluginpeer.com'; $local = 'local' === wp_get_environment_type() || 'development' === wp_get_environment_type(); if ( 'https://pluginpeer.com' !== $origin && ! ( $local && preg_match( '#^http://(localhost|127\.0\.0\.1):[0-9]{2,5}$#D', $origin ) ) ) { throw new \RuntimeException( __( 'The PluginPeer origin is invalid. Use the official service or a loopback development server.', 'smooth-sandbox' ) ); } return $origin; } public static function url( string $tab = '' ): string { return admin_url( 'admin.php?page=' . self::SLUG . ( $tab ? '--' . $tab : '' ) ); } public function menu(): void { add_menu_page( 'Smooth Sandbox', 'Smooth Sandbox', 'manage_options', self::SLUG, [ $this, 'sandbox' ], 'dashicons-welcome-view-site', 82 ); add_submenu_page( self::SLUG, __( 'Sandbox', 'smooth-sandbox' ), __( 'Sandbox', 'smooth-sandbox' ), 'manage_options', self::SLUG, [ $this, 'sandbox' ] ); add_submenu_page( self::SLUG, __( 'Recordings', 'smooth-sandbox' ), __( 'Recordings', 'smooth-sandbox' ), 'manage_options', self::SLUG . '--recordings', [ $this, 'recordings' ] ); add_submenu_page( self::SLUG, __( 'Settings', 'smooth-sandbox' ), __( 'Settings', 'smooth-sandbox' ), 'manage_options', self::SLUG . '--settings', [ $this, 'settings' ] ); } public function assets( string $hook ): void { if ( false === strpos( $hook, self::SLUG ) ) { return; } // Refresh changed assets without bumping the unpublished plugin version. $asset_version = static fn( string $file ): string => SMOOTH_SANDBOX_VERSION . '.' . filemtime( dirname( SMOOTH_SANDBOX_FILE ) . '/assets/' . $file ); wp_enqueue_style( self::SLUG, plugins_url( 'assets/admin.css', SMOOTH_SANDBOX_FILE ), [], $asset_version( 'admin.css' ) ); wp_register_style( self::SLUG . '-preparing', false, [], $asset_version( 'preparing.css' ) ); wp_enqueue_style( self::SLUG . '-preparing' ); // The initial about:blank tab reuses this WordPress-generated style without a request. wp_add_inline_style( self::SLUG . '-preparing', file_get_contents( dirname( SMOOTH_SANDBOX_FILE ) . '/assets/preparing.css' ) ); wp_enqueue_script( self::SLUG, plugins_url( 'assets/admin.js', SMOOTH_SANDBOX_FILE ), [], $asset_version( 'admin.js' ), true ); wp_enqueue_script( self::SLUG . '-environment', plugins_url( 'assets/environment.js', SMOOTH_SANDBOX_FILE ), [ self::SLUG, self::SLUG . '-snapshot' ], $asset_version( 'environment.js' ), true ); wp_enqueue_script( self::SLUG . '-snapshot', plugins_url( 'assets/snapshot.js', SMOOTH_SANDBOX_FILE ), [ self::SLUG ], $asset_version( 'snapshot.js' ), true ); wp_enqueue_script( self::SLUG . '-settings', plugins_url( 'assets/settings.js', SMOOTH_SANDBOX_FILE ), [ self::SLUG . '-snapshot' ], $asset_version( 'settings.js' ), true ); try { $origin = self::origin(); } catch ( \Throwable $e ) { $origin = ''; } wp_localize_script( self::SLUG, 'smooth_sandbox', [ 'ajax' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'smooth_sandbox' ), 'origin' => $origin, 'recordings' => self::url( 'recordings' ), 'maxBytes' => Recordings::MAX_BYTES, 'chunkBytes' => Recordings::CHUNK, 'strings' => [ 'working' => __( 'Preparing content preview…', 'smooth-sandbox' ), 'review' => __( 'Review the exact data below, then confirm the transfer.', 'smooth-sandbox' ), 'changed' => __( 'Selection changed. Prepare a new preview before launching.', 'smooth-sandbox' ), 'waiting' => __( 'Waiting for PluginPeer… Keep this page open.', 'smooth-sandbox' ), 'recheckTimeout' => __( 'WordPress did not finish checking the reviewed content in time. Nothing was sent. Check your connection and retry.', 'smooth-sandbox' ), 'rechecking' => __( 'Checking your permissions and the reviewed content again…', 'smooth-sandbox' ), 'stale' => __( 'Content changed after your review. Nothing was sent. Prepare a new preview and review it again.', 'smooth-sandbox' ), 'bootTimeout' => __( 'WordPress did not become ready within 3 minutes. Startup was cancelled. Try again with a smaller selection or check your connection.', 'smooth-sandbox' ), 'bootFailed' => __( 'PluginPeer could not finish starting WordPress. Launch a new sandbox to retry.', 'smooth-sandbox' ), 'ready' => __( 'WordPress is ready in PluginPeer. Manage tools are available. Changes stay in this temporary browser copy.', 'smooth-sandbox' ), 'closed' => __( 'The PluginPeer window was closed or its connection was blocked. Allow popups and launch again.', 'smooth-sandbox' ), 'cancelled' => __( 'Startup cancelled. You can launch a new sandbox from the reviewed selection.', 'smooth-sandbox' ), 'stage_files' => __( 'Content transferred. Preparing the WordPress runtime…', 'smooth-sandbox' ), 'stage_runtime' => __( 'Content transferred. Loading WordPress…', 'smooth-sandbox' ), 'stage_setup' => __( 'Content transferred. Importing the selected content…', 'smooth-sandbox' ), 'stage_screen' => __( 'Content transferred. Preparing the WordPress screen…', 'smooth-sandbox' ), /* translators: 1: selected item count, 2: JSON size in KiB. */ 'summary' => __( 'Selected: %1$s · JSON: %2$s KiB / 512 KiB', 'smooth-sandbox' ), /* translators: %s: elapsed seconds measured in this browser. */ 'timing' => __( 'Ready after %s seconds in this browser. This timing is not uploaded.', 'smooth-sandbox' ), 'opened' => __( 'Content transferred. The sandbox is starting in PluginPeer. Keep this page open until WordPress is ready.', 'smooth-sandbox' ), 'timeout' => __( 'PluginPeer did not confirm the private import protocol. No content was sent. The Smooth Sandbox adapter must be deployed in PluginPeer.', 'smooth-sandbox' ), 'popup' => __( 'Allow the PluginPeer window to open, then retry.', 'smooth-sandbox' ), 'originUnavailable' => __( 'The PluginPeer address is invalid. Check the SMOOTH_SANDBOX_PLUGINPEER_ORIGIN configuration before launching.', 'smooth-sandbox' ), 'randomUnavailable' => __( 'This browser cannot create a secure transfer channel. Use a current browser on HTTPS or localhost.', 'smooth-sandbox' ), 'error' => __( 'The request failed. Keep this page open and retry. If your login expired, sign in in another tab first.', 'smooth-sandbox' ), 'unsupported' => __( 'This browser cannot record a tab here. Use a desktop browser with screen capture support on HTTPS or localhost.', 'smooth-sandbox' ), 'recording' => __( 'Recording the surface you selected. Stop when finished. Audio is not recorded.', 'smooth-sandbox' ), 'recorded' => __( 'Review the video below. It is only in this browser until you save it privately.', 'smooth-sandbox' ), 'empty' => __( 'No video was captured. Try recording again.', 'smooth-sandbox' ), 'limit' => __( 'The video reached the 20 MB limit. Record a shorter session.', 'smooth-sandbox' ), 'uploading' => __( 'Saving privately…', 'smooth-sandbox' ), 'saved' => __( 'Saved privately. Open Recordings to review, publish or delete it.', 'smooth-sandbox' ), 'leave' => __( 'An unsaved video will be lost if you leave this page.', 'smooth-sandbox' ), ], ] ); } private function header( string $title ): void { self::authorize(); echo '

' . esc_html( $title ) . '

'; ?>

' . esc_html( $notice['message'] ) . '

'; } } public function sandbox(): void { $this->header( 'Smooth Sandbox' ); try { $inventory = Snapshot::inventory(); $versions = Runtime_Versions::get(); } catch ( \Throwable $error ) { echo '

' . esc_html( $error->getMessage() ) . '

'; return; } $default_wp = in_array( $inventory['wp'], $versions['wordpress'], true ) ? $inventory['wp'] : Runtime_Versions::newest( $versions['wordpress'] ); ?>

$plugin ) : ?>

header( __( 'Settings', 'smooth-sandbox' ) ); ?>

'POST required' ], 405 ); } try { $operation = self::input( 'operation' ); switch ( $operation ) { case 'snapshot_status': wp_send_json_success( Snapshot::status() ); break; case 'snapshot_settings': wp_send_json_success( Snapshot::settings() ); break; case 'snapshot_save': $settings = json_decode( self::input( 'settings' ), true, 32, JSON_THROW_ON_ERROR ); if ( ! is_array( $settings ) ) { throw new \RuntimeException( 'Invalid Snapshot settings.' ); } wp_send_json_success( Snapshot::save( $settings ) ); break; case 'snapshot_queue': wp_send_json_success( Snapshot::queue() ); break; case 'snapshot_delete': wp_send_json_success( Snapshot::remove() ); break; case 'snapshot_prepare': $selection = json_decode( self::input( 'selection' ), true, 32, JSON_THROW_ON_ERROR ); if ( ! is_array( $selection ) ) { throw new \RuntimeException( 'Invalid Sandbox selection.' ); } wp_send_json_success( Snapshot::prepare( $selection ) ); break; case 'snapshot_download': Snapshot::stream( self::input( 'id' ) ); break; case 'snapshot_end': Snapshot::end( self::input( 'id' ) ); wp_send_json_success(); break; case 'snapshot_credentials': $selection = json_decode( self::input( 'selection' ), true, 32, JSON_THROW_ON_ERROR ); if ( ! is_array( $selection ) ) { throw new \RuntimeException( 'Invalid data selection.' ); } $selection['plugins'] = Environment::inventory()['plugins']; wp_send_json_success( Environment::credential_inventory( $selection ) ); break; case 'recording_link_save': $data = json_decode( self::input( 'recording' ), true, 8, JSON_THROW_ON_ERROR ); if ( ! is_array( $data ) ) { throw new \RuntimeException( 'Invalid recording link.' ); } wp_send_json_success( Recording_Webhook::save_link( $data ) ); break; case 'recording_webhook_create': wp_send_json_success( Recording_Webhook::create() ); break; case 'recording_webhook_revoke': Recording_Webhook::revoke( self::input( 'id' ) ); wp_send_json_success(); break; case 'environment_preview': case 'environment_begin': $selection = json_decode( self::input( 'selection' ), true, 32, JSON_THROW_ON_ERROR ); if ( ! is_array( $selection ) ) { throw new \RuntimeException( 'Invalid environment selection.' ); } wp_send_json_success( 'environment_preview' === $operation ? Environment::build( $selection ) : Environment::begin( $selection, self::input( 'reviewHash' ) ) ); break; case 'environment_batch': $index = self::input( 'index' ); $offset = self::input( 'offset' ); if ( ! ctype_digit( $index ) || ! ctype_digit( $offset ) ) { throw new \RuntimeException( 'Invalid file cursor.' ); } wp_send_json_success( Environment::batch( self::input( 'id' ), (int) $index, (int) $offset ) ); break; case 'environment_end': Environment::end( self::input( 'id' ) ); wp_send_json_success(); break; case 'environment_users': if ( ! current_user_can( 'list_users' ) ) { throw new \RuntimeException( 'You cannot list users.' ); } $found = get_users( [ 'search' => '*' . sanitize_text_field( self::input( 'search' ) ) . '*', 'search_columns' => [ 'user_login', 'display_name' ], 'number' => 200, 'offset' => min( 1000000, absint( self::input( 'page' ) ) ) * 200, 'fields' => [ 'ID', 'user_login', 'display_name' ] ] ); wp_send_json_success( array_values( array_map( static fn( $user ) => [ 'id' => (int) $user->ID, 'login' => $user->user_login, 'name' => $user->display_name ], array_filter( $found, static fn( $user ) => current_user_can( 'edit_user', $user->ID ) ) ) ) ); break; case 'environment_credentials': $selection = json_decode( self::input( 'selection' ), true, 32, JSON_THROW_ON_ERROR ); if ( ! is_array( $selection ) ) { throw new \RuntimeException( 'Invalid environment selection.' ); } wp_send_json_success( Environment::credential_inventory( $selection ) ); break; case 'preview': $ids = isset( $_POST['ids'] ) && is_array( $_POST['ids'] ) ? wp_unslash( $_POST['ids'] ) : []; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Content::build strictly validates each scalar ID and rejects nested arrays before querying. wp_send_json_success( Content::build( $ids ) ); break; case 'recording_start': wp_send_json_success( [ 'id' => Recordings::start( self::input( 'title' ), self::input( 'mime' ) ) ] ); break; case 'recording_chunk': $sequence = self::input( 'sequence' ); if ( ! ctype_digit( $sequence ) ) { throw new \RuntimeException( 'Invalid chunk sequence' ); } Recordings::append( self::input( 'id' ), (int) $sequence, self::input( 'data' ) ); wp_send_json_success(); break; case 'recording_finish': Recordings::finish( self::input( 'id' ), (int) self::input( 'size' ) ); wp_send_json_success(); break; case 'recording_discard': Recordings::owned( self::input( 'id' ) ); Recordings::remove( self::input( 'id' ) ); wp_send_json_success(); break; default: throw new \RuntimeException( __( 'Unknown action.', 'smooth-sandbox' ) ); } } catch ( \Throwable $e ) { wp_send_json_error( [ 'message' => $e->getMessage() ], 400 ); } } public static function form( string $operation, string $id, string $label, bool $confirm = false ): void { echo '
'; wp_nonce_field( 'smooth_sandbox_action' ); echo ''; if ( $confirm ) { echo '

'; } $button_class = 'unlink_recording' === $operation ? 'button ss-button-danger' : 'button'; echo '
'; } public function action(): void { self::authorize(); check_admin_referer( 'smooth_sandbox_action' ); if ( 'POST' !== sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? '' ) ) ) { wp_die( 'POST required', '', [ 'response' => 405 ] ); } $error = false; try { $id = self::input( 'id' ); if ( 'unlink_recording' !== self::input( 'operation' ) ) { Recordings::owned( $id ); } switch ( self::input( 'operation' ) ) { case 'unlink_recording': Recording_Webhook::remove_link( $id ); $message = __( 'Recording reference removed. The recording remains in PluginPeer.', 'smooth-sandbox' ); break; case 'publish': if ( '1' !== self::input( 'confirm' ) ) { throw new \RuntimeException( __( 'Review the video and confirm publication.', 'smooth-sandbox' ) ); } Recordings::visibility( $id, true ); $message = __( 'Recording published. The sandbox and source content remain separate.', 'smooth-sandbox' ); break; case 'private': Recordings::visibility( $id, false ); $message = __( 'Recording is private. Old public links and embeds are invalid. Previously downloaded copies cannot be recalled.', 'smooth-sandbox' ); break; case 'delete': if ( '1' !== self::input( 'confirm' ) ) { throw new \RuntimeException( __( 'Confirm permanent deletion.', 'smooth-sandbox' ) ); } Recordings::remove( $id ); $message = __( 'Recording deleted.', 'smooth-sandbox' ); break; default: throw new \RuntimeException( __( 'Unknown action.', 'smooth-sandbox' ) ); } } catch ( \Throwable $e ) { $error = true; $message = $e->getMessage(); } set_transient( 'smooth_sandbox_notice_' . get_current_user_id(), compact( 'message', 'error' ), 120 ); wp_safe_redirect( self::url( 'recordings' ) ); exit; } public function recordings(): void { $this->header( __( 'Recordings', 'smooth-sandbox' ) ); require_once __DIR__ . '/class-recordings-list-table.php'; $table = new Recordings_List_Table(); $table->prepare_items(); echo '
'; $table->search_box( __( 'Search recordings', 'smooth-sandbox' ), 'ss-recordings-search' ); echo '
'; $table->display(); echo ''; } }