From d7bc1bccf7a47f76a52f6b5f5fcd01339b900aec Mon Sep 17 00:00:00 2001 From: johnnyq Date: Thu, 5 Nov 2026 14:25:19 -0500 Subject: [PATCH] Make Maintenance > Update easier to follow - it opens with where the install stands (up to date, an update available, updating, the database still to finish, checking) and the one button for it, Update ITFlow in place of Queue Update, with what's new below it (the release notes between this install and the latest, read from the changelog by the update check, developer notes and commits folded away), what to do first including the ITFlow files changed on this install that updating would put back, the update shown running with the page refreshing itself and then when it finished or what stopped it, and this install's version, branch, database, commit, last check, last update, cron and the command to update from a shell alongside; database 3.5.7 adds settings.config_update_details, written by cron/update_check.php through the new updateCheckDetails(), updateChangelogSections() and updateChangelogHtml() render the release notes escaped, cron/app_update.php asks for a fresh check after an update, changelog updated --- CHANGELOG.md | 5 +- admin/database_updates/3.5.7.php | 24 ++ admin/post/update.php | 2 +- admin/update.php | 690 ++++++++++++++++++++----------- cron/app_update.php | 4 + cron/update_check.php | 13 +- css/itflow_custom.css | 7 + db.sql | 1 + functions/app.php | 169 ++++++++ 9 files changed, 667 insertions(+), 248 deletions(-) create mode 100644 admin/database_updates/3.5.7.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 83ff1dca6..05bfb31aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ This file documents all notable changes made to ITFlow. ### Upgrading -Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's two database changes (3.5.4 to 3.5.6) in the same pass. Nothing else is required. Everyone is signed out once after the update, agents and client portal contacts alike - see Security. The new Payment Provider Fees job in Maintenance > Cron is set up by itself. +Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's three database changes (3.5.4 to 3.5.7) in the same pass. Nothing else is required. Everyone is signed out once after the update, agents and client portal contacts alike - see Security. The new Payment Provider Fees job in Maintenance > Cron is set up by itself. ### Security @@ -22,6 +22,7 @@ Update from Maintenance > Update — Queue Update hands the job to cron, which u - API keys: agents are notified in ITFlow a week before an API key expires, so an integration doesn't stop without warning. - Payment Providers: Stripe's fee on a card payment is now recorded as an expense about 15 minutes after the payment, instead of the next morning. A new Payment Provider Fees job in Maintenance > Cron does it, and you can run it more often there. Record fees now still works any time. - Payment Providers: clients can pay the card fee. Turn on Clients pay the card fee on Stripe in Admin > Payment Providers and enter what Stripe charges you, as 2.9% plus 0.30. Each card payment is then made bigger by Stripe's fee on it, so you get the full invoice. The fee goes on the invoice as a Card Fee line, with no tax, when the payment goes through. Clients see it before they pay: on Pay invoices and the checkout page in the client portal, on the page an invoice's link opens, and next to autopay. It applies to payments in the client portal, through an invoice's link, by autopay, and when you charge a saved card, where the Charge Saved Card window shows it too. One payment for several invoices has one fee, shared over them. A client can be let off it with No card fee on their Billing tab. Refunding a card payment from ITFlow takes its Card Fee line back off the invoice. It is off until you turn it on. Card surcharges have rules where you and your clients are - in the US, for one, the card networks cap them and don't allow them on debit cards - so check them before turning it on. +- Maintenance > Update is easier to follow. It opens with where your install stands - up to date, an update available, an update running, or the database still to finish - and the one button for it, now Update ITFlow instead of Queue Update. Below it is what's new: the release notes for the versions between yours and the latest, read from the changelog each time ITFlow checks for updates, with the developer notes folded away and the commits behind them a click further. Before you update it lists what to do first, and says whether any of ITFlow's own files have been changed on your install, which updating would put back. Once you click Update ITFlow, the page shows the update running, refreshes itself until it's done, and then says when ITFlow was updated, or what stopped it. Alongside are this install's version, branch, database, when it last checked and updated, whether cron is running, and the command to run the same update from a shell, with the folder and user filled in for your server. ### Bug Fixes @@ -51,6 +52,8 @@ Update from Maintenance > Update — Queue Update hands the job to cron, which u - `paymentProviderCheck()` takes `card_fee`, `card_fee_percent` (0 to 15) and `card_fee_fixed` (0 to 100), needs at least one rate above 0 when it is on, and keeps the saved rates when it is off. `paymentProviderChangeLines()` and the Create audit line include it, through the new `paymentProviderCardFeeText()`. The client's switch is `agent/modals/client/client_card_fee_field.php`, shown on the Billing tab of Add and Edit Client while Stripe is set up, and saved through `card_fee_field` and `card_fee_exempt` in `agent/post/client_model.php`. - `formatPhoneNumber()` now formats with Google libphonenumber's rules (the data intl-tel-input's bundled utils use), from the new `functions/phone_formats.php`: for each calling code's main country, its national prefix, how that prefix is parsed off (`nationalPrefixForParsing` and its transform rule), the general number pattern and the number formats (pattern, format, last leading-digits pattern, national prefix formatting rule, international format). It's loaded the first time a number is formatted, and was made from libphonenumber v9.0.43's `PhoneNumberMetadata.xml` - its header says how, for a newer release. `phoneParts()` takes the national prefix off the stored digits as libphonenumber does (only when what's left still matches the general pattern, or the whole didn't), and a leading `00`+code or code typed into the number; `phoneFormatWith()` picks the first format whose leading digits and pattern fit. With the country code it writes the international format, without it the national format with the prefix rule; `+1` keeps `(412) 555-0100` after the code. A number no format fits shows its digits, without the prefix after a country code and as stored without one. Checked against intl-tel-input v29.2.3's utils on all 1,450 example numbers in the metadata, stored with and without the national prefix: the international format (`+1` apart, as above) and the dial number match for every one, and the national format for all but five that no format fits, which keep the 0 they were typed with. - New `phoneDialNumber($number, $country_code)` gives `+`, the code and the number without its national prefix for `tel:` links (just the digits without a code). `contactPhoneLink()` and `vendorDialNumber()` use it, as do the links that built `+code` and the stored digits themselves - the contact page and window, the client overview and list, the location page, the ticket and its quick look, the portal's home, contact and ticket pages, and the guest ticket page. The ticket quick look's link no longer runs the extension into the number; it dials it after a pause, as the contact page does. +- Database 3.5.7 adds `settings.config_update_details` (MEDIUMTEXT, JSON), written by `cron/update_check.php` through the new `updateCheckDetails()` in `functions/app.php`: `APP_VERSION` from `git show origin/:includes/app_version.php`, the `CHANGELOG.md` sections newer than the installed `APP_VERSION` (and the installed version's own section when commits are pending but the version hasn't moved, as on develop) from `updateChangelogSections()` - at most twelve, about 200 KB - and `git status --porcelain --untracked-files=no`, the first 50 paths and the count. It's written with `mysqli_real_escape_string()` rather than `escapeSql()`, which strips anything tag-shaped. Like the other update columns, `admin/update.php` and the job check for it first. +- `admin/update.php` is rewritten around one state (updating, checking, database, available, manual for a non-git install, unknown, current), with the existing reads kept and each new one guarded by `settingsColumnExists()`. `updateChangelogHtml()` renders the small part of Markdown `CHANGELOG.md` uses - `###` headings, `-` lists with indented continuation lines, paragraphs, `` `code` ``, `**bold**` and http(s) links - escaping everything first, and folds a Developer heading into `
`. A job left at Running past 10 minutes (the check) or 30 (the update) isn't treated as still going. The updating and checking states reload through `js/auto_reload.js`. `cron/app_update.php` sets `cron_job_run_now` on `update_check` after a successful update, so the page doesn't go on offering what was just installed. ## [26.11.2] diff --git a/admin/database_updates/3.5.7.php b/admin/database_updates/3.5.7.php new file mode 100644 index 000000000..85b726f7e --- /dev/null +++ b/admin/database_updates/3.5.7.php @@ -0,0 +1,24 @@ + Update. + * + * Re-runnable: skipped where the column is already there. + */ + +defined('FROM_DB_UPDATER') || die("Direct file access is not allowed"); + + $itflow_column_exists = mysqli_query($mysqli, "SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'settings' AND COLUMN_NAME = 'config_update_details' LIMIT 1"); + + if (!$itflow_column_exists || mysqli_num_rows($itflow_column_exists) === 0) { + mysqli_query($mysqli, "ALTER TABLE `settings` ADD `config_update_details` MEDIUMTEXT DEFAULT NULL AFTER `config_update_checked_at`"); + } + + unset($itflow_column_exists); diff --git a/admin/post/update.php b/admin/post/update.php index a65770330..8db0186c9 100644 --- a/admin/post/update.php +++ b/admin/post/update.php @@ -59,7 +59,7 @@ if (isset($_GET['queue_update'])) { logAudit("App", "Update", "$session_name queued an update to be applied by cron"); - flashAlert("Update queued - cron will start it within a minute."); + flashAlert("Updating ITFlow - cron starts within a minute, and this page shows how it goes."); redirect(); diff --git a/admin/update.php b/admin/update.php index 84fabb64d..6744e12d1 100644 --- a/admin/update.php +++ b/admin/update.php @@ -3,61 +3,73 @@ require_once "includes/inc_all_admin.php"; require_once "../includes/database_version.php"; -$repo_branch = getRepoBranch(); - /* + * Maintenance > Update. One answer at the top - up to date, an update waiting, one running, + * the database still to update - with the one button that acts on it, then what the update + * brings and what to do first, and this install's details alongside. + * * 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. + * Check Now and Update ITFlow ask the dispatcher for a run rather than working inside the + * request, so the page works the same on a host whose web PHP cannot run external commands. + * + * Every settings column it reads is checked for first. This page has to render on an install + * whose files are newer than its schema, because that is the state it exists to get people + * out of - mysqli throws on an unknown column, and the page would die before its button drew. */ -$current_version = gitCurrentCommit(); -// The stored answer arrives with a migration, and this page has to render on an install -// whose files are newer than its schema +$repo_branch = getRepoBranch(); +$current_version = gitCurrentCommit(); +$is_git_checkout = gitDir() !== ''; + $update_check_available = settingsColumnExists($mysqli, 'config_update_latest_commit'); +$update_details_available = settingsColumnExists($mysqli, 'config_update_details'); +$update_queue_available = settingsColumnExists($mysqli, 'config_update_queued_at'); $latest_version = ''; $update_checked_at = null; $pending_commits = []; $check_job = null; +$details = []; if ($update_check_available) { - $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")); + $update_check_row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_update_latest_commit, config_update_pending_commits, config_update_checked_at" + . ($update_details_available ? ", config_update_details" : '') . " FROM settings WHERE company_id = 1")); $latest_version = (string) ($update_check_row['config_update_latest_commit'] ?? ''); $update_checked_at = $update_check_row['config_update_checked_at'] ?? null; // 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 + // element rather than trusted wholesale - it is the shape the list indexes into $stored_commits = json_decode((string) ($update_check_row['config_update_pending_commits'] ?? ''), true); - 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); + $pending_commits[] = array_map('strval', array_values($stored_commit)); } } } - $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")); + $details = json_decode((string) ($update_check_row['config_update_details'] ?? ''), true); + $details = is_array($details) ? $details : []; + + $check_job = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT cron_job_run_now, cron_job_last_run_at, cron_job_last_status, 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 - * its own process. Neither column is in the global settings load - one page needs them. - * - * The column is checked for rather than assumed. This page has to render on an install whose - * files are newer than its schema, because that is the state it exists to get people out of. - */ -$update_queue_available = settingsColumnExists($mysqli, 'config_update_queued_at'); +// What the check found besides the commits (updateCheckDetails()) - each part checked for shape +$latest_release = is_string($details['version'] ?? null) ? $details['version'] : ''; +$changelog = []; +foreach ((array) ($details['changelog'] ?? []) as $section) { + if (is_array($section) && count($section) === 2 && is_string($section[0]) && is_string($section[1])) { + $changelog[] = $section; + } +} +$local_changes = array_values(array_filter((array) ($details['local_changes'] ?? []), 'is_string')); +$local_changes_count = intval($details['local_changes_count'] ?? count($local_changes)); +$details_known = isset($details['local_changes_count']); $update_queued_at = null; $cron_last_dispatch_at = null; @@ -65,23 +77,22 @@ $update_job = null; if ($update_queue_available) { - $update_cron_row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_update_queued_at, config_cron_last_dispatch_at FROM settings WHERE company_id = 1")); + $update_queued_at = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_update_queued_at FROM settings WHERE company_id = 1"))['config_update_queued_at'] ?? null; - $update_queued_at = $update_cron_row['config_update_queued_at'] ?? null; - $cron_last_dispatch_at = $update_cron_row['config_cron_last_dispatch_at'] ?? null; - - $update_job = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT cron_job_last_run_at, cron_job_last_status, cron_job_last_error, cron_job_last_error_at FROM cron_jobs WHERE cron_job_name = 'app_update' LIMIT 1")); + $update_job = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT cron_job_last_run_at, cron_job_last_finished_at, cron_job_last_status, cron_job_last_error, cron_job_last_error_at + FROM cron_jobs WHERE cron_job_name = 'app_update' LIMIT 1")); } -// Queueing is only worth offering if something is going to pick the request up -$cron_is_running = $update_queue_available - && !empty($config_enable_cron) +// The dispatcher's heartbeat - queueing is only worth offering if something will pick it up +if (settingsColumnExists($mysqli, 'config_cron_last_dispatch_at')) { + $cron_last_dispatch_at = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_cron_last_dispatch_at FROM settings WHERE company_id = 1"))['config_cron_last_dispatch_at'] ?? null; +} +$cron_is_running = !empty($config_enable_cron) && !empty($cron_last_dispatch_at) && strtotime($cron_last_dispatch_at) > strtotime('-5 minutes'); -// 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. +// version_compare, not > - "2.6.10" is less than "2.6.9" as a plain string comparison $db_update_available = version_compare(LATEST_DATABASE_VERSION, CURRENT_DATABASE_VERSION, '>'); // Derived from the two commits, not from the list: a force-push or a local commit leaves the @@ -90,230 +101,421 @@ $app_update_available = $latest_version !== '' && $current_version !== '' && $latest_version !== $current_version; +// 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 +// A job left at Running long past its time died without being recorded - not still going +$job_running = function ($job, $minutes) { + return ($job['cron_job_last_status'] ?? '') === 'Running' && !empty($job['cron_job_last_run_at']) + && strtotime($job['cron_job_last_run_at']) > strtotime("-$minutes minutes"); +}; +$check_in_progress = !empty($check_job['cron_job_run_now']) || $job_running($check_job, 10); +$check_failed = ($check_job['cron_job_last_status'] ?? '') === 'Failed' && !empty($check_job['cron_job_last_error']); +$check_stopped = str_starts_with((string) ($check_job['cron_job_last_status'] ?? ''), 'Stopped: ') + ? substr((string) $check_job['cron_job_last_status'], 9) : ''; + +$update_status = (string) ($update_job['cron_job_last_status'] ?? ''); +$update_running = !empty($update_queued_at) || $job_running($update_job, 30); +$update_failed = !$update_running && $update_status === 'Failed' && !empty($update_job['cron_job_last_error']); +$update_finished = !$update_running && $update_status === 'Completed' && !empty($update_job['cron_job_last_finished_at']) + && strtotime($update_job['cron_job_last_finished_at']) > strtotime('-1 hour'); + +// A release number to show when the branch moves by release (master), or nothing to compare +$new_release = $latest_release !== '' && version_compare($latest_release, APP_VERSION, '>') ? $latest_release : ''; +$commit_count = count($pending_commits); +$has_upgrading_notes = (bool) array_filter($changelog, function ($section) { + return preg_match('/^###\s+Upgrading/mi', $section[1]) === 1; +}); + +/* + * The one thing to say at the top. In this order, because each hides the ones after it: an + * update already on its way, a check on its way, a database behind its code (parts of ITFlow + * error until it catches up), an update waiting, then the quiet states. + */ +if ($update_running) { + $state = 'updating'; +} elseif ($check_in_progress) { + $state = 'checking'; +} elseif ($db_update_available) { + $state = 'database'; +} elseif ($app_update_available) { + $state = 'available'; +} elseif (!$is_git_checkout) { + $state = 'manual'; +} elseif (empty($update_checked_at)) { + $state = 'unknown'; +} else { + $state = 'current'; +} + +// Update ITFlow where there is something to update (or no telling), Check where a check can run +$show_update_button = in_array($state, ['database', 'available', 'unknown'], true) && $update_queue_available; +$show_check_button = in_array($state, ['available', 'unknown', 'current'], true) && $update_check_available && $is_git_checkout; + +$queue_url = "post.php?queue_update&csrf_token=" . urlencode($_SESSION['csrf_token']); +$check_url = "post.php?check_update&csrf_token=" . urlencode($_SESSION['csrf_token']); + +// What the confirmation says - the parts that differ from one install to the next first +$confirm_text = "Cron updates ITFlow's files and then its database in the background, within a minute."; +if ($local_changes_count > 0) { + $confirm_text .= " The $local_changes_count file" . ($local_changes_count === 1 ? '' : 's') . " changed on this install will be put back to the released version" . ($local_changes_count === 1 ? '' : 's') . "."; +} +$confirm_text .= " Make sure you have a backup first."; + +// The command line, as this server runs it - the user that owns the files, in this folder +$install_root = dirname(__DIR__); +$file_owner = ''; +if (function_exists('posix_getpwuid')) { + $file_owner = (string) (posix_getpwuid((int) @fileowner($install_root . '/scripts/update_cli.php'))['name'] ?? ''); +} +$shell_word = function ($word) { + return preg_match('/^[A-Za-z0-9_\/.@%+=:,-]+$/', $word) ? $word : escapeshellarg($word); +}; +$cli_command = "cd " . $shell_word($install_root) . "\n" . ($file_owner !== '' ? "sudo -u " . $shell_word($file_owner) . " " : '') . "php scripts/update_cli.php"; + +$hero = [ + 'updating' => ['icon' => 'fa-sync-alt fa-spin', 'tone' => 'primary', 'title' => 'Updating ITFlow'], + 'checking' => ['icon' => 'fa-circle-notch fa-spin', 'tone' => 'secondary', 'title' => 'Checking for updates'], + 'database' => ['icon' => 'fa-database', 'tone' => 'warning', 'title' => 'Finish updating ITFlow'], + 'available' => ['icon' => 'fa-arrow-alt-circle-up', 'tone' => 'primary', 'title' => $new_release !== '' ? "ITFlow $new_release is available" : 'An update is available'], + 'manual' => ['icon' => 'fa-box-open', 'tone' => 'secondary', 'title' => 'This install is updated by hand'], + 'unknown' => ['icon' => 'fa-question', 'tone' => 'secondary', 'title' => 'ITFlow has not checked for updates yet'], + 'current' => ['icon' => 'fa-check', 'tone' => 'success', 'title' => 'ITFlow is up to date'], +][$state]; + ?> -
-
-

Update

+ +
+
The last update did not finish
+

It stopped . A database update that fails part way stops without recording its version, so once the cause is fixed it is safe to run the update again.

+
+

The forum can help - include 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. -
- Recorded . 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. -
- + +
+ ITFlow was updated - this is ' . escapeHtml(substr($current_version, 0, 7)) . '' : '' ?>. + +
+ - - -
- - 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. + +
+
The last update check did not finish
+

What this page says about updates is from the check before it, so it may be out of date. Check that Git is installed and that this server can reach GitHub.

+
+
+ + +
+ +
+ + +
+
> +
+ +
+

+ + +

+ + Requested . + + Started . + + Cron updates the files and then the database in the background - usually a minute or two. + You can leave this page; you'll get a notification when it's done. +

+ +

Cron hasn't run in the last five minutes, or it's switched off, so nothing will pick the update up until it does. See Maintenance > Cron.

+ + + +

Cron checks within a minute; this page refreshes itself.

+ +

Cron hasn't run in the last five minutes, or it's switched off, so the check won't happen until it does. See Maintenance > Cron.

+ + + +

+ ITFlow's files are newer than its database - the database is at + and these files need . Parts of ITFlow may show errors until it catches up. + A newer version is waiting too, and comes with it. +

+ + +

+ + You're on . + + change on since this copy. + + See what's new below, and take a backup first. +

+ + +

+ It wasn't installed with Git, so it can't fetch new versions itself. Put the new release's files in place by hand, as the + docs describe - this page then updates the database. +

+ + +

+ + The last check stopped: . + + The Update Check job does it every night. + +

+ + +

You're on . Everything is going to be alright.

+ +

While you're here - when did you last check that your backup restores?

+ + + + +
+ + + + + + + + + + +
+ +
+
+ + + +
+ This install has to be updated from a shell once. Updating from this page needs a database change it + hasn't caught up with yet. Run the command under From the command line, and this page works from then on. +
+ +
+ Cron hasn't run in the last five minutes, or it's switched off, so an update would wait until it does. + Check Maintenance > Cron, or update from the command line. +
- -
-
- Release -
-
-
- Branch -
- - Not the release branch - -
-
- Database -
- - - → + + +
+
+ +
+
Take a backup
+
A snapshot of the whole server is safest - you can roll back in a minute. Otherwise make a Full Backup in + Maintenance > Backup and copy it off the server.
+
+
+
+ +
+
Read what's changing
+
+ + Each release's Upgrading notes below say whether it needs a step of its own - ITFlow won't do those for you. + + What's new is below. Some releases need a step of their own, and say so. + + Some releases need a step of their own - the + changelog says. + +
+
+
+ +
+ +
+ 0) { ?> +
of ITFlow's files been changed on this install
+
Updating puts back the released version, so lost. Your config.php, uploads and custom folders are never touched.
+
+ Which files +
    + +
  • + + count($local_changes)) { ?> +
  • and more
  • + +
+
+ +
Nothing of yours is overwritten
+
None of ITFlow's own files have been changed on this install. Your config.php, uploads and custom folders are never touched.
+ +
+
+ +
+ +
+
Pick a quiet time
+
Your team may see errors for a minute or two while it runs.
+
+
+
+ +
+ + + +
+
+

What's new

+
+
+ + + [$section_version, $section_markdown]) { ?> + +
+

+ + + The version you're on, with changes since + +

+ +
+ +
+ +
+
+ + + +

The release notes are in the + changelog. + Check again to see them here.

+ + +
-
- Commit -
+ + +
+ +
+ + +
+
+

This install

+
+
    +
  • + Version + → ' . escapeHtml($new_release) . '' : '' ?> +
  • +
  • + Updates from + + + + Not the release branch - for test copies + + +
  • +
  • + Database + → ' . escapeHtml(LATEST_DATABASE_VERSION) . '' : '' ?> +
  • +
  • + Commit + —' : '' . escapeHtml(substr($current_version, 0, 7)) . '' ?> +
  • +
  • + Last checked + + + + + Never + + +
  • +
  • + Last updated + + + + Failed + + Not from this page yet + + +
  • +
  • + Cron + + + +
  • +
+
+ + +
+
+

From the command line

+
+
+

+ + Does the same as - the files, then the database - without waiting for cron: + + Updates the database once the new release's files are in place: + +

+
+
+ +
+

Add --update_db to update only the database - after restoring an older backup, say. See + Updating ITFlow.

- -

- - - Last checked - (). - - Never checked. - - - Check now - - Checking… - - -

- - -
- An update was queued at - . - - Cron will start it within a minute; this page will show the new version once it finishes. - - Nothing is going to pick it up - cron has not checked in recently or the master - switch is off. See Maintenance > Cron. - -
- - - -
-
The last queued update did not finish
-
- Recorded . Clear it from - Maintenance > Cron once it has been dealt with. -
- - -
- - - -
- -

You are up to date

-

Everything is going to be alright.

-
- - - - - - - -
-
Do not update without a backup
- A VM snapshot is the safest option - other methods are covered in the - docs. Read the - changelog - first: some releases need manual steps, and this page will not do them for you. -
- - - -
-
Pending
- - -

- Application files: - commit - behind . -

- -

- Application files: this install has not checked - yet, so there may or may not be - anything waiting. Queueing an update when there is nothing to do is harmless. -

- - - -

- Database: schema is at - and this code expects - . Parts of the app will error - until the update runs. -

- - - - - Queue Update - - - -

- - - Queue Update hands the job to cron, which runs - scripts/update_cli.php in its own process - it updates the files and then - applies every pending migration, with no request timeout, as the user that owns the - files. It resets the files to , so - local changes to them are lost. A migration that fails part way stops without advancing - the recorded version, so it is safe to queue again once the cause is fixed. - - Cron is not checking in, so a queued update will sit there - until it is. - - - This install cannot queue yet. Queueing needs a schema change it has not - caught up with, so this one has to be started from a shell: - php scripts/update_cli.php, as the user that owns the files. It updates the - files and the database in one step, and Queue Update works from then on. - - -

-
- - - - - -
Pending commits
-
- - - - - - - - - - - - - - - - - - -
CommitWhenDescription
-
- -
+
diff --git a/cron/app_update.php b/cron/app_update.php index c8bcb2161..2a97ad7d5 100644 --- a/cron/app_update.php +++ b/cron/app_update.php @@ -118,3 +118,7 @@ if ($app_update_return !== 0) { appNotify("Update", "The queued update finished", "/admin/update.php"); logAudit("App", "Update", "Cron applied a queued update"); + +// What the Update page knows about updates is from before this one - check again, so it doesn't +// go on offering what was just installed (the dispatcher runs it next cycle, on the new code) +mysqli_query($mysqli, "UPDATE cron_jobs SET cron_job_run_now = 1 WHERE cron_job_name = 'update_check'"); diff --git a/cron/update_check.php b/cron/update_check.php index f5ade90f3..56d0c1398 100644 --- a/cron/update_check.php +++ b/cron/update_check.php @@ -11,8 +11,10 @@ * 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. + * Safe to run twice: it reads and overwrites, and changes nothing outside the settings + * columns it owns. Besides the commits it stores what the Update page shows about the update + * (updateCheckDetails()): the release it brings, the changelog up to it, and the files this + * install has changed that updating would put back. */ // Set working directory to the directory this cron script lives at. @@ -78,4 +80,11 @@ mysqli_query($mysqli, "UPDATE settings SET config_update_checked_at = '" . date('Y-m-d H:i:s') . "' WHERE company_id = 1"); +// Arrives with a later migration than the columns above. Not escapeSql(): it strips anything +// tag-shaped, and the changelog quotes markup in its examples +if (settingsColumnExists($mysqli, 'config_update_details')) { + $update_check_details = mysqli_real_escape_string($mysqli, (string) json_encode(updateCheckDetails(count($updates->pending_commits)))); + mysqli_query($mysqli, "UPDATE settings SET config_update_details = '$update_check_details' WHERE company_id = 1"); +} + echo count($updates->pending_commits) . " commit(s) behind the remote\n"; diff --git a/css/itflow_custom.css b/css/itflow_custom.css index fc91521fe..ba865906a 100644 --- a/css/itflow_custom.css +++ b/css/itflow_custom.css @@ -9287,3 +9287,10 @@ a.itflow-portal-request:focus-visible { width: 460px; } } + +/* A shell command to copy (Maintenance > Update) - its lines kept, a long path wrapped + rather than scrolled out of sight */ +.itflow-command { + white-space: pre-wrap; + word-break: break-all; +} diff --git a/db.sql b/db.sql index 474ecad4f..81d1a217b 100644 --- a/db.sql +++ b/db.sql @@ -3186,6 +3186,7 @@ CREATE TABLE `settings` ( `config_update_latest_commit` varchar(40) DEFAULT NULL, `config_update_pending_commits` text DEFAULT NULL, `config_update_checked_at` datetime DEFAULT NULL, + `config_update_details` mediumtext DEFAULT NULL, `config_share_max_hours` int(11) NOT NULL DEFAULT 720, `config_share_require_protection` tinyint(1) NOT NULL DEFAULT 0, `config_login_alert_failures` int(11) NOT NULL DEFAULT 10, diff --git a/functions/app.php b/functions/app.php index 501ed3574..d2066e2ff 100644 --- a/functions/app.php +++ b/functions/app.php @@ -572,6 +572,175 @@ function checkForUpdates() { } +/* + * What the Update page says about an update besides its commits, asked by + * cron/update_check.php once checkForUpdates() has fetched - the page never runs git. + * + * version APP_VERSION on the remote branch - the release an update brings + * changelog [[version, markdown], ...], newest first: the CHANGELOG.md sections + * newer than this install's release, and on a branch that keeps one + * version while work lands (develop), that version's section too + * local_changes ITFlow's own files this install has changed, which updating puts back + * (update_cli.php resets to the remote) - the first 50, and how many in all + * + * Anything that can't be read comes back empty rather than failing the check. + */ +function updateCheckDetails(int $pending_commits): array +{ + if (!defined('APP_VERSION')) { + require_once __DIR__ . '/../includes/app_version.php'; + } + + $details = ['version' => '', 'changelog' => [], 'local_changes' => [], 'local_changes_count' => 0]; + + if (!shellCommandsAvailable()) { + return $details; + } + + $remote = "origin/" . getRepoBranch(); + + $version_file = (string) shell_exec("git show " . escapeshellarg("$remote:includes/app_version.php") . " 2>/dev/null"); + if (preg_match('/DEFINE\(\s*["\']APP_VERSION["\']\s*,\s*["\']([^"\']+)["\']\s*\)/i', $version_file, $version_match)) { + $details['version'] = $version_match[1]; + } + + $changelog = (string) shell_exec("git show " . escapeshellarg("$remote:CHANGELOG.md") . " 2>/dev/null"); + $include_current = $pending_commits > 0 && $details['version'] === APP_VERSION; + $details['changelog'] = updateChangelogSections($changelog, APP_VERSION, $include_current); + + $changed = []; + exec("git status --porcelain --untracked-files=no 2>/dev/null", $changed); + foreach ($changed as $line) { + $path = trim(substr($line, 3)); + if ($path !== '') { + $details['local_changes_count']++; + if (count($details['local_changes']) < 50) { + $details['local_changes'][] = $path; + } + } + } + + return $details; +} + +/* + * The sections of a CHANGELOG.md newer than $installed - [[version, markdown], ...] in the + * file's order, newest first - plus $installed's own when $include_current. At most twelve + * releases and about 200 KB, so an install far behind still stores a sensible amount. + */ +function updateChangelogSections(string $markdown, string $installed, bool $include_current = false): array +{ + preg_match_all('/^## \[([^\]\n]+)\][^\n]*\n(.*?)(?=^## \[|\z)/ms', $markdown, $matches, PREG_SET_ORDER); + + $sections = []; + $size = 0; + + foreach ($matches as [, $version, $body]) { + $version = trim($version); + $newer = $installed === '' || version_compare($version, $installed, '>'); + + if (!$newer && !($include_current && $version === $installed)) { + continue; + } + + $body = trim($body); + $size += strlen($body); + + if (count($sections) >= 12 || ($sections && $size > 200000)) { + break; + } + + $sections[] = [$version, $body]; + } + + return $sections; +} + +/* + * One changelog section as HTML for the Update page. CHANGELOG.md uses a small part of + * Markdown - ### headings, - lists, paragraphs, `code`, **bold** and links - and only that is + * understood. Everything is escaped first, so nothing in the file reaches the page as markup. + * Developer Updates is folded away: the people reading this page are the MSP's admins. + */ +function updateChangelogHtml(string $markdown): string +{ + $inline = function (string $text): string { + $text = escapeHtml($text); + $text = preg_replace('/`([^`]+)`/', '$1', $text); + $text = preg_replace('/\*\*([^*]+)\*\*/', '$1', $text); + return preg_replace_callback('/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/', function ($link) { + return '' . $link[1] . ''; + }, $text); + }; + + // Into its ### parts - the text before the first heading has none + $parts = []; + $title = ''; + $lines = []; + foreach (preg_split('/\r?\n/', $markdown) as $line) { + if (preg_match('/^###\s+(.+)$/', $line, $heading)) { + if ($title !== '' || trim(implode('', $lines)) !== '') { + $parts[] = [$title, $lines]; + } + $title = trim($heading[1]); + $lines = []; + continue; + } + $lines[] = $line; + } + if ($title !== '' || trim(implode('', $lines)) !== '') { + $parts[] = [$title, $lines]; + } + + $html = ''; + foreach ($parts as [$title, $lines]) { + // Paragraphs and lists - a line indented under a list item carries on that item + $body = ''; + $paragraph = []; + $items = []; + $flush = function () use (&$body, &$paragraph, &$items, $inline) { + if ($paragraph) { + $body .= '

' . $inline(implode(' ', $paragraph)) . '

'; + $paragraph = []; + } + if ($items) { + $body .= '
    ' . implode('', array_map(function ($item) use ($inline) { + return '
  • ' . $inline($item) . '
  • '; + }, $items)) . '
'; + $items = []; + } + }; + foreach ($lines as $line) { + if (trim($line) === '') { + $flush(); + } elseif (preg_match('/^\s{0,3}[-*]\s+(.*)$/', $line, $item)) { + if ($paragraph) { + $flush(); + } + $items[] = $item[1]; + } elseif ($items && preg_match('/^\s+\S/', $line)) { + $items[count($items) - 1] .= ' ' . trim($line); + } else { + if ($items) { + $flush(); + } + $paragraph[] = trim($line); + } + } + $flush(); + + if ($title === '') { + $html .= $body; + } elseif (stripos($title, 'developer') !== false) { + $html .= '
' . escapeHtml($title) . '
' . $body . '
'; + } else { + $html .= '
' . escapeHtml($title) . '
' . $body; + } + } + + return $html; +} + function addToMailQueue($data) { global $mysqli;