migrate to Twig; upgrade request handling engine
This commit is contained in:
parent
f62480c15c
commit
8a98eec610
@ -11,7 +11,8 @@
|
||||
"ext-yaml": "*",
|
||||
"ext-gmp": "*",
|
||||
"ext-memcached": "*",
|
||||
"samdark/sitemap": "^2.1"
|
||||
"samdark/sitemap": "^2.1",
|
||||
"twig/twig": "^3.0"
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
|
332
composer.lock
generated
332
composer.lock
generated
@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "5fba09fb495209abe5954d5dc485a308",
|
||||
"content-hash": "d75fee72f82dcd543f7ac9fd20971de8",
|
||||
"packages": [
|
||||
{
|
||||
"name": "erusev/parsedown",
|
||||
@ -267,19 +267,20 @@
|
||||
},
|
||||
{
|
||||
"name": "samdark/sitemap",
|
||||
"version": "2.1.0",
|
||||
"version": "2.4.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/samdark/sitemap.git",
|
||||
"reference": "6b7eed71534b31d0c6e6dfd18d3cca5a677e0b5b"
|
||||
"reference": "cf514750781275ad90fc9a828b4330c9c5ccba98"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/samdark/sitemap/zipball/6b7eed71534b31d0c6e6dfd18d3cca5a677e0b5b",
|
||||
"reference": "6b7eed71534b31d0c6e6dfd18d3cca5a677e0b5b",
|
||||
"url": "https://api.github.com/repos/samdark/sitemap/zipball/cf514750781275ad90fc9a828b4330c9c5ccba98",
|
||||
"reference": "cf514750781275ad90fc9a828b4330c9c5ccba98",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-xmlwriter": "*",
|
||||
"php": ">=5.3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
@ -311,7 +312,17 @@
|
||||
"issues": "https://github.com/samdark/sitemap/issues",
|
||||
"source": "https://github.com/samdark/sitemap"
|
||||
},
|
||||
"time": "2017-11-24T07:24:48+00:00"
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/samdark",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/samdark",
|
||||
"type": "patreon"
|
||||
}
|
||||
],
|
||||
"time": "2023-11-01T08:41:34+00:00"
|
||||
},
|
||||
{
|
||||
"name": "scrivo/highlight.php",
|
||||
@ -390,6 +401,311 @@
|
||||
}
|
||||
],
|
||||
"time": "2022-12-17T21:53:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/deprecation-contracts",
|
||||
"version": "v3.5.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/deprecation-contracts.git",
|
||||
"reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6",
|
||||
"reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/contracts",
|
||||
"name": "symfony/contracts"
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.5-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"function.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "A generic function and convention to trigger deprecation notices",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-09-25T14:20:29+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-ctype",
|
||||
"version": "v1.31.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-ctype.git",
|
||||
"reference": "a3cc8b044a6ea513310cbd48ef7333b384945638"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638",
|
||||
"reference": "a3cc8b044a6ea513310cbd48ef7333b384945638",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2"
|
||||
},
|
||||
"provide": {
|
||||
"ext-ctype": "*"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-ctype": "For best performance"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/polyfill",
|
||||
"name": "symfony/polyfill"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Polyfill\\Ctype\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Gert de Pagter",
|
||||
"email": "BackEndTea@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony polyfill for ctype functions",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"compatibility",
|
||||
"ctype",
|
||||
"polyfill",
|
||||
"portable"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-09-09T11:45:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-mbstring",
|
||||
"version": "v1.31.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-mbstring.git",
|
||||
"reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341",
|
||||
"reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2"
|
||||
},
|
||||
"provide": {
|
||||
"ext-mbstring": "*"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-mbstring": "For best performance"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/polyfill",
|
||||
"name": "symfony/polyfill"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Polyfill\\Mbstring\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony polyfill for the Mbstring extension",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"compatibility",
|
||||
"mbstring",
|
||||
"polyfill",
|
||||
"portable",
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-09-09T11:45:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "twig/twig",
|
||||
"version": "v3.20.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/twigphp/Twig.git",
|
||||
"reference": "3468920399451a384bef53cf7996965f7cd40183"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/twigphp/Twig/zipball/3468920399451a384bef53cf7996965f7cd40183",
|
||||
"reference": "3468920399451a384bef53cf7996965f7cd40183",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1.0",
|
||||
"symfony/deprecation-contracts": "^2.5|^3",
|
||||
"symfony/polyfill-ctype": "^1.8",
|
||||
"symfony/polyfill-mbstring": "^1.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"psr/container": "^1.0|^2.0",
|
||||
"symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/Resources/core.php",
|
||||
"src/Resources/debug.php",
|
||||
"src/Resources/escaper.php",
|
||||
"src/Resources/string_loader.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Twig\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com",
|
||||
"homepage": "http://fabien.potencier.org",
|
||||
"role": "Lead Developer"
|
||||
},
|
||||
{
|
||||
"name": "Twig Team",
|
||||
"role": "Contributors"
|
||||
},
|
||||
{
|
||||
"name": "Armin Ronacher",
|
||||
"email": "armin.ronacher@active-4.com",
|
||||
"role": "Project Founder"
|
||||
}
|
||||
],
|
||||
"description": "Twig, the flexible, fast, and secure template language for PHP",
|
||||
"homepage": "https://twig.symfony.com",
|
||||
"keywords": [
|
||||
"templating"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/twigphp/Twig/issues",
|
||||
"source": "https://github.com/twigphp/Twig/tree/v3.20.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/twig/twig",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-02-13T08:34:43+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [],
|
||||
@ -409,6 +725,6 @@
|
||||
"ext-gmp": "*",
|
||||
"ext-memcached": "*"
|
||||
},
|
||||
"platform-dev": [],
|
||||
"plugin-api-version": "2.3.0"
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
|
@ -1,10 +1,8 @@
|
||||
#!/bin/sh
|
||||
|
||||
PROGNAME="$0"
|
||||
DIR=$(cd "$(dirname "$(readlink -f "$0")")" && pwd)
|
||||
ROOT="$(realpath "$DIR/../")"
|
||||
CLEANCSS="$ROOT"/node_modules/clean-css-cli/bin/cleancss
|
||||
|
||||
. $DIR/build_common.sh
|
||||
|
||||
build_scss() {
|
||||
|
@ -1,8 +1,6 @@
|
||||
#!/bin/sh
|
||||
|
||||
PROGNAME="$0"
|
||||
DIR=$(cd "$(dirname "$(readlink -f "$0")")" && pwd)
|
||||
|
||||
. $DIR/build_common.sh
|
||||
|
||||
# suckless version of webpack
|
||||
|
@ -61,4 +61,29 @@ EOF;
|
||||
|
||||
function get_hash(string $path): string {
|
||||
return substr(sha1(file_get_contents($path)), 0, 8);
|
||||
}
|
||||
|
||||
function glob_escape(string $pattern): string {
|
||||
if (str_contains($pattern, '[') || str_contains($pattern, ']')) {
|
||||
$placeholder = uniqid();
|
||||
$replaces = array( $placeholder.'[', $placeholder.']', );
|
||||
$pattern = str_replace( array('[', ']', ), $replaces, $pattern);
|
||||
$pattern = str_replace( $replaces, array('[[]', '[]]', ), $pattern);
|
||||
}
|
||||
return $pattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does not support flag GLOB_BRACE
|
||||
*
|
||||
* @param string $pattern
|
||||
* @param int $flags
|
||||
* @return array
|
||||
*/
|
||||
function glob_recursive(string $pattern, int $flags = 0): array {
|
||||
$files = glob(glob_escape($pattern), $flags);
|
||||
foreach (glob(glob_escape(dirname($pattern)).'/*', GLOB_ONLYDIR|GLOB_NOSORT) as $dir) {
|
||||
$files = array_merge($files, glob_recursive($dir.'/'.basename($pattern), $flags));
|
||||
}
|
||||
return $files;
|
||||
}
|
@ -1,6 +1,35 @@
|
||||
<?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 {
|
||||
case ERROR = 10;
|
||||
@ -32,15 +61,15 @@ abstract class Logger {
|
||||
/** @var ?callable $filter */
|
||||
protected $filter = null;
|
||||
|
||||
function setErrorFilter(callable $filter): void {
|
||||
public function setErrorFilter(callable $filter): void {
|
||||
$this->filter = $filter;
|
||||
}
|
||||
|
||||
function disable(): void {
|
||||
public function disable(): void {
|
||||
$this->enabled = false;
|
||||
}
|
||||
|
||||
function enable(): void {
|
||||
public function enable(): void {
|
||||
static $error_handler_set = false;
|
||||
$this->enabled = true;
|
||||
|
||||
@ -87,14 +116,14 @@ abstract class Logger {
|
||||
$error_handler_set = true;
|
||||
}
|
||||
|
||||
function log(LogLevel $level, ?string $stacktrace = null, ...$args): void {
|
||||
if (!is_dev() && $level == LogLevel::DEBUG)
|
||||
public function log(LogLevel $level, ?string $stacktrace = null, ...$args): void {
|
||||
if (!isDev() && $level == LogLevel::DEBUG)
|
||||
return;
|
||||
$this->write($level, strVars($args),
|
||||
stacktrace: $stacktrace);
|
||||
}
|
||||
|
||||
function canReport(): bool {
|
||||
public function canReport(): bool {
|
||||
return $this->recursionLevel < 3;
|
||||
}
|
||||
|
||||
@ -123,7 +152,7 @@ abstract class Logger {
|
||||
|
||||
class FileLogger extends Logger {
|
||||
|
||||
function __construct(protected string $logFile) {}
|
||||
public function __construct(protected string $logFile) {}
|
||||
|
||||
protected function writer(LogLevel $level,
|
||||
int $num,
|
||||
@ -145,7 +174,7 @@ class FileLogger extends Logger {
|
||||
if (strlen($exec_time) < 6)
|
||||
$exec_time .= str_repeat('0', 6 - strlen($exec_time));
|
||||
|
||||
$title = is_cli() ? 'cli' : $_SERVER['REQUEST_URI'];
|
||||
$title = isCli() ? 'cli' : $_SERVER['REQUEST_URI'];
|
||||
$date = date('d/m/y H:i:s', $time);
|
||||
|
||||
$buf = '';
|
||||
@ -178,7 +207,7 @@ class FileLogger extends Logger {
|
||||
|
||||
$buf .= $message."\n";
|
||||
if (in_array($level, [LogLevel::ERROR, LogLevel::WARNING]))
|
||||
$buf .= ($stacktrace ?: backtrace_as_string(2))."\n";
|
||||
$buf .= ($stacktrace ?: backtraceAsString(2))."\n";
|
||||
|
||||
$set_perm = false;
|
||||
if (!file_exists($this->logFile)) {
|
||||
@ -216,8 +245,6 @@ class DatabaseLogger extends Logger {
|
||||
?string $errline = null,
|
||||
?string $stacktrace = null): void
|
||||
{
|
||||
global $AdminSession;
|
||||
|
||||
$db = DB();
|
||||
|
||||
$data = [
|
||||
@ -229,12 +256,12 @@ class DatabaseLogger extends Logger {
|
||||
'line' => $errline ?: 0,
|
||||
'text' => $message,
|
||||
'level' => $level->value,
|
||||
'stacktrace' => $stacktrace ?: backtrace_as_string(2),
|
||||
'is_cli' => intval(is_cli()),
|
||||
'admin_id' => is_admin() ? $AdminSession->id : 0,
|
||||
'stacktrace' => $stacktrace ?: backtraceAsString(2),
|
||||
'is_cli' => intval(isCli()),
|
||||
'admin_id' => isAdmin() ? admin::getId() : 0,
|
||||
];
|
||||
|
||||
if (is_cli()) {
|
||||
if (isCli()) {
|
||||
$data += [
|
||||
'ip' => '',
|
||||
'ua' => '',
|
||||
@ -274,7 +301,7 @@ function strVars(array $args): string {
|
||||
return implode(' ', $args);
|
||||
}
|
||||
|
||||
function backtrace_as_string(int $shift = 0): string {
|
||||
function backtraceAsString(int $shift = 0): string {
|
||||
$bt = debug_backtrace();
|
||||
$lines = [];
|
||||
foreach ($bt as $i => $t) {
|
||||
|
@ -124,7 +124,7 @@ abstract class model {
|
||||
}
|
||||
|
||||
public function get_id() {
|
||||
return $this->{to_camel_case(static::DB_KEY)};
|
||||
return $this->{toCamelCase(static::DB_KEY)};
|
||||
}
|
||||
|
||||
public function as_array(array $properties = [], array $custom_getters = []): array {
|
||||
@ -136,7 +136,7 @@ abstract class model {
|
||||
if (isset($custom_getters[$field]) && is_callable($custom_getters[$field])) {
|
||||
$array[$field] = $custom_getters[$field]();
|
||||
} else {
|
||||
$array[$field] = $this->{to_camel_case($field)};
|
||||
$array[$field] = $this->{toCamelCase($field)};
|
||||
}
|
||||
}
|
||||
|
||||
@ -220,7 +220,7 @@ abstract class model {
|
||||
realType: $real_type,
|
||||
nullable: $type->allowsNull(),
|
||||
modelName: $name,
|
||||
dbName: from_camel_case($name)
|
||||
dbName: fromCamelCase($name)
|
||||
);
|
||||
$list[] = $model_descr;
|
||||
$db_name_map[$model_descr->getDbName()] = $index++;
|
||||
|
@ -2,12 +2,12 @@
|
||||
|
||||
class mysql {
|
||||
|
||||
const DATE_FORMAT = 'Y-m-d';
|
||||
const DATETIME_FORMAT = 'Y-m-d H:i:s';
|
||||
const string DATE_FORMAT = 'Y-m-d';
|
||||
const string DATETIME_FORMAT = 'Y-m-d H:i:s';
|
||||
|
||||
protected ?mysqli $link = null;
|
||||
|
||||
function __construct(
|
||||
public function __construct(
|
||||
protected string $host,
|
||||
protected string $user,
|
||||
protected string $password,
|
||||
@ -40,11 +40,11 @@ class mysql {
|
||||
return $sql;
|
||||
}
|
||||
|
||||
function insert(string $table, array $fields) {
|
||||
public function insert(string $table, array $fields) {
|
||||
return $this->performInsert('INSERT', $table, $fields);
|
||||
}
|
||||
|
||||
function replace(string $table, array $fields) {
|
||||
public function replace(string $table, array $fields) {
|
||||
return $this->performInsert('REPLACE', $table, $fields);
|
||||
}
|
||||
|
||||
@ -66,7 +66,7 @@ class mysql {
|
||||
return $this->query(...$values);
|
||||
}
|
||||
|
||||
function update(string $table, array $rows, ...$cond) {
|
||||
public function update(string $table, array $rows, ...$cond) {
|
||||
$fields = [];
|
||||
$args = [];
|
||||
foreach ($rows as $row_name => $row_value) {
|
||||
@ -82,13 +82,13 @@ class mysql {
|
||||
return $this->query($sql, ...$args);
|
||||
}
|
||||
|
||||
function multipleInsert(string $table, array $rows) {
|
||||
public function multipleInsert(string $table, array $rows) {
|
||||
list($names, $values) = $this->getMultipleInsertValues($rows);
|
||||
$sql = "INSERT INTO `{$table}` (`".implode('`, `', $names)."`) VALUES ".$values;
|
||||
return $this->query($sql);
|
||||
}
|
||||
|
||||
function multipleReplace(string $table, array $rows) {
|
||||
public function multipleReplace(string $table, array $rows) {
|
||||
list($names, $values) = $this->getMultipleInsertValues($rows);
|
||||
$sql = "REPLACE INTO `{$table}` (`".implode('`, `', $names)."`) VALUES ".$values;
|
||||
return $this->query($sql);
|
||||
@ -110,12 +110,12 @@ class mysql {
|
||||
return [$names, implode(', ', $sql_rows)];
|
||||
}
|
||||
|
||||
function __destruct() {
|
||||
public function __destruct() {
|
||||
if ($this->link)
|
||||
$this->link->close();
|
||||
}
|
||||
|
||||
function connect(): bool {
|
||||
public function connect(): bool {
|
||||
$this->link = new mysqli();
|
||||
$result = $this->link->real_connect($this->host, $this->user, $this->password, $this->database);
|
||||
if ($result)
|
||||
@ -123,24 +123,24 @@ class mysql {
|
||||
return !!$result;
|
||||
}
|
||||
|
||||
function query(string $sql, ...$args): mysqli_result|bool {
|
||||
public function query(string $sql, ...$args): mysqli_result|bool {
|
||||
$sql = $this->prepareQuery($sql, ...$args);
|
||||
$q = false;
|
||||
try {
|
||||
$q = $this->link->query($sql);
|
||||
if (!$q)
|
||||
logError(__METHOD__.': '.$this->link->error."\n$sql\n".backtrace_as_string(1));
|
||||
logError(__METHOD__.': '.$this->link->error."\n$sql\n".backtraceAsString(1));
|
||||
} catch (mysqli_sql_exception $e) {
|
||||
logError(__METHOD__.': '.$e->getMessage()."\n$sql\n".backtrace_as_string(1));
|
||||
logError(__METHOD__.': '.$e->getMessage()."\n$sql\n".backtraceAsString(1));
|
||||
}
|
||||
return $q;
|
||||
}
|
||||
|
||||
function error() {
|
||||
public function error() {
|
||||
return $this->link?->error;
|
||||
}
|
||||
|
||||
function fetch($q): ?array {
|
||||
public function fetch($q): ?array {
|
||||
$row = $q->fetch_assoc();
|
||||
if (!$row) {
|
||||
$q->free();
|
||||
@ -149,7 +149,7 @@ class mysql {
|
||||
return $row;
|
||||
}
|
||||
|
||||
function fetchAll($q): ?array {
|
||||
public function fetchAll($q): ?array {
|
||||
if (!$q)
|
||||
return null;
|
||||
$list = [];
|
||||
@ -160,31 +160,31 @@ class mysql {
|
||||
return $list;
|
||||
}
|
||||
|
||||
function fetchRow($q): ?array {
|
||||
public function fetchRow($q): ?array {
|
||||
return $q?->fetch_row();
|
||||
}
|
||||
|
||||
function result($q, $field = 0) {
|
||||
public function result($q, $field = 0) {
|
||||
return $q?->fetch_row()[$field];
|
||||
}
|
||||
|
||||
function insertId(): int {
|
||||
public function insertId(): int {
|
||||
return $this->link->insert_id;
|
||||
}
|
||||
|
||||
function numRows($q): ?int {
|
||||
public function numRows($q): ?int {
|
||||
return $q?->num_rows;
|
||||
}
|
||||
|
||||
function affectedRows(): ?int {
|
||||
public function affectedRows(): ?int {
|
||||
return $this->link?->affected_rows;
|
||||
}
|
||||
|
||||
function foundRows(): int {
|
||||
public function foundRows(): int {
|
||||
return (int)$this->fetch($this->query("SELECT FOUND_ROWS() AS `count`"))['count'];
|
||||
}
|
||||
|
||||
function escape(string $s): string {
|
||||
public function escape(string $s): string {
|
||||
return $this->link->real_escape_string($s);
|
||||
}
|
||||
|
||||
@ -251,7 +251,7 @@ function DB(): mysql|null {
|
||||
$config['mysql']['password'],
|
||||
$config['mysql']['database']);
|
||||
if (!$link->connect()) {
|
||||
if (!is_cli()) {
|
||||
if (!isCli()) {
|
||||
header('HTTP/1.1 503 Service Temporarily Unavailable');
|
||||
header('Status: 503 Service Temporarily Unavailable');
|
||||
header('Retry-After: 300');
|
||||
|
@ -1,43 +1,5 @@
|
||||
<?php
|
||||
|
||||
function dispatch_request(): void {
|
||||
global $RouterInput;
|
||||
|
||||
if (!in_array($_SERVER['REQUEST_METHOD'], ['POST', 'GET']))
|
||||
http_error(HTTPCode::NotImplemented, 'Method '.$_SERVER['REQUEST_METHOD'].' not implemented');
|
||||
|
||||
$route = router_find(request_path());
|
||||
if ($route === null)
|
||||
http_error(HTTPCode::NotFound, 'Route not found');
|
||||
|
||||
$route = preg_split('/ +/', $route);
|
||||
$handler_class = $route[0].'Handler';
|
||||
if (!class_exists($handler_class))
|
||||
http_error(HTTPCode::NotFound, is_dev() ? 'Handler class "'.$handler_class.'" not found' : '');
|
||||
|
||||
$action = $route[1];
|
||||
|
||||
if (count($route) > 2) {
|
||||
for ($i = 2; $i < count($route); $i++) {
|
||||
$var = $route[$i];
|
||||
list($k, $v) = explode('=', $var);
|
||||
$RouterInput[trim($k)] = trim($v);
|
||||
}
|
||||
}
|
||||
|
||||
/** @var request_handler $handler */
|
||||
$handler = new $handler_class();
|
||||
$handler->call_act($_SERVER['REQUEST_METHOD'], $action);
|
||||
}
|
||||
|
||||
function request_path(): string {
|
||||
$uri = $_SERVER['REQUEST_URI'];
|
||||
if (($pos = strpos($uri, '?')) !== false)
|
||||
$uri = substr($uri, 0, $pos);
|
||||
return $uri;
|
||||
}
|
||||
|
||||
|
||||
enum HTTPCode: int {
|
||||
case MovedPermanently = 301;
|
||||
case Found = 302;
|
||||
@ -51,92 +13,6 @@ enum HTTPCode: int {
|
||||
case NotImplemented = 501;
|
||||
}
|
||||
|
||||
function http_error(HTTPCode $http_code, string $message = ''): void {
|
||||
if (is_xhr_request()) {
|
||||
$data = [];
|
||||
if ($message != '')
|
||||
$data['message'] = $message;
|
||||
ajax_error((object)$data, $http_code->value);
|
||||
} else {
|
||||
$ctx = skin('error');
|
||||
$http_message = preg_replace('/(?<!^)([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 {
|
||||
case INTEGER = 'i';
|
||||
case FLOAT = 'f';
|
||||
@ -145,94 +21,218 @@ enum InputVarType: string {
|
||||
case ENUM = 'e';
|
||||
}
|
||||
|
||||
function input(string $input, array $options = []): array {
|
||||
global $RouterInput;
|
||||
//function ensureAdmin() {
|
||||
// if (!isAdmin())
|
||||
// forbidden();
|
||||
// $this->skin->setRenderOptions(['inside_admin_interface' => true]);
|
||||
//}
|
||||
|
||||
$options = array_merge(['trim' => false], $options);
|
||||
$strval = fn(mixed $val): string => $options['trim'] ? trim((string)$val) : (string)$val;
|
||||
abstract class request_handler {
|
||||
|
||||
$input = preg_split('/,\s+?/', $input, -1, PREG_SPLIT_NO_EMPTY);
|
||||
$ret = [];
|
||||
foreach ($input as $var) {
|
||||
$enum_values = null;
|
||||
$enum_default = null;
|
||||
protected array $routerInput = [];
|
||||
protected skin $skin;
|
||||
|
||||
$pos = strpos($var, ':');
|
||||
if ($pos === 1) { // only one-character type specifiers are supported
|
||||
$type = substr($var, 0, $pos);
|
||||
$rest = substr($var, $pos + 1);
|
||||
public static function resolveAndDispatch() {
|
||||
if (!in_array($_SERVER['REQUEST_METHOD'], ['POST', 'GET']))
|
||||
self::httpError(HTTPCode::NotImplemented, 'Method '.$_SERVER['REQUEST_METHOD'].' not implemented');
|
||||
|
||||
$vartype = InputVarType::tryFrom($type);
|
||||
if (is_null($vartype))
|
||||
internal_server_error('invalid input type '.$type);
|
||||
$uri = $_SERVER['REQUEST_URI'];
|
||||
if (($pos = strpos($uri, '?')) !== false)
|
||||
$uri = substr($uri, 0, $pos);
|
||||
|
||||
if ($vartype == InputVarType::ENUM) {
|
||||
$br_from = strpos($rest, '(');
|
||||
$br_to = strpos($rest, ')');
|
||||
$router = router::getInstance();
|
||||
$route = $router->find($uri);
|
||||
if ($route === null)
|
||||
self::httpError(HTTPCode::NotFound, 'Route not found');
|
||||
|
||||
if ($br_from === false || $br_to === false)
|
||||
internal_server_error('failed to parse enum values: '.$rest);
|
||||
$route = preg_split('/ +/', $route);
|
||||
$handler_class = $route[0].'Handler';
|
||||
if (!class_exists($handler_class))
|
||||
self::httpError(HTTPCode::NotFound, isDev() ? 'Handler class "'.$handler_class.'" not found' : '');
|
||||
|
||||
$enum_values = array_map('trim', explode('|', trim(substr($rest, $br_from + 1, $br_to - $br_from - 1))));
|
||||
$name = trim(substr($rest, 0, $br_from));
|
||||
$action = $route[1];
|
||||
$input = [];
|
||||
if (count($route) > 2) {
|
||||
for ($i = 2; $i < count($route); $i++) {
|
||||
$var = $route[$i];
|
||||
list($k, $v) = explode('=', $var);
|
||||
$input[trim($k)] = trim($v);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($enum_values)) {
|
||||
foreach ($enum_values as $key => $val) {
|
||||
if (str_starts_with($val, '=')) {
|
||||
$enum_values[$key] = substr($val, 1);
|
||||
$enum_default = $enum_values[$key];
|
||||
/** @var request_handler $handler */
|
||||
$handler = new $handler_class();
|
||||
$handler->callAct($_SERVER['REQUEST_METHOD'], $action, $input);
|
||||
}
|
||||
|
||||
public function __construct() {
|
||||
$this->skin = skin::getInstance();
|
||||
$this->skin->addStatic(
|
||||
'css/common.css',
|
||||
'js/common.js'
|
||||
);
|
||||
$this->skin->setGlobal([
|
||||
'is_admin' => isAdmin(),
|
||||
'is_dev' => isDev()
|
||||
]);
|
||||
}
|
||||
|
||||
public function beforeDispatch(string $http_method, string $action) {}
|
||||
|
||||
public function callAct(string $http_method, string $action, array $input = []) {
|
||||
$handler_method = $_SERVER['REQUEST_METHOD'].'_'.$action;
|
||||
if (!method_exists($this, $handler_method))
|
||||
$this->notFound(static::class.'::'.$handler_method.' is not defined');
|
||||
|
||||
if (!((new ReflectionMethod($this, $handler_method))->isPublic()))
|
||||
$this->notFound(static::class.'::'.$handler_method.' is not public');
|
||||
|
||||
if (!empty($input))
|
||||
$this->routerInput += $input;
|
||||
|
||||
$args = $this->beforeDispatch($http_method, $action);
|
||||
return call_user_func_array([$this, $handler_method], is_array($args) ? [$args] : []);
|
||||
}
|
||||
|
||||
public function input(string $input, array $options = []): array {
|
||||
$options = array_merge(['trim' => false], $options);
|
||||
$strval = fn(mixed $val): string => $options['trim'] ? trim((string)$val) : (string)$val;
|
||||
|
||||
$input = preg_split('/,\s+?/', $input, -1, PREG_SPLIT_NO_EMPTY);
|
||||
$ret = [];
|
||||
foreach ($input as $var) {
|
||||
$enum_values = null;
|
||||
$enum_default = null;
|
||||
|
||||
$pos = strpos($var, ':');
|
||||
if ($pos === 1) { // only one-character type specifiers are supported
|
||||
$type = substr($var, 0, $pos);
|
||||
$rest = substr($var, $pos + 1);
|
||||
|
||||
$vartype = InputVarType::tryFrom($type);
|
||||
if (is_null($vartype))
|
||||
self::internalServerError('invalid input type '.$type);
|
||||
|
||||
if ($vartype == InputVarType::ENUM) {
|
||||
$br_from = strpos($rest, '(');
|
||||
$br_to = strpos($rest, ')');
|
||||
|
||||
if ($br_from === false || $br_to === false)
|
||||
self::internalServerError('failed to parse enum values: '.$rest);
|
||||
|
||||
$enum_values = array_map('trim', explode('|', trim(substr($rest, $br_from + 1, $br_to - $br_from - 1))));
|
||||
$name = trim(substr($rest, 0, $br_from));
|
||||
|
||||
if (!empty($enum_values)) {
|
||||
foreach ($enum_values as $key => $val) {
|
||||
if (str_starts_with($val, '=')) {
|
||||
$enum_values[$key] = substr($val, 1);
|
||||
$enum_default = $enum_values[$key];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$name = trim($rest);
|
||||
}
|
||||
|
||||
} else {
|
||||
$name = trim($rest);
|
||||
$vartype = InputVarType::STRING;
|
||||
$name = trim($var);
|
||||
}
|
||||
|
||||
} else {
|
||||
$vartype = InputVarType::STRING;
|
||||
$name = trim($var);
|
||||
}
|
||||
|
||||
$val = null;
|
||||
if (isset($RouterInput[$name])) {
|
||||
$val = $RouterInput[$name];
|
||||
} else if (isset($_POST[$name])) {
|
||||
$val = $_POST[$name];
|
||||
} else if (isset($_GET[$name])) {
|
||||
$val = $_GET[$name];
|
||||
}
|
||||
if (is_array($val))
|
||||
$val = $strval(implode($val));
|
||||
$val = null;
|
||||
if (isset($this->routerInput[$name])) {
|
||||
$val = $this->routerInput[$name];
|
||||
} else if (isset($_POST[$name])) {
|
||||
$val = $_POST[$name];
|
||||
} else if (isset($_GET[$name])) {
|
||||
$val = $_GET[$name];
|
||||
}
|
||||
if (is_array($val))
|
||||
$val = $strval(implode($val));
|
||||
|
||||
$ret[] = match($vartype) {
|
||||
InputVarType::INTEGER => (int)$val,
|
||||
InputVarType::FLOAT => (float)$val,
|
||||
InputVarType::BOOLEAN => (bool)$val,
|
||||
InputVarType::ENUM => !in_array($val, $enum_values) ? $enum_default ?? '' : $strval($val),
|
||||
default => $strval($val)
|
||||
};
|
||||
$ret[] = match($vartype) {
|
||||
InputVarType::INTEGER => (int)$val,
|
||||
InputVarType::FLOAT => (float)$val,
|
||||
InputVarType::BOOLEAN => (bool)$val,
|
||||
InputVarType::ENUM => !in_array($val, $enum_values) ? $enum_default ?? '' : $strval($val),
|
||||
default => $strval($val)
|
||||
};
|
||||
}
|
||||
return $ret;
|
||||
}
|
||||
return $ret;
|
||||
}
|
||||
|
||||
function csrf_get(string $key): string {
|
||||
global $AdminSession, $config;
|
||||
$user_key = is_admin() ? $AdminSession->csrfSalt : $_SERVER['REMOTE_ADDR'];
|
||||
return substr(hash('sha256', $config['csrf_token'].$user_key.$key), 0, 20);
|
||||
}
|
||||
public function getPage(int $per_page, ?int $count = null): array {
|
||||
list($page) = $this->input('i:page');
|
||||
$pages = $count !== null ? ceil($count / $per_page) : null;
|
||||
if ($pages !== null && $page > $pages)
|
||||
$page = $pages;
|
||||
if ($page < 1)
|
||||
$page = 1;
|
||||
$offset = $per_page * ($page-1);
|
||||
return [$page, $pages, $offset];
|
||||
}
|
||||
|
||||
function csrf_check(string $key) {
|
||||
if (csrf_get($key) != ($_REQUEST['token'] ?? ''))
|
||||
forbidden('invalid token');
|
||||
}
|
||||
protected static function ensureXhr(): void {
|
||||
if (!self::isXhrRequest())
|
||||
self::invalidRequest();
|
||||
}
|
||||
|
||||
function get_page(int $per_page, ?int $count = null): array {
|
||||
list($page) = input('i:page');
|
||||
$pages = $count !== null ? ceil($count / $per_page) : null;
|
||||
if ($pages !== null && $page > $pages)
|
||||
$page = $pages;
|
||||
if ($page < 1)
|
||||
$page = 1;
|
||||
$offset = $per_page * ($page-1);
|
||||
return [$page, $pages, $offset];
|
||||
}
|
||||
public static function getCSRF(string $key): string {
|
||||
global $config;
|
||||
$user_key = isAdmin() ? admin::getCSRFSalt() : $_SERVER['REMOTE_ADDR'];
|
||||
return substr(hash('sha256', $config['csrf_token'].$user_key.$key), 0, 20);
|
||||
}
|
||||
|
||||
protected static function checkCSRF(string $key): void {
|
||||
if (self::getCSRF($key) != ($_REQUEST['token'] ?? ''))
|
||||
self::forbidden('invalid token');
|
||||
}
|
||||
|
||||
public static function httpError(HTTPCode $http_code, string $message = ''): void {
|
||||
if (self::isXhrRequest()) {
|
||||
$data = [];
|
||||
if ($message != '')
|
||||
$data['message'] = $message;
|
||||
self::ajaxError((object)$data, $http_code->value);
|
||||
} else {
|
||||
$http_message = preg_replace('/(?<!^)([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;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,185 +1,201 @@
|
||||
<?php
|
||||
|
||||
const ROUTER_VERSION = 10;
|
||||
const ROUTER_MC_KEY = '4in1/routes';
|
||||
class router {
|
||||
|
||||
$RouterInput = [];
|
||||
$Routes = [
|
||||
'children' => [],
|
||||
're_children' => []
|
||||
];
|
||||
|
||||
function router_init(): void {
|
||||
global $Routes;
|
||||
$mc = MC();
|
||||
|
||||
$from_cache = !is_dev();
|
||||
$write_cache = !is_dev();
|
||||
|
||||
if ($from_cache) {
|
||||
$cache = $mc->get(ROUTER_MC_KEY);
|
||||
|
||||
if ($cache === false || !isset($cache['version']) || $cache['version'] < ROUTER_VERSION) {
|
||||
$from_cache = false;
|
||||
} else {
|
||||
$Routes = $cache['routes'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$from_cache) {
|
||||
$routes_table = require_once APP_ROOT.'/routes.php';
|
||||
|
||||
foreach ($routes_table as $controller => $routes) {
|
||||
foreach ($routes as $route => $resolve)
|
||||
router_add($route, $controller.' '.$resolve);
|
||||
}
|
||||
|
||||
if ($write_cache)
|
||||
$mc->set(ROUTER_MC_KEY, ['version' => ROUTER_VERSION, 'routes' => $Routes]);
|
||||
}
|
||||
}
|
||||
|
||||
function router_add(string $template, string $value): void {
|
||||
global $Routes;
|
||||
if ($template == '')
|
||||
return;
|
||||
|
||||
// expand {enum,erat,ions}
|
||||
$templates = [[$template, $value]];
|
||||
if (preg_match_all('/\{([\w\d_\-,]+)\}/', $template, $matches)) {
|
||||
foreach ($matches[1] as $match_index => $variants) {
|
||||
$variants = explode(',', $variants);
|
||||
$variants = array_map('trim', $variants);
|
||||
$variants = array_filter($variants, function($s) { return $s != ''; });
|
||||
|
||||
for ($i = 0; $i < count($templates); ) {
|
||||
list($template, $value) = $templates[$i];
|
||||
$new_templates = [];
|
||||
foreach ($variants as $variant_index => $variant) {
|
||||
$new_templates[] = [
|
||||
str_replace_once($matches[0][$match_index], $variant, $template),
|
||||
str_replace('${'.($match_index+1).'}', $variant, $value)
|
||||
];
|
||||
}
|
||||
array_splice($templates, $i, 1, $new_templates);
|
||||
$i += count($new_templates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// process all generated routes
|
||||
foreach ($templates as $template) {
|
||||
list($template, $value) = $template;
|
||||
|
||||
$start_pos = 0;
|
||||
$parent = &$Routes;
|
||||
$template_len = strlen($template);
|
||||
|
||||
while ($start_pos < $template_len) {
|
||||
$slash_pos = strpos($template, '/', $start_pos);
|
||||
if ($slash_pos !== false) {
|
||||
$part = substr($template, $start_pos, $slash_pos-$start_pos+1);
|
||||
$start_pos = $slash_pos+1;
|
||||
} else {
|
||||
$part = substr($template, $start_pos);
|
||||
$start_pos = $template_len;
|
||||
}
|
||||
|
||||
$parent = &_router_add($parent, $part,
|
||||
$start_pos < $template_len ? null : $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function &_router_add(&$parent, $part, $value = null) {
|
||||
$par_pos = strpos($part, '(');
|
||||
$is_regex = $par_pos !== false && ($par_pos == 0 || $part[$par_pos-1] != '\\');
|
||||
|
||||
$children_key = !$is_regex ? 'children' : 're_children';
|
||||
|
||||
if (isset($parent[$children_key][$part])) {
|
||||
if (is_null($value)) {
|
||||
$parent = &$parent[$children_key][$part];
|
||||
} else {
|
||||
if (!isset($parent[$children_key][$part]['value'])) {
|
||||
$parent[$children_key][$part]['value'] = $value;
|
||||
} else {
|
||||
trigger_error(__METHOD__.': route is already defined');
|
||||
}
|
||||
}
|
||||
return $parent;
|
||||
}
|
||||
|
||||
$child = [
|
||||
protected array $routes = [
|
||||
'children' => [],
|
||||
're_children' => []
|
||||
];
|
||||
if (!is_null($value))
|
||||
$child['value'] = $value;
|
||||
protected static ?router $instance = null;
|
||||
|
||||
$parent[$children_key][$part] = $child;
|
||||
return $parent[$children_key][$part];
|
||||
}
|
||||
public static function getInstance(): router {
|
||||
if (self::$instance === null)
|
||||
self::$instance = new router();
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
function router_find($uri) {
|
||||
global $Routes;
|
||||
if ($uri != '/' && $uri[0] == '/')
|
||||
$uri = substr($uri, 1);
|
||||
private function __construct() {
|
||||
$mc = MC();
|
||||
|
||||
$start_pos = 0;
|
||||
$parent = &$Routes;
|
||||
$uri_len = strlen($uri);
|
||||
$matches = [];
|
||||
$from_cache = !isDev();
|
||||
$write_cache = !isDev();
|
||||
|
||||
while ($start_pos < $uri_len) {
|
||||
$slash_pos = strpos($uri, '/', $start_pos);
|
||||
if ($slash_pos !== false) {
|
||||
$part = substr($uri, $start_pos, $slash_pos-$start_pos+1);
|
||||
$start_pos = $slash_pos+1;
|
||||
} else {
|
||||
$part = substr($uri, $start_pos);
|
||||
$start_pos = $uri_len;
|
||||
if ($from_cache) {
|
||||
$cache = $mc->get(ROUTER_MC_KEY);
|
||||
|
||||
if ($cache === false || !isset($cache['version']) || $cache['version'] < ROUTER_VERSION) {
|
||||
$from_cache = false;
|
||||
} else {
|
||||
$this->routes = $cache['routes'];
|
||||
}
|
||||
}
|
||||
|
||||
$found = false;
|
||||
if (isset($parent['children'][$part])) {
|
||||
$parent = &$parent['children'][$part];
|
||||
$found = true;
|
||||
} else if (!empty($parent['re_children'])) {
|
||||
foreach ($parent['re_children'] as $re => &$child) {
|
||||
$exp = '#^'.$re.'$#';
|
||||
$re_result = preg_match($exp, $part, $match);
|
||||
if ($re_result === false) {
|
||||
logError(__METHOD__.": regex $exp failed");
|
||||
continue;
|
||||
}
|
||||
if (!$from_cache) {
|
||||
$routes_table = require_once APP_ROOT.'/routes.php';
|
||||
|
||||
if ($re_result) {
|
||||
if (count($match) > 1)
|
||||
$matches = array_merge($matches, array_slice($match, 1));
|
||||
$parent = &$child;
|
||||
$found = true;
|
||||
break;
|
||||
foreach ($routes_table as $controller => $routes) {
|
||||
foreach ($routes as $route => $resolve)
|
||||
$this->add($route, $controller.' '.$resolve);
|
||||
}
|
||||
|
||||
if ($write_cache)
|
||||
$mc->set(ROUTER_MC_KEY, ['version' => ROUTER_VERSION, 'routes' => $this->routes]);
|
||||
}
|
||||
}
|
||||
|
||||
public function add(string $template, string $value): void {
|
||||
if ($template == '')
|
||||
return;
|
||||
|
||||
// expand {enum,erat,ions}
|
||||
$templates = [[$template, $value]];
|
||||
if (preg_match_all('/\{([\w\d_\-,]+)}/', $template, $matches)) {
|
||||
foreach ($matches[1] as $match_index => $variants) {
|
||||
$variants = explode(',', $variants);
|
||||
$variants = array_map('trim', $variants);
|
||||
$variants = array_filter($variants, function($s) { return $s != ''; });
|
||||
|
||||
for ($i = 0; $i < count($templates); ) {
|
||||
list($template, $value) = $templates[$i];
|
||||
$new_templates = [];
|
||||
foreach ($variants as $variant_index => $variant) {
|
||||
$new_templates[] = [
|
||||
strReplaceOnce($matches[0][$match_index], $variant, $template),
|
||||
str_replace('${'.($match_index+1).'}', $variant, $value)
|
||||
];
|
||||
}
|
||||
array_splice($templates, $i, 1, $new_templates);
|
||||
$i += count($new_templates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$found)
|
||||
return null;
|
||||
}
|
||||
// process all generated routes
|
||||
foreach ($templates as $template) {
|
||||
list($template, $value) = $template;
|
||||
|
||||
if (!isset($parent['value']))
|
||||
return null;
|
||||
$start_pos = 0;
|
||||
$parent = &$this->routes;
|
||||
$template_len = strlen($template);
|
||||
|
||||
$value = $parent['value'];
|
||||
if (!empty($matches)) {
|
||||
foreach ($matches as $i => $match) {
|
||||
$needle = '$('.($i+1).')';
|
||||
$pos = strpos($value, $needle);
|
||||
if ($pos !== false)
|
||||
$value = substr_replace($value, $match, $pos, strlen($needle));
|
||||
while ($start_pos < $template_len) {
|
||||
$slash_pos = strpos($template, '/', $start_pos);
|
||||
if ($slash_pos !== false) {
|
||||
$part = substr($template, $start_pos, $slash_pos-$start_pos+1);
|
||||
$start_pos = $slash_pos+1;
|
||||
} else {
|
||||
$part = substr($template, $start_pos);
|
||||
$start_pos = $template_len;
|
||||
}
|
||||
|
||||
$parent = &$this->_addRoute($parent, $part,
|
||||
$start_pos < $template_len ? null : $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
protected function &_addRoute(&$parent, $part, $value = null) {
|
||||
$par_pos = strpos($part, '(');
|
||||
$is_regex = $par_pos !== false && ($par_pos == 0 || $part[$par_pos-1] != '\\');
|
||||
|
||||
$children_key = !$is_regex ? 'children' : 're_children';
|
||||
|
||||
if (isset($parent[$children_key][$part])) {
|
||||
if (is_null($value)) {
|
||||
$parent = &$parent[$children_key][$part];
|
||||
} else {
|
||||
if (!isset($parent[$children_key][$part]['value'])) {
|
||||
$parent[$children_key][$part]['value'] = $value;
|
||||
} else {
|
||||
trigger_error(__METHOD__.': route is already defined');
|
||||
}
|
||||
}
|
||||
return $parent;
|
||||
}
|
||||
|
||||
$child = [
|
||||
'children' => [],
|
||||
're_children' => []
|
||||
];
|
||||
if (!is_null($value)) {
|
||||
$child['value'] = $value;
|
||||
}
|
||||
|
||||
$parent[$children_key][$part] = $child;
|
||||
return $parent[$children_key][$part];
|
||||
}
|
||||
|
||||
public function find($uri) {
|
||||
if ($uri != '/' && $uri[0] == '/') {
|
||||
$uri = substr($uri, 1);
|
||||
}
|
||||
$start_pos = 0;
|
||||
$parent = &$this->routes;
|
||||
$uri_len = strlen($uri);
|
||||
$matches = [];
|
||||
|
||||
while ($start_pos < $uri_len) {
|
||||
$slash_pos = strpos($uri, '/', $start_pos);
|
||||
if ($slash_pos !== false) {
|
||||
$part = substr($uri, $start_pos, $slash_pos-$start_pos+1);
|
||||
$start_pos = $slash_pos+1;
|
||||
} else {
|
||||
$part = substr($uri, $start_pos);
|
||||
$start_pos = $uri_len;
|
||||
}
|
||||
|
||||
$found = false;
|
||||
if (isset($parent['children'][$part])) {
|
||||
$parent = &$parent['children'][$part];
|
||||
$found = true;
|
||||
} else if (!empty($parent['re_children'])) {
|
||||
foreach ($parent['re_children'] as $re => &$child) {
|
||||
$exp = '#^'.$re.'$#';
|
||||
$re_result = preg_match($exp, $part, $match);
|
||||
if ($re_result === false) {
|
||||
logError(__METHOD__.": regex $exp failed");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($re_result) {
|
||||
if (count($match) > 1) {
|
||||
$matches = array_merge($matches, array_slice($match, 1));
|
||||
}
|
||||
$parent = &$child;
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$found) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($parent['value'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$value = $parent['value'];
|
||||
if (!empty($matches)) {
|
||||
foreach ($matches as $i => $match) {
|
||||
$needle = '$('.($i+1).')';
|
||||
$pos = strpos($value, $needle);
|
||||
if ($pos !== false) {
|
||||
$value = substr_replace($value, $match, $pos, strlen($needle));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function load($routes): void {
|
||||
$this->routes = $routes;
|
||||
}
|
||||
|
||||
public function dump(): array {
|
||||
return $this->routes;
|
||||
}
|
||||
}
|
||||
|
757
engine/skin.php
757
engine/skin.php
@ -1,13 +1,20 @@
|
||||
<?php
|
||||
|
||||
require_once 'lib/themes.php';
|
||||
use Twig\Error\LoaderError;
|
||||
|
||||
const RESOURCE_INTEGRITY_HASHES = ['sha256', 'sha384', 'sha512'];
|
||||
|
||||
$SkinState = new class {
|
||||
class skin {
|
||||
|
||||
public array $lang = [];
|
||||
protected array $vars = [];
|
||||
protected array $globalVars = [];
|
||||
protected bool $globalsApplied = false;
|
||||
public string $title = 'title';
|
||||
/** @var (\Closure(string $title):string)[] */
|
||||
protected array $titleModifiers = [];
|
||||
public array $meta = [];
|
||||
protected array $js = [];
|
||||
public array $options = [
|
||||
'full_width' => false,
|
||||
'wide' => false,
|
||||
@ -19,241 +26,213 @@ $SkinState = new class {
|
||||
'inside_admin_interface' => false,
|
||||
];
|
||||
public array $static = [];
|
||||
public array $svg_defs = [];
|
||||
};
|
||||
protected array $styleNames = [];
|
||||
protected array $svgDefs = [];
|
||||
|
||||
function render($f, ...$vars): void {
|
||||
global $SkinState, $config;
|
||||
public \Twig\Environment $twig;
|
||||
|
||||
add_skin_strings(['4in1']);
|
||||
protected static ?skin $instance = null;
|
||||
|
||||
$ctx = skin(substr($f, 0, ($pos = strrpos(str_replace('/', '\\', $f), '\\'))));
|
||||
$body = call_user_func_array([$ctx, substr($f, $pos + 1)], $vars);
|
||||
if (is_array($body))
|
||||
list($body, $js) = $body;
|
||||
else
|
||||
$js = null;
|
||||
public static function getInstance(): skin {
|
||||
if (self::$instance === null)
|
||||
self::$instance = new skin();
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
$theme = getUserTheme();
|
||||
if ($theme != 'auto' && !themeExists($theme))
|
||||
$theme = 'auto';
|
||||
/**
|
||||
* @throws LoaderError
|
||||
*/
|
||||
protected function __construct() {
|
||||
global $config;
|
||||
$cache_dir = $config['skin_cache_'.(isDev() ? 'dev' : 'prod').'_dir'];
|
||||
if (!file_exists($cache_dir)) {
|
||||
if (mkdir($cache_dir, $config['dirs_mode'], true))
|
||||
setperm($cache_dir);
|
||||
}
|
||||
|
||||
$is_system_theme_dark = $theme == 'auto' && isUserSystemThemeDark();
|
||||
// must specify a second argument ($rootPath) here
|
||||
// otherwise it will be getcwd() and it's www-prod/htdocs/ for apache and www-prod/ for cli code
|
||||
// this is bad for templates rebuilding
|
||||
$twig_loader = new \Twig\Loader\FilesystemLoader(APP_ROOT.'/skin', APP_ROOT);
|
||||
// $twig_loader->addPath(APP_ROOT.'/htdocs/svg', 'svg');
|
||||
|
||||
$layout_ctx = skin('base');
|
||||
|
||||
$lang = [];
|
||||
foreach ($SkinState->lang as $key)
|
||||
$lang[$key] = lang($key);
|
||||
$lang = !empty($lang) ? jsonEncode($lang) : '';
|
||||
|
||||
$title = $SkinState->title;
|
||||
if (!$SkinState->options['is_index'])
|
||||
$title = lang('4in1').' - '.$title;
|
||||
|
||||
$html = $layout_ctx->layout(
|
||||
static: $SkinState->static,
|
||||
theme: $theme,
|
||||
is_system_theme_dark: $is_system_theme_dark,
|
||||
title: $title,
|
||||
opts: $SkinState->options,
|
||||
js: $js,
|
||||
meta: $SkinState->meta,
|
||||
unsafe_lang: $lang,
|
||||
unsafe_body: $body,
|
||||
exec_time: exectime(),
|
||||
admin_email: $config['admin_email'],
|
||||
svg_defs: $SkinState->svg_defs
|
||||
);
|
||||
echo $html;
|
||||
exit;
|
||||
}
|
||||
|
||||
function set_title(string $title): void {
|
||||
global $SkinState;
|
||||
if (str_starts_with($title, '$'))
|
||||
$title = lang(substr($title, 1));
|
||||
else if (str_starts_with($title, '\\$'))
|
||||
$title = substr($title, 1);
|
||||
$SkinState->title = $title;
|
||||
}
|
||||
|
||||
function set_skin_opts(array $options) {
|
||||
global $SkinState;
|
||||
$SkinState->options = array_merge($SkinState->options, $options);
|
||||
}
|
||||
|
||||
function add_skin_strings(array $keys): void {
|
||||
global $SkinState;
|
||||
$SkinState->lang = array_merge($SkinState->lang, $keys);
|
||||
}
|
||||
|
||||
function add_skin_strings_re(string $re): void {
|
||||
global $__lang;
|
||||
add_skin_strings($__lang->search($re));
|
||||
}
|
||||
|
||||
function add_static(string ...$files): void {
|
||||
global $SkinState;
|
||||
foreach ($files as $file)
|
||||
$SkinState->static[] = $file;
|
||||
}
|
||||
|
||||
function add_meta(array $data) {
|
||||
global $SkinState;
|
||||
static $twitter_limits = [
|
||||
'title' => 70,
|
||||
'description' => 200
|
||||
];
|
||||
$real_meta = [];
|
||||
$add_og_twitter = function($key, $value) use (&$real_meta, $twitter_limits) {
|
||||
foreach (['og', 'twitter'] as $social) {
|
||||
if ($social == 'twitter' && isset($twitter_limits[$key])) {
|
||||
if (mb_strlen($value) > $twitter_limits[$key])
|
||||
$value = mb_substr($value, 0, $twitter_limits[$key]-3).'...';
|
||||
}
|
||||
$real_meta[] = [
|
||||
$social == 'twitter' ? 'name' : 'property' => $social.':'.$key,
|
||||
'content' => $value
|
||||
$env_options = [];
|
||||
if (!is_null($cache_dir)) {
|
||||
$env_options += [
|
||||
'cache' => $cache_dir,
|
||||
'auto_reload' => isDev()
|
||||
];
|
||||
}
|
||||
};
|
||||
foreach ($data as $key => $value) {
|
||||
if (str_starts_with($value, '$'))
|
||||
$value = lang(substr($value, 1));
|
||||
switch ($key) {
|
||||
case '$url':
|
||||
case '$title':
|
||||
case '$image':
|
||||
$add_og_twitter(substr($key, 1), $value);
|
||||
break;
|
||||
$twig = new \Twig\Environment($twig_loader, $env_options);
|
||||
$twig->addExtension(new \TwigAddons\MyExtension());
|
||||
|
||||
case '$description':
|
||||
case '$keywords':
|
||||
$real_name = substr($key, 1);
|
||||
$add_og_twitter($real_name, $value);
|
||||
$real_meta[] = ['name' => $real_name, 'content' => $value];
|
||||
break;
|
||||
$this->twig = $twig;
|
||||
}
|
||||
|
||||
default:
|
||||
if (str_starts_with($key, 'og:')) {
|
||||
$real_meta[] = ['property' => $key, 'content' => $value];
|
||||
} else {
|
||||
logWarning("unsupported meta: $key => $value");
|
||||
public function addMeta(array $data) {
|
||||
static $twitter_limits = [
|
||||
'title' => 70,
|
||||
'description' => 200
|
||||
];
|
||||
$real_meta = [];
|
||||
$add_og_twitter = function($key, $value) use (&$real_meta, $twitter_limits) {
|
||||
foreach (['og', 'twitter'] as $social) {
|
||||
if ($social == 'twitter' && isset($twitter_limits[$key])) {
|
||||
if (mb_strlen($value) > $twitter_limits[$key])
|
||||
$value = mb_substr($value, 0, $twitter_limits[$key]-3).'...';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
$SkinState->meta = array_merge($SkinState->meta, $real_meta);
|
||||
}
|
||||
|
||||
|
||||
class SkinContext {
|
||||
|
||||
protected string $ns;
|
||||
protected array $data = [];
|
||||
|
||||
function __construct(string $namespace) {
|
||||
$this->ns = $namespace;
|
||||
require_once APP_ROOT.str_replace('\\', DIRECTORY_SEPARATOR, $namespace).'.phps';
|
||||
}
|
||||
|
||||
function __call($name, array $arguments) {
|
||||
$plain_args = array_is_list($arguments);
|
||||
|
||||
$fn = $this->ns.'\\'.$name;
|
||||
$refl = new ReflectionFunction($fn);
|
||||
$fparams = $refl->getParameters();
|
||||
$fparams_required_count = 0;
|
||||
foreach ($fparams as $param) {
|
||||
if (!$param->isDefaultValueAvailable())
|
||||
$fparams_required_count++;
|
||||
}
|
||||
$given_count = count($arguments)+1;
|
||||
assert($given_count >= $fparams_required_count && $given_count <= count($fparams),
|
||||
"$fn: invalid number of arguments (function has ".$fparams_required_count." required arguments".(count($fparams) != $fparams_required_count ? ' and '.count($fparams).' total arguments' : '').", received ".(count($arguments) + 1).")");
|
||||
|
||||
foreach ($fparams as $n => $param) {
|
||||
if ($n == 0)
|
||||
continue; // skip $ctx
|
||||
|
||||
$key = $plain_args ? $n - 1 : $param->name;
|
||||
if (!$plain_args && !array_key_exists($param->name, $arguments)) {
|
||||
if (!$param->isDefaultValueAvailable())
|
||||
throw new InvalidArgumentException('argument '.$param->name.' not found');
|
||||
else
|
||||
continue;
|
||||
$real_meta[] = [
|
||||
$social == 'twitter' ? 'name' : 'property' => $social.':'.$key,
|
||||
'content' => $value
|
||||
];
|
||||
}
|
||||
};
|
||||
foreach ($data as $key => $value) {
|
||||
if (str_starts_with($value, '@'))
|
||||
$value = lang(substr($value, 1));
|
||||
switch ($key) {
|
||||
case '@url':
|
||||
case '@title':
|
||||
case '@image':
|
||||
$add_og_twitter(substr($key, 1), $value);
|
||||
break;
|
||||
|
||||
if ($plain_args && !isset($arguments[$key]))
|
||||
break;
|
||||
case '@description':
|
||||
case '@keywords':
|
||||
$real_name = substr($key, 1);
|
||||
$add_og_twitter($real_name, $value);
|
||||
$real_meta[] = ['name' => $real_name, 'content' => $value];
|
||||
break;
|
||||
|
||||
if (is_string($arguments[$key]) || $arguments[$key] instanceof SkinString) {
|
||||
if (is_string($arguments[$key]))
|
||||
$arguments[$key] = new SkinString($arguments[$key]);
|
||||
|
||||
if (($pos = strpos($param->name, '_')) !== false) {
|
||||
$mod_type = match (substr($param->name, 0, $pos)) {
|
||||
'unsafe' => SkinStringModificationType::RAW,
|
||||
'urlencoded' => SkinStringModificationType::URL,
|
||||
'jsonencoded' => SkinStringModificationType::JSON,
|
||||
'addslashes' => SkinStringModificationType::ADDSLASHES,
|
||||
'nl2br' => SkinStringModificationType::NL2BR,
|
||||
default => SkinStringModificationType::HTML
|
||||
};
|
||||
} else {
|
||||
$mod_type = SkinStringModificationType::HTML;
|
||||
}
|
||||
$arguments[$key]->setModType($mod_type);
|
||||
default:
|
||||
if (str_starts_with($key, 'og:')) {
|
||||
$real_meta[] = ['property' => $key, 'content' => $value];
|
||||
} else {
|
||||
logWarning("unsupported meta: $key => $value");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
array_unshift($arguments, $this);
|
||||
return call_user_func_array($fn, $arguments);
|
||||
$this->meta = array_merge($this->meta, $real_meta);
|
||||
}
|
||||
|
||||
function &__get(string $name) {
|
||||
$fn = $this->ns.'\\'.$name;
|
||||
if (function_exists($fn)) {
|
||||
$f = [$this, $name];
|
||||
return $f;
|
||||
public function exportStrings(array|string $keys): void {
|
||||
global $__lang;
|
||||
$this->lang = array_merge($this->lang, is_string($keys) ? $__lang->search($keys) : $keys);
|
||||
}
|
||||
|
||||
public function setTitle(string $title): void {
|
||||
if (str_starts_with($title, '$'))
|
||||
$title = lang(substr($title, 1));
|
||||
else if (str_starts_with($title, '\\$'))
|
||||
$title = substr($title, 1);
|
||||
$this->title = $title;
|
||||
}
|
||||
|
||||
public function addPageTitleModifier(callable $callable): void {
|
||||
if (!is_callable($callable)) {
|
||||
trigger_error(__METHOD__.': argument is not callable');
|
||||
} else {
|
||||
$this->titleModifiers[] = $callable;
|
||||
}
|
||||
|
||||
if (array_key_exists($name, $this->data))
|
||||
return $this->data[$name];
|
||||
}
|
||||
|
||||
function __set(string $name, $value) {
|
||||
$this->data[$name] = $value;
|
||||
protected function getTitle(): string {
|
||||
$title = $this->title != '' ? $this->title : lang('site_title');
|
||||
if (!empty($this->titleModifiers)) {
|
||||
foreach ($this->titleModifiers as $modifier)
|
||||
$title = $modifier($title);
|
||||
}
|
||||
return $title;
|
||||
}
|
||||
|
||||
function if_not($cond, $callback, ...$args) {
|
||||
return $this->_if_condition(!$cond, $callback, ...$args);
|
||||
public function set($arg1, $arg2 = null) {
|
||||
if (is_array($arg1)) {
|
||||
foreach ($arg1 as $key => $value)
|
||||
$this->vars[$key] = $value;
|
||||
} elseif ($arg2 !== null) {
|
||||
$this->vars[$arg1] = $arg2;
|
||||
}
|
||||
}
|
||||
|
||||
function if_true($cond, $callback, ...$args) {
|
||||
return $this->_if_condition($cond, $callback, ...$args);
|
||||
public function isSet($key): bool {
|
||||
return isset($this->vars[$key]);
|
||||
}
|
||||
|
||||
function if_admin($callback, ...$args) {
|
||||
return $this->_if_condition(is_admin(), $callback, ...$args);
|
||||
public function setGlobal($arg1, $arg2 = null): void {
|
||||
if ($this->globalsApplied)
|
||||
logError(__METHOD__.': WARNING: globals were already applied, your change will not be visible');
|
||||
|
||||
if (is_array($arg1)) {
|
||||
foreach ($arg1 as $key => $value)
|
||||
$this->globalVars[$key] = $value;
|
||||
} elseif ($arg2 !== null) {
|
||||
$this->globalVars[$arg1] = $arg2;
|
||||
}
|
||||
}
|
||||
|
||||
function if_dev($callback, ...$args) {
|
||||
return $this->_if_condition(is_dev(), $callback, ...$args);
|
||||
public function isGlobalSet($key): bool {
|
||||
return isset($this->globalVars[$key]);
|
||||
}
|
||||
|
||||
function if_then_else($cond, $cb1, $cb2) {
|
||||
return $cond ? $this->_return_callback($cb1) : $this->_return_callback($cb2);
|
||||
public function getGlobal($key) {
|
||||
return $this->isGlobalSet($key) ? $this->globalVars[$key] : null;
|
||||
}
|
||||
|
||||
function csrf($key): string {
|
||||
return csrf_get($key);
|
||||
public function applyGlobals(): void {
|
||||
if (!empty($this->globalVars) && !$this->globalsApplied) {
|
||||
foreach ($this->globalVars as $key => $value)
|
||||
$this->twig->addGlobal($key, $value);
|
||||
$this->globalsApplied = true;
|
||||
}
|
||||
}
|
||||
|
||||
function bc(array $items, ?string $style = null, bool $mt = false): string {
|
||||
public function addStatic(string ...$files): void {
|
||||
foreach ($files as $file)
|
||||
$this->static[] = $file;
|
||||
}
|
||||
|
||||
public function addJS(string $js): void {
|
||||
if ($js != '')
|
||||
$this->js[] = $js;
|
||||
}
|
||||
|
||||
protected function getJS(): string {
|
||||
if (empty($this->js))
|
||||
return '';
|
||||
return implode("\n", $this->js);
|
||||
}
|
||||
|
||||
public function preloadSVG(string $name): void {
|
||||
if (isset($this->svgDefs[$name]))
|
||||
return;
|
||||
|
||||
if (!preg_match_all('/\d+/', $name, $matches))
|
||||
throw new InvalidArgumentException('icon name '.$name.' is invalid, it should follow following pattern: $name_$size[_$size]');
|
||||
|
||||
$size = array_slice($matches[0], -2);
|
||||
$this->svgDefs[$name] = [
|
||||
'width' => $size[0],
|
||||
'height' => $size[1] ?? $size[0]
|
||||
];
|
||||
}
|
||||
|
||||
public function getSVG(string $name, bool $in_place = false): ?string {
|
||||
$this->preloadSVG($name);
|
||||
$w = $this->svgDefs[$name]['width'];
|
||||
$h = $this->svgDefs[$name]['height'];
|
||||
if ($in_place) {
|
||||
$svg = '<svg 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>';
|
||||
|
||||
$buf = implode(array_map(function(array $i) use ($chevron): string {
|
||||
$buf = '';
|
||||
$has_url = array_key_exists('url', $i);
|
||||
@ -277,13 +256,11 @@ class SkinContext {
|
||||
return '<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) {
|
||||
$count = 0;
|
||||
} else {
|
||||
$opts = array_merge([
|
||||
'count' => 0,
|
||||
], $opts);
|
||||
$opts = array_merge(['count' => 0], $opts);
|
||||
$count = $opts['count'];
|
||||
}
|
||||
|
||||
@ -294,25 +271,23 @@ class SkinContext {
|
||||
$base_class = 'pn-button no-hover no-select no-drag is-page';
|
||||
for ($p = $min_page; $p <= $max_page; $p++) {
|
||||
$class = $base_class;
|
||||
if ($p == $page) {
|
||||
if ($p == $page)
|
||||
$class .= ' is-page-cur';
|
||||
}
|
||||
|
||||
$pages_html .= '<a class="'.$class.'" href="'.htmlescape(self::_page_nav_get_link($p, $link_template)).'" data-page="'.$p.'" draggable="false">'.$p.'</a>';
|
||||
$pages_html .= '<a class="'.$class.'" href="'.htmlescape(self::pageNavGetLink($p, $link_template)).'" data-page="'.$p.'" draggable="false">'.$p.'</a>';
|
||||
}
|
||||
|
||||
if ($min_page > 2) {
|
||||
$pages_html = '<div class="pn-button-sep no-select no-drag"> </div>'.$pages_html;
|
||||
}
|
||||
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) {
|
||||
$pages_html .= '<div class="pn-button-sep no-select no-drag"> </div>';
|
||||
}
|
||||
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';
|
||||
@ -334,146 +309,258 @@ HTML;
|
||||
return $html;
|
||||
}
|
||||
|
||||
protected static function _page_nav_get_link($page, $link_template) {
|
||||
return is_callable($link_template)
|
||||
? $link_template($page)
|
||||
: str_replace('{page}', $page, $link_template);
|
||||
protected static function pageNavGetLink($page, $link_template) {
|
||||
return is_callable($link_template) ? $link_template($page) : str_replace('{page}', $page, $link_template);
|
||||
}
|
||||
|
||||
protected function _if_condition($condition, $callback, ...$args) {
|
||||
if (is_string($condition) || $condition instanceof Stringable)
|
||||
$condition = (string)$condition !== '';
|
||||
if ($condition)
|
||||
return $this->_return_callback($callback, $args);
|
||||
return '';
|
||||
protected function getSVGTags(): string {
|
||||
$buf = '<svg style="display: none">';
|
||||
foreach ($this->svgDefs as $name => $icon) {
|
||||
$content = file_get_contents(APP_ROOT.'/skin/svg/'.$name.'.svg');
|
||||
$buf .= "<symbol id=\"svgicon_{$name}\" viewBox=\"0 0 {$icon['width']} {$icon['height']}\" fill=\"currentColor\">$content</symbol>";
|
||||
}
|
||||
$buf .= '</svg>';
|
||||
return $buf;
|
||||
}
|
||||
|
||||
protected function _return_callback($callback, $args = []) {
|
||||
if (is_callable($callback))
|
||||
return call_user_func_array($callback, $args);
|
||||
else if (is_string($callback))
|
||||
return $callback;
|
||||
public function setRenderOptions(array $options): void {
|
||||
$this->options = array_merge($this->options, $options);
|
||||
}
|
||||
|
||||
function for_each(array $iterable, callable $callback) {
|
||||
$html = '';
|
||||
foreach ($iterable as $k => $v)
|
||||
$html .= call_user_func($callback, $v, $k);
|
||||
public function render($template, array $vars = []): string {
|
||||
$this->applyGlobals();
|
||||
return $this->doRender($template, $this->vars + $vars);
|
||||
}
|
||||
|
||||
public function renderPage(string $template, array $vars = []): never {
|
||||
$this->exportStrings(['4in1']);
|
||||
$this->applyGlobals();
|
||||
|
||||
// render body first
|
||||
$b = $this->renderBody($template, $vars);
|
||||
|
||||
// then everything else
|
||||
$h = $this->renderHeader();
|
||||
$f = $this->renderFooter();
|
||||
|
||||
echo $h;
|
||||
echo $b;
|
||||
echo $f;
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
protected function renderHeader(): string {
|
||||
global $config;
|
||||
|
||||
$body_class = [];
|
||||
if ($this->options['full_width'])
|
||||
$body_class[] = 'full-width';
|
||||
else if ($this->options['wide'])
|
||||
$body_class[] = 'wide';
|
||||
|
||||
$title = $this->getTitle();
|
||||
if (!$this->options['is_index'])
|
||||
$title = lang('4in1').' - '.$title;
|
||||
|
||||
$vars = [
|
||||
'title' => $title,
|
||||
'meta_html' => $this->getMetaTags(),
|
||||
'static_html' => $this->getHeaderStaticTags(),
|
||||
'svg_html' => $this->getSVGTags(),
|
||||
'render_options' => $this->options,
|
||||
'app_config' => [
|
||||
'domain' => $config['domain'],
|
||||
'devMode' => $config['is_dev'],
|
||||
'cookieHost' => $config['cookie_host'],
|
||||
],
|
||||
'body_class' => $body_class,
|
||||
'theme' => themes::getUserTheme(),
|
||||
];
|
||||
|
||||
return $this->doRender('header.twig', $vars);
|
||||
}
|
||||
|
||||
protected function renderBody(string $template, array $vars): string {
|
||||
return $this->doRender($template, $this->vars + $vars);
|
||||
}
|
||||
|
||||
protected function renderFooter(): string {
|
||||
global $config;
|
||||
|
||||
$exec_time = microtime(true) - START_TIME;
|
||||
$exec_time = round($exec_time, 4);
|
||||
|
||||
$footer_vars = [
|
||||
'exec_time' => $exec_time,
|
||||
'render_options' => $this->options,
|
||||
'admin_email' => $config['admin_email'],
|
||||
// 'lang_json' => json_encode($this->getLangKeys(), JSON_UNESCAPED_UNICODE),
|
||||
// 'static_config' => $this->getStaticConfig(),
|
||||
'script_html' => $this->getFooterScriptTags(),
|
||||
'this_page_url' => $_SERVER['REQUEST_URI'],
|
||||
'theme' => themes::getUserTheme(),
|
||||
];
|
||||
return $this->doRender('footer.twig', $footer_vars);
|
||||
}
|
||||
|
||||
protected function doRender(string $template, array $vars = []): string {
|
||||
$s = '';
|
||||
try {
|
||||
$s = $this->twig->render($template, $vars);
|
||||
} catch (\Twig\Error\Error $e) {
|
||||
$error = get_class($e).": failed to render";
|
||||
$source_ctx = $e->getSourceContext();
|
||||
if ($source_ctx) {
|
||||
$path = $source_ctx->getPath();
|
||||
if (str_starts_with($path, APP_ROOT))
|
||||
$path = substr($path, strlen(APP_ROOT)+1);
|
||||
$error .= " ".$source_ctx->getName()." (".$path.") at line ".$e->getTemplateLine();
|
||||
}
|
||||
$error .= ": ";
|
||||
$error .= $e->getMessage();
|
||||
logError($error);
|
||||
if (isDev())
|
||||
$s = $error."\n";
|
||||
}
|
||||
return $s;
|
||||
}
|
||||
|
||||
protected function getMetaTags(): string {
|
||||
if (empty($this->meta))
|
||||
return '';
|
||||
return implode('', array_map(function(array $item): string {
|
||||
$s = '<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;
|
||||
}
|
||||
|
||||
function lang(...$args): string {
|
||||
return htmlescape($this->langRaw(...$args));
|
||||
}
|
||||
|
||||
function lang_num(...$args): string {
|
||||
return htmlescape(lang_num(...$args));
|
||||
}
|
||||
|
||||
function langRaw(string $key, ...$args) {
|
||||
$val = lang($key);
|
||||
return empty($args) ? $val : sprintf($val, ...$args);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class SVGSkinContext extends SkinContext {
|
||||
|
||||
function __construct() {
|
||||
parent::__construct('\\skin\\icons');
|
||||
}
|
||||
|
||||
function __call($name, array $arguments) {
|
||||
global $SkinState;
|
||||
|
||||
$already_defined = isset($SkinState->svg_defs[$name]);
|
||||
if (!array_is_list($arguments)) {
|
||||
$in_place = isset($arguments['in_place']) && $arguments['in_place'] === true;
|
||||
$preload_symbol = isset($arguments['preload_symbol']) && $arguments['preload_symbol'] === true;
|
||||
protected function jsLink(string $name): string {
|
||||
list (, $bname) = $this->getStaticNameParts($name);
|
||||
if (isDev()) {
|
||||
$href = '/js.php?name='.urlencode($bname).'&v='.time();
|
||||
} else {
|
||||
$in_place = false;
|
||||
$preload_symbol = false;
|
||||
$href = '/dist-js/'.$bname.'.js?v='.$this->getStaticVersion($name);
|
||||
}
|
||||
return '<script src="'.$href.'" type="text/javascript"'.$this->getStaticIntegrityAttribute($name).'></script>';
|
||||
}
|
||||
|
||||
if ($already_defined && $preload_symbol)
|
||||
return null;
|
||||
protected function cssLink(string $name, string $theme, &$bname = null): string {
|
||||
list(, $bname) = $this->getStaticNameParts($name);
|
||||
|
||||
if ($in_place || !$already_defined) {
|
||||
if (!preg_match_all('/\d+/', $name, $matches))
|
||||
throw new InvalidArgumentException('icon name '.$name.' is invalid, it should follow following pattern: $name_$size[_$size]');
|
||||
$size = array_slice($matches[0], -2);
|
||||
$width = $size[0];
|
||||
$height = $size[1] ?? $size[0];
|
||||
}
|
||||
$config_name = 'css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css';
|
||||
|
||||
if (!$in_place && (!$already_defined || $preload_symbol)) {
|
||||
$SkinState->svg_defs[$name] = [
|
||||
'svg' => parent::__call($name, !$preload_symbol ? $arguments : []),
|
||||
'width' => $width,
|
||||
'height' => $height
|
||||
];
|
||||
}
|
||||
|
||||
if ($preload_symbol)
|
||||
return null;
|
||||
|
||||
if ($already_defined && !isset($width)) {
|
||||
$width = $SkinState->svg_defs[$name]['width'];
|
||||
$height = $SkinState->svg_defs[$name]['height'];
|
||||
}
|
||||
|
||||
if ($in_place) {
|
||||
$content = parent::__call($name, []);
|
||||
return <<<SVG
|
||||
<svg id="svgicon_{$name}" width="{$width}" height="{$height}" fill="currentColor" viewBox="0 0 {$width} {$height}">{$content}</svg>
|
||||
SVG;
|
||||
if (isDev()) {
|
||||
$href = '/sass.php?name='.urlencode($bname).'&theme='.$theme.'&v='.time();
|
||||
} else {
|
||||
return <<<SVG
|
||||
<svg width="{$width}" height="{$height}"><use xlink:href="#svgicon_{$name}"></use></svg>
|
||||
SVG;
|
||||
$version = $this->getStaticVersion($config_name);
|
||||
$href = '/dist-css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css?v='.$version;
|
||||
}
|
||||
|
||||
$id = 'style_'.$bname;
|
||||
if ($theme == 'dark')
|
||||
$id .= '_dark';
|
||||
|
||||
return '<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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -21,19 +21,19 @@ enum NameType: int {
|
||||
class StringsBase implements ArrayAccess {
|
||||
protected array $data = [];
|
||||
|
||||
function offsetSet(mixed $offset, mixed $value): void {
|
||||
public function offsetSet(mixed $offset, mixed $value): void {
|
||||
throw new RuntimeException('Not implemented');
|
||||
}
|
||||
|
||||
function offsetExists(mixed $offset): bool {
|
||||
public function offsetExists(mixed $offset): bool {
|
||||
return isset($this->data[$offset]);
|
||||
}
|
||||
|
||||
function offsetUnset(mixed $offset): void {
|
||||
public function offsetUnset(mixed $offset): void {
|
||||
throw new RuntimeException('Not implemented');
|
||||
}
|
||||
|
||||
function offsetGet(mixed $offset): mixed {
|
||||
public function offsetGet(mixed $offset): mixed {
|
||||
if (!isset($this->data[$offset])) {
|
||||
logError(__METHOD__.': '.$offset.' not found');
|
||||
return '{'.$offset.'}';
|
||||
@ -41,7 +41,7 @@ class StringsBase implements ArrayAccess {
|
||||
return $this->data[$offset];
|
||||
}
|
||||
|
||||
function get(string $key, mixed ...$sprintf_args): string|array {
|
||||
public function get(string $key, mixed ...$sprintf_args): string|array {
|
||||
$val = $this[$key];
|
||||
if (!empty($sprintf_args)) {
|
||||
array_unshift($sprintf_args, $val);
|
||||
@ -51,7 +51,7 @@ class StringsBase implements ArrayAccess {
|
||||
}
|
||||
}
|
||||
|
||||
function num(string $key, int $num, array$opts = []) {
|
||||
public function num(string $key, int $num, array$opts = []) {
|
||||
$s = $this[$key];
|
||||
|
||||
$default_opts = [
|
||||
@ -115,7 +115,7 @@ class Strings extends StringsBase {
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
function load(string ...$pkgs): array {
|
||||
public function load(string ...$pkgs): array {
|
||||
$keys = [];
|
||||
foreach ($pkgs as $name) {
|
||||
$raw = yaml_parse_file(APP_ROOT.'/strings/'.$name.'.yaml');
|
||||
@ -126,13 +126,13 @@ class Strings extends StringsBase {
|
||||
return $keys;
|
||||
}
|
||||
|
||||
function flex(string $s, DeclensionCase $case, NameSex $sex, NameType $type): string {
|
||||
public function flex(string $s, DeclensionCase $case, NameSex $sex, NameType $type): string {
|
||||
$s = iconv('utf-8', 'cp1251', $s);
|
||||
$s = vkflex($s, $case->value, $sex->value, 0, $type->value);
|
||||
return iconv('cp1251', 'utf-8', $s);
|
||||
}
|
||||
|
||||
function search(string $regexp): array {
|
||||
public function search(string $regexp): array {
|
||||
return preg_grep($regexp, array_keys($this->data));
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
function verify_hostname(?string $host = null): void {
|
||||
function verifyHostname(?string $host = null): void {
|
||||
global $config;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -70,7 +70,7 @@ function imageopen(string $filename) {
|
||||
return call_user_func('imagecreatefrom'.$types[$size[2]], $filename);
|
||||
}
|
||||
|
||||
function detect_image_type(string $filename) {
|
||||
function detectImageType(string $filename) {
|
||||
$size = getimagesize($filename);
|
||||
$types = [
|
||||
1 => 'gif',
|
||||
@ -167,7 +167,7 @@ function ulong2ip(int $ip): string {
|
||||
return long2ip(-$long);
|
||||
}
|
||||
|
||||
function from_camel_case(string $s): string {
|
||||
function fromCamelCase(string $s): string {
|
||||
$buf = '';
|
||||
$len = strlen($s);
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
@ -180,11 +180,11 @@ function from_camel_case(string $s): string {
|
||||
return $buf;
|
||||
}
|
||||
|
||||
function to_camel_case(string $input, string $separator = '_'): string {
|
||||
function toCamelCase(string $input, string $separator = '_'): string {
|
||||
return lcfirst(str_replace($separator, '', ucwords($input, $separator)));
|
||||
}
|
||||
|
||||
function str_replace_once(string $needle, string $replace, string $haystack) {
|
||||
function strReplaceOnce(string $needle, string $replace, string $haystack) {
|
||||
$pos = strpos($haystack, $needle);
|
||||
if ($pos !== false)
|
||||
$haystack = substr_replace($haystack, $replace, $pos, strlen($needle));
|
||||
@ -205,7 +205,7 @@ function strgen(int $len): string {
|
||||
return $buf;
|
||||
}
|
||||
|
||||
function sanitize_filename(string $name): string {
|
||||
function sanitizeFilename(string $name): string {
|
||||
$name = mb_strtolower($name);
|
||||
$name = transliterate($name);
|
||||
$name = preg_replace('/[^\w\d\-_\s.]/', '', $name);
|
||||
@ -213,31 +213,6 @@ function sanitize_filename(string $name): string {
|
||||
return $name;
|
||||
}
|
||||
|
||||
function glob_escape(string $pattern): string {
|
||||
if (strpos($pattern, '[') !== false || strpos($pattern, ']') !== false) {
|
||||
$placeholder = uniqid();
|
||||
$replaces = array( $placeholder.'[', $placeholder.']', );
|
||||
$pattern = str_replace( array('[', ']', ), $replaces, $pattern);
|
||||
$pattern = str_replace( $replaces, array('[[]', '[]]', ), $pattern);
|
||||
}
|
||||
return $pattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does not support flag GLOB_BRACE
|
||||
*
|
||||
* @param string $pattern
|
||||
* @param int $flags
|
||||
* @return array
|
||||
*/
|
||||
function glob_recursive(string $pattern, int $flags = 0): array {
|
||||
$files = glob(glob_escape($pattern), $flags);
|
||||
foreach (glob(glob_escape(dirname($pattern)).'/*', GLOB_ONLYDIR|GLOB_NOSORT) as $dir) {
|
||||
$files = array_merge($files, glob_recursive($dir.'/'.basename($pattern), $flags));
|
||||
}
|
||||
return $files;
|
||||
}
|
||||
|
||||
function setperm(string $file): void {
|
||||
global $config;
|
||||
|
||||
@ -259,7 +234,7 @@ function setperm(string $file): void {
|
||||
}
|
||||
}
|
||||
|
||||
function salt_password(string $pwd): string {
|
||||
function saltPassword(string $pwd): string {
|
||||
global $config;
|
||||
return hash('sha256', "{$pwd}|{$config['password_salt']}");
|
||||
}
|
||||
@ -285,19 +260,25 @@ function lang() {
|
||||
global $__lang;
|
||||
return call_user_func_array([$__lang, 'get'], func_get_args());
|
||||
}
|
||||
function lang_num() {
|
||||
function langNum() {
|
||||
global $__lang;
|
||||
return call_user_func_array([$__lang, 'num'], func_get_args());
|
||||
}
|
||||
|
||||
function is_dev(): bool { global $config; return $config['is_dev']; }
|
||||
function is_cli(): bool { return PHP_SAPI == 'cli'; };
|
||||
function is_retina(): bool { return isset($_COOKIE['is_retina']) && $_COOKIE['is_retina']; }
|
||||
function isDev(): bool { global $config; return $config['is_dev']; }
|
||||
function isCli(): bool { return PHP_SAPI == 'cli'; };
|
||||
function isRetina(): bool { return isset($_COOKIE['is_retina']) && $_COOKIE['is_retina']; }
|
||||
|
||||
function isAdmin(): bool {
|
||||
if (admin::getId() === null)
|
||||
admin::check();
|
||||
return admin::getId() != 0;
|
||||
}
|
||||
|
||||
function jsonEncode($obj): ?string { return json_encode($obj, JSON_UNESCAPED_UNICODE) ?: null; }
|
||||
function jsonDecode($json) { return json_decode($json, true); }
|
||||
|
||||
function pcre_no_error(mixed &$result, bool $no_error = false): bool {
|
||||
function pcreNoError(mixed &$result, bool $no_error = false): bool {
|
||||
if ($result === null) {
|
||||
if (preg_last_error() !== PREG_NO_ERROR) {
|
||||
if (!$no_error)
|
||||
@ -308,7 +289,7 @@ function pcre_no_error(mixed &$result, bool $no_error = false): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
function hl_matched(string $s, string|Stringable|SkinString|array|null $keywords = []): string {
|
||||
function highlightSubstring(string $s, string|array|null $keywords = []): string {
|
||||
if (is_null($keywords))
|
||||
return htmlescape($s);
|
||||
|
||||
@ -369,7 +350,7 @@ function hl_matched(string $s, string|Stringable|SkinString|array|null $keywords
|
||||
return $buf;
|
||||
}
|
||||
|
||||
function format_time($ts, array $opts = array()) {
|
||||
function formatTime($ts, array $opts = array()) {
|
||||
$default_opts = [
|
||||
'date_only' => false,
|
||||
'day_of_week' => false,
|
||||
@ -411,30 +392,3 @@ function format_time($ts, array $opts = array()) {
|
||||
return $date;
|
||||
}
|
||||
|
||||
function arabic_to_roman($number) {
|
||||
$map = [
|
||||
1000 => 'M',
|
||||
900 => 'CM',
|
||||
500 => 'D',
|
||||
400 => 'CD',
|
||||
100 => 'C',
|
||||
90 => 'XC',
|
||||
50 => 'L',
|
||||
40 => 'XL',
|
||||
10 => 'X',
|
||||
9 => 'IX',
|
||||
5 => 'V',
|
||||
4 => 'IV',
|
||||
1 => 'I',
|
||||
];
|
||||
$result = '';
|
||||
|
||||
foreach ($map as $arabic => $roman) {
|
||||
while ($number >= $arabic) {
|
||||
$result .= $roman;
|
||||
$number -= $arabic;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
@ -2,50 +2,53 @@
|
||||
|
||||
class AdminHandler extends request_handler {
|
||||
|
||||
function __construct() {
|
||||
public function __construct() {
|
||||
parent::__construct();
|
||||
add_static('css/admin.css', 'js/admin.js');
|
||||
add_skin_strings(['error']);
|
||||
set_skin_opts(['inside_admin_interface' => true]);
|
||||
$this->skin->addStatic('css/admin.css', 'js/admin.js');
|
||||
$this->skin->exportStrings(['error']);
|
||||
$this->skin->setRenderOptions(['inside_admin_interface' => true]);
|
||||
}
|
||||
|
||||
function before_dispatch(string $http_method, string $action) {
|
||||
if ($action != 'login' && !is_admin())
|
||||
forbidden();
|
||||
public function beforeDispatch(string $http_method, string $action) {
|
||||
if ($action != 'login' && !isAdmin())
|
||||
self::forbidden();
|
||||
}
|
||||
|
||||
function GET_index() {
|
||||
global $AdminSession;
|
||||
public function GET_index() {
|
||||
//$admin_info = admin_current_info();
|
||||
set_title('$admin_title');
|
||||
render('admin/index',
|
||||
admin_login: $AdminSession->login);
|
||||
$this->skin->setTitle('$admin_title');
|
||||
$this->skin->renderPage('admin_index.twig', [
|
||||
'admin_login' => admin::getLogin(),
|
||||
'logout_token' => self::getCSRF('logout'),
|
||||
]);
|
||||
}
|
||||
|
||||
function GET_login() {
|
||||
if (is_admin())
|
||||
redirect('/admin/');
|
||||
set_title('$admin_title');
|
||||
render('admin/login');
|
||||
public function GET_login() {
|
||||
if (isAdmin())
|
||||
self::redirect('/admin/');
|
||||
$this->skin->setTitle('$admin_title');
|
||||
$this->skin->renderPage('admin_login.twig', [
|
||||
'form_token' => self::getCSRF('adminlogin'),
|
||||
]);
|
||||
}
|
||||
|
||||
function POST_login() {
|
||||
csrf_check('adminlogin');
|
||||
list($login, $password) = input('login, password');
|
||||
admin_auth($login, $password)
|
||||
? redirect('/admin/')
|
||||
: forbidden();
|
||||
public function POST_login() {
|
||||
self::checkCSRF('adminlogin');
|
||||
list($login, $password) = $this->input('login, password');
|
||||
admin::auth($login, $password)
|
||||
? self::redirect('/admin/')
|
||||
: self::forbidden();
|
||||
}
|
||||
|
||||
function GET_logout() {
|
||||
csrf_check('logout');
|
||||
admin_logout();
|
||||
redirect('/admin/login/', HTTPCode::Found);
|
||||
public function GET_logout() {
|
||||
self::checkCSRF('logout');
|
||||
admin::logout();
|
||||
self::redirect('/admin/login/', HTTPCode::Found);
|
||||
}
|
||||
|
||||
function GET_errors() {
|
||||
public function GET_errors() {
|
||||
list($ip, $query, $url_query, $file_query, $line_query, $per_page)
|
||||
= input('i:ip, query, url_query, file_query, i:line_query, i:per_page');
|
||||
= $this->input('i:ip, query, url_query, file_query, i:line_query, i:per_page');
|
||||
|
||||
if (!$per_page)
|
||||
$per_page = 100;
|
||||
@ -78,14 +81,14 @@ class AdminHandler extends request_handler {
|
||||
}
|
||||
|
||||
$count = (int)$db->result($db->query("SELECT COUNT(*) FROM backend_errors".$sql_where));
|
||||
list($page, $pages, $offset) = get_page($per_page, $count);
|
||||
list($page, $pages, $offset) = $this->getPage($per_page, $count);
|
||||
|
||||
$q = $db->query("SELECT *, INET_NTOA(ip) ip_s FROM backend_errors $sql_where ORDER BY id DESC LIMIT $offset, $per_page");
|
||||
|
||||
$list = [];
|
||||
|
||||
while ($row = $db->fetch($q)) {
|
||||
$row['date'] = format_time($row['ts'], [
|
||||
$row['date'] = formatTime($row['ts'], [
|
||||
'seconds' => true,
|
||||
'short_months' => true,
|
||||
]);
|
||||
@ -138,22 +141,20 @@ class AdminHandler extends request_handler {
|
||||
|
||||
$query_var_names = ['query', 'url_query', 'file_query', 'line_query'];
|
||||
foreach ($query_var_names as $query_var_name) {
|
||||
if ($$query_var_name) {
|
||||
if ($$query_var_name)
|
||||
$vars += [$query_var_name => $$query_var_name];
|
||||
}
|
||||
}
|
||||
|
||||
set_skin_opts(['wide' => true]);
|
||||
set_title('$admin_errors');
|
||||
render('admin/errors',
|
||||
...$vars);
|
||||
$this->skin->setRenderOptions(['wide' => true]);
|
||||
$this->skin->setTitle('$admin_errors');
|
||||
$this->skin->renderPage('admin_errors.twig', $vars);
|
||||
}
|
||||
|
||||
function GET_auth_log() {
|
||||
public function GET_auth_log() {
|
||||
$db = DB();
|
||||
$count = (int)$db->result($db->query("SELECT COUNT(*) FROM admin_log"));
|
||||
$per_page = 100;
|
||||
list($page, $pages, $offset) = get_page($per_page, $count);
|
||||
list($page, $pages, $offset) = $this->getPage($per_page, $count);
|
||||
|
||||
$q = $db->query("SELECT *,
|
||||
INET_NTOA(ip) AS ip,
|
||||
@ -167,24 +168,23 @@ class AdminHandler extends request_handler {
|
||||
|
||||
if (!empty($list)) {
|
||||
$list = array_map(function($item) {
|
||||
$item['date'] = format_time($item['ts']);
|
||||
$item['activity_ts_s'] = format_time($item['activity_ts']);
|
||||
$item['date'] = formatTime($item['ts']);
|
||||
$item['activity_ts_s'] = formatTime($item['activity_ts']);
|
||||
return $item;
|
||||
}, $list);
|
||||
}
|
||||
|
||||
$vars = [
|
||||
$this->skin->setRenderOptions(['wide' => true]);
|
||||
$this->skin->setTitle('$admin_auth_log');
|
||||
$this->skin->set([
|
||||
'list' => $list,
|
||||
'pn_page' => $page,
|
||||
'pn_pages' => $pages
|
||||
];
|
||||
set_skin_opts(['wide' => true]);
|
||||
set_title('$admin_auth_log');
|
||||
render('admin/auth_log',
|
||||
...$vars);
|
||||
]);
|
||||
$this->skin->renderPage('admin_auth_log.twig');
|
||||
}
|
||||
|
||||
function GET_actions_log() {
|
||||
public function GET_actions_log() {
|
||||
$field_types = \AdminActions\Util\Logger::getFieldTypes();
|
||||
foreach ($field_types as $type_prefix => $type_data) {
|
||||
for ($i = 1; $i <= $type_data['count']; $i++) {
|
||||
@ -197,7 +197,7 @@ class AdminHandler extends request_handler {
|
||||
$per_page = 100;
|
||||
|
||||
$count = \AdminActions\Util\Logger::getRecordsCount();
|
||||
list($page, $pages, $offset) = get_page($per_page, $count);
|
||||
list($page, $pages, $offset) = $this->getPage($per_page, $count);
|
||||
|
||||
$admin_ids = [];
|
||||
$admin_logins = [];
|
||||
@ -210,7 +210,7 @@ class AdminHandler extends request_handler {
|
||||
}
|
||||
|
||||
if (!empty($admin_ids))
|
||||
$admin_logins = admin_get_logins_by_id(array_keys($admin_ids));
|
||||
$admin_logins = admin::getLoginsById(array_keys($admin_ids));
|
||||
|
||||
$url = '/admin/actions-log/?';
|
||||
|
||||
@ -222,39 +222,37 @@ class AdminHandler extends request_handler {
|
||||
}
|
||||
}
|
||||
|
||||
$vars = [
|
||||
$this->skin->setRenderOptions(['wide' => true]);
|
||||
$this->skin->setTitle('$admin_actions_log');
|
||||
$this->skin->renderPage('admin_actions_log.twig', [
|
||||
'list' => $records,
|
||||
'pn_page' => $page,
|
||||
'pn_pages' => $pages,
|
||||
'admin_logins' => $admin_logins,
|
||||
'url' => $url,
|
||||
'action_types' => \AdminActions\Util\Logger::getActions(true),
|
||||
];
|
||||
|
||||
set_skin_opts(['wide' => true]);
|
||||
set_title('$admin_actions_log');
|
||||
render('admin/actions_log',
|
||||
...$vars);
|
||||
]);
|
||||
}
|
||||
|
||||
function GET_uploads() {
|
||||
list($error) = input('error');
|
||||
public function GET_uploads() {
|
||||
list($error) = $this->input('error');
|
||||
$uploads = uploads::getAllUploads();
|
||||
|
||||
set_title('$blog_upload');
|
||||
render('admin/uploads',
|
||||
error: $error,
|
||||
uploads: $uploads,
|
||||
langs: PostLanguage::cases());
|
||||
$this->skin->setTitle('$blog_upload');
|
||||
$this->skin->renderPage('admin_uploads.twig', [
|
||||
'error' => $error,
|
||||
'uploads' => $uploads,
|
||||
'langs' => PostLanguage::casesAsStrings(),
|
||||
'form_token' => self::getCSRF('add_upload'),
|
||||
]);
|
||||
}
|
||||
|
||||
function POST_uploads() {
|
||||
csrf_check('addupl');
|
||||
|
||||
list($custom_name, $note_en, $note_ru) = input('name, note_en, note_ru');
|
||||
public function POST_uploads() {
|
||||
self::checkCSRF('add_upload');
|
||||
list($custom_name, $note_en, $note_ru) = $this->input('name, note_en, note_ru');
|
||||
|
||||
if (!isset($_FILES['files']))
|
||||
redirect('/admin/uploads/?error='.urlencode('no file'));
|
||||
self::redirect('/admin/uploads/?error='.urlencode('no file'));
|
||||
|
||||
$files = [];
|
||||
for ($i = 0; $i < count($_FILES['files']['name']); $i++) {
|
||||
@ -275,14 +273,14 @@ class AdminHandler extends request_handler {
|
||||
|
||||
foreach ($files as $f) {
|
||||
if ($f['error'])
|
||||
redirect('/admin/uploads/?error='.urlencode('error code '.$f['error']));
|
||||
self::redirect('/admin/uploads/?error='.urlencode('error code '.$f['error']));
|
||||
|
||||
if (!$f['size'])
|
||||
redirect('/admin/uploads/?error='.urlencode('received empty file'));
|
||||
self::redirect('/admin/uploads/?error='.urlencode('received empty file'));
|
||||
|
||||
$ext = extension($f['name']);
|
||||
if (!uploads::isExtensionAllowed($ext))
|
||||
redirect('/admin/uploads/?error='.urlencode('extension not allowed'));
|
||||
self::redirect('/admin/uploads/?error='.urlencode('extension not allowed'));
|
||||
|
||||
$name = $custom_name ?: $f['name'];
|
||||
$upload_id = uploads::add(
|
||||
@ -292,36 +290,36 @@ class AdminHandler extends request_handler {
|
||||
$note_ru);
|
||||
|
||||
if (!$upload_id)
|
||||
redirect('/admin/uploads/?error='.urlencode('failed to create upload'));
|
||||
self::redirect('/admin/uploads/?error='.urlencode('failed to create upload'));
|
||||
|
||||
admin_log(new \AdminActions\UploadsAdd($upload_id, $name, $note_en, $note_ru));
|
||||
admin::log(new \AdminActions\UploadsAdd($upload_id, $name, $note_en, $note_ru));
|
||||
}
|
||||
|
||||
redirect('/admin/uploads/');
|
||||
self::redirect('/admin/uploads/');
|
||||
}
|
||||
|
||||
function GET_upload_delete() {
|
||||
list($id) = input('i:id');
|
||||
public function GET_upload_delete() {
|
||||
list($id) = $this->input('i:id');
|
||||
$upload = uploads::get($id);
|
||||
if (!$upload)
|
||||
redirect('/admin/uploads/?error='.urlencode('upload not found'));
|
||||
csrf_check('delupl'.$id);
|
||||
self::redirect('/admin/uploads/?error='.urlencode('upload not found'));
|
||||
self::checkCSRF('delupl'.$id);
|
||||
uploads::delete($id);
|
||||
admin_log(new \AdminActions\UploadsDelete($id));
|
||||
redirect('/admin/uploads/');
|
||||
admin::log(new \AdminActions\UploadsDelete($id));
|
||||
self::redirect('/admin/uploads/');
|
||||
}
|
||||
|
||||
function POST_upload_edit_note() {
|
||||
list($id, $note, $lang) = input('i:id, note, lang');
|
||||
public function POST_upload_edit_note() {
|
||||
list($id, $note, $lang) = $this->input('i:id, note, lang');
|
||||
$lang = PostLanguage::tryFrom($lang);
|
||||
if (!$lang)
|
||||
not_found();
|
||||
self::notFound();
|
||||
|
||||
$upload = uploads::get($id);
|
||||
if (!$upload)
|
||||
redirect('/admin/uploads/?error='.urlencode('upload not found'));
|
||||
self::redirect('/admin/uploads/?error='.urlencode('upload not found'));
|
||||
|
||||
csrf_check('editupl'.$id);
|
||||
self::checkCSRF('editupl'.$id);
|
||||
|
||||
$upload->setNote($lang, $note);
|
||||
$texts = posts::getTextsWithUpload($upload);
|
||||
@ -332,13 +330,13 @@ class AdminHandler extends request_handler {
|
||||
}
|
||||
}
|
||||
|
||||
admin_log(new \AdminActions\UploadsEditNote($id, $note, $lang->value));
|
||||
redirect('/admin/uploads/');
|
||||
admin::log(new \AdminActions\UploadsEditNote($id, $note, $lang->value));
|
||||
self::redirect('/admin/uploads/');
|
||||
}
|
||||
|
||||
function POST_ajax_md_preview() {
|
||||
ensure_xhr();
|
||||
list($md, $title, $use_image_previews, $lang, $is_page) = input('md, title, b:use_image_previews, lang, b:is_page');
|
||||
public function POST_ajax_md_preview() {
|
||||
self::ensureXhr();
|
||||
list($md, $title, $use_image_previews, $lang, $is_page) = $this->input('md, title, b:use_image_previews, lang, b:is_page');
|
||||
$lang = PostLanguage::tryFrom($lang);
|
||||
if (!$lang)
|
||||
$lang = PostLanguage::getDefault();
|
||||
@ -347,37 +345,48 @@ class AdminHandler extends request_handler {
|
||||
$title = '';
|
||||
}
|
||||
$html = markup::markdownToHtml($md, $use_image_previews, $lang);
|
||||
$ctx = skin('admin');
|
||||
$html = $ctx->markdownPreview(
|
||||
unsafe_html: $html,
|
||||
title: $title
|
||||
);
|
||||
ajax_ok(['html' => $html]);
|
||||
$html = $this->skin->render('markdown_preview.twig', [
|
||||
'unsafe_html' => $html,
|
||||
'title' => $title
|
||||
]);
|
||||
self::ajaxOk(['html' => $html]);
|
||||
}
|
||||
|
||||
function GET_page_add() {
|
||||
list($name) = input('short_name');
|
||||
public function GET_page_add() {
|
||||
list($name) = $this->input('short_name');
|
||||
$page = pages::getByName($name);
|
||||
if ($page)
|
||||
redirect($page->getUrl(), code: HTTPCode::Found);
|
||||
add_skin_strings_re('/^(err_)?pages_/');
|
||||
add_skin_strings_re('/^(err_)?blog_/');
|
||||
set_title(lang('pages_create_title', $name));
|
||||
static::make_wide();
|
||||
render('admin/pageForm',
|
||||
short_name: $name,
|
||||
title: '',
|
||||
text: '',
|
||||
langs: PostLanguage::cases());
|
||||
self::redirect($page->getUrl(), code: HTTPCode::Found);
|
||||
$this->skin->exportStrings('/^(err_)?pages_/');
|
||||
$this->skin->exportStrings('/^(err_)?blog_/');
|
||||
$this->skin->setTitle(lang('pages_create_title', $name));
|
||||
$this->setWidePageOptions();
|
||||
|
||||
$js_params = [
|
||||
'pages' => true,
|
||||
'edit' => false,
|
||||
'token' => self::getCSRF('addpage'),
|
||||
'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()), // still needed for draft erasing
|
||||
];
|
||||
|
||||
$this->skin->renderPage('admin_page_form.twig', [
|
||||
'is_edit' => false,
|
||||
'short_name' => $name,
|
||||
'title' => '',
|
||||
'text' => '',
|
||||
'langs' => PostLanguage::cases(),
|
||||
'js_params' => $js_params,
|
||||
'form_url' => '/'.$name.'/create/'
|
||||
]);
|
||||
}
|
||||
|
||||
function POST_page_add() {
|
||||
csrf_check('addpage');
|
||||
public function POST_page_add() {
|
||||
self::checkCSRF('addpage');
|
||||
|
||||
list($name, $text, $title) = input('short_name, text, title');
|
||||
list($name, $text, $title) = $this->input('short_name, text, title');
|
||||
$page = pages::getByName($name);
|
||||
if ($page)
|
||||
not_found();
|
||||
self::notFound();
|
||||
|
||||
$error_code = null;
|
||||
|
||||
@ -388,48 +397,48 @@ class AdminHandler extends request_handler {
|
||||
}
|
||||
|
||||
if ($error_code)
|
||||
ajax_error(['code' => $error_code]);
|
||||
self::ajaxError(['code' => $error_code]);
|
||||
|
||||
if (!pages::add([
|
||||
'short_name' => $name,
|
||||
'title' => $title,
|
||||
'md' => $text
|
||||
])) {
|
||||
ajax_error(['code' => 'db_err']);
|
||||
self::ajaxError(['code' => 'db_err']);
|
||||
}
|
||||
|
||||
admin_log(new \AdminActions\PageCreate($name));
|
||||
admin::log(new \AdminActions\PageCreate($name));
|
||||
|
||||
$page = pages::getByName($name);
|
||||
ajax_ok(['url' => $page->getUrl()]);
|
||||
self::ajaxOk(['url' => $page->getUrl()]);
|
||||
}
|
||||
|
||||
function GET_page_delete() {
|
||||
list($name) = input('short_name');
|
||||
public function GET_page_delete() {
|
||||
list($name) = $this->input('short_name');
|
||||
|
||||
$page = pages::getByName($name);
|
||||
if (!$page)
|
||||
not_found();
|
||||
self::notFound();
|
||||
|
||||
$url = $page->getUrl();
|
||||
|
||||
csrf_check('delpage'.$page->shortName);
|
||||
self::checkCSRF('delpage'.$page->shortName);
|
||||
pages::delete($page);
|
||||
admin_log(new \AdminActions\PageDelete($name));
|
||||
redirect($url, code: HTTPCode::Found);
|
||||
admin::log(new \AdminActions\PageDelete($name));
|
||||
self::redirect($url, code: HTTPCode::Found);
|
||||
}
|
||||
|
||||
function GET_page_edit() {
|
||||
list($short_name, $saved) = input('short_name, b:saved');
|
||||
public function GET_page_edit() {
|
||||
list($short_name, $saved) = $this->input('short_name, b:saved');
|
||||
|
||||
$page = pages::getByName($short_name);
|
||||
if (!$page)
|
||||
not_found();
|
||||
self::notFound();
|
||||
|
||||
add_skin_strings_re('/^(err_)?pages_/');
|
||||
add_skin_strings_re('/^(err_)?blog_/');
|
||||
set_title(lang('pages_page_edit_title', $page->shortName));
|
||||
static::make_wide();
|
||||
$this->skin->exportStrings('/^(err_)?pages_/');
|
||||
$this->skin->exportStrings('/^(err_)?blog_/');
|
||||
$this->skin->setTitle(lang('pages_page_edit_title', $page->shortName));
|
||||
$this->setWidePageOptions();
|
||||
$js_text = [
|
||||
'text' => $page->md,
|
||||
'title' => $page->title,
|
||||
@ -442,32 +451,43 @@ class AdminHandler extends request_handler {
|
||||
$parent = $parent_page->shortName;
|
||||
}
|
||||
|
||||
render('admin/pageForm',
|
||||
is_edit: true,
|
||||
short_name: $page->shortName,
|
||||
title: $page->title,
|
||||
text: $page->md,
|
||||
visible: $page->visible,
|
||||
render_title: $page->renderTitle,
|
||||
parent: $parent,
|
||||
saved: $saved,
|
||||
langs: PostLanguage::cases(),
|
||||
js_text: $js_text);
|
||||
$js_params = [
|
||||
'pages' => true,
|
||||
'edit' => true,
|
||||
'token' => self::getCSRF('editpage'.$short_name),
|
||||
'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()), // still needed for draft erasing
|
||||
'text' => [
|
||||
'text' => $page->md,
|
||||
'title' => $page->title,
|
||||
]
|
||||
];
|
||||
|
||||
$this->skin->renderPage('admin_page_form.twig', [
|
||||
'is_edit' => true,
|
||||
'short_name' => $page->shortName,
|
||||
'title' => $page->title,
|
||||
'text' => $page->md,
|
||||
'visible' => $page->visible,
|
||||
'render_title' => $page->renderTitle,
|
||||
'parent' => $parent,
|
||||
'saved' => $saved,
|
||||
'langs' => PostLanguage::cases(),
|
||||
'js_params' => $js_params,
|
||||
]);
|
||||
}
|
||||
|
||||
function POST_page_edit() {
|
||||
ensure_xhr();
|
||||
|
||||
list($short_name) = input('short_name');
|
||||
public function POST_page_edit() {
|
||||
self::ensureXhr();
|
||||
list($short_name) = $this->input('short_name');
|
||||
|
||||
$page = pages::getByName($short_name);
|
||||
if (!$page)
|
||||
not_found();
|
||||
self::notFound();
|
||||
|
||||
csrf_check('editpage'.$page->shortName);
|
||||
self::checkCSRF('editpage'.$page->shortName);
|
||||
|
||||
list($text, $title, $visible, $short_name, $parent, $render_title)
|
||||
= input('text, title, b:visible, new_short_name, parent, b:render_title');
|
||||
= $this->input('text, title, b:visible, new_short_name, parent, b:render_title');
|
||||
|
||||
$text = trim($text);
|
||||
$title = trim($title);
|
||||
@ -482,7 +502,7 @@ class AdminHandler extends request_handler {
|
||||
}
|
||||
|
||||
if ($error_code)
|
||||
ajax_error(['code' => $error_code]);
|
||||
self::ajaxError(['code' => $error_code]);
|
||||
|
||||
$new_short_name = $page->shortName != $short_name ? $short_name : null;
|
||||
$parent_page = pages::getByName($parent);
|
||||
@ -498,14 +518,14 @@ class AdminHandler extends request_handler {
|
||||
'parent_id' => $parent_id
|
||||
]);
|
||||
|
||||
admin_log(new \AdminActions\PageEdit($short_name, $new_short_name));
|
||||
ajax_ok(['url' => $page->getUrl().'edit/?saved=1']);
|
||||
admin::log(new \AdminActions\PageEdit($short_name, $new_short_name));
|
||||
self::ajaxOk(['url' => $page->getUrl().'edit/?saved=1']);
|
||||
}
|
||||
|
||||
function GET_post_add() {
|
||||
add_skin_strings_re('/^(err_)?blog_/');
|
||||
set_title('$blog_write');
|
||||
static::make_wide();
|
||||
public function GET_post_add() {
|
||||
$this->skin->exportStrings('/^(err_)?blog_/');
|
||||
$this->skin->setTitle('$blog_write');
|
||||
$this->setWidePageOptions();
|
||||
|
||||
$js_texts = [];
|
||||
foreach (PostLanguage::cases() as $pl) {
|
||||
@ -517,30 +537,47 @@ class AdminHandler extends request_handler {
|
||||
];
|
||||
}
|
||||
|
||||
render('admin/postForm',
|
||||
title: '',
|
||||
text: '',
|
||||
langs: PostLanguage::cases(),
|
||||
short_name: '',
|
||||
source_url: '',
|
||||
keywords: '',
|
||||
js_texts: $js_texts,
|
||||
lang: PostLanguage::getDefault()->value);
|
||||
$js_params = [
|
||||
'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()),
|
||||
'token' => self::getCSRF('post_add')
|
||||
];
|
||||
$form_url = '/articles/write/';
|
||||
|
||||
$bc = [
|
||||
['url' => '/articles/?lang='.PostLanguage::getDefault()->value, 'text' => lang('articles')],
|
||||
['text' => lang('blog_new_post')]
|
||||
];
|
||||
|
||||
$this->skin->renderPage('admin_post_form.twig', [
|
||||
// form data
|
||||
'title' => '',
|
||||
'text' => '',
|
||||
'short_name' => '',
|
||||
'source_url' => '',
|
||||
'keywords' => '',
|
||||
'date' => '',
|
||||
|
||||
'bc' => $bc,
|
||||
'js_params' => $js_params,
|
||||
'form_url' => $form_url,
|
||||
'langs' => PostLanguage::casesAsStrings(),
|
||||
'lang' => PostLanguage::getDefault()->value
|
||||
]);
|
||||
}
|
||||
|
||||
function POST_post_add() {
|
||||
ensure_xhr();
|
||||
csrf_check('post_add');
|
||||
public function POST_post_add() {
|
||||
self::ensureXhr();
|
||||
self::checkCSRF('post_add');
|
||||
|
||||
list($visibility_enabled, $short_name, $langs, $date)
|
||||
= input('b:visible, short_name, langs, date');
|
||||
= $this->input('b:visible, short_name, langs, date');
|
||||
|
||||
self::_postEditValidateCommonData($date);
|
||||
|
||||
$lang_data = [];
|
||||
$at_least_one_lang_is_written = false;
|
||||
foreach (PostLanguage::cases() as $lang) {
|
||||
list($title, $text, $keywords, $toc_enabled) = input("title:{$lang->value}, text:{$lang->value}, keywords:{$lang->value}, b:toc:{$lang->value}", ['trim' => true]);
|
||||
list($title, $text, $keywords, $toc_enabled) = $this->input("title:{$lang->value}, text:{$lang->value}, keywords:{$lang->value}, b:toc:{$lang->value}", ['trim' => true]);
|
||||
if ($title !== '' && $text !== '') {
|
||||
$lang_data[$lang->value] = [$title, $text, $keywords, $toc_enabled];
|
||||
$at_least_one_lang_is_written = true;
|
||||
@ -554,7 +591,7 @@ class AdminHandler extends request_handler {
|
||||
$error_code = 'no_short_name';
|
||||
}
|
||||
if ($error_code)
|
||||
ajax_error(['code' => $error_code]);
|
||||
self::ajaxError(['code' => $error_code]);
|
||||
|
||||
$post = posts::add([
|
||||
'visible' => $visibility_enabled,
|
||||
@ -564,7 +601,7 @@ class AdminHandler extends request_handler {
|
||||
]);
|
||||
|
||||
if (!$post)
|
||||
ajax_error(['code' => 'db_err', 'message' => 'failed to add post']);
|
||||
self::ajaxError(['code' => 'db_err', 'message' => 'failed to add post']);
|
||||
|
||||
// add texts
|
||||
$added_texts = []; // for admin actions logging, at the end
|
||||
@ -578,47 +615,47 @@ class AdminHandler extends request_handler {
|
||||
toc: $toc_enabled))
|
||||
) {
|
||||
posts::delete($post);
|
||||
ajax_error(['code' => 'db_err', 'message' => 'failed to add text language '.$lang]);
|
||||
self::ajaxError(['code' => 'db_err', 'message' => 'failed to add text language '.$lang]);
|
||||
} else {
|
||||
$added_texts[] = [$new_post_text->id, $lang];
|
||||
}
|
||||
}
|
||||
|
||||
admin_log(new \AdminActions\PostCreate($post->id));
|
||||
admin::log(new \AdminActions\PostCreate($post->id));
|
||||
foreach ($added_texts as $added_text) {
|
||||
list($id, $lang) = $added_text;
|
||||
admin_log(new \AdminActions\PostTextCreate($id, $post->id, $lang));
|
||||
admin::log(new \AdminActions\PostTextCreate($id, $post->id, $lang));
|
||||
}
|
||||
|
||||
// done
|
||||
ajax_ok(['url' => $post->getUrl()]);
|
||||
self::ajaxOk(['url' => $post->getUrl()]);
|
||||
}
|
||||
|
||||
function GET_post_delete() {
|
||||
list($name) = input('short_name');
|
||||
public function GET_post_delete() {
|
||||
list($name) = $this->input('short_name');
|
||||
|
||||
$post = posts::getByName($name);
|
||||
if (!$post)
|
||||
not_found();
|
||||
self::notFound();
|
||||
|
||||
$id = $post->id;
|
||||
csrf_check('delpost'.$id);
|
||||
self::checkCSRF('delpost'.$id);
|
||||
posts::delete($post);
|
||||
admin_log(new \AdminActions\PostDelete($id));
|
||||
redirect('/articles/', code: HTTPCode::Found);
|
||||
admin::log(new \AdminActions\PostDelete($id));
|
||||
self::redirect('/articles/', code: HTTPCode::Found);
|
||||
}
|
||||
|
||||
function GET_post_edit() {
|
||||
list($short_name, $saved, $lang) = input('short_name, b:saved, lang');
|
||||
public function GET_post_edit() {
|
||||
list($short_name, $saved, $lang) = $this->input('short_name, b:saved, lang');
|
||||
$lang = PostLanguage::from($lang);
|
||||
|
||||
$post = posts::getByName($short_name);
|
||||
if (!$post)
|
||||
not_found();
|
||||
self::notFound();
|
||||
|
||||
$texts = $post->getTexts();
|
||||
if (!isset($texts[$lang->value]))
|
||||
not_found();
|
||||
self::notFound();
|
||||
|
||||
$js_texts = [];
|
||||
foreach (PostLanguage::cases() as $pl) {
|
||||
@ -642,48 +679,64 @@ class AdminHandler extends request_handler {
|
||||
|
||||
$text = $texts[$lang->value];
|
||||
|
||||
add_skin_strings_re('/^(err_)?blog_/');
|
||||
add_skin_strings(['blog_post_edit_title']);
|
||||
set_title(lang('blog_post_edit_title', $text->title));
|
||||
static::make_wide();
|
||||
render('admin/postForm',
|
||||
is_edit: true,
|
||||
post_id: $post->id,
|
||||
post_url: $post->getUrl(),
|
||||
title: $text->title,
|
||||
text: $text->md,
|
||||
date: $post->getDateForInputField(),
|
||||
visible: $post->visible,
|
||||
toc: $text->toc,
|
||||
saved: $saved,
|
||||
short_name: $short_name,
|
||||
source_url: $post->sourceUrl,
|
||||
keywords: $text->keywords,
|
||||
langs: PostLanguage::cases(),
|
||||
lang: $text->lang->value,
|
||||
js_texts: $js_texts
|
||||
);
|
||||
$this->skin->exportStrings('/^(err_)?blog_/');
|
||||
$this->skin->exportStrings(['blog_post_edit_title']);
|
||||
$this->skin->setTitle(lang('blog_post_edit_title', $text->title));
|
||||
$this->setWidePageOptions();
|
||||
|
||||
$bc = [
|
||||
['url' => '/articles/?lang='.$text->lang->value, 'text' => lang('articles')],
|
||||
['url' => $post->getUrl().'?lang='.$text->lang->value, 'text' => lang('blog_view_post')]
|
||||
];
|
||||
|
||||
$js_params = [
|
||||
'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()),
|
||||
'token' => self::getCSRF('editpost'.$post->id),
|
||||
'edit' => true,
|
||||
'id' => $post->id,
|
||||
'texts' => $js_texts
|
||||
];
|
||||
$form_url = $post->getUrl().'edit/';
|
||||
|
||||
$this->skin->renderPage('admin_post_form.twig', [
|
||||
'is_edit' => true,
|
||||
'post' => $post,
|
||||
'title' => $text->title,
|
||||
'text' => $text->md,
|
||||
'date' => $post->getDateForInputField(),
|
||||
'visible' => $post->visible,
|
||||
'toc' => $text->toc,
|
||||
'short_name' => $short_name,
|
||||
'source_url' => $post->sourceUrl,
|
||||
'keywords' => $text->keywords,
|
||||
'saved' => $saved,
|
||||
'langs' => PostLanguage::casesAsStrings(),
|
||||
'lang' => $text->lang->value,
|
||||
'js_params' => $js_params,
|
||||
'form_url' => $form_url,
|
||||
'bc' => $bc
|
||||
]);
|
||||
}
|
||||
|
||||
function POST_post_edit() {
|
||||
ensure_xhr();
|
||||
public function POST_post_edit() {
|
||||
self::ensureXhr();
|
||||
|
||||
list($old_short_name, $short_name, $langs, $date, $source_url) = input('short_name, new_short_name, langs, date, source_url');
|
||||
list($old_short_name, $short_name, $langs, $date, $source_url) = $this->input('short_name, new_short_name, langs, date, source_url');
|
||||
|
||||
$post = posts::getByName($old_short_name);
|
||||
if (!$post)
|
||||
not_found();
|
||||
self::notFound();
|
||||
|
||||
csrf_check('editpost'.$post->id);
|
||||
self::checkCSRF('editpost'.$post->id);
|
||||
|
||||
self::_postEditValidateCommonData($date);
|
||||
|
||||
if (empty($short_name))
|
||||
ajax_error(['code' => 'no_short_name']);
|
||||
self::ajaxError(['code' => 'no_short_name']);
|
||||
|
||||
foreach (explode(',', $langs) as $lang) {
|
||||
$lang = PostLanguage::from($lang);
|
||||
list($text, $title, $visible, $toc, $keywords) = input("text:{$lang->value}, title:{$lang->value}, b:visible, b:toc:{$lang->value}, keywords:{$lang->value}");
|
||||
list($text, $title, $visible, $toc, $keywords) = $this->input("text:{$lang->value}, title:{$lang->value}, b:visible, b:toc:{$lang->value}, keywords:{$lang->value}");
|
||||
|
||||
$error_code = null;
|
||||
if (!$title)
|
||||
@ -691,7 +744,7 @@ class AdminHandler extends request_handler {
|
||||
else if (!$text)
|
||||
$error_code = 'no_text';
|
||||
if ($error_code)
|
||||
ajax_error(['code' => $error_code]);
|
||||
self::ajaxError(['code' => $error_code]);
|
||||
|
||||
$pt = $post->getText($lang);
|
||||
if (!$pt) {
|
||||
@ -703,7 +756,7 @@ class AdminHandler extends request_handler {
|
||||
toc: $toc
|
||||
);
|
||||
if (!$pt)
|
||||
ajax_error(['code' => 'db_err']);
|
||||
self::ajaxError(['code' => 'db_err']);
|
||||
} else {
|
||||
previous_texts::add(PreviousText::TYPE_POST_TEXT, $pt->id, $pt->md, $post->getUpdateTimestamp() ?: $post->getTimestamp());
|
||||
$pt->edit([
|
||||
@ -724,24 +777,24 @@ class AdminHandler extends request_handler {
|
||||
$post_data['short_name'] = $short_name;
|
||||
$post->edit($post_data);
|
||||
|
||||
admin_log(new \AdminActions\PostEdit($post->id));
|
||||
ajax_ok(['url' => $post->getUrl().'edit/?saved=1&lang='.$lang->value]);
|
||||
admin::log(new \AdminActions\PostEdit($post->id));
|
||||
self::ajaxOk(['url' => $post->getUrl().'edit/?saved=1&lang='.$lang->value]);
|
||||
}
|
||||
|
||||
function GET_books() {
|
||||
set_title('$admin_books');
|
||||
render('admin/books');
|
||||
public function GET_books() {
|
||||
$this->skin->setTitle('$admin_books');
|
||||
$this->skin->renderPage('admin_books.twig');
|
||||
}
|
||||
|
||||
protected static function _postEditValidateCommonData($date) {
|
||||
$dt = DateTime::createFromFormat("Y-m-d", $date);
|
||||
$date_is_valid = $dt && $dt->format("Y-m-d") === $date;
|
||||
if (!$date_is_valid)
|
||||
ajax_error(['code' => 'no_date']);
|
||||
self::ajaxError(['code' => 'no_date']);
|
||||
}
|
||||
|
||||
protected static function make_wide() {
|
||||
set_skin_opts([
|
||||
protected function setWidePageOptions(): void {
|
||||
$this->skin->setRenderOptions([
|
||||
'full_width' => true,
|
||||
'no_footer' => true
|
||||
]);
|
220
handlers/FilesHandler.php
Normal file
220
handlers/FilesHandler.php
Normal 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
197
handlers/MainHandler.php
Normal 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
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
@ -2,7 +2,7 @@
|
||||
|
||||
class ServicesHandler extends request_handler {
|
||||
|
||||
function GET_robots_txt() {
|
||||
public function GET_robots_txt() {
|
||||
$txt = <<<TXT
|
||||
User-agent: *
|
||||
Disallow: /admin/
|
||||
@ -13,12 +13,12 @@ TXT;
|
||||
exit;
|
||||
}
|
||||
|
||||
function GET_latest() {
|
||||
public function GET_latest() {
|
||||
global $config;
|
||||
list($lang) = input('lang');
|
||||
list($lang) = $this->input('lang');
|
||||
if (!isset($config['book_versions'][$lang]))
|
||||
not_found();
|
||||
redirect("https://files.4in1.ws/4in1-{$lang}.pdf?{$config['book_versions'][$lang]}",
|
||||
self::notFound();
|
||||
self::redirect("https://files.4in1.ws/4in1-{$lang}.pdf?{$config['book_versions'][$lang]}",
|
||||
code: HTTPCode::Found);
|
||||
}
|
||||
|
@ -2,5 +2,4 @@
|
||||
|
||||
require_once __DIR__.'/../init.php';
|
||||
|
||||
router_init();
|
||||
dispatch_request();
|
||||
request_handler::resolveAndDispatch();
|
||||
|
@ -5,7 +5,7 @@ global $config;
|
||||
|
||||
$name = $_REQUEST['name'] ?? '';
|
||||
|
||||
if (!is_dev() || !$name || !is_dir($path = APP_ROOT.'/htdocs/js/'.$name)) {
|
||||
if (!isDev() || !$name || !is_dir($path = APP_ROOT.'/htdocs/js/'.$name)) {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
|
@ -105,7 +105,7 @@ extend(AdminWriteEditForm.prototype, {
|
||||
params.title = this.form.elements.title.value;
|
||||
params.lang = this.getCurrentLang();
|
||||
}
|
||||
if (this.isPage() && this.form.render_title.checked) {
|
||||
if (this.isPage() && this.isEditing() && this.form.render_title.checked) {
|
||||
params.title = this.form.elements.title.value;
|
||||
params.is_page = 1
|
||||
}
|
||||
|
@ -135,12 +135,9 @@ var ThemeSwitcher = (function() {
|
||||
/**
|
||||
* @param {string} selectedMode
|
||||
*/
|
||||
function setIcon(selectedMode) {
|
||||
function setLabel(selectedMode) {
|
||||
document.body.setAttribute('data-theme', selectedMode);
|
||||
for (var i = 0; i < modes.length; i++) {
|
||||
var mode = modes[i];
|
||||
document.getElementById('svgicon_moon_'+mode+'_18').style.display = mode === selectedMode ? 'block': 'none';
|
||||
}
|
||||
ge('switch-theme').innerHTML = escape(selectedMode);
|
||||
}
|
||||
|
||||
return {
|
||||
@ -182,7 +179,7 @@ var ThemeSwitcher = (function() {
|
||||
onSystemChange(window.matchMedia('(prefers-color-scheme: dark)').matches === true);
|
||||
}
|
||||
|
||||
setIcon(modes[currentModeIndex]);
|
||||
setLabel(modes[currentModeIndex]);
|
||||
},
|
||||
|
||||
next: function(e) {
|
||||
@ -214,7 +211,7 @@ var ThemeSwitcher = (function() {
|
||||
break;
|
||||
}
|
||||
|
||||
setIcon(modes[currentModeIndex]);
|
||||
setLabel(modes[currentModeIndex]);
|
||||
setCookie('theme', modes[currentModeIndex]);
|
||||
|
||||
return cancelEvent(e);
|
||||
|
@ -10,7 +10,7 @@ if ($theme != 'light' && $theme != 'dark') {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!is_dev() || !$name || !file_exists($path = APP_ROOT.'/htdocs/scss/entries/'.$name.'/'.$theme.'.scss')) {
|
||||
if (!isDev() || !$name || !file_exists($path = APP_ROOT.'/htdocs/scss/entries/'.$name.'/'.$theme.'.scss')) {
|
||||
// logError(__FILE__.': access denied');
|
||||
http_response_code(403);
|
||||
exit;
|
||||
|
@ -240,7 +240,7 @@ table.contacts div.note {
|
||||
padding: 40px 20px;
|
||||
color: $grey;
|
||||
@include radius(3px);
|
||||
background-color: $dark-bg;
|
||||
background-color: $light-bg;
|
||||
}
|
||||
|
||||
.md-file-attach {
|
||||
|
@ -3,10 +3,21 @@
|
||||
//border-radius: 5px;
|
||||
padding: 15px 0;
|
||||
margin-top: 10px;
|
||||
text-align: right;
|
||||
color: $dark_grey;
|
||||
> span { color: $fg; }
|
||||
> a {
|
||||
@include no-underline(true);
|
||||
|
||||
&-right {
|
||||
float: right;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&-right, &-left {
|
||||
> span { color: $fg; }
|
||||
> a {
|
||||
@include no-underline(true);
|
||||
}
|
||||
}
|
||||
|
||||
&-separator {
|
||||
opacity: 0.33;
|
||||
}
|
||||
}
|
@ -71,10 +71,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
//body:not(.theme-changing) .head-logo {
|
||||
// @include transition(background-color, 0.03s);
|
||||
//}
|
||||
|
||||
.head-items {
|
||||
text-align: right;
|
||||
display: table-cell;
|
||||
@ -101,6 +97,9 @@ a.head-item {
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
&.is-ic {
|
||||
color: $link-color;
|
||||
}
|
||||
|
||||
&:hover, &.is-selected {
|
||||
border-radius: 4px;
|
||||
@ -119,7 +118,6 @@ body a.head-item.is-settings svg path {
|
||||
fill: $fg;
|
||||
}
|
||||
|
||||
|
||||
#svgicon_moon_light_18,
|
||||
#svgicon_moon_dark_18,
|
||||
#svgicon_moon_auto_18 {
|
||||
|
50
init.php
50
init.php
@ -14,42 +14,20 @@ define('START_TIME', microtime(true));
|
||||
set_include_path(get_include_path().PATH_SEPARATOR.APP_ROOT);
|
||||
|
||||
spl_autoload_register(function($class) {
|
||||
static $libs = [
|
||||
'lib/pages' => ['Page', 'pages'],
|
||||
'lib/previous_texts' => ['previous_texts', 'PreviousText'],
|
||||
'lib/posts' => ['Post', 'PostText', 'PostLanguage', 'posts'],
|
||||
'lib/uploads' => ['Upload', 'uploads'],
|
||||
'engine/model' => ['model'],
|
||||
'engine/skin' => ['SkinContext'],
|
||||
];
|
||||
|
||||
if (str_contains($class, '\\'))
|
||||
$class = str_replace('\\', '/', $class);
|
||||
|
||||
$path = null;
|
||||
foreach (['Handler', 'Helper'] as $sfx) {
|
||||
if (str_ends_with($class, $sfx)) {
|
||||
$path = APP_ROOT.'/'.strtolower($sfx).'/'.$class.'.php';
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($class == 'model')
|
||||
$path = 'engine/model';
|
||||
else if (str_ends_with($class, 'Handler'))
|
||||
$path = 'handlers/'.$class;
|
||||
else
|
||||
$path = 'lib/'.$class;
|
||||
|
||||
if (is_null($path)) {
|
||||
foreach ($libs as $lib_file => $class_names) {
|
||||
if (in_array($class, $class_names)) {
|
||||
$path = APP_ROOT.'/'.$lib_file.'.php';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (is_null($path))
|
||||
$path = APP_ROOT.'/lib/'.$class.'.php';
|
||||
|
||||
if (!is_file($path))
|
||||
if (!is_file(APP_ROOT.'/'.$path.'.php'))
|
||||
return;
|
||||
|
||||
require_once $path;
|
||||
require_once APP_ROOT.'/'.$path.'.php';
|
||||
});
|
||||
|
||||
if (!file_exists(APP_ROOT.'/config.yaml'))
|
||||
@ -69,12 +47,12 @@ require_once 'engine/request.php';
|
||||
require_once 'engine/logging.php';
|
||||
|
||||
try {
|
||||
if (is_cli()) {
|
||||
verify_hostname($config['domain']);
|
||||
if (isCli()) {
|
||||
verifyHostname($config['domain']);
|
||||
$_SERVER['HTTP_HOST'] = $config['domain'];
|
||||
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
|
||||
} else {
|
||||
verify_hostname();
|
||||
verifyHostname();
|
||||
if (array_key_exists('HTTP_X_REAL_IP', $_SERVER))
|
||||
$_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_X_REAL_IP'];
|
||||
|
||||
@ -86,12 +64,12 @@ try {
|
||||
die('Fatal error: '.$e->getMessage());
|
||||
}
|
||||
|
||||
$__logger = is_dev()
|
||||
$__logger = isDev()
|
||||
? new FileLogger(APP_ROOT.'/log/debug.log')
|
||||
: new DatabaseLogger();
|
||||
$__logger->enable();
|
||||
|
||||
if (!is_dev()) {
|
||||
if (!isDev()) {
|
||||
if (file_exists(APP_ROOT.'/config-static.php'))
|
||||
$config['static'] = require_once 'config-static.php';
|
||||
else
|
||||
@ -102,7 +80,7 @@ if (!is_dev()) {
|
||||
ini_set('display_errors', 0);
|
||||
}
|
||||
|
||||
if (!is_cli()) {
|
||||
if (!isCli()) {
|
||||
$__lang = Strings::getInstance();
|
||||
$__lang->load('main');
|
||||
}
|
||||
|
@ -55,7 +55,7 @@ abstract class BaseAction {
|
||||
}
|
||||
|
||||
public function getDate(): string {
|
||||
return format_time($this->timeStamp, ['short_months' => true]);
|
||||
return formatTime($this->timeStamp, ['short_months' => true]);
|
||||
}
|
||||
|
||||
public function getTimeStamp(): int {
|
||||
@ -70,7 +70,7 @@ abstract class BaseAction {
|
||||
return $this->recordId;
|
||||
}
|
||||
|
||||
function renderHtml(): string {
|
||||
public function renderHtml(): string {
|
||||
$rc = new \ReflectionClass($this);
|
||||
$lines = [];
|
||||
$fields = $rc->getProperties(\ReflectionProperty::IS_PUBLIC);
|
||||
|
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace AdminActions\util;
|
||||
namespace AdminActions\Util;
|
||||
|
||||
use AdminActions\BaseAction;
|
||||
|
||||
@ -11,20 +11,18 @@ class Logger {
|
||||
|
||||
const TABLE = 'admin_actions';
|
||||
|
||||
const INTS_COUNT = 6;
|
||||
const INTS_PREFIX = 'i';
|
||||
const int INTS_COUNT = 6;
|
||||
const string INTS_PREFIX = 'i';
|
||||
|
||||
const VARCHARS_COUNT = 2;
|
||||
const VARCHARS_PREFIX = 'c';
|
||||
const int VARCHARS_COUNT = 2;
|
||||
const string VARCHARS_PREFIX = 'c';
|
||||
|
||||
const SERIALIZED_COUNT = 1;
|
||||
const SERIALIZED_PREFIX = 's';
|
||||
const int SERIALIZED_COUNT = 1;
|
||||
const string SERIALIZED_PREFIX = 's';
|
||||
|
||||
protected static ?array $classes = null;
|
||||
|
||||
public static function record(BaseAction $action): int {
|
||||
global $AdminSession;
|
||||
|
||||
$packed = self::pack($action);
|
||||
|
||||
$data = [
|
||||
@ -32,13 +30,13 @@ class Logger {
|
||||
'ts' => time(),
|
||||
];
|
||||
|
||||
if (is_cli()) {
|
||||
if (isCli()) {
|
||||
$data += [
|
||||
'cli' => 1,
|
||||
];
|
||||
} else {
|
||||
$data += [
|
||||
'admin_id' => $AdminSession->id,
|
||||
'admin_id' => \admin::getId(),
|
||||
'ip' => !empty($_SERVER['REMOTE_ADDR']) ? ip2ulong($_SERVER['REMOTE_ADDR']) : 0,
|
||||
];
|
||||
}
|
||||
|
62
lib/BaconianaCollectionItem.php
Normal file
62
lib/BaconianaCollectionItem.php
Normal 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
6
lib/BookCategory.php
Normal file
@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
enum BookCategory: string {
|
||||
case BOOKS = 'books';
|
||||
case MISC = 'misc';
|
||||
}
|
7
lib/BookFileType.php
Normal file
7
lib/BookFileType.php
Normal file
@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
enum BookFileType: string {
|
||||
case NONE = 'none';
|
||||
case BOOK = 'book';
|
||||
case ARTICLE = 'article';
|
||||
}
|
87
lib/BookItem.php
Normal file
87
lib/BookItem.php
Normal 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
21
lib/CollectionItem.php
Normal 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
7
lib/FilesCollection.php
Normal file
@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
enum FilesCollection: string {
|
||||
case WilliamFriedman = 'wff';
|
||||
case MercureDeFrance = 'mdf';
|
||||
case Baconiana = 'baconiana';
|
||||
}
|
15
lib/FilesItemInterface.php
Normal file
15
lib/FilesItemInterface.php
Normal 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;
|
||||
}
|
6
lib/FilesItemSizeTrait.php
Normal file
6
lib/FilesItemSizeTrait.php
Normal 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
6
lib/FilesItemType.php
Normal file
@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
enum FilesItemType: string {
|
||||
case FILE = 'file';
|
||||
case FOLDER = 'folder';
|
||||
}
|
19
lib/FilesItemTypeTrait.php
Normal file
19
lib/FilesItemTypeTrait.php
Normal 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
83
lib/MDFCollectionItem.php
Normal 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;
|
||||
}
|
||||
}
|
@ -1,13 +1,12 @@
|
||||
<?php
|
||||
|
||||
require_once 'engine/skin.php';
|
||||
require_once 'lib/posts.php';
|
||||
|
||||
class MyParsedown extends ParsedownExtended {
|
||||
|
||||
protected array $options;
|
||||
|
||||
function __construct(
|
||||
public function __construct(
|
||||
?array $opts = null,
|
||||
protected bool $useImagePreviews = false,
|
||||
protected ?PostLanguage $lang = null,
|
||||
@ -47,8 +46,12 @@ class MyParsedown extends ParsedownExtended {
|
||||
|
||||
unset($result['element']['text']);
|
||||
|
||||
$ctx = self::getSkinContext();
|
||||
$result['element']['rawHtml'] = $ctx->fileupload($upload->name, $upload->getDirectUrl(), $upload->noteRu, $upload->getSize());
|
||||
$result['element']['rawHtml'] = skin::getInstance()->render('markdown_fileupload.twig', [
|
||||
'name' => $upload->name,
|
||||
'direct_url' => $upload->getDirectUrl(),
|
||||
'note' => $upload->noteRu,
|
||||
'size' => $upload->getSize()
|
||||
]);
|
||||
|
||||
return $result;
|
||||
}
|
||||
@ -107,19 +110,19 @@ class MyParsedown extends ParsedownExtended {
|
||||
|
||||
unset($result['element']['text']);
|
||||
|
||||
$ctx = self::getSkinContext();
|
||||
$result['element']['rawHtml'] = $ctx->image(
|
||||
w: $opts['w'],
|
||||
nolabel: $opts['nolabel'],
|
||||
align: $opts['align'],
|
||||
padding_top: round($h / $w * 100, 4),
|
||||
may_have_alpha: $image->imageMayHaveAlphaChannel(),
|
||||
$result['element']['rawHtml'] = skin::getInstance()->render('markdown_image.twig', [
|
||||
'w' => $opts['w'],
|
||||
'nolabel' => $opts['nolabel'],
|
||||
'align' => $opts['align'],
|
||||
'padding_top' => round($h / $w * 100, 4),
|
||||
'may_have_alpha' => $image->imageMayHaveAlphaChannel(),
|
||||
|
||||
url: $image_url,
|
||||
direct_url: $image->getDirectUrl(),
|
||||
unsafe_note: markup::markdownToHtml($this->lang !== null && $this->lang == PostLanguage::Russian ? $image->noteRu : $image->noteEn,
|
||||
no_paragraph: true)
|
||||
);
|
||||
'url' => $image_url,
|
||||
'direct_url' => $image->getDirectUrl(),
|
||||
'unsafe_note' => markup::markdownToHtml(
|
||||
md: $this->lang !== null && $this->lang == PostLanguage::Russian ? $image->noteRu : $image->noteEn,
|
||||
no_paragraph: true),
|
||||
]);
|
||||
|
||||
return $result;
|
||||
}
|
||||
@ -166,12 +169,11 @@ class MyParsedown extends ParsedownExtended {
|
||||
|
||||
unset($result['element']['text']);
|
||||
|
||||
$ctx = self::getSkinContext();
|
||||
$result['element']['rawHtml'] = $ctx->video(
|
||||
url: $video_url,
|
||||
w: $opts['w'],
|
||||
h: $opts['h']
|
||||
);
|
||||
$result['element']['rawHtml'] = skin::getInstance()->render('markdown_video.twig', [
|
||||
'url' => $video_url,
|
||||
'w' => $opts['w'],
|
||||
'h' => $opts['h']
|
||||
]);
|
||||
|
||||
return $result;
|
||||
}
|
||||
@ -218,8 +220,4 @@ class MyParsedown extends ParsedownExtended {
|
||||
return parent::blockFencedCodeComplete($block);
|
||||
}
|
||||
|
||||
protected static function getSkinContext(): SkinContext {
|
||||
return skin('markdown');
|
||||
}
|
||||
|
||||
}
|
47
lib/Page.php
Normal file
47
lib/Page.php
Normal 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
129
lib/Post.php
Normal 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
20
lib/PostLanguage.php
Normal 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
116
lib/PostText.php
Normal 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
16
lib/PreviousText.php
Normal 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;
|
||||
|
||||
}
|
39
lib/TwigAddons/JsTagNode.php
Normal file
39
lib/TwigAddons/JsTagNode.php
Normal 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);
|
||||
}
|
||||
|
||||
}
|
6
lib/TwigAddons/JsTagParamsNode.php
Normal file
6
lib/TwigAddons/JsTagParamsNode.php
Normal file
@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace TwigAddons;
|
||||
|
||||
#[\Twig\Attribute\YieldReady]
|
||||
class JsTagParamsNode extends \Twig\Node\Node {}
|
84
lib/TwigAddons/JsTagTokenParser.php
Normal file
84
lib/TwigAddons/JsTagTokenParser.php
Normal 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';
|
||||
}
|
||||
|
||||
}
|
105
lib/TwigAddons/MyExtension.php
Normal file
105
lib/TwigAddons/MyExtension.php
Normal 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
170
lib/Upload.php
Normal 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
53
lib/WFFCollectionItem.php
Normal 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'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
}
|
285
lib/admin.php
285
lib/admin.php
@ -1,156 +1,132 @@
|
||||
<?php
|
||||
|
||||
const ADMIN_SESSION_TIMEOUT = 86400 * 14;
|
||||
const ADMIN_COOKIE_NAME = 'admin_key';
|
||||
const ADMIN_LOGIN_MAX_LENGTH = 32;
|
||||
class admin {
|
||||
|
||||
$AdminSession = new class {
|
||||
public function __construct(
|
||||
public ?int $id = null,
|
||||
public ?int $authId = null,
|
||||
public ?string $csrfSalt = null,
|
||||
public ?string $login = null,
|
||||
) {}
|
||||
const int ADMIN_SESSION_TIMEOUT = 86400 * 14;
|
||||
const string ADMIN_COOKIE_NAME = 'admin_key';
|
||||
const int ADMIN_LOGIN_MAX_LENGTH = 32;
|
||||
|
||||
public function mrProper(): void {
|
||||
$this->id = null;
|
||||
$this->authId = null;
|
||||
$this->csrfSalt = null;
|
||||
$this->login = null;
|
||||
// session data
|
||||
protected static ?int $id = null;
|
||||
protected static ?int $authId = null;
|
||||
protected static ?string $csrfSalt = null;
|
||||
protected static ?string $login = null;
|
||||
|
||||
public static function exists(string $login): bool {
|
||||
$db = DB();
|
||||
return (int)$db->result($db->query("SELECT COUNT(*) FROM admins WHERE login=? LIMIT 1", $login)) > 0;
|
||||
}
|
||||
|
||||
public function makeCSRFSalt(string $salted_password): void {
|
||||
$this->csrfSalt = salt_password(strrev($salted_password));
|
||||
public static function add(string $login, string $password): int {
|
||||
$db = DB();
|
||||
$db->insert('admins', [
|
||||
'login' => $login,
|
||||
'password' => saltPassword($password),
|
||||
'activity_ts' => 0
|
||||
]);
|
||||
return $db->insertId();
|
||||
}
|
||||
};
|
||||
|
||||
function is_admin(): bool {
|
||||
global $AdminSession;
|
||||
if ($AdminSession->id === null)
|
||||
_admin_check();
|
||||
return $AdminSession->id != 0;
|
||||
}
|
||||
|
||||
function admin_exists(string $login): bool {
|
||||
$db = DB();
|
||||
return (int)$db->result($db->query("SELECT COUNT(*) FROM admins WHERE login=? LIMIT 1", $login)) > 0;
|
||||
}
|
||||
|
||||
function admin_add(string $login, string $password): int {
|
||||
$db = DB();
|
||||
$db->insert('admins', [
|
||||
'login' => $login,
|
||||
'password' => salt_password($password),
|
||||
'activity_ts' => 0
|
||||
]);
|
||||
return $db->insertId();
|
||||
}
|
||||
|
||||
function admin_delete(string $login): bool {
|
||||
$db = DB();
|
||||
$id = admin_get_id_by_login($login);
|
||||
if (!$db->query("DELETE FROM admins WHERE login=?", $login)) return false;
|
||||
if (!$db->query("DELETE FROM admin_auth WHERE admin_id=?", $id)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $ids
|
||||
* @return string[]
|
||||
*/
|
||||
function admin_get_logins_by_id(array $ids): array {
|
||||
$db = DB();
|
||||
$logins = [];
|
||||
$q = $db->query("SELECT id, login FROM admins WHERE id IN (".implode(',', $ids).")");
|
||||
while ($row = $db->fetch($q)) {
|
||||
$logins[(int)$row['id']] = $row['login'];
|
||||
public static function delete(string $login): bool {
|
||||
$db = DB();
|
||||
$id = self::getIdByLogin($login);
|
||||
if (!$db->query("DELETE FROM admins WHERE login=?", $login)) return false;
|
||||
if (!$db->query("DELETE FROM admin_auth WHERE admin_id=?", $id)) return false;
|
||||
return true;
|
||||
}
|
||||
return $logins;
|
||||
}
|
||||
|
||||
function admin_get_id_by_login(string $login): ?int {
|
||||
$db = DB();
|
||||
$q = $db->query("SELECT id FROM admins WHERE login=?", $login);
|
||||
return $db->numRows($q) > 0 ? (int)$db->result($q) : null;
|
||||
}
|
||||
/**
|
||||
* @param int[] $ids
|
||||
* @return string[]
|
||||
*/
|
||||
public static function getLoginsById(array $ids): array {
|
||||
$db = DB();
|
||||
$logins = [];
|
||||
$q = $db->query("SELECT id, login FROM admins WHERE id IN (".implode(',', $ids).")");
|
||||
while ($row = $db->fetch($q)) {
|
||||
$logins[(int)$row['id']] = $row['login'];
|
||||
}
|
||||
return $logins;
|
||||
}
|
||||
|
||||
function admin_set_password(string $login, string $password): bool {
|
||||
$db = DB();
|
||||
$db->query("UPDATE admins SET password=? WHERE login=?", salt_password($password), $login);
|
||||
return $db->affectedRows() > 0;
|
||||
}
|
||||
protected static function getIdByLogin(string $login): ?int {
|
||||
$db = DB();
|
||||
$q = $db->query("SELECT id FROM admins WHERE login=?", $login);
|
||||
return $db->numRows($q) > 0 ? (int)$db->result($q) : null;
|
||||
}
|
||||
|
||||
function admin_auth(string $login, string $password): bool {
|
||||
global $AdminSession;
|
||||
public static function setPassword(string $login, string $password): bool {
|
||||
$db = DB();
|
||||
$db->query("UPDATE admins SET password=? WHERE login=?", saltPassword($password), $login);
|
||||
return $db->affectedRows() > 0;
|
||||
}
|
||||
|
||||
$db = DB();
|
||||
$salted_password = salt_password($password);
|
||||
$q = $db->query("SELECT id, active FROM admins WHERE login=? AND password=?", $login, $salted_password);
|
||||
if (!$db->numRows($q))
|
||||
return false;
|
||||
public static function auth(string $login, string $password): bool {
|
||||
$db = DB();
|
||||
$salted_password = saltPassword($password);
|
||||
$q = $db->query("SELECT id, active FROM admins WHERE login=? AND password=?", $login, $salted_password);
|
||||
if (!$db->numRows($q)) {
|
||||
logDebug(__METHOD__.': login or password is invalid');
|
||||
return false;
|
||||
}
|
||||
|
||||
$row = $db->fetch($q);
|
||||
$id = (int)$row['id'];
|
||||
$active = (bool)$row['active'];
|
||||
if (!$active)
|
||||
return false;
|
||||
$row = $db->fetch($q);
|
||||
$id = (int)$row['id'];
|
||||
$active = (bool)$row['active'];
|
||||
if (!$active)
|
||||
return false;
|
||||
|
||||
$time = time();
|
||||
$time = time();
|
||||
|
||||
do {
|
||||
$token = strgen(32);
|
||||
} while ($db->numRows($db->query("SELECT id FROM admin_auth WHERE token=? LIMIT 1", $token)) > 0);
|
||||
do {
|
||||
$token = strgen(32);
|
||||
} while ($db->numRows($db->query("SELECT id FROM admin_auth WHERE token=? LIMIT 1", $token)) > 0);
|
||||
|
||||
$db->insert('admin_auth', [
|
||||
'admin_id' => $id,
|
||||
'token' => $token,
|
||||
'ts' => $time
|
||||
]);
|
||||
$auth_id = $db->insertId();
|
||||
$db->insert('admin_auth', [
|
||||
'admin_id' => $id,
|
||||
'token' => $token,
|
||||
'ts' => $time
|
||||
]);
|
||||
$auth_id = $db->insertId();
|
||||
|
||||
$db->insert('admin_log', [
|
||||
'admin_id' => $id,
|
||||
'ts' => $time,
|
||||
'ip' => ip2ulong($_SERVER['REMOTE_ADDR']),
|
||||
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||
]);
|
||||
$db->insert('admin_log', [
|
||||
'admin_id' => $id,
|
||||
'ts' => $time,
|
||||
'ip' => ip2ulong($_SERVER['REMOTE_ADDR']),
|
||||
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||
]);
|
||||
|
||||
$db->query("UPDATE admins SET activity_ts=? WHERE id=?", $time, $id);
|
||||
$db->query("UPDATE admins SET activity_ts=? WHERE id=?", $time, $id);
|
||||
|
||||
$AdminSession->id = $id;
|
||||
$AdminSession->login = $login;
|
||||
$AdminSession->makeCSRFSalt($salted_password);
|
||||
$AdminSession->authId = $auth_id;
|
||||
self::setSessionData($id, $login, $auth_id, $salted_password);
|
||||
self::setCookie($token);
|
||||
|
||||
_admin_set_cookie($token);
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function admin_logout() {
|
||||
if (!is_admin())
|
||||
return;
|
||||
public static function logout() {
|
||||
if (!isAdmin())
|
||||
return;
|
||||
|
||||
global $AdminSession;
|
||||
$db = DB();
|
||||
$db->query("DELETE FROM admin_auth WHERE id=?", self::$authId);
|
||||
|
||||
$db = DB();
|
||||
$db->query("DELETE FROM admin_auth WHERE id=?", $AdminSession->authId);
|
||||
self::unsetSessionData();
|
||||
self::unsetCookie();
|
||||
}
|
||||
|
||||
$AdminSession->mrProper();
|
||||
_admin_unset_cookie();
|
||||
}
|
||||
public static function log(\AdminActions\BaseAction $action) {
|
||||
\AdminActions\Util\Logger::record($action);
|
||||
}
|
||||
|
||||
function admin_log(\AdminActions\BaseAction $action) {
|
||||
\AdminActions\Util\Logger::record($action);
|
||||
}
|
||||
public static function check(): void {
|
||||
if (!isset($_COOKIE[self::ADMIN_COOKIE_NAME]))
|
||||
return;
|
||||
|
||||
function _admin_check(): void {
|
||||
if (!isset($_COOKIE[ADMIN_COOKIE_NAME]))
|
||||
return;
|
||||
|
||||
$cookie = (string)$_COOKIE[ADMIN_COOKIE_NAME];
|
||||
$db = DB();
|
||||
$time = time();
|
||||
$q = $db->query("SELECT
|
||||
$cookie = (string)$_COOKIE[self::ADMIN_COOKIE_NAME];
|
||||
$db = DB();
|
||||
$time = time();
|
||||
$q = $db->query("SELECT
|
||||
admin_auth.id AS auth_id,
|
||||
admin_auth.admin_id AS id,
|
||||
admins.activity_ts AS activity_ts,
|
||||
@ -161,27 +137,44 @@ function _admin_check(): void {
|
||||
WHERE admin_auth.token=?
|
||||
LIMIT 1", $cookie);
|
||||
|
||||
if (!$db->numRows($q))
|
||||
return;
|
||||
if (!$db->numRows($q)) {
|
||||
unset($_COOKIE[self::ADMIN_COOKIE_NAME]);
|
||||
return;
|
||||
}
|
||||
|
||||
$info = $db->fetch($q);
|
||||
$info = $db->fetch($q);
|
||||
self::setSessionData((int)$info['id'], $info['login'], (int)$info['auth_id'], $info['salted_password']);
|
||||
|
||||
global $AdminSession;
|
||||
$AdminSession->id = (int)$info['id'];
|
||||
$AdminSession->login = $info['login'];
|
||||
$AdminSession->authId = (int)$info['auth_id'];
|
||||
$AdminSession->makeCSRFSalt($info['salted_password']);
|
||||
if ($time - $info['activity_ts'] > 15)
|
||||
$db->query("UPDATE admins SET activity_ts=? WHERE id=?", $time, self::$id);
|
||||
}
|
||||
|
||||
protected static function setCookie(string $token): void {
|
||||
global $config;
|
||||
setcookie(self::ADMIN_COOKIE_NAME, $token, time() + self::ADMIN_SESSION_TIMEOUT, '/', $config['cookie_host']);
|
||||
}
|
||||
|
||||
protected static function unsetCookie(): void {
|
||||
global $config;
|
||||
setcookie(self::ADMIN_COOKIE_NAME, '', 1, '/', $config['cookie_host']);
|
||||
}
|
||||
|
||||
protected static function setSessionData(int $id, string $login, int $authId, string $saltedPassword) {
|
||||
self::$id = $id;
|
||||
self::$login = $login;
|
||||
self::$authId = $authId;
|
||||
self::$csrfSalt = saltPassword(strrev($saltedPassword));
|
||||
}
|
||||
|
||||
protected static function unsetSessionData(): void {
|
||||
self::$id = null;
|
||||
self::$authId = null;
|
||||
self::$csrfSalt = null;
|
||||
self::$login = null;
|
||||
}
|
||||
|
||||
public static function getId(): ?int { return self::$id; }
|
||||
public static function getCSRFSalt(): ?string { return self::$csrfSalt; }
|
||||
public static function getLogin(): ?string { return self::$login; }
|
||||
|
||||
if ($time - $info['activity_ts'] > 15)
|
||||
$db->query("UPDATE admins SET activity_ts=? WHERE id=?", $time, $AdminSession->id);
|
||||
}
|
||||
|
||||
function _admin_set_cookie(string $token): void {
|
||||
global $config;
|
||||
setcookie(ADMIN_COOKIE_NAME, $token, time() + ADMIN_SESSION_TIMEOUT, '/', $config['cookie_host']);
|
||||
}
|
||||
|
||||
function _admin_unset_cookie(): void {
|
||||
global $config;
|
||||
setcookie(ADMIN_COOKIE_NAME, '', 1, '/', $config['cookie_host']);
|
||||
}
|
||||
|
32
lib/ansi.php
32
lib/ansi.php
@ -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";
|
||||
}
|
@ -17,15 +17,15 @@ class cli {
|
||||
exit(is_null($error) ? 0 : 1);
|
||||
}
|
||||
|
||||
function on(string $command, callable $f) {
|
||||
public function on(string $command, callable $f) {
|
||||
$this->commands[$command] = $f;
|
||||
return $this;
|
||||
}
|
||||
|
||||
function run(): void {
|
||||
public function run(): void {
|
||||
global $argv, $argc;
|
||||
|
||||
if (!is_cli())
|
||||
if (!isCli())
|
||||
cli::die('SAPI != cli');
|
||||
|
||||
if ($argc < 2)
|
||||
|
1045
lib/files.php
1045
lib/files.php
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,5 @@
|
||||
<?php
|
||||
|
||||
require_once 'lib/ext/MyParsedown.php';
|
||||
require_once 'lib/posts.php';
|
||||
|
||||
class markup {
|
||||
|
||||
public static function markdownToHtml(string $md,
|
||||
@ -19,7 +16,7 @@ class markup {
|
||||
// collect references
|
||||
$re = '/^<p>(\[([io]?\d{1,2})]) (.*?)<\/p>/ms';
|
||||
$result = preg_match_all($re, $html, $matches);
|
||||
if (pcre_no_error($result)) {
|
||||
if (pcreNoError($result)) {
|
||||
$reftitles_map = [];
|
||||
foreach ($matches[2] as $i => $refname) {
|
||||
$reftitles_map[$refname] = trim(htmlspecialchars_decode(strip_tags($matches[3][$i])));
|
||||
|
@ -1,56 +1,8 @@
|
||||
<?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 {
|
||||
|
||||
static function add(array $data): bool {
|
||||
public static function add(array $data): bool {
|
||||
$db = DB();
|
||||
$data['ts'] = time();
|
||||
$data['html'] = markup::markdownToHtml($data['md']);
|
||||
@ -59,18 +11,18 @@ class pages {
|
||||
return true;
|
||||
}
|
||||
|
||||
static function delete(Page $page): void {
|
||||
public static function delete(Page $page): void {
|
||||
DB()->query("DELETE FROM pages WHERE short_name=?", $page->shortName);
|
||||
previous_texts::delete(PreviousText::TYPE_PAGE, $page->get_id());
|
||||
}
|
||||
|
||||
static function getById(int $id): ?Page {
|
||||
public static function getById(int $id): ?Page {
|
||||
$db = DB();
|
||||
$q = $db->query("SELECT * FROM pages WHERE id=?", $id);
|
||||
return $db->numRows($q) ? new Page($db->fetch($q)) : null;
|
||||
}
|
||||
|
||||
static function getByName(string $short_name): ?Page {
|
||||
public static function getByName(string $short_name): ?Page {
|
||||
$db = DB();
|
||||
$q = $db->query("SELECT * FROM pages WHERE short_name=?", $short_name);
|
||||
return $db->numRows($q) ? new Page($db->fetch($q)) : null;
|
||||
@ -79,7 +31,7 @@ class pages {
|
||||
/**
|
||||
* @return Page[]
|
||||
*/
|
||||
static function getAll(): array {
|
||||
public static function getAll(): array {
|
||||
$db = DB();
|
||||
return array_map('Page::create_instance', $db->fetchAll($db->query("SELECT * FROM pages")));
|
||||
}
|
||||
|
266
lib/posts.php
266
lib/posts.php
@ -1,254 +1,8 @@
|
||||
<?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 {
|
||||
|
||||
static function getCount(bool $include_hidden = false): int {
|
||||
public static function getCount(bool $include_hidden = false): int {
|
||||
$db = DB();
|
||||
$sql = "SELECT COUNT(*) FROM posts";
|
||||
if (!$include_hidden) {
|
||||
@ -260,7 +14,7 @@ class posts {
|
||||
/**
|
||||
* @return Post[]
|
||||
*/
|
||||
static function getList(int $offset = 0,
|
||||
public static function getList(int $offset = 0,
|
||||
int $count = -1,
|
||||
bool $include_hidden = false,
|
||||
?PostLanguage $filter_by_lang = null
|
||||
@ -293,14 +47,14 @@ class posts {
|
||||
return array_values($posts);
|
||||
}
|
||||
|
||||
static function add(array $data = []): ?Post {
|
||||
public static function add(array $data = []): ?Post {
|
||||
$db = DB();
|
||||
if (!$db->insert('posts', $data))
|
||||
return null;
|
||||
return self::get($db->insertId());
|
||||
}
|
||||
|
||||
static function delete(Post $post): void {
|
||||
public static function delete(Post $post): void {
|
||||
$db = DB();
|
||||
$db->query("DELETE FROM posts WHERE id=?", $post->id);
|
||||
|
||||
@ -313,25 +67,25 @@ class posts {
|
||||
$db->query("DELETE FROM posts_texts WHERE post_id=?", $post->id);
|
||||
}
|
||||
|
||||
static function get(int $id): ?Post {
|
||||
public static function get(int $id): ?Post {
|
||||
$db = DB();
|
||||
$q = $db->query("SELECT * FROM posts WHERE id=?", $id);
|
||||
return $db->numRows($q) ? new Post($db->fetch($q)) : null;
|
||||
}
|
||||
|
||||
static function getText(int $text_id): ?PostText {
|
||||
public static function getText(int $text_id): ?PostText {
|
||||
$db = DB();
|
||||
$q = $db->query("SELECT * FROM posts_texts WHERE id=?", $text_id);
|
||||
return $db->numRows($q) ? new PostText($db->fetch($q)) : null;
|
||||
}
|
||||
|
||||
static function getByName(string $short_name): ?Post {
|
||||
public static function getByName(string $short_name): ?Post {
|
||||
$db = DB();
|
||||
$q = $db->query("SELECT * FROM posts WHERE short_name=?", $short_name);
|
||||
return $db->numRows($q) ? new Post($db->fetch($q)) : null;
|
||||
}
|
||||
|
||||
static function getPostsById(array $ids, bool $flat = false): array {
|
||||
public static function getPostsById(array $ids, bool $flat = false): array {
|
||||
if (empty($ids)) {
|
||||
return [];
|
||||
}
|
||||
@ -357,7 +111,7 @@ class posts {
|
||||
return $posts;
|
||||
}
|
||||
|
||||
static function getPostTextsById(array $ids, bool $flat = false): array {
|
||||
public static function getPostTextsById(array $ids, bool $flat = false): array {
|
||||
if (empty($ids)) {
|
||||
return [];
|
||||
}
|
||||
@ -387,7 +141,7 @@ class posts {
|
||||
* @param Upload $upload
|
||||
* @return PostText[] Array of PostTexts that includes specified upload
|
||||
*/
|
||||
static function getTextsWithUpload(Upload $upload): array {
|
||||
public static function getTextsWithUpload(Upload $upload): array {
|
||||
$db = DB();
|
||||
$q = $db->query("SELECT id FROM posts_texts WHERE md LIKE '%{image:{$upload->randomId}%'");
|
||||
$ids = [];
|
||||
|
@ -1,23 +1,8 @@
|
||||
<?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 {
|
||||
|
||||
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->insert(PreviousText::DB_TABLE, [
|
||||
'object_type' => $object_type,
|
||||
@ -27,7 +12,7 @@ class previous_texts {
|
||||
]);
|
||||
}
|
||||
|
||||
static function delete(int $object_type, int|array $object_id): void {
|
||||
public static function delete(int $object_type, int|array $object_id): void {
|
||||
$sql = "DELETE FROM ".PreviousText::DB_TABLE." WHERE object_type=? AND object_id";
|
||||
$args = [$object_type];
|
||||
if (is_array($object_id))
|
||||
|
109
lib/sphinx.php
Normal file
109
lib/sphinx.php
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
}
|
@ -1,45 +1,50 @@
|
||||
<?php
|
||||
|
||||
const THEMES = [
|
||||
'dark' => [
|
||||
'bg' => 0x222222,
|
||||
// 'alpha' => 0x303132,
|
||||
'alpha' => 0x222222,
|
||||
],
|
||||
'light' => [
|
||||
'bg' => 0xffffff,
|
||||
// 'alpha' => 0xf2f2f2,
|
||||
'alpha' => 0xffffff,
|
||||
]
|
||||
];
|
||||
class themes {
|
||||
|
||||
const array COLORS = [
|
||||
'dark' => [
|
||||
'bg' => 0x222222,
|
||||
// 'alpha' => 0x303132,
|
||||
'alpha' => 0x222222,
|
||||
],
|
||||
'light' => [
|
||||
'bg' => 0xffffff,
|
||||
// 'alpha' => 0xf2f2f2,
|
||||
'alpha' => 0xffffff,
|
||||
]
|
||||
];
|
||||
|
||||
function getThemes(): array {
|
||||
return array_keys(THEMES);
|
||||
}
|
||||
public static function getThemes(): array {
|
||||
return array_keys(self::COLORS);
|
||||
}
|
||||
|
||||
function themeExists(string $name): bool {
|
||||
return array_key_exists($name, THEMES);
|
||||
}
|
||||
public static function themeExists(string $name): bool {
|
||||
return array_key_exists($name, self::COLORS);
|
||||
}
|
||||
|
||||
function getThemeAlphaColorAsRGB(string $name): array {
|
||||
$color = THEMES[$name]['alpha'];
|
||||
$r = ($color >> 16) & 0xff;
|
||||
$g = ($color >> 8) & 0xff;
|
||||
$b = $color & 0xff;
|
||||
return [$r, $g, $b];
|
||||
}
|
||||
public static function getThemeAlphaColorAsRGB(string $name): array {
|
||||
$color = self::COLORS[$name]['alpha'];
|
||||
$r = ($color >> 16) & 0xff;
|
||||
$g = ($color >> 8) & 0xff;
|
||||
$b = $color & 0xff;
|
||||
return [$r, $g, $b];
|
||||
}
|
||||
|
||||
function getUserTheme(): string {
|
||||
if (isset($_COOKIE['theme'])) {
|
||||
$val = $_COOKIE['theme'];
|
||||
if (is_array($val))
|
||||
$val = implode($val);
|
||||
} else
|
||||
$val = 'auto';
|
||||
return $val;
|
||||
}
|
||||
public static function getUserTheme(): string {
|
||||
if (isset($_COOKIE['theme'])) {
|
||||
$val = $_COOKIE['theme'];
|
||||
if (is_array($val))
|
||||
$val = implode($val);
|
||||
if ($val != 'auto' && !self::themeExists($val))
|
||||
$val = 'auto';
|
||||
} else
|
||||
$val = 'auto';
|
||||
return $val;
|
||||
}
|
||||
|
||||
public static function isUserSystemThemeDark(): bool {
|
||||
return ($_COOKIE['theme-system-value'] ?? '') === 'dark';
|
||||
}
|
||||
|
||||
function isUserSystemThemeDark(): bool {
|
||||
return ($_COOKIE['theme-system-value'] ?? '') === 'dark';
|
||||
}
|
205
lib/uploads.php
205
lib/uploads.php
@ -1,38 +1,36 @@
|
||||
<?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 {
|
||||
|
||||
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();
|
||||
return (int)$db->result($db->query("SELECT COUNT(*) FROM uploads"));
|
||||
}
|
||||
|
||||
static function isExtensionAllowed(string $ext): bool {
|
||||
return in_array($ext, UPLOADS_ALLOWED_EXTENSIONS);
|
||||
public static function isExtensionAllowed(string $ext): bool {
|
||||
return in_array($ext, self::ALLOWED_EXTENSIONS);
|
||||
}
|
||||
|
||||
static function add(string $tmp_name,
|
||||
public static function add(string $tmp_name,
|
||||
string $name,
|
||||
string $note_en = '',
|
||||
string $note_ru = '',
|
||||
string $source_url = ''): ?int {
|
||||
global $config;
|
||||
|
||||
$name = sanitize_filename($name);
|
||||
$name = sanitizeFilename($name);
|
||||
if (!$name)
|
||||
$name = 'file';
|
||||
|
||||
$random_id = self::_getNewUploadRandomId();
|
||||
$size = filesize($tmp_name);
|
||||
$is_image = detect_image_type($tmp_name) !== false;
|
||||
$is_image = detectImageType($tmp_name) !== false;
|
||||
$image_w = 0;
|
||||
$image_h = 0;
|
||||
if ($is_image) {
|
||||
@ -70,7 +68,7 @@ class uploads {
|
||||
return $id;
|
||||
}
|
||||
|
||||
static function delete(int $id): bool {
|
||||
public static function delete(int $id): bool {
|
||||
$upload = self::get($id);
|
||||
if (!$upload)
|
||||
return false;
|
||||
@ -85,13 +83,13 @@ class uploads {
|
||||
/**
|
||||
* @return Upload[]
|
||||
*/
|
||||
static function getAllUploads(): array {
|
||||
public static function getAllUploads(): array {
|
||||
$db = DB();
|
||||
$q = $db->query("SELECT * FROM uploads ORDER BY id DESC");
|
||||
return array_map('Upload::create_instance', $db->fetchAll($q));
|
||||
}
|
||||
|
||||
static function get(int $id): ?Upload {
|
||||
public static function get(int $id): ?Upload {
|
||||
$db = DB();
|
||||
$q = $db->query("SELECT * FROM uploads WHERE id=?", $id);
|
||||
if ($db->numRows($q)) {
|
||||
@ -106,7 +104,7 @@ class uploads {
|
||||
* @param bool $flat
|
||||
* @return Upload[]
|
||||
*/
|
||||
static function getUploadsByRandomId(array $ids, bool $flat = false): array {
|
||||
public static function getUploadsByRandomId(array $ids, bool $flat = false): array {
|
||||
if (empty($ids)) {
|
||||
return [];
|
||||
}
|
||||
@ -132,7 +130,7 @@ class uploads {
|
||||
return $uploads;
|
||||
}
|
||||
|
||||
static function getUploadByRandomId(string $random_id): ?Upload {
|
||||
public static function getUploadByRandomId(string $random_id): ?Upload {
|
||||
$db = DB();
|
||||
$q = $db->query("SELECT * FROM uploads WHERE random_id=? LIMIT 1", $random_id);
|
||||
if ($db->numRows($q)) {
|
||||
@ -142,7 +140,7 @@ class uploads {
|
||||
}
|
||||
}
|
||||
|
||||
static function getUploadBySourceUrl(string $source_url): ?Upload {
|
||||
public static function getUploadBySourceUrl(string $source_url): ?Upload {
|
||||
$db = DB();
|
||||
$q = $db->query("SELECT * FROM uploads WHERE source_url=? LIMIT 1", $source_url);
|
||||
if ($db->numRows($q)) {
|
||||
@ -152,7 +150,7 @@ class uploads {
|
||||
}
|
||||
}
|
||||
|
||||
static function _getNewUploadRandomId(): string {
|
||||
public static function _getNewUploadRandomId(): string {
|
||||
$db = DB();
|
||||
do {
|
||||
$random_id = strgen(8);
|
||||
@ -161,168 +159,3 @@ class uploads {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class Upload extends model {
|
||||
|
||||
const DB_TABLE = 'uploads';
|
||||
|
||||
public static array $ImageExtensions = ['jpg', 'jpeg', 'png', 'gif'];
|
||||
public static array $VideoExtensions = ['mp4', 'ogg'];
|
||||
|
||||
public int $id;
|
||||
public string $randomId;
|
||||
public int $ts;
|
||||
public string $name;
|
||||
public int $size;
|
||||
public int $downloads;
|
||||
public int $image; // TODO: remove
|
||||
public int $imageW;
|
||||
public int $imageH;
|
||||
public string $noteRu;
|
||||
public string $noteEn;
|
||||
public string $sourceUrl;
|
||||
|
||||
function getDirectory(): string {
|
||||
global $config;
|
||||
return $config['uploads_dir'].'/'.$this->randomId;
|
||||
}
|
||||
|
||||
function getDirectUrl(): string {
|
||||
global $config;
|
||||
return $config['uploads_path'].'/'.$this->randomId.'/'.$this->name;
|
||||
}
|
||||
|
||||
function getDirectPreviewUrl(int $w, int $h, bool $retina = false): string {
|
||||
global $config;
|
||||
if ($w == $this->imageW && $this->imageH == $h)
|
||||
return $this->getDirectUrl();
|
||||
|
||||
if ($retina) {
|
||||
$w *= 2;
|
||||
$h *= 2;
|
||||
}
|
||||
|
||||
$prefix = $this->imageMayHaveAlphaChannel() ? 'a' : 'p';
|
||||
return $config['uploads_path'].'/'.$this->randomId.'/'.$prefix.$w.'x'.$h.'.jpg';
|
||||
}
|
||||
|
||||
// TODO remove?
|
||||
function incrementDownloads() {
|
||||
$db = DB();
|
||||
$db->query("UPDATE uploads SET downloads=downloads+1 WHERE id=?", $this->id);
|
||||
$this->downloads++;
|
||||
}
|
||||
|
||||
function getSize(): string {
|
||||
return sizeString($this->size);
|
||||
}
|
||||
|
||||
function getMarkdown(?string $options = null): string {
|
||||
if ($this->isImage()) {
|
||||
$md = '{image:'.$this->randomId.',w='.$this->imageW.',h='.$this->imageH.($options ? ','.$options : '').'}{/image}';
|
||||
} else if ($this->isVideo()) {
|
||||
$md = '{video:'.$this->randomId.($options ? ','.$options : '').'}{/video}';
|
||||
} else {
|
||||
$md = '{fileAttach:'.$this->randomId.($options ? ','.$options : '').'}{/fileAttach}';
|
||||
}
|
||||
$md .= ' <!-- '.$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;
|
||||
}
|
||||
|
||||
}
|
@ -1,5 +1,8 @@
|
||||
<?php
|
||||
|
||||
const ROUTER_VERSION = 10;
|
||||
const ROUTER_MC_KEY = '4in1/routes';
|
||||
|
||||
return (function() {
|
||||
global $config;
|
||||
|
||||
|
730
skin/admin.phps
730
skin/admin.phps
@ -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>
|
||||
<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"> </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;
|
||||
}
|
37
skin/admin_actions_log.twig
Normal file
37
skin/admin_actions_log.twig
Normal 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
33
skin/admin_auth_log.twig
Normal 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
69
skin/admin_errors.twig
Normal 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
8
skin/admin_index.twig
Normal 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
28
skin/admin_login.twig
Normal 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
104
skin/admin_page_form.twig
Normal 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
5
skin/admin_page_new.twig
Normal 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
112
skin/admin_post_form.twig
Normal 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>
|
||||
<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"> </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
72
skin/admin_uploads.twig
Normal 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
30
skin/articles.twig
Normal 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 %}
|
24
skin/articles_right_links.twig
Normal file
24
skin/articles_right_links.twig
Normal 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>
|
305
skin/base.phps
305
skin/base.phps
@ -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).'&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).'&theme='.$theme.'&v='.time();
|
||||
} else {
|
||||
$version = getStaticVersion($config_name);
|
||||
$href = '/dist-css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css?v='.$version;
|
||||
}
|
||||
|
||||
$id = 'style_'.$bname;
|
||||
if ($theme == 'dark')
|
||||
$id .= '_dark';
|
||||
|
||||
return '<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;
|
||||
|
||||
}
|
@ -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
9
skin/error.twig
Normal 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>
|
261
skin/files.phps
261
skin/files.phps
@ -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), ' ')}
|
||||
</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;
|
||||
}
|
44
skin/files_collection.twig
Normal file
44
skin/files_collection.twig
Normal 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 %} {% 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
67
skin/files_file.twig
Normal 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
7
skin/files_folder.twig
Normal 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
14
skin/files_index.twig
Normal 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
3
skin/files_list.twig
Normal file
@ -0,0 +1,3 @@
|
||||
{% for file in files %}
|
||||
{% include 'files_file.twig' with {file: file} %}
|
||||
{% endfor %}
|
44
skin/footer.twig
Normal file
44
skin/footer.twig
Normal 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
40
skin/header.twig
Normal 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">
|
@ -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
43
skin/index.twig
Normal 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">•</span> 379 pp. <span class="bullet">•</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">•</span> 453 стр. <span class="bullet">•</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>
|
269
skin/main.phps
269
skin/main.phps
@ -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">•</span> 379 pp. <span class="bullet">•</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">•</span> 453 стр. <span class="bullet">•</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;
|
||||
}
|
@ -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;
|
||||
}
|
7
skin/markdown_fileupload.twig
Normal file
7
skin/markdown_fileupload.twig
Normal 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
10
skin/markdown_image.twig
Normal 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>
|
6
skin/markdown_preview.twig
Normal file
6
skin/markdown_preview.twig
Normal 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
9
skin/markdown_video.twig
Normal 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
14
skin/page.twig
Normal 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
63
skin/post.twig
Normal 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 %}
|
@ -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
Loading…
x
Reference in New Issue
Block a user