Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.97% covered (warning)
81.97%
100 / 122
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Singleton_Template_Cpt
81.97% covered (warning)
81.97%
100 / 122
72.73% covered (warning)
72.73%
8 / 11
50.86
0.00% covered (danger)
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
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 sync_filters_popover_in_rest_response
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 maybe_cleanup_on_singleton_delete
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 register_post_type
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
1
 get_customized_content
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 get_post_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_customized
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 get_editor_url
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 maybe_handle_editor_request
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 ensure_post_exists
66.67% covered (warning)
66.67%
22 / 33
0.00% covered (danger)
0.00%
0 / 1
19.26
 reset_customized_content_cache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
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
14namespace 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 */
32abstract 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}