label.
*/
public static function verdict_labels() {
return array(
'verified_by_signature' => __( 'Verified by signature', 'posterna' ),
'verified_by_ip' => __( 'Verified by IP or DNS', 'posterna' ),
'declared_unverifiable' => __( 'Claimed, cannot be checked', 'posterna' ),
'claimed_failed' => __( 'Claimed, failed the check', 'posterna' ),
'unknown_automation' => __( 'Unknown automation', 'posterna' ),
'human' => __( 'Browsers (likely people)', 'posterna' ),
);
}
/**
* Adds the Posterna menu item.
*/
public static function add_menu() {
add_menu_page(
__( 'Posterna', 'posterna' ),
__( 'Posterna', 'posterna' ),
'manage_options',
self::PAGE,
array( __CLASS__, 'render_page' ),
'dashicons-visibility',
81
);
}
/**
* A Settings link on the Plugins screen.
*
* @param array $links Links.
* @return array
*/
public static function action_links( $links ) {
$url = add_query_arg(
array(
'page' => self::PAGE,
'tab' => 'settings',
),
admin_url( 'admin.php' )
);
array_unshift( $links, '' . esc_html__( 'Settings', 'posterna' ) . '' );
return $links;
}
/**
* Loads the stylesheet on the Posterna page and the dashboard.
*
* @param string $hook Admin page hook.
*/
public static function enqueue( $hook ) {
if ( 'toplevel_page_' . self::PAGE === $hook || 'index.php' === $hook ) {
wp_enqueue_style( 'posterna-admin', plugins_url( 'assets/admin.css', POSTERNA_FILE ), array(), POSTERNA_VERSION );
}
}
/**
* Registers the settings.
*/
public static function register_settings() {
register_setting(
'posterna',
Plugin::SETTINGS_OPTION,
array(
'type' => 'array',
'sanitize_callback' => array( __CLASS__, 'sanitize' ),
'show_in_rest' => false,
)
);
}
/**
* Checks and cleans submitted settings.
*
* @param mixed $input Submitted values.
* @return array
*/
public static function sanitize( $input ) {
$old = Plugin::settings();
$input = is_array( $input ) ? $input : array();
$clean = $old;
$mode = isset( $input['client_ip'] ) ? sanitize_key( $input['client_ip'] ) : 'remote_addr';
$clean['client_ip'] = in_array( $mode, array( 'remote_addr', 'header', 'hops' ), true ) ? $mode : 'remote_addr';
$header = isset( $input['client_ip_header'] ) ? strtolower( trim( sanitize_text_field( $input['client_ip_header'] ) ) ) : '';
if ( '' !== $header && ( ! preg_match( '/^[a-z0-9-]{1,64}$/', $header ) || Request::is_inbound_verdict_header( $header ) ) ) {
add_settings_error( Plugin::SETTINGS_OPTION, 'posterna_header', __( 'The client address header must be a header name such as cf-connecting-ip.', 'posterna' ) );
$header = $old['client_ip_header'];
}
$clean['client_ip_header'] = $header;
if ( 'header' === $clean['client_ip'] && '' === $header ) {
add_settings_error( Plugin::SETTINGS_OPTION, 'posterna_header_missing', __( 'Enter the header name, or choose another way to find the client address. REMOTE_ADDR is used until then.', 'posterna' ) );
$clean['client_ip'] = 'remote_addr';
}
$proxies = array();
$raw = isset( $input['trusted_proxies'] ) ? sanitize_textarea_field( $input['trusted_proxies'] ) : '';
foreach ( preg_split( '/[\s,]+/', $raw, -1, PREG_SPLIT_NO_EMPTY ) as $cidr ) {
if ( 0 === Prefix_Matcher::compile( array( $cidr ) )->invalid && Prefix_Matcher::compile( array( $cidr ) )->size() > 0 ) {
$proxies[] = $cidr;
} else {
/* translators: %s: the text that is not an address range */
add_settings_error( Plugin::SETTINGS_OPTION, 'posterna_cidr', sprintf( __( '"%s" is not an address range such as 173.245.48.0/20; it was left out.', 'posterna' ), $cidr ) );
}
}
$clean['trusted_proxies'] = array_slice( array_values( array_unique( $proxies ) ), 0, 200 );
$clean['hops'] = isset( $input['hops'] ) ? min( 10, max( 1, absint( $input['hops'] ) ) ) : 1;
$template = isset( $input['template'] ) ? sanitize_key( $input['template'] ) : 'monitor';
$clean['template'] = array_key_exists( $template, Policy::templates() ) ? $template : 'monitor';
$clean['updates'] = ! empty( $input['updates'] );
$key = isset( $input['site_key'] ) ? trim( sanitize_text_field( $input['site_key'] ) ) : '';
if ( '' !== $key && ! preg_match( '/^psk_[A-Za-z0-9_-]{43}$/', $key ) ) {
add_settings_error( Plugin::SETTINGS_OPTION, 'posterna_key', __( 'That does not look like a Posterna site key (it starts with psk_). The previous key was kept.', 'posterna' ) );
$key = $old['site_key'];
}
$clean['site_key'] = $key;
$clean['setup_done'] = true;
return $clean;
}
/**
* After the settings change: schedules what they call for.
*
* @param mixed $old_value Old settings.
* @param mixed $value New settings.
*/
public static function settings_saved( $old_value, $value ) {
Plugin::sync_schedules();
$was_on = is_array( $old_value ) && ! empty( $old_value['updates'] );
if ( is_array( $value ) && ! empty( $value['updates'] ) && ! $was_on ) {
wp_schedule_single_event( time(), Plugin::CRON_REFRESH_NOW );
}
}
/**
* The first save of the settings.
*
* @param string $option Option name.
* @param mixed $value Settings.
*/
public static function settings_added( $option, $value ) {
self::settings_saved( array(), $value );
}
/**
* "Check for updates now".
*/
public static function handle_refresh() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have permission to do that.', 'posterna' ), 403 );
}
check_admin_referer( 'posterna_refresh' );
Plugin::instance()->refresh_now();
wp_safe_redirect( self::url( 'check', array( 'done' => 'refresh' ) ) );
exit;
}
/**
* "Send counts now".
*/
public static function handle_send() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have permission to do that.', 'posterna' ), 403 );
}
check_admin_referer( 'posterna_send' );
Plugin::instance()->send_counts();
wp_safe_redirect( self::url( 'check', array( 'done' => 'send' ) ) );
exit;
}
/**
* A URL on the Posterna page.
*
* @param string $tab Tab.
* @param array $extra Extra query arguments.
* @return string
*/
private static function url( $tab, $extra = array() ) {
return add_query_arg(
array_merge(
array(
'page' => self::PAGE,
'tab' => $tab,
),
$extra
),
admin_url( 'admin.php' )
);
}
/**
* Tabs.
*
* @return array Slug => label.
*/
private static function tabs() {
return array(
'overview' => __( 'Overview', 'posterna' ),
'agents' => __( 'Agents', 'posterna' ),
'paths' => __( 'Paths', 'posterna' ),
'signups' => __( 'Sign-ups', 'posterna' ),
'check' => __( 'Check my setup', 'posterna' ),
'robots' => __( 'robots.txt', 'posterna' ),
'settings' => __( 'Settings', 'posterna' ),
);
}
/**
* Renders the Posterna page.
*/
public static function render_page() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
// Reading the tab name only chooses what to show; nothing is changed.
$tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'overview'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$tab = array_key_exists( $tab, self::tabs() ) ? $tab : 'overview';
echo '
';
echo '
' . esc_html__( 'Posterna', 'posterna' ) . '
';
echo '
' . esc_html__( 'Which AI agents and crawlers visit this site, and whether they are who they say they are. Version 1.0 only monitors: it labels and counts requests and never blocks anything.', 'posterna' ) . '
';
echo '';
switch ( $tab ) {
case 'agents':
self::render_agents();
break;
case 'paths':
self::render_paths();
break;
case 'signups':
self::render_signups();
break;
case 'check':
self::render_check();
break;
case 'robots':
self::render_robots();
break;
case 'settings':
self::render_settings();
break;
default:
self::render_overview();
}
echo '
';
}
/**
* A count, formatted for the site's locale.
*
* @param int $value Count.
* @return string
*/
private static function number( $value ) {
return number_format_i18n( (int) $value );
}
/**
* The setup panel, shown until the settings are saved once.
*/
private static function render_setup() {
echo '
';
echo '
' . esc_html__( 'Set up Posterna', 'posterna' ) . '
';
echo '
' . esc_html__( 'Posterna is already labeling and counting requests. Three steps make the numbers right:', 'posterna' ) . '
',
esc_html__( 'Make sure Posterna sees each visitor\'s real address, not your proxy\'s.', 'posterna' ),
esc_url( self::url( 'check' ) ),
esc_html__( 'Check my setup', 'posterna' )
);
printf(
'
',
esc_html__( 'Decide on the two optional features: directory updates and the posterna.com connection. Both are off until you turn them on.', 'posterna' ),
esc_url( self::url( 'settings' ) ),
esc_html__( 'Settings', 'posterna' )
);
printf(
'
' . esc_html__( 'Requests by verdict', 'posterna' ) . '
';
if ( 0 === $total ) {
echo '
' . esc_html__( 'Nothing counted yet. Requests are counted as they arrive; logged-in users and pages served from a full-page cache are not counted.', 'posterna' ) . '
' . esc_html__( 'Verified: the request proved who sent it, with a Web Bot Auth signature or an address the operator publishes. Claimed, failed the check: it named a known agent but came from somewhere that agent does not use. Claimed, cannot be checked: it named an agent whose operator publishes nothing to check against, or a check is still running.', 'posterna' ) . '
' . esc_html__( 'Paths in the last 7 days', 'posterna' ) . '
';
echo '
' . esc_html__( 'Paths are grouped by their first segment (/blog/* covers every post under /blog). Segments that look like numbers, IDs or e-mail addresses become /:id/*, and after 50 groups in an hour the rest count as "other".', 'posterna' ) . '
' . esc_html__( 'Sign-ups by verdict', 'posterna' ) . '
';
echo '
' . esc_html__( 'New accounts from the WordPress registration form and, when WooCommerce is active, new WooCommerce customers. Posterna only records the verdict of the request that created the account; it never blocks or slows a sign-up, and it does not store who signed up.', 'posterna' ) . '
';
}
/**
* Full-page caches this site appears to use.
*
* @return array Names.
*/
public static function page_caches() {
$found = array();
if ( defined( 'WP_CACHE' ) && WP_CACHE && file_exists( WP_CONTENT_DIR . '/advanced-cache.php' ) ) {
$found[] = __( 'A page cache drop-in (WP_CACHE is on and wp-content/advanced-cache.php exists)', 'posterna' );
}
$constants = array(
'WP_ROCKET_VERSION' => 'WP Rocket',
'W3TC' => 'W3 Total Cache',
'WPCACHEHOME' => 'WP Super Cache',
'LSCWP_V' => 'LiteSpeed Cache',
'CACHE_ENABLER_VERSION' => 'Cache Enabler',
'BREEZE_VERSION' => 'Breeze',
'WPHB_VERSION' => 'Hummingbird',
'SiteGround_Optimizer\VERSION' => 'Speed Optimizer (SiteGround)',
'KINSTAMU_VERSION' => 'Kinsta',
'PANTHEON_ENVIRONMENT' => 'Pantheon',
'ATOMIC_SITE_ID' => 'WordPress.com or Pressable',
);
foreach ( $constants as $constant => $name ) {
if ( defined( $constant ) ) {
$found[] = $name;
}
}
if ( class_exists( 'WpFastestCache', false ) ) {
$found[] = 'WP Fastest Cache';
}
if ( function_exists( 'is_wpe' ) && is_wpe() ) {
$found[] = 'WP Engine';
}
return $found;
}
/**
* A small key-value table row.
*
* @param string $label Label.
* @param string $value Value (escaped here).
*/
private static function row( $label, $value ) {
echo '
' . esc_html( $label ) . '
' . esc_html( $value ) . '
';
}
/**
* A time as "N minutes ago" or "never".
*
* @param int|null $time Unix time.
* @return string
*/
private static function ago( $time ) {
if ( empty( $time ) ) {
return __( 'never', 'posterna' );
}
/* translators: %s: a time difference such as "5 mins" */
return sprintf( __( '%s ago', 'posterna' ), human_time_diff( (int) $time, time() ) );
}
/**
* A scheduled time as "in N minutes" or "not scheduled".
*
* @param string $hook Cron hook.
* @return string
*/
private static function next_run( $hook ) {
$next = wp_next_scheduled( $hook );
if ( false === $next ) {
return __( 'not scheduled', 'posterna' );
}
if ( $next <= time() ) {
return __( 'due now', 'posterna' );
}
/* translators: %s: a time difference such as "5 mins" */
return sprintf( __( 'in %s', 'posterna' ), human_time_diff( time(), $next ) );
}
/**
* Check my setup: how this request was seen, and the common problems.
*/
private static function render_check() {
$plugin = Plugin::instance();
$settings = Plugin::settings();
$state = Plugin::state();
$store = $plugin->store();
$index = $store->index();
$started = hrtime( true );
// Read as sent, as on visitor requests; shown only to this administrator, never stored.
$request = Request::from_server( $_SERVER ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$verifier = $plugin->verifier();
$client = $verifier->resolve( $request );
$verdict = $verifier->verify( $request );
$elapsed = ( hrtime( true ) - $started ) / 1e6;
$labels = self::verdict_labels();
$remote = Address::parse( $request->remote_address );
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only picks a message to show.
$done = isset( $_GET['done'] ) ? sanitize_key( wp_unslash( $_GET['done'] ) ) : '';
if ( 'refresh' === $done ) {
echo '
' . esc_html__( 'Checked posterna.com for directory updates. The result is below.', 'posterna' ) . '
';
} elseif ( 'send' === $done ) {
echo '
' . esc_html__( 'Sent waiting counts to posterna.com. The result is below.', 'posterna' ) . '
';
}
$warnings = array();
$forward_names = array( 'x-forwarded-for', 'x-real-ip', 'cf-connecting-ip', 'true-client-ip', 'forwarded', 'fastly-client-ip', 'x-client-ip' );
$forwarded = array();
foreach ( $forward_names as $name ) {
$value = $request->value( $name );
if ( null !== $value ) {
$forwarded[ $name ] = $value;
}
}
if ( null !== $client['address'] && $client['address']->internal && array() !== $forwarded ) {
$warnings[] = __( 'Your request reached WordPress from an internal address, and it carries headers a proxy adds. The site is behind a proxy or load balancer, so every visitor may look like the same address. Real crawlers would then fail their checks. Choose how to find the client address in Settings.', 'posterna' );
} elseif ( null !== $client['address'] && $client['address']->internal ) {
$warnings[] = __( 'Your request reached WordPress from an internal address. That is normal for a site on your own computer or office network. On a public site it usually means a proxy or load balancer sits in front of WordPress, and every visitor may look like the same address. Ask your host which header carries the visitor\'s address, then choose it in Settings.', 'posterna' );
} elseif ( Plugin::proxy_suspect() ) {
$warnings[] = __( 'Most requests in the last day came from internal addresses. The site is probably behind a proxy Posterna has not been told about, so every visitor looks the same. Choose how to find the client address in Settings.', 'posterna' );
}
$caches = self::page_caches();
if ( array() !== $caches ) {
/* translators: %s: list of cache plugins or hosts */
$warnings[] = sprintf( __( 'This site uses a full-page cache (%s). Pages served from the cache never run PHP, so Posterna does not see or count those requests. Crawlers that fetch cached pages are undercounted.', 'posterna' ), implode( ', ', $caches ) );
}
if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) {
$warnings[] = __( 'WP-Cron is disabled in wp-config.php. Make sure a real cron job calls wp-cron.php, or reverse DNS checks, directory updates and counter sending will not run.', 'posterna' );
}
if ( '' !== $settings['site_key'] && isset( $state['key_rejected'] ) && hash( 'sha256', $settings['site_key'] ) === $state['key_rejected'] ) {
$warnings[] = __( 'posterna.com did not accept the site key, so nothing is being sent. Paste the key again from your dashboard on posterna.com.', 'posterna' );
}
if ( null === $index ) {
$warnings[] = __( 'The operator directory could not be loaded, so every request is labeled only as a browser or unknown automation. Reinstalling the plugin restores the bundled copy.', 'posterna' );
}
echo '
' . esc_html__( 'Check my setup', 'posterna' ) . '
' . esc_html__( 'These lines state your preferences to crawlers that honor robots.txt, and add Content Signals (contentsignals.org) that say how your content may be used. Posterna never changes robots.txt. Copy what you want into your robots.txt yourself, for example with your SEO plugin\'s robots.txt editor. Choose the template in Settings.', 'posterna' ) . '
';
if ( null === $index ) {
echo '
' . esc_html__( 'The operator directory is not loaded, so there is nothing to suggest.', 'posterna' ) . '
';
return;
}
$header = array(
'# Suggested by the Posterna WordPress plugin from the "' . $name . '" template.',
'# Crawlers that honor robots.txt are asked to stay out of what the template blocks.',
'# Posterna 1.0 only monitors: it does not block agents that ignore robots.txt.',
);
$text = Robots::emit( Policy::from_array( $templates[ $name ] ), $index->operators(), $header );
echo '
' . esc_html(
'allow-list' === $name
? __( 'Template: allow-list. Agents that fetch pages to train AI models are asked to stay out; everything else is allowed.', 'posterna' )
: __( 'Template: monitor. Everything is allowed; only Content Signals and an open group are written.', 'posterna' )
) . '
';
echo '';
}
/**
* The settings form.
*/
private static function render_settings() {
$settings = Plugin::settings();
$option = Plugin::SETTINGS_OPTION;
settings_errors( $option );
echo '';
}
/**
* Adds the dashboard widget for administrators.
*/
public static function add_dashboard_widget() {
if ( current_user_can( 'manage_options' ) ) {
wp_add_dashboard_widget( 'posterna_summary', __( 'Posterna: last 24 hours', 'posterna' ), array( __CLASS__, 'render_widget' ) );
}
}
/**
* The dashboard widget.
*/
public static function render_widget() {
$totals = Counter_Table::totals( 'page', time() - DAY_IN_SECONDS );
echo '