Files
coursehub/includes/db.php
T
Harwinder Sekhon 2fa7452253 Show quiz exhibits, keep sign-ins alive, and close a batch of edge cases
- 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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXMrtWNUV7NU1pCUHBG4t1
2026-09-06 12:31:21 -04:00

76 lines
3.0 KiB
PHP

<?php
require_once __DIR__ . '/../config.php';
// DEBUG is optional: config.php is not in version control, so an install that
// predates the setting simply behaves as if it were false.
if (!defined('DEBUG')) {
define('DEBUG', false);
}
// Everything is logged either way. DEBUG only controls how much detail is sent
// back to the browser — never turn it on for an install exposed to the public,
// since the payload includes file paths and stack traces.
error_reporting(E_ALL);
ini_set('log_errors', '1');
// Errors must never be printed inline: every endpoint here answers with JSON,
// and a warning echoed before the body would corrupt it. They travel in the
// JSON payload below instead.
ini_set('display_errors', '0');
if (PHP_SAPI !== 'cli') {
/** Send a JSON error body, replacing whatever partial output exists. */
$emit = function (string $message, array $debug = []): void {
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: application/json');
}
while (ob_get_level() > 0) {
ob_end_clean();
}
echo json_encode(DEBUG ? ['error' => $message] + $debug : ['error' => $message]);
};
// Uncaught exceptions become JSON errors (the SPA surfaces them) instead
// of blank 500 pages.
// The message itself is DEBUG-only too: a PDOException spells out the DB
// user, host and table names, and this handler also runs for visitors who
// never logged in (the sign-in call touches the database).
set_exception_handler(function (Throwable $e) use ($emit): void {
error_log($e);
$emit(DEBUG ? 'Server error: ' . $e->getMessage()
: 'Server error — the details are in the PHP error log.', [
'type' => get_class($e),
'file' => $e->getFile() . ':' . $e->getLine(),
'trace' => explode("\n", $e->getTraceAsString()),
]);
});
// set_exception_handler never sees fatals (parse errors, exhausted memory,
// failed require) — those end the request silently and are the usual cause
// of an endpoint returning an empty body. Catch them on shutdown.
register_shutdown_function(function () use ($emit): void {
$err = error_get_last();
if ($err === null || !($err['type'] & (E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR))) {
return;
}
$emit(DEBUG ? 'Fatal error: ' . $err['message']
: 'Server error — the details are in the PHP error log.', [
'file' => $err['file'] . ':' . $err['line'],
]);
});
}
function db(): PDO
{
static $pdo = null;
if ($pdo === null) {
$dsn = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';dbname=' . DB_NAME . ';charset=utf8mb4';
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
return $pdo;
}