'Requirements',
2 => 'Database',
3 => 'Server',
4 => 'Schema',
5 => 'Administrator',
6 => 'Finish',
);
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
function install_root(): string {
return dirname(__DIR__);
}
function install_lock_file(): string {
return __DIR__ . '/' . INSTALL_LOCK;
}
function install_config_file(): string {
return install_root() . '/config.local.php';
}
// ---------------------------------------------------------------------------
// The lock
// ---------------------------------------------------------------------------
/**
* Why two conditions rather than one:
*
* The lock file alone is not enough - someone restoring a backup or unpacking
* a fresh copy over an installed site would drop it and the installer would
* happily reset the admin account. The database is the second opinion: if the
* znote table already holds a row, this site is installed regardless of what
* the filesystem says.
*
* Returns '' when the installer may run, or a reason when it may not.
*/
function install_locked_reason(): string {
if (is_file(install_lock_file())) {
return 'This site is already installed. Delete install/' . INSTALL_LOCK
. ' if you really mean to run the installer again.';
}
// A wizard already in progress must not be locked out by its own work:
// step 4 creates the znote table, which is exactly what the check below
// looks for. Step 2 has already warned if the database was not empty.
if (!empty($_SESSION['install']) || install_max_step() > 1) {
return '';
}
$config = install_saved_config();
if (!$config) {
return '';
}
$link = @new mysqli($config['sqlHost'], $config['sqlUser'], $config['sqlPassword'], $config['sqlDatabase']);
if ($link->connect_errno) {
return '';
}
$result = @$link->query('SELECT `id` FROM `znote` LIMIT 1');
$rows = ($result !== false) ? $result->num_rows : 0;
$link->close();
if ($rows > 0) {
return 'The database already contains a ZnoteX installation. The installer will not'
. ' overwrite it. Delete the znote table first if that is really what you want.';
}
return '';
}
// ---------------------------------------------------------------------------
// Wizard state
//
// Kept in the session, so a refresh does not lose the credentials typed two
// steps ago. Nothing is written to disk until the final step.
// ---------------------------------------------------------------------------
function install_state(?array $merge = null): array {
if (!isset($_SESSION['install'])) {
$_SESSION['install'] = array();
}
if ($merge !== null) {
$_SESSION['install'] = array_merge($_SESSION['install'], $merge);
}
return $_SESSION['install'];
}
function install_get(string $key, $default = '') {
$state = install_state();
return $state[$key] ?? $default;
}
function install_reset(): void {
unset($_SESSION['install']);
}
/** Highest step reached, so someone cannot skip ahead by editing the URL. */
function install_max_step(?int $reached = null): int {
if ($reached !== null && $reached > (int)($_SESSION['install_max'] ?? 1)) {
$_SESSION['install_max'] = $reached;
}
return (int)($_SESSION['install_max'] ?? 1);
}
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
/** Read config.local.php if it exists, else fall back to config.php values. */
function install_saved_config(): array {
$keys = array('sqlHost', 'sqlUser', 'sqlPassword', 'sqlDatabase');
$out = array();
foreach (array(install_config_file(), install_root() . '/config.php') as $file) {
if (!is_file($file)) {
continue;
}
$config = array();
// Included in a function so it cannot pollute anything.
@include $file;
foreach ($keys as $key) {
if (!isset($out[$key]) && isset($config[$key])) {
$out[$key] = (string)$config[$key];
}
}
}
return (count($out) === count($keys)) ? $out : array();
}
/** A connection using the values collected so far, or null. */
function install_connect(?string &$error = null): ?mysqli {
$link = @new mysqli(
(string)install_get('sqlHost', '127.0.0.1'),
(string)install_get('sqlUser'),
(string)install_get('sqlPassword'),
(string)install_get('sqlDatabase')
);
if ($link->connect_errno) {
$error = $link->connect_error;
return null;
}
$link->set_charset('utf8mb4');
$link->query("SET collation_connection = 'utf8mb4_general_ci'");
return $link;
}
// ---------------------------------------------------------------------------
// Checks
// ---------------------------------------------------------------------------
/** Requirements, as [label, ok, detail, fatal]. */
function install_requirements(): array {
$checks = array();
$checks[] = array(
'PHP 8.1 or newer',
PHP_VERSION_ID >= 80100,
'You are on PHP ' . PHP_VERSION,
true,
);
foreach (array('mysqli' => true, 'curl' => false, 'openssl' => false, 'gd' => false, 'zip' => false) as $ext => $fatal) {
$checks[] = array(
'Extension: ' . $ext,
extension_loaded($ext),
$fatal ? 'Required' : 'Optional',
$fatal,
);
}
$cache = install_root() . '/engine/cache';
$checks[] = array(
'engine/cache/ is writable',
is_dir($cache) && is_writable($cache),
$cache,
true,
);
$checks[] = array(
'The site root is writable',
is_writable(install_root()),
'Needed to write config.local.php. You can also create it by hand at the last step.',
false,
);
$schema = install_root() . '/SQL/znote_schema.sql';
$checks[] = array(
'SQL/znote_schema.sql is present',
is_file($schema),
$schema,
true,
);
return $checks;
}
/**
* Tables the OT server creates, which ZnoteX reads but never creates itself.
* Their absence is what makes the installer refuse to go on.
*/
function install_server_tables(mysqli $link): array {
$required = array('accounts', 'players');
$optional = array('guilds', 'houses', 'player_deaths', 'players_online');
$present = array();
$result = @$link->query('SHOW TABLES');
if ($result !== false) {
while ($row = $result->fetch_array()) {
$present[strtolower($row[0])] = true;
}
}
$out = array('required' => array(), 'optional' => array(), 'ok' => true);
foreach ($required as $table) {
$found = isset($present[$table]);
$out['required'][$table] = $found;
if (!$found) {
$out['ok'] = false;
}
}
foreach ($optional as $table) {
$out['optional'][$table] = isset($present[$table]);
}
$out['znote_installed'] = isset($present['znote']);
return $out;
}
// ---------------------------------------------------------------------------
// Small view helpers
// ---------------------------------------------------------------------------
function ih($value): string {
return htmlspecialchars((string)($value ?? ''), ENT_QUOTES, 'UTF-8');
}
function install_url(int $step): string {
return 'index.php?step=' . $step;
}
function install_error(string $message): void {
$_SESSION['install_error'] = $message;
}
function install_take_error(): string {
$error = (string)($_SESSION['install_error'] ?? '');
unset($_SESSION['install_error']);
return $error;
}
function install_csrf_token(): string {
if (empty($_SESSION['install_csrf']) || !is_string($_SESSION['install_csrf'])) {
$_SESSION['install_csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['install_csrf'];
}
function install_csrf_field(): string {
return '';
}
function install_csrf_validate(): bool {
$posted = $_POST['install_csrf'] ?? null;
$token = $_SESSION['install_csrf'] ?? null;
if (!is_string($posted) || $posted === '' || !is_string($token) || $token === '') {
return false;
}
$valid = hash_equals($token, $posted);
if ($valid) {
$_SESSION['install_csrf'] = bin2hex(random_bytes(32));
}
return $valid;
}
function install_csrf_inject(string $html): string {
if (stripos($html, '