PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.8
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.8
4.4.8 4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.9.1 4.3.9 4.3.8 4.3.7 4.1.6.9 4.1.6.9.1 4.1.6.9.2 4.1.6.9.3 4.1.6.9.4 4.1.7 4.1.7.1 4.1.7.2 4.1.7.3 4.1.7.3.1 4.1.7.3.2 4.2.0 All 139 releases
← All changes | inc/MCP/Auth/ApiKeysRepository.php +517 -517 4.3.94.4.8 View file →
@@ -1,517 +1,517 @@
1 -<?php
2 -
3 -namespace LearnPress\MCP\Auth;
4 -
5 -use LP_Database;
6 -use LP_Filter;
7 -use LP_Helper;
8 -use LearnPress\Databases\UserDB;
9 -use LearnPress\Filters\UserFilter;
10 -use Exception;
11 -
12 -defined( 'ABSPATH' ) || exit;
13 -
14 -/**
15 - * Handles persistence and credential lifecycle for LearnPress MCP API keys.
16 - */
17 -class ApiKeysRepository {
18 - /**
19 - * Allowed key permission values.
20 - */
21 - public const PERMISSIONS = array( 'read', 'write', 'read_write' );
22 -
23 - /**
24 - * @var \wpdb
25 - */
26 - protected $wpdb;
27 -
28 - /**
29 - * @var string
30 - */
31 - protected $table;
32 -
33 - /**
34 - * @var string
35 - */
36 - protected $users_table;
37 -
38 - /**
39 - * Initialize DB handles for MCP API key storage.
40 - *
41 - * @return void
42 - */
43 - public function __construct() {
44 - global $wpdb;
45 -
46 - $this->wpdb = $wpdb;
47 - $this->table = LP_Database::getInstance()->tb_lp_mcp_api_keys;
48 - $this->users_table = $wpdb->users;
49 - }
50 -
51 - /**
52 - * Create a new API key and return plaintext credentials once.
53 - *
54 - * @param int $user_id User ID that owns the key.
55 - * @param string $description Optional key description.
56 - * @param string $permissions Key permission.
57 - *
58 - * @return array<string, mixed>|null
59 - * @throws Exception
60 - */
61 - public function create_key( int $user_id, string $description = '', string $permissions = 'read' ): ?array {
62 - $user_id = absint( $user_id );
63 - if ( $user_id <= 0 || ! $this->is_valid_user_id( $user_id ) ) {
64 - return null;
65 - }
66 -
67 - $permissions = $this->normalize_permissions( $permissions );
68 - $description = $this->normalize_description( $description );
69 - $consumer_key = $this->generate_token( 'ck_' );
70 - $consumer_secret = $this->generate_token( 'cs_' );
71 - $created_at = current_time( 'mysql', true );
72 -
73 - $inserted = $this->wpdb->insert(
74 - $this->table,
75 - array(
76 - 'user_id' => $user_id,
77 - 'description' => $description,
78 - 'permissions' => $permissions,
79 - 'consumer_key' => self::hash_consumer_key( $consumer_key ),
80 - 'consumer_secret' => self::hash_consumer_secret( $consumer_secret ),
81 - 'truncated_key' => substr( $consumer_key, -7 ),
82 - 'created_at' => $created_at,
83 - ),
84 - array( '%d', '%s', '%s', '%s', '%s', '%s', '%s' )
85 - );
86 -
87 - if ( ! $inserted ) {
88 - return null;
89 - }
90 -
91 - return array(
92 - 'key_id' => (int) $this->wpdb->insert_id,
93 - 'user_id' => $user_id,
94 - 'description' => $description,
95 - 'permissions' => $permissions,
96 - 'consumer_key' => $consumer_key,
97 - 'consumer_secret' => $consumer_secret,
98 - 'truncated_key' => substr( $consumer_key, -7 ),
99 - 'created_at' => $created_at,
100 - );
101 - }
102 -
103 - /**
104 - * Update mutable metadata for an API key.
105 - *
106 - * @param int $key_id Key ID.
107 - * @param int $user_id New owner user ID.
108 - * @param string $description New key description.
109 - * @param string $permissions New key permissions.
110 - *
111 - * @return bool
112 - */
113 - public function update_key_meta( int $key_id, int $user_id, string $description, string $permissions ): bool {
114 - $key_id = absint( $key_id );
115 - $user_id = absint( $user_id );
116 -
117 - if ( $key_id <= 0 || $user_id <= 0 || ! $this->is_valid_user_id( $user_id ) ) {
118 - return false;
119 - }
120 -
121 - $updated = $this->wpdb->update(
122 - $this->table,
123 - array(
124 - 'user_id' => $user_id,
125 - 'description' => $this->normalize_description( $description ),
126 - 'permissions' => $this->normalize_permissions( $permissions ),
127 - 'updated_at' => current_time( 'mysql', true ),
128 - ),
129 - array( 'key_id' => $key_id ),
130 - array( '%d', '%s', '%s', '%s' ),
131 - array( '%d' )
132 - );
133 -
134 - return false !== $updated;
135 - }
136 -
137 - /**
138 - * Rotate consumer key and secret for an existing key.
139 - *
140 - * @param int $key_id Key ID.
141 - *
142 - * @return array<string, mixed>|null
143 - */
144 - public function regenerate_key( int $key_id ): ?array {
145 - $key_id = absint( $key_id );
146 - if ( $key_id <= 0 ) {
147 - return null;
148 - }
149 -
150 - $row = $this->get_key( $key_id );
151 - if ( ! $row ) {
152 - return null;
153 - }
154 -
155 - $consumer_key = $this->generate_token( 'ck_' );
156 - $consumer_secret = $this->generate_token( 'cs_' );
157 - $updated_at = current_time( 'mysql', true );
158 -
159 - $updated = $this->wpdb->update(
160 - $this->table,
161 - array(
162 - 'consumer_key' => self::hash_consumer_key( $consumer_key ),
163 - 'consumer_secret' => self::hash_consumer_secret( $consumer_secret ),
164 - 'truncated_key' => substr( $consumer_key, -7 ),
165 - 'updated_at' => $updated_at,
166 - ),
167 - array( 'key_id' => $key_id ),
168 - array( '%s', '%s', '%s', '%s' ),
169 - array( '%d' )
170 - );
171 -
172 - if ( false === $updated ) {
173 - return null;
174 - }
175 -
176 - return array(
177 - 'key_id' => $key_id,
178 - 'user_id' => (int) $row->user_id,
179 - 'description' => (string) $row->description,
180 - 'permissions' => (string) $row->permissions,
181 - 'consumer_key' => $consumer_key,
182 - 'consumer_secret' => $consumer_secret,
183 - 'truncated_key' => substr( $consumer_key, -7 ),
184 - 'updated_at' => $updated_at,
185 - );
186 - }
187 -
188 - /**
189 - * Revoke (delete) one key.
190 - *
191 - * @param int $key_id Key ID.
192 - *
193 - * @return bool
194 - */
195 - public function revoke_key( int $key_id ): bool {
196 - $key_id = absint( $key_id );
197 - if ( $key_id <= 0 ) {
198 - return false;
199 - }
200 -
201 - $deleted = $this->wpdb->delete( $this->table, array( 'key_id' => $key_id ), array( '%d' ) );
202 -
203 - return false !== $deleted;
204 - }
205 -
206 - /**
207 - * Revoke multiple keys.
208 - *
209 - * @param array<int, int|string> $key_ids Key IDs to revoke.
210 - *
211 - * @return int
212 - */
213 - public function revoke_keys( array $key_ids ): int {
214 - $key_ids = array_values( array_filter( array_map( 'absint', $key_ids ) ) );
215 - if ( empty( $key_ids ) ) {
216 - return 0;
217 - }
218 -
219 - $sql = $this->wpdb->prepare(
220 - "DELETE FROM {$this->table} WHERE key_id IN (" . LP_Helper::db_format_array( $key_ids, '%d' ) . ')',
221 - $key_ids
222 - );
223 -
224 - $deleted = $this->wpdb->query( $sql );
225 -
226 - return $deleted > 0 ? (int) $deleted : 0;
227 - }
228 -
229 - /**
230 - * Find key row by plaintext consumer key.
231 - *
232 - * @param string $consumer_key Plaintext consumer key.
233 - *
234 - * @return object|null
235 - */
236 - public function find_by_consumer_key( string $consumer_key ) {
237 - $consumer_key = LP_Helper::sanitize_params_submitted( $consumer_key );
238 - if ( '' === $consumer_key ) {
239 - return null;
240 - }
241 -
242 - $filter = new LP_Filter();
243 - $filter->collection = $this->table;
244 - $filter->collection_alias = 'k';
245 - $filter->only_fields = array( 'k.*' );
246 - $filter->where[] = $this->wpdb->prepare( 'AND k.consumer_key = %s', self::hash_consumer_key( $consumer_key ) );
247 - $filter->limit = 1;
248 - $filter->field_count = 'k.key_id';
249 - $filter->run_query_count = false;
250 -
251 - $total_rows = 0;
252 - $rows = LP_Database::getInstance()->execute( $filter, $total_rows );
253 - if ( ! is_array( $rows ) || empty( $rows ) ) {
254 - return null;
255 - }
256 -
257 - return reset( $rows );
258 - }
259 -
260 - /**
261 - * Get a key row by key ID.
262 - *
263 - * @param int $key_id Key ID.
264 - *
265 - * @return object|null
266 - */
267 - public function get_key( int $key_id ) {
268 - $key_id = absint( $key_id );
269 - if ( $key_id <= 0 ) {
270 - return null;
271 - }
272 -
273 - $filter = new LP_Filter();
274 - $filter->collection = $this->table;
275 - $filter->collection_alias = 'k';
276 - $filter->only_fields = array( 'k.*' );
277 - $filter->where[] = $this->wpdb->prepare( 'AND k.key_id = %d', $key_id );
278 - $filter->limit = 1;
279 - $filter->field_count = 'k.key_id';
280 - $filter->run_query_count = false;
281 -
282 - $total_rows = 0;
283 - $rows = LP_Database::getInstance()->execute( $filter, $total_rows );
284 - if ( ! is_array( $rows ) || empty( $rows ) ) {
285 - return null;
286 - }
287 -
288 - return reset( $rows );
289 - }
290 -
291 - /**
292 - * Check whether raw secret matches stored secret hash.
293 - *
294 - * @param string $stored_hash Stored secret hash from database.
295 - * @param string $provided_secret Raw secret provided by request.
296 - *
297 - * @return bool
298 - */
299 - public function verify_secret_hash( string $stored_hash, string $provided_secret ): bool {
300 - $provided_hash = self::hash_consumer_secret( $provided_secret );
301 -
302 - return hash_equals( $stored_hash, $provided_hash );
303 - }
304 -
305 - /**
306 - * Update key usage metrics.
307 - *
308 - * @param int $key_id Key ID.
309 - *
310 - * @return void
311 - */
312 - public function touch_usage( int $key_id ): void {
313 - $key_id = absint( $key_id );
314 - if ( $key_id <= 0 ) {
315 - return;
316 - }
317 -
318 - $now = current_time( 'mysql', true );
319 - $sql = $this->wpdb->prepare(
320 - "UPDATE {$this->table} SET last_access = %s, call_count = call_count + 1 WHERE key_id = %d",
321 - $now,
322 - $key_id
323 - );
324 -
325 - $this->wpdb->query( $sql );
326 - }
327 -
328 - /**
329 - * Query keys list for admin table.
330 - *
331 - * @param array<string, mixed> $args Query arguments.
332 - *
333 - * @return array<string, mixed>
334 - */
335 - public function query_keys( array $args = array() ): array {
336 - $args = wp_parse_args(
337 - $args,
338 - array(
339 - 'page' => 1,
340 - 'per_page' => 20,
341 - 'search' => '',
342 - 'user_id' => 0,
343 - 'orderby' => 'created_at',
344 - 'order' => 'DESC',
345 - )
346 - );
347 -
348 - $page = max( 1, absint( $args['page'] ) );
349 - $per_page = max( 1, min( 100, absint( $args['per_page'] ) ) );
350 -
351 - $filter = new LP_Filter();
352 - $filter->collection = $this->table;
353 - $filter->collection_alias = 'k';
354 - $filter->only_fields = array(
355 - 'k.*',
356 - 'u.display_name AS user_display_name',
357 - 'u.user_login',
358 - );
359 - $filter->join[] = "LEFT JOIN {$this->users_table} u ON u.ID = k.user_id";
360 - $filter->field_count = 'k.key_id';
361 - $filter->limit = $per_page;
362 - $filter->page = $page;
363 -
364 - $search = LP_Helper::sanitize_params_submitted( (string) $args['search'] );
365 - if ( '' !== $search ) {
366 - $filter->where[] = $this->wpdb->prepare( 'AND k.description LIKE %s', '%' . $this->wpdb->esc_like( $search ) . '%' );
367 - }
368 -
369 - $user_id = absint( $args['user_id'] );
370 - if ( $user_id > 0 ) {
371 - $filter->where[] = $this->wpdb->prepare( 'AND k.user_id = %d', $user_id );
372 - }
373 -
374 - $order_by_map = array(
375 - 'description' => 'k.description',
376 - 'user' => 'u.display_name',
377 - 'permissions' => 'k.permissions',
378 - 'last_access' => 'k.last_access',
379 - 'call_count' => 'k.call_count',
380 - 'created_at' => 'k.created_at',
381 - );
382 - $filter->order_by = $order_by_map[ $args['orderby'] ] ?? $order_by_map['created_at'];
383 - $filter->order = 'ASC' === strtoupper( (string) $args['order'] ) ? LP_Filter::ORDER_ASC : LP_Filter::ORDER_DESC;
384 -
385 - $total_items = 0;
386 - $items = LP_Database::getInstance()->execute( $filter, $total_items );
387 -
388 - return array(
389 - 'items' => is_array( $items ) ? $items : array(),
390 - 'total' => $total_items,
391 - 'page' => $page,
392 - 'per_page' => $per_page,
393 - );
394 - }
395 -
396 - /**
397 - * Return users currently owning MCP API keys.
398 - *
399 - * @return array<int, object>
400 - */
401 - public function users_with_keys(): array {
402 - $filter = new LP_Filter();
403 - $filter->collection = $this->users_table;
404 - $filter->collection_alias = 'u';
405 - $filter->only_fields = array(
406 - 'u.ID',
407 - 'u.user_login',
408 - 'u.display_name',
409 - );
410 - $filter->join[] = "INNER JOIN {$this->table} k ON u.ID = k.user_id";
411 - $filter->group_by = 'u.ID';
412 - $filter->order_by = 'u.display_name';
413 - $filter->order = LP_Filter::ORDER_ASC;
414 - $filter->limit = -1;
415 - $filter->run_query_count = false;
416 -
417 - $total_rows = 0;
418 - $rows = LP_Database::getInstance()->execute( $filter, $total_rows );
419 -
420 - return is_array( $rows ) ? $rows : array();
421 - }
422 -
423 - /**
424 - * Hash plaintext consumer key for storage/lookup.
425 - *
426 - * @param string $consumer_key Plaintext consumer key.
427 - *
428 - * @return string
429 - */
430 - public static function hash_consumer_key( string $consumer_key ): string {
431 - return hash_hmac( 'sha256', $consumer_key, 'lp-mcp-api' );
432 - }
433 -
434 - /**
435 - * Hash plaintext consumer secret for storage/verification.
436 - *
437 - * @param string $consumer_secret Plaintext consumer secret.
438 - *
439 - * @return string
440 - */
441 - public static function hash_consumer_secret( string $consumer_secret ): string {
442 - return hash_hmac( 'sha256', $consumer_secret, 'lp-mcp-secret' );
443 - }
444 -
445 - /**
446 - * Normalize description for DB storage.
447 - *
448 - * @param string $description Raw key description.
449 - *
450 - * @return string
451 - */
452 - protected function normalize_description( string $description ): string {
453 - $description = LP_Helper::sanitize_params_submitted( $description );
454 -
455 - return function_exists( 'mb_substr' ) ? mb_substr( $description, 0, 200 ) : substr( $description, 0, 200 );
456 - }
457 -
458 - /**
459 - * Normalize requested permission to a supported value.
460 - *
461 - * @param string $permissions Raw requested permission.
462 - *
463 - * @return string
464 - */
465 - protected function normalize_permissions( string $permissions ): string {
466 - $permissions = LP_Helper::sanitize_params_submitted( $permissions, 'key' );
467 -
468 - if ( ! in_array( $permissions, self::PERMISSIONS, true ) ) {
469 - $permissions = 'read';
470 - }
471 -
472 - return $permissions;
473 - }
474 -
475 - /**
476 - * Generate token in ck_/cs_ format.
477 - *
478 - * @param string $prefix Token prefix (`ck_` or `cs_`).
479 - *
480 - * @return string
481 - */
482 - protected function generate_token( string $prefix ): string {
483 - try {
484 - $hex = bin2hex( random_bytes( 20 ) );
485 - } catch ( Exception $e ) {
486 - $hex = substr( hash( 'sha256', wp_generate_password( 64, true, true ) . microtime( true ) ), 0, 40 );
487 - }
488 -
489 - return $prefix . $hex;
490 - }
491 -
492 - /**
493 - * Validate user existence via LearnPress DB + Filter classes.
494 - *
495 - * @param int $user_id User ID.
496 - *
497 - * @return bool
498 - * @throws Exception
499 - */
500 - protected function is_valid_user_id( int $user_id ): bool {
501 - $user_id = absint( $user_id );
502 - if ( $user_id <= 0 ) {
503 - return false;
504 - }
505 -
506 - $filter = new UserFilter();
507 - $filter->ID = $user_id;
508 - $filter->limit = 1;
509 - $filter->only_fields = array( 'u.ID' );
510 - $filter->field_count = 'u.ID';
511 -
512 - $total_rows = 0;
513 - $rows = UserDB::getInstance()->get_users( $filter, $total_rows );
514 -
515 - return is_array( $rows ) && ! empty( $rows );
516 - }
517 -}
1 +<?php
2 +
3 +namespace LearnPress\MCP\Auth;
4 +
5 +use LP_Database;
6 +use LP_Filter;
7 +use LP_Helper;
8 +use LearnPress\Databases\UserDB;
9 +use LearnPress\Filters\UserFilter;
10 +use Exception;
11 +
12 +defined( 'ABSPATH' ) || exit;
13 +
14 +/**
15 + * Handles persistence and credential lifecycle for LearnPress MCP API keys.
16 + */
17 +class ApiKeysRepository {
18 + /**
19 + * Allowed key permission values.
20 + */
21 + public const PERMISSIONS = array( 'read', 'write', 'read_write' );
22 +
23 + /**
24 + * @var \wpdb
25 + */
26 + protected $wpdb;
27 +
28 + /**
29 + * @var string
30 + */
31 + protected $table;
32 +
33 + /**
34 + * @var string
35 + */
36 + protected $users_table;
37 +
38 + /**
39 + * Initialize DB handles for MCP API key storage.
40 + *
41 + * @return void
42 + */
43 + public function __construct() {
44 + global $wpdb;
45 +
46 + $this->wpdb = $wpdb;
47 + $this->table = LP_Database::getInstance()->tb_lp_mcp_api_keys;
48 + $this->users_table = $wpdb->users;
49 + }
50 +
51 + /**
52 + * Create a new API key and return plaintext credentials once.
53 + *
54 + * @param int $user_id User ID that owns the key.
55 + * @param string $description Optional key description.
56 + * @param string $permissions Key permission.
57 + *
58 + * @return array<string, mixed>|null
59 + * @throws Exception
60 + */
61 + public function create_key( int $user_id, string $description = '', string $permissions = 'read' ): ?array {
62 + $user_id = absint( $user_id );
63 + if ( $user_id <= 0 || ! $this->is_valid_user_id( $user_id ) ) {
64 + return null;
65 + }
66 +
67 + $permissions = $this->normalize_permissions( $permissions );
68 + $description = $this->normalize_description( $description );
69 + $consumer_key = $this->generate_token( 'ck_' );
70 + $consumer_secret = $this->generate_token( 'cs_' );
71 + $created_at = current_time( 'mysql', true );
72 +
73 + $inserted = $this->wpdb->insert(
74 + $this->table,
75 + array(
76 + 'user_id' => $user_id,
77 + 'description' => $description,
78 + 'permissions' => $permissions,
79 + 'consumer_key' => self::hash_consumer_key( $consumer_key ),
80 + 'consumer_secret' => self::hash_consumer_secret( $consumer_secret ),
81 + 'truncated_key' => substr( $consumer_key, -7 ),
82 + 'created_at' => $created_at,
83 + ),
84 + array( '%d', '%s', '%s', '%s', '%s', '%s', '%s' )
85 + );
86 +
87 + if ( ! $inserted ) {
88 + return null;
89 + }
90 +
91 + return array(
92 + 'key_id' => (int) $this->wpdb->insert_id,
93 + 'user_id' => $user_id,
94 + 'description' => $description,
95 + 'permissions' => $permissions,
96 + 'consumer_key' => $consumer_key,
97 + 'consumer_secret' => $consumer_secret,
98 + 'truncated_key' => substr( $consumer_key, -7 ),
99 + 'created_at' => $created_at,
100 + );
101 + }
102 +
103 + /**
104 + * Update mutable metadata for an API key.
105 + *
106 + * @param int $key_id Key ID.
107 + * @param int $user_id New owner user ID.
108 + * @param string $description New key description.
109 + * @param string $permissions New key permissions.
110 + *
111 + * @return bool
112 + */
113 + public function update_key_meta( int $key_id, int $user_id, string $description, string $permissions ): bool {
114 + $key_id = absint( $key_id );
115 + $user_id = absint( $user_id );
116 +
117 + if ( $key_id <= 0 || $user_id <= 0 || ! $this->is_valid_user_id( $user_id ) ) {
118 + return false;
119 + }
120 +
121 + $updated = $this->wpdb->update(
122 + $this->table,
123 + array(
124 + 'user_id' => $user_id,
125 + 'description' => $this->normalize_description( $description ),
126 + 'permissions' => $this->normalize_permissions( $permissions ),
127 + 'updated_at' => current_time( 'mysql', true ),
128 + ),
129 + array( 'key_id' => $key_id ),
130 + array( '%d', '%s', '%s', '%s' ),
131 + array( '%d' )
132 + );
133 +
134 + return false !== $updated;
135 + }
136 +
137 + /**
138 + * Rotate consumer key and secret for an existing key.
139 + *
140 + * @param int $key_id Key ID.
141 + *
142 + * @return array<string, mixed>|null
143 + */
144 + public function regenerate_key( int $key_id ): ?array {
145 + $key_id = absint( $key_id );
146 + if ( $key_id <= 0 ) {
147 + return null;
148 + }
149 +
150 + $row = $this->get_key( $key_id );
151 + if ( ! $row ) {
152 + return null;
153 + }
154 +
155 + $consumer_key = $this->generate_token( 'ck_' );
156 + $consumer_secret = $this->generate_token( 'cs_' );
157 + $updated_at = current_time( 'mysql', true );
158 +
159 + $updated = $this->wpdb->update(
160 + $this->table,
161 + array(
162 + 'consumer_key' => self::hash_consumer_key( $consumer_key ),
163 + 'consumer_secret' => self::hash_consumer_secret( $consumer_secret ),
164 + 'truncated_key' => substr( $consumer_key, -7 ),
165 + 'updated_at' => $updated_at,
166 + ),
167 + array( 'key_id' => $key_id ),
168 + array( '%s', '%s', '%s', '%s' ),
169 + array( '%d' )
170 + );
171 +
172 + if ( false === $updated ) {
173 + return null;
174 + }
175 +
176 + return array(
177 + 'key_id' => $key_id,
178 + 'user_id' => (int) $row->user_id,
179 + 'description' => (string) $row->description,
180 + 'permissions' => (string) $row->permissions,
181 + 'consumer_key' => $consumer_key,
182 + 'consumer_secret' => $consumer_secret,
183 + 'truncated_key' => substr( $consumer_key, -7 ),
184 + 'updated_at' => $updated_at,
185 + );
186 + }
187 +
188 + /**
189 + * Revoke (delete) one key.
190 + *
191 + * @param int $key_id Key ID.
192 + *
193 + * @return bool
194 + */
195 + public function revoke_key( int $key_id ): bool {
196 + $key_id = absint( $key_id );
197 + if ( $key_id <= 0 ) {
198 + return false;
199 + }
200 +
201 + $deleted = $this->wpdb->delete( $this->table, array( 'key_id' => $key_id ), array( '%d' ) );
202 +
203 + return false !== $deleted;
204 + }
205 +
206 + /**
207 + * Revoke multiple keys.
208 + *
209 + * @param array<int, int|string> $key_ids Key IDs to revoke.
210 + *
211 + * @return int
212 + */
213 + public function revoke_keys( array $key_ids ): int {
214 + $key_ids = array_values( array_filter( array_map( 'absint', $key_ids ) ) );
215 + if ( empty( $key_ids ) ) {
216 + return 0;
217 + }
218 +
219 + $sql = $this->wpdb->prepare(
220 + "DELETE FROM {$this->table} WHERE key_id IN (" . LP_Helper::db_format_array( $key_ids, '%d' ) . ')',
221 + $key_ids
222 + );
223 +
224 + $deleted = $this->wpdb->query( $sql );
225 +
226 + return $deleted > 0 ? (int) $deleted : 0;
227 + }
228 +
229 + /**
230 + * Find key row by plaintext consumer key.
231 + *
232 + * @param string $consumer_key Plaintext consumer key.
233 + *
234 + * @return object|null
235 + */
236 + public function find_by_consumer_key( string $consumer_key ) {
237 + $consumer_key = LP_Helper::sanitize_params_submitted( $consumer_key );
238 + if ( '' === $consumer_key ) {
239 + return null;
240 + }
241 +
242 + $filter = new LP_Filter();
243 + $filter->collection = $this->table;
244 + $filter->collection_alias = 'k';
245 + $filter->only_fields = array( 'k.*' );
246 + $filter->where[] = $this->wpdb->prepare( 'AND k.consumer_key = %s', self::hash_consumer_key( $consumer_key ) );
247 + $filter->limit = 1;
248 + $filter->field_count = 'k.key_id';
249 + $filter->run_query_count = false;
250 +
251 + $total_rows = 0;
252 + $rows = LP_Database::getInstance()->execute( $filter, $total_rows );
253 + if ( ! is_array( $rows ) || empty( $rows ) ) {
254 + return null;
255 + }
256 +
257 + return reset( $rows );
258 + }
259 +
260 + /**
261 + * Get a key row by key ID.
262 + *
263 + * @param int $key_id Key ID.
264 + *
265 + * @return object|null
266 + */
267 + public function get_key( int $key_id ) {
268 + $key_id = absint( $key_id );
269 + if ( $key_id <= 0 ) {
270 + return null;
271 + }
272 +
273 + $filter = new LP_Filter();
274 + $filter->collection = $this->table;
275 + $filter->collection_alias = 'k';
276 + $filter->only_fields = array( 'k.*' );
277 + $filter->where[] = $this->wpdb->prepare( 'AND k.key_id = %d', $key_id );
278 + $filter->limit = 1;
279 + $filter->field_count = 'k.key_id';
280 + $filter->run_query_count = false;
281 +
282 + $total_rows = 0;
283 + $rows = LP_Database::getInstance()->execute( $filter, $total_rows );
284 + if ( ! is_array( $rows ) || empty( $rows ) ) {
285 + return null;
286 + }
287 +
288 + return reset( $rows );
289 + }
290 +
291 + /**
292 + * Check whether raw secret matches stored secret hash.
293 + *
294 + * @param string $stored_hash Stored secret hash from database.
295 + * @param string $provided_secret Raw secret provided by request.
296 + *
297 + * @return bool
298 + */
299 + public function verify_secret_hash( string $stored_hash, string $provided_secret ): bool {
300 + $provided_hash = self::hash_consumer_secret( $provided_secret );
301 +
302 + return hash_equals( $stored_hash, $provided_hash );
303 + }
304 +
305 + /**
306 + * Update key usage metrics.
307 + *
308 + * @param int $key_id Key ID.
309 + *
310 + * @return void
311 + */
312 + public function touch_usage( int $key_id ): void {
313 + $key_id = absint( $key_id );
314 + if ( $key_id <= 0 ) {
315 + return;
316 + }
317 +
318 + $now = current_time( 'mysql', true );
319 + $sql = $this->wpdb->prepare(
320 + "UPDATE {$this->table} SET last_access = %s, call_count = call_count + 1 WHERE key_id = %d",
321 + $now,
322 + $key_id
323 + );
324 +
325 + $this->wpdb->query( $sql );
326 + }
327 +
328 + /**
329 + * Query keys list for admin table.
330 + *
331 + * @param array<string, mixed> $args Query arguments.
332 + *
333 + * @return array<string, mixed>
334 + */
335 + public function query_keys( array $args = array() ): array {
336 + $args = wp_parse_args(
337 + $args,
338 + array(
339 + 'page' => 1,
340 + 'per_page' => 20,
341 + 'search' => '',
342 + 'user_id' => 0,
343 + 'orderby' => 'created_at',
344 + 'order' => 'DESC',
345 + )
346 + );
347 +
348 + $page = max( 1, absint( $args['page'] ) );
349 + $per_page = max( 1, min( 100, absint( $args['per_page'] ) ) );
350 +
351 + $filter = new LP_Filter();
352 + $filter->collection = $this->table;
353 + $filter->collection_alias = 'k';
354 + $filter->only_fields = array(
355 + 'k.*',
356 + 'u.display_name AS user_display_name',
357 + 'u.user_login',
358 + );
359 + $filter->join[] = "LEFT JOIN {$this->users_table} u ON u.ID = k.user_id";
360 + $filter->field_count = 'k.key_id';
361 + $filter->limit = $per_page;
362 + $filter->page = $page;
363 +
364 + $search = LP_Helper::sanitize_params_submitted( (string) $args['search'] );
365 + if ( '' !== $search ) {
366 + $filter->where[] = $this->wpdb->prepare( 'AND k.description LIKE %s', '%' . $this->wpdb->esc_like( $search ) . '%' );
367 + }
368 +
369 + $user_id = absint( $args['user_id'] );
370 + if ( $user_id > 0 ) {
371 + $filter->where[] = $this->wpdb->prepare( 'AND k.user_id = %d', $user_id );
372 + }
373 +
374 + $order_by_map = array(
375 + 'description' => 'k.description',
376 + 'user' => 'u.display_name',
377 + 'permissions' => 'k.permissions',
378 + 'last_access' => 'k.last_access',
379 + 'call_count' => 'k.call_count',
380 + 'created_at' => 'k.created_at',
381 + );
382 + $filter->order_by = $order_by_map[ $args['orderby'] ] ?? $order_by_map['created_at'];
383 + $filter->order = 'ASC' === strtoupper( (string) $args['order'] ) ? LP_Filter::ORDER_ASC : LP_Filter::ORDER_DESC;
384 +
385 + $total_items = 0;
386 + $items = LP_Database::getInstance()->execute( $filter, $total_items );
387 +
388 + return array(
389 + 'items' => is_array( $items ) ? $items : array(),
390 + 'total' => $total_items,
391 + 'page' => $page,
392 + 'per_page' => $per_page,
393 + );
394 + }
395 +
396 + /**
397 + * Return users currently owning MCP API keys.
398 + *
399 + * @return array<int, object>
400 + */
401 + public function users_with_keys(): array {
402 + $filter = new LP_Filter();
403 + $filter->collection = $this->users_table;
404 + $filter->collection_alias = 'u';
405 + $filter->only_fields = array(
406 + 'u.ID',
407 + 'u.user_login',
408 + 'u.display_name',
409 + );
410 + $filter->join[] = "INNER JOIN {$this->table} k ON u.ID = k.user_id";
411 + $filter->group_by = 'u.ID';
412 + $filter->order_by = 'u.display_name';
413 + $filter->order = LP_Filter::ORDER_ASC;
414 + $filter->limit = -1;
415 + $filter->run_query_count = false;
416 +
417 + $total_rows = 0;
418 + $rows = LP_Database::getInstance()->execute( $filter, $total_rows );
419 +
420 + return is_array( $rows ) ? $rows : array();
421 + }
422 +
423 + /**
424 + * Hash plaintext consumer key for storage/lookup.
425 + *
426 + * @param string $consumer_key Plaintext consumer key.
427 + *
428 + * @return string
429 + */
430 + public static function hash_consumer_key( string $consumer_key ): string {
431 + return hash_hmac( 'sha256', $consumer_key, 'lp-mcp-api' );
432 + }
433 +
434 + /**
435 + * Hash plaintext consumer secret for storage/verification.
436 + *
437 + * @param string $consumer_secret Plaintext consumer secret.
438 + *
439 + * @return string
440 + */
441 + public static function hash_consumer_secret( string $consumer_secret ): string {
442 + return hash_hmac( 'sha256', $consumer_secret, 'lp-mcp-secret' );
443 + }
444 +
445 + /**
446 + * Normalize description for DB storage.
447 + *
448 + * @param string $description Raw key description.
449 + *
450 + * @return string
451 + */
452 + protected function normalize_description( string $description ): string {
453 + $description = LP_Helper::sanitize_params_submitted( $description );
454 +
455 + return function_exists( 'mb_substr' ) ? mb_substr( $description, 0, 200 ) : substr( $description, 0, 200 );
456 + }
457 +
458 + /**
459 + * Normalize requested permission to a supported value.
460 + *
461 + * @param string $permissions Raw requested permission.
462 + *
463 + * @return string
464 + */
465 + protected function normalize_permissions( string $permissions ): string {
466 + $permissions = LP_Helper::sanitize_params_submitted( $permissions, 'key' );
467 +
468 + if ( ! in_array( $permissions, self::PERMISSIONS, true ) ) {
469 + $permissions = 'read';
470 + }
471 +
472 + return $permissions;
473 + }
474 +
475 + /**
476 + * Generate token in ck_/cs_ format.
477 + *
478 + * @param string $prefix Token prefix (`ck_` or `cs_`).
479 + *
480 + * @return string
481 + */
482 + protected function generate_token( string $prefix ): string {
483 + try {
484 + $hex = bin2hex( random_bytes( 20 ) );
485 + } catch ( Exception $e ) {
486 + $hex = substr( hash( 'sha256', wp_generate_password( 64, true, true ) . microtime( true ) ), 0, 40 );
487 + }
488 +
489 + return $prefix . $hex;
490 + }
491 +
492 + /**
493 + * Validate user existence via LearnPress DB + Filter classes.
494 + *
495 + * @param int $user_id User ID.
496 + *
497 + * @return bool
498 + * @throws Exception
499 + */
500 + protected function is_valid_user_id( int $user_id ): bool {
501 + $user_id = absint( $user_id );
502 + if ( $user_id <= 0 ) {
503 + return false;
504 + }
505 +
506 + $filter = new UserFilter();
507 + $filter->ID = $user_id;
508 + $filter->limit = 1;
509 + $filter->only_fields = array( 'u.ID' );
510 + $filter->field_count = 'u.ID';
511 +
512 + $total_rows = 0;
513 + $rows = UserDB::getInstance()->get_users( $filter, $total_rows );
514 +
515 + return is_array( $rows ) && ! empty( $rows );
516 + }
517 +}