prefix . 'aoedia_log'; } /** * Creates or upgrades the log table when the schema version changes. */ function aoedia_log_maybe_install(): void { if ( get_option( 'aoedia_db_version' ) === AOEDIA_DB_VERSION ) { return; } global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = aoedia_log_table(); $charset = $wpdb->get_charset_collate(); dbDelta( "CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, created_at datetime NOT NULL, user_id bigint(20) unsigned NOT NULL DEFAULT 0, source varchar(20) NOT NULL DEFAULT '', event varchar(20) NOT NULL DEFAULT '', ability varchar(100) NOT NULL DEFAULT '', status varchar(20) NOT NULL DEFAULT '', object_id bigint(20) unsigned NOT NULL DEFAULT 0, tokens int(10) unsigned NOT NULL DEFAULT 0, details longtext NOT NULL, PRIMARY KEY (id), KEY created_at (created_at), KEY user_created (user_id,created_at), KEY event (event) ) {$charset};" ); update_option( 'aoedia_db_version', AOEDIA_DB_VERSION ); } /** * Writes one log entry. * * @param string $event One of request, ability, proposed, approved, cancelled. * @param array $args { * Optional. Entry fields. * * @type string $source Where the call came from: chat, editor, rest or other. * @type string $ability Ability name. * @type string $status ok, error, pending or cancelled. * @type int $object_id Related post ID. * @type int $tokens Tokens used by a request. * @type array $details Extra data, stored as JSON. * } */ function aoedia_log( string $event, array $args = array() ): void { global $wpdb; $details = wp_json_encode( $args['details'] ?? array(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); // Keep entries small; full post content does not belong in an audit trail. if ( strlen( $details ) > 4000 ) { $details = wp_json_encode( array( 'truncated' => mb_strcut( $details, 0, 4000 ) ), JSON_UNESCAPED_UNICODE ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Plugin table, write only. $wpdb->insert( aoedia_log_table(), array( 'created_at' => current_time( 'mysql', true ), 'user_id' => get_current_user_id(), 'source' => $args['source'] ?? '', 'event' => $event, 'ability' => $args['ability'] ?? '', 'status' => $args['status'] ?? '', 'object_id' => (int) ( $args['object_id'] ?? 0 ), 'tokens' => (int) ( $args['tokens'] ?? 0 ), 'details' => (string) $details, ), array( '%s', '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%s' ) ); } /** * Shortens ability input for the log, dropping long fields such as post content. * * @param mixed $input The ability input. * @return mixed The shortened input. */ function aoedia_log_input( $input ) { if ( ! is_array( $input ) ) { return $input; } foreach ( $input as $key => $value ) { if ( is_string( $value ) && mb_strlen( $value ) > 300 ) { $input[ $key ] = mb_substr( wp_strip_all_tags( $value ), 0, 200 ) . '…'; } } return $input; } /** * Finds the post ID an ability call is about, if any. * * @param mixed $input The ability input. * @param mixed $result The ability result. * @return int The post ID, or 0. */ function aoedia_log_object_id( $input, $result ): int { // Term abilities carry term IDs, which must not be shown as posts. if ( is_array( $input ) && isset( $input['taxonomy'] ) ) { return 0; } if ( is_array( $result ) && isset( $result['id'] ) && is_int( $result['id'] ) ) { return $result['id']; } if ( is_array( $input ) && isset( $input['id'] ) ) { return (int) $input['id']; } return 0; } /** * Logs Aoedia abilities run outside Aoedia, for example through REST or MCP. * * @param string $ability_name The ability name. * @param mixed $input The ability input. * @param mixed $result The ability result. */ function aoedia_log_external_execution( string $ability_name, $input, $result ): void { if ( null !== $GLOBALS['aoedia_log_source'] || 0 !== strpos( $ability_name, 'aoedia/' ) ) { return; } aoedia_log( 'ability', array( 'source' => defined( 'REST_REQUEST' ) && REST_REQUEST ? 'rest' : 'other', 'ability' => $ability_name, 'status' => 'ok', 'object_id' => aoedia_log_object_id( $input, $result ), 'details' => array( 'input' => aoedia_log_input( $input ) ), ) ); } /** * Deletes log entries older than the retention period. */ function aoedia_log_cleanup(): void { global $wpdb; /** * Filters how many days Aoedia keeps audit log entries. * * @param int $days Number of days. Default 90. */ $days = max( 1, (int) apply_filters( 'aoedia_log_retention_days', 90 ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Plugin table. $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE created_at < %s', aoedia_log_table(), gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ) ) ); } /** * Schedules the daily cleanup if it is not scheduled yet. */ function aoedia_log_schedule_cleanup(): void { if ( ! wp_next_scheduled( 'aoedia_log_cleanup' ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'aoedia_log_cleanup' ); } }