PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.1-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.1-beta
16.2-a.3 16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / jetpack_vendor / automattic / jetpack-search / src / search-blocks / class-singleton-template-cpt.php
jetpack / jetpack_vendor / automattic / jetpack-search / src / search-blocks Last commit date
blocks 4 weeks ago patterns 2 months ago templates 2 months ago class-custom-taxonomy-slot-mapping.php 2 months ago class-filter-post-type.php 2 months ago class-overlay-template.php 2 months ago class-product-overlay-template.php 2 months ago class-product-search-template.php 2 months ago class-search-blocks.php 3 weeks ago class-search-template.php 2 months ago class-singleton-template-cpt.php 1 month ago class-theme-chrome-slug-resolver.php 3 months ago class-wc-block-helpers.php 2 months ago
class-singleton-template-cpt.php
378 lines
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