Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
63.87% |
76 / 119 |
|
81.82% |
9 / 11 |
CRAP | |
0.00% |
0 / 1 |
| Singleton_Template_Cpt | |
63.87% |
76 / 119 |
|
81.82% |
9 / 11 |
110.76 | |
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 | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| 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 | |||
| get_reset_rest_path | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| maybe_handle_editor_request | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
| ensure_post_exists | |
0.00% |
0 / 33 |
|
0.00% |
0 / 1 |
182 | |||
| 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 | * Phan can't statically prove our late-static-binding callers always |
| 8 | * resolve to a concrete subclass — every `static::abstract_method()` |
| 9 | * site in here is reached only through `Overlay_Template::init()` or |
| 10 | * `Search_Template::init()` (which forward `static::class` to the |
| 11 | * registered actions/filters), so the abstract methods are always |
| 12 | * resolved at runtime. The warning is a false positive for this file. |
| 13 | * |
| 14 | * @phan-file-suppress PhanAbstractStaticMethodCallInStatic |
| 15 | */ |
| 16 | |
| 17 | namespace Automattic\Jetpack\Search; |
| 18 | |
| 19 | /** |
| 20 | * Shared machinery for "edit a bundled block template via the standard block |
| 21 | * editor on a hidden CPT" — a theme-agnostic customization surface that |
| 22 | * concrete subclasses ({@see Overlay_Template}, {@see Search_Template}) |
| 23 | * specialize by declaring the post-type / option / nonce / REST identifiers |
| 24 | * and providing the seed content + admin-facing copy. |
| 25 | * |
| 26 | * The lifecycle (admin clicks "Edit …" → nonce'd handler lazy-creates a |
| 27 | * singleton seeded from the bundled markup → admin redirected to |
| 28 | * `post.php?post=<id>&action=edit` → front-end renderers prefer the |
| 29 | * customization → "Restore default" force-deletes the singleton via REST → |
| 30 | * `before_delete_post` clears the option + per-request cache) is identical |
| 31 | * across both subclasses. Keeping the variations to a handful of constants |
| 32 | * and abstract hooks lets a third bundled template (search-product, future |
| 33 | * variants) opt in with ~50 lines instead of a 350-line copy. |
| 34 | * |
| 35 | * Subclasses **must** override every const + abstract method below. The |
| 36 | * defaults are intentionally empty / unsatisfiable so a misconfigured |
| 37 | * subclass surfaces at registration time rather than silently broken at |
| 38 | * delete-cleanup time. Per-class state (the customization cache) is keyed |
| 39 | * by `static::class` so two subclasses can't cross-contaminate each |
| 40 | * other's memoized lookup within a request. |
| 41 | */ |
| 42 | abstract class Singleton_Template_Cpt { |
| 43 | |
| 44 | /** |
| 45 | * Hidden CPT slug. 20 char max per `register_post_type()`. Use a |
| 46 | * `jp_` prefix to stay under the limit while remaining greppable. |
| 47 | */ |
| 48 | const POST_TYPE = ''; |
| 49 | |
| 50 | /** |
| 51 | * REST base for the CPT — appears in `/wp/v2/<rest_base>/<id>` and |
| 52 | * in `get_reset_rest_path()`. |
| 53 | */ |
| 54 | const REST_BASE = ''; |
| 55 | |
| 56 | /** |
| 57 | * Option name that stores the singleton post ID (0 / absent ⇒ |
| 58 | * "no customization"). |
| 59 | */ |
| 60 | const OPTION_POST_ID = ''; |
| 61 | |
| 62 | /** |
| 63 | * `$_GET` key the nonce'd "open editor" URL sets. |
| 64 | */ |
| 65 | const EDITOR_REQUEST_KEY = ''; |
| 66 | |
| 67 | /** |
| 68 | * Nonce action paired with EDITOR_REQUEST_KEY. |
| 69 | */ |
| 70 | const EDITOR_NONCE = ''; |
| 71 | |
| 72 | /** |
| 73 | * Post-meta key stamped on freshly-seeded singletons so a future |
| 74 | * re-seed pass (if the bundled markup evolves) can find rows it |
| 75 | * created. |
| 76 | */ |
| 77 | const SEED_META_KEY = ''; |
| 78 | |
| 79 | /** |
| 80 | * Per-request memo backing `get_customized_content()`, keyed by |
| 81 | * subclass name so concrete subclasses can't cross-contaminate each |
| 82 | * other's lookups. |
| 83 | * |
| 84 | * Values: missing key = uncached; `false` = "no customization on |
| 85 | * file"; `string` = the customization's content (including the |
| 86 | * empty string when the admin saved a blank canvas). |
| 87 | * |
| 88 | * @var array<class-string, string|false> |
| 89 | */ |
| 90 | private static $caches = array(); |
| 91 | |
| 92 | /** |
| 93 | * Labels for `register_post_type()` — translation-aware so subclass |
| 94 | * copy stays consistent with the rest of the admin UI. |
| 95 | * |
| 96 | * @return array{name:string,singular_name:string} |
| 97 | */ |
| 98 | abstract protected static function labels(): array; |
| 99 | |
| 100 | /** |
| 101 | * Default title for the singleton on first creation. Translation- |
| 102 | * aware. |
| 103 | * |
| 104 | * @return string |
| 105 | */ |
| 106 | abstract protected static function post_title(): string; |
| 107 | |
| 108 | /** |
| 109 | * Initial `post_content` for the singleton. Called only during |
| 110 | * lazy-creation in `ensure_post_exists()`. |
| 111 | * |
| 112 | * @return string |
| 113 | */ |
| 114 | abstract protected static function read_seed_content(): string; |
| 115 | |
| 116 | /** |
| 117 | * Copy used in `wp_die()` when a non-admin tries to trigger the |
| 118 | * editor URL. Translation-aware. |
| 119 | * |
| 120 | * @return string |
| 121 | */ |
| 122 | abstract protected static function forbidden_message(): string; |
| 123 | |
| 124 | /** |
| 125 | * Copy used in `wp_die()` when the singleton creation fails (e.g. |
| 126 | * `wp_insert_post()` returns a WP_Error). Translation-aware. |
| 127 | * |
| 128 | * @return string |
| 129 | */ |
| 130 | abstract protected static function create_failure_message(): string; |
| 131 | |
| 132 | /** |
| 133 | * Wire the hooks. Called from the subclass's own `init()` invocation |
| 134 | * in `Search_Blocks::init()`. |
| 135 | */ |
| 136 | public static function init() { |
| 137 | // Priority 9: register the CPT just before |
| 138 | // `Search_Blocks::register_blocks()` (also on `init`, default |
| 139 | // priority 10) so the Search blocks are registered against a |
| 140 | // known CPT when `do_blocks()` runs on the singleton's content. |
| 141 | add_action( 'init', array( static::class, 'register_post_type' ), 9 ); |
| 142 | add_action( 'admin_init', array( static::class, 'maybe_handle_editor_request' ) ); |
| 143 | // Keep the singleton option + per-request cache consistent |
| 144 | // regardless of which delete path is taken: the dashboard's |
| 145 | // AJAX reset, the REST endpoint, or an admin trashing then |
| 146 | // permanently deleting via post.php. `before_delete_post` fires |
| 147 | // for force-delete too, which is what `wp_delete_post( $id, true )` |
| 148 | // and REST DELETE `?force=true` do. |
| 149 | add_action( 'before_delete_post', array( static::class, 'maybe_cleanup_on_singleton_delete' ) ); |
| 150 | } |
| 151 | |
| 152 | /** |
| 153 | * Reset the option + per-request cache when our singleton post is |
| 154 | * deleted, regardless of which delete path the admin took. Catches |
| 155 | * deletions from any source — REST, post.php, our own dashboard |
| 156 | * flow — so the state never drifts (option still pointing at a |
| 157 | * deleted post would otherwise hide "Restore default" while the |
| 158 | * front end already serves the bundled template). |
| 159 | * |
| 160 | * @param int $post_id The post being deleted. |
| 161 | */ |
| 162 | public static function maybe_cleanup_on_singleton_delete( $post_id ) { |
| 163 | $post = get_post( $post_id ); |
| 164 | if ( ! $post || static::POST_TYPE !== $post->post_type ) { |
| 165 | return; |
| 166 | } |
| 167 | if ( (int) get_option( static::OPTION_POST_ID, 0 ) === (int) $post_id ) { |
| 168 | delete_option( static::OPTION_POST_ID ); |
| 169 | } |
| 170 | unset( self::$caches[ static::class ] ); |
| 171 | } |
| 172 | |
| 173 | /** |
| 174 | * Register the hidden singleton CPT. No menu, no UI surface of its |
| 175 | * own; the only way to land in the block editor on this post is via |
| 176 | * the dashboard's edit link. |
| 177 | */ |
| 178 | public static function register_post_type() { |
| 179 | register_post_type( |
| 180 | static::POST_TYPE, |
| 181 | array( |
| 182 | 'labels' => static::labels(), |
| 183 | 'public' => false, |
| 184 | 'show_ui' => true, // post.php / edit.php need the UI machinery even though we hide the menu. |
| 185 | 'show_in_menu' => false, |
| 186 | 'show_in_admin_bar' => false, |
| 187 | 'show_in_nav_menus' => false, |
| 188 | 'show_in_rest' => true, |
| 189 | 'rest_base' => static::REST_BASE, |
| 190 | 'supports' => array( 'editor', 'custom-fields', 'revisions' ), |
| 191 | // Lock every relevant capability to `manage_options` so |
| 192 | // editing requires admin, regardless of which entry |
| 193 | // point (post.php direct URL, REST API, the dashboard |
| 194 | // link) the user takes. The dashboard handlers already |
| 195 | // gate on `manage_options` themselves; this prevents an |
| 196 | // Editor-role user who happens to know the singleton's |
| 197 | // post ID from bypassing that gate via post.php or REST. |
| 198 | // `map_meta_cap: false` makes the literal capability |
| 199 | // names below the ones WordPress actually checks. |
| 200 | 'capabilities' => array( |
| 201 | 'edit_post' => 'manage_options', |
| 202 | 'read_post' => 'manage_options', |
| 203 | 'delete_post' => 'manage_options', |
| 204 | 'edit_posts' => 'manage_options', |
| 205 | 'edit_others_posts' => 'manage_options', |
| 206 | 'delete_posts' => 'manage_options', |
| 207 | 'delete_others_posts' => 'manage_options', |
| 208 | 'publish_posts' => 'manage_options', |
| 209 | 'read_private_posts' => 'manage_options', |
| 210 | 'delete_private_posts' => 'manage_options', |
| 211 | 'delete_published_posts' => 'manage_options', |
| 212 | 'edit_private_posts' => 'manage_options', |
| 213 | 'edit_published_posts' => 'manage_options', |
| 214 | 'create_posts' => 'manage_options', |
| 215 | ), |
| 216 | 'map_meta_cap' => false, |
| 217 | 'has_archive' => false, |
| 218 | 'exclude_from_search' => true, |
| 219 | 'rewrite' => false, |
| 220 | 'can_export' => false, |
| 221 | 'delete_with_user' => false, |
| 222 | 'template_lock' => false, |
| 223 | ) |
| 224 | ); |
| 225 | } |
| 226 | |
| 227 | /** |
| 228 | * Return the singleton post's content if present. `null` means |
| 229 | * there's no customization on file and callers should fall back to |
| 230 | * the bundled template. Memoized per-request. |
| 231 | * |
| 232 | * @return string|null |
| 233 | */ |
| 234 | public static function get_customized_content(): ?string { |
| 235 | if ( array_key_exists( static::class, self::$caches ) ) { |
| 236 | $cached = self::$caches[ static::class ]; |
| 237 | return false === $cached ? null : $cached; |
| 238 | } |
| 239 | $post_id = static::get_post_id(); |
| 240 | if ( ! $post_id ) { |
| 241 | self::$caches[ static::class ] = false; |
| 242 | return null; |
| 243 | } |
| 244 | $post = get_post( $post_id ); |
| 245 | if ( ! $post || static::POST_TYPE !== $post->post_type || 'trash' === $post->post_status ) { |
| 246 | self::$caches[ static::class ] = false; |
| 247 | return null; |
| 248 | } |
| 249 | // Empty post content means the admin saved a blank canvas — |
| 250 | // honor that explicitly rather than silently falling back to the |
| 251 | // bundled default (the editor would loop with the bundled |
| 252 | // content on every save otherwise). |
| 253 | self::$caches[ static::class ] = (string) $post->post_content; |
| 254 | return self::$caches[ static::class ]; |
| 255 | } |
| 256 | |
| 257 | /** |
| 258 | * Singleton post ID, or 0 if no customization exists yet. |
| 259 | * |
| 260 | * @return int |
| 261 | */ |
| 262 | public static function get_post_id(): int { |
| 263 | return (int) get_option( static::OPTION_POST_ID, 0 ); |
| 264 | } |
| 265 | |
| 266 | /** |
| 267 | * Whether a live customization exists — the singleton post is set |
| 268 | * AND not in the trash. The trash check matters because `show_ui` |
| 269 | * is on, so an admin could navigate to the post-list and trash the |
| 270 | * singleton directly; the option would still point at the |
| 271 | * (trashed) post, but `get_customized_content()` already returns |
| 272 | * null for trashed rows so the front end falls back to the bundled |
| 273 | * template. Reflecting that in the dashboard means "Restore default" |
| 274 | * disappears when there's nothing the user would perceive as |
| 275 | * customized. |
| 276 | * |
| 277 | * @return bool |
| 278 | */ |
| 279 | public static function is_customized(): bool { |
| 280 | $post_id = static::get_post_id(); |
| 281 | if ( ! $post_id ) { |
| 282 | return false; |
| 283 | } |
| 284 | $post = get_post( $post_id ); |
| 285 | return $post && static::POST_TYPE === $post->post_type && 'trash' !== $post->post_status; |
| 286 | } |
| 287 | |
| 288 | /** |
| 289 | * Nonce'd admin URL that lazy-creates the singleton (if missing) |
| 290 | * and redirects the admin into the block editor on it. Used by the |
| 291 | * dashboard edit links. |
| 292 | * |
| 293 | * Built with `add_query_arg` + `wp_create_nonce` (not |
| 294 | * `wp_nonce_url`) so the returned string contains raw `&` |
| 295 | * separators, not the HTML-encoded `&` that `wp_nonce_url` |
| 296 | * emits. The URL is JSON-serialized to the React dashboard's |
| 297 | * initial state and then set as an `<a href>` value — React/JSX |
| 298 | * doesn't HTML-decode attribute values, so encoded amps would |
| 299 | * round-trip into the browser's URL bar verbatim and break the |
| 300 | * `$_GET` parse. |
| 301 | * |
| 302 | * @return string |
| 303 | */ |
| 304 | public static function get_editor_url(): string { |
| 305 | return add_query_arg( |
| 306 | array( |
| 307 | static::EDITOR_REQUEST_KEY => '1', |
| 308 | '_wpnonce' => wp_create_nonce( static::EDITOR_NONCE ), |
| 309 | ), |
| 310 | admin_url( 'admin.php?page=jetpack-search' ) |
| 311 | ); |
| 312 | } |
| 313 | |
| 314 | /** |
| 315 | * REST path used by the dashboard's "Restore default" link to |
| 316 | * delete the singleton via the CPT's built-in REST endpoint |
| 317 | * (`/wp/v2/<rest_base>/<id>?force=true`). The dashboard calls this |
| 318 | * with `apiFetch({ method: 'DELETE', path: <…> })`; the |
| 319 | * `before_delete_post` cleanup keeps the option + cache in sync. |
| 320 | * |
| 321 | * Returns `null` when no singleton exists — the React link is |
| 322 | * hidden by `isCustomized` in that state, so this should never be |
| 323 | * hit, but returning null keeps the type honest. |
| 324 | * |
| 325 | * @return string|null |
| 326 | */ |
| 327 | public static function get_reset_rest_path(): ?string { |
| 328 | if ( ! static::is_customized() ) { |
| 329 | return null; |
| 330 | } |
| 331 | return '/wp/v2/' . static::REST_BASE . '/' . static::get_post_id() . '?force=true'; |
| 332 | } |
| 333 | |
| 334 | /** |
| 335 | * Handle the "open editor" admin request: create the singleton on |
| 336 | * first click (seeded from the bundled template), then redirect to |
| 337 | * the block editor on it. |
| 338 | */ |
| 339 | public static function maybe_handle_editor_request() { |
| 340 | // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce checked below. |
| 341 | if ( empty( $_GET[ static::EDITOR_REQUEST_KEY ] ) ) { |
| 342 | return; |
| 343 | } |
| 344 | if ( ! current_user_can( 'manage_options' ) ) { |
| 345 | wp_die( esc_html( static::forbidden_message() ), '', array( 'response' => 403 ) ); |
| 346 | } |
| 347 | check_admin_referer( static::EDITOR_NONCE ); |
| 348 | $post_id = static::ensure_post_exists(); |
| 349 | if ( ! $post_id ) { |
| 350 | wp_die( esc_html( static::create_failure_message() ), '', array( 'response' => 500 ) ); |
| 351 | } |
| 352 | wp_safe_redirect( admin_url( 'post.php?post=' . $post_id . '&action=edit' ) ); |
| 353 | exit; |
| 354 | } |
| 355 | |
| 356 | /** |
| 357 | * Ensure the singleton post exists. Returns its ID. If it doesn't |
| 358 | * exist yet, creates it seeded with `read_seed_content()` so the |
| 359 | * editor opens populated rather than empty. |
| 360 | * |
| 361 | * @return int Post ID on success, 0 on failure. |
| 362 | */ |
| 363 | protected static function ensure_post_exists(): int { |
| 364 | $existing = static::get_post_id(); |
| 365 | if ( $existing ) { |
| 366 | $existing_post = get_post( $existing ); |
| 367 | // Only reuse live singleton posts. If the option points to |
| 368 | // a trashed row (or a stale/mismatched ID), treat it as |
| 369 | // missing so clicking the edit link recreates a fresh |
| 370 | // editable singleton. |
| 371 | if ( $existing_post && static::POST_TYPE === $existing_post->post_type && 'trash' !== $existing_post->post_status ) { |
| 372 | return $existing; |
| 373 | } |
| 374 | // Force-delete the stale post (typically trashed) before |
| 375 | // recreating, so admins who repeatedly trash the singleton |
| 376 | // don't accumulate orphan rows. `before_delete_post` will |
| 377 | // null the option + cache. |
| 378 | if ( $existing_post && static::POST_TYPE === $existing_post->post_type ) { |
| 379 | wp_delete_post( $existing, true ); |
| 380 | } else { |
| 381 | delete_option( static::OPTION_POST_ID ); |
| 382 | unset( self::$caches[ static::class ] ); |
| 383 | } |
| 384 | } |
| 385 | $seed_content = static::read_seed_content(); |
| 386 | $post_id = wp_insert_post( |
| 387 | array( |
| 388 | 'post_type' => static::POST_TYPE, |
| 389 | 'post_status' => 'publish', |
| 390 | 'post_title' => static::post_title(), |
| 391 | 'post_content' => $seed_content, |
| 392 | 'meta_input' => array( |
| 393 | static::SEED_META_KEY => '1', |
| 394 | ), |
| 395 | ), |
| 396 | true |
| 397 | ); |
| 398 | if ( is_wp_error( $post_id ) || ! $post_id ) { |
| 399 | return 0; |
| 400 | } |
| 401 | // Race-safe option write: if a parallel request also raced |
| 402 | // past the early `get_post_id()` check and inserted its own |
| 403 | // singleton + claimed the option in between, drop ours and |
| 404 | // adopt theirs. The orphaned post would otherwise never be |
| 405 | // deleted by the reset flow because that only follows the |
| 406 | // option pointer. |
| 407 | $other_post_id = (int) get_option( static::OPTION_POST_ID, 0 ); |
| 408 | $other_post = $other_post_id ? get_post( $other_post_id ) : null; |
| 409 | if ( $other_post && static::POST_TYPE === $other_post->post_type && 'trash' !== $other_post->post_status ) { |
| 410 | wp_delete_post( $post_id, true ); |
| 411 | unset( self::$caches[ static::class ] ); |
| 412 | return $other_post_id; |
| 413 | } |
| 414 | update_option( static::OPTION_POST_ID, $post_id, false ); |
| 415 | self::$caches[ static::class ] = $seed_content; |
| 416 | return (int) $post_id; |
| 417 | } |
| 418 | |
| 419 | /** |
| 420 | * Reset the per-request content memo. Tests only. |
| 421 | */ |
| 422 | public static function reset_customized_content_cache() { |
| 423 | unset( self::$caches[ static::class ] ); |
| 424 | } |
| 425 | } |