time() + MONTH_IN_SECONDS, 'path' => defined('COOKIEPATH') && COOKIEPATH ? COOKIEPATH : '/', 'domain' => defined('COOKIE_DOMAIN') ? COOKIE_DOMAIN : '', 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax', ]); } $_COOKIE[self::COOKIE] = $value; return $value; } public static function issue(string $kind, array $payload, int $ttl = 600, bool $bind_browser = true): string { $token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); $key = self::key($token); $row = [ 'kind' => sanitize_key($kind), 'payload' => $payload, 'browser_hash' => $bind_browser ? hash('sha256', self::browser_id()) : '', 'created_at' => time(), 'expires_at' => time() + max(30, $ttl), 'claimed_at' => 0, ]; if (!add_option($key, $row, '', false)) { throw new \RuntimeException('oauth_transaction_issue_failed'); } return $token; } public static function peek(string $token, string $kind = '', bool $require_browser = true): ?array { $key = self::key($token); $row = get_option($key, null); if (is_array($row) && (int) ($row['expires_at'] ?? 0) < time()) { delete_option($key); return null; } if (!self::valid_row($row, $kind, $require_browser)) { return null; } return $row; } public static function claim(string $token, string $kind = '', bool $require_browser = true): ?array { global $wpdb; $key = self::key($token); $row = get_option($key, null); if (is_array($row) && (int) ($row['expires_at'] ?? 0) < time()) { delete_option($key); return null; } if (!self::valid_row($row, $kind, $require_browser)) { return null; } $serialized = maybe_serialize($row); $row['claimed_at'] = time(); $deleted = $wpdb->query($wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", $key, $serialized )); if ($deleted !== 1) { return null; } wp_cache_delete($key, 'options'); return $row; } public static function update(string $token, string $kind, callable $mutator, bool $require_browser = false): bool { global $wpdb; $key = self::key($token); $current = get_option($key, null); if (is_array($current) && (int) ($current['expires_at'] ?? 0) < time()) { delete_option($key); return false; } if (!self::valid_row($current, $kind, $require_browser)) { return false; } $next = $mutator($current); if (!is_array($next)) { return false; } $updated = $wpdb->query($wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s", maybe_serialize($next), $key, maybe_serialize($current) )); if ($updated === 1) { wp_cache_delete($key, 'options'); return true; } return false; } public static function delete(string $token): void { delete_option(self::key($token)); } public static function acquire_lock(string $scope, int $ttl = self::LOCK_TTL) { $key = self::lock_key($scope); $token = wp_generate_uuid4(); $value = ['token' => $token, 'expires_at' => time() + max(5, $ttl)]; if (add_option($key, $value, '', false)) { return $token; } $current = get_option($key, null); if (!is_array($current) || (int) ($current['expires_at'] ?? 0) >= time()) { return false; } global $wpdb; $updated = $wpdb->query($wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s", maybe_serialize($value), $key, maybe_serialize($current) )); if ($updated === 1) { wp_cache_delete($key, 'options'); return $token; } return false; } public static function release_lock(string $scope, string $token): void { global $wpdb; $key = self::lock_key($scope); $current = get_option($key, null); if (!is_array($current) || !hash_equals((string) ($current['token'] ?? ''), $token)) { return; } $deleted = $wpdb->query($wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", $key, maybe_serialize($current) )); if ($deleted === 1) { wp_cache_delete($key, 'options'); } } public static function cleanup(int $limit = 200): int { global $wpdb; $rows = $wpdb->get_results($wpdb->prepare( "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s LIMIT %d", $wpdb->esc_like(self::PREFIX) . '%', max(1, min(1000, $limit)) ), ARRAY_A); $removed = 0; foreach (is_array($rows) ? $rows : [] as $row) { $value = maybe_unserialize($row['option_value'] ?? ''); if (!is_array($value) || (int) ($value['expires_at'] ?? 0) >= time()) { continue; } if (delete_option((string) $row['option_name'])) { $removed++; } } return $removed; } private static function valid_row($row, string $kind, bool $require_browser): bool { if (!is_array($row) || (int) ($row['expires_at'] ?? 0) < time()) { return false; } if ($kind !== '' && !hash_equals(sanitize_key($kind), (string) ($row['kind'] ?? ''))) { return false; } if ($require_browser) { $expected = (string) ($row['browser_hash'] ?? ''); if ($expected === '' || !hash_equals($expected, hash('sha256', self::browser_id()))) { return false; } } return true; } private static function key(string $token): string { return self::PREFIX . hash('sha256', trim($token)); } private static function lock_key(string $scope): string { return self::PREFIX . 'lock_' . hash('sha256', $scope); } }