diff --git a/CHANGELOG.md b/CHANGELOG.md index 85a4953f8..4190f38fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1018,6 +1018,9 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Your account: the password is typed twice, each with its eye, and the avatar shows once picked. A missing name, an email that isn't one, a short password, passwords that differ or a file that isn't a picture is said beside the field before anything is sent - and by the server too, with the name and email kept. - Company: the phone takes its country code from the country picked, as in Admin > Settings > Company, and the logo shows once picked. A mistake comes back with everything typed kept, and the website is saved without its http:// and trailing slash. - Region starts on the currency of the company's country, a language of that country, your browser's where there is one (French (Canada) for a French browser in Canada, English (Canada) for others) and your browser's timezone - or the country's, when it has only one - and shows money and a date as they will look. + - Finish says what to know before signing in - that sign-in needs HTTPS when the page came over http://, with the config.php line for a test install; your backup key, made there and then so it can be kept, with a copy button; and the address links in emails go to - then what to do once signed in: cron (its crontab line, to copy), mail, backups, starter content and your team. Telling the ITFlow project about the install is off unless switched on, and shows exactly what it sends. + - Restore a backup: drop it on the page or choose it. One larger than the server takes through a browser is pointed at the command line before it is sent; otherwise the upload shows how far it has got, then that it is restoring, and a page says when it is done. + - `scripts/setup_cli.php` runs the wizard's checks and checks what it is given the same way, asks again for an answer that is wrong (the password twice, not shown), suggests the language, currency and timezone from the country, and ends with the backup key and the crontab line. `--non-interactive` asks nothing at all. New options: `--phone-country-code`, `--tax-id`, `--share-telemetry` and `--comments`. ### Bug Fixes @@ -1154,6 +1157,10 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Setup: a password with a space at the start or end could not sign in - setup saved it without them. - Setup: the account, company and region steps saved whatever they were sent - no name, an email that isn't one, a language, currency or timezone not on the list - and an avatar or logo was taken on its name alone. - Setup: the company's phone was saved without its country code. +- Setup: a backup larger than PHP takes was never answered - the page came back as it was, without the message about restoring from the command line. +- Setup: after a restore, the message that it had worked was lost on the way to the sign-in page. +- `setup_cli.php --non-interactive` stopped and waited at the optional questions - the address, phone, website and branch. +- `setup_cli.php`: a name with an apostrophe in it stopped the install part-way, a password with a space at either end couldn't sign in, and it wrote config.php before knowing the tables could be made - over a database that already had tables in it, too. ### Developer Updates @@ -1704,13 +1711,15 @@ If you are coming from 26.08 or earlier, the database update still has to be run - `agent/js/file_delete_modal.js`, `certificate_fetch_ssl.js`, `credential_show_password_via_id.js` and `credential_show_otp_via_id.js` - `title` attributes were added to about 300 icon-only buttons, and `aria-label` to the show-password buttons and the portal's mobile menu button. - API: `api/v1/services/read.php` is new, with `services` mapped to `module_support` in `enforce_api_rbac.php`. Ticket create and update use `ticketClientOwnedId()` and `ticketAgentId()`, and certificate create uses `certificateFormSet()`. -- Setup: `setup/index.php` keeps the handlers and draws the frame; each step's page is `setup/steps/.php` (`welcome`, `checks`, `database`, `user`, `company`, `localization`, `telemetry`, `restore`). +- Setup: `setup/index.php` keeps the handlers and draws the frame; each step's page is `setup/steps/.php` (`welcome`, `checks`, `database`, `user`, `company`, `localization`, `telemetry`, `restore`, `restored`). - `setup/setup_checks.php`: `setupChecks($root)` returns `required` and `recommended` lists of `setupCheck($label, $passed, $value, $fix)`, with `setupChecksFailed()`. `SETUP_PHP_MINIMUM` is 8.2.0; `setupRequiredExtensions()` adds `openssl` and `fileinfo` to the six there were; the git check needs both `exec` and `shell_exec`, which the updater uses. - `setup/setup_database.php`: `setupConnect()` catches `mysqli_sql_exception` and says why; `setupTables()`; `setupImportSchema()` runs `db.sql` a statement at a time and drops what it made if one fails; `setupConfigContents()` writes the values with `var_export()`; `setupClose()` appends `$config_enable_setup = 0` and `setupConfigChanged()` drops opcache's copy of `config.php`. - `config.php` is included inside a try/catch, its error in `$config_error`. Setup has its own session (`itflow_setup`) and `setupFlash($message, $type)`. - New POST `create_tables`. `add_user` needs `$database_ready`, `add_company_settings` `$install_is_live`, `add_localization_settings` `$company_exists` and `add_telemetry` `$localization_done` - each otherwise goes to `?$resume_step`. - `setup/setup_forms.php`: `setupUserForm()`, `setupCompanyForm()` and `setupRegionForm()` each return the values and the errors by field, drawn with `setupFieldInvalid()` and `setupFieldError()` (`data-field-error`); `setupImageProblem()` checks a picture with `getimagesize()`; Region starts on `setupSuggestCurrency()` (intl's currency for the country), `setupSuggestLocale()` (the country and `Accept-Language`) and `setupSuggestTimezone()`. The avatar is saved with `userSaveAvatar()`, and the company's `company_phone_country_code` is set. - `js/setup.js`: `form[data-setup-form]` checked before it is sent, `data-setup-same-as`, `data-setup-image` with its `data-setup-preview`, `data-setup-browser-timezone`, the Region sample, and each setup form sent once. The company step loads intl-tel-input and `js/phone_inputs.js`. + - `setup/setup_install.php`: what setup writes - `setupCreateFirstUser()`, `setupCreateCompany()`, `setupSaveRegion()`, `setupTelemetryDetails()` and `setupShareTelemetry()` - shared by the wizard and `scripts/setup_cli.php`, which now also uses `setupChecks($root, false)` (no HTTPS, domain name or upload size from the command line), `setupHostProblem()`, `setupConnect()`, `setupImportSchema()`, `setupConfigContents()` (which takes `$repo_branch`) and the form checks. It exits 1 with the reason on stderr; `--password` may be empty, and `--locale`, `--currency` and `--timezone` default to the country's when there is one. + - Restore: the page sends the upload itself with `X-Setup-Upload: 1`, and `setupGo()` answers it in JSON with where to go. A POST to `?restore` that PHP emptied (over `post_max_size`) is answered too. `?restored` is shown once, from `$_SESSION['setup_restored']`, even though setup is closed. Finish makes the backup key with `backupEncryptionKey()`. - `.itflow-setup*` styles in `css/itflow_custom.css`. - `js/app.js`: the four `tinymce.init()` calls go through `itflowTinyMce`, which does nothing where TinyMCE isn't loaded. diff --git a/css/itflow_custom.css b/css/itflow_custom.css index 93b932a4d..060a08cd4 100644 --- a/css/itflow_custom.css +++ b/css/itflow_custom.css @@ -8033,6 +8033,78 @@ a.itflow-portal-request:focus-visible { object-fit: contain; padding: .2rem; } +/* Finish - what to do once signed in, numbered */ +.itflow-setup-next { + counter-reset: itflow-setup-next; +} +.itflow-setup-next > li { + counter-increment: itflow-setup-next; + position: relative; + padding: .45rem 0 .45rem 2.1rem; +} +.itflow-setup-next > li::before { + content: counter(itflow-setup-next); + position: absolute; + left: 0; + top: .45rem; + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + background: var(--bs-tertiary-bg); + border: 1px solid var(--bs-border-color); + font-size: .75rem; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; +} +/* Finish - telling the ITFlow project, and exactly what that sends */ +.itflow-setup-share { + border: 1px solid var(--bs-border-color); + border-radius: .5rem; + padding: 1rem; +} +.itflow-setup-share-list { + display: grid; + grid-template-columns: max-content 1fr; + gap: .15rem 1rem; +} +.itflow-setup-share-list dt { + font-weight: 400; + color: var(--bs-secondary-color); +} +.itflow-setup-share-list dd { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; +} +/* Restore - where a backup is dropped */ +.itflow-setup-drop { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: .15rem; + padding: 1.5rem 1rem; + margin-bottom: .5rem; + border: 2px dashed var(--bs-border-color); + border-radius: .5rem; + background: var(--bs-tertiary-bg); + cursor: pointer; + overflow-wrap: anywhere; + transition: border-color .15s, background-color .15s; +} +.itflow-setup-drop:hover, +.itflow-setup-drop.is-over { + border-color: var(--bs-primary); + background: rgba(var(--bs-primary-rgb), .06); +} +.itflow-setup-drop.has-file { + border-style: solid; +} +.itflow-setup-drop.has-file > i { + color: var(--bs-primary) !important; +} /* Region - money and a date as they will look */ .itflow-setup-sample { display: flex; diff --git a/js/setup.js b/js/setup.js index 1f79df9d6..fcd81fc61 100644 --- a/js/setup.js +++ b/js/setup.js @@ -12,6 +12,8 @@ * [data-setup-preview=its id] * [data-setup-browser-timezone] starts on the browser's timezone * form[data-setup-region] shows money and a date as they will look + * [data-setup-reveal=id] a switch that shows that element while on + * form[data-restore-form] a backup dropped or picked, sent with its progress * * And every setup form shows it is working once sent, and is sent once. */ @@ -232,10 +234,163 @@ draw(); } + // A switch that shows what goes with it - the comment sent with sharing + function initReveal(toggle) { + var target = document.getElementById(toggle.dataset.setupReveal); + if (!target) { + return; + } + var sync = function () { + target.hidden = !toggle.checked; + }; + toggle.addEventListener('change', sync); + sync(); + } + + function formatBytes(bytes) { + var units = ['bytes', 'KB', 'MB', 'GB']; + var i = 0; + while (bytes >= 1024 && i < units.length - 1) { + bytes /= 1024; + i++; + } + return (i === 0 ? bytes : bytes.toFixed(bytes < 10 ? 1 : 0)) + ' ' + units[i]; + } + + /* + * Restore a backup - dropped or picked, checked against what this server + * takes, then sent with how far it has got shown. The server answers in + * JSON (setupGo() in setup/index.php) with where to go next. + */ + function initRestore(form) { + var input = form.querySelector('input[name="backup_zip"]'); + var drop = form.querySelector('[data-setup-drop]'); + var tooBig = form.querySelector('[data-restore-too-big]'); + var progress = form.querySelector('[data-restore-progress]'); + var failed = form.querySelector('[data-restore-failed]'); + if (!input || !drop || !progress) { + return; + } + var name = drop.querySelector('[data-setup-drop-name]'); + var size = drop.querySelector('[data-setup-drop-size]'); + var nameText = name.textContent; + var sizeText = size.textContent; + var max = Number(input.dataset.setupMaxBytes || 0); + var bar = progress.querySelector('.progress-bar'); + var label = progress.querySelector('[data-restore-progress-label]'); + var amount = progress.querySelector('[data-restore-progress-amount]'); + + function chosen() { + var file = input.files && input.files[0]; + var message = ''; + if (file && !/\.zip$/i.test(file.name)) { + message = 'Only .zip backup archives can be restored.'; + } + var big = !!file && message === '' && max > 0 && file.size > max; + if (big) { + message = 'Larger than this server takes through a browser - restore it from the command line.'; + } + input.setCustomValidity(message); + tooBig.hidden = !big; + drop.classList.toggle('has-file', !!file); + name.textContent = file ? file.name : nameText; + size.textContent = file ? formatBytes(file.size) : sizeText; + showField(input, message); + } + input.addEventListener('change', chosen); + + ['dragenter', 'dragover'].forEach(function (type) { + drop.addEventListener(type, function (e) { + e.preventDefault(); + drop.classList.add('is-over'); + }); + }); + ['dragleave', 'drop'].forEach(function (type) { + drop.addEventListener(type, function (e) { + e.preventDefault(); + drop.classList.remove('is-over'); + }); + }); + drop.addEventListener('drop', function (e) { + if (e.dataTransfer && e.dataTransfer.files.length) { + input.files = e.dataTransfer.files; + chosen(); + } + }); + + function fail(message) { + progress.hidden = true; + failed.textContent = message; + failed.hidden = false; + delete form.dataset.setupSent; + form.querySelectorAll('[aria-disabled="true"]').forEach(function (button) { + button.classList.remove('disabled'); + button.removeAttribute('aria-disabled'); + var spinner = button.querySelector('.spinner-border'); + if (spinner) { + spinner.remove(); + } + }); + } + + form.addEventListener('submit', function (e) { + if (e.defaultPrevented || !window.FormData || !window.XMLHttpRequest) { + return; + } + e.preventDefault(); + + var data = new FormData(form); + data.append('restore', '1'); + var xhr = new XMLHttpRequest(); + xhr.open('POST', form.getAttribute('action') || window.location.href); + xhr.setRequestHeader('X-Setup-Upload', '1'); + + failed.hidden = true; + progress.hidden = false; + label.textContent = 'Sending the backup'; + bar.classList.remove('progress-bar-striped', 'progress-bar-animated'); + bar.style.width = '0%'; + + xhr.upload.addEventListener('progress', function (ev) { + if (ev.lengthComputable) { + var percent = Math.round(ev.loaded * 100 / ev.total); + bar.style.width = percent + '%'; + bar.parentNode.setAttribute('aria-valuenow', percent); + amount.textContent = formatBytes(ev.loaded) + ' of ' + formatBytes(ev.total); + } + }); + xhr.upload.addEventListener('load', function () { + label.textContent = 'Restoring - the database, then the files'; + amount.textContent = ''; + bar.style.width = '100%'; + bar.classList.add('progress-bar-striped', 'progress-bar-animated'); + }); + xhr.addEventListener('load', function () { + var next = ''; + try { + next = JSON.parse(xhr.responseText).location || ''; + } catch (err) { + next = ''; + } + if (next) { + window.location.href = next; + return; + } + fail('The restore stopped with an error' + (xhr.status ? ' (' + xhr.status + ')' : '') + " - the web server's error log says why. Restoring from the command line has no time or size limit."); + }); + xhr.addEventListener('error', function () { + fail('The upload was cut off. Check the connection - or restore from the command line, which has no size limit.'); + }); + xhr.send(data); + }); + } + function init() { + document.querySelectorAll('[data-setup-reveal]').forEach(initReveal); document.querySelectorAll('form[data-setup-form]').forEach(initForm); document.querySelectorAll('.itflow-setup form').forEach(initBusy); document.querySelectorAll('form[data-setup-region]').forEach(initRegion); + document.querySelectorAll('form[data-restore-form]').forEach(initRestore); } // Back to a page the browser kept as it was left - sent and spinning - ready to send again diff --git a/scripts/setup_cli.php b/scripts/setup_cli.php index 4e08a0344..d403e7be0 100644 --- a/scripts/setup_cli.php +++ b/scripts/setup_cli.php @@ -1,357 +1,417 @@ #!/usr/bin/env php 'Database host', - 'username' => 'Database username', - 'password' => 'Database password', - 'database' => 'Database name', - 'base-url' => 'Base URL (without protocol, e.g. example.com/itflow)', - 'locale' => 'Locale (e.g. en_US)', - 'timezone' => 'Timezone (e.g. UTC)', - 'currency' => 'Currency code (e.g. USD)', - 'company-name' => 'Company name', - 'country' => 'Company country (e.g. United States)', - 'user-name' => 'Admin user full name', - 'user-email' => 'Admin user email', - 'user-password'=> 'Admin user password (min 8 chars)' +// Every option - what it is, whether setup needs it, and its default +$cli_fields = [ + 'Database' => [ + 'database' => ['Database name', true], + 'host' => ['Database host', true, 'localhost'], + 'username' => ['Database user', true], + 'password' => ['Database password', false], + ], + 'Where ITFlow is reached' => [ + 'base-url' => ['Its address, without https:// (e.g. itflow.example.com)', true], + 'repo-branch' => ['Git branch it updates from', false, 'master'], + ], + 'Your account - an administrator' => [ + 'user-name' => ['Your name', true], + 'user-email' => ['Your email - what you sign in with', true], + 'user-password' => ['Password (at least 8 characters)', true], + ], + 'Company' => [ + 'company-name' => ['Company name', true], + 'country' => ['Country (e.g. United States)', true], + 'address' => ['Address', false], + 'city' => ['City', false], + 'state' => ['State / province', false], + 'zip' => ['Postal code', false], + 'phone-country-code' => ['Phone country code (e.g. 1)', false], + 'phone' => ['Phone', false], + 'company-email' => ['Company email', false], + 'website' => ['Website', false], + 'tax-id' => ['Tax ID', false], + ], + 'Region' => [ + 'locale' => ['Language (e.g. en_US)', true], + 'currency' => ['Currency code (e.g. USD)', true], + 'timezone' => ['Timezone (e.g. America/New_York)', true], + ], +]; +$cli_flags = [ + 'share-telemetry' => 'Tell the ITFlow project about this install, once - its company name, website, city, state, country and currency', + 'comments' => 'Something to tell the ITFlow project with it (optional)', + 'non-interactive' => 'Ask for nothing - stop if anything needed is missing or wrong', + 'help' => 'Show this help', ]; -// Additional optional arguments -// address, city, state, zip, phone, company-email, website -// These are optional and don't need error checks if missing. -$optional_args = [ - 'address' => 'Company address (optional)', - 'city' => 'Company city (optional)', - 'state' => 'Company state (optional)', - 'zip' => 'Company postal code (optional)', - 'phone' => 'Company phone (optional)', - 'company-email' => 'Company email (optional)', - 'website' => 'Company website (optional)', - 'repo-branch' => 'Git repo branch to track (optional, default master)' -]; +$longopts = ['help', 'non-interactive', 'share-telemetry', 'comments:']; +foreach ($cli_fields as $fields) { + foreach ($fields as $key => $field) { + $longopts[] = "$key:"; + } +} +$options = getopt('', $longopts); +$non_interactive = isset($options['non-interactive']); -// Parse command line options -$shortopts = ""; -$longopts = [ - "help", - "host:", - "username:", - "password:", - "database:", - "base-url:", - "locale:", - "timezone:", - "currency:", - "company-name:", - "country:", - "address::", - "city::", - "state::", - "zip::", - "phone::", - "company-email::", - "website::", - "repo-branch::", - "user-name:", - "user-email:", - "user-password:", - "non-interactive" -]; - -$options = getopt($shortopts, $longopts); - -// If --help is set, print usage and exit if (isset($options['help'])) { - echo "ITFlow CLI Setup Script\n\n"; - echo "Usage:\n"; - echo " php setup_cli.php [options]\n\n"; - echo "Options:\n"; - foreach ($required_args as $arg => $desc) { - echo " --$arg\t$desc (required)\n"; + echo "ITFlow setup from the command line\n\n"; + echo "Usage:\n php setup_cli.php [options]\n\n"; + foreach ($cli_fields as $group => $fields) { + echo "$group:\n"; + foreach ($fields as $key => $field) { + echo sprintf(" --%-20s %s%s%s\n", $key, $field[0], $field[1] ? ' (needed)' : '', isset($field[2]) ? " [default: {$field[2]}]" : ''); + } + echo "\n"; } - foreach ($optional_args as $arg => $desc) { - echo " --$arg\t$desc\n"; + foreach ($cli_flags as $key => $what) { + echo sprintf(" --%-20s %s\n", $key, $what); } - echo " --non-interactive\tRun in non-interactive mode (fail if required args missing)\n"; - echo " --help\t\tShow this help message\n\n"; - echo "If running interactively (without --non-interactive), any missing required arguments will be prompted.\n"; - echo "If running non-interactively, all required arguments must be provided.\n\n"; + echo "\nLeft out, an option is asked for - unless --non-interactive. The language, currency and timezone\n"; + echo "are suggested from the country, and --non-interactive takes the suggestion when there is one.\n"; exit(0); } +$config_error = ''; if (file_exists("../config.php")) { - include_once "../config.php"; + try { + include_once "../config.php"; + } catch (Throwable $e) { + $config_error = $e->getMessage(); + } } require_once "../functions.php"; require_once "../includes/database_version.php"; define('FROM_SETUP', true); -require_once "../setup/seed_data.php"; // Shared install-time seed data - -if (!isset($config_enable_setup)) { - $config_enable_setup = 1; -} - -if ($config_enable_setup == 0) { - echo "Setup is disabled. Please delete or modify config.php if you need to re-run the setup.\n"; - exit; -} - -$errorLog = ini_get('error_log') ?: "/var/log/apache2/error.log"; +require_once "../setup/seed_data.php"; +require_once "../setup/setup_checks.php"; +require_once "../setup/setup_database.php"; +require_once "../setup/setup_forms.php"; +require_once "../setup/setup_install.php"; +require_once "../includes/settings_localization_array.php"; $timezones = DateTimeZone::listIdentifiers(); -function prompt($message) { - echo $message . ": "; - return trim(fgets(STDIN)); +/* Stop, saying why - to stderr, with a failing exit code. */ +function cliStop($message) { + fwrite(STDERR, "\n$message\n"); + exit(1); } -$non_interactive = isset($options['non-interactive']); +/* A setup hint is written for the page - as plain text. */ +function cliText($html) { + return html_entity_decode(strip_tags((string) $html), ENT_QUOTES, 'UTF-8'); +} -function getOptionOrPrompt($key, $promptMessage, $required = false, $default = '', $optionsGlobal = []) { +/* + * A line typed in answer - null at the end of input. A secret is not shown + * as it is typed, and kept exactly as typed; anything else is trimmed. + */ +function cliPrompt($label, $secret = false) { + echo "$label: "; + $hidden = $secret && function_exists('posix_isatty') && @posix_isatty(STDIN) && trim((string) @shell_exec('command -v stty')) !== ''; + if ($hidden) { + @shell_exec('stty -echo'); + } + $line = fgets(STDIN); + if ($hidden) { + @shell_exec('stty echo'); + echo "\n"; + } + if ($line === false) { + return null; + } + $line = rtrim($line, "\r\n"); + return $secret ? $line : trim($line); +} + +/* + * One answer - the option given, or else asked for (with its default) until + * $check, which says what is wrong with a value or '', is happy. With + * --non-interactive, a missing or wrong one stops setup. + */ +function cliAsk($key, $label, $required, $default = '', $check = null, $secret = false) { global $options, $non_interactive; - if (isset($options[$key])) { - return $options[$key]; - } else { - if ($non_interactive && $required) { - die("Missing required argument: --$key\n"); + + // What was given rather than typed, said back + $given = function ($value) use ($label, $secret) { + if ($value !== '') { + echo " $label: " . ($secret ? str_repeat('*', 8) : $value) . "\n"; } - $val = prompt($promptMessage . (strlen($default) ? " [$default]" : '')); - if (empty($val) && !empty($default)) { - $val = $default; + return $value; + }; + $problem = function ($value) use ($required, $check) { + if ($value === '') { + return $required ? 'This is needed.' : ''; } - if ($required && empty($val)) { - die("Error: $promptMessage is required.\n"); + return $check ? (string) $check($value) : ''; + }; + + if (array_key_exists($key, $options)) { + $value = is_string($options[$key]) ? $options[$key] : ''; + $value = $secret ? $value : trim($value); + if ($value === '') { + $value = (string) $default; } - return $val; + $error = $problem($value); + if ($error === '') { + return $given($value); + } + if ($non_interactive) { + cliStop("--$key: $error"); + } + echo " --$key: $error\n"; + } elseif ($non_interactive) { + if ($required && (string) $default === '') { + cliStop("--$key is needed - $label."); + } + return $given((string) $default); + } + + while (true) { + $value = cliPrompt($label . ((string) $default !== '' && !$secret ? " [$default]" : '') . ($required ? '' : ' (optional)'), $secret); + if ($value === null) { + cliStop("No answer for: $label."); + } + if ($value === '') { + $value = (string) $default; + } + $error = $problem($value); + if ($error === '') { + return $value; + } + echo " $error\n"; } } -// Start setup -echo "Welcome to the ITFlow CLI Setup.\n"; +/* Yes or no, asked - no unless answered yes. */ +function cliConfirm($question) { + $answer = cliPrompt("$question [y/N]"); + return $answer !== null && in_array(strtolower($answer), ['y', 'yes'], true); +} -// If config exists, abort +echo "ITFlow setup\n"; + +// ---------- Already set up? ---------- if (file_exists('../config.php')) { - echo "Database is already configured in config.php.\n"; - echo "To re-run the setup, remove config.php and run this script again.\n"; - exit; + if (isset($config_enable_setup) && $config_enable_setup == 0) { + cliStop("ITFlow is already set up. To start again, remove config.php and empty the database."); + } + cliStop("config.php is already there" . ($config_error !== '' ? " (and can't connect: $config_error)" : '') . ". Finish setting up in a browser at /setup, or remove config.php and run this again."); } -// If non-interactive is set, ensure all required arguments are present +// ---------- What this server has ---------- +echo "\n== Checks ==\n"; +$checks = setupChecks(dirname(__DIR__), false); +foreach (['required' => 'Needed', 'recommended' => 'Worth having'] as $kind => $heading) { + foreach ($checks[$kind] as $check) { + echo sprintf(" %s %s - %s\n", $check['passed'] ? '[ok]' : ($kind === 'required' ? '[!!]' : '[--]'), $check['label'], $check['value']); + if (!$check['passed'] && $check['fix'] !== '') { + echo " " . cliText($check['fix']) . "\n"; + } + } +} +if (setupChecksFailed($checks['required'])) { + cliStop("Fix what is marked [!!], then run this again."); +} + +// Asking nothing, all that has no default or suggestion has to be given - said before anything is done if ($non_interactive) { - foreach (array_keys($required_args) as $arg) { - if (!isset($options[$arg])) { - die("Missing required argument: --$arg\n"); + foreach ($cli_fields as $fields) { + foreach ($fields as $key => $field) { + if ($field[1] && !isset($field[2]) && !in_array($key, ['locale', 'currency', 'timezone'], true) && trim((string) ($options[$key] ?? '')) === '') { + cliStop("--$key is needed - {$field[0]}."); + } } } } -// Database Setup -echo "\n=== Database Setup ===\n"; -$database = getOptionOrPrompt('database', "Enter the database name", true); -$host = getOptionOrPrompt('host', "Enter the database host", true, 'localhost'); -if (empty($host)) $host = "localhost"; -$username = getOptionOrPrompt('username', "Enter the database username", true); -$password = getOptionOrPrompt('password', "Enter the database password", true); +// ---------- Database ---------- +echo "\n== Database ==\n"; +$database_defaults = ['database' => '', 'host' => 'localhost', 'username' => '']; +while (true) { + $database = cliAsk('database', 'Database name', true, $database_defaults['database']); + $host = cliAsk('host', 'Database host', true, $database_defaults['host'], 'setupHostProblem'); + $username = cliAsk('username', 'Database user', true, $database_defaults['username']); + $password = cliAsk('password', 'Database password', false, '', null, true); -// Base URL -$base_url = getOptionOrPrompt('base-url', "Enter the base URL (e.g. example.com/itflow)", true); -$base_url = rtrim($base_url, '/'); - -// Repo Branch -$repo_branch = getOptionOrPrompt('repo-branch', "Git repo branch to track", false, 'master'); -if (empty($repo_branch)) $repo_branch = 'master'; - -// Locale, Timezone, Currency -echo "\n=== Localization ===\n"; -$locale = getOptionOrPrompt('locale', "Enter the locale (e.g. en_US)", true); -$timezone = getOptionOrPrompt('timezone', "Enter the timezone (e.g. UTC or America/New_York)", true); -$currency_code = getOptionOrPrompt('currency', "Enter the currency code (e.g. USD)", true); - -// Company Details -echo "\n=== Company Details ===\n"; -$company_name = getOptionOrPrompt('company-name', "Company Name", true); -$country = getOptionOrPrompt('country', "Country (e.g. United States)", true); -$address = getOptionOrPrompt('address', "Address (optional)", false); -$city = getOptionOrPrompt('city', "City (optional)", false); -$state = getOptionOrPrompt('state', "State/Province (optional)", false); -$zip = getOptionOrPrompt('zip', "Postal Code (optional)", false); -$phone = getOptionOrPrompt('phone', "Phone (optional)", false); -$phone = preg_replace("/[^0-9]/", '', $phone); -$company_email = getOptionOrPrompt('company-email', "Company Email (optional)", false); -$website = getOptionOrPrompt('website', "Website (optional)", false); - -// User Setup -echo "\n=== Create First User ===\n"; -$user_name = getOptionOrPrompt('user-name', "Full Name", true); -$user_email = getOptionOrPrompt('user-email', "Email Address", true); -while (!filter_var($user_email, FILTER_VALIDATE_EMAIL)) { - echo "Invalid email.\n"; + $connection = setupConnect($host, $username, $password, $database, $connect_error); + if ($connection && !setupTables($connection)) { + echo " Connected to $database on $host - it is empty.\n"; + break; + } + $why = $connection + ? "$database already has tables in it. A new install needs an empty database - to bring a backup back, create an empty one, run setup, then restore." + : "Could not connect to $database on $host as $username: $connect_error."; if ($non_interactive) { - die("Invalid email address: $user_email\n"); + cliStop($why); } - $user_email = prompt("Email Address"); + echo " $why\n"; + // Asked again - with what was given, bar the password, as the defaults + foreach (['database', 'host', 'username', 'password'] as $key) { + unset($options[$key]); + } + $database_defaults = ['database' => $database, 'host' => $host, 'username' => $username]; } -$user_password_plain = getOptionOrPrompt('user-password', "Password (at least 8 chars)", true); -if (strlen($user_password_plain) < 8) { - if ($non_interactive) { - die("Password must be at least 8 characters.\n"); + +echo "\n== Where ITFlow is reached ==\n"; +$base_url = cliAsk('base-url', 'Its address, without https:// (e.g. itflow.example.com)', true, '', function ($value) { + $value = rtrim(preg_replace('#^[a-z][a-z0-9+.-]*://#i', '', $value), '/'); + return $value === '' || preg_match('/\s/', $value) ? 'An address like itflow.example.com, or example.com/itflow.' : ''; +}); +$base_url = rtrim(preg_replace('#^[a-z][a-z0-9+.-]*://#i', '', $base_url), '/'); +$repo_branch = $options['repo-branch'] ?? 'master'; +$repo_branch = is_string($repo_branch) && trim($repo_branch) !== '' ? trim($repo_branch) : 'master'; + +// ---------- Your account ---------- +echo "\n== Your account - an administrator ==\n"; +$user_field = function ($field) { + return function ($value) use ($field) { + $sample = ['name' => 'Name', 'email' => 'name@example.com', 'password' => str_repeat('x', USER_PASSWORD_MIN)]; + [, $errors] = setupUserForm([$field => $value] + $sample); + return $errors[$field] ?? ''; + }; +}; +$user = [ + 'name' => cliAsk('user-name', 'Your name', true, '', $user_field('name')), + 'email' => cliAsk('user-email', 'Your email - what you sign in with', true, '', $user_field('email')), +]; +$password_given = array_key_exists('user-password', $options); +while (true) { + $user['password'] = cliAsk('user-password', 'Password (at least ' . USER_PASSWORD_MIN . ' characters)', true, '', 'userPasswordProblem', true); + if ($password_given || $non_interactive) { + break; } - while (strlen($user_password_plain) < 8) { - echo "Password too short. Try again.\n"; - $user_password_plain = prompt("Password"); + $again = cliPrompt('The same again', true); + if ($again === null) { + cliStop('No answer for: the password again.'); + } + if ($again === $user['password']) { + break; + } + echo " The two passwords are not the same.\n"; +} + +// ---------- Company ---------- +echo "\n== Company ==\n"; +$country_names = []; +foreach ($countries_array as $country_name) { + $country_names[strtolower($country_name)] = $country_name; +} +$company_field = function ($field) use ($countries_array) { + return function ($value) use ($field, $countries_array) { + [, $errors] = setupCompanyForm([$field => $value] + ['name' => 'Company', 'country' => $countries_array[0]], $countries_array); + return $errors[$field] ?? ''; + }; +}; +$company = ['name' => cliAsk('company-name', 'Company name', true, '', $company_field('name'))]; +$company['country'] = cliAsk('country', 'Country (e.g. United States)', true, '', function ($value) use ($country_names) { + return isset($country_names[strtolower($value)]) ? '' : 'Not a country ITFlow knows - as it is named in Admin > Settings > Company, like United States or United Kingdom.'; +}); +$company['country'] = $country_names[strtolower($company['country'])]; +foreach (['address' => 'address', 'city' => 'city', 'state' => 'state', 'zip' => 'zip', 'phone_country_code' => 'phone-country-code', 'phone' => 'phone', 'email' => 'company-email', 'website' => 'website', 'tax_id' => 'tax-id'] as $field => $key) { + $company[$field] = cliAsk($key, $cli_fields['Company'][$key][0], false, '', in_array($field, ['email', 'website'], true) ? $company_field($field) : null); +} +[$company] = setupCompanyForm($company, $countries_array); + +// ---------- Region ---------- +echo "\n== Region ==\n"; +$iso2 = $country_iso2_array[$company['country']] ?? ''; +$shell_language = str_replace('_', '-', strtok((string) (getenv('LC_ALL') ?: getenv('LANG') ?: ''), '.')); +$region_field = function ($field) use ($locales_array, $currencies_array, $timezones) { + return function ($value) use ($field, $locales_array, $currencies_array, $timezones) { + $sample = ['locale' => 'en_US', 'currency_code' => 'USD', 'timezone' => 'UTC']; + [, $errors] = setupRegionForm([$field => $value] + $sample, $locales_array, $currencies_array, $timezones); + return $errors[$field] ?? ''; + }; +}; +$region = [ + 'locale' => cliAsk('locale', 'Language (e.g. en_US)', true, setupSuggestLocale($iso2, in_array($shell_language, ['', 'C', 'POSIX'], true) ? '' : $shell_language, $locales_array), $region_field('locale')), + 'currency_code' => strtoupper(cliAsk('currency', 'Currency code (e.g. USD)', true, setupSuggestCurrency($iso2, $currencies_array), $region_field('currency_code'))), + 'timezone' => cliAsk('timezone', 'Timezone (e.g. America/New_York)', true, setupSuggestTimezone($iso2), $region_field('timezone')), +]; + +// ---------- Telling the ITFlow project ---------- +$share = isset($options['share-telemetry']); +$comments = is_string($options['comments'] ?? null) ? trim($options['comments']) : ''; +if (!$share && !$non_interactive) { + echo "\n== Tell the ITFlow project about this install? ==\n"; + echo " Once, now: the company's name, website, city, state, country and currency, and this install's id.\n"; + $share = cliConfirm('Share them'); + if ($share && !array_key_exists('comments', $options)) { + $comments = (string) cliPrompt('Anything to tell us? (Enter for nothing)'); } } -if (!preg_match('/^[a-zA-Z0-9.\-\/]+$/', $host)) { - die("Invalid host format.\n"); -} - -// Test Database -$conn = @mysqli_connect($host, $username, $password, $database); -if (!$conn) { - die("Database connection failed - " . mysqli_connect_error() . "\n"); +// ---------- Setting it up ---------- +echo "\n== Setting up ==\n"; +if (!setupImportSchema($connection, '../db.sql', $import_error)) { + cliStop("Creating ITFlow's tables failed: $import_error. The tables it had made were taken away again - fix the cause and run this again."); } +echo " ITFlow's tables are made.\n"; $installation_id = randomString(32); +if (@file_put_contents('../config.php', setupConfigContents($host, $username, $password, $database, $base_url, $installation_id, $repo_branch)) === false) { + setupDropTables($connection); + cliStop("config.php could not be written to " . dirname(__DIR__) . " - make the folder writable, then run this again."); +} +echo " config.php is written.\n"; -$new_config = " "$installation_id", - 'company_name' => "$company_name_db", - 'website' => "$website_db", - 'city' => "$city_db", - 'state' => "$state_db", - 'country' => "$country_db", - 'currency' => "$currency_db", - 'comments' => "$comments", - 'collection_method' => 1 - ]); - - $opts = ['http' => - [ - 'method' => 'POST', - 'header' => 'Content-type: application/x-www-form-urlencoded', - 'content' => $postdata - ] - ]; - - $context = stream_context_create($opts); - $result = @file_get_contents('https://telemetry.itflow.org', false, $context); - echo "Telemetry response: $result\n"; - } -} - -// finalize config -$myfile = fopen("../config.php", "a"); -$txt = "\$config_enable_setup = 0;\n\n"; -fwrite($myfile, $txt); -fclose($myfile); - -echo "\nSetup complete!\n"; -echo "You can now log in with the user you created at: https://$base_url/login.php\n"; - -// Whoever ran this is already at a shell on the right host, which makes this the one moment -// they can paste the crontab line without going and finding it. The web setup shows the same -// two steps on its finish page. $itflow_path = realpath(__DIR__ . '/..'); - -echo "\nTwo things left before ITFlow can send mail, parse tickets or bill anyone:\n\n"; +echo "\nITFlow is set up. Sign in at https://$base_url/login.php\n"; +if ($backup_key !== '') { + echo "\nBackup key - keep a copy somewhere safe, away from this server; a backup can't be restored without it:\n\n"; + echo " $backup_key\n"; +} +echo "\nNext, before ITFlow can send mail, parse tickets or bill anyone:\n\n"; echo " 1. Add this single entry to the crontab of the user that owns the ITFlow files:\n\n"; echo " * * * * * php $itflow_path/cron/cron.php >/dev/null\n\n"; echo " 2. Turn cron on in Maintenance > Cron. It ships off, and every job stops\n"; diff --git a/setup/index.php b/setup/index.php index cd935a8e7..a36299a4a 100644 --- a/setup/index.php +++ b/setup/index.php @@ -27,6 +27,7 @@ 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) { @@ -43,9 +44,24 @@ function setupFlash($message, $type = 'success') { $_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; -$should_skip_to_user = false; /* * How far through the wizard this install already is. Each step is a separate question @@ -68,7 +84,6 @@ 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; - $should_skip_to_user = true; $resume_step = 'user'; $user_count_result = mysqli_query($mysqli, "SELECT COUNT(*) AS user_count FROM users"); @@ -133,7 +148,10 @@ if (!isset($config_enable_setup)) { $config_enable_setup = 1; } -if ($config_enable_setup == 0) { +// 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; } @@ -174,8 +192,8 @@ if (isset($_POST['add_database'])) { if ($database === '' || $host === '' || $username === '') { $database_error = "Fill in the database's name, its host and the user to connect as."; - } elseif (!preg_match('/^[a-zA-Z0-9.-]+$/', $host)) { - $database_error = "The host is a name or an address - localhost, db.example.com or 10.0.0.5 - without a port or a slash."; + } elseif (($host_problem = setupHostProblem($host)) !== '') { + $database_error = escapeHtml($host_problem); } else { $connection = setupConnect($host, $username, $password, $database, $connect_error); if (!$connection) { @@ -222,21 +240,26 @@ if (isset($_POST['create_tables'])) { } -if (isset($_POST['restore'])) { +/* + * 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'); - header("Location: ?restore"); - exit; + setupGo("?restore"); } - // An upload larger than post_max_size arrives with $_FILES and $_POST both empty, so - // PHP cannot tell us which field failed - it is worth naming, because a real full - // backup is usually bigger than the limit and the old message just said the upload - // failed. + // 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); @@ -252,20 +275,17 @@ if (isset($_POST['restore'])) { . 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'); - header("Location: ?restore"); - exit; + 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'); - header("Location: ?restore"); - exit; + setupGo("?restore"); } if (strtolower(pathinfo($_FILES['backup_zip']['name'], PATHINFO_EXTENSION)) !== 'zip') { setupFlash("Only .zip backup archives can be restored.", 'danger'); - header("Location: ?restore"); - exit; + setupGo("?restore"); } // The key belongs to the install that MADE the backup, which on a rebuilt server is not @@ -277,16 +297,14 @@ if (isset($_POST['restore'])) { if ($restore_key === '') { setupFlash("Enter the backup encryption key. It is shown in Maintenance > Backup on the install that made this archive.", 'danger'); - header("Location: ?restore"); - exit; + 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'); - header("Location: ?restore"); - exit; + setupGo("?restore"); } @chmod($temp_zip, 0600); @@ -297,21 +315,14 @@ if (isset($_POST['restore'])) { if (!$restored) { setupFlash(escapeHtml((string) $restore_error), 'danger'); - header("Location: ?restore"); - exit; + 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. - if (!setupClose(__DIR__ . "/../config.php")) { - $_SESSION['alert_message'] = "Backup restored, but config.php could not be updated - please set \$config_enable_setup = 0 in it by hand."; - } else { - $_SESSION['alert_message'] = "Backup restored. Log in with the credentials that were in use when the backup was taken."; - } - - header("Location: ../login.php"); - exit; + // 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'])) { @@ -338,23 +349,7 @@ if (isset($_POST['add_user'])) { } if (!$user_errors) { - $name = escapeSql($user_form['name']); - $email = escapeSql($user_form['email']); - // Exactly as typed - the sign-in page checks exactly what is typed - $password = escapeSql(password_hash($user_form['password'], PASSWORD_DEFAULT)); - - //Generate master encryption key - $site_encryption_master_key = randomString(); - - //Generate user specific key - $user_specific_encryption_ciphertext = setupFirstUserSpecificKey($user_form['password'], $site_encryption_master_key); - - mysqli_query($mysqli,"INSERT INTO users SET user_name = '$name', user_email = '$email', user_password = '$password', user_specific_encryption_ciphertext = '$user_specific_encryption_ciphertext', user_role_id = 3"); - - // Normally 1, but the table's AUTO_INCREMENT can already have moved on, so ask for it. - $user_id = intval(mysqli_insert_id($mysqli)); - - mkdirMissing("../uploads/users/$user_id"); + $user_id = setupCreateFirstUser($mysqli, $user_form); // Checked above - saved as Profile saves one $avatar_message = ''; @@ -363,9 +358,6 @@ if (isset($_POST['add_user'])) { $avatar_message = " - the avatar was left off: $avatar_error"; } - //Create Settings - mysqli_query($mysqli,"INSERT INTO user_settings SET user_id = $user_id"); - setupFlash("Account " . escapeHtml($user_form['name']) . " created$avatar_message.", $avatar_message ? 'warning' : 'success'); header("Location: ?company"); @@ -394,11 +386,8 @@ if (isset($_POST['add_company_settings'])) { } if (!$company_errors) { - $sql_values = []; - foreach (['name', 'address', 'city', 'state', 'zip', 'country', 'phone_country_code', 'phone', 'email', 'website', 'tax_id'] as $field) { - $sql_values[] = "company_$field = '" . escapeSql($company_form[$field]) . "'"; - } - mysqli_query($mysqli, "INSERT INTO companies SET " . implode(', ', $sql_values)); + // 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 = ''; @@ -411,9 +400,6 @@ if (isset($_POST['add_company_settings'])) { } } - // Seed the defaults shared with the CLI installer - seedDefaultData($mysqli); - setupFlash("Company " . escapeHtml($company_form['name']) . " saved$logo_message.", $logo_message ? 'warning' : 'success'); header("Location: ?localization"); @@ -438,16 +424,7 @@ if (isset($_POST['add_localization_settings'])) { [$region_form, $region_errors] = setupRegionForm($_POST, $locales_array, $currencies_array, $timezones); if (!$region_errors) { - $locale = escapeSql($region_form['locale']); - $currency_code = escapeSql($region_form['currency_code']); - $timezone = escapeSql($region_form['timezone']); - - mysqli_query($mysqli,"UPDATE companies SET company_locale = '$locale', company_currency = '$currency_code' WHERE company_id = 1"); - - mysqli_query($mysqli,"UPDATE settings SET config_timezone = '$timezone' WHERE company_id = 1"); - - // Create Default Cash Account - mysqli_query($mysqli,"INSERT INTO accounts SET account_name = 'Cash', account_type = 4, account_currency_code = '$currency_code'"); + setupSaveRegion($mysqli, $region_form); setupFlash("Language, currency and timezone saved - one last step."); @@ -465,51 +442,8 @@ if (isset($_POST['add_telemetry'])) { exit; } - if (isset($_POST['share_data']) && $_POST['share_data'] == 1) { - - mysqli_query($mysqli,"UPDATE settings SET config_telemetry = 2"); - - $comments = escapeSql($_POST['comments']); - - $sql = mysqli_query($mysqli,"SELECT company_city, company_country, company_currency, company_name, company_state, - company_website FROM companies WHERE company_id = 1"); - $row = mysqli_fetch_assoc($sql); - - $company_name = $row['company_name']; - $website = $row['company_website']; - $city = $row['company_city']; - $state = $row['company_state']; - $country = $row['company_country']; - $currency = $row['company_currency']; - - $postdata = http_build_query( - array( - 'installation_id' => "$installation_id", - 'company_name' => "$company_name", - 'website' => "$website", - 'city' => "$city", - 'state' => "$state", - 'country' => "$country", - 'currency' => "$currency", - 'comments' => "$comments", - 'collection_method' => 1 - ) - ); - - $opts = array('http' => - array( - 'method' => 'POST', - 'header' => 'Content-type: application/x-www-form-urlencoded', - 'content' => $postdata, - 'timeout' => 10 - ) - ); - - $context = stream_context_create($opts); - - // Best effort - an install that can't reach it still finishes - @file_get_contents('https://telemetry.itflow.org', false, $context); - + if (($_POST['share_data'] ?? '') === '1') { + setupShareTelemetry($mysqli, $installation_id ?? '', trim((string) ($_POST['comments'] ?? ''))); } // Close setup - the sign-in page is next @@ -539,7 +473,7 @@ $setup_steps = [ 'telemetry' => ['label' => 'Finish', 'icon' => 'fa-flag-checkered'], ]; $setup_step = 'welcome'; -foreach (array_merge(array_keys($setup_steps), ['restore']) as $key) { +foreach (array_merge(array_keys($setup_steps), ['restore', 'restored']) as $key) { if ($key !== 'welcome' && isset($_GET[$key])) { $setup_step = $key; break; @@ -578,7 +512,10 @@ if (!empty($_SESSION['alert_message'])) { $_SESSION['alert_type'] = ''; } -$setup_title = $setup_step === 'restore' ? 'Restore a backup' : $setup_steps[$setup_step]['label']; +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']; ?> @@ -621,6 +558,7 @@ $setup_title = $setup_step === 'restore' ? 'Restore a backup' : $setup_steps[$se Setup + +
diff --git a/setup/setup_checks.php b/setup/setup_checks.php index 129387933..272c68d7f 100644 --- a/setup/setup_checks.php +++ b/setup/setup_checks.php @@ -82,9 +82,10 @@ function setupCertificateValid($host) { /* * 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. + * files go into its uploads folder. From the command line ($from_web false) + * there is no request to say how the site is reached, so those are left out. */ -function setupChecks($root) { +function setupChecks($root, $from_web = true) { $required = []; $recommended = []; @@ -110,27 +111,30 @@ function setupChecks($root) { $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 ' . escapeHtml($uploads) . '. 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.'); + // How it is reached, and what the web server takes - neither known from the command line + if ($from_web) { + $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.'); + $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 upload_max_filesize = 500M and post_max_size = 500M, and restart the web server.'); + } - // 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 upload_max_filesize = 500M and post_max_size = 500M, 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, apt install php-zip on Debian and Ubuntu.'); diff --git a/setup/setup_database.php b/setup/setup_database.php index 63047dfaa..e6be00e5e 100644 --- a/setup/setup_database.php +++ b/setup/setup_database.php @@ -29,6 +29,17 @@ function setupConnect($host, $username, $password, $database, &$error = '') { return $connection; } +/* What is wrong with a database host - '' when nothing is. A name or an address, without a port or a path. */ +function setupHostProblem($host) { + if ($host === '') { + return "The database's host - localhost, unless it is on another server."; + } + if (!preg_match('/^[a-zA-Z0-9.-]+$/', $host)) { + return 'The host is a name or an address - localhost, db.example.com or 10.0.0.5 - without a port or a slash.'; + } + return ''; +} + /* The tables a database has - their names. */ function setupTables($connection) { $tables = []; @@ -99,8 +110,8 @@ function setupDropTables($connection) { } } -/* What config.php says - the database to use, where ITFlow is reached, and this install's id. */ -function setupConfigContents($host, $username, $password, $database, $base_url, $installation_id) { +/* What config.php says - the database to use, where ITFlow is reached, the branch it updates from, and this install's id. */ +function setupConfigContents($host, $username, $password, $database, $base_url, $installation_id, $repo_branch = 'master') { $config = " (string) ($row['company_name'] ?? ''), + 'website' => (string) ($row['company_website'] ?? ''), + 'city' => (string) ($row['company_city'] ?? ''), + 'state' => (string) ($row['company_state'] ?? ''), + 'country' => (string) ($row['company_country'] ?? ''), + 'currency' => (string) ($row['company_currency'] ?? ''), + ]; +} + +/* + * Tell the ITFlow project about this install, once - when asked to. Best + * effort: an install that can't reach it still finishes, after 10 seconds at + * most. True when the project answered. + */ +function setupShareTelemetry($mysqli, $installation_id, $comments) { + mysqli_query($mysqli, "UPDATE settings SET config_telemetry = 2"); + + $context = stream_context_create(['http' => [ + 'method' => 'POST', + 'header' => 'Content-type: application/x-www-form-urlencoded', + 'content' => http_build_query(['installation_id' => (string) $installation_id] + setupTelemetryDetails($mysqli) + ['comments' => (string) $comments, 'collection_method' => 1]), + 'timeout' => 10, + ]]); + + return @file_get_contents('https://telemetry.itflow.org', false, $context) !== false; +} diff --git a/setup/steps/restore.php b/setup/steps/restore.php index edef24bfb..d25df6067 100644 --- a/setup/steps/restore.php +++ b/setup/steps/restore.php @@ -3,11 +3,23 @@ /* * Setup - Restore a backup: an ITFlow backup archive brought back into an * install that has its tables but no users yet (restore in setup/index.php). - * One that has users restores from the command line instead. + * Dropped or picked, it is sent with how far it has got shown (js/setup.js); + * one larger than this server takes is pointed at the command line before it + * is sent. One that has users restores from the command line instead. */ defined('FROM_SETUP') || die("Direct file access is not allowed"); +$restore_command = "php " . dirname(__DIR__, 2) . "/scripts/restore_cli.php --file=/path/to/backup.zip"; +$command_field = function ($id) use ($restore_command) { + ob_start(); ?> +
+ + +
+
@@ -17,7 +29,7 @@ defined('FROM_SETUP') || die("Direct file access is not allowed");
This install already has users, so it can't be restored over from here. Restore from the command line instead: -
+
@@ -26,33 +38,56 @@ defined('FROM_SETUP') || die("Direct file access is not allowed"); Set up the database -
+
-
+ +
- +
+ + +
-
+
- +
Shown in Maintenance > Backup on that install. The archive can't be opened without it.
-
- This server takes uploads up to . - A full backup is often larger. If yours is, copy it onto the server and restore it from the command line - there is no size limit there: -
+ -

The restore replaces the database and the uploads folder. A large one takes several minutes - keep this page open.

+ + +
+ Restoring from the command line instead +
Any size, and nothing to upload - copy the backup onto this server and run:
+ +
Back - +
diff --git a/setup/steps/restored.php b/setup/steps/restored.php new file mode 100644 index 000000000..03eacd604 --- /dev/null +++ b/setup/steps/restored.php @@ -0,0 +1,24 @@ + +
+
+ +

Backup restored

+

Its database and files are back. Sign in with an account from the backup - its password and two-step sign-in are as they were when the backup was made.

+ +
config.php could not be written to, so setup is still open. Add this line to the end of it now: $config_enable_setup = 0;
+ + Sign in +
+
diff --git a/setup/steps/telemetry.php b/setup/steps/telemetry.php index 804497aa2..57e7a3f91 100644 --- a/setup/steps/telemetry.php +++ b/setup/steps/telemetry.php @@ -1,44 +1,114 @@ +
+ + +
+

Finish

-

Nearly there.

+

Nearly there - a few things to know before you sign in.

There are steps still to do before setup can finish.
Carry on where setup is -
-

Tell the ITFlow project about this install?

-
- - -
-
Once, now - it helps the project see who uses ITFlow. What is sent
-
- - -
+ + + +
+
Signing in needs HTTPS
+ This page came over http://, and ITFlow only lets you sign in over https:// - so the sign-in page will turn you away until the site has a certificate (Let's Encrypt is free). + Only for a test install that never leaves your network, change this line in config.php instead: + +
+ + +
    +
  • + +
    + Your backup key + + Backups are locked with it. Keep a copy somewhere safe, away from this server - a backup can't be restored without it. It is in Maintenance > Backup too. + + + config.php couldn't be written to, so there isn't one yet - Maintenance > Backup says how to add it. + +
    +
  • +
  • + +
    + Its address + + Links in emails and the client portal go to https://.$config_base_url in config.php before you invite clients.' ?> + + Put the address people reach ITFlow at in $config_base_url in config.php - links in emails go to it. + +
    +
  • +

Once you are signed in

-
    -
  • CronITFlow needs one crontab entry, for the user that owns its files - the install script adds it for you: -
    /dev/null") ?>
    - Then turn it on in Maintenance > Cron - it ships off.
  • -
  • BackupsSet them up in Maintenance > Backup, and keep its key somewhere safe.
  • -
  • Star ITFlowon GitHub :)
  • -
+
    +
  1. + Cron - ITFlow needs one crontab entry, for the user that owns its files. The install script adds it for you; by hand, it is: + /dev/null", 'Crontab entry') ?> + Then turn it on in Maintenance > Cron - it ships off. +
  2. +
  3. Mail - the mailboxes ITFlow sends from and turns emails into tickets from, in Admin > Settings > Mail.
  4. +
  5. Backups - when they run, in Maintenance > Backup.
  6. +
  7. Starter content - templates, categories and checklists to start from, in Maintenance > Starter Content.
  8. +
  9. Your team - invite them from Admin > Users.
  10. +
+ +
+
+ + +
+
Once, now - so the project can see who uses ITFlow. Nothing else is ever sent. More
+
What is sent
+
+ 'Company', 'website' => 'Website', 'city' => 'City', 'state' => 'State', 'country' => 'Country', 'currency' => 'Currency'] as $key => $label) { ?> +
+
-' ?>
+ +
Install
+
a random id made for it
+
+
+ + +
+