HeaderFooterCodeScreen::url(), 'label' => __( 'Edit code', 'adminkeep' ), ); } /** * The editing screen. * * @return HeaderFooterCodeScreen */ public function screen() { if ( null === $this->screen ) { $this->screen = new HeaderFooterCodeScreen(); } return $this->screen; } /** * Register hooks. * * An admin request gets the screen and the route, nothing else: the print hooks are front-end * hooks and have no business being registered there. * * The route is hooked before the admin branch returns. A REST request is not an admin request, * but the block editor preloads REST responses from inside wp-admin, so both paths need it. * * @param array $settings Resolved settings. Unused — this feature has none. * @return void */ public function boot( array $settings ) { add_action( 'rest_api_init', array( $this, 'register_rest_route' ) ); if ( is_admin() ) { $this->screen()->boot(); return; } add_action( 'wp_head', array( $this, 'print_head' ), self::HEAD_PRIORITY ); add_action( 'wp_body_open', array( $this, 'print_body' ) ); add_action( 'wp_footer', array( $this, 'print_footer' ), self::FOOTER_PRIORITY ); } /** * Translated name of a box, for the screen and for messages. * * @param string $location head, body or footer. * @return string */ public static function location_label( $location ) { switch ( $location ) { case 'head': return __( 'Head', 'adminkeep' ); case 'body': return __( 'Body open', 'adminkeep' ); case 'footer': return __( 'Footer', 'adminkeep' ); } return (string) $location; } /** * All three boxes, always well formed, whatever the option holds. * * A box never saved has empty code and a changed_at of 0. * * @return array */ public static function codes() { $stored = get_option( self::OPTION, array() ); $stored = is_array( $stored ) ? $stored : array(); $codes = array(); foreach ( self::LOCATIONS as $location ) { $box = isset( $stored[ $location ] ) && is_array( $stored[ $location ] ) ? $stored[ $location ] : array(); $codes[ $location ] = array( 'code' => isset( $box['code'] ) && is_string( $box['code'] ) ? $box['code'] : '', 'changed_by' => isset( $box['changed_by'] ) ? absint( $box['changed_by'] ) : 0, 'changed_at' => isset( $box['changed_at'] ) ? absint( $box['changed_at'] ) : 0, ); } return $codes; } /** * Check boxes before anything is stored: the size cap, measured in bytes since bytes are what * the database stores, and valid UTF-8, which a strict database refuses or cuts short. * * @param array $codes Location => code. Unknown locations are ignored. * @return true|\WP_Error */ public static function validate( array $codes ) { foreach ( self::LOCATIONS as $location ) { if ( ! isset( $codes[ $location ] ) ) { continue; } $bytes = strlen( (string) $codes[ $location ] ); if ( $bytes > self::MAX_BYTES ) { return new \WP_Error( 'adminkeep_code_too_long', sprintf( /* translators: 1: box name, 2: its size in bytes, 3: the limit in bytes, 4: the limit as a readable size such as "64 KB". */ __( 'The %1$s box holds %2$s bytes; one box can hold up to %3$s bytes (%4$s). Nothing was saved. For a large script, upload it as a file and load it with a script tag\'s src attribute instead of pasting it in.', 'adminkeep' ), self::location_label( $location ), number_format_i18n( $bytes ), number_format_i18n( self::MAX_BYTES ), size_format( self::MAX_BYTES ) ) ); } // wp_check_invalid_utf8() hands back an empty string for invalid UTF-8, and the text // unchanged when it is valid or the site does not use UTF-8 at all. if ( wp_check_invalid_utf8( (string) $codes[ $location ] ) !== (string) $codes[ $location ] ) { return new \WP_Error( 'adminkeep_code_invalid_utf8', sprintf( /* translators: %s: box name. */ __( 'The %s box contains characters that are not valid UTF-8, which the database may refuse or cut short. Nothing was saved. Copy the snippet again from where it came from, or save it as UTF-8 first.', 'adminkeep' ), self::location_label( $location ) ) ); } } return true; } /** * Store boxes, stamping only the ones whose code actually changed. The one write path, used by * the screen, by `wp adminkeep code set` and by the REST route. * * @param array $codes Location => code. A location left out is kept as it is. * @param int $user_id Who is saving; 0 when WP-CLI runs without a user. * @return true|\WP_Error Error when a box is too long or the database refused the write. */ public static function save( array $codes, $user_id ) { foreach ( $codes as $location => $code ) { $codes[ $location ] = str_replace( "\r\n", "\n", (string) $code ); } $valid = self::validate( $codes ); if ( is_wp_error( $valid ) ) { return $valid; } $next = self::codes(); $changed = false; foreach ( self::LOCATIONS as $location ) { if ( ! isset( $codes[ $location ] ) || $codes[ $location ] === $next[ $location ]['code'] ) { continue; } $next[ $location ] = array( 'code' => $codes[ $location ], 'changed_by' => absint( $user_id ), 'changed_at' => time(), ); $changed = true; } if ( ! $changed ) { return true; } global $wpdb; // Start clean, so any error reported below is this write's and not a leftover from an // earlier query on the same request. $wpdb->last_error = ''; if ( update_option( self::OPTION, $next, true ) ) { return true; } // update_option() answers false for a refused write, but also when the row already holds // this exact value and the UPDATE touches nothing: a double-clicked Save whose first // request landed a moment earlier. Only the row itself can tell the two apart. $error = (string) $wpdb->last_error; $row = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", self::OPTION ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- The cache is what cannot be trusted here. if ( null !== $row && maybe_unserialize( $row ) === $next ) { // The cache still holds the old value, since update_option() stopped before refreshing it. wp_cache_delete( self::OPTION, 'options' ); wp_cache_delete( 'alloptions', 'options' ); return true; } $message = __( 'The code could not be saved: the database did not accept the change.', 'adminkeep' ); if ( '' !== $error ) { /* translators: %s: the database's own error message. */ $message .= ' ' . sprintf( __( 'It said: %s', 'adminkeep' ), $error ); } return new \WP_Error( 'adminkeep_code_not_saved', $message ); } /** * Register GET and POST /adminkeep/v1/header-footer-code. * * Application passwords authenticate REST requests only, so this route is what lets the code be * updated on a site reachable over HTTP but not over SSH. * * @return void */ public function register_rest_route() { $args = array(); foreach ( self::LOCATIONS as $location ) { $args[ $location ] = array( 'type' => 'string', 'required' => false, // Named explicitly: in a hand-written args array the type alone is not // enforced, and an array or number would otherwise reach the callback. 'validate_callback' => 'rest_validate_request_arg', ); } register_rest_route( RestController::NAMESPACE_V1, '/header-footer-code', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'rest_get' ), 'permission_callback' => array( $this, 'rest_can_edit' ), ), array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'rest_save' ), 'permission_callback' => array( $this, 'rest_can_edit' ), 'args' => $args, ), ) ); } /** * The screen's gate: manage_options and unfiltered_html together. * * @return bool|\WP_Error */ public function rest_can_edit() { if ( HeaderFooterCodeScreen::can_edit() ) { return true; } return new \WP_Error( 'adminkeep_code_forbidden', __( 'You are not allowed to edit the header and footer code on this site.', 'adminkeep' ), array( 'status' => rest_authorization_required_code() ) ); } /** * GET: all three boxes, each with its code byte for byte and who changed it when. * * @return \WP_REST_Response */ public function rest_get() { return rest_ensure_response( self::codes() ); } /** * POST: replace the boxes the request names and keep the others. * * An empty string is a value like any other: it clears that box. A request naming none of the * three is a 400 rather than a silent success, since it is almost always a wrong key or body. * Refused code is a 400 carrying the same message the screen shows, and nothing is written. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function rest_save( $request ) { $codes = array(); foreach ( self::LOCATIONS as $location ) { if ( $request->has_param( $location ) ) { $codes[ $location ] = (string) $request->get_param( $location ); } } if ( array() === $codes ) { return new \WP_Error( 'adminkeep_code_missing', sprintf( /* translators: %s: the accepted keys, such as "head, body, footer". */ __( 'Nothing to save: send at least one of %s. A box left out is kept as it is.', 'adminkeep' ), implode( ', ', self::LOCATIONS ) ), array( 'status' => 400 ) ); } $saved = self::save( $codes, get_current_user_id() ); if ( is_wp_error( $saved ) ) { // A refused database write is the server's fault, not the request's. $saved->add_data( array( 'status' => 'adminkeep_code_not_saved' === $saved->get_error_code() ? 500 : 400 ) ); return $saved; } return rest_ensure_response( self::codes() ); } /** * Print the Head box. * * @return void */ public function print_head() { $this->print_location( 'head' ); } /** * Print the Body open box. * * @return void */ public function print_body() { $this->print_location( 'body' ); } /** * Print the Footer box. * * @return void */ public function print_footer() { $this->print_location( 'footer' ); } /** * Print one box, unless it is empty or this is an admin request. * * @param string $location head, body or footer. * @return void */ private function print_location( $location ) { // boot() does not hook these in the admin; this covers anything that fires the front-end // hooks from an admin request all the same. if ( is_admin() ) { return; } $code = self::codes()[ $location ]['code']; if ( '' === trim( $code ) ) { return; } // Printed as saved, deliberately: this is the site owner's own markup and script, and any // escaping would break it. Only users holding unfiltered_html can save it. echo $code . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Raw by design; saving needs unfiltered_html. } }