Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/ChurchCRM/Service/UpgradeAPIService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

namespace ChurchCRM\Service;

use ChurchCRM\Utils\ChurchCRMReleaseManager;

/**
* UpgradeAPIService
*
* Provides API operations for system upgrade and update checking.
* This service wraps ChurchCRMReleaseManager operations for admin API routes.
* Admin authentication is enforced by AdminRoleAuthMiddleware at the application level.
*/
class UpgradeAPIService
{
/**
* Download the latest release from GitHub
*
* @return array Upgrade file information (fileName, fullPath, releaseNotes, sha1)
* @throws \Exception
*/
public static function downloadLatestRelease(): array
{
return ChurchCRMReleaseManager::downloadLatestRelease();
}

/**
* Apply the upgrade with the given file and SHA1 hash
*
* @param string $fullPath Full path to upgrade file
* @param string $sha1 SHA1 hash for verification
* @return void
* @throws \Exception
*/
public static function doUpgrade(string $fullPath, string $sha1): void
{
ChurchCRMReleaseManager::doUpgrade($fullPath, $sha1);
}

/**
* Refresh upgrade information from GitHub and update session state
*
* @return array Session update data (updateAvailable, updateVersion, latestVersion)
* @throws \Exception
*/
public static function refreshUpgradeInfo(): array
{
// Force fresh check from GitHub
ChurchCRMReleaseManager::checkForUpdates();

// Recompute whether an update is available
$updateInfo = ChurchCRMReleaseManager::checkSystemUpdateAvailable();
$_SESSION['systemUpdateAvailable'] = $updateInfo['available'];
$_SESSION['systemUpdateVersion'] = $updateInfo['version'];
$_SESSION['systemLatestVersion'] = $updateInfo['latestVersion'];

// Return updated session data
return [
'updateAvailable' => $_SESSION['systemUpdateAvailable'] ?? false,
'updateVersion' => isset($_SESSION['systemUpdateVersion']) ? $_SESSION['systemUpdateVersion']->__toString() : null,
'latestVersion' => isset($_SESSION['systemLatestVersion']) ? $_SESSION['systemLatestVersion']->__toString() : null
];
}
}
41 changes: 34 additions & 7 deletions src/ChurchCRM/dto/ChurchCRMReleaseManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,25 @@ public static function checkForUpdates(): void
public static function isReleaseCurrent(ChurchCRMRelease $Release): bool
{
if (empty($_SESSION['ChurchCRMReleases'])) {
// If we don't have cached releases, populate them first
$_SESSION['ChurchCRMReleases'] = self::populateReleases();
}

if (empty($_SESSION['ChurchCRMReleases'])) {
// If still empty (no releases found), assume current
return true;
} else {
$CurrentRelease = $_SESSION['ChurchCRMReleases'][0];
$isEqual = $CurrentRelease->equals($Release);

return $isEqual;
}

$CurrentRelease = $_SESSION['ChurchCRMReleases'][0];
$isEqual = $CurrentRelease->equals($Release);

LoggerUtils::getAppLogger()->debug('isReleaseCurrent comparison: Release=' . $Release->__toString()
. ' (MAJOR=' . $Release->MAJOR . ' MINOR=' . $Release->MINOR . ' PATCH=' . $Release->PATCH . ')'
. ' vs CurrentRelease=' . $CurrentRelease->__toString()
. ' (MAJOR=' . $CurrentRelease->MAJOR . ' MINOR=' . $CurrentRelease->MINOR . ' PATCH=' . $CurrentRelease->PATCH . ')'
. ' equals=' . ($isEqual ? 'true' : 'false'));

return $isEqual;
}

private static function getHighestReleaseInArray(array $eligibleUpgradeTargetReleases)
Expand Down Expand Up @@ -358,7 +370,9 @@ public static function checkSystemUpdateAvailable(): array
$installedVersion = self::getReleaseFromString($installedVersionString);

if (empty($_SESSION['ChurchCRMReleases'])) {
$_SESSION['ChurchCRMReleases'] = self::populateReleases();
$releases = self::populateReleases();
$_SESSION['ChurchCRMReleases'] = $releases;
$logger->debug('Populated releases cache with ' . count($releases) . ' releases');
}

// Get the latest release from GitHub
Expand All @@ -367,21 +381,34 @@ public static function checkSystemUpdateAvailable(): array
$latestRelease = $_SESSION['ChurchCRMReleases'][0] ?? null;
}

if ($latestRelease === null) {
$logger->warning('No releases available from GitHub cache');
}

$logger->debug('Update check: installed=' . $installedVersion->__toString()
. ' (MAJOR=' . $installedVersion->MAJOR . ' MINOR=' . $installedVersion->MINOR . ' PATCH=' . $installedVersion->PATCH . ')'
. ', latest=' . ($latestRelease ? $latestRelease->__toString() : 'null')
. ($latestRelease ? ' (MAJOR=' . $latestRelease->MAJOR . ' MINOR=' . $latestRelease->MINOR . ' PATCH=' . $latestRelease->PATCH . ')' : ''));

$isCurrent = self::isReleaseCurrent($installedVersion);
$logger->debug('isCurrent=' . ($isCurrent ? 'true' : 'false'));

if (!$isCurrent) {
$nextRelease = self::getNextReleaseStep($installedVersion);
if (null !== $nextRelease) {
$logger->info('System update available', [
'currentVersion' => $installedVersionString,
'availableVersion' => $nextRelease->__toString()
'availableVersion' => $nextRelease->__toString(),
'latestVersion' => $latestRelease ? $latestRelease->__toString() : null
]);
return [
'available' => true,
'version' => $nextRelease,
'latestVersion' => $latestRelease
];
}
} else {
$logger->debug('System is current - no update needed. latestVersion=' . ($latestRelease ? $latestRelease->__toString() : 'null'));
}

return [
Expand Down
1 change: 1 addition & 0 deletions src/admin/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
require __DIR__ . '/routes/api/demo.php';
require __DIR__ . '/routes/api/database.php';
require __DIR__ . '/routes/api/orphaned-files.php';
require __DIR__ . '/routes/api/upgrade.php';
require __DIR__ . '/routes/system.php';

// Body parsing and routing middleware
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
<?php

use ChurchCRM\Slim\Middleware\Request\Auth\AdminRoleAuthMiddleware;
use ChurchCRM\Service\UpgradeAPIService;
use ChurchCRM\Slim\SlimUtils;
use ChurchCRM\Utils\ChurchCRMReleaseManager;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Routing\RouteCollectorProxy;

$app->group('/systemupgrade', function (RouteCollectorProxy $group): void {
$app->group('/api/upgrade', function (RouteCollectorProxy $group): void {
/**
* Download the latest release from GitHub
* GET /admin/api/upgrade/download-latest-release
*
* @return 200 Success with file info (fileName, fullPath, releaseNotes, sha1)
* @return 400 Error downloading file
*/
$group->get('/download-latest-release', function (Request $request, Response $response, array $args): Response {
try {
$upgradeFile = ChurchCRMReleaseManager::downloadLatestRelease();
$upgradeFile = UpgradeAPIService::downloadLatestRelease();
return SlimUtils::renderJSON($response, $upgradeFile);
} catch (\Exception $e) {
return SlimUtils::renderJSON($response, [
Expand All @@ -19,10 +25,21 @@
}
});

/**
* Apply the system upgrade
* POST /admin/api/upgrade/do-upgrade
*
* Request body:
* - fullPath (string): Full path to upgrade file
* - sha1 (string): SHA1 hash for verification
*
* @return 200 Success with empty data
* @return 500 Error applying upgrade
*/
$group->post('/do-upgrade', function (Request $request, Response $response, array $args): Response {
try {
$input = $request->getParsedBody();
ChurchCRMReleaseManager::doUpgrade($input['fullPath'], $input['sha1']);
UpgradeAPIService::doUpgrade($input['fullPath'], $input['sha1']);
return SlimUtils::renderSuccessJSON($response);
} catch (\Exception $e) {
return SlimUtils::renderJSON($response, [
Expand All @@ -31,17 +48,21 @@
}
});

/**
* Refresh upgrade information from GitHub
* POST /admin/api/upgrade/refresh-upgrade-info
*
* Forces a fresh check of available updates from GitHub and updates session state.
*
* @return 200 Success with updated session data
* @return 500 Error refreshing information
*/
$group->post('/refresh-upgrade-info', function (Request $request, Response $response, array $args): Response {
try {
// Force refresh of upgrade information from GitHub
ChurchCRMReleaseManager::checkForUpdates();

// Return fresh session data
$updateData = UpgradeAPIService::refreshUpgradeInfo();

return SlimUtils::renderJSON($response, [
'data' => [
'updateAvailable' => $_SESSION['systemUpdateAvailable'] ?? false,
'updateVersion' => isset($_SESSION['systemUpdateVersion']) ? $_SESSION['systemUpdateVersion']->__toString() : null
],
'data' => $updateData,
'message' => gettext('Upgrade information refreshed successfully')
]);
} catch (\Exception $e) {
Expand All @@ -50,4 +71,4 @@
], 500);
}
});
})->add(AdminRoleAuthMiddleware::class);
});
10 changes: 10 additions & 0 deletions src/admin/routes/system.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use ChurchCRM\model\ChurchCRM\PersonQuery;
use ChurchCRM\Service\AppIntegrityService;
use ChurchCRM\Service\TaskService;
use ChurchCRM\Utils\ChurchCRMReleaseManager;
use ChurchCRM\Utils\VersionUtils;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
Expand Down Expand Up @@ -147,6 +148,15 @@
$group->get('/upgrade', function (Request $request, Response $response): Response {
$renderer = new PhpRenderer(__DIR__ . '/../views/');

// Ensure we have fresh release information
ChurchCRMReleaseManager::checkForUpdates();

// Recompute update availability with fresh data
$updateInfo = ChurchCRMReleaseManager::checkSystemUpdateAvailable();
$_SESSION['systemUpdateAvailable'] = $updateInfo['available'];
$_SESSION['systemUpdateVersion'] = $updateInfo['version'];
$_SESSION['systemLatestVersion'] = $updateInfo['latestVersion'];

// Get pre-upgrade tasks
$taskService = new TaskService();
$preUpgradeTasks = $taskService->getActivePreUpgradeTasks();
Expand Down
1 change: 0 additions & 1 deletion src/api/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@
require __DIR__ . '/routes/system/system-issues.php';
require __DIR__ . '/routes/system/system-logs.php';
require __DIR__ . '/routes/system/system-register.php';
require __DIR__ . '/routes/system/system-upgrade.php';
require __DIR__ . '/routes/system/system-custom-menu.php';
require __DIR__ . '/routes/system/system-locale.php';
require __DIR__ . '/routes/cart.php';
Expand Down
22 changes: 22 additions & 0 deletions src/skin/js/CRMJSOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,28 @@ window.CRM.APIRequest = function (options) {
return $.ajax(options);
};

/**
* Admin-only API Request wrapper
* Used for endpoints in /admin/api/* - does NOT add /api prefix
* Endpoint paths should be like "upgrade/download-latest-release" which becomes "/admin/api/upgrade/download-latest-release"
*/
window.CRM.AdminAPIRequest = function (options) {
if (!options.method) {
options.method = "GET";
} else {
options.dataType = "json";
}
Comment on lines +28 to +32
Copy link

Copilot AI Dec 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The conditional logic is incorrect. When a method is provided, dataType should be set to "json", but the current logic only sets it when a method is NOT provided. This means GET requests won't have dataType: "json" but POST/PUT requests will.

This should be:

if (!options.method) {
    options.method = "GET";
}
options.dataType = "json";

The same bug exists in the original APIRequest function above (lines 6-10).

Copilot uses AI. Check for mistakes.
options.url = window.CRM.root + "/admin/api/" + options.path;
options.contentType = "application/json";
options.beforeSend = function (jqXHR, settings) {
jqXHR.url = settings.url;
};
options.error = function (jqXHR, textStatus, errorThrown) {
window.CRM.system.handlejQAJAXError(jqXHR, textStatus, errorThrown, options.suppressErrorDialog);
};
return $.ajax(options);
};

window.CRM.DisplayErrorMessage = function (endpoint, error) {
console.trace(error);
let message =
Expand Down
46 changes: 38 additions & 8 deletions webpack/upgrade-wizard-app.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,40 @@ import 'bs-stepper/dist/css/bs-stepper.min.css';

let upgradeStepper;

// Ensure AdminAPIRequest is available - fallback to regular APIRequest if not defined
if (window.CRM && !window.CRM.AdminAPIRequest) {
window.CRM.AdminAPIRequest = function (options) {
// Fallback: if AdminAPIRequest is not defined, assume it's the same as APIRequest
// The path should already be prefixed with admin/api/
if (!options.method) {
options.method = "GET";
} else {
options.dataType = "json";
}
options.url = window.CRM.root + "/admin/api/" + options.path;
options.contentType = "application/json";
options.beforeSend = function (jqXHR, settings) {
jqXHR.url = settings.url;
};
options.error = function (jqXHR, textStatus, errorThrown) {
if (window.CRM.system && window.CRM.system.handlejQAJAXError) {
window.CRM.system.handlejQAJAXError(jqXHR, textStatus, errorThrown, options.suppressErrorDialog);
}
};
return $.ajax(options);
};
}

/**
* Initialize the upgrade wizard when DOM is ready
*/
$(document).ready(function () {
// Verify AdminAPIRequest is available
if (!window.CRM || !window.CRM.AdminAPIRequest) {
console.error('AdminAPIRequest not available - upgrade wizard cannot proceed');
return;
}

// Initialize bs-stepper
upgradeStepper = new Stepper(document.querySelector('#upgrade-stepper'), {
linear: true,
Expand Down Expand Up @@ -172,9 +202,9 @@ function performDownload() {
const $statusIcon = $("#status-apply");
const $downloadStatus = $("#downloadStatus");

window.CRM.APIRequest({
window.CRM.AdminAPIRequest({
type: 'GET',
path: 'systemupgrade/download-latest-release',
path: 'upgrade/download-latest-release',
})
.done(function (data) {
$statusIcon.html('<i class="fa-solid fa-check text-success"></i>');
Expand Down Expand Up @@ -246,9 +276,9 @@ function setupApplyStep() {
$statusIcon.html('<i class="fa-solid fa-circle-notch fa-spin text-primary"></i>');
$button.prop('disabled', true);

window.CRM.APIRequest({
window.CRM.AdminAPIRequest({
method: 'POST',
path: 'systemupgrade/do-upgrade',
path: 'upgrade/do-upgrade',
data: JSON.stringify({
fullPath: window.CRM.updateFile.fullPath,
sha1: window.CRM.updateFile.sha1
Expand Down Expand Up @@ -343,9 +373,9 @@ function setupPrereleaseToggle() {
data: JSON.stringify({ value: newValue ? '1' : '0' })
}).done(function () {
// Refresh upgrade info from GitHub
window.CRM.APIRequest({
window.CRM.AdminAPIRequest({
method: 'POST',
path: 'systemupgrade/refresh-upgrade-info'
path: 'upgrade/refresh-upgrade-info'
}).done(function (data) {
$spinner.removeClass('active');
window.CRM.notify(i18next.t('Setting saved. Reloading page...'), {
Expand Down Expand Up @@ -399,9 +429,9 @@ function setupRefreshButton() {
$spinner.addClass('active');

// Call refresh API
window.CRM.APIRequest({
window.CRM.AdminAPIRequest({
method: 'POST',
path: 'systemupgrade/refresh-upgrade-info'
path: 'upgrade/refresh-upgrade-info'
}).done(function (data) {
$spinner.removeClass('active');
window.CRM.notify(i18next.t('Upgrade information refreshed. Reloading page...'), {
Expand Down
Loading