upgrade engine

This commit is contained in:
E. S 2025-04-28 14:47:25 +03:00
parent 8d5149ccf0
commit 266f6b1a59
183 changed files with 4804 additions and 3731 deletions

3
.gitignore vendored
View File

@ -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

View File

@ -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

View File

@ -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"
]
}
}
} }

View File

@ -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);

View File

@ -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;
} }

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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">&nbsp;</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">&nbsp;</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).'&amp;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).'&amp;theme='.$theme.'&amp;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)).'"';
}
}

View File

@ -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));
}
}

View File

@ -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');
}
}

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1,5 +1,5 @@
<?php <?php
require_once __DIR__.'/../init.php'; require_once __DIR__.'/../src/init.php';
request_handler::resolveAndDispatch(); engine\http\RequestHandler::resolveAndDispatch();

View File

@ -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'] ?? '';

View File

@ -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'] ?? '';

View File

@ -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;
}
}

View File

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

View File

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

View File

@ -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;
}
}

View File

@ -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; }
}

View File

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

View File

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

View File

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

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -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;
}

View File

@ -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)));
}
}

View File

@ -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'
]
];
}
}

View File

@ -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;
}
}

View File

@ -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")));
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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>

View 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;
}
}

View File

@ -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;
}
}
}

View 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;
}

View 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
View 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);
}
}

View File

@ -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;
} }

View 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 . ')');
}
}

View File

@ -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));

View File

@ -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;
} }
} }

View File

@ -0,0 +1,5 @@
<?php
namespace engine\exceptions;
class InvalidDomainException extends \Exception {}

View File

@ -0,0 +1,5 @@
<?php
namespace engine\exceptions;
class NotImplementedException extends \Exception {}

View File

@ -0,0 +1,5 @@
<?php
namespace engine\exceptions;
class ParseFormException extends \Exception {}

View 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);
}
}

View 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);
}
}

View 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',
];
}
}

View 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);
}
}

View File

@ -0,0 +1,9 @@
<?php
namespace engine\http;
enum HTTPMethod: string
{
case GET = 'GET';
case POST = 'POST';
}

View 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];
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace engine\http;
enum InputVarType: string
{
case INTEGER = 'i';
case FLOAT = 'f';
case BOOLEAN = 'b';
case STRING = 's';
}

View 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'];
}
}

View 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'];
}
}

View 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();
}
}

View 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;
}

View 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;
}
}

View 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);
}
}

View 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;
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace engine\http\errors;
class PermanentRedirect
extends BaseRedirect
{
public function __construct(...$args) {
parent::__construct(...$args);
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace engine\http\errors;
class Redirect
extends BaseRedirect
{
public function __construct(...$args) {
parent::__construct(...$args);
}
}

View 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);
}
}

View 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);
}
}

View 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));
}
}

View 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);
}
}

View 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;
}
}

View 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;
}

View 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);
}
}

View 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);
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace engine\logging;
enum LogLevel: int
{
case ERROR = 10;
case WARNING = 5;
// case INFO = 3;
case DEBUG = 2;
}

View 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;
}

View 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();
}
}

View 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;
}
}

View 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;
}
}

View 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">&nbsp;</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">&nbsp;</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).'&amp;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).'&amp;theme='.$theme.'&amp;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
View 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));
}
}

View 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;
}
}

View 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.
}
}

View File

@ -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);
} }
} }

View File

@ -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 {}

View 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);
}
}

View File

@ -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';
} }
} }

View 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()];
}
}

View File

@ -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
View 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;
}

View File

@ -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,

View File

@ -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
]);
} }
} }

View 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()
]);
}
}

View 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');
}
}

View File

@ -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
]); ]);
} }
} }

View 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]}");
}
}

View 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()
]);
}
}

View 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