diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..f64671c
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,98 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ unit:
+ name: Syntax check and unit tests (PHP ${{ matrix.php }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['8.1', '8.4']
+ steps:
+ - uses: actions/checkout@v7
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ tools: phpunit:11
+ coverage: none
+ - name: Check PHP syntax
+ run: find . -name '*.php' -not -path './.git/*' -print0 | xargs -0 -n1 php -l
+ - name: Unit tests
+ # PHPUnit 11 needs PHP >= 8.2
+ if: matrix.php != '8.1'
+ run: phpunit -c tests/phpunit.xml.dist --testsuite unit
+
+ integration:
+ name: Joomla ${{ matrix.joomla }}, ${{ matrix.db }}, PHP ${{ matrix.php }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { joomla: 5.4.8, php: '8.2', db: 'mysql:8.0' }
+ - { joomla: 5.4.8, php: '8.2', db: 'mariadb:10.11' }
+ - { joomla: 5.4.8, php: '8.2', db: 'postgres:12' }
+ - { joomla: 6.1.3, php: '8.4', db: 'mysql:8.4' }
+ - { joomla: 6.1.3, php: '8.4', db: 'mariadb:11.4' }
+ - { joomla: 6.1.3, php: '8.4', db: 'postgres:17' }
+ env:
+ JOOMLA_VERSION: ${{ matrix.joomla }}
+ DB_HOST: 127.0.0.1
+ DB_NAME: joomla
+ DB_PASS: bfstop-test
+ steps:
+ - name: Set paths
+ run: |
+ echo "JOOMLA_ROOT=$RUNNER_TEMP/joomla" >> "$GITHUB_ENV"
+ echo "COM_BFSTOP_ROOT=$RUNNER_TEMP/com_bfstop" >> "$GITHUB_ENV"
+ - uses: actions/checkout@v7
+
+ # the component is tested together with the plugin: use its branch of
+ # the same name if there is one (for changes spanning both), else main
+ - name: Determine com_bfstop branch
+ id: com
+ run: |
+ branch="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}"
+ if git ls-remote --exit-code --heads https://github.com/codeling/com_bfstop.git "$branch" > /dev/null; then
+ echo "ref=$branch" >> "$GITHUB_OUTPUT"
+ else
+ echo "ref=main" >> "$GITHUB_OUTPUT"
+ fi
+ - uses: actions/checkout@v7
+ with:
+ repository: codeling/com_bfstop
+ ref: ${{ steps.com.outputs.ref }}
+ path: com_bfstop
+ - name: Move com_bfstop checkout out of the plugin's
+ run: mv com_bfstop "$COM_BFSTOP_ROOT"
+
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: mysqli, pdo_mysql, pgsql, pdo_pgsql, zip, mbstring, intl, gd
+ tools: phpunit:11
+ coverage: none
+
+ - name: Start database (${{ matrix.db }})
+ env:
+ DB_IMAGE: ${{ matrix.db }}
+ run: tests/ci/start-database.sh
+
+ - name: Install Joomla and bfstop
+ run: tests/ci/install-joomla.sh
+
+ - name: Tests
+ run: phpunit -c tests/phpunit.xml.dist
+
+ - name: Plugin log
+ if: failure()
+ run: cat "$JOOMLA_ROOT"/administrator/logs/*.php || true
diff --git a/CHANGELOG b/CHANGELOG
index 87aecd0..af9b78d 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -35,6 +35,11 @@
two-factor auth when risk is high) would need to hook earlier in the
login pipeline than this plugin currently does and is left for a
separate, larger initiative
+ - PostgreSQL support (#206): install/uninstall scripts for PostgreSQL;
+ the plugin previously installed without creating its tables there and
+ then silently never blocked anything. IP subnet matching for block and
+ allow list entries is now done in PHP instead of with MySQL-only SQL
+ functions, and fixed-length time windows are computed in PHP
1.5.2 (2024-02-18)
- Note: Only component changes, no plugin changes
diff --git a/bfstop.xml b/bfstop.xml
index e050379..d38f485 100644
--- a/bfstop.xml
+++ b/bfstop.xml
@@ -38,16 +38,19 @@
sql/install.mysql.utf8.sql
+ sql/install.postgresql.utf8.sql
sql/uninstall.mysql.utf8.sql
+ sql/uninstall.postgresql.utf8.sql
sql/updates
+ sql/updates/postgresql
diff --git a/sql/install.postgresql.utf8.sql b/sql/install.postgresql.utf8.sql
new file mode 100644
index 0000000..f63d4fc
--- /dev/null
+++ b/sql/install.postgresql.utf8.sql
@@ -0,0 +1,86 @@
+-- install script for bfstop plugin, PostgreSQL version (issue #206);
+-- see install.mysql.utf8.sql for a description of the tables. Keep both in sync!
+--
+-- differences to the MySQL schema:
+-- - handled is a smallint, not a BOOLEAN: the code compares it with 0/1,
+-- which PostgreSQL doesn't allow for boolean columns
+-- - no unsigned integer types in PostgreSQL
+-- - ipaddress in bannedip and allowlist is varchar(49) (as for MySQL
+-- installs updated via 1.2.0.sql), to hold IPv6 subnets like
+-- "ffff:ffff:ffff:ffff:ffff:ffff:255.255.255.255/128"
+
+CREATE TABLE IF NOT EXISTS "#__bfstop_failedlogin" (
+ "id" serial NOT NULL,
+ "username" varchar(150) NOT NULL,
+ "ipaddress" varchar(45) NOT NULL,
+ "logtime" timestamp without time zone NOT NULL,
+ "origin" integer NOT NULL,
+ "handled" smallint NOT NULL DEFAULT 0,
+ PRIMARY KEY ("id")
+);
+CREATE INDEX IF NOT EXISTS "#__bfstop_failedlogin_username_logtime" ON "#__bfstop_failedlogin" ("username", "logtime");
+
+
+CREATE TABLE IF NOT EXISTS "#__bfstop_bannedip" (
+ "id" serial NOT NULL,
+ "ipaddress" varchar(49) NOT NULL,
+ "crdate" timestamp without time zone NOT NULL,
+ "duration" integer NOT NULL,
+ "attempts" integer NOT NULL DEFAULT 0,
+ "last_attempt" timestamp without time zone DEFAULT NULL,
+ PRIMARY KEY ("id")
+);
+
+
+CREATE TABLE IF NOT EXISTS "#__bfstop_unblock" (
+ "block_id" integer NOT NULL,
+ "source" integer NOT NULL,
+ "crdate" timestamp without time zone NOT NULL,
+ PRIMARY KEY ("block_id")
+);
+
+
+CREATE TABLE IF NOT EXISTS "#__bfstop_unblock_token" (
+ "token" varchar(40) NOT NULL,
+ "block_id" integer NOT NULL,
+ "crdate" timestamp without time zone NOT NULL,
+ PRIMARY KEY ("token")
+);
+
+
+CREATE TABLE IF NOT EXISTS "#__bfstop_allowlist" (
+ "id" serial NOT NULL,
+ "ipaddress" varchar(49) NOT NULL,
+ "notes" varchar(255) NOT NULL DEFAULT '',
+ PRIMARY KEY ("id")
+);
+
+
+CREATE TABLE IF NOT EXISTS "#__bfstop_knownip" (
+ "id" serial NOT NULL,
+ "ipaddress" varchar(45) NOT NULL,
+ "username" varchar(150) NOT NULL,
+ "first_success" timestamp without time zone NOT NULL,
+ "last_success" timestamp without time zone NOT NULL,
+ PRIMARY KEY ("id"),
+ CONSTRAINT "#__bfstop_knownip_ip_username" UNIQUE ("ipaddress", "username")
+);
+
+
+CREATE TABLE IF NOT EXISTS "#__bfstop_dnscache" (
+ "ipaddress" varchar(45) NOT NULL,
+ "hostname" varchar(255) DEFAULT NULL,
+ "checked_at" timestamp without time zone NOT NULL,
+ PRIMARY KEY ("ipaddress")
+);
+
+
+CREATE TABLE IF NOT EXISTS "#__bfstop_username_stats" (
+ "username" varchar(150) NOT NULL,
+ "attempts" integer NOT NULL DEFAULT 0,
+ "first_attempt" timestamp without time zone NOT NULL,
+ "last_attempt" timestamp without time zone NOT NULL,
+ PRIMARY KEY ("username")
+);
+CREATE INDEX IF NOT EXISTS "#__bfstop_username_stats_attempts" ON "#__bfstop_username_stats" ("attempts");
+CREATE INDEX IF NOT EXISTS "#__bfstop_username_stats_last_attempt" ON "#__bfstop_username_stats" ("last_attempt");
diff --git a/sql/uninstall.postgresql.utf8.sql b/sql/uninstall.postgresql.utf8.sql
new file mode 100644
index 0000000..44c197f
--- /dev/null
+++ b/sql/uninstall.postgresql.utf8.sql
@@ -0,0 +1,17 @@
+-- uninstall script for bfstop plugin, PostgreSQL version
+
+DROP TABLE IF EXISTS "#__bfstop_failedlogin";
+
+DROP TABLE IF EXISTS "#__bfstop_bannedip";
+
+DROP TABLE IF EXISTS "#__bfstop_unblock";
+
+DROP TABLE IF EXISTS "#__bfstop_unblock_token";
+
+DROP TABLE IF EXISTS "#__bfstop_allowlist";
+
+DROP TABLE IF EXISTS "#__bfstop_knownip";
+
+DROP TABLE IF EXISTS "#__bfstop_dnscache";
+
+DROP TABLE IF EXISTS "#__bfstop_username_stats";
diff --git a/sql/updates/postgresql/2.0.0.sql b/sql/updates/postgresql/2.0.0.sql
new file mode 100644
index 0000000..0ca1a70
--- /dev/null
+++ b/sql/updates/postgresql/2.0.0.sql
@@ -0,0 +1 @@
+-- PostgreSQL support starts with version 2.0.0, see install.postgresql.utf8.sql
diff --git a/sql/updates/postgresql/index.html b/sql/updates/postgresql/index.html
new file mode 100644
index 0000000..0dc101b
--- /dev/null
+++ b/sql/updates/postgresql/index.html
@@ -0,0 +1 @@
+
diff --git a/src/Helper/DatabaseHelper.php b/src/Helper/DatabaseHelper.php
index 1a8c9f6..6d4691a 100644
--- a/src/Helper/DatabaseHelper.php
+++ b/src/Helper/DatabaseHelper.php
@@ -32,6 +32,27 @@ public function getClientString($id)
return ($id == 0) ? 'Frontend' : 'Backend';
}
+ // $time (as written by date("Y-m-d H:i:s")) moved back by $minutes;
+ // computed in PHP so that no database-specific date arithmetic (like
+ // MySQL's DATE_SUB) is needed, see issue #206
+ private static function minutesBefore($time, $minutes)
+ {
+ return date("Y-m-d H:i:s", strtotime($time) - ((int)$minutes) * 60);
+ }
+
+ // SQL expression for $dateExpr + $minutesExpr minutes, for the cases
+ // where the number of minutes comes from a column and can therefore not
+ // be computed in PHP. Neither DATE_ADD nor Joomla's
+ // DatabaseQuery::dateAdd() work on PostgreSQL for this (issue #206)
+ private function addMinutesSql($dateExpr, $minutesExpr)
+ {
+ if ($this->db->getServerType() === 'postgresql')
+ {
+ return '('.$dateExpr.' + '.$minutesExpr." * INTERVAL '1 minute')";
+ }
+ return 'DATE_ADD('.$dateExpr.', INTERVAL '.$minutesExpr.' MINUTE)';
+ }
+
public function __construct(LoggerHelper $logger)
{
$this->db = Factory::getDbo();
@@ -63,9 +84,9 @@ public function eventsInInterval(
// check if in the last $interval hours, $number incidents have occured already:
$sql = "SELECT COUNT(*) FROM ".$table." t ".
"WHERE t.".$timecol.
- " between DATE_SUB(".
- $this->db->quote($time).
- ", INTERVAL $interval MINUTE) AND ".
+ " between ".
+ $this->db->quote(self::minutesBefore($time, $interval)).
+ " AND ".
$this->db->quote($time).
" ".$additionalWhere;
$this->db->setQuery($sql);
@@ -108,9 +129,8 @@ public function getFailedLoginsInLastHour()
{
$nowDateTime = date("Y-m-d H:i:s");
$sql = "SELECT COUNT(*) FROM #__bfstop_failedlogin ".
- "WHERE logtime > DATE_SUB(".
- $this->db->quote($nowDateTime).
- ", INTERVAL 1 HOUR)";
+ "WHERE logtime > ".
+ $this->db->quote(self::minutesBefore($nowDateTime, 60));
$this->db->setQuery($sql);
return $this->db->loadResult();
}
@@ -139,8 +159,8 @@ public function getFormattedFailedList($ipAddress, $curTime, $interval)
$sql = "SELECT * FROM #__bfstop_failedlogin t where ipaddress=".
$this->db->quote($ipAddress).
" AND t.logtime".
- " between DATE_SUB(".$this->db->quote($curTime).
- ", INTERVAL $interval MINUTE) AND ".
+ " between ".$this->db->quote(self::minutesBefore($curTime, $interval)).
+ " AND ".
$this->db->quote($curTime);
$this->db->setQuery($sql);
$entries = $this->db->loadObjectList();
@@ -165,126 +185,39 @@ public function getFormattedFailedList($ipAddress, $curTime, $interval)
}
}
- public function ipAddressMatch($ipaddress)
- {
- // literal match
- return
- "(".
- "ipaddress=".$this->db->quote($ipaddress)." AND ".
- "LOCATE('/', ipaddress) = 0".
- ")";
- }
-
- public function ipSubNetIPv4Match($ipaddress)
- {
- $DashPos = 'LOCATE("/", ipaddress)';
- $SubNetAddress = 'SUBSTR(ipaddress, 1, LOCATE("/", ipaddress)-1)';
- $BitsText = 'SUBSTR(ipaddress, '.$DashPos.'+1, LENGTH(ipaddress)-'.$DashPos.')';
- // the original mask expression used $BitsText directly as a
- // string in "32 - SUBSTR(...)"; MySQL/MariaDB then coerces that
- // implicitly to a DOUBLE, and if a stored row has a corrupted
- // prefix length (huge or malformed - e.g. hand-edited in the DB,
- // or an old bug that let one in), "32 - " produces a DOUBLE
- // whose magnitude overflows BIGINT UNSIGNED once the shift
- // operator converts it, raising "BIGINT UNSIGNED value is out of
- // range" for every query against the whole table (#134).
- $RawBits = 'CAST('.$BitsText.' AS SIGNED)';
- // clamped to [0,32] before any further arithmetic, so a corrupted
- // row can never blow up the mask math into an out-of-range error -
- // same fix as ipSubNetIPv6Match() below (#142). The BitsValid
- // guard further down keeps such a clamped-but-bogus row from
- // silently matching as a wildcard.
- $Bits = 'GREATEST(LEAST('.$RawBits.', 32), 0)';
- $IPv4NetMask = '~((1 << (32 - '.$Bits.'))-1)';
- // rejects a corrupted prefix length outright (rather than letting
- // the clamp above silently turn it into a "/0" wildcard match) -
- // the digit-count-limited REGEXP is itself overflow-safe, so this
- // check never needs the clamped value to decide validity
- $BitsValid = $BitsText." REGEXP '^[0-9]{1,2}$' AND ".$RawBits." BETWEEN 0 AND 32";
- return
- "(".
- // IPv4 subnet match (CIDR Suffix notation)
- "(".
- "LOCATE('/', ipaddress) != 0 AND LOCATE('.', ipaddress) != 0 AND ".
- $BitsValid." AND ".
- "(INET_ATON(".$this->db->quote($ipaddress).") & ".$IPv4NetMask.")".
- " = ".
- "(INET_ATON(".$SubNetAddress.") & ".$IPv4NetMask.")".
- ")".
- ")";
- }
-
- // splits a stored INET6_ATON() value into its high/low 8-byte halves and
- // widens each to a plain BIGINT UNSIGNED, since MySQL's bitwise operators
- // only work on 64-bit integers, not on 16-byte VARBINARY values directly
- // (see issue #142/#117 - a 128-bit mask can't be applied in one step)
- private function inet6HalfAsUnsigned($ipExpr, $startByte)
- {
- return 'CAST(CONV(HEX(SUBSTR(INET6_ATON('.$ipExpr.'), '.$startByte.', 8)), 16, 10) AS UNSIGNED)';
- }
-
- public function ipSubNetIPv6Match($ipaddress)
- {
- $ipQuoted = $this->db->quote($ipaddress);
- $DashPos = 'LOCATE("/", ipaddress)';
- $SubNetAddress = 'SUBSTR(ipaddress, 1, '.$DashPos.'-1)';
- $BitsText = 'SUBSTR(ipaddress, '.$DashPos.'+1, LENGTH(ipaddress)-'.$DashPos.')';
- // signed, not unsigned: bits-64 goes negative for a /0..'/63 prefix,
- // and an UNSIGNED subtraction underflowing below 0 raises "BIGINT
- // UNSIGNED out of range" in strict mode (this is the #117 crash).
- $RawBits = 'CAST('.$BitsText.' AS SIGNED)';
- // clamped to [0,128] before any further arithmetic, so a row with a
- // corrupted prefix length (e.g. hand-edited in the DB) can never
- // blow up the '-64'/shift math below into an out-of-range error -
- // see #134, which hit this exact class of bug in the analogous
- // IPv4 query. The BitsValid guard further down keeps such a
- // clamped-but-bogus row from silently matching as a wildcard.
- $Bits = 'GREATEST(LEAST('.$RawBits.', 128), 0)';
-
- // the 128-bit prefix length is applied as two independent 64-bit
- // masks, one per half of the address
- $HiBits = 'LEAST('.$Bits.', 64)';
- $LoBits = 'GREATEST('.$Bits.' - 64, 0)';
- $HiMask = '(0xFFFFFFFFFFFFFFFF << (64 - '.$HiBits.'))';
- $LoMask = '(0xFFFFFFFFFFFFFFFF << (64 - '.$LoBits.'))';
-
- // rejects a corrupted prefix length outright (rather than letting
- // the clamp above silently turn it into a "/0" wildcard match) -
- // the digit-count-limited REGEXP is itself overflow-safe, so this
- // check never needs the clamped value to decide validity
- $BitsValid = $BitsText." REGEXP '^[0-9]{1,3}$' AND ".$RawBits." BETWEEN 0 AND 128";
-
- return
- "(".
- // IPv6 subnet match (CIDR Suffix notation)
- "(".
- "LOCATE('/', ipaddress) != 0 AND LOCATE(':', ipaddress) != 0 AND ".
- $BitsValid." AND ".
- "INET6_ATON(".$ipQuoted.") IS NOT NULL AND LENGTH(INET6_ATON(".$ipQuoted.")) = 16 AND ".
- "INET6_ATON(".$SubNetAddress.") IS NOT NULL AND LENGTH(INET6_ATON(".$SubNetAddress.")) = 16 AND ".
- "(".$this->inet6HalfAsUnsigned($ipQuoted, 1)." & ".$HiMask.")".
- " = ".
- "(".$this->inet6HalfAsUnsigned($SubNetAddress, 1)." & ".$HiMask.")".
- " AND ".
- "(".$this->inet6HalfAsUnsigned($ipQuoted, 9)." & ".$LoMask.")".
- " = ".
- "(".$this->inet6HalfAsUnsigned($SubNetAddress, 9)." & ".$LoMask.")".
- ")".
- ")";
- }
-
- private function loadMatchingEntries($sql, $action)
+ /**
+ * Loads the rows of $table (aliased t, needs id and ipaddress columns)
+ * which match the given IP address, either literally or as part of a
+ * stored IPv4/IPv6 subnet (CIDR notation). Only the literal match is done
+ * in SQL; subnet entries are matched in PHP (IpHelper::isInSubnet), which
+ * works the same on every database - the previous SQL implementation
+ * relied on MySQL-only functions (INET_ATON, INET6_ATON, LOCATE, REGEXP,
+ * ...), see issue #206, and was the source of #117, #134 and #142.
+ */
+ private function loadMatchingEntries($ipaddress, $table, $additionalWhere, $action)
{
try
{
+ // LOWER: IPv6 addresses may be stored in upper case; MySQL's
+ // default collations compare case-insensitively, PostgreSQL doesn't
+ $sql = "SELECT t.id, t.ipaddress FROM ".$table." t WHERE ".
+ "((t.ipaddress NOT LIKE '%/%' AND LOWER(t.ipaddress) = LOWER(".$this->db->quote($ipaddress)."))".
+ " OR t.ipaddress LIKE '%/%')".
+ $additionalWhere;
$this->db->setQuery($sql);
- $entries = $this->db->loadObjectList();
- foreach ($entries as $entry)
+ $entries = array();
+ foreach ($this->db->loadObjectList() as $entry)
{
+ if (strpos($entry->ipaddress, '/') !== false &&
+ !IpHelper::isInSubnet($ipaddress, $entry->ipaddress))
+ {
+ continue;
+ }
$this->logger->log($action." because of entry: ".
"id=".$entry->id.", ".
"ipaddress=".$entry->ipaddress,
Log::DEBUG);
+ $entries[] = $entry;
}
return $entries;
}
@@ -295,33 +228,22 @@ private function loadMatchingEntries($sql, $action)
}
}
- private function checkForEntries($sql, $action)
- {
- return count($this->loadMatchingEntries($sql, $action));
- }
-
/**
* IDs of all currently active blocks matching the given IP address,
* whether as single address or as part of a blocked IPv4/IPv6 subnet.
*/
public function getActiveBlockIds($ipaddress)
{
- $sqlCheckPattern = "SELECT id, ipaddress, crdate, duration FROM #__bfstop_bannedip b WHERE ".
- "%s AND (b.duration=0 OR DATE_ADD(b.crdate, INTERVAL b.duration MINUTE) >= ".
+ $activeWhere = " AND (t.duration=0 OR ".
+ $this->addMinutesSql('t.crdate', 't.duration')." >= ".
$this->db->quote(date("Y-m-d H:i:s")).")".
- " AND NOT EXISTS (SELECT 1 FROM #__bfstop_unblock u WHERE b.id = u.block_id)";
+ " AND NOT EXISTS (SELECT 1 FROM #__bfstop_unblock u WHERE t.id = u.block_id)";
$ids = array();
- foreach (array(
- $this->ipAddressMatch($ipaddress),
- $this->ipSubNetIPv4Match($ipaddress),
- $this->ipSubNetIPv6Match($ipaddress)) as $matchExpr)
+ foreach ($this->loadMatchingEntries($ipaddress, '#__bfstop_bannedip', $activeWhere, "Blocked") as $entry)
{
- foreach ($this->loadMatchingEntries(sprintf($sqlCheckPattern, $matchExpr), "Blocked") as $entry)
- {
- $ids[] = (int)$entry->id;
- }
+ $ids[] = (int)$entry->id;
}
- return array_values(array_unique($ids));
+ return $ids;
}
public function isIPBlocked($ipaddress)
@@ -357,14 +279,7 @@ public function recordBlockedAttempt($blockIds)
public function isIPOnAllowList($ipaddress)
{
- $sqlCheckPattern = "SELECT id, ipaddress from #__bfstop_allowlist WHERE %s";
- $sqlIPCheck = sprintf($sqlCheckPattern, $this->ipAddressMatch($ipaddress));
- $sqlSubNetIPv4Check = sprintf($sqlCheckPattern, $this->ipSubNetIPv4Match($ipaddress));
- $sqlSubNetIPv6Check = sprintf($sqlCheckPattern, $this->ipSubNetIPv6Match($ipaddress));
- $entryCount = $this->checkForEntries($sqlIPCheck, "Allowed");
- $entryCount += $this->checkForEntries($sqlSubNetIPv4Check, "Allowed");
- $entryCount += $this->checkForEntries($sqlSubNetIPv6Check, "Allowed");
- return ($entryCount > 0);
+ return count($this->loadMatchingEntries($ipaddress, '#__bfstop_allowlist', '', "Allowed")) > 0;
}
public function blockIP($logEntry, $duration, $usehtaccess, $htaccessPath)
@@ -510,8 +425,17 @@ private function recordUsernameAttempt($username, $logtime)
$time = $this->db->quote($logtime);
$sql = 'INSERT INTO #__bfstop_username_stats'.
' (username, attempts, first_attempt, last_attempt) VALUES ('.
- $this->db->quote($username).', 1, '.$time.', '.$time.')'.
- ' ON DUPLICATE KEY UPDATE attempts=attempts+1, last_attempt='.$time;
+ $this->db->quote($username).', 1, '.$time.', '.$time.')';
+ // upsert: ON DUPLICATE KEY UPDATE is MySQL-only (issue #206)
+ if ($this->db->getServerType() === 'postgresql')
+ {
+ $sql .= ' ON CONFLICT (username) DO UPDATE SET'.
+ ' attempts=#__bfstop_username_stats.attempts+1, last_attempt='.$time;
+ }
+ else
+ {
+ $sql .= ' ON DUPLICATE KEY UPDATE attempts=attempts+1, last_attempt='.$time;
+ }
$this->db->setQuery($sql);
$this->db->execute();
}
@@ -676,15 +600,14 @@ public function purgeOldEntries($purgeAgeWeeks)
// all timestamps are written with PHP's date(), so compare against
// the same clock instead of the database's NOW(), which may use a
// different time zone
- $now = $this->db->quote(date("Y-m-d H:i:s"));
- $deleteDate = 'DATE_SUB('.$now.
- ', INTERVAL '.((int) $purgeAgeWeeks).
- ' WEEK)';
+ $now = date("Y-m-d H:i:s");
+ $deleteDate = $this->db->quote(self::minutesBefore($now,
+ ((int) $purgeAgeWeeks) * 7 * 24 * 60));
$this->db->setQuery('DELETE FROM #__bfstop_failedlogin WHERE logtime < '.$deleteDate);
$this->db->execute();
- $this->db->setQuery('DELETE FROM #__bfstop_bannedip WHERE duration != 0 AND
- DATE_ADD(crdate, INTERVAL duration MINUTE) < '.$deleteDate);
+ $this->db->setQuery('DELETE FROM #__bfstop_bannedip WHERE duration != 0 AND '.
+ $this->addMinutesSql('crdate', 'duration').' < '.$deleteDate);
$this->db->execute();
$this->db->setQuery('DELETE FROM #__bfstop_unblock WHERE NOT EXISTS '.
@@ -699,7 +622,7 @@ public function purgeOldEntries($purgeAgeWeeks)
// admin-configured purge age above - this just reclaims the
// storage for rows nothing will ever read as valid again.
$this->db->setQuery('DELETE FROM #__bfstop_dnscache WHERE checked_at < '.
- 'DATE_SUB('.$now.', INTERVAL '.self::$DNS_CACHE_TTL_DAYS.' DAY)');
+ $this->db->quote(self::minutesBefore($now, self::$DNS_CACHE_TTL_DAYS * 24 * 60)));
$this->db->execute();
}
catch (\Exception $e)
@@ -713,9 +636,11 @@ public function saveParams($params)
try
{
$query = $this->db->getQuery(true);
- $query->update('#__extensions AS a');
- $query->set('a.params = '. $this->db->quote((string)$params));
- $query->where('a.element = '.$this->db->quote('bfstop'));
+ // no table alias here: PostgreSQL doesn't allow qualified column
+ // names in the SET clause (issue #206)
+ $query->update($this->db->quoteName('#__extensions'));
+ $query->set($this->db->quoteName('params').' = '. $this->db->quote((string)$params));
+ $query->where($this->db->quoteName('element').' = '.$this->db->quote('bfstop'));
$this->db->setQuery($query);
$this->db->execute();
}
diff --git a/src/Helper/IpHelper.php b/src/Helper/IpHelper.php
index 73f8d8f..ef8b8e4 100644
--- a/src/Helper/IpHelper.php
+++ b/src/Helper/IpHelper.php
@@ -33,6 +33,45 @@ class IpHelper
'HTTP_CLIENT_IP',
);
+ /**
+ * Whether $ip lies within $subnet, given in CIDR notation (e.g.
+ * "192.0.2.0/24" or "2001:db8::/32"). Anything malformed - an address
+ * that doesn't parse, IPv4 vs. IPv6 mismatch, or a prefix length that
+ * isn't a plain number within range for the address family - never
+ * matches, so a corrupted stored entry can't turn into a wildcard.
+ */
+ public static function isInSubnet($ip, $subnet)
+ {
+ $parts = explode('/', $subnet);
+ if (count($parts) !== 2 || !preg_match('/^[0-9]{1,3}$/', $parts[1]))
+ {
+ return false;
+ }
+ $ipBin = @inet_pton($ip);
+ $subnetBin = @inet_pton($parts[0]);
+ if ($ipBin === false || $subnetBin === false || strlen($ipBin) !== strlen($subnetBin))
+ {
+ return false;
+ }
+ $bits = (int)$parts[1];
+ if ($bits > strlen($ipBin) * 8)
+ {
+ return false;
+ }
+ $fullBytes = intdiv($bits, 8);
+ if (substr($ipBin, 0, $fullBytes) !== substr($subnetBin, 0, $fullBytes))
+ {
+ return false;
+ }
+ $remainingBits = $bits % 8;
+ if ($remainingBits === 0)
+ {
+ return true;
+ }
+ $mask = (0xff << (8 - $remainingBits)) & 0xff;
+ return (ord($ipBin[$fullBytes]) & $mask) === (ord($subnetBin[$fullBytes]) & $mask);
+ }
+
private static function firstValidPublicIP($headerValue)
{
foreach (explode(',', $headerValue) as $ip)
diff --git a/tests/Integration/ComponentTest.php b/tests/Integration/ComponentTest.php
new file mode 100644
index 0000000..8803724
--- /dev/null
+++ b/tests/Integration/ComponentTest.php
@@ -0,0 +1,132 @@
+ $this->db), new MVCFactory('Codeling\\Component\\Bfstop'));
+ $model->setDatabase($this->db);
+ return $model;
+ }
+
+ private function listCount($class)
+ {
+ $model = $this->model($class);
+ $getListQuery = new \ReflectionMethod($model, 'getListQuery');
+ $this->db->setQuery($getListQuery->invoke($model));
+ return count($this->db->loadObjectList());
+ }
+
+ /**
+ * Saves a new entry like the component's edit views do; the id comes
+ * empty from the form when creating a new entry
+ */
+ private function saveNew($modelClass, array $data)
+ {
+ $model = $this->model($modelClass);
+ $model->getState(); // populate the state now, so it doesn't overwrite the saved id later
+ $this->assertTrue($model->save(array('id' => '') + $data), implode(', ', $model->getErrors()));
+ return (int) $model->getState($model->getName().'.id');
+ }
+
+ public function testTablesAndListViews()
+ {
+ $blockId = $this->saveNew(BlockModel::class,
+ array('ipaddress' => '192.0.2.0/24', 'crdate' => '2026-01-02', 'duration' => '0'));
+ $allowId = $this->saveNew(AllowModel::class,
+ array('ipaddress' => '198.51.100.0/24', 'notes' => 'test'));
+ $this->assertGreaterThan(0, $blockId);
+ $this->assertGreaterThan(0, $allowId);
+ $this->insert('#__bfstop_unblock', array('block_id' => $blockId, 'source' => 0, 'crdate' => self::minutesAgo(0)));
+ $this->insert('#__bfstop_failedlogin', array('ipaddress' => '192.0.2.1', 'logtime' => self::minutesAgo(0), 'username' => 'bob', 'origin' => 0));
+
+ $this->assertSame(1, $this->listCount(BlocklistModel::class));
+ $this->assertSame(1, $this->listCount(AllowlistModel::class));
+ $this->assertSame(1, $this->listCount(FailedloginlistModel::class));
+
+ $this->model(AllowlistModel::class)->remove(array($allowId), $this->logger);
+ $this->assertSame(0, $this->listCount(AllowlistModel::class));
+ }
+
+ public function testPurgeFailedLogins()
+ {
+ $this->insert('#__bfstop_failedlogin', array('ipaddress' => '192.0.2.1', 'logtime' => self::minutesAgo(31 * 24 * 60), 'username' => 'old', 'origin' => 0));
+ $this->insert('#__bfstop_failedlogin', array('ipaddress' => '192.0.2.1', 'logtime' => self::minutesAgo(0), 'username' => 'new', 'origin' => 0));
+ $this->assertSame(1, $this->model(FailedloginlistModel::class)->purgeOlderThan(30));
+ $this->assertSame('new', $this->queryValue('SELECT username FROM #__bfstop_failedlogin'));
+ }
+
+ public function testUsernameStatistics()
+ {
+ $this->insert('#__bfstop_username_stats', array('username' => 'Admin', 'attempts' => 12,
+ 'first_attempt' => self::minutesAgo(3 * 24 * 60), 'last_attempt' => self::minutesAgo(60)));
+ $this->insert('#__bfstop_username_stats', array('username' => 'old', 'attempts' => 3,
+ 'first_attempt' => self::minutesAgo(40 * 24 * 60), 'last_attempt' => self::minutesAgo(31 * 24 * 60)));
+
+ $this->assertSame(2, $this->listCount(UsernamestatsModel::class));
+ $model = $this->model(UsernamestatsModel::class);
+ $this->assertSame(12, $model->getMaxAttempts());
+ $this->assertSame(1, $model->purgeNotSeenFor(30));
+ $this->assertSame('Admin', $this->queryValue('SELECT username FROM #__bfstop_username_stats'));
+ }
+
+ public function testUnblockViaEmailToken()
+ {
+ $blockId = $this->insert('#__bfstop_bannedip', array('ipaddress' => '203.0.113.5', 'crdate' => self::minutesAgo(0), 'duration' => 60), 'id');
+ $this->insert('#__bfstop_unblock_token', array('token' => 'expired', 'block_id' => $blockId, 'crdate' => self::minutesAgo(4 * 24 * 60)));
+ $this->insert('#__bfstop_unblock_token', array('token' => 'valid', 'block_id' => $blockId, 'crdate' => self::minutesAgo(60)));
+ $helper = new DatabaseHelper($this->logger);
+ $this->assertTrue($helper->isIPBlocked('203.0.113.5'));
+
+ $model = $this->model(TokenunblockModel::class);
+ $this->assertFalse((bool) $model->unblock('expired', $this->logger), 'expired token must not unblock');
+ $this->logger->errors = array(); // expected: "token not found" error
+ $this->assertTrue((bool) $model->unblock('valid', $this->logger));
+
+ $this->assertFalse($helper->isIPBlocked('203.0.113.5'));
+ $this->assertSame(1, (int) $this->queryValue('SELECT source FROM #__bfstop_unblock WHERE block_id='.$blockId));
+ $this->assertSame(0, (int) $this->queryValue('SELECT COUNT(*) FROM #__bfstop_unblock_token'));
+ }
+
+ public function testWarnsAboutAdminUserCaseInsensitively()
+ {
+ // tests/ci/install-joomla.sh creates the super user as "Admin"
+ $app = Factory::getApplication();
+ $app->getMessageQueue(true);
+ $controller = new DisplayController(array('base_path' => JPATH_ADMINISTRATOR.'/components/com_bfstop'), new MVCFactory('Codeling\\Component\\Bfstop'), $app, $app->getInput());
+ $controller->warnIfAdminUserExists();
+ $this->assertCount(1, $app->getMessageQueue(true));
+ }
+}
diff --git a/tests/Integration/DatabaseHelperTest.php b/tests/Integration/DatabaseHelperTest.php
new file mode 100644
index 0000000..d22c2ed
--- /dev/null
+++ b/tests/Integration/DatabaseHelperTest.php
@@ -0,0 +1,221 @@
+helper = new DatabaseHelper($this->logger);
+ }
+
+ private function failedLogin($ip, $username, $minutesAgo, $origin = 0)
+ {
+ $this->helper->insertFailedLogin((object) array('ipaddress' => $ip,
+ 'logtime' => self::minutesAgo($minutesAgo), 'username' => $username, 'origin' => $origin));
+ }
+
+ private function block($ip, $minutesAgo, $duration)
+ {
+ return $this->insert('#__bfstop_bannedip', array('ipaddress' => $ip,
+ 'crdate' => self::minutesAgo($minutesAgo), 'duration' => $duration), 'id');
+ }
+
+ private function activeBlocks($ip)
+ {
+ $ids = $this->helper->getActiveBlockIds($ip);
+ sort($ids);
+ return $ids;
+ }
+
+ public function testFailedLoginCounting()
+ {
+ $now = self::minutesAgo(0);
+ foreach (array(0, 5, 30, 120) as $minutesAgo)
+ {
+ $this->failedLogin('203.0.113.5', 'bob', $minutesAgo);
+ }
+ $this->failedLogin('203.0.113.99', 'bob', 1, 1);
+ $this->failedLogin('203.0.113.5', 'eve', 8 * 24 * 60);
+
+ $this->assertSame(3, $this->helper->getNumberOfFailedLogins(60, '203.0.113.5', $now));
+ $this->assertSame(4, $this->helper->getNumberOfFailedLogins(180, '203.0.113.5', $now));
+ $this->assertSame(4, $this->helper->getNumberOfFailedLoginsForUsername(60, 'bob', $now));
+ $this->assertSame(4, (int) $this->helper->getFailedLoginsInLastHour());
+ $list = $this->helper->getFormattedFailedList('203.0.113.5', $now, 60);
+ $this->assertSame(3 + 2, substr_count($list, "\n"), "2 header lines + 3 entries:\n".$list);
+
+ $this->helper->setFailedLoginHandled((object) array('ipaddress' => '203.0.113.5', 'username' => 'bob'), true);
+ $this->assertSame(0, $this->helper->getNumberOfFailedLogins(60, '203.0.113.5', $now));
+ $this->assertSame(1, $this->helper->getNumberOfFailedLoginsForUsername(60, 'bob', $now), 'other IP not handled');
+ }
+
+ public function testUsernameStatistics()
+ {
+ $this->failedLogin('203.0.113.5', 'bob', 30);
+ $this->failedLogin('203.0.113.6', 'bob', 20);
+ $this->failedLogin('203.0.113.5', 'bob', 10);
+ $this->failedLogin('203.0.113.5', 'eve', 5);
+
+ $this->db->setQuery('SELECT username, attempts, first_attempt, last_attempt FROM #__bfstop_username_stats ORDER BY username');
+ $rows = $this->db->loadObjectList();
+ $this->assertCount(2, $rows);
+ $this->assertSame(array('bob', 3), array($rows[0]->username, (int) $rows[0]->attempts));
+ $this->assertSame(self::minutesAgo(30), substr($rows[0]->first_attempt, 0, 19));
+ $this->assertSame(self::minutesAgo(10), substr($rows[0]->last_attempt, 0, 19));
+ $this->assertSame(array('eve', 1), array($rows[1]->username, (int) $rows[1]->attempts));
+
+ // not removed by the automatic purge, unlike the failed logins themselves
+ $this->insert('#__bfstop_username_stats', array('username' => 'old', 'attempts' => 7,
+ 'first_attempt' => self::minutesAgo(90 * 24 * 60), 'last_attempt' => self::minutesAgo(80 * 24 * 60)));
+ $this->helper->purgeOldEntries(1);
+ $this->assertSame(3, (int) $this->queryValue('SELECT COUNT(*) FROM #__bfstop_username_stats'));
+ }
+
+ public function testActiveBlocks()
+ {
+ $exact = $this->block('203.0.113.5', 0, 60);
+ $this->block('203.0.113.6', 120, 60); // expired
+ $unlimited = $this->block('203.0.113.7', 500000, 0);
+ $net4 = $this->block('198.51.100.0/24', 0, 0);
+ $net4small = $this->block('203.0.113.4/31', 0, 0);
+ $net6 = $this->block('2001:DB8:AB::/48', 0, 60);
+ $exact6 = $this->block('2001:DB8::9', 0, 60);
+ $this->block('192.0.2.0/99', 0, 0); // corrupted prefix lengths
+ $this->block('192.0.2.0/abc', 0, 0);
+ $unblocked = $this->block('203.0.113.8', 0, 0);
+ $this->insert('#__bfstop_unblock', array('block_id' => $unblocked, 'source' => 0, 'crdate' => self::minutesAgo(0)));
+
+ $this->assertSame(array($exact, $net4small), $this->activeBlocks('203.0.113.5'));
+ $this->assertSame(array(), $this->activeBlocks('203.0.113.6'), 'expired');
+ $this->assertSame(array($unlimited), $this->activeBlocks('203.0.113.7'));
+ $this->assertSame(array($net4), $this->activeBlocks('198.51.100.200'));
+ $this->assertSame(array(), $this->activeBlocks('198.51.101.1'));
+ $this->assertSame(array($net6), $this->activeBlocks('2001:db8:ab:1::5'));
+ $this->assertSame(array(), $this->activeBlocks('2001:db8:ac::1'));
+ $this->assertSame(array($exact6), $this->activeBlocks('2001:db8::9'), 'stored in upper case');
+ $this->assertSame(array(), $this->activeBlocks('192.0.2.1'), 'corrupted entries must not match');
+ $this->assertSame(array(), $this->activeBlocks('203.0.113.8'), 'unblocked');
+ $this->assertTrue($this->helper->isIPBlocked('198.51.100.1'));
+ $this->assertFalse($this->helper->isIPBlocked('192.0.2.200'));
+ }
+
+ public function testNumberOfPreviousBlocks()
+ {
+ $this->block('203.0.113.7', 500000, 0);
+ $this->block('203.0.113.7', 10, 60);
+ $manuallyUnblocked = $this->block('203.0.113.8', 10, 60);
+ $this->insert('#__bfstop_unblock', array('block_id' => $manuallyUnblocked, 'source' => 0, 'crdate' => self::minutesAgo(0)));
+ $unblockedByMail = $this->block('203.0.113.9', 10, 60);
+ $this->insert('#__bfstop_unblock', array('block_id' => $unblockedByMail, 'source' => 1, 'crdate' => self::minutesAgo(0)));
+
+ $this->assertSame(2, $this->helper->getNumberOfPreviousBlocks('203.0.113.7'));
+ $this->assertSame(0, $this->helper->getNumberOfPreviousBlocks('203.0.113.8'));
+ $this->assertSame(1, $this->helper->getNumberOfPreviousBlocks('203.0.113.9'));
+ }
+
+ public function testBlockIPAndRecordAttempts()
+ {
+ $this->failedLogin('203.0.113.50', 'bob', 0);
+ $id = $this->helper->blockIP((object) array('ipaddress' => '203.0.113.50', 'username' => 'bob'), 30, false, '');
+ $this->assertGreaterThan(0, $id);
+ $this->assertSame(array($id), $this->activeBlocks('203.0.113.50'));
+ $this->assertSame(0, $this->helper->getNumberOfFailedLogins(60, '203.0.113.50', self::minutesAgo(0)),
+ 'failed logins are marked handled when blocking');
+
+ $this->helper->recordBlockedAttempt(array($id));
+ $this->helper->recordBlockedAttempt(array($id));
+ $this->db->setQuery('SELECT attempts, last_attempt FROM #__bfstop_bannedip WHERE id='.$id);
+ $row = $this->db->loadObject();
+ $this->assertSame(2, (int) $row->attempts);
+ $this->assertNotNull($row->last_attempt);
+ }
+
+ public function testAllowList()
+ {
+ $this->insert('#__bfstop_allowlist', array('ipaddress' => '10.1.0.0/16', 'notes' => ''));
+ $this->insert('#__bfstop_allowlist', array('ipaddress' => '2001:DB8:FFFF::1', 'notes' => ''));
+ $this->insert('#__bfstop_allowlist', array('ipaddress' => '198.51.100.3', 'notes' => ''));
+
+ $this->assertTrue($this->helper->isIPOnAllowList('10.1.2.3'));
+ $this->assertFalse($this->helper->isIPOnAllowList('10.2.0.1'));
+ $this->assertTrue($this->helper->isIPOnAllowList('2001:db8:ffff::1'));
+ $this->assertTrue($this->helper->isIPOnAllowList('198.51.100.3'));
+ $this->assertFalse($this->helper->isIPOnAllowList('198.51.100.30'));
+ }
+
+ public function testUnblockToken()
+ {
+ $this->assertSame('tokenA', $this->helper->getNewUnblockToken(1, 'tokenA'));
+ $this->assertTrue($this->helper->unblockTokenExists('tokenA'));
+ $this->assertFalse($this->helper->unblockTokenExists('tokenB'));
+ }
+
+ public function testKnownIpUsername()
+ {
+ $login = (object) array('ipaddress' => '203.0.113.5', 'username' => 'bob');
+ $this->helper->successfulLogin($login);
+ $this->helper->successfulLogin($login); // second time: update instead of insert
+ $this->assertTrue($this->helper->isKnownIpUsername('203.0.113.5', 'bob'));
+ $this->assertFalse($this->helper->isKnownIpUsername('203.0.113.5', 'eve'));
+ $this->assertFalse($this->helper->isKnownIpUsername('203.0.113.6', 'bob'));
+ $this->assertSame(1, (int) $this->queryValue('SELECT COUNT(*) FROM #__bfstop_knownip'));
+ }
+
+ public function testDnsCache()
+ {
+ $this->assertFalse($this->helper->getCachedHostname('203.0.113.5'), 'not cached');
+ $this->helper->cacheHostname('203.0.113.5', 'host.example');
+ $this->assertSame('host.example', $this->helper->getCachedHostname('203.0.113.5'));
+ $this->helper->cacheHostname('203.0.113.5', null);
+ $this->assertNull($this->helper->getCachedHostname('203.0.113.5'), 'cached "no PTR record"');
+ }
+
+ public function testPurgeOldEntries()
+ {
+ $this->failedLogin('203.0.113.5', 'old', 8 * 24 * 60);
+ $this->failedLogin('203.0.113.5', 'recent', 60);
+ $oldExpiredBlock = $this->block('203.0.113.60', 10 * 24 * 60, 60);
+ $oldUnlimitedBlock = $this->block('203.0.113.61', 10 * 24 * 60, 0);
+ $this->insert('#__bfstop_unblock', array('block_id' => $oldExpiredBlock, 'source' => 0, 'crdate' => self::minutesAgo(0)));
+ $this->insert('#__bfstop_unblock_token', array('token' => 'old', 'block_id' => $oldExpiredBlock, 'crdate' => self::minutesAgo(8 * 24 * 60)));
+ $this->insert('#__bfstop_dnscache', array('ipaddress' => '203.0.113.77', 'hostname' => null, 'checked_at' => self::minutesAgo(8 * 24 * 60)));
+ $this->insert('#__bfstop_dnscache', array('ipaddress' => '203.0.113.78', 'hostname' => null, 'checked_at' => self::minutesAgo(60)));
+
+ $this->helper->purgeOldEntries(1);
+
+ $this->assertSame('recent', $this->queryValue('SELECT username FROM #__bfstop_failedlogin'));
+ $this->assertSame(array($oldUnlimitedBlock), array_map('intval', $this->db->setQuery('SELECT id FROM #__bfstop_bannedip')->loadColumn()));
+ $this->assertSame(0, (int) $this->queryValue('SELECT COUNT(*) FROM #__bfstop_unblock'), 'unblock of purged block');
+ $this->assertSame(0, (int) $this->queryValue('SELECT COUNT(*) FROM #__bfstop_unblock_token'));
+ $this->assertSame('203.0.113.78', $this->queryValue('SELECT ipaddress FROM #__bfstop_dnscache'));
+ }
+
+ public function testSaveParams()
+ {
+ $original = $this->getPluginParams();
+ try
+ {
+ $this->helper->saveParams(new Registry(array('marker' => 'saved-by-test')));
+ $this->assertStringContainsString('saved-by-test', $this->getPluginParams());
+ }
+ finally
+ {
+ $this->setPluginParams($original);
+ }
+ }
+}
diff --git a/tests/Integration/InstallTest.php b/tests/Integration/InstallTest.php
new file mode 100644
index 0000000..92458c8
--- /dev/null
+++ b/tests/Integration/InstallTest.php
@@ -0,0 +1,42 @@
+db->getPrefix();
+ $existing = array_map('strtolower', $this->db->getTableList());
+ foreach (self::Tables as $table)
+ {
+ $this->assertContains($prefix.'bfstop_'.$table, $existing);
+ }
+ }
+
+ public function testPluginEnabledAndSchemaVersionRecorded()
+ {
+ $this->db->setQuery("SELECT e.enabled, s.version_id FROM #__extensions e ".
+ "LEFT JOIN #__schemas s ON s.extension_id = e.extension_id ".
+ "WHERE e.type='plugin' AND e.element='bfstop'");
+ $row = $this->db->loadObject();
+ $this->assertSame(1, (int) $row->enabled);
+ $manifest = simplexml_load_file(dirname(__DIR__, 2).'/bfstop.xml');
+ $this->assertSame((string) $manifest->version, $row->version_id);
+ }
+}
diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php
new file mode 100644
index 0000000..8a66e23
--- /dev/null
+++ b/tests/Integration/IntegrationTestCase.php
@@ -0,0 +1,121 @@
+errors[] = $msg;
+ }
+ }
+}
+
+/**
+ * Base class for tests running against a real Joomla site with bfstop
+ * installed (see tests/README.md); skipped if JOOMLA_ROOT isn't set.
+ */
+abstract class IntegrationTestCase extends TestCase
+{
+ public const Tables = array('failedlogin', 'bannedip', 'unblock',
+ 'unblock_token', 'allowlist', 'knownip', 'dnscache', 'username_stats');
+
+ protected $db;
+ protected $logger;
+
+ public static function setUpBeforeClass(): void
+ {
+ if (!getenv('JOOMLA_ROOT'))
+ {
+ self::markTestSkipped('JOOMLA_ROOT not set - integration tests need an installed Joomla site, see tests/README.md');
+ }
+ }
+
+ protected function setUp(): void
+ {
+ $this->db = Factory::getDbo();
+ $this->logger = new RecordingLogger();
+ $this->emptyTables();
+ }
+
+ protected function emptyTables()
+ {
+ $existing = array_map('strtolower', $this->db->getTableList());
+ foreach (self::Tables as $table)
+ {
+ if (!in_array($this->db->getPrefix().'bfstop_'.$table, $existing))
+ {
+ $this->fail('Table #__bfstop_'.$table.' does not exist - the installation did not create it');
+ }
+ $this->db->setQuery('DELETE FROM #__bfstop_'.$table);
+ $this->db->execute();
+ }
+ }
+
+ protected function assertPostConditions(): void
+ {
+ $this->assertSame(array(), $this->logger->errors, 'errors were logged');
+ }
+
+ /** 'Y-m-d H:i:s' timestamp $minutes ago */
+ protected static function minutesAgo($minutes)
+ {
+ return date('Y-m-d H:i:s', time() - $minutes * 60);
+ }
+
+ /** inserts a row, returns the new id if $key is given */
+ protected function insert($table, array $row, $key = null)
+ {
+ $object = (object) $row;
+ $this->db->insertObject($table, $object, $key);
+ return $key ? (int) $object->$key : null;
+ }
+
+ protected function queryValue($sql)
+ {
+ $this->db->setQuery($sql);
+ return $this->db->loadResult();
+ }
+
+ protected function getPluginParams()
+ {
+ return $this->queryValue("SELECT params FROM #__extensions WHERE type='plugin' AND element='bfstop'");
+ }
+
+ protected function setPluginParams($params)
+ {
+ $this->db->setQuery('UPDATE #__extensions SET params='.$this->db->quote($params).
+ ", enabled=1 WHERE type='plugin' AND element='bfstop'");
+ $this->db->execute();
+ }
+}
diff --git a/tests/Integration/PluginEventsTest.php b/tests/Integration/PluginEventsTest.php
new file mode 100644
index 0000000..30eed89
--- /dev/null
+++ b/tests/Integration/PluginEventsTest.php
@@ -0,0 +1,124 @@
+ 'full',
+ 'blockNumber' => 3,
+ 'checkInterval' => 60,
+ 'blockDuration' => 60,
+ 'delayDuration' => 0,
+ 'riskDelaySecondsPerPoint' => 0,
+ 'riskBlockNumberReductionPerPoint' => 0,
+ 'accountThrottleEnabled' => 0,
+ 'notifyBlockedNumber' => 0,
+ 'logLevel' => 8, // errors only
+ );
+
+ private static $originalParams;
+
+ public static function setUpBeforeClass(): void
+ {
+ parent::setUpBeforeClass();
+ $db = Factory::getDbo();
+ $db->setQuery("SELECT params FROM #__extensions WHERE type='plugin' AND element='bfstop'");
+ self::$originalParams = $db->loadResult();
+ $db->setQuery('UPDATE #__extensions SET params='.$db->quote(json_encode(self::Params)).
+ ", enabled=1 WHERE type='plugin' AND element='bfstop'");
+ $db->execute();
+ // forget the plugin list possibly already loaded with the old params
+ (new \ReflectionProperty(PluginHelper::class, 'plugins'))->setValue(null, null);
+ $app = Factory::getApplication();
+ PluginHelper::importPlugin('system', 'bfstop', true, $app->getDispatcher());
+ }
+
+ public static function tearDownAfterClass(): void
+ {
+ if (self::$originalParams !== null)
+ {
+ $db = Factory::getDbo();
+ $db->setQuery('UPDATE #__extensions SET params='.$db->quote(self::$originalParams).
+ " WHERE type='plugin' AND element='bfstop'");
+ $db->execute();
+ }
+ }
+
+ private function dispatch($eventName, array $arguments)
+ {
+ Factory::getApplication()->getDispatcher()->dispatch($eventName, new Event($eventName, $arguments));
+ }
+
+ private function failedLogin($ip, $username)
+ {
+ $_SERVER['REMOTE_ADDR'] = $ip;
+ $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0';
+ $this->dispatch('onUserLoginFailure', array(array('username' => $username, 'status' => 4), array()));
+ }
+
+ /**
+ * Runs a request from the given IP address in a separate process, since
+ * the plugin ends a blocked request with exit(); returns its output.
+ */
+ private function requestFrom($ip)
+ {
+ $command = escapeshellarg(PHP_BINARY).' '.escapeshellarg(__DIR__.'/fixtures/request.php').' '.escapeshellarg($ip).' 2>&1';
+ exec($command, $output, $exitCode);
+ $output = implode("\n", $output);
+ $this->assertSame(0, $exitCode, $output);
+ return $output;
+ }
+
+ private function pluginLogErrors()
+ {
+ $file = Factory::getApplication()->get('log_path').'/plg_system_bfstop.log.php';
+ return is_file($file) ? substr_count(file_get_contents($file), ' ERROR ') : 0;
+ }
+
+ public function testBlocksAfterTooManyFailedLogins()
+ {
+ $logErrorsBefore = $this->pluginLogErrors();
+ $ip = '203.0.113.21';
+ $this->failedLogin($ip, 'Admin');
+ $this->failedLogin($ip, 'Admin');
+ $this->assertSame(2, (int) $this->queryValue('SELECT COUNT(*) FROM #__bfstop_failedlogin'));
+ $this->assertSame(0, (int) $this->queryValue('SELECT COUNT(*) FROM #__bfstop_bannedip'));
+ $this->failedLogin($ip, 'Admin');
+ $this->assertSame($ip, $this->queryValue('SELECT ipaddress FROM #__bfstop_bannedip'));
+
+ $this->assertStringContainsString('has been blocked', $this->requestFrom($ip));
+ $this->assertSame(1, (int) $this->queryValue('SELECT attempts FROM #__bfstop_bannedip'));
+ $this->assertStringContainsString('NOT BLOCKED', $this->requestFrom('203.0.113.22'));
+
+ $this->assertSame($logErrorsBefore, $this->pluginLogErrors(), 'plugin logged errors');
+ }
+
+ public function testSubnetBlockAndAllowList()
+ {
+ $this->insert('#__bfstop_bannedip', array('ipaddress' => '198.51.100.0/24', 'crdate' => self::minutesAgo(0), 'duration' => 0));
+ $this->insert('#__bfstop_allowlist', array('ipaddress' => '198.51.100.128/25', 'notes' => ''));
+ $this->assertStringContainsString('has been blocked', $this->requestFrom('198.51.100.1'));
+ $this->assertStringContainsString('NOT BLOCKED', $this->requestFrom('198.51.100.200'), 'allow list wins');
+ }
+
+ public function testSuccessfulLoginRecordsKnownIp()
+ {
+ $_SERVER['REMOTE_ADDR'] = '203.0.113.30';
+ $this->dispatch('onUserLogin', array(array('username' => 'Admin'), array()));
+ $this->assertSame('Admin', $this->queryValue("SELECT username FROM #__bfstop_knownip WHERE ipaddress='203.0.113.30'"));
+ }
+}
diff --git a/tests/Integration/TokenHelperTest.php b/tests/Integration/TokenHelperTest.php
new file mode 100644
index 0000000..4559cd5
--- /dev/null
+++ b/tests/Integration/TokenHelperTest.php
@@ -0,0 +1,21 @@
+logger);
+ $this->assertMatchesRegularExpression('/^[0-9a-f]{40}$/', $token);
+ $this->assertNotSame($token, TokenHelper::getToken($this->logger));
+ }
+}
diff --git a/tests/Integration/fixtures/request.php b/tests/Integration/fixtures/request.php
new file mode 100644
index 0000000..006c42f
--- /dev/null
+++ b/tests/Integration/fixtures/request.php
@@ -0,0 +1,23 @@
+getDispatcher());
+$app->getDispatcher()->dispatch('onAfterInitialise', new Event('onAfterInitialise', array()));
+echo "NOT BLOCKED\n";
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 0000000..911cf9c
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,37 @@
+# Tests
+
+- `Unit/`: tests needing nothing but PHP and [PHPUnit](https://phpunit.de) 11.
+- `Integration/`: tests against a real Joomla site with bfstop installed. They
+ run every database query of the plugin (and of the component, if
+ `COM_BFSTOP_ROOT` is set) on the site's database, and let the plugin react
+ to Joomla events as on a live site (failed logins, requests from blocked
+ addresses). Skipped if `JOOMLA_ROOT` is not set.
+
+The classes under test are always loaded from this checkout (and the
+component checkout), not from the copies installed into the Joomla site, so
+the site only needs to be set up again when the database schema changes.
+
+GitHub Actions runs everything on each push and pull request, on Joomla 5 and
+6 with MySQL, MariaDB and PostgreSQL, see `.github/workflows/ci.yml`.
+
+## Running locally
+
+Unit tests only:
+
+ phpunit -c tests/phpunit.xml.dist --testsuite unit
+
+For the integration tests, create an empty database, then let
+`tests/ci/install-joomla.sh` set up a Joomla site with bfstop in it (it
+downloads Joomla, installs it, then installs plugin and component from the
+checkouts). **Everything in that database will be overwritten.** For example,
+with PostgreSQL:
+
+ export JOOMLA_VERSION=5.4.8 JOOMLA_ROOT=/tmp/bfstop-joomla \
+ COM_BFSTOP_ROOT=/path/to/com_bfstop \
+ DB_TYPE=pgsql DB_HOST=127.0.0.1 DB_USER=postgres DB_PASS=secret DB_NAME=bfstop_test
+ tests/ci/install-joomla.sh
+ phpunit -c tests/phpunit.xml.dist
+
+`DB_TYPE` is `mysqli` for MySQL/MariaDB. The tests empty the bfstop tables
+and change the plugin's settings (restoring them afterwards), so don't point
+`JOOMLA_ROOT` at a site you care about.
diff --git a/tests/Unit/IpHelperTest.php b/tests/Unit/IpHelperTest.php
new file mode 100644
index 0000000..0a23859
--- /dev/null
+++ b/tests/Unit/IpHelperTest.php
@@ -0,0 +1,58 @@
+assertTrue(IpHelper::isInSubnet('198.51.100.200', '198.51.100.0/24'));
+ $this->assertFalse(IpHelper::isInSubnet('198.51.101.1', '198.51.100.0/24'));
+ $this->assertTrue(IpHelper::isInSubnet('203.0.113.5', '203.0.113.4/31'));
+ $this->assertFalse(IpHelper::isInSubnet('203.0.113.6', '203.0.113.4/31'));
+ $this->assertTrue(IpHelper::isInSubnet('203.0.113.5', '203.0.113.5/32'));
+ $this->assertFalse(IpHelper::isInSubnet('203.0.113.6', '203.0.113.5/32'));
+ $this->assertTrue(IpHelper::isInSubnet('192.0.2.1', '0.0.0.0/0'));
+ // subnet address not aligned to the prefix length
+ $this->assertTrue(IpHelper::isInSubnet('198.51.100.7', '198.51.100.99/24'));
+ }
+
+ public function testIPv6Subnets()
+ {
+ $this->assertTrue(IpHelper::isInSubnet('2001:db8:ab:1::5', '2001:DB8:AB::/48'));
+ $this->assertFalse(IpHelper::isInSubnet('2001:db8:ac::1', '2001:db8:ab::/48'));
+ $this->assertTrue(IpHelper::isInSubnet('2001:db8::1', '2001:db8::/32'));
+ $this->assertTrue(IpHelper::isInSubnet('2001:db8::1', '2001:db8::1/128'));
+ $this->assertFalse(IpHelper::isInSubnet('2001:db8::2', '2001:db8::1/128'));
+ $this->assertTrue(IpHelper::isInSubnet('2001:db8:0:7fff::1', '2001:db8::/49'));
+ $this->assertFalse(IpHelper::isInSubnet('2001:db8:0:8000::1', '2001:db8::/49'));
+ }
+
+ public function testMalformedNeverMatches()
+ {
+ // prefix length out of range, not a number, missing, or too long
+ $this->assertFalse(IpHelper::isInSubnet('192.0.2.1', '192.0.2.0/33'));
+ $this->assertFalse(IpHelper::isInSubnet('192.0.2.1', '192.0.2.0/99'));
+ $this->assertFalse(IpHelper::isInSubnet('192.0.2.1', '192.0.2.0/abc'));
+ $this->assertFalse(IpHelper::isInSubnet('192.0.2.1', '192.0.2.0/'));
+ $this->assertFalse(IpHelper::isInSubnet('192.0.2.1', '192.0.2.0/-1'));
+ $this->assertFalse(IpHelper::isInSubnet('192.0.2.1', '192.0.2.0/0024'));
+ $this->assertFalse(IpHelper::isInSubnet('2001:db8::1', '2001:db8::/129'));
+ $this->assertFalse(IpHelper::isInSubnet('192.0.2.1', '192.0.2.0'));
+ $this->assertFalse(IpHelper::isInSubnet('192.0.2.1', '192.0.2.0/24/8'));
+ // unparseable addresses
+ $this->assertFalse(IpHelper::isInSubnet('192.0.2.1', 'foo/24'));
+ $this->assertFalse(IpHelper::isInSubnet('', '192.0.2.0/24'));
+ // IPv4 vs. IPv6 never match each other
+ $this->assertFalse(IpHelper::isInSubnet('192.0.2.1', '::/0'));
+ $this->assertFalse(IpHelper::isInSubnet('2001:db8::1', '0.0.0.0/0'));
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..022ae23
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,78 @@
+alias($alias, 'session.cli');
+ }
+ $app = $container->get(SiteApplication::class);
+ Factory::$application = $app;
+ $app->createExtensionNamespaceMap();
+ // as in a real request, the language is loaded before any plugin (plugins
+ // only load their own language strings if there already is one)
+ $app->loadLanguage();
+}
+
+$sourceNamespaces = array(
+ 'Codeling\\Plugin\\System\\Bfstop\\' => dirname(__DIR__).'/src/',
+);
+$comRoot = getenv('COM_BFSTOP_ROOT');
+if ($comRoot)
+{
+ $comRoot = realpath($comRoot);
+ $sourceNamespaces['Codeling\\Component\\Bfstop\\Administrator\\'] = $comRoot.'/admin/src/';
+ $sourceNamespaces['Codeling\\Component\\Bfstop\\Site\\'] = $comRoot.'/site/src/';
+}
+$sourceNamespaces['Codeling\\Bfstop\\Tests\\'] = __DIR__.'/';
+spl_autoload_register(function ($class) use ($sourceNamespaces)
+{
+ foreach ($sourceNamespaces as $prefix => $dir)
+ {
+ if (strncmp($class, $prefix, strlen($prefix)) === 0)
+ {
+ $file = $dir.str_replace('\\', '/', substr($class, strlen($prefix))).'.php';
+ if (is_file($file))
+ {
+ require $file;
+ return;
+ }
+ }
+ }
+}, true, true);
diff --git a/tests/ci/install-joomla.sh b/tests/ci/install-joomla.sh
new file mode 100755
index 0000000..30d30f4
--- /dev/null
+++ b/tests/ci/install-joomla.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+#
+# Sets up a Joomla site with bfstop installed, for the integration tests:
+# downloads the Joomla full package (unless $JOOMLA_ROOT already contains a
+# not yet installed Joomla), runs Joomla's CLI installer against the given
+# database, and installs the plugin (and, if $COM_BFSTOP_ROOT is set, the
+# component) from the source checkouts through Joomla's extension installer.
+#
+# Environment:
+# JOOMLA_VERSION Joomla version to download, e.g. 5.4.8
+# JOOMLA_ROOT directory to set up the site in
+# DB_TYPE mysqli or pgsql
+# DB_HOST, DB_USER, DB_PASS, DB_NAME database connection (database must exist)
+# COM_BFSTOP_ROOT optional: checkout of https://github.com/codeling/com_bfstop
+set -euo pipefail
+
+: "${JOOMLA_ROOT:?}" "${DB_TYPE:?}" "${DB_HOST:?}" "${DB_USER:?}" "${DB_NAME:?}"
+plugin_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+build_dir="$(mktemp -d)"
+
+if [ ! -f "$JOOMLA_ROOT/index.php" ]; then
+ : "${JOOMLA_VERSION:?}"
+ echo "Downloading Joomla $JOOMLA_VERSION"
+ mkdir -p "$JOOMLA_ROOT"
+ curl -fsSL "https://github.com/joomla/joomla-cms/releases/download/$JOOMLA_VERSION/Joomla_$JOOMLA_VERSION-Stable-Full_Package.tar.gz" \
+ | tar -xz -C "$JOOMLA_ROOT"
+fi
+
+echo "Installing Joomla ($DB_TYPE on $DB_HOST)"
+# the admin username deliberately differs from "admin" only in case, see
+# ComponentTest::testWarnsAboutAdminUserCaseInsensitively
+php "$JOOMLA_ROOT/installation/joomla.php" install -n \
+ --site-name="bfstop tests" \
+ --admin-user="Test Admin" --admin-username=Admin \
+ --admin-password=bfstop-test-password --admin-email=admin@example.org \
+ --db-type="$DB_TYPE" --db-host="$DB_HOST" --db-user="$DB_USER" \
+ --db-pass="${DB_PASS:-}" --db-name="$DB_NAME" --db-prefix=jos_ \
+ --db-encryption=0 --public-folder=""
+
+install_extension() {
+ local src="$1" zip="$build_dir/$2.zip"
+ (cd "$src" && zip -qr "$zip" . -x '.git/*' '.github/*' 'tests/*')
+ echo "Installing $2"
+ php "$JOOMLA_ROOT/cli/joomla.php" extension:install --path="$zip" -n
+}
+
+install_extension "$plugin_root" plg_system_bfstop
+if [ -n "${COM_BFSTOP_ROOT:-}" ]; then
+ install_extension "$COM_BFSTOP_ROOT" com_bfstop
+fi
+rm -rf "$build_dir"
diff --git a/tests/ci/start-database.sh b/tests/ci/start-database.sh
new file mode 100755
index 0000000..918f54f
--- /dev/null
+++ b/tests/ci/start-database.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+#
+# Starts a database server in a docker container named "db" (for CI) and
+# waits until it accepts connections. Writes DB_TYPE and DB_USER for
+# install-joomla.sh to $GITHUB_ENV (if set).
+#
+# Environment:
+# DB_IMAGE docker image, e.g. mysql:8.4, mariadb:11.4 or postgres:17
+# DB_HOST, DB_PASS, DB_NAME
+set -euo pipefail
+
+: "${DB_IMAGE:?}" "${DB_HOST:?}" "${DB_PASS:?}" "${DB_NAME:?}"
+
+case "$DB_IMAGE" in
+ postgres:*)
+ docker run -d --name db -p 5432:5432 -e POSTGRES_PASSWORD="$DB_PASS" -e POSTGRES_DB="$DB_NAME" "$DB_IMAGE"
+ db_type=pgsql
+ db_user=postgres
+ ready() { PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -U postgres -d "$DB_NAME" -c 'SELECT 1' > /dev/null 2>&1; }
+ ;;
+ mysql:*|mariadb:*)
+ docker run -d --name db -p 3306:3306 -e MYSQL_ROOT_PASSWORD="$DB_PASS" -e MYSQL_DATABASE="$DB_NAME" "$DB_IMAGE"
+ db_type=mysqli
+ db_user=root
+ ready() { mysql -h "$DB_HOST" -uroot -p"$DB_PASS" -e 'SELECT 1' "$DB_NAME" > /dev/null 2>&1; }
+ ;;
+ *)
+ echo "Unsupported database image: $DB_IMAGE" >&2
+ exit 1
+ ;;
+esac
+
+if [ -n "${GITHUB_ENV:-}" ]; then
+ echo "DB_TYPE=$db_type" >> "$GITHUB_ENV"
+ echo "DB_USER=$db_user" >> "$GITHUB_ENV"
+fi
+
+for _ in $(seq 90); do
+ if ready; then
+ echo "Database ready (DB_TYPE=$db_type, DB_USER=$db_user)"
+ exit 0
+ fi
+ sleep 2
+done
+docker logs db
+exit 1
diff --git a/tests/phpunit.xml.dist b/tests/phpunit.xml.dist
new file mode 100644
index 0000000..5752431
--- /dev/null
+++ b/tests/phpunit.xml.dist
@@ -0,0 +1,19 @@
+
+
+
+
+ Unit
+
+
+ Integration
+
+
+
diff --git a/unittests/cryptotest.php b/unittests/cryptotest.php
deleted file mode 100644
index 54813d7..0000000
--- a/unittests/cryptotest.php
+++ /dev/null
@@ -1,56 +0,0 @@
-message = $msg;
- $logMsg->level = $lvl;
- $this->logMessages[] = $logMsg;
- }
-}
-
-class BFStopTokenGeneratorTest extends PHPUnit_Framework_TestCase
-{
- public function testGenerate() {
- $testlogger = new TestLogger;
- $token = BFStopTokenGenerator::getToken($testlogger);
- printf("Generated Token: %s", $token);
- $this->assertEquals(strlen($token), 40);
- $this->assertTrue(ctype_xdigit($token));
-
- if (function_exists('openssl_random_pseudo_bytes') ||
- (function_exists('mcrypt_create_iv') &&
- (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN' ||
- version_compare(phpversion(), '5.3.7') > 0) ) ) {
- $this->assertEquals(sizeof($testlogger->logMessages), 1);
- $this->assertEquals($testlogger->logMessages[0]->level, Log::VERBOSE);
- } else {
- $this->assertEquals(sizeof($testlogger->logMessages), 1);
- $this->assertEquals($testlogger->logMessages[0]->level, Log::WARNING);
- }
- }
-}
diff --git a/updatescript.php b/updatescript.php
index 6d7d37c..114cf93 100644
--- a/updatescript.php
+++ b/updatescript.php
@@ -51,21 +51,25 @@ public function postflight($type, InstallerAdapter $adapter)
public function update(InstallerAdapter $adapter)
{
// for version 1.4.2, whitelist was renamed to allowlist, but only for updates;
- // for new installs, the old name remained, so let's fix this for all installations:
+ // for new installs, the old name remained, so let's fix this for all installations
+ // (MySQL only - PostgreSQL is supported from 2.0.0 on, so no such table exists there):
$db = Factory::getDbo();
- try
+ if ($db->getServerType() === 'mysql')
{
- $sql = "SELECT COUNT(*) FROM `#__bfstop_whitelist`";
- $db->setQuery($sql);
- $numEntries = ((int)$db->loadResult());
- $sql = "RENAME TABLE `#__bfstop_whitelist` TO `#__bfstop_allowlist`";
- $db->setQuery($sql);
- $db->execute();
- }
- catch (Exception $e)
- {
- // if table doesn't exist, there's nothing we need to do
-// Log::add("Update ERROR: ".$e->getMessage(), Log::ERROR, 'Update');
+ try
+ {
+ $sql = "SELECT COUNT(*) FROM `#__bfstop_whitelist`";
+ $db->setQuery($sql);
+ $numEntries = ((int)$db->loadResult());
+ $sql = "RENAME TABLE `#__bfstop_whitelist` TO `#__bfstop_allowlist`";
+ $db->setQuery($sql);
+ $db->execute();
+ }
+ catch (Exception $e)
+ {
+ // if table doesn't exist, there's nothing we need to do
+// Log::add("Update ERROR: ".$e->getMessage(), Log::ERROR, 'Update');
+ }
}
// for 2.0.0, the previously separate blockEnabled/useHtaccess switches