--- a/leadpages/includes/rest/oauth2/Controller.php
+++ b/leadpages/includes/rest/oauth2/Controller.php
@@ -65,7 +65,7 @@
[
'methods' => 'GET',
'callback' => [ $this, 'authorize_oauth2' ],
- 'permission_callback' => [ $this, 'signin_callback_permissions_check' ],
+ 'permission_callback' => [ $this, 'authorize_permissions_check' ],
],
]
);
@@ -120,7 +120,12 @@
}
/**
- * Check if the user has permission to access the sign in callback
+ * Check if the user has permission to access the sign in callback.
+ *
+ * This intentionally allows unauthenticated access because it serves as the
+ * OAuth2 redirect_uri — the identity provider redirects the browser here after
+ * login, before a WordPress session exists.
+ *
* @return bool
*/
public function signin_callback_permissions_check() {
@@ -128,6 +133,14 @@
}
/**
+ * Check if the user has permission to start the OAuth2 authorization flow
+ * @return bool
+ */
+ public function authorize_permissions_check() {
+ return current_user_can('manage_options');
+ }
+
+ /**
* Handle the authorization of the OAuth2 sign in flow. This endpoint will create a
* code_verifier and code_challenge, and return the a URL to direct a user to for login.
*/
@@ -209,7 +222,7 @@
* @return bool
*/
public function get_oauth2_status_permissions_check() {
- return true;
+ return current_user_can('manage_options');
}
/**
@@ -236,7 +249,7 @@
* Check if the user has permission to access to the signout callback
*/
public function sign_out_callback_permissions_check() {
- return true;
+ return current_user_can('manage_options');
}
/**
--- a/leadpages/includes/rest/pages/Controller.php
+++ b/leadpages/includes/rest/pages/Controller.php
@@ -19,8 +19,8 @@
/**
* WordPress controller class for the leadpages/v1/pages collection.
*
- * Requests can only be made by authenticated WordPress users by default. There is no additional
- * permissions checking for user roles.
+ * Requests require authenticated WordPress users with the manage_options capability.
+ * This is enforced via permission_callback in register_routes.
*
* @see Schema for the request schema
*/
@@ -104,8 +104,7 @@
* @return WP_Error|bool
*/
public function get_items_permissions_check( $request ) {
- // get item and update item are calling this method so they all have the same lenient permissions
- return true;
+ return current_user_can('manage_options');
}
/**
@@ -283,6 +282,10 @@
* @return WP_Error|bool
*/
public function sync_items_permissions_check() {
+ if (! current_user_can('manage_options')) {
+ return false;
+ }
+
$logged_in = $this->is_user_logged_into_plugin();
if (! $logged_in) {
return new WP_Error('no_token', 'User is not logged in?', [ 'status' => 401 ]);
--- a/leadpages/leadpages-official.php
+++ b/leadpages/leadpages-official.php
@@ -4,7 +4,7 @@
* Plugin Name: Leadpages
* Plugin URI: https://leadpages.com/integrations/wordpress
* Description: Easily publish your Leadpages landing pages to your WordPress site. Promote your lead magnets, events, promotions, and more.
- * Version: 1.1.3
+ * Version: 1.1.4
* Author: Leadpages
* Author URI: https://leadpages.com
* Requires at least: 6.0
@@ -29,7 +29,7 @@
define('LEADPAGES_NS', 'leadpages');
define('LEADPAGES_DB_PREFIX', 'lp'); // The table name prefix wp_{prefix}
define('LEADPAGES_OPT_PREFIX', 'lp'); // The option name prefix in wp_options
-define('LEADPAGES_VERSION', '1.1.3');
+define('LEADPAGES_VERSION', '1.1.4');
require_once LEADPAGES_PATH . '/vendor/autoload.php';
--- a/leadpages/trunk/build/landingpages.asset.php
+++ b/leadpages/trunk/build/landingpages.asset.php
@@ -0,0 +1 @@
+<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-components', 'wp-element', 'wp-escape-html', 'wp-primitives', 'wp-url'), 'version' => '37c8bea1cca42cee564d');
--- a/leadpages/trunk/build/lp_settings.asset.php
+++ b/leadpages/trunk/build/lp_settings.asset.php
@@ -0,0 +1 @@
+<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-components', 'wp-element'), 'version' => '424cc99b673000e62c4b');
--- a/leadpages/trunk/includes/Activator.php
+++ b/leadpages/trunk/includes/Activator.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Leadpages;
+
+defined('ABSPATH') || die('No script kiddies please!'); // Avoid direct file request
+
+use LeadpagesprovidersUtils;
+use LeadpagesmodelsDB;
+use LeadpagesmodelsOptions;
+
+/**
+ * This class handles plugin operations that need to happen on plugin activation/deactivation
+ * or when the plugin is updated to a new version. It is only used in the plugins Core class.
+ *
+ * Note: we are not doing anything on plugin activation. If this changes in the future we should
+ * create a new method in this class and use the register_activation_hook() function to register it
+ * in the Core class.
+ *
+ */
+class Activator {
+
+ use Utils;
+
+ /** @var DB $db */
+ private $db;
+
+ public function __construct() {
+ $this->db = new DB();
+ }
+
+ /**
+ * This method gets fired when the user deactivates the plugin.
+ *
+ * On deactivation, we delete the access and refresh tokens from the options table but leave the
+ * rest of the users data intact, should they wish to reactivate later to resume the work they had done.
+ */
+ public function deactivate() {
+ Options::delete(Options::$refresh_token);
+ Options::delete(Options::$access_token);
+ }
+
+ /**
+ * Ensure that the database schema this plugin is dependent on is up to date for the current
+ * version of the plugin, whatever that might be.
+ *
+ * An admin notice will be shown if the database update failed. This would be horrendous if
+ * it occurred but there is little we can do and need to bubble it up to the user.
+ */
+ public function update_db_check() {
+ $lpdb = $this->db;
+ $current_db_version = Options::get(Options::$db_version);
+
+ try {
+ // if the database version is not set we need to install all tables
+ // otherwise migrate the database to the latest version
+ if (! $current_db_version) {
+ $this->debug('Installing leadpages database tables');
+ $lpdb->install();
+ } else {
+ $this->debug("Migrating leadpages database tables from $current_db_version to " . LEADPAGES_VERSION);
+ $lpdb->migrate($current_db_version);
+ }
+ } catch (Exception $e) {
+ add_action(
+ 'admin_notices',
+ function () {
+ echo wp_kses(
+ "<div class='notice notice-error is-dismissible'><p>
+ Leadpages encountered an error while installing/updating its database tables.
+ You can try refreshing the page or
+ <a href='https://support.leadpages.com/hc/en-us/articles/205046170'>
+ contact support
+ </a>
+ for assistance.
+ </p></div>",
+ [
+ 'div' => [
+ 'class' => [],
+ ],
+ 'p' => [],
+ 'a' => [
+ 'href' => [],
+ ],
+ ]
+ );
+ }
+ );
+ }
+ }
+}
--- a/leadpages/trunk/includes/Assets.php
+++ b/leadpages/trunk/includes/Assets.php
@@ -0,0 +1,253 @@
+<?php
+
+namespace Leadpages;
+
+defined('ABSPATH') || die('No script kiddies please!'); // Avoid direct file request
+
+use LeadpagesprovidersconfigConfig;
+use LeadpagesprovidersUtils;
+
+// All of the names of the assets the plugin uses.
+// Values must match those configured in webpack
+const ASSET_LANDINGPAGES = 'landingpages';
+const ASSET_SETTINGS = 'lp_settings';
+// Names of the pages the plugin uses
+const SLUG_LEADPAGES = 'leadpages';
+const SLUG_SETTINGS = 'lp_settings';
+const SLUG_OAUTH_COMPLETE = 'lp_login_complete';
+
+/**
+ * Base asset management class for frontend scripts and styles.
+ */
+class Assets {
+
+ use Utils;
+
+ /** @var Config */
+ public $config;
+
+ /**
+ * Path to the build and assets directories
+ */
+ public $build_path = 'build/';
+ public $assets_path = 'public/';
+
+ /**
+ * Array of arrays containing file paths to each type of asset (js, css, php)
+ * @var array
+ */
+ private $file_paths;
+
+ public function __construct() {
+ $this->config = Config::get_instance();
+
+ $assets = $this->get_asset_names();
+ foreach ($assets as $asset) {
+ $base_path = $this->build_path . $asset;
+ $this->file_paths[ $asset ] = [
+ 'js' => $base_path . '.js',
+ 'css' => $base_path . '.css',
+ 'php' => $base_path . '.asset.php',
+ 'name' => LEADPAGES_SLUG . '_' . $asset,
+ ];
+ }
+ }
+
+ /**
+ * Return all the names of all the assets that the plugin uses
+ * @return array
+ */
+ private function get_asset_names() {
+ return [
+ ASSET_LANDINGPAGES,
+ ASSET_SETTINGS,
+ // add more assets as needed...
+ ];
+ }
+
+ /**
+ * Wrapper around include construct for testing
+ *
+ * @param string $path
+ * @return mixed
+ */
+ public function get_asset_file( $path ) {
+ return include LEADPAGES_PATH . '/' . $path;
+ }
+
+ /**
+ * Enqueue scripts and styles for admin pages.
+ *
+ * @param string $hook_suffix The current admin page
+ */
+ public function enqueue_admin_scripts( $hook_suffix ) {
+ $this->enqueue_landing_pages_page($hook_suffix);
+ $this->enqueue_settings_page($hook_suffix);
+ $this->enqueue_leadpages_icons();
+ // add more scripts as needed...
+ }
+
+ /**
+ * Render the Landing Pages page (also the admin menu page)
+ *
+ * This method adds a menu page for the Leadpages plugin in the WordPress admin dashboard.
+ * The page is simply a div that a React component will attach to from the script enqueue'd
+ * in the `enqueue_admin_scripts` method.
+ */
+ public function render_admin_menu_page() {
+ add_menu_page(
+ 'Leadpages', // Page title
+ 'Leadpages', // Menu title
+ 'manage_options', // Capability
+ SLUG_LEADPAGES,
+ function () {
+ echo '
+ <div id="leadpages-page-root"></div>
+ ';
+ },
+ null, // The icon will be added through CSS
+ 80 // Positions menu under the "Settings" sidebar
+ );
+
+ // Top-level menu item (Leadpages) differ from the first sub-level item (Landing Pages)
+ // NOTE: This submenu is not visible in the UI navigation menu until there's been more
+ // than one submenu added (ex. Settings). This is a WordPress quirk. Adding this in now
+ // though for future development.
+ add_submenu_page(
+ SLUG_LEADPAGES, // Parent slug
+ 'Landing Pages', // Page title
+ 'Landing Pages', // Menu title
+ 'manage_options', // Capability
+ SLUG_LEADPAGES,
+ function () {
+ echo '
+ <div id="leadpages-page-root"></div>
+ ';
+ }
+ );
+ }
+
+ public function render_settings_page() {
+ add_submenu_page(
+ SLUG_LEADPAGES, // Parent slug
+ 'Settings', // Page title
+ 'Settings', // Menu title
+ 'manage_options', // Capability
+ SLUG_SETTINGS, // Menu slug
+ function () {
+ echo '
+ <div id="settings-page-root"></div>
+ ';
+ }
+ );
+ }
+
+ /**
+ * Render the OAuth2 completion page without a menu item. This page should only be seen within
+ * the popup window that the Leadpages OAuth login is displayed in.
+ *
+ * @see https://stackoverflow.com/questions/3902760/how-do-you-add-a-wordpress-admin-page-without-adding-it-to-the-menu/47577455#47577455
+ */
+ public function render_oauth_complete_page() {
+ add_submenu_page(
+ '', // By setting the parent slug to an empty string, it should not be visible in the menu
+ 'Login Complete',
+ 'Login Complete',
+ 'manage_options',
+ SLUG_OAUTH_COMPLETE,
+ function () {
+ include_once LEADPAGES_PATH . '/includes/other/login_complete_page.php';
+ }
+ );
+ }
+
+ /**
+ * Enqueue scripts and styles for the Landing Pages page.
+ * This method loads the scripts and styles only on the Leadpages Landing Pages page.
+ *
+ * @param string $hook The current admin page
+ */
+ private function enqueue_landing_pages_page( $hook ) {
+ // Load only on ?page=leadpages (Leadpages page)
+ if ('toplevel_page_' . SLUG_LEADPAGES !== $hook) {
+ return;
+ }
+
+ // Automatically load imported dependencies and assets version.
+ $landing_pages_asset_file = $this->get_asset_file($this->file_paths[ ASSET_LANDINGPAGES ]['php']);
+
+ // Load JavaScript
+ wp_enqueue_script(
+ $this->file_paths[ ASSET_LANDINGPAGES ]['name'],
+ plugins_url($this->file_paths[ ASSET_LANDINGPAGES ]['js'], LEADPAGES_FILE),
+ $landing_pages_asset_file['dependencies'],
+ $landing_pages_asset_file['version'],
+ true
+ );
+
+ // leverage this to inject additional data into the script
+ // these are used to conditionally link out to analytics based on environment configuration
+ wp_localize_script(
+ $this->file_paths[ ASSET_LANDINGPAGES ]['name'],
+ LEADPAGES_NS . 'Data',
+ [
+ 'homeUrl' => home_url(),
+ 'leadpagesUrl' => $this->config->get('LEADPAGES_URL'),
+ 'builderUrl' => $this->config->get('BUILDER_URL'),
+ ]
+ );
+
+ // Load CSS
+ wp_enqueue_style(
+ $this->file_paths[ ASSET_LANDINGPAGES ]['name'],
+ plugins_url($this->file_paths[ ASSET_LANDINGPAGES ]['css'], LEADPAGES_FILE),
+ [ 'wp-components' ],
+ $landing_pages_asset_file['version']
+ );
+ }
+
+ /**
+ * Enqueue scripts and styles for the Settings page.
+ * This method loads the scripts and styles only on the Leadpages Settings page.
+ *
+ * @param string $hook The current admin page
+ */
+ private function enqueue_settings_page( $hook ) {
+ // Load only on ?page=settings (Settings page)
+ if ('leadpages_page_' . SLUG_SETTINGS !== $hook) {
+ return;
+ }
+
+ // Automatically load imported dependencies and assets version.
+ $settings_asset_file = $this->get_asset_file($this->file_paths[ ASSET_SETTINGS ]['php']);
+
+ // Load JavaScript
+ wp_enqueue_script(
+ $this->file_paths[ ASSET_SETTINGS ]['name'],
+ plugins_url($this->file_paths[ ASSET_SETTINGS ]['js'], LEADPAGES_FILE),
+ $settings_asset_file['dependencies'],
+ $settings_asset_file['version'],
+ true
+ );
+
+ // Load CSS
+ wp_enqueue_style(
+ $this->file_paths[ ASSET_SETTINGS ]['name'],
+ plugins_url($this->file_paths[ ASSET_SETTINGS ]['css'], LEADPAGES_FILE),
+ [ 'wp-components' ],
+ $settings_asset_file['version']
+ );
+ }
+
+ /*
+ * Enqueue the Leadpages icons. The LP icon in the top level menu item is dependent on this.
+ */
+ private function enqueue_leadpages_icons() {
+ wp_enqueue_style(
+ 'leadpages-icons',
+ plugins_url('public/lp-icons.css', LEADPAGES_FILE),
+ [],
+ LEADPAGES_VERSION
+ );
+ }
+}
--- a/leadpages/trunk/includes/Cache.php
+++ b/leadpages/trunk/includes/Cache.php
@@ -0,0 +1,70 @@
+<?php
+
+namespace Leadpages;
+
+defined('ABSPATH') || die('No script kiddies please!'); // Avoid direct file request
+
+
+/**
+ * A class for managing a cache for Leadpages asset serving backed by the WP Transients API.
+ *
+ * WordPress Transients API: https://developer.wordpress.org/apis/transients/
+ * Transient expiration times are a maximum time. There is no minimum age. Transients
+ * might disappear one second after you set them, or 24 hours, but they will never be
+ * around after the expiration time.
+ *
+ * Pages are cached by the slug that they are published under in WordPress so that they
+ * can be quickly referenced when serving. The entire response when fetching the page
+ * is cached, not just the page HTML content.
+ */
+class Cache {
+ // The maximum amount of time a value should be cached for
+ private static $max_time = 60 * 60 * 24; // 1 day
+
+ // The prefix to cache pages with
+ private static $page_key_prefix = LEADPAGES_OPT_PREFIX . '_page_';
+
+ /*
+ * Build the cache key given the slug of the page.
+ *
+ * @param string $slug
+ * @return string
+ */
+ public static function page_key( $slug ) {
+ return self::$page_key_prefix . $slug;
+ }
+
+ /*
+ * Set a value in the cache.
+ *
+ * Example:
+ * Cache::set(Cache::page_key($slug), $page)
+ *
+ * @param string $name
+ * @param mixed $value
+ * @return boolean
+ */
+ public static function set( $name, $value ) {
+ return set_transient($name, $value, self::$max_time);
+ }
+
+ /*
+ * Retrieve a value from the cache.
+ *
+ * @param string $name
+ * @return mixed
+ */
+ public static function get( $name ) {
+ return get_transient($name);
+ }
+
+ /*
+ * Remove a value from the cache.
+ *
+ * @param string $name
+ * @return boolean
+ */
+ public static function delete( $name ) {
+ return delete_transient($name);
+ }
+}
--- a/leadpages/trunk/includes/Core.php
+++ b/leadpages/trunk/includes/Core.php
@@ -0,0 +1,172 @@
+<?php
+
+namespace Leadpages;
+
+use LeadpagesprovidersUtils;
+use LeadpagesrestService;
+use LeadpagesmodelsPage;
+use LeadpagesprovidersconfigConfig;
+use LeadpagesmodelsOptions;
+
+defined('ABSPATH') || die('No script kiddies please!'); // Avoid direct file request
+
+/**
+ * This is the core class of the plugin responsible for initializing all other dependencies
+ * and running the plugin. All of the hooks the plugin depends on are added in this class.
+ */
+class Core {
+
+ use Utils;
+
+ /** @var Core $me the singleton instantiation of the plugin */
+ private static $me;
+
+ /** @var Activator $activator */
+ private $activator;
+
+ /** @var Assets $assets */
+ private $assets;
+
+ /** @var Service $service */
+ private $service;
+
+ /** @var Proxy $proxy */
+ private $proxy;
+
+ /** @var Config */
+ private $config;
+
+ /**
+ * Instantiate all of the class dependencies.
+ *
+ * The constructor is protected because a factory method should only create
+ * a Core object.
+ */
+ protected function __construct() {
+ $this->config = Config::get_instance();
+
+ $this->activator = new Activator();
+ $this->assets = new Assets();
+ $this->service = new Service();
+ $this->proxy = new Proxy();
+ }
+
+ /**
+ * Get singleton core class.
+ *
+ * @return Core
+ */
+ public static function get_instance() {
+ return ! isset(self::$me) ? ( self::$me = new Core() ) : self::$me;
+ }
+
+ /**
+ * Register all of the hooks for the plugin.
+ */
+ public function run() {
+ add_action('init', [ $this->get_proxy(), 'serve_landing_page' ], 1);
+ add_action('init', [ $this, 'init' ]);
+ add_action('rest_api_init', [ $this->get_service(), 'rest_api_init' ]);
+
+ add_action('plugins_loaded', [ $this->get_activator(), 'update_db_check' ]);
+ register_deactivation_hook(LEADPAGES_FILE, [ $this->get_activator(), 'deactivate' ]);
+
+ add_filter('wp_insert_post_data', [ $this, 'check_and_modify_post_slug' ], 1, 1);
+ $this->setup_admin_notices();
+ }
+
+ /**
+ * This method is fired on the WordPress init hook. It sets up the admin
+ * menu items and enqueues the scripts we need for our admin pages to work.
+ */
+ public function init() {
+ add_action('admin_menu', [ $this->get_assets(), 'render_admin_menu_page' ]);
+ add_action('admin_menu', [ $this->get_assets(), 'render_oauth_complete_page' ]);
+ add_action('admin_enqueue_scripts', [ $this->get_assets(), 'enqueue_admin_scripts' ]);
+
+ if ($this->is_user_logged_into_plugin()) {
+ add_action('admin_menu', [ $this->get_assets(), 'render_settings_page' ]);
+ }
+ }
+
+ /**
+ * Alert the user if:
+ * - Permalinks are not enabled (i.e. "Plain")
+ */
+ private function setup_admin_notices() {
+ if ('' === Options::get(Options::$permalink_structure)) {
+ add_action('admin_notices', [ $this, 'turn_on_permalinks' ]);
+ }
+ }
+
+
+ /**
+ * Show an admin notice informing the user that they need to enable permalinks
+ */
+ public function turn_on_permalinks() {
+ echo wp_kses(
+ "<div class='notice notice-error is-dismissible'>
+ <p> Leadpages plugin needs
+ <a href='options-permalink.php'>permalinks</a> enabled!
+ Permalink structure can not be 'Plain'.
+ </p></div>",
+ [
+ 'div' => [
+ 'class' => [],
+ ],
+ 'p' => [],
+ 'a' => [
+ 'href' => [],
+ ],
+ ]
+ );
+ }
+
+ /**
+ * Check if Leadpages landing page is already connected with the same slug as the
+ * name of the WordPress post. This would cause permalink conflicts when serving pages if not prevented.
+ * Update the post name to a unique value across the posts and Leadpages tables.
+ *
+ * @param array $data an array of slashed, sanitized, and processed wp post data
+ * @return array
+ */
+ public function check_and_modify_post_slug( $data ) {
+ $slug = $data['post_name'];
+ $conflicts = Page::get_by_slug($slug);
+
+ // if a conflict is found, modify the slug by adding a numbered suffix and repeat until there is no conflict
+ if ($conflicts) {
+ $suffix = 2;
+ do {
+ $new_slug = $slug . '-' . $suffix;
+ ++$suffix;
+ $this->debug("Conflict with $slug, changing post name to $new_slug");
+ $conflicts = Page::get_by_slug($new_slug);
+ } while ($conflicts);
+
+ $data['post_name'] = $new_slug;
+ }
+
+ return $data;
+ }
+
+ /** @return Activator */
+ public function get_activator() {
+ return $this->activator;
+ }
+
+ /** @return Assets */
+ public function get_assets() {
+ return $this->assets;
+ }
+
+ /** @return Service */
+ public function get_service() {
+ return $this->service;
+ }
+
+ /** @return Proxy */
+ public function get_proxy() {
+ return $this->proxy;
+ }
+}
--- a/leadpages/trunk/includes/Proxy.php
+++ b/leadpages/trunk/includes/Proxy.php
@@ -0,0 +1,267 @@
+<?php
+
+namespace Leadpages;
+
+defined('ABSPATH') || die('No script kiddies please!'); // Avoid direct file request
+
+use LeadpagesprovidersUtils;
+use LeadpagesprovidershttpClient;
+use LeadpagesprovidershttpexceptionsServerException;
+use LeadpagesprovidershttpexceptionsNotFoundException;
+use LeadpagesmodelsPage;
+use LeadpagesmodelsOptions;
+
+/**
+ * A class for serving Leadpages assets within the WordPress environment.
+ */
+class Proxy {
+
+ use Utils;
+
+ /** @var Client */
+ private $client;
+
+ public function __construct() {
+ $this->client = new Client();
+ }
+
+ /**
+ * Proxy requests to Leadpages landing pages when a page has been connected
+ * for the requested path. Ignores any request methods that are not GET and ignores
+ * any paths not associated with a Leadpages landing page. If the page being served
+ * is a split test, the cookie identifying the split test variation is set.
+ *
+ * "path" and "slug" are used synonymously here and are the same as a WP "permalink"
+ *
+ * Renders the page html and exits the current process so no other code can execute.
+ *
+ * @return void
+ */
+ public function serve_landing_page() {
+ $request_method = sanitize_key($_SERVER['REQUEST_METHOD']);
+ if ('get' !== $request_method) {
+ return;
+ }
+
+ $start = microtime(true);
+
+ $current_url = $this->get_current_url();
+ $slug = sanitize_title($this->parse_request($current_url));
+
+ $cached_value = Cache::get(Cache::page_key($slug));
+ if ($cached_value) {
+ $this->debug('Serving page from cache');
+ $this->render_html($cached_value);
+ } else {
+ $page = Page::get_by_slug($slug);
+ // do not serve unpublished, unconnected or deleted pages, or pages that are variations of a splittest
+ if (! $page || ! $page->current_edition || ! $page->connected || $page->deleted_at || $page->split_test) {
+ $this->debug("Ignoring request to $current_url");
+ return;
+ }
+
+ $target_url = esc_url_raw($page->published_url, [ 'http', 'https' ]);
+ $this->debug("Proxying $current_url to $target_url");
+ $response = $this->fetch_page_html($target_url);
+ if (! $response) {
+ $this->debug('Something went wrong, aborting proxy');
+ return;
+ }
+
+ $this->render_html($response);
+
+ // Cache the page only if it is not a split test. Split tests need to
+ // be fetched each time so that our system can generate the HTML based
+ // on the split test cookie.
+ if ('LeadpageSplitTestV2' !== $page->kind) {
+ Cache::set(Cache::page_key($slug), $response);
+ }
+ }
+
+ $end = microtime(true);
+ $time_taken = ( $end - $start ) * 1000;
+ $this->debug("Successful served page in $time_taken ms");
+
+ $this->lp_exit(0);
+ }
+
+ /**
+ * Strip the base url and params out of the request url and differentiate the
+ * result against the users specified permalink structure. The result will be
+ * the slug we can expect a landing page to be published under.
+ *
+ * @param string $url
+ * @return string
+ */
+ private function parse_request( $url ) {
+ $path_and_params = substr($url, strlen(home_url()));
+ $path = explode('?', $path_and_params);
+ $tokens = explode('/', $path[0]);
+
+ $permalink_structure = $this->clean_permalink_for_leadpage();
+ $tokens = array_diff($tokens, $permalink_structure);
+
+ foreach ($tokens as $index => $token) {
+ if (empty($token)) {
+ unset($tokens[ $index ]);
+ } else {
+ $tokens[ $index ] = sanitize_title($token);
+ }
+ }
+ $tokens = array_values($tokens);
+ $slug = implode('/', $tokens);
+
+ return $slug;
+ }
+
+
+ /**
+ * Get the WordPress permalink structure with any %parameters% removed
+ *
+ * @return string[]
+ */
+ private function clean_permalink_for_leadpage() {
+ $permalink_structure = explode('/', Options::get(Options::$permalink_structure));
+ foreach ($permalink_structure as $key => $value) {
+ if (empty($value) || strpos($value, '%') !== false) {
+ unset($permalink_structure[ $key ]);
+ }
+ }
+ return $permalink_structure;
+ }
+
+ /**
+ * Get the page content for a provided url. Leadpages "variation" cookies will automatically be forwarded
+ * with the request.
+ *
+ * @param string $url
+ * @param bool $retry whether or not to retry the request on server errors
+ * @return WP_HTTP_Response|null response object or null if the 500 and 400 errors (other than 404)
+ */
+ private function fetch_page_html( $url, $retry = true ) {
+ $url = esc_url_raw($url);
+
+ $options = [ 'timeout' => 10 ];
+
+ // Transfer potential split test cookies from incoming request to proxied request.
+ // Only process the "variation" cookie if it exists.
+ if (isset($_COOKIE['variation'])) {
+ $variation_cookie = $_COOKIE['variation'];
+ $cookie = new WP_Http_Cookie('variation');
+ $cookie->name = 'variation';
+ $cookie->value = $variation_cookie;
+ $options['cookies'] = [ $cookie ];
+ }
+
+ try {
+ $response = $this->client->get($url, $options);
+ } catch (NotFoundException $e) {
+ $response = $e->response;
+ } catch (ServerException $e) {
+ $status_code = wp_remote_retrieve_response_code($e->response);
+ if ($status_code >= 500 && $retry) {
+ $response = $this->fetch_page_html($url, false);
+ } else {
+ $response = null;
+ }
+ } catch (Exception $e) {
+ $response = null;
+ }
+
+ return $response;
+ }
+
+ /**
+ * Render HTML from an HTTP response object to the page with the same status
+ * code and variation cookie (if set) as the provided response.
+ *
+ * @param WP_HTTP_Response $response
+ */
+ public function render_html( $response ) {
+ if (ob_get_length() > 0) {
+ ob_clean();
+ }
+
+ $html = $response['body'];
+ $status = wp_remote_retrieve_response_code($response);
+ $split_test_cookie = wp_remote_retrieve_cookie($response, 'variation');
+
+ status_header($status);
+ if ($split_test_cookie) {
+ setcookie(
+ $split_test_cookie->name,
+ $split_test_cookie->value,
+ $split_test_cookie->expires ?? 0
+ );
+ }
+
+ ob_start([ get_called_class(), 'preprocess_html' ]);
+ // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ echo $html;
+ ob_end_flush();
+ }
+
+ /**
+ * Wrap the exit construct for testing purposes
+ */
+ public function lp_exit() {
+ exit(0);
+ }
+
+ private static function get_current_url() {
+ return esc_url_raw(( is_ssl() ? 'https://' : 'http://' ) . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
+ }
+
+ /**
+ * Process HTML content for output buffering.
+ *
+ * @param string $content html
+ * @return string
+ */
+ public static function preprocess_html( $content ) {
+ $html = self::modify_url_tag($content);
+ $html = self::modify_serving_tags($html);
+ return $html;
+ }
+
+ /**
+ * Output buffering callback to add a "leadpages-serving-tags" meta tag for analytics. This
+ * helps us differentiate WordPress traffic in our system.
+ *
+ * @param string $content html
+ * @return string
+ */
+ public static function modify_serving_tags( $content ) {
+ $search = '</head>';
+ $replace = '<meta name="leadpages-serving-tags" content="wordpress-official"></head>';
+ return str_replace($search, $replace, $content);
+ }
+
+ /**
+ * Output buffering callback for "og:url" meta tag. Replace the tag content with
+ * the url of the WordPress page.
+ *
+ * Open Graph meta tags are snippets of code that control how URLs are displayed when shared
+ * on social media. A link to this page shared on social media should point to the users WP
+ * site and not the page within Leadpages.
+ *
+ * @param string $content html
+ * @return string
+ */
+ public static function modify_url_tag( $content ) {
+ global $wp;
+ if (empty($wp)) {
+ // we can't build the correct WP url in this case, so return the original page
+ return $content;
+ }
+
+ $url = self::get_current_url();
+ $regex = '/(<meta property="og:url" content=")[^"]+(">)/';
+ $html = preg_replace($regex, '${1}' . $url . '${2}', $content);
+ if (null === $html) {
+ // An error occured so we return the original content
+ return $content;
+ }
+ return $html;
+ }
+}
--- a/leadpages/trunk/includes/models/DB.php
+++ b/leadpages/trunk/includes/models/DB.php
@@ -0,0 +1,108 @@
+<?php
+
+namespace Leadpagesmodels;
+
+defined('ABSPATH') || die('No script kiddies please!');
+
+use LeadpagesprovidersUtils;
+use LeadpagesmodelsPage;
+use LeadpagesmodelsOptions;
+
+/**
+ * A class responsible for managing the database schema of the plugin.
+ */
+class DB {
+
+ use Utils;
+
+ /**
+ * A list of all of the database versions that the plugin may need to migrate over
+ * to reach the current version. Database versions are equivalent to plugin versions
+ * but not all plugin versions will have a corresponding database version in this list.
+ * Only when a change to the database schema is needed will the version be recorded here.
+ */
+ public static $db_versions = [
+ '1.0.0',
+ // versions must be ordered from oldest to newest - top to bottom
+ ];
+
+ /**
+ * On a fresh activation of our plugin.
+ *
+ * Create all database tables by migrating over all
+ * of the database versions to the current version.
+ */
+ public function install() {
+ foreach (self::$db_versions as $version) {
+ $this->call_migration($version);
+ Options::set(Options::$db_version, $version);
+ }
+ }
+
+ /**
+ * On a plugin update.
+ *
+ * Migrate the database to the latest version by migrating over all of the database
+ * versions from the previous version to the current version. If the previous version
+ * is the last version no operation will occur.
+ *
+ * @param string $current_db_version
+ * @returns void
+ * @throws Exception
+ */
+ public function migrate( $current_db_version ) {
+ // Get the index of the current version in our list of all versions
+ $current_index = array_search($current_db_version, self::$db_versions, true);
+ $last_index = count(self::$db_versions) - 1;
+
+ if ($current_index === $last_index) {
+ $this->debug('Already at the latest version. Nothing to migrate.');
+ return;
+ }
+
+ // If the previous version was found in the array, start the migration from there
+ if (false !== $current_index) {
+ $start = $current_index + 1;
+ $end = count(self::$db_versions);
+
+ for ($i = $start; $i < $end; $i++) {
+ $version = self::$db_versions[ $i ];
+ $this->call_migration($version);
+ Options::set(Options::$db_version, $version);
+ }
+ }
+ }
+
+ /**
+ * Call the class method corresponding to the version of the database
+ *
+ * @param string $version example: 1.2.3
+ * @returns void
+ * @throws Exception
+ */
+ private function call_migration( $version ) {
+ $this->debug('DB migration for version ' . $version);
+ // Convert the version to a format that matches the method names
+ // Example: 1.2.3 -> v1_2_3
+ $method = 'v' . str_replace('.', '_', $version);
+
+ try {
+ // Check if the method exists and call it
+ if (method_exists($this, $method)) {
+ $this->$method();
+ }
+ } catch (Exception $e) {
+ $this->debug("Migration {$version} failed: " . $e->getMessage(), __METHOD__);
+ throw $e;
+ }
+ }
+
+ /**
+ * Database version 1.0.0. Create the page table.
+ *
+ * @returns void
+ */
+ private function v1_0_0() {
+ Page::create_table();
+ }
+}
--- a/leadpages/trunk/includes/models/ModelBase.php
+++ b/leadpages/trunk/includes/models/ModelBase.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Leadpagesmodels;
+
+defined('ABSPATH') || die('No script kiddies please!');
+
+use LeadpagesmodelsexceptionsDatabaseError;
+
+/**
+ * Base class for all Leadpages models. This class provides useful methods to
+ * access the database and get the table name for the model.
+ */
+class ModelBase {
+
+ /**
+ * Throw an exception if there is a database error with the last query
+ *
+ * @return void
+ * @throws DatabaseError
+ */
+ public static function throw_on_db_error() {
+ global $wpdb;
+ $message = $wpdb->last_error;
+ if ('' !== $message) {
+ throw new DatabaseError(esc_html($message));
+ }
+ }
+}
--- a/leadpages/trunk/includes/models/Options.php
+++ b/leadpages/trunk/includes/models/Options.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace Leadpagesmodels;
+
+defined('ABSPATH') || die('No script kidd');
+
+/*
+ * A class to centralize all of our use of the options API with wrapper methods to work with them.
+ */
+class Options {
+
+ // The last time we synced the pages from leadpages.com
+ public static $last_page_sync_date = LEADPAGES_OPT_PREFIX . '_last_page_sync_date';
+
+ // The current version of the database (corresponds to the version of the plugin an update was last required)
+ public static $db_version = LEADPAGES_OPT_PREFIX . '_db_version';
+
+ // The permalink structure set in WordPress
+ // We do not create this option but we do use it. It should be readonly.
+ public static $permalink_structure = 'permalink_structure';
+
+ // The code_verifier used in the OAuth 2.0 authorization code flow
+ public static $code_verifier = LEADPAGES_OPT_PREFIX . '_code_verifier';
+
+ // The refresh and access tokens for leadpages.com OAuth 2.0. The existence of these
+ // tokens is how we determine the users authenticated status.
+ public static $refresh_token = LEADPAGES_OPT_PREFIX . '_refresh_token';
+ public static $access_token = LEADPAGES_OPT_PREFIX . '_access_token';
+
+ /*
+ * Get and return an option with the given name
+ *
+ * @param string $name
+ * @return mixed
+ */
+ public static function get( $name ) {
+ return get_option($name);
+ }
+
+ /*
+ * Set (create or update) an option given the name and it's new value
+ *
+ * @param string $name
+ * @param mixed $value
+ * @return boolean
+ */
+ public static function set( $name, $value ) {
+ return update_option($name, $value);
+ }
+
+ /*
+ * Remove an option with the given name
+ *
+ * @param string $name
+ * @return boolean
+ */
+ public static function delete( $name ) {
+ return delete_option($name);
+ }
+
+ /*
+ * Remove all of the options this plugin uses.
+ *
+ * Used in uninstall.php to clear all plugin option data
+ */
+ public static function delete_all() {
+ self::delete(self::$last_page_sync_date);
+ self::delete(self::$db_version);
+ self::delete(self::$refresh_token);
+ self::delete(self::$access_token);
+ }
+}
--- a/leadpages/trunk/includes/models/Page.php
+++ b/leadpages/trunk/includes/models/Page.php
@@ -0,0 +1,306 @@
+<?php
+
+namespace Leadpagesmodels;
+
+defined('ABSPATH') || die('No script kiddies please!');
+
+use LeadpagesmodelsexceptionsDatabaseError;
+use LeadpagesmodelsModelBase;
+
+/**
+ * A class to interact with the landing page database table
+ */
+class Page extends ModelBase {
+ /* Database table name for storing landing page data */
+ // phpcs:ignore Generic.NamingConventions.UpperCaseConstantName.ClassConstantNotUpperCase
+ public const table_name = LEADPAGES_DB_PREFIX . '_landingpages';
+
+ /**
+ * Create a table name to store landing page data synced from Leadpages
+ * - uuid - unique identifier for the page in Leadpages
+ * - name - name of landing page in Leadpages
+ * - published_url - url of landing page if published with Leadpages, null otherwise
+ * - redirect - JSON data
+ * - lp_slug - slug of landing page as published with Leadpages
+ * - last_published - last time the landing page was published with Leadpages, null if never published
+ * - current_edition - indicates whether a page is published in Leadpages, null if not currently published
+ * - split_test - indicates that the page is a split test variation. JSON data - see Scribe
+ * for the property schema. This will be null on LeadpageSplitTestV2 pages.
+ * - variations - indicates which page variations are in a split test. JSON data. This will be null
+ * on LeadpageV3 pages.
+ * - kind - Leadpages kind of page (either LeadpageV3 | LeadpageSplitTestV2)
+ * - conversions - analytic data for the page
+ * - conversion_rate - analytic data for the page
+ * - lead_value - analytic data for the page
+ * - views - analytic data for the page
+ * - visitors - analytic data for the page
+ * - updated_at - date the page was last updated on the Leadpages platform
+ * - deleted_at - date the page was deleted from Leadpages
+ * - connected - whether or not the landing page is being served through WordPress
+ * - wp_page_type - identifies how the page should be served in WordPress (not currently in use)
+ * - wp_slug - slug the page is connected under in WordPress
+ *
+ * @return void
+ * @throws Exception
+ */
+ public static function create_table() {
+ global $wpdb;
+
+ $sql = "
+ CREATE TABLE $wpdb->prefix" . self::table_name . " (
+ id INT NOT null AUTO_INCREMENT,
+ uuid VARCHAR(100) UNIQUE NOT null,
+ name VARCHAR(255) NOT null,
+ published_url VARCHAR(255) null,
+ redirect LONGTEXT null,
+ lp_slug VARCHAR(255) NOT null,
+ last_published DATETIME null,
+ current_edition VARCHAR(25) null,
+ split_test LONGTEXT null,
+ variations LONGTEXT null,
+ kind VARCHAR(50) NOT null,
+ conversion_rate FLOAT null,
+ lead_value FLOAT null,
+ conversions INT null,
+ views INT null,
+ visitors INT null,
+ updated_at DATETIME null,
+ deleted_at DATETIME null,
+ connected BOOLEAN DEFAULT FALSE not null,
+ wp_page_type VARCHAR(10) UNIQUE null,
+ wp_slug VARCHAR(100) UNIQUE null,
+ PRIMARY KEY (id),
+ INDEX uuid_idx (uuid),
+ INDEX wp_slug_idx (wp_slug)
+ ) {$wpdb->get_charset_collate()};
+ ";
+
+ // must require this file to use dbDelta
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+ dbDelta($sql);
+
+ // there does not seem to be a way to catch errors during table creation with dbDelta
+ // so we'll check if the table exists and throw an exception if it doesn't
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ $exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->prefix . self::table_name));
+ if (! $exists) {
+ throw new Exception('Could not create table: ' . esc_html(self::table_name));
+ }
+ }
+
+ /**
+ * Prepare raw landing page data from a foundry request for the database
+ *
+ * @param object $data should be raw landing page data from foundry
+ * @return array
+ */
+ private static function prepare_foundry_data( $data ) {
+ $content = $data->content;
+ $meta = $data->_meta;
+
+ return [
+ // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+ 'uuid' => $meta->id,
+ 'name' => $content->name,
+ 'published_url' => $content->publishedUrl,
+ 'redirect' => isset($content->redirect) ? wp_json_encode($content->redirect) : null,
+ 'lp_slug' => $content->slug,
+ 'last_published' => $content->lastPublished,
+ 'current_edition' => $content->currentEdition,
+ 'split_test' => isset($content->splitTest) ? wp_json_encode($content->splitTest) : null,
+ 'variations' => isset($content->variations) ? wp_json_encode($content->variations) : null,
+ 'kind' => $data->kind,
+ 'conversion_rate' => $content->conversionRate,
+ 'lead_value' => isset($content->leadValue) ? $content->leadValue : null,
+ 'conversions' => $content->conversions,
+ 'views' => $content->views,
+ 'visitors' => $content->visitors,
+ 'updated_at' => $meta->updated,
+ 'deleted_at' => $meta->deleted,
+ // phpcs:enable
+ ];
+ }
+
+ /**
+ * Create a new entry in the database for a raw landing page from a foundry request
+ *
+ * @param object $data should be raw landing page data from foundry
+ * @return int the number of rows inserted
+ * @throws DatabaseError
+ */
+ public static function create( $data ) {
+ global $wpdb;
+
+ $data = self::prepare_foundry_data($data);
+
+ $wpdb->insert(
+ $wpdb->prefix . self::table_name,
+ $data
+ );
+
+ self::throw_on_db_error();
+ return $wpdb->insert_id;
+ }
+
+ /**
+ * Update a page by it's "id" in WordPress or the "uuid" of the page in Leadpages
+ *
+ * If the data passed in as the second argument is an object it will be assumed that it's
+ * raw landing page data from a foundry request and will be prepared for database entry. Otherwise
+ * the values will be treated as an array of values to update.
+ *
+ * @param string|int $identifier can be int (id) or string (uuid)
+ * @param array|object $data can be an array of values to update or raw landing page data from foundry
+ * @return false|int number of rows updated or false when no rows were updated
+ * @throws DatabaseError
+ */
+ public static function update( $identifier, $data ) {
+ global $wpdb;
+
+ // If data is an object it must be a raw landing page data from foundry request
+ if (is_object($data)) {
+ $data = self::prepare_foundry_data($data);
+ }
+
+ // Determine the identifier type (ID or UUID)
+ $identifier_type = is_int($identifier) ? 'id' : 'uuid';
+
+ $result = $wpdb->update(
+ $wpdb->prefix . self::table_name,
+ $data,
+ [ $identifier_type => $identifier ]
+ );
+
+ self::throw_on_db_error();
+ return $result;
+ }
+
+ /**
+ * Get a page by it's "id" in WordPress or the "uuid" of the page in Leadpages
+ *
+ * @param mixed $identifier can be int (id) or string (uuid)
+ * @return object landing page data
+ * @throws DatabaseError
+ */
+ public static function get( $identifier ) {
+ global $wpdb;
+
+ // Determine the identifier type (ID or UUID)
+ // Identifier type is now a controlled value. It can be 'id' or 'uuid'
+ $identifier_type = is_int($identifier) ? 'id' : 'uuid';
+
+ $result = $wpdb->get_row(
+ $wpdb->prepare(
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
+ 'SELECT * FROM ' . $wpdb->prefix . self::table_name . " WHERE $identifier_type = %s",
+ $identifier
+ )
+ );
+
+ self::throw_on_db_error();
+ return $result;
+ }
+
+ /**
+ * Retrieve a landing page by the slug it's connected to WordPress under
+ *
+ * @param string $slug
+ * return object|null the landing page data or null if not found
+ */
+ public static function get_by_slug( $slug ) {
+ global $wpdb;
+ $slug = sanitize_title($slug);
+
+ $result = $wpdb ->get_row(
+ $wpdb->prepare(
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ 'SELECT * FROM ' . $wpdb->prefix . self::table_name . ' WHERE connected = 1 AND wp_slug = %s',
+ $slug
+ )
+ );
+
+ return $result;
+ }
+
+
+ /**
+ * Get all pages with support for pagination and filtering
+ *
+ * @param int $page
+ * @param int $per_page
+ * @param bool|null $connected
+ * @param string $order_by
+ * @param string $order
+ * @param string $search
+ * @return array results and total page count for the query returned in the form [ results, total_count ]
+ * @throws DatabaseError
+ */
+ public static function get_many( $page, $per_page, $connected, $order_by, $order, $search ) {
+ global $wpdb;
+
+ $allowed_orderby = [ 'ASC', 'DESC' ];
+ $order = strtoupper($order);
+ if (!in_array($order, $allowed_orderby, true)) {
+ throw new InvalidArgumentException(
+ 'Order must be one of: ' . esc_html(implode(', ', $allowed_orderby))
+ );
+ }
+ $column = 'date' === $order_by ? 'updated_at' : 'name';
+ $start = ( $page - 1 ) * $per_page;
+
+ $page_sql = 'SELECT * FROM ' . $wpdb->prefix . self::table_name . ' WHERE current_edition IS NOT NULL AND deleted_at IS NULL AND split_test IS NULL';
+ $count_sql = 'SELECT COUNT(*) FROM ' . $wpdb->prefix . self::table_name . ' WHERE current_edition IS NOT NULL AND deleted_at IS NULL AND split_test IS NULL';
+
+ if (null !== $connected) {
+ $page_sql .= $wpdb->prepare(' AND connected = %d', $connected);
+ $count_sql .= $wpdb->prepare(' AND connected = %d', $connected);
+ }
+
+ if ('' !== $search) {
+ $like = $wpdb->esc_like($search);
+ $page_sql .= $wpdb->prepare(' AND name LIKE %s', "%$like%");
+ }
+
+ // column and order are controlled
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $page_sql .= $wpdb->prepare(" ORDER BY $column $order LIMIT %d, %d", $start, $per_page);
+
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ $total_count = $wpdb->get_var($count_sql);
+ self::throw_on_db_error();
+
+ if (0 === $total_count) {
+ return [ [], $total_count ];
+ }
+
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ $results = $wpdb->get_results($page_sql);
+ self::throw_on_db_error();
+
+ return [ $results, $total_count ];
+ }
+
+ /**
+ * Check if there are any WordPress posts or pages with the same name as the provided slug
+ *
+ * @param string $slug
+ * @return WP_Post[]
+ */
+ public static function is_post_name_collision( $slug ) {
+ global $wpdb;
+ $slug = sanitize_title($slug);
+
+ $results = $wpdb->get_results(
+ $wpdb->prepare(
+ // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
+ "SELECT p.ID, p.post_title, p.post_name FROM $wpdb->posts p WHERE
+ EXISTS ( SELECT 1 FROM " . $wpdb->prefix . self::table_name . " WHERE p.post_name = %s)
+ AND p.post_type IN ('post', 'page')",
+ // phpcs:enable
+ $slug
+ )
+ // phpcs:enable
+ );
+ return $results;
+ }
+}
--- a/leadpages/trunk/includes/models/exceptions/DatabaseError.php
+++ b/leadpages/trunk/includes/models/exceptions/DatabaseError.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Leadpagesmodelsexceptions;
+
+defined('ABSPATH') || die('No script kiddies please!'); // Avoid direct file request
+
+use LeadpagesprovidersUtils;
+
+/**
+ * This is the base exception class for database errors
+ */
+class DatabaseError extends Exception {
+
+ use Utils;
+
+ /**
+ * Log the debug message and construct the Exception class with a default message
+ *
+ * @param string $message
+ * @return void
+ */
+ public function __construct( $message ) {
+ $error = "Database operation failed: $message";
+ $this->debug($error);
+ parent::__construct($error);
+ }
+}
--- a/leadpages/trunk/includes/other/fallback-php-version.php
+++ b/leadpages/trunk/includes/other/fallback-php-version.php
@@ -0,0 +1,25 @@
+<?php
+
+defined('ABSPATH') || die('No script kiddies please!'); // Avoid direct file request
+
+if (! function_exists('leadpages_skip_php_admin_notice')) {
+ /**
+ * Show an admin notice to administrators when the minimum PHP version
+ * could not be reached. The error message is only in english available.
+ */
+ function leadpages_skip_php_admin_notice() {
+ if (current_user_can('install_plugins')) {
+ $data = get_plugin_data(LEADPAGES_FILE, true, false);
+ echo '<div class='notice notice-error'>
+ <p><strong>' .
+ esc_html($data['Name']) .
+ '</strong> could not be initialized because you need minimum PHP version ' .
+ esc_html(LEADPAGES_MIN_PHP) .
+ ' ... you are running: ' .
+ esc_html(phpversion()) .
+ '.
+ </div>';
+ }
+ }
+}
+add_action('admin_notices', 'leadpages_skip_php_admin_notice');
--- a/leadpages/trunk/includes/other/fallback-wp-version.php
+++ b/leadpages/trunk/includes/other/fallback-wp-version.php
@@ -0,0 +1,29 @@
+<?php
+
+defined('ABSPATH') || die('No script kiddies please!'); // Avoid direct file request
+
+if (! function_exists('leadpages_skip_wp_admin_notice')) {
+ /**
+ * Show an admin notice to administrators when the minimum WP version
+ * could not be reached. The error message is only in english available.
+ */
+ function leadpages_skip_wp_admin_notice() {
+ if (current_user_can('install_plugins')) {
+ $data = get_plugin_data(LEADPAGES_FILE, true, false);
+ global $wp_version;
+ echo '<div class='notice notice-error'>
+ <p><strong>' .
+ esc_html($data['Name']) .
+ '</strong> could not be initialized because you need minimum WordPress version ' .
+ esc_html(LEADPAGES_MIN_WP) .
+ ' ... you are running: ' .
+ esc_html($wp_version) .
+ '.
+ <a href="' .
+ esc_url(admin_url('update-core.php')) .
+ '">Update WordPress now.</a>
+ </div>';
+ }
+ }
+}
+add_action('admin_notices', 'leadpages_skip_wp_admin_notice');
--- a/leadpages/trunk/includes/other/login_complete_page.php
+++ b/leadpages/trunk/includes/other/login_complete_page.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Login Complete Page Template
+ *
+ * This page has one job: to communicate from the popup window to the original window the final state
+ * of the OAuth2 login. This has to be done from a page hosted by WordPress so that the page origins are
+ * the same. Using the BroadcastChannel API, send the success status or the error message to the primary window
+ * and immediately close the popup.
+ *
+ * We are opting not to do this with React because it is faster to send a script tag with this logic
+ * than too enqueue a pre-built script with a dependency on React.
+ */
+?>
+
+<div id="fallback-root"></div>
+
+<script type="text/javascript">
+ var OAUTH_CHANNEL = 'oauth_channel';
+ var LP_ERROR_PARAM = 'lperror';
+ var ACCESS_DENIED = 'access_denied';
+ var INVALID_CODE = 'invalid_code';
+
+ /**
+ * When OAuth2 login fails, either because the user denied access or because of an
+ * issue with the authorization server, an error code will be present as a URL search
+ * parameter. Use this code to determine which error message to return.
+ */
+ function getErrorMessageFromParam() {
+ var urlParams = new URLSearchParams(window.location.search);
+ if (!urlParams.has(LP_ERROR_PARAM)) {
+ return '';
+ }
+
+ var errorCode = urlParams.get(LP_ERROR_PARAM);
+ if (errorCode === ACCESS_DENIED) {
+ return 'Access was denied.';
+ } else if (errorCode === INVALID_CODE) {
+ return 'We encountered an error while processing your request. Please try again.';
+ }
+ return 'An unknown error occurred.';
+ };
+
+ (function LoginCompleteView() {
+ var errorMessage = getErrorMessageFromParam();
+
+ if (window.BroadcastChannel) {
+ var oauthChannel = new BroadcastChannel(OAUTH_CHANNEL);
+
+ // post the result back to the opening window
+ if (!errorMessage) {
+ oauthChannel.postMessage({ success: true, error: '' });
+ } else {
+ oauthChannel.postMessage({ success: false, error: errorMessage});
+ }
+
+ oauthChannel.close();
+ window.close();
+ } else {
+ // fallback to showing a simple message to the user.
+ console.error('BroadcastChannel not supported by your browser.');
+ document.getElementById('fallback-root')
+ .innerHTML = errorMessage ? errorMessage : 'Login successful. You may close this window.';
+ }
+ })();
+</script>
--- a/leadpages/trunk/includes/providers/Utils.php
+++ b/leadpages/trunk/includes/providers/Utils.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Leadpagesproviders;
+
+use LeadpagesmodelsOptions;
+
+defined('ABSPATH') || die('No script kiddies please!'); // Avoid direct file request
+
+/**
+ * Helpful utility methods that can be used in all classes
+ */
+trait Utils {
+
+ /**
+ * Simple-to-use error_log debug log when debugging is enabled
+ *
+ * @param mixed $message The message
+ * @param string $method_or_function __METHOD__ or __FUNCTION__
+ * @return string
+ */
+ public function debug( $message, $method_or_function = null ) {
+ if (WP_DEBUG) {
+