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 / Connections.php

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

183 lines 5.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * The AI assistants a user has authorized.
5 */
6
7 namespace Extendify\Mcp;
8
9 defined('ABSPATH') || die('No direct access.');
10
11 /**
12 * One usermeta row per authorized assistant; OAuth\Tokens mints and finds them.
13 */
14 class Connections
15 {
16 // phpcs:disable PSR12.Properties.ConstantVisibility.NotFound
17 /**
18 * The client names itself, and the name lands in a usermeta row and a table cell.
19 */
20 const LABEL_LENGTH = 80;
21 // phpcs:enable PSR12.Properties.ConstantVisibility.NotFound
22
23 /**
24 * @param integer $userId - The user whose connections to list.
25 * @return array
26 */
27 public static function all($userId)
28 {
29 $wpdb = $GLOBALS['wpdb'];
30 $rows = $wpdb->get_results($wpdb->prepare(
31 "SELECT meta_key, meta_value FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key LIKE %s",
32 (int) $userId,
33 $wpdb->esc_like(self::prefix()) . '%'
34 ));
35
36 $connections = [];
37 foreach ($rows ?: [] as $row) {
38 $data = (array) \maybe_unserialize($row->meta_value);
39 // Nothing else prunes an expired grant's row.
40 if (isset($data['expires']) && $data['expires'] < time()) {
41 \delete_user_meta((int) $userId, $row->meta_key);
42 continue;
43 }
44
45 $connection = array_merge(
46 ['id' => '', 'label' => '', 'created' => 0, 'lastUsed' => 0, 'salt' => ''],
47 $data,
48 ['key' => $row->meta_key]
49 );
50 $connection['grants'] = Grants::sanitize($connection['grants'] ?? null);
51 $connection['invalidated'] = $connection['salt'] !== ''
52 && !hash_equals($connection['salt'], self::fingerprint());
53 $connections[] = $connection;
54 }
55
56 usort($connections, function ($a, $b) {
57 return $b['created'] <=> $a['created'];
58 });
59
60 return $connections;
61 }
62
63 /**
64 * Expired rows count until their user's screen prunes them.
65 *
66 * @return integer
67 */
68 public static function count()
69 {
70 $wpdb = $GLOBALS['wpdb'];
71
72 return (int) $wpdb->get_var($wpdb->prepare(
73 "SELECT COUNT(*) FROM {$wpdb->usermeta} WHERE meta_key LIKE %s",
74 $wpdb->esc_like(self::prefix()) . '%'
75 ));
76 }
77
78 /**
79 * @param integer $userId - The user whose connections to describe.
80 * @return array - What a screen would have to redraw for: how many, and the newest.
81 */
82 public static function state($userId)
83 {
84 $created = array_column(self::all($userId), 'created');
85
86 return ['count' => count($created), 'newest' => $created ? (int) max($created) : 0];
87 }
88
89 /**
90 * @param integer $userId - The user the connection belongs to.
91 * @param string $id - The connection's id.
92 * @return boolean
93 */
94 public static function revoke($userId, $id)
95 {
96 if ($id === '') {
97 return false;
98 }
99
100 foreach (self::all($userId) as $connection) {
101 if ($connection['id'] === $id) {
102 return self::end($userId, $connection);
103 }
104 }
105
106 return false;
107 }
108
109 /**
110 * @param integer $userId - The user whose connections to end.
111 * @return void
112 */
113 public static function revokeAll($userId)
114 {
115 foreach (self::all($userId) as $connection) {
116 self::end($userId, $connection);
117 }
118 }
119
120 /**
121 * @param integer $userId - The user the connection belongs to.
122 * @param array $connection - The connection as all() lists it.
123 * @return boolean
124 */
125 private static function end($userId, array $connection)
126 {
127 Log::forget($userId, $connection['id']);
128
129 return (bool) \delete_user_meta((int) $userId, $connection['key']);
130 }
131
132 /**
133 * @param array $connection - The connection a request just arrived on.
134 * @return void
135 */
136 public static function touch(array $connection)
137 {
138 $data = $connection['data'];
139 $data['lastUsed'] = time();
140
141 // update_user_meta() would re-add a row that a revoke or a refresh deleted mid-call.
142 $wpdb = $GLOBALS['wpdb'];
143 $wpdb->update(
144 $wpdb->usermeta,
145 ['meta_value' => \maybe_serialize($data)],
146 ['user_id' => (int) $connection['userId'], 'meta_key' => $connection['metaKey']]
147 );
148 \wp_cache_delete((int) $connection['userId'], 'user_meta');
149 }
150
151 /**
152 * A clone copies the database and the salts, so the salt alone is not this site.
153 * home_url() is filtered per request, so it would hash one token two ways.
154 * Pinned to https so moving the site to https keeps its tokens.
155 *
156 * @return string
157 */
158 public static function secret()
159 {
160 return \wp_salt('auth') . '|' . \set_url_scheme(\get_option('home'), 'https');
161 }
162
163 /**
164 * A row minted under a changed salt or address otherwise reads as an unknown token.
165 *
166 * @return string
167 */
168 public static function fingerprint()
169 {
170 return substr(hash_hmac('sha256', 'connection', self::secret()), 0, 16);
171 }
172
173 /**
174 * A token minted on one site of a network must not reach another.
175 *
176 * @return string
177 */
178 public static function prefix()
179 {
180 return 'extendify_mcp_' . \get_current_blog_id() . '_';
181 }
182 }
183