PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3-beta 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 All 507 releases
← All changes | jetpack_vendor/automattic/jetpack-sync/src/class-activity-log-event.php +518 -0 16.2-beta → 16.3-beta View file →
@@ -1,0 +1,518 @@
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 +}