PluginProbe
Extendify / 3.2.3
Extendify v3.2.3
3.2.3 3.2.2 3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 All 129 releases
extendify / app / Mcp / Log.php

Log.php in Extendify 3.2.3, at app/Mcp/Log.php

216 lines 6.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * The tool calls a connection has made on this site.
5 */
6
7 namespace Extendify\Mcp;
8
9 defined('ABSPATH') || die('No direct access.');
10
11 /**
12 * Arguments are never stored: they carry post bodies, alt text, user filters and
13 * email addresses, while a tool name and its outcome answer what support asks.
14 * If the shape of a call is ever wanted, store argument keys, never values.
15 */
16 class Log
17 {
18 // phpcs:disable PSR12.Properties.ConstantVisibility.NotFound
19 const KEEP_ROWS = 5000;
20
21 const KEEP_DAYS = 90;
22
23 const ERROR_LENGTH = 500;
24
25 const SHOWN = 10;
26
27 const USAGE_DAYS = 2;
28 // phpcs:enable PSR12.Properties.ConstantVisibility.NotFound
29
30 /**
31 * @return string
32 */
33 public static function table()
34 {
35 return $GLOBALS['wpdb']->prefix . 'extendify_mcp_log';
36 }
37
38 /**
39 * @param array $connection - The connection the call arrived on.
40 * @param string $tool - The tool that was called.
41 * @param array $details - outcome, error and duration in seconds.
42 * @return void
43 */
44 public static function write(array $connection, $tool, array $details)
45 {
46 self::install();
47 $wpdb = $GLOBALS['wpdb'];
48 $wpdb->insert(self::table(), [
49 'created_at' => gmdate('Y-m-d H:i:s'),
50 'user_id' => (int) ($connection['userId'] ?? \get_current_user_id()),
51 'connection' => (string) ($connection['id'] ?? ''),
52 'client' => substr((string) ($connection['data']['label'] ?? ''), 0, Connections::LABEL_LENGTH),
53 'tool' => substr((string) $tool, 0, Surface::MAX_NAME),
54 'outcome' => (string) $details['outcome'],
55 'error' => substr((string) ($details['error'] ?? ''), 0, self::ERROR_LENGTH),
56 'duration_ms' => (int) round(((float) ($details['duration'] ?? 0)) * 1000),
57 ]);
58
59 self::prune();
60 }
61
62 /**
63 * @param integer $userId - The user the connection belongs to.
64 * @param string $connection - The connection's id.
65 * @param integer $limit - How many of the newest calls to read.
66 * @return array
67 */
68 public static function recent($userId, $connection, $limit = self::SHOWN)
69 {
70 if (!self::exists()) {
71 return [];
72 }
73
74 $wpdb = $GLOBALS['wpdb'];
75 $table = self::table();
76
77 return $wpdb->get_results($wpdb->prepare(
78 "SELECT created_at, tool, outcome, error FROM {$table}"
79 . ' WHERE user_id = %d AND `connection` = %s ORDER BY id DESC LIMIT %d',
80 (int) $userId,
81 (string) $connection,
82 (int) $limit
83 ), ARRAY_A) ?: [];
84 }
85
86 /**
87 * Unknown names become `ability`: an unknown-tool call logs the client's free text.
88 *
89 * @return array
90 */
91 public static function usage()
92 {
93 if (!self::exists()) {
94 return [];
95 }
96
97 $wpdb = $GLOBALS['wpdb'];
98 $table = self::table();
99 $tools = array_merge(array_keys(Tools::all()), Surface::abilityNames());
100 $in = implode(',', array_fill(0, count($tools), '%s'));
101 $since = gmdate('Y-m-d', time() - ((self::USAGE_DAYS - 1) * DAY_IN_SECONDS));
102
103 // Binary, or the collation lets `LIST_POSTS` or a trailing space pass as ours.
104 $rows = $wpdb->get_results($wpdb->prepare(
105 "SELECT DATE(created_at), IF(CAST(tool AS BINARY) IN ({$in}), tool, 'ability'), COUNT(*),"
106 . " SUM(outcome = 'error'), SUM(outcome = 'refused') FROM {$table}"
107 . ' WHERE created_at >= %s GROUP BY 1, 2 ORDER BY 1, 2',
108 array_merge($tools, [$since])
109 ), ARRAY_N) ?: [];
110
111 return array_map(function ($row) {
112 return [
113 'day' => $row[0],
114 'tool' => $row[1],
115 'calls' => (int) $row[2],
116 'errors' => (int) $row[3],
117 'refused' => (int) $row[4],
118 ];
119 }, $rows);
120 }
121
122 /**
123 * @param integer $userId - The user the connection belonged to.
124 * @param string $id - The connection's id.
125 * @return void
126 */
127 public static function forget($userId, $id)
128 {
129 if (!self::exists()) {
130 return;
131 }
132
133 $GLOBALS['wpdb']->delete(self::table(), [
134 'user_id' => (int) $userId,
135 'connection' => (string) $id,
136 ]);
137 }
138
139 /**
140 * @return void
141 */
142 private static function prune()
143 {
144 $wpdb = $GLOBALS['wpdb'];
145 $table = self::table();
146 $wpdb->query($wpdb->prepare(
147 "DELETE FROM {$table} WHERE created_at < %s",
148 gmdate('Y-m-d H:i:s', time() - (self::KEEP_DAYS * DAY_IN_SECONDS))
149 ));
150
151 $held = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
152 if ($held <= self::KEEP_ROWS) {
153 return;
154 }
155
156 $wpdb->query($wpdb->prepare("DELETE FROM {$table} ORDER BY id LIMIT %d", $held - self::KEEP_ROWS));
157 }
158
159 /**
160 * @return void
161 */
162 public static function install()
163 {
164 $wpdb = $GLOBALS['wpdb'];
165 $table = self::table();
166 if (self::exists()) {
167 $held = array_column($wpdb->get_results("SHOW COLUMNS FROM {$table}", ARRAY_A), 'Field');
168 foreach (self::columns() as $name => $type) {
169 if (!in_array($name, $held, true)) {
170 $wpdb->query("ALTER TABLE {$table} ADD COLUMN `{$name}` {$type}");
171 }
172 }
173
174 return;
175 }
176
177 $columns = [];
178 foreach (self::columns() as $name => $type) {
179 $columns[] = "`{$name}` {$type}";
180 }
181
182 $wpdb->query(
183 "CREATE TABLE {$table} (" . implode(',', $columns) . ', INDEX(user_id), INDEX(created_at)) '
184 . $wpdb->get_charset_collate() . ';'
185 );
186 }
187
188 /**
189 * @return boolean
190 */
191 private static function exists()
192 {
193 $wpdb = $GLOBALS['wpdb'];
194
195 return (bool) $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', self::table()));
196 }
197
198 /**
199 * @return array
200 */
201 private static function columns()
202 {
203 return [
204 'id' => 'BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY',
205 'created_at' => 'DATETIME NOT NULL',
206 'user_id' => 'BIGINT UNSIGNED NOT NULL',
207 'connection' => 'VARCHAR(191) NOT NULL DEFAULT \'\'',
208 'client' => 'VARCHAR(' . Connections::LABEL_LENGTH . ') NOT NULL DEFAULT \'\'',
209 'tool' => 'VARCHAR(' . Surface::MAX_NAME . ') NOT NULL DEFAULT \'\'',
210 'outcome' => 'VARCHAR(16) NOT NULL DEFAULT \'\'',
211 'error' => 'VARCHAR(' . self::ERROR_LENGTH . ') NOT NULL DEFAULT \'\'',
212 'duration_ms' => 'INT UNSIGNED NOT NULL DEFAULT 0',
213 ];
214 }
215 }
216