Implemented Check for updates which removs the last shell exec from web ui, and reimplemented db_update switch to update_cli.php

This commit is contained in:
johnnyq
2026-08-26 23:38:23 -04:00
parent 14c38b9814
commit 069f587c1d
13 changed files with 573 additions and 132 deletions

View File

@@ -0,0 +1,26 @@
<?php
/*
* ITFlow - Database update to version 2.7.3 (from 2.7.2)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Maintenance > 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'");

View File

@@ -6,11 +6,10 @@ require_once "../config.php";
$checks = []; $checks = [];
// Execute the git command to get the latest commit hash // Read straight out of .git - no shell needed, and it still answers on a hardened host
$commitHash = shell_exec('git log -1 --format=%H'); $commitHash = gitCurrentCommit();
// Get branch info $gitBranch = gitCurrentBranch();
$gitBranch = shell_exec('git rev-parse --abbrev-ref HEAD');
// Section: System Information // Section: System Information
$systemInfo = []; $systemInfo = [];
@@ -146,27 +145,15 @@ $phpConfig[] = [
// Section: Shell Commands // Section: Shell Commands
$shellCommands = []; $shellCommands = [];
if ($shell_exec_enabled) { // Located by walking PATH rather than by running `which`, so this reports the truth on a
$commands = ['git']; // host with shell_exec disabled instead of reporting the host's php.ini back at itself
foreach (['git'] as $command) {
foreach ($commands as $command) { $path = commandPath($command);
$which = trim(shell_exec("which $command 2>/dev/null")); $shellCommands[] = [
$exists = !empty($which); 'name' => "Command '$command' available",
$shellCommands[] = [ 'passed' => $path !== '',
'name' => "Command '$command' available", 'value' => $path !== '' ? $path : 'Not Found',
'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',
];
}
} }
// Section: SSL Checks // Section: SSL Checks
@@ -531,11 +518,11 @@ $mysqli->close();
</tr> </tr>
<tr> <tr>
<td>Current Code Commit</td> <td>Current Code Commit</td>
<td><?= $commitHash ?></td> <td><?= $commitHash === '' ? 'Not a git checkout' : escapeHtml($commitHash) ?></td>
</tr> </tr>
<tr> <tr>
<td>Current Branch</td> <td>Current Branch</td>
<td><?= $gitBranch ?></td> <td><?= $gitBranch === '' ? 'Not a git checkout' : escapeHtml($gitBranch) ?></td>
</tr> </tr>
</table> </table>
</div> </div>

View File

@@ -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 * 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. * 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'])) { if (isset($_GET['queue_update'])) {
validateCSRFToken(); validateCSRFToken();

View File

@@ -4,52 +4,51 @@ require_once "includes/inc_all_admin.php";
require_once "../includes/database_version.php"; require_once "../includes/database_version.php";
$repo_branch = getRepoBranch(); $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 * This page does not run git. cron/update_check.php does the fetch on its own schedule and
* shell gate below therefore only decides whether this page can READ the git remote to say * stores what it found; everything below is a read of that, plus .git for the local commit.
* an update is waiting; where PHP cannot run one - shared hosting, a hardened php.ini, an * Check Now asks the dispatcher for a run rather than checking inside the request, so the
* FPM pool locked down while the command line is not - the page offers the queue blind, * page works the same on a host whose web PHP cannot run external commands at all.
* which is harmless when there is nothing to fetch. The database half is plain PHP and
* works everywhere.
*/ */
$shell_available = shellCommandsAvailable(); $current_version = gitCurrentCommit();
$current_version = ''; // The stored answer arrives with a migration, and this page has to render on an install
$fetch_ok = true; // whose files are newer than its schema
$updates = null; $update_check_available = settingsColumnExists($mysqli, 'config_update_latest_commit');
// Commits sitting between this working tree and the remote branch. Fields are separated $latest_version = '';
// by \x1f rather than having git build the table markup, because a commit subject comes $update_checked_at = null;
// from outside this install and used to reach the page as unescaped HTML.
$pending_commits = []; $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; $latest_version = (string) ($update_check_row['config_update_latest_commit'] ?? '');
$fetch_ok = $updates->result === 0; $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 (is_array($stored_commits)) {
foreach ($stored_commits as $stored_commit) {
if ($commit_line === '') { if (is_array($stored_commit) && count($stored_commit) === 3) {
continue; $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 * 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 * 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 // 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. // the plain comparison silently stops offering database updates once a minor reaches 10.
$db_update_available = version_compare(LATEST_DATABASE_VERSION, CURRENT_DATABASE_VERSION, '>'); $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);
</div> </div>
<div class="card-body"> <div class="card-body">
<?php if ($shell_available && !$fetch_ok) { ?> <?php if (!empty($check_job['cron_job_last_error'])) { ?>
<div class="alert alert-danger"> <div class="alert alert-danger">
<h5><i class="fas fa-fw fa-exclamation-triangle me-2"></i>Cannot reach the Git remote</h5> <h5><i class="fas fa-fw fa-exclamation-triangle me-2"></i>The last update check did not finish</h5>
ITFlow updates itself with Git, so nothing below is current until this is fixed. Anything below is from the check before it, so it may be out of date.
<?php if (!empty($updates->output)) { ?> <pre class="bg-dark text-white p-2 mt-2 mb-2"><?= escapeHtml($check_job['cron_job_last_error']) ?></pre>
<pre class="bg-dark text-white p-2 mt-2 mb-2"><?= escapeHtml(implode("\n", $updates->output)) ?></pre> Recorded <?= escapeHtml((string) $check_job['cron_job_last_error_at']) ?>. Check that Git is installed
<?php } ?> and that the remote is reachable from this server, then clear the error from
Check that Git is installed, that the remote is reachable from this server, and that the web server <a href="cron.php" class="alert-link">Maintenance &gt; Cron</a>. The
user can write to the ITFlow directory. The <a href="https://forum.itflow.org" class="alert-link" target="_blank">forum</a> can help - include the
<a href="https://forum.itflow.org" class="alert-link" target="_blank">forum</a> can help - include output above.
your PHP error log and the output above.
</div> </div>
<?php } ?> <?php } ?>
<?php if (!$shell_available) { ?> <?php if ($check_in_progress) { ?>
<div class="alert alert-info"> <?php /* data-itflow-reload-seconds is read by js/auto_reload.js, loaded at the foot
<h5><i class="fas fa-fw fa-info-circle me-2"></i>This server cannot check for updates</h5> of this page. The dispatcher clears run_now when it claims the job, so one
PHP here has <code>exec</code> and <code>shell_exec</code> disabled, so this page cannot read the Git reload a few seconds later is normally enough to land on the result. */ ?>
remote to tell you whether one is waiting. Queueing still works - cron runs the update from the command <div class="alert alert-info" data-itflow-reload-seconds="15">
line, which is usually not restricted the same way. Database updates are plain PHP and are unaffected. <span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
<strong>Checking for updates.</strong>
<?php if ($cron_is_running) { ?>
Cron runs the check within a minute; this page refreshes itself.
<?php } else { ?>
<strong class="text-warning">Nothing is going to pick it up</strong> - cron has not checked in
recently or the master switch is off. See
<a href="cron.php" class="alert-link">Maintenance &gt; Cron</a>.
<?php } ?>
</div> </div>
<?php } ?> <?php } ?>
@@ -144,6 +155,25 @@ $app_update_available = !empty($pending_commits);
</div> </div>
</div> </div>
<?php /* Outside the pending block on purpose - re-checking has to be reachable from the
up-to-date view too, which is where somebody goes when they have just read that
a release is out. */ ?>
<p class="text-muted mb-3">
<small>
<?php if (!empty($update_checked_at)) { ?>
Last checked <?= escapeHtml(timeAgo($update_checked_at)) ?>
(<?= escapeHtml($update_checked_at) ?>).
<?php } else { ?>
Never checked.
<?php } ?>
<?php if ($update_check_available && !$check_in_progress) { ?>
<a href="post.php?check_update&csrf_token=<?= $_SESSION['csrf_token'] ?>">Check now</a>
<?php } elseif ($check_in_progress) { ?>
<span class="text-secondary">Checking&hellip;</span>
<?php } ?>
</small>
</p>
<?php if (!empty($update_queued_at)) { ?> <?php if (!empty($update_queued_at)) { ?>
<div class="alert alert-info"> <div class="alert alert-info">
<i class="fas fa-fw fa-clock me-2"></i>An update was queued at <i class="fas fa-fw fa-clock me-2"></i>An update was queued at
@@ -168,7 +198,7 @@ $app_update_available = !empty($pending_commits);
<hr> <hr>
<?php if ($shell_available && !$app_update_available && !$db_update_available) { ?> <?php if (!empty($update_checked_at) && !$app_update_available && !$db_update_available) { ?>
<div class="text-center py-3"> <div class="text-center py-3">
<i class="far fa-3x fa-smile-wink text-dark"></i> <i class="far fa-3x fa-smile-wink text-dark"></i>
@@ -198,7 +228,7 @@ $app_update_available = !empty($pending_commits);
together under one button rather than as separate steps. The db_update_available 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 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. */ ?> exactly the state that needs queueing, and without it the button would not draw. */ ?>
<?php if ($app_update_available || $db_update_available || !$shell_available) { ?> <?php if ($app_update_available || $db_update_available || empty($update_checked_at)) { ?>
<div class="mb-4"> <div class="mb-4">
<h6 class="text-uppercase text-secondary">Pending</h6> <h6 class="text-uppercase text-secondary">Pending</h6>
@@ -208,11 +238,11 @@ $app_update_available = !empty($pending_commits);
<?= count($pending_commits) ?> commit<?= count($pending_commits) === 1 ? '' : 's' ?> <?= count($pending_commits) ?> commit<?= count($pending_commits) === 1 ? '' : 's' ?>
behind <code><?= escapeHtml("origin/$repo_branch") ?></code>. behind <code><?= escapeHtml("origin/$repo_branch") ?></code>.
</p> </p>
<?php } elseif (!$shell_available) { ?> <?php } elseif (empty($update_checked_at)) { ?>
<p class="mb-2"> <p class="mb-2">
<strong>Application files:</strong> this server cannot check <strong>Application files:</strong> this install has not checked
<code><?= escapeHtml("origin/$repo_branch") ?></code>, so there may or may not be anything <code><?= escapeHtml("origin/$repo_branch") ?></code> yet, so there may or may not be
waiting. Queueing an update when there is nothing to do is harmless. anything waiting. Queueing an update when there is nothing to do is harmless.
</p> </p>
<?php } ?> <?php } ?>
@@ -272,7 +302,9 @@ $app_update_available = !empty($pending_commits);
<?php foreach ($pending_commits as $commit) { ?> <?php foreach ($pending_commits as $commit) { ?>
<tr> <tr>
<td><code><?= escapeHtml($commit[0]) ?></code></td> <td><code><?= escapeHtml($commit[0]) ?></code></td>
<td class="text-nowrap"><?= escapeHtml($commit[1]) ?></td> <?php /* stored as an absolute ISO date by the check job - a stored
"2 hours ago" would be wrong the moment it was written */ ?>
<td class="text-nowrap"><?= escapeHtml(timeAgo($commit[1])) ?></td>
<td><?= escapeHtml($commit[2]) ?></td> <td><?= escapeHtml($commit[2]) ?></td>
</tr> </tr>
<?php } ?> <?php } ?>
@@ -284,6 +316,8 @@ $app_update_available = !empty($pending_commits);
</div> </div>
</div> </div>
<script src="../js/auto_reload.js"></script>
<?php <?php
require_once "../includes/footer.php"; require_once "../includes/footer.php";

View File

@@ -1156,7 +1156,7 @@ while ($row = mysqli_fetch_assoc($sql_invalid_recurring_expenses)) {
if ($config_telemetry > 0 || $config_telemetry == 2) { if ($config_telemetry > 0 || $config_telemetry == 2) {
$current_version = exec("git rev-parse HEAD"); $current_version = gitCurrentCommit();
// Client Count // Client Count
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('client_id') AS num FROM clients")); $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 // 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");
} }

81
cron/update_check.php Normal file
View File

@@ -0,0 +1,81 @@
<?php
/*
* ITFlow - Update check job
*
* Asks the git remote whether a newer release exists and stores the answer. Nothing in the
* web tier shells out any more, so this is the only place the question gets asked: the
* Update page and the nightly notification both read what this job wrote.
*
* Check Now on Maintenance > 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";

5
db.sql
View File

@@ -2349,6 +2349,9 @@ CREATE TABLE `settings` (
`config_backup_cron_type` varchar(20) NOT NULL DEFAULT 'full', `config_backup_cron_type` varchar(20) NOT NULL DEFAULT 'full',
`config_update_queued_at` datetime DEFAULT NULL, `config_update_queued_at` datetime DEFAULT NULL,
`config_internal_client_id` int(11) NOT NULL DEFAULT 0, `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`) PRIMARY KEY (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET character_set_client = @saved_cs_client */;
@@ -3193,4 +3196,4 @@ CREATE TABLE `vendors` (
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; /*!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

View File

@@ -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 * Reading git WITHOUT running git.
* 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() - * Everything below answers from the files in .git, so it works on hosts where exec() and
* shared hosting, a hardened php.ini, an FPM pool locked down while the CLI is not - lose the * shell_exec() are disabled and costs a couple of file reads instead of a process. Only
* "an update is waiting" readout, not the ability to update. * 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: <path>" 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 * 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 * read as well because some hardening extensions leave the function defined and refuse the
@@ -393,18 +583,47 @@ function checkForUpdates() {
$updates->current_version = ''; $updates->current_version = '';
$updates->latest_version = ''; $updates->latest_version = '';
$updates->update_message = "Cannot check for updates"; $updates->update_message = "Cannot check for updates";
$updates->pending_commits = [];
return $updates; return $updates;
} }
$remote_ref = escapeshellarg("origin/" . getRepoBranch());
// Fetch the latest code changes but don't apply them. stderr is merged in because git // 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 // 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. // breaks - it used to run a second git fetch of its own just to get the message.
exec("git fetch 2>&1", $output, $result); 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) { if ($current_version == $latest_version) {
$update_message = "No Updates available"; $update_message = "No Updates available";

View File

@@ -102,6 +102,14 @@ function cronJobRegistry(): array
'schedule' => 'Daily', 'schedule' => 'Daily',
'daily_at' => '03:30', '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', 'name' => 'app_update',
'label' => 'Application Update', 'label' => 'Application Update',

34
js/auto_reload.js Normal file
View File

@@ -0,0 +1,34 @@
/**
* Reload a page that is waiting on something cron will do.
*
* Opt in by putting data-itflow-reload-seconds="<n>" 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 <script>: inline scripts are the thing
* standing between ITFlow and a CSP without unsafe-inline, and this would have been another.
*/
(function () {
'use strict';
document.addEventListener('DOMContentLoaded', function () {
const waiting = document.querySelector('[data-itflow-reload-seconds]');
if (!waiting) {
return;
}
const seconds = parseInt(waiting.dataset.itflowReloadSeconds, 10);
if (!Number.isFinite(seconds) || seconds < 1) {
return;
}
setTimeout(function () {
window.location.reload();
}, seconds * 1000);
});
})();

View File

@@ -130,7 +130,7 @@ echo "\nDone.\n";
echo "\nNext steps:\n"; echo "\nNext steps:\n";
echo " 1. Log in with the credentials that were in use when the backup was taken.\n"; echo " 1. Log in with the credentials that were in use when the backup was taken.\n";
echo " 2. If the backup is older than the code in this directory, finish the database update:\n"; echo " 2. If the backup is older than the code in this directory, finish the database update:\n";
echo " php " . realpath(__DIR__) . "/update_cli.php\n"; echo " php " . realpath(__DIR__) . "/update_cli.php --update_db\n";
echo " 3. Check Maintenance > Cron - the schedule came back with the database.\n"; echo " 3. Check Maintenance > Cron - the schedule came back with the database.\n";
exit(0); exit(0);

View File

@@ -24,19 +24,25 @@ require_once "../config.php";
require_once "../functions.php"; require_once "../functions.php";
/* /*
* This script takes no options. It updates the application and then the database, in that * Run with no options this updates the application and then the database, in that order,
* order, because new code against an old schema is what breaks an install mid-upgrade. * because new code against an old schema is what breaks an install mid-upgrade.
* *
* The application update is a hard reset onto the branch this install tracks, so local * The application update is a hard reset onto the branch this install tracks, so local
* modifications to tracked files are discarded. Untracked files are left alone, which is * modifications to tracked files are discarded. Untracked files are left alone, which is
* everything an install keeps for itself: config.php, uploads/ and the custom/ directories. * everything an install keeps for itself: config.php, uploads/ and the custom/ directories.
*
* --update_db does the database half ON ITS OWN and never touches git. That matters on a box
* carrying work that is not pushed: the full run would hard-reset it away, and the database
* half is usually all that is wanted after a manual checkout or a restore.
*/ */
function printUsage($stream = STDOUT) { function printUsage($stream = STDOUT) {
fwrite($stream, "Usage: php update_cli.php\n\n"); fwrite($stream, "Usage: php update_cli.php [--update_db]\n\n");
fwrite($stream, "Updates the application to the latest code on the branch this install tracks,\n"); fwrite($stream, "With no options, updates the application to the latest code on the branch this\n");
fwrite($stream, "discarding local changes to tracked files, and then applies any outstanding\n"); fwrite($stream, "install tracks, discarding local changes to tracked files, and then applies any\n");
fwrite($stream, "database updates. There are no options.\n"); fwrite($stream, "outstanding database updates.\n\n");
fwrite($stream, " --update_db Apply outstanding database updates only. The application files\n");
fwrite($stream, " are left exactly as they are - git is not run at all.\n");
} }
/* /*
@@ -47,22 +53,39 @@ function printUsage($stream = STDOUT) {
$database_phase_only = getenv('ITFLOW_UPDATE_PHASE') === 'database'; $database_phase_only = getenv('ITFLOW_UPDATE_PHASE') === 'database';
/* /*
* The switches this script used to take are gone. Anything on the command line is refused * --db_update is accepted alongside --update_db on purpose. The two spellings have been used
* rather than ignored, so that an old --update_db call from a script or a set of notes * interchangeably in this project's own notes and release steps for years, and the cost of a
* cannot silently trigger a hard reset of the application instead. * typo here is the opposite of what was asked for - a hard reset instead of a database update.
* Anything else on the command line is still REFUSED rather than ignored, for the same reason.
*/ */
$database_only_requested = false;
$arguments = array_slice($argv, 1); $arguments = array_slice($argv, 1);
if (!$database_phase_only && count($arguments) > 0) { if (!$database_phase_only && count($arguments) > 0) {
if (in_array($arguments[0], ['--help', '-h', 'help'], true)) { if (count($arguments) === 1 && in_array($arguments[0], ['--help', '-h', 'help'], true)) {
printUsage(); printUsage();
exit; exit;
} }
fwrite(STDERR, "Error: this script takes no options.\n\n"); if (count($arguments) === 1 && in_array($arguments[0], ['--update_db', '--db_update', '--database'], true)) {
printUsage(STDERR);
exit(1); $database_phase_only = true;
$database_only_requested = true;
} else {
fwrite(STDERR, "Error: unrecognised option.\n\n");
printUsage(STDERR);
exit(1);
}
}
if ($database_only_requested) {
echo "Database updates only - the application files will not be touched.\n";
} }
// Whether the working tree actually moved. Decides how the database phase runs below // Whether the working tree actually moved. Decides how the database phase runs below

View File

@@ -754,27 +754,17 @@ if (isset($_POST['add_telemetry'])) {
// Section: Shell Commands // Section: Shell Commands
$shellCommands = []; $shellCommands = [];
if ($shell_exec_enabled) { // Located by walking PATH rather than by running `which`, so this reports
$commands = ['git']; // the truth on a host with shell_exec disabled. The no-shell branch this
// replaces also still listed whois and dig, which ITFlow stopped shelling
foreach ($commands as $command) { // out to when domain lookups moved to RDAP and native DNS
$which = trim(shell_exec("which $command 2>/dev/null")); foreach (['git'] as $command) {
$exists = !empty($which); $path = commandPath($command);
$shellCommands[] = [ $shellCommands[] = [
'name' => "Command '$command' available", 'name' => "Command '$command' available",
'passed' => $exists, 'passed' => $path !== '',
'value' => $exists ? $which : 'Not Found', 'value' => $path !== '' ? $path : 'Not Found',
]; ];
}
} else {
// If shell_exec is disabled, mark commands as unavailable
foreach (['whois', 'dig', 'git'] as $command) {
$shellCommands[] = [
'name' => "Command '$command' available",
'passed' => false,
'value' => 'shell_exec Disabled',
];
}
} }
// Section: SSL Checks // Section: SSL Checks