404 ] ); } if ( $existing && (int) $existing['form_id'] !== (int) $form['id'] ) { // A token for a different form being replayed against this one // — treat identically to "not found" rather than a distinct // error, so a caller probing tokens across forms learns nothing. return new \WP_Error( 'chargeforms_draft_not_found', __( 'This draft could not be found — it may have expired.', 'chargeforms' ), [ 'status' => 404 ] ); } if ( $existing && null !== $expected_updated_at && $existing['updated_at'] !== $expected_updated_at ) { // Concurrent edit — see class doc comment / this feature's // "Concurrent edits" requirement. Mirrors RestApi::update_form()'s // existing expected_updated_at precondition pattern exactly. return new \WP_Error( 'chargeforms_draft_conflict', __( 'This draft was updated elsewhere since you last loaded it.', 'chargeforms' ), [ 'status' => 409 ] ); } $values = self::sanitize_and_filter_values( $form, $raw_values ); $step = max( 0, (int) $current_step ); $hash = self::hash_values( $values, $step ); if ( $existing ) { if ( ! $rotate_token && $hash === $existing['values_hash'] ) { // Nothing changed since the last save — the whole point of // hashing is to make this the common case for autosave (a // user reading a long page, or an idle tab) cost zero writes. return [ 'token' => $token, 'expires_at' => $existing['expires_at'], 'unchanged' => true, ]; } $new_token = $rotate_token ? Draft_Token::generate() : $token; $update = [ 'current_step' => $step, 'response' => wp_json_encode( $values ), 'values_hash' => $hash, 'expires_at' => self::next_expiry( $existing['created_at'] ), ]; if ( $rotate_token ) { $update['token_hash'] = Draft_Token::hash( $new_token ); } Draft::update( $existing['id'], $update ); return [ 'token' => $new_token, 'expires_at' => $update['expires_at'], 'unchanged' => false, ]; } $new_token = Draft_Token::generate(); Draft::create( [ 'form_id' => $form['id'], 'current_step' => $step, 'values' => $values, 'values_hash' => $hash, 'source_url' => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only context capture, not a state-changing use of the referer. ], $new_token ); return [ 'token' => $new_token, 'expires_at' => gmdate( 'Y-m-d H:i:s', time() + Draft::default_ttl_seconds() ), 'unchanged' => false, ]; } /** * Restores a draft for display — re-validates the stored values * against the CURRENT form definition (a form can be edited after a * draft was saved against an earlier version of it) rather than * trusting the stored JSON blindly: any field id the draft has a * value for that no longer exists on the live form is silently * dropped, never restored. Returns null for anything invalid * (not-found/expired/wrong-form) — same generic outcome regardless * of which, for the same reason save() above treats them identically. * Does NOT rotate the token (see class doc comment) and does NOT * modify the draft row at all — purely a read. */ public static function restore( array $form, $token ) { $draft = Draft::get_by_token( $token ); if ( ! $draft || (int) $draft['form_id'] !== (int) $form['id'] ) { return null; } $current_ids = array_column( Field_Sanitizer::flatten_submittable_fields( $form['fields'] ), 'id' ); $values = array_intersect_key( $draft['values'], array_flip( $current_ids ) ); return [ 'token' => $token, 'values' => $values, 'current_step' => $draft['current_step'], 'saved_at' => $draft['updated_at'], 'expires_at' => $draft['expires_at'], ]; } /** The logged-in-user auto-resume path — no token needed at all, see Draft::get_active_for_user()'s own doc comment. Same restore/re-validation logic as restore() above, just a different lookup. */ public static function restore_for_user( array $form, $user_id ) { $draft = Draft::get_active_for_user( $user_id, $form['id'] ); if ( ! $draft ) { return null; } return self::restore( $form, self::reissue_token_for( $draft ) ); } /** * The logged-in auto-resume path has no token to read from a URL — * one is minted fresh, silently, purely so restore()'s single * token-based code path can stay the one implementation both resume * routes share, rather than a second parallel "restore by id" * variant. Rotating here is free (the token was never exposed to * begin with) and means the auto-detected banner hands the browser a * real, usable token for continued autosave the moment it's shown. */ private static function reissue_token_for( array $draft ) { $token = Draft_Token::generate(); Draft::update( $draft['id'], [ 'token_hash' => Draft_Token::hash( $token ) ] ); return $token; } /** * Sends (or re-sends) a resume-link email — always rotates the token * first (see class doc comment: handing a token to a new channel is * exactly the "appropriate" moment this feature's own requirement * calls out), so an old copy of a previous resume email can never be * used again once a newer one has been requested. Rate-limited at the * call site (RestApi), not here — see that route's own comment for why. */ public static function email_resume_link( array $form, $token, $email ) { $draft = Draft::get_by_token( $token ); if ( ! $draft || (int) $draft['form_id'] !== (int) $form['id'] ) { return new \WP_Error( 'chargeforms_draft_not_found', __( 'This draft could not be found — it may have expired.', 'chargeforms' ), [ 'status' => 404 ] ); } if ( ! is_email( $email ) ) { return new \WP_Error( 'chargeforms_invalid_email', __( 'Please provide a valid email address.', 'chargeforms' ), [ 'status' => 400 ] ); } $new_token = Draft_Token::generate(); Draft::update( $draft['id'], [ 'token_hash' => Draft_Token::hash( $new_token ), 'last_email_sent_at' => current_time( 'mysql', true ), ] ); $resume_url = add_query_arg( 'chargeforms_resume', $new_token, $draft['source_url'] ?: home_url( '/' ) ); $site_name = get_bloginfo( 'name' ); /* translators: %s: site name */ $subject = sprintf( __( 'Resume your form on %s', 'chargeforms' ), $site_name ); $body = self::build_resume_email_body( $resume_url, $site_name, $draft['expires_at'] ); // Notifications::send() — the existing generic, logged, auto-retried // email primitive (also used for entry notifications) — reused here // rather than a bare wp_mail() call, so a resume email failure gets // the same visibility (Activity Log) and retry behavior any other // ChargeForms email already gets. entry_id is 0: no entry exists yet. Notifications::send( $form['id'], 0, $email, $subject, $body ); return true; } private static function build_resume_email_body( $resume_url, $site_name, $expires_at ) { $expires_local = mysql2date( get_option( 'date_format' ), get_date_from_gmt( $expires_at ) ); $lines = [ sprintf( /* translators: %s: site name */ __( 'You have an unfinished form on %s.', 'chargeforms' ), $site_name ), '', __( 'Continue where you left off:', 'chargeforms' ), esc_url_raw( $resume_url ), '', /* translators: %s: expiry date */ sprintf( __( 'This link works until %s.', 'chargeforms' ), $expires_local ), ]; return implode( "\n", $lines ); } /** * Called by SubmissionHandler once a real Entry exists from a * finished draft — the draft is fully superseded and is deleted * outright (not soft-marked and left for the cleanup cron), which * also immediately invalidates its resume token and removes any * staged files no field already promoted (Entry::create() has * already run by this point, so nothing under wp_chargeforms_draft_files * is still needed). */ public static function convert_to_entry( array $draft, $entry_id ) { Draft::delete( $draft['id'] ); } /** Explicit discard (a "start over" action) — same as conversion, minus the entry. */ public static function discard( $token ) { $draft = Draft::get_by_token( $token ); if ( ! $draft ) { return false; } Draft::delete( $draft['id'] ); return true; } private static function sanitize_and_filter_values( array $form, array $raw_values ) { $fields = Field_Sanitizer::flatten_submittable_fields( $form['fields'] ); $logic_rules = \ChargeForms\Includes\Logic\Evaluator::extract_field_rules( $fields ); $logic_extra = \ChargeForms\Includes\Logic\Context_Builder::build_extra( $logic_rules ); $logic_result = \ChargeForms\Includes\Logic\Evaluator::evaluate_rules( $logic_rules, $fields, [ 'field_values' => $raw_values, 'extra' => $logic_extra ] ); $excluded = array_merge( Field_Sanitizer::NON_INPUT_TYPES, self::NEVER_PERSISTED_TYPES ); $values = []; foreach ( $fields as $field ) { if ( in_array( $field['type'], $excluded, true ) ) { continue; } $key = $field['id']; if ( ! ( $logic_result['field_state'][ $key ]['visible'] ?? true ) ) { // Currently hidden by conditional logic — a draft is allowed // to be incomplete, but it must never retain a value for a // field the visitor can't currently see, same anti-tampering // posture SubmissionHandler::handle() already applies to a // real submission. continue; } if ( ! array_key_exists( $key, $raw_values ) ) { continue; } $raw = $raw_values[ $key ]; if ( in_array( $field['type'], self::FILE_FIELD_TYPES, true ) ) { // A file field's draft value is only ever a staged file's // own URL, written by record_staged_file_reference() below // via the immediate-upload endpoint — never anything the // client can freely set through the ordinary save-values // path, which would let a client claim an arbitrary URL as // "the uploaded file" with nothing ever having been // uploaded at all. continue; } if ( 'repeat_container' === $field['type'] ) { $rows = is_array( $raw ) ? array_values( $raw ) : []; $values[ $key ] = Field_Sanitizer::sanitize_repeater_value( $rows ); continue; } $values[ $key ] = Field_Sanitizer::sanitize_value( $raw ); } // File field values are populated from the draft's OWN already- // staged records (Draft::get_files()), not from $raw_values — see // the FILE_FIELD_TYPES branch above for why the client can't set // these directly. Merged back in by the REST save handler, which // has the draft id this method deliberately isn't given (kept // stateless/pure otherwise) — see RestApi::save_draft(). return $values; } /** * Records a successfully staged file's URL directly into a draft's * own values, bypassing the generic (and file-type-excluding) * sanitize_and_filter_values() path above — called by the REST * upload-staging route right after Draft_File_Stager::stage() * succeeds, so the file shows up in the draft's restored values on * the very next fetch without waiting for a separate save-values * round trip. */ public static function record_staged_file( array $draft, $field_id, $file_url ) { $values = $draft['values']; $values[ $field_id ] = $file_url; Draft::update( $draft['id'], [ 'response' => wp_json_encode( $values ), 'values_hash' => self::hash_values( $values, $draft['current_step'] ), 'expires_at' => self::next_expiry( $draft['created_at'] ), ] ); } private static function hash_values( array $values, $step ) { return md5( wp_json_encode( $values ) . '|' . (int) $step ); } /** Sliding expiration (a draft actively being saved keeps getting more time) capped at Draft::max_lifetime_seconds() from ORIGINAL creation, so a draft can't be resurrected indefinitely just by autosaving it forever. */ private static function next_expiry( $created_at ) { $slide = time() + Draft::default_ttl_seconds(); $cap = strtotime( $created_at . ' UTC' ) + Draft::max_lifetime_seconds(); return gmdate( 'Y-m-d H:i:s', min( $slide, $cap ) ); } }