.*)'; // wildcard path passthrough const CACHE_TIMEOUT = WEEK_IN_SECONDS; // Cloud defaults const CLOUD_JS_URL = 'https://openpanel.dev/op1.js'; const CLOUD_API_BASE = 'https://api.openpanel.dev/'; public function __construct() { add_action('admin_init', [$this, 'register_settings']); add_action('admin_menu', [$this, 'add_settings_page']); add_action('wp_enqueue_scripts', [$this, 'inject_inline_sdk'], 0); add_action('rest_api_init', [$this, 'register_proxy_route']); add_action('admin_init', [$this, 'handle_cache_clear']); } /** ---------------- Settings ---------------- */ public function register_settings() { register_setting(self::OPTION_KEY, self::OPTION_KEY, [ 'type' => 'array', 'show_in_rest' => false, 'sanitize_callback' => function($input) { $out = []; $out['hosting_mode'] = isset($input['hosting_mode']) && $input['hosting_mode'] === 'selfhosted' ? 'selfhosted' : 'cloud'; $out['client_id'] = isset($input['client_id']) ? sanitize_text_field($input['client_id']) : ''; $out['api_url'] = isset($input['api_url']) ? esc_url_raw(untrailingslashit($input['api_url'])) : ''; $out['dashboard_url'] = isset($input['dashboard_url']) ? esc_url_raw(untrailingslashit($input['dashboard_url'])) : ''; $out['track_screen'] = !empty($input['track_screen']) ? 1 : 0; $out['track_outgoing'] = !empty($input['track_outgoing']) ? 1 : 0; $out['track_attributes'] = !empty($input['track_attributes']) ? 1 : 0; return $out; } ]); // Section: Hosting Mode add_settings_section('op_hosting', __('Hosting Mode', 'openpanel'), function() { echo '

' . esc_html__('Choose between OpenPanel Cloud or your own Self-Hosted instance.', 'openpanel') . '

'; }, self::OPTION_KEY); add_settings_field('hosting_mode', __('Mode', 'openpanel'), function() { $opts = get_option(self::OPTION_KEY); $mode = isset($opts['hosting_mode']) ? $opts['hosting_mode'] : 'cloud'; ?>

' . esc_html__('Configure your Self-Hosted OpenPanel URLs. Only required if using Self-Hosted mode.', 'openpanel') . '

'; }, self::OPTION_KEY); add_settings_field('api_url', __('API URL', 'openpanel'), function() { $opts = get_option(self::OPTION_KEY); printf('', esc_attr(self::OPTION_KEY), isset($opts['api_url']) ? esc_attr($opts['api_url']) : '' ); echo '

' . esc_html__('Your OpenPanel API endpoint (e.g., https://api.openpanel.yourdomain.com)', 'openpanel') . '

'; }, self::OPTION_KEY, 'op_selfhosted'); add_settings_field('dashboard_url', __('Dashboard URL', 'openpanel'), function() { $opts = get_option(self::OPTION_KEY); printf('', esc_attr(self::OPTION_KEY), isset($opts['dashboard_url']) ? esc_attr($opts['dashboard_url']) : '' ); echo '

' . esc_html__('Your OpenPanel Dashboard URL — used to load op1.js from your server instead of the CDN (e.g., https://openpanel.yourdomain.com)', 'openpanel') . '

'; }, self::OPTION_KEY, 'op_selfhosted'); // Section: Main Settings add_settings_section('op_main', __('OpenPanel Settings', 'openpanel'), function() { echo '

' . esc_html__('Set your OpenPanel Client ID. The SDK and requests are served from your domain to avoid ad blockers.', 'openpanel') . '

'; }, self::OPTION_KEY); add_settings_field('client_id', __('Client ID', 'openpanel'), function() { $opts = get_option(self::OPTION_KEY); printf('', esc_attr(self::OPTION_KEY), isset($opts['client_id']) ? esc_attr($opts['client_id']) : '' ); }, self::OPTION_KEY, 'op_main'); add_settings_field('toggles', __('Auto-tracking (optional)', 'openpanel'), function() { $o = get_option(self::OPTION_KEY); // Default track_screen to true if not set $track_screen = isset($o['track_screen']) ? !empty($o['track_screen']) : true; ?>

' . esc_html__('OpenPanel cache cleared successfully. The latest op1.js will be fetched on the next page load.', 'openpanel') . '

'; }); } } } public function render_settings_page() { $opts = get_option(self::OPTION_KEY); $mode = isset($opts['hosting_mode']) ? $opts['hosting_mode'] : 'cloud'; ?>

OpenPanel


0) { echo '

' . /* translators: %s: human readable time difference */ sprintf(esc_html__('Cache expires in %s', 'openpanel'), esc_html(human_time_diff(time(), $cached_time))) . '

'; } else { echo '

' . esc_html__('Cache has expired and will refresh on next page load.', 'openpanel') . '

'; } } else { echo '

' . esc_html__('No cached version found. op1.js will be fetched on next page load.', 'openpanel') . '

'; } ?>


get_api_base()); ?>
get_js_url()); ?>
get_js_url(); $init = [ 'clientId' => $clientId, 'apiUrl' => $apiUrl, 'trackScreenViews' => isset($opts['track_screen']) ? !empty($opts['track_screen']) : true, 'trackOutgoingLinks' => !empty($opts['track_outgoing']), 'trackAttributes' => !empty($opts['track_attributes']), ]; $bootstrap = "(function(){window.op=window.op||function(){(window.op.q=window.op.q||[]).push(arguments)};window.op('init'," . wp_json_encode($init) . ");})();"; $op_js = get_transient(self::TRANSIENT_JS); if ($op_js === false) { $res = wp_remote_get($jsUrl, ['timeout' => 8]); if (!is_wp_error($res) && 200 === wp_remote_retrieve_response_code($res)) { $op_js = wp_remote_retrieve_body($res); set_transient(self::TRANSIENT_JS, $op_js, self::CACHE_TIMEOUT); } } wp_register_script('op-inline-stub', false, [], self::VERSION, true); wp_enqueue_script('op-inline-stub'); wp_add_inline_script('op-inline-stub', $bootstrap, 'before'); if (!empty($op_js)) { // Validate cached JavaScript content before output if ($this->is_valid_javascript_content($op_js)) { wp_add_inline_script('op-inline-stub', $op_js, 'after'); } else { // Fall back to external script if cached content appears invalid or unsafe wp_enqueue_script('openpanel-op1', $jsUrl, [], self::VERSION, true); } } else { wp_enqueue_script('openpanel-op1', $jsUrl, [], self::VERSION, true); } } /** ---------------- Proxy Route ---------------- */ public function register_proxy_route() { register_rest_route(self::REST_NS, self::REST_ROUTE, [ 'methods' => \WP_REST_Server::ALLMETHODS, // INTENTIONALLY PUBLIC ENDPOINT: This endpoint must be publicly accessible to receive // analytics data from frontend JavaScript running in users' browsers. No authentication // is required as this acts as a proxy for OpenPanel analytics collection. // // Security measures in place: // 1. Only proxies to whitelisted OpenPanel API endpoints (is_valid_proxy_target) // 2. All input data is sanitized and validated before forwarding // 3. Proper CORS headers are set for same-origin requests only // 4. No sensitive WordPress data is exposed through this endpoint 'permission_callback' => '__return_true', 'callback' => [$this, 'proxy_request'], 'args' => [ 'path' => [ 'description' => 'Remaining path to forward', 'required' => false, ], ], ]); } public function proxy_request(\WP_REST_Request $request) { try { // Handle CORS preflight quickly if (strtoupper($request->get_method()) === 'OPTIONS') { $resp = new \WP_REST_Response(null, 204); $this->add_cors_headers($resp); return $resp; } $path = ltrim($request->get_param('path') ?? '', '/'); $api_base = $this->get_api_base(); $target = rtrim($api_base, '/') . '/' . $path; // Security: Ensure we only proxy to OpenPanel API endpoints if (!$this->is_valid_proxy_target($target)) { $resp = new \WP_REST_Response(['error' => 'invalid_target', 'message' => 'Invalid proxy target'], 403); $this->add_cors_headers($resp); return $resp; } $method = strtoupper($request->get_method()); $body = $request->get_body(); $incoming = $this->collect_request_headers(); $query = $request->get_query_params(); if (!empty($query)) { $target = add_query_arg($query, $target); } $args = [ 'method' => $method, 'timeout' => 10, 'headers' => $incoming, 'body' => in_array($method, ['POST','PUT','PATCH','DELETE'], true) ? $body : null, ]; $res = wp_remote_request($target, $args); if (is_wp_error($res)) { $resp = new \WP_REST_Response(['error' => 'proxy_failed', 'message' => $res->get_error_message()], 502); $this->add_cors_headers($resp); $resp->header('Cache-Control', 'no-store'); return $resp; } $code = wp_remote_retrieve_response_code($res); $bodyOut = wp_remote_retrieve_body($res); // Handle empty response (common for tracking endpoints returning 202) if (empty($bodyOut)) { $resp = new \WP_REST_Response(['success' => true], $code ?: 202); $resp->header('Content-Type', 'application/json; charset=utf-8'); $resp->header('Cache-Control', 'no-store'); $this->add_cors_headers($resp); return $resp; } // Try to decode JSON response, otherwise use raw body $decoded = json_decode($bodyOut, true); $responseData = (json_last_error() === JSON_ERROR_NONE && $decoded !== null) ? $decoded : ['raw' => $bodyOut]; $resp = new \WP_REST_Response($responseData, $code); // Set Content-Type header (skip copying other headers to avoid compatibility issues) $resp->header('Content-Type', 'application/json; charset=utf-8'); $resp->header('Cache-Control', 'no-store'); $this->add_cors_headers($resp); return $resp; } catch (\Exception $e) { $resp = new \WP_REST_Response(['error' => 'exception', 'message' => $e->getMessage()], 500); $this->add_cors_headers($resp); return $resp; } catch (\Error $e) { $resp = new \WP_REST_Response(['error' => 'error', 'message' => $e->getMessage()], 500); $this->add_cors_headers($resp); return $resp; } } private function add_cors_headers(\WP_REST_Response $resp) { $origin = get_site_url(); $resp->header('Access-Control-Allow-Origin', $origin); $resp->header('Access-Control-Allow-Credentials', 'true'); $resp->header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); $resp->header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); $resp->header('Vary', 'Origin'); } private function collect_request_headers() { $headers = []; // Content-Type is a special header NOT prefixed with HTTP_ in PHP // This is critical for OpenPanel API which requires application/json if (!empty($_SERVER['CONTENT_TYPE'])) { $headers['Content-Type'] = sanitize_text_field(wp_unslash($_SERVER['CONTENT_TYPE'])); } foreach ($_SERVER as $name => $value) { // Sanitize the header name and ensure it starts with HTTP_ $name = sanitize_text_field($name); if (strpos($name, 'HTTP_') === 0) { // Extract and sanitize the header suffix $header_suffix = substr($name, 5); $header_suffix = sanitize_text_field($header_suffix); $header = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', $header_suffix)))); // remove hop-by-hop headers and Origin (we'll set it ourselves) $lk = strtolower($header); if (in_array($lk, ['host','content-length','accept-encoding','connection','keep-alive','transfer-encoding','upgrade','via','origin'], true)) { continue; } // Sanitize the header value $headers[$header] = sanitize_text_field($value); } } if (!isset($headers['User-Agent'])) { $headers['User-Agent'] = 'OpenPanel-WP-Proxy'; } // Set Origin header to WordPress site URL (required for OpenPanel CORS) $headers['Origin'] = get_site_url(); return $headers; } private function is_valid_proxy_target($target) { $allowed_hosts = $this->get_allowed_hosts(); $parsed = wp_parse_url($target); if (!$parsed || !isset($parsed['host'])) { return false; } return in_array($parsed['host'], $allowed_hosts, true) && (empty($parsed['scheme']) || in_array($parsed['scheme'], ['https', 'http'], true)); } private function is_valid_javascript_content($js_content) { // Comprehensive validation to ensure the content is safe JavaScript if (empty($js_content) || !is_string($js_content)) { return false; } // Note: We don't modify $js_content with wp_kses here because we need to preserve // the original JavaScript content for validation. wp_add_inline_script() handles // proper escaping when outputting to the page. // Ensure it doesn't contain HTML tags or script injection attempts if (preg_match('/<(?:script|iframe|object|embed|form|input|meta|link)/i', $js_content)) { return false; } // Check for potential XSS patterns if (preg_match('/(?:javascript:|data:|vbscript:|on\w+\s*=)/i', $js_content)) { return false; } // Check that it's a reasonable size for a JavaScript file (typical op1.js is ~5KB) $trimmed = trim($js_content); if (strlen($trimmed) < 10 || strlen($trimmed) > 20480) { // Max 20KB return false; } // Should contain typical JavaScript patterns (works for both minified and unminified) return (strpos($trimmed, 'function') !== false || strpos($trimmed, 'var ') !== false || strpos($trimmed, 'let ') !== false || strpos($trimmed, 'const ') !== false || strpos($trimmed, '=>') !== false || // Handle minified code patterns like the actual op1.js preg_match('/[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*function/', $trimmed) || preg_match('/\(\s*function\s*\(/', $trimmed)); } } new OP_WP_Proxy();