.*)'; // 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';
?>
class="op-hosting-mode">
class="op-hosting-mode">
' . 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();