diff --git a/admin/database_updates/2.7.3.php b/admin/database_updates/2.7.3.php
new file mode 100644
index 000000000..846ab947e
--- /dev/null
+++ b/admin/database_updates/2.7.3.php
@@ -0,0 +1,26 @@
+ Update no longer runs git itself. cron/update_check.php does the fetch
+ // on its own schedule and parks the answer here; the page and the nightly notification
+ // both read these rather than shelling out in a web request.
+ //
+ // config_update_checked_at is written only after a check SUCCEEDS, so "last checked"
+ // never claims freshness for a reading that a failed fetch left stale. The cron_jobs row
+ // holds the attempt time and the error.
+
+ mysqli_query($mysqli, "ALTER TABLE `settings`
+ ADD COLUMN IF NOT EXISTS `config_update_latest_commit` varchar(40) DEFAULT NULL,
+ ADD COLUMN IF NOT EXISTS `config_update_pending_commits` text DEFAULT NULL,
+ ADD COLUMN IF NOT EXISTS `config_update_checked_at` datetime DEFAULT NULL");
+
+ // Seeded here as well as by the dispatcher, which only creates rows on its next pass -
+ // without this, a Check Now pressed in the minutes after an upgrade would have no row to
+ // set cron_job_run_now on and would wait for that pass instead of starting.
+ mysqli_query($mysqli, "INSERT IGNORE INTO cron_jobs SET cron_job_name = 'update_check', cron_job_enabled = 1, cron_job_schedule = 'Daily', cron_job_daily_at = '02:30'");
diff --git a/admin/debug.php b/admin/debug.php
index f4147e4a5..d6ac0694c 100644
--- a/admin/debug.php
+++ b/admin/debug.php
@@ -6,11 +6,10 @@ require_once "../config.php";
$checks = [];
-// Execute the git command to get the latest commit hash
-$commitHash = shell_exec('git log -1 --format=%H');
+// Read straight out of .git - no shell needed, and it still answers on a hardened host
+$commitHash = gitCurrentCommit();
-// Get branch info
-$gitBranch = shell_exec('git rev-parse --abbrev-ref HEAD');
+$gitBranch = gitCurrentBranch();
// Section: System Information
$systemInfo = [];
@@ -146,27 +145,15 @@ $phpConfig[] = [
// Section: Shell Commands
$shellCommands = [];
-if ($shell_exec_enabled) {
- $commands = ['git'];
-
- foreach ($commands as $command) {
- $which = trim(shell_exec("which $command 2>/dev/null"));
- $exists = !empty($which);
- $shellCommands[] = [
- 'name' => "Command '$command' available",
- 'passed' => $exists,
- 'value' => $exists ? $which : 'Not Found',
- ];
- }
-} else {
- // If shell_exec is disabled, mark commands as unavailable
- foreach (['git'] as $command) {
- $shellCommands[] = [
- 'name' => "Command '$command' available",
- 'passed' => false,
- 'value' => 'shell_exec Disabled',
- ];
- }
+// Located by walking PATH rather than by running `which`, so this reports the truth on a
+// host with shell_exec disabled instead of reporting the host's php.ini back at itself
+foreach (['git'] as $command) {
+ $path = commandPath($command);
+ $shellCommands[] = [
+ 'name' => "Command '$command' available",
+ 'passed' => $path !== '',
+ 'value' => $path !== '' ? $path : 'Not Found',
+ ];
}
// Section: SSL Checks
@@ -531,11 +518,11 @@ $mysqli->close();
| Current Code Commit |
- = $commitHash ?> |
+ = $commitHash === '' ? 'Not a git checkout' : escapeHtml($commitHash) ?> |
| Current Branch |
- = $gitBranch ?> |
+ = $gitBranch === '' ? 'Not a git checkout' : escapeHtml($gitBranch) ?> |
diff --git a/admin/post/update.php b/admin/post/update.php
index 625473754..a65770330 100644
--- a/admin/post/update.php
+++ b/admin/post/update.php
@@ -14,6 +14,32 @@ defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
* cron/app_update.php acts on, and cron_job_run_now is what gets the dispatcher to look
* before the job's own schedule comes round.
*/
+/*
+ * Check Now. The check runs git fetch, so it belongs on the command line for the same reason
+ * the update does; this only asks the dispatcher to bring the job forward. run_now works on a
+ * disabled job too, so turning the daily check off does not take the button with it.
+ */
+if (isset($_GET['check_update'])) {
+
+ validateCSRFToken();
+
+ enforceAdminPermission();
+
+ if (!settingsColumnExists($mysqli, 'config_update_latest_commit')) {
+ flashAlert("Checking needs a schema change this install has not caught up with yet - run php scripts/update_cli.php from a shell once, and it will work from then on.", 'error');
+ redirect();
+ }
+
+ mysqli_query($mysqli, "UPDATE cron_jobs SET cron_job_run_now = 1 WHERE cron_job_name = 'update_check'");
+
+ logAudit("App", "Update", "$session_name asked cron to check for updates");
+
+ flashAlert("Checking for updates - cron will run the check within a minute.");
+
+ redirect();
+
+}
+
if (isset($_GET['queue_update'])) {
validateCSRFToken();
diff --git a/admin/update.php b/admin/update.php
index 3af7b40f2..84fabb64d 100644
--- a/admin/update.php
+++ b/admin/update.php
@@ -4,52 +4,51 @@ require_once "includes/inc_all_admin.php";
require_once "../includes/database_version.php";
$repo_branch = getRepoBranch();
-$remote_ref = escapeshellarg("origin/$repo_branch");
/*
- * The web server never applies an application update - it queues one and cron runs it. The
- * shell gate below therefore only decides whether this page can READ the git remote to say
- * an update is waiting; where PHP cannot run one - shared hosting, a hardened php.ini, an
- * FPM pool locked down while the command line is not - the page offers the queue blind,
- * which is harmless when there is nothing to fetch. The database half is plain PHP and
- * works everywhere.
+ * This page does not run git. cron/update_check.php does the fetch on its own schedule and
+ * stores what it found; everything below is a read of that, plus .git for the local commit.
+ * Check Now asks the dispatcher for a run rather than checking inside the request, so the
+ * page works the same on a host whose web PHP cannot run external commands at all.
*/
-$shell_available = shellCommandsAvailable();
+$current_version = gitCurrentCommit();
-$current_version = '';
-$fetch_ok = true;
-$updates = null;
+// The stored answer arrives with a migration, and this page has to render on an install
+// whose files are newer than its schema
+$update_check_available = settingsColumnExists($mysqli, 'config_update_latest_commit');
-// Commits sitting between this working tree and the remote branch. Fields are separated
-// by \x1f rather than having git build the table markup, because a commit subject comes
-// from outside this install and used to reach the page as unescaped HTML.
+$latest_version = '';
+$update_checked_at = null;
$pending_commits = [];
+$check_job = null;
-if ($shell_available) {
+if ($update_check_available) {
- $updates = checkForUpdates();
+ $update_check_row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_update_latest_commit, config_update_pending_commits, config_update_checked_at FROM settings WHERE company_id = 1"));
- $current_version = $updates->current_version;
- $fetch_ok = $updates->result === 0;
+ $latest_version = (string) ($update_check_row['config_update_latest_commit'] ?? '');
+ $update_checked_at = $update_check_row['config_update_checked_at'] ?? null;
- $git_log = shell_exec("git log HEAD..$remote_ref --pretty=format:'%h%x1f%ar%x1f%s'");
+ // Stored as JSON by the job: [[short hash, ISO date, subject], ...]. Rebuilt element by
+ // element rather than trusted wholesale - it is the shape the table indexes into
+ $stored_commits = json_decode((string) ($update_check_row['config_update_pending_commits'] ?? ''), true);
- foreach (explode("\n", trim((string) $git_log)) as $commit_line) {
-
- if ($commit_line === '') {
- continue;
+ if (is_array($stored_commits)) {
+ foreach ($stored_commits as $stored_commit) {
+ if (is_array($stored_commit) && count($stored_commit) === 3) {
+ $pending_commits[] = array_values($stored_commit);
+ }
}
-
- $commit_fields = explode("\x1f", $commit_line, 3);
-
- if (count($commit_fields) === 3) {
- $pending_commits[] = $commit_fields;
- }
-
}
+ $check_job = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT cron_job_run_now, cron_job_last_error, cron_job_last_error_at FROM cron_jobs WHERE cron_job_name = 'update_check' LIMIT 1"));
+
}
+// The dispatcher clears run_now inside the same UPDATE that claims the job, so this is true
+// for exactly as long as the request is outstanding
+$check_in_progress = !empty($check_job['cron_job_run_now']);
+
/*
* The queue. Maintenance > Update writes config_update_queued_at and asks the dispatcher for
* an immediate run; cron/app_update.php takes the request and runs scripts/update_cli.php in
@@ -84,7 +83,12 @@ $cron_is_running = $update_queue_available
// version_compare, not > - "2.6.10" is less than "2.6.9" as a plain string comparison, so
// the plain comparison silently stops offering database updates once a minor reaches 10.
$db_update_available = version_compare(LATEST_DATABASE_VERSION, CURRENT_DATABASE_VERSION, '>');
-$app_update_available = !empty($pending_commits);
+
+// Derived from the two commits, not from the list: a force-push or a local commit leaves the
+// working tree at a different place with nothing to list, and that is still "not up to date"
+$app_update_available = $latest_version !== ''
+ && $current_version !== ''
+ && $latest_version !== $current_version;
?>
@@ -94,26 +98,33 @@ $app_update_available = !empty($pending_commits);
-
+
-
Cannot reach the Git remote
- ITFlow updates itself with Git, so nothing below is current until this is fixed.
- output)) { ?>
-
= escapeHtml(implode("\n", $updates->output)) ?>
-
- Check that Git is installed, that the remote is reachable from this server, and that the web server
- user can write to the ITFlow directory. The
-
forum can help - include
- your PHP error log and the output above.
+
The last update check did not finish
+ Anything below is from the check before it, so it may be out of date.
+
= escapeHtml($check_job['cron_job_last_error']) ?>
+ Recorded = escapeHtml((string) $check_job['cron_job_last_error_at']) ?>. Check that Git is installed
+ and that the remote is reachable from this server, then clear the error from
+
Maintenance > Cron. The
+
forum can help - include the
+ output above.
-
-
-
This server cannot check for updates
- PHP here has
exec and
shell_exec disabled, so this page cannot read the Git
- remote to tell you whether one is waiting. Queueing still works - cron runs the update from the command
- line, which is usually not restricted the same way. Database updates are plain PHP and are unaffected.
+
+
+
+
+
Checking for updates.
+
+ Cron runs the check within a minute; this page refreshes itself.
+
+
Nothing is going to pick it up - cron has not checked in
+ recently or the master switch is off. See
+
Maintenance > Cron.
+
@@ -144,6 +155,25 @@ $app_update_available = !empty($pending_commits);
+
+
+
+
+ Last checked = escapeHtml(timeAgo($update_checked_at)) ?>
+ (= escapeHtml($update_checked_at) ?>).
+
+ Never checked.
+
+
+ Check now
+
+ Checking…
+
+
+
+
An update was queued at
@@ -168,7 +198,7 @@ $app_update_available = !empty($pending_commits);
-
+
@@ -198,7 +228,7 @@ $app_update_available = !empty($pending_commits);
together under one button rather than as separate steps. The db_update_available
arm of the condition matters: a schema behind its code with nothing to pull is
exactly the state that needs queueing, and without it the button would not draw. */ ?>
-
+
Pending
@@ -208,11 +238,11 @@ $app_update_available = !empty($pending_commits);
= count($pending_commits) ?> commit= count($pending_commits) === 1 ? '' : 's' ?>
behind
= escapeHtml("origin/$repo_branch") ?>.
-
+
- Application files: this server cannot check
- = escapeHtml("origin/$repo_branch") ?>, so there may or may not be anything
- waiting. Queueing an update when there is nothing to do is harmless.
+ Application files: this install has not checked
+ = escapeHtml("origin/$repo_branch") ?> yet, so there may or may not be
+ anything waiting. Queueing an update when there is nothing to do is harmless.
@@ -272,7 +302,9 @@ $app_update_available = !empty($pending_commits);
= escapeHtml($commit[0]) ?> |
- = escapeHtml($commit[1]) ?> |
+
+ = escapeHtml(timeAgo($commit[1])) ?> |
= escapeHtml($commit[2]) ?> |
@@ -284,6 +316,8 @@ $app_update_available = !empty($pending_commits);
+
+
0 || $config_telemetry == 2) {
- $current_version = exec("git rev-parse HEAD");
+ $current_version = gitCurrentCommit();
// Client Count
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('client_id') AS num FROM clients"));
@@ -1403,13 +1403,23 @@ if ($config_telemetry > 0 || $config_telemetry == 2) {
// Fetch Updates
-$updates = checkForUpdates();
+/*
+ * The check itself is cron/update_check.php now - read what it stored rather than running a
+ * second git fetch here. Nothing to say until that job has run once, which is the same
+ * position this was in when a fetch failed.
+ */
+if (settingsColumnExists($mysqli, 'config_update_latest_commit')) {
-$update_message = $updates->update_message;
+ $update_check_row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_update_latest_commit FROM settings WHERE company_id = 1"));
+
+ $latest_version = (string) ($update_check_row['config_update_latest_commit'] ?? '');
+ $current_version = gitCurrentCommit();
+
+ if ($latest_version !== '' && $current_version !== '' && $latest_version !== $current_version) {
+ // Send Alert to inform Updates Available
+ appNotify("Update", "New Updates are Available [$latest_version]", "/admin/update.php");
+ }
-if ($updates->current_version !== $updates->latest_version) {
- // Send Alert to inform Updates Available
- appNotify("Update", "$update_message", "/admin/update.php");
}
diff --git a/cron/update_check.php b/cron/update_check.php
new file mode 100644
index 000000000..f5ade90f3
--- /dev/null
+++ b/cron/update_check.php
@@ -0,0 +1,81 @@
+ Update sets cron_job_run_now on this job rather than checking
+ * inside the request - same mechanism as Queue Update, and the page shows "Checking" until
+ * the dispatcher consumes the flag.
+ *
+ * Safe to run twice: it reads and overwrites, and changes nothing outside the three settings
+ * columns it owns.
+ */
+
+// Set working directory to the directory this cron script lives at.
+chdir(dirname(__FILE__));
+
+// Ensure we're running from command line
+if (php_sapi_name() !== 'cli') {
+ die("This script must be run from the command line.\n");
+}
+
+// Prevent overlapping runs of this script
+$cron_lock_script = __FILE__;
+require_once "includes/cron_lock.php";
+
+require_once "../config.php";
+
+// Set Timezone
+require_once "../includes/inc_set_timezone.php";
+require_once "../functions.php";
+
+$update_check_settings = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_enable_cron FROM settings WHERE company_id = 1"));
+
+$config_enable_cron = intval($update_check_settings['config_enable_cron'] ?? 0);
+
+if ($config_enable_cron == 0) {
+ cronJobStop("Cron: is not enabled\n");
+}
+
+// An install whose files are newer than its schema reaches this job before the migration
+// that adds the columns has run. Asking for them would throw
+if (!settingsColumnExists($mysqli, 'config_update_latest_commit')) {
+ cronJobStop("The database update has not been applied yet\n");
+}
+
+// A zip-drop install has no remote to ask. Not a failure - there is simply nothing to check
+if (!file_exists("../.git")) {
+ cronJobStop("Not a git checkout - there is no remote to check against\n");
+}
+
+if (!shellCommandsAvailable()) {
+ cronJobStop("PHP on the command line cannot run git (exec is disabled), so the check cannot run\n");
+}
+
+$updates = checkForUpdates();
+
+/*
+ * Thrown rather than stopped: a fetch that fails is something to look at, and the dispatcher
+ * records a thrown message against the job so it shows on both Maintenance > Cron and the
+ * Update page. The last three lines, not the last one - git's final line is often the
+ * suggested fix rather than the problem.
+ */
+if ($updates->result !== 0) {
+ $update_check_error = trim(implode(' | ', array_slice($updates->output, -3)));
+ throw new Exception("git fetch failed: " . ($update_check_error === '' ? 'no output' : $update_check_error));
+}
+
+$update_check_latest = escapeSql($updates->latest_version);
+$update_check_commits = escapeSql((string) json_encode($updates->pending_commits));
+
+mysqli_query($mysqli, "UPDATE settings SET
+ config_update_latest_commit = '$update_check_latest',
+ config_update_pending_commits = '$update_check_commits',
+ config_update_checked_at = '" . date('Y-m-d H:i:s') . "'
+ WHERE company_id = 1");
+
+echo count($updates->pending_commits) . " commit(s) behind the remote\n";
diff --git a/db.sql b/db.sql
index 7ed5c6fe4..56df8241c 100644
--- a/db.sql
+++ b/db.sql
@@ -2349,6 +2349,9 @@ CREATE TABLE `settings` (
`config_backup_cron_type` varchar(20) NOT NULL DEFAULT 'full',
`config_update_queued_at` datetime DEFAULT NULL,
`config_internal_client_id` int(11) NOT NULL DEFAULT 0,
+ `config_update_latest_commit` varchar(40) DEFAULT NULL,
+ `config_update_pending_commits` text DEFAULT NULL,
+ `config_update_checked_at` datetime DEFAULT NULL,
PRIMARY KEY (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
@@ -3193,4 +3196,4 @@ CREATE TABLE `vendors` (
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
--- Dump completed on 2026-08-25 12:18:16
+-- Dump completed on 2026-08-26 23:37:25
diff --git a/functions/app.php b/functions/app.php
index 851b1db96..884ed6676 100644
--- a/functions/app.php
+++ b/functions/app.php
@@ -336,11 +336,201 @@ function getRepoBranch(): string
}
/*
- * Whether this PHP can run external commands at all. Applying an update is cron's job now, so
- * the only thing the web tier still wants a shell for is READING git - checkForUpdates() and
- * the pending-commit list on Maintenance > Update. Hosts that disable exec()/shell_exec() -
- * shared hosting, a hardened php.ini, an FPM pool locked down while the CLI is not - lose the
- * "an update is waiting" readout, not the ability to update.
+ * Reading git WITHOUT running git.
+ *
+ * Everything below answers from the files in .git, so it works on hosts where exec() and
+ * shell_exec() are disabled and costs a couple of file reads instead of a process. Only
+ * operations that need the network (git fetch) or the object database (a commit list) still
+ * need the binary - see CONTRIBUTING rule 6.
+ *
+ * Returns '' rather than throwing on anything unexpected: a zip-drop install has no .git at
+ * all, and every caller here is reporting, not deciding.
+ */
+function gitDir(): string
+{
+ $root = dirname(__DIR__);
+ $git = $root . '/.git';
+
+ if (is_dir($git)) {
+ return $git;
+ }
+
+ // Submodules and linked worktrees put "gitdir:
" in a file instead of a directory
+ if (is_file($git)) {
+ $line = trim((string) @file_get_contents($git));
+
+ if (str_starts_with($line, 'gitdir:')) {
+ $path = trim(substr($line, 7));
+
+ if ($path !== '' && $path[0] !== '/') {
+ $path = $root . '/' . $path;
+ }
+
+ if ($path !== '' && is_dir($path)) {
+ return $path;
+ }
+ }
+ }
+
+ return '';
+}
+
+/*
+ * Where the refs live. A linked worktree keeps its own HEAD but shares the main repository's
+ * refs, and points at them with a commondir file - resolve a ref against the worktree's own
+ * directory and every lookup comes back empty.
+ */
+function gitCommonDir(): string
+{
+ $dir = gitDir();
+
+ if ($dir === '') {
+ return '';
+ }
+
+ $commondir = $dir . '/commondir';
+
+ if (is_file($commondir)) {
+ $path = trim((string) @file_get_contents($commondir));
+
+ if ($path !== '' && $path[0] !== '/') {
+ $path = $dir . '/' . $path;
+ }
+
+ if ($path !== '' && is_dir($path)) {
+ return rtrim($path, '/');
+ }
+ }
+
+ return $dir;
+}
+
+/*
+ * The commit a ref points at, e.g. gitRefCommit('refs/remotes/origin/develop').
+ *
+ * A ref is either its own file or a line in packed-refs; git writes loose files and moves
+ * them into packed-refs when it tidies up, so both have to be read. Lines starting with ^ in
+ * packed-refs are the peeled target of an annotated tag, not a ref.
+ */
+function gitRefCommit(string $ref, int $depth = 0): string
+{
+ // The ref becomes part of a path, and $repo_branch reaches this from config.php
+ if ($depth > 5 || str_contains($ref, '..') || !preg_match('#^[A-Za-z0-9._/-]+$#', $ref)) {
+ return '';
+ }
+
+ $dir = gitCommonDir();
+
+ if ($dir === '') {
+ return '';
+ }
+
+ $loose = $dir . '/' . $ref;
+
+ if (is_file($loose)) {
+ $value = trim((string) @file_get_contents($loose));
+
+ if (str_starts_with($value, 'ref: ')) {
+ return gitRefCommit(trim(substr($value, 5)), $depth + 1);
+ }
+
+ return preg_match('/^[0-9a-f]{40}$/', $value) ? $value : '';
+ }
+
+ $packed = $dir . '/packed-refs';
+
+ if (is_file($packed)) {
+ foreach ((array) @file($packed, FILE_IGNORE_NEW_LINES) as $line) {
+
+ if ($line === '' || $line[0] === '#' || $line[0] === '^') {
+ continue;
+ }
+
+ $parts = explode(' ', $line, 2);
+
+ if (count($parts) === 2 && trim($parts[1]) === $ref) {
+ return preg_match('/^[0-9a-f]{40}$/', $parts[0]) ? $parts[0] : '';
+ }
+
+ }
+ }
+
+ return '';
+}
+
+/* The branch this working tree is on, or 'HEAD' when it is detached. */
+function gitCurrentBranch(): string
+{
+ $dir = gitDir();
+
+ if ($dir === '') {
+ return '';
+ }
+
+ $head = trim((string) @file_get_contents($dir . '/HEAD'));
+
+ if (str_starts_with($head, 'ref: refs/heads/')) {
+ return substr($head, 16);
+ }
+
+ return $head === '' ? '' : 'HEAD';
+}
+
+/* The commit this working tree is on - the file-read equivalent of git rev-parse HEAD. */
+function gitCurrentCommit(): string
+{
+ $dir = gitDir();
+
+ if ($dir === '') {
+ return '';
+ }
+
+ $head = trim((string) @file_get_contents($dir . '/HEAD'));
+
+ if (str_starts_with($head, 'ref: ')) {
+ return gitRefCommit(trim(substr($head, 5)));
+ }
+
+ return preg_match('/^[0-9a-f]{40}$/', $head) ? $head : '';
+}
+
+/*
+ * Where a command lives, or '' if it is not on the path - what `which` was being run for.
+ *
+ * PHP-FPM pools often ship a nearly empty PATH, so the usual locations are checked as well;
+ * a missing hit here would otherwise read as "git is not installed" on a box where it is.
+ */
+function commandPath(string $command): string
+{
+ if (!preg_match('/^[A-Za-z0-9._-]+$/', $command)) {
+ return '';
+ }
+
+ $dirs = array_filter(explode(PATH_SEPARATOR, (string) getenv('PATH')));
+
+ foreach (['/usr/local/sbin', '/usr/local/bin', '/usr/sbin', '/usr/bin', '/sbin', '/bin'] as $fallback) {
+ if (!in_array($fallback, $dirs, true)) {
+ $dirs[] = $fallback;
+ }
+ }
+
+ foreach ($dirs as $dir) {
+ $candidate = rtrim($dir, '/') . '/' . $command;
+
+ if (@is_file($candidate) && @is_executable($candidate)) {
+ return $candidate;
+ }
+ }
+
+ return '';
+}
+
+/*
+ * Whether this PHP can run external commands at all. Nothing in the web tier does any more -
+ * checkForUpdates() is called from cron/update_check.php alone, and the Update page reads what
+ * that job stored. This is what the job asks before it starts, and what admin/debug.php
+ * reports, so a host with exec()/shell_exec() disabled shows why its checks stopped rather
+ * than failing silently.
*
* function_exists() already reports a disabled function as missing. disable_functions is
* read as well because some hardening extensions leave the function defined and refuse the
@@ -393,18 +583,47 @@ function checkForUpdates() {
$updates->current_version = '';
$updates->latest_version = '';
$updates->update_message = "Cannot check for updates";
+ $updates->pending_commits = [];
return $updates;
}
- $remote_ref = escapeshellarg("origin/" . getRepoBranch());
-
// Fetch the latest code changes but don't apply them. stderr is merged in because git
// reports failures there, and it is the only thing the update page can show when this
// breaks - it used to run a second git fetch of its own just to get the message.
exec("git fetch 2>&1", $output, $result);
- $latest_version = exec("git rev-parse $remote_ref");
- $current_version = exec("git rev-parse HEAD");
+
+ // Both sides of the comparison are read out of .git rather than shelled for - the fetch
+ // has already written the remote-tracking ref by the time we get here
+ $latest_version = gitRefCommit("refs/remotes/origin/" . getRepoBranch());
+ $current_version = gitCurrentCommit();
+
+ /*
+ * The commits between here and there. Fields are separated by \x1f rather than letting
+ * git format the row itself, because a subject comes from outside this install and used
+ * to reach the Update page as unescaped HTML.
+ *
+ * The date is %aI (absolute, ISO 8601) rather than %ar. This result is stored and read
+ * back hours later, and a stored "2 hours ago" is wrong the moment it is written - the
+ * relative form is worked out at render time instead.
+ */
+ $updates->pending_commits = [];
+
+ $remote_ref = escapeshellarg("origin/" . getRepoBranch());
+
+ foreach (explode("\n", trim((string) shell_exec("git log HEAD..$remote_ref --pretty=format:'%h%x1f%aI%x1f%s'"))) as $commit_line) {
+
+ if ($commit_line === '') {
+ continue;
+ }
+
+ $commit_fields = explode("\x1f", $commit_line, 3);
+
+ if (count($commit_fields) === 3) {
+ $updates->pending_commits[] = $commit_fields;
+ }
+
+ }
if ($current_version == $latest_version) {
$update_message = "No Updates available";
diff --git a/includes/cron_jobs.php b/includes/cron_jobs.php
index 125ce152a..f3504c86e 100644
--- a/includes/cron_jobs.php
+++ b/includes/cron_jobs.php
@@ -102,6 +102,14 @@ function cronJobRegistry(): array
'schedule' => 'Daily',
'daily_at' => '03:30',
],
+ [
+ 'name' => 'update_check',
+ 'label' => 'Update Check',
+ 'script' => 'update_check.php',
+ 'description' => 'Asks the git remote whether a newer release exists and stores the answer for Maintenance > Update.',
+ 'schedule' => 'Daily',
+ 'daily_at' => '02:30',
+ ],
[
'name' => 'app_update',
'label' => 'Application Update',
diff --git a/js/auto_reload.js b/js/auto_reload.js
new file mode 100644
index 000000000..277e80c2d
--- /dev/null
+++ b/js/auto_reload.js
@@ -0,0 +1,34 @@
+/**
+ * Reload a page that is waiting on something cron will do.
+ *
+ * Opt in by putting data-itflow-reload-seconds="" on the element that says so - the
+ * "Checking for updates" alert on Maintenance > Update is the first user. Rendering that
+ * attribute is what starts the timer, so a view that is not waiting costs one failed
+ * querySelector.
+ *
+ * Deliberately an attribute rather than an inline