<?php
/**
* Activity Log custom event support.
*
* @package automattic/jetpack-sync
*/
namespace Automattic\Jetpack\Sync;
/**
* Handles Activity Log custom event creation and validation.
*/
class Activity_Log_Event {
/**
* Post type name for Activity Log custom entries.
*/
const POST_TYPE = 'jp_act_log_event';
/**
* REST base for the Activity Log event post type.
*/
const REST_BASE = 'activity-log-events';
/**
* Default event severity.
*/
const DEFAULT_SEVERITY = 'info';
/**
* Allowed event severities.
*/
const ALLOWED_SEVERITIES = array(
'info' => true,
'success' => true,
'warning' => true,
'error' => true,
);
/**
* Maximum title length.
*/
const MAX_TITLE_LENGTH = 200;
/**
* Maximum content length.
*/
const MAX_CONTENT_LENGTH = 5000;
/**
* Maximum source length.
*/
const MAX_SOURCE_LENGTH = 100;
/**
* Whether Activity Log custom event hooks have been initialized.
*
* @var bool
*/
private static $initialized = false;
/**
* Initialize Activity Log custom event hooks.
*/
public static function init() {
if ( self::$initialized ) {
return;
}
self::$initialized = true;
add_action( 'init', array( __CLASS__, 'register_post_type' ) );
add_filter( 'rest_request_before_callbacks', array( __CLASS__, 'authorize_rest_request' ), 10, 3 );
add_filter( 'rest_pre_insert_' . self::POST_TYPE, array( __CLASS__, 'normalize_rest_post' ), 10, 2 );
add_filter( 'wp_insert_post_empty_content', array( __CLASS__, 'prevent_invalid_post_insert' ), 10, 2 );
add_filter( 'wp_insert_post_data', array( __CLASS__, 'normalize_post_data' ), 10, 2 );
add_filter( 'publicize_should_publicize_published_post', array( __CLASS__, 'prevent_publicize' ), 10, 2 );
add_filter( 'jetpack_sitemap_post_types', array( __CLASS__, 'filter_sitemap_post_types' ) );
}
/**
* Registers the Activity Log CPT with hardened defaults that prevent leakage
* to front-end queries, RSS, search, sitemaps, and exports.
*/
public static function register_post_type() {
if ( post_type_exists( self::POST_TYPE ) ) {
return;
}
register_post_type(
self::POST_TYPE,
array(
'labels' => array(
'name' => __( 'Activity Log Events', 'jetpack-sync' ),
'singular_name' => __( 'Activity Log Event', 'jetpack-sync' ),
),
'public' => false,
'publicly_queryable' => false,
'show_ui' => false,
'show_in_menu' => false,
'show_in_nav_menus' => false,
'show_in_rest' => true,
'rest_base' => self::REST_BASE,
'show_in_admin_bar' => false,
'exclude_from_search' => true,
'has_archive' => false,
'rewrite' => false,
'query_var' => false,
'can_export' => false,
'capability_type' => array( 'activity_log_event', 'activity_log_events' ),
'map_meta_cap' => true,
'capabilities' => array(
'read' => 'manage_options',
'read_private_posts' => 'manage_options',
'create_posts' => 'manage_options',
'publish_posts' => 'manage_options',
'edit_posts' => 'do_not_allow',
'edit_others_posts' => 'do_not_allow',
'edit_private_posts' => 'do_not_allow',
'edit_published_posts' => 'do_not_allow',
'delete_posts' => 'do_not_allow',
'delete_others_posts' => 'do_not_allow',
'delete_private_posts' => 'do_not_allow',
'delete_published_posts' => 'do_not_allow',
),
'supports' => array( 'title', 'editor' ),
)
);
}
/**
* Logs a custom event to the Jetpack Activity Log.
*
* Call create() on or after the WordPress `init` action so Sync listeners are registered.
* The Activity Log post type is registered defensively if needed before insert.
*
* @param array $args {
* Activity log event arguments.
*
* @type string $title Required. Plain-text title, truncated to 200 chars.
* @type string $content Required. Plain-text body, truncated to 5000 chars.
* @type string $source Optional. Identifier for the source of the event, e.g. 'mc'.
* @type string $severity Optional. 'info', 'success', 'warning', or 'error'. Defaults to 'info'.
* }
* @return int|false Post ID on success, false if validation fails.
*/
public static function create( array $args ) {
$payload = self::build_payload( $args );
if ( false === $payload ) {
return false;
}
if ( ! post_type_exists( self::POST_TYPE ) ) {
self::register_post_type();
}
$post_content = wp_json_encode( $payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
if ( false === $post_content ) {
return false;
}
$post_id = wp_insert_post(
wp_slash(
array(
'post_type' => self::POST_TYPE,
'post_title' => $payload['title'],
'post_content' => $post_content,
'post_status' => 'publish',
)
),
true
);
return is_wp_error( $post_id ) ? false : $post_id;
}
/**
* Checks that an Activity Log custom event has a valid payload before enqueueing it for sync,
* in case data bypasses the Activity_Log_Event::create() helper.
*
* @param \WP_Post $post Activity Log post.
* @return bool
*/
public static function is_valid_post( $post ) {
if ( ! $post instanceof \WP_Post || self::POST_TYPE !== $post->post_type ) {
return false;
}
// Build a sanitized candidate to validate the payload contract without mutating the stored post.
return false !== self::build_payload_from_post_content( $post->post_content );
}
/**
* Prevents invalid Activity Log event posts from being inserted via wp_insert_post().
*
* @param bool $maybe_empty Whether the post should be considered empty.
* @param array $postarr Post data passed to wp_insert_post().
* @return bool
*/
public static function prevent_invalid_post_insert( $maybe_empty, $postarr ) {
if ( ! is_array( $postarr ) || self::POST_TYPE !== ( $postarr['post_type'] ?? '' ) ) {
return $maybe_empty;
}
return false === self::build_payload_from_post_content( $postarr['post_content'] ?? '' );
}
/**
* Restricts the core REST CPT route to trusted Activity Log event writers/readers.
*
* Core REST post collection reads are public by default, even for this non-public CPT,
* so explicitly gate this route when it is exposed via show_in_rest.
*
* @param mixed $response Current REST response.
* @param array $handler Route handler.
* @param \WP_REST_Request $request REST request.
* @return mixed|\WP_Error
*/
public static function authorize_rest_request( $response, $handler, $request ) {
if ( null !== $response || ! $request instanceof \WP_REST_Request ) {
return $response;
}
if ( ! self::is_activity_log_event_rest_route( $request->get_route() ) ) {
return $response;
}
if ( current_user_can( 'manage_options' ) ) {
return $response;
}
return new \WP_Error(
'invalid_user_permission_activity_log_event',
esc_html__( 'You do not have the correct user permissions to access Activity Log events.', 'jetpack-sync' ),
array( 'status' => rest_authorization_required_code() )
);
}
/**
* Checks whether a REST route targets Activity Log event posts.
*
* Supports both normal site-local routes and WordPress.com public API site routes.
*
* @param string $route REST route.
* @return bool
*/
private static function is_activity_log_event_rest_route( $route ) {
if ( ! is_string( $route ) ) {
return false;
}
$rest_base = self::REST_BASE;
if ( false === strpos( $route, $rest_base ) ) {
return false;
}
if ( '/wp/v2/' . $rest_base === $route || 0 === strpos( $route, '/wp/v2/' . $rest_base . '/' ) ) {
return true;
}
$parts = explode( '/', trim( $route, '/' ) );
if ( count( $parts ) < 5 ) {
return false;
}
return (
'wp' === $parts[0]
&& 'v2' === $parts[1]
&& 'sites' === $parts[2]
&& $rest_base === $parts[4]
);
}
/**
* Normalizes Activity Log event REST requests before they are inserted.
*
* @param object $prepared_post Prepared post object.
* @param \WP_REST_Request $request REST request.
* @return object|\WP_Error
*/
public static function normalize_rest_post( $prepared_post, $request ) {
if ( ! is_object( $prepared_post ) || ! $request instanceof \WP_REST_Request ) {
return $prepared_post;
}
$payload = self::build_payload(
array(
'title' => self::get_rest_request_value( $request, 'title', $prepared_post->post_title ?? '' ),
'content' => self::get_rest_request_value( $request, 'content', $prepared_post->post_content ?? '' ),
'source' => $request->get_param( 'source' ),
'severity' => $request->get_param( 'severity' ),
)
);
if ( false === $payload ) {
return new \WP_Error(
'invalid_activity_log_event',
esc_html__( 'Invalid Activity Log event payload.', 'jetpack-sync' ),
array( 'status' => 400 )
);
}
$post_content = wp_json_encode( $payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
if ( false === $post_content ) {
return new \WP_Error(
'invalid_activity_log_event',
esc_html__( 'Invalid Activity Log event payload.', 'jetpack-sync' ),
array( 'status' => 400 )
);
}
$prepared_post->post_title = $payload['title'];
$prepared_post->post_content = $post_content;
$prepared_post->post_status = 'publish';
return $prepared_post;
}
/**
* Normalizes Activity Log event posts before they are inserted via wp_insert_post().
*
* @param array $data Slashed, sanitized post data.
* @param array $postarr Post data passed to wp_insert_post().
* @return array
*/
public static function normalize_post_data( $data, $postarr ) {
if ( ! is_array( $data ) || ! is_array( $postarr ) || self::POST_TYPE !== ( $postarr['post_type'] ?? '' ) ) {
return $data;
}
$payload = self::build_payload_from_post_content( $data['post_content'] ?? '' );
if ( false === $payload ) {
return $data;
}
$post_content = wp_json_encode( $payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
if ( false === $post_content ) {
return $data;
}
$data['post_title'] = wp_slash( $payload['title'] );
$data['post_content'] = wp_slash( $post_content );
return $data;
}
/**
* Never auto-share Activity Log entries via Jetpack Social,
* even if a third party adds 'publicize' post-type support to this CPT.
*
* @param bool $should_publicize Publicize status prior to this filter running.
* @param \WP_Post $post The post to test for Publicizability.
* @return bool
*/
public static function prevent_publicize( $should_publicize, $post ) {
if ( ! $post instanceof \WP_Post ) {
return $should_publicize;
}
return self::POST_TYPE === $post->post_type ? false : $should_publicize;
}
/**
* Never include Activity Log entries in Jetpack sitemaps,
* even if a third party adds this CPT to the sitemap post-type list.
*
* @param string[] $types Sitemap post types.
* @return string[]
*/
public static function filter_sitemap_post_types( $types ) {
$types = (array) $types;
if ( ! in_array( self::POST_TYPE, $types, true ) ) {
return $types;
}
return array_values( array_diff( $types, array( self::POST_TYPE ) ) );
}
/**
* Builds an Activity Log event payload from raw input.
*
* @param array $args Raw event arguments.
* @return array|false Sanitized payload, or false if validation fails.
*/
private static function build_payload( array $args ) {
$severity = self::sanitize_severity( $args['severity'] ?? self::DEFAULT_SEVERITY );
if ( false === $severity ) {
return false;
}
$title = self::sanitize_string( $args['title'] ?? '', self::MAX_TITLE_LENGTH );
$content = self::sanitize_string( $args['content'] ?? '', self::MAX_CONTENT_LENGTH );
if ( '' === $title || '' === $content ) {
return false;
}
$payload = array(
'title' => $title,
'content' => $content,
'severity' => $severity,
);
if ( isset( $args['source'] ) ) {
$source = self::sanitize_string( $args['source'], self::MAX_SOURCE_LENGTH );
if ( '' !== $source ) {
$payload['source'] = $source;
}
}
return $payload;
}
/**
* Builds an Activity Log event payload from post content.
*
* @param mixed $post_content Raw post content.
* @return array|false Sanitized payload, or false if validation fails.
*/
private static function build_payload_from_post_content( $post_content ) {
$data = self::decode_payload( $post_content );
if ( ! is_array( $data ) ) {
return false;
}
return self::build_payload( $data );
}
/**
* Decodes an Activity Log event payload from post content.
*
* @param mixed $post_content Raw post content.
* @return array|false
*/
private static function decode_payload( $post_content ) {
$data = json_decode( (string) $post_content, true );
if ( ! is_array( $data ) ) {
$data = json_decode( wp_unslash( (string) $post_content ), true );
}
return is_array( $data ) ? $data : false;
}
/**
* Gets a string-like value from a REST request field.
*
* @param \WP_REST_Request $request REST request.
* @param string $field Request field name.
* @param mixed $default Default value.
* @return mixed
*/
private static function get_rest_request_value( $request, $field, $default = '' ) {
$value = $request->get_param( $field );
if ( null === $value ) {
return $default;
}
if ( is_array( $value ) && isset( $value['raw'] ) ) {
return $value['raw'];
}
if ( is_array( $value ) && isset( $value['rendered'] ) ) {
return $value['rendered'];
}
return $value;
}
/**
* Strips HTML/PHP from a value and truncates it to a maximum character length, multibyte-safe.
*
* @param mixed $value Raw value.
* @param int $max Maximum length in characters.
* @return string
*/
private static function sanitize_string( $value, $max ) {
if ( is_array( $value ) || is_object( $value ) ) {
return '';
}
$value = wp_strip_all_tags( (string) $value, true );
$value = preg_replace( '/\s+/', ' ', $value );
if ( null === $value ) {
return '';
}
$value = trim( $value );
if ( function_exists( 'mb_substr' ) ) {
return mb_substr( $value, 0, $max );
}
return substr( $value, 0, $max );
}
/**
* Sanitizes an Activity Log severity value.
*
* @param mixed $severity Raw severity.
* @return string|false Sanitized severity, or false if invalid.
*/
private static function sanitize_severity( $severity ) {
if ( is_array( $severity ) || is_object( $severity ) ) {
return false;
}
$severity = strtolower( trim( (string) $severity ) );
if ( '' === $severity ) {
return self::DEFAULT_SEVERITY;
}
return isset( self::ALLOWED_SEVERITIES[ $severity ] ) ? $severity : false;
}
}