user_can_access() ) { return false; } if ( self::ADVANCED !== $level ) { return true; } if ( ! current_user_can( self::advanced_capability() ) ) { return false; } /** * Filter an extra capability Advanced actions need, on top of the default * (`manage_options`, or `manage_network_options` on multisite). It can only make the check * stricter: the default is always required too. Return '' for none. * * @since 1.0.0 * @param string $capability Extra capability ('' by default). * @param AbstractModule $module Power-up the action belongs to. */ $extra = apply_filters( 'apwc_advanced_capability', '', $module ); return ! is_string( $extra ) || '' === $extra || current_user_can( $extra ); } /** * The capability Advanced actions always need: `manage_options` on a single site, * `manage_network_options` (super admin) on multisite, where site admins get Safe and Review * actions only. * * @since 1.0.0 * @return string */ public static function advanced_capability(): string { return is_multisite() ? 'manage_network_options' : 'manage_options'; } /** * A REST `permission_callback`: can() for the level, an optional object capability (for * example `edit_shop_orders`) and, for Advanced routes, the typed confirmation word. * WordPress answers 401 (logged out) or 403 when access fails; a missing or wrong word is a 400 * `apwc_confirm` from check_confirm(). * * Advanced routes must name their word with `$confirm` (`delete`, `cancel`, `purge` or * `generate`). An * Advanced route set up without one (or with another key) is always refused (400 * `apwc_confirm_missing`): it fails closed rather than running without a typed word. * A `$confirm` given for a Safe or Review route is checked too. * * @since 1.0.0 * @param AbstractModule $module Power-up the route belongs to. * @param string $level `safe`, `review` or `advanced`. * @param string $object_cap Extra capability, or ''. * @param string $confirm Confirm action key for confirm_word(): `delete`, `cancel`, * `purge`, `generate`; required for `advanced`, '' otherwise. * @return \Closure(\WP_REST_Request=): (bool|WP_Error) */ public static function permission( AbstractModule $module, string $level = self::SAFE, string $object_cap = '', string $confirm = '' ): \Closure { return static function ( $request = null ) use ( $module, $level, $object_cap, $confirm ) { if ( ! self::can( $module, $level ) || ( '' !== $object_cap && ! current_user_can( $object_cap ) ) ) { return false; } if ( '' === $confirm && self::ADVANCED !== $level ) { return true; } if ( ! in_array( $confirm, self::confirm_actions(), true ) || ! $request instanceof WP_REST_Request ) { return new WP_Error( 'apwc_confirm_missing', __( 'This action needs a typed confirmation, and none is set up for it.', 'admin-powerups-for-woocommerce' ), array( 'status' => 400 ) ); } return self::check_confirm( $request, self::confirm_word( $confirm ) ); }; } /** * Action keys confirm_word() knows. * * @since 1.0.0 * @return string[] */ public static function confirm_actions(): array { return array( 'delete', 'cancel', 'purge', 'generate', 'run', 'retry' ); } /** * The word a user types to confirm an Advanced action, translated. * * @since 1.0.0 * @param string $action `delete`, `cancel`, `purge`, `generate`, `run` or `retry` (anything else gets the delete word). * @return string */ public static function confirm_word( string $action ): string { switch ( $action ) { case 'cancel': /* translators: One short word people type to confirm, in any case. Avoid letters whose capitals don't lowercase back to them, such as Turkish İ/ı ("İPTAL" doesn't match "iptal"). */ return _x( 'cancel', 'word typed to confirm cancelling', 'admin-powerups-for-woocommerce' ); case 'purge': /* translators: One short word people type to confirm, in any case. Avoid letters whose capitals don't lowercase back to them, such as Turkish İ/ı ("İPTAL" doesn't match "iptal"). */ return _x( 'purge', 'word typed to confirm purging', 'admin-powerups-for-woocommerce' ); case 'generate': /* translators: One short word people type to confirm, in any case. Avoid letters whose capitals don't lowercase back to them, such as Turkish İ/ı ("İPTAL" doesn't match "iptal"). */ return _x( 'generate', 'word typed to confirm generating test data', 'admin-powerups-for-woocommerce' ); case 'run': /* translators: One short word people type to confirm, in any case. Avoid letters whose capitals don't lowercase back to them, such as Turkish İ/ı ("İPTAL" doesn't match "iptal"). */ return _x( 'run', 'word typed to confirm running a cron event now', 'admin-powerups-for-woocommerce' ); case 'retry': /* translators: One short word people type to confirm, in any case. Avoid letters whose capitals don't lowercase back to them, such as Turkish İ/ı ("İPTAL" doesn't match "iptal"). */ return _x( 'retry', 'word typed to confirm retrying', 'admin-powerups-for-woocommerce' ); default: /* translators: One short word people type to confirm, in any case. Avoid letters whose capitals don't lowercase back to them, such as Turkish İ/ı ("İPTAL" doesn't match "iptal"). */ return _x( 'delete', 'word typed to confirm deleting', 'admin-powerups-for-woocommerce' ); } } /** * Check the typed confirmation word (`confirm` request parameter) on the server. Case and * surrounding spaces are ignored, for any language (the panel unlocks the button the same way). * * @since 1.0.0 * @param WP_REST_Request $request Request with a `confirm` parameter. * @param string $word Expected word, e.g. confirm_word( 'delete' ). * @return true|WP_Error 400 `apwc_confirm` when it doesn't match. */ public static function check_confirm( WP_REST_Request $request, string $word ): bool|WP_Error { $typed = $request->get_param( 'confirm' ); $typed = self::fold( Str::scalar( $typed ) ); $expected = self::fold( $word ); if ( '' !== $expected && $typed === $expected ) { return true; } $word = trim( $word ); return new WP_Error( 'apwc_confirm', /* translators: %s: word to type, e.g. "delete". */ sprintf( __( 'Type “%s” to confirm.', 'admin-powerups-for-woocommerce' ), $word ), array( 'status' => 400 ) ); } /** * A typed word ready to compare: surrounding (Unicode) spaces removed, NFC-normalized when intl * is available, and case-folded in any script (`İ`, `Σ`/`ς`, `ẞ` …), not only ASCII. * * @since 1.0.0 * @param string $text Text. * @param bool|null $intl Use intl's Normalizer (null: when available; false in tests). * @return string '' for invalid UTF-8. */ private static function fold( string $text, ?bool $intl = null ): string { $text = preg_replace( '/^[\s\p{Z}\x{FEFF}]+|[\s\p{Z}\x{FEFF}]+$/u', '', $text ); if ( ! is_string( $text ) || '' === $text ) { return ''; } if ( null === $intl ? class_exists( '\Normalizer' ) : $intl ) { $normal = \Normalizer::normalize( $text, \Normalizer::FORM_C ); $text = is_string( $normal ) ? $normal : $text; } else { // No intl: composed and decomposed accents can't be matched exactly, so both sides lose // their marks (a little more lenient, the same on both sides). $text = remove_accents( (string) preg_replace( '/\p{Mn}+/u', '', $text ) ); } return mb_convert_case( $text, MB_CASE_FOLD, 'UTF-8' ); } }