query( $wpdb->prepare( "INSERT IGNORE INTO `$wpdb->options` ( `option_name`, `option_value`, `autoload` ) VALUES (%s, %s, 'off') /* LOCK */", self::LOCK_OPTION, time() ) ); if ( $acquired ) { return true; } // Held by someone else — or left behind by a request that died // between taking the lock and releasing it, in which case nothing // but the timeout will ever clear it. // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- must not read a cached copy of a lock. $held = (int) $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM `$wpdb->options` WHERE option_name = %s", self::LOCK_OPTION ) ); if ( ! $held || ( time() - $held ) > self::LOCK_TIMEOUT ) { self::release_lock(); continue; } usleep( 50000 ); } while ( microtime( true ) < $deadline ); return false; } private static function release_lock() { delete_option( self::LOCK_OPTION ); } /** * Read the library past any cached copy. * * Inside the lock the point is to see what the request we just waited for * actually wrote, which a value cached earlier in this request would hide. */ private static function get_all_fresh() { wp_cache_delete( self::OPTION, 'options' ); // And the "this option does not exist" cache: on a site with no library // yet, the first read caches its absence, and the request we waited for // is very likely the one that just created it. wp_cache_delete( 'notoptions', 'options' ); return self::get_all(); } /** * Mark the library as changed: expires the compiled-CSS cache and makes * every generated page stylesheet stale, so they rebuild on next visit. */ public static function bump_revision() { $now = time(); update_option( self::REV_OPTION, $now, true ); delete_transient( self::CSS_TRANSIENT ); return $now; } public static function get_revision() { return (int) get_option( self::REV_OPTION, 0 ); } /** * One-time repair for a library saved before persist() passed the autoload * flag explicitly. */ public function maybe_drop_autoload() { if ( get_option( self::AUTOLOAD_FIXED ) ) { return; } if ( function_exists( 'wp_set_option_autoload' ) ) { wp_set_option_autoload( self::OPTION, false ); } update_option( self::AUTOLOAD_FIXED, 1, true ); } private function sanitize_id( $id ) { return sanitize_html_class( $id ); } /** * Insert or update a class. Returns the stored record. Pass a structured * $styles array ({normal,hover}) for controls-editable classes, or a raw * $css declaration string (legacy). */ public function upsert( $id, $label, $css = '', $styles = null ) { $id = $this->sanitize_id( $id ); if ( '' === $id ) { return null; } $record = null; $locked = self::acquire_lock(); try { // Read INSIDE the lock. Reading first and locking afterwards would // leave exactly the window this is here to close. $classes = self::get_all_fresh(); $index = $this->index_of( $classes, $id ); $existing = null === $index ? null : $classes[ $index ]; $record = $this->build_record( $id, $label, $css, $styles, $existing ); if ( null === $index ) { $classes[] = $record; } else { $classes[ $index ] = $record; } $this->persist( $classes ); } finally { if ( $locked ) { self::release_lock(); } } return $record; } /** * Where a class sits in the list, or null. * * @param array $classes The library. * @param string $id Class id. * @return int|null The index, or null when the class is new. */ private function index_of( array $classes, $id ) { foreach ( $classes as $i => $c ) { if ( isset( $c['id'] ) && $c['id'] === $id ) { return $i; } } return null; } /** * The record to store for a class. * * @param string $id Class id. * @param string $label Display label. * @param string $css Raw declaration string (legacy form). * @param array|null $styles Structured styles, when the caller sent them. * @param array|null $existing The record already stored, if any. * @return array The record. */ private function build_record( $id, $label, $css, $styles, $existing ) { $record = [ 'id' => $id, 'label' => sanitize_text_field( $label ), ]; if ( is_array( $styles ) ) { $record['styles'] = $styles; return $record; } if ( '' !== trim( (string) $css ) ) { // Declaration string only — strip braces/at-rules so a class can't // break out of its own selector. $record['css'] = trim( preg_replace( '/[{}<>]/', '', (string) $css ) ); return $record; } // Neither form came in, so this is a save that only touches the label. // The record is rebuilt from scratch here, so carrying the look over // explicitly is what stops a rename from emptying the class of // everything it styles. if ( isset( $existing['styles'] ) ) { $record['styles'] = $existing['styles']; } elseif ( isset( $existing['css'] ) ) { $record['css'] = $existing['css']; } else { $record['css'] = ''; } return $record; } public function remove( $id ) { $id = $this->sanitize_id( $id ); $locked = self::acquire_lock(); try { $classes = array_filter( self::get_all_fresh(), function ( $c ) use ( $id ) { return ! ( isset( $c['id'] ) && $c['id'] === $id ); } ); $this->persist( $classes ); } finally { if ( $locked ) { self::release_lock(); } } } /** * Compile the library, or only the classes named in $ids. * * Order follows the LIBRARY, not $ids: when two classes on one element set * the same property, library order decides the winner on the front end, and * the editor preview mirrors that (see buildPreviewCss). Emitting in caller * order would let a block preview differently from the published page. * * @param array|null $ids Class ids to compile, or null for the whole library. */ public static function compiled_css_for( $ids = null ) { $wanted = null; if ( is_array( $ids ) ) { if ( empty( $ids ) ) { return ''; } $wanted = array_flip( array_map( 'strval', $ids ) ); } $css = ''; foreach ( self::get_all() as $c ) { if ( empty( $c['id'] ) ) { continue; } $id = $c['id']; if ( null !== $wanted && ! isset( $wanted[ $id ] ) ) { continue; } if ( ! empty( $c['styles'] ) && is_array( $c['styles'] ) ) { $css .= AtomicStyles::compile_variants( '.ablocks-gc-' . $id, $c['styles'] ); } elseif ( ! empty( $c['css'] ) ) { $css .= '.ablocks-gc-' . $id . '{' . $c['css'] . '}'; } } return $css; } public function compiled_css() { return self::compiled_css_for( null ); } /** * The whole library, compiled once per revision. * * This path cannot know which classes the page uses — that needs the block * tree, and only the asset generator walks it — so it still emits every * class. Caching the result at least stops each uncached request from * recompiling byte-identical CSS. */ private static function cached_css() { $rev = self::get_revision(); $cached = get_transient( self::CSS_TRANSIENT ); if ( is_array( $cached ) && isset( $cached['rev'], $cached['css'] ) && (int) $cached['rev'] === $rev ) { return (string) $cached['css']; } $css = self::compiled_css_for( null ); set_transient( self::CSS_TRANSIENT, [ 'rev' => $rev, 'css' => $css, ], WEEK_IN_SECONDS ); return $css; } /** The editor canvas copy; the front end is served from enqueue_css(). */ public function enqueue_editor_css() { if ( ! is_admin() ) { return; } $this->enqueue_css(); } public function enqueue_css() { if ( self::$enqueued ) { return; } // When the generated page stylesheet is in play it already contains the // classes this page uses, compiled by AssetsGenerator. A second, whole- // library copy inline would be pure weight. if ( ! is_admin() && wp_style_is( 'ablocks-blocks-combine-style', 'enqueued' ) ) { self::$enqueued = true; return; } $css = self::cached_css(); if ( '' === $css ) { return; } self::$enqueued = true; if ( ! wp_style_is( self::HANDLE, 'registered' ) ) { wp_register_style( self::HANDLE, false, [], ABLOCKS_VERSION ); } wp_enqueue_style( self::HANDLE ); wp_add_inline_style( self::HANDLE, $css ); } // ---- AJAX ---- private function verify() { check_ajax_referer( 'ablocks_nonce', 'security' ); if ( ! current_user_can( 'edit_posts' ) ) { wp_send_json_error( [ 'message' => 'forbidden' ], 403 ); } } public function ajax_get() { // Same gate as save/delete below. Reading the library is not sensitive, // but leaving one of the three endpoints open to any logged-in user is // the kind of inconsistency that turns into a hole when the payload // grows. $this->verify(); wp_send_json_success( self::get_all() ); } private function sanitize_styles( $value ) { if ( is_array( $value ) ) { $out = []; foreach ( $value as $k => $v ) { $out[ sanitize_text_field( $k ) ] = $this->sanitize_styles( $v ); } return $out; } return sanitize_text_field( (string) $value ); } public function ajax_save() { $this->verify(); $id = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : ''; $label = isset( $_POST['label'] ) ? sanitize_text_field( wp_unslash( $_POST['label'] ) ) : ''; $css = isset( $_POST['css'] ) ? wp_unslash( $_POST['css'] ) : ''; $styles = null; if ( isset( $_POST['styles'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- sanitize_styles() walks the decoded tree. $decoded = json_decode( wp_unslash( $_POST['styles'] ), true ); // A styles payload that will not decode is a truncated or corrupted // request, not an instruction to empty the class. Saving it anyway // used to replace everything the class styled with nothing. if ( ! is_array( $decoded ) ) { wp_send_json_error( [ 'message' => 'invalid styles' ], 400 ); } $styles = $this->sanitize_styles( $decoded ); } $record = $this->upsert( $id, $label, $css, $styles ); if ( ! $record ) { wp_send_json_error( [ 'message' => 'invalid id' ], 400 ); } wp_send_json_success( $record ); } public function ajax_delete() { $this->verify(); $id = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : ''; $this->remove( $id ); wp_send_json_success(); } }