PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
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 0.5.0 All 128 releases
extendify / app / Mcp / Connections.php

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

168 lines 4.9 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 * @param integer $userId - The user whose connections to describe.
65 * @return array - What a screen would have to redraw for: how many, and the newest.
66 */
67 public static function state($userId)
68 {
69 $created = array_column(self::all($userId), 'created');
70
71 return ['count' => count($created), 'newest' => $created ? (int) max($created) : 0];
72 }
73
74 /**
75 * @param integer $userId - The user the connection belongs to.
76 * @param string $id - The connection's id.
77 * @return boolean
78 */
79 public static function revoke($userId, $id)
80 {
81 if ($id === '') {
82 return false;
83 }
84
85 foreach (self::all($userId) as $connection) {
86 if ($connection['id'] === $id) {
87 return self::end($userId, $connection);
88 }
89 }
90
91 return false;
92 }
93
94 /**
95 * @param integer $userId - The user whose connections to end.
96 * @return void
97 */
98 public static function revokeAll($userId)
99 {
100 foreach (self::all($userId) as $connection) {
101 self::end($userId, $connection);
102 }
103 }
104
105 /**
106 * @param integer $userId - The user the connection belongs to.
107 * @param array $connection - The connection as all() lists it.
108 * @return boolean
109 */
110 private static function end($userId, array $connection)
111 {
112 Log::forget($userId, $connection['id']);
113
114 return (bool) \delete_user_meta((int) $userId, $connection['key']);
115 }
116
117 /**
118 * @param array $connection - The connection a request just arrived on.
119 * @return void
120 */
121 public static function touch(array $connection)
122 {
123 $data = $connection['data'];
124 $data['lastUsed'] = time();
125
126 // update_user_meta() would re-add a row that a revoke or a refresh deleted mid-call.
127 $wpdb = $GLOBALS['wpdb'];
128 $wpdb->update(
129 $wpdb->usermeta,
130 ['meta_value' => \maybe_serialize($data)],
131 ['user_id' => (int) $connection['userId'], 'meta_key' => $connection['metaKey']]
132 );
133 \wp_cache_delete((int) $connection['userId'], 'user_meta');
134 }
135
136 /**
137 * A clone copies the database and the salts, so the salt alone is not this site.
138 * home_url() is filtered per request, so it would hash one token two ways.
139 * Pinned to https so moving the site to https keeps its tokens.
140 *
141 * @return string
142 */
143 public static function secret()
144 {
145 return \wp_salt('auth') . '|' . \set_url_scheme(\get_option('home'), 'https');
146 }
147
148 /**
149 * A row minted under a changed salt or address otherwise reads as an unknown token.
150 *
151 * @return string
152 */
153 public static function fingerprint()
154 {
155 return substr(hash_hmac('sha256', 'connection', self::secret()), 0, 16);
156 }
157
158 /**
159 * A token minted on one site of a network must not reach another.
160 *
161 * @return string
162 */
163 public static function prefix()
164 {
165 return 'extendify_mcp_' . \get_current_blog_id() . '_';
166 }
167 }
168