add_action( 'admin_post_advisorppc_connect', $this, 'handle_connect_start' ); $loader->add_action( 'admin_post_advisorppc_manual_connect', $this, 'handle_manual_connect' ); $loader->add_action( 'admin_post_advisorppc_disconnect', $this, 'handle_disconnect' ); $loader->add_action( 'admin_post_advisorppc_refresh_health', $this, 'handle_refresh_health' ); $loader->add_action( 'admin_init', $this, 'maybe_handle_return' ); } /** * Where the browser is sent to approve the connection. * * @return string */ public static function connect_endpoint() { /** * Filter the AdvisorPPC connect page URL. * * @param string $url Connect URL. */ return esc_url_raw( apply_filters( 'advisorppc_connect_url', 'https://advisorppc.com/connect/wordpress' ) ); } /** * The host the connect flow is allowed to leave the site for. */ const CONNECT_HOST = 'advisorppc.com'; /** * Whether a connect URL is a destination we will send an administrator to. * * `advisorppc_connect_url` is a public filter and this is the one place in * the plugin that performs an unrestricted redirect, so the destination is * checked rather than trusted: https only, and the connect host or a * subdomain of it. * * @param string $url Candidate URL. * @return bool */ public static function is_allowed_connect_url( $url ) { /** * Filter the hosts the connect flow may redirect to. * * Narrowing only, exactly like `advisorppc_service_hosts`: a host that * is not the compiled-in connect host is discarded. readme.txt tells * the administrator that clicking Connect opens advisorppc.com, and a * filter any plugin can register must not be able to make that false. * * @since 0.1.0 * @param string[] $hosts Allowed hosts. Subdomains of each are allowed. */ $offered = (array) apply_filters( 'advisorppc_connect_hosts', array( self::CONNECT_HOST ) ); $allowed = array(); foreach ( $offered as $host ) { if ( ! is_scalar( $host ) ) { continue; } $host = strtolower( trim( (string) $host ) ); if ( self::CONNECT_HOST === $host ) { $allowed[] = $host; } } return self::host_matches( $url, $allowed ); } /** * Whether an https URL's host is one of `$allowed`, or a subdomain of one. * * @param string $url Candidate URL. * @param string[] $allowed Registrable domains. * @return bool */ protected static function host_matches( $url, $allowed ) { $url = (string) $url; if ( 0 !== strpos( $url, 'https://' ) ) { return false; } $host = wp_parse_url( $url, PHP_URL_HOST ); if ( ! is_string( $host ) || '' === $host ) { return false; } $host = strtolower( $host ); foreach ( (array) $allowed as $candidate ) { $candidate = strtolower( trim( (string) $candidate ) ); if ( '' === $candidate ) { continue; } if ( $host === $candidate ) { return true; } // Anchored on the dot, so "notadvisorppc.com" and // "advisorppc.com.evil.com" both fail. if ( substr( $host, -( strlen( $candidate ) + 1 ) ) === '.' . $candidate ) { return true; } } return false; } /** * Hosts a service directory entry may name. * * Two registrable domains: advisorppc.com is the product and advisorppc.ai * serves Shield. The retired pre-1.0 domain is deliberately absent: a * stored directory entry that still names it is refused on read, and the * module falls back to its compiled-in host. */ const SERVICE_HOSTS = array( 'advisorppc.com', 'advisorppc.ai' ); /** * Whether a service base URL is one we will point a module at. * * Two tests, both required. The host has to be on the service allowlist, * and the URL has to be a bare https origin: scheme and host only. A base * that carried a path would become the prefix of every endpoint built on * it, so `https://mcp.advisorppc.com/evil` would send signed event traffic * to an arbitrary path on a first-party host. Userinfo, a port, a query and * a fragment are refused for the same reason: none of them has a * legitimate use in a service base, and each is a way to smuggle a * different destination past a host check. * * This is applied at write time (filter_service_directory()) AND at every * read (AdvisorPPC_Options::service_url(), AdvisorPPC_Http::directory_base(), * AdvisorPPC_Shield::base_url()), so a hand-edited or pre-upgrade option * row is refused the same way a bad response is. * * @param string $url Candidate URL. * @return bool */ public static function is_allowed_service_url( $url ) { if ( ! self::is_bare_origin( $url ) ) { return false; } return self::host_matches( $url, self::service_hosts() ); } /** * The service host allowlist, after the filter has had its say. * * `advisorppc_service_hosts` can only ever NARROW the compiled-in list. A * filter that adds a host is discarded, because any plugin on the site can * register one and the readme promises signed traffic and the Shield * collector only ever reach advisorppc.com or advisorppc.ai. * A filter that leaves nothing behind narrows the list to nothing, which * refuses every directory entry and leaves each module on its compiled-in * host: fail closed, never open. * * @return string[] */ public static function service_hosts() { /** * Filter the AdvisorPPC service host allowlist. * * Narrowing only: any host not in the compiled-in list is discarded. * * @since 0.1.0 * @param string[] $hosts Compiled-in registrable domains. */ $offered = (array) apply_filters( 'advisorppc_service_hosts', self::SERVICE_HOSTS ); $narrowed = array(); foreach ( $offered as $host ) { if ( ! is_scalar( $host ) ) { continue; } $host = strtolower( trim( (string) $host ) ); if ( '' === $host || ! in_array( $host, self::SERVICE_HOSTS, true ) ) { continue; } $narrowed[] = $host; } return array_values( array_unique( $narrowed ) ); } /** * Whether a URL is `https://host` and nothing more (a trailing slash is * tolerated, because untrailingslashit() strips it before use). * * @param string $url Candidate URL. * @return bool */ public static function is_bare_origin( $url ) { $url = (string) $url; if ( 0 !== strpos( $url, 'https://' ) ) { return false; } $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || empty( $parts['host'] ) ) { return false; } foreach ( array( 'user', 'pass', 'port', 'query', 'fragment' ) as $forbidden ) { if ( isset( $parts[ $forbidden ] ) ) { return false; } } $path = isset( $parts['path'] ) ? (string) $parts['path'] : ''; return '' === $path || '/' === $path; } /** * The services a directory may name, in the order they are stored. */ const SERVICE_KEYS = array( 'tracking', 'shield', 'gravity', 'tracktor', 'api' ); /** * Reduce an offered `services` block to the entries we will honour. * * Pure: no options, no logging. An entry survives only when it is a * non-empty https URL on the service allowlist. https alone is not enough: * Shield loads s.js from its host and sends it the visitor's IP and user * agent, so a directory that could name any https host would turn one bad * server response into a data leak on every page view. * * @param mixed $offered The block as decoded from the response. * @return array{accepted: array, rejected: string[]} */ public static function filter_service_directory( $offered ) { $accepted = array(); $rejected = array(); if ( ! is_array( $offered ) ) { return array( 'accepted' => $accepted, 'rejected' => $rejected, ); } foreach ( self::SERVICE_KEYS as $service ) { if ( ! isset( $offered[ $service ] ) ) { continue; } $url = is_scalar( $offered[ $service ] ) ? esc_url_raw( trim( (string) $offered[ $service ] ) ) : ''; if ( '' !== $url && self::is_allowed_service_url( $url ) ) { $accepted[ $service ] = untrailingslashit( $url ); continue; } $rejected[] = $service; } return array( 'accepted' => $accepted, 'rejected' => $rejected, ); } /** * Update the stored service directory from a server response. * * Rules, in order: * * 1. No `services` block, or one that is not a non-empty object: the * response says nothing about hosts, so the stored directory is kept. * A health response from an older server must not wipe the directory * the exchange supplied. * 2. A block whose every entry fails the allowlist: kept as well, and * logged. Writing an empty array here would wipe a first-party Shield * host because of one bad response, and the fallback is only the * compiled-in default. * 3. Otherwise the accepted entries are written. A key the server offered * but we rejected keeps its previous value, but only when that previous * value passes the allowlist itself: a stored entry that fails it (a * hand edit, a row written before the allowlist existed) is dropped * rather than preserved, so one bad response can never keep a poisoned * host alive. A key the server no longer offers is dropped, so a * retired host cannot outlive the response that retired it. * * Disconnecting clears the directory outright (handle_disconnect()). * * @param array $body Decoded response body. * @param string $source 'exchange' or 'health', for the log line. * @return bool Whether the stored directory changed. */ public static function store_service_directory( $body, $source = 'exchange' ) { $previous = AdvisorPPC_Options::get( 'services', array() ); $previous = is_array( $previous ) ? $previous : array(); if ( ! is_array( $body ) || ! isset( $body['services'] ) || ! is_array( $body['services'] ) || empty( $body['services'] ) ) { AdvisorPPC_Logger::debug( 'Service directory: no services block in the ' . $source . ' response, keeping the stored directory.' ); return false; } $filtered = self::filter_service_directory( $body['services'] ); if ( empty( $filtered['accepted'] ) ) { AdvisorPPC_Logger::warning( 'Service directory: every entry in the ' . $source . ' response was rejected by the host allowlist, keeping the stored directory.', array( 'rejected' => $filtered['rejected'] ) ); return false; } $services = $filtered['accepted']; foreach ( $filtered['rejected'] as $service ) { if ( ! isset( $previous[ $service ] ) ) { continue; } $kept = is_scalar( $previous[ $service ] ) ? trim( (string) $previous[ $service ] ) : ''; // The kept value is re-checked, never trusted. Keeping a stored // entry that fails the allowlist would preserve exactly the poison // the allowlist exists to remove. if ( '' !== $kept && self::is_allowed_service_url( $kept ) ) { $services[ $service ] = $previous[ $service ]; } } if ( ! empty( $filtered['rejected'] ) ) { AdvisorPPC_Logger::warning( 'Service directory: some entries in the ' . $source . ' response were rejected by the host allowlist and kept their previous value.', array( 'rejected' => $filtered['rejected'] ) ); } if ( $services === $previous ) { return false; } AdvisorPPC_Options::set( 'services', $services ); AdvisorPPC_Logger::info( 'Service directory updated from the ' . $source . ' response.', array( 'services' => array_keys( $services ) ) ); return true; } /** * The wp-admin URL the service sends the administrator back to. * * @return string */ public static function return_url() { return admin_url( 'admin.php?page=advisorppc' ); } /* --------------------------------------------------------------------- * Step 1 and 2: leave for the service * ------------------------------------------------------------------ */ /** * Handle the Connect button. * * @return void */ public function handle_connect_start() { $this->authorise( 'advisorppc_connect' ); $state = wp_create_nonce( self::STATE_ACTION ); set_transient( self::STATE_TRANSIENT . md5( $state ), array( 'user_id' => get_current_user_id(), 'created' => time(), ), self::STATE_TTL ); // add_query_arg() url-encodes each value; do not pre-encode them. $url = add_query_arg( array( 'site' => home_url( '/' ), 'state' => $state, 'return_url' => self::return_url(), 'plugin_ver' => ADVISORPPC_VERSION, ), self::connect_endpoint() ); // Leaving the site is the whole point of the connect flow. That makes // the destination worth checking, // because `advisorppc_connect_url` is a public filter and a compromised // or careless one could send an administrator anywhere. https only, and // only to the connect host or a subdomain of it. if ( ! self::is_allowed_connect_url( $url ) ) { AdvisorPPC_Logger::error( 'Connect refused: the connect URL is not an allowed destination.' ); wp_safe_redirect( self::return_url() ); exit; } AdvisorPPC_Logger::info( 'Connect started.' ); // Leaving the site on purpose. The destination was checked above, so it // is added to the redirect allowlist for this one redirect only. $connect_host = (string) wp_parse_url( $url, PHP_URL_HOST ); add_filter( 'allowed_redirect_hosts', static function ( $hosts ) use ( $connect_host ) { $hosts[] = $connect_host; return $hosts; } ); wp_safe_redirect( $url ); exit; } /* --------------------------------------------------------------------- * Step 4 and 5: come back and exchange * ------------------------------------------------------------------ */ /** * Detect the return from the connect page and exchange the code. * * The state parameter is this flow's CSRF token: it is a WordPress nonce * that we also recorded in a single-use transient, and both must match * before anything is written. * * @return void */ public function maybe_handle_return() { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- The state parameter below IS the nonce check for this flow. if ( ! isset( $_GET['advisorppc_code'] ) || ! isset( $_GET['state'] ) ) { return; } if ( ! isset( $_GET['page'] ) || 'advisorppc' !== sanitize_key( wp_unslash( $_GET['page'] ) ) ) { return; } $code = sanitize_text_field( wp_unslash( $_GET['advisorppc_code'] ) ); $state = sanitize_text_field( wp_unslash( $_GET['state'] ) ); // phpcs:enable WordPress.Security.NonceVerification.Recommended if ( ! current_user_can( 'manage_options' ) ) { $this->redirect_with_notice( 'forbidden' ); } $transient_key = self::STATE_TRANSIENT . md5( $state ); $pending = get_transient( $transient_key ); // Single use: burn it whatever happens next. delete_transient( $transient_key ); if ( ! wp_verify_nonce( $state, self::STATE_ACTION ) || ! is_array( $pending ) ) { AdvisorPPC_Logger::warning( 'Connect return rejected: bad state.' ); $this->redirect_with_notice( 'bad_state' ); } if ( (int) $pending['user_id'] !== get_current_user_id() ) { AdvisorPPC_Logger::warning( 'Connect return rejected: different user.' ); $this->redirect_with_notice( 'bad_state' ); } if ( '' === $code ) { $this->redirect_with_notice( 'no_code' ); } $result = $this->exchange_code( $code, $state ); if ( is_wp_error( $result ) ) { AdvisorPPC_Logger::error( 'Exchange failed: ' . $result->get_error_message() ); $this->redirect_with_notice( 'exchange_failed', $result->get_error_message() ); } $this->redirect_with_notice( 'connected' ); } /** * Exchange a one-time code for the site key and secret. * * @param string $code One-time code from the connect page. * @param string $state State value that produced it. * @return true|WP_Error */ public function exchange_code( $code, $state = '' ) { $response = AdvisorPPC_Http::post( '/wp/v1/site/exchange', array( 'code' => $code, 'state' => $state, 'site_url' => home_url( '/' ), 'plugin_version' => ADVISORPPC_VERSION, ), array( // The one and only pre-connection request, and only ever // after an administrator clicked Connect. 'allow_unconnected' => true, 'timeout' => 15, // Deliberately zero. The code is single use and the server // consumes it the moment it succeeds. If the response is lost // in transit a retry sends a code the server has already // burned: the admin sees "invalid or has expired" while a // perfectly good secret was minted and thrown away, and the // only recovery is a support ticket. One clean failure that // the admin can retry from the button is far better. 'retries' => 0, ) ); if ( is_wp_error( $response ) ) { return $response; } if ( ! AdvisorPPC_Http::is_success( $response ) ) { return new WP_Error( 'advisorppc_exchange_failed', AdvisorPPC_Http::error_message( $response ) ); } $body = isset( $response['body'] ) && is_array( $response['body'] ) ? $response['body'] : array(); $site_key = isset( $body['site_key'] ) ? sanitize_text_field( $body['site_key'] ) : ''; $site_secret = isset( $body['site_secret'] ) && is_string( $body['site_secret'] ) ? trim( $body['site_secret'] ) : ''; $workspace = isset( $body['workspace_name'] ) ? sanitize_text_field( $body['workspace_name'] ) : ''; if ( '' === $site_key || '' === $site_secret ) { return new WP_Error( 'advisorppc_exchange_incomplete', __( 'The AdvisorPPC service did not return a site key and secret.', 'advisorppc' ) ); } $this->store_credentials( $site_key, $site_secret, $workspace ); // The service directory. Storing it is what stops a module hardcoding a // hostname; the server can move Shield or Gravity without a plugin // release. Only https bases on the allowlist are kept; see // store_service_directory() for what happens when the block is absent or // entirely rejected. self::store_service_directory( $body, 'exchange' ); AdvisorPPC_Logger::info( 'Connected to workspace.', array( 'workspace' => $workspace ) ); $this->refresh_health( true ); return true; } /* --------------------------------------------------------------------- * Manual fallback * ------------------------------------------------------------------ */ /** * Handle a manually pasted key pair. * * @return void */ public function handle_manual_connect() { $this->authorise( 'advisorppc_manual_connect' ); // phpcs:disable WordPress.Security.NonceVerification.Missing -- authorise() above ran check_admin_referer(). $site_key = isset( $_POST['advisorppc_site_key'] ) ? sanitize_text_field( wp_unslash( $_POST['advisorppc_site_key'] ) ) : ''; $site_secret = isset( $_POST['advisorppc_site_secret'] ) ? trim( sanitize_text_field( wp_unslash( $_POST['advisorppc_site_secret'] ) ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Missing if ( '' === $site_secret ) { // Empty means "leave the stored secret alone", because the form // never renders the real one back. $site_secret = AdvisorPPC_Options::get_secret(); } if ( '' === $site_key || '' === $site_secret ) { $this->redirect_with_notice( 'missing_keys' ); } $this->store_credentials( $site_key, $site_secret, AdvisorPPC_Options::get( 'workspace_name', '' ) ); $health = $this->refresh_health( true ); if ( is_wp_error( $health ) ) { // Keep what was typed so it can be corrected, but do not claim // to be connected. AdvisorPPC_Options::set( 'connected', false ); AdvisorPPC_Logger::warning( 'Manual connect stored but health check failed: ' . $health->get_error_message() ); $this->redirect_with_notice( 'manual_unverified', $health->get_error_message() ); } $this->redirect_with_notice( 'connected' ); } /** * Forget the connection. * * @return void */ public function handle_disconnect() { $this->authorise( 'advisorppc_disconnect' ); AdvisorPPC_Options::set_many( array( 'site_key' => '', 'connected' => false, 'workspace_name' => '', 'plan' => '', 'last_health' => array(), // The service directory came from the workspace we are leaving. // Keeping it would let a stale host outlive the connection that // supplied it, and modules fall back to their own defaults. 'services' => array(), ) ); AdvisorPPC_Options::set_secret( '' ); /** * Fires after the site has been disconnected so modules can drop * anything they queued. */ do_action( 'advisorppc_disconnected' ); AdvisorPPC_Logger::info( 'Disconnected.' ); $this->redirect_with_notice( 'disconnected' ); } /** * Force a health refresh from the status card. * * @return void */ public function handle_refresh_health() { $this->authorise( 'advisorppc_refresh_health' ); $health = $this->refresh_health( true ); if ( is_wp_error( $health ) ) { $this->redirect_with_notice( 'health_failed', $health->get_error_message() ); } $this->redirect_with_notice( 'health_ok' ); } /* --------------------------------------------------------------------- * Health * ------------------------------------------------------------------ */ /** * Fetch GET /wp/v1/health and cache it. * * Never called on a front-end request and never on cron by default: the * status card in wp-admin asks for it, at most once every HEALTH_TTL. * * @param bool $force Ignore the cache window. * @return array|WP_Error The health payload. */ public function refresh_health( $force = false ) { if ( ! AdvisorPPC_Options::is_connected() ) { return new WP_Error( 'advisorppc_not_connected', __( 'This site is not connected to an AdvisorPPC workspace yet.', 'advisorppc' ) ); } $cached = AdvisorPPC_Options::last_health(); if ( ! $force && ! empty( $cached['fetched_at'] ) && ( time() - (int) $cached['fetched_at'] ) < self::HEALTH_TTL ) { return $cached; } // /wp/v1/health is HMAC-signed exactly like /wp/v1/events. A GET has no // body, so the signed string is `timestamp + "." + ""`, which is what // the server verifies. Sending the site key alone is a permanent 401. $headers = class_exists( 'AdvisorPPC_Signer' ) ? AdvisorPPC_Signer::headers( '' ) : array(); if ( empty( $headers ) ) { return new WP_Error( 'advisorppc_not_configured', __( 'This site is not connected to an AdvisorPPC workspace yet.', 'advisorppc' ) ); } // A GET carries no body, so the JSON content type would be a lie. unset( $headers['Content-Type'] ); $response = AdvisorPPC_Http::get( '/wp/v1/health', array( 'site_url' => home_url( '/' ) ), array( 'timeout' => 10, 'headers' => $headers, ) ); if ( is_wp_error( $response ) ) { return $response; } if ( ! AdvisorPPC_Http::is_success( $response ) ) { return new WP_Error( 'advisorppc_health_failed', AdvisorPPC_Http::error_message( $response ) ); } $health = isset( $response['body'] ) && is_array( $response['body'] ) ? $response['body'] : array(); // The health response carries the same `services` block as the // exchange. Refreshing the stored directory from it is what lets a site // that connected by pasting keys, or that connected before a host move, // pick up the current hosts without reconnecting. Same allowlist, same // keep-what-we-had rules as the exchange. Read before sanitize_health() // because that keeps only the contract's display fields. self::store_service_directory( $health, 'health' ); $health = self::sanitize_health( $health ); $health['fetched_at'] = time(); AdvisorPPC_Options::set( 'last_health', $health ); if ( isset( $health['plan'] ) && is_string( $health['plan'] ) ) { AdvisorPPC_Options::set( 'plan', sanitize_text_field( $health['plan'] ) ); } return $health; } /** * Reduce a health payload to the fields the contract defines. * * The response is cached in an autoloaded option and rendered in * wp-admin, so it is treated as untrusted input even though it came from * our own service: only known keys survive, and only as scalars. A field * the service should never send, such as a credential, cannot be stored * or displayed by accident. * * @param array $payload Decoded response body. * @return array */ public static function sanitize_health( $payload ) { if ( ! is_array( $payload ) ) { return array(); } $clean = array(); if ( isset( $payload['plan'] ) && is_scalar( $payload['plan'] ) ) { $clean['plan'] = sanitize_text_field( (string) $payload['plan'] ); } if ( isset( $payload['modules_allowed'] ) && is_array( $payload['modules_allowed'] ) ) { $allowed = array(); foreach ( $payload['modules_allowed'] as $module ) { if ( is_scalar( $module ) ) { $allowed[] = sanitize_key( (string) $module ); } } $clean['modules_allowed'] = array_slice( array_values( array_filter( $allowed ) ), 0, 20 ); } if ( isset( $payload['tracking'] ) && is_array( $payload['tracking'] ) ) { $tracking = array(); $count = 0; foreach ( $payload['tracking'] as $key => $verdict ) { if ( ! is_scalar( $verdict ) || $count >= 40 ) { continue; } $tracking[ sanitize_key( (string) $key ) ] = sanitize_text_field( (string) $verdict ); $count++; } $clean['tracking'] = $tracking; } if ( isset( $payload['issues'] ) && is_array( $payload['issues'] ) ) { $issues = array(); foreach ( array_slice( $payload['issues'], 0, 25 ) as $issue ) { if ( is_scalar( $issue ) ) { $issues[] = sanitize_text_field( (string) $issue ); continue; } if ( is_array( $issue ) && isset( $issue['message'] ) && is_scalar( $issue['message'] ) ) { $issues[] = sanitize_text_field( (string) $issue['message'] ); } } $clean['issues'] = $issues; } if ( isset( $payload['plan_gated'] ) ) { $clean['plan_gated'] = (bool) $payload['plan_gated']; } return $clean; } /* --------------------------------------------------------------------- * Internals * ------------------------------------------------------------------ */ /** * Capability and nonce check for every admin-post handler. * * @param string $action Nonce action, also the _wpnonce field name context. * @return void */ protected function authorise( $action ) { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to change AdvisorPPC settings.', 'advisorppc' ), esc_html__( 'Permission denied', 'advisorppc' ), array( 'response' => 403 ) ); } check_admin_referer( $action ); } /** * Save a credential pair. * * @param string $site_key Public site key. * @param string $site_secret Private site secret. * @param string $workspace Workspace name. * @return void */ protected function store_credentials( $site_key, $site_secret, $workspace ) { AdvisorPPC_Options::set_secret( $site_secret ); AdvisorPPC_Options::set_many( array( 'site_key' => $site_key, 'workspace_name' => $workspace, 'connected' => true, ) ); /** * Fires after a successful connection. * * @param string $site_key Public site key. * @param string $workspace Workspace name. */ do_action( 'advisorppc_connected', $site_key, $workspace ); } /** * Send the administrator back to the settings screen with a message. * * Only the notice code travels in the URL. The detail goes to the debug * log, because anything carried in the URL could be forged by a link. * * @param string $notice Notice slug. * @param string $detail Optional detail text, logged only. * @return void */ protected function redirect_with_notice( $notice, $detail = '' ) { if ( '' !== $detail ) { AdvisorPPC_Logger::warning( 'Notice ' . $notice . ': ' . wp_strip_all_tags( $detail ) ); } wp_safe_redirect( add_query_arg( array( 'advisorppc_notice' => $notice ), self::return_url() ) ); exit; } }