upgrade engine
This commit is contained in:
parent
8d5149ccf0
commit
266f6b1a59
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,6 +1,6 @@
|
|||||||
/debug.log
|
/debug.log
|
||||||
/log
|
/log
|
||||||
test.php
|
src/test.php
|
||||||
/.git
|
/.git
|
||||||
/node_modules/
|
/node_modules/
|
||||||
/vendor/
|
/vendor/
|
||||||
@ -8,7 +8,6 @@ test.php
|
|||||||
._.DS_Store
|
._.DS_Store
|
||||||
.sass-cache/
|
.sass-cache/
|
||||||
config-static.php
|
config-static.php
|
||||||
/config-local.php
|
|
||||||
/config.yaml
|
/config.yaml
|
||||||
/.idea
|
/.idea
|
||||||
/htdocs/dist-css
|
/htdocs/dist-css
|
||||||
|
@ -38,7 +38,7 @@ This is a source code of 4in1.ws web site.
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Should be done by copying config.php to config-local.php and modifying config-local.php.
|
Should be done by copying config.yaml.example to config.yaml and customizing it.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
@ -26,5 +26,19 @@
|
|||||||
],
|
],
|
||||||
"minimum-stability": "dev",
|
"minimum-stability": "dev",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"preferred-install": "dist"
|
"preferred-install": "dist",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"engine\\": "src/engine",
|
||||||
|
"app\\": "src/lib",
|
||||||
|
"app\\foreignone\\": [
|
||||||
|
"src/lib/foreignone",
|
||||||
|
"src/handlers/foreignone"
|
||||||
|
],
|
||||||
|
"app\\ic\\": [
|
||||||
|
"src/lib/ic",
|
||||||
|
"src/handlers/ic"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
require_once __DIR__.'/../init.php';
|
require_once __DIR__.'/../src/init.php';
|
||||||
require_once 'lib/files.php';
|
|
||||||
|
|
||||||
global $config;
|
global $config;
|
||||||
|
|
||||||
use samdark\sitemap\Sitemap;
|
use samdark\sitemap\Sitemap;
|
||||||
|
use app\foreignone\files\ArchiveType;
|
||||||
|
use app\foreignone\Post;
|
||||||
|
use app\foreignone\PostLanguage;
|
||||||
|
|
||||||
$db = DB();
|
$db = getDB();
|
||||||
|
|
||||||
$sitemap = new Sitemap($config['sitemap_dir'].'/sitemap.xml');
|
$sitemap = new Sitemap($config['sitemap_dir'].'/sitemap.xml');
|
||||||
$addr = 'https://'.$config['domain'];
|
$addr = 'https://'.$config['domain'];
|
||||||
@ -25,20 +27,20 @@ while ($row = $db->fetch($q)) {
|
|||||||
// files
|
// files
|
||||||
$sitemap->addItem("{$addr}/files/",
|
$sitemap->addItem("{$addr}/files/",
|
||||||
changeFrequency: Sitemap::WEEKLY);
|
changeFrequency: Sitemap::WEEKLY);
|
||||||
foreach (FilesCollection::cases() as $fc) {
|
foreach (ArchiveType::cases() as $fc) {
|
||||||
$sitemap->addItem("{$addr}/files/".$fc->value.'/',
|
$sitemap->addItem("{$addr}/files/".$fc->value.'/',
|
||||||
changeFrequency: Sitemap::MONTHLY);
|
changeFrequency: Sitemap::MONTHLY);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ([FilesCollection::WilliamFriedman, FilesCollection::Baconiana] as $fc) {
|
foreach ([ArchiveType::WilliamFriedman, ArchiveType::Baconiana] as $fc) {
|
||||||
$q = $db->query("SELECT id FROM {$fc->value}_collection WHERE type=?", FilesItemType::FOLDER);
|
$q = $db->query("SELECT id FROM {$fc->value}_collection WHERE type='folder'");
|
||||||
while ($row = $db->fetch($q)) {
|
while ($row = $db->fetch($q)) {
|
||||||
$sitemap->addItem("{$addr}/files/".$fc->value.'/'.$row['id'].'/',
|
$sitemap->addItem("{$addr}/files/".$fc->value.'/'.$row['id'].'/',
|
||||||
changeFrequency: Sitemap::MONTHLY);
|
changeFrequency: Sitemap::MONTHLY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$q = $db->query("SELECT id FROM books WHERE type=? AND external=0", FilesItemType::FOLDER);
|
$q = $db->query("SELECT id FROM books WHERE type='folder' AND external=0");
|
||||||
while ($row = $db->fetch($q)) {
|
while ($row = $db->fetch($q)) {
|
||||||
$sitemap->addItem("{$addr}/files/".$row['id'].'/',
|
$sitemap->addItem("{$addr}/files/".$row['id'].'/',
|
||||||
changeFrequency: Sitemap::MONTHLY);
|
changeFrequency: Sitemap::MONTHLY);
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
require __DIR__.'/../init.php';
|
use app\CliUtil;
|
||||||
require 'engine/skin.php';
|
|
||||||
|
require __DIR__.'/../src/init.php';
|
||||||
|
|
||||||
if ($argc <= 1) {
|
if ($argc <= 1) {
|
||||||
usage();
|
usage();
|
||||||
@ -20,18 +21,18 @@ while (count($argv) > 0) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
cli::die('unsupported argument: '.$argv[0]);
|
CliUtil::die('unsupported argument: '.$argv[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (is_null($input_dir))
|
if (is_null($input_dir))
|
||||||
cli::die("input directory has not been specified");
|
CliUtil::die("input directory has not been specified");
|
||||||
|
|
||||||
$hashes = [];
|
$hashes = [];
|
||||||
foreach (['css', 'js'] as $type) {
|
foreach (['css', 'js'] as $type) {
|
||||||
$entries = glob_recursive($input_dir.'/dist-'.$type.'/*.'.$type);
|
$entries = glob_recursive($input_dir.'/dist-'.$type.'/*.'.$type);
|
||||||
if (empty($entries)) {
|
if (empty($entries)) {
|
||||||
cli::error("warning: no files found in $input_dir/dist-$type");
|
CliUtil::error("warning: no files found in $input_dir/dist-$type");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,7 +41,7 @@ foreach (['css', 'js'] as $type) {
|
|||||||
'version' => get_hash($file),
|
'version' => get_hash($file),
|
||||||
'integrity' => []
|
'integrity' => []
|
||||||
];
|
];
|
||||||
foreach (RESOURCE_INTEGRITY_HASHES as $hash_type)
|
foreach (\engine\skin\FeaturedSkin::RESOURCE_INTEGRITY_HASHES as $hash_type)
|
||||||
$hashes[$type.'/'.basename($file)]['integrity'][$hash_type] = base64_encode(hash_file($hash_type, $file, true));
|
$hashes[$type.'/'.basename($file)]['integrity'][$hash_type] = base64_encode(hash_file($hash_type, $file, true));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -66,9 +67,9 @@ function get_hash(string $path): string {
|
|||||||
function glob_escape(string $pattern): string {
|
function glob_escape(string $pattern): string {
|
||||||
if (str_contains($pattern, '[') || str_contains($pattern, ']')) {
|
if (str_contains($pattern, '[') || str_contains($pattern, ']')) {
|
||||||
$placeholder = uniqid();
|
$placeholder = uniqid();
|
||||||
$replaces = array( $placeholder.'[', $placeholder.']', );
|
$replaces = [$placeholder.'[', $placeholder.']', ];
|
||||||
$pattern = str_replace( array('[', ']', ), $replaces, $pattern);
|
$pattern = str_replace( ['[', ']'], $replaces, $pattern);
|
||||||
$pattern = str_replace( $replaces, array('[[]', '[]]', ), $pattern);
|
$pattern = str_replace( $replaces, ['[[]', '[]]'], $pattern);
|
||||||
}
|
}
|
||||||
return $pattern;
|
return $pattern;
|
||||||
}
|
}
|
||||||
|
@ -1,318 +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";
|
|
||||||
}
|
|
||||||
|
|
||||||
enum LogLevel: int {
|
|
||||||
case ERROR = 10;
|
|
||||||
case WARNING = 5;
|
|
||||||
case INFO = 3;
|
|
||||||
case DEBUG = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
function logDebug(...$args): void { global $__logger; $__logger?->log(LogLevel::DEBUG, null, ...$args); }
|
|
||||||
function logInfo(...$args): void { global $__logger; $__logger?->log(LogLevel::INFO, null, ...$args); }
|
|
||||||
function logWarning(...$args): void { global $__logger; $__logger?->log(LogLevel::WARNING, null, ...$args); }
|
|
||||||
function logError(...$args): void {
|
|
||||||
global $__logger;
|
|
||||||
if (array_key_exists('stacktrace', $args)) {
|
|
||||||
$st = $args['stacktrace'];
|
|
||||||
unset($args['stacktrace']);
|
|
||||||
} else {
|
|
||||||
$st = null;
|
|
||||||
}
|
|
||||||
if ($__logger?->canReport())
|
|
||||||
$__logger?->log(LogLevel::ERROR, $st, ...$args);
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract class Logger {
|
|
||||||
protected bool $enabled = false;
|
|
||||||
protected int $counter = 0;
|
|
||||||
protected int $recursionLevel = 0;
|
|
||||||
|
|
||||||
/** @var ?callable $filter */
|
|
||||||
protected $filter = null;
|
|
||||||
|
|
||||||
public function setErrorFilter(callable $filter): void {
|
|
||||||
$this->filter = $filter;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function disable(): void {
|
|
||||||
$this->enabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function enable(): void {
|
|
||||||
static $error_handler_set = false;
|
|
||||||
$this->enabled = true;
|
|
||||||
|
|
||||||
if ($error_handler_set)
|
|
||||||
return;
|
|
||||||
|
|
||||||
$self = $this;
|
|
||||||
|
|
||||||
set_error_handler(function($no, $str, $file, $line) use ($self) {
|
|
||||||
if (!$self->enabled)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (is_callable($self->filter) && !($self->filter)($no, $file, $line, $str))
|
|
||||||
return;
|
|
||||||
|
|
||||||
static::write(LogLevel::ERROR, $str,
|
|
||||||
errno: $no,
|
|
||||||
errfile: $file,
|
|
||||||
errline: $line);
|
|
||||||
});
|
|
||||||
|
|
||||||
set_exception_handler(function(\Throwable $e): void {
|
|
||||||
static::write(LogLevel::ERROR, get_class($e).': '.$e->getMessage(),
|
|
||||||
errfile: $e->getFile() ?: '?',
|
|
||||||
errline: $e->getLine() ?: 0,
|
|
||||||
stacktrace: $e->getTraceAsString());
|
|
||||||
});
|
|
||||||
|
|
||||||
register_shutdown_function(function () use ($self) {
|
|
||||||
if (!$self->enabled || !($error = error_get_last()))
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (is_callable($self->filter)
|
|
||||||
&& !($self->filter)($error['type'], $error['file'], $error['line'], $error['message'])) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
static::write(LogLevel::ERROR, $error['message'],
|
|
||||||
errno: $error['type'],
|
|
||||||
errfile: $error['file'],
|
|
||||||
errline: $error['line']);
|
|
||||||
});
|
|
||||||
|
|
||||||
$error_handler_set = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function log(LogLevel $level, ?string $stacktrace = null, ...$args): void {
|
|
||||||
if (!isDev() && $level == LogLevel::DEBUG)
|
|
||||||
return;
|
|
||||||
$this->write($level, strVars($args),
|
|
||||||
stacktrace: $stacktrace);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function canReport(): bool {
|
|
||||||
return $this->recursionLevel < 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function write(LogLevel $level,
|
|
||||||
string $message,
|
|
||||||
?int $errno = null,
|
|
||||||
?string $errfile = null,
|
|
||||||
?string $errline = null,
|
|
||||||
?string $stacktrace = null): void {
|
|
||||||
$this->recursionLevel++;
|
|
||||||
|
|
||||||
if ($this->canReport())
|
|
||||||
$this->writer($level, $this->counter++, $message, $errno, $errfile, $errline, $stacktrace);
|
|
||||||
|
|
||||||
$this->recursionLevel--;
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract protected function writer(LogLevel $level,
|
|
||||||
int $num,
|
|
||||||
string $message,
|
|
||||||
?int $errno = null,
|
|
||||||
?string $errfile = null,
|
|
||||||
?string $errline = null,
|
|
||||||
?string $stacktrace = null): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
class FileLogger extends Logger {
|
|
||||||
|
|
||||||
public function __construct(protected string $logFile) {}
|
|
||||||
|
|
||||||
protected function writer(LogLevel $level,
|
|
||||||
int $num,
|
|
||||||
string $message,
|
|
||||||
?int $errno = null,
|
|
||||||
?string $errfile = null,
|
|
||||||
?string $errline = null,
|
|
||||||
?string $stacktrace = null): void
|
|
||||||
{
|
|
||||||
if (is_null($this->logFile)) {
|
|
||||||
fprintf(STDERR, __METHOD__.': logfile is not set');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$time = time();
|
|
||||||
|
|
||||||
// TODO rewrite using sprintf
|
|
||||||
$exec_time = strval(exectime());
|
|
||||||
if (strlen($exec_time) < 6)
|
|
||||||
$exec_time .= str_repeat('0', 6 - strlen($exec_time));
|
|
||||||
|
|
||||||
$title = isCli() ? 'cli' : $_SERVER['REQUEST_URI'];
|
|
||||||
$date = date('d/m/y H:i:s', $time);
|
|
||||||
|
|
||||||
$buf = '';
|
|
||||||
if ($num == 0) {
|
|
||||||
$buf .= ansi(" $title ",
|
|
||||||
fg: AnsiColor::WHITE,
|
|
||||||
bg: AnsiColor::MAGENTA,
|
|
||||||
bold: true,
|
|
||||||
fg_bright: true);
|
|
||||||
$buf .= ansi(" $date ", fg: AnsiColor::WHITE, bg: AnsiColor::BLUE, fg_bright: true);
|
|
||||||
$buf .= "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
$letter = strtoupper($level->name[0]);
|
|
||||||
$color = match ($level) {
|
|
||||||
LogLevel::ERROR => AnsiColor::RED,
|
|
||||||
LogLevel::INFO => AnsiColor::GREEN,
|
|
||||||
LogLevel::DEBUG => AnsiColor::WHITE,
|
|
||||||
LogLevel::WARNING => AnsiColor::YELLOW
|
|
||||||
};
|
|
||||||
|
|
||||||
$buf .= ansi($letter.ansi('='.ansi($num, bold: true)), fg: $color).' ';
|
|
||||||
$buf .= ansi($exec_time, fg: AnsiColor::CYAN).' ';
|
|
||||||
if (!is_null($errno)) {
|
|
||||||
$buf .= ansi($errfile, fg: AnsiColor::GREEN);
|
|
||||||
$buf .= ansi(':', fg: AnsiColor::WHITE);
|
|
||||||
$buf .= ansi($errline, fg: AnsiColor::GREEN, fg_bright: true);
|
|
||||||
$buf .= ' ('.getPHPErrorName($errno).') ';
|
|
||||||
}
|
|
||||||
|
|
||||||
$buf .= $message."\n";
|
|
||||||
if (in_array($level, [LogLevel::ERROR, LogLevel::WARNING]))
|
|
||||||
$buf .= ($stacktrace ?: backtraceAsString(2))."\n";
|
|
||||||
|
|
||||||
$set_perm = false;
|
|
||||||
if (!file_exists($this->logFile)) {
|
|
||||||
$set_perm = true;
|
|
||||||
$dir = dirname($this->logFile);
|
|
||||||
echo "dir: $dir\n";
|
|
||||||
|
|
||||||
if (!file_exists($dir)) {
|
|
||||||
mkdir($dir);
|
|
||||||
setperm($dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$f = fopen($this->logFile, 'a');
|
|
||||||
if (!$f) {
|
|
||||||
fprintf(STDERR, __METHOD__.': failed to open file \''.$this->logFile.'\' for writing');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fwrite($f, $buf);
|
|
||||||
fclose($f);
|
|
||||||
|
|
||||||
if ($set_perm)
|
|
||||||
setperm($this->logFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
class DatabaseLogger extends Logger {
|
|
||||||
protected function writer(LogLevel $level,
|
|
||||||
int $num,
|
|
||||||
string $message,
|
|
||||||
?int $errno = null,
|
|
||||||
?string $errfile = null,
|
|
||||||
?string $errline = null,
|
|
||||||
?string $stacktrace = null): void
|
|
||||||
{
|
|
||||||
$db = DB();
|
|
||||||
|
|
||||||
$data = [
|
|
||||||
'ts' => time(),
|
|
||||||
'num' => $num,
|
|
||||||
'time' => exectime(),
|
|
||||||
'errno' => $errno ?: 0,
|
|
||||||
'file' => $errfile ?: '?',
|
|
||||||
'line' => $errline ?: 0,
|
|
||||||
'text' => $message,
|
|
||||||
'level' => $level->value,
|
|
||||||
'stacktrace' => $stacktrace ?: backtraceAsString(2),
|
|
||||||
'is_cli' => intval(isCli()),
|
|
||||||
'admin_id' => isAdmin() ? admin::getId() : 0,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isCli()) {
|
|
||||||
$data += [
|
|
||||||
'ip' => '',
|
|
||||||
'ua' => '',
|
|
||||||
'url' => '',
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
$data += [
|
|
||||||
'ip' => ip2ulong($_SERVER['REMOTE_ADDR']),
|
|
||||||
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
|
||||||
'url' => $_SERVER['HTTP_HOST'].$_SERVER['REQUEST_URI']
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$db->insert('backend_errors', $data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPHPErrorName(int $errno): ?string {
|
|
||||||
static $errors = null;
|
|
||||||
if (is_null($errors))
|
|
||||||
$errors = array_flip(array_slice(get_defined_constants(true)['Core'], 0, 15, true));
|
|
||||||
return $errors[$errno] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function strVarDump($var, bool $print_r = false): string {
|
|
||||||
ob_start();
|
|
||||||
$print_r ? print_r($var) : var_dump($var);
|
|
||||||
return trim(ob_get_clean());
|
|
||||||
}
|
|
||||||
|
|
||||||
function strVars(array $args): string {
|
|
||||||
$args = array_map(fn($a) => match (gettype($a)) {
|
|
||||||
'string' => $a,
|
|
||||||
'array', 'object' => strVarDump($a, true),
|
|
||||||
default => strVarDump($a)
|
|
||||||
}, $args);
|
|
||||||
return implode(' ', $args);
|
|
||||||
}
|
|
||||||
|
|
||||||
function backtraceAsString(int $shift = 0): string {
|
|
||||||
$bt = debug_backtrace();
|
|
||||||
$lines = [];
|
|
||||||
foreach ($bt as $i => $t) {
|
|
||||||
if ($i < $shift)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (!isset($t['file'])) {
|
|
||||||
$lines[] = 'from ?';
|
|
||||||
} else {
|
|
||||||
$lines[] = 'from '.$t['file'].':'.$t['line'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return implode("\n", $lines);
|
|
||||||
}
|
|
@ -1,238 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
enum HTTPCode: int {
|
|
||||||
case MovedPermanently = 301;
|
|
||||||
case Found = 302;
|
|
||||||
|
|
||||||
case InvalidRequest = 400;
|
|
||||||
case Unauthorized = 401;
|
|
||||||
case NotFound = 404;
|
|
||||||
case Forbidden = 403;
|
|
||||||
|
|
||||||
case InternalServerError = 500;
|
|
||||||
case NotImplemented = 501;
|
|
||||||
}
|
|
||||||
|
|
||||||
enum InputVarType: string {
|
|
||||||
case INTEGER = 'i';
|
|
||||||
case FLOAT = 'f';
|
|
||||||
case BOOLEAN = 'b';
|
|
||||||
case STRING = 's';
|
|
||||||
case ENUM = 'e';
|
|
||||||
}
|
|
||||||
|
|
||||||
//function ensureAdmin() {
|
|
||||||
// if (!isAdmin())
|
|
||||||
// forbidden();
|
|
||||||
// $this->skin->setRenderOptions(['inside_admin_interface' => true]);
|
|
||||||
//}
|
|
||||||
|
|
||||||
abstract class request_handler {
|
|
||||||
|
|
||||||
protected array $routerInput = [];
|
|
||||||
protected skin $skin;
|
|
||||||
|
|
||||||
public static function resolveAndDispatch() {
|
|
||||||
if (!in_array($_SERVER['REQUEST_METHOD'], ['POST', 'GET']))
|
|
||||||
self::httpError(HTTPCode::NotImplemented, 'Method '.$_SERVER['REQUEST_METHOD'].' not implemented');
|
|
||||||
|
|
||||||
$uri = $_SERVER['REQUEST_URI'];
|
|
||||||
if (($pos = strpos($uri, '?')) !== false)
|
|
||||||
$uri = substr($uri, 0, $pos);
|
|
||||||
|
|
||||||
$router = router::getInstance();
|
|
||||||
$route = $router->find($uri);
|
|
||||||
if ($route === null)
|
|
||||||
self::httpError(HTTPCode::NotFound, 'Route not found');
|
|
||||||
|
|
||||||
$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' : '');
|
|
||||||
|
|
||||||
$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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @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 {
|
|
||||||
$vartype = InputVarType::STRING;
|
|
||||||
$name = trim($var);
|
|
||||||
}
|
|
||||||
|
|
||||||
$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)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return $ret;
|
|
||||||
}
|
|
||||||
|
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static function ensureXhr(): void {
|
|
||||||
if (!self::isXhrRequest())
|
|
||||||
self::invalidRequest();
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
566
engine/skin.php
566
engine/skin.php
@ -1,566 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Twig\Error\LoaderError;
|
|
||||||
|
|
||||||
const RESOURCE_INTEGRITY_HASHES = ['sha256', 'sha384', 'sha512'];
|
|
||||||
|
|
||||||
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,
|
|
||||||
'logo_path_map' => [],
|
|
||||||
'logo_link_map' => [],
|
|
||||||
'is_index' => false,
|
|
||||||
'head_section' => null,
|
|
||||||
'articles_lang' => null,
|
|
||||||
'inside_admin_interface' => false,
|
|
||||||
];
|
|
||||||
public array $static = [];
|
|
||||||
protected array $styleNames = [];
|
|
||||||
protected array $svgDefs = [];
|
|
||||||
|
|
||||||
public \Twig\Environment $twig;
|
|
||||||
|
|
||||||
protected static ?skin $instance = null;
|
|
||||||
|
|
||||||
public static function getInstance(): skin {
|
|
||||||
if (self::$instance === null)
|
|
||||||
self::$instance = new skin();
|
|
||||||
return self::$instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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');
|
|
||||||
|
|
||||||
$env_options = [];
|
|
||||||
if (!is_null($cache_dir)) {
|
|
||||||
$env_options += [
|
|
||||||
'cache' => $cache_dir,
|
|
||||||
'auto_reload' => isDev()
|
|
||||||
];
|
|
||||||
}
|
|
||||||
$twig = new \Twig\Environment($twig_loader, $env_options);
|
|
||||||
$twig->addExtension(new \TwigAddons\MyExtension());
|
|
||||||
|
|
||||||
$this->twig = $twig;
|
|
||||||
}
|
|
||||||
|
|
||||||
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).'...';
|
|
||||||
}
|
|
||||||
$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;
|
|
||||||
|
|
||||||
case '@description':
|
|
||||||
case '@keywords':
|
|
||||||
$real_name = substr($key, 1);
|
|
||||||
$add_og_twitter($real_name, $value);
|
|
||||||
$real_meta[] = ['name' => $real_name, 'content' => $value];
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
if (str_starts_with($key, 'og:')) {
|
|
||||||
$real_meta[] = ['property' => $key, 'content' => $value];
|
|
||||||
} else {
|
|
||||||
logWarning("unsupported meta: $key => $value");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$this->meta = array_merge($this->meta, $real_meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isSet($key): bool {
|
|
||||||
return isset($this->vars[$key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isGlobalSet($key): bool {
|
|
||||||
return isset($this->globalVars[$key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getGlobal($key) {
|
|
||||||
return $this->isGlobalSet($key) ? $this->globalVars[$key] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function applyGlobals(): void {
|
|
||||||
if (!empty($this->globalVars) && !$this->globalsApplied) {
|
|
||||||
foreach ($this->globalVars as $key => $value)
|
|
||||||
$this->twig->addGlobal($key, $value);
|
|
||||||
$this->globalsApplied = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
if ($has_url)
|
|
||||||
$buf .= '<a class="bc-item" href="'.htmlescape($i['url']).'">';
|
|
||||||
else
|
|
||||||
$buf .= '<span class="bc-item">';
|
|
||||||
$buf .= htmlescape($i['text']);
|
|
||||||
|
|
||||||
if ($has_url)
|
|
||||||
$buf .= ' <span class="bc-arrow">'.$chevron.'</span></a>';
|
|
||||||
else
|
|
||||||
$buf .= '</span>';
|
|
||||||
|
|
||||||
return $buf;
|
|
||||||
}, $items));
|
|
||||||
$class = 'bc';
|
|
||||||
if ($mt)
|
|
||||||
$class .= ' mt';
|
|
||||||
return '<div class="'.$class.'"'.($style ? ' style="'.$style.'"' : '').'>'.$buf.'</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
$count = $opts['count'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$min_page = max(1, $page-2);
|
|
||||||
$max_page = min($pages, $page+2);
|
|
||||||
|
|
||||||
$pages_html = '';
|
|
||||||
$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)
|
|
||||||
$class .= ' is-page-cur';
|
|
||||||
$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::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::pageNavGetLink($pages, $link_template)).'" data-page="'.$pages.'" draggable="false">'.$pages.'</a>';
|
|
||||||
}
|
|
||||||
|
|
||||||
$pn_class = 'pn';
|
|
||||||
if ($pages < 2) {
|
|
||||||
$pn_class .= ' no-nav';
|
|
||||||
if (!$count) {
|
|
||||||
$pn_class .= ' no-results';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$html = <<<HTML
|
|
||||||
<div class="{$pn_class}">
|
|
||||||
<div class="pn-buttons clearfix">
|
|
||||||
{$pages_html}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
HTML;
|
|
||||||
|
|
||||||
return $html;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static function pageNavGetLink($page, $link_template) {
|
|
||||||
return is_callable($link_template) ? $link_template($page) : str_replace('{page}', $page, $link_template);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setRenderOptions(array $options): void {
|
|
||||||
$this->options = array_merge($this->options, $options);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function jsLink(string $name): string {
|
|
||||||
list (, $bname) = $this->getStaticNameParts($name);
|
|
||||||
if (isDev()) {
|
|
||||||
$href = '/js.php?name='.urlencode($bname).'&v='.time();
|
|
||||||
} else {
|
|
||||||
$href = '/dist-js/'.$bname.'.js?v='.$this->getStaticVersion($name);
|
|
||||||
}
|
|
||||||
return '<script src="'.$href.'" type="text/javascript"'.$this->getStaticIntegrityAttribute($name).'></script>';
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function cssLink(string $name, string $theme, &$bname = null): string {
|
|
||||||
list(, $bname) = $this->getStaticNameParts($name);
|
|
||||||
|
|
||||||
$config_name = 'css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css';
|
|
||||||
|
|
||||||
if (isDev()) {
|
|
||||||
$href = '/sass.php?name='.urlencode($bname).'&theme='.$theme.'&v='.time();
|
|
||||||
} else {
|
|
||||||
$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)).'"';
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,138 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
enum DeclensionCase: string {
|
|
||||||
case GEN = 'Gen';
|
|
||||||
case DAT = 'Dat';
|
|
||||||
case ACC = 'Acc';
|
|
||||||
case INS = 'Ins';
|
|
||||||
case ABL = 'Abl';
|
|
||||||
}
|
|
||||||
|
|
||||||
enum NameSex: int {
|
|
||||||
case MALE = 0;
|
|
||||||
case FEMALE = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
enum NameType: int {
|
|
||||||
case FIRST_NAME = 0;
|
|
||||||
case LAST_NAME = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
class StringsBase implements ArrayAccess {
|
|
||||||
protected array $data = [];
|
|
||||||
|
|
||||||
public function offsetSet(mixed $offset, mixed $value): void {
|
|
||||||
throw new RuntimeException('Not implemented');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function offsetExists(mixed $offset): bool {
|
|
||||||
return isset($this->data[$offset]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function offsetUnset(mixed $offset): void {
|
|
||||||
throw new RuntimeException('Not implemented');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function offsetGet(mixed $offset): mixed {
|
|
||||||
if (!isset($this->data[$offset])) {
|
|
||||||
logError(__METHOD__.': '.$offset.' not found');
|
|
||||||
return '{'.$offset.'}';
|
|
||||||
}
|
|
||||||
return $this->data[$offset];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function get(string $key, mixed ...$sprintf_args): string|array {
|
|
||||||
$val = $this[$key];
|
|
||||||
if (!empty($sprintf_args)) {
|
|
||||||
array_unshift($sprintf_args, $val);
|
|
||||||
return call_user_func_array('sprintf', $sprintf_args);
|
|
||||||
} else {
|
|
||||||
return $val;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function num(string $key, int $num, array$opts = []) {
|
|
||||||
$s = $this[$key];
|
|
||||||
|
|
||||||
$default_opts = [
|
|
||||||
'format' => true,
|
|
||||||
'format_delim' => ' ',
|
|
||||||
'lang' => 'ru',
|
|
||||||
];
|
|
||||||
$opts = array_merge($default_opts, $opts);
|
|
||||||
|
|
||||||
switch ($opts['lang']) {
|
|
||||||
case 'ru':
|
|
||||||
$n = $num % 100;
|
|
||||||
if ($n > 19)
|
|
||||||
$n %= 10;
|
|
||||||
|
|
||||||
if ($n == 1) {
|
|
||||||
$word = 0;
|
|
||||||
} elseif ($n >= 2 && $n <= 4) {
|
|
||||||
$word = 1;
|
|
||||||
} elseif ($num == 0 && count($s) == 4) {
|
|
||||||
$word = 3;
|
|
||||||
} else {
|
|
||||||
$word = 2;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
if ($num == 0 && count($s) == 4) {
|
|
||||||
$word = 3;
|
|
||||||
} else {
|
|
||||||
$word = $num == 1 ? 0 : 1;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if zero
|
|
||||||
if ($word == 3) {
|
|
||||||
return $s[3];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_callable($opts['format'])) {
|
|
||||||
$num = $opts['format']($num);
|
|
||||||
} else if ($opts['format'] === true) {
|
|
||||||
$num = formatNumber($num, $opts['format_delim']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sprintf($s[$word], $num);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Strings extends StringsBase {
|
|
||||||
private static ?Strings $instance = null;
|
|
||||||
protected array $loadedPackages = [];
|
|
||||||
|
|
||||||
private function __construct() {}
|
|
||||||
protected function __clone() {}
|
|
||||||
|
|
||||||
public static function getInstance(): self {
|
|
||||||
if (is_null(self::$instance))
|
|
||||||
self::$instance = new self();
|
|
||||||
return self::$instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function load(string ...$pkgs): array {
|
|
||||||
$keys = [];
|
|
||||||
foreach ($pkgs as $name) {
|
|
||||||
$raw = yaml_parse_file(APP_ROOT.'/strings/'.$name.'.yaml');
|
|
||||||
$this->data = array_merge($this->data, $raw);
|
|
||||||
$keys = array_merge($keys, array_keys($raw));
|
|
||||||
$this->loadedPackages[$name] = true;
|
|
||||||
}
|
|
||||||
return $keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function search(string $regexp): array {
|
|
||||||
return preg_grep($regexp, array_keys($this->data));
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,220 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
class ServicesHandler extends request_handler {
|
|
||||||
|
|
||||||
public function GET_robots_txt() {
|
|
||||||
$txt = <<<TXT
|
|
||||||
User-agent: *
|
|
||||||
Disallow: /admin/
|
|
||||||
TXT;
|
|
||||||
|
|
||||||
header('Content-Type: text/plain');
|
|
||||||
echo $txt;
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function GET_latest() {
|
|
||||||
global $config;
|
|
||||||
list($lang) = $this->input('lang');
|
|
||||||
if (!isset($config['book_versions'][$lang]))
|
|
||||||
self::notFound();
|
|
||||||
self::redirect("https://files.4in1.ws/4in1-{$lang}.pdf?{$config['book_versions'][$lang]}",
|
|
||||||
code: HTTPCode::Found);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
BIN
htdocs/img/eagle.jpg
Normal file
BIN
htdocs/img/eagle.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
BIN
htdocs/img/simurgh-big.jpg
Normal file
BIN
htdocs/img/simurgh-big.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 63 KiB |
BIN
htdocs/img/simurgh.jpg
Normal file
BIN
htdocs/img/simurgh.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
@ -1,5 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
require_once __DIR__.'/../init.php';
|
require_once __DIR__.'/../src/init.php';
|
||||||
|
|
||||||
request_handler::resolveAndDispatch();
|
engine\http\RequestHandler::resolveAndDispatch();
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
require __DIR__.'/../init.php';
|
require __DIR__.'/../src/init.php';
|
||||||
global $config;
|
global $config;
|
||||||
|
|
||||||
$name = $_REQUEST['name'] ?? '';
|
$name = $_REQUEST['name'] ?? '';
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
require __DIR__.'/../init.php';
|
require __DIR__.'/../src/init.php';
|
||||||
|
|
||||||
$name = $_GET['name'] ?? '';
|
$name = $_GET['name'] ?? '';
|
||||||
$theme = $_GET['theme'] ?? '';
|
$theme = $_GET['theme'] ?? '';
|
||||||
|
@ -1,62 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
enum BookCategory: string {
|
|
||||||
case BOOKS = 'books';
|
|
||||||
case MISC = 'misc';
|
|
||||||
}
|
|
@ -1,7 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
enum BookFileType: string {
|
|
||||||
case NONE = 'none';
|
|
||||||
case BOOK = 'book';
|
|
||||||
case ARTICLE = 'article';
|
|
||||||
}
|
|
@ -1,87 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,21 +0,0 @@
|
|||||||
<?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; }
|
|
||||||
}
|
|
@ -1,7 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
enum FilesCollection: string {
|
|
||||||
case WilliamFriedman = 'wff';
|
|
||||||
case MercureDeFrance = 'mdf';
|
|
||||||
case Baconiana = 'baconiana';
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
trait FilesItemSizeTrait {
|
|
||||||
public int $size;
|
|
||||||
public function getSize(): ?int { return $this->isFile() ? $this->size : null; }
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
enum FilesItemType: string {
|
|
||||||
case FILE = 'file';
|
|
||||||
case FOLDER = 'folder';
|
|
||||||
}
|
|
@ -1,19 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
47
lib/Page.php
47
lib/Page.php
@ -1,47 +0,0 @@
|
|||||||
<?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
129
lib/Post.php
@ -1,129 +0,0 @@
|
|||||||
<?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());
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,16 +0,0 @@
|
|||||||
<?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;
|
|
||||||
|
|
||||||
}
|
|
170
lib/Upload.php
170
lib/Upload.php
@ -1,170 +0,0 @@
|
|||||||
<?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)));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,53 +0,0 @@
|
|||||||
<?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'
|
|
||||||
]
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
390
lib/files.php
390
lib/files.php
@ -1,390 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Sphinx\SphinxClient;
|
|
||||||
|
|
||||||
class files {
|
|
||||||
|
|
||||||
const string WFF_ARCHIVE_SPHINX_RTINDEX = 'wff_collection';
|
|
||||||
const string MDF_ARCHIVE_SPHINX_RTINDEX = 'mdf_archive';
|
|
||||||
const string BACONIANA_ARCHIVE_SPHINX_RTINDEX = 'baconiana_archive';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string $table
|
|
||||||
* @param string $field_id
|
|
||||||
* @param int[] $ids
|
|
||||||
* @param string[] $keywords Must already be lower-cased
|
|
||||||
* @param int $before
|
|
||||||
* @param int $after
|
|
||||||
* @return array
|
|
||||||
*/
|
|
||||||
public static function _get_text_excerpts(string $table, string $field_id, array $ids, array $keywords, int $before, int $after) {
|
|
||||||
$results = [];
|
|
||||||
foreach ($ids as $id)
|
|
||||||
$results[$id] = null;
|
|
||||||
|
|
||||||
$db = DB();
|
|
||||||
|
|
||||||
$dynamic_sql_parts = [];
|
|
||||||
$combined_parts = [];
|
|
||||||
foreach ($keywords as $keyword) {
|
|
||||||
$part = "LOCATE('".$db->escape($keyword)."', text)";
|
|
||||||
$dynamic_sql_parts[] = $part;
|
|
||||||
}
|
|
||||||
if (count($dynamic_sql_parts) > 1) {
|
|
||||||
foreach ($dynamic_sql_parts as $part)
|
|
||||||
$combined_parts[] = "IF({$part} > 0, {$part}, CHAR_LENGTH(text) + 1)";
|
|
||||||
$combined_parts = implode(', ', $combined_parts);
|
|
||||||
$combined_parts = 'LEAST('.$combined_parts.')';
|
|
||||||
} else {
|
|
||||||
$combined_parts = "IF({$dynamic_sql_parts[0]} > 0, {$dynamic_sql_parts[0]}, CHAR_LENGTH(text) + 1)";
|
|
||||||
}
|
|
||||||
|
|
||||||
$total = $before + $after;
|
|
||||||
$sql = "SELECT
|
|
||||||
{$field_id} AS id,
|
|
||||||
GREATEST(
|
|
||||||
1,
|
|
||||||
{$combined_parts} - {$before}
|
|
||||||
) AS excerpt_start_index,
|
|
||||||
SUBSTRING(
|
|
||||||
text,
|
|
||||||
GREATEST(
|
|
||||||
1,
|
|
||||||
{$combined_parts} - {$before}
|
|
||||||
),
|
|
||||||
LEAST(
|
|
||||||
CHAR_LENGTH(text),
|
|
||||||
{$total} + {$combined_parts} - GREATEST(1, {$combined_parts} - {$before})
|
|
||||||
)
|
|
||||||
) AS excerpt
|
|
||||||
FROM
|
|
||||||
{$table}
|
|
||||||
WHERE
|
|
||||||
{$field_id} IN (".implode(',', $ids).")";
|
|
||||||
|
|
||||||
$q = $db->query($sql);
|
|
||||||
while ($row = $db->fetch($q)) {
|
|
||||||
$results[$row['id']] = [
|
|
||||||
'excerpt' => preg_replace('/\s+/', ' ', $row['excerpt']),
|
|
||||||
'index' => (int)$row['excerpt_start_index']
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $results;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function _search(string $index,
|
|
||||||
string $q,
|
|
||||||
int $offset,
|
|
||||||
int $count,
|
|
||||||
callable $items_getter,
|
|
||||||
?callable $sphinx_client_setup = null): array {
|
|
||||||
$query_filtered = sphinx::mkquery($q);
|
|
||||||
|
|
||||||
$cl = sphinx::getClient();
|
|
||||||
$cl->setLimits($offset, $count);
|
|
||||||
|
|
||||||
$cl->setMatchMode(Sphinx\SphinxClient::SPH_MATCH_EXTENDED);
|
|
||||||
|
|
||||||
if (is_callable($sphinx_client_setup))
|
|
||||||
$sphinx_client_setup($cl);
|
|
||||||
else {
|
|
||||||
$cl->setRankingMode(Sphinx\SphinxClient::SPH_RANK_PROXIMITY_BM25);
|
|
||||||
$cl->setSortMode(Sphinx\SphinxClient::SPH_SORT_RELEVANCE);
|
|
||||||
}
|
|
||||||
|
|
||||||
// run search
|
|
||||||
$final_query = "$query_filtered";
|
|
||||||
$result = $cl->query($final_query, $index);
|
|
||||||
$error = $cl->getLastError();
|
|
||||||
$warning = $cl->getLastWarning();
|
|
||||||
if ($error)
|
|
||||||
logError(__FUNCTION__, $error);
|
|
||||||
if ($warning)
|
|
||||||
logWarning(__FUNCTION__, $warning);
|
|
||||||
if ($result === false)
|
|
||||||
return ['count' => 0, 'items' => []];
|
|
||||||
|
|
||||||
$total_found = (int)$result['total_found'];
|
|
||||||
|
|
||||||
$items = [];
|
|
||||||
if (!empty($result['matches']))
|
|
||||||
$items = $items_getter($result['matches']);
|
|
||||||
|
|
||||||
return ['count' => $total_found, 'items' => $items];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param int $folder_id
|
|
||||||
* @param bool $with_parents
|
|
||||||
* @return WFFCollectionItem|WFFCollectionItem[]|null
|
|
||||||
*/
|
|
||||||
public static function wff_get_folder(int $folder_id, bool $with_parents = false): WFFCollectionItem|array|null {
|
|
||||||
$db = DB();
|
|
||||||
$q = $db->query("SELECT * FROM wff_collection WHERE id=?", $folder_id);
|
|
||||||
if (!$db->numRows($q))
|
|
||||||
return null;
|
|
||||||
$item = new WFFCollectionItem($db->fetch($q));
|
|
||||||
if (!$item->isFolder())
|
|
||||||
return null;
|
|
||||||
if ($with_parents) {
|
|
||||||
$items = [$item];
|
|
||||||
if ($item->parentId) {
|
|
||||||
$parents = self::wff_get_folder($item->parentId, true);
|
|
||||||
if ($parents !== null)
|
|
||||||
$items = array_merge($items, $parents);
|
|
||||||
}
|
|
||||||
return $items;
|
|
||||||
}
|
|
||||||
return $item;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param int|int[]|null $parent_id
|
|
||||||
* @return array
|
|
||||||
*/
|
|
||||||
public static function wff_get(int|array|null $parent_id = null) {
|
|
||||||
$db = DB();
|
|
||||||
|
|
||||||
$where = [];
|
|
||||||
$args = [];
|
|
||||||
|
|
||||||
if (!is_null($parent_id)) {
|
|
||||||
if (is_int($parent_id)) {
|
|
||||||
$where[] = "parent_id=?";
|
|
||||||
$args[] = $parent_id;
|
|
||||||
} else {
|
|
||||||
$where[] = "parent_id IN (".implode(", ", $parent_id).")";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$sql = "SELECT * FROM wff_collection";
|
|
||||||
if (!empty($where))
|
|
||||||
$sql .= " WHERE ".implode(" AND ", $where);
|
|
||||||
$sql .= " ORDER BY title";
|
|
||||||
$q = $db->query($sql, ...$args);
|
|
||||||
|
|
||||||
return array_map('WFFCollectionItem::create_instance', $db->fetchAll($q));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param int[] $ids
|
|
||||||
* @return WFFCollectionItem[]
|
|
||||||
*/
|
|
||||||
public static function wff_get_by_id(array $ids): array {
|
|
||||||
$db = DB();
|
|
||||||
$q = $db->query("SELECT * FROM wff_collection WHERE id IN (".implode(',', $ids).")");
|
|
||||||
return array_map('WFFCollectionItem::create_instance', $db->fetchAll($q));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function wff_search(string $q, int $offset = 0, int $count = 0): array {
|
|
||||||
return self::_search(self::WFF_ARCHIVE_SPHINX_RTINDEX, $q, $offset, $count,
|
|
||||||
items_getter: function($matches) {
|
|
||||||
return self::wff_get_by_id(array_keys($matches));
|
|
||||||
},
|
|
||||||
sphinx_client_setup: function(SphinxClient $cl) {
|
|
||||||
$cl->setFieldWeights([
|
|
||||||
'title' => 50,
|
|
||||||
'document_id' => 60,
|
|
||||||
]);
|
|
||||||
$cl->setRankingMode(Sphinx\SphinxClient::SPH_RANK_PROXIMITY_BM25);
|
|
||||||
$cl->setSortMode(Sphinx\SphinxClient::SPH_SORT_EXTENDED, '@relevance DESC, is_folder DESC');
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function wff_reindex(): void {
|
|
||||||
sphinx::execute("TRUNCATE RTINDEX ".self::WFF_ARCHIVE_SPHINX_RTINDEX);
|
|
||||||
$db = DB();
|
|
||||||
$q = $db->query("SELECT * FROM wff_collection");
|
|
||||||
while ($row = $db->fetch($q)) {
|
|
||||||
$item = new WFFCollectionItem($row);
|
|
||||||
$text = '';
|
|
||||||
if ($item->isFile()) {
|
|
||||||
$text_q = $db->query("SELECT text FROM wff_texts WHERE wff_id=?", $item->id);
|
|
||||||
if ($db->numRows($text_q))
|
|
||||||
$text = $db->result($text_q);
|
|
||||||
}
|
|
||||||
sphinx::execute("INSERT INTO ".self::WFF_ARCHIVE_SPHINX_RTINDEX." (id, document_id, title, text, is_folder, parent_id) VALUES (?, ?, ?, ?, ?, ?)",
|
|
||||||
$item->id, $item->getDocumentId(), $item->title, $text, (int)$item->isFolder(), $item->parentId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function wff_get_text_excerpts(array $ids, array $keywords, int $before = 50, int $after = 40): array {
|
|
||||||
return self::_get_text_excerpts('wff_texts', 'wff_id', $ids, $keywords, $before, $after);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return MDFCollectionItem[]
|
|
||||||
*/
|
|
||||||
public static function mdf_get(): array {
|
|
||||||
$db = DB();
|
|
||||||
$q = $db->query("SELECT * FROM mdf_collection ORDER BY `date`");
|
|
||||||
return array_map('MDFCollectionItem::create_instance', $db->fetchAll($q));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param int[] $ids
|
|
||||||
* @return MDFCollectionItem[]
|
|
||||||
*/
|
|
||||||
public static function mdf_get_by_id(array $ids): array {
|
|
||||||
$db = DB();
|
|
||||||
$q = $db->query("SELECT * FROM mdf_collection WHERE id IN (".implode(',', $ids).")");
|
|
||||||
return array_map('MDFCollectionItem::create_instance', $db->fetchAll($q));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function mdf_search(string $q, int $offset = 0, int $count = 0): array {
|
|
||||||
return self::_search(self::MDF_ARCHIVE_SPHINX_RTINDEX, $q, $offset, $count,
|
|
||||||
items_getter: function($matches) {
|
|
||||||
return self::mdf_get_by_id(array_keys($matches));
|
|
||||||
},
|
|
||||||
sphinx_client_setup: function(SphinxClient $cl) {
|
|
||||||
$cl->setFieldWeights([
|
|
||||||
'date' => 10,
|
|
||||||
'issue' => 9,
|
|
||||||
'text' => 8
|
|
||||||
]);
|
|
||||||
$cl->setRankingMode(Sphinx\SphinxClient::SPH_RANK_PROXIMITY_BM25);
|
|
||||||
$cl->setSortMode(Sphinx\SphinxClient::SPH_SORT_RELEVANCE);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function mdf_reindex(): void {
|
|
||||||
sphinx::execute("TRUNCATE RTINDEX ".self::MDF_ARCHIVE_SPHINX_RTINDEX);
|
|
||||||
$db = DB();
|
|
||||||
$mdf = self::mdf_get();
|
|
||||||
foreach ($mdf as $item) {
|
|
||||||
$text = $db->result($db->query("SELECT text FROM mdf_texts WHERE mdf_id=?", $item->id));
|
|
||||||
sphinx::execute("INSERT INTO ".self::MDF_ARCHIVE_SPHINX_RTINDEX." (id, volume, issue, date, text) VALUES (?, ?, ?, ?, ?)",
|
|
||||||
$item->id, $item->volume, (string)$item->issue, $item->getHumanFriendlyDate(), $text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function mdf_get_text_excerpts(array $ids, array $keywords, int $before = 50, int $after = 40): array {
|
|
||||||
return self::_get_text_excerpts('mdf_texts', 'mdf_id', $ids, $keywords, $before, $after);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BaconianaCollectionItem[]
|
|
||||||
*/
|
|
||||||
public static function baconiana_get(?int $parent_id = 0): array {
|
|
||||||
$db = DB();
|
|
||||||
$sql = "SELECT * FROM baconiana_collection";
|
|
||||||
if ($parent_id !== null)
|
|
||||||
$sql .= " WHERE parent_id='".$db->escape($parent_id)."'";
|
|
||||||
$sql .= " ORDER BY type, year, id";
|
|
||||||
$q = $db->query($sql);
|
|
||||||
return array_map('BaconianaCollectionItem::create_instance', $db->fetchAll($q));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param int[] $ids
|
|
||||||
* @return BaconianaCollectionItem[]
|
|
||||||
*/
|
|
||||||
public static function baconiana_get_by_id(array $ids): array {
|
|
||||||
$db = DB();
|
|
||||||
$q = $db->query("SELECT * FROM baconiana_collection WHERE id IN (".implode(',', $ids).")");
|
|
||||||
return array_map('BaconianaCollectionItem::create_instance', $db->fetchAll($q));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param int $folder_id
|
|
||||||
* @param bool $with_parents
|
|
||||||
* @return BaconianaCollectionItem|BaconianaCollectionItem[]|null
|
|
||||||
*/
|
|
||||||
public static function baconiana_get_folder(int $folder_id, bool $with_parents = false): WFFCollectionItem|array|null {
|
|
||||||
$db = DB();
|
|
||||||
$q = $db->query("SELECT * FROM baconiana_collection WHERE id=?", $folder_id);
|
|
||||||
if (!$db->numRows($q))
|
|
||||||
return null;
|
|
||||||
$item = new BaconianaCollectionItem($db->fetch($q));
|
|
||||||
if (!$item->isFolder())
|
|
||||||
return null;
|
|
||||||
if ($with_parents) {
|
|
||||||
$items = [$item];
|
|
||||||
if ($item->parentId) {
|
|
||||||
$parents = self::baconiana_get_folder($item->parentId, true);
|
|
||||||
if ($parents !== null)
|
|
||||||
$items = array_merge($items, $parents);
|
|
||||||
}
|
|
||||||
return $items;
|
|
||||||
}
|
|
||||||
return $item;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function baconiana_search(string $q, int $offset = 0, int $count = 0): array {
|
|
||||||
return self::_search(self::BACONIANA_ARCHIVE_SPHINX_RTINDEX, $q, $offset, $count,
|
|
||||||
items_getter: function($matches) {
|
|
||||||
return self::baconiana_get_by_id(array_keys($matches));
|
|
||||||
},
|
|
||||||
sphinx_client_setup: function(SphinxClient $cl) {
|
|
||||||
$cl->setFieldWeights([
|
|
||||||
'year' => 10,
|
|
||||||
'issues' => 9,
|
|
||||||
'text' => 8
|
|
||||||
]);
|
|
||||||
$cl->setRankingMode(Sphinx\SphinxClient::SPH_RANK_PROXIMITY_BM25);
|
|
||||||
$cl->setSortMode(Sphinx\SphinxClient::SPH_SORT_RELEVANCE);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function baconiana_reindex(): void {
|
|
||||||
sphinx::execute("TRUNCATE RTINDEX ".self::BACONIANA_ARCHIVE_SPHINX_RTINDEX);
|
|
||||||
$db = DB();
|
|
||||||
$baconiana = self::baconiana_get(null);
|
|
||||||
foreach ($baconiana as $item) {
|
|
||||||
$text_q = $db->query("SELECT text FROM baconiana_texts WHERE bcn_id=?", $item->id);
|
|
||||||
if (!$db->numRows($text_q))
|
|
||||||
continue;
|
|
||||||
$text = $db->result($text_q);
|
|
||||||
sphinx::execute("INSERT INTO ".self::BACONIANA_ARCHIVE_SPHINX_RTINDEX." (id, title, year, text) VALUES (?, ?, ?, ?)",
|
|
||||||
$item->id, "$item->year ($item->issues)", $item->year, $text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function baconiana_get_text_excerpts(array $ids, array $keywords, int $before = 50, int $after = 40): array {
|
|
||||||
return self::_get_text_excerpts('baconiana_texts', 'bcn_id', $ids, $keywords, $before, $after);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BookItem[]
|
|
||||||
*/
|
|
||||||
public static function books_get(int $parent_id = 0, BookCategory $category = BookCategory::BOOKS): array {
|
|
||||||
$db = DB();
|
|
||||||
|
|
||||||
if ($category == BookCategory::BOOKS) {
|
|
||||||
$order_by = "type, ".($parent_id != 0 ? 'year, ': '')."author, title";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
$order_by = "type, title";
|
|
||||||
|
|
||||||
$q = $db->query("SELECT * FROM books WHERE category=? AND parent_id=? ORDER BY $order_by",
|
|
||||||
$category->value, $parent_id);
|
|
||||||
return array_map('BookItem::create_instance', $db->fetchAll($q));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function books_get_folder(int $id, bool $with_parents = false): BookItem|array|null {
|
|
||||||
$db = DB();
|
|
||||||
$q = $db->query("SELECT * FROM books WHERE id=?", $id);
|
|
||||||
if (!$db->numRows($q))
|
|
||||||
return null;
|
|
||||||
$item = new BookItem($db->fetch($q));
|
|
||||||
if (!$item->isFolder())
|
|
||||||
return null;
|
|
||||||
if ($with_parents) {
|
|
||||||
$items = [$item];
|
|
||||||
if ($item->parentId) {
|
|
||||||
$parents = self::books_get_folder($item->parentId, true);
|
|
||||||
if ($parents !== null)
|
|
||||||
$items = array_merge($items, $parents);
|
|
||||||
}
|
|
||||||
return $items;
|
|
||||||
}
|
|
||||||
return $item;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,39 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
class pages {
|
|
||||||
|
|
||||||
public static function add(array $data): bool {
|
|
||||||
$db = DB();
|
|
||||||
$data['ts'] = time();
|
|
||||||
$data['html'] = markup::markdownToHtml($data['md']);
|
|
||||||
if (!$db->insert('pages', $data))
|
|
||||||
return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Page[]
|
|
||||||
*/
|
|
||||||
public static function getAll(): array {
|
|
||||||
$db = DB();
|
|
||||||
return array_map('Page::create_instance', $db->fetchAll($db->query("SELECT * FROM pages")));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
153
lib/posts.php
153
lib/posts.php
@ -1,153 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
class posts {
|
|
||||||
|
|
||||||
public static function getCount(bool $include_hidden = false): int {
|
|
||||||
$db = DB();
|
|
||||||
$sql = "SELECT COUNT(*) FROM posts";
|
|
||||||
if (!$include_hidden) {
|
|
||||||
$sql .= " WHERE visible=1";
|
|
||||||
}
|
|
||||||
return (int)$db->result($db->query($sql));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Post[]
|
|
||||||
*/
|
|
||||||
public static function getList(int $offset = 0,
|
|
||||||
int $count = -1,
|
|
||||||
bool $include_hidden = false,
|
|
||||||
?PostLanguage $filter_by_lang = null
|
|
||||||
): array {
|
|
||||||
$db = DB();
|
|
||||||
$sql = "SELECT * FROM posts";
|
|
||||||
if (!$include_hidden)
|
|
||||||
$sql .= " WHERE visible=1";
|
|
||||||
$sql .= " ORDER BY `date` DESC";
|
|
||||||
if ($offset != 0 || $count != -1)
|
|
||||||
$sql .= " LIMIT $offset, $count";
|
|
||||||
$q = $db->query($sql);
|
|
||||||
$posts = [];
|
|
||||||
while ($row = $db->fetch($q)) {
|
|
||||||
$posts[$row['id']] = $row;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($posts)) {
|
|
||||||
foreach ($posts as &$post)
|
|
||||||
$post = new Post($post);
|
|
||||||
$q = $db->query("SELECT * FROM posts_texts WHERE post_id IN (".implode(',', array_keys($posts)).")");
|
|
||||||
while ($row = $db->fetch($q)) {
|
|
||||||
$posts[$row['post_id']]->registerText(new PostText($row));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($filter_by_lang !== null)
|
|
||||||
$posts = array_filter($posts, fn(Post $post) => $post->hasLang($filter_by_lang));
|
|
||||||
|
|
||||||
return array_values($posts);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function add(array $data = []): ?Post {
|
|
||||||
$db = DB();
|
|
||||||
if (!$db->insert('posts', $data))
|
|
||||||
return null;
|
|
||||||
return self::get($db->insertId());
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function delete(Post $post): void {
|
|
||||||
$db = DB();
|
|
||||||
$db->query("DELETE FROM posts WHERE id=?", $post->id);
|
|
||||||
|
|
||||||
$text_ids = [];
|
|
||||||
$q = $db->query("SELECT id FROM posts_texts WHERE post_id=?", $post->id);
|
|
||||||
while ($row = $db->fetch($q))
|
|
||||||
$text_ids = $row['id'];
|
|
||||||
previous_texts::delete(PreviousText::TYPE_POST_TEXT, $text_ids);
|
|
||||||
|
|
||||||
$db->query("DELETE FROM posts_texts WHERE post_id=?", $post->id);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPostsById(array $ids, bool $flat = false): array {
|
|
||||||
if (empty($ids)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$db = DB();
|
|
||||||
$posts = array_fill_keys($ids, null);
|
|
||||||
|
|
||||||
$q = $db->query("SELECT * FROM posts WHERE id IN(".implode(',', $ids).")");
|
|
||||||
|
|
||||||
while ($row = $db->fetch($q)) {
|
|
||||||
$posts[(int)$row['id']] = new Post($row);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($flat) {
|
|
||||||
$list = [];
|
|
||||||
foreach ($ids as $id) {
|
|
||||||
$list[] = $posts[$id];
|
|
||||||
}
|
|
||||||
unset($posts);
|
|
||||||
return $list;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $posts;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPostTextsById(array $ids, bool $flat = false): array {
|
|
||||||
if (empty($ids)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$db = DB();
|
|
||||||
$posts = array_fill_keys($ids, null);
|
|
||||||
|
|
||||||
$q = $db->query("SELECT * FROM posts_texts WHERE id IN(".implode(',', $ids).")");
|
|
||||||
|
|
||||||
while ($row = $db->fetch($q)) {
|
|
||||||
$posts[(int)$row['id']] = new PostText($row);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($flat) {
|
|
||||||
$list = [];
|
|
||||||
foreach ($ids as $id) {
|
|
||||||
$list[] = $posts[$id];
|
|
||||||
}
|
|
||||||
unset($posts);
|
|
||||||
return $list;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $posts;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Upload $upload
|
|
||||||
* @return PostText[] Array of PostTexts that includes specified upload
|
|
||||||
*/
|
|
||||||
public static function getTextsWithUpload(Upload $upload): array {
|
|
||||||
$db = DB();
|
|
||||||
$q = $db->query("SELECT id FROM posts_texts WHERE md LIKE '%{image:{$upload->randomId}%'");
|
|
||||||
$ids = [];
|
|
||||||
while ($row = $db->fetch($q))
|
|
||||||
$ids[] = (int)$row['id'];
|
|
||||||
return self::getPostTextsById($ids, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
161
lib/uploads.php
161
lib/uploads.php
@ -1,161 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
class uploads {
|
|
||||||
|
|
||||||
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"));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function isExtensionAllowed(string $ext): bool {
|
|
||||||
return in_array($ext, self::ALLOWED_EXTENSIONS);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function add(string $tmp_name,
|
|
||||||
string $name,
|
|
||||||
string $note_en = '',
|
|
||||||
string $note_ru = '',
|
|
||||||
string $source_url = ''): ?int {
|
|
||||||
global $config;
|
|
||||||
|
|
||||||
$name = sanitizeFilename($name);
|
|
||||||
if (!$name)
|
|
||||||
$name = 'file';
|
|
||||||
|
|
||||||
$random_id = self::_getNewUploadRandomId();
|
|
||||||
$size = filesize($tmp_name);
|
|
||||||
$is_image = detectImageType($tmp_name) !== false;
|
|
||||||
$image_w = 0;
|
|
||||||
$image_h = 0;
|
|
||||||
if ($is_image) {
|
|
||||||
list($image_w, $image_h) = getimagesize($tmp_name);
|
|
||||||
}
|
|
||||||
|
|
||||||
$db = DB();
|
|
||||||
if (!$db->insert('uploads', [
|
|
||||||
'random_id' => $random_id,
|
|
||||||
'ts' => time(),
|
|
||||||
'name' => $name,
|
|
||||||
'size' => $size,
|
|
||||||
'image' => (int)$is_image,
|
|
||||||
'image_w' => $image_w,
|
|
||||||
'image_h' => $image_h,
|
|
||||||
'note_ru' => $note_ru,
|
|
||||||
'note_en' => $note_en,
|
|
||||||
'downloads' => 0,
|
|
||||||
'source_url' => $source_url,
|
|
||||||
])) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$id = $db->insertId();
|
|
||||||
|
|
||||||
$dir = $config['uploads_dir'].'/'.$random_id;
|
|
||||||
$path = $dir.'/'.$name;
|
|
||||||
|
|
||||||
mkdir($dir);
|
|
||||||
chmod($dir, 0775); // g+w
|
|
||||||
|
|
||||||
rename($tmp_name, $path);
|
|
||||||
setperm($path);
|
|
||||||
|
|
||||||
return $id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function delete(int $id): bool {
|
|
||||||
$upload = self::get($id);
|
|
||||||
if (!$upload)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
$db = DB();
|
|
||||||
$db->query("DELETE FROM uploads WHERE id=?", $id);
|
|
||||||
|
|
||||||
rrmdir($upload->getDirectory());
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Upload[]
|
|
||||||
*/
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function get(int $id): ?Upload {
|
|
||||||
$db = DB();
|
|
||||||
$q = $db->query("SELECT * FROM uploads WHERE id=?", $id);
|
|
||||||
if ($db->numRows($q)) {
|
|
||||||
return new Upload($db->fetch($q));
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string[] $ids
|
|
||||||
* @param bool $flat
|
|
||||||
* @return Upload[]
|
|
||||||
*/
|
|
||||||
public static function getUploadsByRandomId(array $ids, bool $flat = false): array {
|
|
||||||
if (empty($ids)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$db = DB();
|
|
||||||
$uploads = array_fill_keys($ids, null);
|
|
||||||
|
|
||||||
$q = $db->query("SELECT * FROM uploads WHERE random_id IN('".implode('\',\'', array_map([$db, 'escape'], $ids))."')");
|
|
||||||
|
|
||||||
while ($row = $db->fetch($q)) {
|
|
||||||
$uploads[$row['random_id']] = new Upload($row);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($flat) {
|
|
||||||
$list = [];
|
|
||||||
foreach ($ids as $id) {
|
|
||||||
$list[] = $uploads[$id];
|
|
||||||
}
|
|
||||||
unset($uploads);
|
|
||||||
return $list;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $uploads;
|
|
||||||
}
|
|
||||||
|
|
||||||
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)) {
|
|
||||||
return new Upload($db->fetch($q));
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)) {
|
|
||||||
return new Upload($db->fetch($q));
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function _getNewUploadRandomId(): string {
|
|
||||||
$db = DB();
|
|
||||||
do {
|
|
||||||
$random_id = strgen(8);
|
|
||||||
} while ($db->numRows($db->query("SELECT id FROM uploads WHERE random_id=?", $random_id)) > 0);
|
|
||||||
return $random_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,9 +0,0 @@
|
|||||||
<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>
|
|
56
src/engine/GlobalContext.php
Normal file
56
src/engine/GlobalContext.php
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine;
|
||||||
|
|
||||||
|
use engine\skin\BaseSkin;
|
||||||
|
|
||||||
|
class GlobalContext
|
||||||
|
{
|
||||||
|
public readonly string $project;
|
||||||
|
public readonly http\RequestHandler $requestHandler;
|
||||||
|
public readonly logging\Logger $logger;
|
||||||
|
public readonly bool $isDevelopmentEnvironment;
|
||||||
|
|
||||||
|
private array $setProperties = [];
|
||||||
|
|
||||||
|
private static ?GlobalContext $instance = null;
|
||||||
|
|
||||||
|
private function __construct() {}
|
||||||
|
|
||||||
|
public static function getInstance(): static {
|
||||||
|
if (is_null(self::$instance))
|
||||||
|
self::$instance = new static();
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSkin(): BaseSkin {
|
||||||
|
return $this->requestHandler->skin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStrings(): lang\Strings {
|
||||||
|
return $this->requestHandler->skin->strings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setProject(string $project) {
|
||||||
|
$this->setProperty('project', $project);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRequestHandler(http\RequestHandler $requestHandler) {
|
||||||
|
$this->setProperty('requestHandler', $requestHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLogger(logging\Logger $logger) {
|
||||||
|
$this->setProperty('logger', $logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIsDevelopmentEnvironment(bool $isDevelopmentEnvironment) {
|
||||||
|
$this->setProperty('isDevelopmentEnvironment', $isDevelopmentEnvironment);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setProperty(string $name, mixed $value) {
|
||||||
|
if (isset($this->setProperties[$name]))
|
||||||
|
throw new \RuntimeException("$name can only be set once");
|
||||||
|
$this->$name = $value;
|
||||||
|
$this->setProperties[$name] = true;
|
||||||
|
}
|
||||||
|
}
|
@ -1,19 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
enum ModelFieldType {
|
namespace engine;
|
||||||
case STRING;
|
|
||||||
case INTEGER;
|
|
||||||
case FLOAT;
|
|
||||||
case ARRAY;
|
|
||||||
case BOOLEAN;
|
|
||||||
case JSON;
|
|
||||||
case SERIALIZED;
|
|
||||||
case BITFIELD;
|
|
||||||
case BACKED_ENUM;
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract class model {
|
use ReflectionClass;
|
||||||
|
use ReflectionProperty;
|
||||||
|
|
||||||
|
abstract class Model
|
||||||
|
{
|
||||||
const DB_TABLE = null;
|
const DB_TABLE = null;
|
||||||
const DB_KEY = 'id';
|
const DB_KEY = 'id';
|
||||||
|
|
||||||
@ -40,7 +33,7 @@ abstract class model {
|
|||||||
* TODO: support adding or subtracting (SET value=value+1)
|
* TODO: support adding or subtracting (SET value=value+1)
|
||||||
*/
|
*/
|
||||||
public function edit(array $fields) {
|
public function edit(array $fields) {
|
||||||
$db = DB();
|
$db = getDB();
|
||||||
|
|
||||||
$model_upd = [];
|
$model_upd = [];
|
||||||
$db_upd = [];
|
$db_upd = [];
|
||||||
@ -202,11 +195,11 @@ abstract class model {
|
|||||||
if ($pos === false)
|
if ($pos === false)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (substr($phpdoc, $pos+1, 4) == 'json')
|
if (substr($phpdoc, $pos + 1, 4) == 'json')
|
||||||
$mytype = ModelFieldType::JSON;
|
$mytype = ModelFieldType::JSON;
|
||||||
else if (substr($phpdoc, $pos+1, 5) == 'array')
|
else if (substr($phpdoc, $pos + 1, 5) == 'array')
|
||||||
$mytype = ModelFieldType::ARRAY;
|
$mytype = ModelFieldType::ARRAY;
|
||||||
else if (substr($phpdoc, $pos+1, 10) == 'serialized')
|
else if (substr($phpdoc, $pos + 1, 10) == 'serialized')
|
||||||
$mytype = ModelFieldType::SERIALIZED;
|
$mytype = ModelFieldType::SERIALIZED;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -228,104 +221,4 @@ abstract class model {
|
|||||||
|
|
||||||
return new ModelSpec($list, $db_name_map);
|
return new ModelSpec($list, $db_name_map);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ModelSpec {
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
/** @var ModelProperty[] */
|
|
||||||
protected array $properties,
|
|
||||||
protected array $dbNameMap
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return ModelProperty[]
|
|
||||||
*/
|
|
||||||
public function getProperties(): array {
|
|
||||||
return $this->properties;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDbNameMap(): array {
|
|
||||||
return $this->dbNameMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPropNames(): array {
|
|
||||||
return array_keys($this->dbNameMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
class ModelProperty {
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
protected ?ModelFieldType $type,
|
|
||||||
protected mixed $realType,
|
|
||||||
protected bool $nullable,
|
|
||||||
protected string $modelName,
|
|
||||||
protected string $dbName
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function getDbName(): string {
|
|
||||||
return $this->dbName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getModelName(): string {
|
|
||||||
return $this->modelName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isNullable(): bool {
|
|
||||||
return $this->nullable;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getType(): ?ModelFieldType {
|
|
||||||
return $this->type;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function fromRawValue(mixed $value): mixed {
|
|
||||||
if ($this->nullable && is_null($value))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
switch ($this->type) {
|
|
||||||
case ModelFieldType::BOOLEAN:
|
|
||||||
return (bool)$value;
|
|
||||||
|
|
||||||
case ModelFieldType::INTEGER:
|
|
||||||
return (int)$value;
|
|
||||||
|
|
||||||
case ModelFieldType::FLOAT:
|
|
||||||
return (float)$value;
|
|
||||||
|
|
||||||
case ModelFieldType::ARRAY:
|
|
||||||
return array_filter(explode(',', $value));
|
|
||||||
|
|
||||||
case ModelFieldType::JSON:
|
|
||||||
$val = jsonDecode($value);
|
|
||||||
if (!$val)
|
|
||||||
$val = null;
|
|
||||||
return $val;
|
|
||||||
|
|
||||||
case ModelFieldType::SERIALIZED:
|
|
||||||
$val = unserialize($value);
|
|
||||||
if ($val === false)
|
|
||||||
$val = null;
|
|
||||||
return $val;
|
|
||||||
|
|
||||||
case ModelFieldType::BITFIELD:
|
|
||||||
return new mysql_bitfield($value);
|
|
||||||
|
|
||||||
case ModelFieldType::BACKED_ENUM:
|
|
||||||
try {
|
|
||||||
return $this->realType::from($value);
|
|
||||||
} catch (ValueError $e) {
|
|
||||||
if ($this->nullable)
|
|
||||||
return null;
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return (string)$value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
16
src/engine/ModelFieldType.php
Normal file
16
src/engine/ModelFieldType.php
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine;
|
||||||
|
|
||||||
|
enum ModelFieldType
|
||||||
|
{
|
||||||
|
case STRING;
|
||||||
|
case INTEGER;
|
||||||
|
case FLOAT;
|
||||||
|
case ARRAY;
|
||||||
|
case BOOLEAN;
|
||||||
|
case JSON;
|
||||||
|
case SERIALIZED;
|
||||||
|
case BITFIELD;
|
||||||
|
case BACKED_ENUM;
|
||||||
|
}
|
76
src/engine/ModelProperty.php
Normal file
76
src/engine/ModelProperty.php
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine;
|
||||||
|
|
||||||
|
class ModelProperty
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected ?ModelFieldType $type,
|
||||||
|
protected mixed $realType,
|
||||||
|
protected bool $nullable,
|
||||||
|
protected string $modelName,
|
||||||
|
protected string $dbName
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function getDbName(): string {
|
||||||
|
return $this->dbName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getModelName(): string {
|
||||||
|
return $this->modelName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isNullable(): bool {
|
||||||
|
return $this->nullable;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getType(): ?ModelFieldType {
|
||||||
|
return $this->type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fromRawValue(mixed $value): mixed {
|
||||||
|
if ($this->nullable && is_null($value))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
switch ($this->type) {
|
||||||
|
case ModelFieldType::BOOLEAN:
|
||||||
|
return (bool)$value;
|
||||||
|
|
||||||
|
case ModelFieldType::INTEGER:
|
||||||
|
return (int)$value;
|
||||||
|
|
||||||
|
case ModelFieldType::FLOAT:
|
||||||
|
return (float)$value;
|
||||||
|
|
||||||
|
case ModelFieldType::ARRAY:
|
||||||
|
return array_filter(explode(',', $value));
|
||||||
|
|
||||||
|
case ModelFieldType::JSON:
|
||||||
|
$val = jsonDecode($value);
|
||||||
|
if (!$val)
|
||||||
|
$val = null;
|
||||||
|
return $val;
|
||||||
|
|
||||||
|
case ModelFieldType::SERIALIZED:
|
||||||
|
$val = unserialize($value);
|
||||||
|
if ($val === false)
|
||||||
|
$val = null;
|
||||||
|
return $val;
|
||||||
|
|
||||||
|
case ModelFieldType::BITFIELD:
|
||||||
|
return new MySQLBitField($value);
|
||||||
|
|
||||||
|
case ModelFieldType::BACKED_ENUM:
|
||||||
|
try {
|
||||||
|
return $this->realType::from($value);
|
||||||
|
} catch (\ValueError $e) {
|
||||||
|
if ($this->nullable)
|
||||||
|
return null;
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (string)$value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
27
src/engine/ModelSpec.php
Normal file
27
src/engine/ModelSpec.php
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine;
|
||||||
|
|
||||||
|
class ModelSpec
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
/** @var ModelProperty */
|
||||||
|
protected array $properties,
|
||||||
|
protected array $dbNameMap
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function getProperties(): array {
|
||||||
|
return $this->properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDbNameMap(): array {
|
||||||
|
return $this->dbNameMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPropNames(): array {
|
||||||
|
return array_keys($this->dbNameMap);
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
class mysql {
|
namespace engine;
|
||||||
|
|
||||||
|
use BackedEnum;
|
||||||
|
use mysqli;
|
||||||
|
use mysqli_result;
|
||||||
|
use mysqli_sql_exception;
|
||||||
|
|
||||||
|
class MySQL
|
||||||
|
{
|
||||||
const string DATE_FORMAT = 'Y-m-d';
|
const string DATE_FORMAT = 'Y-m-d';
|
||||||
const string DATETIME_FORMAT = 'Y-m-d H:i:s';
|
const string DATETIME_FORMAT = 'Y-m-d H:i:s';
|
||||||
|
|
||||||
@ -11,7 +18,8 @@ class mysql {
|
|||||||
protected string $host,
|
protected string $host,
|
||||||
protected string $user,
|
protected string $user,
|
||||||
protected string $password,
|
protected string $password,
|
||||||
protected string $database) {}
|
protected string $database) {
|
||||||
|
}
|
||||||
|
|
||||||
protected function prepareQuery(string $sql, ...$args): string {
|
protected function prepareQuery(string $sql, ...$args): string {
|
||||||
global $config;
|
global $config;
|
||||||
@ -60,7 +68,7 @@ class mysql {
|
|||||||
$count++;
|
$count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
$sql = "{$command} INTO `{$table}` (`" . implode('`, `', $names) . "`) VALUES (" . implode(', ', array_fill(0, $count, '?')) . ")";
|
$sql = "{$command} INTO `{$table}` (`".implode('`, `', $names)."`) VALUES (".implode(', ', array_fill(0, $count, '?')).")";
|
||||||
array_unshift($values, $sql);
|
array_unshift($values, $sql);
|
||||||
|
|
||||||
return $this->query(...$values);
|
return $this->query(...$values);
|
||||||
@ -129,9 +137,9 @@ class mysql {
|
|||||||
try {
|
try {
|
||||||
$q = $this->link->query($sql);
|
$q = $this->link->query($sql);
|
||||||
if (!$q)
|
if (!$q)
|
||||||
logError(__METHOD__.': '.$this->link->error."\n$sql\n".backtraceAsString(1));
|
logError(__METHOD__.': '.$this->link->error."\n$sql\n".logging\Util::backtraceAsString(1));
|
||||||
} catch (mysqli_sql_exception $e) {
|
} catch (mysqli_sql_exception $e) {
|
||||||
logError(__METHOD__.': '.$e->getMessage()."\n$sql\n".backtraceAsString(1));
|
logError(__METHOD__.': '.$e->getMessage()."\n$sql\n".logging\Util::backtraceAsString(1));
|
||||||
}
|
}
|
||||||
return $q;
|
return $q;
|
||||||
}
|
}
|
||||||
@ -187,89 +195,4 @@ class mysql {
|
|||||||
public function escape(string $s): string {
|
public function escape(string $s): string {
|
||||||
return $this->link->real_escape_string($s);
|
return $this->link->real_escape_string($s);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
class mysql_bitfield {
|
|
||||||
|
|
||||||
private GMP $value;
|
|
||||||
private int $size;
|
|
||||||
|
|
||||||
public function __construct($value, int $size = 64) {
|
|
||||||
$this->value = gmp_init($value);
|
|
||||||
$this->size = $size;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function has(int $bit): bool {
|
|
||||||
$this->validateBit($bit);
|
|
||||||
return gmp_testbit($this->value, $bit);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function set(int $bit): void {
|
|
||||||
$this->validateBit($bit);
|
|
||||||
gmp_setbit($this->value, $bit);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function clear(int $bit): void {
|
|
||||||
$this->validateBit($bit);
|
|
||||||
gmp_clrbit($this->value, $bit);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isEmpty(): bool {
|
|
||||||
return !gmp_cmp($this->value, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function __toString(): string {
|
|
||||||
$buf = '';
|
|
||||||
for ($bit = $this->size-1; $bit >= 0; --$bit)
|
|
||||||
$buf .= gmp_testbit($this->value, $bit) ? '1' : '0';
|
|
||||||
if (($pos = strpos($buf, '1')) !== false) {
|
|
||||||
$buf = substr($buf, $pos);
|
|
||||||
} else {
|
|
||||||
$buf = '0';
|
|
||||||
}
|
|
||||||
return $buf;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function validateBit(int $bit): void {
|
|
||||||
if ($bit < 0 || $bit >= $this->size)
|
|
||||||
throw new Exception('invalid bit '.$bit.', allowed range: [0..'.$this->size.')');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function DB(): mysql|null {
|
|
||||||
global $config;
|
|
||||||
|
|
||||||
/** @var ?mysql $link */
|
|
||||||
static $link = null;
|
|
||||||
if (!is_null($link))
|
|
||||||
return $link;
|
|
||||||
|
|
||||||
$link = new mysql(
|
|
||||||
$config['mysql']['host'],
|
|
||||||
$config['mysql']['user'],
|
|
||||||
$config['mysql']['password'],
|
|
||||||
$config['mysql']['database']);
|
|
||||||
if (!$link->connect()) {
|
|
||||||
if (!isCli()) {
|
|
||||||
header('HTTP/1.1 503 Service Temporarily Unavailable');
|
|
||||||
header('Status: 503 Service Temporarily Unavailable');
|
|
||||||
header('Retry-After: 300');
|
|
||||||
die('database connection failed');
|
|
||||||
} else {
|
|
||||||
fwrite(STDERR, 'database connection failed');
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $link;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MC(): Memcached {
|
|
||||||
static $mc = null;
|
|
||||||
if ($mc === null) {
|
|
||||||
$mc = new Memcached();
|
|
||||||
$mc->addServer("127.0.0.1", 11211);
|
|
||||||
}
|
|
||||||
return $mc;
|
|
||||||
}
|
}
|
65
src/engine/MySQLBitField.php
Normal file
65
src/engine/MySQLBitField.php
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use GMP;
|
||||||
|
|
||||||
|
class MySQLBitField
|
||||||
|
{
|
||||||
|
private GMP $value;
|
||||||
|
private int $size;
|
||||||
|
|
||||||
|
public function __construct($value, int $size = 64) {
|
||||||
|
$this->value = gmp_init($value);
|
||||||
|
$this->size = $size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function has(int $bit): bool {
|
||||||
|
$this->validateBit($bit);
|
||||||
|
return gmp_testbit($this->value, $bit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function set(int $bit): void {
|
||||||
|
$this->validateBit($bit);
|
||||||
|
gmp_setbit($this->value, $bit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function clear(int $bit): void {
|
||||||
|
$this->validateBit($bit);
|
||||||
|
gmp_clrbit($this->value, $bit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEmpty(): bool {
|
||||||
|
return !gmp_cmp($this->value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string {
|
||||||
|
$buf = '';
|
||||||
|
for ($bit = $this->size - 1; $bit >= 0; --$bit)
|
||||||
|
$buf .= gmp_testbit($this->value, $bit) ? '1' : '0';
|
||||||
|
if (($pos = strpos($buf, '1')) !== false) {
|
||||||
|
$buf = substr($buf, $pos);
|
||||||
|
} else {
|
||||||
|
$buf = '0';
|
||||||
|
}
|
||||||
|
return $buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function validateBit(int $bit): void {
|
||||||
|
if ($bit < 0 || $bit >= $this->size)
|
||||||
|
throw new Exception('invalid bit ' . $bit . ', allowed range: [0..' . $this->size . ')');
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
class router {
|
namespace engine;
|
||||||
|
|
||||||
|
class Router
|
||||||
|
{
|
||||||
const int ROUTER_VERSION = 10;
|
const int ROUTER_VERSION = 10;
|
||||||
const string ROUTER_MC_KEY = '4in1/routes';
|
const string ROUTER_MC_KEY = '4in1/routes';
|
||||||
|
|
||||||
@ -9,16 +11,17 @@ class router {
|
|||||||
'children' => [],
|
'children' => [],
|
||||||
're_children' => []
|
're_children' => []
|
||||||
];
|
];
|
||||||
protected static ?router $instance = null;
|
protected static ?Router $instance = null;
|
||||||
|
|
||||||
public static function getInstance(): router {
|
public static function getInstance(): Router {
|
||||||
if (self::$instance === null)
|
if (self::$instance === null)
|
||||||
self::$instance = new router();
|
self::$instance = new Router();
|
||||||
return self::$instance;
|
return self::$instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function __construct() {
|
private function __construct() {
|
||||||
$mc = MC();
|
global $globalContext;
|
||||||
|
$mc = getMC();
|
||||||
|
|
||||||
$from_cache = !isDev();
|
$from_cache = !isDev();
|
||||||
$write_cache = !isDev();
|
$write_cache = !isDev();
|
||||||
@ -34,9 +37,9 @@ class router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!$from_cache) {
|
if (!$from_cache) {
|
||||||
$routes_table = require_once APP_ROOT.'/routes.php';
|
$routes_table = require_once APP_ROOT.'/src/routes.php';
|
||||||
|
|
||||||
foreach ($routes_table as $controller => $routes) {
|
foreach ($routes_table[$globalContext->project] as $controller => $routes) {
|
||||||
foreach ($routes as $route => $resolve)
|
foreach ($routes as $route => $resolve)
|
||||||
$this->add($route, $controller.' '.$resolve);
|
$this->add($route, $controller.' '.$resolve);
|
||||||
}
|
}
|
||||||
@ -56,15 +59,17 @@ class router {
|
|||||||
foreach ($matches[1] as $match_index => $variants) {
|
foreach ($matches[1] as $match_index => $variants) {
|
||||||
$variants = explode(',', $variants);
|
$variants = explode(',', $variants);
|
||||||
$variants = array_map('trim', $variants);
|
$variants = array_map('trim', $variants);
|
||||||
$variants = array_filter($variants, function($s) { return $s != ''; });
|
$variants = array_filter($variants, function ($s) {
|
||||||
|
return $s != '';
|
||||||
|
});
|
||||||
|
|
||||||
for ($i = 0; $i < count($templates); ) {
|
for ($i = 0; $i < count($templates);) {
|
||||||
list($template, $value) = $templates[$i];
|
list($template, $value) = $templates[$i];
|
||||||
$new_templates = [];
|
$new_templates = [];
|
||||||
foreach ($variants as $variant_index => $variant) {
|
foreach ($variants as $variant_index => $variant) {
|
||||||
$new_templates[] = [
|
$new_templates[] = [
|
||||||
strReplaceOnce($matches[0][$match_index], $variant, $template),
|
strReplaceOnce($matches[0][$match_index], $variant, $template),
|
||||||
str_replace('${'.($match_index+1).'}', $variant, $value)
|
str_replace('${'.($match_index + 1).'}', $variant, $value)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
array_splice($templates, $i, 1, $new_templates);
|
array_splice($templates, $i, 1, $new_templates);
|
||||||
@ -84,8 +89,8 @@ class router {
|
|||||||
while ($start_pos < $template_len) {
|
while ($start_pos < $template_len) {
|
||||||
$slash_pos = strpos($template, '/', $start_pos);
|
$slash_pos = strpos($template, '/', $start_pos);
|
||||||
if ($slash_pos !== false) {
|
if ($slash_pos !== false) {
|
||||||
$part = substr($template, $start_pos, $slash_pos-$start_pos+1);
|
$part = substr($template, $start_pos, $slash_pos - $start_pos + 1);
|
||||||
$start_pos = $slash_pos+1;
|
$start_pos = $slash_pos + 1;
|
||||||
} else {
|
} else {
|
||||||
$part = substr($template, $start_pos);
|
$part = substr($template, $start_pos);
|
||||||
$start_pos = $template_len;
|
$start_pos = $template_len;
|
||||||
@ -99,7 +104,7 @@ class router {
|
|||||||
|
|
||||||
protected function &_addRoute(&$parent, $part, $value = null) {
|
protected function &_addRoute(&$parent, $part, $value = null) {
|
||||||
$par_pos = strpos($part, '(');
|
$par_pos = strpos($part, '(');
|
||||||
$is_regex = $par_pos !== false && ($par_pos == 0 || $part[$par_pos-1] != '\\');
|
$is_regex = $par_pos !== false && ($par_pos == 0 || $part[$par_pos - 1] != '\\');
|
||||||
|
|
||||||
$children_key = !$is_regex ? 'children' : 're_children';
|
$children_key = !$is_regex ? 'children' : 're_children';
|
||||||
|
|
||||||
@ -128,7 +133,7 @@ class router {
|
|||||||
return $parent[$children_key][$part];
|
return $parent[$children_key][$part];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function find($uri) {
|
public function find($uri): ?string {
|
||||||
if ($uri != '/' && $uri[0] == '/') {
|
if ($uri != '/' && $uri[0] == '/') {
|
||||||
$uri = substr($uri, 1);
|
$uri = substr($uri, 1);
|
||||||
}
|
}
|
||||||
@ -140,8 +145,8 @@ class router {
|
|||||||
while ($start_pos < $uri_len) {
|
while ($start_pos < $uri_len) {
|
||||||
$slash_pos = strpos($uri, '/', $start_pos);
|
$slash_pos = strpos($uri, '/', $start_pos);
|
||||||
if ($slash_pos !== false) {
|
if ($slash_pos !== false) {
|
||||||
$part = substr($uri, $start_pos, $slash_pos-$start_pos+1);
|
$part = substr($uri, $start_pos, $slash_pos - $start_pos + 1);
|
||||||
$start_pos = $slash_pos+1;
|
$start_pos = $slash_pos + 1;
|
||||||
} else {
|
} else {
|
||||||
$part = substr($uri, $start_pos);
|
$part = substr($uri, $start_pos);
|
||||||
$start_pos = $uri_len;
|
$start_pos = $uri_len;
|
||||||
@ -171,19 +176,17 @@ class router {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$found) {
|
if (!$found)
|
||||||
return false;
|
return null;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isset($parent['value'])) {
|
if (!isset($parent['value']))
|
||||||
return false;
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
$value = $parent['value'];
|
$value = $parent['value'];
|
||||||
if (!empty($matches)) {
|
if (!empty($matches)) {
|
||||||
foreach ($matches as $i => $match) {
|
foreach ($matches as $i => $match) {
|
||||||
$needle = '$('.($i+1).')';
|
$needle = '$('.($i + 1).')';
|
||||||
$pos = strpos($value, $needle);
|
$pos = strpos($value, $needle);
|
||||||
if ($pos !== false) {
|
if ($pos !== false) {
|
||||||
$value = substr_replace($value, $match, $pos, strlen($needle));
|
$value = substr_replace($value, $match, $pos, strlen($needle));
|
@ -1,13 +1,17 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
class sphinx {
|
namespace engine;
|
||||||
|
|
||||||
|
use Sphinx;
|
||||||
|
|
||||||
|
class SphinxUtil
|
||||||
|
{
|
||||||
public static function execute(string $sql) {
|
public static function execute(string $sql) {
|
||||||
$link = self::getLink();
|
$link = self::getLink();
|
||||||
|
|
||||||
if (func_num_args() > 1) {
|
if (func_num_args() > 1) {
|
||||||
$mark_count = substr_count($sql, '?');
|
$mark_count = substr_count($sql, '?');
|
||||||
$positions = array();
|
$positions = [];
|
||||||
$last_pos = -1;
|
$last_pos = -1;
|
||||||
for ($i = 0; $i < $mark_count; $i++) {
|
for ($i = 0; $i < $mark_count; $i++) {
|
||||||
$last_pos = strpos($sql, '?', $last_pos + 1);
|
$last_pos = strpos($sql, '?', $last_pos + 1);
|
||||||
@ -88,12 +92,12 @@ class sphinx {
|
|||||||
protected static function getLink($auto_create = true) {
|
protected static function getLink($auto_create = true) {
|
||||||
global $config;
|
global $config;
|
||||||
|
|
||||||
/** @var ?mysqli $link */
|
/** @var ?\mysqli $link */
|
||||||
static $link = null;
|
static $link = null;
|
||||||
if (!is_null($link) || !$auto_create)
|
if (!is_null($link) || !$auto_create)
|
||||||
return $link;
|
return $link;
|
||||||
|
|
||||||
$link = new mysqli();
|
$link = new \mysqli();
|
||||||
$link->real_connect(
|
$link->real_connect(
|
||||||
$config['sphinx']['host'],
|
$config['sphinx']['host'],
|
||||||
ini_get('mysql.default_user'),
|
ini_get('mysql.default_user'),
|
||||||
@ -104,6 +108,4 @@ class sphinx {
|
|||||||
|
|
||||||
return $link;
|
return $link;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
5
src/engine/exceptions/InvalidDomainException.php
Normal file
5
src/engine/exceptions/InvalidDomainException.php
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\exceptions;
|
||||||
|
|
||||||
|
class InvalidDomainException extends \Exception {}
|
5
src/engine/exceptions/NotImplementedException.php
Normal file
5
src/engine/exceptions/NotImplementedException.php
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\exceptions;
|
||||||
|
|
||||||
|
class NotImplementedException extends \Exception {}
|
5
src/engine/exceptions/ParseFormException.php
Normal file
5
src/engine/exceptions/ParseFormException.php
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\exceptions;
|
||||||
|
|
||||||
|
class ParseFormException extends \Exception {}
|
11
src/engine/http/AjaxError.php
Normal file
11
src/engine/http/AjaxError.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\http;
|
||||||
|
|
||||||
|
class AjaxError
|
||||||
|
extends AjaxResponse
|
||||||
|
{
|
||||||
|
public function __construct(mixed $error = null, HTTPCode $code = HTTPCode::OK) {
|
||||||
|
parent::__construct(['error' => $error], $code);
|
||||||
|
}
|
||||||
|
}
|
11
src/engine/http/AjaxOk.php
Normal file
11
src/engine/http/AjaxOk.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\http;
|
||||||
|
|
||||||
|
class AjaxOk
|
||||||
|
extends AjaxResponse
|
||||||
|
{
|
||||||
|
public function __construct(mixed $response = null, HTTPCode $code = HTTPCode::OK) {
|
||||||
|
parent::__construct(['response' => $response], $code);
|
||||||
|
}
|
||||||
|
}
|
15
src/engine/http/AjaxResponse.php
Normal file
15
src/engine/http/AjaxResponse.php
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\http;
|
||||||
|
|
||||||
|
class AjaxResponse
|
||||||
|
extends JSONResponse
|
||||||
|
{
|
||||||
|
public function getHeaders(): ?array {
|
||||||
|
return [
|
||||||
|
...parent::getHeaders(),
|
||||||
|
'Cache-Control: no-cache, must-revalidate',
|
||||||
|
'Pragma: no-cache',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
24
src/engine/http/HTTPCode.php
Normal file
24
src/engine/http/HTTPCode.php
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\http;
|
||||||
|
|
||||||
|
enum HTTPCode: int
|
||||||
|
{
|
||||||
|
case OK = 200;
|
||||||
|
|
||||||
|
case MovedPermanently = 301;
|
||||||
|
case Found = 302;
|
||||||
|
|
||||||
|
case BadRequest = 400;
|
||||||
|
case Unauthorized = 401;
|
||||||
|
case NotFound = 404;
|
||||||
|
case Forbidden = 403;
|
||||||
|
case UnavailableForLegalReasons = 451;
|
||||||
|
|
||||||
|
case InternalServerError = 500;
|
||||||
|
case NotImplemented = 501;
|
||||||
|
|
||||||
|
public function getTitle(): string {
|
||||||
|
return preg_replace('/(?<!^)([A-Z])/', ' $1', $this->name);
|
||||||
|
}
|
||||||
|
}
|
9
src/engine/http/HTTPMethod.php
Normal file
9
src/engine/http/HTTPMethod.php
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\http;
|
||||||
|
|
||||||
|
enum HTTPMethod: string
|
||||||
|
{
|
||||||
|
case GET = 'GET';
|
||||||
|
case POST = 'POST';
|
||||||
|
}
|
22
src/engine/http/HtmlResponse.php
Normal file
22
src/engine/http/HtmlResponse.php
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\http;
|
||||||
|
|
||||||
|
class HtmlResponse
|
||||||
|
extends Response
|
||||||
|
{
|
||||||
|
public function __construct(string $html,
|
||||||
|
HTTPCode $code = HTTPCode::OK,
|
||||||
|
protected string $contentType = 'text/html; charset=utf-8') {
|
||||||
|
parent::__construct($code);
|
||||||
|
$this->data = $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBody(): string {
|
||||||
|
return $this->data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeaders(): ?array {
|
||||||
|
return ['Content-Type: '.$this->contentType];
|
||||||
|
}
|
||||||
|
}
|
11
src/engine/http/InputVarType.php
Normal file
11
src/engine/http/InputVarType.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\http;
|
||||||
|
|
||||||
|
enum InputVarType: string
|
||||||
|
{
|
||||||
|
case INTEGER = 'i';
|
||||||
|
case FLOAT = 'f';
|
||||||
|
case BOOLEAN = 'b';
|
||||||
|
case STRING = 's';
|
||||||
|
}
|
20
src/engine/http/JSONResponse.php
Normal file
20
src/engine/http/JSONResponse.php
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\http;
|
||||||
|
|
||||||
|
class JSONResponse
|
||||||
|
extends Response
|
||||||
|
{
|
||||||
|
public function __construct(mixed $data, HTTPCode $code = HTTPCode::OK) {
|
||||||
|
parent::__construct($code);
|
||||||
|
$this->data = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBody(): string {
|
||||||
|
return jsonEncode($this->data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeaders(): ?array {
|
||||||
|
return ['Content-Type: application/json; charset=utf-8'];
|
||||||
|
}
|
||||||
|
}
|
20
src/engine/http/PlainTextResponse.php
Normal file
20
src/engine/http/PlainTextResponse.php
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\http;
|
||||||
|
|
||||||
|
class PlainTextResponse
|
||||||
|
extends Response
|
||||||
|
{
|
||||||
|
public function __construct(string $text, HTTPCode $code = HTTPCode::OK) {
|
||||||
|
parent::__construct($code);
|
||||||
|
$this->data = $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBody(): string {
|
||||||
|
return $this->data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeaders(): ?array {
|
||||||
|
return ['Content-Type: text/plain; charset=utf-8'];
|
||||||
|
}
|
||||||
|
}
|
203
src/engine/http/RequestHandler.php
Normal file
203
src/engine/http/RequestHandler.php
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\http;
|
||||||
|
|
||||||
|
use engine\exceptions\InvalidDomainException;
|
||||||
|
use engine\exceptions\NotImplementedException;
|
||||||
|
use engine\http\errors\BaseRedirect;
|
||||||
|
use engine\http\errors\HTTPError;
|
||||||
|
use engine\http\errors\InvalidRequest;
|
||||||
|
use engine\http\errors\NotFound;
|
||||||
|
use engine\http\errors\NotImplemented;
|
||||||
|
use engine\Router;
|
||||||
|
use engine\skin\BaseSkin;
|
||||||
|
use engine\skin\ErrorSkin;
|
||||||
|
|
||||||
|
abstract class RequestHandler
|
||||||
|
{
|
||||||
|
protected array $routerInput = [];
|
||||||
|
public readonly BaseSkin $skin;
|
||||||
|
|
||||||
|
public static final function resolveAndDispatch(): void {
|
||||||
|
global $config, $globalContext;
|
||||||
|
try {
|
||||||
|
if ($_SERVER['HTTP_HOST'] !== $config['domain'] && !str_ends_with($_SERVER['HTTP_HOST'], '.'.$config['domain']))
|
||||||
|
throw new InvalidDomainException('invalid domain '.$_SERVER['HTTP_HOST']);
|
||||||
|
|
||||||
|
if (strlen($_SERVER['HTTP_HOST']) > ($orig_domain_len = strlen($config['domain']))) {
|
||||||
|
$sub = substr($_SERVER['HTTP_HOST'], 0, -$orig_domain_len - 1);
|
||||||
|
if (!array_key_exists($sub, $config['subdomains']))
|
||||||
|
throw new InvalidDomainException('invalid subdomain '.$sub);
|
||||||
|
$globalContext->setProject($config['subdomains'][$sub]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($_SERVER['REQUEST_METHOD'], ['POST', 'GET']))
|
||||||
|
throw new NotImplemented('Method '.$_SERVER['REQUEST_METHOD'].' not implemented');
|
||||||
|
|
||||||
|
$uri = $_SERVER['REQUEST_URI'];
|
||||||
|
if (($pos = strpos($uri, '?')) !== false)
|
||||||
|
$uri = substr($uri, 0, $pos);
|
||||||
|
|
||||||
|
$router = router::getInstance();
|
||||||
|
$route = $router->find($uri);
|
||||||
|
if ($route === null)
|
||||||
|
throw new NotFound('Route not found');
|
||||||
|
|
||||||
|
$route = preg_split('/ +/', $route);
|
||||||
|
$handler_class = 'app\\'.$globalContext->project.'\\'.$route[0].'Handler';
|
||||||
|
if (!class_exists($handler_class))
|
||||||
|
throw new NotFound(isDev() ? 'Handler class "'.$handler_class.'" not found' : '');
|
||||||
|
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$rh = new $handler_class();
|
||||||
|
$globalContext->setRequestHandler($rh);
|
||||||
|
$response = $rh->callAct($_SERVER['REQUEST_METHOD'], $action, $input);
|
||||||
|
}
|
||||||
|
|
||||||
|
catch (InvalidDomainException|NotImplementedException $e) {
|
||||||
|
$stacktrace = \engine\logging\Util::getErrorFullStackTrace($e);
|
||||||
|
logError($e, stacktrace: $stacktrace);
|
||||||
|
self::renderError($e->getMessage(), HTTPCode::InternalServerError, $stacktrace);
|
||||||
|
}
|
||||||
|
|
||||||
|
catch (BaseRedirect $e) {
|
||||||
|
if (isXHRRequest()) {
|
||||||
|
$response = new AjaxOk(['redirect' => $e->getLocation()]);
|
||||||
|
} else {
|
||||||
|
header('Location: '.$e->getLocation(), $e->shouldReplace(), $e->getHTTPCode()->value);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
catch (HTTPError $e) {
|
||||||
|
if (isXHRRequest()) {
|
||||||
|
$data = [];
|
||||||
|
$message = $e->getDescription();
|
||||||
|
if ($message != '')
|
||||||
|
$data['message'] = $message;
|
||||||
|
$response = new AjaxError((object)$data, $e->getHTTPCode());
|
||||||
|
} else {
|
||||||
|
self::renderError($e->getMessage(), $e->getHTTPCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
catch (\Throwable $e) {
|
||||||
|
$stacktrace = \engine\logging\Util::getErrorFullStackTrace($e);
|
||||||
|
logError(get_class($e).': '.$e->getMessage(), stacktrace: $stacktrace);
|
||||||
|
self::renderError(get_class($e).': '.$e->getMessage(), HTTPCode::InternalServerError, $stacktrace);
|
||||||
|
}
|
||||||
|
|
||||||
|
finally {
|
||||||
|
if (!empty($response)) {
|
||||||
|
if ($response instanceof Response) {
|
||||||
|
$response->send();
|
||||||
|
} else {
|
||||||
|
logError(__METHOD__.': $response is not Response');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function renderError(string $message, HTTPCode $code, ?string $stacktrace = null): never {
|
||||||
|
http_response_code($code->value);
|
||||||
|
$skin = new ErrorSkin();
|
||||||
|
switch ($code) {
|
||||||
|
case HTTPCode::NotFound:
|
||||||
|
$skin->renderNotFound();
|
||||||
|
default:
|
||||||
|
$skin->renderError($code->getTitle(), $message, $stacktrace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
throw new NotFound(static::class.'::'.$handler_method.' is not defined');
|
||||||
|
|
||||||
|
if (!(new \ReflectionMethod($this, $handler_method)->isPublic()))
|
||||||
|
throw new 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] : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected 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];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function input(string $input,
|
||||||
|
bool $trim = false): array
|
||||||
|
{
|
||||||
|
$input = preg_split('/,\s+?/', $input, -1, PREG_SPLIT_NO_EMPTY);
|
||||||
|
$result = [];
|
||||||
|
foreach ($input as $var) {
|
||||||
|
$pos = strpos($var, ':');
|
||||||
|
if ($pos === 1) {
|
||||||
|
$type = InputVarType::from(substr($var, 0, $pos));
|
||||||
|
$name = trim(substr($var, $pos + 1));
|
||||||
|
} else {
|
||||||
|
$type = InputVarType::STRING;
|
||||||
|
$name = trim($var);
|
||||||
|
}
|
||||||
|
$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 = implode($val);
|
||||||
|
$result[] = match ($type) {
|
||||||
|
InputVarType::INTEGER => (int)$val,
|
||||||
|
InputVarType::FLOAT => (float)$val,
|
||||||
|
InputVarType::BOOLEAN => (bool)$val,
|
||||||
|
default => $trim ? trim((string)$val) : (string)$val
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCSRF(string $key): string {
|
||||||
|
global $config;
|
||||||
|
$user_key = isAdmin() ? \app\Admin::getCSRFSalt() : $_SERVER['REMOTE_ADDR'];
|
||||||
|
return substr(hash('sha256', $config['csrf_token'].$user_key.$key), 0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws errors\Forbidden
|
||||||
|
*/
|
||||||
|
protected function checkCSRF(string $key): void {
|
||||||
|
if ($this->getCSRF($key) != ($_REQUEST['token'] ?? ''))
|
||||||
|
throw new errors\Forbidden('invalid token');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws InvalidRequest
|
||||||
|
*/
|
||||||
|
protected function ensureIsXHR(): void {
|
||||||
|
if (!isXHRRequest())
|
||||||
|
throw new InvalidRequest();
|
||||||
|
}
|
||||||
|
}
|
25
src/engine/http/Response.php
Normal file
25
src/engine/http/Response.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\http;
|
||||||
|
|
||||||
|
abstract class Response
|
||||||
|
{
|
||||||
|
protected mixed $data;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected(set) HTTPCode $code
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function send(): void {
|
||||||
|
http_response_code($this->code->value);
|
||||||
|
$headers = $this->getHeaders();
|
||||||
|
if ($headers) {
|
||||||
|
foreach ($headers as $header)
|
||||||
|
header($header);
|
||||||
|
}
|
||||||
|
echo $this->getBody();
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract public function getHeaders(): ?array;
|
||||||
|
abstract public function getBody(): string;
|
||||||
|
}
|
55
src/engine/http/errors/BaseRedirect.php
Normal file
55
src/engine/http/errors/BaseRedirect.php
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\http\errors;
|
||||||
|
|
||||||
|
use engine\http\HTTPCode;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
abstract class BaseRedirect
|
||||||
|
extends HTTPError
|
||||||
|
{
|
||||||
|
protected bool $no_ajax = false;
|
||||||
|
protected bool $replace = false;
|
||||||
|
|
||||||
|
public function __construct(string $location = "",
|
||||||
|
bool $preserve_utm = true,
|
||||||
|
bool $replace = false,
|
||||||
|
bool $no_ajax = false,
|
||||||
|
?Throwable $previous = null) {
|
||||||
|
if (/*!is_cli() && */$_SERVER['REQUEST_METHOD'] == 'GET' && $preserve_utm) {
|
||||||
|
$proxy_params = ['utm_source', 'utm_medium', 'utm_content', 'utm_campaign'];
|
||||||
|
$params = [];
|
||||||
|
foreach ($proxy_params as $p) {
|
||||||
|
if (!empty($_GET[$p])) {
|
||||||
|
$params[$p] = (string)$_GET[$p];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($params)) {
|
||||||
|
if (($anchor_pos = strpos($location, '#')) !== false) {
|
||||||
|
$anchor = substr($location, $anchor_pos+1);
|
||||||
|
$location = substr($location, 0, $anchor_pos);
|
||||||
|
}
|
||||||
|
$location .= (!str_contains($location, '?') ? '?' : '&').http_build_query($params);
|
||||||
|
if ($anchor_pos !== false) {
|
||||||
|
$location .= '#'.$anchor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::__construct(HTTPCode::Found, $location, $previous);
|
||||||
|
|
||||||
|
$this->no_ajax = $no_ajax;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLocation(): string {
|
||||||
|
return $this->message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shouldReplace(): bool {
|
||||||
|
return $this->replace;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isNoAjaxSet(): bool {
|
||||||
|
return $this->no_ajax;
|
||||||
|
}
|
||||||
|
}
|
13
src/engine/http/errors/Forbidden.php
Normal file
13
src/engine/http/errors/Forbidden.php
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\http\errors;
|
||||||
|
|
||||||
|
use engine\http\HTTPCode;
|
||||||
|
|
||||||
|
class Forbidden
|
||||||
|
extends HTTPError
|
||||||
|
{
|
||||||
|
public function __construct(string $message = "", ?\Throwable $previous = null) {
|
||||||
|
parent::__construct(HTTPCode::Forbidden, $message, $previous);
|
||||||
|
}
|
||||||
|
}
|
29
src/engine/http/errors/HTTPError.php
Normal file
29
src/engine/http/errors/HTTPError.php
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\http\errors;
|
||||||
|
|
||||||
|
use engine\http\HTTPCode;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
abstract class HTTPError
|
||||||
|
extends \Exception
|
||||||
|
{
|
||||||
|
protected HTTPCode $http_code;
|
||||||
|
protected string $description;
|
||||||
|
|
||||||
|
public function __construct(HTTPCode $code,
|
||||||
|
string $message,
|
||||||
|
?Throwable $previous = null) {
|
||||||
|
$this->http_code = $code;
|
||||||
|
$this->description = $message;
|
||||||
|
parent::__construct($message, $code->value, $previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHTTPCode(): HTTPCode {
|
||||||
|
return $this->http_code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDescription(): string {
|
||||||
|
return $this->description;
|
||||||
|
}
|
||||||
|
}
|
13
src/engine/http/errors/InternalServerError.php
Normal file
13
src/engine/http/errors/InternalServerError.php
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\http\errors;
|
||||||
|
|
||||||
|
use engine\http\HTTPCode;
|
||||||
|
|
||||||
|
class InternalServerError
|
||||||
|
extends HTTPError
|
||||||
|
{
|
||||||
|
public function __construct(string $message = "", ?\Throwable $previous = null) {
|
||||||
|
parent::__construct(HTTPCode::InternalServerError, $message, $previous);
|
||||||
|
}
|
||||||
|
}
|
13
src/engine/http/errors/InvalidRequest.php
Normal file
13
src/engine/http/errors/InvalidRequest.php
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\http\errors;
|
||||||
|
|
||||||
|
use engine\http\HTTPCode;
|
||||||
|
|
||||||
|
class InvalidRequest
|
||||||
|
extends HTTPError
|
||||||
|
{
|
||||||
|
public function __construct(string $message = "", ?\Throwable $previous = null) {
|
||||||
|
parent::__construct(HTTPCode::BadRequest, $message, $previous);
|
||||||
|
}
|
||||||
|
}
|
13
src/engine/http/errors/NotFound.php
Normal file
13
src/engine/http/errors/NotFound.php
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\http\errors;
|
||||||
|
|
||||||
|
use engine\http\HTTPCode;
|
||||||
|
|
||||||
|
class NotFound
|
||||||
|
extends HTTPError
|
||||||
|
{
|
||||||
|
public function __construct(string $message = "", ?\Throwable $previous = null) {
|
||||||
|
parent::__construct(HTTPCode::NotFound, $message, $previous);
|
||||||
|
}
|
||||||
|
}
|
13
src/engine/http/errors/NotImplemented.php
Normal file
13
src/engine/http/errors/NotImplemented.php
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\http\errors;
|
||||||
|
|
||||||
|
use engine\http\HTTPCode;
|
||||||
|
|
||||||
|
class NotImplemented
|
||||||
|
extends HTTPError
|
||||||
|
{
|
||||||
|
public function __construct(string $message = "", ?\Throwable $previous = null) {
|
||||||
|
parent::__construct(HTTPCode::NotImplemented, $message, $previous);
|
||||||
|
}
|
||||||
|
}
|
11
src/engine/http/errors/PermanentRedirect.php
Normal file
11
src/engine/http/errors/PermanentRedirect.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\http\errors;
|
||||||
|
|
||||||
|
class PermanentRedirect
|
||||||
|
extends BaseRedirect
|
||||||
|
{
|
||||||
|
public function __construct(...$args) {
|
||||||
|
parent::__construct(...$args);
|
||||||
|
}
|
||||||
|
}
|
11
src/engine/http/errors/Redirect.php
Normal file
11
src/engine/http/errors/Redirect.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\http\errors;
|
||||||
|
|
||||||
|
class Redirect
|
||||||
|
extends BaseRedirect
|
||||||
|
{
|
||||||
|
public function __construct(...$args) {
|
||||||
|
parent::__construct(...$args);
|
||||||
|
}
|
||||||
|
}
|
13
src/engine/http/errors/Unauthorized.php
Normal file
13
src/engine/http/errors/Unauthorized.php
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\http\errors;
|
||||||
|
|
||||||
|
use engine\http\HTTPCode;
|
||||||
|
|
||||||
|
class Unauthorized
|
||||||
|
extends HTTPError
|
||||||
|
{
|
||||||
|
public function __construct(string $message = "", ?\Throwable $previous = null) {
|
||||||
|
parent::__construct(HTTPCode::Unauthorized, $message, $previous);
|
||||||
|
}
|
||||||
|
}
|
13
src/engine/http/errors/UnavailableForLegalReasons.php
Normal file
13
src/engine/http/errors/UnavailableForLegalReasons.php
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\http\errors;
|
||||||
|
|
||||||
|
use engine\http\HTTPCode;
|
||||||
|
|
||||||
|
class UnavailableForLegalReasons
|
||||||
|
extends HTTPError
|
||||||
|
{
|
||||||
|
public function __construct(string $message = "", ?\Throwable $previous = null) {
|
||||||
|
parent::__construct(HTTPCode::UnavailableForLegalReasons, $message, $previous);
|
||||||
|
}
|
||||||
|
}
|
55
src/engine/lang/Strings.php
Normal file
55
src/engine/lang/Strings.php
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\lang;
|
||||||
|
|
||||||
|
class Strings extends StringsBase
|
||||||
|
{
|
||||||
|
private static ?Strings $instance = null;
|
||||||
|
protected array $loadedLangPacks = [];
|
||||||
|
|
||||||
|
private function __construct() {}
|
||||||
|
|
||||||
|
protected function __clone() {}
|
||||||
|
|
||||||
|
public static function getInstance(): Strings {
|
||||||
|
if (is_null(self::$instance)) {
|
||||||
|
self::$instance = new self();
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function load(string|StringsPack ...$packs): array {
|
||||||
|
$keys = [];
|
||||||
|
foreach ($packs as $arg) {
|
||||||
|
if ($arg instanceof StringsPack)
|
||||||
|
$raw = $arg->getData();
|
||||||
|
else {
|
||||||
|
// TODO implement proper cache in StringsPack class
|
||||||
|
if (isset($this->loadedLangPacks[$arg]))
|
||||||
|
continue;
|
||||||
|
$raw = $this->getStringsPack($arg, true);
|
||||||
|
}
|
||||||
|
$this->data = array_merge($this->data, $raw);
|
||||||
|
$keys = array_merge($keys, array_keys($raw));
|
||||||
|
$this->loadedLangPacks[$arg] = true;
|
||||||
|
}
|
||||||
|
return $keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $name Pack name.
|
||||||
|
* @param bool $raw Whether to return raw data.
|
||||||
|
* @return array<string,string|string[]>|StringsPack
|
||||||
|
*/
|
||||||
|
public function getStringsPack(string $name, bool $raw = false): array|StringsPack {
|
||||||
|
$file = APP_ROOT.'/src/strings/'.$name.'.yaml';
|
||||||
|
$data = yaml_parse_file($file);
|
||||||
|
if ($data === false)
|
||||||
|
logError(__METHOD__.': yaml_parse_file failed on file '.$file);
|
||||||
|
return $raw ? $data : new StringsPack($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function search(string $regexp): array {
|
||||||
|
return preg_grep($regexp, array_keys($this->data));
|
||||||
|
}
|
||||||
|
}
|
105
src/engine/lang/StringsBase.php
Normal file
105
src/engine/lang/StringsBase.php
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\lang;
|
||||||
|
|
||||||
|
use ArrayAccess;
|
||||||
|
use engine\exceptions\NotImplementedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements ArrayAccess<string,string|string[]>
|
||||||
|
*/
|
||||||
|
class StringsBase implements ArrayAccess
|
||||||
|
{
|
||||||
|
/** @var array<string,string|string[]> */
|
||||||
|
protected array $data = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws NotImplementedException
|
||||||
|
*/
|
||||||
|
public function offsetSet(mixed $offset, mixed $value): void {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function offsetExists(mixed $offset): bool {
|
||||||
|
return isset($this->data[$offset]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws NotImplementedException
|
||||||
|
*/
|
||||||
|
public function offsetUnset(mixed $offset): void {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $offset
|
||||||
|
* @return string|string[]
|
||||||
|
*/
|
||||||
|
public function offsetGet(mixed $offset): mixed {
|
||||||
|
if (!isset($this->data[$offset])) {
|
||||||
|
logError(__METHOD__.': '.$offset.' not found');
|
||||||
|
return '{'.$offset.'}';
|
||||||
|
}
|
||||||
|
return $this->data[$offset];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get string or plural strings.
|
||||||
|
* @param string $key Key.
|
||||||
|
* @param mixed ...$sprintf_args `sprintf` values. If this argument is
|
||||||
|
* supplied, then strings value is treated as `sprintf` format string
|
||||||
|
* and additional arguments as `sprintf` values.
|
||||||
|
* @return string|string[]
|
||||||
|
*/
|
||||||
|
public function get(string $key, mixed ...$sprintf_args): string|array {
|
||||||
|
$val = $this->offsetGet($key);
|
||||||
|
if (!empty($sprintf_args)) {
|
||||||
|
return sprintf($val, ...$sprintf_args);
|
||||||
|
} else {
|
||||||
|
return $val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pluralize by number.
|
||||||
|
* @param string|array{0:string,1:string,2:string,3:string} $key Strings key or plural array.
|
||||||
|
* @param int $num Count.
|
||||||
|
* @param array{format:bool|(\Closure(int $num):string),format_delim:string} $opts Additional options.
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function num(string|array $key, int $num, array $opts = []): string {
|
||||||
|
$s = is_array($key) ? $key : $this->offsetGet($key);
|
||||||
|
/** @var array{format:bool|(\Closure(int $num):string),format_delim:string} */
|
||||||
|
$opts = array_merge([
|
||||||
|
'format' => true,
|
||||||
|
'format_delim' => ' '
|
||||||
|
], $opts);
|
||||||
|
|
||||||
|
$n = $num % 100;
|
||||||
|
if ($n > 19)
|
||||||
|
$n %= 10;
|
||||||
|
|
||||||
|
if ($n == 1) {
|
||||||
|
$word = 0;
|
||||||
|
} elseif ($n >= 2 && $n <= 4) {
|
||||||
|
$word = 1;
|
||||||
|
} elseif ($num == 0 && count($s) == 4) {
|
||||||
|
$word = 3;
|
||||||
|
} else {
|
||||||
|
$word = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if zero
|
||||||
|
if ($word == 3)
|
||||||
|
return $s[3];
|
||||||
|
|
||||||
|
if (is_callable($opts['format'])) {
|
||||||
|
/** @var string */
|
||||||
|
$num = $opts['format']($num);
|
||||||
|
} else if ($opts['format'] === true) {
|
||||||
|
$num = formatNumber($num, $opts['format_delim']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf($s[$word], $num);
|
||||||
|
}
|
||||||
|
}
|
12
src/engine/lang/StringsPack.php
Normal file
12
src/engine/lang/StringsPack.php
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\lang;
|
||||||
|
|
||||||
|
class StringsPack extends StringsBase
|
||||||
|
{
|
||||||
|
public function __construct(protected array $data) {}
|
||||||
|
|
||||||
|
public function getData(): array {
|
||||||
|
return $this->data;
|
||||||
|
}
|
||||||
|
}
|
15
src/engine/logging/AnsiColor.php
Normal file
15
src/engine/logging/AnsiColor.php
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\logging;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
47
src/engine/logging/DatabaseLogger.php
Normal file
47
src/engine/logging/DatabaseLogger.php
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\logging;
|
||||||
|
|
||||||
|
class DatabaseLogger extends Logger
|
||||||
|
{
|
||||||
|
protected function writer(LogLevel $level,
|
||||||
|
int $num,
|
||||||
|
string $message,
|
||||||
|
?int $errno = null,
|
||||||
|
?string $errfile = null,
|
||||||
|
?string $errline = null,
|
||||||
|
?string $stacktrace = null): void
|
||||||
|
{
|
||||||
|
$db = getDB();
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'ts' => time(),
|
||||||
|
'num' => $num,
|
||||||
|
'time' => exectime(),
|
||||||
|
'errno' => $errno ?: 0,
|
||||||
|
'file' => $errfile ?: '?',
|
||||||
|
'line' => $errline ?: 0,
|
||||||
|
'text' => $message,
|
||||||
|
'level' => $level->value,
|
||||||
|
'stacktrace' => $stacktrace ?: Util::backtraceAsString(2),
|
||||||
|
'is_cli' => intval(isCli()),
|
||||||
|
'admin_id' => isAdmin() ? \app\Admin::getId() : 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isCli()) {
|
||||||
|
$data += [
|
||||||
|
'ip' => '',
|
||||||
|
'ua' => '',
|
||||||
|
'url' => '',
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$data += [
|
||||||
|
'ip' => ip2ulong($_SERVER['REMOTE_ADDR']),
|
||||||
|
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||||
|
'url' => $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->insert('backend_errors', $data);
|
||||||
|
}
|
||||||
|
}
|
87
src/engine/logging/FileLogger.php
Normal file
87
src/engine/logging/FileLogger.php
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\logging;
|
||||||
|
|
||||||
|
class FileLogger extends Logger
|
||||||
|
{
|
||||||
|
public function __construct(protected string $logFile) {}
|
||||||
|
|
||||||
|
protected function writer(LogLevel $level,
|
||||||
|
int $num,
|
||||||
|
string $message,
|
||||||
|
?int $errno = null,
|
||||||
|
?string $errfile = null,
|
||||||
|
?string $errline = null,
|
||||||
|
?string $stacktrace = null): void {
|
||||||
|
if (is_null($this->logFile)) {
|
||||||
|
fprintf(STDERR, __METHOD__.': logfile is not set');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$time = time();
|
||||||
|
|
||||||
|
// TODO rewrite using sprintf
|
||||||
|
$exec_time = strval(exectime());
|
||||||
|
if (strlen($exec_time) < 6)
|
||||||
|
$exec_time .= str_repeat('0', 6 - strlen($exec_time));
|
||||||
|
|
||||||
|
$title = isCli() ? 'cli' : $_SERVER['REQUEST_URI'];
|
||||||
|
$date = date('d/m/y H:i:s', $time);
|
||||||
|
|
||||||
|
$buf = '';
|
||||||
|
if ($num == 0) {
|
||||||
|
$buf .= Util::ansi(" $title ",
|
||||||
|
fg: AnsiColor::WHITE,
|
||||||
|
bg: AnsiColor::MAGENTA,
|
||||||
|
bold: true,
|
||||||
|
fg_bright: true);
|
||||||
|
$buf .= Util::ansi(" $date ", fg: AnsiColor::WHITE, bg: AnsiColor::BLUE, fg_bright: true);
|
||||||
|
$buf .= "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$letter = strtoupper($level->name[0]);
|
||||||
|
$color = match ($level) {
|
||||||
|
LogLevel::ERROR => AnsiColor::RED,
|
||||||
|
// LogLevel::INFO => AnsiColor::GREEN,
|
||||||
|
LogLevel::DEBUG => AnsiColor::WHITE,
|
||||||
|
LogLevel::WARNING => AnsiColor::YELLOW
|
||||||
|
};
|
||||||
|
|
||||||
|
$buf .= Util::ansi($letter.Util::ansi('='.Util::ansi($num, bold: true)), fg: $color).' ';
|
||||||
|
$buf .= Util::ansi($exec_time, fg: AnsiColor::CYAN).' ';
|
||||||
|
if (!is_null($errno)) {
|
||||||
|
$buf .= Util::ansi($errfile, fg: AnsiColor::GREEN);
|
||||||
|
$buf .= Util::ansi(':', fg: AnsiColor::WHITE);
|
||||||
|
$buf .= Util::ansi($errline, fg: AnsiColor::GREEN, fg_bright: true);
|
||||||
|
$buf .= ' ('.Util::getPHPErrorName($errno).') ';
|
||||||
|
}
|
||||||
|
|
||||||
|
$buf .= $message."\n";
|
||||||
|
if (in_array($level, [LogLevel::ERROR, LogLevel::WARNING]))
|
||||||
|
$buf .= ($stacktrace ?: Util::backtraceAsString(2))."\n";
|
||||||
|
|
||||||
|
$set_perm = false;
|
||||||
|
if (!file_exists($this->logFile)) {
|
||||||
|
$set_perm = true;
|
||||||
|
$dir = dirname($this->logFile);
|
||||||
|
echo "dir: $dir\n";
|
||||||
|
|
||||||
|
if (!file_exists($dir)) {
|
||||||
|
mkdir($dir);
|
||||||
|
setperm($dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$f = fopen($this->logFile, 'a');
|
||||||
|
if (!$f) {
|
||||||
|
fprintf(STDERR, __METHOD__.': failed to open file \''.$this->logFile.'\' for writing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite($f, $buf);
|
||||||
|
fclose($f);
|
||||||
|
|
||||||
|
if ($set_perm)
|
||||||
|
setperm($this->logFile);
|
||||||
|
}
|
||||||
|
}
|
11
src/engine/logging/LogLevel.php
Normal file
11
src/engine/logging/LogLevel.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\logging;
|
||||||
|
|
||||||
|
enum LogLevel: int
|
||||||
|
{
|
||||||
|
case ERROR = 10;
|
||||||
|
case WARNING = 5;
|
||||||
|
// case INFO = 3;
|
||||||
|
case DEBUG = 2;
|
||||||
|
}
|
101
src/engine/logging/Logger.php
Normal file
101
src/engine/logging/Logger.php
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\logging;
|
||||||
|
|
||||||
|
abstract class Logger
|
||||||
|
{
|
||||||
|
protected bool $enabled = false;
|
||||||
|
protected int $counter = 0;
|
||||||
|
protected int $recursionLevel = 0;
|
||||||
|
|
||||||
|
/** @var ?callable $filter */
|
||||||
|
protected $filter = null;
|
||||||
|
|
||||||
|
public function setErrorFilter(callable $filter): void {
|
||||||
|
$this->filter = $filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function disable(): void {
|
||||||
|
$this->enabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enable(): void {
|
||||||
|
static $error_handler_set = false;
|
||||||
|
$this->enabled = true;
|
||||||
|
|
||||||
|
if ($error_handler_set)
|
||||||
|
return;
|
||||||
|
|
||||||
|
$self = $this;
|
||||||
|
|
||||||
|
set_error_handler(function ($no, $str, $file, $line) use ($self) {
|
||||||
|
if (!$self->enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (is_callable($self->filter) && !($self->filter)($no, $file, $line, $str))
|
||||||
|
return;
|
||||||
|
|
||||||
|
static::write(LogLevel::ERROR, $str,
|
||||||
|
errno: $no,
|
||||||
|
errfile: $file,
|
||||||
|
errline: $line);
|
||||||
|
});
|
||||||
|
|
||||||
|
set_exception_handler(function (\Throwable $e): void {
|
||||||
|
static::write(LogLevel::ERROR, get_class($e).': '.$e->getMessage(),
|
||||||
|
errfile: $e->getFile() ?: '?',
|
||||||
|
errline: $e->getLine() ?: 0,
|
||||||
|
stacktrace: $e->getTraceAsString());
|
||||||
|
});
|
||||||
|
|
||||||
|
register_shutdown_function(function () use ($self) {
|
||||||
|
if (!$self->enabled || !($error = error_get_last()))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (is_callable($self->filter)
|
||||||
|
&& !($self->filter)($error['type'], $error['file'], $error['line'], $error['message'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
static::write(LogLevel::ERROR, $error['message'],
|
||||||
|
errno: $error['type'],
|
||||||
|
errfile: $error['file'],
|
||||||
|
errline: $error['line']);
|
||||||
|
});
|
||||||
|
|
||||||
|
$error_handler_set = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function log(LogLevel $level, ?string $stacktrace = null, ...$args): void {
|
||||||
|
if (!isDev() && $level == LogLevel::DEBUG)
|
||||||
|
return;
|
||||||
|
$this->write($level, Util::strVars($args),
|
||||||
|
stacktrace: $stacktrace);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canReport(): bool {
|
||||||
|
return $this->recursionLevel < 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function write(LogLevel $level,
|
||||||
|
string $message,
|
||||||
|
?int $errno = null,
|
||||||
|
?string $errfile = null,
|
||||||
|
?string $errline = null,
|
||||||
|
?string $stacktrace = null): void {
|
||||||
|
$this->recursionLevel++;
|
||||||
|
|
||||||
|
if ($this->canReport())
|
||||||
|
$this->writer($level, $this->counter++, $message, $errno, $errfile, $errline, $stacktrace);
|
||||||
|
|
||||||
|
$this->recursionLevel--;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract protected function writer(LogLevel $level,
|
||||||
|
int $num,
|
||||||
|
string $message,
|
||||||
|
?int $errno = null,
|
||||||
|
?string $errfile = null,
|
||||||
|
?string $errline = null,
|
||||||
|
?string $stacktrace = null): void;
|
||||||
|
}
|
68
src/engine/logging/Util.php
Normal file
68
src/engine/logging/Util.php
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\logging;
|
||||||
|
|
||||||
|
class Util
|
||||||
|
{
|
||||||
|
public static function getPHPErrorName(int $errno): ?string {
|
||||||
|
static $errors = null;
|
||||||
|
if (is_null($errors))
|
||||||
|
$errors = array_flip(array_slice(get_defined_constants(true)['Core'], 0, 15, true));
|
||||||
|
return $errors[$errno] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function strVarDump($var, bool $print_r = false): string {
|
||||||
|
ob_start();
|
||||||
|
$print_r ? print_r($var) : var_dump($var);
|
||||||
|
return trim(ob_get_clean());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function strVars(array $args): string {
|
||||||
|
$args = array_map(fn($a) => match (gettype($a)) {
|
||||||
|
'string' => $a,
|
||||||
|
'array', 'object' => self::strVarDump($a, true),
|
||||||
|
default => self::strVarDump($a)
|
||||||
|
}, $args);
|
||||||
|
return implode(' ', $args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function backtraceAsString(int $shift = 0): string {
|
||||||
|
$bt = debug_backtrace();
|
||||||
|
$lines = [];
|
||||||
|
foreach ($bt as $i => $t) {
|
||||||
|
if ($i < $shift)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!isset($t['file'])) {
|
||||||
|
$lines[] = 'from ?';
|
||||||
|
} else {
|
||||||
|
$lines[] = 'from '.$t['file'].':'.$t['line'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return implode("\n", $lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static 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";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getErrorFullStackTrace(\Throwable $error): string {
|
||||||
|
return '#_ '.$error->getFile().'('.$error->getLine().')'."\n".$error->getTraceAsString();
|
||||||
|
}
|
||||||
|
}
|
96
src/engine/skin/BaseSkin.php
Normal file
96
src/engine/skin/BaseSkin.php
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\skin;
|
||||||
|
|
||||||
|
use engine\lang\Strings;
|
||||||
|
use engine\skin\TwigAddons\SkinTwigExtension;
|
||||||
|
use Twig\Loader\LoaderInterface;
|
||||||
|
|
||||||
|
abstract class BaseSkin
|
||||||
|
{
|
||||||
|
protected array $vars = [];
|
||||||
|
protected array $globalVars = [];
|
||||||
|
protected bool $globalsApplied = false;
|
||||||
|
|
||||||
|
public readonly Strings $strings;
|
||||||
|
|
||||||
|
protected \Twig\Environment $twig;
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
global $config, $globalContext;
|
||||||
|
|
||||||
|
$cache_dir = $config['skin_cache_'.(isDev() ? 'dev' : 'prod').'_dir'].'/'.$globalContext->project;
|
||||||
|
if (!file_exists($cache_dir)) {
|
||||||
|
if (mkdir($cache_dir, $config['dirs_mode'], true))
|
||||||
|
setperm($cache_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->twig = new \Twig\Environment($this->getTwigLoader(), [
|
||||||
|
'cache' => $cache_dir,
|
||||||
|
'auto_reload' => isDev()
|
||||||
|
]);
|
||||||
|
$this->twig->addExtension(new SkinTwigExtension($this));
|
||||||
|
|
||||||
|
$this->strings = Strings::getInstance(); // why singleton here?
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract protected function getTwigLoader(): LoaderInterface;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyGlobals(): void {
|
||||||
|
if (!empty($this->globalVars) && !$this->globalsApplied) {
|
||||||
|
foreach ($this->globalVars as $key => $value)
|
||||||
|
$this->twig->addGlobal($key, $value);
|
||||||
|
$this->globalsApplied = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render($template, array $vars = []): string {
|
||||||
|
$this->applyGlobals();
|
||||||
|
return $this->doRender($template, $this->vars + $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<br><br>\n";
|
||||||
|
$s .= nl2br(htmlescape($e->getTraceAsString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
}
|
35
src/engine/skin/ErrorSkin.php
Normal file
35
src/engine/skin/ErrorSkin.php
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\skin;
|
||||||
|
|
||||||
|
use Twig\Loader\FilesystemLoader;
|
||||||
|
use Twig\Loader\LoaderInterface;
|
||||||
|
|
||||||
|
class ErrorSkin
|
||||||
|
extends BaseSkin
|
||||||
|
{
|
||||||
|
const string TEMPLATES_ROOT = APP_ROOT.'/src/skins/error';
|
||||||
|
|
||||||
|
protected function getTwigLoader(): LoaderInterface {
|
||||||
|
return new FilesystemLoader(self::TEMPLATES_ROOT, APP_ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function renderNotFound(): never {
|
||||||
|
global $globalContext;
|
||||||
|
if (isset($globalContext->project) && file_exists(self::TEMPLATES_ROOT.'/notfound_'.$globalContext->project.'.twig'))
|
||||||
|
$template = 'notfound_'.$globalContext->project.'.twig';
|
||||||
|
else
|
||||||
|
$template = 'notfound.twig';
|
||||||
|
echo $this->twig->render($template);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function renderError(string $title, ?string $message = null, ?string $stacktrace = null): never {
|
||||||
|
echo $this->twig->render('error.twig', [
|
||||||
|
'title' => $title,
|
||||||
|
'stacktrace' => $stacktrace,
|
||||||
|
'message' => $message,
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
296
src/engine/skin/FeaturedSkin.php
Normal file
296
src/engine/skin/FeaturedSkin.php
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\skin;
|
||||||
|
|
||||||
|
use app\ThemesUtil;
|
||||||
|
use engine\skin\TwigAddons\JsTagRuntime;
|
||||||
|
use engine\skin\TwigAddons\JsTwigExtension;
|
||||||
|
|
||||||
|
abstract class FeaturedSkin
|
||||||
|
extends BaseSkin
|
||||||
|
{
|
||||||
|
const array RESOURCE_INTEGRITY_HASHES = ['sha256', 'sha384', 'sha512'];
|
||||||
|
|
||||||
|
public array $exportedStrings = [];
|
||||||
|
|
||||||
|
protected array $js = [];
|
||||||
|
public array $static = [];
|
||||||
|
protected array $styleNames = [];
|
||||||
|
protected array $svgDefs = [];
|
||||||
|
public readonly Meta $meta;
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
parent::__construct();
|
||||||
|
$this->meta = new Meta();
|
||||||
|
|
||||||
|
$this->twig->addExtension(new JsTwigExtension($this));
|
||||||
|
$this->twig->addRuntimeLoader(new \Twig\RuntimeLoader\FactoryRuntimeLoader([
|
||||||
|
JsTagRuntime::class => function () {
|
||||||
|
return new JsTagRuntime($this);
|
||||||
|
},
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportStrings(array|string $keys): void {
|
||||||
|
$this->exportedStrings = array_merge($this->exportedStrings, is_string($keys) ? $this->strings->search($keys) : $keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.'/src/skins/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);
|
||||||
|
|
||||||
|
if ($has_url)
|
||||||
|
$buf .= '<a class="bc-item" href="'.htmlescape($i['url']).'">';
|
||||||
|
else
|
||||||
|
$buf .= '<span class="bc-item">';
|
||||||
|
$buf .= htmlescape($i['text']);
|
||||||
|
|
||||||
|
if ($has_url)
|
||||||
|
$buf .= ' <span class="bc-arrow">'.$chevron.'</span></a>';
|
||||||
|
else
|
||||||
|
$buf .= '</span>';
|
||||||
|
|
||||||
|
return $buf;
|
||||||
|
}, $items));
|
||||||
|
$class = 'bc';
|
||||||
|
if ($mt)
|
||||||
|
$class .= ' mt';
|
||||||
|
return '<div class="'.$class.'"'.($style ? ' style="'.$style.'"' : '').'>'.$buf.'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
$count = $opts['count'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$min_page = max(1, $page - 2);
|
||||||
|
$max_page = min($pages, $page + 2);
|
||||||
|
|
||||||
|
$pages_html = '';
|
||||||
|
$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)
|
||||||
|
$class .= ' is-page-cur';
|
||||||
|
$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::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::pageNavGetLink($pages, $link_template)).'" data-page="'.$pages.'" draggable="false">'.$pages.'</a>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$pn_class = 'pn';
|
||||||
|
if ($pages < 2) {
|
||||||
|
$pn_class .= ' no-nav';
|
||||||
|
if (!$count) {
|
||||||
|
$pn_class .= ' no-results';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$html = <<<HTML
|
||||||
|
<div class="{$pn_class}">
|
||||||
|
<div class="pn-buttons clearfix">
|
||||||
|
{$pages_html}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
HTML;
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function pageNavGetLink($page, $link_template) {
|
||||||
|
return is_callable($link_template) ? $link_template($page) : str_replace('{page}', $page, $link_template);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getSVGTags(): string {
|
||||||
|
$buf = '<svg style="display: none">';
|
||||||
|
foreach ($this->svgDefs as $name => $icon) {
|
||||||
|
$content = file_get_contents(APP_ROOT.'/src/skins/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 getHeaderStaticTags(): string {
|
||||||
|
$html = [];
|
||||||
|
$theme = ThemesUtil::getUserTheme();
|
||||||
|
$dark = $theme == 'dark' || ($theme == 'auto' && ThemesUtil::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->exportedStrings)) {
|
||||||
|
$lang = [];
|
||||||
|
foreach ($this->exportedStrings 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function jsLink(string $name): string {
|
||||||
|
list (, $bname) = $this->getStaticNameParts($name);
|
||||||
|
if (isDev()) {
|
||||||
|
$href = '/js.php?name='.urlencode($bname).'&v='.time();
|
||||||
|
} else {
|
||||||
|
$href = '/dist-js/'.$bname.'.js?v='.$this->getStaticVersion($name);
|
||||||
|
}
|
||||||
|
return '<script src="'.$href.'" type="text/javascript"'.$this->getStaticIntegrityAttribute($name).'></script>';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function cssLink(string $name, string $theme, &$bname = null): string {
|
||||||
|
list(, $bname) = $this->getStaticNameParts($name);
|
||||||
|
|
||||||
|
$config_name = 'css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css';
|
||||||
|
|
||||||
|
if (isDev()) {
|
||||||
|
$href = '/sass.php?name='.urlencode($bname).'&theme='.$theme.'&v='.time();
|
||||||
|
} else {
|
||||||
|
$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], self::RESOURCE_INTEGRITY_HASHES)).'"';
|
||||||
|
}
|
||||||
|
}
|
67
src/engine/skin/Meta.php
Normal file
67
src/engine/skin/Meta.php
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\skin;
|
||||||
|
|
||||||
|
class Meta
|
||||||
|
{
|
||||||
|
const array TWITTER_LIMITS = [
|
||||||
|
'title' => 70,
|
||||||
|
'description' => 200
|
||||||
|
];
|
||||||
|
|
||||||
|
public ?string $url = null;
|
||||||
|
public ?string $title = null;
|
||||||
|
public ?string $description = null;
|
||||||
|
public ?string $keywords = null;
|
||||||
|
public ?string $image = null;
|
||||||
|
protected array $social = [];
|
||||||
|
|
||||||
|
public function setSocial(string $name, string $value): void {
|
||||||
|
$this->social[$name] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHtml(): string {
|
||||||
|
$tags = [];
|
||||||
|
$add_og_twitter = function ($key, $value) use (&$tags) {
|
||||||
|
foreach (['og', 'twitter'] as $social) {
|
||||||
|
if ($social == 'twitter' && isset(self::TWITTER_LIMITS[$key])) {
|
||||||
|
if (mb_strlen($value) > self::TWITTER_LIMITS[$key])
|
||||||
|
$value = mb_substr($value, 0, self::TWITTER_LIMITS[$key] - 3).'...';
|
||||||
|
}
|
||||||
|
$tags[] = [
|
||||||
|
$social == 'twitter' ? 'name' : 'property' => $social.':'.$key,
|
||||||
|
'content' => $value
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (['url', 'title', 'image'] as $k) {
|
||||||
|
if ($this->$k !== null)
|
||||||
|
$add_og_twitter($k, $this->$k);
|
||||||
|
}
|
||||||
|
foreach (['description', 'keywords'] as $k) {
|
||||||
|
if ($this->$k !== null) {
|
||||||
|
$add_og_twitter($k, $this->$k);
|
||||||
|
$tags[] = ['name' => $k, 'content' => $this->$k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($this->social)) {
|
||||||
|
foreach ($this->social as $key => $value) {
|
||||||
|
if (str_starts_with($key, 'og:')) {
|
||||||
|
$tags[] = ['property' => $key, 'content' => $value];
|
||||||
|
} else {
|
||||||
|
logWarning("unsupported meta: $key => $value");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}, $tags));
|
||||||
|
}
|
||||||
|
}
|
15
src/engine/skin/Options.php
Normal file
15
src/engine/skin/Options.php
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\skin;
|
||||||
|
|
||||||
|
abstract class Options
|
||||||
|
{
|
||||||
|
public function getOptions(): array {
|
||||||
|
$opts = [];
|
||||||
|
foreach (get_object_vars($this) as $prop => $value) {
|
||||||
|
$snake = strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1_$2', $prop));
|
||||||
|
$opts[$snake] = $value;
|
||||||
|
}
|
||||||
|
return $opts;
|
||||||
|
}
|
||||||
|
}
|
14
src/engine/skin/ServiceSkin.php
Normal file
14
src/engine/skin/ServiceSkin.php
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\skin;
|
||||||
|
|
||||||
|
use Twig\Loader\LoaderInterface;
|
||||||
|
|
||||||
|
class ServiceSkin
|
||||||
|
extends BaseSkin
|
||||||
|
{
|
||||||
|
|
||||||
|
protected function getTwigLoader(): LoaderInterface {
|
||||||
|
// TODO: Implement getTwigLoader() method.
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace TwigAddons;
|
namespace engine\skin\TwigAddons;
|
||||||
|
|
||||||
#[\Twig\Attribute\YieldReady]
|
#[\Twig\Attribute\YieldReady]
|
||||||
class JsTagNode extends \Twig\Node\Node {
|
class JsTagNode extends \Twig\Node\Node
|
||||||
|
{
|
||||||
public function compile(\Twig\Compiler $compiler) {
|
public function compile(\Twig\Compiler $compiler) {
|
||||||
$count = count($this->getNode('params'));
|
$count = count($this->getNode('params'));
|
||||||
|
|
||||||
@ -30,10 +31,10 @@ class JsTagNode extends \Twig\Node\Node {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$compiler
|
$compiler
|
||||||
->write('skin::getInstance()->addJS($js);')
|
// ->write('$context[\'__skin_ref\']->addJS($js);')
|
||||||
|
->write('$this->env->getRuntime(\''.JsTagRuntime::class.'\')->addJS($js);')
|
||||||
->raw(PHP_EOL)
|
->raw(PHP_EOL)
|
||||||
->write('unset($js);')
|
->write('unset($js);')
|
||||||
->raw(PHP_EOL);
|
->raw(PHP_EOL);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace TwigAddons;
|
namespace engine\skin\TwigAddons;
|
||||||
|
|
||||||
#[\Twig\Attribute\YieldReady]
|
#[\Twig\Attribute\YieldReady]
|
||||||
class JsTagParamsNode extends \Twig\Node\Node {}
|
class JsTagParamsNode extends \Twig\Node\Node {}
|
18
src/engine/skin/TwigAddons/JsTagRuntime.php
Normal file
18
src/engine/skin/TwigAddons/JsTagRuntime.php
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\skin\TwigAddons;
|
||||||
|
|
||||||
|
use engine\skin\BaseSkin;
|
||||||
|
|
||||||
|
class JsTagRuntime
|
||||||
|
{
|
||||||
|
private BaseSkin $skin;
|
||||||
|
|
||||||
|
public function __construct(BaseSkin $skin) {
|
||||||
|
$this->skin = $skin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addJS(string $js): void {
|
||||||
|
$this->skin->addJS($js);
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace TwigAddons;
|
namespace engine\skin\TwigAddons;
|
||||||
|
|
||||||
// Based on https://stackoverflow.com/questions/26170727/how-to-create-a-twig-custom-tag-that-executes-a-callback
|
// Based on https://stackoverflow.com/questions/26170727/how-to-create-a-twig-custom-tag-that-executes-a-callback
|
||||||
class JsTagTokenParser extends \Twig\TokenParser\AbstractTokenParser {
|
|
||||||
|
class JsTagTokenParser extends \Twig\TokenParser\AbstractTokenParser
|
||||||
|
{
|
||||||
public function parse(\Twig\Token $token) {
|
public function parse(\Twig\Token $token) {
|
||||||
$lineno = $token->getLine();
|
$lineno = $token->getLine();
|
||||||
$stream = $this->parser->getStream();
|
$stream = $this->parser->getStream();
|
||||||
@ -80,5 +82,4 @@ class JsTagTokenParser extends \Twig\TokenParser\AbstractTokenParser {
|
|||||||
public function getTag() {
|
public function getTag() {
|
||||||
return 'js';
|
return 'js';
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
16
src/engine/skin/TwigAddons/JsTwigExtension.php
Normal file
16
src/engine/skin/TwigAddons/JsTwigExtension.php
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace engine\skin\TwigAddons;
|
||||||
|
|
||||||
|
use engine\skin\BaseSkin;
|
||||||
|
use Twig\Extension\AbstractExtension;
|
||||||
|
|
||||||
|
class JsTwigExtension
|
||||||
|
extends AbstractExtension
|
||||||
|
{
|
||||||
|
public function __construct(protected BaseSkin $skin) {}
|
||||||
|
|
||||||
|
public function getTokenParsers() {
|
||||||
|
return [new JsTagTokenParser()];
|
||||||
|
}
|
||||||
|
}
|
@ -1,47 +1,52 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace TwigAddons;
|
namespace engine\skin\TwigAddons;
|
||||||
|
|
||||||
|
use engine\skin\BaseSkin;
|
||||||
use Twig\Extension\AbstractExtension;
|
use Twig\Extension\AbstractExtension;
|
||||||
use Twig\TwigFilter;
|
use Twig\TwigFilter;
|
||||||
use Twig\TwigFunction;
|
use Twig\TwigFunction;
|
||||||
|
|
||||||
class MyExtension extends AbstractExtension {
|
class SkinTwigExtension
|
||||||
|
extends AbstractExtension
|
||||||
|
{
|
||||||
|
public function __construct(protected BaseSkin $skin) {}
|
||||||
|
|
||||||
public function getFunctions() {
|
public function getFunctions() {
|
||||||
return [
|
return [
|
||||||
new TwigFunction('svg', fn($name) => \skin::getInstance()->getSVG($name),
|
new TwigFunction('svg', fn($name) => $this->skin->getSVG($name),
|
||||||
['is_safe' => ['html']]),
|
['is_safe' => ['html']]),
|
||||||
|
|
||||||
new TwigFunction('svgInPlace', fn($name) => \skin::getInstance()->getSVG($name, in_place: true),
|
new TwigFunction('svgInPlace', fn($name) => $this->skin->getSVG($name, in_place: true),
|
||||||
['is_safe' => ['html']]),
|
['is_safe' => ['html']]),
|
||||||
|
|
||||||
new TwigFunction('svgPreload', function(...$icons) {
|
new TwigFunction('svgPreload', function(...$icons) {
|
||||||
$skin = \skin::getInstance();
|
|
||||||
foreach ($icons as $icon)
|
foreach ($icons as $icon)
|
||||||
$skin->preloadSVG($icon);
|
$this->skin->preloadSVG($icon);
|
||||||
return null;
|
return null;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
new TwigFunction('bc', fn(...$args) => \skin::getInstance()->renderBreadCrumbs(...$args),
|
new TwigFunction('bc', fn(...$args) => $this->skin->renderBreadCrumbs(...$args),
|
||||||
['is_safe' => ['html']]),
|
['is_safe' => ['html']]),
|
||||||
|
|
||||||
new TwigFunction('pageNav', fn(...$args) => \skin::getInstance()->renderPageNav(...$args),
|
new TwigFunction('pageNav', fn(...$args) => $this->skin->renderPageNav(...$args),
|
||||||
['is_safe' => ['html']]),
|
['is_safe' => ['html']]),
|
||||||
|
|
||||||
new TwigFunction('csrf', fn($value) => \request_handler::getCSRF($value))
|
new TwigFunction('csrf', function($value) {
|
||||||
|
global $globalContext;
|
||||||
|
return $globalContext->requestHandler->getCSRF($value);
|
||||||
|
})
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getFilters() {
|
public function getFilters() {
|
||||||
return array(
|
return [
|
||||||
new TwigFilter('lang', function($key, array $args = []) {
|
new TwigFilter('lang', function($key, array $args = []) {
|
||||||
global $__lang;
|
|
||||||
array_walk($args, function(&$item, $key) {
|
array_walk($args, function(&$item, $key) {
|
||||||
$item = htmlescape($item);
|
$item = htmlescape($item);
|
||||||
});
|
});
|
||||||
array_unshift($args, $key);
|
array_unshift($args, $key);
|
||||||
return call_user_func_array([$__lang, 'get'], $args);
|
return $this->skin->strings->get(...$args);
|
||||||
}, ['is_variadic' => true]),
|
}, ['is_variadic' => true]),
|
||||||
|
|
||||||
new TwigFilter('hl', function($s, $keywords) {
|
new TwigFilter('hl', function($s, $keywords) {
|
||||||
@ -49,19 +54,9 @@ class MyExtension extends AbstractExtension {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
new TwigFilter('plural', function($text, array $args = []) {
|
new TwigFilter('plural', function($text, array $args = []) {
|
||||||
global $__lang;
|
|
||||||
array_unshift($args, $text);
|
array_unshift($args, $text);
|
||||||
return call_user_func_array([$__lang, 'num'], $args);
|
return $this->skin->strings->num(...$args);
|
||||||
}, ['is_variadic' => true]),
|
}, ['is_variadic' => true]),
|
||||||
);
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTokenParsers() {
|
|
||||||
return [new JsTagTokenParser()];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getName() {
|
|
||||||
return 'lang';
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
81
src/engine_functions.php
Normal file
81
src/engine_functions.php
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
function getDB(): engine\MySQL|null {
|
||||||
|
global $config;
|
||||||
|
|
||||||
|
/** @var ?engine\MySQL $link */
|
||||||
|
static $link = null;
|
||||||
|
if (!is_null($link))
|
||||||
|
return $link;
|
||||||
|
|
||||||
|
$link = new engine\MySQL(
|
||||||
|
$config['mysql']['host'],
|
||||||
|
$config['mysql']['user'],
|
||||||
|
$config['mysql']['password'],
|
||||||
|
$config['mysql']['database']);
|
||||||
|
if (!$link->connect()) {
|
||||||
|
if (!isCli()) {
|
||||||
|
header('HTTP/1.1 503 Service Temporarily Unavailable');
|
||||||
|
header('Status: 503 Service Temporarily Unavailable');
|
||||||
|
header('Retry-After: 300');
|
||||||
|
die('database connection failed');
|
||||||
|
} else {
|
||||||
|
fwrite(STDERR, 'database connection failed');
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $link;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMC(): Memcached {
|
||||||
|
static $mc = null;
|
||||||
|
if ($mc === null) {
|
||||||
|
$mc = new Memcached();
|
||||||
|
$mc->addServer("127.0.0.1", 11211);
|
||||||
|
}
|
||||||
|
return $mc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logDebug(...$args): void {
|
||||||
|
global $globalContext;
|
||||||
|
$globalContext->logger->log(engine\logging\LogLevel::DEBUG, null, ...$args);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logWarning(...$args): void {
|
||||||
|
global $globalContext;
|
||||||
|
$globalContext->logger->log(engine\logging\LogLevel::WARNING, null, ...$args);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logError(...$args): void {
|
||||||
|
global $globalContext;
|
||||||
|
if (array_key_exists('stacktrace', $args)) {
|
||||||
|
$st = $args['stacktrace'];
|
||||||
|
unset($args['stacktrace']);
|
||||||
|
} else {
|
||||||
|
$st = null;
|
||||||
|
}
|
||||||
|
if ($globalContext->logger->canReport())
|
||||||
|
$globalContext->logger->log(engine\logging\LogLevel::ERROR, $st, ...$args);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lang(...$args) {
|
||||||
|
global $globalContext;
|
||||||
|
return $globalContext->getStrings()->get(...$args);
|
||||||
|
}
|
||||||
|
|
||||||
|
function langNum(...$args) {
|
||||||
|
global $globalContext;
|
||||||
|
return $globalContext->getStrings()->num(...$args);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDev(): bool { global $globalContext; return $globalContext->isDevelopmentEnvironment; }
|
||||||
|
function isCli(): bool { return PHP_SAPI == 'cli'; };
|
||||||
|
function isRetina(): bool { return isset($_COOKIE['is_retina']) && $_COOKIE['is_retina']; }
|
||||||
|
function isXHRRequest() { return isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'; }
|
||||||
|
|
||||||
|
function isAdmin(): bool {
|
||||||
|
if (app\Admin::getId() === null)
|
||||||
|
app\Admin::check();
|
||||||
|
return app\Admin::getId() != 0;
|
||||||
|
}
|
@ -1,32 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
function verifyHostname(?string $host = null): void {
|
|
||||||
global $config;
|
|
||||||
|
|
||||||
if ($host === null) {
|
|
||||||
$host = $_SERVER['HTTP_HOST'];
|
|
||||||
|
|
||||||
// IE moment
|
|
||||||
if (($pos = strpos($host, ':')) !== false)
|
|
||||||
$host = substr($host, 0, $pos);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!str_ends_with($host, $config['domain']))
|
|
||||||
throw new RuntimeException('invalid http_host '.$host);
|
|
||||||
|
|
||||||
if (strlen($host) > ($orig_domain_len = strlen($config['domain']))) {
|
|
||||||
$sub = substr($host, 0, -$orig_domain_len-1);
|
|
||||||
if (in_array($sub, $config['dev_domains'])) {
|
|
||||||
$config['is_dev'] = true;
|
|
||||||
} else if (!in_array($sub, $config['subdomains'])) {
|
|
||||||
throw new RuntimeException('invalid subdomain '.$sub);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCli() && str_ends_with(__DIR__, 'www-dev'))
|
|
||||||
$config['is_dev'] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function htmlescape(string|array $s): string|array {
|
function htmlescape(string|array $s): string|array {
|
||||||
if (is_array($s)) {
|
if (is_array($s)) {
|
||||||
foreach ($s as $k => $v) {
|
foreach ($s as $k => $v) {
|
||||||
@ -38,11 +11,11 @@ function htmlescape(string|array $s): string|array {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sizeString(int $size): string {
|
function sizeString(int $size): string {
|
||||||
$ks = array('B', 'KiB', 'MiB', 'GiB');
|
$ks = ['B', 'KiB', 'MiB', 'GiB'];
|
||||||
foreach ($ks as $i => $k) {
|
foreach ($ks as $i => $k) {
|
||||||
if ($size < pow(1024, $i + 1)) {
|
if ($size < pow(1024, $i + 1)) {
|
||||||
if ($i == 0)
|
if ($i == 0)
|
||||||
return $size . ' ' . $k;
|
return $size.' '.$k;
|
||||||
return round($size / pow(1024, $i), 2).' '.$k;
|
return round($size / pow(1024, $i), 2).' '.$k;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -83,20 +56,20 @@ function detectImageType(string $filename) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function transliterate(string $string): string {
|
function transliterate(string $string): string {
|
||||||
$roman = array(
|
$roman = [
|
||||||
'Sch', 'sch', 'Yo', 'Zh', 'Kh', 'Ts', 'Ch', 'Sh', 'Yu', 'ya', 'yo',
|
'Sch', 'sch', 'Yo', 'Zh', 'Kh', 'Ts', 'Ch', 'Sh', 'Yu', 'ya', 'yo',
|
||||||
'zh', 'kh', 'ts', 'ch', 'sh', 'yu', 'ya', 'A', 'B', 'V', 'G', 'D', 'E',
|
'zh', 'kh', 'ts', 'ch', 'sh', 'yu', 'ya', 'A', 'B', 'V', 'G', 'D', 'E',
|
||||||
'Z', 'I', 'Y', 'K', 'L', 'M', 'N', 'O', 'P', 'R', 'S', 'T', 'U', 'F',
|
'Z', 'I', 'Y', 'K', 'L', 'M', 'N', 'O', 'P', 'R', 'S', 'T', 'U', 'F',
|
||||||
'', 'Y', '', 'E', 'a', 'b', 'v', 'g', 'd', 'e', 'z', 'i', 'y', 'k',
|
'', 'Y', '', 'E', 'a', 'b', 'v', 'g', 'd', 'e', 'z', 'i', 'y', 'k',
|
||||||
'l', 'm', 'n', 'o', 'p', 'r', 's', 't', 'u', 'f', '', 'y', '', 'e'
|
'l', 'm', 'n', 'o', 'p', 'r', 's', 't', 'u', 'f', '', 'y', '', 'e'
|
||||||
);
|
];
|
||||||
$cyrillic = array(
|
$cyrillic = [
|
||||||
'Щ', 'щ', 'Ё', 'Ж', 'Х', 'Ц', 'Ч', 'Ш', 'Ю', 'я', 'ё', 'ж', 'х', 'ц',
|
'Щ', 'щ', 'Ё', 'Ж', 'Х', 'Ц', 'Ч', 'Ш', 'Ю', 'я', 'ё', 'ж', 'х', 'ц',
|
||||||
'ч', 'ш', 'ю', 'я', 'А', 'Б', 'В', 'Г', 'Д', 'Е', 'З', 'И', 'Й', 'К',
|
'ч', 'ш', 'ю', 'я', 'А', 'Б', 'В', 'Г', 'Д', 'Е', 'З', 'И', 'Й', 'К',
|
||||||
'Л', 'М', 'Н', 'О', 'П', 'Р', 'С', 'Т', 'У', 'Ф', 'Ь', 'Ы', 'Ъ', 'Э',
|
'Л', 'М', 'Н', 'О', 'П', 'Р', 'С', 'Т', 'У', 'Ф', 'Ь', 'Ы', 'Ъ', 'Э',
|
||||||
'а', 'б', 'в', 'г', 'д', 'е', 'з', 'и', 'й', 'к', 'л', 'м', 'н', 'о',
|
'а', 'б', 'в', 'г', 'д', 'е', 'з', 'и', 'й', 'к', 'л', 'м', 'н', 'о',
|
||||||
'п', 'р', 'с', 'т', 'у', 'ф', 'ь', 'ы', 'ъ', 'э'
|
'п', 'р', 'с', 'т', 'у', 'ф', 'ь', 'ы', 'ъ', 'э'
|
||||||
);
|
];
|
||||||
return str_replace($cyrillic, $roman, $string);
|
return str_replace($cyrillic, $roman, $string);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -256,25 +229,6 @@ function formatNumber(int|float $num, string $delim = ' ', bool $short = false):
|
|||||||
return number_format($num, 0, '.', $delim);
|
return number_format($num, 0, '.', $delim);
|
||||||
}
|
}
|
||||||
|
|
||||||
function lang() {
|
|
||||||
global $__lang;
|
|
||||||
return call_user_func_array([$__lang, 'get'], func_get_args());
|
|
||||||
}
|
|
||||||
function langNum() {
|
|
||||||
global $__lang;
|
|
||||||
return call_user_func_array([$__lang, 'num'], func_get_args());
|
|
||||||
}
|
|
||||||
|
|
||||||
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 jsonEncode($obj): ?string { return json_encode($obj, JSON_UNESCAPED_UNICODE) ?: null; }
|
||||||
function jsonDecode($json) { return json_decode($json, true); }
|
function jsonDecode($json) { return json_decode($json, true); }
|
||||||
|
|
||||||
@ -350,7 +304,7 @@ function highlightSubstring(string $s, string|array|null $keywords = []): string
|
|||||||
return $buf;
|
return $buf;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime($ts, array $opts = array()) {
|
function formatTime($ts, array $opts = []) {
|
||||||
$default_opts = [
|
$default_opts = [
|
||||||
'date_only' => false,
|
'date_only' => false,
|
||||||
'day_of_week' => false,
|
'day_of_week' => false,
|
@ -1,58 +1,72 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
class AdminHandler extends request_handler {
|
namespace app\foreignone;
|
||||||
|
|
||||||
|
use DateTime;
|
||||||
|
use app\Admin;
|
||||||
|
use app\MarkupUtil;
|
||||||
|
use engine\exceptions\ParseFormException;
|
||||||
|
use engine\http\AjaxError;
|
||||||
|
use engine\http\AjaxOk;
|
||||||
|
use engine\http\errors\Forbidden;
|
||||||
|
use engine\http\errors\NotFound;
|
||||||
|
use engine\http\errors\PermanentRedirect;
|
||||||
|
use engine\http\errors\Redirect;
|
||||||
|
use engine\http\Response;
|
||||||
|
|
||||||
|
class AdminHandler
|
||||||
|
extends BaseHandler
|
||||||
|
{
|
||||||
public function __construct() {
|
public function __construct() {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->skin->addStatic('css/admin.css', 'js/admin.js');
|
$this->skin->addStatic('css/admin.css', 'js/admin.js');
|
||||||
$this->skin->exportStrings(['error']);
|
$this->skin->exportStrings(['error']);
|
||||||
$this->skin->setRenderOptions(['inside_admin_interface' => true]);
|
$this->skin->options->insideAdminInterface = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function beforeDispatch(string $http_method, string $action) {
|
public function beforeDispatch(string $http_method, string $action) {
|
||||||
if ($action != 'login' && !isAdmin())
|
if ($action != 'login' && !isAdmin())
|
||||||
self::forbidden();
|
throw new Forbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function GET_index() {
|
public function GET_index(): Response {
|
||||||
//$admin_info = admin_current_info();
|
$this->skin->title = lang('admin_title');
|
||||||
$this->skin->setTitle('$admin_title');
|
return $this->skin->renderPage('admin_index.twig', [
|
||||||
$this->skin->renderPage('admin_index.twig', [
|
'admin_login' => Admin::getLogin(),
|
||||||
'admin_login' => admin::getLogin(),
|
'logout_token' => $this->getCSRF('logout'),
|
||||||
'logout_token' => self::getCSRF('logout'),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function GET_login() {
|
public function GET_login(): Response {
|
||||||
if (isAdmin())
|
if (isAdmin())
|
||||||
self::redirect('/admin/');
|
throw new Redirect('/admin/');
|
||||||
$this->skin->setTitle('$admin_title');
|
$this->skin->title = lang('admin_title');
|
||||||
$this->skin->renderPage('admin_login.twig', [
|
return $this->skin->renderPage('admin_login.twig', [
|
||||||
'form_token' => self::getCSRF('adminlogin'),
|
'form_token' => $this->getCSRF('adminlogin'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function POST_login() {
|
public function POST_login(): Response {
|
||||||
self::checkCSRF('adminlogin');
|
$this->checkCSRF('adminlogin');
|
||||||
list($login, $password) = $this->input('login, password');
|
list($login, $password) = $this->input('login, password');
|
||||||
admin::auth($login, $password)
|
if (Admin::auth($login, $password))
|
||||||
? self::redirect('/admin/')
|
throw new PermanentRedirect('/admin/');
|
||||||
: self::forbidden();
|
throw new Forbidden('');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function GET_logout() {
|
public function GET_logout(): Response {
|
||||||
self::checkCSRF('logout');
|
$this->checkCSRF('logout');
|
||||||
admin::logout();
|
Admin::logout();
|
||||||
self::redirect('/admin/login/', HTTPCode::Found);
|
throw new Redirect('/admin/login/');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function GET_errors() {
|
public function GET_errors(): Response {
|
||||||
list($ip, $query, $url_query, $file_query, $line_query, $per_page)
|
list($ip, $query, $url_query, $file_query, $line_query, $per_page)
|
||||||
= $this->input('i:ip, query, url_query, file_query, i:line_query, i:per_page');
|
= $this->input('i:ip, query, url_query, file_query, i:line_query, i:per_page');
|
||||||
|
|
||||||
if (!$per_page)
|
if (!$per_page)
|
||||||
$per_page = 100;
|
$per_page = 100;
|
||||||
$db = DB();
|
$db = getDB();
|
||||||
|
|
||||||
$query = trim($query ?? '');
|
$query = trim($query ?? '');
|
||||||
$url_query = trim($url_query ?? '');
|
$url_query = trim($url_query ?? '');
|
||||||
@ -93,7 +107,7 @@ class AdminHandler extends request_handler {
|
|||||||
'short_months' => true,
|
'short_months' => true,
|
||||||
]);
|
]);
|
||||||
$row['full_url'] = !str_starts_with($row['url'], 'https://') ? 'https://'.$row['url'] : $row['url'];
|
$row['full_url'] = !str_starts_with($row['url'], 'https://') ? 'https://'.$row['url'] : $row['url'];
|
||||||
$error_name = getPHPErrorName((int)$row['errno']);
|
$error_name = \engine\logging\Util::getPHPErrorName((int)$row['errno']);
|
||||||
if (!is_null($error_name))
|
if (!is_null($error_name))
|
||||||
$row['errtype'] = $error_name;
|
$row['errtype'] = $error_name;
|
||||||
$list[] = $row;
|
$list[] = $row;
|
||||||
@ -145,13 +159,13 @@ class AdminHandler extends request_handler {
|
|||||||
$vars += [$query_var_name => $$query_var_name];
|
$vars += [$query_var_name => $$query_var_name];
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->skin->setRenderOptions(['wide' => true]);
|
$this->skin->options->wide = true;
|
||||||
$this->skin->setTitle('$admin_errors');
|
$this->skin->title = lang('admin_errors');
|
||||||
$this->skin->renderPage('admin_errors.twig', $vars);
|
return $this->skin->renderPage('admin_errors.twig', $vars);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function GET_auth_log() {
|
public function GET_auth_log(): Response {
|
||||||
$db = DB();
|
$db = getDB();
|
||||||
$count = (int)$db->result($db->query("SELECT COUNT(*) FROM admin_log"));
|
$count = (int)$db->result($db->query("SELECT COUNT(*) FROM admin_log"));
|
||||||
$per_page = 100;
|
$per_page = 100;
|
||||||
list($page, $pages, $offset) = $this->getPage($per_page, $count);
|
list($page, $pages, $offset) = $this->getPage($per_page, $count);
|
||||||
@ -174,18 +188,18 @@ class AdminHandler extends request_handler {
|
|||||||
}, $list);
|
}, $list);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->skin->setRenderOptions(['wide' => true]);
|
$this->skin->options->wide = true;
|
||||||
$this->skin->setTitle('$admin_auth_log');
|
$this->skin->title = lang('admin_auth_log');
|
||||||
$this->skin->set([
|
$this->skin->set([
|
||||||
'list' => $list,
|
'list' => $list,
|
||||||
'pn_page' => $page,
|
'pn_page' => $page,
|
||||||
'pn_pages' => $pages
|
'pn_pages' => $pages
|
||||||
]);
|
]);
|
||||||
$this->skin->renderPage('admin_auth_log.twig');
|
return $this->skin->renderPage('admin_auth_log.twig');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function GET_actions_log() {
|
public function GET_actions_log(): Response {
|
||||||
$field_types = \AdminActions\Util\Logger::getFieldTypes();
|
$field_types = \app\AdminActions\Util\Logger::getFieldTypes();
|
||||||
foreach ($field_types as $type_prefix => $type_data) {
|
foreach ($field_types as $type_prefix => $type_data) {
|
||||||
for ($i = 1; $i <= $type_data['count']; $i++) {
|
for ($i = 1; $i <= $type_data['count']; $i++) {
|
||||||
$name = $type_prefix.'arg'.$i;
|
$name = $type_prefix.'arg'.$i;
|
||||||
@ -196,13 +210,13 @@ class AdminHandler extends request_handler {
|
|||||||
|
|
||||||
$per_page = 100;
|
$per_page = 100;
|
||||||
|
|
||||||
$count = \AdminActions\Util\Logger::getRecordsCount();
|
$count = \app\AdminActions\Util\Logger::getRecordsCount();
|
||||||
list($page, $pages, $offset) = $this->getPage($per_page, $count);
|
list($page, $pages, $offset) = $this->getPage($per_page, $count);
|
||||||
|
|
||||||
$admin_ids = [];
|
$admin_ids = [];
|
||||||
$admin_logins = [];
|
$admin_logins = [];
|
||||||
|
|
||||||
$records = \AdminActions\Util\Logger::getRecords($offset, $per_page);
|
$records = \app\AdminActions\Util\Logger::getRecords($offset, $per_page);
|
||||||
foreach ($records as $record) {
|
foreach ($records as $record) {
|
||||||
list($admin_id) = $record->getActorInfo();
|
list($admin_id) = $record->getActorInfo();
|
||||||
if ($admin_id !== null)
|
if ($admin_id !== null)
|
||||||
@ -210,7 +224,7 @@ class AdminHandler extends request_handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($admin_ids))
|
if (!empty($admin_ids))
|
||||||
$admin_logins = admin::getLoginsById(array_keys($admin_ids));
|
$admin_logins = Admin::getLoginsById(array_keys($admin_ids));
|
||||||
|
|
||||||
$url = '/admin/actions-log/?';
|
$url = '/admin/actions-log/?';
|
||||||
|
|
||||||
@ -222,37 +236,37 @@ class AdminHandler extends request_handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->skin->setRenderOptions(['wide' => true]);
|
$this->skin->options->wide = true;
|
||||||
$this->skin->setTitle('$admin_actions_log');
|
$this->skin->title = lang('admin_actions_log');
|
||||||
$this->skin->renderPage('admin_actions_log.twig', [
|
return $this->skin->renderPage('admin_actions_log.twig', [
|
||||||
'list' => $records,
|
'list' => $records,
|
||||||
'pn_page' => $page,
|
'pn_page' => $page,
|
||||||
'pn_pages' => $pages,
|
'pn_pages' => $pages,
|
||||||
'admin_logins' => $admin_logins,
|
'admin_logins' => $admin_logins,
|
||||||
'url' => $url,
|
'url' => $url,
|
||||||
'action_types' => \AdminActions\Util\Logger::getActions(true),
|
'action_types' => \app\AdminActions\Util\Logger::getActions(true),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function GET_uploads() {
|
public function GET_uploads(): Response {
|
||||||
list($error) = $this->input('error');
|
list($error) = $this->input('error');
|
||||||
$uploads = uploads::getAllUploads();
|
$uploads = Upload::getAllUploads();
|
||||||
|
|
||||||
$this->skin->setTitle('$blog_upload');
|
$this->skin->title = lang('blog_upload');
|
||||||
$this->skin->renderPage('admin_uploads.twig', [
|
return $this->skin->renderPage('admin_uploads.twig', [
|
||||||
'error' => $error,
|
'error' => $error,
|
||||||
'uploads' => $uploads,
|
'uploads' => $uploads,
|
||||||
'langs' => PostLanguage::casesAsStrings(),
|
'langs' => PostLanguage::casesAsStrings(),
|
||||||
'form_token' => self::getCSRF('add_upload'),
|
'form_token' => $this->getCSRF('add_upload'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function POST_uploads() {
|
public function POST_uploads(): Response {
|
||||||
self::checkCSRF('add_upload');
|
$this->checkCSRF('add_upload');
|
||||||
list($custom_name, $note_en, $note_ru) = $this->input('name, note_en, note_ru');
|
list($custom_name, $note_en, $note_ru) = $this->input('name, note_en, note_ru');
|
||||||
|
|
||||||
if (!isset($_FILES['files']))
|
if (!isset($_FILES['files']))
|
||||||
self::redirect('/admin/uploads/?error='.urlencode('no file'));
|
throw new Redirect('/admin/uploads/?error='.urlencode('no file'));
|
||||||
|
|
||||||
$files = [];
|
$files = [];
|
||||||
for ($i = 0; $i < count($_FILES['files']['name']); $i++) {
|
for ($i = 0; $i < count($_FILES['files']['name']); $i++) {
|
||||||
@ -273,56 +287,56 @@ class AdminHandler extends request_handler {
|
|||||||
|
|
||||||
foreach ($files as $f) {
|
foreach ($files as $f) {
|
||||||
if ($f['error'])
|
if ($f['error'])
|
||||||
self::redirect('/admin/uploads/?error='.urlencode('error code '.$f['error']));
|
throw new Redirect('/admin/uploads/?error='.urlencode('error code '.$f['error']));
|
||||||
|
|
||||||
if (!$f['size'])
|
if (!$f['size'])
|
||||||
self::redirect('/admin/uploads/?error='.urlencode('received empty file'));
|
throw new Redirect('/admin/uploads/?error='.urlencode('received empty file'));
|
||||||
|
|
||||||
$ext = extension($f['name']);
|
$ext = extension($f['name']);
|
||||||
if (!uploads::isExtensionAllowed($ext))
|
if (!Upload::isExtensionAllowed($ext))
|
||||||
self::redirect('/admin/uploads/?error='.urlencode('extension not allowed'));
|
throw new Redirect('/admin/uploads/?error='.urlencode('extension not allowed'));
|
||||||
|
|
||||||
$name = $custom_name ?: $f['name'];
|
$name = $custom_name ?: $f['name'];
|
||||||
$upload_id = uploads::add(
|
$upload_id = Upload::add(
|
||||||
$f['tmp_name'],
|
$f['tmp_name'],
|
||||||
$name,
|
$name,
|
||||||
$note_en,
|
$note_en,
|
||||||
$note_ru);
|
$note_ru);
|
||||||
|
|
||||||
if (!$upload_id)
|
if (!$upload_id)
|
||||||
self::redirect('/admin/uploads/?error='.urlencode('failed to create upload'));
|
throw new Redirect('/admin/uploads/?error='.urlencode('failed to create upload'));
|
||||||
|
|
||||||
admin::log(new \AdminActions\UploadsAdd($upload_id, $name, $note_en, $note_ru));
|
Admin::logAction(new \app\AdminActions\UploadsAdd($upload_id, $name, $note_en, $note_ru));
|
||||||
}
|
}
|
||||||
|
|
||||||
self::redirect('/admin/uploads/');
|
throw new Redirect('/admin/uploads/');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function GET_upload_delete() {
|
public function GET_upload_delete(): Response {
|
||||||
list($id) = $this->input('i:id');
|
list($id) = $this->input('i:id');
|
||||||
$upload = uploads::get($id);
|
$upload = Upload::get($id);
|
||||||
if (!$upload)
|
if (!$upload)
|
||||||
self::redirect('/admin/uploads/?error='.urlencode('upload not found'));
|
throw new Redirect('/admin/uploads/?error='.urlencode('upload not found'));
|
||||||
self::checkCSRF('delupl'.$id);
|
$this->checkCSRF('delupl'.$id);
|
||||||
uploads::delete($id);
|
Upload::delete($id);
|
||||||
admin::log(new \AdminActions\UploadsDelete($id));
|
Admin::logAction(new \app\AdminActions\UploadsDelete($id));
|
||||||
self::redirect('/admin/uploads/');
|
throw new Redirect('/admin/uploads/');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function POST_upload_edit_note() {
|
public function POST_upload_edit_note(): Response {
|
||||||
list($id, $note, $lang) = $this->input('i:id, note, lang');
|
list($id, $note, $lang) = $this->input('i:id, note, lang');
|
||||||
$lang = PostLanguage::tryFrom($lang);
|
$lang = PostLanguage::tryFrom($lang);
|
||||||
if (!$lang)
|
if (!$lang)
|
||||||
self::notFound();
|
throw new NotFound();
|
||||||
|
|
||||||
$upload = uploads::get($id);
|
$upload = Upload::get($id);
|
||||||
if (!$upload)
|
if (!$upload)
|
||||||
self::redirect('/admin/uploads/?error='.urlencode('upload not found'));
|
throw new Redirect('/admin/uploads/?error='.urlencode('upload not found'));
|
||||||
|
|
||||||
self::checkCSRF('editupl'.$id);
|
$this->checkCSRF('editupl'.$id);
|
||||||
|
|
||||||
$upload->setNote($lang, $note);
|
$upload->setNote($lang, $note);
|
||||||
$texts = posts::getTextsWithUpload($upload);
|
$texts = PostText::getTextsWithUpload($upload);
|
||||||
if (!empty($texts)) {
|
if (!empty($texts)) {
|
||||||
foreach ($texts as $text) {
|
foreach ($texts as $text) {
|
||||||
$text->updateHtml();
|
$text->updateHtml();
|
||||||
@ -330,12 +344,12 @@ class AdminHandler extends request_handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
admin::log(new \AdminActions\UploadsEditNote($id, $note, $lang->value));
|
Admin::logAction(new \app\AdminActions\UploadsEditNote($id, $note, $lang->value));
|
||||||
self::redirect('/admin/uploads/');
|
throw new Redirect('/admin/uploads/');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function POST_ajax_md_preview() {
|
public function POST_ajax_md_preview(): Response {
|
||||||
self::ensureXhr();
|
$this->ensureIsXHR();
|
||||||
list($md, $title, $use_image_previews, $lang, $is_page) = $this->input('md, title, b:use_image_previews, lang, b:is_page');
|
list($md, $title, $use_image_previews, $lang, $is_page) = $this->input('md, title, b:use_image_previews, lang, b:is_page');
|
||||||
$lang = PostLanguage::tryFrom($lang);
|
$lang = PostLanguage::tryFrom($lang);
|
||||||
if (!$lang)
|
if (!$lang)
|
||||||
@ -344,32 +358,33 @@ class AdminHandler extends request_handler {
|
|||||||
$md = '# '.$title."\n\n".$md;
|
$md = '# '.$title."\n\n".$md;
|
||||||
$title = '';
|
$title = '';
|
||||||
}
|
}
|
||||||
$html = markup::markdownToHtml($md, $use_image_previews, $lang);
|
$html = MarkupUtil::markdownToHtml($md, $use_image_previews, $lang);
|
||||||
$html = $this->skin->render('markdown_preview.twig', [
|
$html = $this->skin->render('markdown_preview.twig', [
|
||||||
'unsafe_html' => $html,
|
'unsafe_html' => $html,
|
||||||
'title' => $title
|
'title' => $title
|
||||||
]);
|
]);
|
||||||
self::ajaxOk(['html' => $html]);
|
return new AjaxOk(['html' => $html]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function GET_page_add() {
|
public function GET_page_add(): Response {
|
||||||
list($name) = $this->input('short_name');
|
list($name) = $this->input('short_name');
|
||||||
$page = pages::getByName($name);
|
$page = Page::getByName($name);
|
||||||
if ($page)
|
if ($page)
|
||||||
self::redirect($page->getUrl(), code: HTTPCode::Found);
|
throw new Redirect($page->getUrl());
|
||||||
|
|
||||||
$this->skin->exportStrings('/^(err_)?pages_/');
|
$this->skin->exportStrings('/^(err_)?pages_/');
|
||||||
$this->skin->exportStrings('/^(err_)?blog_/');
|
$this->skin->exportStrings('/^(err_)?blog_/');
|
||||||
$this->skin->setTitle(lang('pages_create_title', $name));
|
$this->skin->title = lang('pages_create_title', $name);
|
||||||
$this->setWidePageOptions();
|
$this->setWidePageOptions();
|
||||||
|
|
||||||
$js_params = [
|
$js_params = [
|
||||||
'pages' => true,
|
'pages' => true,
|
||||||
'edit' => false,
|
'edit' => false,
|
||||||
'token' => self::getCSRF('addpage'),
|
'token' => $this->getCSRF('addpage'),
|
||||||
'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()), // still needed for draft erasing
|
'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()), // still needed for draft erasing
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->skin->renderPage('admin_page_form.twig', [
|
return $this->skin->renderPage('admin_page_form.twig', [
|
||||||
'is_edit' => false,
|
'is_edit' => false,
|
||||||
'short_name' => $name,
|
'short_name' => $name,
|
||||||
'title' => '',
|
'title' => '',
|
||||||
@ -380,13 +395,13 @@ class AdminHandler extends request_handler {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function POST_page_add() {
|
public function POST_page_add(): Response {
|
||||||
self::checkCSRF('addpage');
|
$this->checkCSRF('addpage');
|
||||||
|
|
||||||
list($name, $text, $title) = $this->input('short_name, text, title');
|
list($name, $text, $title) = $this->input('short_name, text, title');
|
||||||
$page = pages::getByName($name);
|
$page = Page::getByName($name);
|
||||||
if ($page)
|
if ($page)
|
||||||
self::notFound();
|
throw new NotFound();
|
||||||
|
|
||||||
$error_code = null;
|
$error_code = null;
|
||||||
|
|
||||||
@ -397,56 +412,52 @@ class AdminHandler extends request_handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($error_code)
|
if ($error_code)
|
||||||
self::ajaxError(['code' => $error_code]);
|
return new AjaxError(['code' => $error_code]);
|
||||||
|
|
||||||
if (!pages::add([
|
if (!Page::add([
|
||||||
'short_name' => $name,
|
'short_name' => $name,
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'md' => $text
|
'md' => $text
|
||||||
])) {
|
])) {
|
||||||
self::ajaxError(['code' => 'db_err']);
|
return new AjaxError(['code' => 'db_err']);
|
||||||
}
|
}
|
||||||
|
|
||||||
admin::log(new \AdminActions\PageCreate($name));
|
Admin::logAction(new \app\AdminActions\PageCreate($name));
|
||||||
|
|
||||||
$page = pages::getByName($name);
|
$page = Page::getByName($name);
|
||||||
self::ajaxOk(['url' => $page->getUrl()]);
|
return new AjaxOk(['url' => $page->getUrl()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function GET_page_delete() {
|
public function GET_page_delete(): Response {
|
||||||
list($name) = $this->input('short_name');
|
list($name) = $this->input('short_name');
|
||||||
|
|
||||||
$page = pages::getByName($name);
|
$page = Page::getByName($name);
|
||||||
if (!$page)
|
if (!$page)
|
||||||
self::notFound();
|
throw new NotFound();
|
||||||
|
|
||||||
$url = $page->getUrl();
|
$url = $page->getUrl();
|
||||||
|
|
||||||
self::checkCSRF('delpage'.$page->shortName);
|
$this->checkCSRF('delpage'.$page->shortName);
|
||||||
pages::delete($page);
|
Page::delete($page);
|
||||||
admin::log(new \AdminActions\PageDelete($name));
|
Admin::logAction(new \app\AdminActions\PageDelete($name));
|
||||||
self::redirect($url, code: HTTPCode::Found);
|
throw new Redirect($url);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function GET_page_edit() {
|
public function GET_page_edit(): Response {
|
||||||
list($short_name, $saved) = $this->input('short_name, b:saved');
|
list($short_name, $saved) = $this->input('short_name, b:saved');
|
||||||
|
|
||||||
$page = pages::getByName($short_name);
|
$page = Page::getByName($short_name);
|
||||||
if (!$page)
|
if (!$page)
|
||||||
self::notFound();
|
throw new NotFound();
|
||||||
|
|
||||||
$this->skin->exportStrings('/^(err_)?pages_/');
|
$this->skin->exportStrings('/^(err_)?pages_/');
|
||||||
$this->skin->exportStrings('/^(err_)?blog_/');
|
$this->skin->exportStrings('/^(err_)?blog_/');
|
||||||
$this->skin->setTitle(lang('pages_page_edit_title', $page->shortName));
|
$this->skin->title = lang('pages_page_edit_title', $page->shortName);
|
||||||
$this->setWidePageOptions();
|
$this->setWidePageOptions();
|
||||||
$js_text = [
|
|
||||||
'text' => $page->md,
|
|
||||||
'title' => $page->title,
|
|
||||||
];
|
|
||||||
|
|
||||||
$parent = '';
|
$parent = '';
|
||||||
if ($page->parentId) {
|
if ($page->parentId) {
|
||||||
$parent_page = pages::getById($page->parentId);
|
$parent_page = Page::getById($page->parentId);
|
||||||
if ($parent_page)
|
if ($parent_page)
|
||||||
$parent = $parent_page->shortName;
|
$parent = $parent_page->shortName;
|
||||||
}
|
}
|
||||||
@ -454,7 +465,7 @@ class AdminHandler extends request_handler {
|
|||||||
$js_params = [
|
$js_params = [
|
||||||
'pages' => true,
|
'pages' => true,
|
||||||
'edit' => true,
|
'edit' => true,
|
||||||
'token' => self::getCSRF('editpage'.$short_name),
|
'token' => $this->getCSRF('editpage'.$short_name),
|
||||||
'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()), // still needed for draft erasing
|
'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()), // still needed for draft erasing
|
||||||
'text' => [
|
'text' => [
|
||||||
'text' => $page->md,
|
'text' => $page->md,
|
||||||
@ -462,7 +473,7 @@ class AdminHandler extends request_handler {
|
|||||||
]
|
]
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->skin->renderPage('admin_page_form.twig', [
|
return $this->skin->renderPage('admin_page_form.twig', [
|
||||||
'is_edit' => true,
|
'is_edit' => true,
|
||||||
'short_name' => $page->shortName,
|
'short_name' => $page->shortName,
|
||||||
'title' => $page->title,
|
'title' => $page->title,
|
||||||
@ -476,15 +487,15 @@ class AdminHandler extends request_handler {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function POST_page_edit() {
|
public function POST_page_edit(): Response {
|
||||||
self::ensureXhr();
|
$this->ensureIsXHR();
|
||||||
list($short_name) = $this->input('short_name');
|
list($short_name) = $this->input('short_name');
|
||||||
|
|
||||||
$page = pages::getByName($short_name);
|
$page = Page::getByName($short_name);
|
||||||
if (!$page)
|
if (!$page)
|
||||||
self::notFound();
|
throw new NotFound();
|
||||||
|
|
||||||
self::checkCSRF('editpage'.$page->shortName);
|
$this->checkCSRF('editpage'.$page->shortName);
|
||||||
|
|
||||||
list($text, $title, $visible, $short_name, $parent, $render_title)
|
list($text, $title, $visible, $short_name, $parent, $render_title)
|
||||||
= $this->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');
|
||||||
@ -502,13 +513,13 @@ class AdminHandler extends request_handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($error_code)
|
if ($error_code)
|
||||||
self::ajaxError(['code' => $error_code]);
|
return new AjaxError(['code' => $error_code]);
|
||||||
|
|
||||||
$new_short_name = $page->shortName != $short_name ? $short_name : null;
|
$new_short_name = $page->shortName != $short_name ? $short_name : null;
|
||||||
$parent_page = pages::getByName($parent);
|
$parent_page = Page::getByName($parent);
|
||||||
$parent_id = $parent_page ? $parent_page->id : 0;
|
$parent_id = $parent_page ? $parent_page->id : 0;
|
||||||
|
|
||||||
previous_texts::add(PreviousText::TYPE_PAGE, $page->get_id(), $page->md, $page->updateTs ?: $page->ts);
|
PreviousText::add(PreviousText::TYPE_PAGE, $page->get_id(), $page->md, $page->updateTs ?: $page->ts);
|
||||||
$page->edit([
|
$page->edit([
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'md' => $text,
|
'md' => $text,
|
||||||
@ -518,13 +529,13 @@ class AdminHandler extends request_handler {
|
|||||||
'parent_id' => $parent_id
|
'parent_id' => $parent_id
|
||||||
]);
|
]);
|
||||||
|
|
||||||
admin::log(new \AdminActions\PageEdit($short_name, $new_short_name));
|
Admin::logAction(new \app\AdminActions\PageEdit($short_name, $new_short_name));
|
||||||
self::ajaxOk(['url' => $page->getUrl().'edit/?saved=1']);
|
return new AjaxOk(['url' => $page->getUrl().'edit/?saved=1']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function GET_post_add() {
|
public function GET_post_add(): Response {
|
||||||
$this->skin->exportStrings('/^(err_)?blog_/');
|
$this->skin->exportStrings('/^(err_)?blog_/');
|
||||||
$this->skin->setTitle('$blog_write');
|
$this->skin->title = lang('blog_write');
|
||||||
$this->setWidePageOptions();
|
$this->setWidePageOptions();
|
||||||
|
|
||||||
$js_texts = [];
|
$js_texts = [];
|
||||||
@ -539,7 +550,7 @@ class AdminHandler extends request_handler {
|
|||||||
|
|
||||||
$js_params = [
|
$js_params = [
|
||||||
'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()),
|
'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()),
|
||||||
'token' => self::getCSRF('post_add')
|
'token' => $this->getCSRF('post_add')
|
||||||
];
|
];
|
||||||
$form_url = '/articles/write/';
|
$form_url = '/articles/write/';
|
||||||
|
|
||||||
@ -548,8 +559,7 @@ class AdminHandler extends request_handler {
|
|||||||
['text' => lang('blog_new_post')]
|
['text' => lang('blog_new_post')]
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->skin->renderPage('admin_post_form.twig', [
|
return $this->skin->renderPage('admin_post_form.twig', [
|
||||||
// form data
|
|
||||||
'title' => '',
|
'title' => '',
|
||||||
'text' => '',
|
'text' => '',
|
||||||
'short_name' => '',
|
'short_name' => '',
|
||||||
@ -565,19 +575,23 @@ class AdminHandler extends request_handler {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function POST_post_add() {
|
public function POST_post_add(): Response {
|
||||||
self::ensureXhr();
|
$this->ensureIsXHR();
|
||||||
self::checkCSRF('post_add');
|
$this->checkCSRF('post_add');
|
||||||
|
|
||||||
list($visibility_enabled, $short_name, $langs, $date)
|
list($visibility_enabled, $short_name, $langs, $date)
|
||||||
= $this->input('b:visible, short_name, langs, date');
|
= $this->input('b:visible, short_name, langs, date');
|
||||||
|
|
||||||
self::_postEditValidateCommonData($date);
|
try {
|
||||||
|
self::_postEditValidateCommonData($date);
|
||||||
|
} catch (ParseFormException $e) {
|
||||||
|
return new AjaxError(['code' => $e->getCode()]);
|
||||||
|
}
|
||||||
|
|
||||||
$lang_data = [];
|
$lang_data = [];
|
||||||
$at_least_one_lang_is_written = false;
|
$at_least_one_lang_is_written = false;
|
||||||
foreach (PostLanguage::cases() as $lang) {
|
foreach (PostLanguage::cases() as $lang) {
|
||||||
list($title, $text, $keywords, $toc_enabled) = $this->input("title:{$lang->value}, text:{$lang->value}, keywords:{$lang->value}, b:toc:{$lang->value}", ['trim' => true]);
|
list($title, $text, $keywords, $toc_enabled) = $this->input("title:{$lang->value}, text:{$lang->value}, keywords:{$lang->value}, b:toc:{$lang->value}", trim: true);
|
||||||
if ($title !== '' && $text !== '') {
|
if ($title !== '' && $text !== '') {
|
||||||
$lang_data[$lang->value] = [$title, $text, $keywords, $toc_enabled];
|
$lang_data[$lang->value] = [$title, $text, $keywords, $toc_enabled];
|
||||||
$at_least_one_lang_is_written = true;
|
$at_least_one_lang_is_written = true;
|
||||||
@ -591,9 +605,9 @@ class AdminHandler extends request_handler {
|
|||||||
$error_code = 'no_short_name';
|
$error_code = 'no_short_name';
|
||||||
}
|
}
|
||||||
if ($error_code)
|
if ($error_code)
|
||||||
self::ajaxError(['code' => $error_code]);
|
return new AjaxError(['code' => $error_code]);
|
||||||
|
|
||||||
$post = posts::add([
|
$post = Post::add([
|
||||||
'visible' => $visibility_enabled,
|
'visible' => $visibility_enabled,
|
||||||
'short_name' => $short_name,
|
'short_name' => $short_name,
|
||||||
'date' => $date,
|
'date' => $date,
|
||||||
@ -601,7 +615,7 @@ class AdminHandler extends request_handler {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!$post)
|
if (!$post)
|
||||||
self::ajaxError(['code' => 'db_err', 'message' => 'failed to add post']);
|
return new AjaxError(['code' => 'db_err', 'message' => 'failed to add post']);
|
||||||
|
|
||||||
// add texts
|
// add texts
|
||||||
$added_texts = []; // for admin actions logging, at the end
|
$added_texts = []; // for admin actions logging, at the end
|
||||||
@ -614,48 +628,48 @@ class AdminHandler extends request_handler {
|
|||||||
keywords: $keywords,
|
keywords: $keywords,
|
||||||
toc: $toc_enabled))
|
toc: $toc_enabled))
|
||||||
) {
|
) {
|
||||||
posts::delete($post);
|
Post::delete($post);
|
||||||
self::ajaxError(['code' => 'db_err', 'message' => 'failed to add text language '.$lang]);
|
return new AjaxError(['code' => 'db_err', 'message' => 'failed to add text language '.$lang]);
|
||||||
} else {
|
} else {
|
||||||
$added_texts[] = [$new_post_text->id, $lang];
|
$added_texts[] = [$new_post_text->id, $lang];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
admin::log(new \AdminActions\PostCreate($post->id));
|
Admin::logAction(new \app\AdminActions\PostCreate($post->id));
|
||||||
foreach ($added_texts as $added_text) {
|
foreach ($added_texts as $added_text) {
|
||||||
list($id, $lang) = $added_text;
|
list($id, $lang) = $added_text;
|
||||||
admin::log(new \AdminActions\PostTextCreate($id, $post->id, $lang));
|
Admin::logAction(new \app\AdminActions\PostTextCreate($id, $post->id, $lang));
|
||||||
}
|
}
|
||||||
|
|
||||||
// done
|
// done
|
||||||
self::ajaxOk(['url' => $post->getUrl()]);
|
return new AjaxOk(['url' => $post->getUrl()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function GET_post_delete() {
|
public function GET_post_delete(): Response {
|
||||||
list($name) = $this->input('short_name');
|
list($name) = $this->input('short_name');
|
||||||
|
|
||||||
$post = posts::getByName($name);
|
$post = Post::getByName($name);
|
||||||
if (!$post)
|
if (!$post)
|
||||||
self::notFound();
|
throw new NotFound();
|
||||||
|
|
||||||
$id = $post->id;
|
$id = $post->id;
|
||||||
self::checkCSRF('delpost'.$id);
|
$this->checkCSRF('delpost'.$id);
|
||||||
posts::delete($post);
|
Post::delete($post);
|
||||||
admin::log(new \AdminActions\PostDelete($id));
|
Admin::logAction(new \app\AdminActions\PostDelete($id));
|
||||||
self::redirect('/articles/', code: HTTPCode::Found);
|
throw new Redirect('/articles/');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function GET_post_edit() {
|
public function GET_post_edit(): Response {
|
||||||
list($short_name, $saved, $lang) = $this->input('short_name, b:saved, lang');
|
list($short_name, $saved, $lang) = $this->input('short_name, b:saved, lang');
|
||||||
$lang = PostLanguage::from($lang);
|
$lang = PostLanguage::from($lang);
|
||||||
|
|
||||||
$post = posts::getByName($short_name);
|
$post = Post::getByName($short_name);
|
||||||
if (!$post)
|
if (!$post)
|
||||||
self::notFound();
|
throw new NotFound();
|
||||||
|
|
||||||
$texts = $post->getTexts();
|
$texts = $post->getTexts();
|
||||||
if (!isset($texts[$lang->value]))
|
if (!isset($texts[$lang->value]))
|
||||||
self::notFound();
|
throw new NotFound();
|
||||||
|
|
||||||
$js_texts = [];
|
$js_texts = [];
|
||||||
foreach (PostLanguage::cases() as $pl) {
|
foreach (PostLanguage::cases() as $pl) {
|
||||||
@ -681,7 +695,7 @@ class AdminHandler extends request_handler {
|
|||||||
|
|
||||||
$this->skin->exportStrings('/^(err_)?blog_/');
|
$this->skin->exportStrings('/^(err_)?blog_/');
|
||||||
$this->skin->exportStrings(['blog_post_edit_title']);
|
$this->skin->exportStrings(['blog_post_edit_title']);
|
||||||
$this->skin->setTitle(lang('blog_post_edit_title', $text->title));
|
$this->skin->title = lang('blog_post_edit_title', $text->title);
|
||||||
$this->setWidePageOptions();
|
$this->setWidePageOptions();
|
||||||
|
|
||||||
$bc = [
|
$bc = [
|
||||||
@ -691,14 +705,14 @@ class AdminHandler extends request_handler {
|
|||||||
|
|
||||||
$js_params = [
|
$js_params = [
|
||||||
'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()),
|
'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()),
|
||||||
'token' => self::getCSRF('editpost'.$post->id),
|
'token' => $this->getCSRF('editpost'.$post->id),
|
||||||
'edit' => true,
|
'edit' => true,
|
||||||
'id' => $post->id,
|
'id' => $post->id,
|
||||||
'texts' => $js_texts
|
'texts' => $js_texts
|
||||||
];
|
];
|
||||||
$form_url = $post->getUrl().'edit/';
|
$form_url = $post->getUrl().'edit/';
|
||||||
|
|
||||||
$this->skin->renderPage('admin_post_form.twig', [
|
return $this->skin->renderPage('admin_post_form.twig', [
|
||||||
'is_edit' => true,
|
'is_edit' => true,
|
||||||
'post' => $post,
|
'post' => $post,
|
||||||
'title' => $text->title,
|
'title' => $text->title,
|
||||||
@ -718,21 +732,24 @@ class AdminHandler extends request_handler {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function POST_post_edit() {
|
public function POST_post_edit(): Response {
|
||||||
self::ensureXhr();
|
$this->ensureIsXHR();
|
||||||
|
|
||||||
list($old_short_name, $short_name, $langs, $date, $source_url) = $this->input('short_name, new_short_name, langs, date, source_url');
|
list($old_short_name, $short_name, $langs, $date, $source_url) = $this->input('short_name, new_short_name, langs, date, source_url');
|
||||||
|
|
||||||
$post = posts::getByName($old_short_name);
|
$post = Post::getByName($old_short_name);
|
||||||
if (!$post)
|
if (!$post)
|
||||||
self::notFound();
|
throw new NotFound();
|
||||||
|
|
||||||
self::checkCSRF('editpost'.$post->id);
|
$this->checkCSRF('editpost'.$post->id);
|
||||||
|
|
||||||
self::_postEditValidateCommonData($date);
|
try {
|
||||||
|
self::_postEditValidateCommonData($date);
|
||||||
|
} catch (ParseFormException $e) {
|
||||||
|
return new AjaxError(['code' => $e->getCode()]);
|
||||||
|
}
|
||||||
|
|
||||||
if (empty($short_name))
|
if (empty($short_name))
|
||||||
self::ajaxError(['code' => 'no_short_name']);
|
return new AjaxError(['code' => 'no_short_name']);
|
||||||
|
|
||||||
foreach (explode(',', $langs) as $lang) {
|
foreach (explode(',', $langs) as $lang) {
|
||||||
$lang = PostLanguage::from($lang);
|
$lang = PostLanguage::from($lang);
|
||||||
@ -744,7 +761,7 @@ class AdminHandler extends request_handler {
|
|||||||
else if (!$text)
|
else if (!$text)
|
||||||
$error_code = 'no_text';
|
$error_code = 'no_text';
|
||||||
if ($error_code)
|
if ($error_code)
|
||||||
self::ajaxError(['code' => $error_code]);
|
return new AjaxError(['code' => $error_code]);
|
||||||
|
|
||||||
$pt = $post->getText($lang);
|
$pt = $post->getText($lang);
|
||||||
if (!$pt) {
|
if (!$pt) {
|
||||||
@ -756,9 +773,9 @@ class AdminHandler extends request_handler {
|
|||||||
toc: $toc
|
toc: $toc
|
||||||
);
|
);
|
||||||
if (!$pt)
|
if (!$pt)
|
||||||
self::ajaxError(['code' => 'db_err']);
|
return new AjaxError(['code' => 'db_err']);
|
||||||
} else {
|
} else {
|
||||||
previous_texts::add(PreviousText::TYPE_POST_TEXT, $pt->id, $pt->md, $post->getUpdateTimestamp() ?: $post->getTimestamp());
|
PreviousText::add(PreviousText::TYPE_POST_TEXT, $pt->id, $pt->md, $post->getUpdateTimestamp() ?: $post->getTimestamp());
|
||||||
$pt->edit([
|
$pt->edit([
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'md' => $text,
|
'md' => $text,
|
||||||
@ -777,27 +794,24 @@ class AdminHandler extends request_handler {
|
|||||||
$post_data['short_name'] = $short_name;
|
$post_data['short_name'] = $short_name;
|
||||||
$post->edit($post_data);
|
$post->edit($post_data);
|
||||||
|
|
||||||
admin::log(new \AdminActions\PostEdit($post->id));
|
Admin::logAction(new \app\AdminActions\PostEdit($post->id));
|
||||||
self::ajaxOk(['url' => $post->getUrl().'edit/?saved=1&lang='.$lang->value]);
|
return new AjaxOk(['url' => $post->getUrl().'edit/?saved=1&lang='.$lang->value]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function GET_books() {
|
public function GET_books(): Response {
|
||||||
$this->skin->setTitle('$admin_books');
|
$this->skin->title = lang('admin_books');
|
||||||
$this->skin->renderPage('admin_books.twig');
|
return $this->skin->renderPage('admin_books.twig');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static function _postEditValidateCommonData($date) {
|
protected static function _postEditValidateCommonData($date) {
|
||||||
$dt = DateTime::createFromFormat("Y-m-d", $date);
|
$dt = DateTime::createFromFormat("Y-m-d", $date);
|
||||||
$date_is_valid = $dt && $dt->format("Y-m-d") === $date;
|
$date_is_valid = $dt && $dt->format("Y-m-d") === $date;
|
||||||
if (!$date_is_valid)
|
if (!$date_is_valid)
|
||||||
self::ajaxError(['code' => 'no_date']);
|
throw new ParseFormException(code: 'no_date');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function setWidePageOptions(): void {
|
protected function setWidePageOptions(): void {
|
||||||
$this->skin->setRenderOptions([
|
$this->skin->options->fullWidth = true;
|
||||||
'full_width' => true,
|
$this->skin->options->noFooter = true;
|
||||||
'no_footer' => true
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
25
src/handlers/foreignone/BaseHandler.php
Normal file
25
src/handlers/foreignone/BaseHandler.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\foreignone;
|
||||||
|
|
||||||
|
use engine\http\RequestHandler;
|
||||||
|
use engine\skin\BaseSkin;
|
||||||
|
|
||||||
|
abstract class BaseHandler
|
||||||
|
extends RequestHandler
|
||||||
|
{
|
||||||
|
public readonly BaseSkin $skin;
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
$this->skin = new ForeignOneSkin();
|
||||||
|
$this->skin->strings->load('main');
|
||||||
|
$this->skin->addStatic(
|
||||||
|
'css/common.css',
|
||||||
|
'js/common.js'
|
||||||
|
);
|
||||||
|
$this->skin->setGlobal([
|
||||||
|
'is_admin' => isAdmin(),
|
||||||
|
'is_dev' => isDev()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
216
src/handlers/foreignone/FilesHandler.php
Normal file
216
src/handlers/foreignone/FilesHandler.php
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\foreignone;
|
||||||
|
|
||||||
|
use app\foreignone\files\Archive;
|
||||||
|
use app\foreignone\files\ArchiveType;
|
||||||
|
use app\foreignone\files\Book;
|
||||||
|
use app\foreignone\files\Util as FilesUtil;
|
||||||
|
use app\foreignone\files\SectionType;
|
||||||
|
use engine\http\AjaxOk;
|
||||||
|
use engine\http\errors\NotFound;
|
||||||
|
use engine\http\Response;
|
||||||
|
|
||||||
|
class FilesHandler
|
||||||
|
extends BaseHandler
|
||||||
|
{
|
||||||
|
const int SEARCH_RESULTS_PER_PAGE = 50;
|
||||||
|
const int SEARCH_MIN_QUERY_LENGTH = 3;
|
||||||
|
|
||||||
|
public function GET_files(): Response {
|
||||||
|
$collections = array_map(fn(ArchiveType $c) => new Archive($c), ArchiveType::cases());
|
||||||
|
$books = Book::getList(section: SectionType::BOOKS_AND_ARTICLES);
|
||||||
|
$misc = Book::getList(section: SectionType::MISC);
|
||||||
|
|
||||||
|
$this->skin->meta->title = lang('meta_files_title');
|
||||||
|
$this->skin->meta->description = lang('meta_files_description');
|
||||||
|
$this->skin->title = lang('files');
|
||||||
|
$this->skin->options->headSection = 'files';
|
||||||
|
return $this->skin->renderPage('files_index.twig', [
|
||||||
|
'collections' => $collections,
|
||||||
|
'books' => $books,
|
||||||
|
'misc' => $misc
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function GET_folder(): Response {
|
||||||
|
list($folder_id) = $this->input('i:folder_id');
|
||||||
|
|
||||||
|
$parents = Book::getFolder($folder_id, with_parents: true);
|
||||||
|
if (!$parents)
|
||||||
|
throw new NotFound();
|
||||||
|
|
||||||
|
if (count($parents) > 1)
|
||||||
|
$parents = array_reverse($parents);
|
||||||
|
|
||||||
|
$folder = $parents[count($parents)-1];
|
||||||
|
$files = Book::getList($folder->section, $folder_id);
|
||||||
|
|
||||||
|
$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->meta->title = lang('meta_files_book_folder_title', $folder->getTitle());
|
||||||
|
$this->skin->meta->description = lang('meta_files_book_folder_description', $folder->getTitle());
|
||||||
|
$this->skin->title = lang('files').' - '.$folder->title;
|
||||||
|
return $this->skin->renderPage('files_folder.twig', [
|
||||||
|
'folder' => $folder,
|
||||||
|
'bc' => $bc,
|
||||||
|
'files' => $files
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function GET_collection(): Response {
|
||||||
|
list($archive, $folder_id, $query, $offset) = $this->input('collection, i:folder_id, q, i:offset');
|
||||||
|
$archive = ArchiveType::from($archive);
|
||||||
|
$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 = $archive->value;
|
||||||
|
|
||||||
|
if ($query !== null) {
|
||||||
|
$files = FilesUtil::searchArchive($archive, $query, $offset, self::SEARCH_RESULTS_PER_PAGE);
|
||||||
|
$vars += [
|
||||||
|
'search_count' => $files['count'],
|
||||||
|
'search_query' => $query
|
||||||
|
];
|
||||||
|
|
||||||
|
$files = $files['items'];
|
||||||
|
$query_words = array_map('mb_strtolower', preg_split('/\s+/', $query));
|
||||||
|
$found = [];
|
||||||
|
$result_ids = [];
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if ($file->type == 'folder')
|
||||||
|
continue;
|
||||||
|
$result_ids[] = $file->id;
|
||||||
|
$candidates = [];
|
||||||
|
switch ($archive) {
|
||||||
|
case ArchiveType::MercureDeFrance:
|
||||||
|
$candidates = [
|
||||||
|
$file->date,
|
||||||
|
(string)$file->issue
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
case ArchiveType::WilliamFriedman:
|
||||||
|
$candidates = [
|
||||||
|
mb_strtolower($file->getTitle()),
|
||||||
|
strtolower($file->documentId)
|
||||||
|
];
|
||||||
|
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 = FilesUtil::getTextExcerpts($archive, $not_found, $query_words);
|
||||||
|
|
||||||
|
if (isXHRRequest()) {
|
||||||
|
return new AjaxOk(
|
||||||
|
[
|
||||||
|
...$vars,
|
||||||
|
'new_offset' => $offset + count($files),
|
||||||
|
'html' => $this->skin->render('files_list.twig', [
|
||||||
|
'files' => $files,
|
||||||
|
'search_query' => $query,
|
||||||
|
'text_excerpts' => $text_excerpts
|
||||||
|
])
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (in_array($archive, [ArchiveType::WilliamFriedman, ArchiveType::Baconiana]) && $folder_id) {
|
||||||
|
$parents = $archive->getFolderGetter()($folder_id, with_parents: true);
|
||||||
|
if (!$parents)
|
||||||
|
throw new NotFound();
|
||||||
|
if (count($parents) > 1)
|
||||||
|
$parents = array_reverse($parents);
|
||||||
|
}
|
||||||
|
$files = $archive->getListGetter()($folder_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = lang('files_'.$archive->value.'_collection');
|
||||||
|
if ($folder_id && $parents)
|
||||||
|
$title .= ' - '.htmlescape($parents[count($parents)-1]->getTitle());
|
||||||
|
if ($query)
|
||||||
|
$title .= ' - '.htmlescape($query);
|
||||||
|
$this->skin->title = $title;
|
||||||
|
|
||||||
|
if (!$folder_id && !$query) {
|
||||||
|
$this->skin->meta->title = lang('4in1').' - '.lang('meta_files_collection_title', lang('files_'.$archive->value.'_collection'));
|
||||||
|
$this->skin->meta->description = lang('meta_files_'.$archive->value.'_description');
|
||||||
|
} else if ($query || $parents) {
|
||||||
|
$this->skin->meta->title = lang('4in1').' - '.$title;
|
||||||
|
$this->skin->meta->description = lang('meta_files_'.($query ? 'search' : 'folder').'_description',
|
||||||
|
$query ?: $parents[count($parents)-1]->getTitle(),
|
||||||
|
lang('files_'.$archive->value.'_collection'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$bc = [
|
||||||
|
['text' => lang('files'), 'url' => '/files/'],
|
||||||
|
];
|
||||||
|
if ($parents) {
|
||||||
|
$bc[] = ['text' => lang('files_'.$archive->value.'_collection_short'), 'url' => "/files/{$archive->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_'.$archive->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/{$archive->value}/",
|
||||||
|
'query' => $vars['search_query'] ?? '',
|
||||||
|
'count' => $vars['search_count'] ?? 0,
|
||||||
|
'collection_name' => $archive->value,
|
||||||
|
'inited_with_search' => !!($vars['search_query'] ?? "")
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->skin->set($vars);
|
||||||
|
$this->skin->set([
|
||||||
|
'collection' => $archive->value,
|
||||||
|
'files' => $files,
|
||||||
|
'bc' => $bc,
|
||||||
|
'do_show_search' => empty($parents),
|
||||||
|
'do_show_more' => ($vars['search_count'] ?? 0) > 0 && count($files) < ($vars['search_count'] ?? 0),
|
||||||
|
'text_excerpts' => $text_excerpts,
|
||||||
|
'js_params' => $js_params,
|
||||||
|
]);
|
||||||
|
return $this->skin->renderPage('files_collection.twig');
|
||||||
|
}
|
||||||
|
}
|
@ -1,66 +1,70 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
class MainHandler extends request_handler {
|
namespace app\foreignone;
|
||||||
|
|
||||||
public function GET_index() {
|
use app\ThemesUtil;
|
||||||
|
use engine\http\errors\NotFound;
|
||||||
|
use engine\http\errors\PermanentRedirect;
|
||||||
|
use engine\http\Response;
|
||||||
|
|
||||||
|
class MainHandler
|
||||||
|
extends BaseHandler
|
||||||
|
{
|
||||||
|
public function GET_index(): Response {
|
||||||
global $config;
|
global $config;
|
||||||
|
|
||||||
$posts_lang = PostLanguage::English;
|
$posts_lang = PostLanguage::English;
|
||||||
$posts = posts::getList(0, 3,
|
$posts = Post::getList(0, 3,
|
||||||
include_hidden: isAdmin(),
|
include_hidden: isAdmin(),
|
||||||
filter_by_lang: $posts_lang);
|
filter_by_lang: $posts_lang);
|
||||||
|
|
||||||
$this->skin->addMeta([
|
$this->skin->meta->title = lang('meta_index_title');
|
||||||
'og:type' => 'website',
|
$this->skin->meta->description = lang('meta_index_description');
|
||||||
'@url' => 'https://'.$config['domain'].'/',
|
$this->skin->meta->url = 'https://'.$config['domain'].'/';
|
||||||
'@title' => lang('meta_index_title'),
|
$this->skin->meta->image = 'https://'.$config['domain'].'/img/4in1-preview.jpg';
|
||||||
'@description' => lang('meta_index_description'),
|
$this->skin->meta->setSocial('og:type', 'website');
|
||||||
'@image' => 'https://'.$config['domain'].'/img/4in1-preview.jpg'
|
|
||||||
]);
|
$this->skin->options->isIndex = true;
|
||||||
$this->skin->setTitle('$site_title');
|
|
||||||
$this->skin->set([
|
$this->skin->set([
|
||||||
'posts' => $posts,
|
'posts' => $posts,
|
||||||
'posts_lang' => $posts_lang,
|
'posts_lang' => $posts_lang,
|
||||||
'versions' => $config['book_versions']
|
'versions' => $config['book_versions']
|
||||||
]);
|
]);
|
||||||
$this->skin->setRenderOptions(['is_index' => true]);
|
return $this->skin->renderPage('index.twig');
|
||||||
$this->skin->renderPage('index.twig');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function GET_about() { self::redirect('/info/'); }
|
public function GET_about() { throw new PermanentRedirect('/info/'); }
|
||||||
public function GET_contacts() { self::redirect('/info/'); }
|
public function GET_contacts() { throw new PermanentRedirect('/info/'); }
|
||||||
|
|
||||||
public function GET_page() {
|
public function GET_page(): Response {
|
||||||
global $config;
|
global $config;
|
||||||
list($name) = $this->input('name');
|
list($name) = $this->input('name');
|
||||||
|
|
||||||
$page = pages::getByName($name);
|
$page = Page::getByName($name);
|
||||||
if (!$page) {
|
if (!$page) {
|
||||||
if (isAdmin()) {
|
if (isAdmin()) {
|
||||||
$this->skin->setTitle($name);
|
$this->skin->title = $name;
|
||||||
$this->skin->renderPage('admin_page_new.twig', [
|
return $this->skin->renderPage('admin_page_new.twig', [
|
||||||
'short_name' => $name
|
'short_name' => $name
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
self::notFound();
|
throw new NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAdmin() && !$page->visible)
|
if (!isAdmin() && !$page->visible)
|
||||||
self::notFound();
|
throw new NotFound();
|
||||||
|
|
||||||
$bc = null;
|
$bc = null;
|
||||||
$render_opts = [];
|
|
||||||
if ($page) {
|
if ($page) {
|
||||||
$this->skin->addMeta([
|
$this->skin->meta->url = 'https://'.$config['domain'].$page->getUrl();
|
||||||
'@url' => 'https://'.$config['domain'].$page->getUrl(),
|
$this->skin->meta->title = $page->title;
|
||||||
'@title' => $page->title,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($page->parentId) {
|
if ($page->parentId) {
|
||||||
$bc = [];
|
$bc = [];
|
||||||
$parent = $page;
|
$parent = $page;
|
||||||
while ($parent?->parentId) {
|
while ($parent?->parentId) {
|
||||||
$parent = pages::getById($parent->parentId);
|
$parent = Page::getById($parent->parentId);
|
||||||
if ($parent)
|
if ($parent)
|
||||||
$bc[] = ['url' => $parent->getUrl(), 'text' => $parent->title];
|
$bc[] = ['url' => $parent->getUrl(), 'text' => $parent->title];
|
||||||
}
|
}
|
||||||
@ -69,22 +73,21 @@ class MainHandler extends request_handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($page->shortName == 'info')
|
if ($page->shortName == 'info')
|
||||||
$render_opts = ['head_section' => 'about'];
|
$this->skin->options->headSection = 'about';
|
||||||
else if ($page->shortName == $config['wiki_root'])
|
else if ($page->shortName == $config['wiki_root'])
|
||||||
$render_opts = ['head_section' => $page->shortName];
|
$this->skin->options->headSection = $page->shortName;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->skin->setRenderOptions($render_opts);
|
$this->skin->title = $page ? $page->title : '???';
|
||||||
$this->skin->setTitle($page ? $page->title : '???');
|
return $this->skin->renderPage('page.twig', [
|
||||||
$this->skin->renderPage('page.twig', [
|
|
||||||
'page' => $page,
|
'page' => $page,
|
||||||
'html' => $page->getHtml(isRetina(), themes::getUserTheme()),
|
'html' => $page->getHtml(isRetina(), ThemesUtil::getUserTheme()),
|
||||||
'bc' => $bc,
|
'bc' => $bc,
|
||||||
'delete_token' => self::getCSRF('delpage'.$page->shortName)
|
'delete_token' => $this->getCSRF('delpage'.$page->shortName)
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function GET_post() {
|
public function GET_post(): Response {
|
||||||
global $config;
|
global $config;
|
||||||
list($name, $input_lang) = $this->input('name, lang');
|
list($name, $input_lang) = $this->input('name, lang');
|
||||||
|
|
||||||
@ -92,23 +95,23 @@ class MainHandler extends request_handler {
|
|||||||
try {
|
try {
|
||||||
if ($input_lang)
|
if ($input_lang)
|
||||||
$lang = PostLanguage::from($input_lang);
|
$lang = PostLanguage::from($input_lang);
|
||||||
} catch (ValueError $e) {
|
} catch (\ValueError $e) {
|
||||||
self::notFound($e->getMessage());
|
throw new NotFound($e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$lang)
|
if (!$lang)
|
||||||
$lang = PostLanguage::getDefault();
|
$lang = PostLanguage::getDefault();
|
||||||
|
|
||||||
$post = posts::getByName($name);
|
$post = Post::getByName($name);
|
||||||
if (!$post || (!$post->visible && !isAdmin()))
|
if (!$post || (!$post->visible && !isAdmin()))
|
||||||
self::notFound();
|
throw new NotFound();
|
||||||
|
|
||||||
if ($lang == PostLanguage::getDefault() && $input_lang == $lang->value)
|
if ($lang == PostLanguage::getDefault() && $input_lang == $lang->value)
|
||||||
self::redirect($post->getUrl());
|
throw new PermanentRedirect($post->getUrl());
|
||||||
if (!$post->hasLang($lang))
|
if (!$post->hasLang($lang))
|
||||||
self::notFound('no text for language '.$lang->name);
|
throw new NotFound('no text for language '.$lang->name);
|
||||||
if (!$post->visible && !isAdmin())
|
if (!$post->visible && !isAdmin())
|
||||||
self::notFound();
|
throw new NotFound();
|
||||||
|
|
||||||
$pt = $post->getText($lang);
|
$pt = $post->getText($lang);
|
||||||
|
|
||||||
@ -120,26 +123,24 @@ class MainHandler extends request_handler {
|
|||||||
$other_langs[] = $pl->value;
|
$other_langs[] = $pl->value;
|
||||||
}
|
}
|
||||||
|
|
||||||
$meta = [
|
$this->skin->meta->title = $pt->title;
|
||||||
'@title' => $pt->title,
|
$this->skin->meta->description = $pt->getDescriptionPreview(155);
|
||||||
'@url' => $config['domain'].$post->getUrl(),
|
$this->skin->meta->url = $config['domain'].$post->getUrl();
|
||||||
'@description' => $pt->getDescriptionPreview(155)
|
|
||||||
];
|
|
||||||
if ($pt->keywords)
|
if ($pt->keywords)
|
||||||
$meta['@keywords'] = $pt->keywords;
|
$this->skin->meta->keywords = $pt->keywords;
|
||||||
$this->skin->addMeta($meta);
|
|
||||||
if (($img = $pt->getFirstImage()) !== null)
|
if (($img = $pt->getFirstImage()) !== null)
|
||||||
$this->skin->addMeta(['@image' => $img->getDirectUrl()]);
|
$this->skin->meta->image = $img->getDirectUrl();
|
||||||
|
|
||||||
$this->skin->setTitle($pt->title);
|
$this->skin->title = $pt->title;
|
||||||
$this->skin->setRenderOptions(['articles_lang' => $lang->value, 'wide' => $pt->hasTableOfContents()]);
|
$this->skin->options->articlesLang = $lang->value;
|
||||||
$this->skin->renderPage('post.twig', [
|
$this->skin->options->wide = $pt->hasTableOfContents();
|
||||||
|
return $this->skin->renderPage('post.twig', [
|
||||||
'post' => $post,
|
'post' => $post,
|
||||||
'pt' => $pt,
|
'pt' => $pt,
|
||||||
'html' => $pt->getHtml(isRetina(), themes::getUserTheme()),
|
'html' => $pt->getHtml(isRetina(), ThemesUtil::getUserTheme()),
|
||||||
'selected_lang' => $lang->value,
|
'selected_lang' => $lang->value,
|
||||||
'other_langs' => $other_langs,
|
'other_langs' => $other_langs,
|
||||||
'delete_token' => self::getCSRF('delpost'.$post->id)
|
'delete_token' => $this->getCSRF('delpost'.$post->id)
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,7 +159,7 @@ class MainHandler extends request_handler {
|
|||||||
'pub_date' => date(DATE_RSS, $post->getTimestamp()),
|
'pub_date' => date(DATE_RSS, $post->getTimestamp()),
|
||||||
'description' => $pt->getDescriptionPreview(500)
|
'description' => $pt->getDescriptionPreview(500)
|
||||||
];
|
];
|
||||||
}, posts::getList(0, 20, filter_by_lang: $lang));
|
}, Post::getList(0, 20, filter_by_lang: $lang));
|
||||||
|
|
||||||
$body = $this->skin->render('rss.twig', [
|
$body = $this->skin->render('rss.twig', [
|
||||||
'title' => lang('site_title'),
|
'title' => lang('site_title'),
|
||||||
@ -172,29 +173,26 @@ class MainHandler extends request_handler {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function GET_articles() {
|
public function GET_articles(): Response {
|
||||||
list($lang) = $this->input('lang');
|
list($lang) = $this->input('lang');
|
||||||
if ($lang) {
|
if ($lang) {
|
||||||
$lang = PostLanguage::tryFrom($lang);
|
$lang = PostLanguage::tryFrom($lang);
|
||||||
if (!$lang || $lang == PostLanguage::getDefault())
|
if (!$lang || $lang == PostLanguage::getDefault())
|
||||||
self::redirect('/articles/');
|
throw new PermanentRedirect('/articles/');
|
||||||
} else {
|
} else {
|
||||||
$lang = PostLanguage::getDefault();
|
$lang = PostLanguage::getDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
$posts = posts::getList(
|
$posts = Post::getList(
|
||||||
include_hidden: isAdmin(),
|
include_hidden: isAdmin(),
|
||||||
filter_by_lang: $lang);
|
filter_by_lang: $lang);
|
||||||
|
|
||||||
$this->skin->setTitle('$articles');
|
$this->skin->title = lang('articles');
|
||||||
$this->skin->addMeta([
|
$this->skin->meta->description = lang('blog_expl_'.$lang->value);
|
||||||
'@description' => lang('blog_expl_'.$lang->value)
|
$this->skin->options->headSection = 'articles';
|
||||||
]);
|
return $this->skin->renderPage('articles.twig', [
|
||||||
$this->skin->setRenderOptions(['head_section' => 'articles']);
|
|
||||||
$this->skin->renderPage('articles.twig', [
|
|
||||||
'posts' => $posts,
|
'posts' => $posts,
|
||||||
'selected_lang' => $lang->value
|
'selected_lang' => $lang->value
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
28
src/handlers/foreignone/ServicesHandler.php
Normal file
28
src/handlers/foreignone/ServicesHandler.php
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\foreignone;
|
||||||
|
|
||||||
|
use engine\http\errors\NotFound;
|
||||||
|
use engine\http\errors\Redirect;
|
||||||
|
use engine\http\PlainTextResponse;
|
||||||
|
use engine\http\Response;
|
||||||
|
|
||||||
|
class ServicesHandler
|
||||||
|
extends BaseHandler
|
||||||
|
{
|
||||||
|
public function GET_robots_txt(): Response {
|
||||||
|
$txt = <<<TXT
|
||||||
|
User-agent: *
|
||||||
|
Disallow: /admin/
|
||||||
|
TXT;
|
||||||
|
return new PlainTextResponse($txt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function GET_latest(): Response {
|
||||||
|
global $config;
|
||||||
|
list($lang) = $this->input('lang');
|
||||||
|
if (!isset($config['book_versions'][$lang]))
|
||||||
|
throw new NotFound();
|
||||||
|
throw new Redirect("https://files.4in1.ws/4in1-{$lang}.pdf?{$config['book_versions'][$lang]}");
|
||||||
|
}
|
||||||
|
}
|
25
src/handlers/ic/BaseHandler.php
Normal file
25
src/handlers/ic/BaseHandler.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\ic;
|
||||||
|
|
||||||
|
use engine\http\RequestHandler;
|
||||||
|
use engine\skin\BaseSkin;
|
||||||
|
|
||||||
|
abstract class BaseHandler
|
||||||
|
extends RequestHandler
|
||||||
|
{
|
||||||
|
public readonly BaseSkin $skin;
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
$this->skin = new InvisibleCollegeSkin();
|
||||||
|
// $this->skin->strings->load('ic');
|
||||||
|
$this->skin->addStatic(
|
||||||
|
'css/common.css',
|
||||||
|
'js/common.js'
|
||||||
|
);
|
||||||
|
$this->skin->setGlobal([
|
||||||
|
'is_admin' => isAdmin(),
|
||||||
|
'is_dev' => isDev()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
13
src/handlers/ic/MainHandler.php
Normal file
13
src/handlers/ic/MainHandler.php
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\ic;
|
||||||
|
|
||||||
|
use engine\http\HtmlResponse;
|
||||||
|
|
||||||
|
class MainHandler
|
||||||
|
extends BaseHandler
|
||||||
|
{
|
||||||
|
public function GET_index() {
|
||||||
|
return new HtmlResponse($this->skin->render('soon.twig'));
|
||||||
|
}
|
||||||
|
}
|
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