getMessage(); } } include "../functions.php"; // Global Functions include "../includes/database_version.php"; define('FROM_SETUP', true); require "seed_data.php"; // Shared install-time seed data require "setup_checks.php"; require "setup_database.php"; require "setup_forms.php"; require "setup_install.php"; // Setup's own session - for what a step has to say on the page after it if (session_status() === PHP_SESSION_NONE) { ini_set('session.cookie_httponly', 1); ini_set('session.cookie_samesite', 'Lax'); ini_set('session.use_strict_mode', 1); session_name('itflow_setup'); session_start(); } /* What a step has to say on the next page - $type success, info, warning or danger. */ function setupFlash($message, $type = 'success') { $_SESSION['alert_message'] = $message; $_SESSION['alert_type'] = $type; } /* * On to the next page. The restore page sends its upload itself, to show how * far it has got (js/setup.js, X-Setup-Upload), and goes where it is told - so * it is told in JSON rather than redirected, which would spend the message * meant for that page on a request nobody sees. */ function setupGo($location) { if (($_SERVER['HTTP_X_SETUP_UPLOAD'] ?? '') === '1') { header('Content-Type: application/json'); echo json_encode(['location' => $location]); } else { header("Location: $location"); } exit; } $mysqli_available = isset($mysqli) && $mysqli instanceof mysqli; $can_show_restore = false; /* * How far through the wizard this install already is. Each step is a separate question * because each one guards a different handler below - answering all of them with "are there * users?" is what used to lock the wizard out three steps early. * * $database_ready - config.php connects, and ITFlow's tables are there. * $install_is_live - users exist. A restore would destroy real data, so restore closes * and points at the CLI. One user row is enough. * $company_exists - the company step has run (companies row, and settings seeded). * $localization_done - the localization step has run (company_locale filled in). */ $database_ready = false; $install_is_live = false; $company_exists = false; $localization_done = false; $resume_step = file_exists("../config.php") ? 'database' : 'checks'; if (file_exists("../config.php") && $mysqli_available) { $table_result = mysqli_query($mysqli, "SHOW TABLES LIKE 'users'"); if ($table_result && mysqli_num_rows($table_result) > 0) { $database_ready = true; $resume_step = 'user'; $user_count_result = mysqli_query($mysqli, "SELECT COUNT(*) AS user_count FROM users"); if ($user_count_result) { $user_count_row = mysqli_fetch_assoc($user_count_result); if (intval($user_count_row['user_count']) > 0) { $install_is_live = true; } } else { // Cannot prove the install is empty, so treat it as live $install_is_live = true; } } if ($install_is_live) { $resume_step = 'company'; $company_result = mysqli_query($mysqli, "SELECT company_locale FROM companies WHERE company_id = 1"); if (!$company_result) { // Cannot prove either step is outstanding, so treat both as done $company_exists = true; $localization_done = true; $resume_step = 'telemetry'; } elseif ($company_row = mysqli_fetch_assoc($company_result)) { $company_exists = true; $resume_step = 'localization'; if (trim($company_row['company_locale'] ?? '') !== '') { $localization_done = true; $resume_step = 'telemetry'; } } } // Restore needs a database connection and an empty install. A populated one restores // from the command line instead - scripts/restore_cli.php. if (!$install_is_live) { $all_tables = mysqli_query($mysqli, "SHOW TABLES"); if ($all_tables && mysqli_num_rows($all_tables) > 0) { $can_show_restore = true; } } } /* * config.php is written when the database step completes, but $config_enable_setup is only * appended to it by the LAST step, so the flag is absent for the whole middle of an install * and the wizard has to stay open across that gap or it cannot be finished. * * Deriving the flag from the database instead - closing setup as soon as the install looked * "live" - is what stranded people: the first user made it live, three steps before there * were companies or settings rows, and /setup and /login.php then redirected at each other * until the browser gave up. Deriving it from any later step has the same shape, because the * step that writes the flag is behind the gate that reads it. * * So the page stays open until the flag says otherwise, and each handler below refuses to run * a second time on its own. That keeps the reason the derived flag was added in the first * place - the restore handler drops every table, imports whatever archive it is handed and * rewrites the uploads directory - without the page-level gate that came with it. */ if (!isset($config_enable_setup)) { $config_enable_setup = 1; } // Bar the page that says a backup was restored - which is what closed it $setup_restored_view = ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET' && isset($_GET['restored']) && !empty($_SESSION['setup_restored']); if ($config_enable_setup == 0 && !$setup_restored_view) { header("Location: /login.php"); exit; } include_once "../includes/settings_localization_array.php"; $errorLog = ini_get('error_log') ?: "Debian/Ubuntu default is usually /var/log/apache2/error.log"; // Get a list of all available timezones $timezones = DateTimeZone::listIdentifiers(); // The Database step's form, when it has something to say about what was typed $database_form = ['database' => '', 'host' => 'localhost', 'username' => '']; $database_error = ''; $config_manual = ''; // The account, company and region steps' forms - what was sent, and what is wrong with it $user_form = ['name' => '', 'email' => '']; $user_errors = []; $company_form = []; $company_errors = []; $region_form = []; $region_errors = []; if (isset($_POST['add_database'])) { // Check if database has been set up already. If it has, direct user to edit directly instead. if (file_exists('../config.php')) { setupFlash("The database is already set up - any change to it is made in config.php.", 'info'); header("Location: ?user"); exit; } $host = trim((string) ($_POST['host'] ?? '')); $database = trim((string) ($_POST['database'] ?? '')); $username = trim((string) ($_POST['username'] ?? '')); $password = (string) ($_POST['password'] ?? ''); $database_form = ['database' => $database, 'host' => $host, 'username' => $username]; if ($database === '' || $host === '' || $username === '') { $database_error = "Fill in the database's name, its host and the user to connect as."; } elseif (($host_problem = setupHostProblem($host)) !== '') { $database_error = escapeHtml($host_problem); } else { $connection = setupConnect($host, $username, $password, $database, $connect_error); if (!$connection) { $database_error = "Could not connect to " . escapeHtml($database) . " on " . escapeHtml($host) . " as " . escapeHtml($username) . ": " . escapeHtml($connect_error) . ". Check the details, and that the user may use that database."; } elseif (setupTables($connection)) { // Never run db.sql over tables that are already there $database_error = "" . escapeHtml($database) . " already has tables in it. A new install needs an empty database. To use an ITFlow database you already have, put its details in config.php by hand; to bring back a backup, create an empty database here first, then choose Restore a backup."; } elseif (!setupImportSchema($connection, '../db.sql', $import_error)) { $database_error = "Creating ITFlow's tables failed: " . escapeHtml($import_error) . ". The tables it had made were taken away again - fix the cause and try again."; } else { // The tables are there - now point ITFlow at them $installation_id = randomString(32); $new_config = setupConfigContents($host, $username, $password, $database, (string) ($_SERVER['HTTP_HOST'] ?? ''), $installation_id); if (@file_put_contents("../config.php", $new_config) !== false && file_exists('../config.php')) { setupConfigChanged("../config.php"); setupFlash("Connected to " . escapeHtml($database) . " and created ITFlow's tables - now your account."); header("Location: ?user"); exit; } // Written by hand instead - the page shows what to put in it $config_manual = $new_config; } } } if (isset($_POST['create_tables'])) { // config.php was made by hand, to an empty database - ITFlow's tables are still to create if ($database_ready || !$mysqli_available) { header("Location: ?database"); exit; } if (setupTables($mysqli)) { $database_error = "That database already has tables in it, but not ITFlow's. Point config.php at an empty database."; } elseif (!setupImportSchema($mysqli, '../db.sql', $import_error)) { $database_error = "Creating ITFlow's tables failed: " . escapeHtml($import_error) . ". The tables it had made were taken away again - fix the cause and try again."; } else { setupFlash("Created ITFlow's tables - now your account."); header("Location: ?user"); exit; } } /* * A backup sent to the restore page. One larger than post_max_size arrives with $_POST and * $_FILES both empty - so it is known by where it was sent, or it would not be answered at * all, and the message about using the command line would never be seen. */ $restore_sent = isset($_POST['restore']) || (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST' && isset($_GET['restore']) && empty($_POST) && empty($_FILES) && intval($_SERVER['CONTENT_LENGTH'] ?? 0) > 0); if ($restore_sent) { // Belt and braces: the page-level gate above already sends a live install to the login // page, but this handler is the destructive one, so it re-checks rather than trusting // that it was only reached through the form. if ($install_is_live || !$can_show_restore) { setupFlash("This install already has users. Restore over it from the command line instead: php scripts/restore_cli.php --file=/path/to/backup.zip", 'danger'); setupGo("?restore"); } // Too large for PHP to take - worth naming, because a real full backup is usually // bigger than the limit, and the command line has none. $max_upload_bytes = backupMaxUploadBytes(); $content_length = intval($_SERVER['CONTENT_LENGTH'] ?? 0); $too_large = false; if (!isset($_FILES['backup_zip']) && $content_length > 0 && $max_upload_bytes > 0 && $content_length > $max_upload_bytes) { $too_large = true; } elseif (isset($_FILES['backup_zip']) && in_array($_FILES['backup_zip']['error'], [UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE], true)) { $too_large = true; } if ($too_large) { setupFlash("That backup is too large to upload through a browser (this server accepts up to " . backupFormatBytes($max_upload_bytes) . "). Restore it from the command line instead - there is no size limit there. Copy the backup onto this server and run: php " . dirname(__DIR__) . "/scripts/restore_cli.php --file=/path/to/backup.zip", 'danger'); setupGo("?restore"); } if (!isset($_FILES['backup_zip']) || $_FILES['backup_zip']['error'] !== UPLOAD_ERR_OK) { setupFlash("No backup file was uploaded, or the upload failed.", 'danger'); setupGo("?restore"); } if (strtolower(pathinfo($_FILES['backup_zip']['name'], PATHINFO_EXTENSION)) !== 'zip') { setupFlash("Only .zip backup archives can be restored.", 'danger'); setupGo("?restore"); } // The key belongs to the install that MADE the backup, which on a rebuilt server is not // this one, so it is asked for rather than read from config.php. $restore_key = trim($_POST['backup_key'] ?? ''); if ($restore_key === '') { $restore_key = $config_backup_key ?? ''; } if ($restore_key === '') { setupFlash("Enter the backup encryption key. It is shown in Maintenance > Backup on the install that made this archive.", 'danger'); setupGo("?restore"); } $temp_zip = tempnam(sys_get_temp_dir(), "itflow_restore_upload_"); if (!move_uploaded_file($_FILES['backup_zip']['tmp_name'], $temp_zip)) { @unlink($temp_zip); setupFlash("Could not save the uploaded backup file.", 'danger'); setupGo("?restore"); } @chmod($temp_zip, 0600); $restore_error = null; $restored = backupRestoreArchive($mysqli, $temp_zip, $restore_key, $restore_error); @unlink($temp_zip); if (!$restored) { setupFlash(escapeHtml((string) $restore_error), 'danger'); setupGo("?restore"); } // Close setup behind us. The gate above would now do this on its own because the // restored database has users, but the flag is what stops the wizard being reachable // at all. The page that says so is the one setup page still open, once. $_SESSION['setup_restored'] = setupClose(__DIR__ . "/../config.php") ? 'closed' : 'open'; setupGo("?restored"); } if (isset($_POST['add_user'])) { // SELECT COUNT(*) returns exactly one row whatever the count is, so the mysqli_num_rows() // test this replaces was always 1 and never fired: a resubmitted form created a second // user and then died on the duplicate user_settings row. $install_is_live is the same // count, taken at the top of the file, and it fails closed. if ($install_is_live) { setupFlash("Users already exist in the database. Clear them to reconfigure here.", 'info'); header("Location: ?company"); exit; } // Each step after the database needs the one before it if (!$database_ready) { header("Location: ?$resume_step"); exit; } [$user_form, $user_errors] = setupUserForm($_POST); $avatar_problem = setupImageProblem($_FILES['file'] ?? null, ['jpg', 'jpeg', 'gif', 'png', 'webp'], [IMAGETYPE_JPEG, IMAGETYPE_GIF, IMAGETYPE_PNG, IMAGETYPE_WEBP], 'JPG, PNG, GIF or WebP'); if ($avatar_problem !== '') { $user_errors['file'] = $avatar_problem; } if (!$user_errors) { $user_id = setupCreateFirstUser($mysqli, $user_form); // Checked above - saved as Profile saves one $avatar_message = ''; $avatar_error = ''; if (!empty($_FILES['file']['tmp_name']) && !userSaveAvatar($user_id, $_FILES['file'], $avatar_error) && $avatar_error !== '') { $avatar_message = " - the avatar was left off: $avatar_error"; } setupFlash("Account " . escapeHtml($user_form['name']) . " created$avatar_message.", $avatar_message ? 'warning' : 'success'); header("Location: ?company"); exit; } } if (isset($_POST['add_company_settings'])) { // Run once. A second pass would add a second companies row and re-seed the defaults. if ($company_exists) { setupFlash("Company details have already been saved.", 'info'); header("Location: ?localization"); exit; } if (!$install_is_live) { header("Location: ?$resume_step"); exit; } [$company_form, $company_errors] = setupCompanyForm($_POST, $countries_array); $logo_problem = setupImageProblem($_FILES['file'] ?? null, ['jpg', 'jpeg', 'png'], [IMAGETYPE_JPEG, IMAGETYPE_PNG], 'JPG or PNG'); if ($logo_problem !== '') { $company_errors['file'] = $logo_problem; } if (!$company_errors) { // The company, and the defaults shared with the CLI installer setupCreateCompany($mysqli, $company_form); // Checked above - into uploads/settings, as Admin > Settings > Company saves one $logo_message = ''; if (!empty($_FILES['file']['tmp_name'])) { $new_file_name = checkFileUpload($_FILES['file'], ['jpg', 'jpeg', 'png']); if ($new_file_name && move_uploaded_file($_FILES['file']['tmp_name'], "../uploads/settings/$new_file_name")) { mysqli_query($mysqli, "UPDATE companies SET company_logo = '" . escapeSql($new_file_name) . "' WHERE company_id = 1"); } else { $logo_message = ' - the logo could not be saved, add it in Admin > Settings > Company'; } } setupFlash("Company " . escapeHtml($company_form['name']) . " saved$logo_message.", $logo_message ? 'warning' : 'success'); header("Location: ?localization"); exit; } } if (isset($_POST['add_localization_settings'])) { // Run once. A second pass would add a second Cash account. if ($localization_done) { setupFlash("Localization has already been saved.", 'info'); header("Location: ?telemetry"); exit; } if (!$company_exists) { header("Location: ?$resume_step"); exit; } [$region_form, $region_errors] = setupRegionForm($_POST, $locales_array, $currencies_array, $timezones); if (!$region_errors) { setupSaveRegion($mysqli, $region_form); setupFlash("Language, currency and timezone saved - one last step."); header("Location: ?telemetry"); exit; } } if (isset($_POST['add_telemetry'])) { // Finishing closes setup for good - not before the steps it needs are done if (!$localization_done) { header("Location: ?$resume_step"); exit; } if (($_POST['share_data'] ?? '') === '1') { setupShareTelemetry($mysqli, $installation_id ?? '', trim((string) ($_POST['comments'] ?? ''))); } // Close setup - the sign-in page is next if (!setupClose(__DIR__ . "/../config.php")) { setupFlash("Setup is done, but config.php could not be written to. Add this line to the end of it by hand, then sign in: \$config_enable_setup = 0;", 'danger'); header("Location: ?telemetry"); exit; } header("Location: ../login.php"); exit; } /* * ---------- the page ---------- */ // The steps, in order - each with its place in the wizard, and the page it is $setup_steps = [ 'welcome' => ['label' => 'Welcome', 'icon' => 'fa-paper-plane'], 'checks' => ['label' => 'Checks', 'icon' => 'fa-clipboard-check'], 'database' => ['label' => 'Database', 'icon' => 'fa-database'], 'user' => ['label' => 'Your account', 'icon' => 'fa-user'], 'company' => ['label' => 'Company', 'icon' => 'fa-building'], 'localization' => ['label' => 'Region', 'icon' => 'fa-globe-americas'], 'telemetry' => ['label' => 'Finish', 'icon' => 'fa-flag-checkered'], ]; $setup_step = 'welcome'; foreach (array_merge(array_keys($setup_steps), ['restore', 'restored']) as $key) { if ($key !== 'welcome' && isset($_GET[$key])) { $setup_step = $key; break; } } // A step's errors show on it - wherever its form was sent from if ($database_error !== '' || $config_manual !== '') { $setup_step = 'database'; } elseif ($user_errors) { $setup_step = 'user'; } elseif ($company_errors) { $setup_step = 'company'; } elseif ($region_errors) { $setup_step = 'localization'; } $setup_order = array_keys($setup_steps); $setup_resume_index = array_search($resume_step, $setup_order, true); $setup_current_index = array_search($setup_step, $setup_order, true); /* Where a step stands - done (behind where the install has got to), current or to come. */ function setupStepState($key) { global $setup_order, $setup_resume_index, $setup_step; if ($key === $setup_step) { return 'current'; } return array_search($key, $setup_order, true) < $setup_resume_index ? 'done' : 'todo'; } // The message a step left for this page $setup_alert = ''; $setup_alert_type = 'info'; if (!empty($_SESSION['alert_message'])) { $setup_alert = $_SESSION['alert_message']; $setup_alert_type = in_array($_SESSION['alert_type'] ?? '', ['success', 'info', 'warning', 'danger'], true) ? $_SESSION['alert_type'] : 'info'; $_SESSION['alert_message'] = ''; $_SESSION['alert_type'] = ''; } if ($setup_step === 'restored' && !$setup_restored_view) { $setup_step = 'welcome'; } $setup_title = ['restore' => 'Restore a backup', 'restored' => 'Backup restored'][$setup_step] ?? $setup_steps[$setup_step]['label']; ?> <?= escapeHtml($setup_title) ?> - ITFlow Setup
ITFlow Setup