diff --git a/includes/ajaxHandler/ajaxHandler.class.php b/includes/ajaxHandler/ajaxHandler.class.php
deleted file mode 100644
index 5094e92f..00000000
--- a/includes/ajaxHandler/ajaxHandler.class.php
+++ /dev/null
@@ -1,71 +0,0 @@
-params = $params;
-
- $this->initRequestData();
- }
-
- public function handle(string &$out) : bool
- {
- if (!$this->handler)
- return false;
-
- if ($this->validParams)
- {
- if (count($this->params) != 1)
- return false;
-
- if (!in_array($this->params[0], $this->validParams))
- return false;
- }
-
- $out = $this->{$this->handler}() ?? '';
-
- return true;
- }
-
- public function getContentType() : string
- {
- return $this->contentType;
- }
-
- protected function reqPOST(string ...$keys) : bool
- {
- foreach ($keys as $k)
- if (!isset($this->_post[$k]) || $this->_post[$k] === null || $this->_post[$k] === '')
- return false;
-
- return true;
- }
-
- protected function reqGET(string ...$keys) : bool
- {
- foreach ($keys as $k)
- if (!isset($this->_get[$k]) || $this->_get[$k] === null || $this->_get[$k] === '')
- return false;
-
- return true;
- }
-}
-
-?>
diff --git a/includes/components/dbtypelist.class.php b/includes/components/dbtypelist.class.php
index 8286f114..8f307f40 100644
--- a/includes/components/dbtypelist.class.php
+++ b/includes/components/dbtypelist.class.php
@@ -632,10 +632,7 @@ trait spawnHelper
$wpSum = [];
$wpIdx = 0;
$worldPos = [];
- $spawns = DB::Aowow()->select("SELECT * FROM ?_spawns WHERE `type` = ?d AND `typeId` IN (?a) AND `posX` > 0 AND `posY` > 0", self::$type, $this->getFoundIDs());
-
- if (!$spawns)
- return;
+ $spawns = DB::Aowow()->select("SELECT * FROM ?_spawns WHERE `type` = ?d AND `typeId` IN (?a) AND `posX` > 0 AND `posY` > 0", self::$type, $this->getFoundIDs()) ?: [];
if (!$skipAdmin && User::isInGroup(U_GROUP_MODERATOR))
if ($guids = array_column(array_filter($spawns, fn($x) => $x['guid'] > 0 || $x['type'] != Type::NPC), 'guid'))
diff --git a/includes/components/frontend/announcement.class.php b/includes/components/frontend/announcement.class.php
index 8823428f..16382009 100644
--- a/includes/components/frontend/announcement.class.php
+++ b/includes/components/frontend/announcement.class.php
@@ -43,7 +43,7 @@ class Announcement implements \JsonSerializable
public function jsonSerialize() : array
{
$json = array(
- 'parent' => 'announcement-' . abs($this->id),
+ 'parent' => 'announcement-' . $this->id,
'id' => $this->editable ? -$this->id : $this->id,
'mode' => $this->mode,
'status' => $this->status,
diff --git a/includes/components/pagetemplate.class.php b/includes/components/pagetemplate.class.php
new file mode 100644
index 00000000..c53c58b5
--- /dev/null
+++ b/includes/components/pagetemplate.class.php
@@ -0,0 +1,559 @@
+locale = Lang::getLocale();
+ $this->gStaticUrl = Cfg::get('STATIC_URL');
+ $this->gHost = Cfg::get('HOST_URL');
+ $this->analyticsTag = Cfg::get('GTAG_MEASUREMENT_ID');
+ $this->gServerTime = sprintf("new Date('%s')", date(Util::$dateFormatInternal));
+ $this->user = User::class;
+ }
+
+ public function addDataLoader(string ...$dataFile) : void
+ {
+ foreach ($dataFile as $df)
+ $this->dataLoader[] = $df;
+ }
+
+ public function addScript(int $type, string $str, int $flags = 0x0) : bool
+ {
+ $tpl = match ($type)
+ {
+ SC_CSS_FILE => '',
+ SC_CSS_STRING => '',
+ SC_JS_FILE => '',
+ SC_JS_STRING => '',
+ default => ''
+ };
+
+ if (!$tpl || !$str)
+
+ if (!$str)
+ {
+ trigger_error('PageTemplate::addScript - content empty', E_USER_WARNING);
+ return false;
+ }
+
+ if (!$tpl)
+ {
+ trigger_error('PageTemplate::addScript - unknown script type #'.$type, E_USER_WARNING);
+ return false;
+ }
+
+ // insert locale string
+ if ($flags & SC_FLAG_LOCALIZED)
+ $str = sprintf($str, Lang::getLocale()->json());
+
+ $this->scripts[] = [$type, $str, $flags, $tpl];
+ return true;
+ }
+
+ /* (optional) set pre-render hooks */
+
+ public function registerDisplayHook(string $var, callable $fn) : void
+ {
+ $this->displayHooks[$var][] = $fn;
+ }
+
+ private function getDisplayHooks(string $var) : array
+ {
+ return $this->displayHooks[$var] ?? [];
+ }
+
+ /* 3) self test, ready to be cached now */
+
+ public function prepare() : bool
+ {
+ if (!self::test('template/pages/', $this->template))
+ {
+ trigger_error('Error: nonexistent template requested: template/pages/'.$this->template.'.tpl.php', E_USER_ERROR);
+ return false;
+ }
+
+ // TODO - more checks and preparations
+
+ return true;
+ }
+
+ /* 4) display */
+
+ public function render() : void
+ {
+ $this->update();
+
+ include('template/pages/'.$this->template.'.tpl.php');
+ }
+
+
+ /***********/
+ /* loaders */
+ /***********/
+
+ // "template_exists"
+ public static function test(string $path, string $file) : bool
+ {
+ if (!preg_match('/^[\w\-_]+(\.tpl(\.php)?)?$/i', $file))
+ return false;
+
+ if ($path && preg_match('/\\{2,}|\/{2,}|\.{2,}|~/i', $path))
+ return false;
+
+ if (!is_file('template/'.$path.$file))
+ return false;
+
+ return true;
+ }
+
+ // load brick
+ private function brick(string $file, array $localVars = []) : void
+ {
+ $file .= '.tpl.php';
+
+ if (!self::test('bricks/', $file))
+ {
+ trigger_error('Nonexistent template requested: template/bricks/'.$file, E_USER_ERROR);
+ return;
+ }
+
+ foreach ($localVars as $n => $v)
+ $$n = $v;
+
+ include('template/bricks/'.$file);
+ }
+
+ private function brickIf(mixed $boolish, string $file, array $localVars = []) : void
+ {
+ if ($boolish)
+ $this->brick($file, $localVars);
+ }
+
+ // load brick with more text then vars
+ private function localizedBrick(string $file, array $localVars = []) : void
+ {
+ foreach ($localVars as $n => $v)
+ $$n = $v;
+
+ $_file = $file.'_'.$this->locale->value.'.tpl.php';
+ if (self::test('localized/', $_file))
+ {
+ include('template/localized/'.$_file);
+ return;
+ }
+
+ $_file = $file.'_'.$this->locale->getFallback()->value.'.tpl.php';
+ if (self::test('localized/', $_file))
+ {
+ include('template/localized/'.$_file);
+ return;
+ }
+
+ trigger_error('Nonexistent template requested: template/localized/'.$_file, E_USER_ERROR);
+ }
+
+ private function localizedBrickIf(mixed $boolish, string $file, array $localVars = []) : void
+ {
+ if ($boolish)
+ $this->localizedBrick($file, $localVars);
+ }
+
+
+ /****************/
+ /* Util wrapper */
+ /****************/
+
+ private function cfg(string $name) : mixed
+ {
+ return Cfg::get($name);
+ }
+
+ private function json(mixed $var, int $jsonFlags = 0x0) : string
+ {
+ if (is_string($var) && $this->$var)
+ $var = $this->$var;
+
+ return preg_replace('/script\s*\>/i', 'scr"+"ipt>', Util::toJSON($var, $jsonFlags));
+ }
+
+ private function escHTML(string $var) : string|array
+ {
+ return Util::htmlEscape($this->$var ?? $var);
+ }
+
+ private function escJS(string $var) : string|array
+ {
+ return Util::jsEscape($this->$var ?? $var);
+ }
+
+ private function ucFirst(string $var) : string
+ {
+ return Util::ucFirst($this->$var ?? $var);
+ }
+
+
+ /*****************/
+ /* render helper */
+ /*****************/
+
+ private function concat(string $arrVar, string $separator = '') : string
+ {
+ if (!is_array($this->$arrVar))
+ return '';
+
+ return implode($separator, $this->$arrVar);
+ }
+
+ private function renderArray(string|array $arrVar, int $lpad = 0) : string
+ {
+ $data = [];
+ if (is_string($arrVar) && isset($this->$arrVar) && is_array($this->$arrVar))
+ $data = $this->$arrVar;
+ else if (is_array($arrVar))
+ $data = $arrVar;
+
+ $buff = '';
+ foreach ($data as $x)
+ $buff .= str_repeat(' ', $lpad) . $x . "\n";
+
+ return $buff;
+ }
+
+ // load jsGlobals
+ private function renderGlobalVars(int $lpad = 0) : string
+ {
+ $buff = '';
+
+ if ($this->guideRating)
+ $buff .= str_repeat(' ', $lpad).sprintf(self::GUIDE_RATING_TPL, ...$this->guideRating);
+
+ foreach ($this->jsGlobals as [$jsVar, $data, $extraData])
+ {
+ $buff .= str_repeat(' ', $lpad).'var _ = '.$jsVar.';';
+
+ foreach ($data as $key => $data)
+ $buff .= ' _['.(is_numeric($key) ? $key : "'".$key."'")."]=".Util::toJSON($data).';';
+
+ $buff .= "\n";
+
+ if (isset($this->gPageInfo['type']) && isset($this->gPageInfo['typeId']) && isset($extraData[$this->gPageInfo['typeId']]))
+ {
+ $buff .= "\n";
+ foreach ($extraData[$this->gPageInfo['typeId']] as $k => $v)
+ if ($v)
+ $buff .= str_repeat(' ', $lpad).'_['.$this->gPageInfo['typeId'].'].'.$k.' = '.Util::toJSON($v).";\n";
+ $buff .= "\n";
+ }
+ }
+
+ return $buff;
+ }
+
+ private function renderSeriesItem(int $idx, array $list, int $lpad = 0) : string
+ {
+ $result = '
| '.($idx + 1).' | ';
+
+ $end = array_key_last($list);
+ foreach ($list as $k => $i) // itemItr
+ {
+ $wrap = match ($i['side'])
+ {
+ SIDE_ALLIANCE => ' %s',
+ SIDE_HORDE => ' %s',
+ default => '%s'
+ };
+
+ if ($i['typeId'] == $this->typeId)
+ $result .= sprintf($wrap, ' '.$i['name'].'');
+ else
+ $result .= sprintf($wrap, ' '.$i['name'].'');
+
+ if ($end != $k)
+ $result .= ' ';
+
+ }
+
+ return str_repeat(' ', $lpad) . $result . " |
\n";
+ }
+
+ private function renderFilter(int $lpad = 0) : string
+ {
+ $result = [];
+
+ // it's worth noting, that this only works on non-cached page calls. Luckily Profiler pages are not cached.
+ if ($this->context instanceof \Aowow\IProfilerList)
+ {
+ $result[] = "pr_setRegionRealm(\$WH.ge('fi').firstChild, '".$this->region."', '".$this->realm."');";
+
+ if ($this->filter->values['ra'])
+ $result[] = "pr_onChangeRace();";
+ }
+
+ if ($this->filter->fiInit) // str: filter template (and init html form)
+ $result[] = "fi_init('".$this->filter->fiInit."');";
+ else if ($this->filter->fiType) // str: filter template (set without init)
+ $result[] = "var fi_type = '".$this->filter->fiType."'";
+
+ if ($this->filter->fiSetCriteria) // arr:criteria, arr:signs, arr:values
+ $result[] = 'fi_setCriteria('.mb_substr(Util::toJSON(array_values($this->filter->fiSetCriteria)), 1, -1).");";
+
+ /*
+ nt: don't try to match provided weights on predefined weight sets (preselects preset from opt list and ..?)
+ ids: weights are encoded as ids, not by their js name and need conversion before use
+ stealth: the ub-selector (items filter) will not visually change (so what..?)
+ */
+ if ($this->filter->fiSetWeights) // arr:weights, bool:nt[0], bool:ids[1], bool:stealth[1]
+ $result[] = 'fi_setWeights('.Util::toJSON(array_values($this->filter->fiSetWeights)).', 0, 1, 1);';
+
+ if ($this->filter->fiExtraCols) // arr:extraCols
+ $result[] = 'fi_extraCols = '.Util::toJSON(array_values(array_unique($this->filter->fiExtraCols))).";";
+
+ return str_repeat(' ', $lpad)."\n";
+ }
+
+ private function makeOptionsList(array $data, mixed $selectedIdx = null, int $lpad = 0, ?callable $callback = null) : string
+ {
+ $callback ??= fn(&$v, &$k) => $v; // default callback: skip empty descriptors
+ $options = '';
+
+ foreach ($data as $idx => $str)
+ {
+ $extraAttributes = [];
+ if (!$callback($str, $idx, $extraAttributes))
+ continue;
+
+ if ($idx === '' || !$str)
+ continue;
+
+ $options .= str_repeat(' ', max(0, $lpad)).''.($lpad < 0 ? '' : "\n");
+ }
+
+ return $options;
+ }
+
+ private function makeRadiosList(string $name, array $data, mixed $selectedIdx = null, int $lpad = 0, ?callable $callback = null) : string
+ {
+ $callback ??= fn(&$v, &$k) => $v; // default callback: skip empty descriptors
+ $options = '';
+
+ foreach ($data as $idx => [$title, $id])
+ {
+ $extraAttributes = [];
+ if (!$callback($title, $idx, $extraAttributes))
+ continue;
+
+ if ($id === '' || !$title)
+ continue;
+
+ $options .= str_repeat(' ', max(0, $lpad)).' $v)
+ $options .= ' '.$k.'="'.$v.'"';
+
+ $options .= '>'.$title.''.($lpad < 0 ? '' : "\n");
+ }
+
+ return $options;
+ }
+
+ // unordered stuff
+
+ private function prepareScripts() : void
+ {
+ $this->js = $this->css = [];
+
+ foreach ($this->scripts as [$type, $str, $flags, $tpl])
+ {
+ $app = [];
+
+ if (($flags & SC_FLAG_APPEND_LOCALE) && $this->locale != \Aowow\Locale::EN)
+ $app[] = 'lang='.$this->locale->domain();
+
+ // append anti-cache timestamp
+ if (!($flags & SC_FLAG_NO_TIMESTAMP))
+ if ($type == SC_JS_FILE || $type == SC_CSS_FILE)
+ $app[] = filemtime('static/'.$str) ?: 0;
+
+ if ($app)
+ $appendix = '?'.implode('&', $app);
+
+ if ($type == SC_JS_FILE || $type == SC_CSS_FILE)
+ $str = Cfg::get('STATIC_URL').'/'.$str;
+
+ if ($flags & SC_FLAG_PREFIX)
+ {
+ if ($type == SC_JS_FILE || $type == SC_JS_STRING)
+ array_unshift($this->js, sprintf($tpl, $str, $appendix ?? ''));
+ else
+ array_unshift($this->css, sprintf($tpl, $str, $appendix ?? ''));
+ }
+ else
+ {
+ if ($type == SC_JS_FILE || $type == SC_JS_STRING)
+ array_push($this->js, sprintf($tpl, $str, $appendix ?? ''));
+ else
+ array_push($this->css, sprintf($tpl, $str, $appendix ?? ''));
+ }
+ }
+
+ if ($data = array_unique($this->dataLoader))
+ {
+ $args = array(
+ 'data' => implode('.', $data),
+ 'locale' => $this->locale->value,
+ 't' => $_SESSION['dataKey']
+ );
+
+ array_push($this->js, '');
+ }
+ }
+
+ // refresh vars that shouldn't be cached
+ private function update() : void
+ {
+ // analytics + consent
+ if (!isset($_COOKIE['consent']))
+ {
+ $this->addScript(SC_CSS_FILE, 'css/consent.css');
+ $this->addScript(SC_JS_FILE, 'js/consent.js');
+
+ $this->consentFooter = true;
+ $this->analyticsTag = null;
+ }
+ else if ($this->analyticsTag && !$_COOKIE['consent'])
+ $this->analyticsTag = null;
+
+ // js + css
+ $this->prepareScripts();
+
+ // db profiling
+ if (Cfg::get('DEBUG') >= LOG_LEVEL_INFO && User::isInGroup(U_GROUP_DEV | U_GROUP_ADMIN))
+ $this->dbProfiles = \Aowow\DB::getProfiles();
+ }
+
+ public function setListviewError() : void
+ {
+ if (!$this->lvTabs)
+ return;
+
+ foreach ($this->lvTabs->iterate() as $lv)
+ if ($lv instanceof \Aowow\Listview)
+ $lv->setError();
+ }
+
+ // pre-serialization: if a var is relevant it was stored in $rawData
+ public function __sleep() : array
+ {
+ $this->context = null; // unlink from TemplateResponse
+ $this->pageData = []; // clear modified data
+
+ $vars = [];
+ foreach ($this as $k => $_)
+ $vars[] = $k;
+
+ return $vars;
+ }
+
+ public function __wakeup() : void
+ {
+ $this->gStaticUrl = Cfg::get('STATIC_URL');
+ $this->gHost = Cfg::get('HOST_URL');
+ $this->analyticsTag = Cfg::get('GTAG_MEASUREMENT_ID');
+ $this->gServerTime = sprintf("new Date('%s')", date(Util::$dateFormatInternal));
+ }
+
+ public function __set(string $var, mixed $value) : void
+ {
+ $this->pageData[$var] = $value;
+ }
+
+ public function __get(string $var) : mixed
+ {
+ // modified data exists
+ if (isset($this->pageData[$var]))
+ return $this->pageData[$var];
+
+ if (!isset($this->rawData[$var]))
+ {
+ if (!$this->context)
+ return null;
+
+ if (!property_exists($this->context, $var))
+ return null;
+
+ $this->rawData[$var] = $this->context->$var;
+ }
+
+ if ($hooks = $this->getDisplayHooks($var))
+ {
+ if (is_object($this->rawData[$var])) // is frontend component
+ $this->pageData[$var] = clone $this->rawData[$var];
+ else
+ $this->pageData[$var] = $this->rawData[$var];
+
+ foreach ($hooks as $fn)
+ $fn($this, $this->pageData[$var]);
+ }
+
+ return $this->pageData[$var] ?? $this->rawData[$var];
+ }
+}
diff --git a/includes/components/response/baseresponse.class.php b/includes/components/response/baseresponse.class.php
new file mode 100644
index 00000000..2be3beb7
--- /dev/null
+++ b/includes/components/response/baseresponse.class.php
@@ -0,0 +1,657 @@
+ ACC_STATUS_CHANGE_PASS)
+ return Lang::main('intError');
+
+ // check if already processing
+ if ($_ = DB::Aowow()->selectCell('SELECT `statusTimer` - UNIX_TIMESTAMP() FROM ?_account WHERE `email` = ? AND `status` > ?d AND `statusTimer` > UNIX_TIMESTAMP()', $email, ACC_STATUS_NEW))
+ return sprintf(Lang::account('isRecovering'), Util::formatTime($_ * 1000));
+
+ // create new token and write to db
+ $token = Util::createHash();
+ if (!DB::Aowow()->query('UPDATE ?_account SET `token` = ?, `status` = ?d, `statusTimer` = UNIX_TIMESTAMP() + ?d WHERE `email` = ?', $token, $newStatus, Cfg::get('ACC_RECOVERY_DECAY'), $email))
+ return Lang::main('intError');
+
+ // send recovery mail
+ if (!Util::sendMail($email, $mailTemplate, [$token], Cfg::get('ACC_RECOVERY_DECAY')))
+ return sprintf(Lang::main('intError2'), 'send mail');
+
+ return '';
+ }
+}
+
+trait TrGetNext
+{
+ private function getNext(bool $forHeader = false) : string
+ {
+ $next = '';
+ if (!empty($this->_get['next']))
+ $next = $this->_get['next'];
+ else if (isset($_SERVER['HTTP_REFERER']) && strstr($_SERVER['HTTP_REFERER'], '?'))
+ $next = explode('?', $_SERVER['HTTP_REFERER'])[1];
+ else if ($forHeader)
+ return '.';
+
+ return ($forHeader ? '?' : '').$next;
+ }
+}
+
+
+Interface ICache
+{
+ public function saveCache(string|Template\PageTemplate $toCache) : void;
+ public function loadCache(bool|string|Template\PageTemplate &$fromCache) : bool;
+ public function setOnCacheLoaded(callable $callback, mixed $params = null) : void;
+ public function getCacheKeyComponents() : array;
+ public function applyOnCacheLoaded(mixed &$data) : mixed;
+}
+
+trait TrCache
+{
+ private const STORE_METHOD_OBJECT = 0;
+ private const STORE_METHOD_STRING = 1;
+
+ private int $_cacheType = CACHE_TYPE_NONE;
+ private int $skipCache = 0x0;
+ private ?int $decay = null;
+ private string $cacheDir = 'cache/template/';
+ private bool $cacheInited = false;
+ private ?\Memcached $memcached = null;
+ private array $onCacheLoaded = [null, null]; // post-load updater
+
+ public static array $cacheStats = []; // load info for page footer
+
+ // visible properties or given strings are cached
+ public function saveCache(string|object $toCache) : void
+ {
+ $this->initCache();
+
+ if ($this->_cacheType == CACHE_TYPE_NONE)
+ return;
+
+ if (!Cfg::get('CACHE_MODE') /* || Cfg::get('DEBUG') */)
+ return;
+
+ if (!$this->decay)
+ return;
+
+ $cKey = $this->formatCacheKey();
+ $method = is_object($toCache) ? self::STORE_METHOD_OBJECT : self::STORE_METHOD_STRING;
+
+ if ($method == self::STORE_METHOD_OBJECT)
+ $toCache = serialize($toCache);
+ else
+ $toCache = (string)$toCache;
+
+ if (is_callable($this->onCacheLoaded[0]))
+ $postCache = serialize($this->onCacheLoaded);
+
+ if (Cfg::get('CACHE_MODE') & CACHE_MODE_MEMCACHED)
+ {
+ // on &refresh also clear related
+ if ($this->skipCache & CACHE_MODE_MEMCACHED)
+ $this->deleteCache(CACHE_MODE_MEMCACHED);
+
+ $data = array(
+ 'timestamp' => time(),
+ 'lifetime' => $this->decay,
+ 'revision' => AOWOW_REVISION,
+ 'method' => $method,
+ 'postCache' => $postCache ?? null,
+ 'data' => $toCache
+ );
+
+ $this->memcached()?->set($cKey[2], $data);
+ }
+
+ if (Cfg::get('CACHE_MODE') & CACHE_MODE_FILECACHE)
+ {
+ $data = time()." ".$this->decay." ".AOWOW_REVISION." ".$method."\n";
+ $data .= ($postCache ?? '')."\n";
+ $data .= gzcompress($toCache, 9);
+
+ // on &refresh also clear related
+ if ($this->skipCache & CACHE_MODE_FILECACHE)
+ $this->deleteCache(CACHE_MODE_FILECACHE);
+
+ if (Util::writeDir($this->cacheDir . implode(DIRECTORY_SEPARATOR, array_slice($cKey, 0, 2))))
+ file_put_contents($this->cacheDir . implode(DIRECTORY_SEPARATOR, $cKey), $data);
+ }
+ }
+
+ public function loadCache(mixed &$fromCache) : bool
+ {
+ $this->initCache();
+
+ if ($this->_cacheType == CACHE_TYPE_NONE)
+ return false;
+
+ if (!Cfg::get('CACHE_MODE') /* || Cfg::get('DEBUG') */)
+ return false;
+
+ $cKey = $this->formatCacheKey();
+ $rev = $method = $data = $postCache = null;
+
+ if ((Cfg::get('CACHE_MODE') & CACHE_MODE_MEMCACHED) && !($this->skipCache & CACHE_MODE_MEMCACHED))
+ {
+ if ($cache = $this->memcached()?->get($cKey[2]))
+ {
+ $method = $cache['method'];
+ $data = $cache['data'];
+ $postCache = $cache['postCache'];
+
+ if (($cache['timestamp'] + $cache['lifetime']) > time() && $cache['revision'] == AOWOW_REVISION)
+ self::$cacheStats = [CACHE_MODE_MEMCACHED, $cache['timestamp'], $cache['lifetime']];
+ }
+ }
+
+ if (!$data && (Cfg::get('CACHE_MODE') & CACHE_MODE_FILECACHE) && !($this->skipCache & CACHE_MODE_FILECACHE))
+ {
+ $file = $this->cacheDir . implode(DIRECTORY_SEPARATOR, $cKey);
+ if (!file_exists($file))
+ return false;
+
+ $content = file_get_contents($file);
+ if (!$content)
+ return false;
+
+ [$head, $postCache, $data] = explode("\n", $content, 3);
+ if (substr_count($head, ' ') != 3)
+ return false;
+
+ [$time, $lifetime, $rev, $method] = explode(' ', $head);
+
+ if (($time + $lifetime) < time() || $rev != AOWOW_REVISION)
+ return false;
+
+ self::$cacheStats = [CACHE_MODE_FILECACHE, $time, $lifetime];
+ $data = gzuncompress($data);
+ }
+
+ if (!$data)
+ return false;
+
+ if ($postCache)
+ $this->onCacheLoaded = unserialize($postCache);
+
+ $fromCache = false;
+ if ($method == self::STORE_METHOD_OBJECT)
+ $fromCache = unserialize($data);
+ else if ($method == self::STORE_METHOD_STRING)
+ $fromCache = $data;
+
+ return $fromCache !== false;
+ }
+
+ public function deleteCache(int $modeMask = 0x3) : void
+ {
+ $this->initCache();
+
+ // type+typeId+catg; 3+6+10
+ $cKey = $this->formatCacheKey();
+ $cKey[2] = substr($cKey[2], 0, 19);
+
+ if ($modeMask & CACHE_MODE_MEMCACHED)
+ foreach ($this->memcached()?->getAllKeys() ?? [] as $k)
+ if (strpos($k, $cKey[2]) === 0)
+ $this->memcached()?->delete($k);
+
+ if ($modeMask & CACHE_MODE_FILECACHE)
+ foreach (glob(implode(DIRECTORY_SEPARATOR, $cKey).'*') as $file)
+ unlink($file);
+ }
+
+ private function memcached() : ?\Memcached
+ {
+ if (!$this->memcached && (Cfg::get('CACHE_MODE') & CACHE_MODE_MEMCACHED))
+ {
+ $this->memcached = new \Memcached();
+ $this->memcached->addServer('localhost', 11211);
+ }
+
+ return $this->memcached;
+ }
+
+ private function initCache() : void
+ {
+ // php's missing trait property conflict resolution is going to be the end of me
+ // also allow reevaluation even if inited. It may have changed in generate(), because of an error.
+ if (isset($this->cacheType))
+ $this->_cacheType = $this->cacheType;
+
+ if ($this->cacheInited)
+ return;
+
+ // force refresh
+ if (isset($_GET['refresh']) && User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU | U_GROUP_DEV))
+ {
+ if ($_GET['refresh'] == 'filecache')
+ $this->skipCache = CACHE_MODE_FILECACHE;
+ else if ($_GET['refresh'] == 'memcached')
+ $this->skipCache = CACHE_MODE_MEMCACHED;
+ else if ($_GET['refresh'] == '')
+ $this->skipCache = CACHE_MODE_FILECACHE | CACHE_MODE_MEMCACHED;
+ }
+
+ $this->decay ??= Cfg::get('CACHE_DECAY');
+
+ $cacheDir = Cfg::get('CACHE_DIR');
+ if ($cacheDir && Util::writeDir($cacheDir))
+ $this->cacheDir = mb_substr($cacheDir, -1) != '/' ? $cacheDir.'/' : $cacheDir;
+
+ $this->cacheInited = true;
+ }
+
+ // https://stackoverflow.com/questions/466521
+ private function formatCacheKey() : array
+ {
+ [$dbType, $dbTypeId, $category, $staffMask, $miscInfo] = $this->getCacheKeyComponents();
+
+ $fileKey = '';
+ // DBType: 3
+ $fileKey .= str_pad(dechex($dbType & 0xFFF), 3, 0, STR_PAD_LEFT);
+ // DBTypeId: 6
+ $fileKey .= str_pad(dechex($dbTypeId & 0xFFFFFF), 6, 0, STR_PAD_LEFT);
+ // category: (2+4+4)
+ $fileKey .= str_pad(dechex($category & 0xFFFFFFFFFF), 2+4+4, 0, STR_PAD_LEFT);
+ // cacheType: 1
+ $fileKey .= str_pad(dechex($this->_cacheType & 0xF), 1, 0, STR_PAD_LEFT);
+ // localeId: 2,
+ $fileKey .= str_pad(dechex(Lang::getLocale()->value & 0xFF), 2, 0, STR_PAD_LEFT);
+ // staff mask: 4
+ $fileKey .= str_pad(dechex($staffMask & 0xFFFFFFFF), 4, 0, STR_PAD_LEFT);
+ // optioal: miscInfo
+ if ($miscInfo)
+ $fileKey .= '-'.$miscInfo;
+
+ // topDir, 2ndDir, file
+ return array(
+ str_pad(dechex($dbType & 0xFF), 2, 0, STR_PAD_LEFT),
+ str_pad(dechex(($dbTypeId > 0 ? $dbTypeId : $category) & 0xFF), 2, 0, STR_PAD_LEFT),
+ $fileKey
+ );
+ }
+
+ public function setOnCacheLoaded(callable $callback, mixed $params = null) : void
+ {
+ $this->onCacheLoaded = [$callback, $params];
+ }
+
+ public function applyOnCacheLoaded(mixed &$data) : mixed
+ {
+ if (is_callable($this->onCacheLoaded[0]))
+ return $this->onCacheLoaded[0]($data, $this->onCacheLoaded[1]);
+
+ return $data;
+ }
+
+ public function setCacheDecay(int $seconds) : void
+ {
+ if ($seconds < 0)
+ return;
+
+ $this->decay = $seconds;
+ }
+
+ abstract public function getCacheKeyComponents() : array;
+}
+
+trait TrSearch
+{
+ private int $maxResults = 500;
+ private string $query = ''; // sanitized search string
+ private int $searchMask = 0; // what to search for
+ private Search $searchObj;
+
+ public function getCacheKeyComponents() : array
+ {
+ return array(
+ -1, // DBType
+ -1, // DBTypeId
+ $this->searchMask, // category
+ User::$groups, // staff mask
+ md5($this->query) // misc (here search query)
+ );
+ }
+}
+
+Interface IProfilerList
+{
+ public function getRegions() : void;
+}
+
+trait TrProfiler
+{
+ protected int $realmId = 0;
+ protected string $battlegroup = ''; // not implemented, since no pserver supports it
+
+ public string $region = '';
+ public string $realm = '';
+
+ private function getSubjectFromUrl(string $pageParam) : void
+ {
+ if (!$pageParam)
+ return;
+
+ // cat[0] is always region
+ // cat[1] is realm or bGroup (must be realm if cat[2] is set)
+ // cat[2] is arena-team, guild or character
+ $cat = explode('.', mb_strtolower($pageParam), 3);
+
+ $cat = array_map('urldecode', $cat);
+
+ if (array_search($cat[0], Util::$regions) === false)
+ return;
+
+ $this->region = $cat[0];
+
+ // if ($cat[1] == Profiler::urlize(Cfg::get('BATTLEGROUP')))
+ // $this->battlegroup = Cfg::get('BATTLEGROUP');
+ if (isset($cat[1]))
+ {
+ foreach (Profiler::getRealms() as $rId => $r)
+ {
+ if (Profiler::urlize($r['name'], true) == $cat[1])
+ {
+ $this->realm = $r['name'];
+ $this->realmId = $rId;
+ if (isset($cat[2]) && mb_strlen($cat[2]) >= 2)
+ $this->subjectName = mb_strtolower($cat[2]); // cannot reconstruct original name from urlized form; match against special name field
+
+ break;
+ }
+ }
+ }
+ }
+
+ private function followBreadcrumbPath() : void
+ {
+ if ($this->region)
+ {
+ $this->breadcrumb[] = $this->region;
+
+ if ($this->realm)
+ $this->breadcrumb[] = Profiler::urlize($this->realm, true);
+ // else
+ // $this->breadcrumb[] = Profiler::urlize(Cfg::get('BATTLEGROUP'));
+ }
+ }
+}
+
+trait TrProfilerDetail
+{
+ use TrProfiler { TrProfiler::getSubjectFromUrl as _getSubjectFromUrl; }
+
+ protected string $subjectName = '';
+
+ public int $typeId = 0;
+ public ?array $doResync = null;
+
+ private function getSubjectFromUrl(string $pageParam) : void
+ {
+ if (!$pageParam)
+ return;
+
+ if (Util::checkNumeric($pageParam, NUM_CAST_INT))
+ $this->typeId = $pageParam;
+ else
+ $this->_getSubjectFromUrl($pageParam);
+ }
+
+ private function handleIncompleteData(int $type, int $guid) : void
+ {
+ // queue full fetch
+ if ($newId = Profiler::scheduleResync($type, $this->realmId, $guid))
+ {
+ $this->template = 'text-page-generic';
+ $this->doResync = [Type::getFileString($type), $newId];
+ $this->inputbox = ['inputbox-status', ['head' => Lang::profiler('firstUseTitle', [Util::ucFirst($this->subjectName), $this->realm])]];
+
+ return;
+ }
+
+ // todo: base info should have been created in __construct .. why are we here..?
+ $this->forward('?'.Type::getFileString($type).'s='.$this->region.'.'.Profiler::urlize($this->realm, true).'&filter=na='.Util::ucFirst($this->subjectName).';ex=on');
+ }
+}
+
+trait TrProfilerList
+{
+ use TrProfiler;
+
+ public array $regions = [];
+
+ public function getRegions() : void
+ {
+ $usedRegions = array_column(Profiler::getRealms(), 'region');
+ foreach (Util::$regions as $idx => $id)
+ if (in_array($id, $usedRegions))
+ $this->regions[$id] = Lang::profiler('regions', $id);
+ }
+}
+
+
+abstract class BaseResponse
+{
+ protected const PATTERN_TEXT_LINE = '/[\p{Cc}\p{Cf}\p{Co}\p{Cs}\p{Cn}]/ui';
+ protected const PATTERN_TEXT_BLOB = '/[\x00-\x09\x0B-\x1F\p{Cf}\p{Co}\p{Cs}\p{Cn}]/ui';
+
+ protected static array $sql = []; // debug: sql stats container
+
+ protected array $expectedPOST = []; // fill with variables you that are going to be used; eg:
+ protected array $expectedGET = []; // 'id' => ['filter' => FILTER_CALLBACK, 'options' => 'AjaxHandler::checkIdList']
+ protected array $expectedCOOKIE = [];
+
+ protected array $_post = []; // the filtered variable result
+ protected array $_get = [];
+ protected array $_cookie = [];
+
+ protected int $requiredUserGroup = U_GROUP_NONE; // by default accessible to everone
+ protected bool $requiresLogin = false; // normal users and guests are both U_GROUP_NONE, soooo.....
+ protected mixed $result = null;
+
+ public function __construct()
+ {
+ $this->initRequestData();
+
+ if (!User::isInGroup($this->requiredUserGroup))
+ $this->onUserGroupMismatch();
+
+ if ($this->requiresLogin && !User::isLoggedIn())
+ $this->onUserGroupMismatch();
+ }
+
+ public function process() : void
+ {
+ $fromCache = false;
+
+ if ($this instanceof ICache)
+ $fromCache = $this->loadCache($this->result);
+
+ if (!$this->result)
+ $this->generate();
+
+ $this->display();
+
+ if ($this instanceof ICache && !$fromCache)
+ $this->saveCache($this->result);
+ }
+
+ private function initRequestData() : void
+ {
+ // php bug? If INPUT_X is empty, filter_input_array returns null/fails
+ // only really relevant for INPUT_POST
+ // manuall set everything null in this case
+
+ if ($this->expectedPOST)
+ {
+ if ($_POST)
+ $this->_post = filter_input_array(INPUT_POST, $this->expectedPOST);
+ else
+ $this->_post = array_fill_keys(array_keys($this->expectedPOST), null);
+ }
+
+ if ($this->expectedGET)
+ {
+ if ($_GET)
+ $this->_get = filter_input_array(INPUT_GET, $this->expectedGET);
+ else
+ $this->_get = array_fill_keys(array_keys($this->expectedGET), null);
+ }
+
+ if ($this->expectedCOOKIE)
+ {
+ if ($_COOKIE)
+ $this->_cookie = filter_input_array(INPUT_COOKIE, $this->expectedCOOKIE);
+ else
+ $this->_cookie = array_fill_keys(array_keys($this->expectedCOOKIE), null);
+ }
+ }
+
+ protected function forward(string $url = '') : never
+ {
+ $this->sendNoCacheHeader();
+ header('Location: '.($url ?: '.'), true, 302);
+ exit;
+ }
+
+ protected function forwardToSignIn(string $next = '') : never
+ {
+ $this->forward('?account=signin'.($next ? '&next='.$next : ''));
+ }
+
+ protected function sumSQLStats() : void
+ {
+ Util::arraySumByKey(self::$sql, DB::Aowow()->getStatistics(), DB::World()->getStatistics());
+ foreach (Profiler::getRealms() as $rId => $_)
+ Util::arraySumByKey(self::$sql, DB::Characters($rId)->getStatistics());
+ }
+
+ protected function sendNoCacheHeader()
+ {
+ header('Expires: Sat, 01 Jan 2000 01:00:00 GMT');
+ header('Last-Modified: ' . gmdate('D, d M Y H:i:s') . ' GMT');
+ header('Cache-Control: no-store, no-cache, must-revalidate');
+ header('Cache-Control: post-check=0, pre-check=0', false);
+ header('Pragma: no-cache');
+ }
+
+
+ /****************************/
+ /* required Parameter tests */
+ /****************************/
+
+ protected function assertPOST(string ...$keys) : bool
+ {
+ foreach ($keys as $k) // not sent by browser || empty text field sent || validation failed
+ if (!isset($this->_post[$k]) || $this->_post[$k] === null || $this->_post[$k] === '' || $this->_post[$k] === false)
+ return false;
+
+ return true;
+ }
+
+ protected function assertGET(string ...$keys) : bool
+ {
+ foreach ($keys as $k)
+ if (!isset($this->_get[$k]) || $this->_get[$k] === null || $this->_get[$k] === '' || $this->_get[$k] === false)
+ return false;
+
+ return true;
+ }
+
+ protected function assertCOOKIE(string ...$keys) : bool
+ {
+ foreach ($keys as $k)
+ if (!isset($this->_cookie[$k]) || $this->_cookie[$k] === null || $this->_cookie[$k] === '' || $this->_cookie[$k] === false)
+ return false;
+
+ return true;
+ }
+
+
+ /*******************************/
+ /* Parameter validation checks */
+ /*******************************/
+
+ protected static function checkRememberMe(string $val) : bool
+ {
+ return $val === 'yes';
+ }
+
+ protected static function checkCheckbox(string $val) : bool
+ {
+ return $val === 'on';
+ }
+
+ protected static function checkEmptySet(string $val) : bool
+ {
+ return $val === ''; // parameter is set and expected to be empty
+ }
+
+ protected static function checkIdList(string $val) : array
+ {
+ if (preg_match('/^-?\d+(,-?\d+)*$/', $val))
+ return array_map('intVal', explode(',', $val));
+
+ return [];
+ }
+
+ protected static function checkIntArray(string $val) : array
+ {
+ if (preg_match('/^-?\d+(:-?\d+)*$/', $val))
+ return array_map('intVal', explode(':', $val));
+
+ return [];
+ }
+
+ protected static function checkIdListUnsigned(string $val) : array
+ {
+ if (preg_match('/^\d+(,\d+)*$/', $val))
+ return array_map('intVal', explode(',', $val));
+
+ return [];
+ }
+
+ protected static function checkTextLine(string $val) : string
+ {
+ // trim non-printable chars
+ return preg_replace(self::PATTERN_TEXT_LINE, '', trim(urldecode($val)));
+ }
+
+ protected static function checkTextBlob(string $val) : string
+ {
+ // trim non-printable chars + excessive whitespaces (pattern includes \r)
+ $str = preg_replace(self::PATTERN_TEXT_BLOB, '', trim($val));
+ return preg_replace('/ +/', ' ', trim($str));
+ }
+
+
+ /********************/
+ /* child implements */
+ /********************/
+
+ // calc response
+ abstract protected function generate() : void;
+
+ // send response
+ abstract protected function display() : void;
+
+ // handling differs by medium
+ abstract protected function onUserGroupMismatch() : never;
+}
+
+?>
diff --git a/includes/components/response/templateresponse.class.php b/includes/components/response/templateresponse.class.php
new file mode 100644
index 00000000..76fb8672
--- /dev/null
+++ b/includes/components/response/templateresponse.class.php
@@ -0,0 +1,687 @@
+type, // DBType
+ $this->typeId, // DBTypeId
+ -1, // category
+ User::$groups, // staff mask
+ '' // misc (here unused)
+ );
+ }
+}
+
+
+trait TrListPage
+{
+ public ?string $subCat = null;
+ public ?Filter $filter = null;
+
+ public function getCacheKeyComponents() : array
+ {
+ // max. 3 catgs
+ // catg max 65535
+ if ($this->category)
+ {
+ $catg = 0x0;
+ for ($i = 0; $i < 3; $i++)
+ {
+ $catg <<= 4;
+ $catg |= ($this->category[$i] ?? 0) & 0xFFFF;
+ }
+ }
+
+ if ($get = $this->filter?->buildGETParam())
+ $misc = md5($get);
+
+ return array(
+ $this->type, // DBType
+ -1, // DBTypeId
+ $catg ?? -1, // category
+ User::$groups, // staff mask
+ $misc ?? '' // misc (here filter)
+ );
+ }
+}
+
+
+trait TrGuideEditor
+{
+ public int $typeId = 0;
+
+ public int $editCategory = 0;
+ public int $editClassId = 0;
+ public int $editSpecId = 0;
+ public int $editRev = 0;
+ public int $editStatus = GUIDE_STATUS_DRAFT;
+ public string $editStatusColor = GuideMgr::STATUS_COLORS[GUIDE_STATUS_DRAFT];
+ public string $editTitle = '';
+ public string $editName = '';
+ public string $editDescription = '';
+ public string $editText = '';
+ public Locale $editLocale = Locale::EN;
+}
+
+class TemplateResponse extends BaseResponse
+{
+ final protected const /* int */ TAB_DATABASE = 0;
+ final protected const /* int */ TAB_TOOLS = 1;
+ final protected const /* int */ TAB_MORE = 2;
+ final protected const /* int */ TAB_COMMUNITY = 3;
+ final protected const /* int */ TAB_STAFF = 4;
+ final protected const /* int */ TAB_GUIDES = 6;
+
+ private array $jsgBuffer = []; // throw any db type references in here to be processed later
+ private array $header = [];
+ private string $fullParams = ''; // effectively articleUrl
+
+ protected string $template = '';
+ protected array $breadcrumb = [];
+ protected ?int $activeTab = null; // [Database, Tools, More, Community, Staff, null, Guides] ?? none
+ protected string $pageName = '';
+ protected array $category = [];
+ protected array $validCats = [];
+ protected ?string $articleUrl = null;
+ protected bool $filterError = false; // retroactively apply error notice to fixed filter result
+
+ protected array $dataLoader = []; // ?data=x.y.z as javascript
+ protected array $scripts = array(
+ [SC_JS_FILE, 'js/jquery-3.7.0.min.js', SC_FLAG_NO_TIMESTAMP ],
+ [SC_JS_FILE, 'js/basic.js' ],
+ [SC_JS_FILE, 'widgets/power.js', SC_FLAG_NO_TIMESTAMP | SC_FLAG_APPEND_LOCALE],
+ [SC_JS_FILE, 'js/locale_%s.js', SC_FLAG_LOCALIZED ],
+ [SC_JS_FILE, 'js/global.js' ],
+ [SC_JS_FILE, 'js/locale.js' ],
+ [SC_JS_FILE, 'js/Markup.js' ],
+ [SC_CSS_FILE, 'css/basic.css' ],
+ [SC_CSS_FILE, 'css/global.css' ],
+ [SC_CSS_FILE, 'css/aowow.css' ],
+ [SC_CSS_FILE, 'css/locale_%s.css', SC_FLAG_LOCALIZED ]
+ );
+
+ // debug: stats
+ protected static float $time = 0.0;
+ // protected static array $sql = [];
+ // protected static array $cacheStats = [];
+ public array $pageStats = []; // static properties carry the values, this is just for the PageTemplate to reference
+
+ // send to template
+ public array $title = []; // head title components
+ public string $h1 = ''; // body title
+ public string $h1Link = ''; //
+ public ?string $headerLogo = null; // url to non-standard logo for events etc.
+ public string $search = ''; // prefilled search bar
+ public string $wowheadLink = 'https://wowhead.com/';
+ public int $contribute = CONTRIBUTE_NONE;
+ public ?array $inputbox = null;
+ public ?string $rss = null; // link rel=alternate for rss auto-discovery
+ public ?string $tabsTitle = null;
+ public ?Markup $extraText = null;
+ public ?string $extraHTML = null;
+ public array $redButtons = []; // see template/redButtons.tpl.php
+
+ // send to template, but it is js stuff
+ public array $gPageInfo = [];
+ public bool $gDataKey = false; // send g_DataKey to template or don't (stored in $_SESSION)
+ public ?Markup $article = null;
+ public ?Tabs $lvTabs = null;
+ public array $pageTemplate = []; // js PageTemplate object
+ public array $jsGlobals = []; // ready to be used in template
+
+ public function __construct(string $pageParam = '')
+ {
+ $this->title[] = Cfg::get('NAME');
+ self::$time = microtime(true);
+
+ parent::__construct();
+
+ $this->fullParams = $this->pageName;
+ if ($pageParam)
+ $this->fullParams .= '='.$pageParam;
+
+ // prep js+css includes
+ $parentVars = get_class_vars(__CLASS__);
+ if ($parentVars['scripts'] != $this->scripts) // additions set in child class
+ $this->scripts = array_merge($parentVars['scripts'], $this->scripts);
+
+ if (User::isInGroup(U_GROUP_STAFF | U_GROUP_SCREENSHOT | U_GROUP_VIDEO))
+ array_push($this->scripts, [SC_CSS_FILE, 'css/staff.css'], [SC_JS_FILE, 'js/staff.js']);
+
+ // get alt header logo
+ if ($ahl = DB::Aowow()->selectCell('SELECT `altHeaderLogo` FROM ?_home_featuredbox WHERE ?d BETWEEN `startDate` AND `endDate` ORDER BY `id` DESC', time()))
+ $this->headerLogo = Util::defStatic($ahl);
+
+ if ($this->pageName)
+ {
+ $this->wowheadLink = sprintf(WOWHEAD_LINK, Lang::getLocale()->domain(), $this->pageName, $pageParam ? '=' . $pageParam : '');
+ $this->pageTemplate['pageName'] = $this->pageName;
+ }
+
+ if (!is_null($this->activeTab))
+ $this->pageTemplate['activeTab'] = $this->activeTab;
+
+ if (!$this->isValidPage())
+ $this->onInvalidCategory();
+
+ if (Cfg::get('MAINTENANCE') && !User::isInGroup(U_GROUP_EMPLOYEE))
+ $this->generateMaintenance();
+ else if (Cfg::get('MAINTENANCE') && User::isInGroup(U_GROUP_EMPLOYEE))
+ Util::addNote('Maintenance mode enabled!');
+ }
+
+ // by default goto login page
+ protected function onUserGroupMismatch() : never
+ {
+ if (User::isLoggedIn())
+ $this->generateError();
+
+ $this->forwardToSignIn($_SERVER['QUERY_STRING'] ?? '');
+ }
+
+ // by default show error page
+ protected function onInvalidCategory() : never
+ {
+ $this->generateError();
+ }
+
+ // just pass through
+ protected function addScript(array ...$scriptDefs) : void
+ {
+ if (!$this->result)
+ $this->scripts = array_merge($this->scripts, $scriptDefs);
+ else
+ foreach ($scriptDefs as $s)
+ $this->result->addScript(...$s);
+ }
+
+ protected function addDataLoader(string ...$dataFiles) : void
+ {
+ if (!$this->result)
+ $this->dataLoader = array_merge($this->dataLoader, $dataFiles);
+ else
+ $this->result->addDataLoader($dataFiles);
+ }
+
+ public static function pageStatsHook(Template\PageTemplate &$pt, array &$stats) : void
+ {
+ if (User::isInGroup(U_GROUP_EMPLOYEE))
+ {
+ $stats['time'] = Util::formatTime((microtime(true) - self::$time) * 1000, true);
+ $stats['sql'] = ['count' => parent::$sql['count'], 'time' => Util::formatTime(parent::$sql['time'] * 1000, true)];
+ $stats['cache'] = !empty(static::$cacheStats) ? [static::$cacheStats[0], Util::formatTimeDiff(static::$cacheStats[1])] : null;
+ }
+ else
+ $stats = [];
+ }
+
+ protected function getCategoryFromUrl(string $pageParam) : void
+ {
+ $arr = explode('.', $pageParam);
+ foreach ($arr as $v)
+ {
+ if (!is_numeric($v))
+ break;
+
+ $this->category[] = (int)$v;
+ }
+ }
+
+ // functionally this should be in PageTemplate but inaccessible there
+ protected function fmtStaffTip(?string $text, string $tip) : string
+ {
+ if (!$text || !User::isInGroup(U_GROUP_EMPLOYEE))
+ return $text ?? '';
+ else
+ return sprintf(Util::$dfnString, $tip, $text);
+ }
+
+
+ /**********************/
+ /* Prepare js-Globals */
+ /**********************/
+
+ // add typeIds that should be displayed as jsGlobal on the page
+ public function extendGlobalIds(int $type, int ...$ids) : void
+ {
+ if (!$type || !$ids)
+ return;
+
+ if (!isset($this->jsgBuffer[$type]))
+ $this->jsgBuffer[$type] = [];
+
+ foreach ($ids as $id)
+ $this->jsgBuffer[$type][] = $id;
+ }
+
+ // add jsGlobals or typeIds (can be mixed in one array: TYPE => [mixeddata]) to display on the page
+ public function extendGlobalData(array $data, ?array $extra = null) : void
+ {
+ foreach ($data as $type => $globals)
+ {
+ if (!is_array($globals) || !$globals)
+ continue;
+
+ $this->initJSGlobal($type);
+
+ // can be id => data
+ // or idx => id
+ // and may be mixed
+ foreach ($globals as $k => $v)
+ {
+ if (is_array($v))
+ {
+ // localize name fields .. except for icons .. icons are special
+ if ($type != Type::ICON)
+ {
+ foreach (['name', 'namefemale'] as $n)
+ {
+ if (!isset($v[$n]))
+ continue;
+
+ $v[$n . '_'.Lang::getLocale()->json()] = $v[$n];
+ unset($v[$n]);
+ }
+ }
+
+ $this->jsGlobals[$type][1][$k] = $v;
+ }
+ else if (is_numeric($v))
+ $this->extendGlobalIds($type, $v);
+ }
+ }
+
+ if ($extra)
+ {
+ $namedExtra = [];
+ foreach ($extra as $typeId => $data)
+ foreach ($data as $k => $v)
+ $namedExtra[$typeId][$k.'_'.Lang::getLocale()->json()] = $v;
+
+ $this->jsGlobals[$type][2] = $namedExtra;
+ }
+ }
+
+ // init store for type
+ private function initJSGlobal(int $type) : void
+ {
+ $jsg = &$this->jsGlobals; // shortcut
+
+ if (isset($jsg[$type]))
+ return;
+
+ if ($tpl = Type::getJSGlobalTemplate($type))
+ $jsg[$type] = $tpl;
+ }
+
+ // lookup jsGlobals from collected typeIds
+ private function applyGlobals() : void
+ {
+ foreach ($this->jsgBuffer as $type => $ids)
+ {
+ foreach ($ids as $k => $id) // filter already generated data, maybe we can save a lookup or two
+ if (isset($this->jsGlobals[$type][1][$id]))
+ unset($ids[$k]);
+
+ if (!$ids)
+ continue;
+
+ $this->initJSGlobal($type);
+
+ $obj = Type::newList($type, [Cfg::get('SQL_LIMIT_NONE'), ['id', array_unique($ids, SORT_NUMERIC)]]);
+ if (!$obj)
+ continue;
+
+ $this->extendGlobalData($obj->getJSGlobals(GLOBALINFO_SELF));
+
+ // delete processed ids
+ $this->jsgBuffer[$type] = [];
+ }
+ }
+
+
+ /************************/
+ /* Generic Page Content */
+ /************************/
+
+ // get announcements and notes for user
+ private function addAnnouncements(bool $onlyGenerics = false) : void
+ {
+ $announcements = [];
+
+ // display occured notices
+ $notes = $_SESSION['notes'] ?? [];
+ unset($_SESSION['notes']);
+
+ $notes[] = [...Util::getNotes(), 'One or more issues occured during page generation'];
+
+ foreach ($notes as $i => [$messages, $logLevel, $head])
+ {
+ if (!$messages)
+ continue;
+
+ array_unshift($messages, $head);
+
+ $colors = array( // [border, text]
+ LOG_LEVEL_ERROR => ['C50F1F', 'E51223'],
+ LOG_LEVEL_WARN => ['C19C00', 'E5B700'],
+ LOG_LEVEL_INFO => ['3A96DD', '42ADFF']
+ );
+
+ $text = new LocString(['name_loc' . Lang::getLocale()->value => '[span]'.implode("[br]", $messages).'[/span]'], callback: Util::defStatic(...));
+ $style = 'color: #'.($colors[$logLevel][1] ?? 'fff').'; font-weight: bold; font-size: 14px; padding-left: 40px; background-image: url('.Cfg::get('STATIC_URL').'/images/announcements/warn-small.png); background-size: 15px 15px; background-position: 12px center; border: dashed 2px #'.($colors[$logLevel][0] ?? 'fff').';';
+
+ $announcements[] = new Announcement(-$i, 'internal error', $text, style: $style);
+ }
+
+ // fetch announcements
+ $fromDB = DB::Aowow()->select(
+ 'SELECT `id`, `mode`, `status`, `name`, `style`, `text_loc0`, `text_loc2`, `text_loc3`, `text_loc4`, `text_loc6`, `text_loc8`
+ FROM ?_announcements
+ WHERE (`status` = ?d { OR `status` = ?d } ) AND
+ (`page` = "*" { OR `page` = ? } ) AND
+ (`groupMask` = 0 OR `groupMask` & ?d)',
+ Announcement::STATUS_ENABLED, User::isInGroup(U_GROUP_ADMIN | U_GROUP_BUREAU) ? Announcement::STATUS_DISABLED : DBSIMPLE_SKIP,
+ $onlyGenerics || !$this->pageName ? DBSIMPLE_SKIP : $this->pageName,
+ User::$groups
+ );
+
+ foreach ($fromDB as $a)
+ if (($ann = new Announcement($a['id'], $a['name'], new LocString($a, 'text', Util::defStatic(...)), $a['mode'], $a['status'], Util::defStatic($a['style'])))->status != Announcement::STATUS_DELETED)
+ $announcements[] = $ann;
+
+ $this->result->announcements = $announcements;
+ }
+
+ // get article & static infobox (run before processing jsGlobals)
+ private function addArticle() : void
+ {
+ if ($this->article)
+ return;
+
+ $article = [];
+ if (isset($this->guideRevision))
+ $article = DB::Aowow()->selectRow('SELECT `article`, `locale`, `editAccess` FROM ?_articles WHERE `type` = ?d AND `typeId` = ?d AND `rev` = ?d',
+ Type::GUIDE, $this->typeId, $this->guideRevision);
+ if (!$article && $this->gPageInfo['articleUrl'])
+ $article = DB::Aowow()->selectRow('SELECT `article`, `locale`, `editAccess` FROM ?_articles WHERE `url` = ? AND `locale` IN (?a) ORDER BY `locale` DESC, `rev` DESC LIMIT 1',
+ $this->gPageInfo['articleUrl'], [Lang::getLocale()->value, Locale::EN->value]);
+ if (!$article && !empty($this->type) && isset($this->typeId))
+ $article = DB::Aowow()->selectRow('SELECT `article`, `locale`, `editAccess` FROM ?_articles WHERE `type` = ?d AND `typeId` = ?d AND `locale` IN (?a) ORDER BY `locale` DESC, `rev` DESC LIMIT 1',
+ $this->type, $this->typeId, [Lang::getLocale()->value, Locale::EN->value]);
+
+ if (!$article)
+ return;
+
+ $text = Util::defStatic($article['article']);
+ $opts = [];
+
+ // convert U_GROUP_* to MARKUP.CLASS_* (as seen in js-object Markup)
+ if ($article['editAccess'] & (U_GROUP_ADMIN | U_GROUP_VIP | U_GROUP_DEV))
+ $opts['allow'] = Markup::CLASS_ADMIN;
+ else if ($article['editAccess'] & U_GROUP_STAFF)
+ $opts['allow'] = Markup::CLASS_STAFF;
+ else if ($article['editAccess'] & U_GROUP_PREMIUM)
+ $opts['allow'] = Markup::CLASS_PREMIUM;
+ else if ($article['editAccess'] & U_GROUP_PENDING)
+ $opts['allow'] = Markup::CLASS_PENDING;
+ else
+ $opts['allow'] = Markup::CLASS_USER;
+
+ if (!empty($this->type) && isset($this->typeId))
+ $opts['dbpage'] = 1;
+
+ if ($article['locale'] != Lang::getLocale()->value)
+ $opts['prepend'] = ''.Lang::main('langOnly', [Lang::lang($article['locale'])]).'
';
+
+ $this->article = new Markup($text, $opts);
+
+ if ($jsg = $this->article->getJsGlobals())
+ $this->extendGlobalData($jsg);
+
+ $this->gPageInfo['editAccess'] = $article['editAccess'];
+
+ if (method_exists($this, 'postArticle')) // e.g. update variables in article
+ $this->postArticle($this->article['text']);
+ }
+
+ private function addCommunityContent() : void
+ {
+ $community = array(
+ 'coError' => $_SESSION['error']['co'] ?? null,
+ 'ssError' => $_SESSION['error']['ss'] ?? null,
+ 'viError' => $_SESSION['error']['vi'] ?? null
+ );
+
+ if ($this->contribute & CONTRIBUTE_CO)
+ $community['co'] = Util::toJSON(CommunityContent::getComments($this->type, $this->typeId));
+
+ if ($this->contribute & CONTRIBUTE_SS)
+ $community['ss'] = Util::toJSON(CommunityContent::getScreenshots($this->type, $this->typeId));
+
+ if ($this->contribute & CONTRIBUTE_VI)
+ $community['vi'] = Util::toJSON(CommunityContent::getVideos($this->type, $this->typeId));
+
+ unset($_SESSION['error']);
+
+ // as comments are not cached, those globals cant be either
+ $this->extendGlobalData(CommunityContent::getJSGlobals());
+
+ $this->result->community = $community;
+ $this->applyGlobals();
+ }
+
+
+ /**************/
+ /* Generators */
+ /**************/
+
+ protected function generate() : void
+ {
+ $this->result = new Template\PageTemplate($this->template, $this);
+
+ foreach ($this->scripts as $s)
+ $this->result->addScript(...$s);
+
+ $this->result->addDataLoader(...$this->dataLoader);
+
+ // static::class so pageStatsHook defined here, can access cacheStats defined in the implementation
+ $this->result->registerDisplayHook('pageStats', [static::class, 'pageStatsHook']);
+
+ // only adds edit links to the staff menu: precursor to guides?
+ if (!($this instanceof GuideBaseResponse))
+ $this->gPageInfo += array(
+ 'articleUrl' => $this->articleUrl ?? $this->fullParams, // is actually be the url-param
+ 'editAccess' => (U_GROUP_ADMIN | U_GROUP_EDITOR | U_GROUP_BUREAU)
+ );
+
+ if ($this->breadcrumb)
+ $this->pageTemplate['breadcrumb'] = $this->breadcrumb;
+
+ if (isset($this->filter))
+ $this->pageTemplate['filter'] = $this->filter->query ? 1 : 0;
+
+ $this->addArticle();
+
+ $this->applyGlobals();
+ }
+
+ // we admit this page exists and an error occured on it
+ public function generateError(?string $altPageName = null) : never
+ {
+ $this->result = new Template\PageTemplate('text-page-generic', $this);
+
+ // only use own script defs
+ foreach (get_class_vars(self::class)['scripts'] as $s)
+ $this->result->addScript(...$s);
+
+ if (User::isInGroup(U_GROUP_STAFF | U_GROUP_SCREENSHOT | U_GROUP_VIDEO))
+ {
+ $this->result->addScript(SC_CSS_FILE, 'css/staff.css');
+ $this->result->addScript(SC_JS_FILE, 'js/staff.js');
+ }
+
+ $this->result->registerDisplayHook('pageStats', [self::class, 'pageStatsHook']);
+
+ $this->title[] = Lang::main('errPageTitle');
+ $this->h1 = Lang::main('errPageTitle');
+ $this->articleUrl = 'page-not-found';
+ $this->gPageInfo += array(
+ 'articleUrl' => 'page-not-found',
+ 'editAccess' => (U_GROUP_ADMIN | U_GROUP_EDITOR | U_GROUP_BUREAU)
+ );
+
+ $this->pageTemplate['pageName'] ??= $altPageName ?? 'page-not-found';
+
+ $this->addArticle();
+
+ $this->sumSQLStats();
+
+ $this->header[] = ['HTTP/1.0 404 Not Found', true, 404];
+
+ $this->display(true);
+ exit;
+ }
+
+ // we do not have this page
+ public function generateNotFound(string $title = '', string $msg = '') : never
+ {
+ $this->result = new Template\PageTemplate('text-page-generic', $this);
+
+ // only use own script defs
+ foreach (get_class_vars(self::class)['scripts'] as $s)
+ $this->result->addScript(...$s);
+
+ if (User::isInGroup(U_GROUP_STAFF | U_GROUP_SCREENSHOT | U_GROUP_VIDEO))
+ {
+ $this->result->addScript(SC_CSS_FILE, 'css/staff.css');
+ $this->result->addScript(SC_JS_FILE, 'js/staff.js');
+ }
+
+ $this->result->registerDisplayHook('pageStats', [self::class, 'pageStatsHook']);
+
+ array_unshift($this->title, Lang::main('nfPageTitle'));
+
+ $this->inputbox = ['inputbox-status', array(
+ 'head' => isset($this->typeId) ? Util::ucWords($title).' #'.$this->typeId : $title,
+ 'error' => !$msg && isset($this->typeId) ? Lang::main('pageNotFound', [$title]) : $msg
+ )];
+
+ $this->contribute = CONTRIBUTE_NONE;
+
+ if (!empty($this->breadcrumb))
+ $this->pageTemplate['breadcrumb'] = $this->breadcrumb;
+
+ $this->sumSQLStats();
+
+ $this->header[] = ['HTTP/1.0 404 Not Found', true, 404];
+
+ $this->display(true);
+ exit;
+ }
+
+ // display brb gnomes
+ public function generateMaintenance() : never
+ {
+ $this->result = new Template\PageTemplate('maintenance', $this);
+
+ $this->header[] = ['HTTP/1.0 503 Service Temporarily Unavailable', true, 503];
+ $this->header[] = ['Retry-After: '.(3 * HOUR)];
+
+ $this->display(true);
+ exit;
+ }
+
+ protected function display(bool $withError = false) : void
+ {
+ $this->title = Util::htmlEscape($this->title);
+ $this->search = Util::htmlEscape($this->search);
+ // can't escape >h1 here, because CharTitles legitimately add HTML
+
+ $this->addAnnouncements($withError);
+ if (!$withError)
+ $this->addCommunityContent();
+
+ // force jsGlobals from Announcements/CommunityContent into PageTemplate
+ // as this may be loaded from cache, it will be unlinked from its response
+ if ($ptJSG = $this->result->jsGlobals)
+ {
+ Util::mergeJsGlobals($ptJSG, $this->jsGlobals);
+ $this->result->jsGlobals = $ptJSG;
+ }
+ else if ($this->jsGlobals)
+ $this->result->jsGlobals = $this->jsGlobals;
+
+ if ($this instanceof ICache)
+ $this->applyOnCacheLoaded($this->result);
+
+ if ($this->result && $this->filterError)
+ $this->result->setListviewError();
+
+ $this->sumSQLStats();
+
+ // Heisenbug: IE11 and FF32 will sometimes (under unknown circumstances) cache 302 redirects and stop
+ // re-requesting them from the server but load them from local cache, thus breaking menu features.
+ $this->sendNoCacheHeader();
+ foreach ($this->header as $h)
+ header(...$h);
+
+ $this->result?->render();
+ }
+
+
+ /**********/
+ /* Checks */
+ /**********/
+
+ // has a valid combination of categories
+ private function isValidPage() : bool
+ {
+ if (!$this->category || !$this->validCats)
+ return true;
+
+ $c = $this->category; // shorthand
+
+ switch (count($c))
+ {
+ case 0: // no params works always
+ return true;
+ case 1: // null is valid || value in a 1-dim-array || (key for a n-dim-array && ( has more subcats || no further subCats ))
+ $filtered = array_filter($this->validCats, fn ($x) => is_int($x));
+ return $c[0] === null || in_array($c[0], $filtered) || (!empty($this->validCats[$c[0]]) && (is_array($this->validCats[$c[0]]) || $this->validCats[$c[0]] === true));
+ case 2: // first param has to be a key. otherwise invalid
+ if (!isset($this->validCats[$c[0]]))
+ return false;
+
+ // check if the sub-array is n-imensional
+ if (is_array($this->validCats[$c[0]]) && count($this->validCats[$c[0]]) == count($this->validCats[$c[0]], COUNT_RECURSIVE))
+ return in_array($c[1], $this->validCats[$c[0]]); // second param is value in second level array
+ else
+ return isset($this->validCats[$c[0]][$c[1]]); // check if params is key of another array
+ case 3: // 3 params MUST point to a specific value
+ return isset($this->validCats[$c[0]][$c[1]]) && in_array($c[2], $this->validCats[$c[0]][$c[1]]);
+ }
+
+ return false;
+ }
+
+}
+
+?>
diff --git a/includes/components/response/textresponse.class.php b/includes/components/response/textresponse.class.php
new file mode 100644
index 00000000..e8000987
--- /dev/null
+++ b/includes/components/response/textresponse.class.php
@@ -0,0 +1,163 @@
+type, // DBType
+ $this->typeId, // DBTypeId
+ -1, // category
+ User::$groups, // staff mask
+ '' // misc (here tooltip)
+ );
+
+ if ($this->enhancedTT)
+ $key[4] = md5(serialize($this->enhancedTT));
+
+ return $key;
+ }
+}
+
+
+trait TrRss
+{
+ private array $feedData = [];
+
+ protected function generateRSS(string $title, string $link) : string
+ {
+ $root = new SimpleXML('');
+ $root->addAttribute('version', '2.0');
+
+ $channel = $root->addChild('channel');
+
+ $channel->addChild('title', Cfg::get('NAME_SHORT').' - '.$title);
+ $channel->addChild('link', Cfg::get('HOST_URL').'/?'.$link);
+ $channel->addChild('description', Cfg::get('NAME'));
+ $channel->addChild('language', implode('-', str_split(Lang::getLocale()->json(), 2)));
+ $channel->addChild('ttl', Cfg::get('TTL_RSS'));
+ $channel->addChild('lastBuildDate', date(DATE_RSS));
+
+ foreach ($this->feedData as $row)
+ {
+ $item = $channel->addChild('item');
+
+ foreach ($row as $key => [$isCData, $attrib, $text])
+ {
+ if ($isCData && $text)
+ $child = $item->addChild($key)->addCData($text);
+ else
+ $child = $item->addChild($key, $text);
+
+ foreach ($attrib as $k => $v)
+ $child->addAttribute($k, $v);
+ }
+ }
+
+ return $root->asXML();
+ }
+}
+
+trait TrCommunityHelper
+{
+ private function handleCaption(?string $caption) : string
+ {
+ if (!$caption)
+ return '';
+
+ // trim excessive whitespaces
+ $caption = trim(preg_replace('/\s{2,}/', ' ', $caption));
+
+ // shorten to fit db
+ $caption = substr($caption, 0, 200);
+
+ // jsEscape just in case
+ return Util::jsEscape($caption);
+ }
+}
+
+abstract class TextResponse extends BaseResponse
+{
+ protected string $contentType = MIME_TYPE_JAVASCRIPT;
+ protected ?string $redirectTo = null;
+ protected array $params = [];
+
+ /// generation stats
+ protected static float $time = 0.0;
+
+ public function __construct(string $pageParam = '')
+ {
+ self::$time = microtime(true);
+ $this->params = explode('.', $pageParam);
+ // todo - validate params?
+
+ parent::__construct();
+
+ if (Cfg::get('MAINTENANCE') && !User::isInGroup(U_GROUP_EMPLOYEE))
+ $this->generate404();
+ }
+
+ // by default ajax has nothing to say
+ protected function onUserGroupMismatch() : never
+ {
+ trigger_error('TextResponse::onUserGroupMismatch - loggedIn: '.($this->requiresLogin ? 'yes' : 'no').'; expected: '.Util::asHex($this->requiredUserGroup).'; is: '.Util::asHex(User::$groups), E_USER_WARNING);
+
+ $this->generate403();
+ }
+
+ public function generate404(?string $out = null) : never
+ {
+ header('HTTP/1.0 404 Not Found', true, 404);
+ header($this->contentType);
+ exit($out);
+ }
+
+ public function generate403(?string $out = null) : never
+ {
+ header('HTTP/1.0 403 Forbidden', true, 403);
+ header($this->contentType);
+ exit($out);
+ }
+
+ protected function display() : void
+ {
+ if ($this->redirectTo)
+ $this->forward($this->redirectTo);
+
+ $out = ($this instanceof ICache) ? $this->applyOnCacheLoaded($this->result) : $this->result;
+
+ $this->sendNoCacheHeader();
+ header($this->contentType);
+
+ // NOTE - this may fuck up some javascripts that say they expect ajax, but use the whole string anyway
+ // so it's limited to tooltips
+ if (Cfg::get('DEBUG') && User::isInGroup(U_GROUP_STAFF) && $this->result instanceof Tooltip)
+ {
+ $this->sumSQLStats();
+
+ echo "/*\n";
+ echo " * generated in ".Util::formatTime((microtime(true) - self::$time) * 1000)."\n";
+ echo " * " . parent::$sql['count'] . " SQL queries in " . Util::formatTime(parent::$sql['time'] * 1000) . "\n";
+ if ($this instanceof ICache && static::$cacheStats)
+ {
+ [$mode, $set, $lifetime] = static::$cacheStats;
+ echo " * stored in " . ($mode == CACHE_MODE_MEMCACHED ? 'Memcached' : 'filecache') . ":\n";
+ echo " * + ".date('c', $set) . ' - ' . Util::formatTimeDiff($set) . "\n";
+ echo " * - ".date('c', $set + $lifetime) . ' - in '.Util::formatTime(($set + $lifetime - time()) * 1000) . "\n";
+ }
+ echo " */\n\n";
+ }
+
+ echo $out;
+ }
+}
+
+?>
diff --git a/includes/dbtypes/user.class.php b/includes/dbtypes/user.class.php
index de383a06..59d346ef 100644
--- a/includes/dbtypes/user.class.php
+++ b/includes/dbtypes/user.class.php
@@ -56,6 +56,8 @@ class UserList extends DBTypeList
public function getListviewData() : array { return []; }
public function renderTooltip() : ?string { return null; }
+
+ public static function getName($id) : ?LocString { return null; }
}
?>
diff --git a/includes/defines.php b/includes/defines.php
index 0dfb320b..86f57e90 100644
--- a/includes/defines.php
+++ b/includes/defines.php
@@ -17,7 +17,7 @@ define('TDB_WORLD_EXPECTED_VER', 24041);
// as of 01.01.2024 https://www.wowhead.com/wotlk/de/spell=40120/{seo}
// https://www.wowhead.com/wotlk/es/search=vuelo
-define('WOWHEAD_LINK', 'https://www.wowhead.com/wotlk/%s/%s=%s');
+define('WOWHEAD_LINK', 'https://www.wowhead.com/wotlk/%s/%s%s');
define('LOG_LEVEL_ERROR', 1);
define('LOG_LEVEL_WARN', 2);
@@ -25,7 +25,8 @@ define('LOG_LEVEL_INFO', 3);
define('MIME_TYPE_TEXT', 'Content-Type: text/plain; charset=utf-8');
define('MIME_TYPE_XML', 'Content-Type: text/xml; charset=utf-8');
-define('MIME_TYPE_JSON', 'Content-Type: application/x-javascript; charset=utf-8');
+define('MIME_TYPE_JAVASCRIPT', 'Content-Type: application/x-javascript; charset=utf-8');
+define('MIME_TYPE_JSON', 'Content-Type: application/json; charset=utf-8');
define('MIME_TYPE_OPENSEARCH', 'Content-Type: application/x-suggestions+json; charset=utf-8');
define('MIME_TYPE_RSS', 'Content-Type: application/rss+xml; charset=utf-8');
define('MIME_TYPE_JPEG', 'Content-Type: image/jpeg');
diff --git a/includes/kernel.php b/includes/kernel.php
index 20ef2719..4d444d5a 100644
--- a/includes/kernel.php
+++ b/includes/kernel.php
@@ -68,6 +68,8 @@ spl_autoload_register(function (string $class) : void
require_once 'includes/components/'.strtolower($class).'.class.php';
else if (file_exists('includes/components/frontend/'.strtolower($class).'.class.php'))
require_once 'includes/components/frontend/'.strtolower($class).'.class.php';
+ else if (file_exists('includes/components/response/'.strtolower($class).'.class.php'))
+ require_once 'includes/components/response/'.strtolower($class).'.class.php';
});
// TC systems in components
@@ -121,41 +123,6 @@ spl_autoload_register(function (string $class) : void
throw new \Exception('could not register type class: '.$cl);
});
-// endpoint loader
-spl_autoload_register(function (string $class) : void
-{
- if ($i = strrpos($class, '\\'))
- $class = substr($class, $i + 1);
-
- if (preg_match('/[^\w]/i', $class))
- return;
-
- $class = strtolower($class);
-
- if (stripos($class, 'ajax') === 0) // handles ajax and jsonp requests
- {
- if (file_exists('includes/ajaxHandler/'.strtr($class, ['ajax' => '']).'.class.php'))
- {
- require_once 'includes/ajaxHandler/ajaxHandler.class.php';
- require_once 'includes/ajaxHandler/'.strtr($class, ['ajax' => '']).'.class.php';
- }
- else
- throw new \Exception('could not register ajaxHandler class: '.$class);
-
- return;
- }
- else if (stripos($class, 'page')) // handles templated pages
- {
- if (file_exists('pages/'.strtr($class, ['page' => '']).'.php'))
- {
- require_once 'pages/genericPage.class.php';
- require_once 'pages/'.strtr($class, ['page' => '']).'.php';
- }
- else if ($class == 'genericpage') // may be called directly in fatal error case
- require_once 'pages/genericPage.class.php';
- }
-});
-
set_error_handler(function(int $errNo, string $errStr, string $errFile, int $errLine) : bool
{
// either from test function or handled separately
@@ -208,13 +175,17 @@ set_exception_handler(function (\Throwable $e) : void
else
{
Util::addNote('Exception - '.$e->getMessage().' @ '.$e->getFile(). ':'.$e->getLine()."\n".$e->getTraceAsString(), U_GROUP_EMPLOYEE, LOG_LEVEL_ERROR);
- (new GenericPage())->error();
+ (new TemplateResponse())->generateError();
}
});
// handle fatal errors
register_shutdown_function(function() : void
{
+ // defer undisplayed error/exception notes
+ if (!CLI && ($n = Util::getNotes()))
+ $_SESSION['notes'][] = [$n[0], $n[1], 'Defered issues from previous request'];
+
if ($e = error_get_last())
{
if (DB::isConnected(DB_AOWOW))
@@ -263,7 +234,7 @@ if (!CLI)
{
// not displaying the brb gnomes as static_host is missing, but eh...
if (!DB::isConnected(DB_AOWOW) || !DB::isConnected(DB_WORLD) || !Cfg::get('HOST_URL') || !Cfg::get('STATIC_URL'))
- (new GenericPage())->maintenance();
+ (new TemplateResponse())->generateMaintenance();
// Setup Session
$cacheDir = Cfg::get('SESSION_CACHE_DIR');
@@ -275,7 +246,7 @@ if (!CLI)
if (!session_start())
{
trigger_error('failed to start session', E_USER_ERROR);
- (new GenericPage())->error();
+ (new TemplateResponse())->generateError();
}
if (User::init())
@@ -300,12 +271,6 @@ if (!CLI)
if (DB::isConnected(DB_CHARACTERS . $idx))
DB::Characters($idx)->setLogger(DB::profiler(...));
}
-
- // parse page-parameters .. sanitize before use!
- $str = explode('&', $_SERVER['QUERY_STRING'] ?? '', 2)[0];
- $_ = explode('=', $str, 2);
- $pageCall = mb_strtolower($_[0]);
- $pageParam = $_[1] ?? '';
}
?>
diff --git a/includes/utilities.php b/includes/utilities.php
index 43d00367..abb5bd21 100644
--- a/includes/utilities.php
+++ b/includes/utilities.php
@@ -18,104 +18,6 @@ class SimpleXML extends \SimpleXMLElement
}
}
-trait TrRequestData
-{
- // const in trait supported in php8.2+
- public const PATTERN_TEXT_LINE = '/[\p{Cc}\p{Cf}\p{Co}\p{Cs}\p{Cn}]/ui';
- public const PATTERN_TEXT_BLOB = '/[\x00-\x09\x0B-\x1F\p{Cf}\p{Co}\p{Cs}\p{Cn}]/ui';
-
- protected $_get = []; // fill with variables you that are going to be used; eg:
- protected $_post = []; // 'id' => ['filter' => FILTER_CALLBACK, 'options' => 'Aowow\AjaxHandler::checkIdList']
- protected $_cookie = [];
-
- private $filtered = false;
-
- private function initRequestData() : void
- {
- if ($this->filtered)
- return;
-
- // php bug? If INPUT_X is empty, filter_input_array returns null/fails
- // only really relevant for INPUT_POST
- // manuall set everything null in this case
-
- if ($this->_post)
- {
- if ($_POST)
- $this->_post = filter_input_array(INPUT_POST, $this->_post);
- else
- $this->_post = array_fill_keys(array_keys($this->_post), null);
- }
-
- if ($this->_get)
- {
- if ($_GET)
- $this->_get = filter_input_array(INPUT_GET, $this->_get);
- else
- $this->_get = array_fill_keys(array_keys($this->_get), null);
- }
-
- if ($this->_cookie)
- {
- if ($_COOKIE)
- $this->_cookie = filter_input_array(INPUT_COOKIE, $this->_cookie);
- else
- $this->_cookie = array_fill_keys(array_keys($this->_cookie), null);
- }
-
- $this->filtered = true;
- }
-
- private static function checkEmptySet(string $val) : bool
- {
- return $val === ''; // parameter is expected to be empty
- }
-
- private static function checkInt(string $val) : int
- {
- if (preg_match('/^-?\d+$/', $val))
- return intVal($val);
-
- return 0;
- }
-
- private static function checkIdList(string $val) : array
- {
- if (preg_match('/^-?\d+(,-?\d+)*$/', $val))
- return array_map('intVal', explode(',', $val));
-
- return [];
- }
-
- private static function checkIntArray(string $val) : array
- {
- if (preg_match('/^-?\d+(:-?\d+)*$/', $val))
- return array_map('intVal', explode(':', $val));
-
- return [];
- }
-
- private static function checkIdListUnsigned(string $val) : array
- {
- if (preg_match('/^\d+(,\d+)*$/', $val))
- return array_map('intVal', explode(',', $val));
-
- return [];
- }
-
- private static function checkTextLine(string $val) : string
- {
- // trim non-printable chars
- return preg_replace(self::PATTERN_TEXT_LINE, '', $val);
- }
-
- private static function checkTextBlob(string $val) : string
- {
- // trim non-printable chars
- return preg_replace(self::PATTERN_TEXT_BLOB, '', $val);
- }
-}
-
abstract class Util
{
@@ -175,9 +77,7 @@ abstract class Util
public static $guideratingString = " $(document).ready(function() {\n $('#guiderating').append(GetStars(%.10F, %s, %u, %u));\n });";
- public static $expansionString = array( // 3 & 4 unused .. obviously
- null, 'bc', 'wotlk', 'cata', 'mop'
- );
+ public static $expansionString = [null, 'bc', 'wotlk'];
public static $tcEncoding = '0zMcmVokRsaqbdrfwihuGINALpTjnyxtgevElBCDFHJKOPQSUWXYZ123456789';
private static $notes = [];
@@ -191,7 +91,7 @@ abstract class Util
{
$notes = [];
$severity = LOG_LEVEL_INFO;
- foreach (self::$notes as [$note, $uGroup, $level])
+ foreach (self::$notes as $k => [$note, $uGroup, $level])
{
if ($uGroup && !User::isInGroup($uGroup))
continue;
@@ -200,6 +100,7 @@ abstract class Util
$severity = $level;
$notes[] = $note;
+ unset(self::$notes[$k]);
}
return [$notes, $severity];
@@ -309,14 +210,14 @@ abstract class Util
if ($ms)
return $ms."\u{00A0}".Lang::timeUnits('ab', 7);
- return '0 '.Lang::timeUnits('ab', 6);
+ return "0\u{00A0}".Lang::timeUnits('ab', 6);
}
else
{
$_ = $d + $h / 24;
if ($_ > 1 && !($_ % 365)) // whole years
- return round(($d + $h / 24) / 364, 2)."\u{00A0}".Lang::timeUnits($d / 364 == 1 && !$h ? 'sg' : 'pl', 0);
- if ($_ > 1 && !($_ % 30)) // whole month
+ return round(($d + $h / 24) / 365, 2)."\u{00A0}".Lang::timeUnits($d / 365 == 1 && !$h ? 'sg' : 'pl', 0);
+ if ($_ > 1 && !($_ % 30)) // whole months
return round(($d + $h / 24) / 30, 2)."\u{00A0}".Lang::timeUnits($d / 30 == 1 && !$h ? 'sg' : 'pl', 1);
if ($_ > 1 && !($_ % 7)) // whole weeks
return round(($d + $h / 24) / 7, 2)."\u{00A0}".Lang::timeUnits($d / 7 == 1 && !$h ? 'sg' : 'pl', 2);
@@ -329,15 +230,15 @@ abstract class Util
if ($s)
return round($s + $ms / 1000, 2)."\u{00A0}".Lang::timeUnits($s == 1 && !$ms ? 'sg' : 'pl', 6);
if ($ms)
- return $ms." ".Lang::timeUnits($ms == 1 ? 'sg' : 'pl', 7);
+ return $ms."\u{00A0}".Lang::timeUnits($ms == 1 ? 'sg' : 'pl', 7);
- return '0 '.Lang::timeUnits('pl', 6);
+ return "0\u{00A0}".Lang::timeUnits('pl', 6);
}
}
public static function formatTimeDiff(int $sec) : string
{
- $delta = time() - $sec;
+ $delta = abs(time() - $sec);
[, $s, $m, $h, $d] = self::parseTime($delta * 1000);
@@ -476,21 +377,23 @@ abstract class Util
));
}
- public static function defStatic($data)
+ public static function defStatic(array|string $data) : array|string
{
if (is_array($data))
{
foreach ($data as &$v)
- $v = self::defStatic($v);
+ if ($v)
+ $v = self::defStatic($v);
return $data;
}
return strtr($data, array(
- '
diff --git a/template/bricks/footer.tpl.php b/template/bricks/footer.tpl.php
index b63bfba3..73eb9586 100644
--- a/template/bricks/footer.tpl.php
+++ b/template/bricks/footer.tpl.php
@@ -1,23 +1,27 @@
-
+