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