PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
jetpack / jetpack_vendor / automattic / jetpack-search / src / search-blocks / class-singleton-template-cpt.php

class-singleton-template-cpt.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-beta, at jetpack_vendor/automattic/jetpack-search/src/search-blocks/class-singleton-template-cpt.php

378 lines 13.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 // The block editor loads this singleton via core's post REST route
132 // directly, bypassing get_customized_content() entirely, so the
133 // filters-popover sync there wouldn't reach the editor canvas without
134 // this — see sync_filters_popover_in_rest_response().
135 add_filter( 'rest_prepare_' . static::POST_TYPE, array( static::class, 'sync_filters_popover_in_rest_response' ) );
136 }
137
138 /**
139 * Mirror `Search_Blocks::sync_filters_popover_content()` onto this
140 * singleton's REST response so the Site Editor opens with the popover
141 * already matching the sidebar Filters block, instead of showing stale
142 * content until the next save/reload. Only `content.raw` (what the editor
143 * hydrates from) needs this — `content.rendered` is post-`the_content`
144 * HTML with block comments already replaced by their output, so the
145 * block-name guard in `sync_filters_popover_content()` never matches it.
146 *
147 * @param \WP_REST_Response $response REST response for the singleton post.
148 * @return \WP_REST_Response
149 */
150 public static function sync_filters_popover_in_rest_response( $response ) {
151 if ( isset( $response->data['content']['raw'] ) ) {
152 $response->data['content']['raw'] = Search_Blocks::sync_filters_popover_content( $response->data['content']['raw'] );
153 }
154 return $response;
155 }
156
157 /**
158 * Clear the option + cache when our singleton is deleted via any path.
159 * Without this, a stale option pointer would hide "Restore default" while
160 * the front end already served the bundled template.
161 *
162 * @param int $post_id The post being deleted.
163 */
164 public static function maybe_cleanup_on_singleton_delete( $post_id ) {
165 $post = get_post( $post_id );
166 if ( ! $post || static::POST_TYPE !== $post->post_type ) {
167 return;
168 }
169 if ( (int) get_option( static::OPTION_POST_ID, 0 ) === (int) $post_id ) {
170 delete_option( static::OPTION_POST_ID );
171 }
172 unset( self::$caches[ static::class ] );
173 }
174
175 /**
176 * Register the hidden singleton CPT. No menu, no UI of its own; the only
177 * way into the block editor is via the dashboard's nonce'd edit link.
178 */
179 public static function register_post_type() {
180 register_post_type(
181 static::POST_TYPE,
182 array(
183 'labels' => static::labels(),
184 'public' => false,
185 'show_ui' => true, // post.php / edit.php need the UI machinery.
186 'show_in_menu' => false,
187 'show_in_admin_bar' => false,
188 'show_in_nav_menus' => false,
189 'show_in_rest' => true,
190 'rest_base' => static::REST_BASE,
191 'supports' => array( 'editor', 'custom-fields', 'revisions' ),
192 // Every cap → `manage_options` so an Editor-role user can't bypass
193 // the dashboard gate via post.php or REST. `map_meta_cap: false`
194 // makes the literal cap names the ones WP checks.
195 'capabilities' => array(
196 'edit_post' => 'manage_options',
197 'read_post' => 'manage_options',
198 'delete_post' => 'manage_options',
199 'edit_posts' => 'manage_options',
200 'edit_others_posts' => 'manage_options',
201 'delete_posts' => 'manage_options',
202 'delete_others_posts' => 'manage_options',
203 'publish_posts' => 'manage_options',
204 'read_private_posts' => 'manage_options',
205 'delete_private_posts' => 'manage_options',
206 'delete_published_posts' => 'manage_options',
207 'edit_private_posts' => 'manage_options',
208 'edit_published_posts' => 'manage_options',
209 'create_posts' => 'manage_options',
210 ),
211 'map_meta_cap' => false,
212 'has_archive' => false,
213 'exclude_from_search' => true,
214 'rewrite' => false,
215 'can_export' => false,
216 'delete_with_user' => false,
217 'template_lock' => false,
218 )
219 );
220 }
221
222 /**
223 * Singleton post content, or `null` if no customization exists (callers
224 * fall back to the bundled template). Memoized per-request.
225 *
226 * @return string|null
227 */
228 public static function get_customized_content(): ?string {
229 if ( array_key_exists( static::class, self::$caches ) ) {
230 $cached = self::$caches[ static::class ];
231 return false === $cached ? null : $cached;
232 }
233 $post_id = static::get_post_id();
234 if ( ! $post_id ) {
235 self::$caches[ static::class ] = false;
236 return null;
237 }
238 $post = get_post( $post_id );
239 if ( ! $post || static::POST_TYPE !== $post->post_type || 'trash' === $post->post_status ) {
240 self::$caches[ static::class ] = false;
241 return null;
242 }
243 // Empty content = admin saved a blank canvas. Honor it explicitly so
244 // the editor doesn't loop with the bundled content on every save.
245 self::$caches[ static::class ] = Search_Blocks::sync_filters_popover_content( (string) $post->post_content );
246 return self::$caches[ static::class ];
247 }
248
249 /**
250 * Singleton post ID, or 0 if no customization exists yet.
251 *
252 * @return int
253 */
254 public static function get_post_id(): int {
255 return (int) get_option( static::OPTION_POST_ID, 0 );
256 }
257
258 /**
259 * Whether a live customization exists — option set AND not trashed. The
260 * trash check matters because `show_ui` is on, so an admin could trash
261 * the singleton from the post-list while the option still points at it.
262 *
263 * @return bool
264 */
265 public static function is_customized(): bool {
266 $post_id = static::get_post_id();
267 if ( ! $post_id ) {
268 return false;
269 }
270 $post = get_post( $post_id );
271 return $post && static::POST_TYPE === $post->post_type && 'trash' !== $post->post_status;
272 }
273
274 /**
275 * Nonce'd admin URL that lazy-creates the singleton and redirects to the
276 * block editor on it.
277 *
278 * Built with `add_query_arg` + `wp_create_nonce` (not `wp_nonce_url`) so
279 * the returned string has raw `&` separators, not `&amp;`. React/JSX
280 * doesn't HTML-decode attribute values, so encoded amps would round-trip
281 * into the URL bar verbatim and break `$_GET` parsing.
282 *
283 * @return string
284 */
285 public static function get_editor_url(): string {
286 return add_query_arg(
287 array(
288 static::EDITOR_REQUEST_KEY => '1',
289 '_wpnonce' => wp_create_nonce( static::EDITOR_NONCE ),
290 ),
291 admin_url( 'admin.php?page=jetpack-search' )
292 );
293 }
294
295 /**
296 * Handle the "open editor" admin request: lazy-create the singleton seeded
297 * from the bundled template, then redirect to the block editor on it.
298 */
299 public static function maybe_handle_editor_request() {
300 if ( empty( $_GET[ static::EDITOR_REQUEST_KEY ] ) ) {
301 return;
302 }
303 if ( ! current_user_can( 'manage_options' ) ) {
304 wp_die( esc_html( static::forbidden_message() ), '', array( 'response' => 403 ) );
305 }
306 check_admin_referer( static::EDITOR_NONCE );
307 $post_id = static::ensure_post_exists();
308 if ( ! $post_id ) {
309 wp_die( esc_html( static::create_failure_message() ), '', array( 'response' => 500 ) );
310 }
311 wp_safe_redirect( admin_url( 'post.php?post=' . $post_id . '&action=edit' ) );
312 exit;
313 }
314
315 /**
316 * Ensure the singleton exists. Returns its ID; creates one seeded from
317 * `read_seed_content()` if missing.
318 *
319 * @return int Post ID on success, 0 on failure.
320 */
321 protected static function ensure_post_exists(): int {
322 $existing = static::get_post_id();
323 if ( $existing ) {
324 $existing_post = get_post( $existing );
325 // Only reuse live singletons — a stale/trashed pointer recreates
326 // a fresh editable row.
327 if ( $existing_post && static::POST_TYPE === $existing_post->post_type && 'trash' !== $existing_post->post_status ) {
328 return $existing;
329 }
330 // Force-delete the stale post so repeated trashings don't orphan rows.
331 // `before_delete_post` nulls the option + cache.
332 if ( $existing_post && static::POST_TYPE === $existing_post->post_type ) {
333 wp_delete_post( $existing, true );
334 } else {
335 delete_option( static::OPTION_POST_ID );
336 unset( self::$caches[ static::class ] );
337 }
338 }
339 $seed_content = static::read_seed_content();
340 $post_id = wp_insert_post(
341 array(
342 'post_type' => static::POST_TYPE,
343 'post_status' => 'publish',
344 'post_title' => static::post_title(),
345 'post_content' => $seed_content,
346 'meta_input' => array(
347 static::SEED_META_KEY => '1',
348 ),
349 ),
350 true
351 );
352 if ( is_wp_error( $post_id ) || ! $post_id ) {
353 return 0;
354 }
355 // Race-safe option write: if a parallel request inserted its own
356 // singleton + claimed the option in between, drop ours and adopt theirs
357 // — the orphan would never be cleaned up otherwise (reset only follows
358 // the option pointer).
359 $other_post_id = (int) get_option( static::OPTION_POST_ID, 0 );
360 $other_post = $other_post_id ? get_post( $other_post_id ) : null;
361 if ( $other_post && static::POST_TYPE === $other_post->post_type && 'trash' !== $other_post->post_status ) {
362 wp_delete_post( $post_id, true );
363 unset( self::$caches[ static::class ] );
364 return $other_post_id;
365 }
366 update_option( static::OPTION_POST_ID, $post_id, false );
367 self::$caches[ static::class ] = $seed_content;
368 return (int) $post_id;
369 }
370
371 /**
372 * Reset the per-request content memo. Tests only.
373 */
374 public static function reset_customized_content_cache() {
375 unset( self::$caches[ static::class ] );
376 }
377 }
378