Ctrl K
ComunidadGuías…

install.php.disabled

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<?php

/**
 * One-time web installer. Disabled by default: rename this file to
 * install.php to activate it. It refuses to run once .env exists, and it
 * never duplicates Hm_Installer's or Hm_Web_Installer's logic, only calls
 * them.
 *
 * Access is gated by a one-time token written to .install_token next to this
 * file. The leading dot matters: the project's .htaccess denies HTTP access
 * to dotfiles, so the token is only reachable over FTP/SSH/file manager, not
 * by requesting it as a URL. Retrieve it that way and pass it as ?token=...
 * before any form is shown, so a bystander who finds this URL while it's
 * enabled still cannot use it. This protection needs Apache (or another
 * server honoring .htaccess) with AllowOverride enabled, which is the norm
 * on the shared hosting this installer targets; on Nginx or other servers,
 * use scripts/install.php instead.
 */

error_reporting(E_ALL);
ini_set('display_errors', '0');

define('APP_PATH', __DIR__.'/');
require APP_PATH.'lib/installer.php';
require APP_PATH.'lib/web_installer.php';

header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');

$installer = new Hm_Installer();

if ($installer->envExists()) {
    render_page('Already installed', '<p>Cypht is already configured. Delete .env first if you really want to run the installer again.</p>', 'locked');
    exit;
}

$web_installer = new Hm_Web_Installer($installer, APP_PATH.'.install_token');

session_set_cookie_params(['httponly' => true, 'samesite' => 'Lax', 'secure' => Hm_Web_Installer::isHttps($_SERVER)]);
session_start();

if (!$web_installer->tokenExists()) {
    $web_installer->generateToken();
}

$given_token = $_GET['token'] ?? ($_POST['token'] ?? '');
if (!$web_installer->tokenMatches($given_token)) {
    render_page('Installer locked', '<p>Missing or invalid access token.</p>'.
        '<p>Retrieve it from <code>.install_token</code> next to this script (over FTP, SSH, or your host\'s file manager; enable "show hidden files" if needed), then reopen this page with <code>?token=</code> appended.</p>', 'locked');
    exit;
}

// A valid token proves filesystem access, promoting this session from
// anonymous to installer-operator: regenerate the id so a session fixed
// before that point (e.g. via a cookie set on a shared subdomain) can't
// be reused to submit the form on the operator's behalf.
if (empty($_SESSION['token_verified'])) {
    session_regenerate_id(true);
    $_SESSION['token_verified'] = true;
}

$missing = array_keys(array_filter($installer->checkRequirements(), fn($ok) => !$ok));
if (!empty($missing)) {
    render_page('Missing requirements', '<p>The server is missing: '.htmlspecialchars(implode(', ', $missing)).'</p>', 'error');
    exit;
}

if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(24));
}
$csrf_token = $_SESSION['csrf_token'];

$ajax_action = $_POST['ajax_action'] ?? '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array($ajax_action, ['test_auth_connection', 'test_storage_dirs'], true)) {
    header('Content-Type: application/json');
    if (!Hm_Web_Installer::secretMatches($csrf_token, $_POST['csrf_token'] ?? '')) {
        echo json_encode(['success' => false, 'error' => 'Invalid session, reload the form and try again.']);
        exit;
    }
    $values = Hm_Web_Installer::collectFormValues($_POST);

    if ($ajax_action === 'test_auth_connection') {
        $errors = Hm_Web_Installer::validate($values, APP_PATH, ['AUTH']);
        if (!empty($errors)) {
            echo json_encode(['success' => false, 'error' => implode(' ', $errors)]);
            exit;
        }
        $result = $web_installer->testAuthConnection($values);
        echo json_encode(['success' => $result['success'], 'error' => $result['error']]);
        exit;
    }

    $errors = Hm_Web_Installer::validate($values, APP_PATH, ['DIRS']);
    if (!empty($errors)) {
        echo json_encode(['success' => false, 'error' => implode(' ', $errors)]);
        exit;
    }
    $result = Hm_Web_Installer::checkStorageWritable($values);
    echo json_encode(['success' => $result['success'], 'error' => $result['error']]);
    exit;
}

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (!Hm_Web_Installer::secretMatches($csrf_token, $_POST['csrf_token'] ?? '')) {
        render_page('Installer locked', '<p>Invalid session, reload the form and try again.</p>', 'locked');
        exit;
    }

    $values = Hm_Web_Installer::collectFormValues($_POST);
    $errors = Hm_Web_Installer::validate($values, APP_PATH);
    if (!empty($errors)) {
        render_page('Installation failed', '<ul><li>'.implode('</li><li>', array_map('htmlspecialchars', $errors)).'</li></ul>', 'error');
        exit;
    }

    try {
        $result = $web_installer->install($values, $_POST['admin_user'] ?? '', $_POST['admin_pass'] ?? '');
        $body = '<pre>'.htmlspecialchars($result['output']).'</pre>';

        if ($result['success']) {
            $web_installer->consumeToken();
            render_page('Installation complete', $body.'<p><strong>Delete or rename install.php now.</strong></p>', 'success');
        }
        else {
            $hint = $result['env_written']
                ? '<p>.env was written before this step failed. If you retry, delete .env first or the installer will report itself as already configured.</p>'
                : '';
            render_page('Installation failed', $body.$hint, 'error');
        }
    }
    catch (Throwable $e) {
        error_log('Cypht installer error: '.$e->getMessage());
        render_page('Installation failed', '<p>An unexpected error occurred. Check the server error log.</p>', 'error');
    }
    exit;
}

render_form($csrf_token, $given_token);

function field($name, $label, $type = 'text', $hint = '', $required = false) {
    $hint_html = $hint ? '<span class="hint">'.htmlspecialchars($hint).'</span>' : '';
    $input = '<input type="'.$type.'" name="'.htmlspecialchars($name).'" autocomplete="off"'.
        ($required ? ' data-required' : '').'>';
    if ($type === 'checkbox') {
        return '<label class="checkbox">'.$input.htmlspecialchars($label).$hint_html.'</label>';
    }
    return '<label>'.htmlspecialchars($label).$hint_html.$input.'</label>';
}

function driver_select() {
    $opts = '';
    foreach (['mysql', 'pgsql', 'sqlite'] as $driver) {
        $opts .= '<option value="'.$driver.'">'.$driver.'</option>';
    }
    return '<label>Driver<select name="DB_DRIVER" data-required>'.$opts.'</select></label>';
}

function auth_type_select() {
    $opts = '';
    foreach (['DB' => 'Database', 'IMAP' => 'IMAP server', 'LDAP' => 'LDAP server'] as $value => $label) {
        $opts .= '<option value="'.$value.'">'.htmlspecialchars($label).'</option>';
    }
    return '<label>Authentication method<select name="AUTH_TYPE" id="auth-type" data-required>'.$opts.'</select></label>';
}

function auth_fields_group($auth_type, $fields, $visible_by_default = false) {
    return '<div class="auth-fields" data-auth="'.htmlspecialchars($auth_type).'"'.($visible_by_default ? '' : ' hidden').'>'.$fields.'</div>';
}

function render_form($csrf_token, $token) {
    $steps = [
        ['title' => 'Authentication', 'validate' => 'auth', 'only_for' => '', 'fields' =>
            auth_type_select().
            auth_fields_group('DB',
                driver_select().
                field('DB_HOST', 'Host', 'text', '', true).
                field('DB_NAME', 'Name', 'text', '', true).
                field('DB_USER', 'User', 'text', '', true).
                field('DB_PASS', 'Password', 'password'), true).
            auth_fields_group('IMAP',
                field('IMAP_AUTH_SERVER', 'Server', 'text', '', true).
                field('IMAP_AUTH_PORT', 'Port', 'text', '', true).
                field('IMAP_AUTH_TLS', 'Use TLS', 'checkbox').
                field('IMAP_AUTH_NAME', 'Display name', 'text', 'optional, defaults to the server address').
                field('IMAP_AUTH_SIEVE_CONF_HOST', 'Sieve filter server', 'text', 'optional')).
            auth_fields_group('LDAP',
                field('LDAP_AUTH_SERVER', 'Server', 'text', '', true).
                field('LDAP_AUTH_PORT', 'Port', 'text', '', true).
                field('LDAP_AUTH_BASE_DN', 'Base DN', 'text', '', true).
                field('LDAP_AUTH_UID_ATTR', 'UID attribute', 'text', '', true).
                field('LDAP_AUTH_TLS', 'Use TLS', 'checkbox'))],
        ['title' => 'Storage', 'validate' => 'dirs', 'only_for' => '', 'fields' =>
            field('USER_SETTINGS_DIR', 'User settings directory', 'text', 'outside the web root', true).
            field('ATTACHMENT_DIR', 'Attachment directory', 'text', 'outside the web root', true)],
        ['title' => 'Admin account (optional)', 'validate' => '', 'only_for' => 'DB', 'fields' =>
            field('admin_user', 'Username').
            field('admin_pass', 'Password', 'password')],
    ];

    $panels = '';
    $dots = '';
    $i = 0;
    foreach ($steps as $step) {
        $i++;
        $panels .= '<section class="step" data-step="'.$i.'"'.($i === 1 ? '' : ' hidden').
            ($step['validate'] !== '' ? ' data-validate="'.htmlspecialchars($step['validate']).'"' : '').
            ($step['only_for'] !== '' ? ' data-only-for="'.htmlspecialchars($step['only_for']).'"' : '').'>'.
            '<h2>'.htmlspecialchars($step['title']).'</h2>'.
            '<p class="step-error" hidden></p>'.
            $step['fields'].'</section>';
        $dots .= '<li'.($i === 1 ? ' class="active"' : '').'></li>';
    }

    $body = '<ol class="steps">'.$dots.'</ol>'.
        '<form method="post" id="install-form">'.
        '<input type="hidden" name="csrf_token" value="'.htmlspecialchars($csrf_token).'">'.
        '<input type="hidden" name="token" value="'.htmlspecialchars($token).'">'.
        $panels.
        '<div class="nav">'.
        '<button type="button" id="back-btn" hidden>Back</button>'.
        '<button type="button" id="next-btn">Next</button>'.
        '<button type="submit" id="submit-btn" hidden>Install</button>'.
        '</div>'.
        '</form>'.
        step_script();
    render_page('Cypht installer', $body);
}

function step_script() {
    return <<<'HTML'
        <script>
        (function () {
            var form = document.getElementById('install-form');
            var steps = Array.prototype.slice.call(document.querySelectorAll('.step'));
            var dots = document.querySelectorAll('.steps li');
            var backBtn = document.getElementById('back-btn');
            var nextBtn = document.getElementById('next-btn');
            var submitBtn = document.getElementById('submit-btn');
            var authSelect = document.getElementById('auth-type');
            var current = steps[0];

            // Steps with data-only-for are skipped unless it matches AUTH_TYPE.
            function activeSteps() {
                return steps.filter(function (el) {
                    return !el.dataset.onlyFor || el.dataset.onlyFor === authSelect.value;
                });
            }

            function show(stepEl) {
                document.querySelectorAll('.auth-fields').forEach(function (group) {
                    group.hidden = group.dataset.auth !== authSelect.value;
                });
                steps.forEach(function (el) {
                    var isCurrent = el === stepEl;
                    el.hidden = !isCurrent;
                    // Only required within the current step AND the active auth-fields group.
                    el.querySelectorAll('[data-required]').forEach(function (input) {
                        var group = input.closest('.auth-fields');
                        input.required = isCurrent && (!group || group.dataset.auth === authSelect.value);
                    });
                });
                var active = activeSteps();
                var idx = active.indexOf(stepEl);
                dots.forEach(function (el, i) {
                    var activeIdx = active.indexOf(steps[i]);
                    el.hidden = activeIdx === -1;
                    el.classList.toggle('active', steps[i] === stepEl);
                    el.classList.toggle('done', activeIdx !== -1 && activeIdx < idx);
                });
                backBtn.hidden = idx === 0;
                nextBtn.hidden = idx === active.length - 1;
                submitBtn.hidden = idx !== active.length - 1;
                current = stepEl;
            }

            function showStepError(stepEl, message) {
                var errorEl = stepEl.querySelector('.step-error');
                errorEl.textContent = message || '';
                errorEl.hidden = !message;
            }

            var AJAX_ACTIONS = {
                auth: 'test_auth_connection',
                dirs: 'test_storage_dirs'
            };
            var DEFAULT_ERRORS = {
                auth: 'Could not verify the auth settings.',
                dirs: 'Storage directories are not usable.'
            };

            function validateStep(validate) {
                var data = new FormData(form);
                data.set('ajax_action', AJAX_ACTIONS[validate]);
                nextBtn.disabled = true;
                var previousLabel = nextBtn.textContent;
                nextBtn.textContent = 'Checking…';
                return fetch(window.location.href, { method: 'POST', body: data, credentials: 'same-origin' })
                    .then(function (response) { return response.json(); })
                    .catch(function () { return { success: false, error: 'Could not reach the server to validate.' }; })
                    .then(function (result) {
                        nextBtn.disabled = false;
                        nextBtn.textContent = previousLabel;
                        return result;
                    });
            }

            function advance() {
                var active = activeSteps();
                var idx = active.indexOf(current);
                show(active[Math.min(idx + 1, active.length - 1)]);
            }

            nextBtn.addEventListener('click', function () {
                if (!form.reportValidity()) return;
                showStepError(current, '');
                var validate = current.dataset.validate;
                if (validate && AJAX_ACTIONS[validate]) {
                    validateStep(validate).then(function (result) {
                        if (result.success) {
                            advance();
                        }
                        else {
                            showStepError(current, result.error || DEFAULT_ERRORS[validate]);
                        }
                    });
                    return;
                }
                advance();
            });
            backBtn.addEventListener('click', function () {
                var active = activeSteps();
                var idx = active.indexOf(current);
                show(active[Math.max(idx - 1, 0)]);
            });
            if (authSelect) {
                authSelect.addEventListener('change', function () { show(current); });
            }

            show(steps[0]);
        })();
        </script>
        HTML;
}

function render_page($title, $body, $variant = 'info') {
    echo '<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">'.
        '<link rel="icon" href="data:,">'.
        '<title>'.htmlspecialchars($title).'</title>'.
        page_style().
        '</head><body><main class="card '.$variant.'">'.
        '<h1>'.htmlspecialchars($title).'</h1>'.
        $body.
        '</main></body></html>';
}

function page_style() {
    return <<<'CSS'
        <style>
            :root {
                color-scheme: light dark;
                --bg: #f4f5f7;
                --card-bg: #fff;
                --text: #1f2430;
                --muted: #6b7280;
                --border: #d8dbe1;
                --accent: #006363;
                --accent-hover: #005555;
                --accent-text: #fff;
                --locked-border: #d97706;
                --error-border: #dc2626;
                --success-border: #16a34a;
            }
            @media (prefers-color-scheme: dark) {
                :root {
                    --bg: #14161c;
                    --card-bg: #1c1f28;
                    --text: #e7e9ee;
                    --muted: #9aa1b1;
                    --border: #2c303b;
                }
            }
            * { box-sizing: border-box; }
            body {
                margin: 0;
                padding: 40px 16px;
                background: var(--bg);
                color: var(--text);
                font: 15px/1.5 -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
            }
            .card {
                max-width: 480px;
                margin: 0 auto;
                background: var(--card-bg);
                border: 1px solid var(--border);
                border-top: 4px solid var(--border);
                border-radius: 8px;
                padding: 28px 28px 24px;
                box-shadow: 0 1px 3px rgba(0, 0, 0, .08);
            }
            .card.locked { border-top-color: var(--locked-border); }
            .card.error { border-top-color: var(--error-border); }
            .card.success { border-top-color: var(--success-border); }
            ol.steps {
                display: flex;
                gap: 6px;
                list-style: none;
                margin: 0 0 20px;
                padding: 0;
            }
            ol.steps li {
                flex: 1;
                height: 4px;
                border-radius: 2px;
                background: var(--border);
            }
            ol.steps li.active, ol.steps li.done { background: var(--accent); }
            .step h2 {
                font-size: 13px;
                font-weight: 600;
                text-transform: uppercase;
                letter-spacing: .04em;
                color: var(--muted);
                margin: 0 0 4px;
            }
            .nav {
                display: flex;
                gap: 10px;
                margin-top: 20px;
            }
            .nav button { flex: 1; width: auto; margin-top: 0; }
            #back-btn {
                background: transparent;
                color: var(--text);
                border: 1px solid var(--border);
            }
            h1 { font-size: 19px; margin: 0 0 16px; }
            p { margin: 0 0 12px; color: var(--muted); }
            code {
                background: var(--bg);
                border: 1px solid var(--border);
                border-radius: 4px;
                padding: 1px 5px;
                font-size: 13px;
            }
            pre {
                background: var(--bg);
                border: 1px solid var(--border);
                border-radius: 6px;
                padding: 12px;
                font-size: 13px;
                white-space: pre-wrap;
                word-break: break-word;
                max-height: 320px;
                overflow-y: auto;
            }
            fieldset {
                border: 1px solid var(--border);
                border-radius: 6px;
                margin: 0 0 16px;
                padding: 4px 14px 14px;
            }
            legend {
                font-size: 12px;
                font-weight: 600;
                text-transform: uppercase;
                letter-spacing: .04em;
                color: var(--muted);
                padding: 0 4px;
            }
            label {
                display: block;
                font-size: 13px;
                font-weight: 500;
                margin-top: 10px;
            }
            ul { margin: 0 0 12px; padding-left: 20px; color: var(--muted); }
            .hint { color: var(--muted); font-weight: 400; }
            .hint::before { content: " \2014 "; }
            .step-error {
                color: var(--error-border);
                font-size: 13px;
                margin: 8px 0 0;
            }
            input, select {
                display: block;
                width: 100%;
                margin-top: 4px;
                padding: 8px 10px;
                font-size: 14px;
                color: var(--text);
                background: var(--card-bg);
                border: 1px solid var(--border);
                border-radius: 5px;
            }
            label.checkbox {
                display: flex;
                align-items: center;
                gap: 8px;
            }
            label.checkbox input {
                display: inline-block;
                width: auto;
                margin-top: 0;
                order: -1;
            }
            input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
            input:invalid:not(:placeholder-shown) { border-color: var(--error-border); }
            button {
                width: 100%;
                margin-top: 20px;
                padding: 10px;
                font-size: 14px;
                font-weight: 600;
                color: var(--accent-text);
                background: var(--accent);
                border: none;
                border-radius: 6px;
                cursor: pointer;
            }
            button:hover { background: var(--accent-hover); }
            #back-btn:hover { background: var(--bg); }
        </style>
        CSS;
}