Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
118 changes: 103 additions & 15 deletions openpanel/openpanel.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';
Expand All @@ -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;
Expand All @@ -47,7 +49,7 @@ public function register_settings() {
]);

add_settings_section('op_main', __('OpenPanel Settings', 'openpanel'), function() {
echo '<p>' . esc_html__('Set your OpenPanel Client ID. The SDK and requests are served from your domain to avoid ad blockers.', 'openpanel') . '</p>';
echo '<p>' . 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') . '</p>';
}, self::OPTION_KEY);

add_settings_field('client_id', __('Client ID', 'openpanel'), function() {
Expand All @@ -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(
'<input type="url" name="%s[api_url]" value="%s" class="regular-text" placeholder="%s"/>',
esc_attr(self::OPTION_KEY),
esc_attr($value),
esc_attr(self::DEFAULT_API_BASE)
);
echo '<p class="description">' . 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') . '</p>';
}, 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(
'<input type="url" name="%s[script_url]" value="%s" class="regular-text" placeholder="%s"/>',
esc_attr(self::OPTION_KEY),
esc_attr($value),
esc_attr(self::OP_JS_URL)
);
echo '<p class="description">' . esc_html__('Leave empty to use the OpenPanel CDN. For fully self-hosted setups, point this at your own copy of op1.js.', 'openpanel') . '</p>';
}, 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
Expand All @@ -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 '<div class="notice notice-success is-dismissible"><p>' .
esc_html__('OpenPanel cache cleared successfully. The latest op1.js will be fetched on the next page load.', 'openpanel') .
Expand Down Expand Up @@ -117,8 +144,9 @@ public function render_settings_page() {

<?php
// Check if transient exists and get expiration info
$cached_js = get_transient(self::TRANSIENT_JS);
$timeout_option = '_transient_timeout_' . self::TRANSIENT_JS;
$cache_key = self::TRANSIENT_JS . '_' . md5($this->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) {
Expand Down Expand Up @@ -147,6 +175,48 @@ public function render_settings_page() {
<?php
}

/** ---------------- Effective URLs & sanitization ---------------- */

/**
* The API base URL the proxy forwards to.
* Precedence: filter > 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;
Expand All @@ -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);
}
}

Expand All @@ -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);
}
}

Expand All @@ -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
Expand All @@ -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)) {
Expand Down Expand Up @@ -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));
}

Expand Down
35 changes: 32 additions & 3 deletions openpanel/readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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.