{token,wait,expires,type} * - interactions/quiz/submit: {post,node,token,answers:{questionIndex:optionIndex}} * - interactions/poll/vote: {post,node,token,answer:optionIndex} * - interactions/poll/results: {post,node} -> aggregate result rows when permitted * * Question and option indexes are zero based at the public boundary. Quiz answers * are graded only from the current published definition. Poll counters use the * option's private stable ID, so labels and display order may change without losing * totals. Storage contains aggregate counters and keyed hashes only; raw addresses, * user agents and individual answers are never stored. */ final class Interactions { private const SCHEMA_VERSION = '2'; private const SCHEMA_OPTION = 'digibl_interactions_schema_version'; private const CLEANUP_HOOK = 'digibl_interactions_cleanup'; private const TOKEN_TTL = 900; private const TOKEN_WAIT = 1; private const MAX_BODY = 65536; private const CLAIM_TTL = 31536000; private static bool $schema_ready = false; private static ?bool $transactions_ready = null; private static array $document_cache = []; public function __construct() { add_action('init', [self::class, 'ensure_schema'], 5); add_action('rest_api_init', [$this, 'routes']); add_action(self::CLEANUP_HOOK, [self::class, 'cleanup']); add_filter('rest_post_dispatch', [$this, 'no_store'], 10, 3); } /** Callable from register_activation_hook; lazy installation also covers upgrades. */ public static function install(): void { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $charset = $wpdb->get_charset_collate(); $counts = self::counts_table(); $claims = self::claims_table(); $limits = self::limits_table(); $engine = !empty($wpdb->is_mysql) ? 'ENGINE=InnoDB' : ''; dbDelta("CREATE TABLE {$counts} ( interaction_key char(64) NOT NULL, answer_key varchar(64) NOT NULL, votes bigint(20) unsigned NOT NULL DEFAULT 0, updated_at datetime NOT NULL, PRIMARY KEY (interaction_key,answer_key), KEY updated_at (updated_at) ) {$engine} {$charset};"); dbDelta("CREATE TABLE {$claims} ( claim_key char(64) NOT NULL, interaction_key char(64) NOT NULL, claim_type varchar(20) NOT NULL, expires_at bigint(20) unsigned NOT NULL DEFAULT 0, created_at datetime NOT NULL, PRIMARY KEY (claim_key), KEY interaction_key (interaction_key), KEY expires_at (expires_at) ) {$engine} {$charset};"); dbDelta("CREATE TABLE {$limits} ( limit_key char(64) NOT NULL, hits bigint(20) unsigned NOT NULL DEFAULT 0, expires_at bigint(20) unsigned NOT NULL DEFAULT 0, updated_at datetime NOT NULL, PRIMARY KEY (limit_key), KEY expires_at (expires_at) ) {$engine} {$charset};"); $transactional = true; if (!empty($wpdb->is_mysql)) { foreach ([$counts, $claims, $limits] as $table) { if ($wpdb->query("ALTER TABLE {$table} ENGINE=InnoDB") === false) { $transactional = false; } } } $counts_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($counts))) === $counts; $claims_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($claims))) === $claims; $limits_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($limits))) === $limits; if ($counts_exists && $claims_exists && $limits_exists && $transactional) { update_option(self::SCHEMA_OPTION, self::SCHEMA_VERSION, false); self::$schema_ready = true; } self::schedule_cleanup(); } public static function ensure_schema(): void { if (self::$schema_ready) { return; } if ((string) get_option(self::SCHEMA_OPTION, '') !== self::SCHEMA_VERSION) { self::install(); return; } self::$schema_ready = true; self::schedule_cleanup(); } public static function cleanup(): void { global $wpdb; self::ensure_schema(); $wpdb->query($wpdb->prepare( 'DELETE FROM %i WHERE expires_at > 0 AND expires_at < %d LIMIT 5000', self::claims_table(), time() )); $wpdb->query($wpdb->prepare( 'DELETE FROM %i WHERE expires_at > 0 AND expires_at < %d LIMIT 5000', self::limits_table(), time() )); } private static function schedule_cleanup(): void { $event = function_exists('wp_get_scheduled_event') ? wp_get_scheduled_event(self::CLEANUP_HOOK) : false; if ($event && ($event->schedule ?? '') !== 'hourly') { wp_clear_scheduled_hook(self::CLEANUP_HOOK); $event = false; } if (!$event && !wp_next_scheduled(self::CLEANUP_HOOK)) { wp_schedule_event(time() + HOUR_IN_SECONDS, 'hourly', self::CLEANUP_HOOK); } } public function routes(): void { $public = '__return_true'; register_rest_route('digiter-builder/v1', '/interactions/session', [ 'methods' => 'POST', 'permission_callback' => $public, 'callback' => [$this, 'session'], ]); register_rest_route('digiter-builder/v1', '/interactions/quiz/submit', [ 'methods' => 'POST', 'permission_callback' => $public, 'callback' => [$this, 'quiz_submit'], ]); register_rest_route('digiter-builder/v1', '/interactions/poll/vote', [ 'methods' => 'POST', 'permission_callback' => $public, 'callback' => [$this, 'poll_vote'], ]); register_rest_route('digiter-builder/v1', '/interactions/poll/results', [ 'methods' => 'POST', 'permission_callback' => $public, 'callback' => [$this, 'poll_results'], ]); } public function no_store($response, $server, $request) { if (str_starts_with((string) $request->get_route(), '/digiter-builder/v1/interactions/')) { $response->header('Cache-Control', 'no-store, private'); $response->header('Pragma', 'no-cache'); } return $response; } public function session($request) { $guard = $this->preflight($request, 'session', 30, 600); if (is_wp_error($guard)) { return $guard; } $type = $request->get_param('type'); if (!is_string($type) || !in_array($type, ['quiz', 'poll'], true)) { return $this->error('Choose a quiz or poll.', 400); } $definition = $this->published_definition($request, $type); if (is_wp_error($definition)) { return $definition; } $payload = time() . '.' . bin2hex(random_bytes(16)); $token = $payload . '.' . $this->signature($payload, $definition); $data = [ 'type' => $type, 'token' => $token, 'wait' => self::TOKEN_WAIT, 'expires' => self::TOKEN_TTL, ]; if ($type === 'poll') { $data['closed'] = $definition['definition']['closed'] !== '' && time() >= strtotime($definition['definition']['closed']); $data['closedMessage'] = $data['closed'] ? $definition['definition']['closedMessage'] : ''; $voted = $this->has_voted($definition); if (is_wp_error($voted)) { return $voted; } $data['voted'] = $voted; } if ($type === 'poll' && ($definition['definition']['results'] === 'always' || ($definition['definition']['results'] === 'after-vote' && $data['voted']))) { $results = $this->result_data($definition); if (is_wp_error($results)) { return $results; } $data['results'] = $results; } return $this->response($data); } public function quiz_submit($request) { $guard = $this->preflight($request, 'quiz-submit', 20, 600); if (is_wp_error($guard)) { return $guard; } $definition = $this->published_definition($request, 'quiz'); if (is_wp_error($definition)) { return $definition; } $token = $this->verify_token($request->get_param('token'), $definition); if (is_wp_error($token)) { return $token; } $answers = $request->get_param('answers'); if (!is_array($answers) || count($answers) !== count($definition['definition']['questions'])) { return $this->error('Answer every quiz question before checking the result.', 400); } $question_keys = []; foreach (array_keys($answers) as $key) { if ((!is_int($key) && (!ctype_digit((string) $key) || (string) (int) $key !== (string) $key)) || (int) $key < 0 || (int) $key >= count($definition['definition']['questions'])) { return $this->error('An answer does not belong to this quiz.', 400); } $question_keys[] = (int) $key; } sort($question_keys, SORT_NUMERIC); if ($question_keys !== range(0, count($definition['definition']['questions']) - 1)) { return $this->error('Answer every quiz question exactly once.', 400); } $selected = []; foreach ($answers as $question => $answer) { if (!is_int($answer) && !(is_string($answer) && ctype_digit($answer))) { return $this->error('Choose one valid answer for each answered question.', 400); } $question = (int) $question; $answer = (int) $answer; if ($answer < 0 || $answer >= count($definition['definition']['questions'][$question]['options'])) { return $this->error('An answer does not belong to this quiz.', 400); } $selected[$question] = $answer; } self::ensure_schema(); $claim = hash('sha256', 'token|' . $token['token']); $claimed = $this->insert_claim($claim, $definition['interaction_key'], 'quiz-token', time() + HOUR_IN_SECONDS); if ($claimed === false) { return $this->error('The quiz result could not be recorded. Try again.', 503); } if ($claimed === 0) { return $this->error('This quiz session has already been submitted.', 409); } $points = 0; $maximum = 0; $details = []; $show_answers = $definition['definition']['showAnswers']; foreach ($definition['definition']['questions'] as $index => $question) { $maximum += $question['points']; $choice = $selected[$index] ?? null; $correct = $choice !== null && $choice === $question['correct']; if ($correct) { $points += $question['points']; } $row = [ 'index' => $index, 'selected' => $choice, 'answered' => $choice !== null, 'isCorrect' => $correct, 'points' => $correct ? $question['points'] : 0, 'maximum' => $question['points'], ]; if ($show_answers) { $row['correct'] = $question['correct']; $row['correctAnswer'] = $question['options'][$question['correct']]; $row['explanation'] = $question['explanation']; } if ($show_answers) { $details[] = $row; } } $percentage = $maximum > 0 ? round(($points * 100) / $maximum, 1) : 0.0; $passed = $percentage >= $definition['definition']['passScore']; $result = [ 'score' => $points, 'maximum' => $maximum, 'percentage' => $percentage, 'passed' => $passed, 'message' => $passed ? $definition['definition']['passMessage'] : $definition['definition']['failMessage'], ]; if ($show_answers) { $result['answers'] = $details; } return $this->response($result); } public function poll_vote($request) { $guard = $this->preflight($request, 'poll-vote', 10, 600); if (is_wp_error($guard)) { return $guard; } $definition = $this->published_definition($request, 'poll'); if (is_wp_error($definition)) { return $definition; } $verified = $this->verify_token($request->get_param('token'), $definition); if (is_wp_error($verified)) { return $verified; } if ($definition['definition']['closed'] && time() >= strtotime($definition['definition']['closed'])) { return $this->error($definition['definition']['closedMessage'], 409); } $answer = $request->get_param('answer'); if (!is_int($answer) && !(is_string($answer) && ctype_digit($answer))) { return $this->error('Choose one poll answer.', 400); } $answer = (int) $answer; if ($answer < 0 || $answer >= count($definition['definition']['options'])) { return $this->error('This answer does not belong to the poll.', 400); } self::ensure_schema(); $claim_key = $this->poll_claim_key($definition); $answer_key = $definition['definition']['options'][$answer]['id']; $stored = $this->store_vote($definition['interaction_key'], $answer_key, $claim_key); if (is_wp_error($stored)) { return $stored; } $result = [ 'message' => $definition['definition']['success'], 'selected' => $answer, ]; if (in_array($definition['definition']['results'], ['always', 'after-vote'], true)) { $results = $this->result_data($definition); if (is_wp_error($results)) { return $results; } $result['results'] = $results; } return $this->response($result); } public function poll_results($request) { $guard = $this->preflight($request, 'poll-results', 60, 600); if (is_wp_error($guard)) { return $guard; } $definition = $this->published_definition($request, 'poll'); if (is_wp_error($definition)) { return $definition; } $policy = $definition['definition']['results']; if ($policy === 'never') { return $this->error('Results are not public for this poll.', 403); } $voted = $this->has_voted($definition); if (is_wp_error($voted)) { return $voted; } if ($policy === 'after-vote') { self::ensure_schema(); if (!$voted) { return $this->error('Vote before viewing these results.', 403); } } $closed = $definition['definition']['closed'] !== '' && time() >= strtotime($definition['definition']['closed']); $results = $this->result_data($definition); if (is_wp_error($results)) { return $results; } return $this->response([ 'results' => $results, 'voted' => $voted, 'closed' => $closed, 'closedMessage' => $closed ? $definition['definition']['closedMessage'] : '', ]); } /** Cheap checks run before decoding and normalizing the published document. */ private function preflight($request, string $action, int $limit, int $window) { if (!$this->same_origin($request)) { return $this->error('This interaction must be used from this website.', 403); } if (strlen((string) $request->get_body()) > self::MAX_BODY) { return $this->error('The interaction request is too large.', 413); } return $this->rate_limit($action, $limit, $window); } /** One atomic increment per request; keys contain only keyed site/address hashes. */ private function rate_limit(string $action, int $limit, int $window) { global $wpdb; self::ensure_schema(); $window = max(60, $window); $bucket = (int) floor(time() / $window); $key = hash_hmac( 'sha256', get_current_blog_id() . '|' . $action . '|' . $this->ip_key() . '|' . $bucket, wp_salt('nonce') ); $wpdb->last_error = ''; $incremented = $wpdb->query($wpdb->prepare( 'INSERT INTO %i (limit_key,hits,expires_at,updated_at) VALUES (%s,1,%d,%s) ON DUPLICATE KEY UPDATE hits=hits+1,updated_at=VALUES(updated_at)', self::limits_table(), $key, ($bucket + 2) * $window, current_time('mysql', true) )); if ($incremented === false || $wpdb->last_error !== '') { return $this->error('The interaction service is temporarily unavailable. Try again.', 503); } $hits = (int) $wpdb->get_var($wpdb->prepare( 'SELECT hits FROM %i WHERE limit_key = %s LIMIT 1', self::limits_table(), $key )); if ($wpdb->last_error !== '' || $hits < 1) { return $this->error('The interaction service is temporarily unavailable. Try again.', 503); } if ($hits > $limit) { return $this->error('Too many interaction requests. Please try again later.', 429); } return true; } private function published_definition($request, string $expected) { if (!$this->same_origin($request)) { return $this->error('This interaction must be used from this website.', 403); } if (strlen((string) $request->get_body()) > self::MAX_BODY) { return $this->error('The interaction request is too large.', 413); } $post_id = absint($request->get_param('post')); $node_id = $request->get_param('node'); if (!$post_id || !is_string($node_id) || !preg_match('/^[a-zA-Z0-9_-]{1,80}$/D', $node_id)) { return $this->error('This interaction is unavailable.', 404); } $chain=PostTypes::source_chain($request->get_param('sourceChain')); if ($chain===null||!PostTypes::public_source($post_id,$chain)) { return $this->error('This interaction is unavailable.', 404); } $json = get_post_meta($post_id, '_digibl_published', true); if (!is_string($json) || $json === '') { return $this->error('This interaction is unavailable.', 404); } $revision = hash('sha256', $json); $cache_key = 'document:' . $post_id . ':' . $revision; if (isset(self::$document_cache[$cache_key])) { $document = self::$document_cache[$cache_key]; } else { $document = wp_cache_get($cache_key, 'digibl_interactions'); if (!is_array($document) || !isset($document['nodes']) || !is_array($document['nodes'])) { $decoded = json_decode($json, true); if (is_array($decoded)) { $decoded = $this->legacy_poll_ids($decoded, $post_id); } $document = Document::clean($decoded); if (is_wp_error($document)) { return $this->error('This interaction is unavailable.', 404); } wp_cache_set($cache_key, $document, 'digibl_interactions', 300); } self::$document_cache[$cache_key] = $document; } $found = null; $scan = function (array $nodes, bool $parent_visible = true, bool $canonical_path = true) use (&$scan, &$found, $node_id, $expected, $post_id): void { foreach ($nodes as $node) { if ($found !== null) { return; } $visible = $parent_visible && DisplayRules::visible($node['style'] ?? [],$post_id); // These modules render a different document and/or repeat it in a // dynamic host. The request contains no server-verifiable host // path, so an interaction below such a boundary is never a // canonical public endpoint, even if malformed cached input // happens to retain children below the module. $type = (string) ($node['type'] ?? ''); $canonical = $canonical_path && !in_array($type, ['reusable', 'data-repeater', 'loop'], true); if ($visible && $canonical && ($node['id'] ?? '') === $node_id && $type === $expected) { $found = $node['props'] ?? []; return; } $scan(is_array($node['children'] ?? null) ? $node['children'] : [], $visible, $canonical); } }; $scan($document['nodes']); if (!is_array($found)) { return $this->error('This interaction is unavailable.', 404); } $normal = $expected === 'quiz' ? $this->quiz_definition($found) : $this->poll_definition($found); if (is_wp_error($normal)) { return $this->error('This interaction is not configured.', 409); } $definition_hash = hash('sha256', wp_json_encode($normal)); $interaction_key = $expected === 'poll' ? hash('sha256', 'dbs-poll-v1|' . $post_id . '|' . $node_id . '|' . $normal['resultSet']) : hash_hmac( 'sha256', 'quiz|' . $post_id . '|' . $node_id . '|' . wp_json_encode($normal['questions']), wp_salt('auth') ); return [ 'post' => $post_id, 'node' => $node_id, 'type' => $expected, 'revision' => hash('sha256',$revision.'|'.wp_json_encode($chain)), 'definition_hash' => $definition_hash, 'interaction_key' => $interaction_key, 'definition' => $normal, ]; } /** * Older published documents predate stored poll option IDs. Give those rows a * deterministic request-time ID until the editor republishes and persists one. */ private function legacy_poll_ids(array $document, int $post_id): array { $walk = function (array &$nodes, string $path = '') use (&$walk, $post_id): void { foreach ($nodes as $index => &$node) { if (!is_array($node)) { continue; } $node_path = $path . '/' . $index; if (($node['type'] ?? '') === 'poll' && is_array($node['props']['options'] ?? null)) { $used = []; foreach ($node['props']['options'] as $option_index => &$option) { if (!is_array($option)) { continue; } $id = is_string($option['id'] ?? null) ? $option['id'] : ''; if (!preg_match('/^[a-z0-9_-]{8,64}$/D', $id) || isset($used[$id])) { $salt = 0; do { $seed = implode('|', [ 'dbs-poll-legacy-v1', $post_id, (string) ($node['id'] ?? $node_path), $option_index, $salt++, ]); $id = 'o_' . substr(hash('sha256', $seed), 0, 32); } while (isset($used[$id])); $option['id'] = $id; } $used[$id] = true; } unset($option); } if (is_array($node['children'] ?? null)) { $walk($node['children'], $node_path); } } unset($node); }; if (is_array($document['nodes'] ?? null)) { $walk($document['nodes']); } return $document; } private function quiz_definition(array $props) { $rows = $props['questions'] ?? null; if (!is_array($rows) || count($rows) < 1 || count($rows) > 20) { return $this->error('A quiz needs between one and twenty questions.', 400); } $questions = []; foreach ($rows as $row) { if (!is_array($row)) { return $this->error('A quiz question is invalid.', 400); } $question = $this->plain($row['question'] ?? '', 1000); $options = $this->lines($row['options'] ?? '', 12, 500); if ($question === '' || count($options) < 2 || count($options) > 12 || count(array_unique(array_map([$this, 'fold'], $options))) !== count($options)) { return $this->error('Every quiz question needs two to twelve unique answers.', 400); } $correct = $row['correct'] ?? null; if (!is_numeric($correct) || (int) $correct < 1 || (int) $correct > count($options)) { return $this->error('Every quiz question needs a valid correct answer number.', 400); } $points = is_numeric($row['points'] ?? null) ? (int) $row['points'] : 1; $questions[] = [ 'question' => $question, 'options' => $options, 'correct' => (int) $correct - 1, 'explanation' => $this->plain($row['explanation'] ?? '', 3000), 'points' => max(1, min(20, $points)), ]; } $pass = is_numeric($props['passScore'] ?? null) ? (float) $props['passScore'] : 60; return [ 'questions' => $questions, 'passScore' => max(0, min(100, $pass)), 'showAnswers' => $this->yes($props['showAnswers'] ?? 'yes'), 'passMessage' => $this->plain($props['passMessage'] ?? 'You passed the quiz.', 1000) ?: 'You passed the quiz.', 'failMessage' => $this->plain($props['failMessage'] ?? 'Try the quiz again.', 1000) ?: 'Try the quiz again.', ]; } private function poll_definition(array $props) { $question = $this->plain($props['question'] ?? '', 1000); $rows = $props['options'] ?? null; if ($question === '' || !is_array($rows) || count($rows) < 2 || count($rows) > 12) { return $this->error('A poll needs a question and two to twelve answers.', 400); } $options = []; $option_ids = []; $labels = []; foreach ($rows as $row) { $id = is_array($row) && is_string($row['id'] ?? null) ? $row['id'] : ''; $label = is_array($row) ? $this->plain($row['label'] ?? '', 500) : ''; if (!preg_match('/^[a-z0-9_-]{8,64}$/D', $id) || isset($option_ids[$id]) || $label === '') { return $this->error('Every poll answer needs a label.', 400); } $option_ids[$id] = true; $labels[] = $label; $options[] = ['id' => $id, 'label' => $label]; } if (count(array_unique(array_map([$this, 'fold'], $labels))) !== count($labels)) { return $this->error('Poll answers must be unique.', 400); } $results = is_scalar($props['results'] ?? null) ? (string) $props['results'] : 'after-vote'; if (in_array($results, ['after', 'after_vote'], true)) { $results = 'after-vote'; } if (!in_array($results, ['always', 'after-vote', 'never'], true)) { $results = 'after-vote'; } $raw_closed = $this->plain($props['closes'] ?? '', 40); $closed = $this->utc($raw_closed); if ($raw_closed !== '' && $closed === '') { return $this->error('The poll close date must be a valid UTC date.', 400); } $result_set = is_numeric($props['resultSet'] ?? null) ? (int) $props['resultSet'] : 1; return [ 'question' => $question, 'options' => $options, 'results' => $results, 'resultSet' => max(1, min(999999, $result_set)), 'closed' => $closed, 'success' => $this->plain($props['success'] ?? 'Thank you for voting.', 1000) ?: 'Thank you for voting.', 'closedMessage' => $this->plain($props['closedMessage'] ?? 'This poll is closed.', 1000) ?: 'This poll is closed.', ]; } private function verify_token($raw, array $definition) { if (!is_string($raw) || !preg_match('/^(\d{10})\.([a-f0-9]{32})\.([a-f0-9]{64})$/D', $raw, $parts)) { return $this->error('Refresh this interaction and try again.', 403); } $payload = $parts[1] . '.' . $parts[2]; $age = time() - (int) $parts[1]; if ($age < self::TOKEN_WAIT) { return $this->error('Please wait a moment and try again.', 429); } if ($age > self::TOKEN_TTL || $age < 0 || !hash_equals($this->signature($payload, $definition), $parts[3])) { return $this->error('This interaction session expired. Refresh and try again.', 403); } return ['token' => $raw, 'issued' => (int) $parts[1]]; } private function signature(string $payload, array $definition): string { return hash_hmac( 'sha256', implode('|', [ $payload, $definition['type'], $definition['post'], $definition['node'], $definition['revision'], $definition['definition_hash'], is_user_logged_in() ? hash_hmac('sha256', 'user:' . get_current_user_id() . '|session:' . wp_get_session_token(), wp_salt('nonce')) : $this->ip_key(), ]), wp_salt('auth') ); } private function store_vote(string $interaction, string $answer, string $claim) { global $wpdb; $claims = self::claims_table(); $counts = self::counts_table(); $now = current_time('mysql', true); if (!self::transactions_ready()) { return $this->error('The vote service needs transactional database tables.', 503); } if ($wpdb->query('START TRANSACTION') === false) { return $this->error('The vote could not be saved. Try again.', 503); } // A site with delayed or disabled WP-Cron must not leave an expired // duplicate claim blocking this visitor forever. Remove only this // interaction claim inside the same transaction before inserting it. $removed = $wpdb->query($wpdb->prepare( "DELETE FROM {$claims} WHERE claim_key = %s AND expires_at > 0 AND expires_at < %d", $claim, time() )); if ($removed === false) { $wpdb->query('ROLLBACK'); return $this->error('The vote could not be saved. Try again.', 503); } $inserted = $wpdb->query($wpdb->prepare( "INSERT IGNORE INTO {$claims} (claim_key,interaction_key,claim_type,expires_at,created_at) VALUES (%s,%s,'poll-vote',%d,%s)", $claim, $interaction, time() + self::CLAIM_TTL, $now )); if ($inserted === false) { $wpdb->query('ROLLBACK'); return $this->error('The vote could not be saved. Try again.', 503); } if ($inserted === 0) { $wpdb->query('ROLLBACK'); return $this->error('A vote from this visitor has already been recorded.', 409); } $counted = $wpdb->query($wpdb->prepare( "INSERT INTO {$counts} (interaction_key,answer_key,votes,updated_at) VALUES (%s,%s,1,%s) ON DUPLICATE KEY UPDATE votes=votes+1,updated_at=VALUES(updated_at)", $interaction, $answer, $now )); if ($counted === false) { $wpdb->query('ROLLBACK'); return $this->error('The vote could not be saved. Try again.', 503); } if ($wpdb->query('COMMIT') === false) { $wpdb->query('ROLLBACK'); return $this->error('The vote could not be saved. Try again.', 503); } return true; } private static function transactions_ready(): bool { global $wpdb; if (self::$transactions_ready !== null) { return self::$transactions_ready; } if (empty($wpdb->is_mysql)) { self::$transactions_ready = true; return true; } $wpdb->last_error = ''; $count = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (%s,%s) AND UPPER(ENGINE)='INNODB'", self::counts_table(), self::claims_table() )); self::$transactions_ready = $wpdb->last_error === '' && (int) $count === 2; return self::$transactions_ready; } private function poll_claim_key(array $definition): string { return hash('sha256', 'poll|' . $definition['interaction_key'] . '|' . $this->visitor_key()); } private function has_voted(array $definition) { global $wpdb; self::ensure_schema(); $claim = $this->poll_claim_key($definition); $wpdb->last_error = ''; $found = $wpdb->get_var($wpdb->prepare( 'SELECT claim_key FROM %i WHERE claim_key = %s AND expires_at >= %d LIMIT 1', self::claims_table(), $claim, time() )); if ($wpdb->last_error !== '') { return $this->error('The poll status could not be loaded. Try again.', 503); } return is_string($found) && hash_equals($claim, $found); } private function insert_claim(string $claim, string $interaction, string $type, int $expires) { global $wpdb; return $wpdb->query($wpdb->prepare( 'INSERT IGNORE INTO %i (claim_key,interaction_key,claim_type,expires_at,created_at) VALUES (%s,%s,%s,%d,%s)', self::claims_table(), $claim, $interaction, $type, $expires, current_time('mysql', true) )); } private function result_data(array $definition) { global $wpdb; self::ensure_schema(); $wpdb->last_error = ''; $rows = $wpdb->get_results($wpdb->prepare( 'SELECT answer_key,votes FROM %i WHERE interaction_key = %s', self::counts_table(), $definition['interaction_key'] ), ARRAY_A); if (!is_array($rows)) { return $this->error('Poll results could not be loaded. Try again.', 503); } $counts = []; foreach ($rows as $row) { $key = (string) ($row['answer_key'] ?? ''); if (preg_match('/^[a-z0-9_-]{8,64}$/D', $key)) { $counts[$key] = max(0, (int) ($row['votes'] ?? 0)); } } $current = []; foreach ($definition['definition']['options'] as $option) { $current[$option['id']] = $counts[$option['id']] ?? 0; } $total = array_sum($current); $items = []; foreach ($definition['definition']['options'] as $index => $option) { $votes = $current[$option['id']]; $items[] = [ 'index' => $index, 'label' => $option['label'], 'votes' => $votes, 'percentage' => $total > 0 ? round(($votes * 100) / $total, 1) : 0.0, ]; } return ['total' => $total, 'items' => $items]; } private function same_origin($request): bool { $origin = $request->get_header('origin'); if (!$origin) { return true; } $incoming = wp_parse_url($origin); $site = wp_parse_url(home_url('/')); return is_array($incoming) && is_array($site) && strtolower((string) ($incoming['scheme'] ?? '')) === strtolower((string) ($site['scheme'] ?? '')) && strtolower((string) ($incoming['host'] ?? '')) === strtolower((string) ($site['host'] ?? '')) && ($incoming['port'] ?? null) === ($site['port'] ?? null); } private function visitor_key(): string { $source = is_user_logged_in() ? 'user:' . get_current_user_id() : 'ip:' . $this->ip_key(); return hash_hmac('sha256', $source, wp_salt('nonce')); } private function ip_key(): string { // Forwarded headers are deliberately ignored; the trusted proxy must set REMOTE_ADDR. $address = isset($_SERVER['REMOTE_ADDR']) && is_string($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : ''; $address = filter_var($address, FILTER_VALIDATE_IP) !== false ? $address : ''; // Trusted Cloudflare/Plesk integrations can restore or filter this value without // teaching the public endpoint to trust spoofable forwarding headers. $address = apply_filters('digibl_interactions_visitor_address', $address); $address = is_string($address) && filter_var($address, FILTER_VALIDATE_IP) !== false ? $address : ''; return hash_hmac('sha256', $address, wp_salt('nonce')); } private function lines($value, int $maximum, int $length): array { if (!is_scalar($value)) { return []; } $rows = preg_split('/\r?\n/', (string) $value); $out = []; foreach (array_slice(is_array($rows) ? $rows : [], 0, $maximum + 1) as $row) { $row = $this->plain($row, $length); if ($row !== '') { $out[] = $row; } } return $out; } private function plain($value, int $maximum): string { if (!is_scalar($value)) { return ''; } $value = wp_strip_all_tags((string) $value, true); $value = function_exists('mb_substr') ? mb_substr($value, 0, $maximum) : substr($value, 0, $maximum); return trim(sanitize_text_field($value)); } private function fold(string $value): string { return function_exists('mb_strtolower') ? mb_strtolower($value, 'UTF-8') : strtolower($value); } private function yes($value): bool { return in_array($value, [true, 1, '1', 'yes', 'true'], true); } private function utc($value): string { if (!is_string($value) || $value === '') { return ''; } if (!preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?Z$/D', $value)) { return ''; } $format = strlen($value) === 17 ? '!Y-m-d\TH:i\Z' : '!Y-m-d\TH:i:s\Z'; $date = \DateTimeImmutable::createFromFormat($format, $value, new \DateTimeZone('UTC')); $errors = \DateTimeImmutable::getLastErrors(); return $date && (!$errors || (!$errors['warning_count'] && !$errors['error_count'])) ? $date->format('Y-m-d\TH:i:s\Z') : ''; } private function response(array $data): \WP_REST_Response { $response = new \WP_REST_Response($data); $response->header('Cache-Control', 'no-store, private'); $response->header('Pragma', 'no-cache'); return $response; } private function error(string $message, int $status): \WP_Error { return new \WP_Error('digibl_interaction', $message, ['status' => $status]); } /** * Public quiz/poll definitions have one verifiable canonical host. * * Normal posts/pages are their own host. A private digibl_template is accepted * only while it is assigned to a live Theme Builder region. Reusable, * repeater and query-loop sources therefore cannot become an unverified * public interaction endpoint merely because another document embeds them. */ public static function public_source_allowed(int $post_id): bool { return PostTypes::public_source($post_id); } private static function counts_table(): string { global $wpdb; return $wpdb->prefix . 'digibl_interaction_counts'; } private static function claims_table(): string { global $wpdb; return $wpdb->prefix . 'digibl_interaction_claims'; } private static function limits_table(): string { global $wpdb; return $wpdb->prefix . 'digibl_interaction_limits'; } }