Export/Erase Personal Data covers * OW Forms submissions, and so does OW Consent's own DSAR fulfilment (it * iterates the very same core filters). * * 4. Retention. Our link rows are pruned on the daily maintenance cron using * the `retention_form_data_days` setting, and link rows whose submission has * disappeared are swept as orphans. * * WHAT IT DOES **NOT** DO (report honestly, do not oversell): * * - It does not write a `form_submission` event into the hash-chained consent * ledger. `OWCO_Consent::ledger_append()` is protected and the chain must not * be written from outside; the link row references the existing ledger entry * instead of creating one. * - It cannot link a submission when OW Forms is configured NOT to store * submissions (`store_submissions` off): there is no row to attach to. * - It cannot recover the consent of visitors who submitted before this * version shipped — linking starts at the first submission after upgrade. * - Matching for DSAR is done on the SHA-256 e-mail hash computed by * `OWFO_GDPR::email_hash()` (OW Forms' own salted hash) with a fallback on * the clear-text `email` column OW Forms also stores. It is NOT a * zero-knowledge scheme: OW Forms keeps the address in clear text so it can * answer the sender. * - It never modifies the OW Forms plugin, its tables or its options. * * Safe to require unconditionally: requiring this file registers nothing on its * own, and `init()` returns immediately when OW Forms is not an active plugin. * * Requires WordPress 6.2 or later: every table identifier in this file is bound * through the `%i` placeholder of `$wpdb->prepare()`, which earlier versions do * not understand. The one exception is the `CREATE TABLE` in `install()`, which * dbDelta() parses itself and must therefore keep a literal table name. * * @package OW_Consent * @since 1.2.0 */ if ( ! defined( 'ABSPATH' ) ) { exit; } /** * OW Forms integration. */ class OWCO_Forms { /** * Schema version of the link table. Bump to trigger a dbDelta pass. */ const DB_VERSION = '1'; /** * Option holding the schema version above. */ const DB_VERSION_OPTION = 'owco_forms_db_version'; /** * Canonical plugin file of OW Forms, as stored in `active_plugins`. */ const PLUGIN_FILE = 'ow-forms/ow-forms.php'; /** * Key both the core exporter and the core eraser are registered under. */ const PRIVACY_KEY = 'ow-consent-forms'; /** * Submissions returned per exporter page. */ const EXPORT_PAGE_SIZE = 20; /** * Rows touched per pruning round. */ const PRUNE_BATCH = 500; /** * Longest value written into an export field. */ const MAX_VALUE_LEN = 2000; /** * True once init() has run, so a double bootstrap cannot double-register. * * @var bool */ protected static $booted = false; /** * True once the schema was verified during this request. * * @var bool */ protected static $schema_checked = false; /** * Memoised form schemas, keyed by form id. * * @var array */ protected static $form_cache = array(); /* ===================================================================== * BOOTSTRAP * ================================================================== */ /** * Register the integration hooks. * * Idempotent, and a no-op when OW Forms is not an active plugin. No setting * is read here on purpose: reading one during `plugins_loaded` would freeze * the memoised settings array before every `owco_settings` filter is in place. * Each callback re-checks `forms_integration_enabled` when it actually fires. * * @return void */ public static function init() { if ( self::$booted ) { return; } self::$booted = true; if ( ! self::forms_plugin_active() ) { return; } add_action( 'owfo_submission_received', array( __CLASS__, 'on_submission_received' ), 10, 4 ); add_action( 'owfo_gdpr_erased_for_email', array( __CLASS__, 'on_forms_erased' ), 10, 2 ); add_action( 'owco_dsar_verified', array( __CLASS__, 'on_dsar_verified' ), 20, 2 ); add_filter( 'wp_privacy_personal_data_exporters', array( __CLASS__, 'register_exporter' ) ); add_filter( 'wp_privacy_personal_data_erasers', array( __CLASS__, 'register_eraser' ) ); add_action( self::maintenance_hook(), array( __CLASS__, 'prune' ) ); add_action( 'admin_init', array( __CLASS__, 'maybe_install' ) ); } /** * Name of the daily maintenance cron event OW Consent already schedules. * * @return string */ protected static function maintenance_hook() { if ( class_exists( 'OWCO_Install' ) && defined( 'OWCO_Install::MAINTENANCE_HOOK' ) ) { return (string) constant( 'OWCO_Install::MAINTENANCE_HOOK' ); } return 'owco_daily_maintenance'; } /** * Is OW Forms an active plugin on this site? * * `is_plugin_active()` lives in wp-admin and fatals on the front end, so the * `active_plugins` option is read directly, plus the network-activated list. * A renamed plugin folder is still recognised by its main file name. * * @return bool */ public static function forms_plugin_active() { $active = get_option( 'active_plugins', array() ); if ( ! is_array( $active ) ) { $active = array(); } if ( in_array( self::PLUGIN_FILE, $active, true ) ) { return true; } foreach ( $active as $plugin ) { if ( is_string( $plugin ) && 'ow-forms.php' === basename( $plugin ) ) { return true; } } if ( is_multisite() ) { $network = get_site_option( 'active_sitewide_plugins', array() ); if ( is_array( $network ) ) { if ( isset( $network[ self::PLUGIN_FILE ] ) ) { return true; } foreach ( array_keys( $network ) as $plugin ) { if ( is_string( $plugin ) && 'ow-forms.php' === basename( $plugin ) ) { return true; } } } } return false; } /** * Is the integration usable right now (plugin active + its classes loaded)? * * @return bool */ public static function is_available() { if ( ! self::forms_plugin_active() ) { return false; } self::define_forms_tables(); return ( class_exists( 'OWFO_GDPR' ) && defined( 'OWFO_TABLE_SUBMISSIONS' ) ); } /** * Is the integration both available and switched on by the administrator? * * @return bool */ public static function is_enabled() { if ( ! self::is_available() ) { return false; } if ( ! class_exists( 'OWCO_Core' ) ) { return false; } return (bool) OWCO_Core::setting( 'forms_integration_enabled', true ); } /** * Make sure OW Forms' table-name constants exist before we query them. * * @return void */ protected static function define_forms_tables() { if ( ! defined( 'OWFO_TABLE_SUBMISSIONS' ) && function_exists( 'owfo_define_tables' ) ) { owfo_define_tables(); } } /** * A short human-readable report for the admin diagnostic screen. * * @return array {plugin_active, available, enabled, table_ready, links} */ public static function status() { $status = array( 'plugin_active' => self::forms_plugin_active(), 'available' => self::is_available(), 'enabled' => self::is_enabled(), 'table_ready' => ( self::DB_VERSION === (string) get_option( self::DB_VERSION_OPTION, '' ) ), 'links' => 0, ); if ( $status['table_ready'] ) { global $wpdb; $table = self::table_name(); if ( '' !== $table && isset( $wpdb ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- diagnostic screen; the live row count is the whole point of the panel, so it must not be served from cache. $status['links'] = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ); } } return $status; } /* ===================================================================== * SCHEMA — our own link table, never OW Forms' * ================================================================== */ /** * Fully prefixed name of the link table for the CURRENT blog. * * @return string Empty string when $wpdb is not available yet. */ public static function table_name() { if ( function_exists( 'owco_table' ) ) { $table = (string) owco_table( 'form_links' ); if ( '' !== $table ) { return $table; } } global $wpdb; if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) { return ''; } return $wpdb->prefix . 'owco_form_links'; } /** * Create or upgrade the link table once per install. * * @return void */ public static function maybe_install() { if ( self::$schema_checked ) { return; } self::$schema_checked = true; if ( self::DB_VERSION === (string) get_option( self::DB_VERSION_OPTION, '' ) ) { return; } self::install(); } /** * Run dbDelta for the link table. * * @return bool True when the schema pass ran. */ public static function install() { global $wpdb; $table = self::table_name(); if ( '' === $table || ! isset( $wpdb ) ) { return false; } if ( ! function_exists( 'dbDelta' ) ) { require_once ABSPATH . 'wp-admin/includes/upgrade.php'; } if ( ! function_exists( 'dbDelta' ) ) { return false; } $charset = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, created_at datetime NOT NULL, submission_id bigint(20) unsigned NOT NULL, form_id bigint(20) unsigned NOT NULL DEFAULT 0, visitor_token char(32) NOT NULL DEFAULT '', ledger_id bigint(20) unsigned NOT NULL DEFAULT 0, email_hash char(64) DEFAULT NULL, categories varchar(255) NOT NULL DEFAULT '', profile varchar(20) NOT NULL DEFAULT '', policies_hash char(64) DEFAULT NULL, consent_at datetime DEFAULT NULL, form_consent tinyint(1) NOT NULL DEFAULT 0, consent_text varchar(500) DEFAULT NULL, plugin_version varchar(20) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY submission_id (submission_id), KEY visitor_token (visitor_token), KEY email_hash (email_hash), KEY created_at (created_at) ) {$charset};"; dbDelta( $sql ); update_option( self::DB_VERSION_OPTION, self::DB_VERSION ); return true; } /** * Drop the link table and forget its schema version. * * Called by uninstall.php when `delete_data_on_uninstall` is on. Kept public * so the uninstaller does not have to know the table name. * * @return void */ public static function drop_table() { global $wpdb; $table = self::table_name(); if ( '' !== $table && isset( $wpdb ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- uninstall-time DROP of this plugin's own link table; dropping a table IS the schema change the uninstaller exists to make, and a DDL statement has nothing to cache. $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $table ) ); } delete_option( self::DB_VERSION_OPTION ); } /* ===================================================================== * LINK — consent record <-> form submission * ================================================================== */ /** * `owfo_submission_received` listener. * * @param int $submission_id Row id in OW Forms' submissions table, 0 when storage is off. * @param int $form_id Form post id. * @param array $cleaned Sanitised field values. * @param array $schema Form schema. * @return void */ public static function on_submission_received( $submission_id, $form_id = 0, $cleaned = array(), $schema = array() ) { $submission_id = (int) $submission_id; if ( $submission_id <= 0 ) { // OW Forms is configured not to store submissions: nothing to link to. return; } if ( ! self::is_enabled() ) { return; } self::link_submission( $submission_id, (int) $form_id, (array) $cleaned, (array) $schema ); } /** * Write the link row for one submission. * * @param int $submission_id Submission row id. * @param int $form_id Form post id. * @param array $cleaned Sanitised field values. * @param array $schema Form schema. * @return int Inserted row id, 0 on failure or when already linked. */ public static function link_submission( $submission_id, $form_id = 0, array $cleaned = array(), array $schema = array() ) { global $wpdb; $submission_id = (int) $submission_id; $form_id = (int) $form_id; if ( $submission_id <= 0 || ! isset( $wpdb ) ) { return 0; } self::maybe_install(); $table = self::table_name(); if ( '' === $table ) { return 0; } if ( self::DB_VERSION !== (string) get_option( self::DB_VERSION_OPTION, '' ) ) { // The link table could not be created — do not query a missing table. return 0; } // Already linked (a plugin replaying the action, or a double submit). // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- existence probe on this plugin's own link table; a cached answer would let a replayed action write a duplicate link row. $exists = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE submission_id = %d LIMIT 1', $table, $submission_id ) ); if ( $exists > 0 ) { return 0; } $state = self::consent_state(); $token = $state['token']; $submission = self::forms_submission( $submission_id ); $consent = self::form_consent_field( $cleaned, $schema ); $row = array( 'created_at' => gmdate( 'Y-m-d H:i:s' ), 'submission_id' => $submission_id, 'form_id' => $form_id > 0 ? $form_id : (int) ( isset( $submission['form_id'] ) ? $submission['form_id'] : 0 ), 'visitor_token' => $token, 'ledger_id' => self::latest_ledger_id( $token ), 'email_hash' => isset( $submission['email_hash'] ) && '' !== (string) $submission['email_hash'] ? (string) $submission['email_hash'] : null, 'categories' => $state['categories'], 'profile' => $state['profile'], 'policies_hash' => '' !== $state['policies_hash'] ? $state['policies_hash'] : null, 'consent_at' => '' !== $state['consent_at'] ? $state['consent_at'] : null, 'form_consent' => $consent['given'], 'consent_text' => '' !== $consent['text'] ? $consent['text'] : null, 'plugin_version' => defined( 'OWCO_VERSION' ) ? (string) OWCO_VERSION : null, ); $formats = array( '%s', '%d', '%d', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned table; write path, nothing to cache. $ok = $wpdb->insert( $table, $row, $formats ); return $ok ? (int) $wpdb->insert_id : 0; } /** * The consent record attached to a submission. * * Public so the DSAR queue, an admin column or another OptionWeb plugin can * show "what this person consented to when they sent this form". * * @param int $submission_id Submission row id. * @return array|null Link row as an associative array, or null when unlinked. */ public static function consent_for_submission( $submission_id ) { global $wpdb; $submission_id = (int) $submission_id; $table = self::table_name(); if ( $submission_id <= 0 || '' === $table || ! isset( $wpdb ) ) { return null; } if ( self::DB_VERSION !== (string) get_option( self::DB_VERSION_OPTION, '' ) ) { return null; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- single-row read of this plugin's own link table, on demand for one submission; there is no object cache to populate. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE submission_id = %d LIMIT 1', $table, $submission_id ), ARRAY_A ); return is_array( $row ) ? $row : null; } /** * Snapshot of the visitor's consent, read from the request cookie. * * Only ever called on a form POST — a per-visitor request — so it does not * break the "identical HTML for every visitor" rule of the cacheable pages. * * @return array {token, categories, profile, policies_hash, consent_at} */ protected static function consent_state() { $out = array( 'token' => '', 'categories' => '', 'profile' => '', 'policies_hash' => '', 'consent_at' => '', ); if ( ! class_exists( 'OWCO_Consent' ) ) { return $out; } $state = OWCO_Consent::current(); if ( ! is_array( $state ) ) { return $out; } $token = isset( $state['token'] ) ? (string) $state['token'] : ''; if ( preg_match( '/^[a-f0-9]{32}$/', $token ) ) { $out['token'] = $token; } $granted = array(); if ( isset( $state['categories'] ) && is_array( $state['categories'] ) ) { foreach ( $state['categories'] as $slug => $value ) { if ( is_string( $slug ) && $value ) { $granted[] = $slug; } } } $out['categories'] = self::truncate( implode( ',', $granted ), 255 ); $out['profile'] = self::truncate( isset( $state['profile'] ) ? (string) $state['profile'] : '', 20 ); $at = isset( $state['at'] ) ? (int) $state['at'] : 0; if ( $at > 0 ) { $out['consent_at'] = gmdate( 'Y-m-d H:i:s', $at ); } // The ledger's policies_hash column stores the FULL 64-hex fingerprint // (OWCO_Consent::policies_hash_full()); current_policies_hash() is the 16-char // cookie form and would never match the pattern below, nor the ledger value. if ( method_exists( 'OWCO_Consent', 'policies_hash_full' ) ) { $hash = (string) OWCO_Consent::policies_hash_full(); if ( preg_match( '/^[a-f0-9]{64}$/', $hash ) ) { $out['policies_hash'] = $hash; } } return $out; } /** * Id of the most recent consent ledger entry written for a visitor token. * * @param string $token 32 hex chars. * @return int Ledger row id, 0 when unknown. */ protected static function latest_ledger_id( $token ) { global $wpdb; $token = (string) $token; if ( ! preg_match( '/^[a-f0-9]{32}$/', $token ) || ! isset( $wpdb ) ) { return 0; } $table = ''; if ( function_exists( 'owco_table' ) ) { $table = (string) owco_table( 'ledger' ); } if ( '' === $table && defined( 'OWCO_TABLE_LEDGER' ) ) { $table = (string) OWCO_TABLE_LEDGER; } if ( '' === $table ) { return 0; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- consent ledger lookup on the submission write path; a cached id would attach the link row to a stale ledger entry. return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE visitor_token = %s ORDER BY id DESC LIMIT 1', $table, $token ) ); } /** * The form's own GDPR checkbox, as ticked at submission time. * * @param array $cleaned Sanitised values. * @param array $schema Form schema. * @return array {given:int, text:string} */ protected static function form_consent_field( array $cleaned, array $schema ) { $out = array( 'given' => 0, 'text' => '', ); $fields = isset( $schema['fields'] ) && is_array( $schema['fields'] ) ? $schema['fields'] : array(); foreach ( $fields as $field ) { if ( ! is_array( $field ) ) { continue; } $type = isset( $field['type'] ) ? (string) $field['type'] : ''; if ( 'consent' !== $type ) { continue; } $key = isset( $field['key'] ) ? (string) $field['key'] : ''; if ( '' !== $key && isset( $cleaned[ $key ] ) && $cleaned[ $key ] ) { $out['given'] = 1; } $label = isset( $field['label'] ) ? (string) $field['label'] : ''; if ( '' !== $label ) { $out['text'] = self::truncate( wp_strip_all_tags( $label ), 500 ); } break; } // Fallback for a form whose schema was not passed along. if ( 0 === $out['given'] && isset( $cleaned['gdpr_consent'] ) && $cleaned['gdpr_consent'] ) { $out['given'] = 1; } return $out; } /* ===================================================================== * DSAR — erasure cascade * ================================================================== */ /** * Erase every OW Forms submission belonging to an address, plus our links. * * This is the method the DSAR module calls. The deletion itself is delegated * to OW Forms so its uploaded files are removed too. * * @param string $email Data subject. * @return array {erased:int, links:int, supported:bool} */ public static function erase_for_email( $email ) { $out = array( 'erased' => 0, 'links' => 0, 'supported' => false, ); $email = is_string( $email ) ? $email : ''; if ( ! is_email( $email ) || ! self::is_enabled() ) { return $out; } if ( ! method_exists( 'OWFO_GDPR', 'erase_by_email' ) ) { // OW Forms is active but too old to expose its erasure routine. return $out; } $out['supported'] = true; $erased = call_user_func( array( 'OWFO_GDPR', 'erase_by_email' ), $email ); $out['erased'] = max( 0, (int) $erased ); $out['links'] = self::delete_links_for_email( $email ); return $out; } /** * How many OW Forms submissions are stored for an address. * * @param string $email Data subject. * @return int */ public static function count_for_email( $email ) { global $wpdb; $email = is_string( $email ) ? $email : ''; if ( ! is_email( $email ) || ! self::is_available() || ! isset( $wpdb ) ) { return 0; } $table = (string) OWFO_TABLE_SUBMISSIONS; $hash = self::email_hash( $email ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- sibling-plugin table read answering a data-subject request; a cached count could under-report what is still stored. return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE ( email_hash IS NOT NULL AND email_hash = %s ) OR email = %s', $table, $hash, $email ) ); } /** * `owco_dsar_verified` listener — fallback cascade. * * OW Forms 1.2+ registers its own listener on the same action (priority 10). * When it is there we let it do the deletion and only prune our link rows; * when it is missing we perform the cascade ourselves. * * @param int $dsar_id DSAR row id. * @param array $row DSAR row. * @return void */ public static function on_dsar_verified( $dsar_id, $row = array() ) { unset( $dsar_id ); if ( ! self::is_enabled() ) { return; } $row = (array) $row; $type = isset( $row['request_type'] ) ? (string) $row['request_type'] : ''; if ( 'erasure' !== $type ) { return; } $email = isset( $row['subject_email'] ) ? (string) $row['subject_email'] : ''; if ( ! is_email( $email ) ) { return; } if ( false !== has_action( 'owco_dsar_verified', array( 'OWFO_GDPR', 'on_dsar_verified' ) ) ) { // OW Forms already erased its own rows on this very action. self::delete_links_for_email( $email ); return; } self::erase_for_email( $email ); } /** * `owfo_gdpr_erased_for_email` listener — keep the link table in step with * every OW Forms erasure, whoever triggered it. * * @param string $hash OW Forms e-mail hash. * @param int $count Submissions erased. * @return void */ public static function on_forms_erased( $hash, $count = 0 ) { unset( $count ); $hash = is_string( $hash ) ? $hash : ''; if ( ! preg_match( '/^[a-f0-9]{64}$/', $hash ) ) { return; } self::delete_links_by_hash( $hash ); } /** * Drop our link rows for an address. * * @param string $email Data subject. * @return int Rows deleted. */ protected static function delete_links_for_email( $email ) { $hash = self::email_hash( $email ); if ( '' === $hash ) { return 0; } return self::delete_links_by_hash( $hash ); } /** * Drop our link rows for an OW Forms e-mail hash. * * @param string $hash 64 hex chars. * @return int Rows deleted. */ protected static function delete_links_by_hash( $hash ) { global $wpdb; $table = self::table_name(); if ( '' === $table || ! isset( $wpdb ) ) { return 0; } if ( self::DB_VERSION !== (string) get_option( self::DB_VERSION_OPTION, '' ) ) { return 0; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- erasure write on this plugin's own link table; a DELETE has no result set to cache. $deleted = $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE email_hash = %s', $table, $hash ) ); return max( 0, (int) $deleted ); } /** * OW Forms' salted SHA-256 of an address. * * We never compute our own digest: a different salt would match nothing. * * @param string $email Address. * @return string 64 hex chars, or '' when OW Forms is unavailable. */ public static function email_hash( $email ) { $email = is_string( $email ) ? $email : ''; if ( '' === $email || ! method_exists( 'OWFO_GDPR', 'email_hash' ) ) { return ''; } $hash = (string) call_user_func( array( 'OWFO_GDPR', 'email_hash' ), $email ); return preg_match( '/^[a-f0-9]{64}$/', $hash ) ? $hash : ''; } /* ===================================================================== * WORDPRESS CORE PRIVACY TOOLS * ================================================================== */ /** * Register the exporter with Tools > Export Personal Data. * * @param array $exporters Registered exporters. * @return array */ public static function register_exporter( $exporters ) { if ( ! is_array( $exporters ) ) { $exporters = array(); } if ( ! self::is_enabled() ) { return $exporters; } $exporters[ self::PRIVACY_KEY ] = array( 'exporter_friendly_name' => __( 'OW Consent — OW Forms submissions', 'ow-consent' ), 'callback' => array( __CLASS__, 'export_personal_data' ), ); return $exporters; } /** * Register the eraser with Tools > Erase Personal Data. * * @param array $erasers Registered erasers. * @return array */ public static function register_eraser( $erasers ) { if ( ! is_array( $erasers ) ) { $erasers = array(); } if ( ! self::is_enabled() ) { return $erasers; } $erasers[ self::PRIVACY_KEY ] = array( 'eraser_friendly_name' => __( 'OW Consent — OW Forms submissions', 'ow-consent' ), 'callback' => array( __CLASS__, 'erase_personal_data' ), ); return $erasers; } /** * Core exporter callback. * * @param string $email_address Data subject. * @param int $page 1-based page. * @return array {data:array, done:bool} */ public static function export_personal_data( $email_address, $page = 1 ) { $email_address = is_string( $email_address ) ? $email_address : ''; if ( ! is_email( $email_address ) || ! self::is_enabled() ) { return array( 'data' => array(), 'done' => true, ); } return self::export_for_email( $email_address, max( 1, (int) $page ) ); } /** * One page of the OW Forms submissions of an address, in core exporter shape. * * Public so the DSAR module can build a portability bundle without going * through the core filter chain. * * @param string $email Data subject. * @param int $page 1-based page. * @return array {data:array, done:bool} */ public static function export_for_email( $email, $page = 1 ) { global $wpdb; $out = array( 'data' => array(), 'done' => true, ); $page = max( 1, (int) $page ); $email = is_string( $email ) ? $email : ''; if ( ! is_email( $email ) || ! self::is_available() || ! isset( $wpdb ) ) { return $out; } $table = (string) OWFO_TABLE_SUBMISSIONS; $hash = self::email_hash( $email ); $limit = self::EXPORT_PAGE_SIZE; $offset = ( $page - 1 ) * $limit; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- sibling-plugin table read building a personal-data export; a cached page would hand the data subject a stale bundle. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id, form_id, created_at, status, page_url, payload FROM %i WHERE ( email_hash IS NOT NULL AND email_hash = %s ) OR email = %s ORDER BY id ASC LIMIT %d OFFSET %d', $table, $hash, $email, $limit, $offset ), ARRAY_A ); if ( ! is_array( $rows ) ) { $rows = array(); } $group_label = __( 'OW Forms — form submissions', 'ow-consent' ); $group_desc = __( 'Messages you sent through a form on this site, with the consent recorded by OW Consent at that moment.', 'ow-consent' ); foreach ( $rows as $row ) { $out['data'][] = array( 'group_id' => 'ow-consent-forms', 'group_label' => $group_label, 'group_description' => $group_desc, 'item_id' => 'owco-forms-submission-' . (int) $row['id'], 'data' => self::export_fields( $row ), ); } $out['done'] = ( count( $rows ) < $limit ); return $out; } /** * Build the name/value pairs of one exported submission. * * @param array $row Submission row. * @return array */ protected static function export_fields( array $row ) { $fields = array(); $fields[] = array( 'name' => __( 'Submission ID', 'ow-consent' ), 'value' => self::guard_value( (string) (int) $row['id'] ), ); $fields[] = array( 'name' => __( 'Form', 'ow-consent' ), 'value' => self::guard_value( self::form_title( (int) $row['form_id'] ) ), ); $fields[] = array( 'name' => __( 'Sent on (UTC)', 'ow-consent' ), 'value' => self::guard_value( isset( $row['created_at'] ) ? (string) $row['created_at'] : '' ), ); if ( ! empty( $row['page_url'] ) ) { $fields[] = array( 'name' => __( 'Page', 'ow-consent' ), 'value' => self::guard_value( (string) $row['page_url'] ), ); } if ( ! empty( $row['status'] ) ) { $fields[] = array( 'name' => __( 'Status', 'ow-consent' ), 'value' => self::guard_value( (string) $row['status'] ), ); } $labels = self::field_labels( (int) $row['form_id'] ); $payload = isset( $row['payload'] ) ? json_decode( (string) $row['payload'], true ) : null; if ( is_array( $payload ) ) { foreach ( $payload as $key => $value ) { if ( ! is_string( $key ) ) { continue; } if ( is_array( $value ) ) { $value = wp_json_encode( $value ); } if ( ! is_scalar( $value ) && null !== $value ) { continue; } $name = isset( $labels[ $key ] ) ? $labels[ $key ] : $key; $fields[] = array( 'name' => self::guard_value( wp_strip_all_tags( (string) $name ) ), 'value' => self::guard_value( wp_strip_all_tags( (string) $value ) ), ); } } $link = self::consent_for_submission( (int) $row['id'] ); if ( is_array( $link ) ) { $fields[] = array( 'name' => __( 'Consent — categories granted', 'ow-consent' ), 'value' => self::guard_value( (string) $link['categories'] ), ); $fields[] = array( 'name' => __( 'Consent — compliance profile', 'ow-consent' ), 'value' => self::guard_value( (string) $link['profile'] ), ); if ( ! empty( $link['consent_at'] ) ) { $fields[] = array( 'name' => __( 'Consent — given on (UTC)', 'ow-consent' ), 'value' => self::guard_value( (string) $link['consent_at'] ), ); } if ( ! empty( $link['visitor_token'] ) ) { $fields[] = array( 'name' => __( 'Consent — browser token', 'ow-consent' ), 'value' => self::guard_value( (string) $link['visitor_token'] ), ); } if ( ! empty( $link['ledger_id'] ) ) { $fields[] = array( 'name' => __( 'Consent — audit ledger entry', 'ow-consent' ), 'value' => self::guard_value( (string) (int) $link['ledger_id'] ), ); } $fields[] = array( 'name' => __( 'Consent — form checkbox ticked', 'ow-consent' ), 'value' => empty( $link['form_consent'] ) ? __( 'No', 'ow-consent' ) : __( 'Yes', 'ow-consent' ), ); if ( ! empty( $link['consent_text'] ) ) { $fields[] = array( 'name' => __( 'Consent — wording accepted', 'ow-consent' ), 'value' => self::guard_value( (string) $link['consent_text'] ), ); } } return $fields; } /** * Core eraser callback. * * @param string $email_address Data subject. * @param int $page 1-based page (everything is erased on page 1). * @return array */ public static function erase_personal_data( $email_address, $page = 1 ) { $out = array( 'items_removed' => false, 'items_retained' => false, 'messages' => array(), 'done' => true, ); $email_address = is_string( $email_address ) ? $email_address : ''; if ( (int) $page > 1 || ! is_email( $email_address ) || ! self::is_enabled() ) { return $out; } $result = self::erase_for_email( $email_address ); if ( ! $result['supported'] ) { $out['items_retained'] = true; $out['messages'][] = __( 'OW Forms is active but does not expose its erasure routine (OWFO_GDPR::erase_by_email). Update OW Forms, then run this erasure again: the stored form submissions were left untouched.', 'ow-consent' ); return $out; } if ( $result['erased'] > 0 ) { $out['items_removed'] = true; $out['messages'][] = sprintf( /* translators: %d: number of form submissions deleted. */ _n( '%d OW Forms submission deleted.', '%d OW Forms submissions deleted.', $result['erased'], 'ow-consent' ), (int) $result['erased'] ); } return $out; } /* ===================================================================== * RETENTION * ================================================================== */ /** * Daily maintenance: drop link rows past the retention window, then sweep * links whose submission no longer exists. * * Only OUR rows are touched. Form submissions themselves are kept: their * retention belongs to OW Forms' own `gdpr_retention_days` purge. * * @return int Rows deleted. */ public static function prune() { global $wpdb; $table = self::table_name(); if ( '' === $table || ! isset( $wpdb ) ) { return 0; } if ( self::DB_VERSION !== (string) get_option( self::DB_VERSION_OPTION, '' ) ) { return 0; } $days = 1095; if ( class_exists( 'OWCO_Core' ) ) { $days = (int) OWCO_Core::setting( 'retention_form_data_days', 1095 ); } $days = min( 3650, max( 1, $days ) ); $cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) ); $deleted = 0; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- retention purge on this plugin's own link table; a DELETE has no result set to cache. $deleted += max( 0, (int) $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE created_at < %s LIMIT %d', $table, $cutoff, self::PRUNE_BATCH ) ) ); $deleted += self::prune_orphans(); return $deleted; } /** * Delete link rows whose submission has disappeared from OW Forms. * * @return int Rows deleted. */ protected static function prune_orphans() { global $wpdb; if ( ! self::is_available() || ! isset( $wpdb ) ) { return 0; } $table = self::table_name(); $subs = (string) OWFO_TABLE_SUBMISSIONS; if ( '' === $table ) { return 0; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- maintenance sweep across this plugin's link table and OW Forms' submissions; a cached list would keep re-reporting rows this run already deleted. $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT l.id FROM %i AS l LEFT JOIN %i AS s ON s.id = l.submission_id WHERE s.id IS NULL LIMIT %d', $table, $subs, self::PRUNE_BATCH ) ); $ids = array_map( 'absint', (array) $ids ); $ids = array_values( array_filter( $ids ) ); if ( empty( $ids ) ) { return 0; } // One fully-bound DELETE per id rather than an interpolated IN() list. The list was // provably digits-only, but it was still the last SQL fragment in this file assembled // by string building, and PluginCheck's UnescapedDBParameter sniff does not honour a // phpcs:ignore — so the choice was between a suppression that does not suppress and a // query with nothing to interpolate. The cost is bounded: this is a cron-time sweep // capped at PRUNE_BATCH rows, on a primary key. $deleted = 0; foreach ( $ids as $orphan_id ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- maintenance delete on this plugin's own link table; there is nothing to cache about a row being removed. $deleted += (int) $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE id = %d', $table, $orphan_id ) ); } return max( 0, (int) $deleted ); } /* ===================================================================== * HELPERS * ================================================================== */ /** * Read the columns we need from one OW Forms submission. * * @param int $submission_id Submission row id. * @return array Empty when unavailable. */ protected static function forms_submission( $submission_id ) { global $wpdb; $submission_id = (int) $submission_id; if ( $submission_id <= 0 || ! self::is_available() || ! isset( $wpdb ) ) { return array(); } $table = (string) OWFO_TABLE_SUBMISSIONS; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- sibling-plugin table read on the submission write path; the row was written moments ago, so a cache would miss it. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT id, form_id, email_hash FROM %i WHERE id = %d LIMIT 1', $table, $submission_id ), ARRAY_A ); return is_array( $row ) ? $row : array(); } /** * Human title of a form. * * @param int $form_id Form post id. * @return string */ protected static function form_title( $form_id ) { $form_id = (int) $form_id; if ( $form_id <= 0 ) { return ''; } $title = get_the_title( $form_id ); $title = is_string( $title ) ? wp_strip_all_tags( $title ) : ''; if ( '' === $title ) { /* translators: %d: form id. */ $title = sprintf( __( 'Form #%d', 'ow-consent' ), $form_id ); } return $title; } /** * Map of field key => label for a form, so the export reads like the form did. * * @param int $form_id Form post id. * @return array */ protected static function field_labels( $form_id ) { $form_id = (int) $form_id; if ( $form_id <= 0 || ! method_exists( 'OWFO_Form', 'load' ) ) { return array(); } if ( isset( self::$form_cache[ $form_id ] ) ) { return self::$form_cache[ $form_id ]; } $labels = array(); $schema = call_user_func( array( 'OWFO_Form', 'load' ), $form_id ); if ( is_array( $schema ) && isset( $schema['fields'] ) && is_array( $schema['fields'] ) ) { foreach ( $schema['fields'] as $field ) { if ( ! is_array( $field ) ) { continue; } $key = isset( $field['key'] ) ? (string) $field['key'] : ''; $label = isset( $field['label'] ) ? (string) $field['label'] : ''; if ( '' !== $key && '' !== $label ) { $labels[ $key ] = wp_strip_all_tags( $label ); } } } self::$form_cache[ $form_id ] = $labels; return $labels; } /** * Neutralise spreadsheet formula injection and cap the length of a value * that will end up in an export file. * * @param string $value Raw value. * @return string */ protected static function guard_value( $value ) { $value = is_string( $value ) ? $value : ''; if ( '' === $value ) { return ''; } $first = substr( $value, 0, 1 ); if ( in_array( $first, array( '=', '+', '-', '@', "\t", "\r" ), true ) ) { $value = "'" . $value; } return self::truncate( $value, self::MAX_VALUE_LEN ); } /** * Cut a string to a byte-safe length without splitting a multibyte char. * * @param string $value Raw value. * @param int $length Maximum length. * @return string */ protected static function truncate( $value, $length ) { $value = is_string( $value ) ? $value : ''; $length = max( 1, (int) $length ); if ( function_exists( 'mb_substr' ) ) { return mb_substr( $value, 0, $length, 'UTF-8' ); } return substr( $value, 0, $length ); } }