PluginProbe
Code Snippets / 3.10.0
Code Snippets v3.10.0
3.10.2 3.10.1 3.10.0 3.10.0-beta.2 3.10.0-beta.1 4.0.0-beta.1 3.9.6 trunk 2.10.0 2.10.1 2.12.0 2.12.1 2.13.0 2.13.1 2.13.2 2.13.3 2.14.0 2.14.1 2.14.2 2.14.3 2.14.4 2.14.5 2.14.6 3.0.0 3.0.1 All 64 releases
code-snippets / php / Client / Cloud_API.php

Cloud_API.php in Code Snippets 3.10.0, at php/Client/Cloud_API.php

598 lines 16.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Code_Snippets\Client;
4
5 use Code_Snippets\Model\Cloud_Snippet;
6 use Code_Snippets\Model\Snippet;
7 use WP_Error;
8 use Code_Snippets\Model\Cloud_Link;
9 use Code_Snippets\Model\Cloud_Snippets;
10 use function Code_Snippets\get_snippet_by_cloud_id;
11 use function Code_Snippets\get_snippets;
12 use function Code_Snippets\save_snippet;
13 use function Code_Snippets\update_snippet_fields;
14
15 /**
16 * Functions used to manage cloud synchronisation.
17 *
18 * @package Code_Snippets
19 */
20 class Cloud_API {
21
22 /**
23 * Maximum number of cloud search results allowed per page.
24 */
25 public const MAX_RESULTS_PER_PAGE = 100;
26
27 /**
28 * Request timeout, in seconds, for the cloud search endpoint. Higher than WordPress's 5s
29 * default because search can be slow on a cold cache and would otherwise time out.
30 */
31 private const SEARCH_REQUEST_TIMEOUT = 15;
32
33 /**
34 * Key used to access the local-to-cloud map transient data.
35 */
36 private const CLOUD_MAP_TRANSIENT_KEY = 'cs_local_to_cloud_map';
37
38 /**
39 * Days to cache data retrieved from API.
40 */
41 private const DAYS_TO_STORE_CS = 1;
42
43 /**
44 * Token used for public API access.
45 *
46 * @var string
47 */
48 private const CLOUD_SEARCH_API_TOKEN = 'csc-1a2b3c4d5e6f7g8h9i0j';
49
50 /**
51 * Option key holding the current featured-snippets cache version.
52 *
53 * Bumped on flush so old transient keys become unreachable and expire naturally.
54 */
55 private const FEATURED_VERSION_OPTION = 'cs_featured_cache_version';
56
57 /**
58 * Transient key for cached cloud types (languages).
59 */
60 private const TYPES_TRANSIENT_KEY = 'cs_cloud_types';
61
62 /**
63 * Transient key for cached cloud categories.
64 */
65 private const CATEGORIES_TRANSIENT_KEY = 'cs_cloud_categories';
66
67 /**
68 * Transient key for cached featured snippets.
69 */
70 private const FEATURED_TRANSIENT_KEY = 'cs_featured_snippets';
71
72 /**
73 * Minimum TTL in seconds for the featured snippets transient.
74 */
75 private const FEATURED_MIN_TTL = 3600;
76
77 /**
78 * Cached list of cloud links.
79 *
80 * @var Cloud_Link[]|null
81 */
82 private ?array $cached_cloud_links = null;
83
84 /**
85 * Base URL for Code Snippets Cloud.
86 *
87 * @var string
88 */
89 private string $cloud_url;
90
91 /**
92 * Base URL for Code Snippets Cloud API.
93 *
94 * @var string
95 */
96 private string $cloud_api_url;
97
98 /**
99 * Class constructor.
100 *
101 * @noinspection PhpUndefinedConstantInspection
102 */
103 public function __construct() {
104 $this->cloud_url = defined( 'CS_CLOUD_URL' )
105 ? untrailingslashit( CS_CLOUD_URL )
106 : 'https://codesnippets.cloud';
107
108 $this->cloud_api_url = defined( 'CS_CLOUD_API_URL' )
109 ? untrailingslashit( CS_CLOUD_API_URL )
110 : sprintf( '%s/api/v1', $this->cloud_url );
111 }
112
113 /**
114 * Retrieve base URL for Code Snippets Cloud.
115 *
116 * @return string
117 */
118 public function get_cloud_url(): string {
119 return $this->cloud_url;
120 }
121
122 /**
123 * Retrieve base URL for Code Snippets Cloud API.
124 *
125 * @return string
126 */
127 public function get_cloud_api_url(): string {
128 return $this->cloud_api_url;
129 }
130
131 /**
132 * Retrieve the cloud local token.
133 *
134 * @return string
135 */
136 public function get_local_token(): string {
137 return self::CLOUD_SEARCH_API_TOKEN;
138 }
139
140 /**
141 * Check if the API key is set and verified.
142 *
143 * @return bool
144 */
145 public function is_cloud_connection_available(): bool {
146 return false;
147 }
148
149 /**
150 * Unpack JSON data from a request response.
151 *
152 * @param array|WP_Error $response Response from wp_request_*.
153 *
154 * @return array<string, mixed>|null Associative array of JSON data on success, null on failure.
155 */
156 private function unpack_request_json( $response ): ?array {
157 $body = wp_remote_retrieve_body( $response );
158
159 if ( ! $body ) {
160 return null;
161 }
162
163 $json = json_decode( $body, true );
164
165 // Return the whole decoded envelope; each caller extracts the key it needs (search reads
166 // `snippets`/`meta`/`available_filters`, single reads `snippet`, taxonomy reads `data`).
167 return is_array( $json ) ? $json : null;
168 }
169
170 /**
171 * Create local-to-cloud map to keep track of local snippets that have been synced to the cloud.
172 *
173 * @return Cloud_Link[]|null
174 */
175 private function get_cloud_links(): ?array {
176 // Return the cached data if available.
177 if ( is_array( $this->cached_cloud_links ) ) {
178 return $this->cached_cloud_links;
179 }
180
181 // Fetch data from the stored transient, if available.
182 $transient_data = get_transient( self::CLOUD_MAP_TRANSIENT_KEY );
183 if ( is_array( $transient_data ) ) {
184 $this->cached_cloud_links = $transient_data;
185 return $this->cached_cloud_links;
186 }
187
188 // Otherwise, regenerate the local-to-cloud-map.
189 $this->cached_cloud_links = [];
190
191 // Fetch and iterate through all local snippets to create the map.
192 foreach ( get_snippets() as $local_snippet ) {
193 // Skip snippets that are only stored locally.
194 if ( ! $local_snippet->cloud_id ) {
195 continue;
196 }
197
198 $link = new Cloud_Link();
199 $cloud_id_owner = $this->get_cloud_id_and_ownership( $local_snippet->cloud_id );
200 $cloud_id_int = intval( $cloud_id_owner['cloud_id'] );
201 $link->local_id = $local_snippet->id;
202 $link->cloud_id = $cloud_id_int;
203 $link->is_owner = $cloud_id_owner['is_owner'];
204 // Check if cloud id exists in cloud_id_rev array - this shows if the snippet is in the codevault.
205 $link->in_codevault = $cloud_id_rev[ $cloud_id_int ] ?? false;
206
207 // Get the cloud snippet revision if in codevault get from cloud_id_rev array otherwise get from cloud.
208 if ( $link->in_codevault ) {
209 $cloud_snippet_revision = $cloud_id_rev[ $cloud_id_int ] ?? $this->get_cloud_snippet_revision( $local_snippet->cloud_id );
210 $link->update_available = $local_snippet->revision < $cloud_snippet_revision;
211 }
212
213 $this->cached_cloud_links[] = $link;
214 }
215
216 set_transient(
217 self::CLOUD_MAP_TRANSIENT_KEY,
218 $this->cached_cloud_links,
219 DAY_IN_SECONDS * self::DAYS_TO_STORE_CS
220 );
221
222 return $this->cached_cloud_links;
223 }
224
225 /**
226 * Get ownership and Cloud ID of a snippet.
227 *
228 * @param string $cloud_id Cloud ID.
229 *
230 * @return array<string, mixed>
231 */
232 public function get_cloud_id_and_ownership( string $cloud_id ): array {
233 $cloud_id_owner = explode( '_', $cloud_id );
234
235 return [
236 'cloud_id' => (int) $cloud_id_owner[0] ?? '',
237 'is_owner' => isset( $cloud_id_owner[1] ) && $cloud_id_owner[1],
238 'is_owner_string' => isset( $cloud_id_owner[1] ) && $cloud_id_owner[1] ? '1' : '0',
239 ];
240 }
241
242 /**
243 * Search Code Snippets Cloud.
244 *
245 * @param string $search_method Search by name of codevault or keyword(s).
246 * @param string $search Search query.
247 * @param int $page Search result page to retrieve. Defaults to '1'.
248 * @param int $per_page Number of search results to retrieve per page.
249 * @param array<string,string> $filters Optional filters: category, type, status.
250 *
251 * @return Cloud_Snippets Result of search query.
252 */
253 public function fetch_search_results( string $search_method, string $search, int $page = 1, int $per_page = 10, array $filters = [] ): Cloud_Snippets {
254 $per_page = min( self::MAX_RESULTS_PER_PAGE, max( 1, $per_page ) );
255
256 $params = [
257 's_method' => $search_method,
258 's' => $search,
259 'page' => max( 0, $page - 1 ),
260 'per_page' => $per_page,
261 'site_token' => self::get_local_token(),
262 'site_host' => wp_parse_url( get_site_url(), PHP_URL_HOST ),
263 ];
264
265 foreach ( [ 'category', 'type', 'status' ] as $key ) {
266 if ( ! empty( $filters[ $key ] ) ) {
267 $params[ $key ] = $filters[ $key ];
268 }
269 }
270
271 $api_url = add_query_arg( $params, sprintf( '%s/public/search', $this->get_cloud_api_url() ) );
272
273 // The search endpoint can be slow on a cold cache; allow more time than WordPress's
274 // default 5s request timeout so the request is not cut short and returned as empty.
275 $response = wp_remote_get( $api_url, [ 'timeout' => self::SEARCH_REQUEST_TIMEOUT ] );
276
277 $json = self::unpack_request_json( $response );
278
279 // Pass the full response envelope to Cloud_Snippets, which reads the `data`/`snippets`,
280 // `meta` and `available_filters` keys. Passing only the unpacked `data` list (as before)
281 // dropped the metadata, so the result normalised to an empty set.
282 if ( ! $json ) {
283 return new Cloud_Snippets();
284 }
285
286 $results = new Cloud_Snippets( $json );
287 $results->page = $page;
288
289 return $results;
290 }
291
292 /**
293 * Add a new link item to the local-to-cloud map.
294 *
295 * @param Cloud_Link $link Link to add.
296 *
297 * @return void
298 */
299 public function add_cloud_link( Cloud_Link $link ) {
300 $local_to_cloud_map = get_transient( self::CLOUD_MAP_TRANSIENT_KEY );
301 $local_to_cloud_map[] = $link;
302
303 set_transient(
304 self::CLOUD_MAP_TRANSIENT_KEY,
305 $local_to_cloud_map,
306 DAY_IN_SECONDS * self::DAYS_TO_STORE_CS
307 );
308 }
309
310 /**
311 * Delete a snippet from local-to-cloud map.
312 *
313 * @param int $snippet_id Local snippet ID.
314 *
315 * @return void
316 */
317 public function delete_snippet_from_transient_data( int $snippet_id ) {
318 $cloud_links = $this->get_cloud_links();
319
320 foreach ( $cloud_links as $link ) {
321 if ( $link->local_id === $snippet_id ) {
322 // Remove the link from the local_to_cloud_map.
323 $index = array_search( $link, $cloud_links, true );
324 unset( $cloud_links[ $index ] );
325 }
326 }
327
328 // Update the transient data.
329 set_transient(
330 self::CLOUD_MAP_TRANSIENT_KEY,
331 $cloud_links,
332 DAY_IN_SECONDS * self::DAYS_TO_STORE_CS
333 );
334
335 $this->cached_cloud_links = $cloud_links;
336 }
337
338 /**
339 * Retrieve a single cloud snippet from the API.
340 *
341 * @param int $cloud_id Remote cloud snippet ID.
342 *
343 * @return Cloud_Snippet Retrieved snippet.
344 */
345 public function get_single_snippet_from_cloud( int $cloud_id ): Cloud_Snippet {
346 $url = sprintf( '%s/public/getsnippet/%s', $this->get_cloud_api_url(), $cloud_id );
347 $response = wp_remote_get( $url );
348 $cloud_snippet = self::unpack_request_json( $response );
349 return new Cloud_Snippet( is_array( $cloud_snippet ) ? ( $cloud_snippet['snippet'] ?? [] ) : [] );
350 }
351
352 /**
353 * Get the current revision of a single cloud snippet.
354 *
355 * @param string $cloud_id Cloud snippet ID.
356 *
357 * @return string|null Revision number on success, null otherwise.
358 */
359 public function get_cloud_snippet_revision( string $cloud_id ): ?string {
360 $api_url = sprintf( '%s/public/getsnippetrevision/%s', $this->get_cloud_api_url(), $cloud_id );
361
362 $cloud_snippet_revision = self::unpack_request_json( wp_remote_get( $api_url ) );
363
364 return $cloud_snippet_revision
365 ? $cloud_snippet_revision['snippet_revision'] ?? null
366 : null;
367 }
368
369 /**
370 * Download a snippet from the cloud.
371 *
372 * @param Cloud_Snippet $snippet_to_store The snippet to be downloaded.
373 *
374 * @return array The result of the download.
375 */
376 public function download_snippet_from_cloud( Cloud_Snippet $snippet_to_store ): array {
377 $snippet = new Snippet( $snippet_to_store );
378
379 // Set the snippet id to 0 to ensure that the snippet is saved as a new snippet.
380 $snippet->id = 0;
381 $snippet->active = 0;
382 $snippet->cloud_id = sprintf( '%d_%d', $snippet_to_store->id, $snippet_to_store->is_owner ? '1' : '0' );
383 $snippet->desc = $snippet_to_store->description ? $snippet_to_store->description : '';
384
385 // Save the snippet to the database.
386 $new_snippet = save_snippet( $snippet );
387
388 $link = new Cloud_Link();
389 $link->local_id = $new_snippet->id;
390 $link->cloud_id = $snippet_to_store->id;
391 $link->is_owner = $snippet_to_store->is_owner;
392 $link->in_codevault = false;
393 $link->update_available = false;
394
395 $this->add_cloud_link( $link );
396
397 return [
398 'success' => true,
399 'action' => 'Single Downloaded',
400 'snippet_id' => $new_snippet->id,
401 'link_id' => $link->cloud_id,
402 ];
403 }
404
405 /**
406 * Update a snippet from the cloud.
407 *
408 * @param Cloud_Snippet $snippet_to_store Snippet to be updated.
409 *
410 * @return array The result of the update.
411 */
412 public function update_snippet_from_cloud( Cloud_Snippet $snippet_to_store ): array {
413 $cloud_id = $snippet_to_store->id . '_' . ( $snippet_to_store->is_owner ? '1' : '0' );
414
415 $local_snippet = get_snippet_by_cloud_id( sanitize_key( $cloud_id ) );
416
417 // Only update the code, active and revision fields.
418 $fields = [
419 'code' => $snippet_to_store->code,
420 'active' => false,
421 'revision' => $snippet_to_store->revision,
422 ];
423
424 update_snippet_fields( $local_snippet->id, $fields );
425 $this->clear_caches();
426
427 return [
428 'success' => true,
429 'action' => __( 'Updated', 'code-snippets' ),
430 ];
431 }
432
433 /**
434 * Get the current featured-snippets cache version, initialising it if absent.
435 *
436 * @return string
437 */
438 private function get_featured_cache_version(): string {
439 $version = get_transient( self::FEATURED_VERSION_OPTION );
440
441 if ( ! $version ) {
442 $version = (string) ( microtime( true ) * 1000 );
443 set_transient( self::FEATURED_VERSION_OPTION, $version, MONTH_IN_SECONDS );
444 }
445
446 return $version;
447 }
448
449 /**
450 * Build the transient key for a specific (version, page, per_page, filters) slot.
451 *
452 * @param int $page Page number (1-indexed).
453 * @param int $per_page Results per page.
454 * @param array<string,string> $filters Filter values.
455 *
456 * @return string
457 */
458 private function build_featured_cache_key( int $page, int $per_page, array $filters ): string {
459 $active_filters = array_filter( $filters );
460 $encoded = wp_json_encode( $active_filters );
461 $filter_hash = md5( false === $encoded ? '' : $encoded );
462 $version = $this->get_featured_cache_version();
463
464 return self::FEATURED_TRANSIENT_KEY . "_v{$version}_p{$page}_pp{$per_page}_{$filter_hash}";
465 }
466
467 /**
468 * Retrieve featured snippets from the cloud API, with transient caching.
469 *
470 * @param int $page Page number (1-indexed).
471 * @param int $per_page Results per page.
472 * @param array<string,string> $filters Optional filters: category, type, status.
473 *
474 * @return Cloud_Snippets Featured snippets, or an empty result on failure.
475 */
476 public function get_featured_snippets( int $page = 1, int $per_page = 10, array $filters = [] ): Cloud_Snippets {
477 $per_page = min( self::MAX_RESULTS_PER_PAGE, max( 1, $per_page ) );
478 $cache_key = self::build_featured_cache_key( $page, $per_page, $filters );
479
480 $cached = get_transient( $cache_key );
481
482 if ( $cached instanceof Cloud_Snippets ) {
483 return $cached;
484 }
485
486 $params = [
487 'page' => max( 0, $page - 1 ),
488 'per_page' => $per_page,
489 ];
490
491 foreach ( [ 'category', 'type', 'status' ] as $key ) {
492 if ( ! empty( $filters[ $key ] ) ) {
493 $params[ $key ] = $filters[ $key ];
494 }
495 }
496
497 $url = add_query_arg( $params, sprintf( '%s/public/featured', $this->get_cloud_api_url() ) );
498
499 $response = wp_remote_get(
500 $url,
501 [
502 'headers' => [
503 'Authorization' => sprintf( 'Bearer %s', self::get_local_token() ),
504 ],
505 ]
506 );
507
508 if ( is_wp_error( $response ) ) {
509 return new Cloud_Snippets();
510 }
511
512 $json = self::unpack_request_json( $response );
513
514 if ( ! $json ) {
515 return new Cloud_Snippets();
516 }
517
518 $result = new Cloud_Snippets( $json );
519 $result->page = $page;
520
521 set_transient( $cache_key, $result, self::FEATURED_MIN_TTL );
522
523 return $result;
524 }
525
526 /**
527 * Retrieve available snippet types (languages) from the cloud API, with transient caching.
528 *
529 * @return array<int, array{id: int, name: string, snippet_count: int}> List of types.
530 */
531 public function get_cloud_types(): array {
532 $cached = get_transient( self::TYPES_TRANSIENT_KEY );
533
534 if ( is_array( $cached ) ) {
535 return $cached;
536 }
537
538 $response = wp_remote_get( sprintf( '%s/public/types', $this->get_cloud_api_url() ) );
539 $json = self::unpack_request_json( $response );
540
541 if ( ! is_array( $json ) || ! isset( $json['data'] ) ) {
542 return [];
543 }
544
545 $types = $json['data'];
546 set_transient( self::TYPES_TRANSIENT_KEY, $types, DAY_IN_SECONDS );
547
548 return $types;
549 }
550
551 /**
552 * Retrieve available snippet categories from the cloud API, with transient caching.
553 *
554 * @return array<int, array{id: int, name: string, snippet_count: int}> List of categories.
555 */
556 public function get_cloud_categories(): array {
557 $cached = get_transient( self::CATEGORIES_TRANSIENT_KEY );
558
559 if ( is_array( $cached ) ) {
560 return $cached;
561 }
562
563 $response = wp_remote_get( sprintf( '%s/public/categories', $this->get_cloud_api_url() ) );
564
565 if ( is_wp_error( $response ) ) {
566 return [];
567 }
568
569 $json = self::unpack_request_json( $response );
570
571 if ( ! is_array( $json ) || ! isset( $json['data'] ) ) {
572 return [];
573 }
574
575 $categories = $json['data'];
576 set_transient( self::CATEGORIES_TRANSIENT_KEY, $categories, DAY_IN_SECONDS );
577
578 return $categories;
579 }
580
581 /**
582 * Refresh the cached synced data.
583 *
584 * Bumps the featured-cache version counter so previously cached keys
585 * become unreachable and expire via WordPress's normal transient path.
586 *
587 * @return void
588 */
589 public function clear_caches() {
590 $this->cached_cloud_links = null;
591
592 delete_transient( self::CLOUD_MAP_TRANSIENT_KEY );
593 delete_transient( 'cs_codevault_snippets' );
594
595 delete_transient( self::FEATURED_VERSION_OPTION );
596 }
597 }
598