Repository: catchpoint/WebPageTest (language: php)

Issue:
Clean up max anon tests error messaging.
Something like this might work.

![image](https://user-images.githubusercontent.com/214783/139282463-0a3e6b13-309d-4bc9-8794-226c35b24867.png)



[AB#141706](https://dev.azure.com/catchpointsystems/3530e88b-329c-4a68-a776-27327b83ff68/_workitems/edit/141706)


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cb63003871..2869a13c7a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -9,11 +9,16 @@ jobs:
     steps:
       - uses: actions/checkout@v2
 
-      - uses: php-actions/composer@v5
-
-      - name: PHPUnit Tests
-        uses: php-actions/phpunit@v3
+      - name: Setup PHP
+        uses: shivammathur/setup-php@v2
         with:
-          configuration: tests/phpunit.xml
-          php_version: 7.4
-          php_extensions: gd
+          php-version: 7.4
+          ini-values: apc.enable_cli=On
+          tools: pecl, phpunit
+          extensions: gd, apcu
+
+      - name: Install deps
+        run: composer install
+
+      - name: Run tests
+        run: composer test
diff --git a/README.md b/README.md
index 34c414e570..0bd3e5e011 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ The cross-platform browser agent is [here](https://github.com/WPO-Foundation/wpt
 
 # API Examples
 
-There are two examples using the [Restful API](https://docs.webpagetest.org/dev/api/):
+There are two examples using the [Restful API](https://docs.webpagetest.org/api/):
 
 - /bulktest - A php cli project that can submit a bulk set of tests, gather the results and aggregate analysis.
 - /batchtool - A python project that can submit a bulk set of tests and gather the results.
diff --git a/www/about.php b/www/about.php
index 471832c44a..ea1c2bb059 100644
--- a/www/about.php
+++ b/www/about.php
@@ -32,8 +32,8 @@
                 The online version at <a href="https://www.webpagetest.org/">www.webpagetest.org</a> is run for the benefit 
                 of the performance community.</p>
 
-                <p>WebPageTest was acquired in September of 2020 by Catchpoint, the leading Digital Experience Monitoring 
-                platform providing Synthetic Monitoring, Real User Measurement, Network Monitoring, and Endpoint Monitoring products.</p>
+                <p>WebPageTest was acquired in September of 2020 by <a href="https://www.catchpoint.com/">Catchpoint</a>, the leading Digital Experience Monitoring 
+                platform providing <a href="https://www.catchpoint.com/guide-to-synthetic-monitoring">Synthetic Monitoring</a>, Real User Measurement, Network Monitoring, and Endpoint Monitoring products.</p>
 
                 <p>The acquisition starts a new and exciting chapter as we plan to expand WebPageTest’s capabilities and WebPageTest.org’s 
                 geographical performance testing footprint, leveraging Catchpoint’s best-in-class infrastructure, adding capacity and 
diff --git a/www/common.inc b/www/common.inc
index 81587d2459..bf71484ab1 100644
--- a/www/common.inc
+++ b/www/common.inc
@@ -2,9 +2,9 @@
 // Copyright 2020 Catchpoint Systems Inc.
 // Use of this source code is governed by the Polyform Shield 1.0.0 license that can be
 // found in the LICENSE.md file.
-require_once('./common_lib.inc');
-require_once('./plugins.php.inc');
-require_once('./util.inc');
+require_once(__DIR__ . '/common_lib.inc');
+require_once(__DIR__ . '/plugins.php.inc');
+require_once(__DIR__ . '/util.inc');
 
 // Disable caching by default
 header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0", true);
@@ -149,7 +149,7 @@ if (!$privateInstall) {
 
 // constants
 define('VER_WEBPAGETEST', '21.07');   // webpagetest version
-define('VER_CSS', 153.2);                // version of the sitewide css file
+define('VER_CSS', 153.4);                // version of the sitewide css file
 define('VER_JS', 40);                 // version of the sitewide javascript file
 define('VER_JS_TEST', 47);            // version of the javascript specific to the test pages
 define('VER_JS_RUNNING', 1);          // version of the javascript specific to the test running status page
diff --git a/www/common/cache_utils.php b/www/common/cache_utils.php
new file mode 100644
index 0000000000..0875897df2
--- /dev/null
+++ b/www/common/cache_utils.php
@@ -0,0 +1,37 @@
+<?php declare(strict_types=1);
+
+class CacheUtils {
+  public static function cache_store (string $key, array $value, int $ttl=0) : bool {
+    $key = sha1(__DIR__) . $key;
+    if (isset($value)) {
+      if (function_exists('apcu_store')) {
+        apcu_store($key, $value, $ttl);
+        return true;
+      } elseif (function_exists('apc_store')) {
+        apc_store($key, $value, $ttl);
+        return true;
+      } else {
+        return false;
+      }
+    }
+  }
+
+  public static function cache_fetch ($key) : array {
+    $ret = array();
+    $success = false;
+
+    $key = sha1(__DIR__) . $key;
+    if (function_exists('apcu_fetch')) {
+      $ret = apcu_fetch($key, $success);
+      if (!$success) {
+        $ret = array();
+      }
+    } elseif (function_exists('apc_fetch')) {
+      $ret = apc_fetch($key, $success);
+      if (!$success) {
+        $ret = array();
+      }
+    }
+    return $ret;
+  }
+}
diff --git a/www/pagestyle2.css b/www/pagestyle2.css
index 5486229d20..d35ceb4c2b 100644
--- a/www/pagestyle2.css
+++ b/www/pagestyle2.css
@@ -1779,6 +1779,54 @@ body.compact .box {
     }
 }
 
+/* error page styles */
+.testerror {
+    text-align: left;
+    max-width: 34rem;
+    margin: 0 auto;
+    padding: 3rem;
+}
+.testerror h1 {
+    color: #dc9739;
+    text-align: left;
+    margin: 1em 0;
+}
+.testerror h1 em {
+    display: block;
+    font-style: normal;
+    font-weight: 500;
+    font-size: .8em;
+    color: #333;
+    margin: .3em 0 .5em;
+    border-bottom: 1px solid #ddd;
+    padding-bottom: 1em;
+}
+.testerror_login {
+    margin: 0 auto;
+    justify-content: center;
+}
+.testerror_loginperks {
+    list-style: disc;
+    padding: 0 0 0 3em;
+}
+.testerror p,
+.testerror_loginperks {
+    line-height: 1.4;
+    margin: 0 0 1.5em;
+}
+.testerror_loginperks li {
+    margin-bottom: 0.5em;
+}
+.testerror_login {
+    margin: 3rem 0 0;
+    justify-content: center;
+    display: flex;
+    gap: 2em;
+    background: #f6f6f6;
+    padding: 1.5em;
+    border-radius: 8px;
+}
+
 /* compare page styles */
 .video_runlabel {
     text-align: left;
@@ -1850,14 +1898,15 @@ body.compact #header_container {
 .test_results-content {
     padding: 1em 1.5em;
 }
-.test_menu + .test_results-content {
+.test_menu+.test_results-content {
     padding-top: 0;
 }
 @media (min-width: 50em) {
     .test_results-content {
         padding: 1em 3em 0;
     }
-    .test_menu + .test_results-content {
+    
+    .test_menu+.test_results-content {
         padding-top: 0;
     }
 }
@@ -2177,7 +2226,7 @@ th.header {
     margin-bottom: 1rem;
 }
 #videoContainer:before,
-.waterfall_marker:before {
+.compare_contain_wrap .waterfall_marker:before {
     content: "";
     border: 1px solid #f00;
     width: 10px;
diff --git a/www/ratelimit/check_monthly_rate_limit.php b/www/ratelimit/check_monthly_rate_limit.php
new file mode 100644
index 0000000000..ff754894a5
--- /dev/null
+++ b/www/ratelimit/check_monthly_rate_limit.php
@@ -0,0 +1,39 @@
+<?php declare(strict_types=1);
+
+require_once(__DIR__ . '/../common/cache_utils.php');
+
+class CheckMonthlyRateLimit {
+  function __construct (string $ip, int $limit = 50, int $days = 28) {
+    $this->ip = $ip;
+    $this->limit = $limit;
+    $this->day_cycle_ttl = $days * (24 * 60 * 60);
+    $this->cache_key = 'rladdr_' . 'per_month_' . $ip;
+    $this->bucket = array();
+  }
+
+  function check () : bool {
+    $bucket = $this->fetch_bucket();
+    if (count($bucket) >= $this->limit) {
+      return false;
+    } else {
+      $bucket[] = time();
+      $this->store_bucket($bucket);
+    }
+    return true;
+  }
+
+  private function fetch_bucket () : array {
+    $bucket = CacheUtils::cache_fetch($this->cache_key);
+    return array_filter($bucket, function ($value) {
+      $time_constraint = time() - $this->day_cycle_ttl;
+      return $time_constraint < $value;
+    }, ARRAY_FILTER_USE_BOTH);
+  }
+
+  private function store_bucket (array $bucket) : void {
+    $success = CacheUtils::cache_store($this->cache_key, $bucket, $this->day_cycle_ttl);
+    if ($success) {
+      $this->bucket = $bucket;
+    }
+  }
+}
diff --git a/www/settings/mobile_devices.ini b/www/settings/mobile_devices.ini
index a35ff85ea2..969576640c 100644
--- a/www/settings/mobile_devices.ini
+++ b/www/settings/mobile_devices.ini
@@ -8,7 +8,7 @@ width=360
 height=512
 dpr=3
 throttle_cpu=8
-ua="Mozilla/5.0 (Linux; Android 6.0.1; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.110 Mobile Safari/537.36"
+ua="Mozilla/5.0 (Linux; Android 8.1.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Mobile Safari/537.36"
 
 [Nexus5]
 label="Nexus 5"
@@ -17,7 +17,7 @@ width=360
 height=511
 dpr=3
 throttle_cpu=6.5
-ua="Mozilla/5.0 (Linux; Android 5.1.1; Nexus 5 Build/LMY48B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.110 Mobile Safari/537.36"
+ua="Mozilla/5.0 (Linux; Android 5.1.1; Nexus 5 Build/LMY48B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Mobile Safari/537.36"
 
 [Nexus5X]
 label="Nexus 5X"
@@ -26,7 +26,7 @@ width=411
 height=600
 dpr=2.6
 throttle_cpu=6
-ua="Mozilla/5.0 (Linux; Android 4.4.4; Nexus 5X Build/KTU84Q) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.110 Mobile Safari/537.36"
+ua="Mozilla/5.0 (Linux; Android 4.4.4; Nexus 5X Build/KTU84Q) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Mobile Safari/537.36"
 
 [Pixel]
 label="Google Pixel"
@@ -35,7 +35,7 @@ width=411
 height=600
 dpr=2.6
 throttle_cpu=4.5
-ua="Mozilla/5.0 (Linux; Android 8.0.0; Pixel Build/OPR3.170623.008) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.110 Mobile Safari/537.36"
+ua="Mozilla/5.0 (Linux; Android 8.1.0; Pixel Build/OPR3.170623.008) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Mobile Safari/537.36"
 
 [PixelXL]
 label="Google Pixel XL"
@@ -44,7 +44,7 @@ width=411
 height=600
 dpr=3.5
 throttle_cpu=4.5
-ua="Mozilla/5.0 (Linux; Android 8.1.0; Pixel XL Build/OPM1.171019.011) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.110 Mobile Safari/537.36"
+ua="Mozilla/5.0 (Linux; Android 8.1.0; Pixel XL Build/OPM1.171019.011) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Mobile Safari/537.36"
 
 [Pixel2]
 label="Google Pixel 2"
@@ -53,7 +53,7 @@ width=411
 height=600
 dpr=2.6
 throttle_cpu=4
-ua="Mozilla/5.0 (Linux; Android 8.1.0; Pixel 2 Build/OPM1.171019.011) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.110 Mobile Safari/537.36"
+ua="Mozilla/5.0 (Linux; Android 8.1.0; Pixel 2 Build/OPM1.171019.011) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Mobile Safari/537.36"
 
 [Pixel2XL]
 label="Google Pixel 2 XL"
@@ -62,7 +62,7 @@ width=411
 height=600
 dpr=3.5
 throttle_cpu=4
-ua="Mozilla/5.0 (Linux; Android 8.1.0; Pixel 2 XL Build/OPM1.171019.011) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.110 Mobile Safari/537.36"
+ua="Mozilla/5.0 (Linux; Android 8.1.0; Pixel 2 XL Build/OPM1.171019.011) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Mobile Safari/537.36"
 
 [MotoG]
 label="Motorola G (gen 1)"
@@ -71,7 +71,7 @@ width=360
 height=511
 dpr=2
 throttle_cpu=11
-ua="Mozilla/5.0 (Linux; Android 4.4.4; XT1034 Build/KXB21.14-L1.61) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.110 Mobile Safari/537.36"
+ua="Mozilla/5.0 (Linux; Android 4.4.4; XT1034 Build/KXB21.14-L1.61) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Mobile Safari/537.36"
 
 [GalaxyS5]
 label="Samsung Galaxy S5"
@@ -80,7 +80,7 @@ width=360
 height=511
 dpr=3
 throttle_cpu=9
-ua="Mozilla/5.0 (Linux; Android 6.0.1; SM-G900T Build/MMB29M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.110 Mobile Safari/537.36"
+ua="Mozilla/5.0 (Linux; Android 6.0.1; SM-G900T Build/MMB29M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Mobile Safari/537.36"
 
 [GalaxyS7]
 label="Samsung Galaxy S7"
@@ -89,7 +89,7 @@ width=360
 height=511
 dpr=4
 throttle_cpu=4.5
-ua="Mozilla/5.0 (Linux; Android 4.4.4; Android 6.0.1; SM-G930F Build/MMB29K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.110 Mobile Safari/537.36"
+ua="Mozilla/5.0 (Linux; Android 8.1.0; SM-G930F Build/MMB29K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Mobile Safari/537.36"
 
 [GalaxyS8]
 label="Samsung Galaxy S8/S8+/Note 8"
@@ -98,7 +98,7 @@ width=360
 height=611
 dpr=4
 throttle_cpu=4
-ua="Mozilla/5.0 (Linux; Android 4.4.4; Android 6.0.1; SM-G950F Build/MMB29K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.110 Mobile Safari/537.36"
+ua="Mozilla/5.0 (Linux; Android 8.1.0; SM-G950F Build/MMB29K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Mobile Safari/537.36"
 
 [MotoE]
 label="Motorola E"
@@ -107,7 +107,7 @@ width=360
 height=511
 dpr=1.5
 throttle_cpu=15
-ua="Mozilla/5.0 (Linux; Android 4.4.4; XT1021 Build/KXC21.5-53) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.110 Mobile Safari/537.36"
+ua="Mozilla/5.0 (Linux; Android 4.4.4; XT1021 Build/KXC21.5-53) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Mobile Safari/537.36"
 
 [AndroidOne]
 label="Android One"
@@ -116,7 +116,7 @@ width=320
 height=440
 dpr=1.5
 throttle_cpu=15
-ua="Mozilla/5.0 (Linux; Android 4.4.4; Micromax AQ4501 Build/KPW53) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.110 Mobile Safari/537.36"
+ua="Mozilla/5.0 (Linux; Android 4.4.4; Micromax AQ4501 Build/KPW53) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Mobile Safari/537.36"
 
 [Nexus7]
 label="Nexus 7"
@@ -125,7 +125,7 @@ width=600
 height=887
 dpr=2
 throttle_cpu=9
-ua="Mozilla/5.0 (Linux; Android 4.4.4; Nexus 7 Build/KTU84P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.110 Mobile Safari/537.36"
+ua="Mozilla/5.0 (Linux; Android 4.4.4; Nexus 7 Build/KTU84P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Mobile Safari/537.36"
 
 [Nexus7L]
 label="Nexus 7 - Landscape"
@@ -134,7 +134,7 @@ width=960
 height=527
 dpr=2
 throttle_cpu=9
-ua="Mozilla/5.0 (Linux; Android 4.4.4; Nexus 7 Build/KTU84P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.110 Mobile Safari/537.36"
+ua="Mozilla/5.0 (Linux; Android 4.4.4; Nexus 7 Build/KTU84P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Mobile Safari/537.36"
 
 ;
 ; iOS Devices
@@ -146,7 +146,7 @@ width=320
 height=460
 dpr=2
 throttle_cpu=2
-ua="Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
+ua="Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/10.3 Mobile/15E148 Safari/604.1"
 
 [iPhone6]
 label="iPhone 6/7/8"
@@ -155,7 +155,7 @@ width=375
 height=559
 dpr=2
 throttle_cpu=1
-ua="Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
+ua="Mozilla/5.0 (iPhone; CPU iPhone OS 15_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1"
 
 [iPhone6plus]
 label="iPhone 6+/7+/8+"
@@ -164,7 +164,7 @@ width=414
 height=622
 dpr=2
 throttle_cpu=1
-ua="Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
+ua="Mozilla/5.0 (iPhone; CPU iPhone OS 15_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1"
 
 [iPhoneX]
 label="iPhone X"
@@ -173,7 +173,7 @@ width=375
 height=635
 dpr=2
 throttle_cpu=1
-ua="Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Mobile/15A372"
+ua="Mozilla/5.0 (iPhone; CPU iPhone OS 15_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1"
 
 [iPad]
 label="iPad"
@@ -182,7 +182,7 @@ width=768
 height=960
 dpr=2
 throttle_cpu=1
-ua="Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1"
+ua="Mozilla/5.0 (iPad; CPU iPhone OS 15_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1"
 
 [iPadMini2]
 label="iPad Mini"
@@ -191,4 +191,4 @@ width=768
 height=960
 dpr=2
 throttle_cpu=1
-ua="Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1"
+ua="Mozilla/5.0 (iPad; CPU iPhone OS 15_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1"


Test patch:
diff --git a/tests/CheckMonthlyRateLimitTest.php b/tests/CheckMonthlyRateLimitTest.php
new file mode 100644
index 0000000000..891573300a
--- /dev/null
+++ b/tests/CheckMonthlyRateLimitTest.php
@@ -0,0 +1,49 @@
+<?php declare(strict_types=1);
+
+require_once __DIR__ . '/..' . '/www/ratelimit/check_monthly_rate_limit.php';
+
+use PHPUnit\Framework\TestCase;
+
+final class CheckMonthlyRateLimitTest extends TestCase {
+  public function testConstructorSetsDefaultValues() : void {
+    $ip = '127.0.0.1';
+    $cmrl = new CheckMonthlyRateLimit($ip);
+    $this->assertEquals('127.0.0.1', $cmrl->ip);
+    $this->assertEquals(50, $cmrl->limit);
+    $this->assertEquals(2419200, $cmrl->day_cycle_ttl);
+    $this->assertEquals('rladdr_per_month_127.0.0.1', $cmrl->cache_key);
+  }
+
+  public function testConstructorSetsValues() : void {
+    $ip = '127.0.0.0';
+    $cmrl = new CheckMonthlyRateLimit($ip, 40, 20);
+    $this->assertEquals('127.0.0.0', $cmrl->ip);
+    $this->assertEquals(40, $cmrl->limit);
+    $this->assertEquals(1728000, $cmrl->day_cycle_ttl);
+    $this->assertEquals('rladdr_per_month_127.0.0.0', $cmrl->cache_key);
+  }
+
+  /**
+   *
+   * @requires extension apcu
+   */
+  public function testCheckFirstTime() : void {
+    $ip = '127.0.0.0';
+    $cmrl = new CheckMonthlyRateLimit($ip, 40, 20);
+    $passes = $cmrl->check();
+    $this->assertTrue($passes);
+  }
+
+  /**
+   *
+   * @requires extension apcu
+   */
+  public function testCheckPastLimit() : void {
+    $ip = '127.0.0.0';
+    $cmrl = new CheckMonthlyRateLimit($ip, 2, 20);
+    $passes = $cmrl->check();
+    $passes = $cmrl->check();
+    $passes = $cmrl->check();
+    $this->assertFalse($passes);
+  }
+}
diff --git a/www/runtest.php b/www/runtest.php
index 73cf0c447d..308067470a 100644
--- a/www/runtest.php
+++ b/www/runtest.php
@@ -61,6 +61,7 @@ function DealWithMagicQuotes(&$arr) {
     require_once('common.inc');
     require_once('./ec2/ec2.inc.php');
     require_once(__DIR__ . '/include/CrUX.php');
+    require_once(__DIR__ . '/ratelimit/check_monthly_rate_limit.php');
     set_time_limit(300);
 
     $redirect_cache = array();
@@ -2880,10 +2881,10 @@ function ErrorPage($error) {
                 <?php
                 include 'header.inc';
               ?>
-              <h1>There was an error with the test</h1>
-                        <div class="box">
-
-              <?php
+              
+              <div class="testerror box">
+                <h1>Oops! <em>There was a problem with the test.</em></h1>
+                <?php
                 echo $error;
                 ?>
           </div>
@@ -3067,6 +3068,25 @@ function ReportAnalytics(&$test, $testId)
   }
 }
 
+function loggedOutLoginForm(){
+  $ret = '<ul class="testerror_login"><li><a href="/saml/login.php">Login</a></li>';
+  $reg .= GetSetting('saml_register');
+  if ($reg) {
+      $ret .= "<li><a class='pill' href='$reg'>Sign-up</a></li>";
+  }
+  $ret .= "</ul>";
+  return $ret;
+}
+
+function loggedInPerks(){
+  $msg = '<ul class="testerror_loginperks">';
+  $msg .= '<li>Access to 13 months of saved tests, making it easier to compare tests and analyze trends.</li>';
+  $msg .= '<li>Ability to contribute to the <a href="https://forums.webpagetest.org/">WebPageTest Forum</a>.</li>';
+  $msg .= '<li>Access to upcoming betas and new features that will enhance your WebPageTest experience.</li>';
+  $msg .= '</ul>';
+  return $msg;
+}
+
 function CheckRateLimit($test, &$error) {
   global $USER_EMAIL;
   global $supportsSaml;
@@ -3087,6 +3107,16 @@ function CheckRateLimit($test, &$error) {
     return true;
   }
 
+  $cmrl = new CheckMonthlyRateLimit($test['ip']);
+  $passesMonthly = $cmrl->check();
+
+  if(!$passesMonthly) {
+    $error = '<p>You\'ve reached the limit for logged-out tests this month, but don\'t worry! You can keep testing once you log in, which will give you access to other nice features like:</p>';
+    $error .= loggedInPerks();
+    $error .= loggedOutLoginForm();
+    return false;
+  }
+
   // Enforce per-IP rate limits for testing
   $limit = GetSetting('rate_limit_anon', null);
   if (isset($limit) && $limit > 0) {
@@ -3104,11 +3134,15 @@ function CheckRateLimit($test, &$error) {
     } else {
       $register = GetSetting('saml_register');
       $apiUrl = GetSetting('api_url');
+      $error = '<p>You\'ve reached the limit for logged-out tests per hour, but don\'t worry! You can keep testing once you log in, which will give you access to other nice features like:</p>';
+
+      $error .= loggedInPerks();
+
+
       if ($supportsSaml && $register && $apiUrl) {
-        $error = "The test has been blocked for exceeding the volume of testing allowed by anonymous users from your IP address.<br>Please <a href='/saml/login.php'>log in</a> with a <a href='$register'>registered account</a> or wait an hour before retrying.<br>If you need to run tests programmatically there is also the <a href='$apiUrl'>WebPageTest API</a>.";
-      } else {
-        $error = "The test has been blocked for exceeding the volume of testing allowed by anonymous users from your IP address.<br>Please log in with a registered account or wait an hour before retrying.";
+        $error .= "<p>And also, if you need to run tests programmatically you might be interested in the <a href='$apiUrl'>WebPageTest API</a></p>";
       }
+      $error .= loggedOutLoginForm();
       $ret = false;
     }
   }
diff --git a/www/testlog.php b/www/testlog.php
index a92016a3be..abd784bdfb 100644
--- a/www/testlog.php
+++ b/www/testlog.php
@@ -32,7 +32,7 @@
 $days      = (int)$_GET["days"];
 $from      = (isset($_GET["from"]) && strlen($_GET["from"])) ? $_GET["from"] : 'now';
 $filter    = $_GET["filter"];
-$filterstr = $filter ? preg_replace('/[^a-zA-Z0-9 \/\:\.\(\))\-\+]/', '', strtolower($filter)) : null;
+$filterstr = $filter ? preg_replace('/[^a-zA-Z0-9 \@\/\:\.\(\))\-\+]/', '', strtolower($filter)) : null;
 $onlyVideo = !empty($_REQUEST['video']);
 $all       = !empty($_REQUEST['all']);
 $repeat    = !empty($_REQUEST['repeat']);