PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.1-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.1-beta
16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / jetpack_vendor / automattic / jetpack-sync / src / class-activity-log-event.php
jetpack / jetpack_vendor / automattic / jetpack-sync / src Last commit date
modules 1 month ago replicastore 4 months ago sync-queue 6 months ago class-actions.php 3 months ago class-activity-log-event.php 1 month ago class-data-settings.php 2 months ago class-dedicated-sender.php 8 months ago class-default-filter-settings.php 4 years ago class-defaults.php 1 month ago class-functions.php 2 months ago class-health.php 5 months ago class-json-deflate-array-codec.php 8 months ago class-listener.php 2 months ago class-lock.php 4 years ago class-main.php 3 months ago class-modules.php 2 years ago class-package-version.php 2 weeks ago class-queue-buffer.php 4 years ago class-queue.php 2 months ago class-replicastore.php 2 months ago class-rest-endpoints.php 5 months ago class-rest-sender.php 2 months ago class-sender.php 2 months ago class-server.php 5 months ago class-settings.php 4 months ago class-simple-codec.php 8 months ago class-users.php 4 months ago class-utils.php 4 years ago interface-codec.php 4 years ago interface-replicastore.php 2 months ago
class-activity-log-event.php
519 lines
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