300, 'display' => 'FormArmor']; return $schedules; }); add_action('init', static function (): void { if (!wp_next_scheduled('formguard_release_poll')) { wp_schedule_event(time() + 300, 'formguard_five_minutes', 'formguard_release_poll'); } }); add_action('formguard_release_poll', [self::class, 'poll']); } public static function request(string $path, array $input, float $timeout = 5): ?array { if (!in_array($path, ['/v1/receipts', '/v1/releases/pull', '/v1/releases/ack'], true)) { return null; } $config = get_option('formguard_connection', []); if (!defined('FORMGUARD_API_ORIGIN') || !is_string(FORMGUARD_API_ORIGIN) || !str_starts_with(FORMGUARD_API_ORIGIN, 'https://') || empty($config['site_id']) || empty($config['key_id']) || empty($config['secret'])) { return null; } $input['site_id'] = $config['site_id']; $input['request_id'] = wp_generate_uuid4(); $body = wp_json_encode($input); if (!is_string($body)) { return null; } $timestamp = (string) time(); $signature = hash_hmac('sha256', implode("\n", ['POST', $path, $timestamp, $input['request_id'], hash('sha256', $body)]), $config['secret']); $response = wp_remote_post(rtrim(FORMGUARD_API_ORIGIN, '/') . $path, [ 'timeout' => max(0.1, min(5, $timeout)), 'redirection' => 0, 'limit_response_size' => 131072, 'headers' => ['Content-Type' => 'application/json', 'X-FormGuard-Key-Id' => $config['key_id'], 'X-FormGuard-Timestamp' => $timestamp, 'X-FormGuard-Signature' => $signature], 'body' => $body, ]); if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) { return null; } $result = json_decode(wp_remote_retrieve_body($response), true); if ($path === '/v1/releases/pull' && (!is_array($result) || ($result['request_id'] ?? null) !== $input['request_id'])) { return null; } return is_array($result) ? $result : null; } private static function validCommand(mixed $command): bool { if (!is_array($command)) { return false; } $uuid = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iD'; foreach (['release_id', 'lease_token'] as $key) { if (!is_string($command[$key] ?? null) || !preg_match($uuid, $command[$key])) { return false; } } if (!is_int($command['expires_at'] ?? null) || $command['expires_at'] <= time() - 120 || $command['expires_at'] > time() + 210) { return false; } if (!is_array($command['payload'] ?? null)) { return false; } $fields = $command['payload']['text_fields'] ?? null; if (!is_array($fields) || !array_is_list($fields) || count($fields) < 1 || count($fields) > 40) { return false; } $length = 0; foreach ($fields as $field) { if (!is_array($field) || !is_string($field['key'] ?? null) || !is_string($field['value'] ?? null)) { return false; } // Match the API's Unicode character budget without requiring mbstring. $keyLength = preg_match_all('/./us', $field['key']); $valueLength = preg_match_all('/./us', $field['value']); if ($keyLength === false || $keyLength > 100 || $valueLength === false) { return false; } $length += $valueLength; if ($length > 6000) { return false; } } return true; } private static function cleanupOutcomes(): void { global $wpdb; // Scan a bounded, rotating batch, including records left by interrupted // senders. Keep longer than the server's maximum 30-day body lifetime. $cursor = get_option('formguard_outcome_cursor', ''); if (!is_string($cursor)) { $cursor = ''; } // Cache cleanup candidates only, never live leases or delivery results. // Expiry is checked again below; delayed discovery affects only old // identifier cleanup, never whether an inquiry is sent or restored. $cache_key = 'outcomes:' . hash('sha256', $cursor); $found = false; $names = wp_cache_get($cache_key, 'formguard_cleanup', false, $found); if (!$found || !is_array($names)) { $names = $wpdb->get_col($wpdb->prepare("SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s AND option_name > %s ORDER BY option_name LIMIT 100", $wpdb->esc_like('formguard_release_') . '%', $cursor)); // Empty scans must not hide newly created outcomes from cleanup. if ($names) { wp_cache_set($cache_key, $names, 'formguard_cleanup', 300); } } foreach ($names as $name) { if (!preg_match('/^formguard_release_[0-9a-f]{32}$/iD', $name)) { continue; } $outcome = get_option($name, []); if (is_array($outcome) && is_int($outcome['at'] ?? null) && $outcome['at'] < time() - 35 * DAY_IN_SECONDS) { delete_option($name); } } update_option('formguard_outcome_cursor', count($names) === 100 ? end($names) : '', false); // Old count-limited indexes omitted interrupted senders and could lose // concurrent updates. Records are now found directly, independently. delete_option('formguard_release_ledger'); } public static function poll(): void { FormGuard_Receipts::flush(); self::cleanupOutcomes(); // Destination is fixed locally. Commands cannot supply email recipients. $config = get_option('formguard_connection', []); $recipient = $config['release_recipient'] ?? ''; if (!is_string($recipient) || !is_email($recipient)) { return; } $response = self::request('/v1/releases/pull', []); $command = $response['command'] ?? null; // Validate the entire response before creating a ledger entry or sending // mail/ACK. Never silently omit malformed fields and report success. if (!self::validCommand($command)) { return; } $release_id = $command['release_id']; // A unique WP option prevents parallel consumers from emailing twice. // Store IDs and outcomes only, never the body. $option = 'formguard_release_' . str_replace('-', '', $release_id); $sent = false; global $wpdb; $started = time(); $inserted = $wpdb->query($wpdb->prepare("INSERT IGNORE INTO {$wpdb->options} (option_name,option_value,autoload) VALUES (%s,%s,'no')", $option, serialize(['at' => $started, 'sent' => null]))); // WordPress's add_option can update a racing insertion on duplicate; // use INSERT IGNORE for an actual single-winner claim. wp_cache_delete($option, 'options'); wp_cache_delete('notoptions', 'options'); if ($inserted === false) { return; } if ($inserted === 1) { $content = "FormArmorで保留された問い合わせを、管理者の操作で復元しました。\n\n"; foreach ($command['payload']['text_fields'] as $field) { $content .= $field['key'] . ":\n" . $field['value'] . "\n\n"; } $sent = wp_mail($recipient, 'FormArmor:保留された問い合わせの復元通知', $content, ['Content-Type: text/plain; charset=UTF-8']); update_option($option, ['at' => $started, 'sent' => (bool) $sent], false); } else { $outcome = get_option($option, []); // An interrupted sender is unknown; never automatically try again. if (($outcome['sent'] ?? null) === null) { return; } $sent = $outcome['sent'] === true; } self::request('/v1/releases/ack', ['release_id' => $release_id, 'lease_token' => $command['lease_token'], 'sent' => (bool) $sent]); } }