PluginProbe
SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking / trunk
SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking vtrunk
1.5.0 1.4.0 1.3.0 1.3.1 trunk 0.0.0-alpha.1 0.0.0-alpha.2 0.0.0-alpha.3 0.0.1-beta.1 0.0.1-beta.2 0.0.1-beta.3 0.0.1-beta.4 1.0.0 1.1.0 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4
surecookie / inc / api / settings.php

settings.php in SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking trunk, at inc/api/settings.php

1,104 lines 35.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Settings class
4 *
5 * Handles installed products related REST API endpoints for the SureCookie plugin.
6 *
7 * @package SureCookie\Inc\API
8 */
9
10 namespace SureCookie\Inc\API;
11
12 use SureCookie\Inc\Functions\Get;
13 use SureCookie\Inc\Functions\Helper;
14 use SureCookie\Inc\Functions\Sanitize;
15 use SureCookie\Inc\Functions\SendJson;
16 use SureCookie\Inc\Functions\Settings as FunctionsSettings;
17 use SureCookie\Inc\Functions\Update;
18 use SureCookie\Inc\Modules\Services\Installed_Services;
19 use SureCookie\Inc\Traits\GetInstance;
20 use SureCookie\Inc\Utils\Options;
21 use WP_REST_Server;
22
23 if ( ! defined( 'ABSPATH' ) ) {
24 exit; // Exit if accessed directly.
25 }
26
27 /**
28 * Class Settings
29 *
30 * Handles this related REST API endpoints.
31 */
32 class Settings extends Base {
33 use GetInstance;
34
35 /**
36 * Route Get Admin Settings
37 */
38 protected const ADMIN_SETTINGS = '/admin/settings';
39
40 /**
41 * Route Export Settings
42 */
43 protected const EXPORT_SETTINGS = '/settings/export';
44
45 /**
46 * Route: list the export sections available on this install.
47 */
48 protected const EXPORT_SECTIONS = '/settings/export-sections';
49
50 /**
51 * Route Import Settings
52 */
53 protected const IMPORT_SETTINGS = '/settings/import';
54
55 /**
56 * Route Get Frontend Settings
57 */
58 protected const FRONTEND_SETTINGS = '/frontend/settings';
59
60 /**
61 * Export file type.
62 */
63 private const EXPORT_TYPE = 'surecookie-settings-export';
64
65 /**
66 * Export file schema version.
67 */
68 private const EXPORT_SCHEMA_VERSION = 1;
69
70 /**
71 * Maximum import file size in bytes.
72 */
73 private const MAX_IMPORT_FILE_SIZE = 5242880;
74
75 /**
76 * Register API routes.
77 *
78 * @since 0.0.1
79 * @return void
80 */
81 public function register_routes(): void {
82 register_rest_route(
83 $this->get_api_namespace(),
84 self::ADMIN_SETTINGS,
85 [
86 'methods' => WP_REST_Server::READABLE, // Admin -- GET method.
87 'callback' => [ $this, 'get_admin_settings' ],
88 'permission_callback' => [ $this, 'validate_permission' ],
89 ]
90 );
91
92 register_rest_route(
93 $this->get_api_namespace(),
94 self::ADMIN_SETTINGS,
95 [
96 'methods' => WP_REST_Server::CREATABLE, // Admin -- POST method.
97 'callback' => [ $this, 'update_admin_settings' ],
98 'permission_callback' => [ $this, 'validate_permission' ],
99 'args' => [
100 'data' => [
101 'required' => true,
102 'type' => 'object',
103 ],
104 ],
105 ]
106 );
107
108 register_rest_route(
109 $this->get_api_namespace(),
110 self::FRONTEND_SETTINGS,
111 [
112 'methods' => WP_REST_Server::READABLE, // Frontend -- GET method.
113 'callback' => [ $this, 'get_frontend_settings' ],
114 'permission_callback' => '__return_true',
115 ]
116 );
117
118 register_rest_route(
119 $this->get_api_namespace(),
120 self::EXPORT_SECTIONS,
121 [
122 'methods' => WP_REST_Server::READABLE,
123 'callback' => [ $this, 'list_export_sections' ],
124 'permission_callback' => [ $this, 'validate_permission' ],
125 ]
126 );
127
128 register_rest_route(
129 $this->get_api_namespace(),
130 self::EXPORT_SETTINGS,
131 [
132 'methods' => WP_REST_Server::READABLE,
133 'callback' => [ $this, 'export_settings' ],
134 'permission_callback' => [ $this, 'validate_permission' ],
135 'args' => [
136 'sections' => [
137 'type' => 'array',
138 'items' => [ 'type' => 'string' ],
139 'default' => [],
140 ],
141 ],
142 ]
143 );
144
145 register_rest_route(
146 $this->get_api_namespace(),
147 self::IMPORT_SETTINGS,
148 [
149 'methods' => WP_REST_Server::CREATABLE,
150 'callback' => [ $this, 'import_settings' ],
151 'permission_callback' => [ $this, 'validate_permission' ],
152 'args' => [
153 'file' => [
154 'required' => true,
155 'type' => 'string',
156 ],
157 'replace' => [
158 'type' => 'boolean',
159 'default' => false,
160 ],
161 ],
162 ]
163 );
164 }
165
166 /**
167 * Get admin settings
168 *
169 * @param \WP_REST_Request<array<string, mixed>> $request Request object.
170 * @since 0.0.1
171 * @return void
172 */
173 public function get_admin_settings( $request ): void {
174 $data = FunctionsSettings::get();
175 $data['surecookie_usage_optin'] = Get::option( 'surecookie_usage_optin' ) === 'yes' ? true : false;
176
177 $data = apply_filters( 'surecookie_get_admin_settings_data', $data );
178 $decode_data = Helper::decode_html_entities_recursive( $data ) ?? $data;
179
180 // Re-sanitize after entity decoding, else the decode undoes the sanitizer:
181 // &#64;import → @import for CSS, &lt;img onerror=…&gt; → live markup for rich text.
182 if ( isset( $decode_data['custom_css'] ) && is_string( $decode_data['custom_css'] ) ) {
183 $decode_data['custom_css'] = Sanitize::stylesheet( $decode_data['custom_css'] );
184 }
185
186 $decode_data = Sanitize::rich_text_keys_after_decode( $decode_data );
187
188 SendJson::success( [ 'data' => $decode_data ] );
189 }
190
191 /**
192 * Update admin settings
193 *
194 * @param \WP_REST_Request<array<string, mixed>> $request Request object.
195 * @since 0.0.1
196 * @return void
197 */
198 public function update_admin_settings( $request ): void {
199 $data = $request->get_param( 'data' );
200 if ( ! is_array( $data ) || empty( $data ) ) {
201 SendJson::error( [ 'message' => __( 'No data found', 'surecookie' ) ] );
202 return;
203 }
204
205 $previous_top_level = FunctionsSettings::get( 'top_level_menu_enabled' );
206 $sanitized_settings = $this->apply_settings_update( $data );
207
208 $response = [
209 'message' => __( 'Settings updated', 'surecookie' ),
210 'redirect_url' => Get::menu_redirect_url( $sanitized_settings, $previous_top_level ),
211 ];
212
213 if ( empty( $response['redirect_url'] ) ) {
214 unset( $response['redirect_url'] );
215 }
216
217 SendJson::success( $response );
218 }
219
220 /**
221 * Export SureCookie settings.
222 *
223 * @param \WP_REST_Request<array<string, mixed>> $request Request object.
224 * @return void
225 */
226 public function export_settings( $request ): void {
227 $sections = $request->get_param( 'sections' );
228 $sections = is_array( $sections ) ? $sections : [];
229
230 SendJson::success(
231 [
232 'data' => $this->get_export_payload( $sections ),
233 ]
234 );
235 }
236
237 /**
238 * List the export picker groups available on this install. Only groups
239 * whose sections own a transferable key here are returned, so the free
240 * plugin never shows Pro-only entries.
241 *
242 * @param \WP_REST_Request<array<string, mixed>> $request Request object.
243 * @since 1.4.0
244 * @return void
245 */
246 public function list_export_sections( $request ): void {
247 $registry = $this->get_section_registry();
248 $groups = [];
249
250 foreach ( $this->get_export_sections() as $section ) {
251 $parent = $section['parent'];
252
253 if ( isset( $groups[ $parent ] ) ) {
254 continue;
255 }
256
257 $meta = $registry[ $parent ] ?? [];
258 $groups[ $parent ] = [
259 'id' => $parent,
260 'label' => isset( $meta['label'] ) ? (string) $meta['label'] : $this->humanize_section_id( $parent ),
261 'description' => isset( $meta['description'] ) ? (string) $meta['description'] : '',
262 ];
263 }
264
265 $order = array_flip( array_keys( $registry ) );
266 uksort(
267 $groups,
268 static function ( string $a, string $b ) use ( $order ): int {
269 return ( $order[ $a ] ?? PHP_INT_MAX ) <=> ( $order[ $b ] ?? PHP_INT_MAX );
270 }
271 );
272
273 SendJson::success( [ 'data' => array_values( $groups ) ] );
274 }
275
276 /**
277 * Import SureCookie settings from a JSON export.
278 *
279 * @param \WP_REST_Request<array<string, mixed>> $request Request object.
280 * @return void
281 */
282 public function import_settings( $request ): void {
283 $file_content = $request->get_param( 'file' );
284 $replace = (bool) $request->get_param( 'replace' );
285
286 if ( ! is_string( $file_content ) || trim( $file_content ) === '' ) {
287 SendJson::error(
288 [
289 'message' => __( 'Please upload a SureCookie export file to import.', 'surecookie' ),
290 ],
291 400
292 );
293 }
294
295 if ( strlen( $file_content ) > self::MAX_IMPORT_FILE_SIZE ) {
296 SendJson::error(
297 [
298 'message' => __( 'The selected file is too large to import.', 'surecookie' ),
299 ],
300 400
301 );
302 }
303
304 $payload = json_decode( $file_content, true );
305
306 if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $payload ) ) {
307 SendJson::error(
308 [
309 'message' => __( 'The uploaded file is not valid JSON.', 'surecookie' ),
310 ],
311 400
312 );
313 }
314
315 if ( ( $payload['type'] ?? '' ) !== self::EXPORT_TYPE ) {
316 SendJson::error(
317 [
318 'message' => __( 'The uploaded file is not a valid SureCookie settings export.', 'surecookie' ),
319 ],
320 400
321 );
322 }
323
324 if ( (int) ( $payload['schema_version'] ?? 0 ) !== self::EXPORT_SCHEMA_VERSION ) {
325 SendJson::error(
326 [
327 'message' => __( 'This settings export uses an unsupported schema version.', 'surecookie' ),
328 ],
329 400
330 );
331 }
332
333 $imported_settings = $payload['settings'] ?? null;
334
335 if ( ! is_array( $imported_settings ) ) {
336 SendJson::error(
337 [
338 'message' => __( 'The uploaded file does not contain a valid settings payload.', 'surecookie' ),
339 ],
340 400
341 );
342 return;
343 }
344
345 $allowed_keys = $this->get_transferable_settings_keys();
346 $allowed_key_map = array_fill_keys( $allowed_keys, true );
347 $filtered_settings = array_intersect_key( $imported_settings, $allowed_key_map );
348 $skipped_keys = array_values( array_diff( array_keys( $imported_settings ), $allowed_keys ) );
349
350 // Recognized keys whose value cannot be stored. Reported apart from `ignored`
351 // (unrecognized fields) because the outcome differs and the user needs to know
352 // which: a merge keeps the stored value, a replace has already defaulted it.
353 $invalid_keys = [];
354 $filtered_settings = array_filter(
355 $filtered_settings,
356 static function ( $value, $key ) use ( &$invalid_keys ) {
357 if ( FunctionsSettings::accepts_value( (string) $key, $value ) ) {
358 return true;
359 }
360
361 $invalid_keys[] = (string) $key;
362 return false;
363 },
364 ARRAY_FILTER_USE_BOTH
365 );
366
367 // Standalone options this install knows how to apply (sanitized at
368 // the boundary below); unknown option names are ignored.
369 $imported_options = isset( $payload['options'] ) && is_array( $payload['options'] ) ? $payload['options'] : [];
370 $transferable_options = $this->get_transferable_options();
371 $applicable_options = array_intersect_key( $imported_options, $transferable_options );
372
373 $file_sections = isset( $payload['sections'] ) && is_array( $payload['sections'] ) ? $payload['sections'] : [];
374 $summary = $this->summarize_import( $imported_settings, array_keys( $filtered_settings ), $file_sections );
375
376 // summarize_import() buckets everything unapplied as unrecognized; these were
377 // recognized, so they belong in their own bucket.
378 $summary['ignored'] = array_values( array_diff( $summary['ignored'], $invalid_keys ) );
379
380 // Nothing this install can apply. If the file carries Pro sections, guide
381 // the user to upgrade instead of failing; otherwise the file is unusable.
382 if ( empty( $filtered_settings ) && empty( $applicable_options ) ) {
383 if ( ! empty( $summary['skipped_pro'] ) ) {
384 SendJson::success(
385 [
386 'message' => __( 'No settings were imported. This export contains Pro features that are not available on this site.', 'surecookie' ),
387 'applied' => [],
388 'applied_sections' => [],
389 'skipped_pro' => $summary['skipped_pro'],
390 'ignored' => $summary['ignored'],
391 'invalid' => $invalid_keys,
392 'upgrade_required' => true,
393 'replaced' => $replace,
394 ]
395 );
396 }
397
398 SendJson::error(
399 [
400 'message' => __( 'The uploaded file does not contain any importable SureCookie settings for this site.', 'surecookie' ),
401 ],
402 400
403 );
404 }
405
406 $previous_top_level = FunctionsSettings::get( 'top_level_menu_enabled' );
407 $updated_settings = empty( $filtered_settings ) ? [] : $this->apply_settings_update( $filtered_settings, $replace );
408
409 // Apply standalone options through their registered sanitizers. An
410 // option import always replaces the whole option - these are
411 // self-contained registries (e.g. known services), not key merges.
412 $applied_options = [];
413 $sections = $this->get_export_sections();
414 foreach ( $applicable_options as $option_name => $value ) {
415 $sanitize = $transferable_options[ $option_name ]['sanitize'] ?? null;
416 if ( ! is_callable( $sanitize ) ) {
417 continue;
418 }
419
420 update_option( $option_name, $sanitize( $value ), false );
421 $applied_options[] = (string) $option_name;
422
423 $section_id = (string) ( $transferable_options[ $option_name ]['section'] ?? '' );
424 if ( isset( $sections[ $section_id ]['label'] ) ) {
425 $summary['applied_sections'][] = $sections[ $section_id ]['label'];
426 $summary['applied_sections'] = array_values( array_unique( $summary['applied_sections'] ) );
427 }
428 }
429
430 $response = [
431 'message' => __( 'Settings imported successfully.', 'surecookie' ),
432 'applied' => array_keys( $filtered_settings ),
433 'applied_options' => $applied_options,
434 'applied_sections' => $summary['applied_sections'],
435 'skipped_pro' => $summary['skipped_pro'],
436 'ignored' => $summary['ignored'],
437 'invalid' => $invalid_keys,
438 'upgrade_required' => ! empty( $summary['skipped_pro'] ),
439 'skipped' => $skipped_keys,
440 'replaced' => $replace,
441 'redirect_url' => Get::menu_redirect_url( $updated_settings, $previous_top_level ),
442 ];
443
444 if ( empty( $response['redirect_url'] ) ) {
445 unset( $response['redirect_url'] );
446 }
447
448 SendJson::success( $response );
449 }
450
451 /**
452 * Update usage tracking setting based on opt-in status.
453 *
454 * @param array<string, mixed> $data The current settings dataset.
455 * @since 0.0.1-beta.1
456 * @return array<string, mixed> The modified settings dataset.
457 */
458 public function update_usage_tracking( $data ): array {
459 if ( ! isset( $data['surecookie_usage_optin'] ) ) {
460 return $data;
461 }
462
463 $enable_contribution = $data['surecookie_usage_optin'] ? 'yes' : 'no';
464 update_option( 'surecookie_usage_optin', $enable_contribution );
465 unset( $data['surecookie_usage_optin'] );
466
467 return $data;
468 }
469
470 /**
471 * Get frontend settings
472 *
473 * @param \WP_REST_Request<array<string, mixed>> $request Request object.
474 * @since 0.0.1
475 * @return void
476 */
477 public function get_frontend_settings( $request ): void {
478 $public_settings = FunctionsSettings::get_public_settings_dataset();
479
480 $frontend_settings = apply_filters( 'surecookie_get_frontend_settings_data', $public_settings );
481 $decode_data = Helper::decode_html_entities_recursive( $frontend_settings ) ?? $frontend_settings;
482
483 // Public route - the decode above must not hand an anonymous caller
484 // markup that kses had already neutralized.
485 $decode_data = Sanitize::rich_text_keys_after_decode( $decode_data );
486
487 SendJson::success( [ 'data' => $decode_data ] );
488 }
489
490 /**
491 * Apply a settings update through the shared sanitization and hook pipeline.
492 *
493 * @param array<string, mixed> $data Settings patch to apply.
494 * @param bool $replace Whether to replace transferable settings instead of merging.
495 * @return array<string, mixed>
496 */
497 private function apply_settings_update( array $data, bool $replace = false ): array {
498 // Defense-in-depth: custom CSS is inlined to every frontend page, so treat it like WP Core treats Customizer Additional CSS - require unfiltered_html. On multisite, sub-admins without the cap keep the previously saved value; the sanitizer is the fallback line.
499 if ( array_key_exists( 'custom_css', $data ) && ! current_user_can( 'unfiltered_html' ) ) {
500 unset( $data['custom_css'] );
501 }
502
503 // Before processing plugin settings data compatibility.
504 do_action( 'surecookie_admin_settings_before_processing', $data );
505
506 $data = apply_filters( 'surecookie_update_admin_settings_data', $data );
507 $data = is_array( $data ) ? $data : [];
508
509 $sanitized_patch = $this->sanitize_settings_patch( $data );
510 $sanitized_patch = $this->update_usage_tracking( $sanitized_patch );
511 $current_option = $this->get_raw_settings_option();
512 $settings_to_save = $replace
513 ? $this->build_replaced_settings( $current_option, $sanitized_patch )
514 : array_merge( $current_option, $sanitized_patch );
515
516 Update::option( SURECOOKIE_SETTINGS_OPTION, $settings_to_save );
517
518 // After processing plugin settings data compatibility.
519 do_action( 'surecookie_admin_settings_after_processing', $settings_to_save );
520
521 return $settings_to_save;
522 }
523
524 /**
525 * Sanitize a settings patch without merging it into the stored option.
526 *
527 * @param array<string, mixed> $data Settings patch.
528 * @return array<string, mixed>
529 */
530 private function sanitize_settings_patch( array $data ): array {
531 $sanitized_settings = [];
532
533 foreach ( $data as $key => $value ) {
534 $key = (string) $key;
535
536 // Wrong shape for an array key: drop it so the merge keeps the stored value instead of wiping it to [].
537 if ( ! FunctionsSettings::accepts_value( $key, $value ) ) {
538 continue;
539 }
540
541 $sanitized_settings[ $key ] = FunctionsSettings::get_cleaned_value( $key, $value );
542 }
543
544 return $sanitized_settings;
545 }
546
547 /**
548 * Build the stored settings array for a replace import.
549 *
550 * @param array<string, mixed> $current_option Current raw option value.
551 * @param array<string, mixed> $sanitized_patch Imported settings patch.
552 * @return array<string, mixed>
553 */
554 private function build_replaced_settings( array $current_option, array $sanitized_patch ): array {
555 $settings_to_save = $current_option;
556 $defaults = FunctionsSettings::get_settings_defaults();
557
558 foreach ( $this->get_transferable_settings_keys() as $key ) {
559 $settings_to_save[ $key ] = $defaults[ $key ] ?? '';
560 }
561
562 return array_merge( $settings_to_save, $sanitized_patch );
563 }
564
565 /**
566 * Get the current raw settings option.
567 *
568 * @return array<string, mixed>
569 */
570 private function get_raw_settings_option(): array {
571 $current_option = Get::option( SURECOOKIE_SETTINGS_OPTION, [], 'array' );
572 return is_array( $current_option ) ? $current_option : [];
573 }
574
575 /**
576 * Build the export payload.
577 *
578 * @param array<int, string> $section_ids Selected section ids; empty (or 'all') exports everything.
579 * @return array<string, mixed>
580 */
581 private function get_export_payload( array $section_ids = [] ): array {
582 $all_settings = FunctionsSettings::get();
583 $transferable_keys = $this->resolve_export_keys( $section_ids );
584 $exported_settings = [];
585
586 foreach ( $transferable_keys as $key ) {
587 if ( array_key_exists( $key, $all_settings ) ) {
588 $exported_settings[ $key ] = $all_settings[ $key ];
589 }
590 }
591
592 // Standalone options for the selected sections (null = everything).
593 $selected = $this->resolve_selected_section_ids( $section_ids );
594 $exported_options = [];
595 foreach ( $this->get_transferable_options() as $option_name => $config ) {
596 $section = is_array( $config ) && ! empty( $config['section'] ) ? (string) $config['section'] : 'advanced';
597 if ( $selected !== null && ! in_array( $section, $selected, true ) ) {
598 continue;
599 }
600
601 $value = get_option( $option_name, null );
602 if ( $value !== null ) {
603 $exported_options[ $option_name ] = $value;
604 }
605 }
606
607 $payload = [
608 'type' => self::EXPORT_TYPE,
609 'schema_version' => self::EXPORT_SCHEMA_VERSION,
610 'plugin_version' => SURECOOKIE_VERSION,
611 'exported_at' => gmdate( 'c' ),
612 'site_url' => site_url(),
613 // Self-describing manifest so an export can be classified on import
614 // (e.g. a Pro export imported into free knows which keys are Pro).
615 'sections' => $this->build_sections_manifest( array_keys( $exported_settings ), array_keys( $exported_options ) ),
616 'settings' => $exported_settings,
617 'options' => $exported_options,
618 ];
619
620 /**
621 * Filters the SureCookie settings export payload.
622 *
623 * @param array<string, mixed> $payload Export payload.
624 */
625 $filtered = apply_filters( 'surecookie_settings_export_payload', $payload );
626 return is_array( $filtered ) ? $filtered : $payload;
627 }
628
629 /**
630 * Export section registry: id => label, description, tier, depends_on,
631 * parent. Keys are derived from the settings dataset `group` tag (see
632 * map_keys_to_sections); untagged keys fall into `advanced`. `parent`
633 * folds a section into a picker group (picker-only; the manifest and
634 * import summary stay fine-grained). The leading entries define the
635 * picker groups, mirroring the admin side navigation.
636 *
637 * @since 1.4.0
638 * @return array<string, array<string, mixed>>
639 */
640 private function get_section_registry(): array {
641 $sections = [
642 'cookies_scripts' => [
643 'label' => __( 'Cookies & Scripts', 'surecookie' ),
644 'description' => __( 'Cookie categories, custom cookies, known services, script blocking and scanning.', 'surecookie' ),
645 'tier' => 'free',
646 ],
647 'geo' => [
648 'label' => __( 'Geographic Rules', 'surecookie' ),
649 'description' => __( 'Region-based banner rules.', 'surecookie' ),
650 'tier' => 'pro',
651 ],
652 'banner' => [
653 'label' => __( 'Banner', 'surecookie' ),
654 'description' => __( 'Banner content, layout, colors, buttons and display.', 'surecookie' ),
655 'tier' => 'free',
656 ],
657 'general' => [
658 'label' => __( 'General', 'surecookie' ),
659 'description' => __( 'Consent model, logging, preferences, custom CSS and other plugin settings.', 'surecookie' ),
660 'tier' => 'free',
661 ],
662 'consent_frameworks' => [
663 'label' => __( 'Consent Frameworks', 'surecookie' ),
664 'description' => __( 'Google Consent Mode and other consent integrations.', 'surecookie' ),
665 'tier' => 'free',
666 ],
667 'buttons' => [
668 'label' => __( 'Buttons & labels', 'surecookie' ),
669 'description' => __( 'Button visibility, order and label text.', 'surecookie' ),
670 'tier' => 'free',
671 'parent' => 'banner',
672 ],
673 'consent' => [
674 'label' => __( 'Consent & compliance', 'surecookie' ),
675 'description' => __( 'Compliance law, consent model, logging and duration.', 'surecookie' ),
676 'tier' => 'free',
677 'parent' => 'general',
678 ],
679 'cookie_categories' => [
680 'label' => __( 'Cookie categories', 'surecookie' ),
681 'description' => __( 'Your consent categories.', 'surecookie' ),
682 'tier' => 'free',
683 'parent' => 'cookies_scripts',
684 ],
685 'cookies' => [
686 'label' => __( 'Cookies', 'surecookie' ),
687 'description' => __( 'Custom cookie definitions.', 'surecookie' ),
688 'tier' => 'free',
689 'depends_on' => [ 'cookie_categories' ],
690 'parent' => 'cookies_scripts',
691 ],
692 'blocking' => [
693 'label' => __( 'Script blocking', 'surecookie' ),
694 'description' => __( 'Blocking rules and scan resource overrides.', 'surecookie' ),
695 'tier' => 'free',
696 'parent' => 'cookies_scripts',
697 ],
698 'services' => [
699 'label' => __( 'Known services', 'surecookie' ),
700 'description' => __( 'Services you added or removed for cookie declarations and blocking.', 'surecookie' ),
701 'tier' => 'free',
702 'parent' => 'cookies_scripts',
703 ],
704 'gcm' => [
705 'label' => __( 'Google Consent Mode', 'surecookie' ),
706 'description' => __( 'GCM toggle and region defaults.', 'surecookie' ),
707 'tier' => 'free',
708 'parent' => 'consent_frameworks',
709 ],
710 'scanning' => [
711 'label' => __( 'Automatic scanning', 'surecookie' ),
712 'description' => __( 'Scan frequency and scope.', 'surecookie' ),
713 'tier' => 'free',
714 'parent' => 'cookies_scripts',
715 ],
716 'advanced' => [
717 'label' => __( 'Advanced', 'surecookie' ),
718 'description' => __( 'Custom CSS, integrations and tool settings.', 'surecookie' ),
719 'tier' => 'free',
720 'parent' => 'general',
721 ],
722 ];
723
724 /**
725 * Filters the export section registry. Modules and Pro register their own
726 * sections here (label, description, tier, depends_on); the keys each
727 * section covers are derived automatically from the dataset `group` tag.
728 *
729 * @since 1.4.0
730 * @param array<string, array<string, mixed>> $sections Section id => meta.
731 */
732 $sections = apply_filters( 'surecookie_export_sections', $sections );
733
734 return is_array( $sections ) ? $sections : [];
735 }
736
737 /**
738 * Derive section id => transferable keys from the settings dataset. Every
739 * transferable key lands in exactly one section (its `group`, or `advanced`
740 * when untagged), so no setting is ever left out of the export.
741 *
742 * @since 1.4.0
743 * @return array<string, array<int, string>>
744 */
745 private function map_keys_to_sections(): array {
746 $dataset = Options::get_all_configurations();
747 $transferable = array_fill_keys( $this->get_transferable_settings_keys(), true );
748 $map = [];
749
750 foreach ( $dataset as $key => $config ) {
751 if ( ! isset( $transferable[ $key ] ) ) {
752 continue;
753 }
754
755 $group = is_array( $config ) && ! empty( $config['group'] ) ? (string) $config['group'] : 'advanced';
756 $map[ $group ][] = (string) $key;
757 }
758
759 return $map;
760 }
761
762 /**
763 * Build the export sections for this install: registry meta joined with the
764 * derived keys. Only sections that own at least one transferable key here
765 * are returned. Sections present in the dataset but missing from the
766 * registry still appear (auto-labelled), so nothing is unreachable.
767 *
768 * @since 1.4.0
769 * @return array<string, array<string, mixed>>
770 */
771 private function get_export_sections(): array {
772 $registry = $this->get_section_registry();
773 $key_map = $this->map_keys_to_sections();
774
775 // Standalone options join their section alongside settings keys, so a
776 // section can exist with keys, options, or both.
777 $option_map = [];
778 foreach ( $this->get_transferable_options() as $option_name => $config ) {
779 $section = is_array( $config ) && ! empty( $config['section'] ) ? (string) $config['section'] : 'advanced';
780 $option_map[ $section ][] = (string) $option_name;
781 }
782
783 $sections = [];
784
785 foreach ( array_unique( array_merge( array_keys( $key_map ), array_keys( $option_map ) ) ) as $id ) {
786 $keys = $key_map[ $id ] ?? [];
787 $options = $option_map[ $id ] ?? [];
788
789 if ( empty( $keys ) && empty( $options ) ) {
790 continue;
791 }
792
793 $meta = $registry[ $id ] ?? [];
794 $sections[ $id ] = [
795 'id' => $id,
796 'label' => isset( $meta['label'] ) ? (string) $meta['label'] : $this->humanize_section_id( $id ),
797 'description' => isset( $meta['description'] ) ? (string) $meta['description'] : '',
798 'tier' => isset( $meta['tier'] ) && $meta['tier'] === 'pro' ? 'pro' : 'free',
799 'depends_on' => isset( $meta['depends_on'] ) && is_array( $meta['depends_on'] ) ? array_values( $meta['depends_on'] ) : [],
800 'parent' => ! empty( $meta['parent'] ) ? (string) $meta['parent'] : $id,
801 'keys' => array_values( array_unique( $keys ) ),
802 'options' => array_values( array_unique( $options ) ),
803 ];
804 }
805
806 return $sections;
807 }
808
809 /**
810 * Resolve the transferable keys for the selected sections. Empty selection
811 * (or an explicit `all`) exports everything. Section dependencies are pulled
812 * in automatically (e.g. Cookies pulls in Cookie categories).
813 *
814 * @param array<int, string> $section_ids Selected section ids.
815 * @since 1.4.0
816 * @return array<int, string>
817 */
818 private function resolve_export_keys( array $section_ids ): array {
819 $transferable = $this->get_transferable_settings_keys();
820 $section_ids = array_values( array_filter( array_map( 'sanitize_key', $section_ids ) ) );
821
822 if ( empty( $section_ids ) || in_array( 'all', $section_ids, true ) ) {
823 return $transferable;
824 }
825
826 $sections = $this->get_export_sections();
827 $wanted = $this->expand_section_dependencies(
828 $this->expand_parent_sections( $section_ids, $sections ),
829 $sections
830 );
831 $keys = [];
832
833 foreach ( $wanted as $id ) {
834 if ( ! empty( $sections[ $id ]['keys'] ) ) {
835 $keys = array_merge( $keys, $sections[ $id ]['keys'] );
836 }
837 }
838
839 return array_values( array_intersect( $transferable, array_unique( $keys ) ) );
840 }
841
842 /**
843 * Expand picker group ids to their member sections; fine-grained ids
844 * resolve unchanged.
845 *
846 * @param array<int, string> $section_ids Requested ids (groups or sections).
847 * @param array<string, array<string, mixed>> $sections All available sections.
848 * @since 1.4.0
849 * @return array<int, string>
850 */
851 private function expand_parent_sections( array $section_ids, array $sections ): array {
852 $expanded = array_fill_keys( $section_ids, true );
853
854 foreach ( $sections as $id => $section ) {
855 if ( isset( $expanded[ $section['parent'] ] ) ) {
856 $expanded[ $id ] = true;
857 }
858 }
859
860 return array_keys( $expanded );
861 }
862
863 /**
864 * Expand a set of section ids to include every section they depend on.
865 *
866 * @param array<int, string> $section_ids Requested section ids.
867 * @param array<string, array<string, mixed>> $sections All available sections.
868 * @since 1.4.0
869 * @return array<int, string>
870 */
871 private function expand_section_dependencies( array $section_ids, array $sections ): array {
872 $resolved = [];
873 $stack = $section_ids;
874
875 while ( $stack ) {
876 $id = (string) array_pop( $stack );
877
878 if ( isset( $resolved[ $id ] ) || ! isset( $sections[ $id ] ) ) {
879 continue;
880 }
881
882 $resolved[ $id ] = true;
883
884 foreach ( $sections[ $id ]['depends_on'] as $dependency ) {
885 if ( ! isset( $resolved[ $dependency ] ) ) {
886 $stack[] = $dependency;
887 }
888 }
889 }
890
891 return array_keys( $resolved );
892 }
893
894 /**
895 * Build the export manifest for the given exported keys: section id =>
896 * label, tier, and the exported keys it owns. Embedded in the export so the
897 * receiving site can classify keys it cannot apply (e.g. Pro sections).
898 *
899 * @param array<int, string> $exported_keys Keys actually included in the export.
900 * @param array<int, string> $exported_options Standalone option names included in the export.
901 * @since 1.4.0
902 * @return array<string, array<string, mixed>>
903 */
904 private function build_sections_manifest( array $exported_keys, array $exported_options = [] ): array {
905 $sections = $this->get_export_sections();
906 $key_lookup = array_fill_keys( $exported_keys, true );
907 $option_lookup = array_fill_keys( $exported_options, true );
908 $manifest = [];
909
910 foreach ( $sections as $id => $section ) {
911 $included = array_values(
912 array_filter(
913 $section['keys'],
914 static function ( string $key ) use ( $key_lookup ): bool {
915 return isset( $key_lookup[ $key ] );
916 }
917 )
918 );
919
920 $included_options = array_values(
921 array_filter(
922 $section['options'],
923 static function ( string $option ) use ( $option_lookup ): bool {
924 return isset( $option_lookup[ $option ] );
925 }
926 )
927 );
928
929 if ( empty( $included ) && empty( $included_options ) ) {
930 continue;
931 }
932
933 $manifest[ $id ] = [
934 'label' => $section['label'],
935 'tier' => $section['tier'],
936 'keys' => $included,
937 'options' => $included_options,
938 ];
939 }
940
941 return $manifest;
942 }
943
944 /**
945 * Turn a section id into a human-readable fallback label.
946 *
947 * @param string $id Section id.
948 * @since 1.4.0
949 * @return string
950 */
951 private function humanize_section_id( string $id ): string {
952 return ucwords( str_replace( [ '_', '-' ], ' ', $id ) );
953 }
954
955 /**
956 * Summarise an import into section-level buckets for the UI: which sections
957 * were applied, which Pro sections were skipped (this install can't use
958 * them), and which keys were ignored as unknown/invalid.
959 *
960 * @param array<string, mixed> $imported_settings Raw settings from the file.
961 * @param array<int, string> $applied_keys Keys actually applied.
962 * @param array<string, array<string, mixed>> $file_sections The export file's own manifest.
963 * @since 1.4.0
964 * @return array{applied_sections: array<int, string>, skipped_pro: array<int, string>, ignored: array<int, string>}
965 */
966 private function summarize_import( array $imported_settings, array $applied_keys, array $file_sections ): array {
967 // Map each key to its label/tier using the file's own manifest.
968 $file_key_meta = [];
969 foreach ( $file_sections as $id => $section ) {
970 if ( ! is_array( $section ) || empty( $section['keys'] ) || ! is_array( $section['keys'] ) ) {
971 continue;
972 }
973
974 $label = isset( $section['label'] ) ? (string) $section['label'] : $this->humanize_section_id( (string) $id );
975 $tier = isset( $section['tier'] ) && $section['tier'] === 'pro' ? 'pro' : 'free';
976
977 foreach ( $section['keys'] as $key ) {
978 $file_key_meta[ (string) $key ] = [
979 'label' => $label,
980 'tier' => $tier,
981 ];
982 }
983 }
984
985 // Prefer this install's own labels for applied keys (always accurate here).
986 $local_key_label = [];
987 foreach ( $this->get_export_sections() as $section ) {
988 foreach ( $section['keys'] as $key ) {
989 $local_key_label[ $key ] = $section['label'];
990 }
991 }
992
993 $applied_lookup = array_fill_keys( $applied_keys, true );
994 $applied_sections = [];
995 foreach ( $applied_keys as $key ) {
996 $label = $local_key_label[ $key ] ?? ( $file_key_meta[ $key ]['label'] ?? '' );
997 if ( $label !== '' ) {
998 $applied_sections[ $label ] = true;
999 }
1000 }
1001
1002 $skipped_pro = [];
1003 $ignored = [];
1004 foreach ( array_keys( $imported_settings ) as $key ) {
1005 if ( isset( $applied_lookup[ $key ] ) ) {
1006 continue;
1007 }
1008
1009 if ( isset( $file_key_meta[ $key ] ) && $file_key_meta[ $key ]['tier'] === 'pro' ) {
1010 $skipped_pro[ $file_key_meta[ $key ]['label'] ] = true;
1011 } else {
1012 $ignored[] = (string) $key;
1013 }
1014 }
1015
1016 return [
1017 'applied_sections' => array_keys( $applied_sections ),
1018 'skipped_pro' => array_keys( $skipped_pro ),
1019 'ignored' => array_values( array_unique( $ignored ) ),
1020 ];
1021 }
1022
1023 /**
1024 * Get transferable settings keys shared by export and import.
1025 *
1026 * @return array<int, string>
1027 */
1028 private function get_transferable_settings_keys(): array {
1029 $settings_keys = array_keys( Options::get_all_configurations() );
1030 $excluded_keys = [
1031 'auto_scan_pages',
1032 'consent_renewed_at',
1033 'cookie_policy_page_id',
1034 'preview_enabled',
1035 'privacy_policy_page_id',
1036 'reconsent_menu_id',
1037 'scan_pages',
1038 'show_preview',
1039 'total_logs',
1040 ];
1041
1042 /**
1043 * Filters the settings keys excluded from SureCookie import/export files.
1044 *
1045 * @param array<int, string> $excluded_keys Excluded settings keys.
1046 */
1047 $excluded_keys = apply_filters( 'surecookie_settings_export_excluded_keys', $excluded_keys );
1048 $excluded_keys = is_array( $excluded_keys ) ? $excluded_keys : [];
1049
1050 return array_values( array_diff( $settings_keys, $excluded_keys ) );
1051 }
1052
1053 /**
1054 * Standalone wp_options rows that travel with an export, beyond the
1055 * settings option: option name => section id + import-boundary sanitizer.
1056 * Modules and Pro register theirs via the filter (e.g. per-language
1057 * banner content).
1058 *
1059 * @since 1.4.0
1060 * @return array<string, array{section: string, sanitize: callable}>
1061 */
1062 private function get_transferable_options(): array {
1063 $options = [
1064 SURECOOKIE_INSTALLED_SERVICES_OPTION => [
1065 'section' => 'services',
1066 'sanitize' => [ Installed_Services::class, 'sanitize_registry' ],
1067 ],
1068 ];
1069
1070 /**
1071 * Filters the standalone options included in SureCookie export files.
1072 *
1073 * @since 1.4.0
1074 * @param array<string, array{section: string, sanitize: callable}> $options Option name => config.
1075 */
1076 $options = apply_filters( 'surecookie_transferable_options', $options );
1077
1078 return is_array( $options ) ? $options : [];
1079 }
1080
1081 /**
1082 * The selected section ids expanded with dependencies, or null when the
1083 * selection means "everything" (empty or explicit `all`).
1084 *
1085 * @param array<int, string> $section_ids Selected section ids.
1086 * @since 1.4.0
1087 * @return array<int, string>|null
1088 */
1089 private function resolve_selected_section_ids( array $section_ids ): ?array {
1090 $section_ids = array_values( array_filter( array_map( 'sanitize_key', $section_ids ) ) );
1091
1092 if ( empty( $section_ids ) || in_array( 'all', $section_ids, true ) ) {
1093 return null;
1094 }
1095
1096 $sections = $this->get_export_sections();
1097
1098 return $this->expand_section_dependencies(
1099 $this->expand_parent_sections( $section_ids, $sections ),
1100 $sections
1101 );
1102 }
1103 }
1104