PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / jetpack-sync / src / class-activity-log-event.php

class-activity-log-event.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.7, at jetpack_vendor/automattic/jetpack-sync/src/class-activity-log-event.php

519 lines 14.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Activity Log custom event support.
4 *
5 * @package automattic/jetpack-sync
6 */
7
8 namespace Automattic\Jetpack\Sync;
9
10 /**
11 * Handles Activity Log custom event creation and validation.
12 */
13 class 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 }
519