diff --git a/composer.json b/composer.json index 6081fe0..2378f70 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,8 @@ "ext-yaml": "*", "ext-gmp": "*", "ext-memcached": "*", - "samdark/sitemap": "^2.1" + "samdark/sitemap": "^2.1", + "twig/twig": "^3.0" }, "repositories": [ { diff --git a/composer.lock b/composer.lock index 5fedc9b..7e7eb39 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5fba09fb495209abe5954d5dc485a308", + "content-hash": "d75fee72f82dcd543f7ac9fd20971de8", "packages": [ { "name": "erusev/parsedown", @@ -267,19 +267,20 @@ }, { "name": "samdark/sitemap", - "version": "2.1.0", + "version": "2.4.1", "source": { "type": "git", "url": "https://github.com/samdark/sitemap.git", - "reference": "6b7eed71534b31d0c6e6dfd18d3cca5a677e0b5b" + "reference": "cf514750781275ad90fc9a828b4330c9c5ccba98" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/samdark/sitemap/zipball/6b7eed71534b31d0c6e6dfd18d3cca5a677e0b5b", - "reference": "6b7eed71534b31d0c6e6dfd18d3cca5a677e0b5b", + "url": "https://api.github.com/repos/samdark/sitemap/zipball/cf514750781275ad90fc9a828b4330c9c5ccba98", + "reference": "cf514750781275ad90fc9a828b4330c9c5ccba98", "shasum": "" }, "require": { + "ext-xmlwriter": "*", "php": ">=5.3.0" }, "require-dev": { @@ -311,7 +312,17 @@ "issues": "https://github.com/samdark/sitemap/issues", "source": "https://github.com/samdark/sitemap" }, - "time": "2017-11-24T07:24:48+00:00" + "funding": [ + { + "url": "https://github.com/samdark", + "type": "github" + }, + { + "url": "https://www.patreon.com/samdark", + "type": "patreon" + } + ], + "time": "2023-11-01T08:41:34+00:00" }, { "name": "scrivo/highlight.php", @@ -390,6 +401,311 @@ } ], "time": "2022-12-17T21:53:22+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.5.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.5-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:20:29+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "twig/twig", + "version": "v3.20.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "3468920399451a384bef53cf7996965f7cd40183" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/3468920399451a384bef53cf7996965f7cd40183", + "reference": "3468920399451a384bef53cf7996965f7cd40183", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Resources/core.php", + "src/Resources/debug.php", + "src/Resources/escaper.php", + "src/Resources/string_loader.php" + ], + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.20.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2025-02-13T08:34:43+00:00" } ], "packages-dev": [], @@ -409,6 +725,6 @@ "ext-gmp": "*", "ext-memcached": "*" }, - "platform-dev": [], - "plugin-api-version": "2.3.0" + "platform-dev": {}, + "plugin-api-version": "2.6.0" } diff --git a/deploy/build_css.sh b/deploy/build_css.sh index c53157d..91fedab 100755 --- a/deploy/build_css.sh +++ b/deploy/build_css.sh @@ -1,10 +1,8 @@ #!/bin/sh -PROGNAME="$0" DIR=$(cd "$(dirname "$(readlink -f "$0")")" && pwd) ROOT="$(realpath "$DIR/../")" CLEANCSS="$ROOT"/node_modules/clean-css-cli/bin/cleancss - . $DIR/build_common.sh build_scss() { diff --git a/deploy/build_js.sh b/deploy/build_js.sh index 07a4285..22ff999 100755 --- a/deploy/build_js.sh +++ b/deploy/build_js.sh @@ -1,8 +1,6 @@ #!/bin/sh -PROGNAME="$0" DIR=$(cd "$(dirname "$(readlink -f "$0")")" && pwd) - . $DIR/build_common.sh # suckless version of webpack diff --git a/deploy/gen_static_config.php b/deploy/gen_static_config.php index dca6f87..f475279 100755 --- a/deploy/gen_static_config.php +++ b/deploy/gen_static_config.php @@ -61,4 +61,29 @@ EOF; function get_hash(string $path): string { return substr(sha1(file_get_contents($path)), 0, 8); +} + +function glob_escape(string $pattern): string { + if (str_contains($pattern, '[') || str_contains($pattern, ']')) { + $placeholder = uniqid(); + $replaces = array( $placeholder.'[', $placeholder.']', ); + $pattern = str_replace( array('[', ']', ), $replaces, $pattern); + $pattern = str_replace( $replaces, array('[[]', '[]]', ), $pattern); + } + return $pattern; +} + +/** + * Does not support flag GLOB_BRACE + * + * @param string $pattern + * @param int $flags + * @return array + */ +function glob_recursive(string $pattern, int $flags = 0): array { + $files = glob(glob_escape($pattern), $flags); + foreach (glob(glob_escape(dirname($pattern)).'/*', GLOB_ONLYDIR|GLOB_NOSORT) as $dir) { + $files = array_merge($files, glob_recursive($dir.'/'.basename($pattern), $flags)); + } + return $files; } \ No newline at end of file diff --git a/engine/logging.php b/engine/logging.php index 5f2b612..375922b 100644 --- a/engine/logging.php +++ b/engine/logging.php @@ -1,6 +1,35 @@ 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"; +} enum LogLevel: int { case ERROR = 10; @@ -32,15 +61,15 @@ abstract class Logger { /** @var ?callable $filter */ protected $filter = null; - function setErrorFilter(callable $filter): void { + public function setErrorFilter(callable $filter): void { $this->filter = $filter; } - function disable(): void { + public function disable(): void { $this->enabled = false; } - function enable(): void { + public function enable(): void { static $error_handler_set = false; $this->enabled = true; @@ -87,14 +116,14 @@ abstract class Logger { $error_handler_set = true; } - function log(LogLevel $level, ?string $stacktrace = null, ...$args): void { - if (!is_dev() && $level == LogLevel::DEBUG) + public function log(LogLevel $level, ?string $stacktrace = null, ...$args): void { + if (!isDev() && $level == LogLevel::DEBUG) return; $this->write($level, strVars($args), stacktrace: $stacktrace); } - function canReport(): bool { + public function canReport(): bool { return $this->recursionLevel < 3; } @@ -123,7 +152,7 @@ abstract class Logger { class FileLogger extends Logger { - function __construct(protected string $logFile) {} + public function __construct(protected string $logFile) {} protected function writer(LogLevel $level, int $num, @@ -145,7 +174,7 @@ class FileLogger extends Logger { if (strlen($exec_time) < 6) $exec_time .= str_repeat('0', 6 - strlen($exec_time)); - $title = is_cli() ? 'cli' : $_SERVER['REQUEST_URI']; + $title = isCli() ? 'cli' : $_SERVER['REQUEST_URI']; $date = date('d/m/y H:i:s', $time); $buf = ''; @@ -178,7 +207,7 @@ class FileLogger extends Logger { $buf .= $message."\n"; if (in_array($level, [LogLevel::ERROR, LogLevel::WARNING])) - $buf .= ($stacktrace ?: backtrace_as_string(2))."\n"; + $buf .= ($stacktrace ?: backtraceAsString(2))."\n"; $set_perm = false; if (!file_exists($this->logFile)) { @@ -216,8 +245,6 @@ class DatabaseLogger extends Logger { ?string $errline = null, ?string $stacktrace = null): void { - global $AdminSession; - $db = DB(); $data = [ @@ -229,12 +256,12 @@ class DatabaseLogger extends Logger { 'line' => $errline ?: 0, 'text' => $message, 'level' => $level->value, - 'stacktrace' => $stacktrace ?: backtrace_as_string(2), - 'is_cli' => intval(is_cli()), - 'admin_id' => is_admin() ? $AdminSession->id : 0, + 'stacktrace' => $stacktrace ?: backtraceAsString(2), + 'is_cli' => intval(isCli()), + 'admin_id' => isAdmin() ? admin::getId() : 0, ]; - if (is_cli()) { + if (isCli()) { $data += [ 'ip' => '', 'ua' => '', @@ -274,7 +301,7 @@ function strVars(array $args): string { return implode(' ', $args); } -function backtrace_as_string(int $shift = 0): string { +function backtraceAsString(int $shift = 0): string { $bt = debug_backtrace(); $lines = []; foreach ($bt as $i => $t) { diff --git a/engine/model.php b/engine/model.php index e967dc2..ffcdc5a 100644 --- a/engine/model.php +++ b/engine/model.php @@ -124,7 +124,7 @@ abstract class model { } public function get_id() { - return $this->{to_camel_case(static::DB_KEY)}; + return $this->{toCamelCase(static::DB_KEY)}; } public function as_array(array $properties = [], array $custom_getters = []): array { @@ -136,7 +136,7 @@ abstract class model { if (isset($custom_getters[$field]) && is_callable($custom_getters[$field])) { $array[$field] = $custom_getters[$field](); } else { - $array[$field] = $this->{to_camel_case($field)}; + $array[$field] = $this->{toCamelCase($field)}; } } @@ -220,7 +220,7 @@ abstract class model { realType: $real_type, nullable: $type->allowsNull(), modelName: $name, - dbName: from_camel_case($name) + dbName: fromCamelCase($name) ); $list[] = $model_descr; $db_name_map[$model_descr->getDbName()] = $index++; diff --git a/engine/mysql.php b/engine/mysql.php index 66c1400..510bab5 100644 --- a/engine/mysql.php +++ b/engine/mysql.php @@ -2,12 +2,12 @@ class mysql { - const DATE_FORMAT = 'Y-m-d'; - const DATETIME_FORMAT = 'Y-m-d H:i:s'; + const string DATE_FORMAT = 'Y-m-d'; + const string DATETIME_FORMAT = 'Y-m-d H:i:s'; protected ?mysqli $link = null; - function __construct( + public function __construct( protected string $host, protected string $user, protected string $password, @@ -40,11 +40,11 @@ class mysql { return $sql; } - function insert(string $table, array $fields) { + public function insert(string $table, array $fields) { return $this->performInsert('INSERT', $table, $fields); } - function replace(string $table, array $fields) { + public function replace(string $table, array $fields) { return $this->performInsert('REPLACE', $table, $fields); } @@ -66,7 +66,7 @@ class mysql { return $this->query(...$values); } - function update(string $table, array $rows, ...$cond) { + public function update(string $table, array $rows, ...$cond) { $fields = []; $args = []; foreach ($rows as $row_name => $row_value) { @@ -82,13 +82,13 @@ class mysql { return $this->query($sql, ...$args); } - function multipleInsert(string $table, array $rows) { + 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); } - function multipleReplace(string $table, array $rows) { + 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); @@ -110,12 +110,12 @@ class mysql { return [$names, implode(', ', $sql_rows)]; } - function __destruct() { + public function __destruct() { if ($this->link) $this->link->close(); } - function connect(): bool { + public function connect(): bool { $this->link = new mysqli(); $result = $this->link->real_connect($this->host, $this->user, $this->password, $this->database); if ($result) @@ -123,24 +123,24 @@ class mysql { return !!$result; } - function query(string $sql, ...$args): mysqli_result|bool { + public function query(string $sql, ...$args): mysqli_result|bool { $sql = $this->prepareQuery($sql, ...$args); $q = false; try { $q = $this->link->query($sql); if (!$q) - logError(__METHOD__.': '.$this->link->error."\n$sql\n".backtrace_as_string(1)); + logError(__METHOD__.': '.$this->link->error."\n$sql\n".backtraceAsString(1)); } catch (mysqli_sql_exception $e) { - logError(__METHOD__.': '.$e->getMessage()."\n$sql\n".backtrace_as_string(1)); + logError(__METHOD__.': '.$e->getMessage()."\n$sql\n".backtraceAsString(1)); } return $q; } - function error() { + public function error() { return $this->link?->error; } - function fetch($q): ?array { + public function fetch($q): ?array { $row = $q->fetch_assoc(); if (!$row) { $q->free(); @@ -149,7 +149,7 @@ class mysql { return $row; } - function fetchAll($q): ?array { + public function fetchAll($q): ?array { if (!$q) return null; $list = []; @@ -160,31 +160,31 @@ class mysql { return $list; } - function fetchRow($q): ?array { + public function fetchRow($q): ?array { return $q?->fetch_row(); } - function result($q, $field = 0) { + public function result($q, $field = 0) { return $q?->fetch_row()[$field]; } - function insertId(): int { + public function insertId(): int { return $this->link->insert_id; } - function numRows($q): ?int { + public function numRows($q): ?int { return $q?->num_rows; } - function affectedRows(): ?int { + public function affectedRows(): ?int { return $this->link?->affected_rows; } - function foundRows(): int { + public function foundRows(): int { return (int)$this->fetch($this->query("SELECT FOUND_ROWS() AS `count`"))['count']; } - function escape(string $s): string { + public function escape(string $s): string { return $this->link->real_escape_string($s); } @@ -251,7 +251,7 @@ function DB(): mysql|null { $config['mysql']['password'], $config['mysql']['database']); if (!$link->connect()) { - if (!is_cli()) { + if (!isCli()) { header('HTTP/1.1 503 Service Temporarily Unavailable'); header('Status: 503 Service Temporarily Unavailable'); header('Retry-After: 300'); diff --git a/engine/request.php b/engine/request.php index 789b2b5..1646293 100644 --- a/engine/request.php +++ b/engine/request.php @@ -1,43 +1,5 @@ 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; @@ -51,92 +13,6 @@ enum HTTPCode: int { case NotImplemented = 501; } -function http_error(HTTPCode $http_code, string $message = ''): void { - if (is_xhr_request()) { - $data = []; - if ($message != '') - $data['message'] = $message; - ajax_error((object)$data, $http_code->value); - } else { - $ctx = skin('error'); - $http_message = preg_replace('/(?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'); - if (is_xhr_request()) { - ajax_ok(['redirect' => $url]); - } - http_response_code($code->value); - header('Location: '.$url); - exit; -} - -function invalid_request(string $message = '') { http_error(HTTPCode::InvalidRequest, $message); } -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; -} - -function ensure_admin() { - if (!is_admin()) - forbidden(); - set_skin_opts(['inside_admin_interface' => true]); -} - -function ensure_xhr() { - if (!is_xhr_request()) - invalid_request(); -} - -abstract class request_handler { - function __construct() { - add_static( - 'css/common.css', - 'js/common.js' - ); - } - - 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'; @@ -145,94 +21,218 @@ enum InputVarType: string { case ENUM = 'e'; } -function input(string $input, array $options = []): array { - global $RouterInput; +//function ensureAdmin() { +// if (!isAdmin()) +// forbidden(); +// $this->skin->setRenderOptions(['inside_admin_interface' => true]); +//} - $options = array_merge(['trim' => false], $options); - $strval = fn(mixed $val): string => $options['trim'] ? trim((string)$val) : (string)$val; +abstract class request_handler { - $input = preg_split('/,\s+?/', $input, -1, PREG_SPLIT_NO_EMPTY); - $ret = []; - foreach ($input as $var) { - $enum_values = null; - $enum_default = null; + protected array $routerInput = []; + protected skin $skin; - $pos = strpos($var, ':'); - if ($pos === 1) { // only one-character type specifiers are supported - $type = substr($var, 0, $pos); - $rest = substr($var, $pos + 1); + public static function resolveAndDispatch() { + if (!in_array($_SERVER['REQUEST_METHOD'], ['POST', 'GET'])) + self::httpError(HTTPCode::NotImplemented, 'Method '.$_SERVER['REQUEST_METHOD'].' not implemented'); - $vartype = InputVarType::tryFrom($type); - if (is_null($vartype)) - internal_server_error('invalid input type '.$type); + $uri = $_SERVER['REQUEST_URI']; + if (($pos = strpos($uri, '?')) !== false) + $uri = substr($uri, 0, $pos); - if ($vartype == InputVarType::ENUM) { - $br_from = strpos($rest, '('); - $br_to = strpos($rest, ')'); + $router = router::getInstance(); + $route = $router->find($uri); + if ($route === null) + self::httpError(HTTPCode::NotFound, 'Route not found'); - if ($br_from === false || $br_to === false) - internal_server_error('failed to parse enum values: '.$rest); + $route = preg_split('/ +/', $route); + $handler_class = $route[0].'Handler'; + if (!class_exists($handler_class)) + self::httpError(HTTPCode::NotFound, isDev() ? 'Handler class "'.$handler_class.'" not found' : ''); - $enum_values = array_map('trim', explode('|', trim(substr($rest, $br_from + 1, $br_to - $br_from - 1)))); - $name = trim(substr($rest, 0, $br_from)); + $action = $route[1]; + $input = []; + if (count($route) > 2) { + for ($i = 2; $i < count($route); $i++) { + $var = $route[$i]; + list($k, $v) = explode('=', $var); + $input[trim($k)] = trim($v); + } + } - 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]; + /** @var request_handler $handler */ + $handler = new $handler_class(); + $handler->callAct($_SERVER['REQUEST_METHOD'], $action, $input); + } + + public function __construct() { + $this->skin = skin::getInstance(); + $this->skin->addStatic( + 'css/common.css', + 'js/common.js' + ); + $this->skin->setGlobal([ + 'is_admin' => isAdmin(), + 'is_dev' => isDev() + ]); + } + + public function beforeDispatch(string $http_method, string $action) {} + + public function callAct(string $http_method, string $action, array $input = []) { + $handler_method = $_SERVER['REQUEST_METHOD'].'_'.$action; + if (!method_exists($this, $handler_method)) + $this->notFound(static::class.'::'.$handler_method.' is not defined'); + + if (!((new ReflectionMethod($this, $handler_method))->isPublic())) + $this->notFound(static::class.'::'.$handler_method.' is not public'); + + if (!empty($input)) + $this->routerInput += $input; + + $args = $this->beforeDispatch($http_method, $action); + return call_user_func_array([$this, $handler_method], is_array($args) ? [$args] : []); + } + + public function input(string $input, array $options = []): array { + $options = array_merge(['trim' => false], $options); + $strval = fn(mixed $val): string => $options['trim'] ? trim((string)$val) : (string)$val; + + $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 === 1) { // only one-character type specifiers are supported + $type = substr($var, 0, $pos); + $rest = substr($var, $pos + 1); + + $vartype = InputVarType::tryFrom($type); + if (is_null($vartype)) + self::internalServerError('invalid input type '.$type); + + if ($vartype == InputVarType::ENUM) { + $br_from = strpos($rest, '('); + $br_to = strpos($rest, ')'); + + if ($br_from === false || $br_to === false) + self::internalServerError('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 { - $name = trim($rest); + $vartype = InputVarType::STRING; + $name = trim($var); } - } 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 = $strval(implode($val)); + $val = null; + if (isset($this->routerInput[$name])) { + $val = $this->routerInput[$name]; + } else if (isset($_POST[$name])) { + $val = $_POST[$name]; + } else if (isset($_GET[$name])) { + $val = $_GET[$name]; + } + if (is_array($val)) + $val = $strval(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 ?? '' : $strval($val), - default => $strval($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 ?? '' : $strval($val), + default => $strval($val) + }; + } + return $ret; } - return $ret; -} -function csrf_get(string $key): string { - global $AdminSession, $config; - $user_key = is_admin() ? $AdminSession->csrfSalt : $_SERVER['REMOTE_ADDR']; - return substr(hash('sha256', $config['csrf_token'].$user_key.$key), 0, 20); -} + public function getPage(int $per_page, ?int $count = null): array { + list($page) = $this->input('i:page'); + $pages = $count !== null ? ceil($count / $per_page) : null; + if ($pages !== null && $page > $pages) + $page = $pages; + if ($page < 1) + $page = 1; + $offset = $per_page * ($page-1); + return [$page, $pages, $offset]; + } -function csrf_check(string $key) { - if (csrf_get($key) != ($_REQUEST['token'] ?? '')) - forbidden('invalid token'); -} + protected static function ensureXhr(): void { + if (!self::isXhrRequest()) + self::invalidRequest(); + } -function get_page(int $per_page, ?int $count = null): array { - list($page) = input('i:page'); - $pages = $count !== null ? ceil($count / $per_page) : null; - if ($pages !== null && $page > $pages) - $page = $pages; - if ($page < 1) - $page = 1; - $offset = $per_page * ($page-1); - return [$page, $pages, $offset]; -} \ No newline at end of file + public static function getCSRF(string $key): string { + global $config; + $user_key = isAdmin() ? admin::getCSRFSalt() : $_SERVER['REMOTE_ADDR']; + return substr(hash('sha256', $config['csrf_token'].$user_key.$key), 0, 20); + } + + protected static function checkCSRF(string $key): void { + if (self::getCSRF($key) != ($_REQUEST['token'] ?? '')) + self::forbidden('invalid token'); + } + + public static function httpError(HTTPCode $http_code, string $message = ''): void { + if (self::isXhrRequest()) { + $data = []; + if ($message != '') + $data['message'] = $message; + self::ajaxError((object)$data, $http_code->value); + } else { + $http_message = preg_replace('/(?name); + $html = skin::getInstance()->render('error.twig', [ + 'code' => $http_code->value, + 'title' => $http_message, + 'message' => $message + ]); + http_response_code($http_code->value); + echo $html; + exit; + } + } + + protected static function redirect(string $url, HTTPCode $code = HTTPCode::MovedPermanently): never { + if (!in_array($code, [HTTPCode::MovedPermanently, HTTPCode::Found])) + self::internalServerError('invalid http code'); + if (self::isXhrRequest()) + self::ajaxOk(['redirect' => $url]); + http_response_code($code->value); + header('Location: '.$url); + exit; + } + + protected static function invalidRequest(string $message = '') { self::httpError(HTTPCode::InvalidRequest, $message); } + protected static function internalServerError(string $message = '') { self::httpError(HTTPCode::InternalServerError, $message); } + protected static function notFound(string $message = '') { self::httpError(HTTPCode::NotFound, $message); } + protected static function forbidden(string $message = '') { self::httpError(HTTPCode::Forbidden, $message); } + protected static function isXhrRequest(): bool { return isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'; } + protected static function ajaxOk(mixed $data): void { self::ajaxResponse(['response' => $data]); } + protected static function ajaxError(mixed $error, int $code = 200): void { self::ajaxResponse(['error' => $error], $code); } + + protected static function ajaxResponse(mixed $data, int $code = 200): never { + 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; + } + +} diff --git a/engine/router.php b/engine/router.php index 5845356..f29dffd 100644 --- a/engine/router.php +++ b/engine/router.php @@ -1,185 +1,201 @@ [], - '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 = [ + protected array $routes = [ 'children' => [], 're_children' => [] ]; - if (!is_null($value)) - $child['value'] = $value; + protected static ?router $instance = null; - $parent[$children_key][$part] = $child; - return $parent[$children_key][$part]; -} + public static function getInstance(): router { + if (self::$instance === null) + self::$instance = new router(); + return self::$instance; + } -function router_find($uri) { - global $Routes; - if ($uri != '/' && $uri[0] == '/') - $uri = substr($uri, 1); + private function __construct() { + $mc = MC(); - $start_pos = 0; - $parent = &$Routes; - $uri_len = strlen($uri); - $matches = []; + $from_cache = !isDev(); + $write_cache = !isDev(); - 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; + if ($from_cache) { + $cache = $mc->get(ROUTER_MC_KEY); + + if ($cache === false || !isset($cache['version']) || $cache['version'] < ROUTER_VERSION) { + $from_cache = false; + } else { + $this->routes = $cache['routes']; + } } - $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 (!$from_cache) { + $routes_table = require_once APP_ROOT.'/routes.php'; - if ($re_result) { - if (count($match) > 1) - $matches = array_merge($matches, array_slice($match, 1)); - $parent = &$child; - $found = true; - break; + foreach ($routes_table as $controller => $routes) { + foreach ($routes as $route => $resolve) + $this->add($route, $controller.' '.$resolve); + } + + if ($write_cache) + $mc->set(ROUTER_MC_KEY, ['version' => ROUTER_VERSION, 'routes' => $this->routes]); + } + } + + public function add(string $template, string $value): void { + 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[] = [ + strReplaceOnce($matches[0][$match_index], $variant, $template), + str_replace('${'.($match_index+1).'}', $variant, $value) + ]; + } + array_splice($templates, $i, 1, $new_templates); + $i += count($new_templates); } } } - if (!$found) - return null; - } + // process all generated routes + foreach ($templates as $template) { + list($template, $value) = $template; - if (!isset($parent['value'])) - return null; + $start_pos = 0; + $parent = &$this->routes; + $template_len = strlen($template); - $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)); + 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 $value; + 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): void { + $this->routes = $routes; + } + + public function dump(): array { + return $this->routes; + } } diff --git a/engine/skin.php b/engine/skin.php index cf38312..e75ce4e 100644 --- a/engine/skin.php +++ b/engine/skin.php @@ -1,13 +1,20 @@ false, 'wide' => false, @@ -19,241 +26,213 @@ $SkinState = new class { 'inside_admin_interface' => false, ]; public array $static = []; - public array $svg_defs = []; -}; + protected array $styleNames = []; + protected array $svgDefs = []; -function render($f, ...$vars): void { - global $SkinState, $config; + public \Twig\Environment $twig; - add_skin_strings(['4in1']); + protected static ?skin $instance = null; - $ctx = skin(substr($f, 0, ($pos = strrpos(str_replace('/', '\\', $f), '\\')))); - $body = call_user_func_array([$ctx, substr($f, $pos + 1)], $vars); - if (is_array($body)) - list($body, $js) = $body; - else - $js = null; + public static function getInstance(): skin { + if (self::$instance === null) + self::$instance = new skin(); + return self::$instance; + } - $theme = getUserTheme(); - if ($theme != 'auto' && !themeExists($theme)) - $theme = 'auto'; + /** + * @throws LoaderError + */ + protected function __construct() { + global $config; + $cache_dir = $config['skin_cache_'.(isDev() ? 'dev' : 'prod').'_dir']; + if (!file_exists($cache_dir)) { + if (mkdir($cache_dir, $config['dirs_mode'], true)) + setperm($cache_dir); + } - $is_system_theme_dark = $theme == 'auto' && isUserSystemThemeDark(); + // must specify a second argument ($rootPath) here + // otherwise it will be getcwd() and it's www-prod/htdocs/ for apache and www-prod/ for cli code + // this is bad for templates rebuilding + $twig_loader = new \Twig\Loader\FilesystemLoader(APP_ROOT.'/skin', APP_ROOT); + // $twig_loader->addPath(APP_ROOT.'/htdocs/svg', 'svg'); - $layout_ctx = skin('base'); - - $lang = []; - foreach ($SkinState->lang as $key) - $lang[$key] = lang($key); - $lang = !empty($lang) ? jsonEncode($lang) : ''; - - $title = $SkinState->title; - if (!$SkinState->options['is_index']) - $title = lang('4in1').' - '.$title; - - $html = $layout_ctx->layout( - static: $SkinState->static, - theme: $theme, - is_system_theme_dark: $is_system_theme_dark, - title: $title, - opts: $SkinState->options, - js: $js, - meta: $SkinState->meta, - unsafe_lang: $lang, - unsafe_body: $body, - exec_time: exectime(), - admin_email: $config['admin_email'], - svg_defs: $SkinState->svg_defs - ); - 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; - static $twitter_limits = [ - 'title' => 70, - 'description' => 200 - ]; - $real_meta = []; - $add_og_twitter = function($key, $value) use (&$real_meta, $twitter_limits) { - foreach (['og', 'twitter'] as $social) { - if ($social == 'twitter' && isset($twitter_limits[$key])) { - if (mb_strlen($value) > $twitter_limits[$key]) - $value = mb_substr($value, 0, $twitter_limits[$key]-3).'...'; - } - $real_meta[] = [ - $social == 'twitter' ? 'name' : 'property' => $social.':'.$key, - 'content' => $value + $env_options = []; + if (!is_null($cache_dir)) { + $env_options += [ + 'cache' => $cache_dir, + 'auto_reload' => isDev() ]; } - }; - foreach ($data as $key => $value) { - if (str_starts_with($value, '$')) - $value = lang(substr($value, 1)); - switch ($key) { - case '$url': - case '$title': - case '$image': - $add_og_twitter(substr($key, 1), $value); - break; + $twig = new \Twig\Environment($twig_loader, $env_options); + $twig->addExtension(new \TwigAddons\MyExtension()); - case '$description': - case '$keywords': - $real_name = substr($key, 1); - $add_og_twitter($real_name, $value); - $real_meta[] = ['name' => $real_name, 'content' => $value]; - break; + $this->twig = $twig; + } - default: - if (str_starts_with($key, 'og:')) { - $real_meta[] = ['property' => $key, 'content' => $value]; - } else { - logWarning("unsupported meta: $key => $value"); + public function addMeta(array $data) { + static $twitter_limits = [ + 'title' => 70, + 'description' => 200 + ]; + $real_meta = []; + $add_og_twitter = function($key, $value) use (&$real_meta, $twitter_limits) { + foreach (['og', 'twitter'] as $social) { + if ($social == 'twitter' && isset($twitter_limits[$key])) { + if (mb_strlen($value) > $twitter_limits[$key]) + $value = mb_substr($value, 0, $twitter_limits[$key]-3).'...'; } - break; - } - } - $SkinState->meta = array_merge($SkinState->meta, $real_meta); -} - - -class SkinContext { - - protected string $ns; - protected array $data = []; - - function __construct(string $namespace) { - $this->ns = $namespace; - require_once APP_ROOT.str_replace('\\', DIRECTORY_SEPARATOR, $namespace).'.phps'; - } - - function __call($name, array $arguments) { - $plain_args = array_is_list($arguments); - - $fn = $this->ns.'\\'.$name; - $refl = new ReflectionFunction($fn); - $fparams = $refl->getParameters(); - $fparams_required_count = 0; - foreach ($fparams as $param) { - if (!$param->isDefaultValueAvailable()) - $fparams_required_count++; - } - $given_count = count($arguments)+1; - assert($given_count >= $fparams_required_count && $given_count <= count($fparams), - "$fn: invalid number of arguments (function has ".$fparams_required_count." required arguments".(count($fparams) != $fparams_required_count ? ' and '.count($fparams).' total arguments' : '').", received ".(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; + $real_meta[] = [ + $social == 'twitter' ? 'name' : 'property' => $social.':'.$key, + 'content' => $value + ]; } + }; + foreach ($data as $key => $value) { + if (str_starts_with($value, '@')) + $value = lang(substr($value, 1)); + switch ($key) { + case '@url': + case '@title': + case '@image': + $add_og_twitter(substr($key, 1), $value); + break; - if ($plain_args && !isset($arguments[$key])) - break; + case '@description': + case '@keywords': + $real_name = substr($key, 1); + $add_og_twitter($real_name, $value); + $real_meta[] = ['name' => $real_name, 'content' => $value]; + break; - 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, - 'nl2br' => SkinStringModificationType::NL2BR, - default => SkinStringModificationType::HTML - }; - } else { - $mod_type = SkinStringModificationType::HTML; - } - $arguments[$key]->setModType($mod_type); + default: + if (str_starts_with($key, 'og:')) { + $real_meta[] = ['property' => $key, 'content' => $value]; + } else { + logWarning("unsupported meta: $key => $value"); + } + break; } } - - array_unshift($arguments, $this); - return call_user_func_array($fn, $arguments); + $this->meta = array_merge($this->meta, $real_meta); } - function &__get(string $name) { - $fn = $this->ns.'\\'.$name; - if (function_exists($fn)) { - $f = [$this, $name]; - return $f; + public function exportStrings(array|string $keys): void { + global $__lang; + $this->lang = array_merge($this->lang, is_string($keys) ? $__lang->search($keys) : $keys); + } + + public function setTitle(string $title): void { + if (str_starts_with($title, '$')) + $title = lang(substr($title, 1)); + else if (str_starts_with($title, '\\$')) + $title = substr($title, 1); + $this->title = $title; + } + + public function addPageTitleModifier(callable $callable): void { + if (!is_callable($callable)) { + trigger_error(__METHOD__.': argument is not callable'); + } else { + $this->titleModifiers[] = $callable; } - - if (array_key_exists($name, $this->data)) - return $this->data[$name]; } - function __set(string $name, $value) { - $this->data[$name] = $value; + protected function getTitle(): string { + $title = $this->title != '' ? $this->title : lang('site_title'); + if (!empty($this->titleModifiers)) { + foreach ($this->titleModifiers as $modifier) + $title = $modifier($title); + } + return $title; } - function if_not($cond, $callback, ...$args) { - return $this->_if_condition(!$cond, $callback, ...$args); + public function set($arg1, $arg2 = null) { + if (is_array($arg1)) { + foreach ($arg1 as $key => $value) + $this->vars[$key] = $value; + } elseif ($arg2 !== null) { + $this->vars[$arg1] = $arg2; + } } - function if_true($cond, $callback, ...$args) { - return $this->_if_condition($cond, $callback, ...$args); + public function isSet($key): bool { + return isset($this->vars[$key]); } - function if_admin($callback, ...$args) { - return $this->_if_condition(is_admin(), $callback, ...$args); + public function setGlobal($arg1, $arg2 = null): void { + if ($this->globalsApplied) + logError(__METHOD__.': WARNING: globals were already applied, your change will not be visible'); + + if (is_array($arg1)) { + foreach ($arg1 as $key => $value) + $this->globalVars[$key] = $value; + } elseif ($arg2 !== null) { + $this->globalVars[$arg1] = $arg2; + } } - function if_dev($callback, ...$args) { - return $this->_if_condition(is_dev(), $callback, ...$args); + public function isGlobalSet($key): bool { + return isset($this->globalVars[$key]); } - function if_then_else($cond, $cb1, $cb2) { - return $cond ? $this->_return_callback($cb1) : $this->_return_callback($cb2); + public function getGlobal($key) { + return $this->isGlobalSet($key) ? $this->globalVars[$key] : null; } - function csrf($key): string { - return csrf_get($key); + public function applyGlobals(): void { + if (!empty($this->globalVars) && !$this->globalsApplied) { + foreach ($this->globalVars as $key => $value) + $this->twig->addGlobal($key, $value); + $this->globalsApplied = true; + } } - function bc(array $items, ?string $style = null, bool $mt = false): string { + public function addStatic(string ...$files): void { + foreach ($files as $file) + $this->static[] = $file; + } + + public function addJS(string $js): void { + if ($js != '') + $this->js[] = $js; + } + + protected function getJS(): string { + if (empty($this->js)) + return ''; + return implode("\n", $this->js); + } + + public function preloadSVG(string $name): void { + if (isset($this->svgDefs[$name])) + return; + + if (!preg_match_all('/\d+/', $name, $matches)) + throw new InvalidArgumentException('icon name '.$name.' is invalid, it should follow following pattern: $name_$size[_$size]'); + + $size = array_slice($matches[0], -2); + $this->svgDefs[$name] = [ + 'width' => $size[0], + 'height' => $size[1] ?? $size[0] + ]; + } + + public function getSVG(string $name, bool $in_place = false): ?string { + $this->preloadSVG($name); + $w = $this->svgDefs[$name]['width']; + $h = $this->svgDefs[$name]['height']; + if ($in_place) { + $svg = ''; + $svg .= file_get_contents(APP_ROOT.'/skin/svg/'.$name.'.svg'); + $svg .= ''; + return $svg; + } else { + return ''; + } + } + + public function renderBreadCrumbs(array $items, ?string $style = null, bool $mt = false): string { static $chevron = ''; - $buf = implode(array_map(function(array $i) use ($chevron): string { $buf = ''; $has_url = array_key_exists('url', $i); @@ -277,13 +256,11 @@ class SkinContext { return '
'.$buf.'
'; } - function pagenav(int $page, int $pages, string $link_template, ?array $opts = null) { + public function renderPageNav(int $page, int $pages, string $link_template, ?array $opts = null): string { if ($opts === null) { $count = 0; } else { - $opts = array_merge([ - 'count' => 0, - ], $opts); + $opts = array_merge(['count' => 0], $opts); $count = $opts['count']; } @@ -294,25 +271,23 @@ class SkinContext { $base_class = 'pn-button no-hover no-select no-drag is-page'; for ($p = $min_page; $p <= $max_page; $p++) { $class = $base_class; - if ($p == $page) { + if ($p == $page) $class .= ' is-page-cur'; - } - - $pages_html .= ''.$p.''; + $pages_html .= ''.$p.''; } if ($min_page > 2) { $pages_html = '
 
'.$pages_html; } if ($min_page > 1) { - $pages_html = '1'.$pages_html; + $pages_html = '1'.$pages_html; } if ($max_page < $pages-1) { $pages_html .= '
 
'; } if ($max_page < $pages) { - $pages_html .= ''.$pages.''; + $pages_html .= ''.$pages.''; } $pn_class = 'pn'; @@ -334,146 +309,258 @@ HTML; return $html; } - protected static function _page_nav_get_link($page, $link_template) { - return is_callable($link_template) - ? $link_template($page) - : str_replace('{page}', $page, $link_template); + protected static function pageNavGetLink($page, $link_template) { + return is_callable($link_template) ? $link_template($page) : str_replace('{page}', $page, $link_template); } - 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 getSVGTags(): string { + $buf = ''; + foreach ($this->svgDefs as $name => $icon) { + $content = file_get_contents(APP_ROOT.'/skin/svg/'.$name.'.svg'); + $buf .= "$content"; + } + $buf .= ''; + return $buf; } - 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 setRenderOptions(array $options): void { + $this->options = array_merge($this->options, $options); } - function for_each(array $iterable, callable $callback) { - $html = ''; - foreach ($iterable as $k => $v) - $html .= call_user_func($callback, $v, $k); + public function render($template, array $vars = []): string { + $this->applyGlobals(); + return $this->doRender($template, $this->vars + $vars); + } + + public function renderPage(string $template, array $vars = []): never { + $this->exportStrings(['4in1']); + $this->applyGlobals(); + + // render body first + $b = $this->renderBody($template, $vars); + + // then everything else + $h = $this->renderHeader(); + $f = $this->renderFooter(); + + echo $h; + echo $b; + echo $f; + + exit; + } + + protected function renderHeader(): string { + global $config; + + $body_class = []; + if ($this->options['full_width']) + $body_class[] = 'full-width'; + else if ($this->options['wide']) + $body_class[] = 'wide'; + + $title = $this->getTitle(); + if (!$this->options['is_index']) + $title = lang('4in1').' - '.$title; + + $vars = [ + 'title' => $title, + 'meta_html' => $this->getMetaTags(), + 'static_html' => $this->getHeaderStaticTags(), + 'svg_html' => $this->getSVGTags(), + 'render_options' => $this->options, + 'app_config' => [ + 'domain' => $config['domain'], + 'devMode' => $config['is_dev'], + 'cookieHost' => $config['cookie_host'], + ], + 'body_class' => $body_class, + 'theme' => themes::getUserTheme(), + ]; + + return $this->doRender('header.twig', $vars); + } + + protected function renderBody(string $template, array $vars): string { + return $this->doRender($template, $this->vars + $vars); + } + + protected function renderFooter(): string { + global $config; + + $exec_time = microtime(true) - START_TIME; + $exec_time = round($exec_time, 4); + + $footer_vars = [ + 'exec_time' => $exec_time, + 'render_options' => $this->options, + 'admin_email' => $config['admin_email'], + // 'lang_json' => json_encode($this->getLangKeys(), JSON_UNESCAPED_UNICODE), + // 'static_config' => $this->getStaticConfig(), + 'script_html' => $this->getFooterScriptTags(), + 'this_page_url' => $_SERVER['REQUEST_URI'], + 'theme' => themes::getUserTheme(), + ]; + return $this->doRender('footer.twig', $footer_vars); + } + + protected function doRender(string $template, array $vars = []): string { + $s = ''; + try { + $s = $this->twig->render($template, $vars); + } catch (\Twig\Error\Error $e) { + $error = get_class($e).": failed to render"; + $source_ctx = $e->getSourceContext(); + if ($source_ctx) { + $path = $source_ctx->getPath(); + if (str_starts_with($path, APP_ROOT)) + $path = substr($path, strlen(APP_ROOT)+1); + $error .= " ".$source_ctx->getName()." (".$path.") at line ".$e->getTemplateLine(); + } + $error .= ": "; + $error .= $e->getMessage(); + logError($error); + if (isDev()) + $s = $error."\n"; + } + return $s; + } + + protected function getMetaTags(): string { + if (empty($this->meta)) + return ''; + return implode('', array_map(function(array $item): string { + $s = ' $v) + $s .= ' '.htmlescape($k).'="'.htmlescape($v).'"'; + $s .= '/>'; + $s .= "\n"; + return $s; + }, $this->meta)); + } + + protected function getHeaderStaticTags(): string { + $html = []; + $theme = themes::getUserTheme(); + $dark = $theme == 'dark' || ($theme == 'auto' && themes::isUserSystemThemeDark()); + $this->styleNames = []; + foreach ($this->static as $name) { + // javascript + if (str_starts_with($name, 'js/')) + $html[] = $this->jsLink($name); + + // css + else if (str_starts_with($name, 'css/')) { + $html[] = $this->cssLink($name, 'light', $style_name_ptr); + $this->styleNames[] = $style_name_ptr; + + if ($dark) + $html[] = $this->cssLink($name, 'dark', $style_name_ptr); + else if (!isDev()) + $html[] = $this->cssPrefetchLink($style_name_ptr.'_dark'); + } + else + logError(__FUNCTION__.': unexpected static entry: '.$name); + } + return implode("\n", $html); + } + + protected function getFooterScriptTags(): string { + global $config; + + $html = ''; return $html; } - function lang(...$args): string { - return htmlescape($this->langRaw(...$args)); - } - - function lang_num(...$args): string { - return htmlescape(lang_num(...$args)); - } - - function langRaw(string $key, ...$args) { - $val = lang($key); - return empty($args) ? $val : sprintf($val, ...$args); - } - -} - -class SVGSkinContext extends SkinContext { - - function __construct() { - parent::__construct('\\skin\\icons'); - } - - function __call($name, array $arguments) { - global $SkinState; - - $already_defined = isset($SkinState->svg_defs[$name]); - if (!array_is_list($arguments)) { - $in_place = isset($arguments['in_place']) && $arguments['in_place'] === true; - $preload_symbol = isset($arguments['preload_symbol']) && $arguments['preload_symbol'] === true; + protected function jsLink(string $name): string { + list (, $bname) = $this->getStaticNameParts($name); + if (isDev()) { + $href = '/js.php?name='.urlencode($bname).'&v='.time(); } else { - $in_place = false; - $preload_symbol = false; + $href = '/dist-js/'.$bname.'.js?v='.$this->getStaticVersion($name); } + return ''; + } - if ($already_defined && $preload_symbol) - return null; + protected function cssLink(string $name, string $theme, &$bname = null): string { + list(, $bname) = $this->getStaticNameParts($name); - if ($in_place || !$already_defined) { - if (!preg_match_all('/\d+/', $name, $matches)) - throw new InvalidArgumentException('icon name '.$name.' is invalid, it should follow following pattern: $name_$size[_$size]'); - $size = array_slice($matches[0], -2); - $width = $size[0]; - $height = $size[1] ?? $size[0]; - } + $config_name = 'css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css'; - if (!$in_place && (!$already_defined || $preload_symbol)) { - $SkinState->svg_defs[$name] = [ - 'svg' => parent::__call($name, !$preload_symbol ? $arguments : []), - 'width' => $width, - 'height' => $height - ]; - } - - if ($preload_symbol) - return null; - - if ($already_defined && !isset($width)) { - $width = $SkinState->svg_defs[$name]['width']; - $height = $SkinState->svg_defs[$name]['height']; - } - - if ($in_place) { - $content = parent::__call($name, []); - return <<{$content} -SVG; + if (isDev()) { + $href = '/sass.php?name='.urlencode($bname).'&theme='.$theme.'&v='.time(); } else { - return << -SVG; + $version = $this->getStaticVersion($config_name); + $href = '/dist-css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css?v='.$version; } + + $id = 'style_'.$bname; + if ($theme == 'dark') + $id .= '_dark'; + + return 'getStaticIntegrityAttribute($config_name).'>'; + } + + protected function cssPrefetchLink(string $name): string { + $url = '/dist-css/'.$name.'.css?v='.$this->getStaticVersion('css/'.$name.'.css'); + $integrity = $this->getStaticIntegrityAttribute('css/'.$name.'.css'); + return ''; + } + + protected function getStaticNameParts(string $name): array { + $dname = dirname($name); + $bname = basename($name); + if (($pos = strrpos($bname, '.'))) { + $ext = substr($bname, $pos+1); + $bname = substr($bname, 0, $pos); + } else { + $ext = ''; + } + return [$dname, $bname, $ext]; + } + + protected function getStaticVersion(string $name): string { + global $config; + if (isDev()) + return time(); + if (str_starts_with($name, '/')) { + logWarning(__FUNCTION__.': '.$name.' starts with /'); + $name = substr($name, 1); + } + return $config['static'][$name]['version'] ?? 'notfound'; + } + + protected function getStaticIntegrityAttribute(string $name): string { + if (isDev()) + return ''; + global $config; + return ' integrity="'.implode(' ', array_map(fn($hash_type) => $hash_type.'-'.$config['static'][$name]['integrity'][$hash_type], RESOURCE_INTEGRITY_HASHES)).'"'; } } - -function skin($name): SkinContext { - static $cache = []; - if (!isset($cache[$name])) { - $cache[$name] = new SkinContext('\\skin\\'.$name); - } - return $cache[$name]; -} - -function svg(): SVGSkinContext { - static $svg = null; - if ($svg === null) - $svg = new SVGSkinContext(); - return $svg; -} - -enum SkinStringModificationType { - case RAW; - case URL; - case HTML; - case JSON; - case ADDSLASHES; - case NL2BR; -} - -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 => jsonEncode($this->string), - SkinStringModificationType::ADDSLASHES => addslashes($this->string), - SkinStringModificationType::NL2BR => nl2br(htmlescape($this->string)), - default => $this->string, - }; - } -} diff --git a/engine/sphinx.php b/engine/sphinx.php deleted file mode 100644 index 34bed9b..0000000 --- a/engine/sphinx.php +++ /dev/null @@ -1,104 +0,0 @@ - 1) { - $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 = func_get_arg($i + 1); - if (is_string($arg)) - $arg = _sphinx_normalize($arg); - $v = '\''.$link->real_escape_string($arg).'\''; - $sql = substr_replace($sql, $v, $positions[$i], 1); - } - } - - $q = $link->query($sql); - - $error = sphinx_error(); - if ($error) - logError(__FUNCTION__, $error); - - return $q; -} - -function sphinx_error() { - $link = _sphinxql_link(auto_create: false); - return $link?->error; -} - -function sphinx_mkquery($q, array $opts = []) { - $defaults = [ - 'any_word' => false, - 'star' => false, - 'and' => false, - 'exact_first' => false - ]; - $opts = array_merge($defaults, $opts); - $q = preg_replace('/\s+/', ' ', $q); - $q = _sphinx_normalize($q); - $q = trim($q); - $q = sphinx_client()->escapeString($q); - if ($opts['star']) { - $words = explode(' ', $q); - $words = array_map(fn($word) => $word.'*', $words); - $q = implode(' ', $words); - } - if ($opts['any_word']) { - $q = str_replace(' ', ' | ', $q); - } else if ($opts['and']) { - $q = str_replace(' ', ' AND ', $q); - } - if ($opts['exact_first']) { - $q = '"^'.$q.'$" | "'.$q.'" | ('.$q.')'; - } - return $q; -} - -function sphinx_client(): Sphinx\SphinxClient { - static $cl = null; - if (!is_null($cl)) - return $cl; - return $cl = new Sphinx\SphinxClient; -} - -function _sphinx_normalize(string $origstr): string { - $buf = preg_replace('/[Ёё]/iu', 'е', $origstr); - if (!pcre_no_error($buf, no_error: true)) { - $origstr = mb_convert_encoding($origstr, 'UTF-8', 'UTF-8'); - $buf = preg_replace('/[Ёё]/iu', 'е', $origstr); - pcre_no_error($buf); - } - if ($buf === null) { - logError(__METHOD__.': preg_replace() failed with error: '.preg_last_error().': '.preg_last_error_msg()); - $buf = $origstr; - } - return preg_replace('/[!\?]/', '', $buf); -} - -function _sphinxql_link($auto_create = true) { - global $config; - - /** @var ?mysqli $link */ - static $link = null; - if (!is_null($link) || !$auto_create) - return $link; - - $link = new mysqli(); - $link->real_connect( - $config['sphinx']['host'], - ini_get('mysql.default_user'), - ini_get('mysql.default_password'), - null, - 9306); - $link->set_charset('utf8'); - - return $link; -} diff --git a/engine/strings.php b/engine/strings.php index 2d9b1d3..b2b289f 100644 --- a/engine/strings.php +++ b/engine/strings.php @@ -21,19 +21,19 @@ enum NameType: int { class StringsBase implements ArrayAccess { protected array $data = []; - function offsetSet(mixed $offset, mixed $value): void { + public function offsetSet(mixed $offset, mixed $value): void { throw new RuntimeException('Not implemented'); } - function offsetExists(mixed $offset): bool { + public function offsetExists(mixed $offset): bool { return isset($this->data[$offset]); } - function offsetUnset(mixed $offset): void { + public function offsetUnset(mixed $offset): void { throw new RuntimeException('Not implemented'); } - function offsetGet(mixed $offset): mixed { + public function offsetGet(mixed $offset): mixed { if (!isset($this->data[$offset])) { logError(__METHOD__.': '.$offset.' not found'); return '{'.$offset.'}'; @@ -41,7 +41,7 @@ class StringsBase implements ArrayAccess { return $this->data[$offset]; } - function get(string $key, mixed ...$sprintf_args): string|array { + public function get(string $key, mixed ...$sprintf_args): string|array { $val = $this[$key]; if (!empty($sprintf_args)) { array_unshift($sprintf_args, $val); @@ -51,7 +51,7 @@ class StringsBase implements ArrayAccess { } } - function num(string $key, int $num, array$opts = []) { + public function num(string $key, int $num, array$opts = []) { $s = $this[$key]; $default_opts = [ @@ -115,7 +115,7 @@ class Strings extends StringsBase { return self::$instance; } - function load(string ...$pkgs): array { + public function load(string ...$pkgs): array { $keys = []; foreach ($pkgs as $name) { $raw = yaml_parse_file(APP_ROOT.'/strings/'.$name.'.yaml'); @@ -126,13 +126,13 @@ class Strings extends StringsBase { return $keys; } - function flex(string $s, DeclensionCase $case, NameSex $sex, NameType $type): string { + public 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 { + public function search(string $regexp): array { return preg_grep($regexp, array_keys($this->data)); } } \ No newline at end of file diff --git a/functions.php b/functions.php index 0b0161c..1142135 100644 --- a/functions.php +++ b/functions.php @@ -1,6 +1,6 @@ 'gif', @@ -167,7 +167,7 @@ function ulong2ip(int $ip): string { return long2ip(-$long); } -function from_camel_case(string $s): string { +function fromCamelCase(string $s): string { $buf = ''; $len = strlen($s); for ($i = 0; $i < $len; $i++) { @@ -180,11 +180,11 @@ function from_camel_case(string $s): string { return $buf; } -function to_camel_case(string $input, string $separator = '_'): string { +function toCamelCase(string $input, string $separator = '_'): string { return lcfirst(str_replace($separator, '', ucwords($input, $separator))); } -function str_replace_once(string $needle, string $replace, string $haystack) { +function strReplaceOnce(string $needle, string $replace, string $haystack) { $pos = strpos($haystack, $needle); if ($pos !== false) $haystack = substr_replace($haystack, $replace, $pos, strlen($needle)); @@ -205,7 +205,7 @@ function strgen(int $len): string { return $buf; } -function sanitize_filename(string $name): string { +function sanitizeFilename(string $name): string { $name = mb_strtolower($name); $name = transliterate($name); $name = preg_replace('/[^\w\d\-_\s.]/', '', $name); @@ -213,31 +213,6 @@ function sanitize_filename(string $name): string { return $name; } -function glob_escape(string $pattern): string { - if (strpos($pattern, '[') !== false || strpos($pattern, ']') !== false) { - $placeholder = uniqid(); - $replaces = array( $placeholder.'[', $placeholder.']', ); - $pattern = str_replace( array('[', ']', ), $replaces, $pattern); - $pattern = str_replace( $replaces, array('[[]', '[]]', ), $pattern); - } - return $pattern; -} - -/** - * Does not support flag GLOB_BRACE - * - * @param string $pattern - * @param int $flags - * @return array - */ -function glob_recursive(string $pattern, int $flags = 0): array { - $files = glob(glob_escape($pattern), $flags); - foreach (glob(glob_escape(dirname($pattern)).'/*', GLOB_ONLYDIR|GLOB_NOSORT) as $dir) { - $files = array_merge($files, glob_recursive($dir.'/'.basename($pattern), $flags)); - } - return $files; -} - function setperm(string $file): void { global $config; @@ -259,7 +234,7 @@ function setperm(string $file): void { } } -function salt_password(string $pwd): string { +function saltPassword(string $pwd): string { global $config; return hash('sha256', "{$pwd}|{$config['password_salt']}"); } @@ -285,19 +260,25 @@ function lang() { global $__lang; return call_user_func_array([$__lang, 'get'], func_get_args()); } -function lang_num() { +function langNum() { global $__lang; return call_user_func_array([$__lang, 'num'], func_get_args()); } -function is_dev(): bool { global $config; return $config['is_dev']; } -function is_cli(): bool { return PHP_SAPI == 'cli'; }; -function is_retina(): bool { return isset($_COOKIE['is_retina']) && $_COOKIE['is_retina']; } +function isDev(): bool { global $config; return $config['is_dev']; } +function isCli(): bool { return PHP_SAPI == 'cli'; }; +function isRetina(): bool { return isset($_COOKIE['is_retina']) && $_COOKIE['is_retina']; } + +function isAdmin(): bool { + if (admin::getId() === null) + admin::check(); + return admin::getId() != 0; +} function jsonEncode($obj): ?string { return json_encode($obj, JSON_UNESCAPED_UNICODE) ?: null; } function jsonDecode($json) { return json_decode($json, true); } -function pcre_no_error(mixed &$result, bool $no_error = false): bool { +function pcreNoError(mixed &$result, bool $no_error = false): bool { if ($result === null) { if (preg_last_error() !== PREG_NO_ERROR) { if (!$no_error) @@ -308,7 +289,7 @@ function pcre_no_error(mixed &$result, bool $no_error = false): bool { return true; } -function hl_matched(string $s, string|Stringable|SkinString|array|null $keywords = []): string { +function highlightSubstring(string $s, string|array|null $keywords = []): string { if (is_null($keywords)) return htmlescape($s); @@ -369,7 +350,7 @@ function hl_matched(string $s, string|Stringable|SkinString|array|null $keywords return $buf; } -function format_time($ts, array $opts = array()) { +function formatTime($ts, array $opts = array()) { $default_opts = [ 'date_only' => false, 'day_of_week' => false, @@ -411,30 +392,3 @@ function format_time($ts, array $opts = array()) { return $date; } -function arabic_to_roman($number) { - $map = [ - 1000 => 'M', - 900 => 'CM', - 500 => 'D', - 400 => 'CD', - 100 => 'C', - 90 => 'XC', - 50 => 'L', - 40 => 'XL', - 10 => 'X', - 9 => 'IX', - 5 => 'V', - 4 => 'IV', - 1 => 'I', - ]; - $result = ''; - - foreach ($map as $arabic => $roman) { - while ($number >= $arabic) { - $result .= $roman; - $number -= $arabic; - } - } - - return $result; -} diff --git a/handler/FilesHandler.php b/handler/FilesHandler.php deleted file mode 100644 index 40e10cc..0000000 --- a/handler/FilesHandler.php +++ /dev/null @@ -1,166 +0,0 @@ - '$meta_files_title', - '$description' => '$meta_files_description' - ]); - set_title('$files'); - set_skin_opts(['head_section' => 'files']); - $collections = array_map(fn(FilesCollection $c) => new CollectionItem($c), FilesCollection::cases()); - $books = books_get(); - $misc = books_get(category: BookCategory::MISC); - render('files/index', - collections: $collections, - books: $books, - misc: $misc); - } - - function GET_folder() { - list($folder_id) = input('i:folder_id'); - $parents = books_get_folder($folder_id, true); - if (!$parents) - not_found(); - if (count($parents) > 1) - $parents = array_reverse($parents); - $folder = $parents[count($parents)-1]; - $files = books_get($folder_id, category: $folder->category); - add_meta([ - '$title' => lang('meta_files_book_folder_title', $folder->getTitle()), - '$description' => lang('meta_files_book_folder_description', $folder->getTitle()) - ]); - set_title(lang('files').' - '.$folder->title); - render('files/folder', - folder: $folder, - parents: $parents, - files: $files); - } - - function GET_collection() { - list($collection, $folder_id, $query, $offset) = input('collection, i:folder_id, q, i:offset'); - $collection = FilesCollection::from($collection); - $parents = null; - - $query = trim($query); - if (!$query) - $query = null; - - add_skin_strings_re('/^files_(.*?)_collection$/'); - add_skin_strings([ - 'files_search_results_count' - ]); - - $vars = []; - $text_excerpts = null; - $func_prefix = $collection->value; - - if ($query !== null) { - $files = call_user_func("{$func_prefix}_search", $query, $offset, self::SEARCH_RESULTS_PER_PAGE); - $vars += [ - 'search_count' => $files['count'], - 'search_query' => $query - ]; - - /** @var WFFCollectionItem[]|MDFCollectionItem[]|BaconianaCollectionItem[] $files */ - $files = $files['items']; - - $query_words = array_map('mb_strtolower', preg_split('/\s+/', $query)); - $found = []; - $result_ids = []; - foreach ($files as $file) { - if ($file->isFolder()) - continue; - $result_ids[] = $file->id; - - switch ($collection) { - case FilesCollection::MercureDeFrance: - $candidates = [ - $file->date, - (string)$file->issue - ]; - break; - case FilesCollection::WilliamFriedman: - $candidates = [ - mb_strtolower($file->getTitle()), - strtolower($file->documentId) - ]; - break; - case FilesCollection::Baconiana: - $candidates = [ - // TODO - ]; - break; - } - - foreach ($candidates as $haystack) { - foreach ($query_words as $qw) { - if (mb_strpos($haystack, $qw) !== false) { - $found[$file->id] = true; - continue 2; - } - } - } - } - - $found = array_map('intval', array_keys($found)); - $not_found = array_diff($result_ids, $found); - if (!empty($not_found)) - $text_excerpts = call_user_func("{$func_prefix}_get_text_excerpts", $not_found, $query_words); - - if (is_xhr_request()) { - ajax_ok([ - ...$vars, - 'new_offset' => $offset + count($files), - 'html' => skin('files')->collection_files($files, $query, $text_excerpts) - ]); - } - } else { - if (in_array($collection, [FilesCollection::WilliamFriedman, FilesCollection::Baconiana]) && $folder_id) { - $parents = call_user_func("{$func_prefix}_get_folder", $folder_id, true); - if (!$parents) - not_found(); - if (count($parents) > 1) - $parents = array_reverse($parents); - } - $files = call_user_func("{$func_prefix}_get", $folder_id); - } - - $title = lang('files_'.$collection->value.'_collection'); - if ($folder_id && $parents) - $title .= ' - '.htmlescape($parents[count($parents)-1]->getTitle()); - if ($query) - $title .= ' - '.htmlescape($query); - set_title($title); - - if (!$folder_id && !$query) - add_meta([ - '$title' => lang('4in1').' - '.lang('meta_files_collection_title', lang('files_'.$collection->value.'_collection')), - '$description' => lang('meta_files_'.$collection->value.'_description') - ]); - else if ($query || $parents) { - add_meta([ - '$title' => lang('4in1').' - '.$title, - '$description' => lang('meta_files_'.($query ? 'search' : 'folder').'_description', - $query ?: $parents[count($parents)-1]->getTitle(), - lang('files_'.$collection->value.'_collection')) - ]); - } - - render('files/collection', - ...$vars, - collection: $collection, - files: $files, - parents: $parents, - search_results_per_page: self::SEARCH_RESULTS_PER_PAGE, - search_min_query_length: self::SEARCH_MIN_QUERY_LENGTH, - text_excerpts: $text_excerpts); - } - -} \ No newline at end of file diff --git a/handler/MainHandler.php b/handler/MainHandler.php deleted file mode 100644 index db40b08..0000000 --- a/handler/MainHandler.php +++ /dev/null @@ -1,206 +0,0 @@ - 'website', - '$url' => 'https://'.$config['domain'].'/', - '$title' => lang('meta_index_title'), - '$description' => lang('meta_index_description'), - '$image' => 'https://'.$config['domain'].'/img/4in1-preview.jpg' - ]); - - set_title('$site_title'); - set_skin_opts(['is_index' => true]); - - render('main/index', - posts: $posts, - posts_lang: $posts_lang, - versions: $config['book_versions']); - } - - function GET_about() { redirect('/info/'); } - function GET_contacts() { redirect('/info/'); } - - function GET_page() { - global $config; - - list($name) = input('name'); - - $page = pages::getByName($name); - if (!$page) { - if (is_admin()) { - set_title($name); - render('admin/pageNew', - short_name: $name); - } - not_found(); - } - - if (!is_admin() && !$page->visible) - not_found(); - - if ($page->shortName == 'info') - set_skin_opts(['head_section' => 'about']); - else if ($page->shortName == $config['wiki_root']) - set_skin_opts(['head_section' => $page->shortName]); - - $bc = null; - if ($page) { - add_meta([ - '$url' => 'https://'.$config['domain'].$page->getUrl(), - '$title' => $page->title, - ]); - - if ($page->parentId) { - $bc = []; - $parent = $page; - while ($parent?->parentId) { - $parent = pages::getById($parent->parentId); - if ($parent) - $bc[] = ['url' => $parent->getUrl(), 'text' => $parent->title]; - } - if (empty($bc)) - $bc = null; - } - } - - set_title($page ? $page->title : '???'); - render('main/page', - unsafe_html: $page->getHtml(is_retina(), getUserTheme()), - page_url: $page->getUrl(), - bc: $bc, - short_name: $page->shortName); - - not_found(); - } - - function GET_post() { - global $config; - list($name, $input_lang) = input('name, lang'); - - $lang = null; - try { - if ($input_lang) - $lang = PostLanguage::from($input_lang); - } catch (ValueError $e) { - not_found($e->getMessage()); - } - - if (!$lang) - $lang = PostLanguage::getDefault(); - - $post = posts::getByName($name); - if (!$post || (!$post->visible && !is_admin())) - not_found(); - - if ($lang == PostLanguage::getDefault() && $input_lang == $lang->value) - redirect($post->getUrl()); - if (!$post->hasLang($lang)) - not_found('no text for language '.$lang->name); - if (!$post->visible && !is_admin()) - not_found(); - - $pt = $post->getText($lang); - - $other_langs = []; - foreach (PostLanguage::cases() as $pl) { - if ($pl == $lang) - continue; - if ($post->hasLang($pl)) - $other_langs[] = $pl->value; - } - - $meta = [ - '$title' => $pt->title, - '$url' => $config['domain'].$post->getUrl(), - '$description' => $pt->getDescriptionPreview(155) - ]; - if ($pt->keywords) - $meta['$keywords'] = $pt->keywords; - add_meta($meta); - - if (($img = $pt->getFirstImage()) !== null) - add_meta(['$image' => $img->getDirectUrl()]); - - set_skin_opts(['articles_lang' => $lang->value]); - - set_title($pt->title); - - if ($pt->hasTableOfContents()) - set_skin_opts(['wide' => true]); - - render('main/post', - title: $pt->title, - id: $post->id, - source_url: $post->sourceUrl, - unsafe_html: $pt->getHtml(is_retina(), getUserTheme()), - unsafe_toc_html: $pt->getTableOfContentsHtml(), - date: $post->getFullDate(), - visible: $post->visible, - url: $post->getUrl(), - lang: $lang->value, - other_langs: $other_langs); - } - - function GET_rss() { - global $config; - - $lang = PostLanguage::getDefault(); - $items = array_map(function(Post $post) use ($lang) { - $pt = $post->getText($lang); - return [ - 'title' => $pt->title, - 'link' => $post->getUrl(), - 'pub_date' => date(DATE_RSS, $post->getTimestamp()), - 'description' => $pt->getDescriptionPreview(500) - ]; - }, posts::getList(0, 20, filter_by_lang: $lang)); - - $ctx = skin('rss'); - $body = $ctx->atom( - title: lang('site_title'), - link: 'https://'.$config['domain'], - rss_link: 'https://'.$config['domain'].'/feed.rss', - items: $items); - - header('Content-Type: application/rss+xml; charset=utf-8'); - echo $body; - exit; - } - - function GET_articles() { - list($lang) = input('lang'); - if ($lang) { - $lang = PostLanguage::tryFrom($lang); - if (!$lang || $lang == PostLanguage::getDefault()) - redirect('/articles/'); - } else { - $lang = PostLanguage::getDefault(); - } - - add_meta([ - '$description' => lang('blog_expl_'.$lang->value) - ]); - - $posts = posts::getList( - include_hidden: is_admin(), - filter_by_lang: $lang); - - set_title('$articles'); - set_skin_opts(['head_section' => 'articles']); - - render('main/articles', - posts: $posts, - selected_lang: $lang); - } - -} \ No newline at end of file diff --git a/handler/AdminHandler.php b/handlers/AdminHandler.php similarity index 52% rename from handler/AdminHandler.php rename to handlers/AdminHandler.php index 684581c..3a1ef23 100644 --- a/handler/AdminHandler.php +++ b/handlers/AdminHandler.php @@ -2,50 +2,53 @@ class AdminHandler extends request_handler { - function __construct() { + public function __construct() { parent::__construct(); - add_static('css/admin.css', 'js/admin.js'); - add_skin_strings(['error']); - set_skin_opts(['inside_admin_interface' => true]); + $this->skin->addStatic('css/admin.css', 'js/admin.js'); + $this->skin->exportStrings(['error']); + $this->skin->setRenderOptions(['inside_admin_interface' => true]); } - function before_dispatch(string $http_method, string $action) { - if ($action != 'login' && !is_admin()) - forbidden(); + public function beforeDispatch(string $http_method, string $action) { + if ($action != 'login' && !isAdmin()) + self::forbidden(); } - function GET_index() { - global $AdminSession; + public function GET_index() { //$admin_info = admin_current_info(); - set_title('$admin_title'); - render('admin/index', - admin_login: $AdminSession->login); + $this->skin->setTitle('$admin_title'); + $this->skin->renderPage('admin_index.twig', [ + 'admin_login' => admin::getLogin(), + 'logout_token' => self::getCSRF('logout'), + ]); } - function GET_login() { - if (is_admin()) - redirect('/admin/'); - set_title('$admin_title'); - render('admin/login'); + public function GET_login() { + if (isAdmin()) + self::redirect('/admin/'); + $this->skin->setTitle('$admin_title'); + $this->skin->renderPage('admin_login.twig', [ + 'form_token' => self::getCSRF('adminlogin'), + ]); } - function POST_login() { - csrf_check('adminlogin'); - list($login, $password) = input('login, password'); - admin_auth($login, $password) - ? redirect('/admin/') - : forbidden(); + public function POST_login() { + self::checkCSRF('adminlogin'); + list($login, $password) = $this->input('login, password'); + admin::auth($login, $password) + ? self::redirect('/admin/') + : self::forbidden(); } - function GET_logout() { - csrf_check('logout'); - admin_logout(); - redirect('/admin/login/', HTTPCode::Found); + public function GET_logout() { + self::checkCSRF('logout'); + admin::logout(); + self::redirect('/admin/login/', HTTPCode::Found); } - function GET_errors() { + public function GET_errors() { list($ip, $query, $url_query, $file_query, $line_query, $per_page) - = input('i:ip, query, url_query, file_query, i:line_query, i:per_page'); + = $this->input('i:ip, query, url_query, file_query, i:line_query, i:per_page'); if (!$per_page) $per_page = 100; @@ -78,14 +81,14 @@ class AdminHandler extends request_handler { } $count = (int)$db->result($db->query("SELECT COUNT(*) FROM backend_errors".$sql_where)); - list($page, $pages, $offset) = get_page($per_page, $count); + list($page, $pages, $offset) = $this->getPage($per_page, $count); $q = $db->query("SELECT *, INET_NTOA(ip) ip_s FROM backend_errors $sql_where ORDER BY id DESC LIMIT $offset, $per_page"); $list = []; while ($row = $db->fetch($q)) { - $row['date'] = format_time($row['ts'], [ + $row['date'] = formatTime($row['ts'], [ 'seconds' => true, 'short_months' => true, ]); @@ -138,22 +141,20 @@ class AdminHandler extends request_handler { $query_var_names = ['query', 'url_query', 'file_query', 'line_query']; foreach ($query_var_names as $query_var_name) { - if ($$query_var_name) { + if ($$query_var_name) $vars += [$query_var_name => $$query_var_name]; - } } - set_skin_opts(['wide' => true]); - set_title('$admin_errors'); - render('admin/errors', - ...$vars); + $this->skin->setRenderOptions(['wide' => true]); + $this->skin->setTitle('$admin_errors'); + $this->skin->renderPage('admin_errors.twig', $vars); } - function GET_auth_log() { + public function GET_auth_log() { $db = DB(); $count = (int)$db->result($db->query("SELECT COUNT(*) FROM admin_log")); $per_page = 100; - list($page, $pages, $offset) = get_page($per_page, $count); + list($page, $pages, $offset) = $this->getPage($per_page, $count); $q = $db->query("SELECT *, INET_NTOA(ip) AS ip, @@ -167,24 +168,23 @@ class AdminHandler extends request_handler { if (!empty($list)) { $list = array_map(function($item) { - $item['date'] = format_time($item['ts']); - $item['activity_ts_s'] = format_time($item['activity_ts']); + $item['date'] = formatTime($item['ts']); + $item['activity_ts_s'] = formatTime($item['activity_ts']); return $item; }, $list); } - $vars = [ + $this->skin->setRenderOptions(['wide' => true]); + $this->skin->setTitle('$admin_auth_log'); + $this->skin->set([ 'list' => $list, 'pn_page' => $page, 'pn_pages' => $pages - ]; - set_skin_opts(['wide' => true]); - set_title('$admin_auth_log'); - render('admin/auth_log', - ...$vars); + ]); + $this->skin->renderPage('admin_auth_log.twig'); } - function GET_actions_log() { + public function GET_actions_log() { $field_types = \AdminActions\Util\Logger::getFieldTypes(); foreach ($field_types as $type_prefix => $type_data) { for ($i = 1; $i <= $type_data['count']; $i++) { @@ -197,7 +197,7 @@ class AdminHandler extends request_handler { $per_page = 100; $count = \AdminActions\Util\Logger::getRecordsCount(); - list($page, $pages, $offset) = get_page($per_page, $count); + list($page, $pages, $offset) = $this->getPage($per_page, $count); $admin_ids = []; $admin_logins = []; @@ -210,7 +210,7 @@ class AdminHandler extends request_handler { } if (!empty($admin_ids)) - $admin_logins = admin_get_logins_by_id(array_keys($admin_ids)); + $admin_logins = admin::getLoginsById(array_keys($admin_ids)); $url = '/admin/actions-log/?'; @@ -222,39 +222,37 @@ class AdminHandler extends request_handler { } } - $vars = [ + $this->skin->setRenderOptions(['wide' => true]); + $this->skin->setTitle('$admin_actions_log'); + $this->skin->renderPage('admin_actions_log.twig', [ 'list' => $records, 'pn_page' => $page, 'pn_pages' => $pages, 'admin_logins' => $admin_logins, 'url' => $url, 'action_types' => \AdminActions\Util\Logger::getActions(true), - ]; - - set_skin_opts(['wide' => true]); - set_title('$admin_actions_log'); - render('admin/actions_log', - ...$vars); + ]); } - function GET_uploads() { - list($error) = input('error'); + public function GET_uploads() { + list($error) = $this->input('error'); $uploads = uploads::getAllUploads(); - set_title('$blog_upload'); - render('admin/uploads', - error: $error, - uploads: $uploads, - langs: PostLanguage::cases()); + $this->skin->setTitle('$blog_upload'); + $this->skin->renderPage('admin_uploads.twig', [ + 'error' => $error, + 'uploads' => $uploads, + 'langs' => PostLanguage::casesAsStrings(), + 'form_token' => self::getCSRF('add_upload'), + ]); } - function POST_uploads() { - csrf_check('addupl'); - - list($custom_name, $note_en, $note_ru) = input('name, note_en, note_ru'); + public function POST_uploads() { + self::checkCSRF('add_upload'); + list($custom_name, $note_en, $note_ru) = $this->input('name, note_en, note_ru'); if (!isset($_FILES['files'])) - redirect('/admin/uploads/?error='.urlencode('no file')); + self::redirect('/admin/uploads/?error='.urlencode('no file')); $files = []; for ($i = 0; $i < count($_FILES['files']['name']); $i++) { @@ -275,14 +273,14 @@ class AdminHandler extends request_handler { foreach ($files as $f) { if ($f['error']) - redirect('/admin/uploads/?error='.urlencode('error code '.$f['error'])); + self::redirect('/admin/uploads/?error='.urlencode('error code '.$f['error'])); if (!$f['size']) - redirect('/admin/uploads/?error='.urlencode('received empty file')); + self::redirect('/admin/uploads/?error='.urlencode('received empty file')); $ext = extension($f['name']); if (!uploads::isExtensionAllowed($ext)) - redirect('/admin/uploads/?error='.urlencode('extension not allowed')); + self::redirect('/admin/uploads/?error='.urlencode('extension not allowed')); $name = $custom_name ?: $f['name']; $upload_id = uploads::add( @@ -292,36 +290,36 @@ class AdminHandler extends request_handler { $note_ru); if (!$upload_id) - redirect('/admin/uploads/?error='.urlencode('failed to create upload')); + self::redirect('/admin/uploads/?error='.urlencode('failed to create upload')); - admin_log(new \AdminActions\UploadsAdd($upload_id, $name, $note_en, $note_ru)); + admin::log(new \AdminActions\UploadsAdd($upload_id, $name, $note_en, $note_ru)); } - redirect('/admin/uploads/'); + self::redirect('/admin/uploads/'); } - function GET_upload_delete() { - list($id) = input('i:id'); + public function GET_upload_delete() { + list($id) = $this->input('i:id'); $upload = uploads::get($id); if (!$upload) - redirect('/admin/uploads/?error='.urlencode('upload not found')); - csrf_check('delupl'.$id); + self::redirect('/admin/uploads/?error='.urlencode('upload not found')); + self::checkCSRF('delupl'.$id); uploads::delete($id); - admin_log(new \AdminActions\UploadsDelete($id)); - redirect('/admin/uploads/'); + admin::log(new \AdminActions\UploadsDelete($id)); + self::redirect('/admin/uploads/'); } - function POST_upload_edit_note() { - list($id, $note, $lang) = input('i:id, note, lang'); + public function POST_upload_edit_note() { + list($id, $note, $lang) = $this->input('i:id, note, lang'); $lang = PostLanguage::tryFrom($lang); if (!$lang) - not_found(); + self::notFound(); $upload = uploads::get($id); if (!$upload) - redirect('/admin/uploads/?error='.urlencode('upload not found')); + self::redirect('/admin/uploads/?error='.urlencode('upload not found')); - csrf_check('editupl'.$id); + self::checkCSRF('editupl'.$id); $upload->setNote($lang, $note); $texts = posts::getTextsWithUpload($upload); @@ -332,13 +330,13 @@ class AdminHandler extends request_handler { } } - admin_log(new \AdminActions\UploadsEditNote($id, $note, $lang->value)); - redirect('/admin/uploads/'); + admin::log(new \AdminActions\UploadsEditNote($id, $note, $lang->value)); + self::redirect('/admin/uploads/'); } - function POST_ajax_md_preview() { - ensure_xhr(); - list($md, $title, $use_image_previews, $lang, $is_page) = input('md, title, b:use_image_previews, lang, b:is_page'); + public function POST_ajax_md_preview() { + self::ensureXhr(); + list($md, $title, $use_image_previews, $lang, $is_page) = $this->input('md, title, b:use_image_previews, lang, b:is_page'); $lang = PostLanguage::tryFrom($lang); if (!$lang) $lang = PostLanguage::getDefault(); @@ -347,37 +345,48 @@ class AdminHandler extends request_handler { $title = ''; } $html = markup::markdownToHtml($md, $use_image_previews, $lang); - $ctx = skin('admin'); - $html = $ctx->markdownPreview( - unsafe_html: $html, - title: $title - ); - ajax_ok(['html' => $html]); + $html = $this->skin->render('markdown_preview.twig', [ + 'unsafe_html' => $html, + 'title' => $title + ]); + self::ajaxOk(['html' => $html]); } - function GET_page_add() { - list($name) = input('short_name'); + public function GET_page_add() { + list($name) = $this->input('short_name'); $page = pages::getByName($name); if ($page) - redirect($page->getUrl(), code: HTTPCode::Found); - add_skin_strings_re('/^(err_)?pages_/'); - add_skin_strings_re('/^(err_)?blog_/'); - set_title(lang('pages_create_title', $name)); - static::make_wide(); - render('admin/pageForm', - short_name: $name, - title: '', - text: '', - langs: PostLanguage::cases()); + self::redirect($page->getUrl(), code: HTTPCode::Found); + $this->skin->exportStrings('/^(err_)?pages_/'); + $this->skin->exportStrings('/^(err_)?blog_/'); + $this->skin->setTitle(lang('pages_create_title', $name)); + $this->setWidePageOptions(); + + $js_params = [ + 'pages' => true, + 'edit' => false, + 'token' => self::getCSRF('addpage'), + 'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()), // still needed for draft erasing + ]; + + $this->skin->renderPage('admin_page_form.twig', [ + 'is_edit' => false, + 'short_name' => $name, + 'title' => '', + 'text' => '', + 'langs' => PostLanguage::cases(), + 'js_params' => $js_params, + 'form_url' => '/'.$name.'/create/' + ]); } - function POST_page_add() { - csrf_check('addpage'); + public function POST_page_add() { + self::checkCSRF('addpage'); - list($name, $text, $title) = input('short_name, text, title'); + list($name, $text, $title) = $this->input('short_name, text, title'); $page = pages::getByName($name); if ($page) - not_found(); + self::notFound(); $error_code = null; @@ -388,48 +397,48 @@ class AdminHandler extends request_handler { } if ($error_code) - ajax_error(['code' => $error_code]); + self::ajaxError(['code' => $error_code]); if (!pages::add([ 'short_name' => $name, 'title' => $title, 'md' => $text ])) { - ajax_error(['code' => 'db_err']); + self::ajaxError(['code' => 'db_err']); } - admin_log(new \AdminActions\PageCreate($name)); + admin::log(new \AdminActions\PageCreate($name)); $page = pages::getByName($name); - ajax_ok(['url' => $page->getUrl()]); + self::ajaxOk(['url' => $page->getUrl()]); } - function GET_page_delete() { - list($name) = input('short_name'); + public function GET_page_delete() { + list($name) = $this->input('short_name'); $page = pages::getByName($name); if (!$page) - not_found(); + self::notFound(); $url = $page->getUrl(); - csrf_check('delpage'.$page->shortName); + self::checkCSRF('delpage'.$page->shortName); pages::delete($page); - admin_log(new \AdminActions\PageDelete($name)); - redirect($url, code: HTTPCode::Found); + admin::log(new \AdminActions\PageDelete($name)); + self::redirect($url, code: HTTPCode::Found); } - function GET_page_edit() { - list($short_name, $saved) = input('short_name, b:saved'); + public function GET_page_edit() { + list($short_name, $saved) = $this->input('short_name, b:saved'); $page = pages::getByName($short_name); if (!$page) - not_found(); + self::notFound(); - add_skin_strings_re('/^(err_)?pages_/'); - add_skin_strings_re('/^(err_)?blog_/'); - set_title(lang('pages_page_edit_title', $page->shortName)); - static::make_wide(); + $this->skin->exportStrings('/^(err_)?pages_/'); + $this->skin->exportStrings('/^(err_)?blog_/'); + $this->skin->setTitle(lang('pages_page_edit_title', $page->shortName)); + $this->setWidePageOptions(); $js_text = [ 'text' => $page->md, 'title' => $page->title, @@ -442,32 +451,43 @@ class AdminHandler extends request_handler { $parent = $parent_page->shortName; } - render('admin/pageForm', - is_edit: true, - short_name: $page->shortName, - title: $page->title, - text: $page->md, - visible: $page->visible, - render_title: $page->renderTitle, - parent: $parent, - saved: $saved, - langs: PostLanguage::cases(), - js_text: $js_text); + $js_params = [ + 'pages' => true, + 'edit' => true, + 'token' => self::getCSRF('editpage'.$short_name), + 'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()), // still needed for draft erasing + 'text' => [ + 'text' => $page->md, + 'title' => $page->title, + ] + ]; + + $this->skin->renderPage('admin_page_form.twig', [ + 'is_edit' => true, + 'short_name' => $page->shortName, + 'title' => $page->title, + 'text' => $page->md, + 'visible' => $page->visible, + 'render_title' => $page->renderTitle, + 'parent' => $parent, + 'saved' => $saved, + 'langs' => PostLanguage::cases(), + 'js_params' => $js_params, + ]); } - function POST_page_edit() { - ensure_xhr(); - - list($short_name) = input('short_name'); + public function POST_page_edit() { + self::ensureXhr(); + list($short_name) = $this->input('short_name'); $page = pages::getByName($short_name); if (!$page) - not_found(); + self::notFound(); - csrf_check('editpage'.$page->shortName); + self::checkCSRF('editpage'.$page->shortName); list($text, $title, $visible, $short_name, $parent, $render_title) - = input('text, title, b:visible, new_short_name, parent, b:render_title'); + = $this->input('text, title, b:visible, new_short_name, parent, b:render_title'); $text = trim($text); $title = trim($title); @@ -482,7 +502,7 @@ class AdminHandler extends request_handler { } if ($error_code) - ajax_error(['code' => $error_code]); + self::ajaxError(['code' => $error_code]); $new_short_name = $page->shortName != $short_name ? $short_name : null; $parent_page = pages::getByName($parent); @@ -498,14 +518,14 @@ class AdminHandler extends request_handler { 'parent_id' => $parent_id ]); - admin_log(new \AdminActions\PageEdit($short_name, $new_short_name)); - ajax_ok(['url' => $page->getUrl().'edit/?saved=1']); + admin::log(new \AdminActions\PageEdit($short_name, $new_short_name)); + self::ajaxOk(['url' => $page->getUrl().'edit/?saved=1']); } - function GET_post_add() { - add_skin_strings_re('/^(err_)?blog_/'); - set_title('$blog_write'); - static::make_wide(); + public function GET_post_add() { + $this->skin->exportStrings('/^(err_)?blog_/'); + $this->skin->setTitle('$blog_write'); + $this->setWidePageOptions(); $js_texts = []; foreach (PostLanguage::cases() as $pl) { @@ -517,30 +537,47 @@ class AdminHandler extends request_handler { ]; } - render('admin/postForm', - title: '', - text: '', - langs: PostLanguage::cases(), - short_name: '', - source_url: '', - keywords: '', - js_texts: $js_texts, - lang: PostLanguage::getDefault()->value); + $js_params = [ + 'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()), + 'token' => self::getCSRF('post_add') + ]; + $form_url = '/articles/write/'; + + $bc = [ + ['url' => '/articles/?lang='.PostLanguage::getDefault()->value, 'text' => lang('articles')], + ['text' => lang('blog_new_post')] + ]; + + $this->skin->renderPage('admin_post_form.twig', [ + // form data + 'title' => '', + 'text' => '', + 'short_name' => '', + 'source_url' => '', + 'keywords' => '', + 'date' => '', + + 'bc' => $bc, + 'js_params' => $js_params, + 'form_url' => $form_url, + 'langs' => PostLanguage::casesAsStrings(), + 'lang' => PostLanguage::getDefault()->value + ]); } - function POST_post_add() { - ensure_xhr(); - csrf_check('post_add'); + public function POST_post_add() { + self::ensureXhr(); + self::checkCSRF('post_add'); list($visibility_enabled, $short_name, $langs, $date) - = input('b:visible, short_name, langs, date'); + = $this->input('b:visible, short_name, langs, date'); self::_postEditValidateCommonData($date); $lang_data = []; $at_least_one_lang_is_written = false; foreach (PostLanguage::cases() as $lang) { - list($title, $text, $keywords, $toc_enabled) = input("title:{$lang->value}, text:{$lang->value}, keywords:{$lang->value}, b:toc:{$lang->value}", ['trim' => true]); + list($title, $text, $keywords, $toc_enabled) = $this->input("title:{$lang->value}, text:{$lang->value}, keywords:{$lang->value}, b:toc:{$lang->value}", ['trim' => true]); if ($title !== '' && $text !== '') { $lang_data[$lang->value] = [$title, $text, $keywords, $toc_enabled]; $at_least_one_lang_is_written = true; @@ -554,7 +591,7 @@ class AdminHandler extends request_handler { $error_code = 'no_short_name'; } if ($error_code) - ajax_error(['code' => $error_code]); + self::ajaxError(['code' => $error_code]); $post = posts::add([ 'visible' => $visibility_enabled, @@ -564,7 +601,7 @@ class AdminHandler extends request_handler { ]); if (!$post) - ajax_error(['code' => 'db_err', 'message' => 'failed to add post']); + self::ajaxError(['code' => 'db_err', 'message' => 'failed to add post']); // add texts $added_texts = []; // for admin actions logging, at the end @@ -578,47 +615,47 @@ class AdminHandler extends request_handler { toc: $toc_enabled)) ) { posts::delete($post); - ajax_error(['code' => 'db_err', 'message' => 'failed to add text language '.$lang]); + self::ajaxError(['code' => 'db_err', 'message' => 'failed to add text language '.$lang]); } else { $added_texts[] = [$new_post_text->id, $lang]; } } - admin_log(new \AdminActions\PostCreate($post->id)); + admin::log(new \AdminActions\PostCreate($post->id)); foreach ($added_texts as $added_text) { list($id, $lang) = $added_text; - admin_log(new \AdminActions\PostTextCreate($id, $post->id, $lang)); + admin::log(new \AdminActions\PostTextCreate($id, $post->id, $lang)); } // done - ajax_ok(['url' => $post->getUrl()]); + self::ajaxOk(['url' => $post->getUrl()]); } - function GET_post_delete() { - list($name) = input('short_name'); + public function GET_post_delete() { + list($name) = $this->input('short_name'); $post = posts::getByName($name); if (!$post) - not_found(); + self::notFound(); $id = $post->id; - csrf_check('delpost'.$id); + self::checkCSRF('delpost'.$id); posts::delete($post); - admin_log(new \AdminActions\PostDelete($id)); - redirect('/articles/', code: HTTPCode::Found); + admin::log(new \AdminActions\PostDelete($id)); + self::redirect('/articles/', code: HTTPCode::Found); } - function GET_post_edit() { - list($short_name, $saved, $lang) = input('short_name, b:saved, lang'); + public function GET_post_edit() { + list($short_name, $saved, $lang) = $this->input('short_name, b:saved, lang'); $lang = PostLanguage::from($lang); $post = posts::getByName($short_name); if (!$post) - not_found(); + self::notFound(); $texts = $post->getTexts(); if (!isset($texts[$lang->value])) - not_found(); + self::notFound(); $js_texts = []; foreach (PostLanguage::cases() as $pl) { @@ -642,48 +679,64 @@ class AdminHandler extends request_handler { $text = $texts[$lang->value]; - add_skin_strings_re('/^(err_)?blog_/'); - add_skin_strings(['blog_post_edit_title']); - set_title(lang('blog_post_edit_title', $text->title)); - static::make_wide(); - render('admin/postForm', - is_edit: true, - post_id: $post->id, - post_url: $post->getUrl(), - title: $text->title, - text: $text->md, - date: $post->getDateForInputField(), - visible: $post->visible, - toc: $text->toc, - saved: $saved, - short_name: $short_name, - source_url: $post->sourceUrl, - keywords: $text->keywords, - langs: PostLanguage::cases(), - lang: $text->lang->value, - js_texts: $js_texts - ); + $this->skin->exportStrings('/^(err_)?blog_/'); + $this->skin->exportStrings(['blog_post_edit_title']); + $this->skin->setTitle(lang('blog_post_edit_title', $text->title)); + $this->setWidePageOptions(); + + $bc = [ + ['url' => '/articles/?lang='.$text->lang->value, 'text' => lang('articles')], + ['url' => $post->getUrl().'?lang='.$text->lang->value, 'text' => lang('blog_view_post')] + ]; + + $js_params = [ + 'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()), + 'token' => self::getCSRF('editpost'.$post->id), + 'edit' => true, + 'id' => $post->id, + 'texts' => $js_texts + ]; + $form_url = $post->getUrl().'edit/'; + + $this->skin->renderPage('admin_post_form.twig', [ + 'is_edit' => true, + 'post' => $post, + 'title' => $text->title, + 'text' => $text->md, + 'date' => $post->getDateForInputField(), + 'visible' => $post->visible, + 'toc' => $text->toc, + 'short_name' => $short_name, + 'source_url' => $post->sourceUrl, + 'keywords' => $text->keywords, + 'saved' => $saved, + 'langs' => PostLanguage::casesAsStrings(), + 'lang' => $text->lang->value, + 'js_params' => $js_params, + 'form_url' => $form_url, + 'bc' => $bc + ]); } - function POST_post_edit() { - ensure_xhr(); + public function POST_post_edit() { + self::ensureXhr(); - list($old_short_name, $short_name, $langs, $date, $source_url) = input('short_name, new_short_name, langs, date, source_url'); + list($old_short_name, $short_name, $langs, $date, $source_url) = $this->input('short_name, new_short_name, langs, date, source_url'); $post = posts::getByName($old_short_name); if (!$post) - not_found(); + self::notFound(); - csrf_check('editpost'.$post->id); + self::checkCSRF('editpost'.$post->id); self::_postEditValidateCommonData($date); if (empty($short_name)) - ajax_error(['code' => 'no_short_name']); + self::ajaxError(['code' => 'no_short_name']); foreach (explode(',', $langs) as $lang) { $lang = PostLanguage::from($lang); - list($text, $title, $visible, $toc, $keywords) = input("text:{$lang->value}, title:{$lang->value}, b:visible, b:toc:{$lang->value}, keywords:{$lang->value}"); + list($text, $title, $visible, $toc, $keywords) = $this->input("text:{$lang->value}, title:{$lang->value}, b:visible, b:toc:{$lang->value}, keywords:{$lang->value}"); $error_code = null; if (!$title) @@ -691,7 +744,7 @@ class AdminHandler extends request_handler { else if (!$text) $error_code = 'no_text'; if ($error_code) - ajax_error(['code' => $error_code]); + self::ajaxError(['code' => $error_code]); $pt = $post->getText($lang); if (!$pt) { @@ -703,7 +756,7 @@ class AdminHandler extends request_handler { toc: $toc ); if (!$pt) - ajax_error(['code' => 'db_err']); + self::ajaxError(['code' => 'db_err']); } else { previous_texts::add(PreviousText::TYPE_POST_TEXT, $pt->id, $pt->md, $post->getUpdateTimestamp() ?: $post->getTimestamp()); $pt->edit([ @@ -724,24 +777,24 @@ class AdminHandler extends request_handler { $post_data['short_name'] = $short_name; $post->edit($post_data); - admin_log(new \AdminActions\PostEdit($post->id)); - ajax_ok(['url' => $post->getUrl().'edit/?saved=1&lang='.$lang->value]); + admin::log(new \AdminActions\PostEdit($post->id)); + self::ajaxOk(['url' => $post->getUrl().'edit/?saved=1&lang='.$lang->value]); } - function GET_books() { - set_title('$admin_books'); - render('admin/books'); + public function GET_books() { + $this->skin->setTitle('$admin_books'); + $this->skin->renderPage('admin_books.twig'); } protected static function _postEditValidateCommonData($date) { $dt = DateTime::createFromFormat("Y-m-d", $date); $date_is_valid = $dt && $dt->format("Y-m-d") === $date; if (!$date_is_valid) - ajax_error(['code' => 'no_date']); + self::ajaxError(['code' => 'no_date']); } - protected static function make_wide() { - set_skin_opts([ + protected function setWidePageOptions(): void { + $this->skin->setRenderOptions([ 'full_width' => true, 'no_footer' => true ]); diff --git a/handlers/FilesHandler.php b/handlers/FilesHandler.php new file mode 100644 index 0000000..f8bf679 --- /dev/null +++ b/handlers/FilesHandler.php @@ -0,0 +1,220 @@ + new CollectionItem($c), FilesCollection::cases()); + $books = files::books_get(); + $misc = files::books_get(category: BookCategory::MISC); + $this->skin->addMeta([ + '@title' => '$meta_files_title', + '@description' => '$meta_files_description' + ]); + $this->skin->setTitle('$files'); + $this->skin->setRenderOptions(['head_section' => 'files']); + $this->skin->renderPage('files_index.twig', [ + 'collections' => $collections, + 'books' => $books, + 'misc' => $misc + ]); + } + + public function GET_folder() { + list($folder_id) = $this->input('i:folder_id'); + + $parents = files::books_get_folder($folder_id, true); + if (!$parents) + self::notFound(); + + if (count($parents) > 1) + $parents = array_reverse($parents); + + $folder = $parents[count($parents)-1]; + $files = files::books_get($folder_id, category: $folder->category); + + $bc = [ + ['text' => lang('files'), 'url' => '/files/'], + ]; + if ($parents) { + for ($i = 0; $i < count($parents)-1; $i++) { + $parent = $parents[$i]; + $bc_item = ['text' => $parent->getTitle()]; + if ($i < count($parents)-1) + $bc_item['url'] = $parent->getUrl(); + $bc[] = $bc_item; + } + } + $bc[] = ['text' => $folder->title]; + + $this->skin->addMeta([ + '@title' => lang('meta_files_book_folder_title', $folder->getTitle()), + '@description' => lang('meta_files_book_folder_description', $folder->getTitle()) + ]); + $this->skin->setTitle(lang('files').' - '.$folder->title); + $this->skin->renderPage('files_folder.twig', [ + 'folder' => $folder, + 'bc' => $bc, + 'files' => $files + ]); + } + + public function GET_collection() { + list($collection, $folder_id, $query, $offset) = $this->input('collection, i:folder_id, q, i:offset'); + $collection = FilesCollection::from($collection); + $parents = null; + + $query = trim($query); + if (!$query) + $query = null; + + $this->skin->exportStrings('/^files_(.*?)_collection$/'); + $this->skin->exportStrings([ + 'files_search_results_count' + ]); + + $vars = []; + $text_excerpts = null; + $func_prefix = $collection->value; + + if ($query !== null) { + $files = call_user_func("files::{$func_prefix}_search", $query, $offset, self::SEARCH_RESULTS_PER_PAGE); + $vars += [ + 'search_count' => $files['count'], + 'search_query' => $query + ]; + + /** @var WFFCollectionItem[]|MDFCollectionItem[]|BaconianaCollectionItem[] $files */ + $files = $files['items']; + + $query_words = array_map('mb_strtolower', preg_split('/\s+/', $query)); + $found = []; + $result_ids = []; + foreach ($files as $file) { + if ($file->isFolder()) + continue; + $result_ids[] = $file->id; + + switch ($collection) { + case FilesCollection::MercureDeFrance: + $candidates = [ + $file->date, + (string)$file->issue + ]; + break; + case FilesCollection::WilliamFriedman: + $candidates = [ + mb_strtolower($file->getTitle()), + strtolower($file->documentId) + ]; + break; + case FilesCollection::Baconiana: + $candidates = [ + // TODO + ]; + break; + } + + foreach ($candidates as $haystack) { + foreach ($query_words as $qw) { + if (mb_strpos($haystack, $qw) !== false) { + $found[$file->id] = true; + continue 2; + } + } + } + } + + $found = array_map('intval', array_keys($found)); + $not_found = array_diff($result_ids, $found); + if (!empty($not_found)) + $text_excerpts = call_user_func("files::{$func_prefix}_get_text_excerpts", $not_found, $query_words); + + if (self::isXhrRequest()) { + self::ajaxOk([ + ...$vars, + 'new_offset' => $offset + count($files), + 'html' => skin::getInstance()->render('files_list.twig', [ + 'files' => $files, + 'query' => $query, + 'text_excerpts' => $text_excerpts + ]) + ]); + } + } else { + if (in_array($collection, [FilesCollection::WilliamFriedman, FilesCollection::Baconiana]) && $folder_id) { + $parents = call_user_func("files::{$func_prefix}_get_folder", $folder_id, true); + if (!$parents) + self::notFound(); + if (count($parents) > 1) + $parents = array_reverse($parents); + } + $files = call_user_func("files::{$func_prefix}_get", $folder_id); + } + + $title = lang('files_'.$collection->value.'_collection'); + if ($folder_id && $parents) + $title .= ' - '.htmlescape($parents[count($parents)-1]->getTitle()); + if ($query) + $title .= ' - '.htmlescape($query); + $this->skin->setTitle($title); + + if (!$folder_id && !$query) { + $this->skin->addMeta([ + '@title' => lang('4in1').' - '.lang('meta_files_collection_title', lang('files_'.$collection->value.'_collection')), + '@description' => lang('meta_files_'.$collection->value.'_description') + ]); + } else if ($query || $parents) { + $this->skin->addMeta([ + '@title' => lang('4in1').' - '.$title, + '@description' => lang('meta_files_'.($query ? 'search' : 'folder').'_description', + $query ?: $parents[count($parents)-1]->getTitle(), + lang('files_'.$collection->value.'_collection')) + ]); + } + + $bc = [ + ['text' => lang('files'), 'url' => '/files/'], + ]; + if ($parents) { + $bc[] = ['text' => lang('files_'.$collection->value.'_collection_short'), 'url' => "/files/{$collection->value}/"]; + for ($i = 0; $i < count($parents); $i++) { + $parent = $parents[$i]; + $bc_item = ['text' => $parent->getTitle()]; + if ($i < count($parents)-1) + $bc_item['url'] = $parent->getUrl(); + $bc[] = $bc_item; + } + } else { + $bc[] = ['text' => lang('files_'.$collection->value.'_collection')]; + } + + $js_params = [ + 'container' => 'files_list', + 'per_page' => self::SEARCH_RESULTS_PER_PAGE, + 'min_query_length' => self::SEARCH_MIN_QUERY_LENGTH, + 'base_url' => "/files/{$collection->value}/", + 'query' => $vars['search_query'], + 'count' => $vars['search_count'], + 'collection_name' => $collection->value, + 'inited_with_search' => !!$vars['search_query'] + ]; + + $this->skin->set($vars); + $this->skin->set([ + 'collection' => $collection->value, + 'files' => $files, + 'bc' => $bc, + 'do_show_search' => empty($parents), + 'do_show_more' => $vars['search_count'] > 0 && count($files) < $vars['search_count'], + // 'search_results_per_page' => self::SEARCH_RESULTS_PER_PAGE, + // 'search_min_query_length' => self::SEARCH_MIN_QUERY_LENGTH, + 'text_excerpts' => $text_excerpts, + 'js_params' => $js_params, + ]); + $this->skin->renderPage('files_collection.twig'); + } + +} \ No newline at end of file diff --git a/handlers/MainHandler.php b/handlers/MainHandler.php new file mode 100644 index 0000000..2b81a30 --- /dev/null +++ b/handlers/MainHandler.php @@ -0,0 +1,197 @@ +skin->addMeta([ + 'og:type' => 'website', + '@url' => 'https://'.$config['domain'].'/', + '@title' => lang('meta_index_title'), + '@description' => lang('meta_index_description'), + '@image' => 'https://'.$config['domain'].'/img/4in1-preview.jpg' + ]); + $this->skin->setTitle('$site_title'); + $this->skin->set([ + 'posts' => $posts, + 'posts_lang' => $posts_lang, + 'versions' => $config['book_versions'] + ]); + $this->skin->setRenderOptions(['is_index' => true]); + $this->skin->renderPage('index.twig'); + } + + public function GET_about() { self::redirect('/info/'); } + public function GET_contacts() { self::redirect('/info/'); } + + public function GET_page() { + global $config; + list($name) = $this->input('name'); + + $page = pages::getByName($name); + if (!$page) { + if (isAdmin()) { + $this->skin->setTitle($name); + $this->skin->renderPage('admin_page_new.twig', [ + 'short_name' => $name + ]); + } + self::notFound(); + } + + if (!isAdmin() && !$page->visible) + self::notFound(); + + $bc = null; + $render_opts = []; + if ($page) { + $this->skin->addMeta([ + '@url' => 'https://'.$config['domain'].$page->getUrl(), + '@title' => $page->title, + ]); + + if ($page->parentId) { + $bc = []; + $parent = $page; + while ($parent?->parentId) { + $parent = pages::getById($parent->parentId); + if ($parent) + $bc[] = ['url' => $parent->getUrl(), 'text' => $parent->title]; + } + if (empty($bc)) + $bc = null; + } + + if ($page->shortName == 'info') + $render_opts = ['head_section' => 'about']; + else if ($page->shortName == $config['wiki_root']) + $render_opts = ['head_section' => $page->shortName]; + } + + $this->skin->setRenderOptions($render_opts); + $this->skin->setTitle($page ? $page->title : '???'); + $this->skin->renderPage('page.twig', [ + 'page' => $page, + 'html' => $page->getHtml(isRetina(), themes::getUserTheme()), + 'bc' => $bc, + 'delete_token' => self::getCSRF('delpage'.$page->shortName) + ]); + } + + public function GET_post() { + global $config; + list($name, $input_lang) = $this->input('name, lang'); + + $lang = null; + try { + if ($input_lang) + $lang = PostLanguage::from($input_lang); + } catch (ValueError $e) { + self::notFound($e->getMessage()); + } + + if (!$lang) + $lang = PostLanguage::getDefault(); + + $post = posts::getByName($name); + if (!$post || (!$post->visible && !isAdmin())) + self::notFound(); + + if ($lang == PostLanguage::getDefault() && $input_lang == $lang->value) + self::redirect($post->getUrl()); + if (!$post->hasLang($lang)) + self::notFound('no text for language '.$lang->name); + if (!$post->visible && !isAdmin()) + self::notFound(); + + $pt = $post->getText($lang); + + $other_langs = []; + foreach (PostLanguage::cases() as $pl) { + if ($pl == $lang) + continue; + if ($post->hasLang($pl)) + $other_langs[] = $pl->value; + } + + $meta = [ + '@title' => $pt->title, + '@url' => $config['domain'].$post->getUrl(), + '@description' => $pt->getDescriptionPreview(155) + ]; + if ($pt->keywords) + $meta['@keywords'] = $pt->keywords; + $this->skin->addMeta($meta); + if (($img = $pt->getFirstImage()) !== null) + $this->skin->addMeta(['@image' => $img->getDirectUrl()]); + + $this->skin->setTitle($pt->title); + $this->skin->setRenderOptions(['articles_lang' => $lang->value, 'wide' => $pt->hasTableOfContents()]); + $this->skin->renderPage('post.twig', [ + 'post' => $post, + 'pt' => $pt, + 'html' => $pt->getHtml(isRetina(), themes::getUserTheme()), + 'selected_lang' => $lang->value, + 'other_langs' => $other_langs, + 'delete_token' => self::getCSRF('delpost'.$post->id) + ]); + } + + public function GET_rss() { + global $config; + + $lang = PostLanguage::getDefault(); + $items = array_map(function(Post $post) use ($lang) { + $pt = $post->getText($lang); + return [ + 'title' => $pt->title, + 'link' => $post->getUrl(), + 'pub_date' => date(DATE_RSS, $post->getTimestamp()), + 'description' => $pt->getDescriptionPreview(500) + ]; + }, posts::getList(0, 20, filter_by_lang: $lang)); + + $body = $this->skin->render('rss.twig', [ + 'title' => lang('site_title'), + 'link' => 'https =>//'.$config['domain'], + 'rss_link' => 'https =>//'.$config['domain'].'/feed.rss', + 'items' => $items + ]); + + header('Content-Type: application/rss+xml; charset=utf-8'); + echo $body; + exit; + } + + public function GET_articles() { + list($lang) = $this->input('lang'); + if ($lang) { + $lang = PostLanguage::tryFrom($lang); + if (!$lang || $lang == PostLanguage::getDefault()) + self::redirect('/articles/'); + } else { + $lang = PostLanguage::getDefault(); + } + + $posts = posts::getList( + include_hidden: isAdmin(), + filter_by_lang: $lang); + + $this->skin->setTitle('$articles'); + $this->skin->addMeta([ + '@description' => lang('blog_expl_'.$lang->value) + ]); + $this->skin->setRenderOptions(['head_section' => 'articles']); + $this->skin->renderPage('articles.twig', [ + 'posts' => $posts, + 'selected_lang' => $lang->value + ]); + } + +} \ No newline at end of file diff --git a/handler/ServicesHandler.php b/handlers/ServicesHandler.php similarity index 56% rename from handler/ServicesHandler.php rename to handlers/ServicesHandler.php index 1b8d26b..57b7c1f 100644 --- a/handler/ServicesHandler.php +++ b/handlers/ServicesHandler.php @@ -2,7 +2,7 @@ class ServicesHandler extends request_handler { - function GET_robots_txt() { + public function GET_robots_txt() { $txt = <<input('lang'); if (!isset($config['book_versions'][$lang])) - not_found(); - redirect("https://files.4in1.ws/4in1-{$lang}.pdf?{$config['book_versions'][$lang]}", + self::notFound(); + self::redirect("https://files.4in1.ws/4in1-{$lang}.pdf?{$config['book_versions'][$lang]}", code: HTTPCode::Found); } diff --git a/htdocs/index.php b/htdocs/index.php index deee5ad..f8697d7 100644 --- a/htdocs/index.php +++ b/htdocs/index.php @@ -2,5 +2,4 @@ require_once __DIR__.'/../init.php'; -router_init(); -dispatch_request(); \ No newline at end of file +request_handler::resolveAndDispatch(); diff --git a/htdocs/js.php b/htdocs/js.php index 9899291..cdd7d2b 100644 --- a/htdocs/js.php +++ b/htdocs/js.php @@ -5,7 +5,7 @@ global $config; $name = $_REQUEST['name'] ?? ''; -if (!is_dev() || !$name || !is_dir($path = APP_ROOT.'/htdocs/js/'.$name)) { +if (!isDev() || !$name || !is_dir($path = APP_ROOT.'/htdocs/js/'.$name)) { http_response_code(403); exit; } diff --git a/htdocs/js/admin/11-write-form.js b/htdocs/js/admin/11-write-form.js index 1dd9158..6348a79 100644 --- a/htdocs/js/admin/11-write-form.js +++ b/htdocs/js/admin/11-write-form.js @@ -105,7 +105,7 @@ extend(AdminWriteEditForm.prototype, { params.title = this.form.elements.title.value; params.lang = this.getCurrentLang(); } - if (this.isPage() && this.form.render_title.checked) { + if (this.isPage() && this.isEditing() && this.form.render_title.checked) { params.title = this.form.elements.title.value; params.is_page = 1 } diff --git a/htdocs/js/common/35-theme-switcher.js b/htdocs/js/common/35-theme-switcher.js index ac2e167..c4fd69f 100644 --- a/htdocs/js/common/35-theme-switcher.js +++ b/htdocs/js/common/35-theme-switcher.js @@ -135,12 +135,9 @@ var ThemeSwitcher = (function() { /** * @param {string} selectedMode */ - function setIcon(selectedMode) { + function setLabel(selectedMode) { document.body.setAttribute('data-theme', selectedMode); - for (var i = 0; i < modes.length; i++) { - var mode = modes[i]; - document.getElementById('svgicon_moon_'+mode+'_18').style.display = mode === selectedMode ? 'block': 'none'; - } + ge('switch-theme').innerHTML = escape(selectedMode); } return { @@ -182,7 +179,7 @@ var ThemeSwitcher = (function() { onSystemChange(window.matchMedia('(prefers-color-scheme: dark)').matches === true); } - setIcon(modes[currentModeIndex]); + setLabel(modes[currentModeIndex]); }, next: function(e) { @@ -214,7 +211,7 @@ var ThemeSwitcher = (function() { break; } - setIcon(modes[currentModeIndex]); + setLabel(modes[currentModeIndex]); setCookie('theme', modes[currentModeIndex]); return cancelEvent(e); diff --git a/htdocs/sass.php b/htdocs/sass.php index 05a334d..40aec7e 100644 --- a/htdocs/sass.php +++ b/htdocs/sass.php @@ -10,7 +10,7 @@ if ($theme != 'light' && $theme != 'dark') { exit; } -if (!is_dev() || !$name || !file_exists($path = APP_ROOT.'/htdocs/scss/entries/'.$name.'/'.$theme.'.scss')) { +if (!isDev() || !$name || !file_exists($path = APP_ROOT.'/htdocs/scss/entries/'.$name.'/'.$theme.'.scss')) { // logError(__FILE__.': access denied'); http_response_code(403); exit; diff --git a/htdocs/scss/app/common.scss b/htdocs/scss/app/common.scss index 0e8d667..0930074 100644 --- a/htdocs/scss/app/common.scss +++ b/htdocs/scss/app/common.scss @@ -240,7 +240,7 @@ table.contacts div.note { padding: 40px 20px; color: $grey; @include radius(3px); - background-color: $dark-bg; + background-color: $light-bg; } .md-file-attach { diff --git a/htdocs/scss/app/foot.scss b/htdocs/scss/app/foot.scss index 03a04db..4b25d41 100644 --- a/htdocs/scss/app/foot.scss +++ b/htdocs/scss/app/foot.scss @@ -3,10 +3,21 @@ //border-radius: 5px; padding: 15px 0; margin-top: 10px; - text-align: right; color: $dark_grey; - > span { color: $fg; } - > a { - @include no-underline(true); + + &-right { + float: right; + text-align: right; + } + + &-right, &-left { + > span { color: $fg; } + > a { + @include no-underline(true); + } + } + + &-separator { + opacity: 0.33; } } \ No newline at end of file diff --git a/htdocs/scss/app/head.scss b/htdocs/scss/app/head.scss index 2e7289e..9e3f638 100644 --- a/htdocs/scss/app/head.scss +++ b/htdocs/scss/app/head.scss @@ -71,10 +71,6 @@ } } -//body:not(.theme-changing) .head-logo { -// @include transition(background-color, 0.03s); -//} - .head-items { text-align: right; display: table-cell; @@ -101,6 +97,9 @@ a.head-item { height: 18px; } } + &.is-ic { + color: $link-color; + } &:hover, &.is-selected { border-radius: 4px; @@ -119,7 +118,6 @@ body a.head-item.is-settings svg path { fill: $fg; } - #svgicon_moon_light_18, #svgicon_moon_dark_18, #svgicon_moon_auto_18 { diff --git a/init.php b/init.php index 078c611..fb3ce41 100644 --- a/init.php +++ b/init.php @@ -14,42 +14,20 @@ define('START_TIME', microtime(true)); set_include_path(get_include_path().PATH_SEPARATOR.APP_ROOT); spl_autoload_register(function($class) { - static $libs = [ - 'lib/pages' => ['Page', 'pages'], - 'lib/previous_texts' => ['previous_texts', 'PreviousText'], - 'lib/posts' => ['Post', 'PostText', 'PostLanguage', 'posts'], - 'lib/uploads' => ['Upload', 'uploads'], - 'engine/model' => ['model'], - 'engine/skin' => ['SkinContext'], - ]; - if (str_contains($class, '\\')) $class = str_replace('\\', '/', $class); - $path = null; - foreach (['Handler', 'Helper'] as $sfx) { - if (str_ends_with($class, $sfx)) { - $path = APP_ROOT.'/'.strtolower($sfx).'/'.$class.'.php'; - break; - } - } + if ($class == 'model') + $path = 'engine/model'; + else if (str_ends_with($class, 'Handler')) + $path = 'handlers/'.$class; + else + $path = 'lib/'.$class; - if (is_null($path)) { - foreach ($libs as $lib_file => $class_names) { - if (in_array($class, $class_names)) { - $path = APP_ROOT.'/'.$lib_file.'.php'; - break; - } - } - } - - if (is_null($path)) - $path = APP_ROOT.'/lib/'.$class.'.php'; - - if (!is_file($path)) + if (!is_file(APP_ROOT.'/'.$path.'.php')) return; - require_once $path; + require_once APP_ROOT.'/'.$path.'.php'; }); if (!file_exists(APP_ROOT.'/config.yaml')) @@ -69,12 +47,12 @@ require_once 'engine/request.php'; require_once 'engine/logging.php'; try { - if (is_cli()) { - verify_hostname($config['domain']); + if (isCli()) { + verifyHostname($config['domain']); $_SERVER['HTTP_HOST'] = $config['domain']; $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; } else { - verify_hostname(); + verifyHostname(); if (array_key_exists('HTTP_X_REAL_IP', $_SERVER)) $_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_X_REAL_IP']; @@ -86,12 +64,12 @@ try { die('Fatal error: '.$e->getMessage()); } -$__logger = is_dev() +$__logger = isDev() ? new FileLogger(APP_ROOT.'/log/debug.log') : new DatabaseLogger(); $__logger->enable(); -if (!is_dev()) { +if (!isDev()) { if (file_exists(APP_ROOT.'/config-static.php')) $config['static'] = require_once 'config-static.php'; else @@ -102,7 +80,7 @@ if (!is_dev()) { ini_set('display_errors', 0); } -if (!is_cli()) { +if (!isCli()) { $__lang = Strings::getInstance(); $__lang->load('main'); } diff --git a/lib/AdminActions/BaseAction.php b/lib/AdminActions/BaseAction.php index 1bacbfb..b2f0072 100644 --- a/lib/AdminActions/BaseAction.php +++ b/lib/AdminActions/BaseAction.php @@ -55,7 +55,7 @@ abstract class BaseAction { } public function getDate(): string { - return format_time($this->timeStamp, ['short_months' => true]); + return formatTime($this->timeStamp, ['short_months' => true]); } public function getTimeStamp(): int { @@ -70,7 +70,7 @@ abstract class BaseAction { return $this->recordId; } - function renderHtml(): string { + public function renderHtml(): string { $rc = new \ReflectionClass($this); $lines = []; $fields = $rc->getProperties(\ReflectionProperty::IS_PUBLIC); diff --git a/lib/AdminActions/Util/Logger.php b/lib/AdminActions/Util/Logger.php index efffc43..555acac 100644 --- a/lib/AdminActions/Util/Logger.php +++ b/lib/AdminActions/Util/Logger.php @@ -1,6 +1,6 @@ time(), ]; - if (is_cli()) { + if (isCli()) { $data += [ 'cli' => 1, ]; } else { $data += [ - 'admin_id' => $AdminSession->id, + 'admin_id' => \admin::getId(), 'ip' => !empty($_SERVER['REMOTE_ADDR']) ? ip2ulong($_SERVER['REMOTE_ADDR']) : 0, ]; } diff --git a/lib/BaconianaCollectionItem.php b/lib/BaconianaCollectionItem.php new file mode 100644 index 0000000..6fe9271 --- /dev/null +++ b/lib/BaconianaCollectionItem.php @@ -0,0 +1,62 @@ +title !== '') + return $this->title; + + return ($this->jobc ? lang('baconiana_old_name') : lang('baconiana')).' №'.$this->issues; + } + + public function isTargetBlank(): bool { return $this->isFile(); } + public function getId(): string { return $this->id; } + + public function getUrl(): string { + if ($this->isFolder()) { + return '/files/'.FilesCollection::Baconiana->value.'/'.$this->id.'/'; + } + global $config; + return 'https://'.$config['files_domain'].'/'.$this->path; + } + + public function getMeta(?string $hl_matched = null): array { + $items = []; + if ($this->isFolder()) + return $items; + + if ($this->year >= 2007) + $items = array_merge($items, ['Online Edition']); + + $items = array_merge($items, [ + sizeString($this->size), + 'PDF' + ]); + + return [ + 'inline' => false, + 'items' => $items + ]; + } + + public function getSubtitle(): ?string { + return $this->year > 0 ? '('.$this->year.')' : null; + } +} diff --git a/lib/BookCategory.php b/lib/BookCategory.php new file mode 100644 index 0000000..7fc1915 --- /dev/null +++ b/lib/BookCategory.php @@ -0,0 +1,6 @@ +id; + } + + public function getUrl(): string { + if ($this->isFolder() && !$this->external) + return '/files/'.$this->id.'/'; + global $config; + $buf = 'https://'.$config['files_domain']; + if (!str_starts_with($this->path, '/')) + $buf .= '/'; + $buf .= $this->path; + return $buf; + } + + public function getTitleHtml(): ?string { + if ($this->isFolder() || !$this->author) + return null; + $buf = ''.htmlescape($this->author).''; + if (!str_ends_with($this->author, '.')) + $buf .= '.'; + $buf .= ' '.htmlescape($this->title).''; + return $buf; + } + + public function getTitle(): string { + return $this->title; + } + + public function getMeta(?string $hl_matched = null): array { + if ($this->isFolder()) + return []; + + $items = [ + sizeString($this->size), + strtoupper($this->getExtension()) + ]; + + return [ + 'inline' => false, + 'items' => $items + ]; + } + + protected function getExtension(): string { + return extension(basename($this->path)); + } + + public function isAvailable(): bool { + return true; + } + + public function isTargetBlank(): bool { + return $this->isFile() || $this->external; + } + + public function getSubtitle(): ?string { + if (!$this->year && !$this->subtitle) + return null; + $buf = '('; + $buf .= $this->subtitle ?: $this->year; + $buf .= ')'; + return $buf; + } +} diff --git a/lib/CollectionItem.php b/lib/CollectionItem.php new file mode 100644 index 0000000..fa47bf2 --- /dev/null +++ b/lib/CollectionItem.php @@ -0,0 +1,21 @@ +collection->value; } + public function isFolder(): bool { return true; } + public function isFile(): bool { return false; } + public function isAvailable(): bool { return true; } + public function getUrl(): string { + return '/files/'.$this->collection->value.'/'; + } + public function getSize(): ?int { return null; } + public function getTitle(): string { return lang("files_{$this->collection->value}_collection"); } + public function getMeta(?string $hl_matched = null): array { return []; } + public function isTargetBlank(): bool { return false; } + public function getSubtitle(): ?string { return null; } +} \ No newline at end of file diff --git a/lib/FilesCollection.php b/lib/FilesCollection.php new file mode 100644 index 0000000..2532902 --- /dev/null +++ b/lib/FilesCollection.php @@ -0,0 +1,7 @@ +isFile() ? $this->size : null; } +} \ No newline at end of file diff --git a/lib/FilesItemType.php b/lib/FilesItemType.php new file mode 100644 index 0000000..90357e6 --- /dev/null +++ b/lib/FilesItemType.php @@ -0,0 +1,6 @@ +type == FilesItemType::FOLDER; + } + + public function isFile(): bool { + return $this->type == FilesItemType::FILE; + } + + public function isBook(): bool { + return $this instanceof BookItem && $this->fileType == BookFileType::BOOK; + } + +} \ No newline at end of file diff --git a/lib/MDFCollectionItem.php b/lib/MDFCollectionItem.php new file mode 100644 index 0000000..fff6f08 --- /dev/null +++ b/lib/MDFCollectionItem.php @@ -0,0 +1,83 @@ +issue}, {$this->getHumanFriendlyDate()}"; + } + + public function getHumanFriendlyDate(): string { + $dt = new DateTime($this->date); + return $dt->format('j M Y'); + } + + public function isTargetBlank(): bool { return true; } + public function getId(): string { return $this->id; } + public function getUrl(): string { + global $config; + return 'https://'.$config['files_domain'].'/Mercure-de-France-OCR/'.$this->path; + } + + public function getMeta(?string $hl_matched = null): array { + return [ + 'inline' => true, + 'items' => [ + 'Vol. '.$this->getRomanVolume(), + 'pp. '.$this->pageFrom.'-'.$this->pageTo, + sizeString($this->size), + 'PDF' + ] + ]; + } + + public function getRomanVolume(): string { + $number = $this->volume; + $map = [ + 1000 => 'M', + 900 => 'CM', + 500 => 'D', + 400 => 'CD', + 100 => 'C', + 90 => 'XC', + 50 => 'L', + 40 => 'XL', + 10 => 'X', + 9 => 'IX', + 5 => 'V', + 4 => 'IV', + 1 => 'I', + ]; + $result = ''; + foreach ($map as $arabic => $roman) { + while ($number >= $arabic) { + $result .= $roman; + $number -= $arabic; + } + } + return $result; + } + + public function getSubtitle(): ?string { + return null; + //return 'Vol. '.$this->getRomanVolume().', pp. '.$this->pageFrom.'-'.$this->pageTo; + } +} diff --git a/lib/ext/MyParsedown.php b/lib/MyParsedown.php similarity index 83% rename from lib/ext/MyParsedown.php rename to lib/MyParsedown.php index 20eb664..4b5160c 100644 --- a/lib/ext/MyParsedown.php +++ b/lib/MyParsedown.php @@ -1,13 +1,12 @@ fileupload($upload->name, $upload->getDirectUrl(), $upload->noteRu, $upload->getSize()); + $result['element']['rawHtml'] = skin::getInstance()->render('markdown_fileupload.twig', [ + 'name' => $upload->name, + 'direct_url' => $upload->getDirectUrl(), + 'note' => $upload->noteRu, + 'size' => $upload->getSize() + ]); return $result; } @@ -107,19 +110,19 @@ class MyParsedown extends ParsedownExtended { unset($result['element']['text']); - $ctx = self::getSkinContext(); - $result['element']['rawHtml'] = $ctx->image( - w: $opts['w'], - nolabel: $opts['nolabel'], - align: $opts['align'], - padding_top: round($h / $w * 100, 4), - may_have_alpha: $image->imageMayHaveAlphaChannel(), + $result['element']['rawHtml'] = skin::getInstance()->render('markdown_image.twig', [ + 'w' => $opts['w'], + 'nolabel' => $opts['nolabel'], + 'align' => $opts['align'], + 'padding_top' => round($h / $w * 100, 4), + 'may_have_alpha' => $image->imageMayHaveAlphaChannel(), - url: $image_url, - direct_url: $image->getDirectUrl(), - unsafe_note: markup::markdownToHtml($this->lang !== null && $this->lang == PostLanguage::Russian ? $image->noteRu : $image->noteEn, - no_paragraph: true) - ); + 'url' => $image_url, + 'direct_url' => $image->getDirectUrl(), + 'unsafe_note' => markup::markdownToHtml( + md: $this->lang !== null && $this->lang == PostLanguage::Russian ? $image->noteRu : $image->noteEn, + no_paragraph: true), + ]); return $result; } @@ -166,12 +169,11 @@ class MyParsedown extends ParsedownExtended { unset($result['element']['text']); - $ctx = self::getSkinContext(); - $result['element']['rawHtml'] = $ctx->video( - url: $video_url, - w: $opts['w'], - h: $opts['h'] - ); + $result['element']['rawHtml'] = skin::getInstance()->render('markdown_video.twig', [ + 'url' => $video_url, + 'w' => $opts['w'], + 'h' => $opts['h'] + ]); return $result; } @@ -218,8 +220,4 @@ class MyParsedown extends ParsedownExtended { return parent::blockFencedCodeComplete($block); } - protected static function getSkinContext(): SkinContext { - return skin('markdown'); - } - } diff --git a/lib/Page.php b/lib/Page.php new file mode 100644 index 0000000..6d5067e --- /dev/null +++ b/lib/Page.php @@ -0,0 +1,47 @@ +md || $fields['render_title'] != $this->renderTitle || $fields['title'] != $this->title) { + $md = $fields['md']; + if ($fields['render_title']) + $md = '# '.$fields['title']."\n\n".$md; + $fields['html'] = markup::markdownToHtml($md); + } + parent::edit($fields); + } + + public function isUpdated(): bool { + return $this->updateTs && $this->updateTs != $this->ts; + } + + public function getHtml(bool $is_retina, string $user_theme): string { + return markup::htmlImagesFix($this->html, $is_retina, $user_theme); + } + + public function getUrl(): string { + return "/{$this->shortName}/"; + } + + public function updateHtml(): void { + $html = markup::markdownToHtml($this->md); + $this->html = $html; + DB()->query("UPDATE pages SET html=? WHERE short_name=?", $html, $this->shortName); + } + +} diff --git a/lib/Post.php b/lib/Post.php new file mode 100644 index 0000000..5304ec8 --- /dev/null +++ b/lib/Post.php @@ -0,0 +1,129 @@ + $title, + 'lang' => $lang->value, + 'post_id' => $this->id, + 'html' => $html, + 'text' => $text, + 'md' => $md, + 'toc' => $toc, + 'keywords' => $keywords, + ]; + + $db = DB(); + if (!$db->insert('posts_texts', $data)) + return null; + + $id = $db->insertId(); + + $post_text = posts::getText($id); + $post_text->updateImagePreviews(); + + return $post_text; + } + + public function registerText(PostText $postText): void { + if (array_key_exists($postText->lang->value, $this->texts)) + throw new Exception("text for language {$postText->lang->value} has already been registered"); + $this->texts[$postText->lang->value] = $postText; + } + + public function loadTexts() { + if (!empty($this->texts)) + return; + $db = DB(); + $q = $db->query("SELECT * FROM posts_texts WHERE post_id=?", $this->id); + while ($row = $db->fetch($q)) { + $text = new PostText($row); + $this->registerText($text); + } + } + + /** + * @return PostText[] + */ + public function getTexts(): array { + $this->loadTexts(); + return $this->texts; + } + + public function getText(PostLanguage|string $lang): ?PostText { + if (is_string($lang)) + $lang = PostLanguage::from($lang); + $this->loadTexts(); + return $this->texts[$lang->value] ?? null; + } + + public function hasLang(PostLanguage $lang) { + $this->loadTexts(); + foreach ($this->texts as $text) { + if ($text->lang == $lang) + return true; + } + return false; + } + + public function hasSourceUrl(): bool { + return $this->sourceUrl != ''; + } + + public function getUrl(PostLanguage|string|null $lang = null): string { + $buf = $this->shortName != '' ? "/articles/{$this->shortName}/" : "/articles/{$this->id}/"; + if ($lang) { + if (is_string($lang)) + $lang = PostLanguage::from($lang); + if ($lang != PostLanguage::English) + $buf .= '?lang=' . $lang->value; + } + return $buf; + } + + public function getTimestamp(): int { + return (new DateTime($this->date))->getTimestamp(); + } + + public function getUpdateTimestamp(): ?int { + if (!$this->updateTime) + return null; + return (new DateTime($this->updateTime))->getTimestamp(); + } + + public function getDate(): string { + return date('j M', $this->getTimestamp()); + } + + public function getYear(): int { + return (int)date('Y', $this->getTimestamp()); + } + + public function getFullDate(): string { + return date('j F Y', $this->getTimestamp()); + } + + public function getDateForInputField(): string { + return date('Y-m-d', $this->getTimestamp()); + } +} \ No newline at end of file diff --git a/lib/PostLanguage.php b/lib/PostLanguage.php new file mode 100644 index 0000000..ac5b5e7 --- /dev/null +++ b/lib/PostLanguage.php @@ -0,0 +1,20 @@ +value, self::cases(), true); + } + + public static function casesAsStrings(): array { + return array_map(fn($v) => $v->value, self::cases()); + } + +} \ No newline at end of file diff --git a/lib/PostText.php b/lib/PostText.php new file mode 100644 index 0000000..1796188 --- /dev/null +++ b/lib/PostText.php @@ -0,0 +1,116 @@ +md) { + $fields['html'] = markup::markdownToHtml($fields['md'], lang: $this->lang); + $fields['text'] = markup::htmlToText($fields['html']); + } + + if ((isset($fields['toc']) && $fields['toc']) || $this->toc) { + $fields['toc_html'] = markup::toc($fields['md']); + } + + parent::edit($fields); + $this->updateImagePreviews(); + } + + public function updateHtml(): void { + $html = markup::markdownToHtml($this->md, lang: $this->lang); + $this->html = $html; + DB()->query("UPDATE posts_texts SET html=? WHERE id=?", $html, $this->id); + } + + public function updateText(): void { + $html = markup::markdownToHtml($this->md, lang: $this->lang); + $text = markup::htmlToText($html); + $this->text = $text; + DB()->query("UPDATE posts_texts SET text=? WHERE id=?", $text, $this->id); + } + + public function getDescriptionPreview(int $len): string { + if (mb_strlen($this->text) >= $len) + return mb_substr($this->text, 0, $len-3).'...'; + return $this->text; + } + + public function getFirstImage(): ?Upload { + if (!preg_match('/\{image:([\w]{8})/', $this->md, $match)) + return null; + return uploads::getUploadByRandomId($match[1]); + } + + public function getHtml(bool $is_retina, string $theme): string { + $html = $this->html; + return markup::htmlImagesFix($html, $is_retina, $theme); + } + + public function getTableOfContentsHtml(): ?string { + return $this->tocHtml ?: null; + } + + public function hasTableOfContents(): bool { + return $this->toc; + } + + /** + * @param bool $update Whether to overwrite preview if already exists + * @return int + * @throws Exception + */ + public function updateImagePreviews(bool $update = false): int { + $images = []; + if (!preg_match_all('/\{image:([\w]{8}),(.*?)}/', $this->md, $matches)) + return 0; + + for ($i = 0; $i < count($matches[0]); $i++) { + $id = $matches[1][$i]; + $w = $h = null; + $opts = explode(',', $matches[2][$i]); + foreach ($opts as $opt) { + if (str_contains($opt, '=')) { + list($k, $v) = explode('=', $opt); + if ($k == 'w') + $w = (int)$v; + else if ($k == 'h') + $h = (int)$v; + } + } + $images[$id][] = [$w, $h]; + } + + if (empty($images)) + return 0; + + $images_affected = 0; + $uploads = uploads::getUploadsByRandomId(array_keys($images), true); + foreach ($uploads as $upload_key => $u) { + if ($u === null) { + logError(__METHOD__.': upload '.$upload_key.' is null'); + continue; + } + foreach ($images[$u->randomId] as $s) { + list($w, $h) = $s; + list($w, $h) = $u->getImagePreviewSize($w, $h); + if ($u->createImagePreview($w, $h, $update, $u->imageMayHaveAlphaChannel())) + $images_affected++; + } + } + + return $images_affected; + } + +} diff --git a/lib/PreviousText.php b/lib/PreviousText.php new file mode 100644 index 0000000..1bb47aa --- /dev/null +++ b/lib/PreviousText.php @@ -0,0 +1,16 @@ +getNode('params')); + + $compiler->addDebugInfo($this); + $compiler + ->raw(PHP_EOL); + + for ($i = 0; ($i < $count); $i++) { + // argument is not an expression (such as, a \Twig\Node\Textbody) + // we should trick with output buffering to get a valid argument to pass + // to the functionToCall() function. + if (!($this->getNode('params')->getNode($i) instanceof \Twig\Node\Expression\AbstractExpression)) { + $compiler + ->write('ob_start();') + ->raw(PHP_EOL); + + $compiler + ->subcompile($this->getNode('params')->getNode($i)); + + $compiler + ->write('$js = ob_get_clean();') + ->raw(PHP_EOL); + } + } + + $compiler + ->write('skin::getInstance()->addJS($js);') + ->raw(PHP_EOL) + ->write('unset($js);') + ->raw(PHP_EOL); + } + +} diff --git a/lib/TwigAddons/JsTagParamsNode.php b/lib/TwigAddons/JsTagParamsNode.php new file mode 100644 index 0000000..e0a07a3 --- /dev/null +++ b/lib/TwigAddons/JsTagParamsNode.php @@ -0,0 +1,6 @@ +getLine(); + $stream = $this->parser->getStream(); + + // recovers all inline parameters close to your tag name + $params = array_merge([], $this->getInlineParams($token)); + + $continue = true; + while ($continue) { + // create subtree until the decideJsTagFork() callback returns true + $body = $this->parser->subparse($this->decideJsTagFork(...)); + + // I like to put a switch here, in case you need to add middle tags, such + // as: {% js %}, {% nextjs %}, {% endjs %}. + $tag = $stream->next()->getValue(); + switch ($tag) { + case 'endjs': + $continue = false; + break; + default: + throw new \Twig\Error\SyntaxError(sprintf( + 'Unexpected end of template. Twig was looking for the ' . + 'following tags "endjs" to close the "js" block started ' . + 'at line %d)', + $lineno, + ), -1); + } + + // you want $body at the beginning of your arguments + array_unshift($params, $body); + + // if your endjs can also contains params, you can uncomment this line: + // $params = array_merge($params, $this->getInlineParams($token)); + // and comment this one: + $stream->expect(\Twig\Token::BLOCK_END_TYPE); + } + + return new JsTagNode(['params' => new JsTagParamsNode($params)], [], $lineno); + } + + /** + * Recovers all tag parameters until we find a BLOCK_END_TYPE ( %} ) + * + * @param \Twig\Token $token + * @return \Twig\Node\Expression\AbstractExpression[] + */ + protected function getInlineParams(\Twig\Token $token) { + $stream = $this->parser->getStream(); + $params = array (); + while (!$stream->test(\Twig\Token::BLOCK_END_TYPE)) { + $params[] = $this->parser->getExpressionParser()->parseExpression(); + } + $stream->expect(\Twig\Token::BLOCK_END_TYPE); + return $params; + } + + /** + * Callback called at each tag name when subparsing, must return + * true when the expected end tag is reached. + * + * @param \Twig\Token $token + * @return bool + */ + public function decideJsTagFork(\Twig\Token $token) { + return $token->test(['endjs']); + } + + /** + * Your tag name: if the parsed tag match the one you put here, your parse() + * method will be called. + * + * @return string + */ + public function getTag() { + return 'js'; + } + +} diff --git a/lib/TwigAddons/MyExtension.php b/lib/TwigAddons/MyExtension.php new file mode 100644 index 0000000..408a86b --- /dev/null +++ b/lib/TwigAddons/MyExtension.php @@ -0,0 +1,105 @@ + \skin::getInstance()->getSVG($name), + ['is_safe' => ['html']]), + + new TwigFunction('svgInPlace', fn($name) => \skin::getInstance()->getSVG($name, in_place: true), + ['is_safe' => ['html']]), + + new TwigFunction('svgPreload', function(...$icons) { + $skin = \skin::getInstance(); + foreach ($icons as $icon) + $skin->preloadSVG($icon); + return null; + }), + + new TwigFunction('bc', fn(...$args) => \skin::getInstance()->renderBreadCrumbs(...$args), + ['is_safe' => ['html']]), + + new TwigFunction('pageNav', fn(...$args) => \skin::getInstance()->renderPageNav(...$args), + ['is_safe' => ['html']]), + + new TwigFunction('csrf', fn($value) => \request_handler::getCSRF($value)) + ]; + } + + public function getFilters() { + return array( + new TwigFilter('lang', function($key, array $args = []) { + global $__lang; + array_walk($args, function(&$item, $key) { + $item = htmlescape($item); + }); + array_unshift($args, $key); + return call_user_func_array([$__lang, 'get'], $args); + }, ['is_variadic' => true]), + + new TwigFilter('hl', function($s, $keywords) { + return highlightSubstring($s, $keywords); + }), + + new TwigFilter('plural', function($text, array $args = []) { + global $__lang; + array_unshift($args, $text); + return call_user_func_array([$__lang, 'num'], $args); + }, ['is_variadic' => true]) + + // new TwigFilter('format_number', function($number, array $args = []) { + // array_unshift($args, $number); + // return call_user_func_array('formatNumber', $args); + // }, ['is_variadic' => true]), + + // new TwigFilter('short_number', function($number, array $args = []) { + // array_unshift($args, $number); + // return call_user_func_array('shortNumber', $args); + // }, ['is_variadic']), + // + // new TwigFilter('format_time', function($ts, array $args = []) { + // array_unshift($args, $ts); + // return call_user_func_array('formatTime', $args); + // }, ['is_variadic' => true]), + // + // new TwigFilter('format_duration', function($seconds, array $args = []) { + // array_unshift($args, $seconds); + // return call_user_func_array('formatDuration', $args); + // }, ['is_variadic' => true]), + // + // new TwigFilter('format_size', function ($number, array $args = []) { + // array_unshift($args, $number); + // return call_user_func_array('sizeString', $args); + // }, ['is_variadic' => true]), + +// new TwigFilter('hl', function($text, array $args = []) { +// $keywords = explode(' ', $args[0]); +// $keywords = array_filter($keywords, function($item) { +// return trim($item) != ''; +// }); +// +// return highlightKeyword($text, $keywords); +// }, ['is_variadic' => true, 'is_safe' => ['html']]), + +// new TwigFilter('hex', function($number, array $args = []) { +// return '0x'.dechex((int)$number); +// }) + ); + } + + public function getTokenParsers() { + return [new JsTagTokenParser()]; + } + + public function getName() { + return 'lang'; + } + +} diff --git a/lib/Upload.php b/lib/Upload.php new file mode 100644 index 0000000..3a25ff5 --- /dev/null +++ b/lib/Upload.php @@ -0,0 +1,170 @@ +randomId; + } + + public function getDirectUrl(): string { + global $config; + return $config['uploads_path'].'/'.$this->randomId.'/'.$this->name; + } + + public function getDirectPreviewUrl(int $w, int $h, bool $retina = false): string { + global $config; + if ($w == $this->imageW && $this->imageH == $h) + return $this->getDirectUrl(); + + if ($retina) { + $w *= 2; + $h *= 2; + } + + $prefix = $this->imageMayHaveAlphaChannel() ? 'a' : 'p'; + return $config['uploads_path'].'/'.$this->randomId.'/'.$prefix.$w.'x'.$h.'.jpg'; + } + + // TODO remove? + public function incrementDownloads() { + $db = DB(); + $db->query("UPDATE uploads SET downloads=downloads+1 WHERE id=?", $this->id); + $this->downloads++; + } + + public function getSize(): string { + return sizeString($this->size); + } + + public function getMarkdown(?string $options = null): string { + if ($this->isImage()) { + $md = '{image:'.$this->randomId.',w='.$this->imageW.',h='.$this->imageH.($options ? ','.$options : '').'}{/image}'; + } else if ($this->isVideo()) { + $md = '{video:'.$this->randomId.($options ? ','.$options : '').'}{/video}'; + } else { + $md = '{fileAttach:'.$this->randomId.($options ? ','.$options : '').'}{/fileAttach}'; + } + $md .= ' '; + return $md; + } + + public function setNote(PostLanguage $lang, string $note) { + $db = DB(); + $db->query("UPDATE uploads SET note_{$lang->value}=? WHERE id=?", $note, $this->id); + } + + public function isImage(): bool { + return in_array(extension($this->name), self::$ImageExtensions); + } + + // assume all png images have alpha channel + // i know this is wrong, but anyway + public function imageMayHaveAlphaChannel(): bool { + return strtolower(extension($this->name)) == 'png'; + } + + public function isVideo(): bool { + return in_array(extension($this->name), self::$VideoExtensions); + } + + public function getImageRatio(): float { + return $this->imageW / $this->imageH; + } + + public function getImagePreviewSize(?int $w = null, ?int $h = null): array { + if (is_null($w) && is_null($h)) + throw new Exception(__METHOD__.': both width and height can\'t be null'); + + if (is_null($h)) + $h = round($w / $this->getImageRatio()); + + if (is_null($w)) + $w = round($h * $this->getImageRatio()); + + return [$w, $h]; + } + + public function createImagePreview(?int $w = null, + ?int $h = null, + bool $force_update = false, + bool $may_have_alpha = false): bool { + global $config; + + $orig = $config['uploads_dir'].'/'.$this->randomId.'/'.$this->name; + $updated = false; + + foreach (themes::getThemes() as $theme) { + if (!$may_have_alpha && $theme == 'dark') + continue; + + for ($mult = 1; $mult <= 2; $mult++) { + $dw = $w * $mult; + $dh = $h * $mult; + + $prefix = $may_have_alpha ? 'a' : 'p'; + $dst = $config['uploads_dir'].'/'.$this->randomId.'/'.$prefix.$dw.'x'.$dh.($theme == 'dark' ? '_dark' : '').'.jpg'; + + if (file_exists($dst)) { + if (!$force_update) + continue; + unlink($dst); + } + + $img = imageopen($orig); + imageresize($img, $dw, $dh, themes::getThemeAlphaColorAsRGB($theme)); + imagejpeg($img, $dst, $mult == 1 ? 93 : 67); + imagedestroy($img); + + setperm($dst); + $updated = true; + } + } + + return $updated; + } + + /** + * @return int Number of deleted files + */ + public function deleteAllImagePreviews(): int { + global $config; + $dir = $config['uploads_dir'].'/'.$this->randomId; + $files = scandir($dir); + $deleted = 0; + foreach ($files as $f) { + if (preg_match('/^[ap](\d+)x(\d+)(?:_dark)?\.jpg$/', $f)) { + if (is_file($dir.'/'.$f)) + unlink($dir.'/'.$f); + else + logError(__METHOD__.': '.$dir.'/'.$f.' is not a file!'); + $deleted++; + } + } + return $deleted; + } + + public function getJSONEncodedHtmlSafeNote(string $lang): string { + $value = $lang == 'en' ? $this->noteEn : $this->noteRu; + return jsonEncode(preg_replace('/(\r)?\n/', '\n', addslashes($value))); + } + +} \ No newline at end of file diff --git a/lib/WFFCollectionItem.php b/lib/WFFCollectionItem.php new file mode 100644 index 0000000..6a14f71 --- /dev/null +++ b/lib/WFFCollectionItem.php @@ -0,0 +1,53 @@ +id; } + public function isAvailable(): bool { return true; } + public function getTitle(): string { return $this->title; } + public function getDocumentId(): string { return $this->isFolder() ? str_replace('_', ' ', basename($this->path)) : $this->documentId; } + public function isTargetBlank(): bool { return $this->isFile(); } + public function getSubtitle(): ?string { return null; } + + public function getUrl(): string { + global $config; + return $this->isFolder() + ? "/files/wff/{$this->id}/" + : "https://{$config['files_domain']}/NSA Friedman Documents/{$this->path}"; + } + + public function getMeta(?string $hl_matched = null): array { + if ($this->isFolder()) { + if (!$this->parentId) + return []; + return [ + 'items' => [ + highlightSubstring($this->getDocumentId(), $hl_matched), + langNum('files_count', $this->filesCount) + ] + ]; + } + return [ + 'inline' => false, + 'items' => [ + highlightSubstring('Document '.$this->documentId), + sizeString($this->size), + 'PDF' + ] + ]; + } + +} diff --git a/lib/admin.php b/lib/admin.php index debc3bb..e3e2c7b 100644 --- a/lib/admin.php +++ b/lib/admin.php @@ -1,156 +1,132 @@ id = null; - $this->authId = null; - $this->csrfSalt = null; - $this->login = null; + // session data + protected static ?int $id = null; + protected static ?int $authId = null; + protected static ?string $csrfSalt = null; + protected static ?string $login = null; + + public static function exists(string $login): bool { + $db = DB(); + return (int)$db->result($db->query("SELECT COUNT(*) FROM admins WHERE login=? LIMIT 1", $login)) > 0; } - public function makeCSRFSalt(string $salted_password): void { - $this->csrfSalt = salt_password(strrev($salted_password)); + public static function add(string $login, string $password): int { + $db = DB(); + $db->insert('admins', [ + 'login' => $login, + 'password' => saltPassword($password), + 'activity_ts' => 0 + ]); + return $db->insertId(); } -}; -function is_admin(): bool { - global $AdminSession; - if ($AdminSession->id === null) - _admin_check(); - return $AdminSession->id != 0; -} - -function admin_exists(string $login): bool { - $db = DB(); - return (int)$db->result($db->query("SELECT COUNT(*) FROM admins WHERE login=? LIMIT 1", $login)) > 0; -} - -function admin_add(string $login, string $password): int { - $db = DB(); - $db->insert('admins', [ - 'login' => $login, - 'password' => salt_password($password), - 'activity_ts' => 0 - ]); - return $db->insertId(); -} - -function admin_delete(string $login): bool { - $db = DB(); - $id = admin_get_id_by_login($login); - if (!$db->query("DELETE FROM admins WHERE login=?", $login)) return false; - if (!$db->query("DELETE FROM admin_auth WHERE admin_id=?", $id)) return false; - return true; -} - -/** - * @param int[] $ids - * @return string[] - */ -function admin_get_logins_by_id(array $ids): array { - $db = DB(); - $logins = []; - $q = $db->query("SELECT id, login FROM admins WHERE id IN (".implode(',', $ids).")"); - while ($row = $db->fetch($q)) { - $logins[(int)$row['id']] = $row['login']; + public static function delete(string $login): bool { + $db = DB(); + $id = self::getIdByLogin($login); + if (!$db->query("DELETE FROM admins WHERE login=?", $login)) return false; + if (!$db->query("DELETE FROM admin_auth WHERE admin_id=?", $id)) return false; + return true; } - return $logins; -} -function admin_get_id_by_login(string $login): ?int { - $db = DB(); - $q = $db->query("SELECT id FROM admins WHERE login=?", $login); - return $db->numRows($q) > 0 ? (int)$db->result($q) : null; -} + /** + * @param int[] $ids + * @return string[] + */ + public static function getLoginsById(array $ids): array { + $db = DB(); + $logins = []; + $q = $db->query("SELECT id, login FROM admins WHERE id IN (".implode(',', $ids).")"); + while ($row = $db->fetch($q)) { + $logins[(int)$row['id']] = $row['login']; + } + return $logins; + } -function admin_set_password(string $login, string $password): bool { - $db = DB(); - $db->query("UPDATE admins SET password=? WHERE login=?", salt_password($password), $login); - return $db->affectedRows() > 0; -} + protected static function getIdByLogin(string $login): ?int { + $db = DB(); + $q = $db->query("SELECT id FROM admins WHERE login=?", $login); + return $db->numRows($q) > 0 ? (int)$db->result($q) : null; + } -function admin_auth(string $login, string $password): bool { - global $AdminSession; + public static function setPassword(string $login, string $password): bool { + $db = DB(); + $db->query("UPDATE admins SET password=? WHERE login=?", saltPassword($password), $login); + return $db->affectedRows() > 0; + } - $db = DB(); - $salted_password = salt_password($password); - $q = $db->query("SELECT id, active FROM admins WHERE login=? AND password=?", $login, $salted_password); - if (!$db->numRows($q)) - return false; + public static function auth(string $login, string $password): bool { + $db = DB(); + $salted_password = saltPassword($password); + $q = $db->query("SELECT id, active FROM admins WHERE login=? AND password=?", $login, $salted_password); + if (!$db->numRows($q)) { + logDebug(__METHOD__.': login or password is invalid'); + return false; + } - $row = $db->fetch($q); - $id = (int)$row['id']; - $active = (bool)$row['active']; - if (!$active) - return false; + $row = $db->fetch($q); + $id = (int)$row['id']; + $active = (bool)$row['active']; + if (!$active) + return false; - $time = time(); + $time = time(); - do { - $token = strgen(32); - } while ($db->numRows($db->query("SELECT id FROM admin_auth WHERE token=? LIMIT 1", $token)) > 0); + do { + $token = strgen(32); + } while ($db->numRows($db->query("SELECT id FROM admin_auth WHERE token=? LIMIT 1", $token)) > 0); - $db->insert('admin_auth', [ - 'admin_id' => $id, - 'token' => $token, - 'ts' => $time - ]); - $auth_id = $db->insertId(); + $db->insert('admin_auth', [ + 'admin_id' => $id, + 'token' => $token, + 'ts' => $time + ]); + $auth_id = $db->insertId(); - $db->insert('admin_log', [ - 'admin_id' => $id, - 'ts' => $time, - 'ip' => ip2ulong($_SERVER['REMOTE_ADDR']), - 'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '', - ]); + $db->insert('admin_log', [ + 'admin_id' => $id, + 'ts' => $time, + 'ip' => ip2ulong($_SERVER['REMOTE_ADDR']), + 'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '', + ]); - $db->query("UPDATE admins SET activity_ts=? WHERE id=?", $time, $id); + $db->query("UPDATE admins SET activity_ts=? WHERE id=?", $time, $id); - $AdminSession->id = $id; - $AdminSession->login = $login; - $AdminSession->makeCSRFSalt($salted_password); - $AdminSession->authId = $auth_id; + self::setSessionData($id, $login, $auth_id, $salted_password); + self::setCookie($token); - _admin_set_cookie($token); - return true; -} + return true; + } -function admin_logout() { - if (!is_admin()) - return; + public static function logout() { + if (!isAdmin()) + return; - global $AdminSession; + $db = DB(); + $db->query("DELETE FROM admin_auth WHERE id=?", self::$authId); - $db = DB(); - $db->query("DELETE FROM admin_auth WHERE id=?", $AdminSession->authId); + self::unsetSessionData(); + self::unsetCookie(); + } - $AdminSession->mrProper(); - _admin_unset_cookie(); -} + public static function log(\AdminActions\BaseAction $action) { + \AdminActions\Util\Logger::record($action); + } -function admin_log(\AdminActions\BaseAction $action) { - \AdminActions\Util\Logger::record($action); -} + public static function check(): void { + if (!isset($_COOKIE[self::ADMIN_COOKIE_NAME])) + return; -function _admin_check(): void { - if (!isset($_COOKIE[ADMIN_COOKIE_NAME])) - return; - - $cookie = (string)$_COOKIE[ADMIN_COOKIE_NAME]; - $db = DB(); - $time = time(); - $q = $db->query("SELECT + $cookie = (string)$_COOKIE[self::ADMIN_COOKIE_NAME]; + $db = DB(); + $time = time(); + $q = $db->query("SELECT admin_auth.id AS auth_id, admin_auth.admin_id AS id, admins.activity_ts AS activity_ts, @@ -161,27 +137,44 @@ function _admin_check(): void { WHERE admin_auth.token=? LIMIT 1", $cookie); - if (!$db->numRows($q)) - return; + if (!$db->numRows($q)) { + unset($_COOKIE[self::ADMIN_COOKIE_NAME]); + return; + } - $info = $db->fetch($q); + $info = $db->fetch($q); + self::setSessionData((int)$info['id'], $info['login'], (int)$info['auth_id'], $info['salted_password']); - global $AdminSession; - $AdminSession->id = (int)$info['id']; - $AdminSession->login = $info['login']; - $AdminSession->authId = (int)$info['auth_id']; - $AdminSession->makeCSRFSalt($info['salted_password']); + if ($time - $info['activity_ts'] > 15) + $db->query("UPDATE admins SET activity_ts=? WHERE id=?", $time, self::$id); + } + + protected static function setCookie(string $token): void { + global $config; + setcookie(self::ADMIN_COOKIE_NAME, $token, time() + self::ADMIN_SESSION_TIMEOUT, '/', $config['cookie_host']); + } + + protected static function unsetCookie(): void { + global $config; + setcookie(self::ADMIN_COOKIE_NAME, '', 1, '/', $config['cookie_host']); + } + + protected static function setSessionData(int $id, string $login, int $authId, string $saltedPassword) { + self::$id = $id; + self::$login = $login; + self::$authId = $authId; + self::$csrfSalt = saltPassword(strrev($saltedPassword)); + } + + protected static function unsetSessionData(): void { + self::$id = null; + self::$authId = null; + self::$csrfSalt = null; + self::$login = null; + } + + public static function getId(): ?int { return self::$id; } + public static function getCSRFSalt(): ?string { return self::$csrfSalt; } + public static function getLogin(): ?string { return self::$login; } - if ($time - $info['activity_ts'] > 15) - $db->query("UPDATE admins SET activity_ts=? WHERE id=?", $time, $AdminSession->id); -} - -function _admin_set_cookie(string $token): void { - global $config; - setcookie(ADMIN_COOKIE_NAME, $token, time() + ADMIN_SESSION_TIMEOUT, '/', $config['cookie_host']); -} - -function _admin_unset_cookie(): void { - global $config; - setcookie(ADMIN_COOKIE_NAME, '', 1, '/', $config['cookie_host']); } diff --git a/lib/ansi.php b/lib/ansi.php deleted file mode 100644 index 9e0a425..0000000 --- a/lib/ansi.php +++ /dev/null @@ -1,32 +0,0 @@ -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/lib/cli.php b/lib/cli.php index 624a719..8df2203 100644 --- a/lib/cli.php +++ b/lib/cli.php @@ -17,15 +17,15 @@ class cli { exit(is_null($error) ? 0 : 1); } - function on(string $command, callable $f) { + public function on(string $command, callable $f) { $this->commands[$command] = $f; return $this; } - function run(): void { + public function run(): void { global $argv, $argc; - if (!is_cli()) + if (!isCli()) cli::die('SAPI != cli'); if ($argc < 2) diff --git a/lib/files.php b/lib/files.php index 185d13f..eb477c7 100644 --- a/lib/files.php +++ b/lib/files.php @@ -2,738 +2,389 @@ use Sphinx\SphinxClient; -require_once 'engine/sphinx.php'; +class files { + const string WFF_ARCHIVE_SPHINX_RTINDEX = 'wff_collection'; + const string MDF_ARCHIVE_SPHINX_RTINDEX = 'mdf_archive'; + const string BACONIANA_ARCHIVE_SPHINX_RTINDEX = 'baconiana_archive'; -// ---------------------------------------------- -// ------------------- Common ------------------- -// ---------------------------------------------- + /** + * @param string $table + * @param string $field_id + * @param int[] $ids + * @param string[] $keywords Must already be lower-cased + * @param int $before + * @param int $after + * @return array + */ + public static function _get_text_excerpts(string $table, string $field_id, array $ids, array $keywords, int $before, int $after) { + $results = []; + foreach ($ids as $id) + $results[$id] = null; -const WFF_ARCHIVE_SPHINX_RTINDEX = 'wff_collection'; -const MDF_ARCHIVE_SPHINX_RTINDEX = 'mdf_archive'; -const BACONIANA_ARCHIVE_SPHINX_RTINDEX = 'baconiana_archive'; + $db = DB(); -enum FilesItemType: string { - case FILE = 'file'; - case FOLDER = 'folder'; -} + $dynamic_sql_parts = []; + $combined_parts = []; + foreach ($keywords as $keyword) { + $part = "LOCATE('".$db->escape($keyword)."', text)"; + $dynamic_sql_parts[] = $part; + } + if (count($dynamic_sql_parts) > 1) { + foreach ($dynamic_sql_parts as $part) + $combined_parts[] = "IF({$part} > 0, {$part}, CHAR_LENGTH(text) + 1)"; + $combined_parts = implode(', ', $combined_parts); + $combined_parts = 'LEAST('.$combined_parts.')'; + } else { + $combined_parts = "IF({$dynamic_sql_parts[0]} > 0, {$dynamic_sql_parts[0]}, CHAR_LENGTH(text) + 1)"; + } -interface FilesItemInterface { - public function getId(): string; - public function isFolder(): bool; - public function isFile(): bool; - public function getUrl(): string; - public function getSize(): ?int; - public function getTitle(): string; - public function getTitleHtml(): ?string; - public function getMeta(?string $hl_matched = null): array; - public function isAvailable(): bool; - public function isTargetBlank(): bool; - public function getSubtitle(): ?string; -} - -trait FilesItemTypeTrait { - public FilesItemType $type; - public function isFolder(): bool { return $this->type == FilesItemType::FOLDER; } - public function isFile(): bool { return $this->type == FilesItemType::FILE; } -} - -trait FilesItemSizeTrait { - public int $size; - public function getSize(): ?int { return $this->isFile() ? $this->size : null; } -} - -/** - * @param string $table - * @param string $field_id - * @param int[] $ids - * @param string[] $keywords Must already be lower-cased - * @param int $before - * @param int $after - * @return array - */ -function _get_text_excerpts(string $table, string $field_id, array $ids, array $keywords, int $before, int $after) { - $results = []; - foreach ($ids as $id) - $results[$id] = null; - - $db = DB(); - - $dynamic_sql_parts = []; - $combined_parts = []; - foreach ($keywords as $keyword) { - $part = "LOCATE('".$db->escape($keyword)."', text)"; - $dynamic_sql_parts[] = $part; - } - if (count($dynamic_sql_parts) > 1) { - foreach ($dynamic_sql_parts as $part) - $combined_parts[] = "IF({$part} > 0, {$part}, CHAR_LENGTH(text) + 1)"; - $combined_parts = implode(', ', $combined_parts); - $combined_parts = 'LEAST('.$combined_parts.')'; - } else { - $combined_parts = "IF({$dynamic_sql_parts[0]} > 0, {$dynamic_sql_parts[0]}, CHAR_LENGTH(text) + 1)"; - } - - $total = $before + $after; - $sql = "SELECT - {$field_id} AS id, - GREATEST( - 1, - {$combined_parts} - {$before} - ) AS excerpt_start_index, - SUBSTRING( - text, + $total = $before + $after; + $sql = "SELECT + {$field_id} AS id, GREATEST( 1, {$combined_parts} - {$before} - ), - LEAST( - CHAR_LENGTH(text), - {$total} + {$combined_parts} - GREATEST(1, {$combined_parts} - {$before}) - ) - ) AS excerpt - FROM - {$table} - WHERE - {$field_id} IN (".implode(',', $ids).")"; + ) AS excerpt_start_index, + SUBSTRING( + text, + GREATEST( + 1, + {$combined_parts} - {$before} + ), + LEAST( + CHAR_LENGTH(text), + {$total} + {$combined_parts} - GREATEST(1, {$combined_parts} - {$before}) + ) + ) AS excerpt + FROM + {$table} + WHERE + {$field_id} IN (".implode(',', $ids).")"; - $q = $db->query($sql); - while ($row = $db->fetch($q)) { - $results[$row['id']] = [ - 'excerpt' => preg_replace('/\s+/', ' ', $row['excerpt']), - 'index' => (int)$row['excerpt_start_index'] - ]; - } - - return $results; -} - -function _search(string $index, - string $q, - int $offset, - int $count, - callable $items_getter, - ?callable $sphinx_client_setup = null): array { - $query_filtered = sphinx_mkquery($q); - - $cl = sphinx_client(); - $cl->setLimits($offset, $count); - - $cl->setMatchMode(Sphinx\SphinxClient::SPH_MATCH_EXTENDED); - - if (is_callable($sphinx_client_setup)) - $sphinx_client_setup($cl); - else { - $cl->setRankingMode(Sphinx\SphinxClient::SPH_RANK_PROXIMITY_BM25); - $cl->setSortMode(Sphinx\SphinxClient::SPH_SORT_RELEVANCE); - } - - // run search - $final_query = "$query_filtered"; - $result = $cl->query($final_query, $index); - $error = $cl->getLastError(); - $warning = $cl->getLastWarning(); - if ($error) - logError(__FUNCTION__, $error); - if ($warning) - logWarning(__FUNCTION__, $warning); - if ($result === false) - return ['count' => 0, 'items' => []]; - - $total_found = (int)$result['total_found']; - - $items = []; - if (!empty($result['matches'])) - $items = $items_getter($result['matches']); - - return ['count' => $total_found, 'items' => $items]; -} - -// --------------------------------------------------- -// ------------------- Collections ------------------- -// --------------------------------------------------- - -enum FilesCollection: string { - case WilliamFriedman = 'wff'; - case MercureDeFrance = 'mdf'; - case Baconiana = 'baconiana'; -} - -class CollectionItem implements FilesItemInterface { - public function __construct( - protected FilesCollection $collection - ) {} - - public function getTitleHtml(): ?string { return null; } - public function getId(): string { return $this->collection->value; } - public function isFolder(): bool { return true; } - public function isFile(): bool { return false; } - public function isAvailable(): bool { return true; } - public function getUrl(): string { - return '/files/'.$this->collection->value.'/'; - } - public function getSize(): ?int { return null; } - public function getTitle(): string { return lang("files_{$this->collection->value}_collection"); } - public function getMeta(?string $hl_matched = null): array { return []; } - public function isTargetBlank(): bool { return false; } - public function getSubtitle(): ?string { return null; } -} - - -// ---------------------------------------------------------------- -// ------------------- William Friedman Archive ------------------- -// ---------------------------------------------------------------- - -class WFFCollectionItem extends model implements FilesItemInterface { - - const DB_TABLE = 'wff_collection'; - - use FilesItemTypeTrait; - use FilesItemSizeTrait; - - public int $id; - public int $parentId; - public string $title; - public string $documentId; - public string $path; - public int $filesCount; - - public function getTitleHtml(): ?string { return null; } - public function getId(): string { return (string)$this->id; } - public function isAvailable(): bool { return true; } - public function getTitle(): string { return $this->title; } - public function getDocumentId(): string { return $this->isFolder() ? str_replace('_', ' ', basename($this->path)) : $this->documentId; } - public function isTargetBlank(): bool { return $this->isFile(); } - public function getSubtitle(): ?string { return null; } - - public function getUrl(): string { - global $config; - return $this->isFolder() - ? "/files/wff/{$this->id}/" - : "https://{$config['files_domain']}/NSA Friedman Documents/{$this->path}"; - } - - public function getMeta(?string $hl_matched = null): array { - if ($this->isFolder()) { - if (!$this->parentId) - return []; - return [ - 'items' => [ - hl_matched($this->getDocumentId(), $hl_matched), - lang_num('files_count', $this->filesCount) - ] + $q = $db->query($sql); + while ($row = $db->fetch($q)) { + $results[$row['id']] = [ + 'excerpt' => preg_replace('/\s+/', ' ', $row['excerpt']), + 'index' => (int)$row['excerpt_start_index'] ]; } - return [ - 'inline' => false, - 'items' => [ - hl_matched('Document '.$this->documentId), - sizeString($this->size), - 'PDF' - ] - ]; + + return $results; } -} + public static function _search(string $index, + string $q, + int $offset, + int $count, + callable $items_getter, + ?callable $sphinx_client_setup = null): array { + $query_filtered = sphinx::mkquery($q); -/** - * @param int $folder_id - * @param bool $with_parents - * @return WFFCollectionItem|WFFCollectionItem[]|null - */ -function wff_get_folder(int $folder_id, bool $with_parents = false): WFFCollectionItem|array|null { - $db = DB(); - $q = $db->query("SELECT * FROM wff_collection WHERE id=?", $folder_id); - if (!$db->numRows($q)) - return null; - $item = new WFFCollectionItem($db->fetch($q)); - if (!$item->isFolder()) - return null; - if ($with_parents) { - $items = [$item]; - if ($item->parentId) { - $parents = wff_get_folder($item->parentId, true); - if ($parents !== null) - $items = array_merge($items, $parents); - } - return $items; - } - return $item; -} + $cl = sphinx::getClient(); + $cl->setLimits($offset, $count); -/** - * @param int|int[]|null $parent_id - * @return array - */ -function wff_get(int|array|null $parent_id = null) { - $db = DB(); + $cl->setMatchMode(Sphinx\SphinxClient::SPH_MATCH_EXTENDED); - $where = []; - $args = []; - - if (!is_null($parent_id)) { - if (is_int($parent_id)) { - $where[] = "parent_id=?"; - $args[] = $parent_id; - } else { - $where[] = "parent_id IN (".implode(", ", $parent_id).")"; - } - } - $sql = "SELECT * FROM wff_collection"; - if (!empty($where)) - $sql .= " WHERE ".implode(" AND ", $where); - $sql .= " ORDER BY title"; - $q = $db->query($sql, ...$args); - - return array_map('WFFCollectionItem::create_instance', $db->fetchAll($q)); -} - -/** - * @param int[] $ids - * @return WFFCollectionItem[] - */ -function wff_get_by_id(array $ids): array { - $db = DB(); - $q = $db->query("SELECT * FROM wff_collection WHERE id IN (".implode(',', $ids).")"); - return array_map('WFFCollectionItem::create_instance', $db->fetchAll($q)); -} - -function wff_search(string $q, int $offset = 0, int $count = 0): array { - return _search(WFF_ARCHIVE_SPHINX_RTINDEX, $q, $offset, $count, - items_getter: function($matches) { - return wff_get_by_id(array_keys($matches)); - }, - sphinx_client_setup: function(SphinxClient $cl) { - $cl->setFieldWeights([ - 'title' => 50, - 'document_id' => 60, - ]); - $cl->setRankingMode(Sphinx\SphinxClient::SPH_RANK_PROXIMITY_BM25); - $cl->setSortMode(Sphinx\SphinxClient::SPH_SORT_EXTENDED, '@relevance DESC, is_folder DESC'); - } - ); -} - -function wff_reindex(): void { - sphinx_execute("TRUNCATE RTINDEX ".WFF_ARCHIVE_SPHINX_RTINDEX); - $db = DB(); - $q = $db->query("SELECT * FROM wff_collection"); - while ($row = $db->fetch($q)) { - $item = new WFFCollectionItem($row); - $text = ''; - if ($item->isFile()) { - $text_q = $db->query("SELECT text FROM wff_texts WHERE wff_id=?", $item->id); - if ($db->numRows($text_q)) - $text = $db->result($text_q); - } - sphinx_execute("INSERT INTO ".WFF_ARCHIVE_SPHINX_RTINDEX." (id, document_id, title, text, is_folder, parent_id) VALUES (?, ?, ?, ?, ?, ?)", - $item->id, $item->getDocumentId(), $item->title, $text, (int)$item->isFolder(), $item->parentId); - } -} - -function wff_get_text_excerpts(array $ids, array $keywords, int $before = 50, int $after = 40): array { - return _get_text_excerpts('wff_texts', 'wff_id', $ids, $keywords, $before, $after); -} - - -// --------------------------------------------------------- -// ------------------- Mercure de France ------------------- -// --------------------------------------------------------- - -class MDFCollectionItem extends model implements FilesItemInterface { - - const DB_TABLE = 'mdf_collection'; - - use FilesItemTypeTrait; - use FilesItemSizeTrait; - - public int $id; - public int $issue; - public string $path; - public string $date; - public int $volume; - public int $pageFrom; - public int $pageTo; - public int $pdfPages; - public int $size; - - public function isAvailable(): bool { return true; } - - public function getTitleHtml(): ?string { return null; } - - public function getTitle(): string { - return "№{$this->issue}, {$this->getHumanFriendlyDate()}"; - } - - public function getHumanFriendlyDate(): string { - $dt = new DateTime($this->date); - return $dt->format('j M Y'); - } - - public function isTargetBlank(): bool { return true; } - public function getId(): string { return $this->id; } - public function getUrl(): string { - global $config; - return 'https://'.$config['files_domain'].'/Mercure-de-France-OCR/'.$this->path; - } - - public function getMeta(?string $hl_matched = null): array { - return [ - 'inline' => true, - 'items' => [ - 'Vol. '.$this->getRomanVolume(), - 'pp. '.$this->pageFrom.'-'.$this->pageTo, - sizeString($this->size), - 'PDF' - ] - ]; - } - - public function getRomanVolume(): string { - return arabic_to_roman($this->volume); - } - - public function getSubtitle(): ?string { - return null; - //return 'Vol. '.$this->getRomanVolume().', pp. '.$this->pageFrom.'-'.$this->pageTo; - } -} - -/** - * @return MDFCollectionItem[] - */ -function mdf_get(): array { - $db = DB(); - $q = $db->query("SELECT * FROM mdf_collection ORDER BY `date`"); - return array_map('MDFCollectionItem::create_instance', $db->fetchAll($q)); -} - -/** - * @param int[] $ids - * @return MDFCollectionItem[] - */ -function mdf_get_by_id(array $ids): array { - $db = DB(); - $q = $db->query("SELECT * FROM mdf_collection WHERE id IN (".implode(',', $ids).")"); - return array_map('MDFCollectionItem::create_instance', $db->fetchAll($q)); -} - -function mdf_search(string $q, int $offset = 0, int $count = 0): array { - return _search(MDF_ARCHIVE_SPHINX_RTINDEX, $q, $offset, $count, - items_getter: function($matches) { - return mdf_get_by_id(array_keys($matches)); - }, - sphinx_client_setup: function(SphinxClient $cl) { - $cl->setFieldWeights([ - 'date' => 10, - 'issue' => 9, - 'text' => 8 - ]); + if (is_callable($sphinx_client_setup)) + $sphinx_client_setup($cl); + else { $cl->setRankingMode(Sphinx\SphinxClient::SPH_RANK_PROXIMITY_BM25); $cl->setSortMode(Sphinx\SphinxClient::SPH_SORT_RELEVANCE); } - ); -} -function mdf_reindex(): void { - sphinx_execute("TRUNCATE RTINDEX ".MDF_ARCHIVE_SPHINX_RTINDEX); - $db = DB(); - $mdf = mdf_get(); - foreach ($mdf as $item) { - $text = $db->result($db->query("SELECT text FROM mdf_texts WHERE mdf_id=?", $item->id)); - sphinx_execute("INSERT INTO ".MDF_ARCHIVE_SPHINX_RTINDEX." (id, volume, issue, date, text) VALUES (?, ?, ?, ?, ?)", - $item->id, $item->volume, (string)$item->issue, $item->getHumanFriendlyDate(), $text); - } -} + // run search + $final_query = "$query_filtered"; + $result = $cl->query($final_query, $index); + $error = $cl->getLastError(); + $warning = $cl->getLastWarning(); + if ($error) + logError(__FUNCTION__, $error); + if ($warning) + logWarning(__FUNCTION__, $warning); + if ($result === false) + return ['count' => 0, 'items' => []]; -function mdf_get_text_excerpts(array $ids, array $keywords, int $before = 50, int $after = 40): array { - return _get_text_excerpts('mdf_texts', 'mdf_id', $ids, $keywords, $before, $after); -} + $total_found = (int)$result['total_found']; - -// ------------------------------------------------- -// ------------------- Baconiana ------------------- -// ------------------------------------------------- - -class BaconianaCollectionItem extends model implements FilesItemInterface { - - const DB_TABLE = 'baconiana_collection'; - - use FilesItemTypeTrait; - use FilesItemSizeTrait; - - public int $id; - public int $parentId; - public int $year; - public string $issues; - public string $path; - public bool $jobc; // Journal of the Bacon Society - public string $title; // Only for folders - - public function isAvailable(): bool { return true; } - - public function getTitleHtml(): ?string { return null; } - - public function getTitle(): string { - if ($this->title !== '') - return $this->title; - - return ($this->jobc ? lang('baconiana_old_name') : lang('baconiana')).' №'.$this->issues; - } - - public function isTargetBlank(): bool { return $this->isFile(); } - public function getId(): string { return $this->id; } - - public function getUrl(): string { - if ($this->isFolder()) { - return '/files/'.FilesCollection::Baconiana->value.'/'.$this->id.'/'; - } - global $config; - return 'https://'.$config['files_domain'].'/'.$this->path; - } - - public function getMeta(?string $hl_matched = null): array { $items = []; - if ($this->isFolder()) + if (!empty($result['matches'])) + $items = $items_getter($result['matches']); + + return ['count' => $total_found, 'items' => $items]; + } + + + /** + * @param int $folder_id + * @param bool $with_parents + * @return WFFCollectionItem|WFFCollectionItem[]|null + */ + public static function wff_get_folder(int $folder_id, bool $with_parents = false): WFFCollectionItem|array|null { + $db = DB(); + $q = $db->query("SELECT * FROM wff_collection WHERE id=?", $folder_id); + if (!$db->numRows($q)) + return null; + $item = new WFFCollectionItem($db->fetch($q)); + if (!$item->isFolder()) + return null; + if ($with_parents) { + $items = [$item]; + if ($item->parentId) { + $parents = self::wff_get_folder($item->parentId, true); + if ($parents !== null) + $items = array_merge($items, $parents); + } return $items; - - if ($this->year >= 2007) - $items = array_merge($items, ['Online Edition']); - - $items = array_merge($items, [ - sizeString($this->size), - 'PDF' - ]); - - return [ - 'inline' => false, - 'items' => $items - ]; - } - - public function getSubtitle(): ?string { - return $this->year > 0 ? '('.$this->year.')' : null; - } -} - -/** - * @return BaconianaCollectionItem[] - */ -function baconiana_get(?int $parent_id = 0): array { - $db = DB(); - $sql = "SELECT * FROM baconiana_collection"; - if ($parent_id !== null) - $sql .= " WHERE parent_id='".$db->escape($parent_id)."'"; - $sql .= " ORDER BY type, year, id"; - $q = $db->query($sql); - return array_map('BaconianaCollectionItem::create_instance', $db->fetchAll($q)); -} - -/** - * @param int[] $ids - * @return BaconianaCollectionItem[] - */ -function baconiana_get_by_id(array $ids): array { - $db = DB(); - $q = $db->query("SELECT * FROM baconiana_collection WHERE id IN (".implode(',', $ids).")"); - return array_map('BaconianaCollectionItem::create_instance', $db->fetchAll($q)); -} - -/** - * @param int $folder_id - * @param bool $with_parents - * @return BaconianaCollectionItem|BaconianaCollectionItem[]|null - */ -function baconiana_get_folder(int $folder_id, bool $with_parents = false): WFFCollectionItem|array|null { - $db = DB(); - $q = $db->query("SELECT * FROM baconiana_collection WHERE id=?", $folder_id); - if (!$db->numRows($q)) - return null; - $item = new BaconianaCollectionItem($db->fetch($q)); - if (!$item->isFolder()) - return null; - if ($with_parents) { - $items = [$item]; - if ($item->parentId) { - $parents = baconiana_get_folder($item->parentId, true); - if ($parents !== null) - $items = array_merge($items, $parents); } - return $items; + return $item; } - return $item; -} -function baconiana_search(string $q, int $offset = 0, int $count = 0): array { - return _search(BACONIANA_ARCHIVE_SPHINX_RTINDEX, $q, $offset, $count, - items_getter: function($matches) { - return baconiana_get_by_id(array_keys($matches)); - }, - sphinx_client_setup: function(SphinxClient $cl) { - $cl->setFieldWeights([ - 'year' => 10, - 'issues' => 9, - 'text' => 8 - ]); - $cl->setRankingMode(Sphinx\SphinxClient::SPH_RANK_PROXIMITY_BM25); - $cl->setSortMode(Sphinx\SphinxClient::SPH_SORT_RELEVANCE); + /** + * @param int|int[]|null $parent_id + * @return array + */ + public static function wff_get(int|array|null $parent_id = null) { + $db = DB(); + + $where = []; + $args = []; + + if (!is_null($parent_id)) { + if (is_int($parent_id)) { + $where[] = "parent_id=?"; + $args[] = $parent_id; + } else { + $where[] = "parent_id IN (".implode(", ", $parent_id).")"; + } } - ); -} + $sql = "SELECT * FROM wff_collection"; + if (!empty($where)) + $sql .= " WHERE ".implode(" AND ", $where); + $sql .= " ORDER BY title"; + $q = $db->query($sql, ...$args); -function baconiana_reindex(): void { - sphinx_execute("TRUNCATE RTINDEX ".BACONIANA_ARCHIVE_SPHINX_RTINDEX); - $db = DB(); - $baconiana = baconiana_get(null); - foreach ($baconiana as $item) { - $text_q = $db->query("SELECT text FROM baconiana_texts WHERE bcn_id=?", $item->id); - if (!$db->numRows($text_q)) - continue; - $text = $db->result($text_q); - sphinx_execute("INSERT INTO ".BACONIANA_ARCHIVE_SPHINX_RTINDEX." (id, title, year, text) VALUES (?, ?, ?, ?)", - $item->id, "$item->year ($item->issues)", $item->year, $text); - } -} - -function baconiana_get_text_excerpts(array $ids, array $keywords, int $before = 50, int $after = 40): array { - return _get_text_excerpts('baconiana_texts', 'bcn_id', $ids, $keywords, $before, $after); -} - - -// ---------------------------------------------------------- -// ------------------- Books and Articles ------------------- -// ---------------------------------------------------------- - -enum BookFileType: string { - case NONE = 'none'; - case BOOK = 'book'; - case ARTICLE = 'article'; -} - -enum BookCategory: string { - case BOOKS = 'books'; - case MISC = 'misc'; -} - -class BookItem extends model implements FilesItemInterface { - - const DB_TABLE = 'books'; - - public int $id; - public int $parentId; - public string $author; - public string $title; - public string $subtitle; - public int $year; - public int $size; - public FilesItemType $type; - public BookFileType $fileType; - public string $path; - public bool $external; - public BookCategory $category; - - use FilesItemSizeTrait; - use FilesItemTypeTrait; - - public function getId(): string { - return $this->id; + return array_map('WFFCollectionItem::create_instance', $db->fetchAll($q)); } - public function getUrl(): string { - if ($this->isFolder() && !$this->external) - return '/files/'.$this->id.'/'; - global $config; - $buf = 'https://'.$config['files_domain']; - if (!str_starts_with($this->path, '/')) - $buf .= '/'; - $buf .= $this->path; - return $buf; + /** + * @param int[] $ids + * @return WFFCollectionItem[] + */ + public static function wff_get_by_id(array $ids): array { + $db = DB(); + $q = $db->query("SELECT * FROM wff_collection WHERE id IN (".implode(',', $ids).")"); + return array_map('WFFCollectionItem::create_instance', $db->fetchAll($q)); } - public function getTitleHtml(): ?string { - if ($this->isFolder() || !$this->author) + public static function wff_search(string $q, int $offset = 0, int $count = 0): array { + return self::_search(self::WFF_ARCHIVE_SPHINX_RTINDEX, $q, $offset, $count, + items_getter: function($matches) { + return self::wff_get_by_id(array_keys($matches)); + }, + sphinx_client_setup: function(SphinxClient $cl) { + $cl->setFieldWeights([ + 'title' => 50, + 'document_id' => 60, + ]); + $cl->setRankingMode(Sphinx\SphinxClient::SPH_RANK_PROXIMITY_BM25); + $cl->setSortMode(Sphinx\SphinxClient::SPH_SORT_EXTENDED, '@relevance DESC, is_folder DESC'); + } + ); + } + + public static function wff_reindex(): void { + sphinx::execute("TRUNCATE RTINDEX ".self::WFF_ARCHIVE_SPHINX_RTINDEX); + $db = DB(); + $q = $db->query("SELECT * FROM wff_collection"); + while ($row = $db->fetch($q)) { + $item = new WFFCollectionItem($row); + $text = ''; + if ($item->isFile()) { + $text_q = $db->query("SELECT text FROM wff_texts WHERE wff_id=?", $item->id); + if ($db->numRows($text_q)) + $text = $db->result($text_q); + } + sphinx::execute("INSERT INTO ".self::WFF_ARCHIVE_SPHINX_RTINDEX." (id, document_id, title, text, is_folder, parent_id) VALUES (?, ?, ?, ?, ?, ?)", + $item->id, $item->getDocumentId(), $item->title, $text, (int)$item->isFolder(), $item->parentId); + } + } + + public static function wff_get_text_excerpts(array $ids, array $keywords, int $before = 50, int $after = 40): array { + return self::_get_text_excerpts('wff_texts', 'wff_id', $ids, $keywords, $before, $after); + } + + /** + * @return MDFCollectionItem[] + */ + public static function mdf_get(): array { + $db = DB(); + $q = $db->query("SELECT * FROM mdf_collection ORDER BY `date`"); + return array_map('MDFCollectionItem::create_instance', $db->fetchAll($q)); + } + + /** + * @param int[] $ids + * @return MDFCollectionItem[] + */ + public static function mdf_get_by_id(array $ids): array { + $db = DB(); + $q = $db->query("SELECT * FROM mdf_collection WHERE id IN (".implode(',', $ids).")"); + return array_map('MDFCollectionItem::create_instance', $db->fetchAll($q)); + } + + public static function mdf_search(string $q, int $offset = 0, int $count = 0): array { + return self::_search(self::MDF_ARCHIVE_SPHINX_RTINDEX, $q, $offset, $count, + items_getter: function($matches) { + return self::mdf_get_by_id(array_keys($matches)); + }, + sphinx_client_setup: function(SphinxClient $cl) { + $cl->setFieldWeights([ + 'date' => 10, + 'issue' => 9, + 'text' => 8 + ]); + $cl->setRankingMode(Sphinx\SphinxClient::SPH_RANK_PROXIMITY_BM25); + $cl->setSortMode(Sphinx\SphinxClient::SPH_SORT_RELEVANCE); + } + ); + } + + public static function mdf_reindex(): void { + sphinx::execute("TRUNCATE RTINDEX ".self::MDF_ARCHIVE_SPHINX_RTINDEX); + $db = DB(); + $mdf = self::mdf_get(); + foreach ($mdf as $item) { + $text = $db->result($db->query("SELECT text FROM mdf_texts WHERE mdf_id=?", $item->id)); + sphinx::execute("INSERT INTO ".self::MDF_ARCHIVE_SPHINX_RTINDEX." (id, volume, issue, date, text) VALUES (?, ?, ?, ?, ?)", + $item->id, $item->volume, (string)$item->issue, $item->getHumanFriendlyDate(), $text); + } + } + + public static function mdf_get_text_excerpts(array $ids, array $keywords, int $before = 50, int $after = 40): array { + return self::_get_text_excerpts('mdf_texts', 'mdf_id', $ids, $keywords, $before, $after); + } + + + /** + * @return BaconianaCollectionItem[] + */ + public static function baconiana_get(?int $parent_id = 0): array { + $db = DB(); + $sql = "SELECT * FROM baconiana_collection"; + if ($parent_id !== null) + $sql .= " WHERE parent_id='".$db->escape($parent_id)."'"; + $sql .= " ORDER BY type, year, id"; + $q = $db->query($sql); + return array_map('BaconianaCollectionItem::create_instance', $db->fetchAll($q)); + } + + /** + * @param int[] $ids + * @return BaconianaCollectionItem[] + */ + public static function baconiana_get_by_id(array $ids): array { + $db = DB(); + $q = $db->query("SELECT * FROM baconiana_collection WHERE id IN (".implode(',', $ids).")"); + return array_map('BaconianaCollectionItem::create_instance', $db->fetchAll($q)); + } + + /** + * @param int $folder_id + * @param bool $with_parents + * @return BaconianaCollectionItem|BaconianaCollectionItem[]|null + */ + public static function baconiana_get_folder(int $folder_id, bool $with_parents = false): WFFCollectionItem|array|null { + $db = DB(); + $q = $db->query("SELECT * FROM baconiana_collection WHERE id=?", $folder_id); + if (!$db->numRows($q)) return null; - $buf = ''.htmlescape($this->author).''; - if (!str_ends_with($this->author, '.')) - $buf .= '.'; - $buf .= ' '.htmlescape($this->title).''; - return $buf; - } - - public function getTitle(): string { - return $this->title; - } - - public function getMeta(?string $hl_matched = null): array { - if ($this->isFolder()) - return []; - - $items = [ - sizeString($this->size), - strtoupper($this->getExtension()) - ]; - - return [ - 'inline' => false, - 'items' => $items - ]; - } - - protected function getExtension(): string { - return extension(basename($this->path)); - } - - public function isAvailable(): bool { - return true; - } - - public function isTargetBlank(): bool { - return $this->isFile() || $this->external; - } - - public function getSubtitle(): ?string { - if (!$this->year && !$this->subtitle) + $item = new BaconianaCollectionItem($db->fetch($q)); + if (!$item->isFolder()) return null; - $buf = '('; - $buf .= $this->subtitle ?: $this->year; - $buf .= ')'; - return $buf; - } -} - -/** - * @return BookItem[] - */ -function books_get(int $parent_id = 0, BookCategory $category = BookCategory::BOOKS): array { - $db = DB(); - - if ($category == BookCategory::BOOKS) { - $order_by = "type, ".($parent_id != 0 ? 'year, ': '')."author, title"; - } - else - $order_by = "type, title"; - - $q = $db->query("SELECT * FROM books WHERE category=? AND parent_id=? ORDER BY $order_by", - $category->value, $parent_id); - return array_map('BookItem::create_instance', $db->fetchAll($q)); -} - -function books_get_folder(int $id, bool $with_parents = false): BookItem|array|null { - $db = DB(); - $q = $db->query("SELECT * FROM books WHERE id=?", $id); - if (!$db->numRows($q)) - return null; - $item = new BookItem($db->fetch($q)); - if (!$item->isFolder()) - return null; - if ($with_parents) { - $items = [$item]; - if ($item->parentId) { - $parents = books_get_folder($item->parentId, true); - if ($parents !== null) - $items = array_merge($items, $parents); + if ($with_parents) { + $items = [$item]; + if ($item->parentId) { + $parents = self::baconiana_get_folder($item->parentId, true); + if ($parents !== null) + $items = array_merge($items, $parents); + } + return $items; } - return $items; + return $item; } - return $item; + + public static function baconiana_search(string $q, int $offset = 0, int $count = 0): array { + return self::_search(self::BACONIANA_ARCHIVE_SPHINX_RTINDEX, $q, $offset, $count, + items_getter: function($matches) { + return self::baconiana_get_by_id(array_keys($matches)); + }, + sphinx_client_setup: function(SphinxClient $cl) { + $cl->setFieldWeights([ + 'year' => 10, + 'issues' => 9, + 'text' => 8 + ]); + $cl->setRankingMode(Sphinx\SphinxClient::SPH_RANK_PROXIMITY_BM25); + $cl->setSortMode(Sphinx\SphinxClient::SPH_SORT_RELEVANCE); + } + ); + } + + public static function baconiana_reindex(): void { + sphinx::execute("TRUNCATE RTINDEX ".self::BACONIANA_ARCHIVE_SPHINX_RTINDEX); + $db = DB(); + $baconiana = self::baconiana_get(null); + foreach ($baconiana as $item) { + $text_q = $db->query("SELECT text FROM baconiana_texts WHERE bcn_id=?", $item->id); + if (!$db->numRows($text_q)) + continue; + $text = $db->result($text_q); + sphinx::execute("INSERT INTO ".self::BACONIANA_ARCHIVE_SPHINX_RTINDEX." (id, title, year, text) VALUES (?, ?, ?, ?)", + $item->id, "$item->year ($item->issues)", $item->year, $text); + } + } + + public static function baconiana_get_text_excerpts(array $ids, array $keywords, int $before = 50, int $after = 40): array { + return self::_get_text_excerpts('baconiana_texts', 'bcn_id', $ids, $keywords, $before, $after); + } + + + /** + * @return BookItem[] + */ + public static function books_get(int $parent_id = 0, BookCategory $category = BookCategory::BOOKS): array { + $db = DB(); + + if ($category == BookCategory::BOOKS) { + $order_by = "type, ".($parent_id != 0 ? 'year, ': '')."author, title"; + } + else + $order_by = "type, title"; + + $q = $db->query("SELECT * FROM books WHERE category=? AND parent_id=? ORDER BY $order_by", + $category->value, $parent_id); + return array_map('BookItem::create_instance', $db->fetchAll($q)); + } + + public static function books_get_folder(int $id, bool $with_parents = false): BookItem|array|null { + $db = DB(); + $q = $db->query("SELECT * FROM books WHERE id=?", $id); + if (!$db->numRows($q)) + return null; + $item = new BookItem($db->fetch($q)); + if (!$item->isFolder()) + return null; + if ($with_parents) { + $items = [$item]; + if ($item->parentId) { + $parents = self::books_get_folder($item->parentId, true); + if ($parents !== null) + $items = array_merge($items, $parents); + } + return $items; + } + return $item; + } + } diff --git a/lib/markup.php b/lib/markup.php index 8bb63ab..29b73dd 100644 --- a/lib/markup.php +++ b/lib/markup.php @@ -1,8 +1,5 @@ (\[([io]?\d{1,2})]) (.*?)<\/p>/ms'; $result = preg_match_all($re, $html, $matches); - if (pcre_no_error($result)) { + if (pcreNoError($result)) { $reftitles_map = []; foreach ($matches[2] as $i => $refname) { $reftitles_map[$refname] = trim(htmlspecialchars_decode(strip_tags($matches[3][$i]))); diff --git a/lib/pages.php b/lib/pages.php index ad24cb4..1ea6be1 100644 --- a/lib/pages.php +++ b/lib/pages.php @@ -1,56 +1,8 @@ md || $fields['render_title'] != $this->renderTitle || $fields['title'] != $this->title) { - $md = $fields['md']; - if ($fields['render_title']) - $md = '# '.$fields['title']."\n\n".$md; - $fields['html'] = markup::markdownToHtml($md); - } - parent::edit($fields); - } - - function isUpdated(): bool { - return $this->updateTs && $this->updateTs != $this->ts; - } - - function getHtml(bool $is_retina, string $user_theme): string { - $html = $this->html; - $html = markup::htmlImagesFix($html, $is_retina, $user_theme); - return $html; - } - - function getUrl(): string { - return "/{$this->shortName}/"; - } - - function updateHtml(): void { - $html = markup::markdownToHtml($this->md); - $this->html = $html; - DB()->query("UPDATE pages SET html=? WHERE short_name=?", $html, $this->shortName); - } - -} - class pages { - static function add(array $data): bool { + public static function add(array $data): bool { $db = DB(); $data['ts'] = time(); $data['html'] = markup::markdownToHtml($data['md']); @@ -59,18 +11,18 @@ class pages { return true; } - static function delete(Page $page): void { + public static function delete(Page $page): void { DB()->query("DELETE FROM pages WHERE short_name=?", $page->shortName); previous_texts::delete(PreviousText::TYPE_PAGE, $page->get_id()); } - static function getById(int $id): ?Page { + public static function getById(int $id): ?Page { $db = DB(); $q = $db->query("SELECT * FROM pages WHERE id=?", $id); return $db->numRows($q) ? new Page($db->fetch($q)) : null; } - static function getByName(string $short_name): ?Page { + public static function getByName(string $short_name): ?Page { $db = DB(); $q = $db->query("SELECT * FROM pages WHERE short_name=?", $short_name); return $db->numRows($q) ? new Page($db->fetch($q)) : null; @@ -79,7 +31,7 @@ class pages { /** * @return Page[] */ - static function getAll(): array { + public static function getAll(): array { $db = DB(); return array_map('Page::create_instance', $db->fetchAll($db->query("SELECT * FROM pages"))); } diff --git a/lib/posts.php b/lib/posts.php index 563dc5c..f95f865 100644 --- a/lib/posts.php +++ b/lib/posts.php @@ -1,254 +1,8 @@ value, self::cases(), true); - } -} - -class Post extends model { - - const DB_TABLE = 'posts'; - - public int $id; - public string $date; - public ?string $updateTime; - public bool $visible; - public string $shortName; - public string $sourceUrl; - - protected array $texts = []; - - public function edit(array $fields) { - $fields['update_time'] = date(mysql::DATETIME_FORMAT, time()); - parent::edit($fields); - } - - public function addText(PostLanguage $lang, string $title, string $md, string $keywords, bool $toc): ?PostText { - $html = markup::markdownToHtml($md, lang: $lang); - $text = markup::htmlToText($html); - - $data = [ - 'title' => $title, - 'lang' => $lang->value, - 'post_id' => $this->id, - 'html' => $html, - 'text' => $text, - 'md' => $md, - 'toc' => $toc, - 'keywords' => $keywords, - ]; - - $db = DB(); - if (!$db->insert('posts_texts', $data)) - return null; - - $id = $db->insertId(); - - $post_text = posts::getText($id); - $post_text->updateImagePreviews(); - - return $post_text; - } - - public function registerText(PostText $postText): void { - if (array_key_exists($postText->lang->value, $this->texts)) - throw new Exception("text for language {$postText->lang->value} has already been registered"); - $this->texts[$postText->lang->value] = $postText; - } - - public function loadTexts() { - if (!empty($this->texts)) - return; - $db = DB(); - $q = $db->query("SELECT * FROM posts_texts WHERE post_id=?", $this->id); - while ($row = $db->fetch($q)) { - $text = new PostText($row); - $this->registerText($text); - } - } - - /** - * @return PostText[] - */ - public function getTexts(): array { - $this->loadTexts(); - return $this->texts; - } - - public function getText(PostLanguage $lang): ?PostText { - $this->loadTexts(); - return $this->texts[$lang->value] ?? null; - } - - public function hasLang(PostLanguage $lang) { - $this->loadTexts(); - foreach ($this->texts as $text) { - if ($text->lang == $lang) - return true; - } - return false; - } - - public function getUrl(?PostLanguage $lang = null): string { - $buf = $this->shortName != '' ? "/articles/{$this->shortName}/" : "/articles/{$this->id}/"; - if ($lang && $lang != PostLanguage::English) - $buf .= '?lang='.$lang->value; - return $buf; - } - - public function getTimestamp(): int { - return (new DateTime($this->date))->getTimestamp(); - } - - public function getUpdateTimestamp(): ?int { - if (!$this->updateTime) - return null; - return (new DateTime($this->updateTime))->getTimestamp(); - } - - public function getDate(): string { - return date('j M', $this->getTimestamp()); - } - - public function getYear(): int { - return (int)date('Y', $this->getTimestamp()); - } - - public function getFullDate(): string { - return date('j F Y', $this->getTimestamp()); - } - - public function getDateForInputField(): string { - return date('Y-m-d', $this->getTimestamp()); - } -} - -class PostText extends model { - const DB_TABLE = 'posts_texts'; - - public int $id; - public int $postId; - public PostLanguage $lang; - public string $title; - public string $md; - public string $html; - public string $text; - public bool $toc; - public string $tocHtml; - public string $keywords; - - public function edit(array $fields) { - if ($fields['md'] != $this->md) { - $fields['html'] = markup::markdownToHtml($fields['md'], lang: $this->lang); - $fields['text'] = markup::htmlToText($fields['html']); - } - - if ((isset($fields['toc']) && $fields['toc']) || $this->toc) { - $fields['toc_html'] = markup::toc($fields['md']); - } - - parent::edit($fields); - $this->updateImagePreviews(); - } - - public function updateHtml(): void { - $html = markup::markdownToHtml($this->md, lang: $this->lang); - $this->html = $html; - DB()->query("UPDATE posts_texts SET html=? WHERE id=?", $html, $this->id); - } - - public function updateText(): void { - $html = markup::markdownToHtml($this->md, lang: $this->lang); - $text = markup::htmlToText($html); - $this->text = $text; - DB()->query("UPDATE posts_texts SET text=? WHERE id=?", $text, $this->id); - } - - public function getDescriptionPreview(int $len): string { - if (mb_strlen($this->text) >= $len) - return mb_substr($this->text, 0, $len-3).'...'; - return $this->text; - } - - public function getFirstImage(): ?Upload { - if (!preg_match('/\{image:([\w]{8})/', $this->md, $match)) - return null; - return uploads::getUploadByRandomId($match[1]); - } - - public function getHtml(bool $is_retina, string $theme): string { - $html = $this->html; - return markup::htmlImagesFix($html, $is_retina, $theme); - } - - public function getTableOfContentsHtml(): ?string { - return $this->toc ? $this->tocHtml : null; - } - - public function hasTableOfContents(): bool { - return $this->toc; - } - - /** - * @param bool $update Whether to overwrite preview if already exists - * @return int - * @throws Exception - */ - public function updateImagePreviews(bool $update = false): int { - $images = []; - if (!preg_match_all('/\{image:([\w]{8}),(.*?)}/', $this->md, $matches)) - return 0; - - for ($i = 0; $i < count($matches[0]); $i++) { - $id = $matches[1][$i]; - $w = $h = null; - $opts = explode(',', $matches[2][$i]); - foreach ($opts as $opt) { - if (str_contains($opt, '=')) { - list($k, $v) = explode('=', $opt); - if ($k == 'w') - $w = (int)$v; - else if ($k == 'h') - $h = (int)$v; - } - } - $images[$id][] = [$w, $h]; - } - - if (empty($images)) - return 0; - - $images_affected = 0; - $uploads = uploads::getUploadsByRandomId(array_keys($images), true); - foreach ($uploads as $upload_key => $u) { - if ($u === null) { - logError(__METHOD__.': upload '.$upload_key.' is null'); - continue; - } - foreach ($images[$u->randomId] as $s) { - list($w, $h) = $s; - list($w, $h) = $u->getImagePreviewSize($w, $h); - if ($u->createImagePreview($w, $h, $update, $u->imageMayHaveAlphaChannel())) - $images_affected++; - } - } - - return $images_affected; - } - -} - class posts { - static function getCount(bool $include_hidden = false): int { + public static function getCount(bool $include_hidden = false): int { $db = DB(); $sql = "SELECT COUNT(*) FROM posts"; if (!$include_hidden) { @@ -260,7 +14,7 @@ class posts { /** * @return Post[] */ - static function getList(int $offset = 0, + public static function getList(int $offset = 0, int $count = -1, bool $include_hidden = false, ?PostLanguage $filter_by_lang = null @@ -293,14 +47,14 @@ class posts { return array_values($posts); } - static function add(array $data = []): ?Post { + public static function add(array $data = []): ?Post { $db = DB(); if (!$db->insert('posts', $data)) return null; return self::get($db->insertId()); } - static function delete(Post $post): void { + public static function delete(Post $post): void { $db = DB(); $db->query("DELETE FROM posts WHERE id=?", $post->id); @@ -313,25 +67,25 @@ class posts { $db->query("DELETE FROM posts_texts WHERE post_id=?", $post->id); } - static function get(int $id): ?Post { + public static function get(int $id): ?Post { $db = DB(); $q = $db->query("SELECT * FROM posts WHERE id=?", $id); return $db->numRows($q) ? new Post($db->fetch($q)) : null; } - static function getText(int $text_id): ?PostText { + public static function getText(int $text_id): ?PostText { $db = DB(); $q = $db->query("SELECT * FROM posts_texts WHERE id=?", $text_id); return $db->numRows($q) ? new PostText($db->fetch($q)) : null; } - static function getByName(string $short_name): ?Post { + public static function getByName(string $short_name): ?Post { $db = DB(); $q = $db->query("SELECT * FROM posts WHERE short_name=?", $short_name); return $db->numRows($q) ? new Post($db->fetch($q)) : null; } - static function getPostsById(array $ids, bool $flat = false): array { + public static function getPostsById(array $ids, bool $flat = false): array { if (empty($ids)) { return []; } @@ -357,7 +111,7 @@ class posts { return $posts; } - static function getPostTextsById(array $ids, bool $flat = false): array { + public static function getPostTextsById(array $ids, bool $flat = false): array { if (empty($ids)) { return []; } @@ -387,7 +141,7 @@ class posts { * @param Upload $upload * @return PostText[] Array of PostTexts that includes specified upload */ - static function getTextsWithUpload(Upload $upload): array { + public static function getTextsWithUpload(Upload $upload): array { $db = DB(); $q = $db->query("SELECT id FROM posts_texts WHERE md LIKE '%{image:{$upload->randomId}%'"); $ids = []; diff --git a/lib/previous_texts.php b/lib/previous_texts.php index 3eb4014..c4f22c8 100644 --- a/lib/previous_texts.php +++ b/lib/previous_texts.php @@ -1,23 +1,8 @@ insert(PreviousText::DB_TABLE, [ 'object_type' => $object_type, @@ -27,7 +12,7 @@ class previous_texts { ]); } - static function delete(int $object_type, int|array $object_id): void { + public static function delete(int $object_type, int|array $object_id): void { $sql = "DELETE FROM ".PreviousText::DB_TABLE." WHERE object_type=? AND object_id"; $args = [$object_type]; if (is_array($object_id)) diff --git a/lib/sphinx.php b/lib/sphinx.php new file mode 100644 index 0000000..5be2d48 --- /dev/null +++ b/lib/sphinx.php @@ -0,0 +1,109 @@ + 1) { + $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 = func_get_arg($i + 1); + if (is_string($arg)) + $arg = self::normalize($arg); + $v = '\''.$link->real_escape_string($arg).'\''; + $sql = substr_replace($sql, $v, $positions[$i], 1); + } + } + + $q = $link->query($sql); + + $error = self::getError(); + if ($error) + logError(__FUNCTION__, $error); + + return $q; + } + + public static function getError() { + $link = self::getLink(auto_create: false); + return $link?->error; + } + + public static function mkquery($q, array $opts = []) { + $defaults = [ + 'any_word' => false, + 'star' => false, + 'and' => false, + 'exact_first' => false + ]; + $opts = array_merge($defaults, $opts); + $q = preg_replace('/\s+/', ' ', $q); + $q = self::normalize($q); + $q = trim($q); + $q = self::getClient()->escapeString($q); + if ($opts['star']) { + $words = explode(' ', $q); + $words = array_map(fn($word) => $word.'*', $words); + $q = implode(' ', $words); + } + if ($opts['any_word']) { + $q = str_replace(' ', ' | ', $q); + } else if ($opts['and']) { + $q = str_replace(' ', ' AND ', $q); + } + if ($opts['exact_first']) { + $q = '"^'.$q.'$" | "'.$q.'" | ('.$q.')'; + } + return $q; + } + + public static function getClient(): Sphinx\SphinxClient { + static $cl = null; + if (!is_null($cl)) + return $cl; + return $cl = new Sphinx\SphinxClient; + } + + protected static function normalize(string $origstr): string { + $buf = preg_replace('/[Ёё]/iu', 'е', $origstr); + if (!pcreNoError($buf, no_error: true)) { + $origstr = mb_convert_encoding($origstr, 'UTF-8', 'UTF-8'); + $buf = preg_replace('/[Ёё]/iu', 'е', $origstr); + pcreNoError($buf); + } + if ($buf === null) { + logError(__METHOD__.': preg_replace() failed with error: '.preg_last_error().': '.preg_last_error_msg()); + $buf = $origstr; + } + return preg_replace('/[!\?]/', '', $buf); + } + + protected static function getLink($auto_create = true) { + global $config; + + /** @var ?mysqli $link */ + static $link = null; + if (!is_null($link) || !$auto_create) + return $link; + + $link = new mysqli(); + $link->real_connect( + $config['sphinx']['host'], + ini_get('mysql.default_user'), + ini_get('mysql.default_password'), + null, + 9306); + $link->set_charset('utf8'); + + return $link; + } + + +} \ No newline at end of file diff --git a/lib/themes.php b/lib/themes.php index dabe7aa..07a025a 100644 --- a/lib/themes.php +++ b/lib/themes.php @@ -1,45 +1,50 @@ [ - 'bg' => 0x222222, - // 'alpha' => 0x303132, - 'alpha' => 0x222222, - ], - 'light' => [ - 'bg' => 0xffffff, - // 'alpha' => 0xf2f2f2, - 'alpha' => 0xffffff, - ] -]; +class themes { + const array COLORS = [ + 'dark' => [ + 'bg' => 0x222222, + // 'alpha' => 0x303132, + 'alpha' => 0x222222, + ], + 'light' => [ + 'bg' => 0xffffff, + // 'alpha' => 0xf2f2f2, + 'alpha' => 0xffffff, + ] + ]; -function getThemes(): array { - return array_keys(THEMES); -} + public static function getThemes(): array { + return array_keys(self::COLORS); + } -function themeExists(string $name): bool { - return array_key_exists($name, THEMES); -} + public static function themeExists(string $name): bool { + return array_key_exists($name, self::COLORS); + } -function getThemeAlphaColorAsRGB(string $name): array { - $color = THEMES[$name]['alpha']; - $r = ($color >> 16) & 0xff; - $g = ($color >> 8) & 0xff; - $b = $color & 0xff; - return [$r, $g, $b]; -} + public static function getThemeAlphaColorAsRGB(string $name): array { + $color = self::COLORS[$name]['alpha']; + $r = ($color >> 16) & 0xff; + $g = ($color >> 8) & 0xff; + $b = $color & 0xff; + return [$r, $g, $b]; + } -function getUserTheme(): string { - if (isset($_COOKIE['theme'])) { - $val = $_COOKIE['theme']; - if (is_array($val)) - $val = implode($val); - } else - $val = 'auto'; - return $val; -} + public static function getUserTheme(): string { + if (isset($_COOKIE['theme'])) { + $val = $_COOKIE['theme']; + if (is_array($val)) + $val = implode($val); + if ($val != 'auto' && !self::themeExists($val)) + $val = 'auto'; + } else + $val = 'auto'; + return $val; + } + + public static function isUserSystemThemeDark(): bool { + return ($_COOKIE['theme-system-value'] ?? '') === 'dark'; + } -function isUserSystemThemeDark(): bool { - return ($_COOKIE['theme-system-value'] ?? '') === 'dark'; } \ No newline at end of file diff --git a/lib/uploads.php b/lib/uploads.php index d698b4b..01fc0a0 100644 --- a/lib/uploads.php +++ b/lib/uploads.php @@ -1,38 +1,36 @@ result($db->query("SELECT COUNT(*) FROM uploads")); } - static function isExtensionAllowed(string $ext): bool { - return in_array($ext, UPLOADS_ALLOWED_EXTENSIONS); + public static function isExtensionAllowed(string $ext): bool { + return in_array($ext, self::ALLOWED_EXTENSIONS); } - static function add(string $tmp_name, + public static function add(string $tmp_name, string $name, string $note_en = '', string $note_ru = '', string $source_url = ''): ?int { global $config; - $name = sanitize_filename($name); + $name = sanitizeFilename($name); if (!$name) $name = 'file'; $random_id = self::_getNewUploadRandomId(); $size = filesize($tmp_name); - $is_image = detect_image_type($tmp_name) !== false; + $is_image = detectImageType($tmp_name) !== false; $image_w = 0; $image_h = 0; if ($is_image) { @@ -70,7 +68,7 @@ class uploads { return $id; } - static function delete(int $id): bool { + public static function delete(int $id): bool { $upload = self::get($id); if (!$upload) return false; @@ -85,13 +83,13 @@ class uploads { /** * @return Upload[] */ - static function getAllUploads(): array { + public static function getAllUploads(): array { $db = DB(); $q = $db->query("SELECT * FROM uploads ORDER BY id DESC"); return array_map('Upload::create_instance', $db->fetchAll($q)); } - static function get(int $id): ?Upload { + public static function get(int $id): ?Upload { $db = DB(); $q = $db->query("SELECT * FROM uploads WHERE id=?", $id); if ($db->numRows($q)) { @@ -106,7 +104,7 @@ class uploads { * @param bool $flat * @return Upload[] */ - static function getUploadsByRandomId(array $ids, bool $flat = false): array { + public static function getUploadsByRandomId(array $ids, bool $flat = false): array { if (empty($ids)) { return []; } @@ -132,7 +130,7 @@ class uploads { return $uploads; } - static function getUploadByRandomId(string $random_id): ?Upload { + public static function getUploadByRandomId(string $random_id): ?Upload { $db = DB(); $q = $db->query("SELECT * FROM uploads WHERE random_id=? LIMIT 1", $random_id); if ($db->numRows($q)) { @@ -142,7 +140,7 @@ class uploads { } } - static function getUploadBySourceUrl(string $source_url): ?Upload { + public static function getUploadBySourceUrl(string $source_url): ?Upload { $db = DB(); $q = $db->query("SELECT * FROM uploads WHERE source_url=? LIMIT 1", $source_url); if ($db->numRows($q)) { @@ -152,7 +150,7 @@ class uploads { } } - static function _getNewUploadRandomId(): string { + public static function _getNewUploadRandomId(): string { $db = DB(); do { $random_id = strgen(8); @@ -161,168 +159,3 @@ class uploads { } } - - -class Upload extends model { - - const DB_TABLE = 'uploads'; - - public static array $ImageExtensions = ['jpg', 'jpeg', 'png', 'gif']; - public static array $VideoExtensions = ['mp4', 'ogg']; - - public int $id; - public string $randomId; - public int $ts; - public string $name; - public int $size; - public int $downloads; - public int $image; // TODO: remove - public int $imageW; - public int $imageH; - public string $noteRu; - public string $noteEn; - public string $sourceUrl; - - function getDirectory(): string { - global $config; - return $config['uploads_dir'].'/'.$this->randomId; - } - - function getDirectUrl(): string { - global $config; - return $config['uploads_path'].'/'.$this->randomId.'/'.$this->name; - } - - function getDirectPreviewUrl(int $w, int $h, bool $retina = false): string { - global $config; - if ($w == $this->imageW && $this->imageH == $h) - return $this->getDirectUrl(); - - if ($retina) { - $w *= 2; - $h *= 2; - } - - $prefix = $this->imageMayHaveAlphaChannel() ? 'a' : 'p'; - return $config['uploads_path'].'/'.$this->randomId.'/'.$prefix.$w.'x'.$h.'.jpg'; - } - - // TODO remove? - function incrementDownloads() { - $db = DB(); - $db->query("UPDATE uploads SET downloads=downloads+1 WHERE id=?", $this->id); - $this->downloads++; - } - - function getSize(): string { - return sizeString($this->size); - } - - function getMarkdown(?string $options = null): string { - if ($this->isImage()) { - $md = '{image:'.$this->randomId.',w='.$this->imageW.',h='.$this->imageH.($options ? ','.$options : '').'}{/image}'; - } else if ($this->isVideo()) { - $md = '{video:'.$this->randomId.($options ? ','.$options : '').'}{/video}'; - } else { - $md = '{fileAttach:'.$this->randomId.($options ? ','.$options : '').'}{/fileAttach}'; - } - $md .= ' '; - return $md; - } - - function setNote(PostLanguage $lang, string $note) { - $db = DB(); - $db->query("UPDATE uploads SET note_{$lang->value}=? WHERE id=?", $note, $this->id); - } - - function isImage(): bool { - return in_array(extension($this->name), self::$ImageExtensions); - } - - // assume all png images have alpha channel - // i know this is wrong, but anyway - function imageMayHaveAlphaChannel(): bool { - return strtolower(extension($this->name)) == 'png'; - } - - function isVideo(): bool { - return in_array(extension($this->name), self::$VideoExtensions); - } - - function getImageRatio(): float { - return $this->imageW / $this->imageH; - } - - function getImagePreviewSize(?int $w = null, ?int $h = null): array { - if (is_null($w) && is_null($h)) - throw new Exception(__METHOD__.': both width and height can\'t be null'); - - if (is_null($h)) - $h = round($w / $this->getImageRatio()); - - if (is_null($w)) - $w = round($h * $this->getImageRatio()); - - return [$w, $h]; - } - - function createImagePreview(?int $w = null, - ?int $h = null, - bool $force_update = false, - bool $may_have_alpha = false): bool { - global $config; - - $orig = $config['uploads_dir'].'/'.$this->randomId.'/'.$this->name; - $updated = false; - - foreach (getThemes() as $theme) { - if (!$may_have_alpha && $theme == 'dark') - continue; - - for ($mult = 1; $mult <= 2; $mult++) { - $dw = $w * $mult; - $dh = $h * $mult; - - $prefix = $may_have_alpha ? 'a' : 'p'; - $dst = $config['uploads_dir'].'/'.$this->randomId.'/'.$prefix.$dw.'x'.$dh.($theme == 'dark' ? '_dark' : '').'.jpg'; - - if (file_exists($dst)) { - if (!$force_update) - continue; - unlink($dst); - } - - $img = imageopen($orig); - imageresize($img, $dw, $dh, getThemeAlphaColorAsRGB($theme)); - imagejpeg($img, $dst, $mult == 1 ? 93 : 67); - imagedestroy($img); - - setperm($dst); - $updated = true; - } - } - - return $updated; - } - - /** - * @return int Number of deleted files - */ - function deleteAllImagePreviews(): int { - global $config; - $dir = $config['uploads_dir'].'/'.$this->randomId; - $files = scandir($dir); - $deleted = 0; - foreach ($files as $f) { - if (preg_match('/^[ap](\d+)x(\d+)(?:_dark)?\.jpg$/', $f)) { - if (is_file($dir.'/'.$f)) - unlink($dir.'/'.$f); - else - logError(__METHOD__.': '.$dir.'/'.$f.' is not a file!'); - $deleted++; - } - } - return $deleted; - } - -} \ No newline at end of file diff --git a/routes.php b/routes.php index c4ae38c..656b5d6 100644 --- a/routes.php +++ b/routes.php @@ -1,5 +1,8 @@ - - -
-
{$ctx->lang('admin_login')}:
-
- -
-
- -
-
{$ctx->lang('admin_password')}:
-
- -
-
- -
-
-
- -
-
- -HTML; - -$js = << - Authorized as {$admin_login} | Sign out
- - Uploads
- {$ctx->lang('admin_errors')}
- {$ctx->lang('admin_auth_log')}
- {$ctx->lang('admin_actions_log')}
- -HTML; -} - - -// uploads page -// ------------ - -function uploads($ctx, $uploads, $error, array $langs) { -return <<if_true($error, $ctx->formError, $error)} - -{$ctx->bc([ - ['text' => $ctx->lang('admin_title'), 'url' => '/admin/'], - ['text' => $ctx->lang('blog_uploads')], -])} - -
-
- - -
-
{$ctx->lang('blog_upload_form_file')}:
-
- -
-
- -
-
{$ctx->lang('blog_upload_form_custom_name')}:
-
- -
-
- - {$ctx->for_each($langs, - fn($l) => $ctx->uploads_form_note_field($l))} - -
-
-
- -
-
-
-
- -
- {$ctx->for_each($uploads, fn($u) => $ctx->uploads_item( - id: $u->id, - name: $u->name, - direct_url: $u->getDirectUrl(), - note_ru: $u->noteRu, - note_en: $u->noteEn, - markdown: $u->getMarkdown(), - size: $u->getSize(), - ))} -
-HTML; -} - -function uploads_form_note_field($ctx, PostLanguage $lang) { -$label = $ctx->lang('blog_upload_form_note'); -$label .= ' ('.$lang->name.')'; - -return << -
{$label}:
-
- -
- -HTML; -} - -function uploads_item($ctx, $id, $direct_url, $note_en, $note_ru, $markdown, $name, $size) { -$fix_nl_re = '/(\r)?\n/'; -$as_note_ru = jsonEncode(preg_replace($fix_nl_re, '\n', addslashes($note_ru))); -$as_note_en = jsonEncode(preg_replace($fix_nl_re, '\n', addslashes($note_en))); -return << - - -
{$size}
- {$ctx->if_true($note_en, - fn() => '
En'.$note_en.'
')} - {$ctx->if_true($note_ru, - fn() => '
Ru'.$note_ru.'
')} - - -HTML; -} - -function postForm(\SkinContext $ctx, - string|Stringable $title, - string|Stringable $text, - string|Stringable $short_name, - string|Stringable $source_url, - string|Stringable $keywords, - array $langs, - array $js_texts, - string|Stringable|null $date = null, - bool $is_edit = false, - ?bool $saved = null, - ?bool $visible = null, - ?bool $toc = null, - string|Stringable|null $post_url = null, - ?int $post_id = null, - ?string $lang = null): array { - -$form_url = !$is_edit ? '/articles/write/' : $post_url.'edit/'; - -// breadcrumbs -$bc_tree = [ - ['url' => '/articles/?lang='.$lang, 'text' => $ctx->lang('articles')] -]; -if ($is_edit) { - $bc_tree[] = ['url' => $post_url.'?lang='.$lang, 'text' => $ctx->lang('blog_view_post')]; -} else { - $bc_tree[] = ['text' => $ctx->lang('blog_new_post')]; -} - -$html = << -{$ctx->if_true($saved, fn() => '
'.$ctx->lang('info_saved').'
')} -{$ctx->bc($bc_tree, 'padding-bottom: 12px')} - - - - - -
-
-
-
{$ctx->lang('blog_write_form_title')}
-
- -
-
- -
-
{$ctx->lang('blog_write_form_text')}
- -
- -
- - - - - - - - - - - - - -
-
-
{$ctx->lang('blog_post_options')}
-
- -
-
-
-
-
{$ctx->lang('blog_text_options')}
-
- -   -
-
-
-
-
{$ctx->lang('blog_write_form_date')}
-
- if_true($date, ' value="'.$date.'"')}> -
-
-
-
- - - - - -
-
{$ctx->lang('blog_write_form_keywords')}
-
- -
-
-
{$ctx->lang('blog_write_form_source')}
-
- -
-
-
-
-
-
{$ctx->lang('blog_write_form_short_name')}
-
- -
-
-
-
 
-
- -
-
-
-
-
-
-
-
-HTML; - -$js_params = [ - 'langs' => array_map(fn($lang) => $lang->value, $langs), - 'token' => $is_edit ? csrf_get('editpost'.$post_id) : csrf_get('post_add') -]; -if ($is_edit) - $js_params += [ - 'edit' => true, - 'id' => $post_id, - 'texts' => $js_texts - ]; -$js_params = jsonEncode($js_params); - -$js = <<bc([ - ['url' => '/'.$short_name.'/', 'text' => $ctx->lang('view_page')] - ], 'padding-bottom: 12px'); -} else { - $bc_html = ''; -} - -$html = << -{$ctx->if_true($saved, fn() => '
'.$ctx->lang('info_saved').'
')} -{$bc_html} - - - - - -
-
-
-
{$ctx->lang('pages_write_form_title')}
-
- -
-
- -
-
{$ctx->lang('pages_write_form_text')}
- -
- - {$ctx->if_then_else($is_edit, - fn() => $ctx->pageFormEditOptions($short_name, $parent, $visible, $render_title), - fn() => $ctx->pageFormAddOptions($short_name))} - -
-
-
-
-
-HTML; - -$js_params = [ - 'pages' => true, - 'edit' => $is_edit, - 'token' => $is_edit ? $ctx->csrf('editpage'.$short_name) : $ctx->csrf('addpage'), - 'langs' => array_map(fn($lang) => $lang->value, $langs), // still needed for draft erasing -]; -if ($js_text !== null) - $js_params['text'] = $js_text; -$js_params = jsonEncode($js_params); - -$js = << - - - - - - - - - -
-
-
{$ctx->lang('pages_write_form_short_name')}
-
- -
-
-
-
-
{$ctx->lang('pages_write_form_parent')}
-
- -
-
-
-
-
{$ctx->lang('pages_write_form_options')}
-
- - -
-
-
- -
- -HTML; -} - -// TODO: add visible and reader_title checkbox here -function pageFormAddOptions($ctx, $short_name) { -return << -
-
- -
- - -HTML; -} - -function pageNew($ctx, $short_name) { -return << - - -HTML; - -} - -// misc -function formError($ctx, $error) { -return <<{$ctx->lang('error')}: {$error} -HTML; -} - -function markdownPreview($ctx, $unsafe_html, $title) { -return << - {$ctx->if_true($title, '

'.$title.'

')} -
{$unsafe_html}
- -HTML; -} - - - - -// ---------------------------------------------------- -// ---------------------- ERRORS ---------------------- -// ---------------------------------------------------- - - -function errors($ctx, - array $list, - int $count, - int $pn_page, - int $pn_pages, - string $url, - ?string $ip_filter = null, - ?int $ip = null, - ?string $query = null, - ?string $url_query = null, - ?string $file_query = null, - ?string $line_query = null) { -return <<bc([ - ['text' => $ctx->lang('admin_title'), 'url' => '/admin/'], - ['text' => $ctx->lang('admin_errors')], -])} - -
- {$ctx->if_true($ip, fn() => '')} - - - - - -
- -{$ctx->if_then_else(!empty($list), - fn() => $ctx->errors_table($list), - fn() => '
Error log is empty.
')} - -{$ctx->pagenav($pn_page, $pn_pages, $url.'page={page}')} -HTML; -} - -function errors_table($ctx, - array $list) { -return << - - - Time - Source - Error - - - - {$ctx->for_each($list, fn($item) => $ctx->errors_table_item( - date: $item['date'], - is_cli: (bool)$item['is_cli'], - is_custom: (bool)$item['custom'], - user_agent: $item['ua'], - ip: (int)$item['ip'], - ip_s: $item['ip_s'], - full_url: $item['full_url'], - url: $item['url'], - file: $item['file'], - line: (int)$item['line'], - admin_id: (int)$item['admin_id'], - nl2br_text: $item['text'], - num: (int)$item['num'], - errtype: $item['errtype'] ?? null, - time: $item['time'], - stacktrace: $item['stacktrace'], - item_id: (int)$item['id'] - ))} - - -HTML; -} - -function errors_table_item($ctx, - int $item_id, - string $date, - bool $is_cli, - bool $is_custom, - string $user_agent, - int $ip, - string $ip_s, - string $full_url, - string $url, - string $file, - int $line, - int $admin_id, - string $nl2br_text, - int $num, - ?string $errtype, - string $time, - string $stacktrace) { -return << - - {$date} - - - {$ctx->if_then_else(!$is_cli, - fn() => ''.$ip_s.' '.$url.'
'.$user_agent, - fn() => 'cmd')} - - - - {$ctx->if_true($admin_id, - fn() => 'admin='.$admin_id.'')} - {$ctx->if_then_else($is_custom, - fn() => ''.$num.', '.$time.' '.$file.':'.$line.'
' - .''.$errtype.' '.$nl2br_text, - fn() => ''.$num.', '.$time.' '.$nl2br_text)} - - {$ctx->if_true($stacktrace, - fn() => $ctx->errors_table_item_stacktrace($item_id, $stacktrace))} - - -HTML; -} - -function errors_table_item_stacktrace($ctx, $item_id, $nl2br_stacktrace) { -return << - Show/hide stacktrace - - -HTML; -} - - -// ------------------------------------------------------ -// ---------------------- AUTH LOG ---------------------- -// ------------------------------------------------------ - -function auth_log($ctx, array $list, int $pn_page, int $pn_pages) { -return <<bc([ - ['text' => $ctx->lang('admin_title'), 'url' => '/admin/'], - ['text' => $ctx->lang('admin_auth_log')], -])} - -{$ctx->if_then_else(!empty($list), - fn() => $ctx->auth_log_table($list), - fn() => '
Auth log is empty.
')} - -{$ctx->pagenav($pn_page, $pn_pages, '/admin/auth-log/?page={page}')} -HTML; -} - -function auth_log_table($ctx, array $list) { -return << - - - Admin - Time - IP - User-Agent - - - - {$ctx->for_each($list, - fn($item) => $ctx->auth_log_table_item( - date: $item['date'], - ip: $item['ip'], - user_agent: $item['ua'], - admin_login: $item['login'], - admin_id: (int)$item['admin_id'], - activity_ts: $item['activity_ts_s']))} - - -HTML; -} - -function auth_log_table_item($ctx, $date, $ip, $user_agent, string $admin_login, int $admin_id, string $activity_ts) { -return << - - {$admin_login} (id={$admin_id}) - - {$date} - {$ip} - {$user_agent} - -HTML; -} - - -// --------------------------------------------------------- -// ---------------------- ACTIONS LOG ---------------------- -// --------------------------------------------------------- - -function actions_log($ctx, - array $list, - array $admin_logins, - string $url, - array $action_types, - int $pn_page, - int $pn_pages) { - return <<bc([ - ['text' => $ctx->lang('admin_title'), 'url' => '/admin/'], - ['text' => $ctx->lang('admin_actions_log')], - ])} - -{$ctx->if_then_else(!empty($list), - fn() => $ctx->actions_log_table($list, $admin_logins, $action_types), - fn() => '
Actions log is empty.
')} - -{$ctx->pagenav($pn_page, $pn_pages, $url.'page={page}')} -HTML; -} - -function actions_log_table($ctx, array $list, array $admin_logins, array $action_types) { - return << - - - Time - Who - Action - Data - - - - {$ctx->for_each($list, - fn(\AdminActions\BaseAction $item) => $ctx->actions_log_table_item( - date: $item->getDate(), - ip: $item->getIPv4(), - is_cli: $item->isCommandLineAction(), - admin_login: $admin_logins[$item->getAdminId()], - action_name: $item->getActionName(), - unsafe_data: $item->renderHtml()))} - - -HTML; -} - -function actions_log_table_item($ctx, - string $date, - string $ip, - bool $is_cli, - string $admin_login, - string $action_name, - string $unsafe_data) { -return << - {$date} - - {$ctx->if_then_else(!$is_cli, - fn() => $admin_login.', '.$ip, - fn() => 'console')} - - {$action_name} - {$unsafe_data} - -HTML; -} diff --git a/skin/admin_actions_log.twig b/skin/admin_actions_log.twig new file mode 100644 index 0000000..ad22c2b --- /dev/null +++ b/skin/admin_actions_log.twig @@ -0,0 +1,37 @@ +{{ bc([ + {text: "admin_title"|lang, url: '/admin/'}, + {text: "admin_actions_log"|lang} +]) }} + +{% if list %} + + + + + + + + + + + {% for item in list %} + + + + + + + {% endfor %} + +
TimeWhoActionData
{{ item.getDate() }} + {% if item.isCommandLineActions() %} + {{ admin_logs[item.getAdminId()] }}, {{ item.getIPv4() }} + {% else %} + console + {% endif %} + {{ item.getActionName() }}{{ item.renderHtml()|raw }}
+ + {{ pageNav(pn_page, pn_pages, "#{url}page={page}") }} +{% else %} +
Actions log is empty.
+{% endif %} \ No newline at end of file diff --git a/skin/admin_auth_log.twig b/skin/admin_auth_log.twig new file mode 100644 index 0000000..c056985 --- /dev/null +++ b/skin/admin_auth_log.twig @@ -0,0 +1,33 @@ +{{ bc([ + {text: "admin_title"|lang, url: '/admin/'}, + {text: "admin_auth_log"|lang} +]) }} + +{% if list %} + + + + + + + + + + + {% for item in list %} + + + + + + + {% endfor %} + +
AdminTimeIPUser-Agent
+ {{ item.login }} (id={{ item.admin_id }}) + {{ item.date }}{{ item.ip }}{{ item.ua }}
+ + {{ pageNav(pn_page, pn_pages, "/admin/auth-log/?page={page}") }} +{% else %} +
Auth log is empty.
+{% endif %} \ No newline at end of file diff --git a/skin/admin_errors.twig b/skin/admin_errors.twig new file mode 100644 index 0000000..8e2ee91 --- /dev/null +++ b/skin/admin_errors.twig @@ -0,0 +1,69 @@ +{{ bc([ + {text: "admin_title"|lang, url: '/admin/'}, + {text: "admin_errors"|lang} +]) }} + +{% if list %} +
+ {% if ip %} + + {% endif %} + + + + + +
+ + + + + + + + + + + {% for item in list %} + + + + + + {% endfor %} + +
TimeSourceError
{{ item.date }} + {% if item.is_cli %} + cmd + {% else %} + + {{ item.ip_s }} + {{ item.url }}
+ {{ item.ua }} + {% endif %} +
+ {% if item.admin_id %} + admin={{ item.admin_id }} + {% endif %} + + {% if item.custom %} + {{ item.num }}, {{ item.time }} {{ item.file }}:{{ item.line }}
+ {{ item.errtype }} {{ item.text|nl2br }} + {% else %} + {{ item.num }}, {{ item.time }} {{ item.text|nl2br }} + {% endif %} + + {% if item.stacktrace %} +
+ Show/hide stacktrace + +
+ {% endif %} +
+ + {% if pn_pages > 1 %} + {{ pageNav(pn_page, pn_pages, url~'page={page}') }} + {% endif %} +{% else %} +
Error log is empty.
+{% endif %} diff --git a/skin/admin_index.twig b/skin/admin_index.twig new file mode 100644 index 0000000..ac16fb8 --- /dev/null +++ b/skin/admin_index.twig @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/skin/admin_login.twig b/skin/admin_login.twig new file mode 100644 index 0000000..37399b7 --- /dev/null +++ b/skin/admin_login.twig @@ -0,0 +1,28 @@ +
+ + +
+
{{ "admin_login"|lang }}:
+
+ +
+
+ +
+
{{ "admin_password"|lang }}:
+
+ +
+
+ +
+
+
+ +
+
+
+ +{% js %} +document.forms.admin_login.login.focus(); +{% endjs %} \ No newline at end of file diff --git a/skin/admin_page_form.twig b/skin/admin_page_form.twig new file mode 100644 index 0000000..8b200bf --- /dev/null +++ b/skin/admin_page_form.twig @@ -0,0 +1,104 @@ + + +{% if saved %} +
{{ "info_saved"|lang }}
+{% endif %} + +{% if is_edit %} + {{ bc([ + {url: "/#{short_name}/", text: "view_page"|lang}, + ], 'padding-bottom: 12px') }} +{% endif %} + + + + + + +
+
+
+
{{ "pages_write_form_title"|lang }}
+
+ +
+
+ +
+
{{ "pages_write_form_text"|lang }}
+ +
+ + {% if is_edit %} +
+ + + + + + + + + +
+
+
{{ "pages_write_form_short_name"|lang }}
+
+ +
+
+
+
+
{{ "pages_write_form_parent"|lang }}
+
+ +
+
+
+
+
{{ "pages_write_form_options"|lang }}
+
+ + + +
+
+
+ +
+
+ {% else %} +
+
+
+ +
+
+ + {% endif %} +
+
+
+
+
+ +{% js %} +cur.form = new AdminWriteEditForm({{ js_params|json_encode|raw }}); +{% endjs %} \ No newline at end of file diff --git a/skin/admin_page_new.twig b/skin/admin_page_new.twig new file mode 100644 index 0000000..430996d --- /dev/null +++ b/skin/admin_page_new.twig @@ -0,0 +1,5 @@ + diff --git a/skin/admin_post_form.twig b/skin/admin_post_form.twig new file mode 100644 index 0000000..a86a107 --- /dev/null +++ b/skin/admin_post_form.twig @@ -0,0 +1,112 @@ + + +{% if saved %} +
{{ "info_saved"|lang }}
+{% endif %} + +{{ bc(bc) }} + + + + + + +
+
+
+
{{ "blog_write_form_title"|lang }}
+
+ +
+
+ +
+
{{ "blog_write_form_text"|lang }}
+ +
+ +
+ + + + + + + + + + + + + +
+
+
{{ "blog_post_options"|lang }}
+
+ +
+
+
+
+
{{ "blog_text_options"|lang }}
+
+ +   +
+
+
+
+
{{ "blog_write_form_date"|lang }}
+
+ +
+
+
+
+ + + + + +
+
{{ "blog_write_form_keywords"|lang }}
+
+ +
+
+
{{ "blog_write_form_source"|lang }}
+
+ +
+
+
+
+
+
{{ "blog_write_form_short_name"|lang }}
+
+ +
+
+
+
 
+
+ +
+
+
+
+
+
+
+
+ +{% js %} +cur.form = new AdminWriteEditForm({{ js_params|json_encode|raw }}); +{% endjs %} \ No newline at end of file diff --git a/skin/admin_uploads.twig b/skin/admin_uploads.twig new file mode 100644 index 0000000..8445114 --- /dev/null +++ b/skin/admin_uploads.twig @@ -0,0 +1,72 @@ +{% if error %} +
{{ "error"|lang }}: {{ error }}
+{% endif %} + +{{ bc([ + {text: "admin_title"|lang, url: '/admin/'}, + {text: "blog_uploads"|lang} +]) }} + +
+
+ + +
+
{{ "blog_upload_form_file"|lang }}:
+
+ +
+
+ +
+
{{ "blog_upload_form_custom_name"|lang }}:
+
+ +
+
+ + {% for l in langs %} +
+
{{ "blog_upload_form_note"|lang }} ({{ ("lang_"~l)|lang }}):
+
+ +
+
+ {% endfor %} + +
+
+
+ +
+
+
+
+ +
+ {% for item in uploads %} +
+ + + +
{{ item.getSize() }}
+ + {% if item.noteEn %} +
En{{ item.noteEn }}
+ {% endif %} + + {% if item.noteRu %} +
Ru{{ item.noteRu }}
+ {% endif %} + + +
+ {% endfor %} +
\ No newline at end of file diff --git a/skin/articles.twig b/skin/articles.twig new file mode 100644 index 0000000..b0ee01b --- /dev/null +++ b/skin/articles.twig @@ -0,0 +1,30 @@ +{% if not posts %} +
+ {{ "blog_no"|lang }} + {% include 'articles_right_links.twig' %} +
+{% else %} +
+ {{ ("blog_expl_"~selected_lang)|lang|nl2br }} +
+
+
+ {% set year = 3000 %} + {% for post in posts %} + {% if year > post.getYear() %} +
+ {{ post.getYear() }} + {% if loop.index == 1 %} + {% include 'articles_right_links.twig' %} + {% endif %} +
+ {% set year = post.getYear() %} + {% endif %} + +
{{ post.getDate() }}
+
{{ post.getText(selected_lang).title }}
+
+ {% endfor %} +
+
+{% endif %} \ No newline at end of file diff --git a/skin/articles_right_links.twig b/skin/articles_right_links.twig new file mode 100644 index 0000000..4a3cb4f --- /dev/null +++ b/skin/articles_right_links.twig @@ -0,0 +1,24 @@ + +{% set links = [ + {url: selected_lang != 'en' ? '/articles/' : null, label: "lang_en"|lang}, + {url: selected_lang != 'ru' ? '/articles/?lang=ru' : null, label: "lang_ru"|lang}, +] %} +{% if is_admin %} + {% set links = links|merge([{ + url: '/articles/write/?lang='~selected_lang, + label: "write"|lang + }]) %} +{% endif %} + + diff --git a/skin/base.phps b/skin/base.phps deleted file mode 100644 index 7d9d985..0000000 --- a/skin/base.phps +++ /dev/null @@ -1,305 +0,0 @@ - $config['domain'], - 'devMode' => $config['is_dev'], - 'cookieHost' => $config['cookie_host'], -]); - -$body_class = []; -if ($opts['full_width']) - $body_class[] = 'full-width'; -else if ($opts['wide']) - $body_class[] = 'wide'; - -return << - - - - - - - - {$title} - - {$ctx->meta($meta)} - {$ctx->renderStatic($static, $theme, $is_system_theme_dark)} - -if_true($body_class, ' class="'.implode(' ', $body_class).'"')}> - {$ctx->if_true($svg_defs, fn() => $ctx->renderSVGIcons($svg_defs))} -
- {$ctx->renderHeader($theme, $opts['head_section'], $opts['articles_lang'], $opts['is_index'])} -
{$unsafe_body}
- {$ctx->if_not($opts['full_width'], fn() => $ctx->renderFooter($admin_email))} -
- {$ctx->renderScript($js, $unsafe_lang)} - {$ctx->if_not($opts['inside_admin_interface'], fn() => $ctx->render_external_counters())} - - - -{$ctx->if_admin(fn() => "")} -HTML; -} - -function render_external_counters($ctx) { -return << - - - -HTML; - -} - -function renderSVGIcons($ctx, $svg_defs) { - $buf = ''; - foreach ($svg_defs as $name => $icon) { - $buf .= << - {$icon['svg']} - -SVG; - } - $buf .= ''; - return $buf; -} - -function renderScript($ctx, $unsafe_js, $unsafe_lang) { -global $config; - -$styles = jsonEncode($ctx->styleNames); -if ($config['is_dev']) - $versions = '{}'; -else { - $versions = []; - foreach ($config['static'] as $name => $v) { - list($type, $bname) = getStaticNameParts($name); - $versions[$type][$bname] = $v; - } - $versions = jsonEncode($versions); -} - -return << -StaticManager.init({$styles}, {$versions}); -{$ctx->if_true($unsafe_js, '(function(){try{'.$unsafe_js.'}catch(e){window.console&&console.error("caught exception:",e)}})();')} -{$ctx->if_true($unsafe_lang, 'extend(__lang, '.$unsafe_lang.');')} -ThemeSwitcher.init(); - -HTML; -} - -function meta($ctx, $meta) { - if (empty($meta)) - return ''; - return implode('', array_map(function(array $item): string { - $s = ' $v) - $s .= ' '.htmlescape($k).'="'.htmlescape($v).'"'; - $s .= '/>'; - $s .= "\n"; - return $s; - }, $meta)); -} - -function renderStatic($ctx, $static, $theme, $is_system_theme_dark) { - global $config; - $html = []; - $dark = $theme == 'dark' || $is_system_theme_dark; - $ctx->styleNames = []; - foreach ($static as $name) { - // javascript - if (str_starts_with($name, 'js/')) - $html[] = jsLink($name); - - // css - else if (str_starts_with($name, 'css/')) { - $html[] = cssLink($name, 'light', $style_name); - $ctx->styleNames[] = $style_name; - - if ($dark) - $html[] = cssLink($name, 'dark', $style_name); - else if (!$config['is_dev']) - $html[] = cssPrefetchLink($style_name.'_dark'); - } - else - logError(__FUNCTION__.': unexpected static entry: '.$name); - } - return implode("\n", $html); -} - -function jsLink(string $name): string { - global $config; - list (, $bname) = getStaticNameParts($name); - if ($config['is_dev']) { - $href = '/js.php?name='.urlencode($bname).'&v='.time(); - } else { - $href = '/dist-js/'.$bname.'.js?v='.getStaticVersion($name); - } - return ''; -} - -function cssLink(string $name, string $theme, &$bname = null): string { - list(, $bname) = getStaticNameParts($name); - - $config_name = 'css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css'; - - if (is_dev()) { - $href = '/sass.php?name='.urlencode($bname).'&theme='.$theme.'&v='.time(); - } else { - $version = getStaticVersion($config_name); - $href = '/dist-css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css?v='.$version; - } - - $id = 'style_'.$bname; - if ($theme == 'dark') - $id .= '_dark'; - - return ''; -} - -function cssPrefetchLink(string $name): string { -$url = '/dist-css/'.$name.'.css?v='.getStaticVersion('css/'.$name.'.css'); -$integrity = getStaticIntegrityAttribute('css/'.$name.'.css'); -return << -HTML; -} - -function getStaticNameParts(string $name): array { - $dname = dirname($name); - $bname = basename($name); - if (($pos = strrpos($bname, '.'))) { - $ext = substr($bname, $pos+1); - $bname = substr($bname, 0, $pos); - } else { - $ext = ''; - } - return [$dname, $bname, $ext]; -} - -function getStaticVersion(string $name): string { - global $config; - if ($config['is_dev']) - return time(); - if (str_starts_with($name, '/')) { - logWarning(__FUNCTION__.': '.$name.' starts with /'); - $name = substr($name, 1); - } - return $config['static'][$name]['version'] ?? 'notfound'; -} - -function getStaticIntegrityAttribute(string $name): string { - if (is_dev()) - return ''; - global $config; - return ' integrity="'.implode(' ', array_map(fn($hash_type) => $hash_type.'-'.$config['static'][$name]['integrity'][$hash_type], RESOURCE_INTEGRITY_HASHES)).'"'; -} - - -function renderHeader(SkinContext $ctx, - string $theme, - ?string $section, - ?string $articles_lang, - bool $show_subtitle): string { -global $config; -$icons = svg(); -$items = []; -$items[] = ['url' => '/articles/'.($articles_lang && $articles_lang != 'en' ? '?lang='.$articles_lang : ''), 'label' => 'articles', 'selected' => $section === 'articles']; -$items[] = ['url' => '/files/', 'label' => 'files', 'selected' => $section === 'files']; -$items[] = ['url' => '/'.$config['wiki_root'].'/', 'label' => 'wiki', 'selected' => $section === $config['wiki_root']]; -$items[] = ['url' => '/info/', 'label' => 'about', 'selected' => $section === 'about']; -if (is_admin()) - $items[] = ['url' => '/admin/', 'label' => $icons->settings_28(in_place: true), 'type' => 'settings', 'selected' => $section === 'admin']; -$items[] = [ - 'url' => 'javascript:void(0)', - 'label' => $icons->moon_auto_18(in_place: true) - .$icons->moon_light_18(in_place: true) - .$icons->moon_dark_18(in_place: true), - 'type' => 'theme-switcher', - 'type_opts' => $theme -]; - -// here, items are rendered using for_each, so that there are no gaps (whitespaces) between tags - -$class = 'head'; -if (!$show_subtitle) - $class .= ' no-subtitle'; - -return << -
- -
- {$ctx->for_each($items, fn($item) => $ctx->renderHeaderItem( - $item['url'], - $item['label'], - $item['type'] ?? false, - $item['type_opts'] ?? null, - $item['selected'] ?? false - ))} -
-
- -HTML; -} - -function renderHeaderItem(SkinContext $ctx, - string $url, - ?Stringable $unsafe_label, - ?string $type, - ?string $type_opts, - bool $selected): string { -$args = ''; -$class = ''; -switch ($type) { - case 'theme-switcher': - $args = ' onclick="return ThemeSwitcher.next(event)"'; - $class = ' is-theme-switcher '.$type_opts; - break; - case 'settings': - $class = ' is-settings'; - break; -} -if ($selected) - $class .= ' is-selected'; - -return <<{$unsafe_label} -HTML; -} - -function renderFooter($ctx, $admin_email): string { -return << - Email: {$admin_email} - -HTML; - -} diff --git a/skin/error.phps b/skin/error.phps deleted file mode 100644 index cfcb1ab..0000000 --- a/skin/error.phps +++ /dev/null @@ -1,23 +0,0 @@ - - $code $title - -

$code $title

- {$ctx->if_true($message, - '

'.$message.'

' - )} - - -HTML; - -} \ No newline at end of file diff --git a/skin/error.twig b/skin/error.twig new file mode 100644 index 0000000..934f165 --- /dev/null +++ b/skin/error.twig @@ -0,0 +1,9 @@ + +{{ code }} {{ title }} + +

{{ code }} {{ title }}

+{% if message %} +

{{ message }}

+{% endif %} + + \ No newline at end of file diff --git a/skin/files.phps b/skin/files.phps deleted file mode 100644 index ecaa952..0000000 --- a/skin/files.phps +++ /dev/null @@ -1,261 +0,0 @@ -bc([ - ['text' => $ctx->lang('files_archives')] -])} - -
- {$ctx->for_each($collections, - fn(FilesItemInterface $file) => $ctx->file( - file: $file, - disabled: !$file->isAvailable()))} -
- -{$ctx->bc([ - ['text' => $ctx->lang('files_books')] -], mt: true)} - -
- {$ctx->for_each($books, fn(FilesItemInterface $file) => $ctx->file(file: $file))} -
- -{$ctx->bc([ - ['text' => $ctx->lang('files_misc')] -], mt: true)} - -
- {$ctx->for_each($misc, fn(FilesItemInterface $file) => $ctx->file(file: $file))} -
-HTML; -} - -function folder($ctx, BookItem $folder, array $files, ?array $parents) { -$svg = svg(); -$svg->folder_20(preload_symbol: true); -$svg->file_20(preload_symbol: true); - -$bc = [ - ['text' => $ctx->lang('files'), 'url' => '/files/'], -]; - -if ($parents) { - for ($i = 0; $i < count($parents)-1; $i++) { - $parent = $parents[$i]; - $bc_item = ['text' => $parent->getTitle()]; - if ($i < count($parents)-1) - $bc_item['url'] = $parent->getUrl(); - $bc[] = $bc_item; - } -} -$bc[] = ['text' => $folder->title]; - -return <<bc($bc)} -
-
- {$ctx->collection_files($files)} -
-
-HTML; - -} - -function collection(SkinContext $ctx, - FilesCollection $collection, - array $files, - ?array $parents, - int $search_results_per_page, - int $search_min_query_length, - ?string $search_query = null, - ?int $search_count = null, - ?array $text_excerpts = null) { - $widgets = skin('widgets'); - - $svg = svg(); - $svg->folder_20(preload_symbol: true); - $svg->file_20(preload_symbol: true); - - $bc = [ - ['text' => $ctx->lang('files'), 'url' => '/files/'], - ]; - if ($parents) { - $bc[] = ['text' => $ctx->lang('files_'.$collection->value.'_collection_short'), 'url' => "/files/{$collection->value}/"]; - for ($i = 0; $i < count($parents); $i++) { - $parent = $parents[$i]; - $bc_item = ['text' => $parent->getTitle()]; - if ($i < count($parents)-1) - $bc_item['url'] = $parent->getUrl(); - $bc[] = $bc_item; - } - } else { - $bc[] = ['text' => $ctx->lang('files_'.$collection->value.'_collection')]; - } - - $do_show_search = empty($parents); - $do_show_more = $search_count > 0 && count($files) < $search_count; - - $html = <<bc($bc)} -{$ctx->if_true($do_show_search, fn() => $ctx->collection_search($search_count, $search_query, $ctx->lang('files_'.$collection->value.'_search_ph')))} - -
-
- {$ctx->collection_files($files, $search_query, $text_excerpts)} -
-
if_not($do_show_more, ' style="display: none"')}> - {$ctx->lang('files_show_more')} - {$widgets->spinner('files_show_more_spinner')} -
-
- -HTML; - - if ($do_show_search) { - $opts = [ - 'container' => 'files_list', - 'per_page' => $search_results_per_page, - 'min_query_length' => $search_min_query_length, - 'base_url' => "/files/{$collection->value}/", - 'query' => $search_query, - 'count' => $search_count, - 'collection_name' => $collection->value, - 'inited_with_search' => !!$search_query - ]; - $opts = jsonEncode($opts); - - $js = <<for_each($files, fn(FilesItemInterface $f) => $ctx->file( - file: $f, - unsafe_query: $search_query, - text_excerpts: $text_excerpts)); -} - -function collection_search(SkinContext $ctx, $count, $query, ?string $placeholder = null) { - $icons = svg(); - $widgets = skin('widgets'); - $clear_dsp = $query ? 'block' : 'none'; - - return << - - -
-
-
{$widgets->spinner()}
- {$ctx->if_then_else($query, fn() => $ctx->lang_num('files_search_results_count', $count), ' ')} -
-
- -HTML; -} - - -function file(SkinContext $ctx, - FilesItemInterface $file, - ?SkinString $unsafe_query = null, - bool $disabled = false, - ?array $text_excerpts = null) { -$icons = svg(); -if ($file instanceof BookItem && $file->fileType == BookFileType::BOOK) - $icon = $icons->book_20(); -else - $icon = $file->isFile() ? $icons->file_20() : $icons->folder_20(); - -$class = 'files-list-item clearfix'; -if ($disabled) - $class .= ' is-disabled'; - -$mapper = function($s) use ($unsafe_query) { - if ($unsafe_query !== null) { - return hl_matched($s, $unsafe_query); - } else { - return htmlescape($s); - } -}; - -$title = $file->getTitleHtml(); -if ($title === null) { - // we don't apply $mapper to already htmlescaped string - $title = $mapper($file->getTitle()); -} - -$meta = $file->getMeta($unsafe_query); -$meta_is_inline = $meta['inline'] ?? false; -$meta_items = $meta['items'] ?? []; -$url = htmlescape($file->getUrl()); - -$subtitle = $file->getSubtitle(); - -return <<if_true($file->isTargetBlank(), ' target="_blank"')}> -
{$icon}
-
-
- {$title} - {$ctx->if_true($file->isFolder() && $file->isTargetBlank(), fn() => ''.$icons->arrow_up_right_out_square_outline_12().'')} - {$ctx->if_true($subtitle, fn() => ''.htmlescape($subtitle).'')} - {$ctx->if_true($meta_is_inline, $ctx->for_each($meta_items, fn($s) => '
'.$s.'
'))} -
- {$ctx->if_true($meta_items && !$meta_is_inline, $ctx->meta($meta_items))} - {$ctx->if_true(is_array($text_excerpts) && isset($text_excerpts[$file->getId()]), - fn() => $ctx->text_excerpt($text_excerpts[$file->getId()]['excerpt'], $text_excerpts[$file->getId()]['index'], $unsafe_query))} -
- -HTML; -} - -/** - * @param SkinContext $ctx - * @param string[] $meta strings are already html-safe - * @return string - */ -function meta($ctx, array $meta) { -return << - {$ctx->for_each($meta, fn($s) => '
'.$s.'
')} - -HTML; -} - - -function text_excerpt($ctx, $unsafe_excerpt, $index, $unsafe_query) { - if ($index > 0) - $unsafe_excerpt = '...'.$unsafe_excerpt; - $unsafe_excerpt .= '...'; - $text = hl_matched($unsafe_excerpt, $unsafe_query); - return <<{$text} -HTML; -} \ No newline at end of file diff --git a/skin/files_collection.twig b/skin/files_collection.twig new file mode 100644 index 0000000..f63c58a --- /dev/null +++ b/skin/files_collection.twig @@ -0,0 +1,44 @@ +{{ svgPreload('folder_20', 'file_20') }} +{{ bc(bc) }} + +{% set do_show_more = search_count > 0 and files|length < search_count %} +{% if do_show_search %} + {% set placeholder = "files_#{collection}_search_ph"|lang %} + +
+ + +
+
+
{% include 'spinner.twig' %}
+ {% if search_query %}{{ "files_search_results_count"|plural(search_count) }}{% else %} {% endif %} +
+
+
+{% endif %} + +
+
+ {% include 'files_list.twig' %} +
+ +
+ + + +{% js %} +{% if do_show_search %} +cur.search = new FileSearch({{ js_params|json_encode|raw }}); +{% endif %} +{% endjs %} \ No newline at end of file diff --git a/skin/files_file.twig b/skin/files_file.twig new file mode 100644 index 0000000..a10a5d5 --- /dev/null +++ b/skin/files_file.twig @@ -0,0 +1,67 @@ +{% macro excerptWithHighlight(index, unsafe_excerpt, q) %} + {% set modified_excerpt = unsafe_excerpt %} + {% if index > 0 %} + {% set modified_excerpt = '...' ~ modified_excerpt %} + {% endif %} + {% set modified_excerpt = modified_excerpt ~ '...' %} + {% set text = modified_excerpt|hl(q) %} +
{{ text|raw }}
+{% endmacro %} + +{% import _self as macros %} + +{% set subtitle = file.getSubtitle() %} +{% set meta = file.getMeta(query) %} +{% set title = file.getTitleHtml() %} +{% if not title %} + {% set title = file.getTitle()|hl(query) %} +{% endif %} + + +
+ {% if file.isBook() %} + {{ svg('book_20') }} + {% else %} + {% if file.isFile() %} + {{ svg('file_20') }} + {% else %} + {{ svg('folder_20') }} + {% endif %} + {% endif %} +
+ +
+
+ {{ title|raw }} + {% if file.isFolder() and file.isTargetBlank() %} + {{ svg('arrow_up_right_out_square_outline_12') }} + {% endif %} + + {% if subtitle %} + {{ subtitle }} + {% endif %} + + {% if meta.inline %} + {% for item in meta.items %} +
{{ item }}
+ {% endfor %} + {% endif %} +
+ + {% if meta.items and not meta.inline %} +
+ {% for item in meta.items %} +
{{ item }}
+ {% endfor %} +
+ {% endif %} + + {% if text_excerpts[file.getId()] %} + {{ macros.excerptWithHighlight(text_excerpts[file.getId()]['index'], text_excerpts[file.getId()]['excerpt'], query) }} + {% endif %} +
+
\ No newline at end of file diff --git a/skin/files_folder.twig b/skin/files_folder.twig new file mode 100644 index 0000000..bf66cad --- /dev/null +++ b/skin/files_folder.twig @@ -0,0 +1,7 @@ +{{ svgPreload('folder_20', 'file_20') }} +{{ bc(bc) }} +
+
+ {% include 'files_list.twig' %} +
+
\ No newline at end of file diff --git a/skin/files_index.twig b/skin/files_index.twig new file mode 100644 index 0000000..6cde643 --- /dev/null +++ b/skin/files_index.twig @@ -0,0 +1,14 @@ +{{ bc([{text: "files_archives"|lang}]) }} +
+ {% include 'files_list.twig' with {files: collections} %} +
+ +{{ bc([{text: "files_books"|lang}], null, true) }} +
+ {% include 'files_list.twig' with {files: books} %} +
+ +{{ bc([{text: "files_misc"|lang}], null, true) }} +
+ {% include 'files_list.twig' with {files: misc} %} +
diff --git a/skin/files_list.twig b/skin/files_list.twig new file mode 100644 index 0000000..edd31f3 --- /dev/null +++ b/skin/files_list.twig @@ -0,0 +1,3 @@ +{% for file in files %} + {% include 'files_file.twig' with {file: file} %} +{% endfor %} \ No newline at end of file diff --git a/skin/footer.twig b/skin/footer.twig new file mode 100644 index 0000000..5ba5ddc --- /dev/null +++ b/skin/footer.twig @@ -0,0 +1,44 @@ + + +{% if not render_options.full_width %} + +{% endif %} + + + +{{ script_html|raw }} + +{% if not render_options.inside_admin_interface %} + + + + +{% endif %} + + + + +{% if is_admin %} + +{% endif %} \ No newline at end of file diff --git a/skin/header.twig b/skin/header.twig new file mode 100644 index 0000000..a448967 --- /dev/null +++ b/skin/header.twig @@ -0,0 +1,40 @@ + + + + + + + + + {{ title }} + + {{ meta_html|raw }} + {{ static_html|raw }} + + +{{ svg_html|raw }} +
+ + + +
\ No newline at end of file diff --git a/skin/icons.phps b/skin/icons.phps deleted file mode 100644 index abc2e48..0000000 --- a/skin/icons.phps +++ /dev/null @@ -1,71 +0,0 @@ - -SVG; -} - -function settings_28($ctx) { -return << -SVG; -} - -function moon_auto_18($ctx) { -return << - -SVG; -} - -function moon_light_18($ctx) { -return << -SVG; -} - -function moon_dark_18($ctx) { -return << -SVG; -} - -function search_20($ctx) { -return << -SVG; -} - -function file_20($ctx) { -return << -SVG; -} - -function book_20($ctx) { -return << -SVG; -} - -function clear_20($ctx) { -return << -SVG; -} - -function clear_16($ctx) { -return << -SVG; -} - -function arrow_up_right_out_square_outline_12($ctx) { -return << -SVG; - -} \ No newline at end of file diff --git a/skin/index.twig b/skin/index.twig new file mode 100644 index 0000000..b61a0cc --- /dev/null +++ b/skin/index.twig @@ -0,0 +1,43 @@ +
+
+ +
+
+
{{ "recent_articles"|lang }}
+
+ {% for i, post in posts %} +
+
{{ post.getDate() }}
+ +
+ {% endfor %} + + + {{ "view_all_articles"|lang }} + + +
+
+
\ No newline at end of file diff --git a/skin/main.phps b/skin/main.phps deleted file mode 100644 index ee49389..0000000 --- a/skin/main.phps +++ /dev/null @@ -1,269 +0,0 @@ - -
- -
-
-
{$ctx->lang('recent_articles')}
-
- {$ctx->for_each($posts, - fn($post, $i) => $ctx->articles_post_row($i, $post, $posts_lang, true, false))} - - {$ctx->lang('view_all_articles')} - - -
-
-
-HTML; - -} - -function articles($ctx, array $posts, PostLanguage $selected_lang): string { -if (empty($posts)) - return $ctx->articles_empty($selected_lang); - -$expl = $ctx->lang('blog_expl_'.$selected_lang->value); -$expl = nl2br($expl); - -return <<{$expl}
-
- {$ctx->articles_posts_table($posts, $selected_lang)} -
-HTML; -} - -function articles_empty($ctx, PostLanguage $selected_lang) { -return << - {$ctx->lang('blog_no')} - {$ctx->articles_right_links($selected_lang->value)} - -HTML; -} - -function articles_posts_table($ctx, array $posts, PostLanguage $selected_lang): string { -$ctx->year = 3000; -return << - {$ctx->for_each($posts, - fn($post, $i) => $ctx->articles_post_row($i, $post, $selected_lang))} - -HTML; -} - -function articles_post_row($ctx, - int $index, - Post $post, - PostLanguage $selected_lang, - bool $no_block = false, - bool $show_year = true): string { -$year = $post->getYear(); -$date = $post->getDate(); -$url = $post->getUrl($selected_lang); - -$pt = $post->getText($selected_lang); -$title = $pt->title; - -$class = ['blog-list-item', 'clearfix']; -if (!$post->visible) - $class[] = 'is-hidden'; -if ($no_block) - $class[] = 'no-block'; -$class = 'class="'.implode(' ', $class).'"'; - -$buf = $no_block ? '' : ''; - -return <<if_true($show_year && $ctx->year > $year, - fn() => $ctx->articles_index_year_line($year, $index === 0, $selected_lang->value))} -{$buf} -HTML; -} - -function articles_index_year_line($ctx, $year, $show_right_links, string $selected_lang): string { -$ctx->year = $year; -return << - {$year} - - {$ctx->if_true($show_right_links, - fn() => $ctx->articles_right_links($selected_lang))} - -HTML; -} - -function articles_right_links($ctx, string $selected_lang) { -$links = [ - ['url' => $selected_lang != 'en' ? '/articles/' : null, 'label' => lang('lang_en')], - ['url' => $selected_lang != 'ru' ? '/articles/?lang=ru' : null, 'label' => lang('lang_ru')], -]; -if (is_admin()) { - $links[] = ['url' => '/articles/write/?lang='.$selected_lang, 'label' => 'write']; -} - -return << - {$ctx->for_each($links, fn($link, $index) => $ctx->articles_right_link($link['url'], $link['label'], $index))} - -HTML; -} - -function articles_right_link($ctx, $url, string $label, int $index) { - $buf = ''; - if ($index > 0) - $buf .= ' | '; - $buf .= !$url ? $label : ''.$label.''; - return $buf; -} - - -// any page -// -------- - -function page($ctx, $page_url, $short_name, $unsafe_html, $bc) { -$html = << - {$ctx->if_true($bc, fn() => $ctx->bc($bc))} - {$ctx->if_admin($ctx->page_admin_links, $page_url, $short_name)} -
{$unsafe_html}
- -HTML; - -return [$html, js_markdownThemeChangeListener()]; -} - -function page_admin_links($ctx, $url, $short_name) { -return << - {$ctx->lang('edit')} - {$ctx->lang('delete')} - -HTML; - -} - - -// post page -// --------- - -function post($ctx, $id, $title, $unsafe_html, $unsafe_toc_html, $date, $visible, $url, string $lang, $other_langs, string $source_url) { -$html = << -
-
- {$ctx->bc([ - ['url' => '/articles/?lang='.$lang, 'text' => $ctx->lang('articles')] - ])} -
-

{$title}

- -
-
{$unsafe_html}
-
- {$ctx->if_true($unsafe_toc_html, $ctx->postToc, $unsafe_toc_html)} -
- -HTML; - -return [$html, js_markdownThemeChangeListener()]; -} - -function post_other_langs($ctx, $url, $other_langs) { - $buf = ''; - foreach ($other_langs as $lang) { - $buf .= ' | '.$ctx->lang('blog_read_in_'.$lang).''; - } - return $buf; -} - -function postToc($ctx, $unsafe_toc_html) { -return << -
-
-
{$ctx->lang('toc')}
- {$unsafe_toc_html} -
-
- -HTML; - -} - -function post_admin_links($ctx, $url, $id, string $lang) { -return <<{$ctx->lang('edit')} -| {$ctx->lang('delete')} -HTML; -} - -function js_markdownThemeChangeListener() { -return << div'); - if (!div) { - console.warn('could not found a>div on this node:', node); - continue; - } - var style = div.getAttribute('style'); - if (isDark) { - style = style.replace(/(a[\d]+x[\d]+)\.jpg/, '$1_dark.jpg'); - } else { - style = style.replace(/(a[\d]+x[\d]+)_dark\.jpg/, '$1.jpg'); - } - div.setAttribute('style', style); - } -}); -JS; -} diff --git a/skin/markdown.phps b/skin/markdown.phps deleted file mode 100644 index 31aa1dc..0000000 --- a/skin/markdown.phps +++ /dev/null @@ -1,41 +0,0 @@ - - {$name} - {$ctx->if_true($note, ''.$note.'')} - {$size} - -HTML; -} - -function image($ctx, - // options - $align, $nolabel, $w, $padding_top, $may_have_alpha, - // image data - $direct_url, $url, $unsafe_note) { -return << -
- -
-
-
- {$ctx->if_true($unsafe_note != '' && !$nolabel, - fn() => '
'.$unsafe_note.'
')} - -HTML; -} - -function video($ctx, $url, $w, $h) { -return << -
- -
- -HTML; -} \ No newline at end of file diff --git a/skin/markdown_fileupload.twig b/skin/markdown_fileupload.twig new file mode 100644 index 0000000..5ae7c72 --- /dev/null +++ b/skin/markdown_fileupload.twig @@ -0,0 +1,7 @@ +
+ {{ name }} + {% if note %} + {{ note }} + {% endif %} + {{ size }} +
\ No newline at end of file diff --git a/skin/markdown_image.twig b/skin/markdown_image.twig new file mode 100644 index 0000000..377cc6d --- /dev/null +++ b/skin/markdown_image.twig @@ -0,0 +1,10 @@ +
+
+ +
+
+
+ {% if unsafe_note and not nolabel %} +
{{ unsafe_note|raw }}
+ {% endif %} +
\ No newline at end of file diff --git a/skin/markdown_preview.twig b/skin/markdown_preview.twig new file mode 100644 index 0000000..830a187 --- /dev/null +++ b/skin/markdown_preview.twig @@ -0,0 +1,6 @@ +
+ {% if title %} +

{{ title }}

+ {% endif %} +
{{ unsafe_html|raw }}
+
\ No newline at end of file diff --git a/skin/markdown_video.twig b/skin/markdown_video.twig new file mode 100644 index 0000000..17dcf1e --- /dev/null +++ b/skin/markdown_video.twig @@ -0,0 +1,9 @@ +
+
+ +
+
\ No newline at end of file diff --git a/skin/page.twig b/skin/page.twig new file mode 100644 index 0000000..ee87445 --- /dev/null +++ b/skin/page.twig @@ -0,0 +1,14 @@ +
+ {% if bc %} + {{ bc(bc) }} + {% endif %} + + {% if is_admin %} + + {% endif %} + +
{{ html|raw }}
+
\ No newline at end of file diff --git a/skin/post.twig b/skin/post.twig new file mode 100644 index 0000000..66eb6fd --- /dev/null +++ b/skin/post.twig @@ -0,0 +1,63 @@ +
+
+
+ {{ bc([{url: '/articles/?lang='~selected_lang, text: "articles"|lang}]) }} +
+

{{ pt.title }}

+ +
+
{{ html|raw }}
+
+ {% if pt.hasTableOfContents() %} +
+
+
+
{{ "toc"|lang }}
+ {{ pt.getTableOfContentsHtml()|raw }} +
+
+
+ {% endif %} +
+
+ +{% js %} +ThemeSwitcher.addOnChangeListener(function(isDark) { + var nodes = document.querySelectorAll('.md-image-wrap'); + for (var i = 0; i < nodes.length; i++) { + var node = nodes[i]; + var alpha = parseInt(node.getAttribute('data-alpha'), 10); + if (!alpha) + continue; + var div = node.querySelector('a > div'); + if (!div) { + console.warn('could not found a>div on this node:', node); + continue; + } + var style = div.getAttribute('style'); + if (isDark) { + style = style.replace(/(a[\d]+x[\d]+)\.jpg/, '$1_dark.jpg'); + } else { + style = style.replace(/(a[\d]+x[\d]+)_dark\.jpg/, '$1.jpg'); + } + div.setAttribute('style', style); + } +}); +{% endjs %} \ No newline at end of file diff --git a/skin/rss.phps b/skin/rss.phps deleted file mode 100644 index 8bc19e9..0000000 --- a/skin/rss.phps +++ /dev/null @@ -1,39 +0,0 @@ - - - - {$title} - {$link} - - - {$ctx->for_each($items, fn($item) => $ctx->item(...$item))} - - -HTML; -} - - -function item(\SkinContext $ctx, - string $title, - string $link, - string $pub_date, - string $description): string { -return << - {$title} - {$link} - {$pub_date} - {$description} - -HTML; -} \ No newline at end of file diff --git a/skin/rss.twig b/skin/rss.twig new file mode 100644 index 0000000..4b4e46a --- /dev/null +++ b/skin/rss.twig @@ -0,0 +1,17 @@ + + + + {{ title }} + {{ link }} + + + {% for item in items %} + + {{ title }} + {{ link }} + {{ pub_date }} + {{ description }} + + {% endfor %} + + \ No newline at end of file diff --git a/skin/spinner.twig b/skin/spinner.twig new file mode 100644 index 0000000..8e5ce5d --- /dev/null +++ b/skin/spinner.twig @@ -0,0 +1,5 @@ +
+ + + +
\ No newline at end of file diff --git a/skin/svg/arrow_up_right_out_square_outline_12.svg b/skin/svg/arrow_up_right_out_square_outline_12.svg new file mode 100644 index 0000000..691c20f --- /dev/null +++ b/skin/svg/arrow_up_right_out_square_outline_12.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/skin/svg/book_20.svg b/skin/svg/book_20.svg new file mode 100644 index 0000000..29f0396 --- /dev/null +++ b/skin/svg/book_20.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/skin/svg/clear_16.svg b/skin/svg/clear_16.svg new file mode 100644 index 0000000..a7dabcb --- /dev/null +++ b/skin/svg/clear_16.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/skin/svg/clear_20.svg b/skin/svg/clear_20.svg new file mode 100644 index 0000000..529172f --- /dev/null +++ b/skin/svg/clear_20.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/skin/svg/file_20.svg b/skin/svg/file_20.svg new file mode 100644 index 0000000..aa4ac5a --- /dev/null +++ b/skin/svg/file_20.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/skin/svg/folder_20.svg b/skin/svg/folder_20.svg new file mode 100644 index 0000000..6a65211 --- /dev/null +++ b/skin/svg/folder_20.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/skin/svg/moon_auto_18.svg b/skin/svg/moon_auto_18.svg new file mode 100644 index 0000000..156638d --- /dev/null +++ b/skin/svg/moon_auto_18.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/skin/svg/moon_dark_18.svg b/skin/svg/moon_dark_18.svg new file mode 100644 index 0000000..b5a5801 --- /dev/null +++ b/skin/svg/moon_dark_18.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/skin/svg/moon_light_18.svg b/skin/svg/moon_light_18.svg new file mode 100644 index 0000000..a59293d --- /dev/null +++ b/skin/svg/moon_light_18.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/skin/svg/search_20.svg b/skin/svg/search_20.svg new file mode 100644 index 0000000..de8af12 --- /dev/null +++ b/skin/svg/search_20.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/skin/svg/settings_28.svg b/skin/svg/settings_28.svg new file mode 100644 index 0000000..df4330f --- /dev/null +++ b/skin/svg/settings_28.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/skin/widgets.phps b/skin/widgets.phps deleted file mode 100644 index 3acb6ae..0000000 --- a/skin/widgets.phps +++ /dev/null @@ -1,14 +0,0 @@ -if_true($id, ' id="'.$id.'"')}> - - - - -HTML; -} \ No newline at end of file diff --git a/tools/cli_util.php b/tools/cli_util.php index b3405bd..384bca9 100755 --- a/tools/cli_util.php +++ b/tools/cli_util.php @@ -9,25 +9,25 @@ require_once 'lib/admin.php'; ->on('admin-add', function() { list($login, $password) = _get_admin_login_password_input(); - if (admin_exists($login)) + if (admin::exists($login)) cli::die("Admin ".$login." already exists"); - $id = admin_add($login, $password); + $id = admin::add($login, $password); echo "ok: id = $id\n"; }) ->on('admin-delete', function() { $login = cli::input('Login: '); - if (!admin_exists($login)) + if (!admin::exists($login)) cli::die("No such admin"); - if (!admin_delete($login)) + if (!admin::delete($login)) cli::die("Database error"); echo "ok\n"; }) ->on('admin-set-password', function() { list($login, $password) = _get_admin_login_password_input(); - echo admin_set_password($login, $password) ? 'ok' : 'fail'; + echo admin::setPassword($login, $password) ? 'ok' : 'fail'; echo "\n"; }) @@ -103,8 +103,8 @@ function _get_admin_login_password_input(): array { if (trim($pwd1) == '') cli::die("Password can not be empty"); - if (strlen($login) > ADMIN_LOGIN_MAX_LENGTH) - cli::die("Login is longer than max length (".ADMIN_LOGIN_MAX_LENGTH.")"); + if (strlen($login) > admin::ADMIN_LOGIN_MAX_LENGTH) + cli::die("Login is longer than max length (".admin::ADMIN_LOGIN_MAX_LENGTH.")"); return [$login, $pwd1]; }