diff options
Diffstat (limited to 'engine')
27 files changed, 1516 insertions, 0 deletions
diff --git a/engine/AjaxErrorResponse.php b/engine/AjaxErrorResponse.php new file mode 100644 index 0000000..a1fe381 --- /dev/null +++ b/engine/AjaxErrorResponse.php @@ -0,0 +1,9 @@ +<?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 new file mode 100644 index 0000000..253a563 --- /dev/null +++ b/engine/AjaxOkResponse.php @@ -0,0 +1,9 @@ +<?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 new file mode 100644 index 0000000..931e5e7 --- /dev/null +++ b/engine/AjaxResponse.php @@ -0,0 +1,13 @@ +<?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 new file mode 100644 index 0000000..401f7ca --- /dev/null +++ b/engine/InputType.php @@ -0,0 +1,8 @@ +<?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 new file mode 100644 index 0000000..db55b3b --- /dev/null +++ b/engine/LangAccess.php @@ -0,0 +1,8 @@ +<?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 new file mode 100644 index 0000000..6f108f2 --- /dev/null +++ b/engine/LangData.php @@ -0,0 +1,108 @@ +<?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 new file mode 100644 index 0000000..e80b09d --- /dev/null +++ b/engine/Model.php @@ -0,0 +1,240 @@ +<?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 new file mode 100644 index 0000000..7900229 --- /dev/null +++ b/engine/RedirectResponse.php @@ -0,0 +1,11 @@ +<?php + +class RedirectResponse extends Response { + + public function __construct(string $url) { + parent::__construct(301); + $this->addHeader('HTTP/1.1 301 Moved Permanently'); + $this->addHeader('Location: '.$url); + } + +}
\ No newline at end of file diff --git a/engine/RequestDispatcher.php b/engine/RequestDispatcher.php new file mode 100644 index 0000000..38b965d --- /dev/null +++ b/engine/RequestDispatcher.php @@ -0,0 +1,79 @@ +<?php + +class RequestDispatcher { + + public function __construct( + protected Router $router + ) {} + + public function dispatch(): void { + 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('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-bundle.css'; + $skin->static[] = '/js/common.js'; + + /** @var RequestHandler $handler */ + $handler = new $handler_class($skin, LangData::getInstance(), $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 new file mode 100644 index 0000000..a9dfccd --- /dev/null +++ b/engine/RequestHandler.php @@ -0,0 +1,56 @@ +<?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 new file mode 100644 index 0000000..6250063 --- /dev/null +++ b/engine/Response.php @@ -0,0 +1,28 @@ +<?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 new file mode 100644 index 0000000..0cb761d --- /dev/null +++ b/engine/Router.php @@ -0,0 +1,165 @@ +<?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 new file mode 100644 index 0000000..57f8b90 --- /dev/null +++ b/engine/Skin.php @@ -0,0 +1,57 @@ +<?php + +class Skin { + + public string $title = 'title'; + public array $static = []; + public array $meta = []; + + protected array $langKeys = []; + protected array $options = [ + 'full_width' => 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; + + $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, + 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 new file mode 100644 index 0000000..b50c172 --- /dev/null +++ b/engine/SkinBase.php @@ -0,0 +1,22 @@ +<?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 new file mode 100644 index 0000000..cfb5068 --- /dev/null +++ b/engine/SkinContext.php @@ -0,0 +1,116 @@ +<?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)) + return [$this, $name]; + + 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 new file mode 100644 index 0000000..0f8f14d --- /dev/null +++ b/engine/SkinString.php @@ -0,0 +1,23 @@ +<?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 new file mode 100644 index 0000000..7e750f2 --- /dev/null +++ b/engine/SkinStringModificationType.php @@ -0,0 +1,9 @@ +<?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 new file mode 100644 index 0000000..311c837 --- /dev/null +++ b/engine/ansi.php @@ -0,0 +1,34 @@ +<?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 new file mode 100644 index 0000000..20ea919 --- /dev/null +++ b/engine/csrf.php @@ -0,0 +1,22 @@ +<?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 new file mode 100644 index 0000000..13ea79c --- /dev/null +++ b/engine/database/CommonDatabase.php @@ -0,0 +1,107 @@ +<?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 new file mode 100644 index 0000000..9b473cb --- /dev/null +++ b/engine/database/MySQLConnection.php @@ -0,0 +1,79 @@ +<?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(); + return !!$this->link->real_connect($this->host, $this->user, $this->password, $this->database); + } + + 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 new file mode 100644 index 0000000..f124ced --- /dev/null +++ b/engine/database/SQLiteConnection.php @@ -0,0 +1,90 @@ +<?php + +class SQLiteConnection extends CommonDatabase { + + const SCHEMA_VERSION = 2; + + 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) { + $this->link->exec("CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT, + password TEXT + )"); + } + if ($cur < 2) { + $this->link->exec("CREATE TABLE vk_processed ( + last_message_time INTEGER + )"); + $this->link->exec("INSERT INTO vk_processed (last_message_time) VALUES (0)"); + } + $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 new file mode 100644 index 0000000..4184908 --- /dev/null +++ b/engine/exceptions/ForbiddenException.php @@ -0,0 +1,9 @@ +<?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 new file mode 100644 index 0000000..211106f --- /dev/null +++ b/engine/exceptions/NotFoundException.php @@ -0,0 +1,9 @@ +<?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 new file mode 100644 index 0000000..1c4562a --- /dev/null +++ b/engine/exceptions/NotImplementedException.php @@ -0,0 +1,9 @@ +<?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 new file mode 100644 index 0000000..84a1251 --- /dev/null +++ b/engine/exceptions/UnauthorizedException.php @@ -0,0 +1,9 @@ +<?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 new file mode 100644 index 0000000..f77a5fa --- /dev/null +++ b/engine/logging.php @@ -0,0 +1,187 @@ +<?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); } + +class logging { + + // private static $instance = null; + + protected static ?string $logFile = null; + protected static bool $enabled = false; + protected static int $counter = 0; + + /** @var ?callable $filter */ + protected static $filter = null; + + public static function setLogFile(string $log_file): void { + self::$logFile = $log_file; + } + + public static function setErrorFilter(callable $filter): void { + self::$filter = $filter; + } + + public static function disable(): void { + self::$enabled = false; + + restore_error_handler(); + register_shutdown_function(function() {}); + } + + public static function enable(): void { + self::$enabled = true; + + set_error_handler(function($no, $str, $file, $line) { + if (is_callable(self::$filter) && !(self::$filter)($no, $file, $line, $str)) + return; + + self::write(LogLevel::ERROR, $str, + errno: $no, + errfile: $file, + errline: $line); + }); + + register_shutdown_function(function() { + if (!($error = error_get_last())) + return; + + if (is_callable(self::$filter) + && !(self::$filter)($error['type'], $error['file'], $error['line'], $error['message'])) { + return; + } + + self::write(LogLevel::ERROR, $error['message'], + errno: $error['type'], + errfile: $error['file'], + errline: $error['line']); + }); + } + + public static function logCustom(LogLevel $level, ...$args): void { + self::write($level, self::strVars($args)); + } + + protected static function write(LogLevel $level, + string $message, + ?int $errno = null, + ?string $errfile = null, + ?string $errline = null): void { + + // TODO test + if (is_null(self::$logFile)) { + fprintf(STDERR, __METHOD__.': logfile is not set'); + return; + } + + $num = self::$counter++; + $time = time(); + + // TODO rewrite using sprintf + $exec_time = strval(exectime()); + 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']; + $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 .= "\n"; + } + + $letter = strtoupper($level->name[0]); + $color = match ($level) { + LogLevel::ERROR => Color::RED, + LogLevel::INFO, LogLevel::DEBUG => Color::WHITE, + LogLevel::WARNING => Color::YELLOW + }; + + $buf .= wrap($letter.wrap('='.wrap($num, bold: true)), fg: $color).' '; + $buf .= wrap($exec_time, fg: Color::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 .= $message."\n"; + if (in_array($level, [LogLevel::ERROR, LogLevel::WARNING])) + $buf .= backtrace(2)."\n"; + + // TODO test + $set_perm = !file_exists(self::$logFile); + $f = fopen(self::$logFile, 'a'); + if (!$f) { + fprintf(STDERR, __METHOD__.': failed to open file "'.self::$logFile.'" for writing'); + return; + } + + fwrite($f, $buf); + fclose($f); + + if ($set_perm) + setperm($f); + } + + 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()); + } + + 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); + } + +} + +function backtrace(int $shift = 0): string { + $bt = debug_backtrace(); + $lines = []; + foreach ($bt as $i => $t) { + if ($i < $shift) + continue; + + if (!isset($t['file'])) { + $lines[] = 'from ?'; + } else { + $lines[] = 'from '.$t['file'].':'.$t['line']; + } + } + return implode("\n", $lines); +} |