6a4c972c47
- A quiz question can now carry the diagram it refers to: a Markdown image line anywhere inside the question block is parsed out of the text and rendered under it (referrerpolicy="no-referrer", lazy). The URL points at the course provider, so an offline install gets a note and a link instead of an empty box. - New optional SESSION_LIFETIME (30 days in the example config) replaces the browser-session cookie plus php.ini's 24-minute idle limit, which signed people out over a long pause. Configs that predate it behave as before. Behind a proxy trusted enough for CLIENT_IP_HEADER, the cookie is also marked Secure from X-Forwarded-Proto — TLS ending at the proxy no longer costs the flag. - Error responses no longer echo the exception message unless DEBUG: a PDOException names the DB user, host and tables, and that handler also runs for visitors who never signed in. - The .htaccess denials are anchored to a path segment rather than the root, so videos/ and includes/ stay unreachable under a prefix such as /coursehub/ instead of silently opening up. - A rescan takes a MySQL advisory lock; a second one answers "already running" rather than racing the first and flagging its rows missing. A file swapped in at the same path with a different size drops its cached duration so the probe reads the new length. - stream.php discards output buffering and zlib compression (both break the promised Content-Length) and answers HEAD with headers alone. - A progress save with no duration keeps the length already stored instead of nulling it, and the folder view builds its rollup once. - In the SPA, a view whose data arrives after the route moved on is discarded — previously a slow load could paint over a newer screen or start a heartbeat for a video nobody was watching. Also: the quiz submit button can't be double-fired, leaving a video during the autoplay countdown no longer jumps ahead later, the final position is always saved on 'ended', the playlist scrolls the current entry into its own box, the login field takes focus, views start at the top, and a stray "%" in the hash no longer throws. - Dropped the unused requireLogin(); registerUser() answers "taken" instead of a 500 when two people race for the same name. Claude-Session: https://claude.ai/code/session_01AXMrtWNUV7NU1pCUHBG4t1
228 lines
8.4 KiB
PHP
228 lines
8.4 KiB
PHP
<?php
|
|
require_once __DIR__ . '/db.php';
|
|
|
|
/**
|
|
* Whether the visitor reached us over HTTPS. Behind the proxy that
|
|
* CLIENT_IP_HEADER already declares trustworthy (Cloudflare), the origin
|
|
* connection may be plain HTTP while the visitor's is TLS; the proxy says so in
|
|
* X-Forwarded-Proto. Without that setting only the connection itself counts —
|
|
* the header is spoofable by anyone who can reach the origin directly.
|
|
*/
|
|
function requestIsHttps(): bool
|
|
{
|
|
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
|
return true;
|
|
}
|
|
if (defined('CLIENT_IP_HEADER') && CLIENT_IP_HEADER !== '') {
|
|
$proto = strtolower(trim(explode(',', $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')[0]));
|
|
return $proto === 'https';
|
|
}
|
|
return false;
|
|
}
|
|
|
|
if (session_status() === PHP_SESSION_NONE) {
|
|
// SESSION_LIFETIME (seconds) keeps people signed in across browser restarts
|
|
// and idle stretches. 0 / undefined = a browser-session cookie plus the
|
|
// php.ini idle limit (24 min by default — short for a video app).
|
|
$lifetime = defined('SESSION_LIFETIME') ? max(0, (int)SESSION_LIFETIME) : 0;
|
|
if ($lifetime > 0) {
|
|
ini_set('session.gc_maxlifetime', (string)$lifetime);
|
|
}
|
|
// Reject session IDs the server never issued (blocks session fixation),
|
|
// and keep the cookie away from scripts and cross-site requests.
|
|
ini_set('session.use_strict_mode', '1');
|
|
session_set_cookie_params([
|
|
'lifetime' => $lifetime,
|
|
'path' => '/',
|
|
'secure' => requestIsHttps(),
|
|
'httponly' => true,
|
|
'samesite' => 'Lax',
|
|
]);
|
|
session_start();
|
|
}
|
|
|
|
function currentUserId(): ?int
|
|
{
|
|
return isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : null;
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
* CSRF
|
|
* The token lives in the session and reaches the SPA via GET api/auth.php
|
|
* (and again in the login/register response, since login rotates it). Every
|
|
* state-changing request must echo it back — fetch() sends the X-CSRF-Token
|
|
* header; sendBeacon() can't set headers, so a `csrf` body field counts too.
|
|
* ------------------------------------------------------------------------- */
|
|
function csrfToken(): string
|
|
{
|
|
if (empty($_SESSION['csrf_token'])) {
|
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(16));
|
|
}
|
|
return $_SESSION['csrf_token'];
|
|
}
|
|
|
|
/** 403s the request unless it carries the session's CSRF token. */
|
|
function verifyCsrfApi(?array $body = null): void
|
|
{
|
|
$sent = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? $body['csrf'] ?? '';
|
|
if (!is_string($sent) || $sent === '' || !hash_equals(csrfToken(), $sent)) {
|
|
http_response_code(403);
|
|
header('Content-Type: application/json');
|
|
echo json_encode(['error' => 'Invalid session token — reload the page and try again.']);
|
|
exit;
|
|
}
|
|
}
|
|
|
|
function currentUsername(): ?string
|
|
{
|
|
return $_SESSION['username'] ?? null;
|
|
}
|
|
|
|
function requireLoginApi(): int
|
|
{
|
|
$uid = currentUserId();
|
|
if ($uid === null) {
|
|
http_response_code(401);
|
|
header('Content-Type: application/json');
|
|
echo json_encode(['error' => 'Not logged in']);
|
|
exit;
|
|
}
|
|
return $uid;
|
|
}
|
|
|
|
function attemptLogin(string $username, string $password): bool
|
|
{
|
|
$stmt = db()->prepare('SELECT id, username, password_hash FROM users WHERE username = ?');
|
|
$stmt->execute([$username]);
|
|
$user = $stmt->fetch();
|
|
if ($user && password_verify($password, $user['password_hash'])) {
|
|
session_regenerate_id(true);
|
|
unset($_SESSION['csrf_token']); // a fresh token for the fresh session id
|
|
$_SESSION['user_id'] = (int)$user['id'];
|
|
$_SESSION['username'] = $user['username'];
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
* Login brute-force throttle
|
|
* Failed attempts are recorded per username + IP. Once a username collects
|
|
* LOGIN_MAX_USER_FAILS failures (or an IP LOGIN_MAX_IP_FAILS, across any
|
|
* usernames) inside LOGIN_FAIL_WINDOW, sign-in answers 429 until the window
|
|
* has passed since the last failure. Retrying while blocked never verifies a
|
|
* password, so it doesn't extend the block. A successful login clears the
|
|
* username's failures.
|
|
* ------------------------------------------------------------------------- */
|
|
const LOGIN_FAIL_WINDOW = 900; // seconds
|
|
const LOGIN_MAX_USER_FAILS = 5;
|
|
const LOGIN_MAX_IP_FAILS = 20;
|
|
|
|
/**
|
|
* The client's IP for the throttle. Behind a proxy that hides the visitor
|
|
* (Cloudflare), REMOTE_ADDR is the proxy — set CLIENT_IP_HEADER in
|
|
* config.php to the header carrying the real address instead. Optional and
|
|
* off by default: installs whose config.php predates the setting keep using
|
|
* REMOTE_ADDR.
|
|
*/
|
|
function clientIp(): string
|
|
{
|
|
if (defined('CLIENT_IP_HEADER') && CLIENT_IP_HEADER !== '') {
|
|
$key = 'HTTP_' . strtoupper(str_replace('-', '_', CLIENT_IP_HEADER));
|
|
$ip = trim(explode(',', $_SERVER[$key] ?? '')[0]);
|
|
if ($ip !== '' && filter_var($ip, FILTER_VALIDATE_IP)) {
|
|
return $ip;
|
|
}
|
|
}
|
|
return $_SERVER['REMOTE_ADDR'] ?? '';
|
|
}
|
|
|
|
/** Seconds until this username/IP may try again — 0 means go ahead. */
|
|
function loginRetryAfter(string $username, string $ip): int
|
|
{
|
|
// The clock math stays inside MySQL: TIMESTAMP values come back in the
|
|
// connection's time zone, so comparing them against PHP's clock would be
|
|
// off by the server's UTC offset.
|
|
$recent = function (string $col, string $value): array {
|
|
$stmt = db()->prepare(
|
|
"SELECT COUNT(*) AS fails,
|
|
COALESCE(TIMESTAMPDIFF(SECOND, NOW(),
|
|
MAX(attempted_at) + INTERVAL " . LOGIN_FAIL_WINDOW . " SECOND), 0) AS retry
|
|
FROM login_attempts
|
|
WHERE $col = ? AND attempted_at > NOW() - INTERVAL " . LOGIN_FAIL_WINDOW . " SECOND"
|
|
);
|
|
$stmt->execute([$value]);
|
|
return $stmt->fetch();
|
|
};
|
|
|
|
try {
|
|
$user = $recent('username', $username);
|
|
$byIp = $recent('ip', $ip);
|
|
} catch (Throwable $e) {
|
|
return 0; // table not created yet — no failures recorded so far
|
|
}
|
|
|
|
$retry = 0;
|
|
if ((int)$user['fails'] >= LOGIN_MAX_USER_FAILS) {
|
|
$retry = max($retry, (int)$user['retry']);
|
|
}
|
|
if ((int)$byIp['fails'] >= LOGIN_MAX_IP_FAILS) {
|
|
$retry = max($retry, (int)$byIp['retry']);
|
|
}
|
|
return max(0, $retry);
|
|
}
|
|
|
|
function recordLoginFailure(string $username, string $ip): void
|
|
{
|
|
$db = db();
|
|
// Lazy creation keeps installs made before this table existed working
|
|
// (same pattern as app_meta).
|
|
$db->exec(
|
|
'CREATE TABLE IF NOT EXISTS login_attempts (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
username VARCHAR(50) NOT NULL,
|
|
ip VARCHAR(45) NOT NULL,
|
|
attempted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
INDEX idx_la_user_time (username, attempted_at),
|
|
INDEX idx_la_ip_time (ip, attempted_at)
|
|
) ENGINE=InnoDB'
|
|
);
|
|
$db->prepare('DELETE FROM login_attempts WHERE attempted_at < NOW() - INTERVAL 1 HOUR')->execute();
|
|
$db->prepare('INSERT INTO login_attempts (username, ip) VALUES (?, ?)')->execute([$username, $ip]);
|
|
}
|
|
|
|
function clearLoginFailures(string $username): void
|
|
{
|
|
try {
|
|
db()->prepare('DELETE FROM login_attempts WHERE username = ?')->execute([$username]);
|
|
} catch (Throwable $e) {
|
|
// table doesn't exist yet — nothing to clear
|
|
}
|
|
}
|
|
|
|
/** Returns true on success, or an error-message string. */
|
|
function registerUser(string $username, string $password): string|bool
|
|
{
|
|
if (!preg_match('/^[A-Za-z0-9_.-]{3,50}$/', $username)) {
|
|
return 'Username must be 3-50 characters (letters, digits, _ . -).';
|
|
}
|
|
if (strlen($password) < 6) {
|
|
return 'Password must be at least 6 characters.';
|
|
}
|
|
$stmt = db()->prepare('SELECT id FROM users WHERE username = ?');
|
|
$stmt->execute([$username]);
|
|
if ($stmt->fetch()) {
|
|
return 'That username is already taken.';
|
|
}
|
|
try {
|
|
$stmt = db()->prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
|
|
$stmt->execute([$username, password_hash($password, PASSWORD_DEFAULT)]);
|
|
} catch (PDOException $e) {
|
|
if ($e->getCode() === '23000') { // lost the race to the same name
|
|
return 'That username is already taken.';
|
|
}
|
|
throw $e;
|
|
}
|
|
return true;
|
|
}
|