Add install.php: browser/CLI installer for database and schema
Creates config.php from the example if missing, creates the database (and app DB user when admin credentials are given), applies schema.sql idempotently, optionally creates the first login account, and locks itself with .install.lock after a successful run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,3 +3,6 @@ config.php
|
||||
|
||||
# Video library lives outside version control
|
||||
videos/
|
||||
|
||||
# Written by install.php after a successful install
|
||||
.install.lock
|
||||
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
// CourseHub installer — sets up the database and schema.
|
||||
//
|
||||
// Browser: open install.php and fill in the form.
|
||||
// CLI: php install.php [db_admin_user] [db_admin_pass]
|
||||
//
|
||||
// Steps performed:
|
||||
// 1. Creates config.php from config.example.php if missing (edit it, re-run).
|
||||
// 2. Connects to MySQL — with admin credentials if supplied (needed when the
|
||||
// app DB user or database don't exist yet), else with config.php creds.
|
||||
// 3. Creates the database; with admin credentials also creates the app DB
|
||||
// user and grants it access.
|
||||
// 4. Applies schema.sql (idempotent — safe to re-run on an existing install).
|
||||
// 5. Optionally creates the first CourseHub login account.
|
||||
// 6. Writes .install.lock — delete that file to allow re-running, and
|
||||
// consider deleting install.php on a production server.
|
||||
|
||||
$isCli = PHP_SAPI === 'cli';
|
||||
$lockFile = __DIR__ . '/.install.lock';
|
||||
|
||||
function respond(array $steps, bool $done, string $notice = ''): void
|
||||
{
|
||||
global $isCli;
|
||||
if ($isCli) {
|
||||
foreach ($steps as [$ok, $msg]) {
|
||||
echo ($ok ? '[ ok ] ' : '[FAIL] ') . $msg . "\n";
|
||||
}
|
||||
if ($notice) echo $notice . "\n";
|
||||
exit($done ? 0 : 1);
|
||||
}
|
||||
renderPage($steps, $done, $notice);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (file_exists($lockFile)) {
|
||||
respond([], false,
|
||||
'CourseHub is already installed (.install.lock exists). '
|
||||
. 'Delete .install.lock to re-run the installer.');
|
||||
}
|
||||
|
||||
// -- Step 1: ensure config.php exists ---------------------------------------
|
||||
if (!file_exists(__DIR__ . '/config.php')) {
|
||||
if (!@copy(__DIR__ . '/config.example.php', __DIR__ . '/config.php')) {
|
||||
respond([[false, 'config.php is missing and could not be created from config.example.php '
|
||||
. '(check directory permissions).']], false);
|
||||
}
|
||||
respond([[true, 'Created config.php from config.example.php.']], false,
|
||||
'Edit config.php (database credentials, VIDEOS_ROOT), then run the installer again.');
|
||||
}
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
// -- Collect input -----------------------------------------------------------
|
||||
if ($isCli) {
|
||||
$adminUser = $_SERVER['argv'][1] ?? '';
|
||||
$adminPass = $_SERVER['argv'][2] ?? '';
|
||||
$appUser = '';
|
||||
$appPass = '';
|
||||
$run = true;
|
||||
} else {
|
||||
$adminUser = trim($_POST['admin_user'] ?? '');
|
||||
$adminPass = $_POST['admin_pass'] ?? '';
|
||||
$appUser = trim($_POST['app_user'] ?? '');
|
||||
$appPass = $_POST['app_pass'] ?? '';
|
||||
$run = $_SERVER['REQUEST_METHOD'] === 'POST';
|
||||
}
|
||||
|
||||
if (!$run) {
|
||||
renderPage(null, false, '');
|
||||
exit;
|
||||
}
|
||||
|
||||
// -- Run the installation ----------------------------------------------------
|
||||
[$done, $steps, $notice] = performInstall($adminUser, $adminPass, $appUser, $appPass);
|
||||
if ($done && @file_put_contents($lockFile, date('c') . "\n") === false) {
|
||||
$steps[] = [false, 'Could not write .install.lock — the installer will stay re-runnable. '
|
||||
. 'Delete or protect install.php on a production server.'];
|
||||
}
|
||||
respond($steps, $done, $notice);
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function performInstall(string $adminUser, string $adminPass,
|
||||
string $appUser, string $appPass): array
|
||||
{
|
||||
$steps = [];
|
||||
$notice = '';
|
||||
try {
|
||||
if (!extension_loaded('pdo_mysql')) {
|
||||
throw new RuntimeException('PHP extension pdo_mysql is not loaded.');
|
||||
}
|
||||
|
||||
$opts = [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION];
|
||||
$dsnServer = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';charset=utf8mb4';
|
||||
$useAdmin = $adminUser !== '';
|
||||
|
||||
$pdo = new PDO($dsnServer, $useAdmin ? $adminUser : DB_USER,
|
||||
$useAdmin ? $adminPass : DB_PASS, $opts);
|
||||
$steps[] = [true, 'Connected to MySQL at ' . DB_HOST . ':' . DB_PORT
|
||||
. ' as ' . ($useAdmin ? $adminUser : DB_USER) . '.'];
|
||||
|
||||
// Database
|
||||
$dbQuoted = '`' . str_replace('`', '', DB_NAME) . '`';
|
||||
$pdo->exec("CREATE DATABASE IF NOT EXISTS $dbQuoted
|
||||
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||
$steps[] = [true, 'Database "' . DB_NAME . '" is ready.'];
|
||||
|
||||
// App DB user (only possible with admin privileges)
|
||||
if ($useAdmin) {
|
||||
foreach (['localhost', '127.0.0.1'] as $host) {
|
||||
$u = $pdo->quote(DB_USER) . '@' . $pdo->quote($host);
|
||||
$pdo->exec("CREATE USER IF NOT EXISTS $u IDENTIFIED BY " . $pdo->quote(DB_PASS));
|
||||
$pdo->exec("GRANT ALL PRIVILEGES ON $dbQuoted.* TO $u");
|
||||
}
|
||||
$pdo->exec('FLUSH PRIVILEGES');
|
||||
$steps[] = [true, 'App DB user "' . DB_USER . '" created/updated and granted access.'];
|
||||
}
|
||||
|
||||
// Schema
|
||||
$sql = @file_get_contents(__DIR__ . '/schema.sql');
|
||||
if ($sql === false) {
|
||||
throw new RuntimeException('Cannot read schema.sql.');
|
||||
}
|
||||
$pdo->exec("USE $dbQuoted");
|
||||
$applied = 0;
|
||||
foreach (splitSqlStatements($sql) as $stmt) {
|
||||
if (preg_match('/^\s*(CREATE\s+DATABASE|USE)\b/i', $stmt)) {
|
||||
continue; // installer already handled DB creation/selection
|
||||
}
|
||||
$pdo->exec($stmt);
|
||||
$applied++;
|
||||
}
|
||||
$tables = $pdo->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN);
|
||||
$steps[] = [true, "Applied $applied schema statements — tables: " . implode(', ', $tables) . '.'];
|
||||
|
||||
// Verify the app credentials from config.php actually work
|
||||
$dsnDb = $dsnServer . ';dbname=' . DB_NAME;
|
||||
try {
|
||||
$app = new PDO($dsnDb, DB_USER, DB_PASS, $opts);
|
||||
$steps[] = [true, 'Verified config.php credentials — the app can connect.'];
|
||||
} catch (PDOException $e) {
|
||||
throw new RuntimeException('The app credentials in config.php cannot connect ('
|
||||
. $e->getMessage() . '). Fix DB_USER/DB_PASS, or re-run with database '
|
||||
. 'admin credentials so the installer can create the user.');
|
||||
}
|
||||
|
||||
// Optional first login account
|
||||
if ($appUser !== '') {
|
||||
if (!preg_match('/^[A-Za-z0-9_.-]{3,50}$/', $appUser)) {
|
||||
throw new RuntimeException('Account username must be 3-50 characters (letters, digits, _ . -).');
|
||||
}
|
||||
if (strlen($appPass) < 6) {
|
||||
throw new RuntimeException('Account password must be at least 6 characters.');
|
||||
}
|
||||
$stmt = $app->prepare('SELECT id FROM users WHERE username = ?');
|
||||
$stmt->execute([$appUser]);
|
||||
if ($stmt->fetch()) {
|
||||
$steps[] = [true, 'Account "' . $appUser . '" already exists — left unchanged.'];
|
||||
} else {
|
||||
$app->prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)')
|
||||
->execute([$appUser, password_hash($appPass, PASSWORD_DEFAULT)]);
|
||||
$steps[] = [true, 'Created login account "' . $appUser . '".'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_dir(VIDEOS_ROOT)) {
|
||||
$steps[] = [false, 'Warning: VIDEOS_ROOT (' . VIDEOS_ROOT . ') does not exist yet — '
|
||||
. 'set it in config.php before scanning.'];
|
||||
}
|
||||
|
||||
$notice = 'Installation complete. Open the app, log in, and use "Rescan library" '
|
||||
. '(or run: php scan.php) to index your videos.';
|
||||
return [true, $steps, $notice];
|
||||
} catch (Throwable $e) {
|
||||
$steps[] = [false, $e->getMessage()];
|
||||
return [false, $steps, ''];
|
||||
}
|
||||
}
|
||||
|
||||
/** Split a .sql file into statements (strips -- comments; no procedure support). */
|
||||
function splitSqlStatements(string $sql): array
|
||||
{
|
||||
$lines = array_filter(
|
||||
explode("\n", $sql),
|
||||
fn($l) => !preg_match('/^\s*--/', $l)
|
||||
);
|
||||
$stmts = array_map('trim', explode(';', implode("\n", $lines)));
|
||||
return array_values(array_filter($stmts, fn($s) => $s !== ''));
|
||||
}
|
||||
|
||||
function renderPage(?array $steps, bool $done, string $notice): void
|
||||
{
|
||||
$e = fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CourseHub installer</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<main class="container py-5" style="max-width: 640px">
|
||||
<h1 class="h3 mb-4"><i class="bi bi-collection-play-fill text-primary me-1"></i>CourseHub installer</h1>
|
||||
|
||||
<?php if ($notice): ?>
|
||||
<div class="alert alert-<?= $done ? 'success' : 'warning' ?>"><?= $e($notice) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($steps !== null): ?>
|
||||
<ul class="list-group mb-4">
|
||||
<?php foreach ($steps as [$ok, $msg]): ?>
|
||||
<li class="list-group-item d-flex gap-2">
|
||||
<i class="bi <?= $ok ? 'bi-check-circle-fill text-success' : 'bi-x-circle-fill text-danger' ?>"></i>
|
||||
<span><?= $e($msg) ?></span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php if ($done): ?>
|
||||
<a class="btn btn-primary" href="index.php"><i class="bi bi-box-arrow-in-right me-1"></i>Open CourseHub</a>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!$done): ?>
|
||||
<form method="post" class="card">
|
||||
<div class="card-body vstack gap-3">
|
||||
<div>
|
||||
<div class="fw-semibold mb-1">Database admin credentials <span class="text-secondary fw-normal">(optional)</span></div>
|
||||
<div class="form-text mb-2">
|
||||
Needed only on first install, to create the database and the app DB user
|
||||
(<code><?= $e(defined('DB_USER') ? DB_USER : 'app user') ?></code>). Used once, never stored.
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col"><input class="form-control" name="admin_user" placeholder="root"
|
||||
value="<?= $e($_POST['admin_user'] ?? '') ?>"></div>
|
||||
<div class="col"><input class="form-control" type="password" name="admin_pass" placeholder="password"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold mb-1">First login account <span class="text-secondary fw-normal">(optional)</span></div>
|
||||
<div class="row g-2">
|
||||
<div class="col"><input class="form-control" name="app_user" placeholder="username"
|
||||
value="<?= $e($_POST['app_user'] ?? '') ?>"></div>
|
||||
<div class="col"><input class="form-control" type="password" name="app_pass" placeholder="password (min 6)"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary"><i class="bi bi-gear me-1"></i>Install</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
}
|
||||
Reference in New Issue
Block a user