PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.1
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.1
1.6.1 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.6.1, at inc/import-export/config-io.php

501 lines 14.8 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 // Recipient addresses default to this site's admin email, and an
192 // import writes options directly, bypassing the sanitiser and the
193 // scheduler; a restored key would arm this site's weekly revenue
194 // report to another site's addresses.
195 \SureDonation\Inc\Emails\Email_Reports::OPTION_KEY,
196 ];
197 }
198
199 /**
200 * Import campaigns (and their linked forms) from an exported payload.
201 *
202 * Campaigns and forms are created as published. Each form's embedded `formId`
203 * is rewritten from the old id to the new one across the form and campaign
204 * block content, the campaign's default-form link is remapped, and only
205 * `_suredonation_*` meta is written back.
206 *
207 * @param array<int, mixed> $campaigns Campaign export objects.
208 * @return array<string, int> Counts: { campaigns, forms }.
209 * @since 1.3.0
210 */
211 public static function import_campaigns( $campaigns ) {
212 $result = [
213 'campaigns' => 0,
214 'forms' => 0,
215 ];
216
217 if ( ! is_array( $campaigns ) ) {
218 return $result;
219 }
220
221 /**
222 * Maximum number of campaigns imported in one request. This path runs
223 * synchronously (2x wp_update_post per campaign) with no rollback, so a
224 * very large payload is capped to avoid a mid-import timeout.
225 *
226 * @param int $limit Campaign import cap.
227 * @since 1.3.0
228 */
229 $limit = (int) apply_filters( 'suredonation_import_campaigns_limit', 1000 );
230 $campaigns = array_slice( $campaigns, 0, max( 0, $limit ) );
231
232 foreach ( $campaigns as $campaign ) {
233 if ( ! is_array( $campaign ) ) {
234 continue;
235 }
236
237 $post_fields = is_array( $campaign['post'] ?? null ) ? $campaign['post'] : [];
238
239 $campaign_id = wp_insert_post(
240 [
241 'post_type' => Campaign_Cpt::POST_TYPE,
242 'post_status' => 'publish',
243 'post_title' => wp_slash( sanitize_text_field( Helper::get_string_value( $post_fields['post_title'] ?? '' ) ) ),
244 'post_content' => wp_slash( wp_kses_post( Helper::get_string_value( $post_fields['post_content'] ?? '' ) ) ),
245 'post_excerpt' => wp_slash( sanitize_textarea_field( Helper::get_string_value( $post_fields['post_excerpt'] ?? '' ) ) ),
246 ],
247 true
248 );
249
250 if ( is_wp_error( $campaign_id ) || ! $campaign_id ) {
251 continue;
252 }
253 $campaign_id = (int) $campaign_id;
254 ++$result['campaigns'];
255
256 $campaign_meta = is_array( $campaign['meta'] ?? null ) ? $campaign['meta'] : [];
257 $old_default = absint( Helper::get_string_value( $campaign_meta[ Campaign_Cpt::META_DEFAULT_FORM_ID ] ?? 0 ) );
258 $forms = is_array( $campaign['forms'] ?? null ) ? $campaign['forms'] : [];
259
260 $form_id_map = [];
261 $default_new = 0;
262
263 foreach ( $forms as $form ) {
264 if ( ! is_array( $form ) ) {
265 continue;
266 }
267 $old_form_id = absint( Helper::get_string_value( $form['id'] ?? 0 ) );
268 $form_post = is_array( $form['post'] ?? null ) ? $form['post'] : [];
269
270 $new_form_id = wp_insert_post(
271 [
272 'post_type' => Donation_Form::POST_TYPE,
273 'post_status' => 'publish',
274 'post_title' => wp_slash( sanitize_text_field( Helper::get_string_value( $form_post['post_title'] ?? '' ) ) ),
275 'post_content' => wp_slash( wp_kses_post( Helper::get_string_value( $form_post['post_content'] ?? '' ) ) ),
276 ],
277 true
278 );
279
280 if ( is_wp_error( $new_form_id ) || ! $new_form_id ) {
281 continue;
282 }
283 $new_form_id = (int) $new_form_id;
284 ++$result['forms'];
285
286 if ( $old_form_id > 0 ) {
287 $form_id_map[ $old_form_id ] = $new_form_id;
288 }
289 if ( $old_default > 0 && $old_form_id === $old_default ) {
290 $default_new = $new_form_id;
291 } elseif ( 0 === $default_new ) {
292 $default_new = $new_form_id;
293 }
294
295 $form_meta = is_array( $form['meta'] ?? null ) ? $form['meta'] : [];
296 $form_meta[ Donation_Form::META_CAMPAIGN_ID ] = $campaign_id;
297 self::write_suredonation_meta( $new_form_id, $form_meta );
298 }
299
300 // Rewrite formId references now that every new id is known.
301 foreach ( $form_id_map as $new_id ) {
302 self::rewrite_form_ids_in_post( $new_id, $form_id_map );
303 }
304 self::rewrite_form_ids_in_post( $campaign_id, $form_id_map );
305
306 if ( $default_new > 0 ) {
307 $campaign_meta[ Campaign_Cpt::META_DEFAULT_FORM_ID ] = $default_new;
308 }
309 self::write_suredonation_meta( $campaign_id, $campaign_meta );
310 }
311
312 return $result;
313 }
314
315 /**
316 * Import the settings blob with a Merge or Replace strategy.
317 *
318 * Never writes credential or operational keys: they are stripped from the
319 * incoming data, and on Replace the current values for those keys are
320 * preserved so a restore can't wipe live gateway credentials.
321 *
322 * @param array<string, mixed> $settings Incoming settings.
323 * @param string $mode 'merge' or 'replace'.
324 * @return array<string, int> { applied } count of applied keys.
325 * @since 1.3.0
326 */
327 public static function import_settings( $settings, $mode ) {
328 if ( ! is_array( $settings ) ) {
329 return [ 'applied' => 0 ];
330 }
331
332 $current = get_option( Helper::OPTION_NAME, [] );
333 if ( ! is_array( $current ) ) {
334 $current = [];
335 }
336
337 $excluded = array_merge( self::get_secret_option_keys(), self::get_non_portable_option_keys() );
338 foreach ( $excluded as $key ) {
339 unset( $settings[ $key ] );
340 }
341
342 // Sanitize the uploaded values (untrusted JSON): strip scripts/dangerous
343 // markup from string leaves while preserving structure and non-strings.
344 $sanitized = self::sanitize_import_values( $settings );
345 $settings = is_array( $sanitized ) ? $sanitized : [];
346
347 if ( 'replace' === $mode ) {
348 $preserved = array_intersect_key( $current, array_flip( $excluded ) );
349 $new = array_merge( $preserved, $settings );
350 } else {
351 $new = array_merge( $current, $settings );
352 }
353
354 update_option( Helper::OPTION_NAME, $new );
355
356 return [ 'applied' => count( $settings ) ];
357 }
358
359 /**
360 * Recursively sanitize imported setting values.
361 *
362 * The structure and non-string scalars (int/float/bool/null) are preserved;
363 * string leaves are run through wp_kses_post so an uploaded settings file
364 * cannot smuggle scripts/dangerous markup into a value, while still allowing
365 * the safe HTML some settings legitimately contain.
366 *
367 * @param mixed $value Value to sanitize.
368 * @return mixed Sanitized value.
369 * @since 1.3.0
370 */
371 private static function sanitize_import_values( $value ) {
372 if ( is_array( $value ) ) {
373 $clean = [];
374 foreach ( $value as $key => $item ) {
375 $clean[ $key ] = self::sanitize_import_values( $item );
376 }
377 return $clean;
378 }
379 if ( is_string( $value ) ) {
380 return wp_kses_post( $value );
381 }
382 return $value;
383 }
384
385 /**
386 * Write a post's `_suredonation_*` meta from an import payload.
387 *
388 * Non-SureDonation keys are ignored. Values are slashed for the meta API so
389 * JSON-string metas round-trip intact.
390 *
391 * @param int $post_id Post ID.
392 * @param array<string, mixed> $meta Meta key => value.
393 * @return void
394 * @since 1.3.0
395 */
396 private static function write_suredonation_meta( $post_id, $meta ) {
397 if ( ! is_array( $meta ) ) {
398 return;
399 }
400 foreach ( $meta as $key => $value ) {
401 if ( 0 !== strpos( (string) $key, '_suredonation_' ) ) {
402 continue;
403 }
404 $stored = ( is_array( $value ) || is_string( $value ) ) ? wp_slash( $value ) : $value;
405 update_post_meta( $post_id, (string) $key, $stored );
406 }
407 }
408
409 /**
410 * Rewrite embedded `formId` block attributes in a post's content using an
411 * old-id => new-id map (handles both numeric and quoted attribute forms).
412 *
413 * @param int $post_id Post whose content to rewrite.
414 * @param array<int, int> $map Old form id => new form id.
415 * @return void
416 * @since 1.3.0
417 */
418 private static function rewrite_form_ids_in_post( $post_id, $map ) {
419 if ( empty( $map ) ) {
420 return;
421 }
422 $post = get_post( $post_id );
423 if ( ! $post instanceof WP_Post ) {
424 return;
425 }
426
427 // Single pass over each "formId":<n> / "formId":"<n>" token. A greedy
428 // \d+ consumes the whole number so old id 12 does not match inside 123,
429 // and looking each match up once (rather than chained str_replace calls)
430 // prevents a freshly-written id from being rewritten again by a later
431 // map entry.
432 $content = preg_replace_callback(
433 '/"formId":("?)(\d+)\1/',
434 static function ( $matches ) use ( $map ) {
435 $old = (int) $matches[2];
436 if ( ! isset( $map[ $old ] ) ) {
437 return $matches[0];
438 }
439 $new = (int) $map[ $old ];
440 return '"' === $matches[1] ? '"formId":"' . $new . '"' : '"formId":' . $new;
441 },
442 $post->post_content
443 );
444
445 if ( is_string( $content ) && $content !== $post->post_content ) {
446 wp_update_post(
447 [
448 'ID' => $post_id,
449 'post_content' => wp_slash( $content ),
450 ]
451 );
452 }
453 }
454
455 /**
456 * Pluck the exportable post fields from a post object.
457 *
458 * @param WP_Post $post Post object.
459 * @return array<string, mixed> Post fields keyed by field name.
460 * @since 1.3.0
461 */
462 private static function export_post_fields( $post ) {
463 $fields = [];
464 foreach ( self::POST_FIELDS as $field ) {
465 $fields[ $field ] = $post->$field ?? '';
466 }
467 return $fields;
468 }
469
470 /**
471 * Collect a post's `_suredonation_*` meta as a key => value map.
472 *
473 * Only SureDonation-owned meta is exported; core/third-party meta is
474 * skipped. Single values are taken as stored (JSON-string metas such as the
475 * campaign meta round-trip verbatim).
476 *
477 * @param int $post_id Post ID.
478 * @return array<string, mixed> Meta values keyed by meta key.
479 * @since 1.3.0
480 */
481 private static function export_suredonation_meta( $post_id ) {
482 $all = get_post_meta( $post_id );
483 $meta = [];
484
485 if ( ! is_array( $all ) ) {
486 return $meta;
487 }
488
489 foreach ( $all as $key => $values ) {
490 if ( 0 !== strpos( (string) $key, '_suredonation_' ) ) {
491 continue;
492 }
493 $meta[ $key ] = is_array( $values ) && isset( $values[0] )
494 ? maybe_unserialize( $values[0] )
495 : '';
496 }
497
498 return $meta;
499 }
500 }
501