Setup wizard stage 1: rebuild the setup wizard as a centred installer with a step bar and each step in its own file under setup/steps, split checks into required and recommended with fixes and Check again, make the database step explain connection failures, refuse a database that already has tables, create the tables before writing config.php and show its contents when it cannot be written, make each step wait for the one before, show the messages after each step, stop Finish halting on the telemetry reply or landing back on setup from a cached config.php, fix the show-password and copy buttons, and fit the wizard on a phone

This commit is contained in:
johnnyq
2026-10-20 11:24:37 -04:00
parent 717c6234d5
commit 9b95bb6d07
14 changed files with 1352 additions and 1024 deletions

155
setup/setup_checks.php Normal file
View File

@@ -0,0 +1,155 @@
<?php
/*
* What the server needs for ITFlow, and what it should have - the Checks
* step of the setup wizard (setup/index.php). Each check is a label, whether
* it passed, what was found, and what to do when it didn't. Required ones
* stop setup until they pass; recommended ones are what ITFlow works better
* with - HTTPS, a real domain name, room to upload a backup, and what
* one-click updates use.
*/
defined('FROM_SETUP') || die("Direct file access is not allowed");
/** The PHP version ITFlow needs. */
const SETUP_PHP_MINIMUM = '8.2.0';
/** What a backup or a large file upload needs, in bytes - 500 MB. */
const SETUP_UPLOAD_RECOMMENDED = 500 * 1024 * 1024;
/*
* The PHP extensions ITFlow can't run without - name => what it is for.
* mysqli the database, intl money and dates, curl mail and outside calls,
* mbstring text, gd images, xml mail and documents, openssl the encrypted
* passwords and 2FA codes, fileinfo checking what an upload really is.
*/
function setupRequiredExtensions() {
return [
'mysqli' => 'the database',
'intl' => 'money, numbers and dates in your language',
'curl' => 'mail and the services ITFlow talks to',
'mbstring' => 'text in any language',
'gd' => 'logos, avatars and images',
'xml' => 'mail and documents',
'openssl' => 'the encrypted passwords and 2FA codes',
'fileinfo' => 'checking what an uploaded file really is',
];
}
/* One check - its label, whether it passed, what was found, and how to fix it. */
function setupCheck($label, $passed, $value, $fix = '') {
return ['label' => $label, 'passed' => (bool) $passed, 'value' => (string) $value, 'fix' => $passed ? '' : $fix];
}
/*
* The site's address as the browser asked for it - its host without a port,
* and whether it came over HTTPS (directly, or through a proxy that says so).
*/
function setupRequestHost() {
$host = strtolower(trim((string) ($_SERVER['HTTP_HOST'] ?? '')));
return preg_replace('/:\d+$/', '', $host);
}
function setupRequestIsHttps() {
return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || intval($_SERVER['SERVER_PORT'] ?? 0) === 443
|| strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https';
}
/*
* Whether the certificate this host serves is current - true, false, or null
* when it can't be reached from here (behind a proxy, say). A few seconds at
* most, so a closed port doesn't hold the page up.
*/
function setupCertificateValid($host) {
if ($host === '') {
return null;
}
$context = stream_context_create(['ssl' => ['capture_peer_cert' => true, 'verify_peer' => false, 'verify_peer_name' => false, 'SNI_enabled' => true, 'peer_name' => $host]]);
$socket = @stream_socket_client("ssl://$host:443", $errno, $errstr, 4, STREAM_CLIENT_CONNECT, $context);
if (!$socket) {
return null;
}
$params = stream_context_get_params($socket);
fclose($socket);
$cert = $params['options']['ssl']['peer_certificate'] ?? null;
$info = $cert ? openssl_x509_parse($cert) : false;
if (!$info) {
return null;
}
return time() >= intval($info['validFrom_time_t']) && time() <= intval($info['validTo_time_t']);
}
/*
* Every check - ['required' => [...], 'recommended' => [...]], each a list of
* setupCheck(). $root is ITFlow's folder: config.php is written into it, and
* files go into its uploads folder.
*/
function setupChecks($root) {
$required = [];
$recommended = [];
$required[] = setupCheck('PHP ' . SETUP_PHP_MINIMUM . ' or later', version_compare(PHP_VERSION, SETUP_PHP_MINIMUM, '>='), 'PHP ' . PHP_VERSION,
'Upgrade PHP to ' . SETUP_PHP_MINIMUM . ' or later - your distribution\'s php8.x packages, or the ondrej/php repository on Debian and Ubuntu.');
foreach (setupRequiredExtensions() as $extension => $what) {
$loaded = extension_loaded($extension);
$required[] = setupCheck("PHP $extension extension", $loaded, $loaded ? 'Installed' : 'Not installed',
"Install it - <code>apt install php-$extension</code> on Debian and Ubuntu - and restart the web server. ITFlow uses it for $what.");
}
// config.php is written by the Database step, unless it is there already
$config = $root . '/config.php';
if (file_exists($config)) {
$required[] = setupCheck('config.php', true, 'Already there');
} else {
$writable = is_writable($root);
$required[] = setupCheck('ITFlow\'s folder can be written to', $writable, $writable ? 'Writable' : 'Not writable',
'Setup writes config.php into <code>' . escapeHtml($root) . '</code>. Make the folder writable by the web server - <code>chown -R www-data:www-data ' . escapeHtml($root) . '</code> on Debian and Ubuntu.');
}
$uploads = $root . '/uploads';
$writable = is_dir($uploads) && is_writable($uploads);
$required[] = setupCheck('The uploads folder can be written to', $writable, $writable ? 'Writable' : (is_dir($uploads) ? 'Not writable' : 'Missing'),
'Logos, avatars, attachments and files are kept in <code>' . escapeHtml($uploads) . '</code>. Create it if it is missing, and make it writable by the web server.');
// How it is reached
$host = setupRequestHost();
$https = setupRequestIsHttps();
$recommended[] = setupCheck('Reached over HTTPS', $https, $https ? 'Yes' : 'No',
'Sign-ins, passwords and client data cross the network - put a certificate on the site (Let\'s Encrypt is free) and come back to it on https://. ITFlow only lets you sign in over HTTPS unless config.php says otherwise.');
if ($https) {
$valid = setupCertificateValid($host);
if ($valid !== null) {
$recommended[] = setupCheck('The certificate is current', $valid, $valid ? 'Valid' : 'Expired or not yet valid', 'Renew the site\'s certificate.');
}
}
$fqdn = (bool) preg_match('/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i', $host);
$recommended[] = setupCheck('A domain name', $fqdn, $host !== '' ? $host : 'Unknown',
'Clients follow links in ITFlow\'s emails and use the client portal - give it a domain name, like itflow.example.com, rather than an IP address or a local name.');
// Room to upload a backup or a large file
$upload_max = backupParseIniBytes(ini_get('upload_max_filesize'));
$post_max = backupParseIniBytes(ini_get('post_max_size'));
$recommended[] = setupCheck('Uploads of 500 MB', $upload_max >= SETUP_UPLOAD_RECOMMENDED && $post_max >= SETUP_UPLOAD_RECOMMENDED,
'upload_max_filesize ' . ini_get('upload_max_filesize') . ', post_max_size ' . ini_get('post_max_size'),
'Restoring a backup and uploading large files need room - in php.ini set <code>upload_max_filesize = 500M</code> and <code>post_max_size = 500M</code>, and restart the web server.');
$zip = class_exists('ZipArchive');
$recommended[] = setupCheck('PHP zip extension', $zip, $zip ? 'Installed' : 'Not installed',
'Backups and client exports are zip files - install it, <code>apt install php-zip</code> on Debian and Ubuntu.');
// One-click updates (Maintenance > Update) run git, through exec and shell_exec
$disabled = array_map('trim', explode(',', (string) ini_get('disable_functions')));
$exec_off = array_values(array_filter(['exec', 'shell_exec'], function ($function) use ($disabled) {
return !function_exists($function) || in_array($function, $disabled, true);
}));
$git = commandPath('git');
$recommended[] = setupCheck('git, for one-click updates', !$exec_off && $git !== '', $exec_off ? implode(' and ', $exec_off) . ' turned off' : ($git !== '' ? $git : 'Not found'),
'Maintenance > Update fetches new versions with git. Install git' . ($exec_off ? ' and take ' . implode(' and ', $exec_off) . ' off disable_functions in php.ini' : '') . ' - or update by hand as the docs say.');
return ['required' => $required, 'recommended' => $recommended];
}
/* How many of a list of checks failed. */
function setupChecksFailed(array $checks) {
return count(array_filter($checks, function ($check) {
return !$check['passed'];
}));
}