summaryrefslogtreecommitdiff
path: root/engine
diff options
context:
space:
mode:
authorEvgeny Zinoviev <me@ch1p.io>2022-07-09 19:40:17 +0300
committerEvgeny Zinoviev <me@ch1p.io>2022-07-09 19:40:17 +0300
commitf7bfdf58def6aadc922e1632f407d1418269a0d7 (patch)
treed7a0b2819e6a26c11d40ee0b27267ea827fbb345 /engine
initial
Diffstat (limited to 'engine')
-rw-r--r--engine/AjaxErrorResponse.php9
-rw-r--r--engine/AjaxOkResponse.php9
-rw-r--r--engine/AjaxResponse.php13
-rw-r--r--engine/InputType.php8
-rw-r--r--engine/LangAccess.php8
-rw-r--r--engine/LangData.php108
-rw-r--r--engine/Model.php240
-rw-r--r--engine/RedirectResponse.php11
-rw-r--r--engine/RequestDispatcher.php79
-rw-r--r--engine/RequestHandler.php56
-rw-r--r--engine/Response.php28
-rw-r--r--engine/Router.php165
-rw-r--r--engine/Skin.php57
-rw-r--r--engine/SkinBase.php22
-rw-r--r--engine/SkinContext.php116
-rw-r--r--engine/SkinString.php23
-rw-r--r--engine/SkinStringModificationType.php9
-rw-r--r--engine/ansi.php34
-rw-r--r--engine/csrf.php22
-rw-r--r--engine/database/CommonDatabase.php107
-rw-r--r--engine/database/MySQLConnection.php79
-rw-r--r--engine/database/SQLiteConnection.php90
-rw-r--r--engine/exceptions/ForbiddenException.php9
-rw-r--r--engine/exceptions/NotFoundException.php9
-rw-r--r--engine/exceptions/NotImplementedException.php9
-rw-r--r--engine/exceptions/UnauthorizedException.php9
-rw-r--r--engine/logging.php187
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);
+}