diff --git a/app/Base.php b/app/Base.php index d5a85bb2e..c6483566c 100644 --- a/app/Base.php +++ b/app/Base.php @@ -5,16 +5,25 @@ namespace Gazelle; abstract class Base { protected static DB\Mysql $db; protected static Cache $cache; + protected static BaseRequestContext $requestContext; protected static \Twig\Environment $twig; public static function initialize(Cache $cache, DB\Mysql $db, \Twig\Environment $twig): void { - self::$db = $db; - self::$cache = $cache; - self::$twig = $twig; + static::$db = $db; + static::$cache = $cache; + static::$twig = $twig; + } + + public static function setRequestContext(BaseRequestContext $c): void { + static::$requestContext = $c; + } + + public function requestContext(): BaseRequestContext { + return static::$requestContext; } public function enumList(string $table, string $column): array { - $columnType = (string)self::$db->scalar(" + $columnType = (string)static::$db->scalar(" SELECT column_type FROM information_schema.columns WHERE table_schema = ? @@ -29,7 +38,7 @@ abstract class Base { } public function enumDefault(string $table, string $column): ?string { - $default = self::$db->scalar(" + $default = static::$db->scalar(" SELECT column_default FROM information_schema.columns WHERE table_schema = ? diff --git a/app/BaseRequestContext.php b/app/BaseRequestContext.php new file mode 100644 index 000000000..77984ec01 --- /dev/null +++ b/app/BaseRequestContext.php @@ -0,0 +1,80 @@ +module = ''; + $this->isValid = false; + } else { + $this->module = $info['filename']; + $this->isValid = $info['dirname'] === '/'; + } + $this->ua = \parse_user_agent($useragent); + } + + public function ua(): array { + return $this->ua; + } + + public function browser(): ?string { + return $this->ua()['Browser']; + } + + public function browserVersion(): ?string { + return $this->ua()['BrowserVersion']; + } + + public function isValid(): bool { + return $this->isValid; + } + + public function module(): string { + return $this->module; + } + + public function os(): ?string { + return $this->ua()['OperatingSystem']; + } + + public function osVersion(): ?string { + return $this->ua()['OperatingSystemVersion']; + } + + public function remoteAddr(): string { + return $this->remoteAddr; + } + + /** + * Because we <3 our staff + */ + public function anonymize(): static { + $this->ua = [ + 'Browser' => 'staff-browser', + 'BrowserVersion' => null, + 'OperatingSystem' => null, + 'OperatingSystemVersion' => null, + ]; + $this->remoteAddr = '127.0.0.1'; + return $this; + } + + /** + * Early in the startup phase, it may be desirable to + * redirect processing to another section. + */ + public function setModule(string $module): static { + $this->module = $module; + return $this; + } +} diff --git a/app/DB/Mysql.php b/app/DB/Mysql.php index 40ad383af..228365e8f 100644 --- a/app/DB/Mysql.php +++ b/app/DB/Mysql.php @@ -263,17 +263,28 @@ class Mysql { if ($this->LinkID === false) { return false; } - // In the event of a MySQL deadlock, we sleep allowing MySQL time to unlock, then attempt again for a maximum of 5 tries + // In the event of a MySQL deadlock, we sleep allowing MySQL time to unlock + // then attempt again for a maximum of 5 attempts + $sleep = 0.5; for ($i = 1; $i < 6; $i++) { $this->QueryID = $Closure(); if (!in_array(mysqli_errno($this->LinkID), [1213, 1205])) { break; } - global $Debug; - $Debug->analysis('Non-Fatal Deadlock:', $Query, 3600 * 24); + // if we have a viewer, we have a request context, otherwise, it must be script + global $Debug, $Viewer; + $Debug->analysis( + is_null($Viewer) && isset($_SERVER['argv']) + ? ($_SERVER['argv'][0] ?? 'cli') + : $Viewer->requestContext()->module(), + 'Non-Fatal Deadlock:', + $Query, + 86_400, + ); trigger_error("Database deadlock, attempt $i"); - sleep($i * random_int(2, 5)); // Wait longer as attempts increase + usleep((int)($sleep * 1e6)); + $sleep *= 1.75; } $QueryEndTime = microtime(true); // Kills admin pages, and prevents Debug->analysis when the whole set exceeds 1 MB diff --git a/app/Debug.php b/app/Debug.php index ba1d23f57..bf744dde4 100644 --- a/app/Debug.php +++ b/app/Debug.php @@ -73,7 +73,10 @@ class Debug { } if (isset($Reason[0])) { - $this->analysis(implode(', ', $Reason)); + $this->analysis( + $user->requestContext()->module(), + implode(', ', $Reason) + ); return true; } @@ -135,23 +138,22 @@ class Debug { return $id; } - public function analysis($Message, $Report = ''): void { - $RequestURI = empty($_SERVER['REQUEST_URI']) ? '' : substr($_SERVER['REQUEST_URI'], 1); + public function analysis(string $module, string $message, string $report = ''): void { + $uri = empty($_SERVER['REQUEST_URI']) ? '' : substr($_SERVER['REQUEST_URI'], 1); if ( PHP_SAPI === 'cli' - || in_array($RequestURI, ['tools.php?action=db_sandbox']) + || in_array($uri, ['tools.php?action=db_sandbox']) ) { // Don't spam IRC from Boris or these pages return; } - if (empty($Report)) { - $Report = $Message; + if (empty($report)) { + $report = $message; } - $case = $this->saveCase($Report); - global $Document; - Irc::sendMessage(IRC_CHAN_STATUS, "{$Message} $Document " + $case = $this->saveCase($report); + Irc::sendMessage(IRC_CHAN_STATUS, "{$message} $module " . SITE_URL . "/tools.php?action=analysis&case=$case " - . SITE_URL . '/' . $RequestURI + . SITE_URL . "/{$uri}" ); } diff --git a/app/Search/Torrent.php b/app/Search/Torrent.php index f063e85fb..662f636ae 100644 --- a/app/Search/Torrent.php +++ b/app/Search/Torrent.php @@ -192,7 +192,12 @@ class Torrent { ) { $ErrMsg = "Search\Torrent constructor arguments:\n" . print_r(func_get_args(), true); global $Debug; - $Debug->analysis('Bad arguments in Search\Torrent constructor', $ErrMsg, 3600 * 24); + $Debug->analysis( + $tgMan->requestContext()->module(), + 'Bad arguments in Search\Torrent constructor', + $ErrMsg, + 86_400, + ); error('-1'); } $this->Page = $searchMany ? $Page : min($Page, SPHINX_MAX_MATCHES / $PageSize); diff --git a/bin/scheduler b/bin/scheduler index 654334479..1debc2f06 100755 --- a/bin/scheduler +++ b/bin/scheduler @@ -2,4 +2,8 @@ run(); diff --git a/classes/sphinxql.class.php b/classes/sphinxql.class.php index e0a98df2d..8067b4d16 100644 --- a/classes/sphinxql.class.php +++ b/classes/sphinxql.class.php @@ -91,19 +91,23 @@ class Sphinxql extends mysqli { /** * Print a message to privileged users and optionally halt page processing - * - * @param string $Msg message to display - * @param bool $Halt halt page processing. Default is to continue processing the page */ - public function error($Msg, $Halt = false) { + public function error(string $message, bool $halt = false) { global $Debug, $Viewer; - $ErrorMsg = 'SphinxQL (' . $this->Ident . '): ' . strval($Msg); - $Debug->analysis('SphinxQL Error', $ErrorMsg, 3600 * 24); - if ($Halt === true && (DEBUG_MODE || $Viewer->permitted('site_debug'))) { - echo '
' . display_str($ErrorMsg) . '
'; - die(); - } elseif ($Halt === true) { - error('-1'); + $error = "SphinxQL ({$this->Ident}): $message"; + $Debug->analysis( + $Viewer->requestContext()->module(), + 'SphinxQL Error', + $error, + 86_400, + ); + if ($halt === true) { + if (DEBUG_MODE || $Viewer->permitted('site_debug')) { + echo '
' . display_str($error) . '
'; + die(); + } else { + error('-1'); + } } } diff --git a/classes/text.class.php b/classes/text.class.php index 287081bc7..3cc10c6ac 100644 --- a/classes/text.class.php +++ b/classes/text.class.php @@ -337,8 +337,14 @@ class Text { } parse_str($info['query'] ?? '', $args); - if (isset($args['postid']) && isset($info['path']) && in_array($info['path'], - ['/artist.php', '/collages.php', '/requests.php', '/torrents.php'])) { + if ( + isset($args['postid']) + && isset($info['path']) + && in_array( + $info['path'], + ['/artist.php', '/collages.php', '/requests.php', '/torrents.php'] + ) + ) { return self::bbcodeCommentUrl((int)$args['postid']); } @@ -1207,8 +1213,8 @@ class Text { * that html_escape does. */ public static function parse_html(string $Html): string { - $Document = new DOMDocument(); - $Document->loadHTML(stripslashes($Html)); + $dom = new DOMDocument(); + $dom->loadHTML(stripslashes($Html)); // For any manipulation that we do on the DOM tree, always go in reverse order or // else you end up with broken array pointers and missed elements @@ -1222,42 +1228,42 @@ class Text { } }; - $Elements = $Document->getElementsByTagName('div'); + $Elements = $dom->getElementsByTagName('div'); for ($i = $Elements->length - 1; $i >= 0; $i--) { /** @var \DOMElement $Element */ $Element = $Elements->item($i); if (str_contains($Element->getAttribute('style'), 'text-align')) { - $NewElement = $Document->createElement('align'); + $NewElement = $dom->createElement('align'); $CopyNode($Element, $NewElement); $NewElement->setAttribute('align', str_replace('text-align: ', '', $Element->getAttribute('style'))); $Element->parentNode->replaceChild($NewElement, $Element); } } - $Elements = $Document->getElementsByTagName('span'); + $Elements = $dom->getElementsByTagName('span'); for ($i = $Elements->length - 1; $i >= 0; $i--) { /** @var \DOMElement $Element */ $Element = $Elements->item($i); if (str_contains($Element->getAttribute('class'), 'size')) { - $NewElement = $Document->createElement('size'); + $NewElement = $dom->createElement('size'); $CopyNode($Element, $NewElement); $NewElement->setAttribute('size', str_replace('size', '', $Element->getAttribute('class'))); $Element->parentNode->replaceChild($NewElement, $Element); } elseif (str_contains($Element->getAttribute('style'), 'font-style: italic')) { - $NewElement = $Document->createElement('italic'); + $NewElement = $dom->createElement('italic'); $CopyNode($Element, $NewElement); $Element->parentNode->replaceChild($NewElement, $Element); } elseif (str_contains($Element->getAttribute('style'), 'text-decoration: underline')) { - $NewElement = $Document->createElement('underline'); + $NewElement = $dom->createElement('underline'); $CopyNode($Element, $NewElement); $Element->parentNode->replaceChild($NewElement, $Element); } elseif (str_contains($Element->getAttribute('style'), 'color: ')) { - $NewElement = $Document->createElement('color'); + $NewElement = $dom->createElement('color'); $CopyNode($Element, $NewElement); $NewElement->setAttribute('color', str_replace(['color: ', ';'], '', $Element->getAttribute('style'))); $Element->parentNode->replaceChild($NewElement, $Element); } elseif (preg_match("/display:[ ]*inline\-block;[ ]*padding:/", $Element->getAttribute('style')) !== false) { - $NewElement = $Document->createElement('pad'); + $NewElement = $dom->createElement('pad'); $CopyNode($Element, $NewElement); $Padding = explode(' ', trim(explode(':', (explode(';', $Element->getAttribute('style'))[1]))[1])); $NewElement->setAttribute('pad', implode('|', array_map(fn($x) => rtrim($x, 'px'), $Padding))); @@ -1265,44 +1271,44 @@ class Text { } } - $Elements = $Document->getElementsByTagName('ul'); + $Elements = $dom->getElementsByTagName('ul'); for ($i = 0; $i < $Elements->length; $i++) { /** @var \DOMElement $Element */ $Element = $Elements->item($i); $InnerElements = $Element->getElementsByTagName('li'); for ($j = $InnerElements->length - 1; $j >= 0; $j--) { $Element = $InnerElements->item($j); - $NewElement = $Document->createElement('bullet'); + $NewElement = $dom->createElement('bullet'); $CopyNode($Element, $NewElement); $Element->parentNode->replaceChild($NewElement, $Element); } } - $Elements = $Document->getElementsByTagName('ol'); + $Elements = $dom->getElementsByTagName('ol'); for ($i = 0; $i < $Elements->length; $i++) { /** @var \DOMElement $Element */ $Element = $Elements->item($i); $InnerElements = $Element->getElementsByTagName('li'); for ($j = $InnerElements->length - 1; $j >= 0; $j--) { $Element = $InnerElements->item($j); - $NewElement = $Document->createElement('number'); + $NewElement = $dom->createElement('number'); $CopyNode($Element, $NewElement); $Element->parentNode->replaceChild($NewElement, $Element); } } - $Elements = $Document->getElementsByTagName('strong'); + $Elements = $dom->getElementsByTagName('strong'); for ($i = $Elements->length - 1; $i >= 0; $i--) { /** @var \DOMElement $Element */ $Element = $Elements->item($i); if (in_array('important_text', explode(' ', $Element->getAttribute('class')))) { - $NewElement = $Document->createElement('important'); + $NewElement = $dom->createElement('important'); $CopyNode($Element, $NewElement); $Element->parentNode->replaceChild($NewElement, $Element); } } - $Elements = $Document->getElementsByTagName('a'); + $Elements = $dom->getElementsByTagName('a'); for ($i = $Elements->length - 1; $i >= 0; $i--) { /** @var \DOMElement $Element */ $Element = $Elements->item($i); @@ -1311,14 +1317,16 @@ class Text { $Element->removeAttribute('target'); if ($Element->getAttribute('href') === $Element->nodeValue) { $Element->removeAttribute('href'); - } elseif ($Element->getAttribute('href') === 'javascript:void(0);' - && $Element->getAttribute('onclick') === 'BBCode.spoiler(this);') { - $Spoilers = $Document->getElementsByTagName('blockquote'); + } elseif ( + $Element->getAttribute('href') === 'javascript:void(0);' + && $Element->getAttribute('onclick') === 'BBCode.spoiler(this);' + ) { + $Spoilers = $dom->getElementsByTagName('blockquote'); for ($j = $Spoilers->length - 1; $j >= 0; $j--) { /** @var \DOMElement $Spoiler */ $Spoiler = $Spoilers->item($j); if ($Spoiler->hasAttribute('class') && $Spoiler->getAttribute('class') === 'hidden spoiler') { - $NewElement = $Document->createElement('spoiler'); + $NewElement = $dom->createElement('spoiler'); $CopyNode($Spoiler, $NewElement); $Element->parentNode->replaceChild($NewElement, $Element); $Spoiler->parentNode->removeChild($Spoiler); @@ -1326,18 +1334,18 @@ class Text { } } } elseif (str_starts_with($Element->getAttribute('href'), 'artist.php?artistname=')) { - $NewElement = $Document->createElement('artist'); + $NewElement = $dom->createElement('artist'); $CopyNode($Element, $NewElement); $Element->parentNode->replaceChild($NewElement, $Element); } elseif (str_starts_with($Element->getAttribute('href'), 'user.php?action=search&search=')) { - $NewElement = $Document->createElement('user'); + $NewElement = $dom->createElement('user'); $CopyNode($Element, $NewElement); $Element->parentNode->replaceChild($NewElement, $Element); } } } - $Str = (string)$Document->saveHTML($Document->getElementsByTagName('body')->item(0)); + $Str = (string)$dom->saveHTML($dom->getElementsByTagName('body')->item(0)); $Str = str_replace(["\n", "\n", "", ""], "", $Str); $Str = str_replace(["\r\n", "\n"], "", $Str); $Str = preg_replace("/\([a-zA-Z0-9 ]+)\<\/strong\>\: \/", "[spoiler=\\1]", $Str); diff --git a/classes/view.class.php b/classes/view.class.php index 38ac6a10e..df2ec6c10 100644 --- a/classes/view.class.php +++ b/classes/view.class.php @@ -14,11 +14,14 @@ class View { * @param array $option */ public static function header(string $pageTitle, array $option = []): string { - global $Document, $Twig, $Viewer; if ($pageTitle != '') { $pageTitle .= ' :: '; } $pageTitle .= SITE_NAME; + global $Viewer; + $module = is_null($Viewer) + ? 'index' + : $Viewer->requestContext()->module(); $js = [ 'jquery', @@ -34,6 +37,7 @@ class View { array_push($js, ...explode(',', $option['js'])); } + global $Twig; if (!isset($Viewer) || $pageTitle == 'Recover Password :: ' . SITE_NAME) { $js[] = 'storage.class'; echo $Twig->render('index/public-header.twig', [ @@ -63,7 +67,7 @@ class View { ->setStaffPM(new Gazelle\Manager\StaffPM()); $notifier = new Gazelle\User\Notification($Viewer); - $alertList = $notifier->setDocument($Document, $_REQUEST['action'] ?? '')->alertList(); + $alertList = $notifier->setDocument($module, $_REQUEST['action'] ?? '')->alertList(); foreach ($alertList as $alert) { if (in_array($alert->display(), [Gazelle\User\Notification::DISPLAY_TRADITIONAL, Gazelle\User\Notification::DISPLAY_TRADITIONAL_PUSH])) { $activity->setAlert(sprintf('%s', $alert->notificationUrl(), $alert->title())); @@ -92,7 +96,7 @@ class View { } } - $PageID = [$Document, $_REQUEST['action'] ?? false, $_REQUEST['type'] ?? false]; + $PageID = [$module, $_REQUEST['action'] ?? false, $_REQUEST['type'] ?? false]; $navLinks = []; foreach ((new Gazelle\Manager\UserNavigation())->userControlList($Viewer) as $n) { [$ID, $Key, $Title, $Target, $Tests, $TestUser, $Mandatory] = array_values($n); @@ -149,7 +153,7 @@ class View { 'action_list' => $activity->actionList(), 'alert_list' => $activity->alertList(), 'bonus' => new Gazelle\User\Bonus($Viewer), - 'document' => $Document, + 'document' => $module, 'dono_target' => $payMan->monthlyPercent(new Gazelle\Manager\Donation()), 'nav_links' => $navLinks, 'user' => $Viewer, @@ -201,8 +205,12 @@ class View { $launch = SITE_LAUNCH_YEAR . "-$launch"; } - global $Document; - $alertList = (new Gazelle\User\Notification($Viewer))->setDocument($Document, $_REQUEST['action'] ?? '')->alertList(); + $alertList = (new Gazelle\User\Notification($Viewer)) + ->setDocument( + $Viewer->requestContext()->module(), + $_REQUEST['action'] ?? '' + ) + ->alertList(); $notification = []; foreach ($alertList as $alert) { if (in_array($alert->display(), [Gazelle\User\Notification::DISPLAY_POPUP, Gazelle\User\Notification::DISPLAY_POPUP_PUSH])) { diff --git a/gazelle.php b/gazelle.php index 76772009e..c45b9447b 100644 --- a/gazelle.php +++ b/gazelle.php @@ -5,27 +5,6 @@ use Gazelle\Util\Time; // 1. Basic sanity checks and initialization -if (PHP_VERSION_ID < 80201) { - die("Gazelle (Orpheus fork) requires at least PHP version 8.2.1"); -} -foreach (['memcached', 'mysqli'] as $e) { - if (!extension_loaded($e)) { - die("$e extension not loaded"); - } -} -date_default_timezone_set('UTC'); - -$PathInfo = pathinfo($_SERVER['SCRIPT_NAME']); -$Document = $PathInfo['filename']; - -if ($PathInfo['dirname'] !== '/') { /** @phpstan-ignore-line */ - exit; -} elseif (in_array($Document, ['announce', 'scrape']) || (isset($_REQUEST['info_hash']) && isset($_REQUEST['peer_id']))) { - die("d14:failure reason40:Invalid .torrent, try downloading again.e"); -} - -// 2. Start the engine - require_once(__DIR__ . '/lib/bootstrap.php'); global $Cache, $Debug, $Twig; @@ -37,9 +16,27 @@ if ( ) { $_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_X_FORWARDED_FOR']; } -if (!isset($_SERVER['HTTP_USER_AGENT'])) { - $_SERVER['HTTP_USER_AGENT'] = '[no-useragent]'; + +$context = new Gazelle\BaseRequestContext( + $_SERVER['SCRIPT_NAME'], + $_SERVER['REMOTE_ADDR'], + $_SERVER['HTTP_USER_AGENT'] ?? '[no-useragent]', +); +if (!$context->isValid()) { + exit; } +$module = $context->module(); +if ( + in_array($module, ['announce', 'scrape']) + || ( + isset($_REQUEST['info_hash']) + && isset($_REQUEST['peer_id']) + ) +) { + die("d14:failure reason40:Invalid .torrent, try downloading again.e"); +} + +// 2. Start the engine // 3. Do we have a viewer? @@ -50,12 +47,12 @@ $userMan = new Gazelle\Manager\User(); Gazelle\Util\Twig::setUserMan($userMan); // Authorization header only makes sense for the ajax endpoint -if (!empty($_SERVER['HTTP_AUTHORIZATION']) && $Document === 'ajax') { - if ($ipv4Man->isBanned($_SERVER['REMOTE_ADDR'])) { +if (!empty($_SERVER['HTTP_AUTHORIZATION']) && $module === 'ajax') { + if ($ipv4Man->isBanned($context->remoteAddr())) { header('Content-type: application/json'); json_die('failure', 'your ip address has been banned'); } - [$success, $result] = $userMan->findByAuthorization($ipv4Man, $_SERVER['HTTP_AUTHORIZATION'], $_SERVER['REMOTE_ADDR']); + [$success, $result] = $userMan->findByAuthorization($ipv4Man, $_SERVER['HTTP_AUTHORIZATION'], $context->remoteAddr()); if ($success) { $Viewer = $result; define('AUTHED_BY_TOKEN', true); @@ -66,7 +63,7 @@ if (!empty($_SERVER['HTTP_AUTHORIZATION']) && $Document === 'ajax') { } elseif (isset($_COOKIE['session'])) { $forceLogout = function (): never { setcookie('session', '', [ - 'expires' => time() - 60 * 60 * 24 * 90, + 'expires' => time() - 86_400 * 90, 'path' => '/', 'secure' => !DEBUG_MODE, 'httponly' => true, @@ -84,7 +81,7 @@ if (!empty($_SERVER['HTTP_AUTHORIZATION']) && $Document === 'ajax') { if (is_null($Viewer)) { $forceLogout(); } - if ($Viewer->isDisabled() && !in_array($Document, ['index', 'login'])) { + if ($Viewer->isDisabled() && !in_array($module, ['index', 'login'])) { $Viewer->logoutEverywhere(); $forceLogout(); } @@ -93,26 +90,22 @@ if (!empty($_SERVER['HTTP_AUTHORIZATION']) && $Document === 'ajax') { $Viewer->logout($SessionID); $forceLogout(); } - $browser = parse_user_agent($_SERVER['HTTP_USER_AGENT']); if ($Viewer->permitted('site_disable_ip_history')) { - $ipaddr = '127.0.0.1'; - $browser['BrowserVersion'] = null; - $browser['OperatingSystemVersion'] = null; - } else { - $ipaddr = $_SERVER['REMOTE_ADDR']; + $context->anonymize(); + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; } - $session->refresh($SessionID, $ipaddr, $browser); - unset($ipaddr, $browser, $session, $userId, $cookieData, $forceLogout); -} elseif ($Document === 'torrents' && ($_REQUEST['action'] ?? '') == 'download' && isset($_REQUEST['torrent_pass'])) { + $session->refresh($SessionID, $context->remoteAddr(), $context->ua()); + unset($browser, $session, $userId, $cookieData, $forceLogout); +} elseif ($module === 'torrents' && ($_REQUEST['action'] ?? '') == 'download' && isset($_REQUEST['torrent_pass'])) { $Viewer = $userMan->findByAnnounceKey($_REQUEST['torrent_pass']); if (is_null($Viewer) || $Viewer->isDisabled() || $Viewer->isLocked()) { header('HTTP/1.1 403 Forbidden'); exit; } -} elseif (!in_array($Document, ['enable', 'index', 'login', 'recovery', 'register'])) { +} elseif (!in_array($module, ['enable', 'index', 'login', 'recovery', 'register'])) { if ( // Ocelot is allowed - !($Document === 'tools' && ($_GET['action'] ?? '') === 'ocelot' && ($_GET['key'] ?? '') === TRACKER_SECRET) + !($module === 'tools' && ($_GET['action'] ?? '') === 'ocelot' && ($_GET['key'] ?? '') === TRACKER_SECRET) ) { // but for everything else, we need a $Viewer header('Location: login.php'); @@ -126,22 +119,18 @@ if ($Viewer) { if ($Viewer->hasAttr('admin-error-reporting')) { error_reporting(E_ALL); } - - // Because we <3 our staff if ($Viewer->permitted('site_disable_ip_history')) { - $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; - $_SERVER['HTTP_X_FORWARDED_FOR'] = '127.0.0.1'; - $_SERVER['HTTP_X_REAL_IP'] = '127.0.0.1'; - $_SERVER['HTTP_USER_AGENT'] = 'staff-browser'; + $context->anonymize(); + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; } - if ($Viewer->ipaddr() != $_SERVER['REMOTE_ADDR'] && !$Viewer->permitted('site_disable_ip_history')) { - if ($ipv4Man->isBanned($_SERVER['REMOTE_ADDR'])) { + if ($Viewer->ipaddr() != $context->remoteAddr() && !$Viewer->permitted('site_disable_ip_history')) { + if ($ipv4Man->isBanned($context->remoteAddr())) { error('Your IP address has been banned.'); } - $ipv4Man->register($Viewer, $_SERVER['REMOTE_ADDR']); + $ipv4Man->register($Viewer, $context->remoteAddr()); } - if ($Viewer->isLocked() && !in_array($Document, ['staffpm', 'ajax', 'locked', 'logout', 'login'])) { - $Document = 'locked'; + if ($Viewer->isLocked() && !in_array($module, ['staffpm', 'ajax', 'locked', 'logout', 'login'])) { + $context->setModule('locked'); } // To proxify images (or not), or e.g. not render the name of a thread @@ -153,11 +142,12 @@ $Debug->set_flag('load page'); if (DEBUG_MODE || ($Viewer && $Viewer->permitted('site_debug'))) { $Twig->addExtension(new Twig\Extension\DebugExtension()); } +Gazelle\Base::setRequestContext($context); // for sections/tools/development/process_info.php $Cache->cache_value('php_' . getmypid(), [ 'start' => Time::sqlTime(), - 'document' => $Document, + 'document' => $module, 'query' => $_SERVER['QUERY_STRING'], 'get' => $_GET, 'post' => array_diff_key( @@ -184,8 +174,8 @@ register_shutdown_function( header('Cache-Control: no-cache, must-revalidate, post-check=0, pre-check=0'); header('Pragma: no-cache'); -$file = realpath(__DIR__ . "/sections/{$Document}/index.php"); -if (!$file || !preg_match('/^[a-z][a-z0-9_]+$/', $Document)) { +$file = realpath(__DIR__ . "/sections/{$module}/index.php"); +if (!$file || !preg_match('/^[a-z][a-z0-9_]+$/', $module)) { error($Viewer ? 403 : 404); } @@ -210,5 +200,5 @@ try { $Debug->set_flag('and send to user'); if (!is_null($Viewer)) { - $Debug->profile($Viewer, $Document); + $Debug->profile($Viewer, $module); } diff --git a/lib/bootstrap.php b/lib/bootstrap.php index 18ea87239..4092122f7 100644 --- a/lib/bootstrap.php +++ b/lib/bootstrap.php @@ -2,6 +2,16 @@ /* require this file to have a fully-initialized Gazelle runtime */ +if (PHP_VERSION_ID < 80201) { + die("Gazelle (Orpheus fork) requires at least PHP version 8.2.1"); +} +foreach (['memcached', 'mysqli'] as $e) { + if (!extension_loaded($e)) { + die("$e extension not loaded"); + } +} +date_default_timezone_set('UTC'); + $now = microtime(true); // To track how long a page takes to create if (!defined('SITE_NAME')) { diff --git a/lib/util.php b/lib/util.php index 9a315d4a3..37916fd99 100644 --- a/lib/util.php +++ b/lib/util.php @@ -355,10 +355,10 @@ function parse_user_agent(string $useragent): array { * $Log If true, the user is given a link to search $Log in the site log. */ function error(int|string $Error, bool $NoHTML = false, bool $Log = false): never { - global $Debug, $Document, $Viewer, $Twig; + global $Debug, $Viewer, $Twig; require_once(__DIR__ . '/../sections/error/index.php'); if (isset($Viewer)) { - $Debug->profile($Viewer, $Document); + $Debug->profile($Viewer, $Viewer->requestContext()->module()); } exit; } diff --git a/sections/login/login.php b/sections/login/login.php index 53885c1fb..6a06c3385 100644 --- a/sections/login/login.php +++ b/sections/login/login.php @@ -39,23 +39,21 @@ if (!empty($_POST['username']) && !empty($_POST['password'])) { echo $Twig->render('login/weak-password.twig'); exit; } - - $browser = parse_user_agent($_SERVER['HTTP_USER_AGENT']); + $useragent = $_SERVER['HTTP_USER_AGENT'] ?? '[no-useragent]'; + $context = new Gazelle\BaseRequestContext( + $_SERVER['SCRIPT_NAME'], + $_SERVER['REMOTE_ADDR'], + $useragent, + ); if ($user->permitted('site_disable_ip_history')) { - $ipaddr = '127.0.0.1'; - $browser['BrowserVersion'] = null; - $browser['OperatingSystemVersion'] = null; - $full_ua = 'staff-browser'; - } else { - $ipaddr = $_SERVER['REMOTE_ADDR']; - $full_ua = $_SERVER['HTTP_USER_AGENT']; + $context->anonymize(); } $session = new Gazelle\User\Session($user); $current = $session->create([ 'keep-logged' => $login->persistent() ? '1' : '0', - 'browser' => $browser, - 'ipaddr' => $ipaddr, - 'useragent' => $full_ua, + 'browser' => $context->ua(), + 'ipaddr' => $context->remoteAddr(), + 'useragent' => $useragent, ]); setcookie('session', $session->cookie($current['SessionID']), [ 'expires' => (int)$login->persistent() * (time() + 60 * 60 * 24 * 90), diff --git a/tests/phpunit/BaseRequestContext.php b/tests/phpunit/BaseRequestContext.php new file mode 100644 index 000000000..54e6e28b3 --- /dev/null +++ b/tests/phpunit/BaseRequestContext.php @@ -0,0 +1,68 @@ +user)) { + $this->user->remove(); + } + } + + public function testBaseRequestContext(): void { + $context = new Gazelle\BaseRequestContext( + '/phpunit.php', + '224.0.0.1', + 'Lidarr/3.5.8 (windows 98)', + ); + $this->assertTrue($context->isValid(), 'context-is-valid'); + $this->assertEquals('phpunit', $context->module(), 'context-module'); + $this->assertEquals('224.0.0.1', $context->remoteAddr(), 'context-remote-addr'); + $this->assertEquals('Lidarr', $context->browser(), 'context-browser'); + $this->assertEquals('3.5', $context->browserVersion(), 'context-version-browser'); + $this->assertEquals('98', $context->osVersion(), 'context-version-os'); + $this->assertEquals('Windows', $context->os(), 'context-os'); + + $module = randomString(4); + $context->setModule($module); + $this->assertEquals($module, $context->module(), 'context-override-module'); + + $context->anonymize(); + $this->assertEquals('127.0.0.1', $context->remoteAddr(), 'context-override-remoteaddr'); + $this->assertEquals('staff-browser', $context->browser(), 'context-override-browser'); + $this->assertNull($context->os(), 'context-override-os'); + } + + public function testBadRequest(): void { + $context = new Gazelle\BaseRequestContext('', '', ''); + $this->assertFalse($context->isValid(), 'context-not-valid'); + $this->assertNull($context->browser(), 'context-invalid-browser'); + } + + // Any object that derives from Base has access to the request context + public function testObject(): void { + Gazelle\Base::setRequestContext( + new Gazelle\BaseRequestContext( + '/phpunit.php', + '225.0.0.1', + 'Lidarr/5.8.13 (windows 98)', + ) + ); + $this->assertEquals( + '225.0.0.1', + (new Gazelle\Manager\TGroup())->requestContext()->remoteAddr(), + 'context-manager-ip', + ); + $this->user = Helper::makeUser('base.' . randomString(6), 'base object'); + $this->assertEquals( + 'Lidarr', + $this->user->requestContext()->browser(), + 'context-user-browser', + ); + } +} diff --git a/tests/phpunit/DonorTest.php b/tests/phpunit/DonorTest.php index 60ed98e3f..630e3cae2 100644 --- a/tests/phpunit/DonorTest.php +++ b/tests/phpunit/DonorTest.php @@ -441,8 +441,7 @@ class DonorTest extends TestCase { ]); global $SessionID; $SessionID = $current['SessionID']; // more sadness - global $Document; - $Document = 'index'; // utter misery + Gazelle\Base::setRequestContext(new Gazelle\BaseRequestContext('/index.php', '127.0.0.1', '')); $paginator = (new Gazelle\Util\Paginator(USERS_PER_PAGE, 1))->setTotal($manager->rewardTotal()); $render = (Gazelle\Util\Twig::factory())->render('donation/reward-list.twig', [ diff --git a/tests/phpunit/ForumTest.php b/tests/phpunit/ForumTest.php index ab8ceb81e..ebff0545b 100644 --- a/tests/phpunit/ForumTest.php +++ b/tests/phpunit/ForumTest.php @@ -546,10 +546,11 @@ class ForumTest extends TestCase { description: 'This is where it renders', ); $paginator = (new Gazelle\Util\Paginator(TOPICS_PER_PAGE, 1))->setTotal(1); - global $Document, $SessionID, $Viewer; // to render header() - $Document = 'forum'; + Gazelle\Base::setRequestContext(new Gazelle\BaseRequestContext('/forum.php', '127.0.0.1', '')); + global $SessionID; // to render header() $SessionID = 'phpunit'; - $Viewer = $admin; + global $Viewer; + $Viewer = $admin; $this->assertStringContainsString( "$name", (Gazelle\Util\Twig::factory())->render('forum/forum.twig', [ diff --git a/tests/phpunit/FriendTest.php b/tests/phpunit/FriendTest.php index 0f50b097c..4c80d0e72 100644 --- a/tests/phpunit/FriendTest.php +++ b/tests/phpunit/FriendTest.php @@ -60,8 +60,8 @@ class FriendTest extends TestCase { 'ipaddr' => '127.0.0.1', 'useragent' => 'phpunit-browser', ]); - global $Document, $SessionID, $Viewer; - $Document = 'friends'; + Gazelle\Base::setRequestContext(new Gazelle\BaseRequestContext('/friends.php', '127.0.0.1', '')); + global $SessionID, $Viewer; $SessionID = $current['SessionID']; $Viewer = $this->friend[0]->user(); diff --git a/tests/phpunit/LogTest.php b/tests/phpunit/LogTest.php index 12fd9d879..2f0208b8e 100644 --- a/tests/phpunit/LogTest.php +++ b/tests/phpunit/LogTest.php @@ -131,10 +131,9 @@ class LogTest extends TestCase { // FIXME: $Viewer should not be necessary $this->user = Helper::makeUser('sitelog.' . randomString(6), 'sitelog'); + Gazelle\Base::setRequestContext(new Gazelle\BaseRequestContext('/index.php', '127.0.0.1', '')); global $Viewer; $Viewer = $this->user; - global $Document; - $Document = ''; global $SessionID; $SessionID = 'phpunit'; $html = (Gazelle\Util\Twig::factory())->render('sitelog.twig', [ diff --git a/tests/phpunit/UploadTest.php b/tests/phpunit/UploadTest.php index 94e96f3e2..89a4936b2 100644 --- a/tests/phpunit/UploadTest.php +++ b/tests/phpunit/UploadTest.php @@ -21,8 +21,7 @@ class UploadTest extends TestCase { $this->assertStringContainsString($this->user->auth(), $upload->head(0), 'upload-head'); - global $Document; - $Document = ''; + Gazelle\Base::setRequestContext(new Gazelle\BaseRequestContext('/upload.php', '127.0.0.1', '')); global $SessionID; $SessionID = ''; global $Viewer; diff --git a/tests/phpunit/Util/TwigTest.php b/tests/phpunit/Util/TwigTest.php index 646005850..a3a05b278 100644 --- a/tests/phpunit/Util/TwigTest.php +++ b/tests/phpunit/Util/TwigTest.php @@ -210,10 +210,9 @@ END; } public function testFunction(): void { - global $Document; - $Document = 'index'; + Gazelle\Base::setRequestContext(new Gazelle\BaseRequestContext('/index.php', '127.0.0.1', '')); global $Viewer; - $Viewer = $this->user; + $Viewer = $this->user; $this->assertStringStartsWith('', self::twig('{{ header("page") }}')->render(), 'twig-function-header'); $current = (new Gazelle\User\Session($Viewer))->create([