array( 'type' => 'string', 'required' => true, 'enum' => array_keys( Prefs::KEYS ), ), 'product' => array( // no default: WordPress validates defaults against the schema too, and '' fails the pattern 'type' => 'string', 'pattern' => '^[a-z0-9][a-z0-9_-]{0,39}$', ), 'board' => array( // the page board a `boards` value is for (0.58.0) 'type' => 'string', 'pattern' => '^[a-z0-9][a-z0-9_./-]{0,119}$', ), 'table' => array( // the table a `tables` value is for (0.61.0): its DOM id (Guidance::TABLE_PATTERN; Guidance is not loaded yet here) 'type' => 'string', 'pattern' => '^[A-Za-z][A-Za-z0-9_-]{0,119}$', ), 'panel' => array( // the side panel a `panel_state` value is for (0.67.0): its key 'type' => 'string', 'pattern' => '^[a-z0-9][a-z0-9_-]{0,39}$', ), 'layout' => array( // the page's windows a `windows` value is for (0.68.13): the layout's key 'type' => 'string', 'pattern' => '^[a-z0-9][a-z0-9_-]{0,39}$', ), ); register_rest_route( self::NAMESPACE, self::ROUTE, array( array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( self::class, 'set' ), 'permission_callback' => array( self::class, 'permission' ), 'args' => $args + array( 'value' => array( 'type' => array( 'string', 'array', 'object' ), // a word for the theme (0.24.0), else a list; string first, because rest_is_array() takes a plain string for a list; a board's arrangement is an object (0.58.0: rest_is_array() takes lists only); clean() decides per key 'required' => true, ), ), ), array( 'methods' => \WP_REST_Server::DELETABLE, 'callback' => array( self::class, 'remove' ), 'permission_callback' => array( self::class, 'permission' ), 'args' => $args, ), ) ); register_rest_route( self::NAMESPACE, '/feedback', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( self::class, 'feedback' ), 'permission_callback' => array( self::class, 'permission' ), 'args' => array( 'product' => array( 'type' => 'string', 'required' => true, 'pattern' => '^[a-z0-9][a-z0-9_-]{0,39}$', ), 'message' => array( 'type' => 'string', 'required' => true, ), 'category' => array( 'type' => 'string', 'default' => 'other', ), 'rating' => array( 'type' => 'integer', 'default' => 0, 'minimum' => 0, 'maximum' => 5, ), 'anonymous' => array( 'type' => 'boolean', 'default' => false, ), 'email' => array( 'type' => 'string', 'default' => '', ), 'screen' => array( 'type' => 'string', 'default' => '', ), ), ) ); } /** * POST /feedback (0.20.0): the brand bar's feedback dialog, through the product's sender (Feedback::submit()). * The registry boots here, for the product and its sender. * * @param \WP_REST_Request $request The request. * @return \WP_REST_Response|\WP_Error */ public static function feedback( \WP_REST_Request $request ) { $registry = Library::ensure_registry(); $input = array(); foreach ( array( 'message', 'category', 'rating', 'anonymous', 'email', 'screen' ) as $field ) { $input[ $field ] = $request->get_param( $field ); } $result = Feedback::submit( $registry, (string) $request['product'], $input ); if ( is_wp_error( $result ) ) { return $result; } $response = rest_ensure_response( $result ); $response->header( 'Cache-Control', 'no-store' ); return $response; } /** * Forgets that routes() ran (tests, where one process serves many requests). * * @internal */ public static function reset(): void { self::$registered = false; } /** * The permission: a signed-in user who can read (cookie authentication has checked the nonce by now). */ public static function permission(): bool { return is_user_logged_in() && current_user_can( 'read' ); } /** * POST: stores one preference. * * @param \WP_REST_Request $request The request. * @return \WP_REST_Response|\WP_Error */ public static function set( \WP_REST_Request $request ) { $result = self::save( (string) $request['key'], (string) $request['product'], $request['value'], self::item( $request ) ); return is_wp_error( $result ) ? $result : rest_ensure_response( $result ); } /** * The item a value is for: a table's id (`tables`), a panel's key (`panel_state`, 0.67.0), a layout's key * (`windows`, 0.68.13), else a board's key. * * @param \WP_REST_Request $request The request. */ private static function item( \WP_REST_Request $request ): string { $key = (string) $request['key']; if ( Prefs::WINDOWS === $key ) { return (string) $request['layout']; } return (string) ( Prefs::TABLES === $key || Prefs::TEXT_SIZES === $key ? $request['table'] : ( Prefs::PANEL_STATE === $key ? $request['panel'] : $request['board'] ) ); } /** * DELETE: removes one preference (one board's arrangement, for `boards`). * * @param \WP_REST_Request $request The request. * @return \WP_REST_Response|\WP_Error */ public static function remove( \WP_REST_Request $request ) { $result = self::save( (string) $request['key'], (string) $request['product'], null, self::item( $request ) ); return is_wp_error( $result ) ? $result : rest_ensure_response( $result ); } /** * Validates and stores one preference (null removes it). The result: key, product, value. A `boards` value is one * board's arrangement (0.58.0): stored under `$board` among the product's others (Board::store()), the result its * own with the board's key. A `tables` value (0.61.0) is one table's shown columns, stored the same way under the * table's id (Guidance::store()), the result with the table's id. * * @param string $key Preference key (Prefs::KEYS). * @param string $product Product slug ('' for a key that is not per product). * @param mixed $value The decoded value, or null to remove. * @param string $board The page board (`boards`), or the table's id (`tables`). * @return array|\WP_Error */ public static function save( string $key, string $product, $value, string $board = '' ) { $user = get_current_user_id(); if ( $user <= 0 ) { return new \WP_Error( 'rooster_admin_forbidden', __( 'You must be signed in.', 'ai-eshop-optimizer' ), array( 'status' => 401 ) ); } $key = sanitize_key( $key ); $product = sanitize_key( $product ); if ( ! isset( Prefs::KEYS[ $key ] ) ) { return new \WP_Error( 'rooster_admin_key', __( 'Unknown preference.', 'ai-eshop-optimizer' ), array( 'status' => 400 ) ); } $registry = Library::ensure_registry(); $per = Prefs::KEYS[ $key ]; $def = $per ? $registry->product( $product ) : null; if ( $per && null === $def ) { return new \WP_Error( 'rooster_admin_product', __( 'Unknown product.', 'ai-eshop-optimizer' ), array( 'status' => 400 ) ); } if ( null !== $value ) { if ( strlen( (string) wp_json_encode( $value ) ) > ( Prefs::BOARDS === $key ? Board::VALUE_BYTES : Prefs::MAX_BYTES ) ) { // 0.80.0: a board's rows and layouts with it return new \WP_Error( 'rooster_admin_value', __( 'The value is too large.', 'ai-eshop-optimizer' ), array( 'status' => 400 ) ); } $value = self::clean( $registry, $key, $value, $def ); if ( null === $value ) { return new \WP_Error( 'rooster_admin_value', __( 'The value is not acceptable.', 'ai-eshop-optimizer' ), array( 'status' => 400 ) ); } } if ( Prefs::BOARDS === $key ) { if ( 1 !== preg_match( Board::KEY_PATTERN, $board ) ) { return new \WP_Error( 'rooster_admin_board', __( 'Unknown board.', 'ai-eshop-optimizer' ), array( 'status' => 400 ) ); } $layout = $value; $boards = Board::store( Prefs::get( $user, Prefs::BOARDS, $product, array() ), $board, $layout ); if ( ! Prefs::set( $user, $key, array() === $boards ? null : $boards, $product ) ) { return new \WP_Error( 'rooster_admin_save', __( 'That was not saved. Try again.', 'ai-eshop-optimizer' ), array( 'status' => 500 ) ); } return array( 'key' => $key, 'product' => $product, 'board' => $board, 'value' => $layout, ); } if ( Prefs::TEXT_SIZES === $key ) { // 0.68.0: one table's text size among the product's others; the default forgets it if ( 1 !== preg_match( '/^[A-Za-z][A-Za-z0-9_-]{0,119}$/', $board ) ) { return new \WP_Error( 'rooster_admin_table', __( 'Unknown table.', 'ai-eshop-optimizer' ), array( 'status' => 400 ) ); } $sizes = (array) Prefs::get( $user, Prefs::TEXT_SIZES, $product, array() ); unset( $sizes[ $board ] ); if ( null !== $value && 3 !== $value ) { $sizes[ $board ] = $value; } while ( count( $sizes ) > 1 && strlen( (string) wp_json_encode( $sizes ) ) > Prefs::LIMITS[ Prefs::TEXT_SIZES ] ) { array_shift( $sizes ); } if ( ! Prefs::set( $user, $key, array() === $sizes ? null : $sizes, $product ) ) { return new \WP_Error( 'rooster_admin_save', __( 'That was not saved. Try again.', 'ai-eshop-optimizer' ), array( 'status' => 500 ) ); } return array( 'key' => $key, 'product' => $product, 'table' => $board, 'value' => $value, ); } if ( Prefs::PANEL_STATE === $key ) { // 0.67.0: one panel's state among the product's others if ( 1 !== preg_match( '/^[a-z0-9][a-z0-9_-]{0,39}$/', $board ) ) { return new \WP_Error( 'rooster_admin_panel', __( 'Unknown panel.', 'ai-eshop-optimizer' ), array( 'status' => 400 ) ); } $panels = (array) Prefs::get( $user, Prefs::PANEL_STATE, $product, array() ); unset( $panels[ $board ] ); if ( null !== $value ) { $panels[ $board ] = $value; // the panel saved last at the end } while ( count( $panels ) > 1 && strlen( (string) wp_json_encode( $panels ) ) > Prefs::LIMITS[ Prefs::PANEL_STATE ] ) { array_shift( $panels ); } if ( ! Prefs::set( $user, $key, array() === $panels ? null : $panels, $product ) ) { return new \WP_Error( 'rooster_admin_save', __( 'That was not saved. Try again.', 'ai-eshop-optimizer' ), array( 'status' => 500 ) ); } return array( 'key' => $key, 'product' => $product, 'panel' => $board, 'value' => $value, ); } if ( Prefs::WINDOWS === $key ) { // 0.68.13: one layout's windows among the product's others if ( 1 !== preg_match( '/^[a-z0-9][a-z0-9_-]{0,39}$/', $board ) ) { return new \WP_Error( 'rooster_admin_layout', __( 'Unknown layout.', 'ai-eshop-optimizer' ), array( 'status' => 400 ) ); } $layouts = (array) Prefs::get( $user, Prefs::WINDOWS, $product, array() ); unset( $layouts[ $board ] ); if ( null !== $value ) { $layouts[ $board ] = $value; // the layout saved last at the end } while ( count( $layouts ) > 1 && strlen( (string) wp_json_encode( $layouts ) ) > Prefs::LIMITS[ Prefs::WINDOWS ] ) { array_shift( $layouts ); } if ( ! Prefs::set( $user, $key, array() === $layouts ? null : $layouts, $product ) ) { return new \WP_Error( 'rooster_admin_save', __( 'That was not saved. Try again.', 'ai-eshop-optimizer' ), array( 'status' => 500 ) ); } return array( 'key' => $key, 'product' => $product, 'layout' => $board, 'value' => $value, ); } if ( Prefs::TABLES === $key ) { if ( 1 !== preg_match( Guidance::TABLE_PATTERN, $board ) ) { return new \WP_Error( 'rooster_admin_table', __( 'Unknown table.', 'ai-eshop-optimizer' ), array( 'status' => 400 ) ); } $tables = Guidance::store( (array) Prefs::get( $user, Prefs::TABLES, $product, array() ), $board, $value ); if ( ! Prefs::set( $user, $key, array() === $tables ? null : $tables, $product ) ) { return new \WP_Error( 'rooster_admin_save', __( 'That was not saved. Try again.', 'ai-eshop-optimizer' ), array( 'status' => 500 ) ); } return array( 'key' => $key, 'product' => $product, 'table' => $board, 'value' => $value, ); } if ( ! Prefs::set( $user, $key, $value, $per ? $product : null ) ) { return new \WP_Error( 'rooster_admin_save', __( 'That was not saved. Try again.', 'ai-eshop-optimizer' ), array( 'status' => 500 ) ); } return array( 'key' => $key, 'product' => $per ? $product : '', 'value' => $value, ); } /** * A validated value, or null. * * @param Registry $registry The registry. * @param string $key Preference key. * @param mixed $value Decoded value. * @param array|null $product The product (per-product keys). * @return mixed */ public static function clean( Registry $registry, string $key, $value, ?array $product ) { switch ( $key ) { case Prefs::PINS: if ( ! is_array( $value ) || count( $value ) > Chrome::MAX_PINS ) { return null; } $visible = $registry->visible( $product['slug'] ); $out = array(); foreach ( $value as $id ) { if ( ! is_string( $id ) || ! isset( $visible[ $id ] ) || ! $visible[ $id ]['available'] || ! $visible[ $id ]['pin'] || in_array( $id, $out, true ) ) { return null; } $out[] = $id; } return $out; case Prefs::LAYOUT: return HomePage::clean_layout( $value, array_keys( $product['sections'] ) ); case Prefs::BOARDS: return Board::clean( $value ); // the shape; the cards are checked when the board is drawn case Prefs::AUDIENCES: if ( ! is_array( $value ) ) { return null; } $clean = Personas::clean( $product, $value ); return count( $clean ) === count( $value ) ? $clean : null; case Prefs::THEME: return is_string( $value ) && in_array( $value, Prefs::THEMES, true ) ? $value : null; case Prefs::GUIDANCE: // a plain word, as the theme (0.61.0) return is_string( $value ) && in_array( $value, Prefs::GUIDANCE_LEVELS, true ) ? $value : null; case Prefs::TABLES: return Guidance::clean_shown( $value ); // a list of column keys; the table's own columns are checked when it is drawn case Prefs::PANEL_STATE: return Prefs::clean_panel( $value ); // one panel's (0.67.0) case Prefs::WINDOWS: return Prefs::clean_windows( $value ); // one layout's windows (0.68.13) case Prefs::TEXT_SIZES: // one table's level (0.68.0) return is_numeric( $value ) && isset( Prefs::TEXT_SIZE_LEVELS[ (int) $value ] ) && (float) $value === (float) (int) $value ? (int) $value : null; case Prefs::PANELS: if ( ! is_array( $value ) || count( $value ) > self::MAX_PANELS ) { return null; } $out = array(); foreach ( $value as $panel ) { if ( ! is_string( $panel ) || 1 !== preg_match( '/^[a-z0-9][a-z0-9_-]{0,39}$/', $panel ) || in_array( $panel, $out, true ) ) { return null; } $out[] = $panel; } return $out; case Prefs::HINTS: if ( ! is_array( $value ) || count( $value ) > self::MAX_HINTS ) { return null; } $out = array(); foreach ( $value as $hint ) { if ( ! is_string( $hint ) || 1 !== preg_match( '/^[a-z0-9][a-z0-9_\/.-]{0,59}$/', $hint ) || in_array( $hint, $out, true ) ) { return null; } $out[] = $hint; } return $out; } return null; } }