| 1 |
<?php |
| 2 |
/** |
| 3 |
* Audit logging for post-related events in WordPress. |
| 4 |
* |
| 5 |
* @package WP_Defender\Component\Audit |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace WP_Defender\Component\Audit; |
| 9 |
|
| 10 |
use WP_Defender\Traits\User; |
| 11 |
use Calotes\Helper\Array_Cache; |
| 12 |
use WP_Defender\Model\Audit_Log; |
| 13 |
|
| 14 |
/** |
| 15 |
* Handles post-related audit events such as post creation, post update, and post deletion. |
| 16 |
*/ |
| 17 |
class Post_Audit extends Audit_Event { |
| 18 |
|
| 19 |
use User; |
| 20 |
|
| 21 |
/** |
| 22 |
* Exclude events. |
| 23 |
* |
| 24 |
* @return array |
| 25 |
* @since 3.9.0 |
| 26 |
*/ |
| 27 |
protected function exclude_events() { |
| 28 |
// Share excluded post types, e.g. from Hummingbird. |
| 29 |
$public_events = apply_filters( 'wd_audit_excluded_post_types', array( 'wphb_minify_group' ) ); |
| 30 |
$public_events = is_array( $public_events ) ? $public_events : (array) $public_events; |
| 31 |
$excluded_post_types = array( |
| 32 |
// From Defender. |
| 33 |
'wdscan_result', |
| 34 |
'wdf_scan', |
| 35 |
'wd_iplockout_log', |
| 36 |
'wd_ip_lockout', |
| 37 |
'wdf_scan_item', |
| 38 |
// Default types. |
| 39 |
'nav_menu_item', |
| 40 |
'revision', |
| 41 |
); |
| 42 |
|
| 43 |
return array_unique( array_merge( $excluded_post_types, $public_events ) ); |
| 44 |
} |
| 45 |
|
| 46 |
/** |
| 47 |
* We will add a hook, for updated event, and cache that event content. |
| 48 |
* Later we weill use the hook save post, to determine this is insert new post or update the cache will be |
| 49 |
* the array of various post, as we don't want data be excluded. This way we can get more control. |
| 50 |
*/ |
| 51 |
public function __construct() { |
| 52 |
add_action( 'post_updated', array( $this, 'cache_post_updated' ), 10, 3 ); |
| 53 |
} |
| 54 |
|
| 55 |
/** |
| 56 |
* Caches the before and after states of a post when it is updated. This cache is used later to determine the |
| 57 |
* nature of the update. |
| 58 |
* |
| 59 |
* @param mixed $post_id The ID of the post being updated. |
| 60 |
* @param mixed $after The state of the post after the update. |
| 61 |
* @param mixed $before The state of the post before the update. |
| 62 |
* |
| 63 |
* @return void |
| 64 |
*/ |
| 65 |
public function cache_post_updated( $post_id, $after, $before ) { |
| 66 |
Array_Cache::append( |
| 67 |
'post_updated', |
| 68 |
array( |
| 69 |
'post_id' => $post_id, |
| 70 |
'after' => $after, |
| 71 |
'before' => $before, |
| 72 |
), |
| 73 |
'audit' |
| 74 |
); |
| 75 |
} |
| 76 |
|
| 77 |
/** |
| 78 |
* Defines and returns an array of hooks associated with post events and their corresponding callback functions, |
| 79 |
* event types, and other relevant parameters. |
| 80 |
* |
| 81 |
* @return array |
| 82 |
*/ |
| 83 |
public function get_hooks(): array { |
| 84 |
return array( |
| 85 |
'save_post' => array( |
| 86 |
'args' => array( 'post_ID', 'post', 'is_updated' ), |
| 87 |
'callback' => array( self::class, 'post_updated_callback' ), |
| 88 |
'event_type' => Audit_Log::EVENT_TYPE_CONTENT, |
| 89 |
'action_type' => self::ACTION_UPDATED, |
| 90 |
), |
| 91 |
'transition_post_status' => array( |
| 92 |
'args' => array( 'new_status', 'old_status', 'post' ), |
| 93 |
'event_type' => Audit_Log::EVENT_TYPE_CONTENT, |
| 94 |
'action_type' => self::ACTION_UPDATED, |
| 95 |
'false_when' => array( |
| 96 |
array( |
| 97 |
'{{post->post_status}}', |
| 98 |
array( |
| 99 |
'inherit', |
| 100 |
'new', |
| 101 |
'auto-draft', |
| 102 |
'trash', |
| 103 |
), |
| 104 |
), |
| 105 |
array( |
| 106 |
'{{post->post_type}}', |
| 107 |
$this->exclude_events(), |
| 108 |
), |
| 109 |
array( |
| 110 |
'{{new_status}}', |
| 111 |
'{{old_status}}', |
| 112 |
), |
| 113 |
array( |
| 114 |
'{{old_status}}', |
| 115 |
array( |
| 116 |
'trash', |
| 117 |
'new', |
| 118 |
), |
| 119 |
), |
| 120 |
), |
| 121 |
'text' => array( |
| 122 |
array( |
| 123 |
sprintf( |
| 124 |
/* translators: 1: Blog name, 2: Source of action. For e.g. Hub or a logged-in user, 3: Post type, 4: Post title */ |
| 125 |
esc_html__( '%1$s %2$s published %3$s "%4$s"', 'defender-security' ), |
| 126 |
'{{blog_name}}', |
| 127 |
'{{wp_user}}', |
| 128 |
'{{post_type_label}}', |
| 129 |
'{{post_title}}' |
| 130 |
), |
| 131 |
'{{new_status}}', |
| 132 |
'publish', |
| 133 |
'==', |
| 134 |
), |
| 135 |
array( |
| 136 |
sprintf( |
| 137 |
/* translators: 1: Blog name, 2: Source of action. For e.g. Hub or a logged-in user, 3: Post type, 4: Post title */ |
| 138 |
esc_html__( '%1$s %2$s pending %3$s "%4$s"', 'defender-security' ), |
| 139 |
'{{blog_name}}', |
| 140 |
'{{wp_user}}', |
| 141 |
'{{post_type_label}}', |
| 142 |
'{{post_title}}' |
| 143 |
), |
| 144 |
'{{new_status}}', |
| 145 |
'pending', |
| 146 |
'==', |
| 147 |
), |
| 148 |
array( |
| 149 |
sprintf( |
| 150 |
/* translators: 1: Blog name, 2: Source of action. For e.g. Hub or a logged-in user, 3: Post type, 4: Post title */ |
| 151 |
esc_html__( '%1$s %2$s drafted %3$s "%4$s"', 'defender-security' ), |
| 152 |
'{{blog_name}}', |
| 153 |
'{{wp_user}}', |
| 154 |
'{{post_type_label}}', |
| 155 |
'{{post_title}}' |
| 156 |
), |
| 157 |
'{{new_status}}', |
| 158 |
'draft', |
| 159 |
'==', |
| 160 |
), |
| 161 |
array( |
| 162 |
sprintf( |
| 163 |
/* translators: 1: Blog name, 2: Source of action. For e.g. Hub or a logged-in user, 3: Post type, 4: Post title, 5: Old status, 6: New status */ |
| 164 |
esc_html__( '%1$s %2$s changed %3$s "%4$s" status from %5$s to %6$s', 'defender-security' ), |
| 165 |
'{{blog_name}}', |
| 166 |
'{{wp_user}}', |
| 167 |
'{{post_type_label}}', |
| 168 |
'{{post_title}}', |
| 169 |
'{{old_status}}', |
| 170 |
'{{new_status}}' |
| 171 |
), |
| 172 |
'{{new_status}}', |
| 173 |
'{{new_status}}', |
| 174 |
'==', |
| 175 |
), |
| 176 |
), |
| 177 |
'program_args' => array( |
| 178 |
'post_type_label' => array( |
| 179 |
'callable' => 'get_post_type_object', |
| 180 |
'params' => array( '{{post->post_type}}' ), |
| 181 |
'result_property' => 'labels->singular_name', |
| 182 |
), |
| 183 |
), |
| 184 |
'custom_args' => array( |
| 185 |
'post_title' => '{{post->post_title}}', |
| 186 |
), |
| 187 |
'context' => '{{post_type_label}}', |
| 188 |
), |
| 189 |
'delete_post' => array( |
| 190 |
'args' => array( 'post_ID' ), |
| 191 |
'event_type' => Audit_Log::EVENT_TYPE_CONTENT, |
| 192 |
'action_type' => self::ACTION_DELETED, |
| 193 |
'text' => sprintf( |
| 194 |
/* translators: 1: Blog name, 2: Source of action. For e.g. Hub or a logged-in user, 3: Post type, 4: Post title */ |
| 195 |
esc_html__( '%1$s %2$s deleted %3$s "%4$s"', 'defender-security' ), |
| 196 |
'{{blog_name}}', |
| 197 |
'{{wp_user}}', |
| 198 |
'{{post_type_label}}', |
| 199 |
'{{post_title}}' |
| 200 |
), |
| 201 |
'program_args' => array( |
| 202 |
'post' => array( |
| 203 |
'callable' => 'get_post', |
| 204 |
'params' => array( '{{post_ID}}' ), |
| 205 |
), |
| 206 |
'post_type_label' => array( |
| 207 |
'callable' => 'get_post_type_object', |
| 208 |
'params' => array( '{{post->post_type}}' ), |
| 209 |
'result_property' => 'labels->singular_name', |
| 210 |
), |
| 211 |
'post_title' => array( |
| 212 |
'callable' => 'get_post', |
| 213 |
'params' => array( '{{post_ID}}' ), |
| 214 |
'result_property' => 'post_title', |
| 215 |
), |
| 216 |
), |
| 217 |
'context' => '{{post_type_label}}', |
| 218 |
'false_when' => array( |
| 219 |
array( |
| 220 |
'{{post->post_type}}', |
| 221 |
array_merge( |
| 222 |
array( 'attachment' ), |
| 223 |
$this->exclude_events() |
| 224 |
), |
| 225 |
), |
| 226 |
array( |
| 227 |
'{{post_type_label}}', |
| 228 |
'', |
| 229 |
), |
| 230 |
), |
| 231 |
), |
| 232 |
'untrashed_post' => array( |
| 233 |
'args' => array( 'post_ID' ), |
| 234 |
'action_type' => self::ACTION_RESTORED, |
| 235 |
'event_type' => Audit_Log::EVENT_TYPE_CONTENT, |
| 236 |
'text' => sprintf( |
| 237 |
/* translators: 1: Blog name, 2: Source of action. For e.g. Hub or a logged-in user, 3: Post type, 4: Post title */ |
| 238 |
esc_html__( '%1$s %2$s untrashed %3$s "%4$s"', 'defender-security' ), |
| 239 |
'{{blog_name}}', |
| 240 |
'{{wp_user}}', |
| 241 |
'{{post_type_label}}', |
| 242 |
'{{post_title}}' |
| 243 |
), |
| 244 |
'program_args' => array( |
| 245 |
'post' => array( |
| 246 |
'callable' => 'get_post', |
| 247 |
'params' => array( '{{post_ID}}' ), |
| 248 |
), |
| 249 |
'post_type_label' => array( |
| 250 |
'callable' => 'get_post_type_object', |
| 251 |
'params' => array( '{{post->post_type}}' ), |
| 252 |
'result_property' => 'labels->singular_name', |
| 253 |
), |
| 254 |
'post_title' => array( |
| 255 |
'callable' => 'get_post', |
| 256 |
'params' => array( '{{post_ID}}' ), |
| 257 |
'result_property' => 'post_title', |
| 258 |
), |
| 259 |
), |
| 260 |
'context' => '{{post_type_label}}', |
| 261 |
), |
| 262 |
'trashed_post' => array( |
| 263 |
'args' => array( 'post_ID' ), |
| 264 |
'action_type' => self::ACTION_TRASHED, |
| 265 |
'event_type' => Audit_Log::EVENT_TYPE_CONTENT, |
| 266 |
'text' => sprintf( |
| 267 |
/* translators: 1: Blog name, 2: Source of action. For e.g. Hub or a logged-in user, 3: Post type, 4: Post title */ |
| 268 |
esc_html__( '%1$s %2$s trashed %3$s "%4$s"', 'defender-security' ), |
| 269 |
'{{blog_name}}', |
| 270 |
'{{wp_user}}', |
| 271 |
'{{post_type_label}}', |
| 272 |
'{{post_title}}' |
| 273 |
), |
| 274 |
'program_args' => array( |
| 275 |
'post' => array( |
| 276 |
'callable' => 'get_post', |
| 277 |
'params' => array( '{{post_ID}}' ), |
| 278 |
), |
| 279 |
'post_type_label' => array( |
| 280 |
'callable' => 'get_post_type_object', |
| 281 |
'params' => array( '{{post->post_type}}' ), |
| 282 |
'result_property' => 'labels->singular_name', |
| 283 |
), |
| 284 |
'post_title' => array( |
| 285 |
'callable' => 'get_post', |
| 286 |
'params' => array( '{{post_ID}}' ), |
| 287 |
'result_property' => 'post_title', |
| 288 |
), |
| 289 |
), |
| 290 |
'context' => '{{post_type_label}}', |
| 291 |
), |
| 292 |
); |
| 293 |
} |
| 294 |
|
| 295 |
/** |
| 296 |
* Callback function for the save_post hook. It determines if a post has been updated and logs the appropriate |
| 297 |
* audit event. |
| 298 |
* |
| 299 |
* @return bool|array |
| 300 |
*/ |
| 301 |
public function post_updated_callback() { |
| 302 |
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { |
| 303 |
return false; |
| 304 |
} |
| 305 |
|
| 306 |
$args = func_get_args(); |
| 307 |
$post = $args[1]['post']; |
| 308 |
|
| 309 |
if ( in_array( $post->post_status, array( 'trash', 'auto-draft' ), true ) ) { |
| 310 |
// Usually, wp adds :trash to the post name, so this case we just return. |
| 311 |
return false; |
| 312 |
} |
| 313 |
|
| 314 |
if ( in_array( $post->post_type, $this->exclude_events(), true ) ) { |
| 315 |
return false; |
| 316 |
} |
| 317 |
|
| 318 |
$post_type = get_post_type_object( $post->post_type ); |
| 319 |
if ( ! is_object( $post_type ) ) { |
| 320 |
return false; |
| 321 |
} |
| 322 |
|
| 323 |
$is_updated = $args[1]['is_updated']; |
| 324 |
$post_before = null; |
| 325 |
$cached = Array_Cache::get( 'post_updated', 'audit', array() ); |
| 326 |
foreach ( $cached as $post_arr ) { |
| 327 |
if ( $post->ID === $post_arr['post_id'] ) { |
| 328 |
$post_before = $post_arr['before']; |
| 329 |
break; |
| 330 |
} |
| 331 |
} |
| 332 |
|
| 333 |
$blog_name = is_multisite() ? '[' . get_bloginfo( 'name' ) . ']' : ''; |
| 334 |
if ( true === $is_updated ) { |
| 335 |
if ( ! is_null( $post_before ) ) { |
| 336 |
$post_after = $post->to_array(); |
| 337 |
$post_before = $post_before->to_array(); |
| 338 |
|
| 339 |
// Status transitions are handled via other hooks. |
| 340 |
if ( $post_before['post_status'] !== $post_after['post_status'] ) { |
| 341 |
return false; |
| 342 |
} |
| 343 |
|
| 344 |
// Unset the date modified, and post status, as we got another hook for that. |
| 345 |
unset( $post_after['post_modified'] ); |
| 346 |
unset( $post_after['post_modified_gmt'] ); |
| 347 |
unset( $post_after['post_status'] ); |
| 348 |
unset( $post_before['post_modified'] ); |
| 349 |
unset( $post_before['post_modified_gmt'] ); |
| 350 |
unset( $post_before['post_status'] ); |
| 351 |
if ( wp_json_encode( $post_before ) !== wp_json_encode( $post_after ) ) { |
| 352 |
$item_changed_count = count( self::array_recursive_diff( $post_before, $post_after ) ); |
| 353 |
if ( $post_before['post_title'] !== $post_after['post_title'] && 1 === $item_changed_count ) { |
| 354 |
$text = sprintf( |
| 355 |
/* translators: 1: Blog name, 2: User's display name, 3: Post type, 4: Post ID, 5: Old post title, 6: New post title */ |
| 356 |
esc_html__( '%1$s %2$s updated %3$s ID %4$d, title from "%5$s" to "%6$s"', 'defender-security' ), |
| 357 |
$blog_name, |
| 358 |
$this->get_user_display( get_current_user_id() ), |
| 359 |
$post_type->labels->singular_name, |
| 360 |
$post_after['ID'], |
| 361 |
$post_before['post_title'], |
| 362 |
$post_after['post_title'] |
| 363 |
); |
| 364 |
} elseif ( $post_before['post_name'] !== $post_after['post_name'] && 1 === $item_changed_count ) { |
| 365 |
$text = sprintf( |
| 366 |
/* translators: 1: Blog name, 2: User's display name, 3: Post type, 4: Post ID, 5: Old post slug, 6: New post slug */ |
| 367 |
esc_html__( '%1$s %2$s updated %3$s ID %4$d, slug from "%5$s" to "%6$s"', 'defender-security' ), |
| 368 |
$blog_name, |
| 369 |
$this->get_user_display( get_current_user_id() ), |
| 370 |
$post_type->labels->singular_name, |
| 371 |
$post_after['ID'], |
| 372 |
$post_before['post_name'], |
| 373 |
$post_after['post_name'] |
| 374 |
); |
| 375 |
} elseif ( $post_before['post_author'] !== $post_after['post_author'] && 1 === $item_changed_count ) { |
| 376 |
$text = sprintf( |
| 377 |
/* translators: 1: Blog name, 2: User's display name, 3: Post type, 4: Post ID, 5: Old author name, 6: New author name */ |
| 378 |
esc_html__( '%1$s %2$s updated %3$s ID %4$d, author from "%5$s" to "%6$s"', 'defender-security' ), |
| 379 |
$blog_name, |
| 380 |
$this->get_user_display( get_current_user_id() ), |
| 381 |
$post_type->labels->singular_name, |
| 382 |
$post_after['ID'], |
| 383 |
$this->get_user_display( $post_before['post_author'] ), |
| 384 |
$this->get_user_display( $post_after['post_author'] ) |
| 385 |
); |
| 386 |
} else { |
| 387 |
$text = sprintf( |
| 388 |
/* translators: 1: Blog name, 2: User's display name, 3: Post type, 4: Post title */ |
| 389 |
esc_html__( '%1$s %2$s updated %3$s "%4$s"', 'defender-security' ), |
| 390 |
$blog_name, |
| 391 |
$this->get_user_display( get_current_user_id() ), |
| 392 |
$post_type->labels->singular_name, |
| 393 |
$post_after['post_title'] |
| 394 |
); |
| 395 |
} |
| 396 |
|
| 397 |
return array( |
| 398 |
$text, |
| 399 |
$post_type->labels->singular_name, |
| 400 |
); |
| 401 |
} |
| 402 |
} |
| 403 |
} elseif ( is_null( $post_before ) ) { |
| 404 |
$text = sprintf( |
| 405 |
/* translators: 1: Blog name, 2: User's display name, 3: Post type, 4: Post title */ |
| 406 |
esc_html__( '%1$s %2$s added new %3$s "%4$s"', 'defender-security' ), |
| 407 |
$blog_name, |
| 408 |
$this->get_user_display( get_current_user_id() ), |
| 409 |
$post_type->labels->singular_name, |
| 410 |
$post->post_title |
| 411 |
); |
| 412 |
|
| 413 |
return array( |
| 414 |
$text, |
| 415 |
$post_type->labels->singular_name, |
| 416 |
); |
| 417 |
} |
| 418 |
|
| 419 |
return false; |
| 420 |
} |
| 421 |
|
| 422 |
/** |
| 423 |
* Provides a dictionary or glossary that could be used for translating or explaining terms. Currently, returns an |
| 424 |
* empty array. |
| 425 |
* |
| 426 |
* @return array |
| 427 |
*/ |
| 428 |
public function dictionary(): array { |
| 429 |
return array(); |
| 430 |
} |
| 431 |
} |
| 432 |
|