value JSON blob, same as * Entry::create()'s own `response` column) so a reader who already knows * Entry's shape doesn't have to learn a second vocabulary for the same * idea. */ class Draft { const STATUSES = [ 'active', 'converted', 'expired' ]; public static function table() { global $wpdb; return $wpdb->prefix . 'chargeforms_drafts'; } public static function files_table() { global $wpdb; return $wpdb->prefix . 'chargeforms_draft_files'; } /** * `$token` is the RAW token — hashed here, once, right before storage; * nothing above this layer should ever compute or handle the hash * directly. Returns the new draft's id (internal use only — see this * class's own doc comment and Draft_Service, which never lets an id * leak into anything returned to a client). */ public static function create( array $data, $token ) { global $wpdb; $row = [ 'form_id' => (int) ( $data['form_id'] ?? 0 ), 'token_hash' => Draft_Token::hash( $token ), 'owner_type' => is_user_logged_in() ? 'user' : 'anon', 'user_id' => is_user_logged_in() ? get_current_user_id() : null, 'current_step' => max( 0, (int) ( $data['current_step'] ?? 0 ) ), 'response' => wp_json_encode( $data['values'] ?? [] ), 'values_hash' => $data['values_hash'] ?? '', 'ip_address' => Settings::get( 'disable_ip_logging' ) ? '' : self::client_ip(), 'source_url' => isset( $data['source_url'] ) ? esc_url_raw( $data['source_url'] ) : '', 'status' => 'active', 'expires_at' => $data['expires_at'] ?? gmdate( 'Y-m-d H:i:s', time() + self::default_ttl_seconds() ), 'created_at' => current_time( 'mysql', true ), 'updated_at' => current_time( 'mysql', true ), ]; $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- custom plugin table, no core API exists for it. self::table(), $row, [ '%d', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ] ); return $wpdb->insert_id; } public static function default_ttl_seconds() { return max( 1, (int) Settings::get( 'save_resume_expiry_days', 30 ) ) * DAY_IN_SECONDS; } /** The hard ceiling a sliding expiration (see Draft_Service::touch_expiry()) can never push past — a draft actively being saved every day forever would otherwise never expire. 3x the configured TTL, matching no single existing convention in this codebase but a deliberately simple, explainable multiplier. */ public static function max_lifetime_seconds() { return self::default_ttl_seconds() * 3; } private static function client_ip() { return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; } /** * The only lookup a resume request ever uses. Takes the RAW token * (from the URL/POST body), hashes it, and matches against the stored * hash — never queries or exposes the numeric id to the caller's * decision-making beyond what's needed internally. Returns null for * any invalid, expired, or non-active draft — callers get one boolean * outcome (found-and-usable, or not), never a reason that could help * an attacker distinguish "wrong token" from "expired" from * "belongs to someone else" (see Draft_Service::restore() for how the * user-facing message stays generic). */ public static function get_by_token( $token ) { if ( ! is_string( $token ) || '' === $token ) { return null; } global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed. 'SELECT * FROM %i WHERE token_hash = %s AND status = %s LIMIT 1', self::table(), Draft_Token::hash( $token ), 'active' ), ARRAY_A ); if ( ! $row ) { return null; } if ( strtotime( $row['expires_at'] . ' UTC' ) < time() ) { return null; } return self::hydrate( $row ); } /** Internal-only (never exposed via a token) — the logged-in-user auto-resume path: "you already have an unfinished draft for this exact form." One row per (user, form) is enforced at the service layer, not a DB constraint, since a form is intentionally re-resumable after being explicitly discarded and re-started. */ public static function get_active_for_user( $user_id, $form_id ) { global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed. 'SELECT * FROM %i WHERE user_id = %d AND form_id = %d AND status = %s ORDER BY id DESC LIMIT 1', self::table(), $user_id, $form_id, 'active' ), ARRAY_A ); if ( ! $row || strtotime( $row['expires_at'] . ' UTC' ) < time() ) { return null; } return self::hydrate( $row ); } /** Internal id-based lookup — used only by code that already legitimately holds the id (Draft_Service right after create(), the cleanup cron's own batch queries). Never called with an id sourced from client input. */ public static function get( $id ) { global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', self::table(), (int) $id ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed. return $row ? self::hydrate( $row ) : null; } /** Whitelisted column update, same discipline as Subscription::update() — every caller passes only the columns it knows changed. */ public static function update( $id, array $data ) { global $wpdb; $allowed = [ 'current_step' => '%d', 'response' => '%s', 'values_hash' => '%s', 'status' => '%s', 'expires_at' => '%s', 'token_hash' => '%s', 'last_email_sent_at' => '%s', ]; $row = []; $formats = []; foreach ( $allowed as $column => $format ) { if ( array_key_exists( $column, $data ) ) { $row[ $column ] = $data[ $column ]; $formats[] = $format; } } if ( ! $row ) { return false; } $row['updated_at'] = current_time( 'mysql', true ); $formats[] = '%s'; return false !== $wpdb->update( self::table(), $row, [ 'id' => (int) $id ], $formats, [ '%d' ] ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed; not worth an object-cache layer for these tables. } public static function delete( $id ) { global $wpdb; foreach ( self::get_files( $id ) as $file ) { Draft_File_Stager::delete_file( $file ); } $wpdb->delete( self::files_table(), [ 'draft_id' => (int) $id ], [ '%d' ] ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed; not worth an object-cache layer for these tables. $wpdb->delete( self::table(), [ 'id' => (int) $id ], [ '%d' ] ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed; not worth an object-cache layer for these tables. return true; } /** Batched, for the cleanup cron — never an unbounded DELETE. */ public static function expired_ids( $limit ) { global $wpdb; return array_map( 'intval', $wpdb->get_col( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed. "SELECT id FROM %i WHERE status = 'active' AND expires_at < %s LIMIT %d", self::table(), current_time( 'mysql', true ), (int) $limit ) ) ); } public static function record_file( $draft_id, $field_id, array $file ) { global $wpdb; // Re-staging the same field (user picks a different file before // ever finishing the draft) replaces the previous row rather than // accumulating orphans — the old file on disk is removed first. $existing = $wpdb->get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed. 'SELECT * FROM %i WHERE draft_id = %d AND field_id = %s', self::files_table(), (int) $draft_id, $field_id ), ARRAY_A ); if ( $existing ) { Draft_File_Stager::delete_file( $existing ); $wpdb->delete( self::files_table(), [ 'id' => (int) $existing['id'] ], [ '%d' ] ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed; not worth an object-cache layer for these tables. } $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- custom plugin table, no core API exists for it. self::files_table(), [ 'draft_id' => (int) $draft_id, 'field_id' => $field_id, 'file_path' => $file['path'], 'file_url' => $file['url'], 'original_name' => $file['name'] ?? '', 'created_at' => current_time( 'mysql', true ), ], [ '%d', '%s', '%s', '%s', '%s', '%s' ] ); return $wpdb->insert_id; } public static function get_files( $draft_id ) { global $wpdb; return $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE draft_id = %d', self::files_table(), (int) $draft_id ), ARRAY_A ) ?: []; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed. } /** * Called by Draft_Service::convert_to_entry() when a staged file is * being promoted into a real Entry attachment. Removes just this one * field's row from wp_chargeforms_draft_files WITHOUT deleting the * physical file — the file stays exactly where it was uploaded * (under the isolated chargeforms-private/drafts/ subfolder — see * Draft_File_Stager), now referenced by the real entry instead. This * is what stops the eventual Draft::delete()/cleanup cron from * deleting a file a real entry now depends on: once detached, the * draft's own file list no longer knows about it, so cleanup can * never touch it. */ public static function detach_file( $draft_id, $field_id ) { global $wpdb; $wpdb->delete( self::files_table(), [ 'draft_id' => (int) $draft_id, 'field_id' => $field_id ], [ '%d', '%s' ] ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed; not worth an object-cache layer for these tables. } public static function get_file_for_field( $draft_id, $field_id ) { global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed. 'SELECT * FROM %i WHERE draft_id = %d AND field_id = %s', self::files_table(), (int) $draft_id, $field_id ), ARRAY_A ); return $row ?: null; } private static function hydrate( array $row ) { $row['id'] = (int) $row['id']; $row['form_id'] = (int) $row['form_id']; $row['user_id'] = $row['user_id'] ? (int) $row['user_id'] : null; $row['current_step'] = (int) $row['current_step']; $row['values'] = json_decode( $row['response'] ?? '[]', true ) ?: []; return $row; } }