, 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
+ List of available parameters and their script set defaults (null'ed items unless specified get UNSET()):
+ Importing '" . substr(basename(__FILE__), 0, -4) . ".ini' key => value pairs from all directories starting in [DOCUMENT_ROOT] thru " . $this->aParams['dir'] . "...
+ 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)
+ Current [PAGES] for this page (CATS.INI / [pages])
+ Current [KEYWORDS] for this page (CATS.INI / [keywords])
+ Retrieving templates...
+ [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 "
+ 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 = "| " . $sRecord . " | "; if ($iColNum == $iCols) { $this->sRecords .= ""; $iColNum = 0; $iRowNum++; } $iRecord++; } $this->aRecordset['return'] = count($this->aData); debug("DONE
| " . htmlspecialchars($header) . " | "; } $sTable .= "
|---|
| " . htmlspecialchars($cell) . " | "; } $sTable .= "
+ Filtered [DATA] set contains ZERO records
"); $this->sRecords = "
+ Plugin returned ZERO records
"); $this->sRecords = "+ 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_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 = "| " . htmlspecialchars($header) . " | "; } $sTable .= "
|---|
| " . htmlspecialchars($cell) . " | "; } $sTable .= "