Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
63.87% covered (warning)
63.87%
76 / 119
81.82% covered (warning)
81.82%
9 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Singleton_Template_Cpt
63.87% covered (warning)
63.87%
76 / 119
81.82% covered (warning)
81.82%
9 / 11
110.76
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
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 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
 get_reset_rest_path
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 maybe_handle_editor_request
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 ensure_post_exists
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
182
 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 * 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
17namespace 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 */
42abstract 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 `&amp;` 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}