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.

+

+ + + + + +
+
+
+ + 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.'); +} + +?>