diff --git a/README.md b/README.md index 6fb7123..0d7937d 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,27 @@ A privacy-friendly analytics WordPress plugin that integrates [OpenPanel](https: 3. Configure your Client ID in **Settings → OpenPanel** 4. Start tracking! +## Self-Hosted OpenPanel + +Runs on your own infrastructure? The plugin ships with hosted endpoints by default +(`https://api.openpanel.dev` / `https://openpanel.dev/op1.js`), but both are configurable: + +- **Settings → OpenPanel → API URL** – base URL of your self-hosted OpenPanel API + (e.g. `https://analytics.example.com/api`) +- **Settings → OpenPanel → Tracking script URL** – optional, your own copy of `op1.js` + +For code-level control, the effective endpoints follow filter > setting > default: + +| Filter | Purpose | +| --- | --- | +| `openpanel_api_url` | Override the API base URL the proxy forwards to | +| `openpanel_script_url` | Override the URL `op1.js` is loaded from | +| `openpanel_proxy_allowed_hosts` | Extend the hostname whitelist the proxy may forward to | + +```php +add_filter('openpanel_api_url', fn () => 'https://analytics.example.com/api'); +``` + ## Documentation For detailed installation instructions, FAQ, and full documentation, see [`readme.txt`](openpanel/readme.txt). diff --git a/openpanel/openpanel.php b/openpanel/openpanel.php index 8e25a0e..bdce43d 100644 --- a/openpanel/openpanel.php +++ b/openpanel/openpanel.php @@ -2,7 +2,7 @@ /** * Plugin Name: OpenPanel * Description: Activate OpenPanel to start tracking your website. - * Version: 1.0.0 + * Version: 1.1.0 * Author: OpenPanel * License: GPLv2 or later * Requires at least: 5.8 @@ -14,7 +14,7 @@ if (!defined('ABSPATH')) { exit; } final class OP_WP_Proxy { - const VERSION = '1.0.0'; + const VERSION = '1.1.0'; const OPTION_KEY = 'op_wp_proxy_settings'; const TRANSIENT_JS = 'op_wp_op1_js'; const OP_JS_URL = 'https://openpanel.dev/op1.js'; @@ -39,6 +39,8 @@ public function register_settings() { 'sanitize_callback' => function($input) { $out = []; $out['client_id'] = isset($input['client_id']) ? sanitize_text_field($input['client_id']) : ''; + $out['api_url'] = isset($input['api_url']) ? $this->sanitize_url_option($input['api_url']) : ''; + $out['script_url'] = isset($input['script_url']) ? $this->sanitize_url_option($input['script_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; @@ -47,7 +49,7 @@ public function register_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') . '

'; + echo '

' . esc_html__('Set your OpenPanel Client ID. The SDK and requests are served from your domain to avoid ad blockers. Self-hosted instances can set a custom API URL below.', 'openpanel') . '

'; }, self::OPTION_KEY); add_settings_field('client_id', __('Client ID', 'openpanel'), function() { @@ -58,6 +60,30 @@ public function register_settings() { ); }, self::OPTION_KEY, 'op_main'); + add_settings_field('api_url', __('API URL (optional)', 'openpanel'), function() { + $opts = get_option(self::OPTION_KEY); + $value = isset($opts['api_url']) ? $opts['api_url'] : ''; + printf( + '', + esc_attr(self::OPTION_KEY), + esc_attr($value), + esc_attr(self::DEFAULT_API_BASE) + ); + echo '

' . esc_html__('Leave empty to use the hosted OpenPanel API. For self-hosted instances, enter the base URL of your OpenPanel API (e.g. https://analytics.example.com/api).', 'openpanel') . '

'; + }, self::OPTION_KEY, 'op_main'); + + add_settings_field('script_url', __('Tracking script URL (optional)', 'openpanel'), function() { + $opts = get_option(self::OPTION_KEY); + $value = isset($opts['script_url']) ? $opts['script_url'] : ''; + printf( + '', + esc_attr(self::OPTION_KEY), + esc_attr($value), + esc_attr(self::OP_JS_URL) + ); + echo '

' . esc_html__('Leave empty to use the OpenPanel CDN. For fully self-hosted setups, point this at your own copy of op1.js.', 'openpanel') . '

'; + }, 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 @@ -84,6 +110,7 @@ public function handle_cache_clear() { if (isset($_POST['op_clear_cache']) && current_user_can('manage_options')) { if (isset($_POST['_wpnonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])), 'op_clear_cache_nonce')) { delete_transient(self::TRANSIENT_JS); + delete_transient(self::TRANSIENT_JS . '_' . md5($this->get_script_url())); add_action('admin_notices', function() { echo '

' . esc_html__('OpenPanel cache cleared successfully. The latest op1.js will be fetched on the next page load.', 'openpanel') . @@ -117,8 +144,9 @@ public function render_settings_page() { get_script_url()); + $cached_js = get_transient($cache_key); + $timeout_option = '_transient_timeout_' . $cache_key; $cached_time = get_option($timeout_option); if ($cached_js !== false && $cached_time) { @@ -147,6 +175,48 @@ public function render_settings_page() { saved setting > hosted default (https://api.openpanel.dev/). + */ + public function get_api_base_url() { + $opts = get_option(self::OPTION_KEY, []); + $url = !empty($opts['api_url']) ? $opts['api_url'] : self::DEFAULT_API_BASE; + $url = apply_filters('openpanel_api_url', $url); + return untrailingslashit($url); + } + + /** + * The URL op1.js is loaded from (and cached/inlined from). + * Precedence: filter > saved setting > hosted default (https://openpanel.dev/op1.js). + */ + public function get_script_url() { + $opts = get_option(self::OPTION_KEY, []); + $url = !empty($opts['script_url']) ? $opts['script_url'] : self::OP_JS_URL; + return apply_filters('openpanel_script_url', $url); + } + + /** + * Validate and normalize a URL setting. Returns '' for empty/invalid values + * so only http(s) URLs with a host can be stored. + */ + private function sanitize_url_option($value) { + $value = trim((string) $value); + if ($value === '') { + return ''; + } + $parsed = wp_parse_url($value); + if (!$parsed || empty($parsed['host']) || empty($parsed['scheme'])) { + return ''; + } + if (!in_array(strtolower($parsed['scheme']), ['https', 'http'], true)) { + return ''; + } + return untrailingslashit($value); + } + /** ---------------- Inline SDK ---------------- */ public function inject_inline_sdk() { if (is_admin()) return; @@ -167,12 +237,15 @@ public function inject_inline_sdk() { $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); + $script_url = $this->get_script_url(); + $cache_key = self::TRANSIENT_JS . '_' . md5($script_url); + + $op_js = get_transient($cache_key); if ($op_js === false) { - $res = wp_remote_get(self::OP_JS_URL, ['timeout' => 8]); + $res = wp_remote_get($script_url, ['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); + set_transient($cache_key, $op_js, self::CACHE_TIMEOUT); } } @@ -187,10 +260,10 @@ public function inject_inline_sdk() { wp_add_inline_script('op-inline-stub', $op_js, 'after'); } else { // Fall back to CDN if cached content appears invalid or unsafe - wp_enqueue_script('openpanel-op1', self::OP_JS_URL, [], self::VERSION, true); + wp_enqueue_script('openpanel-op1', $script_url, [], self::VERSION, true); } } else { - wp_enqueue_script('openpanel-op1', self::OP_JS_URL, [], self::VERSION, true); + wp_enqueue_script('openpanel-op1', $script_url, [], self::VERSION, true); } } @@ -203,7 +276,7 @@ public function register_proxy_route() { // 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) + // 1. Only proxies to allowed OpenPanel API endpoints (hostname whitelist, see 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 @@ -227,7 +300,7 @@ public function proxy_request(\WP_REST_Request $request) { } $path = ltrim($request->get_param('path') ?? '', '/'); - $target = rtrim(self::DEFAULT_API_BASE, '/') . '/' . $path; + $target = rtrim($this->get_api_base_url(), '/') . '/' . $path; // Security: Ensure we only proxy to OpenPanel API endpoints if (!$this->is_valid_proxy_target($target)) { @@ -318,17 +391,32 @@ private function collect_request_headers() { } private function is_valid_proxy_target($target) { + // Always allow the hosted OpenPanel endpoints, plus the configured + // (self-hosted) API host. Hostname list is extensible via filter. $allowed_hosts = [ 'api.openpanel.dev', 'openpanel.dev' ]; - + + $api_parsed = wp_parse_url($this->get_api_base_url()); + if ($api_parsed && !empty($api_parsed['host'])) { + $allowed_hosts[] = $api_parsed['host']; + } + + /** + * Filter the hosts the proxy is allowed to forward to. + * + * @param string[] $allowed_hosts Hostnames the proxy may target. + */ + $allowed_hosts = apply_filters('openpanel_proxy_allowed_hosts', $allowed_hosts); + $allowed_hosts = array_map('strtolower', array_unique($allowed_hosts)); + $parsed = wp_parse_url($target); if (!$parsed || !isset($parsed['host'])) { return false; } - - return in_array($parsed['host'], $allowed_hosts, true) && + + return in_array(strtolower($parsed['host']), $allowed_hosts, true) && (empty($parsed['scheme']) || in_array($parsed['scheme'], ['https', 'http'], true)); } diff --git a/openpanel/readme.txt b/openpanel/readme.txt index 06c0a58..067d042 100644 --- a/openpanel/readme.txt +++ b/openpanel/readme.txt @@ -4,7 +4,7 @@ Tags: analytics, web analytics, privacy-friendly, tracking, proxy Requires at least: 5.8 Tested up to: 6.8 Requires PHP: 7.4 -Stable tag: 1.0.0 +Stable tag: 1.1.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -65,6 +65,7 @@ This plugin integrates OpenPanel with WordPress in a blocker-resistant way: - ✅ **Track page views automatically** - ✅ **Track clicks on outgoing links** - ✅ **Track additional page attributes** + * **Self-hosted?** If you run your own OpenPanel instance, enter its API base URL in the **API URL** field (e.g. `https://analytics.example.com/api`) and, optionally, point the **Tracking script URL** at your own copy of `op1.js`. Leave both empty to use the hosted OpenPanel service. 4. **Verify Installation**: * Visit your website frontend @@ -93,6 +94,23 @@ The plugin automatically falls back to loading the script from the OpenPanel CDN = How is the script cached? = The `op1.js` script is cached locally for 1 week using WordPress transients. You can manually clear the cache from the plugin settings page if needed. += Can I use a self-hosted OpenPanel instance? = +Yes. In **Settings → OpenPanel**, set the **API URL** field to the base URL of your self-hosted OpenPanel API (it must end in `/api`, e.g. `https://analytics.example.com/api`). The proxy will then forward tracking requests to your instance instead of the hosted API. For fully self-hosted setups you can also set the **Tracking script URL** to your own copy of `op1.js`. + += Can I override the endpoints from code? = +Yes. The effective endpoints follow the precedence filter > setting > default. Developers can use: + +* `openpanel_api_url` – filter the API base URL the proxy forwards to +* `openpanel_script_url` – filter the URL `op1.js` is loaded from +* `openpanel_proxy_allowed_hosts` – filter the hostname whitelist the proxy may forward to + +Example: +```php +add_filter('openpanel_api_url', function () { + return 'https://analytics.example.com/api'; +}); +``` + = Can I limit tracking to certain users or pages? = Yes! The plugin includes hooks and checks. For example, tracking is automatically disabled for admin pages. You can extend this by modifying the `inject_inline_sdk()` method or using WordPress filters. @@ -132,8 +150,10 @@ This plugin connects to external OpenPanel.dev services to provide web analytics - **No background tracking** - data is sent only for the specific interactions you've configured * **External endpoints used**: - - `https://openpanel.dev/op1.js` - Analytics tracking script (cached locally) - - `https://api.openpanel.dev/` - Analytics data collection API (proxied through your WordPress site) + - `https://openpanel.dev/op1.js` - Analytics tracking script (cached locally; configurable via the "Tracking script URL" setting for self-hosted setups) + - `https://api.openpanel.dev/` - Analytics data collection API (proxied through your WordPress site; configurable via the "API URL" setting for self-hosted setups) + +When self-hosting, these endpoints are replaced by the URLs configured in **Settings → OpenPanel**. * **Legal Information**: - Service Terms: https://openpanel.dev/terms @@ -143,6 +163,12 @@ This integration is essential for the plugin's core functionality of providing w == Changelog == += 1.1.0 = +* ✨ **Self-hosted support**: configure a custom **API URL** (and optional tracking **script URL**) in Settings → OpenPanel +* 🔧 New filters: `openpanel_api_url`, `openpanel_script_url`, `openpanel_proxy_allowed_hosts` +* 🔒 Proxy hostname whitelist now includes the configured API host (always allows hosted OpenPanel endpoints by default) +* ⚡ Script cache is keyed by script URL so switching providers never serves a stale `op1.js` + = 1.0.0 = * **Initial Release** - Complete OpenPanel WordPress integration * ✅ Automatic script inlining with local caching (1 week cache duration) @@ -156,5 +182,8 @@ This integration is essential for the plugin's core functionality of providing w == Upgrade Notice == += 1.1.0 = +Adds support for self-hosted OpenPanel instances via configurable API/script URLs (settings and filters). Fully backward compatible with the hosted service. + = 1.0.0 = Initial release of the OpenPanel WordPress plugin. Provides ad-blocker resistant analytics with local script caching and API proxying.