PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.4.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.4.0
1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
suredonation / inc / import-export / config-io.php

config-io.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.4.0, at inc/import-export/config-io.php

496 lines 14.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Campaign and settings config import/export (JSON).
4 *
5 * Handles the one-shot JSON operations of the Import & Export feature — the
6 * small, config-shaped data that doesn't warrant the batched CSV path:
7 * - Campaigns (post + `_suredonation_*` meta + linked donation forms), for
8 * site-to-site moves and backups.
9 * - Settings (the `suredonation_options` blob), with credentials stripped.
10 *
11 * This PR implements the export side; the import side (Merge/Replace, formId
12 * rewrite) lands with the import tasks.
13 *
14 * @package SureDonation
15 * @since 1.3.0
16 */
17
18 namespace SureDonation\Inc\Import_Export;
19
20 use SureDonation\Inc\API\Settings_API;
21 use SureDonation\Inc\Campaigns\Campaign_Cpt;
22 use SureDonation\Inc\Database\Base;
23 use SureDonation\Inc\Helper;
24 use SureDonation\Inc\Payments\Payment_Helper;
25 use SureDonation\Inc\Post_Types\Donation_Form;
26 use WP_Post;
27
28 // Exit if accessed directly.
29 if ( ! defined( 'ABSPATH' ) ) {
30 exit;
31 }
32
33 /**
34 * Config import/export helper.
35 *
36 * @since 1.3.0
37 */
38 class Config_IO {
39
40 /**
41 * Post fields carried in a campaign/form export — enough to recreate the
42 * post on import without leaking site-specific IDs beyond the reference id.
43 *
44 * @var array<int, string>
45 * @since 1.3.0
46 */
47 const POST_FIELDS = [
48 'post_title',
49 'post_content',
50 'post_excerpt',
51 'post_status',
52 'post_name',
53 'post_type',
54 'menu_order',
55 ];
56
57 /**
58 * Export campaigns (with their linked donation forms) as a portable array.
59 *
60 * @param array<int, int> $campaign_ids Specific campaign IDs, or empty for all.
61 * @return array<int, array<string, mixed>> Campaign export objects.
62 * @since 1.3.0
63 */
64 public static function export_campaigns( $campaign_ids = [] ) {
65 /**
66 * Maximum number of campaigns exported in one pass. Bounds memory/time
67 * on sites with a very large number of campaigns (each campaign also
68 * loads its linked forms + meta).
69 *
70 * @param int $limit Campaign export cap.
71 * @since 1.3.0
72 */
73 $limit = (int) apply_filters( 'suredonation_export_campaigns_limit', 10000 );
74
75 $args = [
76 'post_type' => Campaign_Cpt::POST_TYPE,
77 'post_status' => 'any',
78 'posts_per_page' => $limit,
79 'fields' => 'ids',
80 'no_found_rows' => true,
81 ];
82
83 if ( ! empty( $campaign_ids ) ) {
84 $args['post__in'] = array_map( 'absint', $campaign_ids );
85 }
86
87 $ids = get_posts( $args );
88 $campaigns = [];
89
90 foreach ( $ids as $campaign_id ) {
91 $campaign_id = absint( $campaign_id );
92 $post = get_post( $campaign_id );
93 if ( ! $post instanceof WP_Post ) {
94 continue;
95 }
96
97 $forms = [];
98 foreach ( Donation_Form::get_forms_by_campaign( $campaign_id ) as $form ) {
99 if ( ! $form instanceof WP_Post ) {
100 continue;
101 }
102 $forms[] = [
103 'id' => $form->ID,
104 'post' => self::export_post_fields( $form ),
105 'meta' => self::export_suredonation_meta( $form->ID ),
106 ];
107 }
108
109 $campaigns[] = [
110 'id' => $campaign_id,
111 'post' => self::export_post_fields( $post ),
112 'meta' => self::export_suredonation_meta( $campaign_id ),
113 'forms' => $forms,
114 ];
115 }
116
117 return $campaigns;
118 }
119
120 /**
121 * Export the SureDonation settings blob with credentials removed.
122 *
123 * Strips the option sub-keys that hold secrets (gateway API keys/tokens/
124 * webhook secrets, AI key, captcha secrets) so nothing sensitive ever
125 * leaves the site in a downloadable file. Site-specific / license options
126 * are not included.
127 *
128 * @return array<string, mixed> Settings safe to export.
129 * @since 1.3.0
130 */
131 public static function export_settings() {
132 $options = get_option( Helper::OPTION_NAME, [] );
133 if ( ! is_array( $options ) ) {
134 return [];
135 }
136
137 $exclude = array_merge(
138 self::get_secret_option_keys(),
139 self::get_non_portable_option_keys()
140 );
141 foreach ( $exclude as $key ) {
142 unset( $options[ $key ] );
143 }
144
145 return $options;
146 }
147
148 /**
149 * Option sub-keys that must never be exported (credentials/secrets).
150 *
151 * @return array<int, string> Secret sub-key names.
152 * @since 1.3.0
153 */
154 public static function get_secret_option_keys() {
155 $keys = [
156 Payment_Helper::OPTION_KEY,
157 Settings_API::AI_OPTION_KEY,
158 Settings_API::SPAM_OPTION_KEY,
159 ];
160
161 /**
162 * Option sub-keys that hold secrets and must never be exported. The
163 * settings blob is shared with SureDonation Pro and future gateways;
164 * each new secret-bearing key must register here so it is stripped from
165 * every settings export (and preserved on import).
166 *
167 * @param array<int, string> $keys Secret sub-key names.
168 * @since 1.3.0
169 */
170 $keys = apply_filters( 'suredonation_export_secret_option_keys', $keys );
171
172 return is_array( $keys ) ? array_values( array_filter( $keys, 'is_string' ) ) : [];
173 }
174
175 /**
176 * Option sub-keys excluded from export because they are operational/state,
177 * not user settings, and are unsafe or meaningless to restore (schema
178 * versions, analytics queues, onboarding flags).
179 *
180 * @return array<int, string> Non-portable sub-key names.
181 * @since 1.3.0
182 */
183 public static function get_non_portable_option_keys() {
184 return [
185 Base::VERSION_OPTION_KEY,
186 'usage_events_pending',
187 'usage_events_pushed',
188 'onboarding_completed',
189 'onboarding_user_details',
190 'onboarding_lead_sent_at',
191 ];
192 }
193
194 /**
195 * Import campaigns (and their linked forms) from an exported payload.
196 *
197 * Campaigns and forms are created as published. Each form's embedded `formId`
198 * is rewritten from the old id to the new one across the form and campaign
199 * block content, the campaign's default-form link is remapped, and only
200 * `_suredonation_*` meta is written back.
201 *
202 * @param array<int, mixed> $campaigns Campaign export objects.
203 * @return array<string, int> Counts: { campaigns, forms }.
204 * @since 1.3.0
205 */
206 public static function import_campaigns( $campaigns ) {
207 $result = [
208 'campaigns' => 0,
209 'forms' => 0,
210 ];
211
212 if ( ! is_array( $campaigns ) ) {
213 return $result;
214 }
215
216 /**
217 * Maximum number of campaigns imported in one request. This path runs
218 * synchronously (2x wp_update_post per campaign) with no rollback, so a
219 * very large payload is capped to avoid a mid-import timeout.
220 *
221 * @param int $limit Campaign import cap.
222 * @since 1.3.0
223 */
224 $limit = (int) apply_filters( 'suredonation_import_campaigns_limit', 1000 );
225 $campaigns = array_slice( $campaigns, 0, max( 0, $limit ) );
226
227 foreach ( $campaigns as $campaign ) {
228 if ( ! is_array( $campaign ) ) {
229 continue;
230 }
231
232 $post_fields = is_array( $campaign['post'] ?? null ) ? $campaign['post'] : [];
233
234 $campaign_id = wp_insert_post(
235 [
236 'post_type' => Campaign_Cpt::POST_TYPE,
237 'post_status' => 'publish',
238 'post_title' => wp_slash( sanitize_text_field( Helper::get_string_value( $post_fields['post_title'] ?? '' ) ) ),
239 'post_content' => wp_slash( wp_kses_post( Helper::get_string_value( $post_fields['post_content'] ?? '' ) ) ),
240 'post_excerpt' => wp_slash( sanitize_textarea_field( Helper::get_string_value( $post_fields['post_excerpt'] ?? '' ) ) ),
241 ],
242 true
243 );
244
245 if ( is_wp_error( $campaign_id ) || ! $campaign_id ) {
246 continue;
247 }
248 $campaign_id = (int) $campaign_id;
249 ++$result['campaigns'];
250
251 $campaign_meta = is_array( $campaign['meta'] ?? null ) ? $campaign['meta'] : [];
252 $old_default = absint( Helper::get_string_value( $campaign_meta[ Campaign_Cpt::META_DEFAULT_FORM_ID ] ?? 0 ) );
253 $forms = is_array( $campaign['forms'] ?? null ) ? $campaign['forms'] : [];
254
255 $form_id_map = [];
256 $default_new = 0;
257
258 foreach ( $forms as $form ) {
259 if ( ! is_array( $form ) ) {
260 continue;
261 }
262 $old_form_id = absint( Helper::get_string_value( $form['id'] ?? 0 ) );
263 $form_post = is_array( $form['post'] ?? null ) ? $form['post'] : [];
264
265 $new_form_id = wp_insert_post(
266 [
267 'post_type' => Donation_Form::POST_TYPE,
268 'post_status' => 'publish',
269 'post_title' => wp_slash( sanitize_text_field( Helper::get_string_value( $form_post['post_title'] ?? '' ) ) ),
270 'post_content' => wp_slash( wp_kses_post( Helper::get_string_value( $form_post['post_content'] ?? '' ) ) ),
271 ],
272 true
273 );
274
275 if ( is_wp_error( $new_form_id ) || ! $new_form_id ) {
276 continue;
277 }
278 $new_form_id = (int) $new_form_id;
279 ++$result['forms'];
280
281 if ( $old_form_id > 0 ) {
282 $form_id_map[ $old_form_id ] = $new_form_id;
283 }
284 if ( $old_default > 0 && $old_form_id === $old_default ) {
285 $default_new = $new_form_id;
286 } elseif ( 0 === $default_new ) {
287 $default_new = $new_form_id;
288 }
289
290 $form_meta = is_array( $form['meta'] ?? null ) ? $form['meta'] : [];
291 $form_meta[ Donation_Form::META_CAMPAIGN_ID ] = $campaign_id;
292 self::write_suredonation_meta( $new_form_id, $form_meta );
293 }
294
295 // Rewrite formId references now that every new id is known.
296 foreach ( $form_id_map as $new_id ) {
297 self::rewrite_form_ids_in_post( $new_id, $form_id_map );
298 }
299 self::rewrite_form_ids_in_post( $campaign_id, $form_id_map );
300
301 if ( $default_new > 0 ) {
302 $campaign_meta[ Campaign_Cpt::META_DEFAULT_FORM_ID ] = $default_new;
303 }
304 self::write_suredonation_meta( $campaign_id, $campaign_meta );
305 }
306
307 return $result;
308 }
309
310 /**
311 * Import the settings blob with a Merge or Replace strategy.
312 *
313 * Never writes credential or operational keys: they are stripped from the
314 * incoming data, and on Replace the current values for those keys are
315 * preserved so a restore can't wipe live gateway credentials.
316 *
317 * @param array<string, mixed> $settings Incoming settings.
318 * @param string $mode 'merge' or 'replace'.
319 * @return array<string, int> { applied } count of applied keys.
320 * @since 1.3.0
321 */
322 public static function import_settings( $settings, $mode ) {
323 if ( ! is_array( $settings ) ) {
324 return [ 'applied' => 0 ];
325 }
326
327 $current = get_option( Helper::OPTION_NAME, [] );
328 if ( ! is_array( $current ) ) {
329 $current = [];
330 }
331
332 $excluded = array_merge( self::get_secret_option_keys(), self::get_non_portable_option_keys() );
333 foreach ( $excluded as $key ) {
334 unset( $settings[ $key ] );
335 }
336
337 // Sanitize the uploaded values (untrusted JSON): strip scripts/dangerous
338 // markup from string leaves while preserving structure and non-strings.
339 $sanitized = self::sanitize_import_values( $settings );
340 $settings = is_array( $sanitized ) ? $sanitized : [];
341
342 if ( 'replace' === $mode ) {
343 $preserved = array_intersect_key( $current, array_flip( $excluded ) );
344 $new = array_merge( $preserved, $settings );
345 } else {
346 $new = array_merge( $current, $settings );
347 }
348
349 update_option( Helper::OPTION_NAME, $new );
350
351 return [ 'applied' => count( $settings ) ];
352 }
353
354 /**
355 * Recursively sanitize imported setting values.
356 *
357 * The structure and non-string scalars (int/float/bool/null) are preserved;
358 * string leaves are run through wp_kses_post so an uploaded settings file
359 * cannot smuggle scripts/dangerous markup into a value, while still allowing
360 * the safe HTML some settings legitimately contain.
361 *
362 * @param mixed $value Value to sanitize.
363 * @return mixed Sanitized value.
364 * @since 1.3.0
365 */
366 private static function sanitize_import_values( $value ) {
367 if ( is_array( $value ) ) {
368 $clean = [];
369 foreach ( $value as $key => $item ) {
370 $clean[ $key ] = self::sanitize_import_values( $item );
371 }
372 return $clean;
373 }
374 if ( is_string( $value ) ) {
375 return wp_kses_post( $value );
376 }
377 return $value;
378 }
379
380 /**
381 * Write a post's `_suredonation_*` meta from an import payload.
382 *
383 * Non-SureDonation keys are ignored. Values are slashed for the meta API so
384 * JSON-string metas round-trip intact.
385 *
386 * @param int $post_id Post ID.
387 * @param array<string, mixed> $meta Meta key => value.
388 * @return void
389 * @since 1.3.0
390 */
391 private static function write_suredonation_meta( $post_id, $meta ) {
392 if ( ! is_array( $meta ) ) {
393 return;
394 }
395 foreach ( $meta as $key => $value ) {
396 if ( 0 !== strpos( (string) $key, '_suredonation_' ) ) {
397 continue;
398 }
399 $stored = ( is_array( $value ) || is_string( $value ) ) ? wp_slash( $value ) : $value;
400 update_post_meta( $post_id, (string) $key, $stored );
401 }
402 }
403
404 /**
405 * Rewrite embedded `formId` block attributes in a post's content using an
406 * old-id => new-id map (handles both numeric and quoted attribute forms).
407 *
408 * @param int $post_id Post whose content to rewrite.
409 * @param array<int, int> $map Old form id => new form id.
410 * @return void
411 * @since 1.3.0
412 */
413 private static function rewrite_form_ids_in_post( $post_id, $map ) {
414 if ( empty( $map ) ) {
415 return;
416 }
417 $post = get_post( $post_id );
418 if ( ! $post instanceof WP_Post ) {
419 return;
420 }
421
422 // Single pass over each "formId":<n> / "formId":"<n>" token. A greedy
423 // \d+ consumes the whole number so old id 12 does not match inside 123,
424 // and looking each match up once (rather than chained str_replace calls)
425 // prevents a freshly-written id from being rewritten again by a later
426 // map entry.
427 $content = preg_replace_callback(
428 '/"formId":("?)(\d+)\1/',
429 static function ( $matches ) use ( $map ) {
430 $old = (int) $matches[2];
431 if ( ! isset( $map[ $old ] ) ) {
432 return $matches[0];
433 }
434 $new = (int) $map[ $old ];
435 return '"' === $matches[1] ? '"formId":"' . $new . '"' : '"formId":' . $new;
436 },
437 $post->post_content
438 );
439
440 if ( is_string( $content ) && $content !== $post->post_content ) {
441 wp_update_post(
442 [
443 'ID' => $post_id,
444 'post_content' => wp_slash( $content ),
445 ]
446 );
447 }
448 }
449
450 /**
451 * Pluck the exportable post fields from a post object.
452 *
453 * @param WP_Post $post Post object.
454 * @return array<string, mixed> Post fields keyed by field name.
455 * @since 1.3.0
456 */
457 private static function export_post_fields( $post ) {
458 $fields = [];
459 foreach ( self::POST_FIELDS as $field ) {
460 $fields[ $field ] = $post->$field ?? '';
461 }
462 return $fields;
463 }
464
465 /**
466 * Collect a post's `_suredonation_*` meta as a key => value map.
467 *
468 * Only SureDonation-owned meta is exported; core/third-party meta is
469 * skipped. Single values are taken as stored (JSON-string metas such as the
470 * campaign meta round-trip verbatim).
471 *
472 * @param int $post_id Post ID.
473 * @return array<string, mixed> Meta values keyed by meta key.
474 * @since 1.3.0
475 */
476 private static function export_suredonation_meta( $post_id ) {
477 $all = get_post_meta( $post_id );
478 $meta = [];
479
480 if ( ! is_array( $all ) ) {
481 return $meta;
482 }
483
484 foreach ( $all as $key => $values ) {
485 if ( 0 !== strpos( (string) $key, '_suredonation_' ) ) {
486 continue;
487 }
488 $meta[ $key ] = is_array( $values ) && isset( $values[0] )
489 ? maybe_unserialize( $values[0] )
490 : '';
491 }
492
493 return $meta;
494 }
495 }
496