diff options
Diffstat (limited to 'engine')
34 files changed, 1533 insertions, 1470 deletions
diff --git a/engine/AjaxErrorResponse.php b/engine/AjaxErrorResponse.php deleted file mode 100644 index a1fe381..0000000 --- a/engine/AjaxErrorResponse.php +++ /dev/null @@ -1,9 +0,0 @@ -<?php - -class AjaxErrorResponse extends AjaxResponse { - - public function __construct(string $error, int $code = 200) { - parent::__construct(code: $code, body: json_encode(['error' => $error], JSON_UNESCAPED_UNICODE)); - } - -}
\ No newline at end of file diff --git a/engine/AjaxOkResponse.php b/engine/AjaxOkResponse.php deleted file mode 100644 index 253a563..0000000 --- a/engine/AjaxOkResponse.php +++ /dev/null @@ -1,9 +0,0 @@ -<?php - -class AjaxOkResponse extends AjaxResponse { - - public function __construct($data) { - parent::__construct(code: 200, body: json_encode(['response' => $data], JSON_UNESCAPED_UNICODE)); - } - -}
\ No newline at end of file diff --git a/engine/AjaxResponse.php b/engine/AjaxResponse.php deleted file mode 100644 index 931e5e7..0000000 --- a/engine/AjaxResponse.php +++ /dev/null @@ -1,13 +0,0 @@ -<?php - -class AjaxResponse extends Response { - - public function __construct(...$args) { - parent::__construct(...$args); - $this->addHeader('Content-Type: application/json; charset=utf-8'); - $this->addHeader('Cache-Control: no-cache, must-revalidate'); - $this->addHeader('Pragma: no-cache'); - $this->addHeader('Content-Type: application/json; charset=utf-8'); - } - -}
\ No newline at end of file diff --git a/engine/InputType.php b/engine/InputType.php deleted file mode 100644 index 401f7ca..0000000 --- a/engine/InputType.php +++ /dev/null @@ -1,8 +0,0 @@ -<?php - -enum InputType: string { - case INT = 'i'; - case FLOAT = 'f'; - case BOOL = 'b'; - case STRING = 's'; -}
\ No newline at end of file diff --git a/engine/LangAccess.php b/engine/LangAccess.php deleted file mode 100644 index db55b3b..0000000 --- a/engine/LangAccess.php +++ /dev/null @@ -1,8 +0,0 @@ -<?php - -interface LangAccess { - - public function lang(...$args): string; - public function langRaw(string $key, ...$args); - -}
\ No newline at end of file diff --git a/engine/LangData.php b/engine/LangData.php deleted file mode 100644 index 6f108f2..0000000 --- a/engine/LangData.php +++ /dev/null @@ -1,108 +0,0 @@ -<?php - -class LangData implements ArrayAccess { - - private static ?LangData $instance = null; - protected array $data = []; - protected array $loaded = []; - - public static function getInstance(): static { - if (is_null(self::$instance)) { - self::$instance = new self(); - self::$instance->load('en'); - } - return self::$instance; - } - - public function __invoke(string $key, ...$args) { - $val = $this[$key]; - return empty($args) ? $val : sprintf($val, ...$args); - } - - public function load(string $name) { - if (array_key_exists($name, $this->loaded)) - return; - - $data = require_once ROOT."/lang/{$name}.php"; - $this->data = array_replace($this->data, - $data); - - $this->loaded[$name] = true; - } - - public function offsetSet(mixed $offset, mixed $value): void { - logError(__METHOD__ . ': not implemented'); - } - - public function offsetExists($offset): bool { - return isset($this->data[$offset]); - } - - public function offsetUnset(mixed $offset): void { - logError(__METHOD__ . ': not implemented'); - } - - public function offsetGet(mixed $offset): mixed { - return $this->data[$offset] ?? '{' . $offset . '}'; - } - - public function search(string $regexp): array|false { - return preg_grep($regexp, array_keys($this->data)); - } - - // function plural(array $s, int $n, array $opts = []) { - // $opts = array_merge([ - // 'format' => true, - // 'format_delim' => ' ', - // 'lang' => 'en', - // ], $opts); - // - // switch ($opts['lang']) { - // case 'ru': - // $n = $n % 100; - // if ($n > 19) - // $n %= 10; - // - // if ($n == 1) { - // $word = 0; - // } else if ($n >= 2 && $n <= 4) { - // $word = 1; - // } else if ($n == 0 && count($s) == 4) { - // $word = 3; - // } else { - // $word = 2; - // } - // break; - // - // default: - // if (!$n && count($s) == 4) { - // $word = 3; - // } else { - // $word = (int)!!$n; - // } - // break; - // } - // - // // if zero - // if ($word == 3) - // return $s[3]; - // - // if (is_callable($opts['format'])) { - // $num = $opts['format']($n); - // } else if ($opts['format'] === true) { - // $num = formatNumber($n, $opts['format_delim']); - // } - // - // return sprintf($s[$word], $num); - // } - // - // function formatNumber(int $num, string $delim = ' ', bool $short = false): string { - // if ($short) { - // if ($num >= 1000000) - // return floor($num / 1000000).'m'; - // if ($num >= 1000) - // return floor($num / 1000).'k'; - // } - // return number_format($num, 0, '.', $delim); - // } -} diff --git a/engine/Model.php b/engine/Model.php deleted file mode 100644 index e80b09d..0000000 --- a/engine/Model.php +++ /dev/null @@ -1,240 +0,0 @@ -<?php - -enum Type { - case STRING; - case INTEGER; - case FLOAT; - case ARRAY; - case BOOLEAN; - case JSON; - case SERIALIZED; -} - -abstract class Model { - - const DB_TABLE = null; - const DB_KEY = 'id'; - - protected static array $SpecCache = []; - - public static function create_instance(...$args) { - $cl = get_called_class(); - return new $cl(...$args); - } - - public function __construct(array $raw) { - if (!isset(self::$SpecCache[static::class])) { - list($fields, $model_name_map, $db_name_map) = static::get_spec(); - self::$SpecCache[static::class] = [ - 'fields' => $fields, - 'model_name_map' => $model_name_map, - 'db_name_map' => $db_name_map - ]; - } - - foreach (self::$SpecCache[static::class]['fields'] as $field) - $this->{$field['model_name']} = self::cast_to_type($field['type'], $raw[$field['db_name']]); - - if (is_null(static::DB_TABLE)) - trigger_error('class '.get_class($this).' doesn\'t have DB_TABLE defined'); - } - - public function edit(array $fields) { - $db = getDb(); - - $model_upd = []; - $db_upd = []; - - foreach ($fields as $name => $value) { - $index = self::$SpecCache[static::class]['db_name_map'][$name] ?? null; - if (is_null($index)) { - logError(__METHOD__.': field `'.$name.'` not found in '.static::class); - continue; - } - - $field = self::$SpecCache[static::class]['fields'][$index]; - switch ($field['type']) { - case Type::ARRAY: - if (is_array($value)) { - $db_upd[$name] = implode(',', $value); - $model_upd[$field['model_name']] = $value; - } else { - logError(__METHOD__.': field `'.$name.'` is expected to be array. skipping.'); - } - break; - - case Type::INTEGER: - $value = (int)$value; - $db_upd[$name] = $value; - $model_upd[$field['model_name']] = $value; - break; - - case Type::FLOAT: - $value = (float)$value; - $db_upd[$name] = $value; - $model_upd[$field['model_name']] = $value; - break; - - case Type::BOOLEAN: - $db_upd[$name] = $value ? 1 : 0; - $model_upd[$field['model_name']] = $value; - break; - - case Type::JSON: - $db_upd[$name] = json_encode($value, JSON_UNESCAPED_UNICODE); - $model_upd[$field['model_name']] = $value; - break; - - case Type::SERIALIZED: - $db_upd[$name] = serialize($value); - $model_upd[$field['model_name']] = $value; - break; - - default: - $value = (string)$value; - $db_upd[$name] = $value; - $model_upd[$field['model_name']] = $value; - break; - } - } - - if (!empty($db_upd) && !$db->update(static::DB_TABLE, $db_upd, static::DB_KEY."=?", $this->get_id())) { - logError(__METHOD__.': failed to update database'); - return; - } - - if (!empty($model_upd)) { - foreach ($model_upd as $name => $value) - $this->{$name} = $value; - } - } - - public function get_id() { - return $this->{to_camel_case(static::DB_KEY)}; - } - - public function as_array(array $fields = [], array $custom_getters = []): array { - if (empty($fields)) - $fields = array_keys(static::$SpecCache[static::class]['db_name_map']); - - $array = []; - foreach ($fields as $field) { - if (isset($custom_getters[$field]) && is_callable($custom_getters[$field])) { - $array[$field] = $custom_getters[$field](); - } else { - $array[$field] = $this->{to_camel_case($field)}; - } - } - - return $array; - } - - protected static function cast_to_type(Type $type, $value) { - switch ($type) { - case Type::BOOLEAN: - return (bool)$value; - - case Type::INTEGER: - return (int)$value; - - case Type::FLOAT: - return (float)$value; - - case Type::ARRAY: - return array_filter(explode(',', $value)); - - case Type::JSON: - $val = json_decode($value, true); - if (!$val) - $val = null; - return $val; - - case Type::SERIALIZED: - $val = unserialize($value); - if ($val === false) - $val = null; - return $val; - - default: - return (string)$value; - } - } - - protected static function get_spec(): array { - $rc = new ReflectionClass(static::class); - $props = $rc->getProperties(ReflectionProperty::IS_PUBLIC); - - $list = []; - $index = 0; - - $model_name_map = []; - $db_name_map = []; - - foreach ($props as $prop) { - if ($prop->isStatic()) - continue; - - $name = $prop->getName(); - if (str_starts_with($name, '_')) - continue; - - $type = $prop->getType(); - $phpdoc = $prop->getDocComment(); - - $mytype = null; - if (!$prop->hasType() && !$phpdoc) - $mytype = Type::STRING; - else { - $typename = $type->getName(); - switch ($typename) { - case 'string': - $mytype = Type::STRING; - break; - case 'int': - $mytype = Type::INTEGER; - break; - case 'float': - $mytype = Type::FLOAT; - break; - case 'array': - $mytype = Type::ARRAY; - break; - case 'bool': - $mytype = Type::BOOLEAN; - break; - } - - if ($phpdoc != '') { - $pos = strpos($phpdoc, '@'); - if ($pos === false) - continue; - - if (substr($phpdoc, $pos+1, 4) == 'json') - $mytype = Type::JSON; - else if (substr($phpdoc, $pos+1, 5) == 'array') - $mytype = Type::ARRAY; - else if (substr($phpdoc, $pos+1, 10) == 'serialized') - $mytype = Type::SERIALIZED; - } - } - - if (is_null($mytype)) - logError(__METHOD__.": ".$name." is still null in ".static::class); - - $dbname = from_camel_case($name); - $list[] = [ - 'type' => $mytype, - 'model_name' => $name, - 'db_name' => $dbname - ]; - - $model_name_map[$name] = $index; - $db_name_map[$dbname] = $index; - - $index++; - } - - return [$list, $model_name_map, $db_name_map]; - } - -} diff --git a/engine/RedirectResponse.php b/engine/RedirectResponse.php deleted file mode 100644 index 4526c6c..0000000 --- a/engine/RedirectResponse.php +++ /dev/null @@ -1,10 +0,0 @@ -<?php - -class RedirectResponse extends Response { - - public function __construct(string $url, int $code = 302) { - parent::__construct($code); - $this->addHeader('Location: '.$url); - } - -}
\ No newline at end of file diff --git a/engine/RequestDispatcher.php b/engine/RequestDispatcher.php deleted file mode 100644 index adb61c9..0000000 --- a/engine/RequestDispatcher.php +++ /dev/null @@ -1,84 +0,0 @@ -<?php - -class RequestDispatcher { - - public function __construct( - protected Router $router - ) {} - - public function dispatch(): void { - global $config; - - try { - if (!in_array($_SERVER['REQUEST_METHOD'], ['POST', 'GET'])) - throw new NotImplementedException('Method '.$_SERVER['REQUEST_METHOD'].' not implemented'); - - $route = $this->router->find(self::path()); - if ($route === null) - throw new NotFoundException('Route not found'); - - $route = preg_split('/ +/', $route); - $handler_class = $route[0]; - if (($pos = strrpos($handler_class, '/')) !== false) { - $class_name = substr($handler_class, $pos+1); - $class_name = ucfirst(to_camel_case($class_name)); - $handler_class = str_replace('/', '\\', substr($handler_class, 0, $pos)).'\\'.$class_name; - } else { - $handler_class = ucfirst(to_camel_case($handler_class)); - } - $handler_class = 'handler\\'.$handler_class; - - if (!class_exists($handler_class)) - throw new NotFoundException($config['is_dev'] ? 'Handler class "'.$handler_class.'" not found' : ''); - - $router_input = []; - if (count($route) > 1) { - for ($i = 1; $i < count($route); $i++) { - $var = $route[$i]; - list($k, $v) = explode('=', $var); - $router_input[trim($k)] = trim($v); - } - } - - $skin = new Skin(); - $skin->static[] = 'css/common.css'; - $skin->static[] = 'js/common.js'; - - $lang = LangData::getInstance(); - $skin->addLangKeys($lang->search('/^theme_/')); - - /** @var RequestHandler $handler */ - $handler = new $handler_class($skin, $lang, $router_input); - $resp = $handler->beforeDispatch(); - if ($resp instanceof Response) { - $resp->send(); - return; - } - - $resp = call_user_func([$handler, strtolower($_SERVER['REQUEST_METHOD'])]); - } catch (NotFoundException $e) { - $resp = $this->getErrorResponse($e, 'not_found'); - } catch (ForbiddenException $e) { - $resp = $this->getErrorResponse($e, 'forbidden'); - } catch (NotImplementedException $e) { - $resp = $this->getErrorResponse($e, 'not_implemented'); - } catch (UnauthorizedException $e) { - $resp = $this->getErrorResponse($e, 'unauthorized'); - } - $resp->send(); - } - - protected function getErrorResponse(Exception $e, string $render_function): Response { - $ctx = new SkinContext('\\skin\\error'); - $html = call_user_func([$ctx, $render_function], $e->getMessage()); - return new Response($e->getCode(), $html); - } - - public static function path(): string { - $uri = $_SERVER['REQUEST_URI']; - if (($pos = strpos($uri, '?')) !== false) - $uri = substr($uri, 0, $pos); - return $uri; - } - -} diff --git a/engine/RequestHandler.php b/engine/RequestHandler.php deleted file mode 100644 index a9dfccd..0000000 --- a/engine/RequestHandler.php +++ /dev/null @@ -1,56 +0,0 @@ -<?php - -class RequestHandler { - - public function __construct( - protected Skin $skin, - protected LangData $lang, - protected array $routerInput - ) {} - - public function beforeDispatch(): ?Response { - return null; - } - - public function get(): Response { - throw new NotImplementedException(); - } - - public function post(): Response { - throw new NotImplementedException(); - } - - public function input(string $input): array { - $input = preg_split('/,\s+?/', $input, -1, PREG_SPLIT_NO_EMPTY); - $ret = []; - foreach ($input as $var) { - if (($pos = strpos($var, ':')) !== false) { - $type = InputType::from(substr($var, 0, $pos)); - $name = trim(substr($var, $pos+1)); - } else { - $type = InputType::STRING; - $name = $var; - } - - $value = $this->routerInput[$name] ?? $_REQUEST[$name] ?? ''; - switch ($type) { - case InputType::INT: - $value = (int)$value; - break; - case InputType::FLOAT: - $value = (float)$value; - break; - case InputType::BOOL: - $value = (bool)$value; - break; - } - - $ret[] = $value; - } - return $ret; - } - - protected function isRetina(): bool { - return isset($_COOKIE['is_retina']) && $_COOKIE['is_retina']; - } -} diff --git a/engine/Response.php b/engine/Response.php deleted file mode 100644 index 6250063..0000000 --- a/engine/Response.php +++ /dev/null @@ -1,28 +0,0 @@ -<?php - -class Response { - - protected array $headers = []; - - public function __construct( - public int $code = 200, - public ?string $body = null - ) {} - - public function send(): void { - $this->setHeaders(); - if ($this->code == 200 || $this->code >= 400) - echo $this->body; - } - - public function addHeader(string $header): void { - $this->headers[] = $header; - } - - public function setHeaders(): void { - http_response_code($this->code); - foreach ($this->headers as $header) - header($header); - } - -}
\ No newline at end of file diff --git a/engine/Router.php b/engine/Router.php deleted file mode 100644 index 0cb761d..0000000 --- a/engine/Router.php +++ /dev/null @@ -1,165 +0,0 @@ -<?php - -class Router { - - protected array $routes = [ - 'children' => [], - 're_children' => [] - ]; - - public function add($template, $value) { - if ($template == '') - return $this; - - // expand {enum,erat,ions} - $templates = [[$template, $value]]; - if (preg_match_all('/\{([\w\d_\-,]+)\}/', $template, $matches)) { - foreach ($matches[1] as $match_index => $variants) { - $variants = explode(',', $variants); - $variants = array_map('trim', $variants); - $variants = array_filter($variants, function($s) { return $s != ''; }); - - for ($i = 0; $i < count($templates); ) { - list($template, $value) = $templates[$i]; - $new_templates = []; - foreach ($variants as $variant_index => $variant) { - $new_templates[] = [ - str_replace_once($matches[0][$match_index], $variant, $template), - str_replace('${'.($match_index+1).'}', $variant, $value) - ]; - } - array_splice($templates, $i, 1, $new_templates); - $i += count($new_templates); - } - } - } - - // process all generated routes - foreach ($templates as $template) { - list($template, $value) = $template; - - $start_pos = 0; - $parent = &$this->routes; - $template_len = strlen($template); - - while ($start_pos < $template_len) { - $slash_pos = strpos($template, '/', $start_pos); - if ($slash_pos !== false) { - $part = substr($template, $start_pos, $slash_pos-$start_pos+1); - $start_pos = $slash_pos+1; - } else { - $part = substr($template, $start_pos); - $start_pos = $template_len; - } - - $parent = &$this->_addRoute($parent, $part, - $start_pos < $template_len ? null : $value); - } - } - - return $this; - } - - protected function &_addRoute(&$parent, $part, $value = null) { - $par_pos = strpos($part, '('); - $is_regex = $par_pos !== false && ($par_pos == 0 || $part[$par_pos-1] != '\\'); - - $children_key = !$is_regex ? 'children' : 're_children'; - - if (isset($parent[$children_key][$part])) { - if (is_null($value)) { - $parent = &$parent[$children_key][$part]; - } else { - if (!isset($parent[$children_key][$part]['value'])) { - $parent[$children_key][$part]['value'] = $value; - } else { - trigger_error(__METHOD__.': route is already defined'); - } - } - return $parent; - } - - $child = [ - 'children' => [], - 're_children' => [] - ]; - if (!is_null($value)) - $child['value'] = $value; - - $parent[$children_key][$part] = $child; - return $parent[$children_key][$part]; - } - - public function find($uri) { - if ($uri != '/' && $uri[0] == '/') { - $uri = substr($uri, 1); - } - $start_pos = 0; - $parent = &$this->routes; - $uri_len = strlen($uri); - $matches = []; - - while ($start_pos < $uri_len) { - $slash_pos = strpos($uri, '/', $start_pos); - if ($slash_pos !== false) { - $part = substr($uri, $start_pos, $slash_pos-$start_pos+1); - $start_pos = $slash_pos+1; - } else { - $part = substr($uri, $start_pos); - $start_pos = $uri_len; - } - - $found = false; - if (isset($parent['children'][$part])) { - $parent = &$parent['children'][$part]; - $found = true; - } else if (!empty($parent['re_children'])) { - foreach ($parent['re_children'] as $re => &$child) { - $exp = '#^'.$re.'$#'; - $re_result = preg_match($exp, $part, $match); - if ($re_result === false) { - logError(__METHOD__.": regex $exp failed"); - continue; - } - - if ($re_result) { - if (count($match) > 1) { - $matches = array_merge($matches, array_slice($match, 1)); - } - $parent = &$child; - $found = true; - break; - } - } - } - - if (!$found) - return false; - } - - if (!isset($parent['value'])) - return false; - - $value = $parent['value']; - if (!empty($matches)) { - foreach ($matches as $i => $match) { - $needle = '$('.($i+1).')'; - $pos = strpos($value, $needle); - if ($pos !== false) { - $value = substr_replace($value, $match, $pos, strlen($needle)); - } - } - } - - return $value; - } - - public function load($routes) { - $this->routes = $routes; - } - - public function dump(): array { - return $this->routes; - } - -} diff --git a/engine/Skin.php b/engine/Skin.php deleted file mode 100644 index b1523e6..0000000 --- a/engine/Skin.php +++ /dev/null @@ -1,63 +0,0 @@ -<?php - -class Skin { - - public string $title = 'title'; - public array $static = []; - public array $meta = []; - - protected array $langKeys = []; - protected array $options = [ - 'full_width' => false, - 'wide' => false, - 'dynlogo_enabled' => true, - 'logo_path_map' => [], - 'logo_link_map' => [], - ]; - - public function renderPage($f, ...$vars): Response { - $f = '\\skin\\'.str_replace('/', '\\', $f); - $ctx = new SkinContext(substr($f, 0, ($pos = strrpos($f, '\\')))); - $body = call_user_func_array([$ctx, substr($f, $pos+1)], $vars); - if (is_array($body)) - list($body, $js) = $body; - else - $js = null; - - $theme = themes::getUserTheme(); - if ($theme != 'auto' && !themes::themeExists($theme)) - $theme = 'auto'; - - $layout_ctx = new SkinContext('\\skin\\base'); - $lang = $this->getLang(); - $lang = !empty($lang) ? json_encode($lang, JSON_UNESCAPED_UNICODE) : ''; - return new Response(200, $layout_ctx->layout( - static: $this->static, - theme: $theme, - title: $this->title, - opts: $this->options, - js: $js, - meta: $this->meta, - unsafe_lang: $lang, - unsafe_body: $body, - exec_time: exectime() - )); - } - - public function addLangKeys(array $keys): void { - $this->langKeys = array_merge($this->langKeys, $keys); - } - - protected function getLang(): array { - $lang = []; - $ld = LangData::getInstance(); - foreach ($this->langKeys as $key) - $lang[$key] = $ld[$key]; - return $lang; - } - - public function setOptions(array $options): void { - $this->options = array_merge($this->options, $options); - } - -} diff --git a/engine/SkinBase.php b/engine/SkinBase.php deleted file mode 100644 index b50c172..0000000 --- a/engine/SkinBase.php +++ /dev/null @@ -1,22 +0,0 @@ -<?php - -class SkinBase implements LangAccess { - - protected static LangData $ld; - - public static function __constructStatic(): void { - self::$ld = LangData::getInstance(); - } - - public function lang(...$args): string { - return htmlescape($this->langRaw(...$args)); - } - - public function langRaw(string $key, ...$args) { - $val = self::$ld[$key]; - return empty($args) ? $val : sprintf($val, ...$args); - } - -} - -SkinBase::__constructStatic();
\ No newline at end of file diff --git a/engine/SkinContext.php b/engine/SkinContext.php deleted file mode 100644 index c395453..0000000 --- a/engine/SkinContext.php +++ /dev/null @@ -1,118 +0,0 @@ -<?php - -class SkinContext extends SkinBase { - - protected string $ns; - protected array $data = []; - - public function __construct(string $namespace) { - $this->ns = $namespace; - require_once ROOT.str_replace('\\', DIRECTORY_SEPARATOR, $namespace).'.skin.php'; - } - - public function __call($name, array $arguments) { - $plain_args = array_is_list($arguments); - - $fn = $this->ns.'\\'.$name; - $refl = new ReflectionFunction($fn); - $fparams = $refl->getParameters(); - assert(count($fparams) == count($arguments)+1, "$fn: invalid number of arguments (".count($fparams)." != ".(count($arguments)+1).")"); - - foreach ($fparams as $n => $param) { - if ($n == 0) - continue; // skip $ctx - - $key = $plain_args ? $n-1 : $param->name; - if (!$plain_args && !array_key_exists($param->name, $arguments)) { - if (!$param->isDefaultValueAvailable()) - throw new InvalidArgumentException('argument '.$param->name.' not found'); - else - continue; - } - - if (is_string($arguments[$key]) || $arguments[$key] instanceof SkinString) { - if (is_string($arguments[$key])) - $arguments[$key] = new SkinString($arguments[$key]); - - if (($pos = strpos($param->name, '_')) !== false) { - $mod_type = match(substr($param->name, 0, $pos)) { - 'unsafe' => SkinStringModificationType::RAW, - 'urlencoded' => SkinStringModificationType::URL, - 'jsonencoded' => SkinStringModificationType::JSON, - 'addslashes' => SkinStringModificationType::ADDSLASHES, - default => SkinStringModificationType::HTML - }; - } else { - $mod_type = SkinStringModificationType::HTML; - } - $arguments[$key]->setModType($mod_type); - } - } - - array_unshift($arguments, $this); - return call_user_func_array($fn, $arguments); - } - - public function &__get(string $name) { - $fn = $this->ns.'\\'.$name; - if (function_exists($fn)) { - $f = [$this, $name]; - return $f; - } - - if (array_key_exists($name, $this->data)) - return $this->data[$name]; - } - - public function __set(string $name, $value) { - $this->data[$name] = $value; - } - - public function if_not($cond, $callback, ...$args) { - return $this->_if_condition(!$cond, $callback, ...$args); - } - - public function if_true($cond, $callback, ...$args) { - return $this->_if_condition($cond, $callback, ...$args); - } - - public function if_admin($callback, ...$args) { - return $this->_if_condition(admin::isAdmin(), $callback, ...$args); - } - - public function if_dev($callback, ...$args) { - global $config; - return $this->_if_condition($config['is_dev'], $callback, ...$args); - } - - public function if_then_else($cond, $cb1, $cb2) { - return $cond ? $this->_return_callback($cb1) : $this->_return_callback($cb2); - } - - public function csrf($key): string { - return csrf::get($key); - } - - protected function _if_condition($condition, $callback, ...$args) { - if (is_string($condition) || $condition instanceof Stringable) - $condition = (string)$condition !== ''; - if ($condition) - return $this->_return_callback($callback, $args); - return ''; - } - - protected function _return_callback($callback, $args = []) { - if (is_callable($callback)) - return call_user_func_array($callback, $args); - else if (is_string($callback)) - return $callback; - } - - public function for_each(array $iterable, callable $callback) { - $html = ''; - foreach ($iterable as $k => $v) - $html .= call_user_func($callback, $v, $k); - return $html; - } - -} diff --git a/engine/SkinString.php b/engine/SkinString.php deleted file mode 100644 index 0f8f14d..0000000 --- a/engine/SkinString.php +++ /dev/null @@ -1,23 +0,0 @@ -<?php - -class SkinString implements Stringable { - - protected SkinStringModificationType $modType; - - public function __construct(protected string $string) {} - - public function setModType(SkinStringModificationType $modType) { - $this->modType = $modType; - } - - public function __toString(): string { - return match ($this->modType) { - SkinStringModificationType::HTML => htmlescape($this->string), - SkinStringModificationType::URL => urlencode($this->string), - SkinStringModificationType::JSON => json_encode($this->string, JSON_UNESCAPED_UNICODE), - SkinStringModificationType::ADDSLASHES => addslashes($this->string), - default => $this->string, - }; - } - -}
\ No newline at end of file diff --git a/engine/SkinStringModificationType.php b/engine/SkinStringModificationType.php deleted file mode 100644 index 7e750f2..0000000 --- a/engine/SkinStringModificationType.php +++ /dev/null @@ -1,9 +0,0 @@ -<?php - -enum SkinStringModificationType { - case RAW; - case URL; - case HTML; - case JSON; - case ADDSLASHES; -}
\ No newline at end of file diff --git a/engine/ansi.php b/engine/ansi.php deleted file mode 100644 index 311c837..0000000 --- a/engine/ansi.php +++ /dev/null @@ -1,34 +0,0 @@ -<?php - -namespace ansi; - -enum Color: int { - case BLACK = 0; - case RED = 1; - case GREEN = 2; - case YELLOW = 3; - case BLUE = 4; - case MAGENTA = 5; - case CYAN = 6; - case WHITE = 7; -} - -function wrap(string $text, - ?Color $fg = null, - ?Color $bg = null, - bool $bold = false, - bool $fg_bright = false, - bool $bg_bright = false): string { - $codes = []; - if (!is_null($fg)) - $codes[] = $fg->value + ($fg_bright ? 90 : 30); - if (!is_null($bg)) - $codes[] = $bg->value + ($bg_bright ? 100 : 40); - if ($bold) - $codes[] = 1; - - if (empty($codes)) - return $text; - - return "\033[".implode(';', $codes)."m".$text."\033[0m"; -}
\ No newline at end of file diff --git a/engine/csrf.php b/engine/csrf.php deleted file mode 100644 index 20ea919..0000000 --- a/engine/csrf.php +++ /dev/null @@ -1,22 +0,0 @@ -<?php - -class csrf { - - public static function check(string $key): void { - $user_csrf = self::get($key); - $sent_csrf = $_REQUEST['token'] ?? ''; - - if ($sent_csrf != $user_csrf) - throw new ForbiddenException("csrf error"); - } - - public static function get(string $key): string { - return self::getToken($_SERVER['REMOTE_ADDR'], $key); - } - - protected static function getToken(string $user_token, string $key): string { - global $config; - return substr(sha1($config['csrf_token'].$user_token.$key), 0, 20); - } - -}
\ No newline at end of file diff --git a/engine/database/CommonDatabase.php b/engine/database/CommonDatabase.php deleted file mode 100644 index 13ea79c..0000000 --- a/engine/database/CommonDatabase.php +++ /dev/null @@ -1,107 +0,0 @@ -<?php - -abstract class CommonDatabase { - - abstract public function query(string $sql, ...$args); - abstract public function escape(string $s): string; - abstract public function fetch($q): ?array; - abstract public function fetchAll($q): ?array; - abstract public function fetchRow($q): ?array; - abstract public function result($q, int $field = 0); - abstract public function insertId(): ?int; - abstract public function numRows($q): ?int; - - protected function prepareQuery(string $sql, ...$args): string { - global $config; - if (!empty($args)) { - $mark_count = substr_count($sql, '?'); - $positions = array(); - $last_pos = -1; - for ($i = 0; $i < $mark_count; $i++) { - $last_pos = strpos($sql, '?', $last_pos + 1); - $positions[] = $last_pos; - } - for ($i = $mark_count - 1; $i >= 0; $i--) { - $arg_val = $args[$i]; - if (is_null($arg_val)) { - $v = 'NULL'; - } else { - $v = '\''.$this->escape($arg_val) . '\''; - } - $sql = substr_replace($sql, $v, $positions[$i], 1); - } - } - if (!empty($config['db']['log'])) - logDebug(__METHOD__.': ', $sql); - return $sql; - } - - public function insert(string $table, array $fields) { - return $this->performInsert('INSERT', $table, $fields); - } - - public function replace(string $table, array $fields) { - return $this->performInsert('REPLACE', $table, $fields); - } - - protected function performInsert(string $command, string $table, array $fields) { - $names = []; - $values = []; - $count = 0; - foreach ($fields as $k => $v) { - $names[] = $k; - $values[] = $v; - $count++; - } - - $sql = "{$command} INTO `{$table}` (`" . implode('`, `', $names) . "`) VALUES (" . implode(', ', array_fill(0, $count, '?')) . ")"; - array_unshift($values, $sql); - - return $this->query(...$values); - } - - public function update(string $table, array $rows, ...$cond) { - $fields = []; - $args = []; - foreach ($rows as $row_name => $row_value) { - $fields[] = "`{$row_name}`=?"; - $args[] = $row_value; - } - $sql = "UPDATE `$table` SET ".implode(', ', $fields); - if (!empty($cond)) { - $sql .= " WHERE ".$cond[0]; - if (count($cond) > 1) - $args = array_merge($args, array_slice($cond, 1)); - } - return $this->query($sql, ...$args); - } - - public function multipleInsert(string $table, array $rows) { - list($names, $values) = $this->getMultipleInsertValues($rows); - $sql = "INSERT INTO `{$table}` (`".implode('`, `', $names)."`) VALUES ".$values; - return $this->query($sql); - } - - public function multipleReplace(string $table, array $rows) { - list($names, $values) = $this->getMultipleInsertValues($rows); - $sql = "REPLACE INTO `{$table}` (`".implode('`, `', $names)."`) VALUES ".$values; - return $this->query($sql); - } - - protected function getMultipleInsertValues(array $rows): array { - $names = []; - $sql_rows = []; - foreach ($rows as $i => $fields) { - $row_values = []; - foreach ($fields as $field_name => $field_val) { - if ($i == 0) { - $names[] = $field_name; - } - $row_values[] = $this->escape($field_val); - } - $sql_rows[] = "('".implode("', '", $row_values)."')"; - } - return [$names, implode(', ', $sql_rows)]; - } - -}
\ No newline at end of file diff --git a/engine/database/MySQLConnection.php b/engine/database/MySQLConnection.php deleted file mode 100644 index c4e47e5..0000000 --- a/engine/database/MySQLConnection.php +++ /dev/null @@ -1,82 +0,0 @@ -<?php - -class MySQLConnection extends CommonDatabase { - - protected ?mysqli $link = null; - - public function __construct( - protected string $host, - protected string $user, - protected string $password, - protected string $database) {} - - public function __destruct() { - if ($this->link) - $this->link->close(); - } - - public function connect(): bool { - $this->link = new mysqli(); - $result = $this->link->real_connect($this->host, $this->user, $this->password, $this->database); - if ($result) - $this->link->set_charset('utf8mb4'); - return !!$result; - } - - public function query(string $sql, ...$args): mysqli_result|bool { - $sql = $this->prepareQuery($sql, ...$args); - $q = $this->link->query($sql); - if (!$q) - logError(__METHOD__.': '.$this->link->error."\n$sql\n".backtrace(1)); - return $q; - } - - public function fetch($q): ?array { - $row = $q->fetch_assoc(); - if (!$row) { - $q->free(); - return null; - } - return $row; - } - - public function fetchAll($q): ?array { - if (!$q) - return null; - $list = []; - while ($f = $q->fetch_assoc()) { - $list[] = $f; - } - $q->free(); - return $list; - } - - public function fetchRow($q): ?array { - return $q?->fetch_row(); - } - - public function result($q, $field = 0) { - return $q?->fetch_row()[$field]; - } - - public function insertId(): int { - return $this->link->insert_id; - } - - public function numRows($q): ?int { - return $q?->num_rows; - } - - // public function affectedRows() { - // return $this->link->affected_rows; - // } - // - // public function foundRows() { - // return $this->fetch($this->query("SELECT FOUND_ROWS() AS `count`"))['count']; - // } - - public function escape(string $s): string { - return $this->link->real_escape_string($s); - } - -} diff --git a/engine/database/SQLiteConnection.php b/engine/database/SQLiteConnection.php deleted file mode 100644 index 6b03c7c..0000000 --- a/engine/database/SQLiteConnection.php +++ /dev/null @@ -1,81 +0,0 @@ -<?php - -class SQLiteConnection extends CommonDatabase { - - const SCHEMA_VERSION = 0; - - protected SQLite3 $link; - - public function __construct(string $db_path) { - $will_create = !file_exists($db_path); - $this->link = new SQLite3($db_path); - if ($will_create) - setperm($db_path); - $this->link->enableExceptions(true); - $this->upgradeSchema(); - } - - protected function upgradeSchema() { - $cur = $this->getSchemaVersion(); - if ($cur == self::SCHEMA_VERSION) - return; - - if ($cur < 1) { - // TODO - } - - $this->syncSchemaVersion(); - } - - protected function getSchemaVersion() { - return $this->link->query("PRAGMA user_version")->fetchArray()[0]; - } - - protected function syncSchemaVersion() { - $this->link->exec("PRAGMA user_version=".self::SCHEMA_VERSION); - } - - public function query(string $sql, ...$params): SQLite3Result { - return $this->link->query($this->prepareQuery($sql, ...$params)); - } - - public function exec(string $sql, ...$params) { - return $this->link->exec($this->prepareQuery($sql, ...$params)); - } - - public function querySingle(string $sql, ...$params) { - return $this->link->querySingle($this->prepareQuery($sql, ...$params)); - } - - public function querySingleRow(string $sql, ...$params) { - return $this->link->querySingle($this->prepareQuery($sql, ...$params), true); - } - - public function insertId(): int { - return $this->link->lastInsertRowID(); - } - - public function escape(string $s): string { - return $this->link->escapeString($s); - } - - public function fetch($q): ?array { - // TODO: Implement fetch() method. - } - - public function fetchAll($q): ?array { - // TODO: Implement fetchAll() method. - } - - public function fetchRow($q): ?array { - // TODO: Implement fetchRow() method. - } - - public function result($q, int $field = 0) { - return $q?->fetchArray()[$field]; - } - - public function numRows($q): ?int { - // TODO: Implement numRows() method. - } -}
\ No newline at end of file diff --git a/engine/exceptions/ForbiddenException.php b/engine/exceptions/ForbiddenException.php deleted file mode 100644 index 4184908..0000000 --- a/engine/exceptions/ForbiddenException.php +++ /dev/null @@ -1,9 +0,0 @@ -<?php - -class ForbiddenException extends BadMethodCallException { - - public function __construct(string $message = '') { - parent::__construct($message, 403); - } - -}
\ No newline at end of file diff --git a/engine/exceptions/NotFoundException.php b/engine/exceptions/NotFoundException.php deleted file mode 100644 index 211106f..0000000 --- a/engine/exceptions/NotFoundException.php +++ /dev/null @@ -1,9 +0,0 @@ -<?php - -class NotFoundException extends BadMethodCallException { - - public function __construct(string $message = '') { - parent::__construct($message, 404); - } - -}
\ No newline at end of file diff --git a/engine/exceptions/NotImplementedException.php b/engine/exceptions/NotImplementedException.php deleted file mode 100644 index 1c4562a..0000000 --- a/engine/exceptions/NotImplementedException.php +++ /dev/null @@ -1,9 +0,0 @@ -<?php - -class NotImplementedException extends BadMethodCallException { - - public function __construct(string $message = '') { - parent::__construct($message, 501); - } - -}
\ No newline at end of file diff --git a/engine/exceptions/UnauthorizedException.php b/engine/exceptions/UnauthorizedException.php deleted file mode 100644 index 84a1251..0000000 --- a/engine/exceptions/UnauthorizedException.php +++ /dev/null @@ -1,9 +0,0 @@ -<?php - -class UnauthorizedException extends BadMethodCallException { - - public function __construct(string $message = '') { - parent::__construct($message, 401); - } - -}
\ No newline at end of file diff --git a/engine/logging.php b/engine/logging.php index 24803cf..d3fcc78 100644 --- a/engine/logging.php +++ b/engine/logging.php @@ -1,96 +1,122 @@ <?php -require_once 'engine/ansi.php'; -use \ansi\Color; -use function \ansi\wrap; - -enum LogLevel { - case ERROR; - case WARNING; - case INFO; - case DEBUG; -} - -function logDebug(...$args): void { logging::logCustom(LogLevel::DEBUG, ...$args); } -function logInfo(...$args): void { logging::logCustom(LogLevel::INFO, ...$args); } -function logWarning(...$args): void { logging::logCustom(LogLevel::WARNING, ...$args); } -function logError(...$args): void { logging::logCustom(LogLevel::ERROR, ...$args); } +require_once 'lib/ansi.php'; -class logging { +enum LogLevel: int { + case ERROR = 10; + case WARNING = 5; + case INFO = 3; + case DEBUG = 2; +} - // private static $instance = null; +function logDebug(...$args): void { global $__logger; $__logger->log(LogLevel::DEBUG, ...$args); } +function logInfo(...$args): void { global $__logger; $__logger->log(LogLevel::INFO, ...$args); } +function logWarning(...$args): void { global $__logger; $__logger->log(LogLevel::WARNING, ...$args); } +function logError(...$args): void { global $__logger; $__logger->log(LogLevel::ERROR, ...$args); } - protected static ?string $logFile = null; - protected static bool $enabled = false; - protected static int $counter = 0; +abstract class Logger { + protected bool $enabled = false; + protected int $counter = 0; + protected int $recursionLevel = 0; /** @var ?callable $filter */ - protected static $filter = null; + protected $filter = null; - public static function setLogFile(string $log_file): void { - self::$logFile = $log_file; + function setErrorFilter(callable $filter): void { + $this->filter = $filter; } - public static function setErrorFilter(callable $filter): void { - self::$filter = $filter; + function disable(): void { + $this->enabled = false; } - public static function disable(): void { - self::$enabled = false; + function enable(): void { + static $error_handler_set = false; + $this->enabled = true; - restore_error_handler(); - register_shutdown_function(function() {}); - } + if ($error_handler_set) + return; - public static function enable(): void { - self::$enabled = true; + $self = $this; - set_error_handler(function($no, $str, $file, $line) { - if (is_callable(self::$filter) && !(self::$filter)($no, $file, $line, $str)) + set_error_handler(function($no, $str, $file, $line) use ($self) { + if (!$self->enabled) return; - self::write(LogLevel::ERROR, $str, + if (is_callable($self->filter) && !($self->filter)($no, $file, $line, $str)) + return; + + static::write(LogLevel::ERROR, $str, errno: $no, errfile: $file, errline: $line); }); - register_shutdown_function(function() { - if (!($error = error_get_last())) + register_shutdown_function(function () use ($self) { + if (!$self->enabled || !($error = error_get_last())) return; - if (is_callable(self::$filter) - && !(self::$filter)($error['type'], $error['file'], $error['line'], $error['message'])) { + if (is_callable($self->filter) + && !($self->filter)($error['type'], $error['file'], $error['line'], $error['message'])) { return; } - self::write(LogLevel::ERROR, $error['message'], + static::write(LogLevel::ERROR, $error['message'], errno: $error['type'], - errfile: $error['file'], + errfile: $error['file'], errline: $error['line']); }); + + $error_handler_set = true; } - public static function logCustom(LogLevel $level, ...$args): void { - global $config; - if (!$config['is_dev'] && $level == LogLevel::DEBUG) + function log(LogLevel $level, ...$args): void { + if (!is_dev() && $level == LogLevel::DEBUG) return; - self::write($level, self::strVars($args)); + $this->write($level, strVars($args)); } - protected static function write(LogLevel $level, - string $message, - ?int $errno = null, - ?string $errfile = null, - ?string $errline = null): void { + protected function canReport(): bool { + return $this->recursionLevel < 3; + } + + protected function write(LogLevel $level, + string $message, + ?int $errno = null, + ?string $errfile = null, + ?string $errline = null): void { + $this->recursionLevel++; + + if ($this->canReport()) + $this->writer($level, $this->counter++, $message, $errno, $errfile, $errline); + + $this->recursionLevel--; + } + + abstract protected function writer(LogLevel $level, + int $num, + string $message, + ?int $errno = null, + ?string $errfile = null, + ?string $errline = null): void; +} + +class FileLogger extends Logger { + + function __construct(protected string $logFile) {} - // TODO test - if (is_null(self::$logFile)) { + protected function writer(LogLevel $level, + int $num, + string $message, + ?int $errno = null, + ?string $errfile = null, + ?string $errline = null): void + { + if (is_null($this->logFile)) { fprintf(STDERR, __METHOD__.': logfile is not set'); return; } - $num = self::$counter++; $time = time(); // TODO rewrite using sprintf @@ -98,47 +124,56 @@ class logging { if (strlen($exec_time) < 6) $exec_time .= str_repeat('0', 6 - strlen($exec_time)); - // $bt = backtrace(2); - - $title = PHP_SAPI == 'cli' ? 'cli' : $_SERVER['REQUEST_URI']; + $title = is_cli() ? 'cli' : $_SERVER['REQUEST_URI']; $date = date('d/m/y H:i:s', $time); $buf = ''; if ($num == 0) { - $buf .= wrap(" $title ", - fg: Color::WHITE, - bg: Color::MAGENTA, - fg_bright: true, - bold: true); - $buf .= wrap(" $date ", fg: Color::WHITE, bg: Color::BLUE, fg_bright: true); + $buf .= ansi(" $title ", + fg: AnsiColor::WHITE, + bg: AnsiColor::MAGENTA, + bold: true, + fg_bright: true); + $buf .= ansi(" $date ", fg: AnsiColor::WHITE, bg: AnsiColor::BLUE, fg_bright: true); $buf .= "\n"; } $letter = strtoupper($level->name[0]); $color = match ($level) { - LogLevel::ERROR => Color::RED, - LogLevel::INFO, LogLevel::DEBUG => Color::WHITE, - LogLevel::WARNING => Color::YELLOW + LogLevel::ERROR => AnsiColor::RED, + LogLevel::INFO => AnsiColor::GREEN, + LogLevel::DEBUG => AnsiColor::WHITE, + LogLevel::WARNING => AnsiColor::YELLOW }; - $buf .= wrap($letter.wrap('='.wrap($num, bold: true)), fg: $color).' '; - $buf .= wrap($exec_time, fg: Color::CYAN).' '; + $buf .= ansi($letter.ansi('='.ansi($num, bold: true)), fg: $color).' '; + $buf .= ansi($exec_time, fg: AnsiColor::CYAN).' '; if (!is_null($errno)) { - $buf .= wrap($errfile, fg: Color::GREEN); - $buf .= wrap(':', fg: Color::WHITE); - $buf .= wrap($errline, fg: Color::GREEN, fg_bright: true); - $buf .= ' ('.self::getPhpErrorName($errno).') '; + $buf .= ansi($errfile, fg: AnsiColor::GREEN); + $buf .= ansi(':', fg: AnsiColor::WHITE); + $buf .= ansi($errline, fg: AnsiColor::GREEN, fg_bright: true); + $buf .= ' ('.getPHPErrorName($errno).') '; } $buf .= $message."\n"; if (in_array($level, [LogLevel::ERROR, LogLevel::WARNING])) - $buf .= backtrace(2)."\n"; + $buf .= backtrace_as_string(2)."\n"; + + $set_perm = false; + if (!file_exists($this->logFile)) { + $set_perm = true; + $dir = dirname($this->logFile); + echo "dir: $dir\n"; + + if (!file_exists($dir)) { + mkdir($dir); + setperm($dir); + } + } - // TODO test - $set_perm = !file_exists(self::$logFile); - $f = fopen(self::$logFile, 'a'); + $f = fopen($this->logFile, 'a'); if (!$f) { - fprintf(STDERR, __METHOD__.': failed to open file "'.self::$logFile.'" for writing'); + fprintf(STDERR, __METHOD__.': failed to open file \''.$this->logFile.'\' for writing'); return; } @@ -146,34 +181,76 @@ class logging { fclose($f); if ($set_perm) - setperm(self::$logFile); + setperm($this->logFile); } - protected static function getPhpErrorName(int $errno): string { - static $errors = null; - if (is_null($errors)) - $errors = array_flip(array_slice(get_defined_constants(true)['Core'], 0, 15, true)); - return $errors[$errno]; - } +} - protected static function strVarDump($var, bool $print_r = false): string { - ob_start(); - $print_r ? print_r($var) : var_dump($var); - return trim(ob_get_clean()); - } +class DatabaseLogger extends Logger { + protected function writer(LogLevel $level, + int $num, + string $message, + ?int $errno = null, + ?string $errfile = null, + ?string $errline = null): void + { + $db = DB(); + + $data = [ + 'ts' => time(), + 'num' => $num, + 'time' => exectime(), + 'errno' => $errno, + 'file' => $errfile, + 'line' => $errline, + 'text' => $message, + 'level' => $level->value, + 'stacktrace' => backtrace_as_string(2), + 'is_cli' => intval(is_cli()), + 'user_id' => 0, + ]; + + if (is_cli()) { + $data += [ + 'ip' => '', + 'ua' => '', + 'url' => '', + ]; + } else { + $data += [ + 'ip' => ip2ulong($_SERVER['REMOTE_ADDR']), + 'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '', + 'url' => $_SERVER['HTTP_HOST'].$_SERVER['REQUEST_URI'] + ]; + } - protected static function strVars(array $args): string { - $args = array_map(fn($a) => match (gettype($a)) { - 'string' => $a, - 'array', 'object' => self::strVarDump($a, true), - default => self::strVarDump($a) - }, $args); - return implode(' ', $args); + $db->insert('backend_errors', $data); } +} + +function getPHPErrorName(int $errno): string { + static $errors = null; + if (is_null($errors)) + $errors = array_flip(array_slice(get_defined_constants(true)['Core'], 0, 15, true)); + return $errors[$errno]; +} + +function strVarDump($var, bool $print_r = false): string { + ob_start(); + $print_r ? print_r($var) : var_dump($var); + return trim(ob_get_clean()); +} +function strVars(array $args): string { + $args = array_map(fn($a) => match (gettype($a)) { + 'string' => $a, + 'array', 'object' => strVarDump($a, true), + default => strVarDump($a) + }, $args); + return implode(' ', $args); } -function backtrace(int $shift = 0): string { +function backtrace_as_string(int $shift = 0): string { $bt = debug_backtrace(); $lines = []; foreach ($bt as $i => $t) { @@ -187,4 +264,4 @@ function backtrace(int $shift = 0): string { } } return implode("\n", $lines); -} +}
\ No newline at end of file diff --git a/engine/model.php b/engine/model.php new file mode 100644 index 0000000..e967dc2 --- /dev/null +++ b/engine/model.php @@ -0,0 +1,331 @@ +<?php + +enum ModelFieldType { + case STRING; + case INTEGER; + case FLOAT; + case ARRAY; + case BOOLEAN; + case JSON; + case SERIALIZED; + case BITFIELD; + case BACKED_ENUM; +} + +abstract class model { + + const DB_TABLE = null; + const DB_KEY = 'id'; + + /** @var $SpecCache ModelSpec[] */ + protected static array $SpecCache = []; + + public static function create_instance(...$args) { + $cl = get_called_class(); + return new $cl(...$args); + } + + public function __construct(array $raw) { + if (!isset(self::$SpecCache[static::class])) + self::$SpecCache[static::class] = static::get_spec(); + + foreach (self::$SpecCache[static::class]->getProperties() as $prop) + $this->{$prop->getModelName()} = $prop->fromRawValue($raw[$prop->getDbName()]); + + if (is_null(static::DB_TABLE)) + trigger_error('class '.get_class($this).' doesn\'t have DB_TABLE defined'); + } + + /** + * TODO: support adding or subtracting (SET value=value+1) + */ + public function edit(array $fields) { + $db = DB(); + + $model_upd = []; + $db_upd = []; + + $spec_db_name_map = self::$SpecCache[static::class]->getDbNameMap(); + $spec_props = self::$SpecCache[static::class]->getProperties(); + + foreach ($fields as $name => $value) { + $index = $spec_db_name_map[$name] ?? null; + if (is_null($index)) { + logError(__METHOD__.': field `'.$name.'` not found in '.static::class); + continue; + } + + $field = $spec_props[$index]; + if ($field->isNullable() && is_null($value)) { + $model_upd[$field->getModelName()] = $value; + $db_upd[$name] = $value; + continue; + } + + switch ($field->getType()) { + case ModelFieldType::ARRAY: + if (is_array($value)) { + $db_upd[$name] = implode(',', $value); + $model_upd[$field->getModelName()] = $value; + } else { + logError(__METHOD__.': field `'.$name.'` is expected to be array. skipping.'); + } + break; + + case ModelFieldType::INTEGER: + $value = (int)$value; + $db_upd[$name] = $value; + $model_upd[$field->getModelName()] = $value; + break; + + case ModelFieldType::FLOAT: + $value = (float)$value; + $db_upd[$name] = $value; + $model_upd[$field->getModelName()] = $value; + break; + + case ModelFieldType::BOOLEAN: + $db_upd[$name] = $value ? 1 : 0; + $model_upd[$field->getModelName()] = $value; + break; + + case ModelFieldType::JSON: + $db_upd[$name] = jsonEncode($value); + $model_upd[$field->getModelName()] = $value; + break; + + case ModelFieldType::SERIALIZED: + $db_upd[$name] = serialize($value); + $model_upd[$field->getModelName()] = $value; + break; + + case ModelFieldType::BITFIELD: + $db_upd[$name] = $value; + $model_upd[$field->getModelName()] = $value; + break; + + default: + $value = (string)$value; + $db_upd[$name] = $value; + $model_upd[$field->getModelName()] = $value; + break; + } + } + + if (!empty($db_upd) && !$db->update(static::DB_TABLE, $db_upd, static::DB_KEY."=?", $this->get_id())) { + logError(__METHOD__.': failed to update database'); + return; + } + + if (!empty($model_upd)) { + foreach ($model_upd as $name => $value) + $this->{$name} = $value; + } + } + + public function get_id() { + return $this->{to_camel_case(static::DB_KEY)}; + } + + public function as_array(array $properties = [], array $custom_getters = []): array { + if (empty($properties)) + $properties = static::$SpecCache[static::class]->getPropNames(); + + $array = []; + foreach ($properties as $field) { + if (isset($custom_getters[$field]) && is_callable($custom_getters[$field])) { + $array[$field] = $custom_getters[$field](); + } else { + $array[$field] = $this->{to_camel_case($field)}; + } + } + + return $array; + } + + protected static function get_spec(): ModelSpec { + $rc = new ReflectionClass(static::class); + $props = $rc->getProperties(ReflectionProperty::IS_PUBLIC); + + $list = []; + $index = 0; + + $db_name_map = []; + + foreach ($props as $prop) { + if ($prop->isStatic()) + continue; + + $name = $prop->getName(); + if (str_starts_with($name, '_')) + continue; + + $real_type = null; + $type = $prop->getType(); + $phpdoc = $prop->getDocComment(); + + /** @var ?ModelFieldType $mytype */ + $mytype = null; + if (!$prop->hasType() && !$phpdoc) + $mytype = ModelFieldType::STRING; + else { + $typename = $type->getName(); + switch ($typename) { + case 'string': + $mytype = ModelFieldType::STRING; + break; + case 'int': + $mytype = ModelFieldType::INTEGER; + break; + case 'float': + $mytype = ModelFieldType::FLOAT; + break; + case 'array': + $mytype = ModelFieldType::ARRAY; + break; + case 'bool': + $mytype = ModelFieldType::BOOLEAN; + break; + case 'mysql_bitfield': + $mytype = ModelFieldType::BITFIELD; + break; + default: + if (enum_exists($typename)) { + $mytype = ModelFieldType::BACKED_ENUM; + $real_type = $typename; + } + break; + } + + if ($phpdoc != '') { + $pos = strpos($phpdoc, '@'); + if ($pos === false) + continue; + + if (substr($phpdoc, $pos+1, 4) == 'json') + $mytype = ModelFieldType::JSON; + else if (substr($phpdoc, $pos+1, 5) == 'array') + $mytype = ModelFieldType::ARRAY; + else if (substr($phpdoc, $pos+1, 10) == 'serialized') + $mytype = ModelFieldType::SERIALIZED; + } + } + + if (is_null($mytype)) + logError(__METHOD__.": ".$name." is still null in ".static::class); + + // $dbname = from_camel_case($name); + $model_descr = new ModelProperty( + type: $mytype, + realType: $real_type, + nullable: $type->allowsNull(), + modelName: $name, + dbName: from_camel_case($name) + ); + $list[] = $model_descr; + $db_name_map[$model_descr->getDbName()] = $index++; + } + + return new ModelSpec($list, $db_name_map); + } + +} + +class ModelSpec { + + public function __construct( + /** @var ModelProperty[] */ + protected array $properties, + protected array $dbNameMap + ) {} + + /** + * @return ModelProperty[] + */ + public function getProperties(): array { + return $this->properties; + } + + public function getDbNameMap(): array { + return $this->dbNameMap; + } + + public function getPropNames(): array { + return array_keys($this->dbNameMap); + } + +} + +class ModelProperty { + + public function __construct( + protected ?ModelFieldType $type, + protected mixed $realType, + protected bool $nullable, + protected string $modelName, + protected string $dbName + ) {} + + public function getDbName(): string { + return $this->dbName; + } + + public function getModelName(): string { + return $this->modelName; + } + + public function isNullable(): bool { + return $this->nullable; + } + + public function getType(): ?ModelFieldType { + return $this->type; + } + + public function fromRawValue(mixed $value): mixed { + if ($this->nullable && is_null($value)) + return null; + + switch ($this->type) { + case ModelFieldType::BOOLEAN: + return (bool)$value; + + case ModelFieldType::INTEGER: + return (int)$value; + + case ModelFieldType::FLOAT: + return (float)$value; + + case ModelFieldType::ARRAY: + return array_filter(explode(',', $value)); + + case ModelFieldType::JSON: + $val = jsonDecode($value); + if (!$val) + $val = null; + return $val; + + case ModelFieldType::SERIALIZED: + $val = unserialize($value); + if ($val === false) + $val = null; + return $val; + + case ModelFieldType::BITFIELD: + return new mysql_bitfield($value); + + case ModelFieldType::BACKED_ENUM: + try { + return $this->realType::from($value); + } catch (ValueError $e) { + if ($this->nullable) + return null; + throw $e; + } + + default: + return (string)$value; + } + } + +}
\ No newline at end of file diff --git a/engine/mysql.php b/engine/mysql.php new file mode 100644 index 0000000..bab8048 --- /dev/null +++ b/engine/mysql.php @@ -0,0 +1,261 @@ +<?php + +class mysql { + + protected ?mysqli $link = null; + + function __construct( + protected string $host, + protected string $user, + protected string $password, + protected string $database) {} + + protected function prepareQuery(string $sql, ...$args): string { + global $config; + if (!empty($args)) { + $mark_count = substr_count($sql, '?'); + $positions = array(); + $last_pos = -1; + for ($i = 0; $i < $mark_count; $i++) { + $last_pos = strpos($sql, '?', $last_pos + 1); + $positions[] = $last_pos; + } + for ($i = $mark_count - 1; $i >= 0; $i--) { + $arg_val = $args[$i]; + if (is_null($arg_val)) { + $v = 'NULL'; + } else { + $v = '\''.$this->escape($arg_val) . '\''; + } + $sql = substr_replace($sql, $v, $positions[$i], 1); + } + } + if (!empty($config['mysql']['log'])) + logDebug(__METHOD__.': ', $sql); + return $sql; + } + + function insert(string $table, array $fields) { + return $this->performInsert('INSERT', $table, $fields); + } + + function replace(string $table, array $fields) { + return $this->performInsert('REPLACE', $table, $fields); + } + + protected function performInsert(string $command, string $table, array $fields) { + $names = []; + $values = []; + $count = 0; + foreach ($fields as $k => $v) { + $names[] = $k; + $values[] = $v; + $count++; + } + + $sql = "{$command} INTO `{$table}` (`" . implode('`, `', $names) . "`) VALUES (" . implode(', ', array_fill(0, $count, '?')) . ")"; + array_unshift($values, $sql); + + return $this->query(...$values); + } + + function update(string $table, array $rows, ...$cond) { + $fields = []; + $args = []; + foreach ($rows as $row_name => $row_value) { + $fields[] = "`{$row_name}`=?"; + $args[] = $row_value; + } + $sql = "UPDATE `$table` SET ".implode(', ', $fields); + if (!empty($cond)) { + $sql .= " WHERE ".$cond[0]; + if (count($cond) > 1) + $args = array_merge($args, array_slice($cond, 1)); + } + return $this->query($sql, ...$args); + } + + function multipleInsert(string $table, array $rows) { + list($names, $values) = $this->getMultipleInsertValues($rows); + $sql = "INSERT INTO `{$table}` (`".implode('`, `', $names)."`) VALUES ".$values; + return $this->query($sql); + } + + function multipleReplace(string $table, array $rows) { + list($names, $values) = $this->getMultipleInsertValues($rows); + $sql = "REPLACE INTO `{$table}` (`".implode('`, `', $names)."`) VALUES ".$values; + return $this->query($sql); + } + + protected function getMultipleInsertValues(array $rows): array { + $names = []; + $sql_rows = []; + foreach ($rows as $i => $fields) { + $row_values = []; + foreach ($fields as $field_name => $field_val) { + if ($i == 0) { + $names[] = $field_name; + } + $row_values[] = $this->escape($field_val); + } + $sql_rows[] = "('".implode("', '", $row_values)."')"; + } + return [$names, implode(', ', $sql_rows)]; + } + + function __destruct() { + if ($this->link) + $this->link->close(); + } + + function connect(): bool { + $this->link = new mysqli(); + $result = $this->link->real_connect($this->host, $this->user, $this->password, $this->database); + if ($result) + $this->link->set_charset('utf8mb4'); + return !!$result; + } + + function query(string $sql, ...$args): mysqli_result|bool { + $sql = $this->prepareQuery($sql, ...$args); + $q = $this->link->query($sql); + if (!$q) + logError(__METHOD__.': '.$this->link->error."\n$sql\n".backtrace_as_string(1)); + return $q; + } + + function fetch($q): ?array { + $row = $q->fetch_assoc(); + if (!$row) { + $q->free(); + return null; + } + return $row; + } + + function fetchAll($q): ?array { + if (!$q) + return null; + $list = []; + while ($f = $q->fetch_assoc()) { + $list[] = $f; + } + $q->free(); + return $list; + } + + function fetchRow($q): ?array { + return $q?->fetch_row(); + } + + function result($q, $field = 0) { + return $q?->fetch_row()[$field]; + } + + function insertId(): int { + return $this->link->insert_id; + } + + function numRows($q): ?int { + return $q?->num_rows; + } + + function affectedRows(): ?int { + return $this->link?->affected_rows; + } + + function foundRows(): int { + return (int)$this->fetch($this->query("SELECT FOUND_ROWS() AS `count`"))['count']; + } + + function escape(string $s): string { + return $this->link->real_escape_string($s); + } + +} + +class mysql_bitfield { + + private GMP $value; + private int $size; + + public function __construct($value, int $size = 64) { + $this->value = gmp_init($value); + $this->size = $size; + } + + public function has(int $bit): bool { + $this->validateBit($bit); + return gmp_testbit($this->value, $bit); + } + + public function set(int $bit): void { + $this->validateBit($bit); + gmp_setbit($this->value, $bit); + } + + public function clear(int $bit): void { + $this->validateBit($bit); + gmp_clrbit($this->value, $bit); + } + + public function isEmpty(): bool { + return !gmp_cmp($this->value, 0); + } + + public function __toString(): string { + $buf = ''; + for ($bit = $this->size-1; $bit >= 0; --$bit) + $buf .= gmp_testbit($this->value, $bit) ? '1' : '0'; + if (($pos = strpos($buf, '1')) !== false) { + $buf = substr($buf, $pos); + } else { + $buf = '0'; + } + return $buf; + } + + private function validateBit(int $bit): void { + if ($bit < 0 || $bit >= $this->size) + throw new Exception('invalid bit '.$bit.', allowed range: [0..'.$this->size.')'); + } + + +} + +function DB(): mysql|null { + global $config; + + /** @var ?mysql $link */ + static $link = null; + if (!is_null($link)) + return $link; + + $link = new mysql( + $config['mysql']['host'], + $config['mysql']['user'], + $config['mysql']['password'], + $config['mysql']['database']); + if (!$link->connect()) { + if (!is_cli()) { + header('HTTP/1.1 503 Service Temporarily Unavailable'); + header('Status: 503 Service Temporarily Unavailable'); + header('Retry-After: 300'); + die('database connection failed'); + } else { + fwrite(STDERR, 'database connection failed'); + exit(1); + } + } + + return $link; +} + +function MC(): Memcached { + static $mc = null; + if ($mc === null) { + $mc = new Memcached(); + $mc->addServer("127.0.0.1", 11211); + } + return $mc; +} diff --git a/engine/request.php b/engine/request.php new file mode 100644 index 0000000..e67d4fb --- /dev/null +++ b/engine/request.php @@ -0,0 +1,203 @@ +<?php + +function dispatch_request(): void { + global $RouterInput; + + if (!in_array($_SERVER['REQUEST_METHOD'], ['POST', 'GET'])) + http_error(HTTPCode::NotImplemented, 'Method '.$_SERVER['REQUEST_METHOD'].' not implemented'); + + $route = router_find(request_path()); + if ($route === null) + http_error(HTTPCode::NotFound, 'Route not found'); + + $route = preg_split('/ +/', $route); + $handler_class = $route[0].'Handler'; + if (!class_exists($handler_class)) + http_error(HTTPCode::NotFound, is_dev() ? 'Handler class "'.$handler_class.'" not found' : ''); + + $action = $route[1]; + + if (count($route) > 2) { + for ($i = 2; $i < count($route); $i++) { + $var = $route[$i]; + list($k, $v) = explode('=', $var); + $RouterInput[trim($k)] = trim($v); + } + } + + /** @var request_handler $handler */ + $handler = new $handler_class(); + $handler->call_act($_SERVER['REQUEST_METHOD'], $action); +} + +function request_path(): string { + $uri = $_SERVER['REQUEST_URI']; + if (($pos = strpos($uri, '?')) !== false) + $uri = substr($uri, 0, $pos); + return $uri; +} + + +enum HTTPCode: int { + case MovedPermanently = 301; + case Found = 302; + + case Unauthorized = 401; + case NotFound = 404; + case Forbidden = 403; + + case InternalServerError = 500; + case NotImplemented = 501; +} + +function http_error(HTTPCode $http_code, string $message = ''): void { + $ctx = new SkinContext('\\skin\\error'); + $http_message = preg_replace('/(?<!^)([A-Z])/', ' $1', $http_code->name); + $html = $ctx->http_error($http_code->value, $http_message, $message); + http_response_code($http_code->value); + echo $html; + exit; +} + +function redirect(string $url, HTTPCode $code = HTTPCode::MovedPermanently): void { + if (!in_array($code, [HTTPCode::MovedPermanently, HTTPCode::Found])) + internal_server_error('invalid http code'); + http_response_code($code->value); + header('Location: '.$url); + exit; +} + +function internal_server_error(string $message = '') { http_error(HTTPCode::InternalServerError, $message); } +function not_found(string $message = '') { http_error(HTTPCode::NotFound, $message); } +function forbidden(string $message = '') { http_error(HTTPCode::Forbidden, $message); } +function is_xhr_request(): bool { return isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'; } +function ajax_ok(mixed $data): void { ajax_response(['response' => $data]); } +function ajax_error(mixed $error, int $code = 200): void { ajax_response(['error' => $error], $code); } + +function ajax_response(mixed $data, int $code = 200): void { + header('Cache-Control: no-cache, must-revalidate'); + header('Pragma: no-cache'); + header('Content-Type: application/json; charset=utf-8'); + http_response_code($code); + echo jsonEncode($data); + exit; +} + + +abstract class request_handler { + function __construct() { + add_static( + 'css/common.css', + 'js/common.js' + ); + add_skin_strings_re('/^theme_/'); + } + + function before_dispatch(string $http_method, string $action) {} + + function call_act(string $http_method, string $action, array $input = []) { + global $RouterInput; + + $handler_method = $_SERVER['REQUEST_METHOD'].'_'.$action; + if (!method_exists($this, $handler_method)) + not_found(static::class.'::'.$handler_method.' is not defined'); + + if (!((new ReflectionMethod($this, $handler_method))->isPublic())) + not_found(static::class.'::'.$handler_method.' is not public'); + + if (!empty($input)) { + foreach ($input as $k => $v) + $RouterInput[$k] = $v; + } + + $args = $this->before_dispatch($http_method, $action); + return call_user_func_array([$this, $handler_method], is_array($args) ? [$args] : []); + } +} + + +enum InputVarType: string { + case INTEGER = 'i'; + case FLOAT = 'f'; + case BOOLEAN = 'b'; + case STRING = 's'; + case ENUM = 'e'; +} + +function input(string $input): array { + global $RouterInput; + $input = preg_split('/,\s+?/', $input, -1, PREG_SPLIT_NO_EMPTY); + $ret = []; + foreach ($input as $var) { + $enum_values = null; + $enum_default = null; + + $pos = strpos($var, ':'); + if ($pos !== false) { + $type = substr($var, 0, $pos); + $rest = substr($var, $pos + 1); + + $vartype = InputVarType::tryFrom($type); + if (is_null($vartype)) + internal_server_error('invalid input type '.$type); + + if ($vartype == InputVarType::ENUM) { + $br_from = strpos($rest, '('); + $br_to = strpos($rest, ')'); + + if ($br_from === false || $br_to === false) + internal_server_error('failed to parse enum values: '.$rest); + + $enum_values = array_map('trim', explode('|', trim(substr($rest, $br_from + 1, $br_to - $br_from - 1)))); + $name = trim(substr($rest, 0, $br_from)); + + if (!empty($enum_values)) { + foreach ($enum_values as $key => $val) { + if (str_starts_with($val, '=')) { + $enum_values[$key] = substr($val, 1); + $enum_default = $enum_values[$key]; + } + } + } + } else { + $name = trim($rest); + } + + } else { + $vartype = InputVarType::STRING; + $name = trim($var); + } + + $val = null; + if (isset($RouterInput[$name])) { + $val = $RouterInput[$name]; + } else if (isset($_POST[$name])) { + $val = $_POST[$name]; + } else if (isset($_GET[$name])) { + $val = $_GET[$name]; + } + if (is_array($val)) + $val = implode($val); + + $ret[] = match($vartype) { + InputVarType::INTEGER => (int)$val, + InputVarType::FLOAT => (float)$val, + InputVarType::BOOLEAN => (bool)$val, + InputVarType::ENUM => !in_array($val, $enum_values) ? $enum_default ?? '' : (string)$val, + default => (string)$val + }; + } + return $ret; +} + +function csrf_get(string $key): string { return _csrf_get_token($_SERVER['REMOTE_ADDR'], $key); } +function csrf_check(string $key) { + if (csrf_get($key) != ($_REQUEST['token'] ?? '')) { + forbidden('invalid token'); + } +} + +function _csrf_get_token(string $user_token, string $key): string { + global $config; + return substr(sha1($config['csrf_token'].$user_token.$key), 0, 20); +}
\ No newline at end of file diff --git a/engine/router.php b/engine/router.php new file mode 100644 index 0000000..401e177 --- /dev/null +++ b/engine/router.php @@ -0,0 +1,185 @@ +<?php + +const ROUTER_VERSION = 1; +const ROUTER_MC_KEY = 'ch1p/routes'; + +$RouterInput = []; +$Routes = [ + 'children' => [], + 're_children' => [] +]; + +function router_init(): void { + global $Routes; + $mc = MC(); + + $from_cache = !is_dev(); + $write_cache = !is_dev(); + + if ($from_cache) { + $cache = $mc->get(ROUTER_MC_KEY); + + if ($cache === false || !isset($cache['version']) || $cache['version'] < ROUTER_VERSION) { + $from_cache = false; + } else { + $Routes = $cache['routes']; + } + } + + if (!$from_cache) { + $routes_table = require_once APP_ROOT.'/routes.php'; + + foreach ($routes_table as $controller => $routes) { + foreach ($routes as $route => $resolve) + router_add($route, $controller.' '.$resolve); + } + + if ($write_cache) + $mc->set(ROUTER_MC_KEY, ['version' => ROUTER_VERSION, 'routes' => $Routes]); + } +} + +function router_add(string $template, string $value): void { + global $Routes; + if ($template == '') + return; + + // expand {enum,erat,ions} + $templates = [[$template, $value]]; + if (preg_match_all('/\{([\w\d_\-,]+)\}/', $template, $matches)) { + foreach ($matches[1] as $match_index => $variants) { + $variants = explode(',', $variants); + $variants = array_map('trim', $variants); + $variants = array_filter($variants, function($s) { return $s != ''; }); + + for ($i = 0; $i < count($templates); ) { + list($template, $value) = $templates[$i]; + $new_templates = []; + foreach ($variants as $variant_index => $variant) { + $new_templates[] = [ + str_replace_once($matches[0][$match_index], $variant, $template), + str_replace('${'.($match_index+1).'}', $variant, $value) + ]; + } + array_splice($templates, $i, 1, $new_templates); + $i += count($new_templates); + } + } + } + + // process all generated routes + foreach ($templates as $template) { + list($template, $value) = $template; + + $start_pos = 0; + $parent = &$Routes; + $template_len = strlen($template); + + while ($start_pos < $template_len) { + $slash_pos = strpos($template, '/', $start_pos); + if ($slash_pos !== false) { + $part = substr($template, $start_pos, $slash_pos-$start_pos+1); + $start_pos = $slash_pos+1; + } else { + $part = substr($template, $start_pos); + $start_pos = $template_len; + } + + $parent = &_router_add($parent, $part, + $start_pos < $template_len ? null : $value); + } + } +} + +function &_router_add(&$parent, $part, $value = null) { + $par_pos = strpos($part, '('); + $is_regex = $par_pos !== false && ($par_pos == 0 || $part[$par_pos-1] != '\\'); + + $children_key = !$is_regex ? 'children' : 're_children'; + + if (isset($parent[$children_key][$part])) { + if (is_null($value)) { + $parent = &$parent[$children_key][$part]; + } else { + if (!isset($parent[$children_key][$part]['value'])) { + $parent[$children_key][$part]['value'] = $value; + } else { + trigger_error(__METHOD__.': route is already defined'); + } + } + return $parent; + } + + $child = [ + 'children' => [], + 're_children' => [] + ]; + if (!is_null($value)) + $child['value'] = $value; + + $parent[$children_key][$part] = $child; + return $parent[$children_key][$part]; +} + +function router_find($uri) { + global $Routes; + if ($uri != '/' && $uri[0] == '/') + $uri = substr($uri, 1); + + $start_pos = 0; + $parent = &$Routes; + $uri_len = strlen($uri); + $matches = []; + + while ($start_pos < $uri_len) { + $slash_pos = strpos($uri, '/', $start_pos); + if ($slash_pos !== false) { + $part = substr($uri, $start_pos, $slash_pos-$start_pos+1); + $start_pos = $slash_pos+1; + } else { + $part = substr($uri, $start_pos); + $start_pos = $uri_len; + } + + $found = false; + if (isset($parent['children'][$part])) { + $parent = &$parent['children'][$part]; + $found = true; + } else if (!empty($parent['re_children'])) { + foreach ($parent['re_children'] as $re => &$child) { + $exp = '#^'.$re.'$#'; + $re_result = preg_match($exp, $part, $match); + if ($re_result === false) { + logError(__METHOD__.": regex $exp failed"); + continue; + } + + if ($re_result) { + if (count($match) > 1) + $matches = array_merge($matches, array_slice($match, 1)); + $parent = &$child; + $found = true; + break; + } + } + } + + if (!$found) + return null; + } + + if (!isset($parent['value'])) + return null; + + $value = $parent['value']; + if (!empty($matches)) { + foreach ($matches as $i => $match) { + $needle = '$('.($i+1).')'; + $pos = strpos($value, $needle); + if ($pos !== false) + $value = substr_replace($value, $match, $pos, strlen($needle)); + } + } + + return $value; +} diff --git a/engine/skin.php b/engine/skin.php new file mode 100644 index 0000000..6150668 --- /dev/null +++ b/engine/skin.php @@ -0,0 +1,241 @@ +<?php + +require_once 'lib/themes.php'; + +$SkinState = new class { + public array $lang = []; + public string $title = 'title'; + public array $meta = []; + public array $options = [ + 'full_width' => false, + 'wide' => false, + 'dynlogo_enabled' => true, + 'logo_path_map' => [], + 'logo_link_map' => [], + ]; + public array $static = []; +}; + +function render($f, ...$vars): void { + global $SkinState; + + $f = '\\skin\\'.str_replace('/', '\\', $f); + $ctx = new SkinContext(substr($f, 0, ($pos = strrpos($f, '\\')))); + $body = call_user_func_array([$ctx, substr($f, $pos + 1)], $vars); + if (is_array($body)) + list($body, $js) = $body; + else + $js = null; + + $theme = getUserTheme(); + if ($theme != 'auto' && !themeExists($theme)) + $theme = 'auto'; + + $layout_ctx = new SkinContext('\\skin\\base'); + + $lang = []; + foreach ($SkinState->lang as $key) + $lang[$key] = lang($key); + $lang = !empty($lang) ? json_encode($lang, JSON_UNESCAPED_UNICODE) : ''; + + $html = $layout_ctx->layout( + static: $SkinState->static, + theme: $theme, + title: $SkinState->title, + opts: $SkinState->options, + js: $js, + meta: $SkinState->meta, + unsafe_lang: $lang, + unsafe_body: $body, + exec_time: exectime() + ); + echo $html; + exit; +} + +function set_title(string $title): void { + global $SkinState; + if (str_starts_with($title, '$')) + $title = lang(substr($title, 1)); + else if (str_starts_with($title, '\\$')) + $title = substr($title, 1); + $SkinState->title = $title; +} + +function set_skin_opts(array $options) { + global $SkinState; + $SkinState->options = array_merge($SkinState->options, $options); +} + +function add_skin_strings(array $keys): void { + global $SkinState; + $SkinState->lang = array_merge($SkinState->lang, $keys); +} + +function add_skin_strings_re(string $re): void { + global $__lang; + add_skin_strings($__lang->search($re)); +} + +function add_static(string ...$files): void { + global $SkinState; + foreach ($files as $file) + $SkinState->static[] = $file; +} + +function add_meta(array ...$data) { + global $SkinState; + $SkinState->meta = array_merge($SkinState->meta, $data); +} + + +class SkinContext { + + protected string $ns; + protected array $data = []; + + function __construct(string $namespace) { + $this->ns = $namespace; + require_once APP_ROOT.str_replace('\\', DIRECTORY_SEPARATOR, $namespace).'.skin.php'; + } + + function __call($name, array $arguments) { + $plain_args = array_is_list($arguments); + + $fn = $this->ns.'\\'.$name; + $refl = new ReflectionFunction($fn); + $fparams = $refl->getParameters(); + assert(count($fparams) == count($arguments) + 1, "$fn: invalid number of arguments (".count($fparams)." != ".(count($arguments) + 1).")"); + + foreach ($fparams as $n => $param) { + if ($n == 0) + continue; // skip $ctx + + $key = $plain_args ? $n - 1 : $param->name; + if (!$plain_args && !array_key_exists($param->name, $arguments)) { + if (!$param->isDefaultValueAvailable()) + throw new InvalidArgumentException('argument '.$param->name.' not found'); + else + continue; + } + + if (is_string($arguments[$key]) || $arguments[$key] instanceof SkinString) { + if (is_string($arguments[$key])) + $arguments[$key] = new SkinString($arguments[$key]); + + if (($pos = strpos($param->name, '_')) !== false) { + $mod_type = match (substr($param->name, 0, $pos)) { + 'unsafe' => SkinStringModificationType::RAW, + 'urlencoded' => SkinStringModificationType::URL, + 'jsonencoded' => SkinStringModificationType::JSON, + 'addslashes' => SkinStringModificationType::ADDSLASHES, + default => SkinStringModificationType::HTML + }; + } else { + $mod_type = SkinStringModificationType::HTML; + } + $arguments[$key]->setModType($mod_type); + } + } + + array_unshift($arguments, $this); + return call_user_func_array($fn, $arguments); + } + + function &__get(string $name) { + $fn = $this->ns.'\\'.$name; + if (function_exists($fn)) { + $f = [$this, $name]; + return $f; + } + + if (array_key_exists($name, $this->data)) + return $this->data[$name]; + } + + function __set(string $name, $value) { + $this->data[$name] = $value; + } + + function if_not($cond, $callback, ...$args) { + return $this->_if_condition(!$cond, $callback, ...$args); + } + + function if_true($cond, $callback, ...$args) { + return $this->_if_condition($cond, $callback, ...$args); + } + + function if_admin($callback, ...$args) { + return $this->_if_condition(is_admin(), $callback, ...$args); + } + + function if_dev($callback, ...$args) { + return $this->_if_condition(is_dev(), $callback, ...$args); + } + + function if_then_else($cond, $cb1, $cb2) { + return $cond ? $this->_return_callback($cb1) : $this->_return_callback($cb2); + } + + function csrf($key): string { + return csrf_get($key); + } + + protected function _if_condition($condition, $callback, ...$args) { + if (is_string($condition) || $condition instanceof Stringable) + $condition = (string)$condition !== ''; + if ($condition) + return $this->_return_callback($callback, $args); + return ''; + } + + protected function _return_callback($callback, $args = []) { + if (is_callable($callback)) + return call_user_func_array($callback, $args); + else if (is_string($callback)) + return $callback; + } + + function for_each(array $iterable, callable $callback) { + $html = ''; + foreach ($iterable as $k => $v) + $html .= call_user_func($callback, $v, $k); + return $html; + } + + function lang(...$args): string { + return htmlescape($this->langRaw(...$args)); + } + + function langRaw(string $key, ...$args) { + $val = lang($key); + return empty($args) ? $val : sprintf($val, ...$args); + } + +} + + +enum SkinStringModificationType { + case RAW; + case URL; + case HTML; + case JSON; + case ADDSLASHES; +} + +class SkinString implements Stringable { + protected SkinStringModificationType $modType; + + function __construct(protected string $string) {} + function setModType(SkinStringModificationType $modType) { $this->modType = $modType; } + + function __toString(): string { + return match ($this->modType) { + SkinStringModificationType::HTML => htmlescape($this->string), + SkinStringModificationType::URL => urlencode($this->string), + SkinStringModificationType::JSON => json_encode($this->string, JSON_UNESCAPED_UNICODE), + SkinStringModificationType::ADDSLASHES => addslashes($this->string), + default => $this->string, + }; + } +} diff --git a/engine/strings.php b/engine/strings.php new file mode 100644 index 0000000..2d9b1d3 --- /dev/null +++ b/engine/strings.php @@ -0,0 +1,138 @@ +<?php + +enum DeclensionCase: string { + case GEN = 'Gen'; + case DAT = 'Dat'; + case ACC = 'Acc'; + case INS = 'Ins'; + case ABL = 'Abl'; +} + +enum NameSex: int { + case MALE = 0; + case FEMALE = 1; +} + +enum NameType: int { + case FIRST_NAME = 0; + case LAST_NAME = 1; +} + +class StringsBase implements ArrayAccess { + protected array $data = []; + + function offsetSet(mixed $offset, mixed $value): void { + throw new RuntimeException('Not implemented'); + } + + function offsetExists(mixed $offset): bool { + return isset($this->data[$offset]); + } + + function offsetUnset(mixed $offset): void { + throw new RuntimeException('Not implemented'); + } + + function offsetGet(mixed $offset): mixed { + if (!isset($this->data[$offset])) { + logError(__METHOD__.': '.$offset.' not found'); + return '{'.$offset.'}'; + } + return $this->data[$offset]; + } + + function get(string $key, mixed ...$sprintf_args): string|array { + $val = $this[$key]; + if (!empty($sprintf_args)) { + array_unshift($sprintf_args, $val); + return call_user_func_array('sprintf', $sprintf_args); + } else { + return $val; + } + } + + function num(string $key, int $num, array$opts = []) { + $s = $this[$key]; + + $default_opts = [ + 'format' => true, + 'format_delim' => ' ', + 'lang' => 'ru', + ]; + $opts = array_merge($default_opts, $opts); + + switch ($opts['lang']) { + case 'ru': + $n = $num % 100; + if ($n > 19) + $n %= 10; + + if ($n == 1) { + $word = 0; + } elseif ($n >= 2 && $n <= 4) { + $word = 1; + } elseif ($num == 0 && count($s) == 4) { + $word = 3; + } else { + $word = 2; + } + break; + + default: + if ($num == 0 && count($s) == 4) { + $word = 3; + } else { + $word = $num == 1 ? 0 : 1; + } + break; + } + + // if zero + if ($word == 3) { + return $s[3]; + } + + if (is_callable($opts['format'])) { + $num = $opts['format']($num); + } else if ($opts['format'] === true) { + $num = formatNumber($num, $opts['format_delim']); + } + + return sprintf($s[$word], $num); + } +} + +class Strings extends StringsBase { + private static ?Strings $instance = null; + protected array $loadedPackages = []; + + private function __construct() {} + protected function __clone() {} + + public static function getInstance(): self { + if (is_null(self::$instance)) + self::$instance = new self(); + return self::$instance; + } + + function load(string ...$pkgs): array { + $keys = []; + foreach ($pkgs as $name) { + $raw = yaml_parse_file(APP_ROOT.'/strings/'.$name.'.yaml'); + $this->data = array_merge($this->data, $raw); + $keys = array_merge($keys, array_keys($raw)); + $this->loadedPackages[$name] = true; + } + return $keys; + } + + function flex(string $s, DeclensionCase $case, NameSex $sex, NameType $type): string { + $s = iconv('utf-8', 'cp1251', $s); + $s = vkflex($s, $case->value, $sex->value, 0, $type->value); + return iconv('cp1251', 'utf-8', $s); + } + + function search(string $regexp): array { + return preg_grep($regexp, array_keys($this->data)); + } +}
\ No newline at end of file diff --git a/engine/themes.php b/engine/themes.php deleted file mode 100644 index 839377f..0000000 --- a/engine/themes.php +++ /dev/null @@ -1,38 +0,0 @@ -<?php - -class themes { - - public static array $Themes = [ - 'dark' => [ - 'bg' => 0x222222, - // 'alpha' => 0x303132, - 'alpha' => 0x222222, - ], - 'light' => [ - 'bg' => 0xffffff, - // 'alpha' => 0xf2f2f2, - 'alpha' => 0xffffff, - ] - ]; - - public static function getThemes(): array { - return array_keys(self::$Themes); - } - - public static function themeExists(string $name): bool { - return array_key_exists($name, self::$Themes); - } - - public static function getThemeAlphaColorAsRGB(string $name): array { - $color = self::$Themes[$name]['alpha']; - $r = ($color >> 16) & 0xff; - $g = ($color >> 8) & 0xff; - $b = $color & 0xff; - return [$r, $g, $b]; - } - - public static function getUserTheme(): string { - return ($_COOKIE['theme'] ?? 'auto'); - } - -}
\ No newline at end of file |