Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
81.36% |
96 / 118 |
|
70.00% |
7 / 10 |
CRAP | |
0.00% |
0 / 1 |
| Singleton_Template_Cpt | |
81.36% |
96 / 118 |
|
70.00% |
7 / 10 |
48.86 | |
0.00% |
0 / 1 |
| labels | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
| post_title | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
| read_seed_content | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
| forbidden_message | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
| create_failure_message | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
| init | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
3.07 | |||
| maybe_cleanup_on_singleton_delete | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
| register_post_type | |
100.00% |
37 / 37 |
|
100.00% |
1 / 1 |
1 | |||
| get_customized_content | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
7 | |||
| get_post_id | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| is_customized | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
| get_editor_url | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| maybe_handle_editor_request | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
| ensure_post_exists | |
66.67% |
22 / 33 |
|
0.00% |
0 / 1 |
19.26 | |||
| reset_customized_content_cache | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Abstract scaffolding for the bundled-template singleton-CPT editor flow. |
| 4 | * |
| 5 | * @package automattic/jetpack-search |
| 6 | * |
| 7 | * Every `static::abstract_method()` call here is reached only through |
| 8 | * `Overlay_Template::init()` / `Search_Template::init()`, so the abstract is |
| 9 | * resolved at runtime. The Phan warning is a false positive. |
| 10 | * |
| 11 | * @phan-file-suppress PhanAbstractStaticMethodCallInStatic |
| 12 | */ |
| 13 | |
| 14 | namespace Automattic\Jetpack\Search; |
| 15 | |
| 16 | /** |
| 17 | * Shared machinery for "edit a bundled block template via the standard editor |
| 18 | * on a hidden CPT" — a theme-agnostic customization surface that subclasses |
| 19 | * ({@see Overlay_Template}, {@see Search_Template}) specialize via constants |
| 20 | * and abstract hooks. |
| 21 | * |
| 22 | * Lifecycle: admin clicks "Edit…" → nonce'd handler lazy-creates a singleton |
| 23 | * from the bundled markup → admin lands in `post.php?post=<id>&action=edit` → |
| 24 | * front-end renderers prefer the customization → "Restore default" deletes the |
| 25 | * singleton via REST → `before_delete_post` clears the option + cache. |
| 26 | * |
| 27 | * Subclasses MUST override every const + abstract method. Defaults are |
| 28 | * intentionally empty so a misconfigured subclass surfaces at registration |
| 29 | * time. Per-class cache is keyed by `static::class` so subclasses can't |
| 30 | * cross-contaminate. |
| 31 | */ |
| 32 | abstract class Singleton_Template_Cpt { |
| 33 | |
| 34 | /** |
| 35 | * Hidden CPT slug. Max 20 chars; use a `jp_` prefix to stay greppable. |
| 36 | */ |
| 37 | const POST_TYPE = ''; |
| 38 | |
| 39 | /** |
| 40 | * REST base for the core CPT controller (`/wp/v2/<rest_base>`) the block |
| 41 | * editor uses to load + save the singleton. The "Restore default" path |
| 42 | * lives on jetpack/v4 instead, registered by `REST_Controller`. |
| 43 | */ |
| 44 | const REST_BASE = ''; |
| 45 | |
| 46 | /** |
| 47 | * Option storing the singleton post ID (0/absent ⇒ no customization). |
| 48 | */ |
| 49 | const OPTION_POST_ID = ''; |
| 50 | |
| 51 | /** |
| 52 | * `$_GET` key the nonce'd "open editor" URL sets. |
| 53 | */ |
| 54 | const EDITOR_REQUEST_KEY = ''; |
| 55 | |
| 56 | /** |
| 57 | * Nonce action paired with EDITOR_REQUEST_KEY. |
| 58 | */ |
| 59 | const EDITOR_NONCE = ''; |
| 60 | |
| 61 | /** |
| 62 | * Post-meta key stamped on seeded singletons so a future re-seed pass |
| 63 | * can find rows it created. |
| 64 | */ |
| 65 | const SEED_META_KEY = ''; |
| 66 | |
| 67 | /** |
| 68 | * Per-request memo backing `get_customized_content()`, keyed by subclass. |
| 69 | * Values: missing = uncached; `false` = no customization; `string` = content |
| 70 | * (including the empty string when the admin saved a blank canvas). |
| 71 | * |
| 72 | * @var array<class-string, string|false> |
| 73 | */ |
| 74 | private static $caches = array(); |
| 75 | |
| 76 | /** |
| 77 | * Labels for `register_post_type()`. |
| 78 | * |
| 79 | * @return array{name:string,singular_name:string} |
| 80 | */ |
| 81 | abstract protected static function labels(): array; |
| 82 | |
| 83 | /** |
| 84 | * Default title for the singleton on first creation. |
| 85 | * |
| 86 | * @return string |
| 87 | */ |
| 88 | abstract protected static function post_title(): string; |
| 89 | |
| 90 | /** |
| 91 | * Initial `post_content` for the singleton. Called only during lazy creation. |
| 92 | * |
| 93 | * @return string |
| 94 | */ |
| 95 | abstract protected static function read_seed_content(): string; |
| 96 | |
| 97 | /** |
| 98 | * `wp_die()` copy for non-admin editor-URL attempts. |
| 99 | * |
| 100 | * @return string |
| 101 | */ |
| 102 | abstract protected static function forbidden_message(): string; |
| 103 | |
| 104 | /** |
| 105 | * `wp_die()` copy when singleton creation fails (e.g. `wp_insert_post()` WP_Error). |
| 106 | * |
| 107 | * @return string |
| 108 | */ |
| 109 | abstract protected static function create_failure_message(): string; |
| 110 | |
| 111 | /** |
| 112 | * Wire the hooks. Called from the subclass's `init()` invocation. |
| 113 | */ |
| 114 | public static function init() { |
| 115 | // Self-healing CPT registration: a downstream consumer may hook |
| 116 | // `Search_Blocks::init()` onto `init:N` itself, in which case queuing |
| 117 | // `register_post_type` onto `init:9` from inside that callback lands on |
| 118 | // a priority WordPress is already iterating — PHP's `foreach` snapshot |
| 119 | // drops it. Register synchronously when we're already inside (or past) |
| 120 | // `init`; queue on `init:9` otherwise to preserve the standard |
| 121 | // `plugins_loaded` → `init:9` → `register_blocks` at `init:10` ordering. |
| 122 | if ( did_action( 'init' ) || doing_action( 'init' ) ) { |
| 123 | static::register_post_type(); |
| 124 | } else { |
| 125 | add_action( 'init', array( static::class, 'register_post_type' ), 9 ); |
| 126 | } |
| 127 | add_action( 'admin_init', array( static::class, 'maybe_handle_editor_request' ) ); |
| 128 | // `before_delete_post` fires for force-delete too, so it catches every |
| 129 | // delete path: AJAX reset, REST DELETE, post.php trash-then-delete. |
| 130 | add_action( 'before_delete_post', array( static::class, 'maybe_cleanup_on_singleton_delete' ) ); |
| 131 | } |
| 132 | |
| 133 | /** |
| 134 | * Clear the option + cache when our singleton is deleted via any path. |
| 135 | * Without this, a stale option pointer would hide "Restore default" while |
| 136 | * the front end already served the bundled template. |
| 137 | * |
| 138 | * @param int $post_id The post being deleted. |
| 139 | */ |
| 140 | public static function maybe_cleanup_on_singleton_delete( $post_id ) { |
| 141 | $post = get_post( $post_id ); |
| 142 | if ( ! $post || static::POST_TYPE !== $post->post_type ) { |
| 143 | return; |
| 144 | } |
| 145 | if ( (int) get_option( static::OPTION_POST_ID, 0 ) === (int) $post_id ) { |
| 146 | delete_option( static::OPTION_POST_ID ); |
| 147 | } |
| 148 | unset( self::$caches[ static::class ] ); |
| 149 | } |
| 150 | |
| 151 | /** |
| 152 | * Register the hidden singleton CPT. No menu, no UI of its own; the only |
| 153 | * way into the block editor is via the dashboard's nonce'd edit link. |
| 154 | */ |
| 155 | public static function register_post_type() { |
| 156 | register_post_type( |
| 157 | static::POST_TYPE, |
| 158 | array( |
| 159 | 'labels' => static::labels(), |
| 160 | 'public' => false, |
| 161 | 'show_ui' => true, // post.php / edit.php need the UI machinery. |
| 162 | 'show_in_menu' => false, |
| 163 | 'show_in_admin_bar' => false, |
| 164 | 'show_in_nav_menus' => false, |
| 165 | 'show_in_rest' => true, |
| 166 | 'rest_base' => static::REST_BASE, |
| 167 | 'supports' => array( 'editor', 'custom-fields', 'revisions' ), |
| 168 | // Every cap → `manage_options` so an Editor-role user can't bypass |
| 169 | // the dashboard gate via post.php or REST. `map_meta_cap: false` |
| 170 | // makes the literal cap names the ones WP checks. |
| 171 | 'capabilities' => array( |
| 172 | 'edit_post' => 'manage_options', |
| 173 | 'read_post' => 'manage_options', |
| 174 | 'delete_post' => 'manage_options', |
| 175 | 'edit_posts' => 'manage_options', |
| 176 | 'edit_others_posts' => 'manage_options', |
| 177 | 'delete_posts' => 'manage_options', |
| 178 | 'delete_others_posts' => 'manage_options', |
| 179 | 'publish_posts' => 'manage_options', |
| 180 | 'read_private_posts' => 'manage_options', |
| 181 | 'delete_private_posts' => 'manage_options', |
| 182 | 'delete_published_posts' => 'manage_options', |
| 183 | 'edit_private_posts' => 'manage_options', |
| 184 | 'edit_published_posts' => 'manage_options', |
| 185 | 'create_posts' => 'manage_options', |
| 186 | ), |
| 187 | 'map_meta_cap' => false, |
| 188 | 'has_archive' => false, |
| 189 | 'exclude_from_search' => true, |
| 190 | 'rewrite' => false, |
| 191 | 'can_export' => false, |
| 192 | 'delete_with_user' => false, |
| 193 | 'template_lock' => false, |
| 194 | ) |
| 195 | ); |
| 196 | } |
| 197 | |
| 198 | /** |
| 199 | * Singleton post content, or `null` if no customization exists (callers |
| 200 | * fall back to the bundled template). Memoized per-request. |
| 201 | * |
| 202 | * @return string|null |
| 203 | */ |
| 204 | public static function get_customized_content(): ?string { |
| 205 | if ( array_key_exists( static::class, self::$caches ) ) { |
| 206 | $cached = self::$caches[ static::class ]; |
| 207 | return false === $cached ? null : $cached; |
| 208 | } |
| 209 | $post_id = static::get_post_id(); |
| 210 | if ( ! $post_id ) { |
| 211 | self::$caches[ static::class ] = false; |
| 212 | return null; |
| 213 | } |
| 214 | $post = get_post( $post_id ); |
| 215 | if ( ! $post || static::POST_TYPE !== $post->post_type || 'trash' === $post->post_status ) { |
| 216 | self::$caches[ static::class ] = false; |
| 217 | return null; |
| 218 | } |
| 219 | // Empty content = admin saved a blank canvas. Honor it explicitly so |
| 220 | // the editor doesn't loop with the bundled content on every save. |
| 221 | self::$caches[ static::class ] = (string) $post->post_content; |
| 222 | return self::$caches[ static::class ]; |
| 223 | } |
| 224 | |
| 225 | /** |
| 226 | * Singleton post ID, or 0 if no customization exists yet. |
| 227 | * |
| 228 | * @return int |
| 229 | */ |
| 230 | public static function get_post_id(): int { |
| 231 | return (int) get_option( static::OPTION_POST_ID, 0 ); |
| 232 | } |
| 233 | |
| 234 | /** |
| 235 | * Whether a live customization exists — option set AND not trashed. The |
| 236 | * trash check matters because `show_ui` is on, so an admin could trash |
| 237 | * the singleton from the post-list while the option still points at it. |
| 238 | * |
| 239 | * @return bool |
| 240 | */ |
| 241 | public static function is_customized(): bool { |
| 242 | $post_id = static::get_post_id(); |
| 243 | if ( ! $post_id ) { |
| 244 | return false; |
| 245 | } |
| 246 | $post = get_post( $post_id ); |
| 247 | return $post && static::POST_TYPE === $post->post_type && 'trash' !== $post->post_status; |
| 248 | } |
| 249 | |
| 250 | /** |
| 251 | * Nonce'd admin URL that lazy-creates the singleton and redirects to the |
| 252 | * block editor on it. |
| 253 | * |
| 254 | * Built with `add_query_arg` + `wp_create_nonce` (not `wp_nonce_url`) so |
| 255 | * the returned string has raw `&` separators, not `&`. React/JSX |
| 256 | * doesn't HTML-decode attribute values, so encoded amps would round-trip |
| 257 | * into the URL bar verbatim and break `$_GET` parsing. |
| 258 | * |
| 259 | * @return string |
| 260 | */ |
| 261 | public static function get_editor_url(): string { |
| 262 | return add_query_arg( |
| 263 | array( |
| 264 | static::EDITOR_REQUEST_KEY => '1', |
| 265 | '_wpnonce' => wp_create_nonce( static::EDITOR_NONCE ), |
| 266 | ), |
| 267 | admin_url( 'admin.php?page=jetpack-search' ) |
| 268 | ); |
| 269 | } |
| 270 | |
| 271 | /** |
| 272 | * Handle the "open editor" admin request: lazy-create the singleton seeded |
| 273 | * from the bundled template, then redirect to the block editor on it. |
| 274 | */ |
| 275 | public static function maybe_handle_editor_request() { |
| 276 | if ( empty( $_GET[ static::EDITOR_REQUEST_KEY ] ) ) { |
| 277 | return; |
| 278 | } |
| 279 | if ( ! current_user_can( 'manage_options' ) ) { |
| 280 | wp_die( esc_html( static::forbidden_message() ), '', array( 'response' => 403 ) ); |
| 281 | } |
| 282 | check_admin_referer( static::EDITOR_NONCE ); |
| 283 | $post_id = static::ensure_post_exists(); |
| 284 | if ( ! $post_id ) { |
| 285 | wp_die( esc_html( static::create_failure_message() ), '', array( 'response' => 500 ) ); |
| 286 | } |
| 287 | wp_safe_redirect( admin_url( 'post.php?post=' . $post_id . '&action=edit' ) ); |
| 288 | exit; |
| 289 | } |
| 290 | |
| 291 | /** |
| 292 | * Ensure the singleton exists. Returns its ID; creates one seeded from |
| 293 | * `read_seed_content()` if missing. |
| 294 | * |
| 295 | * @return int Post ID on success, 0 on failure. |
| 296 | */ |
| 297 | protected static function ensure_post_exists(): int { |
| 298 | $existing = static::get_post_id(); |
| 299 | if ( $existing ) { |
| 300 | $existing_post = get_post( $existing ); |
| 301 | // Only reuse live singletons — a stale/trashed pointer recreates |
| 302 | // a fresh editable row. |
| 303 | if ( $existing_post && static::POST_TYPE === $existing_post->post_type && 'trash' !== $existing_post->post_status ) { |
| 304 | return $existing; |
| 305 | } |
| 306 | // Force-delete the stale post so repeated trashings don't orphan rows. |
| 307 | // `before_delete_post` nulls the option + cache. |
| 308 | if ( $existing_post && static::POST_TYPE === $existing_post->post_type ) { |
| 309 | wp_delete_post( $existing, true ); |
| 310 | } else { |
| 311 | delete_option( static::OPTION_POST_ID ); |
| 312 | unset( self::$caches[ static::class ] ); |
| 313 | } |
| 314 | } |
| 315 | $seed_content = static::read_seed_content(); |
| 316 | $post_id = wp_insert_post( |
| 317 | array( |
| 318 | 'post_type' => static::POST_TYPE, |
| 319 | 'post_status' => 'publish', |
| 320 | 'post_title' => static::post_title(), |
| 321 | 'post_content' => $seed_content, |
| 322 | 'meta_input' => array( |
| 323 | static::SEED_META_KEY => '1', |
| 324 | ), |
| 325 | ), |
| 326 | true |
| 327 | ); |
| 328 | if ( is_wp_error( $post_id ) || ! $post_id ) { |
| 329 | return 0; |
| 330 | } |
| 331 | // Race-safe option write: if a parallel request inserted its own |
| 332 | // singleton + claimed the option in between, drop ours and adopt theirs |
| 333 | // — the orphan would never be cleaned up otherwise (reset only follows |
| 334 | // the option pointer). |
| 335 | $other_post_id = (int) get_option( static::OPTION_POST_ID, 0 ); |
| 336 | $other_post = $other_post_id ? get_post( $other_post_id ) : null; |
| 337 | if ( $other_post && static::POST_TYPE === $other_post->post_type && 'trash' !== $other_post->post_status ) { |
| 338 | wp_delete_post( $post_id, true ); |
| 339 | unset( self::$caches[ static::class ] ); |
| 340 | return $other_post_id; |
| 341 | } |
| 342 | update_option( static::OPTION_POST_ID, $post_id, false ); |
| 343 | self::$caches[ static::class ] = $seed_content; |
| 344 | return (int) $post_id; |
| 345 | } |
| 346 | |
| 347 | /** |
| 348 | * Reset the per-request content memo. Tests only. |
| 349 | */ |
| 350 | public static function reset_customized_content_cache() { |
| 351 | unset( self::$caches[ static::class ] ); |
| 352 | } |
| 353 | } |