PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / 3.8.0
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! v3.8.0
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
templately / modules / mcp-core / Activity / ActivityLog.php

ActivityLog.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! 3.8.0, at modules/mcp-core/Activity/ActivityLog.php

137 lines 3.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Record of what an agent CHANGED on this site (spec 044, FR-039d–h).
4 *
5 * Answers the question a site owner will ask first: "what did the AI do to my
6 * site?" Before 044 that was unanswerable — write access was granted with no
7 * record of its use.
8 *
9 * ## Storage
10 *
11 * A capped ring buffer in ONE non-autoloaded option, trimmed on append by both
12 * entry count and age. Bounded by construction (FR-039e), so it needs no sweep
13 * job and cannot grow without limit. Plain get_option/update_option, so on
14 * multisite it is per-site automatically (FR-039a) — see Credentials.php for
15 * why get_user_option is deliberately avoided.
16 *
17 * ## Best-effort by design
18 *
19 * FR-039h: a recording failure MUST NOT fail or roll back the invocation it was
20 * describing. Every write here is wrapped and swallowed. This is an accountability
21 * aid, not a security control — losing an entry under concurrent append is
22 * acceptable and explicitly permitted, which is why no locking is used.
23 *
24 * @package Templately\Modules\McpCore\Activity
25 */
26
27 namespace Templately\Modules\McpCore\Activity;
28
29 class ActivityLog {
30
31 const OPTION = 'templately_mcp_activity';
32
33 /** Hard cap on retained entries. */
34 const MAX_ENTRIES = 200;
35
36 /** Hard cap on retained age. */
37 const MAX_AGE = 2592000; // 30 days.
38
39 /**
40 * Append one record. Never throws; never fails the caller (FR-039h).
41 *
42 * @param string|null $credential_id Credential identifier — NEVER the secret or its hash.
43 * @param int $user_id
44 * @param string $capability
45 * @param bool $success
46 * @param string|null $message Short reason on failure. Never a stack trace or path.
47 */
48 public static function record( ?string $credential_id, int $user_id, string $capability, bool $success, ?string $message = null ): void {
49 try {
50 $entries = self::all();
51
52 $entries[] = [
53 'at' => time(),
54 'credential_id' => $credential_id,
55 'user_id' => $user_id,
56 'capability' => $capability,
57 'outcome' => $success ? 'success' : 'failure',
58 'message' => $message ? self::sanitize_message( $message ) : null,
59 ];
60
61 update_option( self::OPTION, self::trim( $entries ), 'no' );
62 } catch ( \Throwable $e ) {
63 // Swallowed deliberately — see class docblock (FR-039h).
64 return;
65 }
66 }
67
68 /**
69 * @return array
70 */
71 public static function all(): array {
72 $entries = get_option( self::OPTION, [] );
73
74 return is_array( $entries ) ? $entries : [];
75 }
76
77 /**
78 * Most recent first, for display.
79 *
80 * @param int $limit
81 * @return array
82 */
83 public static function recent( int $limit = 50 ): array {
84 $entries = self::trim( self::all() );
85
86 return array_slice( array_reverse( $entries ), 0, max( 1, $limit ) );
87 }
88
89 public static function clear(): void {
90 delete_option( self::OPTION );
91 }
92
93 /**
94 * Trim by age first, then by count. Both bounds exist so a busy site does
95 * not retain unboundedly and a quiet site does not retain forever.
96 *
97 * @param array $entries
98 * @return array
99 */
100 private static function trim( array $entries ): array {
101 $cutoff = time() - self::MAX_AGE;
102
103 $entries = array_values(
104 array_filter(
105 $entries,
106 static function ( $entry ) use ( $cutoff ) {
107 return isset( $entry['at'] ) && (int) $entry['at'] >= $cutoff;
108 }
109 )
110 );
111
112 if ( count( $entries ) > self::MAX_ENTRIES ) {
113 $entries = array_slice( $entries, -self::MAX_ENTRIES );
114 }
115
116 return $entries;
117 }
118
119 /**
120 * Keep messages short and free of internal detail (FR-039f, FR-044).
121 * Anything resembling an absolute path is dropped rather than truncated.
122 *
123 * @param string $message
124 * @return string
125 */
126 private static function sanitize_message( string $message ): string {
127 $message = (string) preg_replace( '#(/[^\s:]+)+\.php#', '', $message );
128 $message = trim( wp_strip_all_tags( $message ) );
129
130 if ( function_exists( 'mb_substr' ) ) {
131 return mb_substr( $message, 0, 200 );
132 }
133
134 return substr( $message, 0, 200 );
135 }
136 }
137