635 lines · 29.9 KB
Raw Download
1
<?php
2
/**
3
 * Guided web installer for NestEggCode.
4
 *
5
 * Point your browser at this file (e.g. https://your-site/install.php) and it
6
 * walks you through creating config.php, testing the database, importing the
7
 * schema, and matching the URL settings. It is intentionally written in
8
 * conservative PHP so that even an out-of-date server can still load it and
9
 * show you a helpful "please upgrade PHP" message instead of a blank page.
10
 *
11
 * SECURITY: delete this file once the site works. The admin dashboard will
12
 * remind you and can delete it for you after you confirm the site is working.
13
 */
14
15
error_reporting(E_ALL & ~E_DEPRECATED & ~E_STRICT & ~E_NOTICE);
16
@ini_set('display_errors', '1');
17
18
$ROOT         = __DIR__;
19
$CONFIG_PATH  = $ROOT . '/config.php';
20
$SCHEMA_PATH  = $ROOT . '/schema.sql';
21
$HTACCESS     = $ROOT . '/.htaccess';
22
$UPLOADS_DIR  = $ROOT . '/uploads';
23
24
// --------------------------------------------------------------------------
25
// Small helpers (kept PHP 5.4-compatible on purpose).
26
// --------------------------------------------------------------------------
27
28
function h($s) {
29
    return htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8');
30
}
31
32
/** Best guess at the URL prefix the app is served under, e.g. "" or "/pgh5". */
33
function detect_base_path() {
34
    $script = isset($_SERVER['SCRIPT_NAME']) ? $_SERVER['SCRIPT_NAME'] : '';
35
    $dir    = str_replace('\\', '/', dirname($script));
36
    $dir    = rtrim($dir, '/');
37
    return $dir === '' ? '' : $dir;
38
}
39
40
/** Best guess at the site's public origin, e.g. "https://code.example.com". */
41
function detect_site_url() {
42
    $https = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
43
        || (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https')
44
        || (isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443);
45
    $host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : '';
46
    $host = preg_replace('/[^A-Za-z0-9.\-:\[\]]/', '', $host);
47
    return $host === '' ? '' : (($https ? 'https' : 'http') . '://' . $host);
48
}
49
50
/** Turn a PHP shorthand size like "64M" into bytes (for display only). */
51
function human_bytes($bytes) {
52
    $units = array('B', 'KB', 'MB', 'GB');
53
    $i = 0;
54
    $n = (float) $bytes;
55
    while ($n >= 1024 && $i < count($units) - 1) { $n /= 1024; $i++; }
56
    return ($i === 0 ? $n : round($n, 1)) . ' ' . $units[$i];
57
}
58
59
// --------------------------------------------------------------------------
60
// Environment pre-flight checks. Each entry: label, ok, detail, fatal.
61
// --------------------------------------------------------------------------
62
63
function preflight_checks($configPath, $schemaPath, $uploadsDir) {
64
    $checks = array();
65
66
    $phpOk = version_compare(PHP_VERSION, '7.4.0', '>=');
67
    $checks[] = array(
68
        'label'  => 'PHP version 7.4 or newer',
69
        'ok'     => $phpOk,
70
        'detail' => $phpOk
71
            ? 'Running PHP ' . PHP_VERSION . '.'
72
            : 'You are on PHP ' . PHP_VERSION . '. Ask your host to switch the PHP '
73
              . 'version (in cPanel this is "Select PHP Version" / "MultiPHP Manager"), '
74
              . 'or update your local MAMP/XAMPP.',
75
        'fatal'  => true,
76
    );
77
78
    $pdo = extension_loaded('pdo_mysql');
79
    $checks[] = array(
80
        'label'  => 'PDO MySQL database driver',
81
        'ok'     => $pdo,
82
        'detail' => $pdo
83
            ? 'The pdo_mysql extension is enabled.'
84
            : 'The pdo_mysql extension is missing. Enable it in your PHP settings '
85
              . '(cPanel: "Select PHP Version" → tick "pdo_mysql"; MAMP usually has it on).',
86
        'fatal'  => true,
87
    );
88
89
    $canWriteConfig = file_exists($configPath) ? is_writable($configPath) : is_writable(dirname($configPath));
90
    $checks[] = array(
91
        'label'  => 'config.php can be written',
92
        'ok'     => $canWriteConfig,
93
        'detail' => $canWriteConfig
94
            ? (file_exists($configPath) ? 'config.php exists and is writable.' : 'The folder is writable, so config.php can be created.')
95
            : 'The web server cannot write to this folder. Set the folder\'s permissions to '
96
              . '755 (and config.php to 644) via your host\'s File Manager or FTP client.',
97
        'fatal'  => true,
98
    );
99
100
    $uploadsOk = is_dir($uploadsDir) ? is_writable($uploadsDir) : is_writable(dirname($uploadsDir));
101
    $checks[] = array(
102
        'label'  => 'uploads/ folder is writable',
103
        'ok'     => $uploadsOk,
104
        'detail' => $uploadsOk
105
            ? (is_dir($uploadsDir) ? 'uploads/ exists and is writable.' : 'uploads/ will be created during install.')
106
            : 'The uploads/ folder is not writable. Create a folder named "uploads" next to '
107
              . 'this file and set its permissions to 755.',
108
        'fatal'  => false,
109
    );
110
111
    $schemaOk = is_file($schemaPath) && is_readable($schemaPath);
112
    $checks[] = array(
113
        'label'  => 'schema.sql is present',
114
        'ok'     => $schemaOk,
115
        'detail' => $schemaOk
116
            ? 'Found schema.sql (the database table definitions).'
117
            : 'schema.sql is missing. Re-upload it from the project files — the installer '
118
              . 'needs it to create the database tables.',
119
        'fatal'  => false,
120
    );
121
122
    $zip = class_exists('ZipArchive');
123
    $checks[] = array(
124
        'label'  => 'Zip extension (optional)',
125
        'ok'     => $zip,
126
        'detail' => $zip
127
            ? 'Zip uploads will work.'
128
            : 'The zip extension is off. Everything works except "Upload a zip"; you can '
129
              . 'still upload files and folders. Enable php-zip later if you want it.',
130
        'fatal'  => false,
131
    );
132
133
    return $checks;
134
}
135
136
/** Build the contents of config.php from validated values. */
137
function build_config_php($v) {
138
    $x = function ($s) { return var_export($s, true); };
139
    $lines   = array();
140
    $lines[] = '<?php';
141
    $lines[] = '/**';
142
    $lines[] = ' * Application configuration. Generated by install.php on ' . date('Y-m-d H:i') . '.';
143
    $lines[] = ' *';
144
    $lines[] = ' * This file holds secrets (database password, admin password hash).';
145
    $lines[] = ' * Keep it OUT of version control and do not share it.';
146
    $lines[] = ' */';
147
    $lines[] = '';
148
    $lines[] = '// ---- Database (PDO / MySQL) -------------------------------------------------';
149
    $lines[] = "define('DB_HOST', "    . $x($v['db_host']) . ');';
150
    $lines[] = "define('DB_NAME', "    . $x($v['db_name']) . ');';
151
    $lines[] = "define('DB_USER', "    . $x($v['db_user']) . ');';
152
    $lines[] = "define('DB_PASS', "    . $x($v['db_pass']) . ');';
153
    $lines[] = "define('DB_CHARSET', " . $x('utf8mb4') . ');';
154
    $lines[] = '';
155
    $lines[] = '// ---- Admin credentials (single user) ---------------------------------------';
156
    $lines[] = '// The password is stored as a bcrypt hash, never in plaintext.';
157
    $lines[] = "define('ADMIN_USER', "      . $x($v['admin_user']) . ');';
158
    $lines[] = "define('ADMIN_PASS_HASH', " . $x($v['admin_hash']) . ');';
159
    $lines[] = '';
160
    $lines[] = '// ---- Paths / routing --------------------------------------------------------';
161
    $lines[] = '// BASE_PATH is the URL prefix the app is served under';
162
    $lines[] = "//   Local MAMP subfolder:  '/pgh5'";
163
    $lines[] = "//   Production domain root: ''";
164
    $lines[] = "define('BASE_PATH', " . $x($v['base_path']) . ');';
165
    $lines[] = '';
166
    $lines[] = '// Public origin of the site — scheme + host, no trailing slash, no BASE_PATH.';
167
    $lines[] = '// Used to build the absolute URLs that social-share metadata requires.';
168
    $lines[] = '// Detected during install; correct it here if the site moves.';
169
    $lines[] = "define('SITE_URL', " . $x(isset($v['site_url']) ? $v['site_url'] : '') . ');';
170
    $lines[] = '';
171
    $lines[] = '// Absolute filesystem path to the uploads directory (no trailing slash).';
172
    $lines[] = "define('UPLOAD_DIR', __DIR__ . '/uploads');";
173
    $lines[] = '';
174
    $lines[] = '// Max upload size per file, in bytes (also bounds each file inside a zip).';
175
    $lines[] = "define('MAX_FILE_SIZE', " . (int) $v['max_file'] . ' * 1024 * 1024); // MB';
176
    $lines[] = '';
177
    $lines[] = '// Ceiling on the total uncompressed size of a single zip import, in bytes.';
178
    $lines[] = "define('MAX_ZIP_TOTAL', " . (int) $v['max_zip'] . ' * 1024 * 1024); // MB';
179
    $lines[] = '';
180
    return implode("\n", $lines);
181
}
182
183
/** Translate a raw PDO error into plain-English advice. */
184
function friendly_db_error($e) {
185
    $msg  = $e->getMessage();
186
    $code = $e->getCode();
187
    if (stripos($msg, 'Access denied') !== false) {
188
        return 'The database refused the username or password. Double-check DB_USER and '
189
             . 'DB_PASS. On cPanel the username is usually your account name plus an '
190
             . 'underscore, like "myacct_code".';
191
    }
192
    if (stripos($msg, 'Unknown database') !== false) {
193
        return 'The database name was not found. Create the database first (cPanel → '
194
             . '"MySQL Databases"), or tick "Create the database if it does not exist" below '
195
             . 'if your user is allowed to create databases.';
196
    }
197
    if (stripos($msg, 'Connection refused') !== false || stripos($msg, "Can't connect") !== false || $code === 2002) {
198
        return 'Could not reach the database server at that host. Try "localhost" (most '
199
             . 'shared hosts and MAMP). If MAMP uses a custom port, use "127.0.0.1:8889".';
200
    }
201
    if (stripos($msg, 'getaddrinfo') !== false || stripos($msg, 'php_network_getaddresses') !== false) {
202
        return 'The database host name could not be resolved. Check DB_HOST for typos; it '
203
             . 'is usually "localhost".';
204
    }
205
    return 'Database error: ' . $msg;
206
}
207
208
// --------------------------------------------------------------------------
209
// Handle the install submission.
210
// --------------------------------------------------------------------------
211
212
$errors  = array();
213
$warnings = array();
214
$success = false;
215
$results = array();
216
217
// Sticky form values (also used to prefill on first load).
218
$f = array(
219
    'db_host'    => 'localhost',
220
    'db_name'    => '',
221
    'db_user'    => '',
222
    'db_pass'    => '',
223
    'admin_user' => '',
224
    'base_path'  => detect_base_path(),
225
    'max_file'   => 25,
226
    'max_zip'    => 200,
227
);
228
229
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['do_install'])) {
230
    foreach (array('db_host','db_name','db_user','db_pass','admin_user','base_path') as $k) {
231
        if (isset($_POST[$k])) { $f[$k] = trim($_POST[$k]); }
232
    }
233
    $f['max_file']  = isset($_POST['max_file']) ? max(1, (int) $_POST['max_file']) : 25;
234
    $f['max_zip']   = isset($_POST['max_zip'])  ? max(1, (int) $_POST['max_zip'])  : 200;
235
    $adminPass      = isset($_POST['admin_pass'])  ? (string) $_POST['admin_pass']  : '';
236
    $adminPass2     = isset($_POST['admin_pass2']) ? (string) $_POST['admin_pass2'] : '';
237
    $createDb       = !empty($_POST['create_db']);
238
    $importSchema   = !empty($_POST['import_schema']);
239
240
    // Normalise base path: ensure a single leading slash, no trailing slash, or ''.
241
    $bp = str_replace('\\', '/', $f['base_path']);
242
    $bp = '/' . trim($bp, '/');
243
    $f['base_path'] = ($bp === '/') ? '' : $bp;
244
245
    // ---- Field validation -------------------------------------------------
246
    if ($f['db_name'] === '')  { $errors[] = 'Database name is required.'; }
247
    if ($f['db_user'] === '')  { $errors[] = 'Database username is required.'; }
248
    if ($f['admin_user'] === '') { $errors[] = 'Admin username is required.'; }
249
    if (strlen($adminPass) < 8) {
250
        $errors[] = 'Admin password must be at least 8 characters. Pick something long and hard to guess.';
251
    } elseif ($adminPass !== $adminPass2) {
252
        $errors[] = 'The two admin passwords do not match. Re-type them carefully.';
253
    }
254
255
    // ---- Database connection & optional creation --------------------------
256
    $pdo = null;
257
    if (!$errors) {
258
        try {
259
            if ($createDb) {
260
                // Connect without a database, create it, then select it.
261
                $dsn = 'mysql:host=' . $f['db_host'] . ';charset=utf8mb4';
262
                $pdo = new PDO($dsn, $f['db_user'], $f['db_pass'], array(PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION));
263
                $safe = str_replace('`', '', $f['db_name']);
264
                $pdo->exec('CREATE DATABASE IF NOT EXISTS `' . $safe . '` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
265
                $pdo->exec('USE `' . $safe . '`');
266
                $results[] = 'Database "' . h($f['db_name']) . '" is ready.';
267
            } else {
268
                $dsn = 'mysql:host=' . $f['db_host'] . ';dbname=' . $f['db_name'] . ';charset=utf8mb4';
269
                $pdo = new PDO($dsn, $f['db_user'], $f['db_pass'], array(PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION));
270
                $results[] = 'Connected to the database successfully.';
271
            }
272
        } catch (PDOException $e) {
273
            $errors[] = friendly_db_error($e);
274
        }
275
    }
276
277
    // ---- Import the schema ------------------------------------------------
278
    if (!$errors && $pdo && $importSchema) {
279
        if (!is_file($SCHEMA_PATH)) {
280
            $errors[] = 'schema.sql is missing, so the tables could not be created. Re-upload it and try again.';
281
        } else {
282
            try {
283
                $sql   = file_get_contents($SCHEMA_PATH);
284
                $sql   = preg_replace('/^\s*--.*$/m', '', $sql); // strip comment lines
285
                $parts = array_filter(array_map('trim', explode(';', $sql)));
286
                foreach ($parts as $stmt) {
287
                    $pdo->exec($stmt);
288
                }
289
                $results[] = 'Database tables created (repositories, files, folders).';
290
            } catch (PDOException $e) {
291
                $errors[] = 'Could not create the tables: ' . h($e->getMessage())
292
                          . ' — you can also import schema.sql manually via phpMyAdmin.';
293
            }
294
        }
295
    }
296
297
    // ---- Write config.php -------------------------------------------------
298
    if (!$errors) {
299
        $vals = array(
300
            'db_host'    => $f['db_host'],
301
            'db_name'    => $f['db_name'],
302
            'db_user'    => $f['db_user'],
303
            'db_pass'    => $f['db_pass'],
304
            'admin_user' => $f['admin_user'],
305
            'admin_hash' => password_hash($adminPass, PASSWORD_DEFAULT),
306
            'base_path'  => $f['base_path'],
307
            'site_url'   => detect_site_url(),
308
            'max_file'   => $f['max_file'],
309
            'max_zip'    => $f['max_zip'],
310
        );
311
        $written = @file_put_contents($CONFIG_PATH, build_config_php($vals));
312
        if ($written === false) {
313
            $errors[] = 'Could not write config.php. Check the folder is writable (permissions 755) '
314
                      . 'and try again. Nothing else was changed.';
315
        } else {
316
            @chmod($CONFIG_PATH, 0644);
317
            $results[] = 'config.php written with your settings.';
318
        }
319
    }
320
321
    // ---- Make sure uploads/ exists ---------------------------------------
322
    if (!$errors) {
323
        if (!is_dir($UPLOADS_DIR)) {
324
            if (@mkdir($UPLOADS_DIR, 0755, true)) {
325
                $results[] = 'Created the uploads/ folder.';
326
            } else {
327
                $warnings[] = 'Could not create the uploads/ folder automatically. Create a folder '
328
                            . 'named "uploads" next to install.php (permissions 755) before uploading files.';
329
            }
330
        }
331
    }
332
333
    // ---- Best-effort: match .htaccess RewriteBase to BASE_PATH ------------
334
    if (!$errors && is_file($HTACCESS) && is_writable($HTACCESS)) {
335
        $ht = file_get_contents($HTACCESS);
336
        $rewriteBase = ($f['base_path'] === '') ? '/' : $f['base_path'] . '/';
337
        $newHt = preg_replace('/^(\s*RewriteBase\s+).*$/m', '${1}' . $rewriteBase, $ht, 1, $count);
338
        if ($count > 0 && $newHt !== null && $newHt !== $ht) {
339
            if (@file_put_contents($HTACCESS, $newHt) !== false) {
340
                $results[] = 'Updated .htaccess RewriteBase to "' . h($rewriteBase) . '".';
341
            } else {
342
                $warnings[] = 'Could not update .htaccess. If pretty URLs 404, set "RewriteBase '
343
                            . h($rewriteBase) . '" in .htaccess by hand.';
344
            }
345
        }
346
    } elseif (!$errors && is_file($HTACCESS)) {
347
        $rewriteBase = ($f['base_path'] === '') ? '/' : $f['base_path'] . '/';
348
        $warnings[] = '.htaccess is not writable, so its RewriteBase was left as-is. If pretty URLs '
349
                    . '404, set "RewriteBase ' . h($rewriteBase) . '" in .htaccess by hand.';
350
    }
351
352
    if (!$errors) {
353
        $success = true;
354
    }
355
}
356
357
// Public login / home URLs for the success screen.
358
$base       = $f['base_path'];
359
$loginUrl   = ($base === '' ? '' : $base) . '/admin/login.php';
360
$homeUrl    = ($base === '' ? '/' : $base . '/');
361
$checks     = preflight_checks($CONFIG_PATH, $SCHEMA_PATH, $UPLOADS_DIR);
362
$fatalBlock = false;
363
foreach ($checks as $c) { if ($c['fatal'] && !$c['ok']) { $fatalBlock = true; } }
364
$alreadyInstalled = is_file($CONFIG_PATH) && !$success;
365
?>
366
<!doctype html>
367
<html lang="en">
368
<head>
369
<meta charset="utf-8">
370
<meta name="viewport" content="width=device-width, initial-scale=1">
371
<title>Install NestEggCode</title>
372
<style>
373
    :root { color-scheme: light dark; }
374
    * { box-sizing: border-box; }
375
    body {
376
        margin: 0; padding: 2rem 1rem;
377
        font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
378
        background: #f4f5f7; color: #1c1e21;
379
    }
380
    .wrap { max-width: 760px; margin: 0 auto; }
381
    .card {
382
        background: #fff; border: 1px solid #e2e4e8; border-radius: 12px;
383
        padding: 1.5rem 1.75rem; margin-bottom: 1.25rem;
384
        box-shadow: 0 1px 3px rgba(0,0,0,.05);
385
    }
386
    h1 { font-size: 1.6rem; margin: 0 0 .25rem; }
387
    h2 { font-size: 1.15rem; margin: 0 0 1rem; }
388
    .lede { color: #616a75; margin: 0 0 1.5rem; }
389
    label { display: block; margin: 0 0 1rem; font-weight: 600; }
390
    label .hint { display: block; font-weight: 400; color: #6b7280; font-size: .86rem; margin-top: .15rem; }
391
    input[type=text], input[type=password], input[type=number] {
392
        width: 100%; margin-top: .4rem; padding: .6rem .7rem; font-size: 1rem;
393
        border: 1px solid #c7ccd3; border-radius: 8px; background: #fff; color: inherit;
394
    }
395
    input:focus { outline: 2px solid #3b82f6; outline-offset: 1px; border-color: #3b82f6; }
396
    .row { display: flex; gap: 1rem; flex-wrap: wrap; }
397
    .row > label { flex: 1 1 200px; }
398
    .check-row { display: flex; gap: .6rem; align-items: flex-start; margin: 0 0 .9rem; font-weight: 400; }
399
    .check-row input { margin-top: .25rem; }
400
    button {
401
        font: inherit; font-weight: 600; cursor: pointer;
402
        background: #2563eb; color: #fff; border: 0; border-radius: 8px;
403
        padding: .7rem 1.4rem; font-size: 1rem;
404
    }
405
    button:hover { background: #1d4ed8; }
406
    ul.checks { list-style: none; padding: 0; margin: 0; }
407
    ul.checks li { display: flex; gap: .6rem; padding: .55rem 0; border-bottom: 1px solid #f0f1f3; }
408
    ul.checks li:last-child { border-bottom: 0; }
409
    .mark { flex: 0 0 1.3rem; font-weight: 700; }
410
    .ok    { color: #15803d; }
411
    .bad   { color: #b91c1c; }
412
    .warn  { color: #b45309; }
413
    .detail { color: #6b7280; font-size: .88rem; }
414
    .banner { padding: .9rem 1rem; border-radius: 8px; margin: 0 0 1rem; }
415
    .banner.err  { background: #fee2e2; border: 1px solid #fca5a5; color: #7f1d1d; }
416
    .banner.ok   { background: #dcfce7; border: 1px solid #86efac; color: #14532d; }
417
    .banner.warn { background: #fef3c7; border: 1px solid #fcd34d; color: #78350f; }
418
    .banner ul { margin: .4rem 0 0; padding-left: 1.2rem; }
419
    code { background: rgba(0,0,0,.06); padding: .1rem .35rem; border-radius: 5px; font-size: .9em; }
420
    ol.checklist { padding-left: 1.3rem; }
421
    ol.checklist li { margin: .5rem 0; }
422
    a.btn-link {
423
        display: inline-block; margin-top: .5rem; padding: .55rem 1rem; border-radius: 8px;
424
        background: #eef2ff; color: #1d4ed8; text-decoration: none; font-weight: 600;
425
    }
426
    a.btn-link:hover { background: #e0e7ff; }
427
    .muted { color: #6b7280; font-size: .9rem; }
428
    @media (prefers-color-scheme: dark) {
429
        body { background: #16181d; color: #e6e8eb; }
430
        .card { background: #1f2228; border-color: #2c3038; box-shadow: none; }
431
        .lede, .detail, .muted, label .hint { color: #9aa3ad; }
432
        input[type=text], input[type=password], input[type=number] { background: #14161a; border-color: #3a3f48; }
433
        code { background: rgba(255,255,255,.1); }
434
        ul.checks li { border-bottom-color: #262a31; }
435
        a.btn-link { background: #1e2740; color: #93c5fd; }
436
    }
437
</style>
438
</head>
439
<body>
440
<div class="wrap">
441
442
<?php if ($success): ?>
443
444
    <div class="card">
445
        <h1>&#127881; Installation complete</h1>
446
        <p class="lede">NestEggCode is set up. Here is what the installer did:</p>
447
        <div class="banner ok">
448
            <ul>
449
                <?php foreach ($results as $r): ?><li><?= $r /* already escaped where needed */ ?></li><?php endforeach; ?>
450
            </ul>
451
        </div>
452
        <?php if ($warnings): ?>
453
        <div class="banner warn">
454
            <strong>Heads-up:</strong>
455
            <ul><?php foreach ($warnings as $w): ?><li><?= $w ?></li><?php endforeach; ?></ul>
456
        </div>
457
        <?php endif; ?>
458
        <a class="btn-link" href="<?= h($loginUrl) ?>">Go to the admin login &rarr;</a>
459
        &nbsp;
460
        <a class="btn-link" href="<?= h($homeUrl) ?>">View the public site &rarr;</a>
461
    </div>
462
463
    <div class="card">
464
        <h2>&#9989; Post-launch checklist</h2>
465
        <p class="muted">Run through this to confirm everything works. Once it all checks out,
466
        delete <code>install.php</code> (the dashboard will offer to do this for you).</p>
467
        <ol class="checklist">
468
            <li><strong>Log in.</strong> Open the <a href="<?= h($loginUrl) ?>">admin login</a> and sign in
469
                with the username and password you just chose.</li>
470
            <li><strong>Create a repository.</strong> On the dashboard, add one (e.g. slug <code>demo</code>,
471
                name <code>Demo</code>). It should appear in the list.</li>
472
            <li><strong>Upload a file.</strong> Click <em>Upload</em>, drop in a small text or code file,
473
                and confirm it saves without an error.</li>
474
            <li><strong>Browse it publicly.</strong> Visit <a href="<?= h($homeUrl) ?>">the home page</a>,
475
                open your repo, and click the file — it should show with line numbers and highlighting.</li>
476
            <li><strong>Check a README renders.</strong> Upload a <code>README.md</code>; it should render
477
                as formatted text on the repo's front page (headings, code blocks, links).</li>
478
            <li><strong>Try Raw &amp; Download.</strong> On a file page, both buttons should work.</li>
479
            <li><strong>Confirm secrets are protected.</strong> Visit <code><?= h(($base === '' ? '' : $base)) ?>/config.php</code>
480
                directly — you should get a "Forbidden" error, not the file contents.</li>
481
            <li><strong>Delete this installer.</strong> Return to the dashboard and use the
482
                "Remove installer" prompt, or delete <code>install.php</code> via FTP/File Manager.</li>
483
        </ol>
484
        <p class="muted">Tip: also add <code>config.php</code>, <code>uploads/</code> and <code>*.zip</code>
485
        to a <code>.gitignore</code> before pushing this project anywhere public.</p>
486
    </div>
487
488
<?php else: ?>
489
490
    <div class="card">
491
        <h1>Install NestEggCode</h1>
492
        <p class="lede">This sets up your self-hosted code browser. Have your database name,
493
        username and password ready (create the database in your host's control panel first if you can).</p>
494
495
        <?php if ($errors): ?>
496
        <div class="banner err">
497
            <strong>Please fix the following, then submit again:</strong>
498
            <ul><?php foreach ($errors as $er): ?><li><?= $er ?></li><?php endforeach; ?></ul>
499
        </div>
500
        <?php endif; ?>
501
502
        <?php if ($alreadyInstalled): ?>
503
        <div class="banner warn">
504
            <strong>config.php already exists.</strong> This looks installed already. Submitting the form
505
            will <em>overwrite</em> config.php with new settings. If the site works, you can skip the
506
            installer and just delete this file instead.
507
        </div>
508
        <?php endif; ?>
509
510
        <h2>Server checks</h2>
511
        <ul class="checks">
512
            <?php foreach ($checks as $c): ?>
513
            <li>
514
                <span class="mark <?= $c['ok'] ? 'ok' : ($c['fatal'] ? 'bad' : 'warn') ?>">
515
                    <?= $c['ok'] ? '&#10003;' : ($c['fatal'] ? '&#10007;' : '!') ?>
516
                </span>
517
                <span>
518
                    <strong><?= h($c['label']) ?></strong><br>
519
                    <span class="detail"><?= h($c['detail']) ?></span>
520
                </span>
521
            </li>
522
            <?php endforeach; ?>
523
        </ul>
524
        <?php if ($fatalBlock): ?>
525
        <div class="banner err" style="margin-top:1rem">
526
            Fix the items marked <span class="bad">&#10007;</span> above before installing —
527
            the app cannot run until they pass.
528
        </div>
529
        <?php endif; ?>
530
    </div>
531
532
    <form method="post" autocomplete="off">
533
        <div class="card">
534
            <h2>Database</h2>
535
            <p class="muted" style="margin-top:-.5rem">On shared hosting, create the database and a user in your
536
            control panel (cPanel &rarr; "MySQL Databases"), then paste those exact values here.</p>
537
            <label>Database host
538
                <input type="text" name="db_host" value="<?= h($f['db_host']) ?>" placeholder="localhost">
539
                <span class="hint">Almost always <code>localhost</code>. MAMP with a custom port: <code>127.0.0.1:8889</code>.</span>
540
            </label>
541
            <div class="row">
542
                <label>Database name
543
                    <input type="text" name="db_name" value="<?= h($f['db_name']) ?>" required>
544
                </label>
545
                <label>Database username
546
                    <input type="text" name="db_user" value="<?= h($f['db_user']) ?>" required>
547
                </label>
548
            </div>
549
            <label>Database password
550
                <input type="password" name="db_pass" value="<?= h($f['db_pass']) ?>">
551
                <span class="hint">Leave blank only if your database truly has no password (rare in production).</span>
552
            </label>
553
            <div class="check-row">
554
                <input type="checkbox" name="create_db" id="create_db" value="1">
555
                <label for="create_db" style="margin:0">Create the database if it doesn't exist
556
                    <span class="hint">Works locally (MAMP). On shared hosting the database usually must exist already.</span>
557
                </label>
558
            </div>
559
            <div class="check-row">
560
                <input type="checkbox" name="import_schema" id="import_schema" value="1" checked>
561
                <label for="import_schema" style="margin:0">Create the tables now (import schema.sql)
562
                    <span class="hint">Leave checked for a fresh install. Uncheck if the tables already exist.</span>
563
                </label>
564
            </div>
565
        </div>
566
567
        <div class="card">
568
            <h2>Admin account</h2>
569
            <p class="muted" style="margin-top:-.5rem">The single login for managing repositories. The password is
570
            stored as a secure bcrypt hash — never in plain text.</p>
571
            <label>Admin username
572
                <input type="text" name="admin_user" value="<?= h($f['admin_user']) ?>" required>
573
            </label>
574
            <div class="row">
575
                <label>Admin password
576
                    <input type="password" name="admin_pass" required>
577
                    <span class="hint">At least 8 characters. Longer is better.</span>
578
                </label>
579
                <label>Confirm password
580
                    <input type="password" name="admin_pass2" required>
581
                </label>
582
            </div>
583
        </div>
584
585
        <div class="card">
586
            <h2>Site settings</h2>
587
            <label>Base path (URL prefix)
588
                <input type="text" name="base_path" value="<?= h($f['base_path']) ?>" placeholder="(blank for a domain root)">
589
                <span class="hint">Auto-detected. Blank for <code>https://yoursite.com/</code>; use
590
                <code>/pgh5</code> for <code>https://yoursite.com/pgh5/</code>.</span>
591
            </label>
592
            <div class="row">
593
                <label>Max file size (MB)
594
                    <input type="number" name="max_file" min="1" value="<?= h($f['max_file']) ?>">
595
                </label>
596
                <label>Max zip total (MB)
597
                    <input type="number" name="max_zip" min="1" value="<?= h($f['max_zip']) ?>">
598
                    <span class="hint">Uncompressed size limit for a single zip import.</span>
599
                </label>
600
            </div>
601
            <p class="muted">Your server also caps uploads via PHP settings
602
            (<code>upload_max_filesize</code> is currently <?= h(human_bytes(return_bytes(ini_get('upload_max_filesize')))) ?>,
603
            <code>post_max_size</code> <?= h(human_bytes(return_bytes(ini_get('post_max_size')))) ?>). Raise those in
604
            <code>.user.ini</code> or <code>php.ini</code> if you need bigger uploads.</p>
605
        </div>
606
607
        <div class="card">
608
            <button type="submit" name="do_install" value="1"<?= $fatalBlock ? ' disabled' : '' ?>>Install NestEggCode</button>
609
            <?php if ($fatalBlock): ?>
610
                <p class="muted" style="margin-top:.6rem">Resolve the failed server checks above to enable this button.</p>
611
            <?php endif; ?>
612
        </div>
613
    </form>
614
615
<?php endif; ?>
616
617
    <p class="muted" style="text-align:center">NestEggCode installer &middot; delete <code>install.php</code> when you're done.</p>
618
</div>
619
</body>
620
</html>
621
<?php
622
/** Convert a PHP ini size shorthand (e.g. "64M") to bytes. Defined last; PHP hoists it. */
623
function return_bytes($val) {
624
    $val  = trim((string) $val);
625
    if ($val === '') { return 0; }
626
    $unit = strtolower($val[strlen($val) - 1]);
627
    $num  = (int) $val;
628
    switch ($unit) {
629
        case 'g': $num *= 1024; // fall through
630
        case 'm': $num *= 1024; // fall through
631
        case 'k': $num *= 1024;
632
    }
633
    return $num;
634
}
635