prefix . 'smooth_sandbox_callbacks'; } public static function links(): string { global $wpdb; return $wpdb->prefix . 'smooth_sandbox_recording_links'; } public function __construct() { add_action( 'admin_init', [ self::class, 'maybe_install' ] ); add_action( 'rest_api_init', [ $this, 'routes' ] ); add_action( 'smooth_sandbox_cleanup', [ self::class, 'cleanup' ] ); add_action( 'delete_user', [ self::class, 'delete_user' ] ); } public static function maybe_install(): void { if ( current_user_can( 'manage_options' ) && self::SCHEMA !== get_option( 'smooth_sandbox_callback_schema' ) ) { self::install(); } } public static function install(): void { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $collate = $wpdb->get_charset_collate(); dbDelta( 'CREATE TABLE ' . self::callbacks() . " ( id varchar(36) NOT NULL, user_id bigint(20) unsigned NOT NULL, token_hash varchar(64) NOT NULL, provider_origin varchar(255) NOT NULL, expires_at datetime NOT NULL, created_at datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY token_hash (token_hash), KEY user_id (user_id), KEY expires_at (expires_at) ) ENGINE=InnoDB $collate;" ); dbDelta( 'CREATE TABLE ' . self::links() . " ( id varchar(36) NOT NULL, user_id bigint(20) unsigned NOT NULL, url text NOT NULL, url_hash varchar(64) NOT NULL, title varchar(200) NOT NULL, kind varchar(16) NOT NULL DEFAULT 'link', created_at datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY owner_url (user_id,url_hash), KEY user_id (user_id) ) ENGINE=InnoDB $collate;" ); foreach ( [ self::callbacks(), self::links() ] as $table ) { if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) !== $table ) { throw new \RuntimeException( 'Could not prepare recording callbacks.' ); } } update_option( 'smooth_sandbox_callback_schema', self::SCHEMA, false ); } private static function lock( int $owner, callable $callback ) { $key = 'smooth_sandbox_callback_lock_' . $owner; if ( ! add_option( $key, time(), '', false ) ) { if ( (int) get_option( $key ) < time() - 60 ) { delete_option( $key ); } throw new \RuntimeException( 'Recording callback is busy. Retry shortly.' ); } try { return $callback(); } finally { delete_option( $key ); } } public static function create(): array { global $wpdb; if ( ! current_user_can( 'manage_options' ) ) { throw new \RuntimeException( 'Administrator access is required.' ); } self::maybe_install(); $owner = get_current_user_id(); return self::lock( $owner, static function () use ( $wpdb, $owner ) { self::cleanup(); $count = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE user_id=%d', self::callbacks(), $owner ) ); if ( $count >= self::MAX_ACTIVE ) { throw new \RuntimeException( 'Too many active recording callbacks. Retry after an earlier session expires.' ); } $id = wp_generate_uuid4(); $token = bin2hex( random_bytes( 32 ) ); $expires = time() + self::TTL; if ( ! $wpdb->insert( self::callbacks(), [ 'id' => $id, 'user_id' => $owner, 'token_hash' => hash( 'sha256', $token ), 'provider_origin' => Admin::origin(), 'expires_at' => gmdate( 'Y-m-d H:i:s', $expires ), 'created_at' => current_time( 'mysql', true ) ] ) ) { throw new \RuntimeException( 'Could not create recording callback.' ); } return [ 'id' => $id, 'url' => add_query_arg( 'token', $token, rest_url( 'smooth-sandbox/v1/recordings' ) ), 'expiresAt' => gmdate( 'c', $expires ) ]; } ); } public static function revoke( string $id ): void { global $wpdb; if ( ! current_user_can( 'manage_options' ) ) { throw new \RuntimeException( 'Administrator access is required.' ); } $owner = get_current_user_id(); self::lock( $owner, static function () use ( $wpdb, $id, $owner ) { $wpdb->delete( self::callbacks(), [ 'id' => $id, 'user_id' => $owner ] ); } ); } public function routes(): void { register_rest_route( 'smooth-sandbox/v1', '/recordings', [ 'methods' => 'POST', 'callback' => [ $this, 'receive' ], 'permission_callback' => [ $this, 'permission' ], ] ); } private static function capability( \WP_REST_Request $request ): ?array { global $wpdb; // Query-only token: never accept an owner or authorization token from the JSON payload. if ( self::SCHEMA !== get_option( 'smooth_sandbox_callback_schema' ) ) { return null; } $query = $request->get_query_params(); $token = $query['token'] ?? ''; if ( ! is_string( $token ) || ! preg_match( '/^[a-f0-9]{64}$/D', $token ) ) { return null; } $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE token_hash=%s AND expires_at > %s', self::callbacks(), hash( 'sha256', $token ), current_time( 'mysql', true ) ), ARRAY_A ); if ( ! $row || ! user_can( (int) $row['user_id'], 'manage_options' ) ) { return null; } return $row; } public function permission( \WP_REST_Request $request ) { return self::capability( $request ) ? true : new \WP_Error( 'callback_unavailable', 'Recording callback is unavailable or expired.', [ 'status' => 404 ] ); } private static function valid_url( string $url, string $origin ): bool { if ( strlen( $url ) > 2048 || preg_match( '/[\x00-\x20\x7f\\\\]/', $url ) ) { return false; } $parts = wp_parse_url( $url ); $expected = wp_parse_url( $origin ); return is_array( $parts ) && ! isset( $parts['user'] ) && ! isset( $parts['pass'] ) && ( $parts['scheme'] ?? '' ) === ( $expected['scheme'] ?? '' ) && strtolower( $parts['host'] ?? '' ) === strtolower( $expected['host'] ?? '' ) && ( $parts['port'] ?? ( 'https' === ( $parts['scheme'] ?? '' ) ? 443 : 80 ) ) === ( $expected['port'] ?? ( 'https' === ( $expected['scheme'] ?? '' ) ? 443 : 80 ) ) && ! empty( $parts['path'] ) && '/' !== $parts['path'] && '' !== esc_url_raw( $url, [ 'https', 'http' ] ); } public function receive( \WP_REST_Request $request ) { global $wpdb; if ( strlen( $request->get_body() ) > self::MAX_BODY ) { return new \WP_Error( 'callback_too_large', 'Callback body exceeds 8 KiB.', [ 'status' => 413 ] ); } if ( ! $request->is_json_content_type() ) { return new \WP_Error( 'callback_format', 'Send application/json.', [ 'status' => 415 ] ); } $data = $request->get_json_params(); if ( ! is_array( $data ) || array_diff( array_keys( $data ), [ 'url', 'type', 'title' ] ) || ! is_string( $data['url'] ?? null ) || ( isset( $data['type'] ) && ! in_array( $data['type'], [ 'public', 'managed' ], true ) ) || ( isset( $data['title'] ) && ( ! is_string( $data['title'] ) || strlen( $data['title'] ) > 800 ) ) ) { return new \WP_Error( 'callback_payload', 'Expected url and optional type (public or managed) and title.', [ 'status' => 400 ] ); } $capability = self::capability( $request ); if ( ! $capability ) { return new \WP_Error( 'callback_unavailable', 'Recording callback is unavailable or expired.', [ 'status' => 404 ] ); } if ( ! self::valid_url( $data['url'], $capability['provider_origin'] ) ) { return new \WP_Error( 'callback_url', 'Recording URL must belong to the configured PluginPeer origin.', [ 'status' => 400 ] ); } $owner = (int) $capability['user_id']; try { $result = self::store_link( $owner, $data, static function () use ( $request ) { if ( ! self::capability( $request ) ) { throw new \RuntimeException( 'Recording callback is unavailable.' ); } } ); } catch ( \Throwable $error ) { return new \WP_Error( 'callback_retry', 'Could not save recording link. Retry later.', [ 'status' => 503 ] ); } $response = new \WP_REST_Response( [ 'received' => true ] + $result, $result['duplicate'] ? 200 : 201 ); $response->header( 'Cache-Control', 'private, no-store, max-age=0' ); $response->header( 'Referrer-Policy', 'no-referrer' ); return $response; } /** Authenticated same-origin save for the negotiated Playground session channel. */ public static function save_link( array $data ): array { if ( ! current_user_can( 'manage_options' ) ) { throw new \RuntimeException( 'Administrator access is required.' ); } if ( strlen( wp_json_encode( $data ) ) > self::MAX_BODY || array_diff( array_keys( $data ), [ 'url' ] ) || ! is_string( $data['url'] ?? null ) || ! self::valid_url( $data['url'], Admin::origin() ) ) { throw new \RuntimeException( 'Invalid PluginPeer recording URL.' ); } self::maybe_install(); $owner = get_current_user_id(); return self::store_link( $owner, $data, static function () use ( $owner ) { if ( get_current_user_id() !== $owner || ! current_user_can( 'manage_options' ) ) { throw new \RuntimeException( 'Administrator access is required.' ); } } ); } private static function store_link( int $owner, array $data, callable $authorize ): array { global $wpdb; return self::lock( $owner, static function () use ( $wpdb, $owner, $data, $authorize ) { $authorize(); $hash = hash( 'sha256', $data['url'] ); $existing = $wpdb->get_row( $wpdb->prepare( 'SELECT id FROM %i WHERE user_id=%d AND url_hash=%s', self::links(), $owner, $hash ), ARRAY_A ); if ( $existing ) { return [ 'id' => $existing['id'], 'duplicate' => true ]; } if ( (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE user_id=%d', self::links(), $owner ) ) >= self::MAX_LINKS ) { throw new \RuntimeException( 'Recording link storage is full.' ); } $id = wp_generate_uuid4(); $title = wp_html_excerpt( sanitize_text_field( $data['title'] ?? '' ) ?: 'PluginPeer recording', 200, '' ); if ( ! $wpdb->insert( self::links(), [ 'id' => $id, 'user_id' => $owner, 'url' => $data['url'], 'url_hash' => $hash, 'title' => $title, 'kind' => $data['type'] ?? 'link', 'created_at' => current_time( 'mysql', true ) ] ) ) { throw new \RuntimeException( 'Could not save recording link.' ); } return [ 'id' => $id, 'duplicate' => false ]; } ); } public static function for_owner(): array { global $wpdb; if ( ! current_user_can( 'manage_options' ) ) { return []; } return $wpdb->get_results( $wpdb->prepare( 'SELECT id,url,title,kind,created_at FROM %i WHERE user_id=%d ORDER BY created_at DESC LIMIT %d', self::links(), get_current_user_id(), self::MAX_LINKS ), ARRAY_A ); } public static function remove_link( string $id ): void { global $wpdb; if ( ! current_user_can( 'manage_options' ) || ! $wpdb->delete( self::links(), [ 'id' => $id, 'user_id' => get_current_user_id() ] ) ) { throw new \RuntimeException( 'Recording link not found in your account.' ); } } public static function cleanup(): void { global $wpdb; if ( self::SCHEMA !== get_option( 'smooth_sandbox_callback_schema' ) ) { return; } $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE expires_at <= %s', self::callbacks(), current_time( 'mysql', true ) ) ); } public static function delete_user( int $owner ): void { global $wpdb; if ( self::SCHEMA !== get_option( 'smooth_sandbox_callback_schema' ) ) { return; } self::lock( $owner, static function () use ( $wpdb, $owner ) { $wpdb->delete( self::callbacks(), [ 'user_id' => $owner ] ); $wpdb->delete( self::links(), [ 'user_id' => $owner ] ); } ); } }