4bd3d63a76
install.php now takes the videos path (form field / --videos= CLI flag), persists it into config.php, and creates the directory if missing. config.example.php default changed from a dev path to /srv/courses.
301 lines
13 KiB
PHP
301 lines
13 KiB
PHP
<?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] [--videos=/path/to/courses]
|
|
//
|
|
// 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) {
|
|
$args = array_slice($_SERVER['argv'], 1);
|
|
$videosRoot = '';
|
|
foreach ($args as $i => $a) {
|
|
if (str_starts_with($a, '--videos=')) {
|
|
$videosRoot = substr($a, strlen('--videos='));
|
|
unset($args[$i]);
|
|
}
|
|
}
|
|
$args = array_values($args);
|
|
$adminUser = $args[0] ?? '';
|
|
$adminPass = $args[1] ?? '';
|
|
$appUser = '';
|
|
$appPass = '';
|
|
$run = true;
|
|
} else {
|
|
$adminUser = trim($_POST['admin_user'] ?? '');
|
|
$adminPass = $_POST['admin_pass'] ?? '';
|
|
$appUser = trim($_POST['app_user'] ?? '');
|
|
$appPass = $_POST['app_pass'] ?? '';
|
|
$videosRoot = trim($_POST['videos_root'] ?? '');
|
|
$run = $_SERVER['REQUEST_METHOD'] === 'POST';
|
|
}
|
|
|
|
if (!$run) {
|
|
renderPage(null, false, '');
|
|
exit;
|
|
}
|
|
|
|
// -- Run the installation ----------------------------------------------------
|
|
[$done, $steps, $notice] = performInstall($adminUser, $adminPass, $appUser, $appPass, $videosRoot);
|
|
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, string $videosRoot): 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 . '".'];
|
|
}
|
|
}
|
|
|
|
// Videos folder: optionally persist a new path into config.php, then
|
|
// make sure the effective path exists.
|
|
$effectiveRoot = VIDEOS_ROOT;
|
|
if ($videosRoot !== '' && $videosRoot !== VIDEOS_ROOT) {
|
|
$cfgFile = __DIR__ . '/config.php';
|
|
$cfg = file_get_contents($cfgFile);
|
|
$new = preg_replace(
|
|
"/define\('VIDEOS_ROOT',\s*'[^']*'\);/",
|
|
"define('VIDEOS_ROOT', '" . addslashes($videosRoot) . "');",
|
|
$cfg, 1, $replaced
|
|
);
|
|
if ($replaced === 1 && @file_put_contents($cfgFile, $new) !== false) {
|
|
$effectiveRoot = $videosRoot;
|
|
$steps[] = [true, 'Set VIDEOS_ROOT in config.php to ' . $videosRoot . '.'];
|
|
} else {
|
|
$steps[] = [false, 'Could not update config.php — set VIDEOS_ROOT to '
|
|
. $videosRoot . ' manually.'];
|
|
}
|
|
}
|
|
if (!is_dir($effectiveRoot)) {
|
|
if (@mkdir($effectiveRoot, 0755, true)) {
|
|
$steps[] = [true, 'Created videos folder ' . $effectiveRoot . '.'];
|
|
} else {
|
|
$steps[] = [false, 'Warning: videos folder ' . $effectiveRoot . ' does not exist '
|
|
. 'and could not be created — create it (or fix VIDEOS_ROOT 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">Videos folder</div>
|
|
<div class="form-text mb-2">
|
|
Absolute path on this server containing your course folders.
|
|
Saved to config.php; created if it doesn't exist.
|
|
</div>
|
|
<input class="form-control" name="videos_root"
|
|
value="<?= $e($_POST['videos_root'] ?? (defined('VIDEOS_ROOT') ? VIDEOS_ROOT : '')) ?>"
|
|
placeholder="/srv/courses">
|
|
</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
|
|
}
|