migrate to Twig; upgrade request handling engine

This commit is contained in:
E. S. 2025-04-13 21:06:16 +03:00
parent f62480c15c
commit 8a98eec610
115 changed files with 4749 additions and 4817 deletions

View File

@ -11,7 +11,8 @@
"ext-yaml": "*", "ext-yaml": "*",
"ext-gmp": "*", "ext-gmp": "*",
"ext-memcached": "*", "ext-memcached": "*",
"samdark/sitemap": "^2.1" "samdark/sitemap": "^2.1",
"twig/twig": "^3.0"
}, },
"repositories": [ "repositories": [
{ {

332
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "5fba09fb495209abe5954d5dc485a308", "content-hash": "d75fee72f82dcd543f7ac9fd20971de8",
"packages": [ "packages": [
{ {
"name": "erusev/parsedown", "name": "erusev/parsedown",
@ -267,19 +267,20 @@
}, },
{ {
"name": "samdark/sitemap", "name": "samdark/sitemap",
"version": "2.1.0", "version": "2.4.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/samdark/sitemap.git", "url": "https://github.com/samdark/sitemap.git",
"reference": "6b7eed71534b31d0c6e6dfd18d3cca5a677e0b5b" "reference": "cf514750781275ad90fc9a828b4330c9c5ccba98"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/samdark/sitemap/zipball/6b7eed71534b31d0c6e6dfd18d3cca5a677e0b5b", "url": "https://api.github.com/repos/samdark/sitemap/zipball/cf514750781275ad90fc9a828b4330c9c5ccba98",
"reference": "6b7eed71534b31d0c6e6dfd18d3cca5a677e0b5b", "reference": "cf514750781275ad90fc9a828b4330c9c5ccba98",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-xmlwriter": "*",
"php": ">=5.3.0" "php": ">=5.3.0"
}, },
"require-dev": { "require-dev": {
@ -311,7 +312,17 @@
"issues": "https://github.com/samdark/sitemap/issues", "issues": "https://github.com/samdark/sitemap/issues",
"source": "https://github.com/samdark/sitemap" "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", "name": "scrivo/highlight.php",
@ -390,6 +401,311 @@
} }
], ],
"time": "2022-12-17T21:53:22+00:00" "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": [], "packages-dev": [],
@ -409,6 +725,6 @@
"ext-gmp": "*", "ext-gmp": "*",
"ext-memcached": "*" "ext-memcached": "*"
}, },
"platform-dev": [], "platform-dev": {},
"plugin-api-version": "2.3.0" "plugin-api-version": "2.6.0"
} }

View File

@ -1,10 +1,8 @@
#!/bin/sh #!/bin/sh
PROGNAME="$0"
DIR=$(cd "$(dirname "$(readlink -f "$0")")" && pwd) DIR=$(cd "$(dirname "$(readlink -f "$0")")" && pwd)
ROOT="$(realpath "$DIR/../")" ROOT="$(realpath "$DIR/../")"
CLEANCSS="$ROOT"/node_modules/clean-css-cli/bin/cleancss CLEANCSS="$ROOT"/node_modules/clean-css-cli/bin/cleancss
. $DIR/build_common.sh . $DIR/build_common.sh
build_scss() { build_scss() {

View File

@ -1,8 +1,6 @@
#!/bin/sh #!/bin/sh
PROGNAME="$0"
DIR=$(cd "$(dirname "$(readlink -f "$0")")" && pwd) DIR=$(cd "$(dirname "$(readlink -f "$0")")" && pwd)
. $DIR/build_common.sh . $DIR/build_common.sh
# suckless version of webpack # suckless version of webpack

View File

@ -61,4 +61,29 @@ EOF;
function get_hash(string $path): string { function get_hash(string $path): string {
return substr(sha1(file_get_contents($path)), 0, 8); 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;
} }

View File

@ -1,6 +1,35 @@
<?php <?php
require_once 'lib/ansi.php'; enum AnsiColor: int {
case BLACK = 0;
case RED = 1;
case GREEN = 2;
case YELLOW = 3;
case BLUE = 4;
case MAGENTA = 5;
case CYAN = 6;
case WHITE = 7;
}
function ansi(string $text,
?AnsiColor $fg = null,
?AnsiColor $bg = null,
bool $bold = false,
bool $fg_bright = false,
bool $bg_bright = false): string {
$codes = [];
if (!is_null($fg))
$codes[] = $fg->value + ($fg_bright ? 90 : 30);
if (!is_null($bg))
$codes[] = $bg->value + ($bg_bright ? 100 : 40);
if ($bold)
$codes[] = 1;
if (empty($codes))
return $text;
return "\033[".implode(';', $codes)."m".$text."\033[0m";
}
enum LogLevel: int { enum LogLevel: int {
case ERROR = 10; case ERROR = 10;
@ -32,15 +61,15 @@ abstract class Logger {
/** @var ?callable $filter */ /** @var ?callable $filter */
protected $filter = null; protected $filter = null;
function setErrorFilter(callable $filter): void { public function setErrorFilter(callable $filter): void {
$this->filter = $filter; $this->filter = $filter;
} }
function disable(): void { public function disable(): void {
$this->enabled = false; $this->enabled = false;
} }
function enable(): void { public function enable(): void {
static $error_handler_set = false; static $error_handler_set = false;
$this->enabled = true; $this->enabled = true;
@ -87,14 +116,14 @@ abstract class Logger {
$error_handler_set = true; $error_handler_set = true;
} }
function log(LogLevel $level, ?string $stacktrace = null, ...$args): void { public function log(LogLevel $level, ?string $stacktrace = null, ...$args): void {
if (!is_dev() && $level == LogLevel::DEBUG) if (!isDev() && $level == LogLevel::DEBUG)
return; return;
$this->write($level, strVars($args), $this->write($level, strVars($args),
stacktrace: $stacktrace); stacktrace: $stacktrace);
} }
function canReport(): bool { public function canReport(): bool {
return $this->recursionLevel < 3; return $this->recursionLevel < 3;
} }
@ -123,7 +152,7 @@ abstract class Logger {
class FileLogger extends Logger { class FileLogger extends Logger {
function __construct(protected string $logFile) {} public function __construct(protected string $logFile) {}
protected function writer(LogLevel $level, protected function writer(LogLevel $level,
int $num, int $num,
@ -145,7 +174,7 @@ class FileLogger extends Logger {
if (strlen($exec_time) < 6) if (strlen($exec_time) < 6)
$exec_time .= str_repeat('0', 6 - strlen($exec_time)); $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); $date = date('d/m/y H:i:s', $time);
$buf = ''; $buf = '';
@ -178,7 +207,7 @@ class FileLogger extends Logger {
$buf .= $message."\n"; $buf .= $message."\n";
if (in_array($level, [LogLevel::ERROR, LogLevel::WARNING])) if (in_array($level, [LogLevel::ERROR, LogLevel::WARNING]))
$buf .= ($stacktrace ?: backtrace_as_string(2))."\n"; $buf .= ($stacktrace ?: backtraceAsString(2))."\n";
$set_perm = false; $set_perm = false;
if (!file_exists($this->logFile)) { if (!file_exists($this->logFile)) {
@ -216,8 +245,6 @@ class DatabaseLogger extends Logger {
?string $errline = null, ?string $errline = null,
?string $stacktrace = null): void ?string $stacktrace = null): void
{ {
global $AdminSession;
$db = DB(); $db = DB();
$data = [ $data = [
@ -229,12 +256,12 @@ class DatabaseLogger extends Logger {
'line' => $errline ?: 0, 'line' => $errline ?: 0,
'text' => $message, 'text' => $message,
'level' => $level->value, 'level' => $level->value,
'stacktrace' => $stacktrace ?: backtrace_as_string(2), 'stacktrace' => $stacktrace ?: backtraceAsString(2),
'is_cli' => intval(is_cli()), 'is_cli' => intval(isCli()),
'admin_id' => is_admin() ? $AdminSession->id : 0, 'admin_id' => isAdmin() ? admin::getId() : 0,
]; ];
if (is_cli()) { if (isCli()) {
$data += [ $data += [
'ip' => '', 'ip' => '',
'ua' => '', 'ua' => '',
@ -274,7 +301,7 @@ function strVars(array $args): string {
return implode(' ', $args); return implode(' ', $args);
} }
function backtrace_as_string(int $shift = 0): string { function backtraceAsString(int $shift = 0): string {
$bt = debug_backtrace(); $bt = debug_backtrace();
$lines = []; $lines = [];
foreach ($bt as $i => $t) { foreach ($bt as $i => $t) {

View File

@ -124,7 +124,7 @@ abstract class model {
} }
public function get_id() { 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 { 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])) { if (isset($custom_getters[$field]) && is_callable($custom_getters[$field])) {
$array[$field] = $custom_getters[$field](); $array[$field] = $custom_getters[$field]();
} else { } else {
$array[$field] = $this->{to_camel_case($field)}; $array[$field] = $this->{toCamelCase($field)};
} }
} }
@ -220,7 +220,7 @@ abstract class model {
realType: $real_type, realType: $real_type,
nullable: $type->allowsNull(), nullable: $type->allowsNull(),
modelName: $name, modelName: $name,
dbName: from_camel_case($name) dbName: fromCamelCase($name)
); );
$list[] = $model_descr; $list[] = $model_descr;
$db_name_map[$model_descr->getDbName()] = $index++; $db_name_map[$model_descr->getDbName()] = $index++;

View File

@ -2,12 +2,12 @@
class mysql { class mysql {
const DATE_FORMAT = 'Y-m-d'; const string DATE_FORMAT = 'Y-m-d';
const DATETIME_FORMAT = 'Y-m-d H:i:s'; const string DATETIME_FORMAT = 'Y-m-d H:i:s';
protected ?mysqli $link = null; protected ?mysqli $link = null;
function __construct( public function __construct(
protected string $host, protected string $host,
protected string $user, protected string $user,
protected string $password, protected string $password,
@ -40,11 +40,11 @@ class mysql {
return $sql; return $sql;
} }
function insert(string $table, array $fields) { public function insert(string $table, array $fields) {
return $this->performInsert('INSERT', $table, $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); return $this->performInsert('REPLACE', $table, $fields);
} }
@ -66,7 +66,7 @@ class mysql {
return $this->query(...$values); return $this->query(...$values);
} }
function update(string $table, array $rows, ...$cond) { public function update(string $table, array $rows, ...$cond) {
$fields = []; $fields = [];
$args = []; $args = [];
foreach ($rows as $row_name => $row_value) { foreach ($rows as $row_name => $row_value) {
@ -82,13 +82,13 @@ class mysql {
return $this->query($sql, ...$args); return $this->query($sql, ...$args);
} }
function multipleInsert(string $table, array $rows) { public function multipleInsert(string $table, array $rows) {
list($names, $values) = $this->getMultipleInsertValues($rows); list($names, $values) = $this->getMultipleInsertValues($rows);
$sql = "INSERT INTO `{$table}` (`".implode('`, `', $names)."`) VALUES ".$values; $sql = "INSERT INTO `{$table}` (`".implode('`, `', $names)."`) VALUES ".$values;
return $this->query($sql); return $this->query($sql);
} }
function multipleReplace(string $table, array $rows) { public function multipleReplace(string $table, array $rows) {
list($names, $values) = $this->getMultipleInsertValues($rows); list($names, $values) = $this->getMultipleInsertValues($rows);
$sql = "REPLACE INTO `{$table}` (`".implode('`, `', $names)."`) VALUES ".$values; $sql = "REPLACE INTO `{$table}` (`".implode('`, `', $names)."`) VALUES ".$values;
return $this->query($sql); return $this->query($sql);
@ -110,12 +110,12 @@ class mysql {
return [$names, implode(', ', $sql_rows)]; return [$names, implode(', ', $sql_rows)];
} }
function __destruct() { public function __destruct() {
if ($this->link) if ($this->link)
$this->link->close(); $this->link->close();
} }
function connect(): bool { public function connect(): bool {
$this->link = new mysqli(); $this->link = new mysqli();
$result = $this->link->real_connect($this->host, $this->user, $this->password, $this->database); $result = $this->link->real_connect($this->host, $this->user, $this->password, $this->database);
if ($result) if ($result)
@ -123,24 +123,24 @@ class mysql {
return !!$result; return !!$result;
} }
function query(string $sql, ...$args): mysqli_result|bool { public function query(string $sql, ...$args): mysqli_result|bool {
$sql = $this->prepareQuery($sql, ...$args); $sql = $this->prepareQuery($sql, ...$args);
$q = false; $q = false;
try { try {
$q = $this->link->query($sql); $q = $this->link->query($sql);
if (!$q) 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) { } 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; return $q;
} }
function error() { public function error() {
return $this->link?->error; return $this->link?->error;
} }
function fetch($q): ?array { public function fetch($q): ?array {
$row = $q->fetch_assoc(); $row = $q->fetch_assoc();
if (!$row) { if (!$row) {
$q->free(); $q->free();
@ -149,7 +149,7 @@ class mysql {
return $row; return $row;
} }
function fetchAll($q): ?array { public function fetchAll($q): ?array {
if (!$q) if (!$q)
return null; return null;
$list = []; $list = [];
@ -160,31 +160,31 @@ class mysql {
return $list; return $list;
} }
function fetchRow($q): ?array { public function fetchRow($q): ?array {
return $q?->fetch_row(); return $q?->fetch_row();
} }
function result($q, $field = 0) { public function result($q, $field = 0) {
return $q?->fetch_row()[$field]; return $q?->fetch_row()[$field];
} }
function insertId(): int { public function insertId(): int {
return $this->link->insert_id; return $this->link->insert_id;
} }
function numRows($q): ?int { public function numRows($q): ?int {
return $q?->num_rows; return $q?->num_rows;
} }
function affectedRows(): ?int { public function affectedRows(): ?int {
return $this->link?->affected_rows; return $this->link?->affected_rows;
} }
function foundRows(): int { public function foundRows(): int {
return (int)$this->fetch($this->query("SELECT FOUND_ROWS() AS `count`"))['count']; 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); return $this->link->real_escape_string($s);
} }
@ -251,7 +251,7 @@ function DB(): mysql|null {
$config['mysql']['password'], $config['mysql']['password'],
$config['mysql']['database']); $config['mysql']['database']);
if (!$link->connect()) { if (!$link->connect()) {
if (!is_cli()) { if (!isCli()) {
header('HTTP/1.1 503 Service Temporarily Unavailable'); header('HTTP/1.1 503 Service Temporarily Unavailable');
header('Status: 503 Service Temporarily Unavailable'); header('Status: 503 Service Temporarily Unavailable');
header('Retry-After: 300'); header('Retry-After: 300');

View File

@ -1,43 +1,5 @@
<?php <?php
function dispatch_request(): void {
global $RouterInput;
if (!in_array($_SERVER['REQUEST_METHOD'], ['POST', 'GET']))
http_error(HTTPCode::NotImplemented, 'Method '.$_SERVER['REQUEST_METHOD'].' not implemented');
$route = router_find(request_path());
if ($route === null)
http_error(HTTPCode::NotFound, 'Route not found');
$route = preg_split('/ +/', $route);
$handler_class = $route[0].'Handler';
if (!class_exists($handler_class))
http_error(HTTPCode::NotFound, is_dev() ? 'Handler class "'.$handler_class.'" not found' : '');
$action = $route[1];
if (count($route) > 2) {
for ($i = 2; $i < count($route); $i++) {
$var = $route[$i];
list($k, $v) = explode('=', $var);
$RouterInput[trim($k)] = trim($v);
}
}
/** @var request_handler $handler */
$handler = new $handler_class();
$handler->call_act($_SERVER['REQUEST_METHOD'], $action);
}
function request_path(): string {
$uri = $_SERVER['REQUEST_URI'];
if (($pos = strpos($uri, '?')) !== false)
$uri = substr($uri, 0, $pos);
return $uri;
}
enum HTTPCode: int { enum HTTPCode: int {
case MovedPermanently = 301; case MovedPermanently = 301;
case Found = 302; case Found = 302;
@ -51,92 +13,6 @@ enum HTTPCode: int {
case NotImplemented = 501; 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('/(?<!^)([A-Z])/', ' $1', $http_code->name);
$html = $ctx->http_error($http_code->value, $http_message, $message);
http_response_code($http_code->value);
echo $html;
exit;
}
}
function redirect(string $url, HTTPCode $code = HTTPCode::MovedPermanently): void {
if (!in_array($code, [HTTPCode::MovedPermanently, HTTPCode::Found]))
internal_server_error('invalid http code');
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 { enum InputVarType: string {
case INTEGER = 'i'; case INTEGER = 'i';
case FLOAT = 'f'; case FLOAT = 'f';
@ -145,94 +21,218 @@ enum InputVarType: string {
case ENUM = 'e'; case ENUM = 'e';
} }
function input(string $input, array $options = []): array { //function ensureAdmin() {
global $RouterInput; // if (!isAdmin())
// forbidden();
// $this->skin->setRenderOptions(['inside_admin_interface' => true]);
//}
$options = array_merge(['trim' => false], $options); abstract class request_handler {
$strval = fn(mixed $val): string => $options['trim'] ? trim((string)$val) : (string)$val;
$input = preg_split('/,\s+?/', $input, -1, PREG_SPLIT_NO_EMPTY); protected array $routerInput = [];
$ret = []; protected skin $skin;
foreach ($input as $var) {
$enum_values = null;
$enum_default = null;
$pos = strpos($var, ':'); public static function resolveAndDispatch() {
if ($pos === 1) { // only one-character type specifiers are supported if (!in_array($_SERVER['REQUEST_METHOD'], ['POST', 'GET']))
$type = substr($var, 0, $pos); self::httpError(HTTPCode::NotImplemented, 'Method '.$_SERVER['REQUEST_METHOD'].' not implemented');
$rest = substr($var, $pos + 1);
$vartype = InputVarType::tryFrom($type); $uri = $_SERVER['REQUEST_URI'];
if (is_null($vartype)) if (($pos = strpos($uri, '?')) !== false)
internal_server_error('invalid input type '.$type); $uri = substr($uri, 0, $pos);
if ($vartype == InputVarType::ENUM) { $router = router::getInstance();
$br_from = strpos($rest, '('); $route = $router->find($uri);
$br_to = strpos($rest, ')'); if ($route === null)
self::httpError(HTTPCode::NotFound, 'Route not found');
if ($br_from === false || $br_to === false) $route = preg_split('/ +/', $route);
internal_server_error('failed to parse enum values: '.$rest); $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)))); $action = $route[1];
$name = trim(substr($rest, 0, $br_from)); $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)) { /** @var request_handler $handler */
foreach ($enum_values as $key => $val) { $handler = new $handler_class();
if (str_starts_with($val, '=')) { $handler->callAct($_SERVER['REQUEST_METHOD'], $action, $input);
$enum_values[$key] = substr($val, 1); }
$enum_default = $enum_values[$key];
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 { } else {
$name = trim($rest); $vartype = InputVarType::STRING;
$name = trim($var);
} }
} else { $val = null;
$vartype = InputVarType::STRING; if (isset($this->routerInput[$name])) {
$name = trim($var); $val = $this->routerInput[$name];
} } else if (isset($_POST[$name])) {
$val = $_POST[$name];
$val = null; } else if (isset($_GET[$name])) {
if (isset($RouterInput[$name])) { $val = $_GET[$name];
$val = $RouterInput[$name]; }
} else if (isset($_POST[$name])) { if (is_array($val))
$val = $_POST[$name]; $val = $strval(implode($val));
} else if (isset($_GET[$name])) {
$val = $_GET[$name];
}
if (is_array($val))
$val = $strval(implode($val));
$ret[] = match($vartype) { $ret[] = match($vartype) {
InputVarType::INTEGER => (int)$val, InputVarType::INTEGER => (int)$val,
InputVarType::FLOAT => (float)$val, InputVarType::FLOAT => (float)$val,
InputVarType::BOOLEAN => (bool)$val, InputVarType::BOOLEAN => (bool)$val,
InputVarType::ENUM => !in_array($val, $enum_values) ? $enum_default ?? '' : $strval($val), InputVarType::ENUM => !in_array($val, $enum_values) ? $enum_default ?? '' : $strval($val),
default => $strval($val) default => $strval($val)
}; };
}
return $ret;
} }
return $ret;
}
function csrf_get(string $key): string { public function getPage(int $per_page, ?int $count = null): array {
global $AdminSession, $config; list($page) = $this->input('i:page');
$user_key = is_admin() ? $AdminSession->csrfSalt : $_SERVER['REMOTE_ADDR']; $pages = $count !== null ? ceil($count / $per_page) : null;
return substr(hash('sha256', $config['csrf_token'].$user_key.$key), 0, 20); 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) { protected static function ensureXhr(): void {
if (csrf_get($key) != ($_REQUEST['token'] ?? '')) if (!self::isXhrRequest())
forbidden('invalid token'); self::invalidRequest();
} }
function get_page(int $per_page, ?int $count = null): array { public static function getCSRF(string $key): string {
list($page) = input('i:page'); global $config;
$pages = $count !== null ? ceil($count / $per_page) : null; $user_key = isAdmin() ? admin::getCSRFSalt() : $_SERVER['REMOTE_ADDR'];
if ($pages !== null && $page > $pages) return substr(hash('sha256', $config['csrf_token'].$user_key.$key), 0, 20);
$page = $pages; }
if ($page < 1)
$page = 1; protected static function checkCSRF(string $key): void {
$offset = $per_page * ($page-1); if (self::getCSRF($key) != ($_REQUEST['token'] ?? ''))
return [$page, $pages, $offset]; 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('/(?<!^)([A-Z])/', ' $1', $http_code->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;
}
}

View File

@ -1,185 +1,201 @@
<?php <?php
const ROUTER_VERSION = 10; class router {
const ROUTER_MC_KEY = '4in1/routes';
$RouterInput = []; protected array $routes = [
$Routes = [
'children' => [],
're_children' => []
];
function router_init(): void {
global $Routes;
$mc = MC();
$from_cache = !is_dev();
$write_cache = !is_dev();
if ($from_cache) {
$cache = $mc->get(ROUTER_MC_KEY);
if ($cache === false || !isset($cache['version']) || $cache['version'] < ROUTER_VERSION) {
$from_cache = false;
} else {
$Routes = $cache['routes'];
}
}
if (!$from_cache) {
$routes_table = require_once APP_ROOT.'/routes.php';
foreach ($routes_table as $controller => $routes) {
foreach ($routes as $route => $resolve)
router_add($route, $controller.' '.$resolve);
}
if ($write_cache)
$mc->set(ROUTER_MC_KEY, ['version' => ROUTER_VERSION, 'routes' => $Routes]);
}
}
function router_add(string $template, string $value): void {
global $Routes;
if ($template == '')
return;
// expand {enum,erat,ions}
$templates = [[$template, $value]];
if (preg_match_all('/\{([\w\d_\-,]+)\}/', $template, $matches)) {
foreach ($matches[1] as $match_index => $variants) {
$variants = explode(',', $variants);
$variants = array_map('trim', $variants);
$variants = array_filter($variants, function($s) { return $s != ''; });
for ($i = 0; $i < count($templates); ) {
list($template, $value) = $templates[$i];
$new_templates = [];
foreach ($variants as $variant_index => $variant) {
$new_templates[] = [
str_replace_once($matches[0][$match_index], $variant, $template),
str_replace('${'.($match_index+1).'}', $variant, $value)
];
}
array_splice($templates, $i, 1, $new_templates);
$i += count($new_templates);
}
}
}
// process all generated routes
foreach ($templates as $template) {
list($template, $value) = $template;
$start_pos = 0;
$parent = &$Routes;
$template_len = strlen($template);
while ($start_pos < $template_len) {
$slash_pos = strpos($template, '/', $start_pos);
if ($slash_pos !== false) {
$part = substr($template, $start_pos, $slash_pos-$start_pos+1);
$start_pos = $slash_pos+1;
} else {
$part = substr($template, $start_pos);
$start_pos = $template_len;
}
$parent = &_router_add($parent, $part,
$start_pos < $template_len ? null : $value);
}
}
}
function &_router_add(&$parent, $part, $value = null) {
$par_pos = strpos($part, '(');
$is_regex = $par_pos !== false && ($par_pos == 0 || $part[$par_pos-1] != '\\');
$children_key = !$is_regex ? 'children' : 're_children';
if (isset($parent[$children_key][$part])) {
if (is_null($value)) {
$parent = &$parent[$children_key][$part];
} else {
if (!isset($parent[$children_key][$part]['value'])) {
$parent[$children_key][$part]['value'] = $value;
} else {
trigger_error(__METHOD__.': route is already defined');
}
}
return $parent;
}
$child = [
'children' => [], 'children' => [],
're_children' => [] 're_children' => []
]; ];
if (!is_null($value)) protected static ?router $instance = null;
$child['value'] = $value;
$parent[$children_key][$part] = $child; public static function getInstance(): router {
return $parent[$children_key][$part]; if (self::$instance === null)
} self::$instance = new router();
return self::$instance;
}
function router_find($uri) { private function __construct() {
global $Routes; $mc = MC();
if ($uri != '/' && $uri[0] == '/')
$uri = substr($uri, 1);
$start_pos = 0; $from_cache = !isDev();
$parent = &$Routes; $write_cache = !isDev();
$uri_len = strlen($uri);
$matches = [];
while ($start_pos < $uri_len) { if ($from_cache) {
$slash_pos = strpos($uri, '/', $start_pos); $cache = $mc->get(ROUTER_MC_KEY);
if ($slash_pos !== false) {
$part = substr($uri, $start_pos, $slash_pos-$start_pos+1); if ($cache === false || !isset($cache['version']) || $cache['version'] < ROUTER_VERSION) {
$start_pos = $slash_pos+1; $from_cache = false;
} else { } else {
$part = substr($uri, $start_pos); $this->routes = $cache['routes'];
$start_pos = $uri_len; }
} }
$found = false; if (!$from_cache) {
if (isset($parent['children'][$part])) { $routes_table = require_once APP_ROOT.'/routes.php';
$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) { foreach ($routes_table as $controller => $routes) {
if (count($match) > 1) foreach ($routes as $route => $resolve)
$matches = array_merge($matches, array_slice($match, 1)); $this->add($route, $controller.' '.$resolve);
$parent = &$child; }
$found = true;
break; 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) // process all generated routes
return null; foreach ($templates as $template) {
} list($template, $value) = $template;
if (!isset($parent['value'])) $start_pos = 0;
return null; $parent = &$this->routes;
$template_len = strlen($template);
$value = $parent['value']; while ($start_pos < $template_len) {
if (!empty($matches)) { $slash_pos = strpos($template, '/', $start_pos);
foreach ($matches as $i => $match) { if ($slash_pos !== false) {
$needle = '$('.($i+1).')'; $part = substr($template, $start_pos, $slash_pos-$start_pos+1);
$pos = strpos($value, $needle); $start_pos = $slash_pos+1;
if ($pos !== false) } else {
$value = substr_replace($value, $match, $pos, strlen($needle)); $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;
}
} }

View File

@ -1,13 +1,20 @@
<?php <?php
require_once 'lib/themes.php'; use Twig\Error\LoaderError;
const RESOURCE_INTEGRITY_HASHES = ['sha256', 'sha384', 'sha512']; const RESOURCE_INTEGRITY_HASHES = ['sha256', 'sha384', 'sha512'];
$SkinState = new class { class skin {
public array $lang = []; public array $lang = [];
protected array $vars = [];
protected array $globalVars = [];
protected bool $globalsApplied = false;
public string $title = 'title'; public string $title = 'title';
/** @var (\Closure(string $title):string)[] */
protected array $titleModifiers = [];
public array $meta = []; public array $meta = [];
protected array $js = [];
public array $options = [ public array $options = [
'full_width' => false, 'full_width' => false,
'wide' => false, 'wide' => false,
@ -19,241 +26,213 @@ $SkinState = new class {
'inside_admin_interface' => false, 'inside_admin_interface' => false,
]; ];
public array $static = []; public array $static = [];
public array $svg_defs = []; protected array $styleNames = [];
}; protected array $svgDefs = [];
function render($f, ...$vars): void { public \Twig\Environment $twig;
global $SkinState, $config;
add_skin_strings(['4in1']); protected static ?skin $instance = null;
$ctx = skin(substr($f, 0, ($pos = strrpos(str_replace('/', '\\', $f), '\\')))); public static function getInstance(): skin {
$body = call_user_func_array([$ctx, substr($f, $pos + 1)], $vars); if (self::$instance === null)
if (is_array($body)) self::$instance = new skin();
list($body, $js) = $body; return self::$instance;
else }
$js = null;
$theme = getUserTheme(); /**
if ($theme != 'auto' && !themeExists($theme)) * @throws LoaderError
$theme = 'auto'; */
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'); $env_options = [];
if (!is_null($cache_dir)) {
$lang = []; $env_options += [
foreach ($SkinState->lang as $key) 'cache' => $cache_dir,
$lang[$key] = lang($key); 'auto_reload' => isDev()
$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
]; ];
} }
}; $twig = new \Twig\Environment($twig_loader, $env_options);
foreach ($data as $key => $value) { $twig->addExtension(new \TwigAddons\MyExtension());
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;
case '$description': $this->twig = $twig;
case '$keywords': }
$real_name = substr($key, 1);
$add_og_twitter($real_name, $value);
$real_meta[] = ['name' => $real_name, 'content' => $value];
break;
default: public function addMeta(array $data) {
if (str_starts_with($key, 'og:')) { static $twitter_limits = [
$real_meta[] = ['property' => $key, 'content' => $value]; 'title' => 70,
} else { 'description' => 200
logWarning("unsupported meta: $key => $value"); ];
$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; $real_meta[] = [
} $social == 'twitter' ? 'name' : 'property' => $social.':'.$key,
} 'content' => $value
$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;
} }
};
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])) case '@description':
break; 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) { default:
if (is_string($arguments[$key])) if (str_starts_with($key, 'og:')) {
$arguments[$key] = new SkinString($arguments[$key]); $real_meta[] = ['property' => $key, 'content' => $value];
} else {
if (($pos = strpos($param->name, '_')) !== false) { logWarning("unsupported meta: $key => $value");
$mod_type = match (substr($param->name, 0, $pos)) { }
'unsafe' => SkinStringModificationType::RAW, break;
'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);
} }
} }
$this->meta = array_merge($this->meta, $real_meta);
array_unshift($arguments, $this);
return call_user_func_array($fn, $arguments);
} }
function &__get(string $name) { public function exportStrings(array|string $keys): void {
$fn = $this->ns.'\\'.$name; global $__lang;
if (function_exists($fn)) { $this->lang = array_merge($this->lang, is_string($keys) ? $__lang->search($keys) : $keys);
$f = [$this, $name]; }
return $f;
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) { protected function getTitle(): string {
$this->data[$name] = $value; $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) { public function set($arg1, $arg2 = null) {
return $this->_if_condition(!$cond, $callback, ...$args); 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) { public function isSet($key): bool {
return $this->_if_condition($cond, $callback, ...$args); return isset($this->vars[$key]);
} }
function if_admin($callback, ...$args) { public function setGlobal($arg1, $arg2 = null): void {
return $this->_if_condition(is_admin(), $callback, ...$args); 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) { public function isGlobalSet($key): bool {
return $this->_if_condition(is_dev(), $callback, ...$args); return isset($this->globalVars[$key]);
} }
function if_then_else($cond, $cb1, $cb2) { public function getGlobal($key) {
return $cond ? $this->_return_callback($cb1) : $this->_return_callback($cb2); return $this->isGlobalSet($key) ? $this->globalVars[$key] : null;
} }
function csrf($key): string { public function applyGlobals(): void {
return csrf_get($key); 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 id="svgicon_'.$name.'" width="'.$w.'" height="'.$h.'" fill="currentColor" viewBox="0 0 '.$w.' '.$h.'">';
$svg .= file_get_contents(APP_ROOT.'/skin/svg/'.$name.'.svg');
$svg .= '</svg>';
return $svg;
} else {
return '<svg width="'.$w.'" height="'.$h.'"><use xlink:href="#svgicon_'.$name.'"></use></svg>';
}
}
public function renderBreadCrumbs(array $items, ?string $style = null, bool $mt = false): string {
static $chevron = '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M7.47 4.217a.75.75 0 0 0 0 1.06L12.185 10 7.469 14.72a.75.75 0 1 0 1.062 1.06l5.245-5.25a.75.75 0 0 0 0-1.061L8.531 4.218a.75.75 0 0 0-1.061-.001z" fill="currentColor"/></svg>'; static $chevron = '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M7.47 4.217a.75.75 0 0 0 0 1.06L12.185 10 7.469 14.72a.75.75 0 1 0 1.062 1.06l5.245-5.25a.75.75 0 0 0 0-1.061L8.531 4.218a.75.75 0 0 0-1.061-.001z" fill="currentColor"/></svg>';
$buf = implode(array_map(function(array $i) use ($chevron): string { $buf = implode(array_map(function(array $i) use ($chevron): string {
$buf = ''; $buf = '';
$has_url = array_key_exists('url', $i); $has_url = array_key_exists('url', $i);
@ -277,13 +256,11 @@ class SkinContext {
return '<div class="'.$class.'"'.($style ? ' style="'.$style.'"' : '').'>'.$buf.'</div>'; return '<div class="'.$class.'"'.($style ? ' style="'.$style.'"' : '').'>'.$buf.'</div>';
} }
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) { if ($opts === null) {
$count = 0; $count = 0;
} else { } else {
$opts = array_merge([ $opts = array_merge(['count' => 0], $opts);
'count' => 0,
], $opts);
$count = $opts['count']; $count = $opts['count'];
} }
@ -294,25 +271,23 @@ class SkinContext {
$base_class = 'pn-button no-hover no-select no-drag is-page'; $base_class = 'pn-button no-hover no-select no-drag is-page';
for ($p = $min_page; $p <= $max_page; $p++) { for ($p = $min_page; $p <= $max_page; $p++) {
$class = $base_class; $class = $base_class;
if ($p == $page) { if ($p == $page)
$class .= ' is-page-cur'; $class .= ' is-page-cur';
} $pages_html .= '<a class="'.$class.'" href="'.htmlescape(self::pageNavGetLink($p, $link_template)).'" data-page="'.$p.'" draggable="false">'.$p.'</a>';
$pages_html .= '<a class="'.$class.'" href="'.htmlescape(self::_page_nav_get_link($p, $link_template)).'" data-page="'.$p.'" draggable="false">'.$p.'</a>';
} }
if ($min_page > 2) { if ($min_page > 2) {
$pages_html = '<div class="pn-button-sep no-select no-drag">&nbsp;</div>'.$pages_html; $pages_html = '<div class="pn-button-sep no-select no-drag">&nbsp;</div>'.$pages_html;
} }
if ($min_page > 1) { if ($min_page > 1) {
$pages_html = '<a class="'.$base_class.'" href="'.htmlescape(self::_page_nav_get_link(1, $link_template)).'" data-page="1" draggable="false">1</a>'.$pages_html; $pages_html = '<a class="'.$base_class.'" href="'.htmlescape(self::pageNavGetLink(1, $link_template)).'" data-page="1" draggable="false">1</a>'.$pages_html;
} }
if ($max_page < $pages-1) { if ($max_page < $pages-1) {
$pages_html .= '<div class="pn-button-sep no-select no-drag">&nbsp;</div>'; $pages_html .= '<div class="pn-button-sep no-select no-drag">&nbsp;</div>';
} }
if ($max_page < $pages) { if ($max_page < $pages) {
$pages_html .= '<a class="'.$base_class.'" href="'.htmlescape(self::_page_nav_get_link($pages, $link_template)).'" data-page="'.$pages.'" draggable="false">'.$pages.'</a>'; $pages_html .= '<a class="'.$base_class.'" href="'.htmlescape(self::pageNavGetLink($pages, $link_template)).'" data-page="'.$pages.'" draggable="false">'.$pages.'</a>';
} }
$pn_class = 'pn'; $pn_class = 'pn';
@ -334,146 +309,258 @@ HTML;
return $html; return $html;
} }
protected static function _page_nav_get_link($page, $link_template) { protected static function pageNavGetLink($page, $link_template) {
return is_callable($link_template) return is_callable($link_template) ? $link_template($page) : str_replace('{page}', $page, $link_template);
? $link_template($page)
: str_replace('{page}', $page, $link_template);
} }
protected function _if_condition($condition, $callback, ...$args) { protected function getSVGTags(): string {
if (is_string($condition) || $condition instanceof Stringable) $buf = '<svg style="display: none">';
$condition = (string)$condition !== ''; foreach ($this->svgDefs as $name => $icon) {
if ($condition) $content = file_get_contents(APP_ROOT.'/skin/svg/'.$name.'.svg');
return $this->_return_callback($callback, $args); $buf .= "<symbol id=\"svgicon_{$name}\" viewBox=\"0 0 {$icon['width']} {$icon['height']}\" fill=\"currentColor\">$content</symbol>";
return ''; }
$buf .= '</svg>';
return $buf;
} }
protected function _return_callback($callback, $args = []) { public function setRenderOptions(array $options): void {
if (is_callable($callback)) $this->options = array_merge($this->options, $options);
return call_user_func_array($callback, $args);
else if (is_string($callback))
return $callback;
} }
function for_each(array $iterable, callable $callback) { public function render($template, array $vars = []): string {
$html = ''; $this->applyGlobals();
foreach ($iterable as $k => $v) return $this->doRender($template, $this->vars + $vars);
$html .= call_user_func($callback, $v, $k); }
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 = '<meta';
foreach ($item as $k => $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 = '<script type="text/javascript">';
if (isDev())
$versions = '{}';
else {
$versions = [];
foreach ($config['static'] as $name => $v) {
list($type, $bname) = $this->getStaticNameParts($name);
$versions[$type][$bname] = $v;
}
$versions = jsonEncode($versions);
}
$html .= 'StaticManager.init('.jsonEncode($this->styleNames).', '.$versions.');';
$html .= 'ThemeSwitcher.init();';
if (!empty($this->lang)) {
$lang = [];
foreach ($this->lang as $key)
$lang[$key] = lang($key);
$html .= 'extend(__lang, '.jsonEncode($lang).');';
}
$js = $this->getJS();
if ($js)
$html .= '(function(){try{'.$js.'}catch(e){window.console&&console.error("caught exception:",e)}})();';
$html .= '</script>';
return $html; return $html;
} }
function lang(...$args): string { protected function jsLink(string $name): string {
return htmlescape($this->langRaw(...$args)); list (, $bname) = $this->getStaticNameParts($name);
} if (isDev()) {
$href = '/js.php?name='.urlencode($bname).'&amp;v='.time();
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;
} else { } else {
$in_place = false; $href = '/dist-js/'.$bname.'.js?v='.$this->getStaticVersion($name);
$preload_symbol = false;
} }
return '<script src="'.$href.'" type="text/javascript"'.$this->getStaticIntegrityAttribute($name).'></script>';
}
if ($already_defined && $preload_symbol) protected function cssLink(string $name, string $theme, &$bname = null): string {
return null; list(, $bname) = $this->getStaticNameParts($name);
if ($in_place || !$already_defined) { $config_name = 'css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css';
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];
}
if (!$in_place && (!$already_defined || $preload_symbol)) { if (isDev()) {
$SkinState->svg_defs[$name] = [ $href = '/sass.php?name='.urlencode($bname).'&amp;theme='.$theme.'&amp;v='.time();
'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 <<<SVG
<svg id="svgicon_{$name}" width="{$width}" height="{$height}" fill="currentColor" viewBox="0 0 {$width} {$height}">{$content}</svg>
SVG;
} else { } else {
return <<<SVG $version = $this->getStaticVersion($config_name);
<svg width="{$width}" height="{$height}"><use xlink:href="#svgicon_{$name}"></use></svg> $href = '/dist-css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css?v='.$version;
SVG;
} }
$id = 'style_'.$bname;
if ($theme == 'dark')
$id .= '_dark';
return '<link rel="stylesheet" id="'.$id.'" type="text/css" href="'.$href.'"'.$this->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 '<link rel="prefetch" href="'.$url.'"'.$integrity.' />';
}
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,
};
}
}

View File

@ -1,104 +0,0 @@
<?php
function sphinx_execute(string $sql) {
$link = _sphinxql_link();
if (func_num_args() > 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;
}

View File

@ -21,19 +21,19 @@ enum NameType: int {
class StringsBase implements ArrayAccess { class StringsBase implements ArrayAccess {
protected array $data = []; protected array $data = [];
function offsetSet(mixed $offset, mixed $value): void { public function offsetSet(mixed $offset, mixed $value): void {
throw new RuntimeException('Not implemented'); throw new RuntimeException('Not implemented');
} }
function offsetExists(mixed $offset): bool { public function offsetExists(mixed $offset): bool {
return isset($this->data[$offset]); return isset($this->data[$offset]);
} }
function offsetUnset(mixed $offset): void { public function offsetUnset(mixed $offset): void {
throw new RuntimeException('Not implemented'); throw new RuntimeException('Not implemented');
} }
function offsetGet(mixed $offset): mixed { public function offsetGet(mixed $offset): mixed {
if (!isset($this->data[$offset])) { if (!isset($this->data[$offset])) {
logError(__METHOD__.': '.$offset.' not found'); logError(__METHOD__.': '.$offset.' not found');
return '{'.$offset.'}'; return '{'.$offset.'}';
@ -41,7 +41,7 @@ class StringsBase implements ArrayAccess {
return $this->data[$offset]; 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]; $val = $this[$key];
if (!empty($sprintf_args)) { if (!empty($sprintf_args)) {
array_unshift($sprintf_args, $val); 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]; $s = $this[$key];
$default_opts = [ $default_opts = [
@ -115,7 +115,7 @@ class Strings extends StringsBase {
return self::$instance; return self::$instance;
} }
function load(string ...$pkgs): array { public function load(string ...$pkgs): array {
$keys = []; $keys = [];
foreach ($pkgs as $name) { foreach ($pkgs as $name) {
$raw = yaml_parse_file(APP_ROOT.'/strings/'.$name.'.yaml'); $raw = yaml_parse_file(APP_ROOT.'/strings/'.$name.'.yaml');
@ -126,13 +126,13 @@ class Strings extends StringsBase {
return $keys; 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 = iconv('utf-8', 'cp1251', $s);
$s = vkflex($s, $case->value, $sex->value, 0, $type->value); $s = vkflex($s, $case->value, $sex->value, 0, $type->value);
return iconv('cp1251', 'utf-8', $s); 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)); return preg_grep($regexp, array_keys($this->data));
} }
} }

View File

@ -1,6 +1,6 @@
<?php <?php
function verify_hostname(?string $host = null): void { function verifyHostname(?string $host = null): void {
global $config; global $config;
if ($host === null) { if ($host === null) {
@ -23,7 +23,7 @@ function verify_hostname(?string $host = null): void {
} }
} }
if (is_cli() && str_ends_with(__DIR__, 'www-dev')) if (isCli() && str_ends_with(__DIR__, 'www-dev'))
$config['is_dev'] = true; $config['is_dev'] = true;
} }
@ -70,7 +70,7 @@ function imageopen(string $filename) {
return call_user_func('imagecreatefrom'.$types[$size[2]], $filename); return call_user_func('imagecreatefrom'.$types[$size[2]], $filename);
} }
function detect_image_type(string $filename) { function detectImageType(string $filename) {
$size = getimagesize($filename); $size = getimagesize($filename);
$types = [ $types = [
1 => 'gif', 1 => 'gif',
@ -167,7 +167,7 @@ function ulong2ip(int $ip): string {
return long2ip(-$long); return long2ip(-$long);
} }
function from_camel_case(string $s): string { function fromCamelCase(string $s): string {
$buf = ''; $buf = '';
$len = strlen($s); $len = strlen($s);
for ($i = 0; $i < $len; $i++) { for ($i = 0; $i < $len; $i++) {
@ -180,11 +180,11 @@ function from_camel_case(string $s): string {
return $buf; 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))); 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); $pos = strpos($haystack, $needle);
if ($pos !== false) if ($pos !== false)
$haystack = substr_replace($haystack, $replace, $pos, strlen($needle)); $haystack = substr_replace($haystack, $replace, $pos, strlen($needle));
@ -205,7 +205,7 @@ function strgen(int $len): string {
return $buf; return $buf;
} }
function sanitize_filename(string $name): string { function sanitizeFilename(string $name): string {
$name = mb_strtolower($name); $name = mb_strtolower($name);
$name = transliterate($name); $name = transliterate($name);
$name = preg_replace('/[^\w\d\-_\s.]/', '', $name); $name = preg_replace('/[^\w\d\-_\s.]/', '', $name);
@ -213,31 +213,6 @@ function sanitize_filename(string $name): string {
return $name; 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 { function setperm(string $file): void {
global $config; global $config;
@ -259,7 +234,7 @@ function setperm(string $file): void {
} }
} }
function salt_password(string $pwd): string { function saltPassword(string $pwd): string {
global $config; global $config;
return hash('sha256', "{$pwd}|{$config['password_salt']}"); return hash('sha256', "{$pwd}|{$config['password_salt']}");
} }
@ -285,19 +260,25 @@ function lang() {
global $__lang; global $__lang;
return call_user_func_array([$__lang, 'get'], func_get_args()); return call_user_func_array([$__lang, 'get'], func_get_args());
} }
function lang_num() { function langNum() {
global $__lang; global $__lang;
return call_user_func_array([$__lang, 'num'], func_get_args()); return call_user_func_array([$__lang, 'num'], func_get_args());
} }
function is_dev(): bool { global $config; return $config['is_dev']; } function isDev(): bool { global $config; return $config['is_dev']; }
function is_cli(): bool { return PHP_SAPI == 'cli'; }; function isCli(): bool { return PHP_SAPI == 'cli'; };
function is_retina(): bool { return isset($_COOKIE['is_retina']) && $_COOKIE['is_retina']; } 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 jsonEncode($obj): ?string { return json_encode($obj, JSON_UNESCAPED_UNICODE) ?: null; }
function jsonDecode($json) { return json_decode($json, true); } 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 ($result === null) {
if (preg_last_error() !== PREG_NO_ERROR) { if (preg_last_error() !== PREG_NO_ERROR) {
if (!$no_error) if (!$no_error)
@ -308,7 +289,7 @@ function pcre_no_error(mixed &$result, bool $no_error = false): bool {
return true; 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)) if (is_null($keywords))
return htmlescape($s); return htmlescape($s);
@ -369,7 +350,7 @@ function hl_matched(string $s, string|Stringable|SkinString|array|null $keywords
return $buf; return $buf;
} }
function format_time($ts, array $opts = array()) { function formatTime($ts, array $opts = array()) {
$default_opts = [ $default_opts = [
'date_only' => false, 'date_only' => false,
'day_of_week' => false, 'day_of_week' => false,
@ -411,30 +392,3 @@ function format_time($ts, array $opts = array()) {
return $date; 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;
}

View File

@ -1,166 +0,0 @@
<?php
require_once 'lib/files.php';
class FilesHandler extends request_handler {
const SEARCH_RESULTS_PER_PAGE = 50;
const SEARCH_MIN_QUERY_LENGTH = 3;
function GET_files() {
add_meta([
'$title' => '$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);
}
}

View File

@ -1,206 +0,0 @@
<?php
class MainHandler extends request_handler {
function GET_index() {
global $config;
$posts_lang = PostLanguage::English;
$posts = posts::getList(0, 3,
include_hidden: is_admin(),
filter_by_lang: $posts_lang);
add_meta([
'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'
]);
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);
}
}

View File

@ -2,50 +2,53 @@
class AdminHandler extends request_handler { class AdminHandler extends request_handler {
function __construct() { public function __construct() {
parent::__construct(); parent::__construct();
add_static('css/admin.css', 'js/admin.js'); $this->skin->addStatic('css/admin.css', 'js/admin.js');
add_skin_strings(['error']); $this->skin->exportStrings(['error']);
set_skin_opts(['inside_admin_interface' => true]); $this->skin->setRenderOptions(['inside_admin_interface' => true]);
} }
function before_dispatch(string $http_method, string $action) { public function beforeDispatch(string $http_method, string $action) {
if ($action != 'login' && !is_admin()) if ($action != 'login' && !isAdmin())
forbidden(); self::forbidden();
} }
function GET_index() { public function GET_index() {
global $AdminSession;
//$admin_info = admin_current_info(); //$admin_info = admin_current_info();
set_title('$admin_title'); $this->skin->setTitle('$admin_title');
render('admin/index', $this->skin->renderPage('admin_index.twig', [
admin_login: $AdminSession->login); 'admin_login' => admin::getLogin(),
'logout_token' => self::getCSRF('logout'),
]);
} }
function GET_login() { public function GET_login() {
if (is_admin()) if (isAdmin())
redirect('/admin/'); self::redirect('/admin/');
set_title('$admin_title'); $this->skin->setTitle('$admin_title');
render('admin/login'); $this->skin->renderPage('admin_login.twig', [
'form_token' => self::getCSRF('adminlogin'),
]);
} }
function POST_login() { public function POST_login() {
csrf_check('adminlogin'); self::checkCSRF('adminlogin');
list($login, $password) = input('login, password'); list($login, $password) = $this->input('login, password');
admin_auth($login, $password) admin::auth($login, $password)
? redirect('/admin/') ? self::redirect('/admin/')
: forbidden(); : self::forbidden();
} }
function GET_logout() { public function GET_logout() {
csrf_check('logout'); self::checkCSRF('logout');
admin_logout(); admin::logout();
redirect('/admin/login/', HTTPCode::Found); self::redirect('/admin/login/', HTTPCode::Found);
} }
function GET_errors() { public function GET_errors() {
list($ip, $query, $url_query, $file_query, $line_query, $per_page) 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) if (!$per_page)
$per_page = 100; $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)); $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"); $q = $db->query("SELECT *, INET_NTOA(ip) ip_s FROM backend_errors $sql_where ORDER BY id DESC LIMIT $offset, $per_page");
$list = []; $list = [];
while ($row = $db->fetch($q)) { while ($row = $db->fetch($q)) {
$row['date'] = format_time($row['ts'], [ $row['date'] = formatTime($row['ts'], [
'seconds' => true, 'seconds' => true,
'short_months' => true, 'short_months' => true,
]); ]);
@ -138,22 +141,20 @@ class AdminHandler extends request_handler {
$query_var_names = ['query', 'url_query', 'file_query', 'line_query']; $query_var_names = ['query', 'url_query', 'file_query', 'line_query'];
foreach ($query_var_names as $query_var_name) { foreach ($query_var_names as $query_var_name) {
if ($$query_var_name) { if ($$query_var_name)
$vars += [$query_var_name => $$query_var_name]; $vars += [$query_var_name => $$query_var_name];
}
} }
set_skin_opts(['wide' => true]); $this->skin->setRenderOptions(['wide' => true]);
set_title('$admin_errors'); $this->skin->setTitle('$admin_errors');
render('admin/errors', $this->skin->renderPage('admin_errors.twig', $vars);
...$vars);
} }
function GET_auth_log() { public function GET_auth_log() {
$db = DB(); $db = DB();
$count = (int)$db->result($db->query("SELECT COUNT(*) FROM admin_log")); $count = (int)$db->result($db->query("SELECT COUNT(*) FROM admin_log"));
$per_page = 100; $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 *, $q = $db->query("SELECT *,
INET_NTOA(ip) AS ip, INET_NTOA(ip) AS ip,
@ -167,24 +168,23 @@ class AdminHandler extends request_handler {
if (!empty($list)) { if (!empty($list)) {
$list = array_map(function($item) { $list = array_map(function($item) {
$item['date'] = format_time($item['ts']); $item['date'] = formatTime($item['ts']);
$item['activity_ts_s'] = format_time($item['activity_ts']); $item['activity_ts_s'] = formatTime($item['activity_ts']);
return $item; return $item;
}, $list); }, $list);
} }
$vars = [ $this->skin->setRenderOptions(['wide' => true]);
$this->skin->setTitle('$admin_auth_log');
$this->skin->set([
'list' => $list, 'list' => $list,
'pn_page' => $page, 'pn_page' => $page,
'pn_pages' => $pages 'pn_pages' => $pages
]; ]);
set_skin_opts(['wide' => true]); $this->skin->renderPage('admin_auth_log.twig');
set_title('$admin_auth_log');
render('admin/auth_log',
...$vars);
} }
function GET_actions_log() { public function GET_actions_log() {
$field_types = \AdminActions\Util\Logger::getFieldTypes(); $field_types = \AdminActions\Util\Logger::getFieldTypes();
foreach ($field_types as $type_prefix => $type_data) { foreach ($field_types as $type_prefix => $type_data) {
for ($i = 1; $i <= $type_data['count']; $i++) { for ($i = 1; $i <= $type_data['count']; $i++) {
@ -197,7 +197,7 @@ class AdminHandler extends request_handler {
$per_page = 100; $per_page = 100;
$count = \AdminActions\Util\Logger::getRecordsCount(); $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_ids = [];
$admin_logins = []; $admin_logins = [];
@ -210,7 +210,7 @@ class AdminHandler extends request_handler {
} }
if (!empty($admin_ids)) 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/?'; $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, 'list' => $records,
'pn_page' => $page, 'pn_page' => $page,
'pn_pages' => $pages, 'pn_pages' => $pages,
'admin_logins' => $admin_logins, 'admin_logins' => $admin_logins,
'url' => $url, 'url' => $url,
'action_types' => \AdminActions\Util\Logger::getActions(true), '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() { public function GET_uploads() {
list($error) = input('error'); list($error) = $this->input('error');
$uploads = uploads::getAllUploads(); $uploads = uploads::getAllUploads();
set_title('$blog_upload'); $this->skin->setTitle('$blog_upload');
render('admin/uploads', $this->skin->renderPage('admin_uploads.twig', [
error: $error, 'error' => $error,
uploads: $uploads, 'uploads' => $uploads,
langs: PostLanguage::cases()); 'langs' => PostLanguage::casesAsStrings(),
'form_token' => self::getCSRF('add_upload'),
]);
} }
function POST_uploads() { public function POST_uploads() {
csrf_check('addupl'); self::checkCSRF('add_upload');
list($custom_name, $note_en, $note_ru) = $this->input('name, note_en, note_ru');
list($custom_name, $note_en, $note_ru) = input('name, note_en, note_ru');
if (!isset($_FILES['files'])) if (!isset($_FILES['files']))
redirect('/admin/uploads/?error='.urlencode('no file')); self::redirect('/admin/uploads/?error='.urlencode('no file'));
$files = []; $files = [];
for ($i = 0; $i < count($_FILES['files']['name']); $i++) { for ($i = 0; $i < count($_FILES['files']['name']); $i++) {
@ -275,14 +273,14 @@ class AdminHandler extends request_handler {
foreach ($files as $f) { foreach ($files as $f) {
if ($f['error']) 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']) if (!$f['size'])
redirect('/admin/uploads/?error='.urlencode('received empty file')); self::redirect('/admin/uploads/?error='.urlencode('received empty file'));
$ext = extension($f['name']); $ext = extension($f['name']);
if (!uploads::isExtensionAllowed($ext)) 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']; $name = $custom_name ?: $f['name'];
$upload_id = uploads::add( $upload_id = uploads::add(
@ -292,36 +290,36 @@ class AdminHandler extends request_handler {
$note_ru); $note_ru);
if (!$upload_id) 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() { public function GET_upload_delete() {
list($id) = input('i:id'); list($id) = $this->input('i:id');
$upload = uploads::get($id); $upload = uploads::get($id);
if (!$upload) if (!$upload)
redirect('/admin/uploads/?error='.urlencode('upload not found')); self::redirect('/admin/uploads/?error='.urlencode('upload not found'));
csrf_check('delupl'.$id); self::checkCSRF('delupl'.$id);
uploads::delete($id); uploads::delete($id);
admin_log(new \AdminActions\UploadsDelete($id)); admin::log(new \AdminActions\UploadsDelete($id));
redirect('/admin/uploads/'); self::redirect('/admin/uploads/');
} }
function POST_upload_edit_note() { public function POST_upload_edit_note() {
list($id, $note, $lang) = input('i:id, note, lang'); list($id, $note, $lang) = $this->input('i:id, note, lang');
$lang = PostLanguage::tryFrom($lang); $lang = PostLanguage::tryFrom($lang);
if (!$lang) if (!$lang)
not_found(); self::notFound();
$upload = uploads::get($id); $upload = uploads::get($id);
if (!$upload) 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); $upload->setNote($lang, $note);
$texts = posts::getTextsWithUpload($upload); $texts = posts::getTextsWithUpload($upload);
@ -332,13 +330,13 @@ class AdminHandler extends request_handler {
} }
} }
admin_log(new \AdminActions\UploadsEditNote($id, $note, $lang->value)); admin::log(new \AdminActions\UploadsEditNote($id, $note, $lang->value));
redirect('/admin/uploads/'); self::redirect('/admin/uploads/');
} }
function POST_ajax_md_preview() { public function POST_ajax_md_preview() {
ensure_xhr(); self::ensureXhr();
list($md, $title, $use_image_previews, $lang, $is_page) = input('md, title, b:use_image_previews, lang, b:is_page'); 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); $lang = PostLanguage::tryFrom($lang);
if (!$lang) if (!$lang)
$lang = PostLanguage::getDefault(); $lang = PostLanguage::getDefault();
@ -347,37 +345,48 @@ class AdminHandler extends request_handler {
$title = ''; $title = '';
} }
$html = markup::markdownToHtml($md, $use_image_previews, $lang); $html = markup::markdownToHtml($md, $use_image_previews, $lang);
$ctx = skin('admin'); $html = $this->skin->render('markdown_preview.twig', [
$html = $ctx->markdownPreview( 'unsafe_html' => $html,
unsafe_html: $html, 'title' => $title
title: $title ]);
); self::ajaxOk(['html' => $html]);
ajax_ok(['html' => $html]);
} }
function GET_page_add() { public function GET_page_add() {
list($name) = input('short_name'); list($name) = $this->input('short_name');
$page = pages::getByName($name); $page = pages::getByName($name);
if ($page) if ($page)
redirect($page->getUrl(), code: HTTPCode::Found); self::redirect($page->getUrl(), code: HTTPCode::Found);
add_skin_strings_re('/^(err_)?pages_/'); $this->skin->exportStrings('/^(err_)?pages_/');
add_skin_strings_re('/^(err_)?blog_/'); $this->skin->exportStrings('/^(err_)?blog_/');
set_title(lang('pages_create_title', $name)); $this->skin->setTitle(lang('pages_create_title', $name));
static::make_wide(); $this->setWidePageOptions();
render('admin/pageForm',
short_name: $name, $js_params = [
title: '', 'pages' => true,
text: '', 'edit' => false,
langs: PostLanguage::cases()); '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() { public function POST_page_add() {
csrf_check('addpage'); 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); $page = pages::getByName($name);
if ($page) if ($page)
not_found(); self::notFound();
$error_code = null; $error_code = null;
@ -388,48 +397,48 @@ class AdminHandler extends request_handler {
} }
if ($error_code) if ($error_code)
ajax_error(['code' => $error_code]); self::ajaxError(['code' => $error_code]);
if (!pages::add([ if (!pages::add([
'short_name' => $name, 'short_name' => $name,
'title' => $title, 'title' => $title,
'md' => $text '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); $page = pages::getByName($name);
ajax_ok(['url' => $page->getUrl()]); self::ajaxOk(['url' => $page->getUrl()]);
} }
function GET_page_delete() { public function GET_page_delete() {
list($name) = input('short_name'); list($name) = $this->input('short_name');
$page = pages::getByName($name); $page = pages::getByName($name);
if (!$page) if (!$page)
not_found(); self::notFound();
$url = $page->getUrl(); $url = $page->getUrl();
csrf_check('delpage'.$page->shortName); self::checkCSRF('delpage'.$page->shortName);
pages::delete($page); pages::delete($page);
admin_log(new \AdminActions\PageDelete($name)); admin::log(new \AdminActions\PageDelete($name));
redirect($url, code: HTTPCode::Found); self::redirect($url, code: HTTPCode::Found);
} }
function GET_page_edit() { public function GET_page_edit() {
list($short_name, $saved) = input('short_name, b:saved'); list($short_name, $saved) = $this->input('short_name, b:saved');
$page = pages::getByName($short_name); $page = pages::getByName($short_name);
if (!$page) if (!$page)
not_found(); self::notFound();
add_skin_strings_re('/^(err_)?pages_/'); $this->skin->exportStrings('/^(err_)?pages_/');
add_skin_strings_re('/^(err_)?blog_/'); $this->skin->exportStrings('/^(err_)?blog_/');
set_title(lang('pages_page_edit_title', $page->shortName)); $this->skin->setTitle(lang('pages_page_edit_title', $page->shortName));
static::make_wide(); $this->setWidePageOptions();
$js_text = [ $js_text = [
'text' => $page->md, 'text' => $page->md,
'title' => $page->title, 'title' => $page->title,
@ -442,32 +451,43 @@ class AdminHandler extends request_handler {
$parent = $parent_page->shortName; $parent = $parent_page->shortName;
} }
render('admin/pageForm', $js_params = [
is_edit: true, 'pages' => true,
short_name: $page->shortName, 'edit' => true,
title: $page->title, 'token' => self::getCSRF('editpage'.$short_name),
text: $page->md, 'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()), // still needed for draft erasing
visible: $page->visible, 'text' => [
render_title: $page->renderTitle, 'text' => $page->md,
parent: $parent, 'title' => $page->title,
saved: $saved, ]
langs: PostLanguage::cases(), ];
js_text: $js_text);
$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() { public function POST_page_edit() {
ensure_xhr(); self::ensureXhr();
list($short_name) = $this->input('short_name');
list($short_name) = input('short_name');
$page = pages::getByName($short_name); $page = pages::getByName($short_name);
if (!$page) 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) 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); $text = trim($text);
$title = trim($title); $title = trim($title);
@ -482,7 +502,7 @@ class AdminHandler extends request_handler {
} }
if ($error_code) if ($error_code)
ajax_error(['code' => $error_code]); self::ajaxError(['code' => $error_code]);
$new_short_name = $page->shortName != $short_name ? $short_name : null; $new_short_name = $page->shortName != $short_name ? $short_name : null;
$parent_page = pages::getByName($parent); $parent_page = pages::getByName($parent);
@ -498,14 +518,14 @@ class AdminHandler extends request_handler {
'parent_id' => $parent_id 'parent_id' => $parent_id
]); ]);
admin_log(new \AdminActions\PageEdit($short_name, $new_short_name)); admin::log(new \AdminActions\PageEdit($short_name, $new_short_name));
ajax_ok(['url' => $page->getUrl().'edit/?saved=1']); self::ajaxOk(['url' => $page->getUrl().'edit/?saved=1']);
} }
function GET_post_add() { public function GET_post_add() {
add_skin_strings_re('/^(err_)?blog_/'); $this->skin->exportStrings('/^(err_)?blog_/');
set_title('$blog_write'); $this->skin->setTitle('$blog_write');
static::make_wide(); $this->setWidePageOptions();
$js_texts = []; $js_texts = [];
foreach (PostLanguage::cases() as $pl) { foreach (PostLanguage::cases() as $pl) {
@ -517,30 +537,47 @@ class AdminHandler extends request_handler {
]; ];
} }
render('admin/postForm', $js_params = [
title: '', 'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()),
text: '', 'token' => self::getCSRF('post_add')
langs: PostLanguage::cases(), ];
short_name: '', $form_url = '/articles/write/';
source_url: '',
keywords: '', $bc = [
js_texts: $js_texts, ['url' => '/articles/?lang='.PostLanguage::getDefault()->value, 'text' => lang('articles')],
lang: PostLanguage::getDefault()->value); ['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() { public function POST_post_add() {
ensure_xhr(); self::ensureXhr();
csrf_check('post_add'); self::checkCSRF('post_add');
list($visibility_enabled, $short_name, $langs, $date) 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); self::_postEditValidateCommonData($date);
$lang_data = []; $lang_data = [];
$at_least_one_lang_is_written = false; $at_least_one_lang_is_written = false;
foreach (PostLanguage::cases() as $lang) { 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 !== '') { if ($title !== '' && $text !== '') {
$lang_data[$lang->value] = [$title, $text, $keywords, $toc_enabled]; $lang_data[$lang->value] = [$title, $text, $keywords, $toc_enabled];
$at_least_one_lang_is_written = true; $at_least_one_lang_is_written = true;
@ -554,7 +591,7 @@ class AdminHandler extends request_handler {
$error_code = 'no_short_name'; $error_code = 'no_short_name';
} }
if ($error_code) if ($error_code)
ajax_error(['code' => $error_code]); self::ajaxError(['code' => $error_code]);
$post = posts::add([ $post = posts::add([
'visible' => $visibility_enabled, 'visible' => $visibility_enabled,
@ -564,7 +601,7 @@ class AdminHandler extends request_handler {
]); ]);
if (!$post) if (!$post)
ajax_error(['code' => 'db_err', 'message' => 'failed to add post']); self::ajaxError(['code' => 'db_err', 'message' => 'failed to add post']);
// add texts // add texts
$added_texts = []; // for admin actions logging, at the end $added_texts = []; // for admin actions logging, at the end
@ -578,47 +615,47 @@ class AdminHandler extends request_handler {
toc: $toc_enabled)) toc: $toc_enabled))
) { ) {
posts::delete($post); 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 { } else {
$added_texts[] = [$new_post_text->id, $lang]; $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) { foreach ($added_texts as $added_text) {
list($id, $lang) = $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 // done
ajax_ok(['url' => $post->getUrl()]); self::ajaxOk(['url' => $post->getUrl()]);
} }
function GET_post_delete() { public function GET_post_delete() {
list($name) = input('short_name'); list($name) = $this->input('short_name');
$post = posts::getByName($name); $post = posts::getByName($name);
if (!$post) if (!$post)
not_found(); self::notFound();
$id = $post->id; $id = $post->id;
csrf_check('delpost'.$id); self::checkCSRF('delpost'.$id);
posts::delete($post); posts::delete($post);
admin_log(new \AdminActions\PostDelete($id)); admin::log(new \AdminActions\PostDelete($id));
redirect('/articles/', code: HTTPCode::Found); self::redirect('/articles/', code: HTTPCode::Found);
} }
function GET_post_edit() { public function GET_post_edit() {
list($short_name, $saved, $lang) = input('short_name, b:saved, lang'); list($short_name, $saved, $lang) = $this->input('short_name, b:saved, lang');
$lang = PostLanguage::from($lang); $lang = PostLanguage::from($lang);
$post = posts::getByName($short_name); $post = posts::getByName($short_name);
if (!$post) if (!$post)
not_found(); self::notFound();
$texts = $post->getTexts(); $texts = $post->getTexts();
if (!isset($texts[$lang->value])) if (!isset($texts[$lang->value]))
not_found(); self::notFound();
$js_texts = []; $js_texts = [];
foreach (PostLanguage::cases() as $pl) { foreach (PostLanguage::cases() as $pl) {
@ -642,48 +679,64 @@ class AdminHandler extends request_handler {
$text = $texts[$lang->value]; $text = $texts[$lang->value];
add_skin_strings_re('/^(err_)?blog_/'); $this->skin->exportStrings('/^(err_)?blog_/');
add_skin_strings(['blog_post_edit_title']); $this->skin->exportStrings(['blog_post_edit_title']);
set_title(lang('blog_post_edit_title', $text->title)); $this->skin->setTitle(lang('blog_post_edit_title', $text->title));
static::make_wide(); $this->setWidePageOptions();
render('admin/postForm',
is_edit: true, $bc = [
post_id: $post->id, ['url' => '/articles/?lang='.$text->lang->value, 'text' => lang('articles')],
post_url: $post->getUrl(), ['url' => $post->getUrl().'?lang='.$text->lang->value, 'text' => lang('blog_view_post')]
title: $text->title, ];
text: $text->md,
date: $post->getDateForInputField(), $js_params = [
visible: $post->visible, 'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()),
toc: $text->toc, 'token' => self::getCSRF('editpost'.$post->id),
saved: $saved, 'edit' => true,
short_name: $short_name, 'id' => $post->id,
source_url: $post->sourceUrl, 'texts' => $js_texts
keywords: $text->keywords, ];
langs: PostLanguage::cases(), $form_url = $post->getUrl().'edit/';
lang: $text->lang->value,
js_texts: $js_texts $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() { public function POST_post_edit() {
ensure_xhr(); 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); $post = posts::getByName($old_short_name);
if (!$post) if (!$post)
not_found(); self::notFound();
csrf_check('editpost'.$post->id); self::checkCSRF('editpost'.$post->id);
self::_postEditValidateCommonData($date); self::_postEditValidateCommonData($date);
if (empty($short_name)) if (empty($short_name))
ajax_error(['code' => 'no_short_name']); self::ajaxError(['code' => 'no_short_name']);
foreach (explode(',', $langs) as $lang) { foreach (explode(',', $langs) as $lang) {
$lang = PostLanguage::from($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; $error_code = null;
if (!$title) if (!$title)
@ -691,7 +744,7 @@ class AdminHandler extends request_handler {
else if (!$text) else if (!$text)
$error_code = 'no_text'; $error_code = 'no_text';
if ($error_code) if ($error_code)
ajax_error(['code' => $error_code]); self::ajaxError(['code' => $error_code]);
$pt = $post->getText($lang); $pt = $post->getText($lang);
if (!$pt) { if (!$pt) {
@ -703,7 +756,7 @@ class AdminHandler extends request_handler {
toc: $toc toc: $toc
); );
if (!$pt) if (!$pt)
ajax_error(['code' => 'db_err']); self::ajaxError(['code' => 'db_err']);
} else { } else {
previous_texts::add(PreviousText::TYPE_POST_TEXT, $pt->id, $pt->md, $post->getUpdateTimestamp() ?: $post->getTimestamp()); previous_texts::add(PreviousText::TYPE_POST_TEXT, $pt->id, $pt->md, $post->getUpdateTimestamp() ?: $post->getTimestamp());
$pt->edit([ $pt->edit([
@ -724,24 +777,24 @@ class AdminHandler extends request_handler {
$post_data['short_name'] = $short_name; $post_data['short_name'] = $short_name;
$post->edit($post_data); $post->edit($post_data);
admin_log(new \AdminActions\PostEdit($post->id)); admin::log(new \AdminActions\PostEdit($post->id));
ajax_ok(['url' => $post->getUrl().'edit/?saved=1&lang='.$lang->value]); self::ajaxOk(['url' => $post->getUrl().'edit/?saved=1&lang='.$lang->value]);
} }
function GET_books() { public function GET_books() {
set_title('$admin_books'); $this->skin->setTitle('$admin_books');
render('admin/books'); $this->skin->renderPage('admin_books.twig');
} }
protected static function _postEditValidateCommonData($date) { protected static function _postEditValidateCommonData($date) {
$dt = DateTime::createFromFormat("Y-m-d", $date); $dt = DateTime::createFromFormat("Y-m-d", $date);
$date_is_valid = $dt && $dt->format("Y-m-d") === $date; $date_is_valid = $dt && $dt->format("Y-m-d") === $date;
if (!$date_is_valid) if (!$date_is_valid)
ajax_error(['code' => 'no_date']); self::ajaxError(['code' => 'no_date']);
} }
protected static function make_wide() { protected function setWidePageOptions(): void {
set_skin_opts([ $this->skin->setRenderOptions([
'full_width' => true, 'full_width' => true,
'no_footer' => true 'no_footer' => true
]); ]);

220
handlers/FilesHandler.php Normal file
View File

@ -0,0 +1,220 @@
<?php
class FilesHandler extends request_handler {
const int SEARCH_RESULTS_PER_PAGE = 50;
const int SEARCH_MIN_QUERY_LENGTH = 3;
public function GET_files() {
$collections = array_map(fn(FilesCollection $c) => 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');
}
}

197
handlers/MainHandler.php Normal file
View File

@ -0,0 +1,197 @@
<?php
class MainHandler extends request_handler {
public function GET_index() {
global $config;
$posts_lang = PostLanguage::English;
$posts = posts::getList(0, 3,
include_hidden: isAdmin(),
filter_by_lang: $posts_lang);
$this->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
]);
}
}

View File

@ -2,7 +2,7 @@
class ServicesHandler extends request_handler { class ServicesHandler extends request_handler {
function GET_robots_txt() { public function GET_robots_txt() {
$txt = <<<TXT $txt = <<<TXT
User-agent: * User-agent: *
Disallow: /admin/ Disallow: /admin/
@ -13,12 +13,12 @@ TXT;
exit; exit;
} }
function GET_latest() { public function GET_latest() {
global $config; global $config;
list($lang) = input('lang'); list($lang) = $this->input('lang');
if (!isset($config['book_versions'][$lang])) if (!isset($config['book_versions'][$lang]))
not_found(); self::notFound();
redirect("https://files.4in1.ws/4in1-{$lang}.pdf?{$config['book_versions'][$lang]}", self::redirect("https://files.4in1.ws/4in1-{$lang}.pdf?{$config['book_versions'][$lang]}",
code: HTTPCode::Found); code: HTTPCode::Found);
} }

View File

@ -2,5 +2,4 @@
require_once __DIR__.'/../init.php'; require_once __DIR__.'/../init.php';
router_init(); request_handler::resolveAndDispatch();
dispatch_request();

View File

@ -5,7 +5,7 @@ global $config;
$name = $_REQUEST['name'] ?? ''; $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); http_response_code(403);
exit; exit;
} }

View File

@ -105,7 +105,7 @@ extend(AdminWriteEditForm.prototype, {
params.title = this.form.elements.title.value; params.title = this.form.elements.title.value;
params.lang = this.getCurrentLang(); 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.title = this.form.elements.title.value;
params.is_page = 1 params.is_page = 1
} }

View File

@ -135,12 +135,9 @@ var ThemeSwitcher = (function() {
/** /**
* @param {string} selectedMode * @param {string} selectedMode
*/ */
function setIcon(selectedMode) { function setLabel(selectedMode) {
document.body.setAttribute('data-theme', selectedMode); document.body.setAttribute('data-theme', selectedMode);
for (var i = 0; i < modes.length; i++) { ge('switch-theme').innerHTML = escape(selectedMode);
var mode = modes[i];
document.getElementById('svgicon_moon_'+mode+'_18').style.display = mode === selectedMode ? 'block': 'none';
}
} }
return { return {
@ -182,7 +179,7 @@ var ThemeSwitcher = (function() {
onSystemChange(window.matchMedia('(prefers-color-scheme: dark)').matches === true); onSystemChange(window.matchMedia('(prefers-color-scheme: dark)').matches === true);
} }
setIcon(modes[currentModeIndex]); setLabel(modes[currentModeIndex]);
}, },
next: function(e) { next: function(e) {
@ -214,7 +211,7 @@ var ThemeSwitcher = (function() {
break; break;
} }
setIcon(modes[currentModeIndex]); setLabel(modes[currentModeIndex]);
setCookie('theme', modes[currentModeIndex]); setCookie('theme', modes[currentModeIndex]);
return cancelEvent(e); return cancelEvent(e);

View File

@ -10,7 +10,7 @@ if ($theme != 'light' && $theme != 'dark') {
exit; 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'); // logError(__FILE__.': access denied');
http_response_code(403); http_response_code(403);
exit; exit;

View File

@ -240,7 +240,7 @@ table.contacts div.note {
padding: 40px 20px; padding: 40px 20px;
color: $grey; color: $grey;
@include radius(3px); @include radius(3px);
background-color: $dark-bg; background-color: $light-bg;
} }
.md-file-attach { .md-file-attach {

View File

@ -3,10 +3,21 @@
//border-radius: 5px; //border-radius: 5px;
padding: 15px 0; padding: 15px 0;
margin-top: 10px; margin-top: 10px;
text-align: right;
color: $dark_grey; color: $dark_grey;
> span { color: $fg; }
> a { &-right {
@include no-underline(true); float: right;
text-align: right;
}
&-right, &-left {
> span { color: $fg; }
> a {
@include no-underline(true);
}
}
&-separator {
opacity: 0.33;
} }
} }

View File

@ -71,10 +71,6 @@
} }
} }
//body:not(.theme-changing) .head-logo {
// @include transition(background-color, 0.03s);
//}
.head-items { .head-items {
text-align: right; text-align: right;
display: table-cell; display: table-cell;
@ -101,6 +97,9 @@ a.head-item {
height: 18px; height: 18px;
} }
} }
&.is-ic {
color: $link-color;
}
&:hover, &.is-selected { &:hover, &.is-selected {
border-radius: 4px; border-radius: 4px;
@ -119,7 +118,6 @@ body a.head-item.is-settings svg path {
fill: $fg; fill: $fg;
} }
#svgicon_moon_light_18, #svgicon_moon_light_18,
#svgicon_moon_dark_18, #svgicon_moon_dark_18,
#svgicon_moon_auto_18 { #svgicon_moon_auto_18 {

View File

@ -14,42 +14,20 @@ define('START_TIME', microtime(true));
set_include_path(get_include_path().PATH_SEPARATOR.APP_ROOT); set_include_path(get_include_path().PATH_SEPARATOR.APP_ROOT);
spl_autoload_register(function($class) { 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, '\\')) if (str_contains($class, '\\'))
$class = str_replace('\\', '/', $class); $class = str_replace('\\', '/', $class);
$path = null; if ($class == 'model')
foreach (['Handler', 'Helper'] as $sfx) { $path = 'engine/model';
if (str_ends_with($class, $sfx)) { else if (str_ends_with($class, 'Handler'))
$path = APP_ROOT.'/'.strtolower($sfx).'/'.$class.'.php'; $path = 'handlers/'.$class;
break; else
} $path = 'lib/'.$class;
}
if (is_null($path)) { if (!is_file(APP_ROOT.'/'.$path.'.php'))
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))
return; return;
require_once $path; require_once APP_ROOT.'/'.$path.'.php';
}); });
if (!file_exists(APP_ROOT.'/config.yaml')) if (!file_exists(APP_ROOT.'/config.yaml'))
@ -69,12 +47,12 @@ require_once 'engine/request.php';
require_once 'engine/logging.php'; require_once 'engine/logging.php';
try { try {
if (is_cli()) { if (isCli()) {
verify_hostname($config['domain']); verifyHostname($config['domain']);
$_SERVER['HTTP_HOST'] = $config['domain']; $_SERVER['HTTP_HOST'] = $config['domain'];
$_SERVER['REMOTE_ADDR'] = '127.0.0.1'; $_SERVER['REMOTE_ADDR'] = '127.0.0.1';
} else { } else {
verify_hostname(); verifyHostname();
if (array_key_exists('HTTP_X_REAL_IP', $_SERVER)) if (array_key_exists('HTTP_X_REAL_IP', $_SERVER))
$_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_X_REAL_IP']; $_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_X_REAL_IP'];
@ -86,12 +64,12 @@ try {
die('Fatal error: '.$e->getMessage()); die('Fatal error: '.$e->getMessage());
} }
$__logger = is_dev() $__logger = isDev()
? new FileLogger(APP_ROOT.'/log/debug.log') ? new FileLogger(APP_ROOT.'/log/debug.log')
: new DatabaseLogger(); : new DatabaseLogger();
$__logger->enable(); $__logger->enable();
if (!is_dev()) { if (!isDev()) {
if (file_exists(APP_ROOT.'/config-static.php')) if (file_exists(APP_ROOT.'/config-static.php'))
$config['static'] = require_once 'config-static.php'; $config['static'] = require_once 'config-static.php';
else else
@ -102,7 +80,7 @@ if (!is_dev()) {
ini_set('display_errors', 0); ini_set('display_errors', 0);
} }
if (!is_cli()) { if (!isCli()) {
$__lang = Strings::getInstance(); $__lang = Strings::getInstance();
$__lang->load('main'); $__lang->load('main');
} }

View File

@ -55,7 +55,7 @@ abstract class BaseAction {
} }
public function getDate(): string { public function getDate(): string {
return format_time($this->timeStamp, ['short_months' => true]); return formatTime($this->timeStamp, ['short_months' => true]);
} }
public function getTimeStamp(): int { public function getTimeStamp(): int {
@ -70,7 +70,7 @@ abstract class BaseAction {
return $this->recordId; return $this->recordId;
} }
function renderHtml(): string { public function renderHtml(): string {
$rc = new \ReflectionClass($this); $rc = new \ReflectionClass($this);
$lines = []; $lines = [];
$fields = $rc->getProperties(\ReflectionProperty::IS_PUBLIC); $fields = $rc->getProperties(\ReflectionProperty::IS_PUBLIC);

View File

@ -1,6 +1,6 @@
<?php <?php
namespace AdminActions\util; namespace AdminActions\Util;
use AdminActions\BaseAction; use AdminActions\BaseAction;
@ -11,20 +11,18 @@ class Logger {
const TABLE = 'admin_actions'; const TABLE = 'admin_actions';
const INTS_COUNT = 6; const int INTS_COUNT = 6;
const INTS_PREFIX = 'i'; const string INTS_PREFIX = 'i';
const VARCHARS_COUNT = 2; const int VARCHARS_COUNT = 2;
const VARCHARS_PREFIX = 'c'; const string VARCHARS_PREFIX = 'c';
const SERIALIZED_COUNT = 1; const int SERIALIZED_COUNT = 1;
const SERIALIZED_PREFIX = 's'; const string SERIALIZED_PREFIX = 's';
protected static ?array $classes = null; protected static ?array $classes = null;
public static function record(BaseAction $action): int { public static function record(BaseAction $action): int {
global $AdminSession;
$packed = self::pack($action); $packed = self::pack($action);
$data = [ $data = [
@ -32,13 +30,13 @@ class Logger {
'ts' => time(), 'ts' => time(),
]; ];
if (is_cli()) { if (isCli()) {
$data += [ $data += [
'cli' => 1, 'cli' => 1,
]; ];
} else { } else {
$data += [ $data += [
'admin_id' => $AdminSession->id, 'admin_id' => \admin::getId(),
'ip' => !empty($_SERVER['REMOTE_ADDR']) ? ip2ulong($_SERVER['REMOTE_ADDR']) : 0, 'ip' => !empty($_SERVER['REMOTE_ADDR']) ? ip2ulong($_SERVER['REMOTE_ADDR']) : 0,
]; ];
} }

View File

@ -0,0 +1,62 @@
<?php
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())
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;
}
}

6
lib/BookCategory.php Normal file
View File

@ -0,0 +1,6 @@
<?php
enum BookCategory: string {
case BOOKS = 'books';
case MISC = 'misc';
}

7
lib/BookFileType.php Normal file
View File

@ -0,0 +1,7 @@
<?php
enum BookFileType: string {
case NONE = 'none';
case BOOK = 'book';
case ARTICLE = 'article';
}

87
lib/BookItem.php Normal file
View File

@ -0,0 +1,87 @@
<?php
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;
}
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 = '<b class="is-author">'.htmlescape($this->author).'</b><span class="is-title">';
if (!str_ends_with($this->author, '.'))
$buf .= '.';
$buf .= ' '.htmlescape($this->title).'</span>';
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;
}
}

21
lib/CollectionItem.php Normal file
View File

@ -0,0 +1,21 @@
<?php
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; }
}

7
lib/FilesCollection.php Normal file
View File

@ -0,0 +1,7 @@
<?php
enum FilesCollection: string {
case WilliamFriedman = 'wff';
case MercureDeFrance = 'mdf';
case Baconiana = 'baconiana';
}

View File

@ -0,0 +1,15 @@
<?php
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;
}

View File

@ -0,0 +1,6 @@
<?php
trait FilesItemSizeTrait {
public int $size;
public function getSize(): ?int { return $this->isFile() ? $this->size : null; }
}

6
lib/FilesItemType.php Normal file
View File

@ -0,0 +1,6 @@
<?php
enum FilesItemType: string {
case FILE = 'file';
case FOLDER = 'folder';
}

View File

@ -0,0 +1,19 @@
<?php
trait FilesItemTypeTrait {
public FilesItemType $type;
public function isFolder(): bool {
return $this->type == FilesItemType::FOLDER;
}
public function isFile(): bool {
return $this->type == FilesItemType::FILE;
}
public function isBook(): bool {
return $this instanceof BookItem && $this->fileType == BookFileType::BOOK;
}
}

83
lib/MDFCollectionItem.php Normal file
View File

@ -0,0 +1,83 @@
<?php
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 {
$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;
}
}

View File

@ -1,13 +1,12 @@
<?php <?php
require_once 'engine/skin.php'; require_once 'engine/skin.php';
require_once 'lib/posts.php';
class MyParsedown extends ParsedownExtended { class MyParsedown extends ParsedownExtended {
protected array $options; protected array $options;
function __construct( public function __construct(
?array $opts = null, ?array $opts = null,
protected bool $useImagePreviews = false, protected bool $useImagePreviews = false,
protected ?PostLanguage $lang = null, protected ?PostLanguage $lang = null,
@ -47,8 +46,12 @@ class MyParsedown extends ParsedownExtended {
unset($result['element']['text']); unset($result['element']['text']);
$ctx = self::getSkinContext(); $result['element']['rawHtml'] = skin::getInstance()->render('markdown_fileupload.twig', [
$result['element']['rawHtml'] = $ctx->fileupload($upload->name, $upload->getDirectUrl(), $upload->noteRu, $upload->getSize()); 'name' => $upload->name,
'direct_url' => $upload->getDirectUrl(),
'note' => $upload->noteRu,
'size' => $upload->getSize()
]);
return $result; return $result;
} }
@ -107,19 +110,19 @@ class MyParsedown extends ParsedownExtended {
unset($result['element']['text']); unset($result['element']['text']);
$ctx = self::getSkinContext(); $result['element']['rawHtml'] = skin::getInstance()->render('markdown_image.twig', [
$result['element']['rawHtml'] = $ctx->image( 'w' => $opts['w'],
w: $opts['w'], 'nolabel' => $opts['nolabel'],
nolabel: $opts['nolabel'], 'align' => $opts['align'],
align: $opts['align'], 'padding_top' => round($h / $w * 100, 4),
padding_top: round($h / $w * 100, 4), 'may_have_alpha' => $image->imageMayHaveAlphaChannel(),
may_have_alpha: $image->imageMayHaveAlphaChannel(),
url: $image_url, 'url' => $image_url,
direct_url: $image->getDirectUrl(), 'direct_url' => $image->getDirectUrl(),
unsafe_note: markup::markdownToHtml($this->lang !== null && $this->lang == PostLanguage::Russian ? $image->noteRu : $image->noteEn, 'unsafe_note' => markup::markdownToHtml(
no_paragraph: true) md: $this->lang !== null && $this->lang == PostLanguage::Russian ? $image->noteRu : $image->noteEn,
); no_paragraph: true),
]);
return $result; return $result;
} }
@ -166,12 +169,11 @@ class MyParsedown extends ParsedownExtended {
unset($result['element']['text']); unset($result['element']['text']);
$ctx = self::getSkinContext(); $result['element']['rawHtml'] = skin::getInstance()->render('markdown_video.twig', [
$result['element']['rawHtml'] = $ctx->video( 'url' => $video_url,
url: $video_url, 'w' => $opts['w'],
w: $opts['w'], 'h' => $opts['h']
h: $opts['h'] ]);
);
return $result; return $result;
} }
@ -218,8 +220,4 @@ class MyParsedown extends ParsedownExtended {
return parent::blockFencedCodeComplete($block); return parent::blockFencedCodeComplete($block);
} }
protected static function getSkinContext(): SkinContext {
return skin('markdown');
}
} }

47
lib/Page.php Normal file
View File

@ -0,0 +1,47 @@
<?php
class Page extends model {
const DB_TABLE = 'pages';
public int $id;
public int $parentId;
public string $title;
public string $md;
public string $html;
public int $ts;
public int $updateTs;
public bool $visible;
public bool $renderTitle;
public string $shortName;
public function edit(array $fields) {
$fields['update_ts'] = time();
if ($fields['md'] != $this->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);
}
}

129
lib/Post.php Normal file
View File

@ -0,0 +1,129 @@
<?php
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|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());
}
}

20
lib/PostLanguage.php Normal file
View File

@ -0,0 +1,20 @@
<?php
enum PostLanguage: string {
case Russian = 'ru';
case English = 'en';
public static function getDefault(): PostLanguage {
return self::English;
}
public function getIndex(): ?int {
return array_search($this->value, self::cases(), true);
}
public static function casesAsStrings(): array {
return array_map(fn($v) => $v->value, self::cases());
}
}

116
lib/PostText.php Normal file
View File

@ -0,0 +1,116 @@
<?php
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->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;
}
}

16
lib/PreviousText.php Normal file
View File

@ -0,0 +1,16 @@
<?php
class PreviousText extends model {
const DB_TABLE = 'previous_texts';
const int TYPE_POST_TEXT = 0x0;
const int TYPE_PAGE = 0x1;
public int $id;
public int $objectType;
public int $objectId;
public string $md;
public int $ts;
}

View File

@ -0,0 +1,39 @@
<?php
namespace TwigAddons;
#[\Twig\Attribute\YieldReady]
class JsTagNode extends \Twig\Node\Node {
public function compile(\Twig\Compiler $compiler) {
$count = count($this->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);
}
}

View File

@ -0,0 +1,6 @@
<?php
namespace TwigAddons;
#[\Twig\Attribute\YieldReady]
class JsTagParamsNode extends \Twig\Node\Node {}

View File

@ -0,0 +1,84 @@
<?php
namespace TwigAddons;
// Based on https://stackoverflow.com/questions/26170727/how-to-create-a-twig-custom-tag-that-executes-a-callback
class JsTagTokenParser extends \Twig\TokenParser\AbstractTokenParser {
public function parse(\Twig\Token $token) {
$lineno = $token->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';
}
}

View File

@ -0,0 +1,105 @@
<?php
namespace TwigAddons;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
class MyExtension extends AbstractExtension {
public function getFunctions() {
return [
new TwigFunction('svg', fn($name) => \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';
}
}

170
lib/Upload.php Normal file
View File

@ -0,0 +1,170 @@
<?php
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;
public function getDirectory(): string {
global $config;
return $config['uploads_dir'].'/'.$this->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 .= ' <!-- '.$this->name.' -->';
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)));
}
}

53
lib/WFFCollectionItem.php Normal file
View File

@ -0,0 +1,53 @@
<?php
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' => [
highlightSubstring($this->getDocumentId(), $hl_matched),
langNum('files_count', $this->filesCount)
]
];
}
return [
'inline' => false,
'items' => [
highlightSubstring('Document '.$this->documentId),
sizeString($this->size),
'PDF'
]
];
}
}

View File

@ -1,156 +1,132 @@
<?php <?php
const ADMIN_SESSION_TIMEOUT = 86400 * 14; class admin {
const ADMIN_COOKIE_NAME = 'admin_key';
const ADMIN_LOGIN_MAX_LENGTH = 32;
$AdminSession = new class { const int ADMIN_SESSION_TIMEOUT = 86400 * 14;
public function __construct( const string ADMIN_COOKIE_NAME = 'admin_key';
public ?int $id = null, const int ADMIN_LOGIN_MAX_LENGTH = 32;
public ?int $authId = null,
public ?string $csrfSalt = null,
public ?string $login = null,
) {}
public function mrProper(): void { // session data
$this->id = null; protected static ?int $id = null;
$this->authId = null; protected static ?int $authId = null;
$this->csrfSalt = null; protected static ?string $csrfSalt = null;
$this->login = 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 { public static function add(string $login, string $password): int {
$this->csrfSalt = salt_password(strrev($salted_password)); $db = DB();
$db->insert('admins', [
'login' => $login,
'password' => saltPassword($password),
'activity_ts' => 0
]);
return $db->insertId();
} }
};
function is_admin(): bool { public static function delete(string $login): bool {
global $AdminSession; $db = DB();
if ($AdminSession->id === null) $id = self::getIdByLogin($login);
_admin_check(); if (!$db->query("DELETE FROM admins WHERE login=?", $login)) return false;
return $AdminSession->id != 0; if (!$db->query("DELETE FROM admin_auth WHERE admin_id=?", $id)) return false;
} return true;
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'];
} }
return $logins;
}
function admin_get_id_by_login(string $login): ?int { /**
$db = DB(); * @param int[] $ids
$q = $db->query("SELECT id FROM admins WHERE login=?", $login); * @return string[]
return $db->numRows($q) > 0 ? (int)$db->result($q) : null; */
} 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 { protected static function getIdByLogin(string $login): ?int {
$db = DB(); $db = DB();
$db->query("UPDATE admins SET password=? WHERE login=?", salt_password($password), $login); $q = $db->query("SELECT id FROM admins WHERE login=?", $login);
return $db->affectedRows() > 0; return $db->numRows($q) > 0 ? (int)$db->result($q) : null;
} }
function admin_auth(string $login, string $password): bool { public static function setPassword(string $login, string $password): bool {
global $AdminSession; $db = DB();
$db->query("UPDATE admins SET password=? WHERE login=?", saltPassword($password), $login);
return $db->affectedRows() > 0;
}
$db = DB(); public static function auth(string $login, string $password): bool {
$salted_password = salt_password($password); $db = DB();
$q = $db->query("SELECT id, active FROM admins WHERE login=? AND password=?", $login, $salted_password); $salted_password = saltPassword($password);
if (!$db->numRows($q)) $q = $db->query("SELECT id, active FROM admins WHERE login=? AND password=?", $login, $salted_password);
return false; if (!$db->numRows($q)) {
logDebug(__METHOD__.': login or password is invalid');
return false;
}
$row = $db->fetch($q); $row = $db->fetch($q);
$id = (int)$row['id']; $id = (int)$row['id'];
$active = (bool)$row['active']; $active = (bool)$row['active'];
if (!$active) if (!$active)
return false; return false;
$time = time(); $time = time();
do { do {
$token = strgen(32); $token = strgen(32);
} while ($db->numRows($db->query("SELECT id FROM admin_auth WHERE token=? LIMIT 1", $token)) > 0); } while ($db->numRows($db->query("SELECT id FROM admin_auth WHERE token=? LIMIT 1", $token)) > 0);
$db->insert('admin_auth', [ $db->insert('admin_auth', [
'admin_id' => $id, 'admin_id' => $id,
'token' => $token, 'token' => $token,
'ts' => $time 'ts' => $time
]); ]);
$auth_id = $db->insertId(); $auth_id = $db->insertId();
$db->insert('admin_log', [ $db->insert('admin_log', [
'admin_id' => $id, 'admin_id' => $id,
'ts' => $time, 'ts' => $time,
'ip' => ip2ulong($_SERVER['REMOTE_ADDR']), 'ip' => ip2ulong($_SERVER['REMOTE_ADDR']),
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '', '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; self::setSessionData($id, $login, $auth_id, $salted_password);
$AdminSession->login = $login; self::setCookie($token);
$AdminSession->makeCSRFSalt($salted_password);
$AdminSession->authId = $auth_id;
_admin_set_cookie($token); return true;
return true; }
}
function admin_logout() { public static function logout() {
if (!is_admin()) if (!isAdmin())
return; return;
global $AdminSession; $db = DB();
$db->query("DELETE FROM admin_auth WHERE id=?", self::$authId);
$db = DB(); self::unsetSessionData();
$db->query("DELETE FROM admin_auth WHERE id=?", $AdminSession->authId); self::unsetCookie();
}
$AdminSession->mrProper(); public static function log(\AdminActions\BaseAction $action) {
_admin_unset_cookie(); \AdminActions\Util\Logger::record($action);
} }
function admin_log(\AdminActions\BaseAction $action) { public static function check(): void {
\AdminActions\Util\Logger::record($action); if (!isset($_COOKIE[self::ADMIN_COOKIE_NAME]))
} return;
function _admin_check(): void { $cookie = (string)$_COOKIE[self::ADMIN_COOKIE_NAME];
if (!isset($_COOKIE[ADMIN_COOKIE_NAME])) $db = DB();
return; $time = time();
$q = $db->query("SELECT
$cookie = (string)$_COOKIE[ADMIN_COOKIE_NAME];
$db = DB();
$time = time();
$q = $db->query("SELECT
admin_auth.id AS auth_id, admin_auth.id AS auth_id,
admin_auth.admin_id AS id, admin_auth.admin_id AS id,
admins.activity_ts AS activity_ts, admins.activity_ts AS activity_ts,
@ -161,27 +137,44 @@ function _admin_check(): void {
WHERE admin_auth.token=? WHERE admin_auth.token=?
LIMIT 1", $cookie); LIMIT 1", $cookie);
if (!$db->numRows($q)) if (!$db->numRows($q)) {
return; 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; if ($time - $info['activity_ts'] > 15)
$AdminSession->id = (int)$info['id']; $db->query("UPDATE admins SET activity_ts=? WHERE id=?", $time, self::$id);
$AdminSession->login = $info['login']; }
$AdminSession->authId = (int)$info['auth_id'];
$AdminSession->makeCSRFSalt($info['salted_password']); 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']);
} }

View File

@ -1,32 +0,0 @@
<?php
enum AnsiColor: int {
case BLACK = 0;
case RED = 1;
case GREEN = 2;
case YELLOW = 3;
case BLUE = 4;
case MAGENTA = 5;
case CYAN = 6;
case WHITE = 7;
}
function ansi(string $text,
?AnsiColor $fg = null,
?AnsiColor $bg = null,
bool $bold = false,
bool $fg_bright = false,
bool $bg_bright = false): string {
$codes = [];
if (!is_null($fg))
$codes[] = $fg->value + ($fg_bright ? 90 : 30);
if (!is_null($bg))
$codes[] = $bg->value + ($bg_bright ? 100 : 40);
if ($bold)
$codes[] = 1;
if (empty($codes))
return $text;
return "\033[".implode(';', $codes)."m".$text."\033[0m";
}

View File

@ -17,15 +17,15 @@ class cli {
exit(is_null($error) ? 0 : 1); exit(is_null($error) ? 0 : 1);
} }
function on(string $command, callable $f) { public function on(string $command, callable $f) {
$this->commands[$command] = $f; $this->commands[$command] = $f;
return $this; return $this;
} }
function run(): void { public function run(): void {
global $argv, $argc; global $argv, $argc;
if (!is_cli()) if (!isCli())
cli::die('SAPI != cli'); cli::die('SAPI != cli');
if ($argc < 2) if ($argc < 2)

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,5 @@
<?php <?php
require_once 'lib/ext/MyParsedown.php';
require_once 'lib/posts.php';
class markup { class markup {
public static function markdownToHtml(string $md, public static function markdownToHtml(string $md,
@ -19,7 +16,7 @@ class markup {
// collect references // collect references
$re = '/^<p>(\[([io]?\d{1,2})]) (.*?)<\/p>/ms'; $re = '/^<p>(\[([io]?\d{1,2})]) (.*?)<\/p>/ms';
$result = preg_match_all($re, $html, $matches); $result = preg_match_all($re, $html, $matches);
if (pcre_no_error($result)) { if (pcreNoError($result)) {
$reftitles_map = []; $reftitles_map = [];
foreach ($matches[2] as $i => $refname) { foreach ($matches[2] as $i => $refname) {
$reftitles_map[$refname] = trim(htmlspecialchars_decode(strip_tags($matches[3][$i]))); $reftitles_map[$refname] = trim(htmlspecialchars_decode(strip_tags($matches[3][$i])));

View File

@ -1,56 +1,8 @@
<?php <?php
class Page extends model {
const DB_TABLE = 'pages';
public int $id;
public int $parentId;
public string $title;
public string $md;
public string $html;
public int $ts;
public int $updateTs;
public bool $visible;
public bool $renderTitle;
public string $shortName;
function edit(array $fields) {
$fields['update_ts'] = time();
if ($fields['md'] != $this->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 { class pages {
static function add(array $data): bool { public static function add(array $data): bool {
$db = DB(); $db = DB();
$data['ts'] = time(); $data['ts'] = time();
$data['html'] = markup::markdownToHtml($data['md']); $data['html'] = markup::markdownToHtml($data['md']);
@ -59,18 +11,18 @@ class pages {
return true; 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); DB()->query("DELETE FROM pages WHERE short_name=?", $page->shortName);
previous_texts::delete(PreviousText::TYPE_PAGE, $page->get_id()); previous_texts::delete(PreviousText::TYPE_PAGE, $page->get_id());
} }
static function getById(int $id): ?Page { public static function getById(int $id): ?Page {
$db = DB(); $db = DB();
$q = $db->query("SELECT * FROM pages WHERE id=?", $id); $q = $db->query("SELECT * FROM pages WHERE id=?", $id);
return $db->numRows($q) ? new Page($db->fetch($q)) : null; 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(); $db = DB();
$q = $db->query("SELECT * FROM pages WHERE short_name=?", $short_name); $q = $db->query("SELECT * FROM pages WHERE short_name=?", $short_name);
return $db->numRows($q) ? new Page($db->fetch($q)) : null; return $db->numRows($q) ? new Page($db->fetch($q)) : null;
@ -79,7 +31,7 @@ class pages {
/** /**
* @return Page[] * @return Page[]
*/ */
static function getAll(): array { public static function getAll(): array {
$db = DB(); $db = DB();
return array_map('Page::create_instance', $db->fetchAll($db->query("SELECT * FROM pages"))); return array_map('Page::create_instance', $db->fetchAll($db->query("SELECT * FROM pages")));
} }

View File

@ -1,254 +1,8 @@
<?php <?php
enum PostLanguage: string {
case Russian = 'ru';
case English = 'en';
public static function getDefault(): PostLanguage {
return self::English;
}
public function getIndex(): ?int {
return array_search($this->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 { class posts {
static function getCount(bool $include_hidden = false): int { public static function getCount(bool $include_hidden = false): int {
$db = DB(); $db = DB();
$sql = "SELECT COUNT(*) FROM posts"; $sql = "SELECT COUNT(*) FROM posts";
if (!$include_hidden) { if (!$include_hidden) {
@ -260,7 +14,7 @@ class posts {
/** /**
* @return Post[] * @return Post[]
*/ */
static function getList(int $offset = 0, public static function getList(int $offset = 0,
int $count = -1, int $count = -1,
bool $include_hidden = false, bool $include_hidden = false,
?PostLanguage $filter_by_lang = null ?PostLanguage $filter_by_lang = null
@ -293,14 +47,14 @@ class posts {
return array_values($posts); return array_values($posts);
} }
static function add(array $data = []): ?Post { public static function add(array $data = []): ?Post {
$db = DB(); $db = DB();
if (!$db->insert('posts', $data)) if (!$db->insert('posts', $data))
return null; return null;
return self::get($db->insertId()); return self::get($db->insertId());
} }
static function delete(Post $post): void { public static function delete(Post $post): void {
$db = DB(); $db = DB();
$db->query("DELETE FROM posts WHERE id=?", $post->id); $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); $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(); $db = DB();
$q = $db->query("SELECT * FROM posts WHERE id=?", $id); $q = $db->query("SELECT * FROM posts WHERE id=?", $id);
return $db->numRows($q) ? new Post($db->fetch($q)) : null; 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(); $db = DB();
$q = $db->query("SELECT * FROM posts_texts WHERE id=?", $text_id); $q = $db->query("SELECT * FROM posts_texts WHERE id=?", $text_id);
return $db->numRows($q) ? new PostText($db->fetch($q)) : null; 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(); $db = DB();
$q = $db->query("SELECT * FROM posts WHERE short_name=?", $short_name); $q = $db->query("SELECT * FROM posts WHERE short_name=?", $short_name);
return $db->numRows($q) ? new Post($db->fetch($q)) : null; 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)) { if (empty($ids)) {
return []; return [];
} }
@ -357,7 +111,7 @@ class posts {
return $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)) { if (empty($ids)) {
return []; return [];
} }
@ -387,7 +141,7 @@ class posts {
* @param Upload $upload * @param Upload $upload
* @return PostText[] Array of PostTexts that includes specified 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(); $db = DB();
$q = $db->query("SELECT id FROM posts_texts WHERE md LIKE '%{image:{$upload->randomId}%'"); $q = $db->query("SELECT id FROM posts_texts WHERE md LIKE '%{image:{$upload->randomId}%'");
$ids = []; $ids = [];

View File

@ -1,23 +1,8 @@
<?php <?php
class PreviousText extends model {
const DB_TABLE = 'previous_texts';
const int TYPE_POST_TEXT = 0x0;
const int TYPE_PAGE = 0x1;
public int $id;
public int $objectType;
public int $objectId;
public string $md;
public int $ts;
}
class previous_texts { class previous_texts {
static function add(int $object_type, int $object_id, string $md, int $ts) { public static function add(int $object_type, int $object_id, string $md, int $ts) {
$db = DB(); $db = DB();
$db->insert(PreviousText::DB_TABLE, [ $db->insert(PreviousText::DB_TABLE, [
'object_type' => $object_type, '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"; $sql = "DELETE FROM ".PreviousText::DB_TABLE." WHERE object_type=? AND object_id";
$args = [$object_type]; $args = [$object_type];
if (is_array($object_id)) if (is_array($object_id))

109
lib/sphinx.php Normal file
View File

@ -0,0 +1,109 @@
<?php
class sphinx {
public static function execute(string $sql) {
$link = self::getLink();
if (func_num_args() > 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;
}
}

View File

@ -1,45 +1,50 @@
<?php <?php
const THEMES = [ class themes {
'dark' => [
'bg' => 0x222222,
// 'alpha' => 0x303132,
'alpha' => 0x222222,
],
'light' => [
'bg' => 0xffffff,
// 'alpha' => 0xf2f2f2,
'alpha' => 0xffffff,
]
];
const array COLORS = [
'dark' => [
'bg' => 0x222222,
// 'alpha' => 0x303132,
'alpha' => 0x222222,
],
'light' => [
'bg' => 0xffffff,
// 'alpha' => 0xf2f2f2,
'alpha' => 0xffffff,
]
];
function getThemes(): array { public static function getThemes(): array {
return array_keys(THEMES); return array_keys(self::COLORS);
} }
function themeExists(string $name): bool { public static function themeExists(string $name): bool {
return array_key_exists($name, THEMES); return array_key_exists($name, self::COLORS);
} }
function getThemeAlphaColorAsRGB(string $name): array { public static function getThemeAlphaColorAsRGB(string $name): array {
$color = THEMES[$name]['alpha']; $color = self::COLORS[$name]['alpha'];
$r = ($color >> 16) & 0xff; $r = ($color >> 16) & 0xff;
$g = ($color >> 8) & 0xff; $g = ($color >> 8) & 0xff;
$b = $color & 0xff; $b = $color & 0xff;
return [$r, $g, $b]; return [$r, $g, $b];
} }
function getUserTheme(): string { public static function getUserTheme(): string {
if (isset($_COOKIE['theme'])) { if (isset($_COOKIE['theme'])) {
$val = $_COOKIE['theme']; $val = $_COOKIE['theme'];
if (is_array($val)) if (is_array($val))
$val = implode($val); $val = implode($val);
} else if ($val != 'auto' && !self::themeExists($val))
$val = 'auto'; $val = 'auto';
return $val; } 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';
} }

View File

@ -1,38 +1,36 @@
<?php <?php
require_once 'lib/themes.php';
const UPLOADS_ALLOWED_EXTENSIONS = [
'jpg', 'png', 'git', 'mp4', 'mp3', 'ogg', 'diff', 'txt', 'gz', 'tar',
'icc', 'icm', 'patch', 'zip', 'brd', 'pdf', 'lua', 'xpi', 'rar', '7z',
'tgz', 'bin', 'py', 'pac', 'yaml', 'toml', 'xml', 'json', 'yml',
];
class uploads { class uploads {
static function getCount(): int { const array ALLOWED_EXTENSIONS = [
'jpg', 'png', 'git', 'mp4', 'mp3', 'ogg', 'diff', 'txt', 'gz', 'tar',
'icc', 'icm', 'patch', 'zip', 'brd', 'pdf', 'lua', 'xpi', 'rar', '7z',
'tgz', 'bin', 'py', 'pac', 'yaml', 'toml', 'xml', 'json', 'yml',
];
public static function getCount(): int {
$db = DB(); $db = DB();
return (int)$db->result($db->query("SELECT COUNT(*) FROM uploads")); return (int)$db->result($db->query("SELECT COUNT(*) FROM uploads"));
} }
static function isExtensionAllowed(string $ext): bool { public static function isExtensionAllowed(string $ext): bool {
return in_array($ext, UPLOADS_ALLOWED_EXTENSIONS); return in_array($ext, self::ALLOWED_EXTENSIONS);
} }
static function add(string $tmp_name, public static function add(string $tmp_name,
string $name, string $name,
string $note_en = '', string $note_en = '',
string $note_ru = '', string $note_ru = '',
string $source_url = ''): ?int { string $source_url = ''): ?int {
global $config; global $config;
$name = sanitize_filename($name); $name = sanitizeFilename($name);
if (!$name) if (!$name)
$name = 'file'; $name = 'file';
$random_id = self::_getNewUploadRandomId(); $random_id = self::_getNewUploadRandomId();
$size = filesize($tmp_name); $size = filesize($tmp_name);
$is_image = detect_image_type($tmp_name) !== false; $is_image = detectImageType($tmp_name) !== false;
$image_w = 0; $image_w = 0;
$image_h = 0; $image_h = 0;
if ($is_image) { if ($is_image) {
@ -70,7 +68,7 @@ class uploads {
return $id; return $id;
} }
static function delete(int $id): bool { public static function delete(int $id): bool {
$upload = self::get($id); $upload = self::get($id);
if (!$upload) if (!$upload)
return false; return false;
@ -85,13 +83,13 @@ class uploads {
/** /**
* @return Upload[] * @return Upload[]
*/ */
static function getAllUploads(): array { public static function getAllUploads(): array {
$db = DB(); $db = DB();
$q = $db->query("SELECT * FROM uploads ORDER BY id DESC"); $q = $db->query("SELECT * FROM uploads ORDER BY id DESC");
return array_map('Upload::create_instance', $db->fetchAll($q)); return array_map('Upload::create_instance', $db->fetchAll($q));
} }
static function get(int $id): ?Upload { public static function get(int $id): ?Upload {
$db = DB(); $db = DB();
$q = $db->query("SELECT * FROM uploads WHERE id=?", $id); $q = $db->query("SELECT * FROM uploads WHERE id=?", $id);
if ($db->numRows($q)) { if ($db->numRows($q)) {
@ -106,7 +104,7 @@ class uploads {
* @param bool $flat * @param bool $flat
* @return Upload[] * @return Upload[]
*/ */
static function getUploadsByRandomId(array $ids, bool $flat = false): array { public static function getUploadsByRandomId(array $ids, bool $flat = false): array {
if (empty($ids)) { if (empty($ids)) {
return []; return [];
} }
@ -132,7 +130,7 @@ class uploads {
return $uploads; return $uploads;
} }
static function getUploadByRandomId(string $random_id): ?Upload { public static function getUploadByRandomId(string $random_id): ?Upload {
$db = DB(); $db = DB();
$q = $db->query("SELECT * FROM uploads WHERE random_id=? LIMIT 1", $random_id); $q = $db->query("SELECT * FROM uploads WHERE random_id=? LIMIT 1", $random_id);
if ($db->numRows($q)) { 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(); $db = DB();
$q = $db->query("SELECT * FROM uploads WHERE source_url=? LIMIT 1", $source_url); $q = $db->query("SELECT * FROM uploads WHERE source_url=? LIMIT 1", $source_url);
if ($db->numRows($q)) { if ($db->numRows($q)) {
@ -152,7 +150,7 @@ class uploads {
} }
} }
static function _getNewUploadRandomId(): string { public static function _getNewUploadRandomId(): string {
$db = DB(); $db = DB();
do { do {
$random_id = strgen(8); $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 .= ' <!-- '.$this->name.' -->';
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;
}
}

View File

@ -1,5 +1,8 @@
<?php <?php
const ROUTER_VERSION = 10;
const ROUTER_MC_KEY = '4in1/routes';
return (function() { return (function() {
global $config; global $config;

View File

@ -1,730 +0,0 @@
<?php
namespace skin\admin;
use PostLanguage;
use Stringable;
use function skin\base\layout;
// login page
// ----------
function login($ctx) {
$html = <<<HTML
<form action="/admin/login/" method="post" class="form-layout-h" name="admin_login">
<input type="hidden" name="token" value="{$ctx->csrf('adminlogin')}" />
<div class="form-field-wrap clearfix">
<div class="form-field-label">{$ctx->lang('admin_login')}:</div>
<div class="form-field">
<input class="form-field-input" type="text" name="login" size="50" />
</div>
</div>
<div class="form-field-wrap clearfix">
<div class="form-field-label">{$ctx->lang('admin_password')}:</div>
<div class="form-field">
<input class="form-field-input" type="password" name="password" size="50" />
</div>
</div>
<div class="form-field-wrap clearfix">
<div class="form-field-label"></div>
<div class="form-field">
<button type="submit">{$ctx->lang('sign_in')}</button>
</div>
</div>
</form>
HTML;
$js = <<<JS
document.forms.admin_login.login.focus();
JS;
return [$html, $js];
}
// index page
// ----------
function index($ctx, $admin_login) {
return <<<HTML
<div class="admin-page">
Authorized as <b>{$admin_login}</b> | <a href="/admin/logout/?token={$ctx->csrf('logout')}">Sign out</a><br>
<!--<a href="/admin/log/">Log</a><br/>-->
<a href="/admin/uploads/">Uploads</a><br>
<a href="/admin/errors/">{$ctx->lang('admin_errors')}</a><br>
<a href="/admin/auth-log/">{$ctx->lang('admin_auth_log')}</a><br>
<a href="/admin/actions-log/">{$ctx->lang('admin_actions_log')}</a><br>
</div>
HTML;
}
// uploads page
// ------------
function uploads($ctx, $uploads, $error, array $langs) {
return <<<HTML
{$ctx->if_true($error, $ctx->formError, $error)}
{$ctx->bc([
['text' => $ctx->lang('admin_title'), 'url' => '/admin/'],
['text' => $ctx->lang('blog_uploads')],
])}
<div class="blog-upload-form">
<form action="/admin/uploads/" method="post" enctype="multipart/form-data" class="form-layout-h">
<input type="hidden" name="token" value="{$ctx->csrf('addupl')}" />
<div class="form-field-wrap clearfix">
<div class="form-field-label">{$ctx->lang('blog_upload_form_file')}:</div>
<div class="form-field">
<input type="file" name="files[]" multiple>
</div>
</div>
<div class="form-field-wrap clearfix">
<div class="form-field-label">{$ctx->lang('blog_upload_form_custom_name')}:</div>
<div class="form-field">
<input type="text" name="name">
</div>
</div>
{$ctx->for_each($langs,
fn($l) => $ctx->uploads_form_note_field($l))}
<div class="form-field-wrap clearfix">
<div class="form-field-label"></div>
<div class="form-field">
<input type="submit" value="Upload">
</div>
</div>
</form>
</div>
<div class="blog-upload-list">
{$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(),
))}
</div>
HTML;
}
function uploads_form_note_field($ctx, PostLanguage $lang) {
$label = $ctx->lang('blog_upload_form_note');
$label .= ' ('.$lang->name.')';
return <<<HTML
<div class="form-field-wrap clearfix">
<div class="form-field-label">{$label}:</div>
<div class="form-field">
<input type="text" name="note_{$lang->value}" size="55">
</div>
</div>
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 <<<HTML
<div class="blog-upload-item">
<div class="blog-upload-item-actions">
<a href="javascript:void(0)" onclick="var mdel = ge('upload{$id}_md'); mdel.style.display = (mdel.style.display === 'none' ? 'block' : 'none')">{$ctx->lang('blog_upload_show_md')}</a>
| <a href="javascript:void(0)" onclick='BlogUploadList.submitNoteEdit("/admin/uploads/edit_note/{$id}/?lang=ru&token={$ctx->csrf('editupl'.$id)}", prompt("Note (Ru):", {$as_note_ru}))'>Edit note Ru</a>
| <a href="javascript:void(0)" onclick='BlogUploadList.submitNoteEdit("/admin/uploads/edit_note/{$id}/?lang=en&token={$ctx->csrf('editupl'.$id)}", prompt("Note (En):", {$as_note_en}))'>Edit note En</a>
| <a href="/admin/uploads/delete/{$id}/?token={$ctx->csrf('delupl'.$id)}" onclick="return confirm('{$ctx->lang('blog_upload_delete_confirmation')}')">{$ctx->lang('blog_upload_delete')}</a>
</div>
<div class="blog-upload-item-name"><a href="{$direct_url}">{$name}</a></div>
<div class="blog-upload-item-info">{$size}</div>
{$ctx->if_true($note_en,
fn() => '<div class="blog-upload-item-note"><span>En</span>'.$note_en.'</div>')}
{$ctx->if_true($note_ru,
fn() => '<div class="blog-upload-item-note"><span>Ru</span>'.$note_ru.'</div>')}
<div class="blog-upload-item-md" id="upload{$id}_md" style="display: none">
<input type="text" value="{$markdown}" onclick="this.select()" readonly style="width: 100%">
</div>
</div>
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 = <<<HTML
<div class="form-error" id="form-error" style="display:none"></div>
{$ctx->if_true($saved, fn() => '<div class="form-success">'.$ctx->lang('info_saved').'</div>')}
{$ctx->bc($bc_tree, 'padding-bottom: 12px')}
<table cellpadding="0" cellspacing="0" class="blog-write-table">
<tr>
<td id="form_first_cell">
<form class="blog-write-form form-layout-v" name="postForm" action="{$form_url}" method="post">
<div class="form-field-wrap clearfix">
<div class="form-field-label">{$ctx->lang('blog_write_form_title')}</div>
<div class="form-field">
<input class="form-field-input" type="text" name="title" value="{$title}" />
</div>
</div>
<div class="form-field-wrap clearfix">
<div class="form-field-label">{$ctx->lang('blog_write_form_text')}</div>
<div class="form-field">
<textarea class="form-field-input" name="text" wrap="soft">{$text}</textarea><br/>
<a class="blog-write-form-toggle-link" id="toggle_wrap" href="">{$ctx->lang('blog_write_form_toggle_wrap')}</a>
</div>
</div>
<div class="form-field-wrap clearfix">
<table class="blog-write-options-table">
<tr>
<td>
<div class="clearfix">
<div class="form-field-label">{$ctx->lang('blog_post_options')}</div>
<div class="form-field">
<label for="visible_cb"><input type="checkbox" id="visible_cb" name="visible"{$ctx->if_true($visible, ' checked="checked"')}> {$ctx->lang('blog_write_form_visible')}</label>
</div>
</div>
</td>
<td>
<div class="clearfix">
<div class="form-field-label">{$ctx->lang('blog_text_options')}</div>
<div class="form-field">
<label for="toc_cb"><input type="checkbox" id="toc_cb" name="toc"{$ctx->if_true($toc, ' checked="checked"')}> {$ctx->lang('blog_write_form_toc')}</label>
&nbsp;<select name="lang">
{$ctx->for_each($langs, fn($l) => '<option value="'.$l->value.'"'.($l->value == $lang ? ' selected="selected"' : '').'>'.$l->value.'</option>')}
</select>
</div>
</div>
</td>
<td>
<div class="clearfix">
<div class="form-field-label">{$ctx->lang('blog_write_form_date')}</div>
<div class="form-field">
<input type="date" name="date"{$ctx->if_true($date, ' value="'.$date.'"')}>
</div>
</div>
</td>
</tr>
<tr>
<td colspan="3">
<div class="clearfix">
<table width="100%" cellspacing="0" cellpadding="0" style="table-layout: fixed; width: 100%; border-collapse: collapse">
<tr>
<td width="50%" style="width: 50% !important;">
<div class="form-field-label">{$ctx->lang('blog_write_form_keywords')}</div>
<div class="form-field">
<input class="form-field-input" type="text" name="keywords" value="{$keywords}" />
</div>
</td>
<td width="50%" style="width: 50% !important;">
<div class="form-field-label">{$ctx->lang('blog_write_form_source')}</div>
<div class="form-field">
<input class="form-field-input" type="text" name="source_url" value="{$source_url}" />
</div>
</td>
</tr>
</table>
</div>
</td>
</tr>
<tr>
<td colspan="2">
<div class="clearfix">
<div class="form-field-label">{$ctx->lang('blog_write_form_short_name')}</div>
<div class="form-field">
<input class="form-field-input" type="text" name="{$ctx->if_then_else($is_edit, 'new_short_name', 'short_name')}" value="{$short_name}" />
</div>
</div>
</td>
<td>
<div class="form-field-label">&nbsp;</div>
<div class="form-field">
<button type="submit" name="submit_btn"><b>{$ctx->lang($is_edit ? 'save' : 'blog_write_form_submit_btn')}</b></button>
</div>
</td>
</tr>
</table>
</div>
</form>
<div id="form_placeholder"></div>
</td>
<td>
<div class="blog-write-form-preview post_text" id="preview_html"></div>
</td>
</tr>
</table>
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 = <<<JAVASCRIPT
cur.form = new AdminWriteEditForm({$js_params});
JAVASCRIPT;
return [$html, $js];
}
function pageForm($ctx,
string|Stringable $title,
string|Stringable $text,
string|Stringable $short_name,
array $langs,
bool $is_edit = false,
?bool $saved = null,
bool $visible = false,
bool $render_title = false,
null|string|Stringable $parent = null,
?array $js_text = null): array {
$form_url = '/'.$short_name.'/'.($is_edit ? 'edit' : 'create').'/';
// breadcrumbs
if ($is_edit) {
$bc_html = $ctx->bc([
['url' => '/'.$short_name.'/', 'text' => $ctx->lang('view_page')]
], 'padding-bottom: 12px');
} else {
$bc_html = '';
}
$html = <<<HTML
<div class="form-error" id="form-error" style="display:none"></div>
{$ctx->if_true($saved, fn() => '<div class="form-success">'.$ctx->lang('info_saved').'</div>')}
{$bc_html}
<table cellpadding="0" cellspacing="0" class="blog-write-table">
<tr>
<td id="form_first_cell">
<form class="blog-write-form form-layout-v" name="pageForm" action="{$form_url}" method="post">
<div class="form-field-wrap clearfix">
<div class="form-field-label">{$ctx->lang('pages_write_form_title')}</div>
<div class="form-field">
<input class="form-field-input" type="text" name="title" value="{$title}" />
</div>
</div>
<div class="form-field-wrap clearfix">
<div class="form-field-label">{$ctx->lang('pages_write_form_text')}</div>
<div class="form-field">
<textarea class="form-field-input" name="text" wrap="soft">{$text}</textarea><br/>
<a class="blog-write-form-toggle-link" id="toggle_wrap" href="">{$ctx->lang('pages_write_form_toggle_wrap')}</a>
</div>
</div>
{$ctx->if_then_else($is_edit,
fn() => $ctx->pageFormEditOptions($short_name, $parent, $visible, $render_title),
fn() => $ctx->pageFormAddOptions($short_name))}
</form>
<div id="form_placeholder"></div>
</td>
<td>
<div class="blog-write-form-preview post_text" id="preview_html"></div>
</td>
</tr>
</table>
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 = <<<JS
cur.form = new AdminWriteEditForm({$js_params});
JS;
return [$html, $js];
}
function pageFormEditOptions($ctx, $short_name, $parent, $visible, $render_title) {
return <<<HTML
<div class="form-field-wrap clearfix">
<table class="blog-write-options-table">
<tr>
<td width="40%">
<div class="clearfix">
<div class="form-field-label">{$ctx->lang('pages_write_form_short_name')}</div>
<div class="form-field">
<input class="form-field-input" type="text" name="new_short_name" value="{$short_name}" />
</div>
</div>
</td>
<td width="30%">
<div class="clearfix">
<div class="form-field-label">{$ctx->lang('pages_write_form_parent')}</div>
<div class="form-field">
<input class="form-field-input" type="text" name="parent" value="{$parent}" />
</div>
</div>
</td>
<td width="30%">
<div class="clearfix">
<div class="form-field-label">{$ctx->lang('pages_write_form_options')}</div>
<div class="form-field">
<label for="visible_cb"><input type="checkbox" id="visible_cb" name="visible"{$ctx->if_true($visible, ' checked="checked"')}> {$ctx->lang('pages_write_form_visible')}</label>
<label for="render_title_cb"><input type="checkbox" id="render_title_cb" name="render_title"{$ctx->if_true($render_title, ' checked="checked"')}> {$ctx->lang('pages_write_form_render_title')}</label>
</div>
</div>
</td>
</tr>
<tr>
<td rowspan="3">
<button type="submit" name="submit_btn"><b>{$ctx->lang('pages_write_form_submit_btn')}</b></button>
</td>
</tr>
</table>
</div>
HTML;
}
// TODO: add visible and reader_title checkbox here
function pageFormAddOptions($ctx, $short_name) {
return <<<HTML
<div class="form-field-wrap clearfix">
<div class="form-field-label"></div>
<div class="form-field">
<button type="submit" name="submit_btn"><b>{$ctx->lang('pages_write_form_submit_btn')}</b></button>
</div>
</div>
<input name="short_name" value="{$short_name}" type="hidden" />
HTML;
}
function pageNew($ctx, $short_name) {
return <<<HTML
<div class="page">
<div class="empty">
<a href="/{$short_name}/create/">{$ctx->lang('pages_create')}</a>
</div>
</div>
HTML;
}
// misc
function formError($ctx, $error) {
return <<<HTML
<div class="form-error">{$ctx->lang('error')}: {$error}</div>
HTML;
}
function markdownPreview($ctx, $unsafe_html, $title) {
return <<<HTML
<div class="blog-post" id="blog_post">
{$ctx->if_true($title, '<div class="blog-post-title"><h1>'.$title.'</h1></div>')}
<div class="blog-post-text">{$unsafe_html}</div>
</div>
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 <<<HTML
{$ctx->bc([
['text' => $ctx->lang('admin_title'), 'url' => '/admin/'],
['text' => $ctx->lang('admin_errors')],
])}
<form action="/admin/errors/" method="get" class="admin_common_query_form">
{$ctx->if_true($ip, fn() => '<input type="hidden" name="ip" value="'.$ip.'" />')}
<input type="text" name="query" placeholder="text_like" value="{$query}" />
<input type="text" name="url_query" placeholder="url_like" value="{$url_query}" />
<input type="text" name="file_query" placeholder="file" value="{$file_query}" />
<input type="text" name="line_query" placeholder="line" value="{$line_query}" style="width: 50px" />
<input class="blue" type="submit" value="query" />
</form>
{$ctx->if_then_else(!empty($list),
fn() => $ctx->errors_table($list),
fn() => '<div class="empty_block">Error log is empty.</div>')}
{$ctx->pagenav($pn_page, $pn_pages, $url.'page={page}')}
HTML;
}
function errors_table($ctx,
array $list) {
return <<<HTML
<table border="1" width="100%" cellpadding="0" cellspacing="0" class="admin-error-log">
<thead>
<tr>
<th width="5%">Time</th>
<th width="20%">Source</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{$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']
))}
</tbody>
</table>
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 <<<HTML
<tr>
<td>
{$date}
</td>
<td>
{$ctx->if_then_else(!$is_cli,
fn() => '<span class="admin-error-log-num"><a href="/admin/errors/?ip='.$ip.'">'.$ip_s.'</a></span> <a class="admin-error-log-link" href="'.$full_url.'">'.$url.'</a><br/>'.$user_agent,
fn() => '<span class="admin-error-log-num">cmd</span>')}
</td>
<td class="admin_error_log_ms">
{$ctx->if_true($admin_id,
fn() => '<span class="admin-error-log-num">admin='.$admin_id.'</span>')}
{$ctx->if_then_else($is_custom,
fn() => '<span class="admin-error-log-num">'.$num.', '.$time.'</span> <b>'.$file.'</b>:'.$line.'<br/>'
.'<span class="admin-error-log-num">'.$errtype.'</span> '.$nl2br_text,
fn() => '<span class="admin-error-log-num">'.$num.', '.$time.'</span> '.$nl2br_text)}
{$ctx->if_true($stacktrace,
fn() => $ctx->errors_table_item_stacktrace($item_id, $stacktrace))}
</td>
</tr>
HTML;
}
function errors_table_item_stacktrace($ctx, $item_id, $nl2br_stacktrace) {
return <<<HTML
<div class="admin-error-log-stacktrace-wrap">
<a href="javascript:void(0)" onclick="toggle(ge('admin_error_log_stacktrace{$item_id}'))">Show/hide stacktrace</a>
<div id="admin_error_log_stacktrace{$item_id}" style="display: none">{$nl2br_stacktrace}</div>
</div>
HTML;
}
// ------------------------------------------------------
// ---------------------- AUTH LOG ----------------------
// ------------------------------------------------------
function auth_log($ctx, array $list, int $pn_page, int $pn_pages) {
return <<<HTML
{$ctx->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() => '<div class="empty_block">Auth log is empty.</div>')}
{$ctx->pagenav($pn_page, $pn_pages, '/admin/auth-log/?page={page}')}
HTML;
}
function auth_log_table($ctx, array $list) {
return <<<HTML
<table border="1" width="100%" cellpadding="0" cellspacing="0" class="admin-error-log">
<thead>
<tr>
<th width="20%">Admin</th>
<th width="15%">Time</th>
<th width="10%">IP</th>
<th width="55%">User-Agent</th>
</tr>
</thead>
<tbody>
{$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']))}
</tbody>
</table>
HTML;
}
function auth_log_table_item($ctx, $date, $ip, $user_agent, string $admin_login, int $admin_id, string $activity_ts) {
return <<<HTML
<tr>
<td>
<span title="Last activity: {$activity_ts}">{$admin_login}</span> (id={$admin_id})
</td>
<td>{$date}</td>
<td>{$ip}</td>
<td>{$user_agent}</td>
</tr>
HTML;
}
// ---------------------------------------------------------
// ---------------------- ACTIONS LOG ----------------------
// ---------------------------------------------------------
function actions_log($ctx,
array $list,
array $admin_logins,
string $url,
array $action_types,
int $pn_page,
int $pn_pages) {
return <<<HTML
{$ctx->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() => '<div class="empty_block">Actions log is empty.</div>')}
{$ctx->pagenav($pn_page, $pn_pages, $url.'page={page}')}
HTML;
}
function actions_log_table($ctx, array $list, array $admin_logins, array $action_types) {
return <<<HTML
<table border="1" width="100%" cellpadding="0" cellspacing="0" class="admin-error-log">
<thead>
<tr>
<th width="9%">Time</th>
<th width="14%">Who</th>
<th width="11%">Action</th>
<th>Data</th>
</tr>
</thead>
<tbody>
{$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()))}
</tbody>
</table>
HTML;
}
function actions_log_table_item($ctx,
string $date,
string $ip,
bool $is_cli,
string $admin_login,
string $action_name,
string $unsafe_data) {
return <<<HTML
<tr>
<td>{$date}</td>
<td>
{$ctx->if_then_else(!$is_cli,
fn() => $admin_login.', '.$ip,
fn() => 'console')}
</td>
<td>{$action_name}</td>
<td>{$unsafe_data}</td>
</tr>
HTML;
}

View File

@ -0,0 +1,37 @@
{{ bc([
{text: "admin_title"|lang, url: '/admin/'},
{text: "admin_actions_log"|lang}
]) }}
{% if list %}
<table border="1" width="100%" cellpadding="0" cellspacing="0" class="admin-error-log">
<thead>
<tr>
<th width="9%">Time</th>
<th width="14%">Who</th>
<th width="11%">Action</th>
<th>Data</th>
</tr>
</thead>
<tbody>
{% for item in list %}
<tr>
<td>{{ item.getDate() }}</td>
<td>
{% if item.isCommandLineActions() %}
{{ admin_logs[item.getAdminId()] }}, {{ item.getIPv4() }}
{% else %}
console
{% endif %}
</td>
<td>{{ item.getActionName() }}</td>
<td>{{ item.renderHtml()|raw }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ pageNav(pn_page, pn_pages, "#{url}page={page}") }}
{% else %}
<div class="empty">Actions log is empty.</div>
{% endif %}

33
skin/admin_auth_log.twig Normal file
View File

@ -0,0 +1,33 @@
{{ bc([
{text: "admin_title"|lang, url: '/admin/'},
{text: "admin_auth_log"|lang}
]) }}
{% if list %}
<table border="1" width="100%" cellpadding="0" cellspacing="0" class="admin-error-log">
<thead>
<tr>
<th width="20%">Admin</th>
<th width="15%">Time</th>
<th width="10%">IP</th>
<th width="55%">User-Agent</th>
</tr>
</thead>
<tbody>
{% for item in list %}
<tr>
<td>
<span title="Last activity: {{ item.activity_ts_s }}">{{ item.login }}</span> (id={{ item.admin_id }})
</td>
<td>{{ item.date }}</td>
<td>{{ item.ip }}</td>
<td>{{ item.ua }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ pageNav(pn_page, pn_pages, "/admin/auth-log/?page={page}") }}
{% else %}
<div class="empty">Auth log is empty.</div>
{% endif %}

69
skin/admin_errors.twig Normal file
View File

@ -0,0 +1,69 @@
{{ bc([
{text: "admin_title"|lang, url: '/admin/'},
{text: "admin_errors"|lang}
]) }}
{% if list %}
<form action="/admin/errors/" method="get" class="admin_common_query_form">
{% if ip %}
<input type="hidden" name="ip" value="{{ ip }}" />
{% endif %}
<input type="text" name="query" placeholder="text_like" value="{{ query }}" />
<input type="text" name="url_query" placeholder="url_like" value="{{ url_query }}" />
<input type="text" name="file_query" placeholder="file" value="{{ file_query }}" />
<input type="text" name="line_query" placeholder="line" value="{{ line_query }}" style="width: 50px" />
<input class="blue" type="submit" value="query" />
</form>
<table border="1" width="100%" cellpadding="0" cellspacing="0" class="admin-error-log">
<thead>
<tr>
<th width="5%">Time</th>
<th width="20%">Source</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{% for item in list %}
<tr>
<td>{{ item.date }}</td>
<td>
{% if item.is_cli %}
<span class="admin-error-log-num">cmd</span>
{% else %}
<span class="admin-error-log-num">
<a href="/admin/errors/?ip={{ item.ip }}">{{ item.ip_s }}</a></span>
<a class="admin-error-log-link" href="{{ item.full_url }}">{{ item.url }}</a><br/>
{{ item.ua }}
{% endif %}
</td>
<td class="admin_error_log_ms">
{% if item.admin_id %}
<span class="admin-error-log-num">admin={{ item.admin_id }}</span>
{% endif %}
{% if item.custom %}
<span class="admin-error-log-num">{{ item.num }}, {{ item.time }}</span> <b>{{ item.file }}</b>:{{ item.line }}<br/>
<span class="admin-error-log-num">{{ item.errtype }}</span> {{ item.text|nl2br }}
{% else %}
<span class="admin-error-log-num">{{ item.num }}, {{ item.time }}</span> {{ item.text|nl2br }}
{% endif %}
{% if item.stacktrace %}
<div class="admin-error-log-stacktrace-wrap">
<a href="javascript:void(0)" onclick="toggle(ge('admin_error_log_stacktrace{{ item.id }}'))">Show/hide stacktrace</a>
<div id="admin_error_log_stacktrace{{ item.id }}" style="display: none">{{ item.stacktrace|nl2br }}</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if pn_pages > 1 %}
{{ pageNav(pn_page, pn_pages, url~'page={page}') }}
{% endif %}
{% else %}
<div class="empty">Error log is empty.</div>
{% endif %}

8
skin/admin_index.twig Normal file
View File

@ -0,0 +1,8 @@
<div class="admin-page">
Authorized as <b>{{ admin_login }}</b> | <a href="/admin/logout/?token={{ logout_token }}">Sign out</a><br>
<!--<a href="/admin/log/">Log</a><br/>-->
<a href="/admin/uploads/">Uploads</a><br>
<a href="/admin/errors/">{{ "admin_errors"|lang }}</a><br>
<a href="/admin/auth-log/">{{ "admin_auth_log"|lang }}</a><br>
<a href="/admin/actions-log/">{{ "admin_actions_log"|lang }}</a><br>
</div>

28
skin/admin_login.twig Normal file
View File

@ -0,0 +1,28 @@
<form action="/admin/login/" method="post" class="form-layout-h" name="admin_login">
<input type="hidden" name="token" value="{{ form_token }}" />
<div class="form-field-wrap clearfix">
<div class="form-field-label">{{ "admin_login"|lang }}:</div>
<div class="form-field">
<input class="form-field-input" type="text" name="login" size="50" />
</div>
</div>
<div class="form-field-wrap clearfix">
<div class="form-field-label">{{ "admin_password"|lang }}:</div>
<div class="form-field">
<input class="form-field-input" type="password" name="password" size="50" />
</div>
</div>
<div class="form-field-wrap clearfix">
<div class="form-field-label"></div>
<div class="form-field">
<button type="submit">{{ "sign_in"|lang }}</button>
</div>
</div>
</form>
{% js %}
document.forms.admin_login.login.focus();
{% endjs %}

104
skin/admin_page_form.twig Normal file
View File

@ -0,0 +1,104 @@
<div class="form-error" id="form-error" style="display:none"></div>
{% if saved %}
<div class="form-success">{{ "info_saved"|lang }}</div>
{% endif %}
{% if is_edit %}
{{ bc([
{url: "/#{short_name}/", text: "view_page"|lang},
], 'padding-bottom: 12px') }}
{% endif %}
<table cellpadding="0" cellspacing="0" class="blog-write-table">
<tr>
<td id="form_first_cell">
<form class="blog-write-form form-layout-v" name="pageForm" action="{{ form_url }}" method="post">
<div class="form-field-wrap clearfix">
<div class="form-field-label">{{ "pages_write_form_title"|lang }}</div>
<div class="form-field">
<input class="form-field-input" type="text" name="title" value="{{ title }}" />
</div>
</div>
<div class="form-field-wrap clearfix">
<div class="form-field-label">{{ "pages_write_form_text"|lang }}</div>
<div class="form-field">
<textarea class="form-field-input" name="text" wrap="soft">{{ text }}</textarea><br/>
<a class="blog-write-form-toggle-link" id="toggle_wrap" href="">{{ "pages_write_form_toggle_wrap"|lang }}</a>
</div>
</div>
{% if is_edit %}
<div class="form-field-wrap clearfix">
<table class="blog-write-options-table">
<tr>
<td width="40%">
<div class="clearfix">
<div class="form-field-label">{{ "pages_write_form_short_name"|lang }}</div>
<div class="form-field">
<input class="form-field-input" type="text" name="new_short_name" value="{{ short_name }}" />
</div>
</div>
</td>
<td width="30%">
<div class="clearfix">
<div class="form-field-label">{{ "pages_write_form_parent"|lang }}</div>
<div class="form-field">
<input class="form-field-input" type="text" name="parent" value="{{ parent }}" />
</div>
</div>
</td>
<td width="30%">
<div class="clearfix">
<div class="form-field-label">{{ "pages_write_form_options"|lang }}</div>
<div class="form-field">
<label for="visible_cb">
<input type="checkbox"
id="visible_cb"
name="visible"
{% if visible %}checked="checked"{% endif %}
>
{{ "pages_write_form_visible"|lang }}
</label>
<label for="render_title_cb">
<input type="checkbox"
id="render_title_cb"
name="render_title"
{% if render_title %} checked="checked"{% endif %}
>
{{ "pages_write_form_render_title"|lang }}
</label>
</div>
</div>
</td>
</tr>
<tr>
<td rowspan="3">
<button type="submit" name="submit_btn"><b>{{ "pages_write_form_submit_btn"|lang }}</b></button>
</td>
</tr>
</table>
</div>
{% else %}
<div class="form-field-wrap clearfix">
<div class="form-field-label"></div>
<div class="form-field">
<button type="submit" name="submit_btn"><b>{{ "pages_write_form_submit_btn"|lang }}</b></button>
</div>
</div>
<input name="short_name" value="{{ short_name }}" type="hidden" />
{% endif %}
</form>
<div id="form_placeholder"></div>
</td>
<td>
<div class="blog-write-form-preview post_text" id="preview_html"></div>
</td>
</tr>
</table>
{% js %}
cur.form = new AdminWriteEditForm({{ js_params|json_encode|raw }});
{% endjs %}

5
skin/admin_page_new.twig Normal file
View File

@ -0,0 +1,5 @@
<div class="page">
<div class="empty">
<a href="/{{ short_name }}/create/">{{ "pages_create"|lang }}</a>
</div>
</div>

112
skin/admin_post_form.twig Normal file
View File

@ -0,0 +1,112 @@
<div class="form-error" id="form-error" style="display:none"></div>
{% if saved %}
<div class="form-success">{{ "info_saved"|lang }}</div>
{% endif %}
{{ bc(bc) }}
<table cellpadding="0" cellspacing="0" class="blog-write-table">
<tr>
<td id="form_first_cell">
<form class="blog-write-form form-layout-v" name="postForm" action="{{ form_url }}" method="post">
<div class="form-field-wrap clearfix">
<div class="form-field-label">{{ "blog_write_form_title"|lang }}</div>
<div class="form-field">
<input class="form-field-input" type="text" name="title" value="{{ title }}" />
</div>
</div>
<div class="form-field-wrap clearfix">
<div class="form-field-label">{{ "blog_write_form_text"|lang }}</div>
<div class="form-field">
<textarea class="form-field-input" name="text" wrap="soft">{{ text }}</textarea><br/>
<a class="blog-write-form-toggle-link" id="toggle_wrap" href="">{{ "blog_write_form_toggle_wrap"|lang }}</a>
</div>
</div>
<div class="form-field-wrap clearfix">
<table class="blog-write-options-table">
<tr>
<td>
<div class="clearfix">
<div class="form-field-label">{{ "blog_post_options"|lang }}</div>
<div class="form-field">
<label for="visible_cb"><input type="checkbox" id="visible_cb" name="visible"{% if visible %} checked="checked"{% endif %}> {{ "blog_write_form_visible"|lang }}</label>
</div>
</div>
</td>
<td>
<div class="clearfix">
<div class="form-field-label">{{ "blog_text_options"|lang }}</div>
<div class="form-field">
<label for="toc_cb"><input type="checkbox" id="toc_cb" name="toc"{% if toc %} checked="checked"{% endif %}> {{ "blog_write_form_toc"|lang }}</label>
&nbsp;<select name="lang">
{% for l in langs %}
<option value="{{ l }}"{% if l == lang %} selected="selected"{% endif %}>{{ l }}</option>
{% endfor %}
</select>
</div>
</div>
</td>
<td>
<div class="clearfix">
<div class="form-field-label">{{ "blog_write_form_date"|lang }}</div>
<div class="form-field">
<input type="date" name="date"{% if date %} value="{{ date }}"{% endif %}>
</div>
</div>
</td>
</tr>
<tr>
<td colspan="3">
<div class="clearfix">
<table width="100%" cellspacing="0" cellpadding="0" style="table-layout: fixed; width: 100%; border-collapse: collapse">
<tr>
<td width="50%" style="width: 50% !important;">
<div class="form-field-label">{{ "blog_write_form_keywords"|lang }}</div>
<div class="form-field">
<input class="form-field-input" type="text" name="keywords" value="{{ keywords }}" />
</div>
</td>
<td width="50%" style="width: 50% !important;">
<div class="form-field-label">{{ "blog_write_form_source"|lang }}</div>
<div class="form-field">
<input class="form-field-input" type="text" name="source_url" value="{{ source_url }}" />
</div>
</td>
</tr>
</table>
</div>
</td>
</tr>
<tr>
<td colspan="2">
<div class="clearfix">
<div class="form-field-label">{{ "blog_write_form_short_name"|lang }}</div>
<div class="form-field">
<input class="form-field-input" type="text" name="{% if is_edit %}new_short_name{% else %}short_name{% endif %}" value="{{ short_name }}" />
</div>
</div>
</td>
<td>
<div class="form-field-label">&nbsp;</div>
<div class="form-field">
<button type="submit" name="submit_btn"><b>{% if is_edit %}{{ "save"|lang }}{% else %}{{ "blog_write_form_submit_btn"|lang }}{% endif %}</b></button>
</div>
</td>
</tr>
</table>
</div>
</form>
<div id="form_placeholder"></div>
</td>
<td>
<div class="blog-write-form-preview post_text" id="preview_html"></div>
</td>
</tr>
</table>
{% js %}
cur.form = new AdminWriteEditForm({{ js_params|json_encode|raw }});
{% endjs %}

72
skin/admin_uploads.twig Normal file
View File

@ -0,0 +1,72 @@
{% if error %}
<div class="form-error">{{ "error"|lang }}: {{ error }}</div>
{% endif %}
{{ bc([
{text: "admin_title"|lang, url: '/admin/'},
{text: "blog_uploads"|lang}
]) }}
<div class="blog-upload-form">
<form action="/admin/uploads/" method="post" enctype="multipart/form-data" class="form-layout-h">
<input type="hidden" name="token" value="{{ form_token }}" />
<div class="form-field-wrap clearfix">
<div class="form-field-label">{{ "blog_upload_form_file"|lang }}:</div>
<div class="form-field">
<input type="file" name="files[]" multiple>
</div>
</div>
<div class="form-field-wrap clearfix">
<div class="form-field-label">{{ "blog_upload_form_custom_name"|lang }}:</div>
<div class="form-field">
<input type="text" name="name">
</div>
</div>
{% for l in langs %}
<div class="form-field-wrap clearfix">
<div class="form-field-label">{{ "blog_upload_form_note"|lang }} ({{ ("lang_"~l)|lang }}):</div>
<div class="form-field">
<input type="text" name="note_{{ l }}" size="55">
</div>
</div>
{% endfor %}
<div class="form-field-wrap clearfix">
<div class="form-field-label"></div>
<div class="form-field">
<input type="submit" value="Upload">
</div>
</div>
</form>
</div>
<div class="blog-upload-list">
{% for item in uploads %}
<div class="blog-upload-item">
<div class="blog-upload-item-actions">
<a href="javascript:void(0)" onclick="var mdel = ge('upload{{ item.id }}_md'); mdel.style.display = (mdel.style.display === 'none' ? 'block' : 'none')">{{ "blog_upload_show_md"|lang }}</a>
| <a href="javascript:void(0)" onclick='BlogUploadList.submitNoteEdit("/admin/uploads/edit_note/{{ item.id }}/?lang=ru&token={{ csrf('editupl'~post.id) }}", prompt("Note (Ru):", {{ item.getJSONEncodedHtmlSafeNote('ru')|raw }}))'>Edit note Ru</a>
| <a href="javascript:void(0)" onclick='BlogUploadList.submitNoteEdit("/admin/uploads/edit_note/{{ item.id }}/?lang=en&token={{ csrf('editupl'~post.id) }}", prompt("Note (En):", {{ item.getJSONEncodedHtmlSafeNote('en')|raw }}))'>Edit note En</a>
| <a href="/admin/uploads/delete/{{ post.id }}/?token={{ csrf('delupl'~post.id) }}" onclick="return confirm('{{ "blog_upload_delete_confirmation"|lang }}')">{{ "blog_upload_delete"|lang }}</a>
</div>
<div class="blog-upload-item-name"><a href="{{ item.getDirectUrl() }}">{{ item.name }}</a></div>
<div class="blog-upload-item-info">{{ item.getSize() }}</div>
{% if item.noteEn %}
<div class="blog-upload-item-note"><span>En</span>{{ item.noteEn }}</div>
{% endif %}
{% if item.noteRu %}
<div class="blog-upload-item-note"><span>Ru</span>{{ item.noteRu }}</div>
{% endif %}
<div class="blog-upload-item-md" id="upload{{ post.id }}_md" style="display: none">
<input type="text" value="{{ item.getMarkdown() }}" onclick="this.select()" readonly style="width: 100%">
</div>
</div>
{% endfor %}
</div>

30
skin/articles.twig Normal file
View File

@ -0,0 +1,30 @@
{% if not posts %}
<div class="empty">
{{ "blog_no"|lang }}
{% include 'articles_right_links.twig' %}
</div>
{% else %}
<div class="blog-expl">
{{ ("blog_expl_"~selected_lang)|lang|nl2br }}
</div>
<div class="blog-list">
<div class="blog-list-wrap">
{% set year = 3000 %}
{% for post in posts %}
{% if year > post.getYear() %}
<div class="blog-list-title">
{{ post.getYear() }}
{% if loop.index == 1 %}
{% include 'articles_right_links.twig' %}
{% endif %}
</div>
{% set year = post.getYear() %}
{% endif %}
<a href="{{ post.getUrl(selected_lang) }}" class="blog-list-item clearfix{% if not post.visible %} is-hidden{% endif %}">
<div class="blog-list-item-date">{{ post.getDate() }}</div>
<div class="blog-list-item-title">{{ post.getText(selected_lang).title }}</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}

View File

@ -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 %}
<div class="blog-item-right-links">
{% for link in links %}
{% if loop.index > 1 %}
<span class="blog-links-separator">|</span>
{% endif %}
{% if link.url %}
<a href="{{ link.url }}">{{ link.label }}</a>
{% else %}
{{ link.label }}
{% endif %}
{% endfor %}
</div>

View File

@ -1,305 +0,0 @@
<?php
namespace skin\base;
use SkinContext;
use Stringable;
function layout($ctx, $title, $unsafe_body, $static, $meta, $js, $opts, $unsafe_lang, $theme, $is_system_theme_dark, $exec_time, $admin_email, $svg_defs) {
global $config;
$app_config = jsonEncode([
'domain' => $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 <<<HTML
<!doctype html>
<html lang="en">
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link rel="icon" href="/favicon.png?v=2" type="image/png">
<link rel="icon" href="/favicon.ico?v=2" type="image/x-icon">
<link rel="alternate" type="application/rss+xml" href="/feed.rss">
<title>{$title}</title>
<script type="text/javascript">window.appConfig = {$app_config};</script>
{$ctx->meta($meta)}
{$ctx->renderStatic($static, $theme, $is_system_theme_dark)}
</head>
<body{$ctx->if_true($body_class, ' class="'.implode(' ', $body_class).'"')}>
{$ctx->if_true($svg_defs, fn() => $ctx->renderSVGIcons($svg_defs))}
<div class="page-content base-width">
{$ctx->renderHeader($theme, $opts['head_section'], $opts['articles_lang'], $opts['is_index'])}
<div class="page-content-inner">{$unsafe_body}</div>
{$ctx->if_not($opts['full_width'], fn() => $ctx->renderFooter($admin_email))}
</div>
{$ctx->renderScript($js, $unsafe_lang)}
{$ctx->if_not($opts['inside_admin_interface'], fn() => $ctx->render_external_counters())}
</body>
</html>
<!-- rootless -->
{$ctx->if_admin(fn() => "<!-- {$exec_time} s -->")}
HTML;
}
function render_external_counters($ctx) {
return <<<HTML
<!-- Yandex.Metrika counter -->
<script type="text/javascript" >
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
ym(96032069, "init", {
clickmap:true,
trackLinks:true,
accurateTrackBounce:true
});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/96032069" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->
HTML;
}
function renderSVGIcons($ctx, $svg_defs) {
$buf = '<svg style="display: none">';
foreach ($svg_defs as $name => $icon) {
$buf .= <<<SVG
<symbol id="svgicon_{$name}" viewBox="0 0 {$icon['width']} {$icon['height']}" fill="currentColor">
{$icon['svg']}
</symbol>
SVG;
}
$buf .= '</svg>';
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 <<<HTML
<script type="text/javascript">
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();
</script>
HTML;
}
function meta($ctx, $meta) {
if (empty($meta))
return '';
return implode('', array_map(function(array $item): string {
$s = '<meta';
foreach ($item as $k => $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).'&amp;v='.time();
} else {
$href = '/dist-js/'.$bname.'.js?v='.getStaticVersion($name);
}
return '<script src="'.$href.'" type="text/javascript"'.getStaticIntegrityAttribute($name).'></script>';
}
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).'&amp;theme='.$theme.'&amp;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 '<link rel="stylesheet" id="'.$id.'" type="text/css" href="'.$href.'"'.getStaticIntegrityAttribute($config_name).'>';
}
function cssPrefetchLink(string $name): string {
$url = '/dist-css/'.$name.'.css?v='.getStaticVersion('css/'.$name.'.css');
$integrity = getStaticIntegrityAttribute('css/'.$name.'.css');
return <<<HTML
<link rel="prefetch" href="{$url}"{$integrity} />
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 <<<HTML
<div class="{$class}">
<div class="head-inner">
<div class="head-logo-wrap">
<div class="head-logo">
<a href="/">
<div class="head-logo-title">4in1 <span class="head-logo-title-author">by idb & friends</span></div>
{$ctx->if_true($show_subtitle, '<div class="head-logo-subtitle">Mask of Shakespeare and Mysteries of Bacon, <br>Book by Cartier and Secrets of the NSA</div>')}
</a>
</div>
</div>
<div class="head-items">
{$ctx->for_each($items, fn($item) => $ctx->renderHeaderItem(
$item['url'],
$item['label'],
$item['type'] ?? false,
$item['type_opts'] ?? null,
$item['selected'] ?? false
))}
</div>
</div>
</div>
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 <<<HTML
<a class="head-item{$class}" href="{$url}"{$args}>{$unsafe_label}</a>
HTML;
}
function renderFooter($ctx, $admin_email): string {
return <<<HTML
<div class="footer">
Email: <a href="mailto:{$admin_email}">{$admin_email}</a>
</div>
HTML;
}

View File

@ -1,23 +0,0 @@
<?php
namespace skin\error;
use Stringable;
function http_error($ctx,
int $code,
string|Stringable $title,
string|Stringable|null $message = null) {
return <<<HTML
<html>
<head><title>$code $title</title></head>
<body>
<center><h1>$code $title</h1></center>
{$ctx->if_true($message,
'<hr><p align="center">'.$message.'</p>'
)}
</body>
</html>
HTML;
}

9
skin/error.twig Normal file
View File

@ -0,0 +1,9 @@
<html>
<head><title>{{ code }} {{ title }}</title></head>
<body>
<center><h1>{{ code }} {{ title }}</h1></center>
{% if message %}
<hr><p align="center">{{ message }}</p>
{% endif %}
</body>
</html>

View File

@ -1,261 +0,0 @@
<?php
namespace skin\files;
use BookFileType;
use BookItem;
use FilesCollection;
use FilesItemInterface;
use SkinContext;
use SkinString;
use function svg;
function index($ctx,
array $collections,
array $books,
array $misc) {
return <<<HTML
{$ctx->bc([
['text' => $ctx->lang('files_archives')]
])}
<div class="files-list">
{$ctx->for_each($collections,
fn(FilesItemInterface $file) => $ctx->file(
file: $file,
disabled: !$file->isAvailable()))}
</div>
{$ctx->bc([
['text' => $ctx->lang('files_books')]
], mt: true)}
<div class="files-list">
{$ctx->for_each($books, fn(FilesItemInterface $file) => $ctx->file(file: $file))}
</div>
{$ctx->bc([
['text' => $ctx->lang('files_misc')]
], mt: true)}
<div class="files-list">
{$ctx->for_each($misc, fn(FilesItemInterface $file) => $ctx->file(file: $file))}
</div>
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 <<<HTML
{$ctx->bc($bc)}
<div class="files-list">
<div id="files_list">
{$ctx->collection_files($files)}
</div>
</div>
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 = <<<HTML
{$ctx->bc($bc)}
{$ctx->if_true($do_show_search, fn() => $ctx->collection_search($search_count, $search_query, $ctx->lang('files_'.$collection->value.'_search_ph')))}
<div class="files-list">
<div id="files_list">
{$ctx->collection_files($files, $search_query, $text_excerpts)}
</div>
<div class="files-list-show-more no-select" id="files_show_more"{$ctx->if_not($do_show_more, ' style="display: none"')}>
<span class="files-list-show-more-label">{$ctx->lang('files_show_more')}</span>
{$widgets->spinner('files_show_more_spinner')}
</div>
</div>
<div id="files_list_hidden" style="display: none"></div>
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 = <<<JAVASCRIPT
cur.search = new FileSearch({$opts});
JAVASCRIPT;
return [$html, $js];
} else {
return $html;
}
}
function collection_files($ctx,
array $files,
?string $search_query = null,
?array $text_excerpts = null) {
return $ctx->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 <<<HTML
<div class="files-search-wrap">
<div class="files-search" id="files_search">
<div class="files-search-icon">{$icons->search_20()}</div>
<input type="text" value="{$query}" placeholder="{$ctx->if_then_else($placeholder !== null, $placeholder, 'Enter your request..')}" id="files_search_input">
<div class="files-search-clear-icon" id="files_search_clear_icon" style="display: {$clear_dsp}">{$icons->clear_16()}</div>
</div>
<div class="files-search-results-info" id="files_search_info" style="display: {$clear_dsp}">
<div class="files-search-results-info-inner">
<div class="files-search-results-info-spinner">{$widgets->spinner()}</div>
<span id="files_search_info_text">{$ctx->if_then_else($query, fn() => $ctx->lang_num('files_search_results_count', $count), '&nbsp;')}
</div>
</div>
</div>
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 <<<HTML
<a href="{$url}" class="{$class}" data-id="{$file->getId()}"{$ctx->if_true($file->isTargetBlank(), ' target="_blank"')}>
<div class="files-list-item-icon">{$icon}</div>
<div class="files-list-item-info">
<div class="files-list-item-title">
<span class="files-list-item-title-label">{$title}</span>
{$ctx->if_true($file->isFolder() && $file->isTargetBlank(), fn() => '<span class="files-list-item-title-label-external-icon">'.$icons->arrow_up_right_out_square_outline_12().'</span>')}
{$ctx->if_true($subtitle, fn() => '<span class="files-list-item-subtitle">'.htmlescape($subtitle).'</span>')}
{$ctx->if_true($meta_is_inline, $ctx->for_each($meta_items, fn($s) => '<div class="files-list-item-meta-item">'.$s.'</div>'))}
</div>
{$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))}
</div>
</a>
HTML;
}
/**
* @param SkinContext $ctx
* @param string[] $meta strings are already html-safe
* @return string
*/
function meta($ctx, array $meta) {
return <<<HTML
<div class="files-list-item-meta">
{$ctx->for_each($meta, fn($s) => '<div class="files-list-item-meta-item">'.$s.'</div>')}
</div>
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 <<<HTML
<div class="files-list-item-text-excerpt">{$text}</div>
HTML;
}

View File

@ -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 %}
<div class="files-search-wrap">
<div class="files-search" id="files_search">
<div class="files-search-icon">{{ svg('search_20') }}</div>
<input
type="text"
value="{{ search_query }}"
placeholder="{% if placeholder %}{{ placeholder }}{% else %}Enter your request..{% endif %}"
id="files_search_input">
<div class="files-search-clear-icon" id="files_search_clear_icon" style="display: {% if search_query %}block{% else %}none{% endif %}">{{ svg('clear_16') }}</div>
</div>
<div class="files-search-results-info" id="files_search_info" style="display: {% if search_query %}block{% else %}none{% endif %}">
<div class="files-search-results-info-inner">
<div class="files-search-results-info-spinner">{% include 'spinner.twig' %}</div>
<span id="files_search_info_text">{% if search_query %}{{ "files_search_results_count"|plural(search_count) }}{% else %}&nbsp;{% endif %}</span>
</div>
</div>
</div>
{% endif %}
<div class="files-list">
<div id="files_list">
{% include 'files_list.twig' %}
</div>
<div class="files-list-show-more no-select" id="files_show_more"{% if not do_show_more %} style="display: none"{% endif %}>
<span class="files-list-show-more-label">{{ "files_show_more"|lang }}</span>
{% include 'spinner.twig' with {spinner_id: 'files_show_more_spinner'} %}
</div>
</div>
<div id="files_list_hidden" style="display: none"></div>
{% js %}
{% if do_show_search %}
cur.search = new FileSearch({{ js_params|json_encode|raw }});
{% endif %}
{% endjs %}

67
skin/files_file.twig Normal file
View File

@ -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) %}
<div class="files-list-item-text-excerpt">{{ text|raw }}</div>
{% 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 %}
<a href="{{ file.getUrl() }}"
class="files-list-item clearfix{% if not file.isAvailable() %} is-disabled{% endif %}"
data-id="{{ file.getId() }}"
{% if file.isTargetBlank() %}target="_blank"{% endif %}
>
<div class="files-list-item-icon">
{% if file.isBook() %}
{{ svg('book_20') }}
{% else %}
{% if file.isFile() %}
{{ svg('file_20') }}
{% else %}
{{ svg('folder_20') }}
{% endif %}
{% endif %}
</div>
<div class="files-list-item-info">
<div class="files-list-item-title">
<span class="files-list-item-title-label">{{ title|raw }}</span>
{% if file.isFolder() and file.isTargetBlank() %}
<span class="files-list-item-title-label-external-icon">{{ svg('arrow_up_right_out_square_outline_12') }}</span>
{% endif %}
{% if subtitle %}
<span class="files-list-item-subtitle">{{ subtitle }}</span>
{% endif %}
{% if meta.inline %}
{% for item in meta.items %}
<div class="files-list-item-meta-item">{{ item }}</div>
{% endfor %}
{% endif %}
</div>
{% if meta.items and not meta.inline %}
<div class="files-list-item-meta">
{% for item in meta.items %}
<div class="files-list-item-meta-item">{{ item }}</div>
{% endfor %}
</div>
{% endif %}
{% if text_excerpts[file.getId()] %}
{{ macros.excerptWithHighlight(text_excerpts[file.getId()]['index'], text_excerpts[file.getId()]['excerpt'], query) }}
{% endif %}
</div>
</a>

7
skin/files_folder.twig Normal file
View File

@ -0,0 +1,7 @@
{{ svgPreload('folder_20', 'file_20') }}
{{ bc(bc) }}
<div class="files-list">
<div id="files_list">
{% include 'files_list.twig' %}
</div>
</div>

14
skin/files_index.twig Normal file
View File

@ -0,0 +1,14 @@
{{ bc([{text: "files_archives"|lang}]) }}
<div class="files-list">
{% include 'files_list.twig' with {files: collections} %}
</div>
{{ bc([{text: "files_books"|lang}], null, true) }}
<div class="files-list">
{% include 'files_list.twig' with {files: books} %}
</div>
{{ bc([{text: "files_misc"|lang}], null, true) }}
<div class="files-list">
{% include 'files_list.twig' with {files: misc} %}
</div>

3
skin/files_list.twig Normal file
View File

@ -0,0 +1,3 @@
{% for file in files %}
{% include 'files_file.twig' with {file: file} %}
{% endfor %}

44
skin/footer.twig Normal file
View File

@ -0,0 +1,44 @@
</div>
{% if not render_options.full_width %}
<div class="footer">
<div class="footer-right">
Email: <a href="mailto:{{ admin_email }}">{{ admin_email }}</a>
</div>
<div class="footer-left">
Theme: <a id="switch-theme" href="javascript:void(0)" onclick="return ThemeSwitcher.next(event)">{{ theme }}</a>
{% if is_admin %}
<span class="footer-separator">|</span> <a href="/admin/">Admin panel</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
{{ script_html|raw }}
{% if not render_options.inside_admin_interface %}
<!-- Yandex.Metrika counter -->
<script type="text/javascript" >
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
ym(96032069, "init", {
clickmap:true,
trackLinks:true,
accurateTrackBounce:true
});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/96032069" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->
{% endif %}
</body>
</html>
<!-- rootless -->
{% if is_admin %}
<!-- {{ exec_time }} s -->
{% endif %}

40
skin/header.twig Normal file
View File

@ -0,0 +1,40 @@
<!doctype html>
<html lang="en">
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link rel="icon" href="/favicon.png?v=2" type="image/png">
<link rel="icon" href="/favicon.ico?v=2" type="image/x-icon">
<link rel="alternate" type="application/rss+xml" href="/feed.rss">
<title>{{ title }}</title>
<script type="text/javascript">window.appConfig = {{ app_config|json_encode|raw }};</script>
{{ meta_html|raw }}
{{ static_html|raw }}
</head>
<body{% if body_class %} class="{{ body_class|join(' ') }}"{% endif %}>
{{ svg_html|raw }}
<div class="page-content base-width">
<div class="head{% if not render_options.is_index %} no-subtitle{% endif %}">
<div class="head-inner">
<div class="head-logo-wrap">
<div class="head-logo">
<a href="/">
<div class="head-logo-title">4in1 <span class="head-logo-title-author">by idb & friends</span></div>
{% if render_options.is_index %}
<div class="head-logo-subtitle">Mask of Shakespeare and Mysteries of Bacon, <br>Book by Cartier and Secrets of the NSA</div>
{% endif %}
</a>
</div>
</div>
<div class="head-items">
<a class="head-item {% if render_options.head_sections == 'articles' %} is-selected{% endif %}" href="/articles/{% if render_options.articles_lang and render_options.articles_lang != 'en' %}?lang={{ render_options.articles_lang }}{% endif %}">articles</a>
<a class="head-item {% if render_options.head_sections == 'files' %} is-selected{% endif %}" href="/files/">files</a>
<a class="head-item {% if render_options.head_sections == 'wiki' %} is-selected{% endif %}" href="/wiki/">wiki</a>
<a class="head-item {% if render_options.head_sections == 'about' %} is-selected{% endif %}" href="/info/">about</a>
<a class="head-item is-ic" href="https://ic.4in1.ws">invisible college</a>
</div>
</div>
</div>
<div class="page-content-inner">

View File

@ -1,71 +0,0 @@
<?php
namespace skin\icons;
function folder_20($ctx) {
return <<<SVG
<path fill-rule="evenodd" d="M7.066 2.058C6.812 2 6.552 2 6.256 2h-.09c-.65 0-1.18 0-1.612.034-.446.035-.85.11-1.23.295A3.25 3.25 0 0 0 1.83 3.824c-.185.38-.26.784-.295 1.23-.034.432-.034.962-.034 1.611v5.616c0 .674 0 1.224.037 1.672.037.463.118.882.317 1.272a3.25 3.25 0 0 0 1.42 1.42c.391.2.81.28 1.273.318.447.037.998.037 1.671.037h7.563c.674 0 1.224 0 1.672-.037.463-.038.882-.118 1.272-.317a3.25 3.25 0 0 0 1.42-1.42c.2-.391.28-.81.318-1.273.037-.448.037-.998.037-1.672V8.85c0-.673 0-1.224-.037-1.672-.037-.463-.118-.881-.317-1.272a3.25 3.25 0 0 0-1.42-1.42c-.391-.2-.81-.28-1.273-.318-.447-.037-.998-.037-1.672-.037h-3.486c-.404 0-.484-.004-.55-.02a.75.75 0 0 1-.218-.091c-.059-.036-.118-.09-.403-.377l-.758-.764c-.208-.21-.391-.395-.611-.534a2.25 2.25 0 0 0-.69-.287ZM6.196 3.5c.39 0 .466.004.53.02a.75.75 0 0 1 .23.095c.056.035.113.086.388.363l.759.766c.216.218.406.41.636.552.202.125.423.217.653.273.263.063.534.063.84.063h3.518c.712 0 1.202 0 1.58.031.371.03.57.086.714.16.33.167.598.435.765.764.074.144.13.343.16.714.005.063.009.129.012.199H3v-.805c0-.686 0-1.157.03-1.523.027-.357.08-.55.147-.69a1.75 1.75 0 0 1 .805-.805c.14-.068.333-.12.69-.148.366-.029.837-.03 1.523-.03ZM3 9v3.25c0 .712 0 1.202.032 1.58.03.371.085.57.159.714.167.33.435.597.764.765.145.074.344.129.714.16.38.03.869.03 1.581.03h7.5c.712 0 1.202 0 1.58-.03.371-.031.57-.086.714-.16a1.75 1.75 0 0 0 .765-.765c.074-.144.13-.343.16-.713.03-.38.031-.869.031-1.581V9H3Z" clip-rule="evenodd"/>
SVG;
}
function settings_28($ctx) {
return <<<SVG
<path d="M10.648 5.894c1.465-.84 1.719-1.714 1.894-2.588.194-.973.486-.973.972-.973h.972c.486 0 .68 0 .972.973.263.876.447 1.752 1.903 2.592 1.63.443 2.428.003 3.17-.491.825-.55 1.031-.344 1.375 0l.687.687c.344.344.482.481 0 1.375-.433.805-.923 1.555-.487 3.179.84 1.465 1.714 1.719 2.588 1.894.973.194.973.486.973.972v.972c0 .486 0 .68-.973.972-.876.263-1.752.447-2.592 1.903-.443 1.63-.003 2.428.491 3.17.55.825.344 1.031 0 1.375l-.687.687c-.344.344-.481.482-1.375 0-.805-.433-1.555-.923-3.179-.487-1.465.84-1.719 1.714-1.894 2.588-.194.973-.486.973-.972.973h-.972c-.486 0-.68 0-.972-.973-.263-.876-.447-1.752-1.903-2.592-1.63-.443-2.428-.003-3.17.491-.825.55-1.031.344-1.375 0l-.687-.687c-.344-.344-.482-.481 0-1.375.433-.805.923-1.555.487-3.179-.84-1.465-1.714-1.719-2.588-1.894-.973-.194-.973-.486-.973-.972v-.972c0-.486 0-.68.973-.972.876-.263 1.752-.447 2.592-1.903.443-1.63.003-2.428-.491-3.17-.55-.825-.344-1.031 0-1.375l.687-.687c.344-.344.481-.482 1.375 0 .805.433 1.555.923 3.179.487ZM14 19.502a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11Z"/>
SVG;
}
function moon_auto_18($ctx) {
return <<<SVG
<path fill-rule="evenodd" d="M14.54 10.37c-4.3 1.548-8.458-2.61-6.91-6.91a.59.59 0 0 0-.74-.75 6.66 6.66 0 0 0-2.47 1.54c-3.028 2.985-2.485 8.012 1.111 10.282 3.596 2.269 8.368.596 9.759-3.422a.59.59 0 0 0-.75-.74Z" />
<path d="M13.502 6.513V5.194h-1.389q-.802 0-1.195.346-.392.346-.392 1.06 0 .651.398 1.032.398.38 1.078.38.674 0 1.084-.415.416-.416.416-1.084zm1.078-1.934v3.27h.961v.62h-2.039v-.673q-.357.433-.826.639-.469.205-1.096.205-1.037 0-1.646-.551-.61-.55-.61-1.488 0-.967.698-1.5.697-.534 1.968-.534h1.512V4.14q0-.71-.433-1.096-.428-.393-1.207-.393-.645 0-1.026.293-.38.293-.474.868h-.557v-1.26q.562-.24 1.09-.358.533-.123 1.037-.123 1.295 0 1.969.645.68.638.68 1.863z" />
SVG;
}
function moon_light_18($ctx) {
return <<<SVG
<path fill-rule="evenodd" d="M14.54 10.37a5.4 5.4 0 0 1-6.91-6.91.59.59 0 0 0-.74-.75 6.66 6.66 0 0 0-2.47 1.54 6.6 6.6 0 1 0 10.87 6.86.59.59 0 0 0-.75-.74zm-1.61 2.39a5.44 5.44 0 0 1-7.69-7.69 5.58 5.58 0 0 1 1-.76 6.55 6.55 0 0 0 7.47 7.47 5.15 5.15 0 0 1-.78.98z"/>
SVG;
}
function moon_dark_18($ctx) {
return <<<SVG
<path fill-rule="evenodd" d="M14.54 10.37c-4.3 1.548-8.458-2.61-6.91-6.91a.59.59 0 0 0-.74-.75 6.66 6.66 0 0 0-2.47 1.54c-3.028 2.985-2.485 8.012 1.111 10.282 3.596 2.269 8.368.596 9.759-3.422a.59.59 0 0 0-.75-.74Z"/>
SVG;
}
function search_20($ctx) {
return <<<SVG
<path clip-rule="evenodd" d="m9.5 4.5c-2.76142 0-5 2.23858-5 5 0 2.7614 2.23858 5 5 5 2.7614 0 5-2.2386 5-5 0-2.76142-2.2386-5-5-5zm-6.5 5c0-3.58985 2.91015-6.5 6.5-6.5 3.5899 0 6.5 2.91015 6.5 6.5 0 1.5247-.525 2.9268-1.404 4.0353l3.1843 3.1844c.2929.2929.2929.7677 0 1.0606s-.7677.2929-1.0606 0l-3.1844-3.1843c-1.1085.879-2.5106 1.404-4.0353 1.404-3.58985 0-6.5-2.9101-6.5-6.5z" fill="currentColor" fill-rule="evenodd"/>
SVG;
}
function file_20($ctx) {
return <<<SVG
<path fill-rule="evenodd" d="M10.175 1.5H9c-.806 0-1.465.006-2.01.05-.63.052-1.172.16-1.67.413a4.25 4.25 0 0 0-1.857 1.858c-.253.497-.361 1.04-.413 1.67C3 6.103 3 6.864 3 7.816v4.366c0 .952 0 1.713.05 2.327.052.63.16 1.172.413 1.67a4.25 4.25 0 0 0 1.858 1.857c.497.253 1.04.361 1.67.413.613.05 1.374.05 2.326.05h1.366c.952 0 1.713 0 2.327-.05.63-.052 1.172-.16 1.67-.413a4.251 4.251 0 0 0 1.857-1.857c.253-.498.361-1.04.413-1.67.05-.614.05-1.375.05-2.327V8.325c0-.489 0-.733-.055-.963-.05-.205-.13-.4-.24-.579-.123-.201-.296-.374-.642-.72l-3.626-3.626c-.346-.346-.519-.519-.72-.642a2.001 2.001 0 0 0-.579-.24c-.23-.055-.474-.055-.963-.055ZM15.5 12.15c0 .992 0 1.692-.045 2.238-.044.537-.127.86-.255 1.11A2.751 2.751 0 0 1 14 16.7c-.252.128-.574.21-1.111.255-.546.044-1.245.045-2.238.045h-1.3c-.992 0-1.692 0-2.238-.045-.537-.044-.86-.127-1.11-.255A2.75 2.75 0 0 1 4.8 15.5c-.128-.252-.21-.574-.255-1.111-.044-.546-.045-1.245-.045-2.238v-4.3c0-.992 0-1.692.045-2.238.044-.537.127-.86.255-1.11A2.75 2.75 0 0 1 6.002 3.3c.25-.128.573-.21 1.11-.255C7.658 3.001 8.358 3 9.35 3H10v2.35c0 .409 0 .761.024 1.051.026.306.083.61.238.902.21.398.537.724.935.935.291.155.596.212.902.238.29.024.642.024 1.051.024h2.35v3.65ZM14.879 7 11.5 3.621V5.32c0 .447 0 .736.02.955.017.21.047.288.067.326a.75.75 0 0 0 .312.312c.038.02.116.05.326.068.22.018.508.019.955.019h1.699Zm-8.876 7.248a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Zm.75-3.749a.75.75 0 1 0 0 1.5h4.5a.75.75 0 0 0 0-1.5h-4.5Z" clip-rule="evenodd"/>
SVG;
}
function book_20($ctx) {
return <<<SVG
<path clip-rule="evenodd" d="m6.91957 1.5h.03043 6.1.0304c.5342-.00001.98-.00002 1.3443.02974.3799.03104.7365.09815 1.0738.26999.5174.26366.9381.68435 1.2018 1.2018.1718.33726.2389.69392.27 1.0738.0297.36422.0297.81005.0297 1.34418v.03049 9.3c0 .4142-.3358.75-.75.75h-.1885c-.0335.1873-.0615.4371-.0615.75s.028.5627.0615.75h.1885c.4142 0 .75.3358.75.75s-.3358.75-.75.75h-10.99999c-1.24265 0-2.25001-1.0073-2.25001-2.25v-10.8-.03043c-.00001-.53415-.00002-.98001.02974-1.34424.03104-.37988.09815-.73654.26999-1.0738.26366-.51745.68435-.93814 1.2018-1.2018.33726-.17184.69392-.23895 1.0738-.26999.36423-.02976.81009-.02975 1.34424-.02974zm7.62533 15.5c-.0272-.218-.0449-.4681-.0449-.75s.0177-.532.0449-.75h-9.29489c-.41422 0-.75001.3358-.75001.75s.33579.75.75001.75zm.9551-11.55v8.55h-10.24999c-.26298 0-.51542.0451-.75001.128v-8.678c0-.57243.00058-.95664.02476-1.25252.02346-.28712.06534-.42441.11148-.51497.11984-.2352.31107-.42642.54627-.54627.09056-.04614.22785-.08802.51497-.11148.29588-.02417.68009-.02476 1.25252-.02476h6.1c.5724 0 .9566.00059 1.2525.02476.2871.02346.4244.06534.515.11148.2352.11985.4264.31107.5463.54627.0461.09056.088.22785.1114.51497.0242.29588.0248.68009.0248 1.25252z" fill="currentColor" fill-rule="evenodd"/>
SVG;
}
function clear_20($ctx) {
return <<<SVG
<path clip-rule="evenodd" d="m20 10c0-5.52285-4.4772-10-10-10-5.52285 0-10 4.47715-10 10 0 5.5228 4.47715 10 10 10 5.5228 0 10-4.4772 10-10zm-12.29289-3.70711c-.39053-.39052-1.02369-.39052-1.41422 0-.39052.39053-.39052 1.02369 0 1.41422l2.2929 2.29289-2.2929 2.2929c-.39052.3905-.39052 1.0237 0 1.4142.39053.3905 1.02369.3905 1.41422 0l2.29289-2.2929 2.2929 2.2929c.3905.3905 1.0237.3905 1.4142 0s.3905-1.0237 0-1.4142l-2.2929-2.2929 2.2929-2.29289c.3905-.39053.3905-1.02369 0-1.41422-.3905-.39052-1.0237-.39052-1.4142 0l-2.2929 2.2929z" fill="currentColor" fill-rule="evenodd"/>
SVG;
}
function clear_16($ctx) {
return <<<SVG
<path fill-rule="evenodd" d="M16 8A8 8 0 1 0 0 8a8 8 0 0 0 16 0ZM4.563 4.564a.9.9 0 0 0 0 1.272L6.727 8l-2.164 2.164a.9.9 0 1 0 1.273 1.272L8 9.273l2.164 2.163a.9.9 0 0 0 1.272-1.272L9.273 8l2.163-2.164a.9.9 0 1 0-1.272-1.272L8 6.727 5.836 4.564a.9.9 0 0 0-1.273 0Z" clip-rule="evenodd"/>
SVG;
}
function arrow_up_right_out_square_outline_12($ctx) {
return <<<SVG
<path fill="currentColor" d="M4.25 1a.75.75 0 1 1 0 1.5c-.5006 0-.6414.0036-.7439.024a1.25 1.25 0 0 0-.982.9821c-.0205.1025-.0241.2433-.0241.7439v2.8c0 .5724.0006.9566.0248 1.2525.0234.2871.0653.4244.1114.515a1.25 1.25 0 0 0 .5463.5463c.0906.0461.2279.088.515.1114.2959.0242.68.0248 1.2525.0248h2.8c.5006 0 .6414-.0036.7439-.024a1.25 1.25 0 0 0 .982-.9821c.0205-.1025.0241-.2433.0241-.7439a.75.75 0 0 1 1.5 0c0 .4287.0036.7526-.0528 1.0365a2.7501 2.7501 0 0 1-2.1607 2.1607c-.2675.0532-.5705.053-.9632.0528-.968-.0005-1.9358 0-2.9037 0-.5342 0-.98 0-1.3443-.0297-.3798-.0311-.7365-.0982-1.0738-.27a2.7504 2.7504 0 0 1-1.2018-1.2018c-.1718-.3373-.239-.694-.27-1.0738C1 8.0604 1 7.6146 1 7.0804c0-.9679.0005-1.9358 0-2.9037-.0002-.3927-.0004-.6957.0528-.9632a2.75 2.75 0 0 1 2.1607-2.1607C3.4975.9964 3.8213 1 4.25 1Z M7 .75A.75.75 0 0 1 7.75 0h3.5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0V2.5607L7.2803 5.7803a.75.75 0 0 1-1.0606-1.0606L9.4393 1.5H7.75A.75.75 0 0 1 7 .75Z"/>
SVG;
}

43
skin/index.twig Normal file
View File

@ -0,0 +1,43 @@
<div class="page is-index">
<div class="blog-post-text">
<div class="clearfix index-book">
<div class="index-book-image-wrap">
<a class="index-book-image" id="index-book-image" href="https://files.4in1.ws/Images/4in1-cover-en.png" target="_blank" data-link-template="https://files.4in1.ws/Images/4in1-cover-{lang}.png"></a>
</div>
<a class="index-dl-line" href="https://files.4in1.ws/4in1-en.pdf?{{versions.en}}" onmouseenter="IndexPage.setCoverLang('en')">
<b>Download in English</b><br>
<div class="index-dl-line-info">
PDF <span class="bullet">&#8226;</span> 379 pp. <span class="bullet">&#8226;</span> 22 MiB
</div>
</a>
<a class="index-dl-line" href="https://files.4in1.ws/4in1-ru.pdf?{{versions.ru}}" onmouseenter="IndexPage.setCoverLang('ru')">
<b>Скачать на русском</b>
<div class="index-dl-line-info">
PDF <span class="bullet">&#8226;</span> 453 стр. <span class="bullet">&#8226;</span> 26 MiB
</div>
</a>
<div class="index-book-updates">
Released by <a href="https://kiwibyrd.org" target="_blank">kiwi arXiv</a> & <a href="https://kniganews.org" target="_blank">kniganews</a> in 2023<br>
English translation by Eline Cat<br>
</div>
</div>
</div>
<div class="blog-list">
<div class="blog-list-title">{{ "recent_articles"|lang }}</div>
<div class="blog-list-wrap">
{% for i, post in posts %}
<div class="blog-list-item clearfix no-block{% if not post.visible %} is-hidden{% endif %}">
<div class="blog-list-item-date">{{ post.getDate() }}</div>
<div class="blog-list-item-title"><a href="{{ post.getUrl(posts_lang) }}">{{ post.getText(posts_lang).title }}</a></div>
</div>
{% endfor %}
<a href="/articles/" class="blog-list-expand">
{{ "view_all_articles"|lang }}
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M7.47 4.217a.75.75 0 0 0 0 1.06L12.185 10 7.469 14.72a.75.75 0 1 0 1.062 1.06l5.245-5.25a.75.75 0 0 0 0-1.061L8.531 4.218a.75.75 0 0 0-1.061-.001z" fill="currentColor"/></svg>
</a>
</div>
</div>
</div>

View File

@ -1,269 +0,0 @@
<?php
namespace skin\main;
// articles page
// -------------
use Post;
use PostLanguage;
use function is_admin;
function index($ctx, array $versions, array $posts, PostLanguage $posts_lang) {
return <<<HTML
<div class="page is-index">
<div class="blog-post-text">
<div class="clearfix index-book">
<div class="index-book-image-wrap">
<a class="index-book-image" id="index-book-image" href="https://files.4in1.ws/Images/4in1-cover-en.png" target="_blank" data-link-template="https://files.4in1.ws/Images/4in1-cover-{lang}.png"></a>
</div>
<a class="index-dl-line" href="https://files.4in1.ws/4in1-en.pdf?{$versions['en']}" onmouseenter="IndexPage.setCoverLang('en')">
<b>Download in English</b><br>
<div class="index-dl-line-info">
PDF <span class="bullet">&#8226;</span> 379 pp. <span class="bullet">&#8226;</span> 22 MiB
</div>
</a>
<a class="index-dl-line" href="https://files.4in1.ws/4in1-ru.pdf?{$versions['ru']}" onmouseenter="IndexPage.setCoverLang('ru')">
<b>Скачать на русском</b>
<div class="index-dl-line-info">
PDF <span class="bullet">&#8226;</span> 453 стр. <span class="bullet">&#8226;</span> 26 MiB
</div>
</a>
<div class="index-book-updates">
Released by <a href="https://kiwibyrd.org" target="_blank">kiwi arXiv</a> & <a href="https://kniganews.org" target="_blank">kniganews</a> in 2023<br>
English translation by Eline Cat<br>
</div>
</div>
</div>
<div class="blog-list">
<div class="blog-list-title">{$ctx->lang('recent_articles')}</div>
<div class="blog-list-wrap">
{$ctx->for_each($posts,
fn($post, $i) => $ctx->articles_post_row($i, $post, $posts_lang, true, false))}
<a href="/articles/" class="blog-list-expand">
{$ctx->lang('view_all_articles')}
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M7.47 4.217a.75.75 0 0 0 0 1.06L12.185 10 7.469 14.72a.75.75 0 1 0 1.062 1.06l5.245-5.25a.75.75 0 0 0 0-1.061L8.531 4.218a.75.75 0 0 0-1.061-.001z" fill="currentColor"/></svg>
</a>
</div>
</div>
</div>
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 <<<HTML
<div class="blog-expl">{$expl}</div>
<div class="blog-list">
{$ctx->articles_posts_table($posts, $selected_lang)}
</div>
HTML;
}
function articles_empty($ctx, PostLanguage $selected_lang) {
return <<<HTML
<div class="empty">
{$ctx->lang('blog_no')}
{$ctx->articles_right_links($selected_lang->value)}
</div>
HTML;
}
function articles_posts_table($ctx, array $posts, PostLanguage $selected_lang): string {
$ctx->year = 3000;
return <<<HTML
<div class="blog-list-wrap">
{$ctx->for_each($posts,
fn($post, $i) => $ctx->articles_post_row($i, $post, $selected_lang))}
</div>
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 ? '<div '.$class.'>' : '<a href="'.$url.'" '.$class.'>';
$buf .= '<div class="blog-list-item-date">'.$date.'</div>';
$buf .= '<div class="blog-list-item-title">'.($no_block ? '<a href="'.$url.'">' : '').$title.($no_block ? '</a>' : '').'</div>';
$buf .= $no_block ? '</div>' : '</a>';
return <<<HTML
{$ctx->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 <<<HTML
<div class="blog-list-title">
{$year}
{$ctx->if_true($show_right_links,
fn() => $ctx->articles_right_links($selected_lang))}
</div>
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 <<<HTML
<div class="blog-item-right-links">
{$ctx->for_each($links, fn($link, $index) => $ctx->articles_right_link($link['url'], $link['label'], $index))}
</div>
HTML;
}
function articles_right_link($ctx, $url, string $label, int $index) {
$buf = '';
if ($index > 0)
$buf .= ' <span class="blog-links-separator">|</span> ';
$buf .= !$url ? $label : '<a href="'.$url.'">'.$label.'</a>';
return $buf;
}
// any page
// --------
function page($ctx, $page_url, $short_name, $unsafe_html, $bc) {
$html = <<<HTML
<div class="page">
{$ctx->if_true($bc, fn() => $ctx->bc($bc))}
{$ctx->if_admin($ctx->page_admin_links, $page_url, $short_name)}
<div class="blog-post-text">{$unsafe_html}</div>
</div>
HTML;
return [$html, js_markdownThemeChangeListener()];
}
function page_admin_links($ctx, $url, $short_name) {
return <<<HTML
<div class="page-edit-links">
<a href="{$url}edit/">{$ctx->lang('edit')}</a>
<a href="{$url}delete/?token={$ctx->csrf('delpage'.$short_name)}" onclick="return confirm('{$ctx->lang('pages_page_delete_confirmation')}')">{$ctx->lang('delete')}</a>
</div>
HTML;
}
// post page
// ---------
function post($ctx, $id, $title, $unsafe_html, $unsafe_toc_html, $date, $visible, $url, string $lang, $other_langs, string $source_url) {
$html = <<<HTML
<div class="blog-post-wrap2">
<div class="blog-post-wrap1">
<div class="blog-post">
{$ctx->bc([
['url' => '/articles/?lang='.$lang, 'text' => $ctx->lang('articles')]
])}
<div class="blog-post-title">
<h1>{$title}</h1>
<div class="blog-post-date">
{$ctx->if_not($visible, $ctx->lang('blog_post_hidden').' |')}
{$date}
{$ctx->if_true($other_langs, fn() => $ctx->post_other_langs($url, $other_langs))}
{$ctx->if_true($source_url, fn() => ' | <a href="'.$source_url.'">Source at kiwi arXiv</a>')}
{$ctx->if_admin($ctx->post_admin_links, $url, $id, $lang)}
</div>
</div>
<div class="blog-post-text">{$unsafe_html}</div>
</div>
{$ctx->if_true($unsafe_toc_html, $ctx->postToc, $unsafe_toc_html)}
</div>
</div>
HTML;
return [$html, js_markdownThemeChangeListener()];
}
function post_other_langs($ctx, $url, $other_langs) {
$buf = '';
foreach ($other_langs as $lang) {
$buf .= ' | <a href="'.$url.($lang != 'en' ? '?lang='.$lang : '').'">'.$ctx->lang('blog_read_in_'.$lang).'</a>';
}
return $buf;
}
function postToc($ctx, $unsafe_toc_html) {
return <<<HTML
<div class="blog-post-toc">
<div class="blog-post-toc-wrap">
<div class="blog-post-toc-inner-wrap">
<div class="blog-post-toc-title">{$ctx->lang('toc')}</div>
{$unsafe_toc_html}
</div>
</div>
</div>
HTML;
}
function post_admin_links($ctx, $url, $id, string $lang) {
return <<<HTML
| <a href="{$url}edit/?lang={$lang}">{$ctx->lang('edit')}</a>
| <a href="{$url}delete/?token={$ctx->csrf('delpost'.$id)}" onclick="return confirm('{$ctx->lang('blog_post_delete_confirmation')}')">{$ctx->lang('delete')}</a>
HTML;
}
function js_markdownThemeChangeListener() {
return <<<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);
}
});
JS;
}

View File

@ -1,41 +0,0 @@
<?php
namespace skin\markdown;
function fileupload($ctx, $name, $direct_url, $note, $size) {
return <<<HTML
<div class="md-file-attach">
<span class="md-file-attach-icon"></span><a href="{$direct_url}">{$name}</a>
{$ctx->if_true($note, '<span class="md-file-attach-note">'.$note.'</span>')}
<span class="md-file-attach-size">{$size}</span>
</div>
HTML;
}
function image($ctx,
// options
$align, $nolabel, $w, $padding_top, $may_have_alpha,
// image data
$direct_url, $url, $unsafe_note) {
return <<<HTML
<div class="md-image align-{$align}">
<div class="md-image-wrap" data-alpha="{$ctx->if_then_else($may_have_alpha, '1', '0')}">
<a href="{$direct_url}">
<div style="background: url('{$url}') no-repeat; background-size: contain; width: {$w}px; padding-top: {$padding_top}%;"></div>
</a>
</div>
{$ctx->if_true($unsafe_note != '' && !$nolabel,
fn() => '<div class="md-image-note">'.$unsafe_note.'</div>')}
</div>
HTML;
}
function video($ctx, $url, $w, $h) {
return <<<HTML
<div class="md-video">
<div class="md-video-wrap">
<video src="{$url}" controls{$ctx->if_true($w, ' width="'.$w.'"')}{$ctx->if_true($h, ' height="'.$h.'"')}></video>
</div>
</div>
HTML;
}

View File

@ -0,0 +1,7 @@
<div class="md-file-attach">
<span class="md-file-attach-icon"></span><a href="{{ direct_url }}">{{ name }}</a>
{% if note %}
<span class="md-file-attach-note">{{ note }}</span>
{% endif %}
<span class="md-file-attach-size">{{ size }}</span>
</div>

10
skin/markdown_image.twig Normal file
View File

@ -0,0 +1,10 @@
<div class="md-image align-{{ align }}">
<div class="md-image-wrap" data-alpha="{% if may_have_alpha %}1{% else %}0{% endif %}">
<a href="{{ direct_url }}">
<div style="background: url('{{ url }}') no-repeat; background-size: contain; width: {{ w }}px; padding-top: {{ padding_top }}%;"></div>
</a>
</div>
{% if unsafe_note and not nolabel %}
<div class="md-image-note">{{ unsafe_note|raw }}</div>
{% endif %}
</div>

View File

@ -0,0 +1,6 @@
<div class="blog-post" id="blog_post">
{% if title %}
<div class="blog-post-title"><h1>{{ title }}</h1></div>
{% endif %}
<div class="blog-post-text">{{ unsafe_html|raw }}</div>
</div>

9
skin/markdown_video.twig Normal file
View File

@ -0,0 +1,9 @@
<div class="md-video">
<div class="md-video-wrap">
<video src="{{ url }}"
controls
{% if w %}width="{{ w }}"{% endif %}
{% if h %}height="{{ h }}"{% endif %}
></video>
</div>
</div>

14
skin/page.twig Normal file
View File

@ -0,0 +1,14 @@
<div class="page">
{% if bc %}
{{ bc(bc) }}
{% endif %}
{% if is_admin %}
<div class="page-edit-links">
<a href="{{ page.getUrl() }}edit/">{{ "edit"|lang }}</a>
<a href="{{ page.getUrl() }}delete/?token={{ delete_token }}" onclick="return confirm('{{ "pages_page_delete_confirmation"|lang }}')">{{ "delete"|lang }}</a>
</div>
{% endif %}
<div class="blog-post-text">{{ html|raw }}</div>
</div>

63
skin/post.twig Normal file
View File

@ -0,0 +1,63 @@
<div class="blog-post-wrap2">
<div class="blog-post-wrap1">
<div class="blog-post">
{{ bc([{url: '/articles/?lang='~selected_lang, text: "articles"|lang}]) }}
<div class="blog-post-title">
<h1>{{ pt.title }}</h1>
<div class="blog-post-date">
{% if not post.visible %}
{{ ("blog_post_hidden"|lang)~" |" }}
{% endif %}
{{ post.getFullDate() }}
{% if other_langs %}
{% for l in other_langs %}
| <a href="{{ post.getUrl() }}{% if l != 'en' %}?lang={{ l }}{% endif %}">{{ ("blog_read_in_"~l)|lang }}</a>
{% endfor %}
{% endif %}
{% if post.hasSourceUrl() %}
| <a href="{{ post.sourceUrl }}">Source at kiwi arXiv</a>
{% endif %}
{% if is_admin %}
| <a href="{{ post.getUrl() }}edit/?lang={{ selected_lang }}">{{ "edit"|lang }}</a>
| <a href="{{ post.getUrl() }}delete/?token={{ delete_token }}" onclick="return confirm('{{ "blog_post_delete_confirmation"|lang }}')">{{ "delete"|lang }}</a>
{% endif %}
</div>
</div>
<div class="blog-post-text">{{ html|raw }}</div>
</div>
{% if pt.hasTableOfContents() %}
<div class="blog-post-toc">
<div class="blog-post-toc-wrap">
<div class="blog-post-toc-inner-wrap">
<div class="blog-post-toc-title">{{ "toc"|lang }}</div>
{{ pt.getTableOfContentsHtml()|raw }}
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% 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 %}

View File

@ -1,39 +0,0 @@
<?php
namespace skin\rss;
function atom(\SkinContext $ctx,
string $title,
string $link,
string $rss_link,
array $items): string {
return <<<HTML
<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:sy="http://purl.org/rss/1.0/modules/syndication/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
<channel>
<title>{$title}</title>
<link>{$link}</link>
<description/>
<atom:link href="{$rss_link}" rel="self" type="application/rss+xml"/>
{$ctx->for_each($items, fn($item) => $ctx->item(...$item))}
</channel>
</rss>
HTML;
}
function item(\SkinContext $ctx,
string $title,
string $link,
string $pub_date,
string $description): string {
return <<<HTML
<item>
<title>{$title}</title>
<link>{$link}</link>
<pubDate>{$pub_date}</pubDate>
<description>{$description}</description>
</item>
HTML;
}

Some files were not shown because too many files have changed in this diff Show More