prefix . 'storepinger_visits'; } public static function pages_table() { global $wpdb; return $wpdb->prefix . 'storepinger_pageviews'; } private static function install() { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $c = $wpdb->get_charset_collate(); dbDelta( 'CREATE TABLE ' . self::table() . " ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, skey CHAR(32) NOT NULL, visitor CHAR(32) NOT NULL, started INT UNSIGNED NOT NULL, last_seen INT UNSIGNED NOT NULL, pages SMALLINT UNSIGNED NOT NULL DEFAULT 1, country CHAR(2) NOT NULL DEFAULT '', PRIMARY KEY (id), UNIQUE KEY skey (skey), KEY last_seen (last_seen), KEY started (started) ) {$c};" ); dbDelta( 'CREATE TABLE ' . self::pages_table() . " ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, ts INT UNSIGNED NOT NULL, PRIMARY KEY (id), KEY ts (ts) ) {$c};" ); update_option( 'storepinger_db', self::DB_VERSION, false ); add_option( 'storepinger_settings', array( 'track_admins' => 0 ), '', false ); } /** Suggested text for the site's privacy policy (Settings → Privacy). */ public static function privacy() { if ( function_exists( 'wp_add_privacy_policy_content' ) ) { wp_add_privacy_policy_content( 'StorePinger', wp_kses_post( '
' . __( 'This site uses StorePinger to count visits. StorePinger sets no cookies. It keeps a random visitor ID in your browser\'s local storage so a returning visit is not counted twice. Your IP address is used only for a moment to find your country and is never stored. Visit data stays on this website and is deleted after 48 hours.', 'storepinger' ) . '
' ) ); } } public static function cleanup() { global $wpdb; $before = time() - self::KEEP; $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE last_seen < %d', self::table(), $before ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- plugin's own table, nothing to cache. $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE ts < %d', self::pages_table(), $before ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- plugin's own table, nothing to cache. } private static function settings() { return wp_parse_args( (array) get_option( 'storepinger_settings', array() ), array( 'track_admins' => 0 ) ); } /* ================= Tracking ================= */ public static function tracker() { if ( is_user_logged_in() && current_user_can( 'manage_options' ) && ! self::settings()['track_admins'] ) { return; } wp_enqueue_script( 'storepinger', STOREPINGER_URL . 'assets/tracker.js', array(), STOREPINGER_VERSION, array( 'strategy' => 'defer', 'in_footer' => true ) ); wp_localize_script( 'storepinger', 'storepinger', array( 'url' => esc_url_raw( rest_url( 'storepinger/v1/hit' ) ), 'admins' => (int) self::settings()['track_admins'] ) ); } public static function routes() { register_rest_route( 'storepinger/v1', '/hit', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'hit' ), 'permission_callback' => '__return_true', // Public by design: visitors send page views. ) ); register_rest_route( 'storepinger/v1', '/stats', array( 'methods' => 'GET', 'callback' => array( __CLASS__, 'stats_rest' ), 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, ) ); } public static function hit( WP_REST_Request $req ) { $ok = new WP_REST_Response( null, 204 ); $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; if ( '' === $ua || preg_match( self::BOTS, $ua ) || self::limited() ) { return $ok; } $p = $req->get_json_params(); if ( ! is_array( $p ) ) { return $ok; } $v = isset( $p['v'] ) ? (string) $p['v'] : ''; $s = isset( $p['s'] ) ? (string) $p['s'] : ''; if ( ! preg_match( '/^[A-Za-z0-9]{16,40}$/', $v ) || ! preg_match( '/^[A-Za-z0-9]{16,40}$/', $s ) ) { return $ok; } $hb = isset( $p['e'] ) && 'hb' === $p['e']; $now = time(); $salt = wp_salt( 'nonce' ); $skey = hash( 'sha256', 'spl|' . $s . '|' . $salt ); $skey = substr( $skey, 0, 32 ); global $wpdb; $t = self::table(); $row = $wpdb->get_row( $wpdb->prepare( 'SELECT id, last_seen FROM %i WHERE skey = %s', $t, $skey ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- live tracking needs the current row. if ( $row && $row->last_seen > $now - 1800 ) { if ( $hb ) { $wpdb->query( $wpdb->prepare( 'UPDATE %i SET last_seen = %d WHERE id = %d', $t, $now, $row->id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- plugin's own table. } else { $wpdb->query( $wpdb->prepare( 'UPDATE %i SET last_seen = %d, pages = LEAST(pages + 1, 65000) WHERE id = %d', $t, $now, $row->id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- plugin's own table. $wpdb->insert( self::pages_table(), array( 'ts' => $now ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- plugin's own table. } return $ok; } if ( $hb ) { return $ok; // A heartbeat never starts a visit. } if ( $row ) { // Same tab after 30 minutes away: a new visit. The old row keeps its own key. $wpdb->update( $t, array( 'skey' => substr( hash( 'sha256', $skey . '|' . $row->id ), 0, 32 ) ), array( 'id' => $row->id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- plugin's own table. } // Never more than 600 new visits a minute site-wide (stops scripted floods). if ( self::bump( 'storepinger_new_' . gmdate( 'YmdHi' ) ) > 600 ) { return $ok; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- plugin's own table. $wpdb->insert( $t, array( 'skey' => $skey, 'visitor' => substr( hash( 'sha256', 'splv|' . $v . '|' . $salt ), 0, 32 ), 'started' => $now, 'last_seen' => $now, 'pages' => 1, 'country' => self::country(), ) ); $wpdb->insert( self::pages_table(), array( 'ts' => $now ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- plugin's own table. return $ok; } /** Counts in the object cache when there is one, otherwise in a short-lived transient. */ private static function bump( $k ) { if ( wp_using_ext_object_cache() ) { wp_cache_add( $k, 0, 'storepinger', MINUTE_IN_SECONDS ); return (int) wp_cache_incr( $k, 1, 'storepinger' ); } $n = (int) get_transient( $k ) + 1; set_transient( $k, $n, MINUTE_IN_SECONDS ); return $n; } private static function limited() { $ip = self::ip(); // IPv6 visitors share one bucket per /64 network (one home or phone usually has a whole /64). $bin = @inet_pton( $ip ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( false !== $bin && 16 === strlen( $bin ) ) { $ip = bin2hex( substr( $bin, 0, 8 ) ); } return self::bump( 'storepinger_rl_' . substr( md5( $ip . wp_salt( 'nonce' ) ), 0, 16 ) ) > 120; } /** Visitor IP, used only for the country lookup and the rate limit. Never stored. */ private static function ip() { $r = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; if ( ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) && self::in_ranges( $r, self::CF_RANGES ) ) { $c = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ); if ( filter_var( $c, FILTER_VALIDATE_IP ) ) { return $c; } } /** * Sites behind their own proxy or load balancer can return the visitor IP here. * * @param string $ip The IP StorePinger would use. */ return (string) apply_filters( 'storepinger_client_ip', $r ); } private static function in_ranges( $ip, $ranges ) { $bin = @inet_pton( $ip ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( false === $bin ) { return false; } foreach ( $ranges as $cidr ) { list( $net, $bits ) = explode( '/', $cidr ); $n = inet_pton( $net ); if ( strlen( $n ) !== strlen( $bin ) ) { continue; } $by = intdiv( (int) $bits, 8 ); $re = (int) $bits % 8; if ( substr( $bin, 0, $by ) === substr( $n, 0, $by ) && ( ! $re || 0 === ( ( ord( $bin[ $by ] ) ^ ord( $n[ $by ] ) ) & ( 0xFF << ( 8 - $re ) ) & 0xFF ) ) ) { return true; } } return false; } /* ================= Country ================= */ private static function geo_dir() { $u = wp_upload_dir(); return trailingslashit( $u['basedir'] ) . 'storepinger/'; } private static function geo_file() { $name = get_option( 'storepinger_geo_file' ); return $name ? self::geo_dir() . $name : ''; } private static function country() { $r = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; if ( ! empty( $_SERVER['HTTP_CF_IPCOUNTRY'] ) && self::in_ranges( $r, self::CF_RANGES ) ) { $c = strtoupper( sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_IPCOUNTRY'] ) ) ); if ( preg_match( '/^[A-Z]{2}$/', $c ) && 'XX' !== $c && 'T1' !== $c ) { return $c; } } $file = self::geo_file(); if ( ! $file || ! file_exists( $file ) ) { return ''; } try { $db = new StorePinger_MMDB_Reader( $file ); $rec = $db->get( self::ip() ); } catch ( Throwable $e ) { return ''; } $c = $rec['country']['iso_code'] ?? ( $rec['country_code'] ?? '' ); return preg_match( '/^[A-Za-z]{2}$/', (string) $c ) ? strtoupper( $c ) : ''; } /** Downloads the free DB-IP Lite country database (CC BY 4.0). Only when the site owner clicks the button. */ private static function download_geo() { require_once ABSPATH . 'wp-admin/includes/file.php'; $dir = self::geo_dir(); if ( ! wp_mkdir_p( $dir ) ) { return new WP_Error( 'spl', __( 'Could not create the uploads folder.', 'storepinger' ) ); } if ( ! file_exists( $dir . 'index.php' ) ) { file_put_contents( $dir . 'index.php', " 120 ) ); if ( is_wp_error( $res ) || 200 !== (int) wp_remote_retrieve_response_code( $res ) ) { continue; } $data = function_exists( 'gzdecode' ) ? gzdecode( (string) wp_remote_retrieve_body( $res ) ) : false; unset( $res ); if ( ! $data || false === strpos( substr( $data, -131072 ), StorePinger_MMDB_Reader::METADATA_MARKER ) ) { continue; } $name = 'country-' . wp_generate_password( 16, false ) . '.mmdb'; file_put_contents( $dir . $name, $data ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents $old = self::geo_file(); update_option( 'storepinger_geo_file', $name, false ); update_option( 'storepinger_geo_updated', time(), false ); if ( $old && file_exists( $old ) ) { wp_delete_file( $old ); } return true; } return new WP_Error( 'spl', __( 'The country database could not be downloaded. Please try again later.', 'storepinger' ) ); } /* ================= Stats ================= */ public static function stats() { global $wpdb; $t = self::table(); $pt = self::pages_table(); $now = time(); $live = $now - self::LIVE; $today = (int) ( new DateTime( 'today', wp_timezone() ) )->getTimestamp(); // phpcs:disable WordPress.DB.DirectDatabaseQuery -- live numbers from the plugin's own tables; caching would make them stale. $out = array( 'live' => (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(DISTINCT visitor) FROM %i WHERE last_seen >= %d', $t, $live ) ), 'live_countries' => $wpdb->get_results( $wpdb->prepare( 'SELECT country AS k, COUNT(DISTINCT visitor) AS n FROM %i WHERE last_seen >= %d GROUP BY country ORDER BY n DESC', $t, $live ), ARRAY_A ), 'live_list' => $wpdb->get_results( $wpdb->prepare( 'SELECT MAX(country) AS country, SUM(pages) AS pages, %d - MAX(last_seen) AS ago FROM %i WHERE last_seen >= %d GROUP BY visitor ORDER BY MAX(last_seen) DESC LIMIT 12', $now, $t, $live ), ARRAY_A ), 'visitors' => (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(DISTINCT visitor) FROM %i WHERE started >= %d', $t, $today ) ), 'visits' => (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE started >= %d', $t, $today ) ), 'pageviews' => (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE ts >= %d', $pt, $today ) ), 'countries' => $wpdb->get_results( $wpdb->prepare( 'SELECT country AS k, COUNT(DISTINCT visitor) AS n FROM %i WHERE started >= %d GROUP BY country ORDER BY n DESC LIMIT 8', $t, $today ), ARRAY_A ), 'time' => $now, ); // phpcs:enable return $out; } public static function stats_rest() { $r = new WP_REST_Response( self::stats() ); $r->header( 'Cache-Control', 'no-store' ); return $r; } /* ================= Admin ================= */ public static function menu() { $icon = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgLTU2MCAxNTk0IDE1OTQiPjxwYXRoIGZpbGw9ImJsYWNrIiBkPSJNIDAsMzkzIEwgMzEwLDM5MyBMIDMzNiwzOTAgTCAzNjksMzgxIEwgNDAwLDM2NSBMIDQyNywzNDIgTCA0NDUsMzE3IEwgNDU4LDI4MyBMIDQ1OCwyNTAgTCA0NDYsMjE4IEwgNDMzLDE5OSBMIDQyMCwxODYgTCAzODksMTY4IEwgMzU4LDE2MCBMIDE2OSwxNTkgTCAxNTYsMTU1IEwgMTM5LDE0MyBMIDEzMywxMzMgTCAxMzIsMTE5IEwgMTM3LDEwOCBMIDE0OCw5NyBMIDE2MSw5MSBMIDE3Niw4OCBMIDQxNCw4OSBMIDQyNCw5MiBMIDQzNyw5OSBMIDQ0OCwxMTEgTCA0ODksMTkzIEwgNTAzLDIwMyBMIDY0NywyMDIgTCA2NTgsMTkzIEwgNjg4LDEzOCBMIDc2NywzMDQgTCA3NzQsMzA5IEwgNzg1LDMwNyBMIDc5MiwyOTcgTCA4NjgsMTA3IEwgOTI0LDMxNiBMIDkzMSwzMzYgTCA5MzksMzQyIEwgOTQ2LDM0MiBMIDk1NCwzMzUgTCAxMDA3LDE5MyBMIDEwMTgsMTg1IEwgMTQ1NSwxODggTCAxNDcxLDE5NSBMIDE0ODIsMjA2IEwgMTQ4NywyMjAgTCAxNDg3LDIyOCBMIDE0ODIsMjQwIEwgMTQ3MiwyNTAgTCAxNDU2LDI1NyBMIDEyNjIsMjU4IEwgMTIzNywyNjQgTCAxMjE3LDI3NyBMIDExOTksMzAyIEwgMTE5MiwzMjggTCAxMTk2LDQ3MCBMIDExOTgsNDczIEwgMTI4Nyw0NzMgTCAxMjkwLDQ3MSBMIDEyOTAsMzU0IEwgMTI5MiwzNDggTCAxMzAwLDM0MSBMIDE0ODIsMzQwIEwgMTUwOSwzMzUgTCAxNTM3LDMyMyBMIDE1NjMsMzAyIEwgMTU3NCwyODggTCAxNTg0LDI2OSBMIDE1OTIsMjQyIEwgMTU5MiwyMTEgTCAxNTgyLDE4MiBMIDE1NjIsMTU2IEwgMTUzNiwxMzkgTCAxNDk5LDEyOSBMIDk5NywxMzIgTCA5ODQsMTM4IEwgOTc0LDE1MiBMIDk0NCwyNDEgTCA5MTEsMTEzIEwgODgwLDE1IEwgODcxLDEwIEwgODYzLDEzIEwgODU4LDIwIEwgNzc0LDIzMiBMIDY5OCw4MiBMIDY5Miw3NyBMIDY4NSw3NyBMIDY3OCw4MyBMIDY0MCwxNTMgTCA2MzMsMTYxIEwgNjI4LDE2MyBMIDUyMCwxNjMgTCA1MTEsMTU5IEwgNDk5LDE0NiBMIDQ0MCwyNSBMIDQzMSwxNCBMIDQxOCw1IEwgNDAxLDAgTCAxNDYsMSBMIDEwOSwxMCBMIDc2LDI2IEwgNTgsMzkgTCA0Miw1NSBMIDI3LDc3IEwgMTYsMTA5IEwgMTUsMTMyIEwgMjEsMTU4IEwgMzYsMTg1IEwgNTEsMjAxIEwgNzAsMjE0IEwgOTIsMjI0IEwgMTE0LDIzMCBMIDEzNiwyMzMgTCAzMDMsMjMzIEwgMzI1LDI0MSBMIDMzNywyNTMgTCAzNDEsMjYyIEwgMzQwLDI3NyBMIDMzMywyODggTCAzMjMsMjk2IEwgMzAwLDMwMyBMIDc4LDMwNCBMIDYyLDMxNCBaIi8+PC9zdmc+'; add_menu_page( 'StorePinger', 'StorePinger', 'manage_options', 'storepinger', array( __CLASS__, 'page' ), $icon, 59 ); } public static function admin_assets( $hook ) { if ( 'toplevel_page_storepinger' !== $hook && 'index.php' !== $hook ) { return; } wp_enqueue_style( 'storepinger-admin', STOREPINGER_URL . 'assets/admin.css', array(), STOREPINGER_VERSION ); if ( 'toplevel_page_storepinger' === $hook ) { wp_enqueue_script( 'storepinger-world', STOREPINGER_URL . 'assets/world.js', array(), STOREPINGER_VERSION, true ); wp_enqueue_script( 'storepinger-admin', STOREPINGER_URL . 'assets/admin.js', array( 'storepinger-world' ), STOREPINGER_VERSION, true ); wp_localize_script( 'storepinger-admin', 'storepingerAdmin', array( 'url' => esc_url_raw( rest_url( 'storepinger/v1/stats' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'locale' => str_replace( '_', '-', get_user_locale() ), 'data' => self::stats(), ) ); } } public static function action_links( $links ) { array_unshift( $links, '' . esc_html__( 'Dashboard', 'storepinger' ) . '' ); $links[] = '' . esc_html__( 'Go Pro', 'storepinger' ) . ''; return $links; } public static function pro_notice() { if ( current_user_can( 'activate_plugins' ) ) { echo '' . esc_html__( 'StorePinger Pro is active and includes everything in the free StorePinger plugin. You can deactivate and delete the free version.', 'storepinger' ) . '