Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.38% covered (warning)
88.38%
175 / 198
23.53% covered (danger)
23.53%
4 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Activity_Log_Event
88.38% covered (warning)
88.38%
175 / 198
23.53% covered (danger)
23.53%
4 / 17
80.13
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 register_post_type
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
2
 create
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
5
 is_valid_post
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 prevent_invalid_post_insert
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 authorize_rest_request
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 is_activity_log_event_rest_route
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
9.02
 normalize_rest_post
77.78% covered (warning)
77.78%
21 / 27
0.00% covered (danger)
0.00%
0 / 1
5.27
 normalize_post_data
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
6.73
 prevent_publicize
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 filter_sitemap_post_types
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 build_payload
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
6
 build_payload_from_post_content
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 decode_payload
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 get_rest_request_value
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
7.90
 sanitize_string
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
5.20
 sanitize_severity
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
1<?php
2/**
3 * Activity Log custom event support.
4 *
5 * @package automattic/jetpack-sync
6 */
7
8namespace Automattic\Jetpack\Sync;
9
10/**
11 * Handles Activity Log custom event creation and validation.
12 */
13class Activity_Log_Event {
14
15    /**
16     * Post type name for Activity Log custom entries.
17     */
18    const POST_TYPE = 'jp_act_log_event';
19
20    /**
21     * REST base for the Activity Log event post type.
22     */
23    const REST_BASE = 'activity-log-events';
24
25    /**
26     * Default event severity.
27     */
28    const DEFAULT_SEVERITY = 'info';
29
30    /**
31     * Allowed event severities.
32     */
33    const ALLOWED_SEVERITIES = array(
34        'info'    => true,
35        'success' => true,
36        'warning' => true,
37        'error'   => true,
38    );
39
40    /**
41     * Maximum title length.
42     */
43    const MAX_TITLE_LENGTH = 200;
44
45    /**
46     * Maximum content length.
47     */
48    const MAX_CONTENT_LENGTH = 5000;
49
50    /**
51     * Maximum source length.
52     */
53    const MAX_SOURCE_LENGTH = 100;
54
55    /**
56     * Whether Activity Log custom event hooks have been initialized.
57     *
58     * @var bool
59     */
60    private static $initialized = false;
61
62    /**
63     * Initialize Activity Log custom event hooks.
64     */
65    public static function init() {
66        if ( self::$initialized ) {
67            return;
68        }
69
70        self::$initialized = true;
71
72        add_action( 'init', array( __CLASS__, 'register_post_type' ) );
73        add_filter( 'rest_request_before_callbacks', array( __CLASS__, 'authorize_rest_request' ), 10, 3 );
74        add_filter( 'rest_pre_insert_' . self::POST_TYPE, array( __CLASS__, 'normalize_rest_post' ), 10, 2 );
75        add_filter( 'wp_insert_post_empty_content', array( __CLASS__, 'prevent_invalid_post_insert' ), 10, 2 );
76        add_filter( 'wp_insert_post_data', array( __CLASS__, 'normalize_post_data' ), 10, 2 );
77        add_filter( 'publicize_should_publicize_published_post', array( __CLASS__, 'prevent_publicize' ), 10, 2 );
78        add_filter( 'jetpack_sitemap_post_types', array( __CLASS__, 'filter_sitemap_post_types' ) );
79    }
80
81    /**
82     * Registers the Activity Log CPT with hardened defaults that prevent leakage
83     * to front-end queries, RSS, search, sitemaps, and exports.
84     */
85    public static function register_post_type() {
86        if ( post_type_exists( self::POST_TYPE ) ) {
87            return;
88        }
89
90        register_post_type(
91            self::POST_TYPE,
92            array(
93                'labels'              => array(
94                    'name'          => __( 'Activity Log Events', 'jetpack-sync' ),
95                    'singular_name' => __( 'Activity Log Event', 'jetpack-sync' ),
96                ),
97                'public'              => false,
98                'publicly_queryable'  => false,
99                'show_ui'             => false,
100                'show_in_menu'        => false,
101                'show_in_nav_menus'   => false,
102                'show_in_rest'        => true,
103                'rest_base'           => self::REST_BASE,
104                'show_in_admin_bar'   => false,
105                'exclude_from_search' => true,
106                'has_archive'         => false,
107                'rewrite'             => false,
108                'query_var'           => false,
109                'can_export'          => false,
110                'capability_type'     => array( 'activity_log_event', 'activity_log_events' ),
111                'map_meta_cap'        => true,
112                'capabilities'        => array(
113                    'read'                   => 'manage_options',
114                    'read_private_posts'     => 'manage_options',
115                    'create_posts'           => 'manage_options',
116                    'publish_posts'          => 'manage_options',
117
118                    'edit_posts'             => 'do_not_allow',
119                    'edit_others_posts'      => 'do_not_allow',
120                    'edit_private_posts'     => 'do_not_allow',
121                    'edit_published_posts'   => 'do_not_allow',
122
123                    'delete_posts'           => 'do_not_allow',
124                    'delete_others_posts'    => 'do_not_allow',
125                    'delete_private_posts'   => 'do_not_allow',
126                    'delete_published_posts' => 'do_not_allow',
127                ),
128                'supports'            => array( 'title', 'editor' ),
129            )
130        );
131    }
132
133    /**
134     * Logs a custom event to the Jetpack Activity Log.
135     *
136     * Call create() on or after the WordPress `init` action so Sync listeners are registered.
137     * The Activity Log post type is registered defensively if needed before insert.
138     *
139     * @param array $args {
140     *     Activity log event arguments.
141     *
142     *     @type string $title       Required. Plain-text title, truncated to 200 chars.
143     *     @type string $content     Required. Plain-text body, truncated to 5000 chars.
144     *     @type string $source      Optional. Identifier for the source of the event, e.g. 'mc'.
145     *     @type string $severity    Optional. 'info', 'success', 'warning', or 'error'. Defaults to 'info'.
146     * }
147     * @return int|false Post ID on success, false if validation fails.
148     */
149    public static function create( array $args ) {
150        $payload = self::build_payload( $args );
151        if ( false === $payload ) {
152            return false;
153        }
154
155        if ( ! post_type_exists( self::POST_TYPE ) ) {
156            self::register_post_type();
157        }
158
159        $post_content = wp_json_encode( $payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
160        if ( false === $post_content ) {
161            return false;
162        }
163
164        $post_id = wp_insert_post(
165            wp_slash(
166                array(
167                    'post_type'    => self::POST_TYPE,
168                    'post_title'   => $payload['title'],
169                    'post_content' => $post_content,
170                    'post_status'  => 'publish',
171                )
172            ),
173            true
174        );
175
176        return is_wp_error( $post_id ) ? false : $post_id;
177    }
178
179    /**
180     * Checks that an Activity Log custom event has a valid payload before enqueueing it for sync,
181     * in case data bypasses the Activity_Log_Event::create() helper.
182     *
183     * @param \WP_Post $post Activity Log post.
184     * @return bool
185     */
186    public static function is_valid_post( $post ) {
187        if ( ! $post instanceof \WP_Post || self::POST_TYPE !== $post->post_type ) {
188            return false;
189        }
190
191        // Build a sanitized candidate to validate the payload contract without mutating the stored post.
192        return false !== self::build_payload_from_post_content( $post->post_content );
193    }
194
195    /**
196     * Prevents invalid Activity Log event posts from being inserted via wp_insert_post().
197     *
198     * @param bool  $maybe_empty Whether the post should be considered empty.
199     * @param array $postarr     Post data passed to wp_insert_post().
200     * @return bool
201     */
202    public static function prevent_invalid_post_insert( $maybe_empty, $postarr ) {
203        if ( ! is_array( $postarr ) || self::POST_TYPE !== ( $postarr['post_type'] ?? '' ) ) {
204            return $maybe_empty;
205        }
206
207        return false === self::build_payload_from_post_content( $postarr['post_content'] ?? '' );
208    }
209
210    /**
211     * Restricts the core REST CPT route to trusted Activity Log event writers/readers.
212     *
213     * Core REST post collection reads are public by default, even for this non-public CPT,
214     * so explicitly gate this route when it is exposed via show_in_rest.
215     *
216     * @param mixed            $response Current REST response.
217     * @param array            $handler  Route handler.
218     * @param \WP_REST_Request $request  REST request.
219     * @return mixed|\WP_Error
220     */
221    public static function authorize_rest_request( $response, $handler, $request ) {
222        if ( null !== $response || ! $request instanceof \WP_REST_Request ) {
223            return $response;
224        }
225
226        if ( ! self::is_activity_log_event_rest_route( $request->get_route() ) ) {
227            return $response;
228        }
229
230        if ( current_user_can( 'manage_options' ) ) {
231            return $response;
232        }
233
234        return new \WP_Error(
235            'invalid_user_permission_activity_log_event',
236            esc_html__( 'You do not have the correct user permissions to access Activity Log events.', 'jetpack-sync' ),
237            array( 'status' => rest_authorization_required_code() )
238        );
239    }
240
241    /**
242     * Checks whether a REST route targets Activity Log event posts.
243     *
244     * Supports both normal site-local routes and WordPress.com public API site routes.
245     *
246     * @param string $route REST route.
247     * @return bool
248     */
249    private static function is_activity_log_event_rest_route( $route ) {
250        if ( ! is_string( $route ) ) {
251            return false;
252        }
253
254        $rest_base = self::REST_BASE;
255
256        if ( false === strpos( $route, $rest_base ) ) {
257            return false;
258        }
259
260        if ( '/wp/v2/' . $rest_base === $route || 0 === strpos( $route, '/wp/v2/' . $rest_base . '/' ) ) {
261            return true;
262        }
263
264        $parts = explode( '/', trim( $route, '/' ) );
265        if ( count( $parts ) < 5 ) {
266            return false;
267        }
268
269        return (
270            'wp' === $parts[0]
271            && 'v2' === $parts[1]
272            && 'sites' === $parts[2]
273            && $rest_base === $parts[4]
274        );
275    }
276
277    /**
278     * Normalizes Activity Log event REST requests before they are inserted.
279     *
280     * @param object           $prepared_post Prepared post object.
281     * @param \WP_REST_Request $request       REST request.
282     * @return object|\WP_Error
283     */
284    public static function normalize_rest_post( $prepared_post, $request ) {
285        if ( ! is_object( $prepared_post ) || ! $request instanceof \WP_REST_Request ) {
286            return $prepared_post;
287        }
288
289        $payload = self::build_payload(
290            array(
291                'title'    => self::get_rest_request_value( $request, 'title', $prepared_post->post_title ?? '' ),
292                'content'  => self::get_rest_request_value( $request, 'content', $prepared_post->post_content ?? '' ),
293                'source'   => $request->get_param( 'source' ),
294                'severity' => $request->get_param( 'severity' ),
295            )
296        );
297
298        if ( false === $payload ) {
299            return new \WP_Error(
300                'invalid_activity_log_event',
301                esc_html__( 'Invalid Activity Log event payload.', 'jetpack-sync' ),
302                array( 'status' => 400 )
303            );
304        }
305
306        $post_content = wp_json_encode( $payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
307        if ( false === $post_content ) {
308            return new \WP_Error(
309                'invalid_activity_log_event',
310                esc_html__( 'Invalid Activity Log event payload.', 'jetpack-sync' ),
311                array( 'status' => 400 )
312            );
313        }
314
315        $prepared_post->post_title   = $payload['title'];
316        $prepared_post->post_content = $post_content;
317        $prepared_post->post_status  = 'publish';
318
319        return $prepared_post;
320    }
321
322    /**
323     * Normalizes Activity Log event posts before they are inserted via wp_insert_post().
324     *
325     * @param array $data    Slashed, sanitized post data.
326     * @param array $postarr Post data passed to wp_insert_post().
327     * @return array
328     */
329    public static function normalize_post_data( $data, $postarr ) {
330        if ( ! is_array( $data ) || ! is_array( $postarr ) || self::POST_TYPE !== ( $postarr['post_type'] ?? '' ) ) {
331            return $data;
332        }
333
334        $payload = self::build_payload_from_post_content( $data['post_content'] ?? '' );
335        if ( false === $payload ) {
336            return $data;
337        }
338
339        $post_content = wp_json_encode( $payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
340        if ( false === $post_content ) {
341            return $data;
342        }
343
344        $data['post_title']   = wp_slash( $payload['title'] );
345        $data['post_content'] = wp_slash( $post_content );
346
347        return $data;
348    }
349
350    /**
351     * Never auto-share Activity Log entries via Jetpack Social,
352     * even if a third party adds 'publicize' post-type support to this CPT.
353     *
354     * @param bool     $should_publicize Publicize status prior to this filter running.
355     * @param \WP_Post $post             The post to test for Publicizability.
356     * @return bool
357     */
358    public static function prevent_publicize( $should_publicize, $post ) {
359        if ( ! $post instanceof \WP_Post ) {
360            return $should_publicize;
361        }
362
363        return self::POST_TYPE === $post->post_type ? false : $should_publicize;
364    }
365
366    /**
367     * Never include Activity Log entries in Jetpack sitemaps,
368     * even if a third party adds this CPT to the sitemap post-type list.
369     *
370     * @param string[] $types Sitemap post types.
371     * @return string[]
372     */
373    public static function filter_sitemap_post_types( $types ) {
374        $types = (array) $types;
375
376        if ( ! in_array( self::POST_TYPE, $types, true ) ) {
377            return $types;
378        }
379
380        return array_values( array_diff( $types, array( self::POST_TYPE ) ) );
381    }
382
383    /**
384     * Builds an Activity Log event payload from raw input.
385     *
386     * @param array $args Raw event arguments.
387     * @return array|false Sanitized payload, or false if validation fails.
388     */
389    private static function build_payload( array $args ) {
390        $severity = self::sanitize_severity( $args['severity'] ?? self::DEFAULT_SEVERITY );
391        if ( false === $severity ) {
392            return false;
393        }
394
395        $title   = self::sanitize_string( $args['title'] ?? '', self::MAX_TITLE_LENGTH );
396        $content = self::sanitize_string( $args['content'] ?? '', self::MAX_CONTENT_LENGTH );
397
398        if ( '' === $title || '' === $content ) {
399            return false;
400        }
401
402        $payload = array(
403            'title'    => $title,
404            'content'  => $content,
405            'severity' => $severity,
406        );
407
408        if ( isset( $args['source'] ) ) {
409            $source = self::sanitize_string( $args['source'], self::MAX_SOURCE_LENGTH );
410            if ( '' !== $source ) {
411                $payload['source'] = $source;
412            }
413        }
414
415        return $payload;
416    }
417
418    /**
419     * Builds an Activity Log event payload from post content.
420     *
421     * @param mixed $post_content Raw post content.
422     * @return array|false Sanitized payload, or false if validation fails.
423     */
424    private static function build_payload_from_post_content( $post_content ) {
425        $data = self::decode_payload( $post_content );
426        if ( ! is_array( $data ) ) {
427            return false;
428        }
429
430        return self::build_payload( $data );
431    }
432
433    /**
434     * Decodes an Activity Log event payload from post content.
435     *
436     * @param mixed $post_content Raw post content.
437     * @return array|false
438     */
439    private static function decode_payload( $post_content ) {
440        $data = json_decode( (string) $post_content, true );
441        if ( ! is_array( $data ) ) {
442            $data = json_decode( wp_unslash( (string) $post_content ), true );
443        }
444
445        return is_array( $data ) ? $data : false;
446    }
447
448    /**
449     * Gets a string-like value from a REST request field.
450     *
451     * @param \WP_REST_Request $request REST request.
452     * @param string           $field   Request field name.
453     * @param mixed            $default Default value.
454     * @return mixed
455     */
456    private static function get_rest_request_value( $request, $field, $default = '' ) {
457        $value = $request->get_param( $field );
458        if ( null === $value ) {
459            return $default;
460        }
461
462        if ( is_array( $value ) && isset( $value['raw'] ) ) {
463            return $value['raw'];
464        }
465
466        if ( is_array( $value ) && isset( $value['rendered'] ) ) {
467            return $value['rendered'];
468        }
469
470        return $value;
471    }
472
473    /**
474     * Strips HTML/PHP from a value and truncates it to a maximum character length, multibyte-safe.
475     *
476     * @param mixed $value Raw value.
477     * @param int   $max   Maximum length in characters.
478     * @return string
479     */
480    private static function sanitize_string( $value, $max ) {
481        if ( is_array( $value ) || is_object( $value ) ) {
482            return '';
483        }
484
485        $value = wp_strip_all_tags( (string) $value, true );
486        $value = preg_replace( '/\s+/', ' ', $value );
487        if ( null === $value ) {
488            return '';
489        }
490
491        $value = trim( $value );
492
493        if ( function_exists( 'mb_substr' ) ) {
494            return mb_substr( $value, 0, $max );
495        }
496
497        return substr( $value, 0, $max );
498    }
499
500    /**
501     * Sanitizes an Activity Log severity value.
502     *
503     * @param mixed $severity Raw severity.
504     * @return string|false Sanitized severity, or false if invalid.
505     */
506    private static function sanitize_severity( $severity ) {
507        if ( is_array( $severity ) || is_object( $severity ) ) {
508            return false;
509        }
510
511        $severity = strtolower( trim( (string) $severity ) );
512        if ( '' === $severity ) {
513            return self::DEFAULT_SEVERITY;
514        }
515
516        return isset( self::ALLOWED_SEVERITIES[ $severity ] ) ? $severity : false;
517    }
518}