diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 32ace3f8a..76b2f5abb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -71,6 +71,8 @@ jobs:
[ -n "$integrationtestdb_container" ] || { echo "integrationtestdb container ID not found"; exit 1; }
timeout 180s bash -c "until [ \"\$(docker inspect --format '{{.State.Health.Status}}' \"${integrationtestdb_container}\" 2>/dev/null)\" = \"healthy\" ]; do sleep 3; done"
+ docker compose -f docker-compose-test.yml exec -T --workdir /var/www/public php php test/scripts/finalizeInstallSchemaVersion.php
+
# 2. Pre-clean the integration database
docker compose -f docker-compose-test.yml exec -T integrationtestdb mysql -udev -pdev -e "DROP DATABASE IF EXISTS cats_integrationtest; CREATE DATABASE cats_integrationtest;"
diff --git a/ajax.php b/ajax.php
index 6908707c0..6058aeab5 100644
--- a/ajax.php
+++ b/ajax.php
@@ -41,6 +41,7 @@
include_once(LEGACY_ROOT . '/lib/Session.php'); /* Depends: MRU, Users, DatabaseConnection. */
include_once(LEGACY_ROOT . '/lib/AJAXInterface.php');
include_once(LEGACY_ROOT . '/lib/CATSUtility.php');
+include_once(LEGACY_ROOT . '/lib/SchemaMigrationStatus.php');
header('Last-Modified: ' . gmdate('D, d M Y H:i:s') . ' GMT');
@@ -117,6 +118,8 @@
}
}
+$module = '';
+
if (strpos($_REQUEST['f'], ':') === false)
{
$function = preg_replace("/[^A-Za-z0-9]/", "", $_REQUEST['f']);
@@ -134,6 +137,30 @@
$filename = sprintf('modules/%s/ajax/%s.php', $module, $function);
}
+/* Fresh installer AJAX remains available. On installed systems, only the
+ * existing maintenance action may pass the pending-migration AJAX gate.
+ */
+$maintenanceAJAXAllowed =
+ $installerActive ||
+ ($module === 'install' && $function === 'maint');
+
+if (isset($_SESSION['CATS']) &&
+ $_SESSION['CATS']->isLoggedIn() &&
+ !$maintenanceAJAXAllowed &&
+ SchemaMigrationStatus::hasPendingInstallMigrations())
+{
+ header('Content-type: text/xml; charset=' . AJAX_ENCODING);
+ echo '', "\n";
+ echo(
+ "\n" .
+ " -1\n" .
+ " Database maintenance is required.\n" .
+ "\n"
+ );
+
+ die();
+}
+
if (!is_readable($filename))
{
header('Content-type: text/xml; charset=' . AJAX_ENCODING);
diff --git a/index.php b/index.php
index fe76ea812..652bfa0b0 100644
--- a/index.php
+++ b/index.php
@@ -162,10 +162,46 @@
}
}
+$isPublicRequest =
+ (isset($careerPage) && $careerPage) ||
+ (isset($_GET['showCareerPortal']) && $_GET['showCareerPortal'] == '1') ||
+ (isset($rssPage) && $rssPage) ||
+ (isset($xmlPage) && $xmlPage);
+$isMigrationGateExcluded =
+ (isset($_GET['m']) && $_GET['m'] === 'install' &&
+ isset($_GET['a']) && $_GET['a'] === 'maint') ||
+ (isset($_GET['m']) && ($_GET['m'] === 'login' || $_GET['m'] === 'logout'));
+
+if ($_SESSION['CATS']->isLoggedIn() &&
+ !$isPublicRequest &&
+ !$isMigrationGateExcluded &&
+ SchemaMigrationStatus::hasPendingInstallMigrations())
+{
+ $template = new Template();
+ $template->assign(
+ 'isAdministrator',
+ $_SESSION['CATS']->getAccessLevel(ACL::SECOBJ_ROOT) >= ACCESS_LEVEL_SA
+ );
+ $template->display('./modules/login/PendingMigrations.tpl');
+ die();
+}
+
/* Check to see if we are supposed to display the career page. */
if (((isset($careerPage) && $careerPage) ||
(isset($_GET['showCareerPortal']) && $_GET['showCareerPortal'] == '1')))
{
+ if (SchemaMigrationStatus::hasPendingInstallMigrations())
+ {
+ header('HTTP/1.1 503 Service Unavailable');
+ header('Content-Type: text/html; charset=UTF-8');
+
+ echo '',
+ '
Career Portal Maintenance',
+ 'The career portal is temporarily unavailable while system maintenance is in progress. Please try again later.
',
+ '';
+ die();
+ }
+
ModuleUtility::loadModule('careers');
}
diff --git a/js/install.js b/js/install.js
index 1cbd2cf02..448a45043 100755
--- a/js/install.js
+++ b/js/install.js
@@ -28,6 +28,7 @@
var response;
var maxSteps;
+var maintenanceOnly = false;
function setActiveStep(step)
@@ -119,9 +120,30 @@ function Installpage_maint()
response = http.responseText;
+ if (maintenanceOnly &&
+ (http.status < 200 ||
+ http.status >= 300 ||
+ AJAX_isPHPError(response) ||
+ response.indexOf("-1") != -1 ||
+ response.indexOf("Query Error") != -1 ||
+ response.indexOf("Access denied.") != -1))
+ {
+ document.getElementById("maintenanceProgress").style.display = "none";
+ document.getElementById("maintenanceError").style.display = "";
+ document.getElementById("startMaintenance").disabled = false;
+ return;
+ }
+
if (response.indexOf("setProgressUpdating") == -1)
- {
- Installpage_populate("a=reindexResumes");
+ {
+ if (maintenanceOnly)
+ {
+ window.location = "index.php";
+ }
+ else
+ {
+ Installpage_populate("a=reindexResumes");
+ }
}
else
{
diff --git a/lib/ModuleUtility.php b/lib/ModuleUtility.php
index d35c42c0e..81720030e 100755
--- a/lib/ModuleUtility.php
+++ b/lib/ModuleUtility.php
@@ -35,6 +35,8 @@
* @package CATS
* @subpackage Library
*/
+include_once(LEGACY_ROOT . '/lib/SchemaMigrationStatus.php');
+
class ModuleUtility
{
/* Prevent this class from being instantiated. */
@@ -441,6 +443,14 @@ public static function getModuleSchemaVersions()
*/
private static function processModuleSchema($moduleName, $schema)
{
+ global $maintPage;
+
+ if ($moduleName === 'install' &&
+ (!isset($maintPage) || $maintPage !== true))
+ {
+ return;
+ }
+
if( ini_get('safe_mode') )
{
//don't do anything in safe mode
@@ -501,7 +511,11 @@ private static function processModuleSchema($moduleName, $schema)
if ($moduleName === 'install' && ($currentVersion === NULL || $currentVersion === ''))
{
- /* A NULL install module version means the database came from cats_schema.sql and should not replay historical install migrations. */
+ /* This explicit installer/maintenance finalization is only for
+ * snapshot databases whose schema already matches the bundled
+ * baseline. It must not run during normal requests and is not
+ * proof that an unknown historical database state is current.
+ */
$sql = sprintf(
"UPDATE
module_schema
@@ -514,6 +528,7 @@ private static function processModuleSchema($moduleName, $schema)
);
$db->query($sql);
+ SchemaMigrationStatus::clearCache();
return;
}
@@ -532,7 +547,6 @@ private static function processModuleSchema($moduleName, $schema)
}
/* if maintPage, execute 1 query, output the next query and progress, and terminate. */
- global $maintPage;
if ((isset($maintPage) && $maintPage === true))
{
if ($executedQuery == false)
@@ -586,6 +600,11 @@ private static function processModuleSchema($moduleName, $schema)
$rs = $db->query($sql);
$currentVersion = $version;
+
+ if ($moduleName === 'install')
+ {
+ SchemaMigrationStatus::clearCache();
+ }
}
}
}
diff --git a/lib/SchemaMigrationStatus.php b/lib/SchemaMigrationStatus.php
new file mode 100644
index 000000000..3ceb9992e
--- /dev/null
+++ b/lib/SchemaMigrationStatus.php
@@ -0,0 +1,128 @@
+makeQueryString('install')
+ );
+ $rs = $db->getAssoc($sql);
+
+ if (empty($rs))
+ {
+ self::$_storedInstallSchemaVersion = false;
+ }
+ else
+ {
+ self::$_storedInstallSchemaVersion = $rs['version'];
+ }
+
+ self::$_storedInstallSchemaVersionLoaded = true;
+
+ return self::$_storedInstallSchemaVersion;
+ }
+
+ /**
+ * Returns whether install schema migrations are pending.
+ *
+ * @return boolean
+ */
+ public static function hasPendingInstallMigrations()
+ {
+ if (self::$_hasPendingInstallMigrations !== NULL)
+ {
+ return self::$_hasPendingInstallMigrations;
+ }
+
+ $storedVersion = self::getStoredInstallSchemaVersion();
+
+ if ($storedVersion === false ||
+ $storedVersion === NULL ||
+ $storedVersion === '')
+ {
+ /* Unknown versions require explicit install or maintenance finalization. */
+ self::$_hasPendingInstallMigrations = true;
+ return true;
+ }
+
+ self::$_hasPendingInstallMigrations =
+ (int) $storedVersion < self::getLatestInstallSchemaVersion();
+
+ return self::$_hasPendingInstallMigrations;
+ }
+
+ /**
+ * Clears cached migration status after explicit maintenance updates.
+ *
+ * @return void
+ */
+ public static function clearCache()
+ {
+ self::$_latestInstallSchemaVersion = NULL;
+ self::$_storedInstallSchemaVersion = NULL;
+ self::$_storedInstallSchemaVersionLoaded = false;
+ self::$_hasPendingInstallMigrations = NULL;
+ }
+}
+
+?>
diff --git a/modules/install/CATSUI.php b/modules/install/CATSUI.php
index a1fdd2380..c2e264d1d 100755
--- a/modules/install/CATSUI.php
+++ b/modules/install/CATSUI.php
@@ -41,6 +41,34 @@ public function __construct()
public function handleRequest()
{
+ if ($this->getAction() !== 'maint')
+ {
+ return;
+ }
+
+ if (!isset($_SESSION['CATS']) || !$_SESSION['CATS']->isLoggedIn())
+ {
+ CATSUtility::transferRelativeURI('m=login');
+ die();
+ }
+
+ if ($_SESSION['CATS']->getAccessLevel(ACL::SECOBJ_ROOT) < ACCESS_LEVEL_SA)
+ {
+ header('HTTP/1.1 403 Forbidden');
+ CommonErrors::fatal(COMMONERROR_PERMISSION, $this);
+ }
+
+ if (!SchemaMigrationStatus::hasPendingInstallMigrations())
+ {
+ CATSUtility::transferRelativeURI('');
+ die();
+ }
+
+ $this->_template->assign(
+ 'csrfToken',
+ $_SESSION['CATS']->getCSRFToken()
+ );
+ $this->_template->display('./modules/install/Maintenance.tpl');
}
}
diff --git a/modules/install/Maintenance.tpl b/modules/install/Maintenance.tpl
new file mode 100644
index 000000000..e157b666f
--- /dev/null
+++ b/modules/install/Maintenance.tpl
@@ -0,0 +1,45 @@
+
+
+
+
+ OpenCATS - Database Maintenance
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Database Maintenance
+
Database migrations are pending. OpenCATS should be updated before normal use continues.
+
+
Maintenance could not be completed. Please reload the page and try again.
+
+
+
+
+
+
SQL Query Being Executed:
+
+
+
+
+
+
+
+
+
diff --git a/modules/install/ajax/maint.php b/modules/install/ajax/maint.php
index 3a3fba924..374a4119e 100755
--- a/modules/install/ajax/maint.php
+++ b/modules/install/ajax/maint.php
@@ -29,27 +29,47 @@
if ($_SERVER['REQUEST_METHOD'] !== 'POST')
{
- header('Content-Type: text/html; charset=UTF-8');
-
- $actionURL = htmlspecialchars($_SERVER['PHP_SELF'], ENT_QUOTES, 'UTF-8');
-
- echo '',
- 'OpenCATS Maintenance',
- 'This maintenance action must be triggered via POST.
',
- 'This page starts maintenance mode and related installer tasks.
',
- '',
- '';
+ header('HTTP/1.1 405 Method Not Allowed');
+ header('Allow: POST');
die();
}
+$installerActive = !file_exists('INSTALL_BLOCK');
+
+if (!$installerActive)
+{
+ /* Fresh installation uses the installer's existing access model. An
+ * installed system requires an authenticated site admin and CSRF token.
+ */
+ include_once('./config.php');
+ include_once(LEGACY_ROOT . '/constants.php');
+ include_once(LEGACY_ROOT . '/lib/DatabaseConnection.php');
+ include_once(LEGACY_ROOT . '/lib/Session.php');
+
+ @session_name(CATS_SESSION_NAME);
+ @session_start();
+
+ if (!isset($_SESSION['CATS']) ||
+ !$_SESSION['CATS']->isLoggedIn() ||
+ $_SESSION['CATS']->getAccessLevel(ACL::SECOBJ_ROOT) < ACCESS_LEVEL_SA ||
+ !isset($_POST['csrfToken']) ||
+ !$_SESSION['CATS']->isCSRFTokenValid($_POST['csrfToken']))
+ {
+ header('HTTP/1.1 403 Forbidden');
+ die('Access denied.');
+ }
+}
+
if (file_exists('./modules.cache'))
{
@unlink('./modules.cache');
}
+if (isset($_SESSION['modules']))
+{
+ unset($_SESSION['modules']);
+}
+
$maintPage = true;
include_once('index.php');
diff --git a/modules/login/PendingMigrations.tpl b/modules/login/PendingMigrations.tpl
new file mode 100644
index 000000000..21f4bf04a
--- /dev/null
+++ b/modules/login/PendingMigrations.tpl
@@ -0,0 +1,41 @@
+
+
+
+
+ OpenCATS - Maintenance Required
+
+
+
+
+
+
+
+
+
+
+
Maintenance Required
+
+ isAdministrator): ?>
+
Database migrations are pending. OpenCATS should be updated before normal use continues.
+
Start Maintenance
+
+
Database maintenance is required before OpenCATS can be used normally. Please contact your administrator.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/runAllTests.sh b/test/runAllTests.sh
index 81660524c..1df454a9a 100755
--- a/test/runAllTests.sh
+++ b/test/runAllTests.sh
@@ -2,6 +2,7 @@
cd /var/www/public/
dockerize -wait tcp://opencats_test_mariadb:3306 -wait http://opencats_test_web:80 -timeout 30s
php test/scripts/waitForDb.php
+php test/scripts/finalizeInstallSchemaVersion.php
cat config.php
./vendor/bin/phpunit --testsuite IntegrationTests
./vendor/bin/behat -v -c ./test/behat.yml --suite="default"
diff --git a/test/scripts/finalizeInstallSchemaVersion.php b/test/scripts/finalizeInstallSchemaVersion.php
new file mode 100644
index 000000000..be90a6a7c
--- /dev/null
+++ b/test/scripts/finalizeInstallSchemaVersion.php
@@ -0,0 +1,41 @@
+query(sprintf(
+ "UPDATE
+ module_schema
+ SET
+ version = %d
+ WHERE
+ name = %s",
+ $latestVersion,
+ $db->makeQueryString('install')
+));
+
+if ($result === false)
+{
+ throw new RuntimeException('Unable to finalize the install schema version.');
+}
+
+SchemaMigrationStatus::clearCache();
+if ((int) SchemaMigrationStatus::getStoredInstallSchemaVersion() !== $latestVersion)
+{
+ throw new RuntimeException('The install schema version was not finalized.');
+}
+
+?>