aboutsummaryrefslogtreecommitdiff
path: root/engine
diff options
context:
space:
mode:
authorEvgeny Zinoviev <me@ch1p.io>2024-01-31 06:11:00 +0300
committerEvgeny Zinoviev <me@ch1p.io>2024-01-31 20:45:40 +0300
commitc0dc531ebefd8912819f3b6c8bda1fed3c7e750c (patch)
tree2c75aa9df182260aef09faf4befd81a4c2b9c5e2 /engine
parent48d688cdf7f9eae1bf11b8a6f0e5b98687c604cb (diff)
make it simple, but not simpler
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.php10
-rw-r--r--engine/RequestDispatcher.php84
-rw-r--r--engine/RequestHandler.php56
-rw-r--r--engine/Response.php28
-rw-r--r--engine/Router.php165
-rw-r--r--engine/Skin.php63
-rw-r--r--engine/SkinBase.php22
-rw-r--r--engine/SkinContext.php118
-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.php82
-rw-r--r--engine/database/SQLiteConnection.php81
-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.php271
-rw-r--r--engine/model.php331
-rw-r--r--engine/mysql.php261
-rw-r--r--engine/request.php203
-rw-r--r--engine/router.php185
-rw-r--r--engine/skin.php241
-rw-r--r--engine/strings.php138
-rw-r--r--engine/themes.php38
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