, no * auth check, dumping DOCUMENT_ROOT, every resolved param, and raw record * data. debug() now also requires the server-side constant * CATS_DEBUG_ENABLED (default false below) -- set it true only in a * trusted/dev environment. * * [SECURITY FIX] Template / page path traversal * File paths built from 'dir', 'templates', 'body', 'nav', 'table', * 'record', 'hr', 'page', and cats.ini [pages] entries are now resolved * with realpath() and required to fall inside DOCUMENT_ROOT before * file_get_contents() runs. Also applied to the per-directory cats.ini * lookup loop, which previously trusted 'dir' unchecked. * * [SECURITY FIX] Reflected XSS in page-level tokens * !param! and $_SERVER-key$ token substitution into the final HTML now * runs through htmlspecialchars(). [DATA] record cells (%column% tokens) * are deliberately left unescaped -- this system's plugins/templates are * designed to emit trusted rich HTML at the record level, matching 4.16. * * [BUG FIX] [WHERE] protection was actually broken * The 4.16 header comment claims a user can never override [WHERE] via * the URL. True after the FIRST $_REQUEST merge -- but 4.16 merged * $_REQUEST a second time later (to give the request final precedence * over cats.ini, per the documented "defaults, INI, then $_REQUEST" * order) and never re-protected 'where' after that second merge, quietly * re-admitting a user-supplied where=. Fixed by re-applying the * protection after both merges. * * [BUG FIX] Plugin extension check used the wrong variable * 4.16 checked `substr($sTemp, -4, 4) != ".php"` to decide whether to * append ".php" to the plugin name -- but $sTemp at that point was * leftover from the unrelated template-loading loop above it, not the * plugin value. Fixed to check $aParams['plugin'] itself. * * [BUG FIX] Division by zero * ?cols=0 (column width calc) and a limit/range that resolves to 0 * (pagination slice math) could fatal the script. Both are now guarded. * * [BUG FIX] Malformed 'where' / 'limit' input * explode() results are now validated before use instead of being fed * straight into list() and arithmetic, which previously produced PHP 8 * warnings and undefined behavior on malformed input. * * [CLEANUP] Dropped the top-level `Global $aParams, $aScript, ...;` * statement -- `global` has no effect outside a function body, so at * top-level scope this line was a no-op in 4.16. * ============================================================================ */ // [SECURITY] Leave false in production. debug() will not print anything, // regardless of ?debug=, unless this is explicitly true. define('CATS_DEBUG_ENABLED', false); date_default_timezone_set("America/Los_Angeles"); class CatsEngine { public $aScript = array(); public $aParams = array(); public $aTemplates = array(); public $aPages = array(); public $aKeywords = array(); public $aData = null; public $aRecordset = array(); private $sDocRoot; private $sRecords = ""; private $sPage = ""; public function __construct() { $this->sDocRoot = $_SERVER['DOCUMENT_ROOT']; } // ======================================================================== // PIPELINE ENTRY POINT // ======================================================================== public function run() { $this->initScript(); $this->resolveDomain(); $this->initDefaultParams(); $this->initTemplatesArray(); $this->aScript['timer:overall'] = microtime(); $this->mergeParamsAndLoadIni(); $this->loadTemplates(); $this->runPlugin(); $this->processData(); $this->finalizeOutput(); } // ======================================================================== // STAGE: script identity / domain resolution // ======================================================================== private function initScript() { $this->aScript = array( "name" => "cats.php", "version" => "4.17-claude", "modified" => "2026.07.08", "release" => "release", "status" => "open source", "author" => "Sean Shrum", "email" => "sshrum01@gmail.com", "created" => "2005.07.21", "distribute" => "http://cats.shrum.net", ); debug("+ Script info"); $this->aScript['timer:templates'] = 0.000; $this->aScript['timer:plugin'] = 0.000; $this->aScript['timer:search'] = 0.000; $this->aScript['timer:sort'] = 0.000; $this->aScript['timer:overall'] = 0.000; $this->aScript['timer:cats'] = 0.000; } private function resolveDomain() { $aParts = explode(".", $_SERVER['SERVER_NAME']); switch (count($aParts)) { case 2: // shrum.net $this->aScript['domain'] = $aParts[0] . "." . $aParts[1]; $this->aScript['subdomain'] = "www"; break; case 3: // www.shrum.net, sean.shrum.net, etc. $this->aScript['domain'] = $aParts[1] . "." . $aParts[2]; $this->aScript['subdomain'] = $aParts[0]; break; case 4: // 205.177.72.189 (if running locally, edit HOSTS file) $this->aScript['domain'] = $_SERVER['SERVER_NAME']; $this->aScript['subdomain'] = ""; break; } $this->aScript['relative'] = "/" . substr($_SERVER['SCRIPT_NAME'], 1, -(strlen(basename(__FILE__)) + 1)); // local/www swap link (developing on a local/remote setup) if ($this->aScript['subdomain'] == "www") { $this->aScript['subdomain'] = "aScript['name'] . "?" . $_SERVER['QUERY_STRING'] . "'>www"; } elseif ($this->aScript['subdomain'] != "") { $this->aScript['subdomain'] = "aScript['name'] . "?" . $_SERVER['QUERY_STRING'] . "'>" . $this->aScript['subdomain'] . ""; } } // ======================================================================== // STAGE: parameter defaults / template slot definitions // ======================================================================== private function initDefaultParams() { $this->aParams = array( "align" => "center", "body" => "null", "border" => "0", "caption" => "null", "cellspacing" => "4", "cellpadding" => "4", "cols" => "1", "debug" => "null", "dir" => "/", "in" => "null", "hr" => "null", "limit" => "null", "nav" => "null", "order" => "null", "page" => "null", "plugin" => "text", "plugins" => "null", "raw" => "null", "record" => "null", "rowcolor1" => "ffffff", "rowcolor2" => "eeeeee", "rows" => "10", "table" => "null", "templates" => "/", "search" => "null", "sort" => "asc", "valign" => "middle", "where" => "null", "width" => "100%", ); ksort($this->aParams); debug("

+ List of available parameters and their script set defaults (null'ed items unless specified get UNSET()):

"); } private function initTemplatesArray() { $this->aTemplates = array( "page" => "", "body" => "", "nav" => "", "table" => "", "hr" => "", "record" => "", ); } // ======================================================================== // STAGE: request merge + cats.ini cascade // ======================================================================== private function mergeParamsAndLoadIni() { // Merge 1: pull $_REQUEST in so 'dir' (and everything else) reflects // the caller's URL before we go looking for cats.ini files. $sWhereDefault = $this->aParams['where']; $this->aParams = array_merge($this->aParams, $_REQUEST); $this->aParams['where'] = $sWhereDefault; // [SECURITY] 'where' never comes from the request debug("

+ Importing '" . substr(basename(__FILE__), 0, -4) . ".ini' key => value pairs from all directories starting in [DOCUMENT_ROOT] thru " . $this->aParams['dir'] . "...

"); // Merge 2: request takes final precedence over ini-configured values, // per the documented order ("aDefaults, INI content and $_REQUEST, // in that order"). 'where' stays protected either way -- cats.ini / // the site owner may set it, the request may not. $sWhereProtected = $this->aParams['where']; $this->aParams = array_merge($this->aParams, $_REQUEST); $this->aParams['where'] = $sWhereProtected; // [BUG FIX] 4.16 skipped this re-protection debug("

+ UNSET()'ing any parameters = 'null'...."); $iCut = 0; ksort($this->aParams); foreach ($this->aParams as $key => $value) { if (strtolower($value) == "null") { $iCut++; unset($this->aParams[$key]); } } debug("" . $iCut . " parameters removed

"); debug("

+ Current [PARAM]eters for this page (CATS.INI::[defaults] + URL-based parameters)

"); if (count($this->aPages) > 0) { debug("

+ Current [PAGES] for this page (CATS.INI / [pages])

"); } if (count($this->aKeywords) > 0) { debug("

+ Current [KEYWORDS] for this page (CATS.INI / [keywords])

"); } } // ======================================================================== // STAGE: template file loading // ======================================================================== private function loadTemplates() { debug("

+ Retrieving templates...

"); $aA = explode(' ', $this->aScript['timer:templates'] . ' ' . microtime()); $this->aScript['timer:templates'] = sprintf('%01.4f', ($aA[2] + $aA[3]) - ($aA[0] + $aA[1])); } /** * Resolves the "/", "./", or bare-filename param conventions used * throughout CATS for body/nav/table/record/hr/page/pages values. */ private function resolveDocRootPath($sValue) { if (substr($sValue, 0, 1) == "/") { return $this->sDocRoot . $sValue; } elseif (substr($sValue, 0, 2) == "./") { return $this->sDocRoot . $this->aParams['dir'] . substr($sValue, 1); } else { return $this->sDocRoot . $this->aParams['templates'] . "/" . $sValue; } } /** * [SECURITY] Resolves $sPath and rejects it unless it exists AND falls * inside DOCUMENT_ROOT. Returns the real path on success, false otherwise. */ private function containedPath($sPath) { $sReal = realpath($sPath); $sRootReal = realpath($this->sDocRoot); if ($sReal === false || $sRootReal === false) { return false; } if (strpos($sReal, $sRootReal) !== 0) { return false; } return $sReal; } // ======================================================================== // STAGE: plugin resolution + execution // ======================================================================== private function runPlugin() { if (!isset($this->aParams['plugin'])) { $this->aScript['timer:plugin'] = 0; return; } debug("

+ [PLUGIN] defined => " . $this->aParams['plugin'] . "

"); // [BUG FIX] 4.16 checked a stale leftover $sTemp (last path from the // templates loop above) instead of the plugin value itself here. if (substr($this->aParams['plugin'], -4) != ".php") { $this->aParams['plugin'] .= ".php"; } if (isset($this->aParams['plugins'])) { debug("

+ [PLUGINS] defined => " . $this->aParams['plugins'] . "

"); $sCandidate = $this->sDocRoot . $this->aParams['plugins']; $sSafeDir = $this->containedPath($sCandidate); // [SECURITY] traversal guard on the directory pointer itself if ($sSafeDir !== false && is_dir($sSafeDir)) { debug("

+ [PLUGINS] => [DOCUMENT_ROOT]" . $this->aParams['plugins'] . "

"); $this->aParams['plugins'] = $sSafeDir; } else { print "

+ [PLUGINS] => " . htmlspecialchars($this->aParams['plugins']) . " could not be found. Please check spelling and file existance. Exiting

"; exit; } } else { debug("

+ [PLUGINS] undefined; using: [DOCUMENT_ROOT]

"); debug("

+ [PLUGINS] => " . $this->sDocRoot . "

"); $this->aParams['plugins'] = $this->sDocRoot; } // [SECURITY] basename() strips any directory component the caller // tried to smuggle into 'plugin', so only a file that lives directly // inside the resolved plugins directory can ever be required(). $sPluginFile = basename($this->aParams['plugin']); $sPluginPath = $this->aParams['plugins'] . "/" . $sPluginFile; $sSafePlugin = $this->containedPath($sPluginPath); if ($sSafePlugin !== false) { debug("

+ [PLUGINS]/[PLUGIN] => '" . $sSafePlugin . "' FOUND"); require $sSafePlugin; } else { print ""; print "

[PLUGINS]/[PLUGIN] => '" . htmlspecialchars($sPluginPath) . "' does not exist."; print "

Please check [PLUGIN] spelling and existance in location.

"; print "

Script execution halted.

"; exit; } debug("

+ Calling [PLUGINS]/[PLUGIN] for [DATA] array...

"); $this->aScript['timer:plugin'] = microtime(); $this->aData = main($this->aParams); // plugin file defines global function main($aParams) if (isset($this->aData) && is_array($this->aData) && count($this->aData) > 0) { debug("

+ [DATA] => [" . count($this->aData) . "] x [" . count($this->aData[0]) . "]

"); debug($this->aData); } $aA = explode(' ', $this->aScript['timer:plugin'] . ' ' . microtime()); $this->aScript['timer:plugin'] = sprintf('%01.4f', ($aA[2] + $aA[3]) - ($aA[0] + $aA[1])); debug("

+ [PLUGIN]:time = " . $this->aScript['timer:plugin'] . "/s

"); } // ======================================================================== // STAGE: WHERE filter -> search/sort -> limit -> nav -> record markup // ======================================================================== private function processData() { while (true) { $this->aRecordset['start'] = 0; if (isset($this->aData) && is_array($this->aData) && count($this->aData) > 0) { $this->applyWhereFilter(); if (count($this->aData) == 0) { break; } $this->aRecordset['total'] = count($this->aData); $this->aRecordset['cols'] = count($this->aData[0]); $sSearch = isset($this->aParams['search']) ? $this->aParams['search'] : ""; $sIn = isset($this->aParams['in']) ? $this->aParams['in'] : ""; $sOrder = isset($this->aParams['order']) ? $this->aParams['order'] : ""; if (!isset($this->aParams['sort'])) { $sSort = "undefined"; } else { $sSort = "undefined"; if (substr(strtolower($this->aParams['sort']), 0, 1) == "a") { $sSort = "ascending"; } if (substr(strtolower($this->aParams['sort']), 0, 1) == "d") { $sSort = "descending"; } if (substr(strtolower($this->aParams['sort']), 0, 1) == "r") { $sSort = "random"; } } $this->aRecordset['ascdesc'] = $sSort; $this->searchAndSort($this->aData, $sSearch, $sIn, $sOrder, $sSort); if (count($this->aData) == 0) { $this->aRecordset = array_merge($this->aRecordset, array( 'match' => 0, 'range' => "N/A", 'colnav' => "N/A", 'limitnav' => "N/A", 'start' => "N/A", 'cols' => "N/A", 'first' => "0", 'last' => "0", 'limit' => "N/A", 'slicecount' => "0", 'slicecurrent' => "0", 'slicenext' => "N/A", 'sliceprevious' => "N/A", 'sliceall' => "N/A", 'sortasc' => "N/A", 'sortdesc' => "N/A", 'sortrandom' => "N/A", 'return' => "0", )); break; } $this->aRecordset['match'] = count($this->aData); $this->aRecordset['range'] = $this->aRecordset['match']; $this->applyLimit(); $this->buildNavigation(); $this->buildSliceInfo(); $this->buildRecordsMarkup(); } break; } } private function applyWhereFilter() { if (isset($this->aParams['where'])) { debug("

+ [WHERE] => '" . $this->aParams['where'] . "'

"); $aParts = explode("=", $this->aParams['where'], 2); // [BUG FIX] guard a malformed 'where' (no '=') instead of // feeding list() a missing second value. if (count($aParts) == 2) { list($sField, $sValue) = $aParts; if (array_key_exists($sField, $this->aData[0])) { $iCut = 0; debug("

+ Unsetting records from [DATA]..."); foreach ($this->aData as $key => $row) { if ($this->aData[$key][$sField] <> $sValue) { $iCut++; unset($this->aData[$key]); } } if ($iCut > 0) { $this->aData = array_values($this->aData); debug("" . $iCut . " entries cut

"); debug("

+ [WHERE][DATA] => [" . count($this->aData) . "] x [" . (count($this->aData) ? count($this->aData[0]) : 0) . "]

"); debug($this->aData); } else { debug("" . $iCut . " entries cut

"); } } else { debug("

+ [WHERE] field => '" . $sValue . "' not found in [DATA], operation skipped"); } } else { debug("

+ [WHERE] malformed (expected field=value); operation skipped

"); } } else { debug("

+ [WHERE] undefined; operation skipped"); } } private function applyLimit() { if (isset($this->aParams['limit'])) { $aParts = explode(",", $this->aParams['limit'], 2); if (count($aParts) == 2 && is_numeric(trim($aParts[0])) && is_numeric(trim($aParts[1]))) { $this->aRecordset['start'] = (int) trim($aParts[0]); $this->aRecordset['range'] = (int) trim($aParts[1]); } else { // [BUG FIX] malformed 'limit' now skips cleanly instead of // feeding list() partial/garbage values. debug("

+ [LIMIT] badly formatted; should be in '#,#' format; [start],[range]

"); debug("

+ [LIMIT] operations are skipped

"); } if ($this->aRecordset['start'] > $this->aRecordset['match']) { print "

+ [LIMIT] start => '" . $this->aRecordset['start'] . "' is beyond the total of records in [DATA]: '" . $this->aRecordset['match'] . "'."; print "

Script execution halted."; exit; } if ($this->aRecordset['start'] + $this->aRecordset['range'] > $this->aRecordset['match']) { $this->aRecordset['range'] = $this->aRecordset['match'] - $this->aRecordset['start']; } if (count($this->aData) < $this->aRecordset['start'] + $this->aRecordset['range']) { $this->aRecordset['range'] = count($this->aData) - $this->aRecordset['start']; } debug("

+ [LIMIT] => [" . $this->aRecordset['start'] . "," . $this->aRecordset['range'] . "]

"); $this->aData = array_slice($this->aData, $this->aRecordset['start'], $this->aRecordset['range']); debug($this->aData); } else { debug("

+ [LIMIT] undefined, setting for all records => [" . $this->aRecordset['start'] . "," . $this->aRecordset['range'] . "]

"); } } private function buildNavigation() { $this->aRecordset['colnav'] = ""; for ($i = 1; $i <= 7; $i++) { if ($i == $this->aParams['cols']) { $sTemp = "" . $i . " "; } else { $sTemp = "" . $i . " "; } $this->aRecordset['colnav'] .= $sTemp; } $this->aRecordset['limitnav'] = ""; for ($i = 2; $i <= 4; $i++) { $sTemp = "aParams['cols'] * $this->aParams['rows'] * $i) . ">" . $this->aParams['cols'] * $this->aParams['rows'] * $i . ""; $this->aRecordset['limitnav'] .= " " . $sTemp; } $this->aRecordset['first'] = $this->aRecordset['start'] + 1; $this->aRecordset['last'] = $this->aRecordset['start'] + $this->aRecordset['range']; } private function buildSliceInfo() { // deals with last slices with row < range if (isset($this->aParams['limit'])) { $aParts = explode(",", $this->aParams['limit'], 2); if (count($aParts) == 2) { $this->aRecordset['start'] = (int) trim($aParts[0]); $this->aRecordset['range'] = (int) trim($aParts[1]); } } else { $this->aRecordset['range'] = $this->aRecordset['match']; } // [BUG FIX] never let 'range' be 0 -- it's used as a divisor below. if (empty($this->aRecordset['range'])) { $this->aRecordset['range'] = max(1, $this->aRecordset['match']); } $this->aRecordset['slicecount'] = 1; $this->aRecordset['slicecurrent'] = 1; $this->aRecordset['sliceprevious'] = $_SERVER['REQUEST_URI']; $this->aRecordset['slicenext'] = $_SERVER['REQUEST_URI']; $this->aRecordset['sliceall'] = query_string_mod($_SERVER['REQUEST_URI'], "limit", "null"); $this->aRecordset['slicefirst'] = query_string_mod($_SERVER['REQUEST_URI'], "limit", "0," . $this->aRecordset['range']); $this->aRecordset['slicelast'] = query_string_mod($_SERVER['REQUEST_URI'], "limit", floor($this->aRecordset['match'] / $this->aRecordset['range']) * $this->aRecordset['range'] . "," . $this->aRecordset['range']); $this->aRecordset['sortasc'] = query_string_mod($_SERVER['REQUEST_URI'], "sort", "asc"); $this->aRecordset['sortdesc'] = query_string_mod($_SERVER['REQUEST_URI'], "sort", "desc"); $this->aRecordset['sortrandom'] = query_string_mod($_SERVER['REQUEST_URI'], "sort", "random"); if ($this->aRecordset['range'] <= $this->aRecordset['match']) { $this->aRecordset['slicecount'] = ceil($this->aRecordset['match'] / $this->aRecordset['range']); $this->aRecordset['slicecurrent'] = ceil($this->aRecordset['first'] / $this->aRecordset['range']); } if ($this->aRecordset['slicecount'] > 1) { if ($this->aRecordset['slicecurrent'] != 1) { $this->aRecordset['sliceprevious'] = query_string_mod($_SERVER['REQUEST_URI'], "limit", ($this->aRecordset['start'] - $this->aRecordset['range']) . "," . $this->aRecordset['range']); } if ($this->aRecordset['slicecurrent'] != $this->aRecordset['slicecount']) { $this->aRecordset['slicenext'] = query_string_mod($_SERVER['REQUEST_URI'], "limit", ($this->aRecordset['start'] + $this->aRecordset['range']) . "," . $this->aRecordset['range']); } } $this->aRecordset['slicecurrent'] = ceil(($this->aRecordset['start'] + 1) / $this->aRecordset['range']); } private function buildRecordsMarkup() { $sHR = ""; if (isset($this->aParams['hr'])) { if (strtoupper($this->aParams['hr']) == "Y") { $sHR = "
"; } else { $sHR = "" . $this->aTemplates['hr'] . ""; } } $sTROdd = isset($this->aParams['rowcolor1']) ? "" : ""; $sTREven = isset($this->aParams['rowcolor2']) ? "" : ""; $this->sRecords = "
"; $iRowNum = 1; debug("

+ Creating / formatting HTML recordset table and populating [RECORD] template with [DATA]..."); // [BUG FIX] guard against a zero/blank 'cols' causing division by zero. $iCols = (int) $this->aParams['cols']; if ($iCols < 1) { $iCols = 1; } $iColWidthPct = 100 / $iCols; if (count($this->aData) == 0 || !isset($this->aData) || $this->aData == null) { return; } if (isset($this->aParams['raw'])) { $this->sRecords = $this->buildRawTable(); return; } $iColNum = 0; $iRecord = 0; $aColNames = array_keys($this->aData[0]); foreach ($this->aData as $row) { $iColNum++; if ($iColNum == 1) { if ($iRowNum > 1) { $this->sRecords .= $sHR; } if ($iRowNum % 2) { $this->sRecords .= $sTREven; } else { $this->sRecords .= $sTROdd; } } $sRecord = $this->aTemplates['record']; $iCol = 0; foreach ($row as $cell) { $sRecord = str_replace("%" . $aColNames[$iCol] . "%", stripslashes($cell), $sRecord); $iCol++; } $sRecord = str_replace("#recordnum#", $this->aRecordset['start'] + $iRecord + 1, $sRecord); $sRecord = str_replace("#rownum#", $iRowNum, $sRecord); $sRecord = str_replace("#colnum#", $iColNum, $sRecord); $this->sRecords .= "

"; if ($iColNum == $iCols) { $this->sRecords .= ""; $iColNum = 0; $iRowNum++; } $iRecord++; } $this->aRecordset['return'] = count($this->aData); debug("DONE

"); $this->sRecords .= "

" . $sRecord . "
"; } private function buildRawTable() { $sTable = ""; foreach (array_keys($this->aData[0]) as $header) { $sTable .= ""; } $sTable .= ""; foreach ($this->aData as $row) { $sTable .= ""; foreach ($row as $cell) { $sTable .= ""; } $sTable .= ""; } $sTable .= "
" . htmlspecialchars($header) . "
" . htmlspecialchars($cell) . "
"; return $sTable; } // ======================================================================== // STAGE: final template assembly + token substitution + output // ======================================================================== private function finalizeOutput() { if (isset($this->aData) && is_array($this->aData) && count($this->aData) == 0) { debug("

+ Filtered [DATA] set contains ZERO records

"); $this->sRecords = "
"; } elseif (isset($this->aData) && !is_array($this->aData)) { debug("

+ Plugin returned ZERO records

"); $this->sRecords = "
No records returned
"; } elseif (!isset($this->aData)) { // static page creation via plugin=null $this->sRecords = ""; } debug("

+ Combining templates; [RECORD]set=>[TABLE]=>[BODY]=>[PAGE]<=NAV

"); $this->sPage = str_replace('$nest$', $this->aTemplates['body'], $this->aTemplates['page']); $this->sPage = str_replace('$nest$', $this->aTemplates['table'], $this->sPage); $this->sPage = str_replace('$nest$', $this->sRecords, $this->sPage); $this->sPage = str_replace('$nav$', $this->aTemplates['nav'], $this->sPage); $this->applyPagesIncludes(); if (isset($this->aData) && is_array($this->aData)) { debug('

+ Applying S&R on recordset entries (start, last, slicenext, sliceprevious, slicefirst, slicecount, etc)

'); foreach ($this->aRecordset as $key => $value) { $this->sPage = str_replace("#" . $key . "#", stripslashes($value), $this->sPage); } } debug('

+ Applying S&R on tokenized entries (parameter, _SERVER, keyword, and script)...'); // [SECURITY FIX] escape reflected request/param and $_SERVER values // before they land in the HTML response. [DATA] record cells above // are intentionally left unescaped -- plugins/templates in this // system are designed to emit trusted rich HTML at the record // level, matching 4.16 behavior for %column% tokens. foreach ($this->aParams as $key => $value) { $this->sPage = str_replace("!" . $key . "!", htmlspecialchars(stripslashes((string) $value)), $this->sPage); } foreach ($_SERVER as $key => $value) { if (!is_array($value)) { $this->sPage = str_replace("$" . $key . "$", htmlspecialchars(stripslashes((string) $value)), $this->sPage); } } foreach ($this->aKeywords as $key => $value) { $this->sPage = str_replace($key, $value, $this->sPage); } foreach (date_array() as $key => $value) { $this->sPage = str_replace(":" . $key . ":", $value, $this->sPage); } $aA = explode(' ', $this->aScript['timer:overall'] . ' ' . microtime()); $this->aScript['timer:overall'] = sprintf('%01.4f', ($aA[2] + $aA[3]) - ($aA[0] + $aA[1])); $this->aScript['timer:cats'] = $this->aScript['timer:overall'] - $this->aScript['timer:plugin'] - $this->aScript['timer:templates']; foreach ($this->aScript as $key => $value) { $this->sPage = str_replace("*" . $key . "*", stripslashes((string) $value), $this->sPage); } $this->sPage = str_replace("*pagesize*", number_format(strlen($this->sPage) / 1024), $this->sPage); print($this->sPage); exit; } private function applyPagesIncludes() { if (count($this->aPages) >= 1) { debug("

+ Retrieving and nesting user defined cats.ini [PAGES] into final PAGE var

"); } } // ======================================================================== // Search / sort (logic unchanged from 4.16, moved into the class as-is) // ======================================================================== private function searchAndSort(&$aData, $sSearch = null, $sIn = null, $sOrder = null, $sSort = null) { debug("

+ search_and_sort()

"); $iTimer = microtime(); debug("

+ [PARAM] sSearch = '" . htmlspecialchars($sSearch) . "'

"); debug("

+ [PARAM] sIn = '" . htmlspecialchars($sIn) . "'

"); debug("

+ [PARAM] sOrder = '" . htmlspecialchars($sOrder) . "'

"); debug("

+ [PARAM] sSort = '" . htmlspecialchars($sSort) . "'

"); $dataValid = true; if (!is_array($aData) || empty($aData)) { debug("

+ [DATA] is not an array or is empty; operation skipped

"); $dataValid = false; $aFiltered = array(); } else { $first = reset($aData); if (!is_array($first) || empty($first)) { debug("

+ [DATA] is not an associative array; operation skipped

"); $dataValid = false; $aFiltered = array(); } } $sort = trim($sSort); $sort = strtolower($sort); $sort = preg_replace('/\s+/u', '', $sort); $asc = array("a", "asc", "ascend", "ascending"); $desc = array("d", "desc", "descend", "descending"); $rand = array("r", "rand", "random"); $isRandom = in_array($sort, $rand); $isAsc = in_array($sort, $asc); $isDesc = in_array($sort, $desc); $doSort = ($sort !== "" && $sort !== null); debug("

+ [SORT] normalized sort = '$sort'

"); debug("

+ [SORT] isAsc = " . ($isAsc ? "TRUE" : "FALSE") . "

"); debug("

+ [SORT] isDesc = " . ($isDesc ? "TRUE" : "FALSE") . "

"); debug("

+ [SORT] isRandom = " . ($isRandom ? "TRUE" : "FALSE") . "

"); debug("

+ [SORT] doSort = " . ($doSort ? "TRUE" : "FALSE") . "

"); $keys = null; if ($sIn !== null && trim($sIn) !== "") { $keys = array_map("trim", explode(",", $sIn)); debug("

+ [SEARCH] limiting search to keys: " . implode(", ", $keys) . "

"); } else { debug("

+ [SEARCH] searching all keys

"); } $filters = array(); if ($sSearch !== null && trim($sSearch) !== "") { preg_match_all('/"[^"]*"|\S+/', $sSearch, $matches); foreach ($matches[0] as $t) { $filters[] = trim($t); } debug("

+ [SEARCH] parsed filters: " . implode(", ", $filters) . "

"); } else { debug("

+ [SEARCH] no filters provided

"); } if ($dataValid) { if (empty($filters)) { debug("

+ [SEARCH] no filters -> skipping search and returning all records

"); $aFiltered = $aData; } else { $aFiltered = array(); foreach ($aData as $row) { $rowMatches = false; foreach ($filters as $f) { $searchKeys = ($keys === null) ? array_keys($row) : $keys; $found = false; foreach ($searchKeys as $k) { if (!isset($row[$k])) continue; $val = $row[$k]; if (preg_match('/^"(.*)"$/', $f, $m)) { if ($val == $m[1]) { $found = true; } continue; } if ($f[0] === "^") { $needle = substr($f, 1); if (strpos($val, $needle) !== false) { $found = true; } continue; } if ($f[0] === "+") { $needle = substr($f, 1); if (stripos($val, $needle) !== false) { $found = true; } continue; } if ($f[0] === "-") { $needle = substr($f, 1); if (stripos($val, $needle) === false) { $found = true; } continue; } if (preg_match('/^(>=|<=|>|<)(.*)$/', $f, $m)) { $op = $m[1]; $num = floatval($m[2]); $v = floatval($val); switch ($op) { case ">": if ($v > $num) $found = true; break; case "<": if ($v < $num) $found = true; break; case ">=": if ($v >= $num) $found = true; break; case "<=": if ($v <= $num) $found = true; break; } continue; } $val = (string) $val; if (stripos($val, $f) !== false) { $found = true; } } if ($found) { $rowMatches = true; break; } } if ($rowMatches) { $aFiltered[] = $row; } } debug("

+ [SEARCH] results after filtering: " . count($aFiltered) . " rows

"); } } if ($sOrder !== null && trim($sOrder) !== "" && $dataValid) { $orderKeys = array_map("trim", explode(",", $sOrder)); $first = reset($aFiltered); $validKeys = is_array($first) ? array_keys($first) : array(); $orderKeys = array_filter($orderKeys, function ($k) use ($validKeys) { return in_array($k, $validKeys); }); debug("

+ [ORDER] valid sort keys: " . implode(", ", $orderKeys) . "

"); if (empty($orderKeys)) { debug("

+ [ORDER] contains no valid keys; sorting skipped

"); $doSort = false; } } if ($dataValid && $doSort && $sOrder !== null && trim($sOrder) !== "") { debug("

+ [SORT] sorting results

"); usort($aFiltered, function ($a, $b) use ($orderKeys, $isAsc, $isDesc) { foreach ($orderKeys as $key) { $va = isset($a[$key]) ? $a[$key] : ""; $vb = isset($b[$key]) ? $b[$key] : ""; $na = is_numeric($va); $nb = is_numeric($vb); if ($na && $nb) { $va = floatval($va); $vb = floatval($vb); if ($va != $vb) { return $isAsc ? ($va - $vb) : ($vb - $va); } } else { if ($va != $vb) { return $isAsc ? strcmp($va, $vb) : strcmp($vb, $va); } } } return 0; }); } if ($isRandom) { debug("

+ [SORT] randomizing results

"); shuffle($aFiltered); } $aData = $aFiltered; $aA = explode(' ', $iTimer . ' ' . microtime()); debug("

+ search_and_sort():time => " . sprintf('%01.4f', ($aA[2] + $aA[3]) - ($aA[0] + $aA[1])) . "/s

"); } } // ============================================================================ // Global helper functions // These have no dependency on engine state, so they stay outside the class. // ============================================================================ function debug($data) { // [SECURITY] Both the server-side flag AND ?debug= are now required. if (!CATS_DEBUG_ENABLED || !isset($_REQUEST['debug'])) { return; } if (!is_array($data)) { print($data); } else { if (count($data) > 0) { $sTable = ""; foreach (array_keys($data[0]) as $header) { $sTable .= ""; } $sTable .= ""; foreach ($data as $row) { $sTable .= ""; foreach ($row as $cell) { $sTable .= ""; } $sTable .= ""; } $sTable .= "
" . htmlspecialchars($header) . "
" . htmlspecialchars($cell) . "
"; print($sTable); } } } function ini_parse($filename) { // Parsed by hand because PHP's built-in parse_ini_file() chokes on // keys with special chars like "$" or values containing "=". // [Foo] // $bar$=this=that <--- errors out on this with parse_ini_file() debug("
  • " . $filename); $array = array(); if ($fp = fopen($filename, 'r')) { debug(" FOUND."); $section = null; while (!feof($fp)) { $line = trim(fgets($fp, 9216)); if (substr($line, 0, 1) == "[") { $section = substr($line, 1, strlen($line) - 2); } elseif (substr($line, 0, 1) == ";") { // comment line, skip } elseif (strlen(trim($line)) == 0) { // blank line, skip } else { $pos = strpos($line, "="); if ($pos !== false && $section !== null) { $key = substr($line, 0, $pos); $value = substr($line, $pos + 1, strlen($line) - $pos - 1); $array[$section][$key] = $value; } } } fclose($fp); } else { debug(" NOT FOUND
  • "); return false; } return $array; } function date_array() { // used for inserting current date/time values into page $aTime = array(); $aTime["sec"] = date("s"); $aTime["min"] = date("i"); $aTime["hour_24"] = date("G"); $aTime["hour_0_24"] = date("H"); $aTime["hour_12"] = date("g"); $aTime["hour_0_12"] = date("h"); $aTime["mdayth"] = date("jS"); $aTime["mdayth_0"] = date("d") . date("S"); $aTime["mday"] = date("j"); $aTime["mday_0"] = date("d"); $aTime["month"] = date("F"); $aTime["mon"] = date("M"); $aTime["mnum_0"] = date("m"); $aTime["mnum"] = date("n"); $aTime["year"] = date("Y"); $aTime["yr"] = date("y"); $aTime["weekday"] = date("l"); $aTime["wkday"] = date("D"); $aTime["timezone"] = date("T"); $aTime["daysinmonth"] = date("t"); $aTime["epochsecs"] = date("U"); if (date('G') >= 0 && date("G") < 12) { $aTime["timeofday"] = "morning"; } elseif (date("G") >= 12 && date("G") < 17) { $aTime["timeofday"] = "afternoon"; } else { $aTime["timeofday"] = "evening"; } $aTime["yday"] = date("z") + 1; $aTime["isdst"] = (date("I") == 1) ? "Daylight Savings Time" : ""; $aTime["am_pm"] = strtolower(date("A")); $aTime["AM_PM"] = date("A"); $aTime["last_month"] = date("F", mktime(0, 0, 0, date("m") - 1, date("d"), date("Y"))); $aTime["last_mon"] = date("M", mktime(0, 0, 0, date("m") - 1, date("d"), date("Y"))); $aTime["last_mnum"] = date("n") - 1; $aTime["last_mnum_0"] = date("m", mktime(0, 0, 0, date("n"), 0, 0)); $aTime['last_year'] = date("Y") - 1; $aTime['last_yr'] = date("y", mktime(0, 0, 0, date("m"), date("d"), date("Y") - 1)); if ($aTime["mnum"] == 1) { $aTime['last_month_year'] = date("Y") - 1; $aTime['last_month_yr'] = date("y", mktime(0, 0, 0, date("m"), date("d"), date("Y"))); } else { $aTime['last_month_year'] = date("Y"); $aTime['last_month_yr'] = date("y"); } $aTime["next_month"] = date("F", mktime(0, 0, 0, date("m") + 1, date("d"), date("Y"))); $aTime["next_mon"] = date("M", mktime(0, 0, 0, date("m") + 1, date("d"), date("Y"))); $aTime["next_mnum"] = date("n", mktime(0, 0, 0, date("m") + 1, date("d"), date("Y"))); $aTime["next_mnum_0"] = date("m", mktime(0, 0, 0, date("m") + 1, date("d"), date("Y"))); $aTime['next_year'] = date("Y") + 1; $aTime['next_yr'] = date("y", mktime(0, 0, 0, date("m"), date("d"), date("Y") + 1)); if ($aTime["mnum"] == "12") { $aTime['next_month_year'] = date("Y") + 1; $aTime['next_month_yr'] = date("y", mktime(0, 0, 0, date("m") + 1, date("d"), date("Y"))); } else { $aTime['next_month_year'] = date("Y"); $aTime['next_month_yr'] = date("y"); } return $aTime; } function query_string_mod($query_string, $argument, $value) { // used for modifying the value of a key=>value pair in the URL $stripped_query_string = stripslashes($query_string); if (strpos($stripped_query_string, "?" . $argument) === false && strpos($stripped_query_string, "&" . $argument) === false) { if (strpos($stripped_query_string, "?") === false) { return $stripped_query_string . "?" . $argument . "=" . $value; } else { return $stripped_query_string . "&" . $argument . "=" . $value; } } else { $argument_length = strlen($argument); $pre_end = strpos($stripped_query_string, $argument); $pre = substr($stripped_query_string, 0, $pre_end); if (strpos($stripped_query_string, "&", $pre_end) === false) { return $pre . $argument . "=" . $value; } else { $post = substr($stripped_query_string, $pre_end + (strpos($stripped_query_string, "&", $pre_end) - $pre_end)); return $pre . $argument . "=" . $value . $post; } } } // ============================================================================ // Bootstrap // ============================================================================ $oCats = new CatsEngine(); $oCats->run();