diff --git a/.gitignore b/.gitignore
index 8001eab..33136ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,6 @@
/debug.log
/log
-test.php
+src/test.php
/.git
/node_modules/
/vendor/
@@ -8,7 +8,6 @@ test.php
._.DS_Store
.sass-cache/
config-static.php
-/config-local.php
/config.yaml
/.idea
/htdocs/dist-css
diff --git a/README.md b/README.md
index bd1dbd3..091c273 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,7 @@ This is a source code of 4in1.ws web site.
## 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
diff --git a/composer.json b/composer.json
index 2378f70..981ce1e 100644
--- a/composer.json
+++ b/composer.json
@@ -26,5 +26,19 @@
],
"minimum-stability": "dev",
"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"
+ ]
+ }
+ }
}
diff --git a/cron/sitemap.php b/cron/sitemap.php
index 4c5e0d0..d51a992 100644
--- a/cron/sitemap.php
+++ b/cron/sitemap.php
@@ -1,13 +1,15 @@
fetch($q)) {
// files
$sitemap->addItem("{$addr}/files/",
changeFrequency: Sitemap::WEEKLY);
-foreach (FilesCollection::cases() as $fc) {
+foreach (ArchiveType::cases() as $fc) {
$sitemap->addItem("{$addr}/files/".$fc->value.'/',
changeFrequency: Sitemap::MONTHLY);
}
-foreach ([FilesCollection::WilliamFriedman, FilesCollection::Baconiana] as $fc) {
- $q = $db->query("SELECT id FROM {$fc->value}_collection WHERE type=?", FilesItemType::FOLDER);
+foreach ([ArchiveType::WilliamFriedman, ArchiveType::Baconiana] as $fc) {
+ $q = $db->query("SELECT id FROM {$fc->value}_collection WHERE type='folder'");
while ($row = $db->fetch($q)) {
$sitemap->addItem("{$addr}/files/".$fc->value.'/'.$row['id'].'/',
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)) {
$sitemap->addItem("{$addr}/files/".$row['id'].'/',
changeFrequency: Sitemap::MONTHLY);
diff --git a/deploy/gen_static_config.php b/deploy/gen_static_config.php
index f475279..084195d 100755
--- a/deploy/gen_static_config.php
+++ b/deploy/gen_static_config.php
@@ -1,8 +1,9 @@
#!/usr/bin/env php
0) {
break;
default:
- cli::die('unsupported argument: '.$argv[0]);
+ CliUtil::die('unsupported argument: '.$argv[0]);
}
}
if (is_null($input_dir))
- cli::die("input directory has not been specified");
+ CliUtil::die("input directory has not been specified");
$hashes = [];
foreach (['css', 'js'] as $type) {
$entries = glob_recursive($input_dir.'/dist-'.$type.'/*.'.$type);
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;
}
@@ -40,7 +41,7 @@ foreach (['css', 'js'] as $type) {
'version' => get_hash($file),
'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));
}
}
@@ -66,9 +67,9 @@ function get_hash(string $path): string {
function glob_escape(string $pattern): string {
if (str_contains($pattern, '[') || str_contains($pattern, ']')) {
$placeholder = uniqid();
- $replaces = array( $placeholder.'[', $placeholder.']', );
- $pattern = str_replace( array('[', ']', ), $replaces, $pattern);
- $pattern = str_replace( $replaces, array('[[]', '[]]', ), $pattern);
+ $replaces = [$placeholder.'[', $placeholder.']', ];
+ $pattern = str_replace( ['[', ']'], $replaces, $pattern);
+ $pattern = str_replace( $replaces, ['[[]', '[]]'], $pattern);
}
return $pattern;
}
diff --git a/engine/logging.php b/engine/logging.php
deleted file mode 100644
index 375922b..0000000
--- a/engine/logging.php
+++ /dev/null
@@ -1,318 +0,0 @@
-value + ($fg_bright ? 90 : 30);
- if (!is_null($bg))
- $codes[] = $bg->value + ($bg_bright ? 100 : 40);
- if ($bold)
- $codes[] = 1;
-
- if (empty($codes))
- return $text;
-
- return "\033[".implode(';', $codes)."m".$text."\033[0m";
-}
-
-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);
-}
\ No newline at end of file
diff --git a/engine/request.php b/engine/request.php
deleted file mode 100644
index 1646293..0000000
--- a/engine/request.php
+++ /dev/null
@@ -1,238 +0,0 @@
-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('/(?name);
- $html = skin::getInstance()->render('error.twig', [
- 'code' => $http_code->value,
- 'title' => $http_message,
- 'message' => $message
- ]);
- http_response_code($http_code->value);
- echo $html;
- exit;
- }
- }
-
- protected static function redirect(string $url, HTTPCode $code = HTTPCode::MovedPermanently): never {
- if (!in_array($code, [HTTPCode::MovedPermanently, HTTPCode::Found]))
- self::internalServerError('invalid http code');
- if (self::isXhrRequest())
- self::ajaxOk(['redirect' => $url]);
- http_response_code($code->value);
- header('Location: '.$url);
- exit;
- }
-
- protected static function invalidRequest(string $message = '') { self::httpError(HTTPCode::InvalidRequest, $message); }
- protected static function internalServerError(string $message = '') { self::httpError(HTTPCode::InternalServerError, $message); }
- protected static function notFound(string $message = '') { self::httpError(HTTPCode::NotFound, $message); }
- protected static function forbidden(string $message = '') { self::httpError(HTTPCode::Forbidden, $message); }
- protected static function isXhrRequest(): bool { return isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'; }
- protected static function ajaxOk(mixed $data): void { self::ajaxResponse(['response' => $data]); }
- protected static function ajaxError(mixed $error, int $code = 200): void { self::ajaxResponse(['error' => $error], $code); }
-
- protected static function ajaxResponse(mixed $data, int $code = 200): never {
- header('Cache-Control: no-cache, must-revalidate');
- header('Pragma: no-cache');
- header('Content-Type: application/json; charset=utf-8');
- http_response_code($code);
- echo jsonEncode($data);
- exit;
- }
-
-}
diff --git a/engine/skin.php b/engine/skin.php
deleted file mode 100644
index e75ce4e..0000000
--- a/engine/skin.php
+++ /dev/null
@@ -1,566 +0,0 @@
- 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 .= file_get_contents(APP_ROOT.'/skin/svg/'.$name.'.svg');
- $svg .= ' ';
- return $svg;
- } else {
- return ' ';
- }
- }
-
- public function renderBreadCrumbs(array $items, ?string $style = null, bool $mt = false): string {
- static $chevron = ' ';
- $buf = implode(array_map(function(array $i) use ($chevron): string {
- $buf = '';
- $has_url = array_key_exists('url', $i);
-
- if ($has_url)
- $buf .= '';
- else
- $buf .= '';
- $buf .= htmlescape($i['text']);
-
- if ($has_url)
- $buf .= ' '.$chevron.' ';
- else
- $buf .= '';
-
- return $buf;
- }, $items));
- $class = 'bc';
- if ($mt)
- $class .= ' mt';
- return '
'.$buf.'
';
- }
-
- 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 .= ''.$p.' ';
- }
-
- if ($min_page > 2) {
- $pages_html = '
'.$pages_html;
- }
- if ($min_page > 1) {
- $pages_html = '1 '.$pages_html;
- }
-
- if ($max_page < $pages-1) {
- $pages_html .= '
';
- }
- if ($max_page < $pages) {
- $pages_html .= ''.$pages.' ';
- }
-
- $pn_class = 'pn';
- if ($pages < 2) {
- $pn_class .= ' no-nav';
- if (!$count) {
- $pn_class .= ' no-results';
- }
- }
-
- $html = <<
-
- {$pages_html}
-
-
-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 = '';
- foreach ($this->svgDefs as $name => $icon) {
- $content = file_get_contents(APP_ROOT.'/skin/svg/'.$name.'.svg');
- $buf .= "$content ";
- }
- $buf .= ' ';
- 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 = ' $v)
- $s .= ' '.htmlescape($k).'="'.htmlescape($v).'"';
- $s .= '/>';
- $s .= "\n";
- return $s;
- }, $this->meta));
- }
-
- protected function getHeaderStaticTags(): string {
- $html = [];
- $theme = themes::getUserTheme();
- $dark = $theme == 'dark' || ($theme == 'auto' && themes::isUserSystemThemeDark());
- $this->styleNames = [];
- foreach ($this->static as $name) {
- // javascript
- if (str_starts_with($name, 'js/'))
- $html[] = $this->jsLink($name);
-
- // css
- else if (str_starts_with($name, 'css/')) {
- $html[] = $this->cssLink($name, 'light', $style_name_ptr);
- $this->styleNames[] = $style_name_ptr;
-
- if ($dark)
- $html[] = $this->cssLink($name, 'dark', $style_name_ptr);
- else if (!isDev())
- $html[] = $this->cssPrefetchLink($style_name_ptr.'_dark');
- }
- else
- logError(__FUNCTION__.': unexpected static entry: '.$name);
- }
- return implode("\n", $html);
- }
-
- protected function getFooterScriptTags(): string {
- global $config;
-
- $html = '';
- return $html;
- }
-
- protected function jsLink(string $name): string {
- list (, $bname) = $this->getStaticNameParts($name);
- if (isDev()) {
- $href = '/js.php?name='.urlencode($bname).'&v='.time();
- } else {
- $href = '/dist-js/'.$bname.'.js?v='.$this->getStaticVersion($name);
- }
- return '';
- }
-
- protected function cssLink(string $name, string $theme, &$bname = null): string {
- list(, $bname) = $this->getStaticNameParts($name);
-
- $config_name = 'css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css';
-
- if (isDev()) {
- $href = '/sass.php?name='.urlencode($bname).'&theme='.$theme.'&v='.time();
- } else {
- $version = $this->getStaticVersion($config_name);
- $href = '/dist-css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css?v='.$version;
- }
-
- $id = 'style_'.$bname;
- if ($theme == 'dark')
- $id .= '_dark';
-
- return ' getStaticIntegrityAttribute($config_name).'>';
- }
-
- protected function cssPrefetchLink(string $name): string {
- $url = '/dist-css/'.$name.'.css?v='.$this->getStaticVersion('css/'.$name.'.css');
- $integrity = $this->getStaticIntegrityAttribute('css/'.$name.'.css');
- return ' ';
- }
-
- protected function getStaticNameParts(string $name): array {
- $dname = dirname($name);
- $bname = basename($name);
- if (($pos = strrpos($bname, '.'))) {
- $ext = substr($bname, $pos+1);
- $bname = substr($bname, 0, $pos);
- } else {
- $ext = '';
- }
- return [$dname, $bname, $ext];
- }
-
- protected function getStaticVersion(string $name): string {
- global $config;
- if (isDev())
- return time();
- if (str_starts_with($name, '/')) {
- logWarning(__FUNCTION__.': '.$name.' starts with /');
- $name = substr($name, 1);
- }
- return $config['static'][$name]['version'] ?? 'notfound';
- }
-
- protected function getStaticIntegrityAttribute(string $name): string {
- if (isDev())
- return '';
- global $config;
- return ' integrity="'.implode(' ', array_map(fn($hash_type) => $hash_type.'-'.$config['static'][$name]['integrity'][$hash_type], RESOURCE_INTEGRITY_HASHES)).'"';
- }
-
-}
diff --git a/engine/strings.php b/engine/strings.php
deleted file mode 100644
index b2b289f..0000000
--- a/engine/strings.php
+++ /dev/null
@@ -1,138 +0,0 @@
-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));
- }
-}
\ No newline at end of file
diff --git a/handlers/FilesHandler.php b/handlers/FilesHandler.php
deleted file mode 100644
index c68d178..0000000
--- a/handlers/FilesHandler.php
+++ /dev/null
@@ -1,220 +0,0 @@
- new CollectionItem($c), FilesCollection::cases());
- $books = files::books_get();
- $misc = files::books_get(category: BookCategory::MISC);
- $this->skin->addMeta([
- '@title' => '$meta_files_title',
- '@description' => '$meta_files_description'
- ]);
- $this->skin->setTitle('$files');
- $this->skin->setRenderOptions(['head_section' => 'files']);
- $this->skin->renderPage('files_index.twig', [
- 'collections' => $collections,
- 'books' => $books,
- 'misc' => $misc
- ]);
- }
-
- public function GET_folder() {
- list($folder_id) = $this->input('i:folder_id');
-
- $parents = files::books_get_folder($folder_id, true);
- if (!$parents)
- self::notFound();
-
- if (count($parents) > 1)
- $parents = array_reverse($parents);
-
- $folder = $parents[count($parents)-1];
- $files = files::books_get($folder_id, category: $folder->category);
-
- $bc = [
- ['text' => lang('files'), 'url' => '/files/'],
- ];
- if ($parents) {
- for ($i = 0; $i < count($parents)-1; $i++) {
- $parent = $parents[$i];
- $bc_item = ['text' => $parent->getTitle()];
- if ($i < count($parents)-1)
- $bc_item['url'] = $parent->getUrl();
- $bc[] = $bc_item;
- }
- }
- $bc[] = ['text' => $folder->title];
-
- $this->skin->addMeta([
- '@title' => lang('meta_files_book_folder_title', $folder->getTitle()),
- '@description' => lang('meta_files_book_folder_description', $folder->getTitle())
- ]);
- $this->skin->setTitle(lang('files').' - '.$folder->title);
- $this->skin->renderPage('files_folder.twig', [
- 'folder' => $folder,
- 'bc' => $bc,
- 'files' => $files
- ]);
- }
-
- public function GET_collection() {
- list($collection, $folder_id, $query, $offset) = $this->input('collection, i:folder_id, q, i:offset');
- $collection = FilesCollection::from($collection);
- $parents = null;
-
- $query = trim($query);
- if (!$query)
- $query = null;
-
- $this->skin->exportStrings('/^files_(.*?)_collection$/');
- $this->skin->exportStrings([
- 'files_search_results_count'
- ]);
-
- $vars = [];
- $text_excerpts = null;
- $func_prefix = $collection->value;
-
- if ($query !== null) {
- $files = call_user_func("files::{$func_prefix}_search", $query, $offset, self::SEARCH_RESULTS_PER_PAGE);
- $vars += [
- 'search_count' => $files['count'],
- 'search_query' => $query
- ];
-
- /** @var WFFCollectionItem[]|MDFCollectionItem[]|BaconianaCollectionItem[] $files */
- $files = $files['items'];
-
- $query_words = array_map('mb_strtolower', preg_split('/\s+/', $query));
- $found = [];
- $result_ids = [];
- foreach ($files as $file) {
- if ($file->isFolder())
- continue;
- $result_ids[] = $file->id;
-
- switch ($collection) {
- case FilesCollection::MercureDeFrance:
- $candidates = [
- $file->date,
- (string)$file->issue
- ];
- break;
- case FilesCollection::WilliamFriedman:
- $candidates = [
- mb_strtolower($file->getTitle()),
- strtolower($file->documentId)
- ];
- break;
- case FilesCollection::Baconiana:
- $candidates = [
- // TODO
- ];
- break;
- }
-
- foreach ($candidates as $haystack) {
- foreach ($query_words as $qw) {
- if (mb_strpos($haystack, $qw) !== false) {
- $found[$file->id] = true;
- continue 2;
- }
- }
- }
- }
-
- $found = array_map('intval', array_keys($found));
- $not_found = array_diff($result_ids, $found);
- if (!empty($not_found))
- $text_excerpts = call_user_func("files::{$func_prefix}_get_text_excerpts", $not_found, $query_words);
-
- if (self::isXhrRequest()) {
- self::ajaxOk([
- ...$vars,
- 'new_offset' => $offset + count($files),
- 'html' => skin::getInstance()->render('files_list.twig', [
- 'files' => $files,
- 'query' => $query,
- 'text_excerpts' => $text_excerpts
- ])
- ]);
- }
- } else {
- if (in_array($collection, [FilesCollection::WilliamFriedman, FilesCollection::Baconiana]) && $folder_id) {
- $parents = call_user_func("files::{$func_prefix}_get_folder", $folder_id, true);
- if (!$parents)
- self::notFound();
- if (count($parents) > 1)
- $parents = array_reverse($parents);
- }
- $files = call_user_func("files::{$func_prefix}_get", $folder_id);
- }
-
- $title = lang('files_'.$collection->value.'_collection');
- if ($folder_id && $parents)
- $title .= ' - '.htmlescape($parents[count($parents)-1]->getTitle());
- if ($query)
- $title .= ' - '.htmlescape($query);
- $this->skin->setTitle($title);
-
- if (!$folder_id && !$query) {
- $this->skin->addMeta([
- '@title' => lang('4in1').' - '.lang('meta_files_collection_title', lang('files_'.$collection->value.'_collection')),
- '@description' => lang('meta_files_'.$collection->value.'_description')
- ]);
- } else if ($query || $parents) {
- $this->skin->addMeta([
- '@title' => lang('4in1').' - '.$title,
- '@description' => lang('meta_files_'.($query ? 'search' : 'folder').'_description',
- $query ?: $parents[count($parents)-1]->getTitle(),
- lang('files_'.$collection->value.'_collection'))
- ]);
- }
-
- $bc = [
- ['text' => lang('files'), 'url' => '/files/'],
- ];
- if ($parents) {
- $bc[] = ['text' => lang('files_'.$collection->value.'_collection_short'), 'url' => "/files/{$collection->value}/"];
- for ($i = 0; $i < count($parents); $i++) {
- $parent = $parents[$i];
- $bc_item = ['text' => $parent->getTitle()];
- if ($i < count($parents)-1)
- $bc_item['url'] = $parent->getUrl();
- $bc[] = $bc_item;
- }
- } else {
- $bc[] = ['text' => lang('files_'.$collection->value.'_collection')];
- }
-
- $js_params = [
- 'container' => 'files_list',
- 'per_page' => self::SEARCH_RESULTS_PER_PAGE,
- 'min_query_length' => self::SEARCH_MIN_QUERY_LENGTH,
- 'base_url' => "/files/{$collection->value}/",
- 'query' => $vars['search_query'],
- 'count' => $vars['search_count'],
- 'collection_name' => $collection->value,
- 'inited_with_search' => !!($vars['search_query'] ?? "")
- ];
-
- $this->skin->set($vars);
- $this->skin->set([
- 'collection' => $collection->value,
- 'files' => $files,
- 'bc' => $bc,
- 'do_show_search' => empty($parents),
- 'do_show_more' => $vars['search_count'] > 0 && count($files) < $vars['search_count'],
- // 'search_results_per_page' => self::SEARCH_RESULTS_PER_PAGE,
- // 'search_min_query_length' => self::SEARCH_MIN_QUERY_LENGTH,
- 'text_excerpts' => $text_excerpts,
- 'js_params' => $js_params,
- ]);
- $this->skin->renderPage('files_collection.twig');
- }
-
-}
\ No newline at end of file
diff --git a/handlers/ServicesHandler.php b/handlers/ServicesHandler.php
deleted file mode 100644
index 57b7c1f..0000000
--- a/handlers/ServicesHandler.php
+++ /dev/null
@@ -1,25 +0,0 @@
-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);
- }
-
-}
\ No newline at end of file
diff --git a/htdocs/img/eagle.jpg b/htdocs/img/eagle.jpg
new file mode 100644
index 0000000..2da4444
Binary files /dev/null and b/htdocs/img/eagle.jpg differ
diff --git a/htdocs/img/simurgh-big.jpg b/htdocs/img/simurgh-big.jpg
new file mode 100644
index 0000000..8a3261c
Binary files /dev/null and b/htdocs/img/simurgh-big.jpg differ
diff --git a/htdocs/img/simurgh.jpg b/htdocs/img/simurgh.jpg
new file mode 100644
index 0000000..0ac1bec
Binary files /dev/null and b/htdocs/img/simurgh.jpg differ
diff --git a/htdocs/index.php b/htdocs/index.php
index f8697d7..83ca986 100644
--- a/htdocs/index.php
+++ b/htdocs/index.php
@@ -1,5 +1,5 @@
title !== '')
- return $this->title;
-
- return ($this->jobc ? lang('baconiana_old_name') : lang('baconiana')).' №'.$this->issues;
- }
-
- public function isTargetBlank(): bool { return $this->isFile(); }
- public function getId(): string { return $this->id; }
-
- public function getUrl(): string {
- if ($this->isFolder()) {
- return '/files/'.FilesCollection::Baconiana->value.'/'.$this->id.'/';
- }
- global $config;
- return 'https://'.$config['files_domain'].'/'.$this->path;
- }
-
- public function getMeta(?string $hl_matched = null): array {
- $items = [];
- if ($this->isFolder())
- return $items;
-
- if ($this->year >= 2007)
- $items = array_merge($items, ['Online Edition']);
-
- $items = array_merge($items, [
- sizeString($this->size),
- 'PDF'
- ]);
-
- return [
- 'inline' => false,
- 'items' => $items
- ];
- }
-
- public function getSubtitle(): ?string {
- return $this->year > 0 ? '('.$this->year.')' : null;
- }
-}
diff --git a/lib/BookCategory.php b/lib/BookCategory.php
deleted file mode 100644
index 7fc1915..0000000
--- a/lib/BookCategory.php
+++ /dev/null
@@ -1,6 +0,0 @@
-id;
- }
-
- public function getUrl(): string {
- if ($this->isFolder() && !$this->external)
- return '/files/'.$this->id.'/';
- global $config;
- $buf = 'https://'.$config['files_domain'];
- if (!str_starts_with($this->path, '/'))
- $buf .= '/';
- $buf .= $this->path;
- return $buf;
- }
-
- public function getTitleHtml(): ?string {
- if ($this->isFolder() || !$this->author)
- return null;
- $buf = ''.htmlescape($this->author).' ';
- if (!str_ends_with($this->author, '.'))
- $buf .= '.';
- $buf .= ' '.htmlescape($this->title).' ';
- return $buf;
- }
-
- public function getTitle(): string {
- return $this->title;
- }
-
- public function getMeta(?string $hl_matched = null): array {
- if ($this->isFolder())
- return [];
-
- $items = [
- sizeString($this->size),
- strtoupper($this->getExtension())
- ];
-
- return [
- 'inline' => false,
- 'items' => $items
- ];
- }
-
- protected function getExtension(): string {
- return extension(basename($this->path));
- }
-
- public function isAvailable(): bool {
- return true;
- }
-
- public function isTargetBlank(): bool {
- return $this->isFile() || $this->external;
- }
-
- public function getSubtitle(): ?string {
- if (!$this->year && !$this->subtitle)
- return null;
- $buf = '(';
- $buf .= $this->subtitle ?: $this->year;
- $buf .= ')';
- return $buf;
- }
-}
diff --git a/lib/CollectionItem.php b/lib/CollectionItem.php
deleted file mode 100644
index fa47bf2..0000000
--- a/lib/CollectionItem.php
+++ /dev/null
@@ -1,21 +0,0 @@
-collection->value; }
- public function isFolder(): bool { return true; }
- public function isFile(): bool { return false; }
- public function isAvailable(): bool { return true; }
- public function getUrl(): string {
- return '/files/'.$this->collection->value.'/';
- }
- public function getSize(): ?int { return null; }
- public function getTitle(): string { return lang("files_{$this->collection->value}_collection"); }
- public function getMeta(?string $hl_matched = null): array { return []; }
- public function isTargetBlank(): bool { return false; }
- public function getSubtitle(): ?string { return null; }
-}
\ No newline at end of file
diff --git a/lib/FilesCollection.php b/lib/FilesCollection.php
deleted file mode 100644
index 2532902..0000000
--- a/lib/FilesCollection.php
+++ /dev/null
@@ -1,7 +0,0 @@
-isFile() ? $this->size : null; }
-}
\ No newline at end of file
diff --git a/lib/FilesItemType.php b/lib/FilesItemType.php
deleted file mode 100644
index 90357e6..0000000
--- a/lib/FilesItemType.php
+++ /dev/null
@@ -1,6 +0,0 @@
-type == FilesItemType::FOLDER;
- }
-
- public function isFile(): bool {
- return $this->type == FilesItemType::FILE;
- }
-
- public function isBook(): bool {
- return $this instanceof BookItem && $this->fileType == BookFileType::BOOK;
- }
-
-}
\ No newline at end of file
diff --git a/lib/Page.php b/lib/Page.php
deleted file mode 100644
index 6d5067e..0000000
--- a/lib/Page.php
+++ /dev/null
@@ -1,47 +0,0 @@
-md || $fields['render_title'] != $this->renderTitle || $fields['title'] != $this->title) {
- $md = $fields['md'];
- if ($fields['render_title'])
- $md = '# '.$fields['title']."\n\n".$md;
- $fields['html'] = markup::markdownToHtml($md);
- }
- parent::edit($fields);
- }
-
- public function isUpdated(): bool {
- return $this->updateTs && $this->updateTs != $this->ts;
- }
-
- public function getHtml(bool $is_retina, string $user_theme): string {
- return markup::htmlImagesFix($this->html, $is_retina, $user_theme);
- }
-
- public function getUrl(): string {
- return "/{$this->shortName}/";
- }
-
- public function updateHtml(): void {
- $html = markup::markdownToHtml($this->md);
- $this->html = $html;
- DB()->query("UPDATE pages SET html=? WHERE short_name=?", $html, $this->shortName);
- }
-
-}
diff --git a/lib/Post.php b/lib/Post.php
deleted file mode 100644
index 5304ec8..0000000
--- a/lib/Post.php
+++ /dev/null
@@ -1,129 +0,0 @@
- $title,
- 'lang' => $lang->value,
- 'post_id' => $this->id,
- 'html' => $html,
- 'text' => $text,
- 'md' => $md,
- 'toc' => $toc,
- 'keywords' => $keywords,
- ];
-
- $db = DB();
- if (!$db->insert('posts_texts', $data))
- return null;
-
- $id = $db->insertId();
-
- $post_text = posts::getText($id);
- $post_text->updateImagePreviews();
-
- return $post_text;
- }
-
- public function registerText(PostText $postText): void {
- if (array_key_exists($postText->lang->value, $this->texts))
- throw new Exception("text for language {$postText->lang->value} has already been registered");
- $this->texts[$postText->lang->value] = $postText;
- }
-
- public function loadTexts() {
- if (!empty($this->texts))
- return;
- $db = DB();
- $q = $db->query("SELECT * FROM posts_texts WHERE post_id=?", $this->id);
- while ($row = $db->fetch($q)) {
- $text = new PostText($row);
- $this->registerText($text);
- }
- }
-
- /**
- * @return PostText[]
- */
- public function getTexts(): array {
- $this->loadTexts();
- return $this->texts;
- }
-
- public function getText(PostLanguage|string $lang): ?PostText {
- if (is_string($lang))
- $lang = PostLanguage::from($lang);
- $this->loadTexts();
- return $this->texts[$lang->value] ?? null;
- }
-
- public function hasLang(PostLanguage $lang) {
- $this->loadTexts();
- foreach ($this->texts as $text) {
- if ($text->lang == $lang)
- return true;
- }
- return false;
- }
-
- public function hasSourceUrl(): bool {
- return $this->sourceUrl != '';
- }
-
- public function getUrl(PostLanguage|string|null $lang = null): string {
- $buf = $this->shortName != '' ? "/articles/{$this->shortName}/" : "/articles/{$this->id}/";
- if ($lang) {
- if (is_string($lang))
- $lang = PostLanguage::from($lang);
- if ($lang != PostLanguage::English)
- $buf .= '?lang=' . $lang->value;
- }
- return $buf;
- }
-
- public function getTimestamp(): int {
- return (new DateTime($this->date))->getTimestamp();
- }
-
- public function getUpdateTimestamp(): ?int {
- if (!$this->updateTime)
- return null;
- return (new DateTime($this->updateTime))->getTimestamp();
- }
-
- public function getDate(): string {
- return date('j M', $this->getTimestamp());
- }
-
- public function getYear(): int {
- return (int)date('Y', $this->getTimestamp());
- }
-
- public function getFullDate(): string {
- return date('j F Y', $this->getTimestamp());
- }
-
- public function getDateForInputField(): string {
- return date('Y-m-d', $this->getTimestamp());
- }
-}
\ No newline at end of file
diff --git a/lib/PreviousText.php b/lib/PreviousText.php
deleted file mode 100644
index 1bb47aa..0000000
--- a/lib/PreviousText.php
+++ /dev/null
@@ -1,16 +0,0 @@
-randomId;
- }
-
- public function getDirectUrl(): string {
- global $config;
- return $config['uploads_path'].'/'.$this->randomId.'/'.$this->name;
- }
-
- public function getDirectPreviewUrl(int $w, int $h, bool $retina = false): string {
- global $config;
- if ($w == $this->imageW && $this->imageH == $h)
- return $this->getDirectUrl();
-
- if ($retina) {
- $w *= 2;
- $h *= 2;
- }
-
- $prefix = $this->imageMayHaveAlphaChannel() ? 'a' : 'p';
- return $config['uploads_path'].'/'.$this->randomId.'/'.$prefix.$w.'x'.$h.'.jpg';
- }
-
- // TODO remove?
- public function incrementDownloads() {
- $db = DB();
- $db->query("UPDATE uploads SET downloads=downloads+1 WHERE id=?", $this->id);
- $this->downloads++;
- }
-
- public function getSize(): string {
- return sizeString($this->size);
- }
-
- public function getMarkdown(?string $options = null): string {
- if ($this->isImage()) {
- $md = '{image:'.$this->randomId.',w='.$this->imageW.',h='.$this->imageH.($options ? ','.$options : '').'}{/image}';
- } else if ($this->isVideo()) {
- $md = '{video:'.$this->randomId.($options ? ','.$options : '').'}{/video}';
- } else {
- $md = '{fileAttach:'.$this->randomId.($options ? ','.$options : '').'}{/fileAttach}';
- }
- $md .= ' ';
- return $md;
- }
-
- public function setNote(PostLanguage $lang, string $note) {
- $db = DB();
- $db->query("UPDATE uploads SET note_{$lang->value}=? WHERE id=?", $note, $this->id);
- }
-
- public function isImage(): bool {
- return in_array(extension($this->name), self::$ImageExtensions);
- }
-
- // assume all png images have alpha channel
- // i know this is wrong, but anyway
- public function imageMayHaveAlphaChannel(): bool {
- return strtolower(extension($this->name)) == 'png';
- }
-
- public function isVideo(): bool {
- return in_array(extension($this->name), self::$VideoExtensions);
- }
-
- public function getImageRatio(): float {
- return $this->imageW / $this->imageH;
- }
-
- public function getImagePreviewSize(?int $w = null, ?int $h = null): array {
- if (is_null($w) && is_null($h))
- throw new Exception(__METHOD__.': both width and height can\'t be null');
-
- if (is_null($h))
- $h = round($w / $this->getImageRatio());
-
- if (is_null($w))
- $w = round($h * $this->getImageRatio());
-
- return [$w, $h];
- }
-
- public function createImagePreview(?int $w = null,
- ?int $h = null,
- bool $force_update = false,
- bool $may_have_alpha = false): bool {
- global $config;
-
- $orig = $config['uploads_dir'].'/'.$this->randomId.'/'.$this->name;
- $updated = false;
-
- foreach (themes::getThemes() as $theme) {
- if (!$may_have_alpha && $theme == 'dark')
- continue;
-
- for ($mult = 1; $mult <= 2; $mult++) {
- $dw = $w * $mult;
- $dh = $h * $mult;
-
- $prefix = $may_have_alpha ? 'a' : 'p';
- $dst = $config['uploads_dir'].'/'.$this->randomId.'/'.$prefix.$dw.'x'.$dh.($theme == 'dark' ? '_dark' : '').'.jpg';
-
- if (file_exists($dst)) {
- if (!$force_update)
- continue;
- unlink($dst);
- }
-
- $img = imageopen($orig);
- imageresize($img, $dw, $dh, themes::getThemeAlphaColorAsRGB($theme));
- imagejpeg($img, $dst, $mult == 1 ? 93 : 67);
- imagedestroy($img);
-
- setperm($dst);
- $updated = true;
- }
- }
-
- return $updated;
- }
-
- /**
- * @return int Number of deleted files
- */
- public function deleteAllImagePreviews(): int {
- global $config;
- $dir = $config['uploads_dir'].'/'.$this->randomId;
- $files = scandir($dir);
- $deleted = 0;
- foreach ($files as $f) {
- if (preg_match('/^[ap](\d+)x(\d+)(?:_dark)?\.jpg$/', $f)) {
- if (is_file($dir.'/'.$f))
- unlink($dir.'/'.$f);
- else
- logError(__METHOD__.': '.$dir.'/'.$f.' is not a file!');
- $deleted++;
- }
- }
- return $deleted;
- }
-
- public function getJSONEncodedHtmlSafeNote(string $lang): string {
- $value = $lang == 'en' ? $this->noteEn : $this->noteRu;
- return jsonEncode(preg_replace('/(\r)?\n/', '\n', addslashes($value)));
- }
-
-}
\ No newline at end of file
diff --git a/lib/WFFCollectionItem.php b/lib/WFFCollectionItem.php
deleted file mode 100644
index 6a14f71..0000000
--- a/lib/WFFCollectionItem.php
+++ /dev/null
@@ -1,53 +0,0 @@
-id; }
- public function isAvailable(): bool { return true; }
- public function getTitle(): string { return $this->title; }
- public function getDocumentId(): string { return $this->isFolder() ? str_replace('_', ' ', basename($this->path)) : $this->documentId; }
- public function isTargetBlank(): bool { return $this->isFile(); }
- public function getSubtitle(): ?string { return null; }
-
- public function getUrl(): string {
- global $config;
- return $this->isFolder()
- ? "/files/wff/{$this->id}/"
- : "https://{$config['files_domain']}/NSA Friedman Documents/{$this->path}";
- }
-
- public function getMeta(?string $hl_matched = null): array {
- if ($this->isFolder()) {
- if (!$this->parentId)
- return [];
- return [
- 'items' => [
- highlightSubstring($this->getDocumentId(), $hl_matched),
- langNum('files_count', $this->filesCount)
- ]
- ];
- }
- return [
- 'inline' => false,
- 'items' => [
- highlightSubstring('Document '.$this->documentId),
- sizeString($this->size),
- 'PDF'
- ]
- ];
- }
-
-}
diff --git a/lib/files.php b/lib/files.php
deleted file mode 100644
index eb477c7..0000000
--- a/lib/files.php
+++ /dev/null
@@ -1,390 +0,0 @@
-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;
- }
-
-}
diff --git a/lib/pages.php b/lib/pages.php
deleted file mode 100644
index 1ea6be1..0000000
--- a/lib/pages.php
+++ /dev/null
@@ -1,39 +0,0 @@
-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")));
- }
-
-}
\ No newline at end of file
diff --git a/lib/posts.php b/lib/posts.php
deleted file mode 100644
index f95f865..0000000
--- a/lib/posts.php
+++ /dev/null
@@ -1,153 +0,0 @@
-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);
- }
-
-}
\ No newline at end of file
diff --git a/lib/uploads.php b/lib/uploads.php
deleted file mode 100644
index 01fc0a0..0000000
--- a/lib/uploads.php
+++ /dev/null
@@ -1,161 +0,0 @@
-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;
- }
-
-}
diff --git a/skin/error.twig b/skin/error.twig
deleted file mode 100644
index 934f165..0000000
--- a/skin/error.twig
+++ /dev/null
@@ -1,9 +0,0 @@
-
-{{ code }} {{ title }}
-
-{{ code }} {{ title }}
-{% if message %}
- {{ message }}
-{% endif %}
-
-
\ No newline at end of file
diff --git a/src/engine/GlobalContext.php b/src/engine/GlobalContext.php
new file mode 100644
index 0000000..f1e2535
--- /dev/null
+++ b/src/engine/GlobalContext.php
@@ -0,0 +1,56 @@
+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;
+ }
+}
\ No newline at end of file
diff --git a/engine/model.php b/src/engine/Model.php
similarity index 72%
rename from engine/model.php
rename to src/engine/Model.php
index ffcdc5a..860f608 100644
--- a/engine/model.php
+++ b/src/engine/Model.php
@@ -1,19 +1,12 @@
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;
- }
- }
-
-}
\ No newline at end of file
diff --git a/src/engine/ModelFieldType.php b/src/engine/ModelFieldType.php
new file mode 100644
index 0000000..a46770a
--- /dev/null
+++ b/src/engine/ModelFieldType.php
@@ -0,0 +1,16 @@
+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;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/engine/ModelSpec.php b/src/engine/ModelSpec.php
new file mode 100644
index 0000000..7672941
--- /dev/null
+++ b/src/engine/ModelSpec.php
@@ -0,0 +1,27 @@
+properties;
+ }
+
+ public function getDbNameMap(): array {
+ return $this->dbNameMap;
+ }
+
+ public function getPropNames(): array {
+ return array_keys($this->dbNameMap);
+ }
+}
\ No newline at end of file
diff --git a/engine/mysql.php b/src/engine/MySQL.php
similarity index 70%
rename from engine/mysql.php
rename to src/engine/MySQL.php
index 510bab5..caa6e62 100644
--- a/engine/mysql.php
+++ b/src/engine/MySQL.php
@@ -1,7 +1,14 @@
query(...$values);
@@ -129,9 +137,9 @@ class mysql {
try {
$q = $this->link->query($sql);
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) {
- logError(__METHOD__.': '.$e->getMessage()."\n$sql\n".backtraceAsString(1));
+ logError(__METHOD__.': '.$e->getMessage()."\n$sql\n".logging\Util::backtraceAsString(1));
}
return $q;
}
@@ -187,89 +195,4 @@ class mysql {
public function escape(string $s): string {
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;
}
diff --git a/src/engine/MySQLBitField.php b/src/engine/MySQLBitField.php
new file mode 100644
index 0000000..f4b6b9b
--- /dev/null
+++ b/src/engine/MySQLBitField.php
@@ -0,0 +1,65 @@
+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 . ')');
+ }
+}
\ No newline at end of file
diff --git a/engine/router.php b/src/engine/Router.php
similarity index 83%
rename from engine/router.php
rename to src/engine/Router.php
index 4baaa2c..ac05f3a 100644
--- a/engine/router.php
+++ b/src/engine/Router.php
@@ -1,7 +1,9 @@
[],
'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)
- self::$instance = new router();
+ self::$instance = new Router();
return self::$instance;
}
private function __construct() {
- $mc = MC();
+ global $globalContext;
+ $mc = getMC();
$from_cache = !isDev();
$write_cache = !isDev();
@@ -34,9 +37,9 @@ class router {
}
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)
$this->add($route, $controller.' '.$resolve);
}
@@ -56,15 +59,17 @@ class router {
foreach ($matches[1] as $match_index => $variants) {
$variants = explode(',', $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];
$new_templates = [];
foreach ($variants as $variant_index => $variant) {
$new_templates[] = [
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);
@@ -84,8 +89,8 @@ class router {
while ($start_pos < $template_len) {
$slash_pos = strpos($template, '/', $start_pos);
if ($slash_pos !== false) {
- $part = substr($template, $start_pos, $slash_pos-$start_pos+1);
- $start_pos = $slash_pos+1;
+ $part = substr($template, $start_pos, $slash_pos - $start_pos + 1);
+ $start_pos = $slash_pos + 1;
} else {
$part = substr($template, $start_pos);
$start_pos = $template_len;
@@ -99,7 +104,7 @@ class router {
protected function &_addRoute(&$parent, $part, $value = null) {
$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';
@@ -128,7 +133,7 @@ class router {
return $parent[$children_key][$part];
}
- public function find($uri) {
+ public function find($uri): ?string {
if ($uri != '/' && $uri[0] == '/') {
$uri = substr($uri, 1);
}
@@ -140,8 +145,8 @@ class router {
while ($start_pos < $uri_len) {
$slash_pos = strpos($uri, '/', $start_pos);
if ($slash_pos !== false) {
- $part = substr($uri, $start_pos, $slash_pos-$start_pos+1);
- $start_pos = $slash_pos+1;
+ $part = substr($uri, $start_pos, $slash_pos - $start_pos + 1);
+ $start_pos = $slash_pos + 1;
} else {
$part = substr($uri, $start_pos);
$start_pos = $uri_len;
@@ -171,19 +176,17 @@ class router {
}
}
- if (!$found) {
- return false;
- }
+ if (!$found)
+ return null;
}
- if (!isset($parent['value'])) {
- return false;
- }
+ if (!isset($parent['value']))
+ return null;
$value = $parent['value'];
if (!empty($matches)) {
foreach ($matches as $i => $match) {
- $needle = '$('.($i+1).')';
+ $needle = '$('.($i + 1).')';
$pos = strpos($value, $needle);
if ($pos !== false) {
$value = substr_replace($value, $match, $pos, strlen($needle));
diff --git a/lib/sphinx.php b/src/engine/SphinxUtil.php
similarity index 95%
rename from lib/sphinx.php
rename to src/engine/SphinxUtil.php
index 5be2d48..29881d5 100644
--- a/lib/sphinx.php
+++ b/src/engine/SphinxUtil.php
@@ -1,13 +1,17 @@
1) {
$mark_count = substr_count($sql, '?');
- $positions = array();
+ $positions = [];
$last_pos = -1;
for ($i = 0; $i < $mark_count; $i++) {
$last_pos = strpos($sql, '?', $last_pos + 1);
@@ -88,12 +92,12 @@ class sphinx {
protected static function getLink($auto_create = true) {
global $config;
- /** @var ?mysqli $link */
+ /** @var ?\mysqli $link */
static $link = null;
if (!is_null($link) || !$auto_create)
return $link;
- $link = new mysqli();
+ $link = new \mysqli();
$link->real_connect(
$config['sphinx']['host'],
ini_get('mysql.default_user'),
@@ -104,6 +108,4 @@ class sphinx {
return $link;
}
-
-
}
\ No newline at end of file
diff --git a/src/engine/exceptions/InvalidDomainException.php b/src/engine/exceptions/InvalidDomainException.php
new file mode 100644
index 0000000..6a499db
--- /dev/null
+++ b/src/engine/exceptions/InvalidDomainException.php
@@ -0,0 +1,5 @@
+ $error], $code);
+ }
+}
\ No newline at end of file
diff --git a/src/engine/http/AjaxOk.php b/src/engine/http/AjaxOk.php
new file mode 100644
index 0000000..460b7a5
--- /dev/null
+++ b/src/engine/http/AjaxOk.php
@@ -0,0 +1,11 @@
+ $response], $code);
+ }
+}
\ No newline at end of file
diff --git a/src/engine/http/AjaxResponse.php b/src/engine/http/AjaxResponse.php
new file mode 100644
index 0000000..0bc6757
--- /dev/null
+++ b/src/engine/http/AjaxResponse.php
@@ -0,0 +1,15 @@
+name);
+ }
+}
\ No newline at end of file
diff --git a/src/engine/http/HTTPMethod.php b/src/engine/http/HTTPMethod.php
new file mode 100644
index 0000000..d8e2dbe
--- /dev/null
+++ b/src/engine/http/HTTPMethod.php
@@ -0,0 +1,9 @@
+data = $html;
+ }
+
+ public function getBody(): string {
+ return $this->data;
+ }
+
+ public function getHeaders(): ?array {
+ return ['Content-Type: '.$this->contentType];
+ }
+}
\ No newline at end of file
diff --git a/src/engine/http/InputVarType.php b/src/engine/http/InputVarType.php
new file mode 100644
index 0000000..570015a
--- /dev/null
+++ b/src/engine/http/InputVarType.php
@@ -0,0 +1,11 @@
+data = $data;
+ }
+
+ public function getBody(): string {
+ return jsonEncode($this->data);
+ }
+
+ public function getHeaders(): ?array {
+ return ['Content-Type: application/json; charset=utf-8'];
+ }
+}
diff --git a/src/engine/http/PlainTextResponse.php b/src/engine/http/PlainTextResponse.php
new file mode 100644
index 0000000..e39c3f9
--- /dev/null
+++ b/src/engine/http/PlainTextResponse.php
@@ -0,0 +1,20 @@
+data = $text;
+ }
+
+ public function getBody(): string {
+ return $this->data;
+ }
+
+ public function getHeaders(): ?array {
+ return ['Content-Type: text/plain; charset=utf-8'];
+ }
+}
\ No newline at end of file
diff --git a/src/engine/http/RequestHandler.php b/src/engine/http/RequestHandler.php
new file mode 100644
index 0000000..a4554c5
--- /dev/null
+++ b/src/engine/http/RequestHandler.php
@@ -0,0 +1,203 @@
+ ($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();
+ }
+}
diff --git a/src/engine/http/Response.php b/src/engine/http/Response.php
new file mode 100644
index 0000000..edccbcd
--- /dev/null
+++ b/src/engine/http/Response.php
@@ -0,0 +1,25 @@
+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;
+}
diff --git a/src/engine/http/errors/BaseRedirect.php b/src/engine/http/errors/BaseRedirect.php
new file mode 100644
index 0000000..cd9030e
--- /dev/null
+++ b/src/engine/http/errors/BaseRedirect.php
@@ -0,0 +1,55 @@
+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;
+ }
+}
\ No newline at end of file
diff --git a/src/engine/http/errors/Forbidden.php b/src/engine/http/errors/Forbidden.php
new file mode 100644
index 0000000..0a4b045
--- /dev/null
+++ b/src/engine/http/errors/Forbidden.php
@@ -0,0 +1,13 @@
+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;
+ }
+}
\ No newline at end of file
diff --git a/src/engine/http/errors/InternalServerError.php b/src/engine/http/errors/InternalServerError.php
new file mode 100644
index 0000000..9d4292c
--- /dev/null
+++ b/src/engine/http/errors/InternalServerError.php
@@ -0,0 +1,13 @@
+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|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));
+ }
+}
diff --git a/src/engine/lang/StringsBase.php b/src/engine/lang/StringsBase.php
new file mode 100644
index 0000000..0c2386c
--- /dev/null
+++ b/src/engine/lang/StringsBase.php
@@ -0,0 +1,105 @@
+
+ */
+class StringsBase implements ArrayAccess
+{
+ /** @var array */
+ 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);
+ }
+}
diff --git a/src/engine/lang/StringsPack.php b/src/engine/lang/StringsPack.php
new file mode 100644
index 0000000..75a6033
--- /dev/null
+++ b/src/engine/lang/StringsPack.php
@@ -0,0 +1,12 @@
+data;
+ }
+}
diff --git a/src/engine/logging/AnsiColor.php b/src/engine/logging/AnsiColor.php
new file mode 100644
index 0000000..7380436
--- /dev/null
+++ b/src/engine/logging/AnsiColor.php
@@ -0,0 +1,15 @@
+ 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);
+ }
+}
diff --git a/src/engine/logging/FileLogger.php b/src/engine/logging/FileLogger.php
new file mode 100644
index 0000000..3585c21
--- /dev/null
+++ b/src/engine/logging/FileLogger.php
@@ -0,0 +1,87 @@
+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);
+ }
+}
\ No newline at end of file
diff --git a/src/engine/logging/LogLevel.php b/src/engine/logging/LogLevel.php
new file mode 100644
index 0000000..f0b2acc
--- /dev/null
+++ b/src/engine/logging/LogLevel.php
@@ -0,0 +1,11 @@
+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;
+}
\ No newline at end of file
diff --git a/src/engine/logging/Util.php b/src/engine/logging/Util.php
new file mode 100644
index 0000000..afe4762
--- /dev/null
+++ b/src/engine/logging/Util.php
@@ -0,0 +1,68 @@
+ 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();
+ }
+}
\ No newline at end of file
diff --git a/src/engine/skin/BaseSkin.php b/src/engine/skin/BaseSkin.php
new file mode 100644
index 0000000..8bb435f
--- /dev/null
+++ b/src/engine/skin/BaseSkin.php
@@ -0,0 +1,96 @@
+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 \n";
+ $s .= nl2br(htmlescape($e->getTraceAsString()));
+ }
+ }
+ return $s;
+ }
+}
diff --git a/src/engine/skin/ErrorSkin.php b/src/engine/skin/ErrorSkin.php
new file mode 100644
index 0000000..45f0516
--- /dev/null
+++ b/src/engine/skin/ErrorSkin.php
@@ -0,0 +1,35 @@
+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;
+ }
+}
\ No newline at end of file
diff --git a/src/engine/skin/FeaturedSkin.php b/src/engine/skin/FeaturedSkin.php
new file mode 100644
index 0000000..5044331
--- /dev/null
+++ b/src/engine/skin/FeaturedSkin.php
@@ -0,0 +1,296 @@
+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 .= file_get_contents(APP_ROOT.'/src/skins/svg/'.$name.'.svg');
+ $svg .= ' ';
+ return $svg;
+ } else {
+ return ' ';
+ }
+ }
+
+ public function renderBreadCrumbs(array $items, ?string $style = null, bool $mt = false): string {
+ static $chevron = ' ';
+ $buf = implode(array_map(function (array $i) use ($chevron): string {
+ $buf = '';
+ $has_url = array_key_exists('url', $i);
+
+ if ($has_url)
+ $buf .= '';
+ else
+ $buf .= '';
+ $buf .= htmlescape($i['text']);
+
+ if ($has_url)
+ $buf .= ' '.$chevron.' ';
+ else
+ $buf .= '';
+
+ return $buf;
+ }, $items));
+ $class = 'bc';
+ if ($mt)
+ $class .= ' mt';
+ return ''.$buf.'
';
+ }
+
+ 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 .= ''.$p.' ';
+ }
+
+ if ($min_page > 2) {
+ $pages_html = '
'.$pages_html;
+ }
+ if ($min_page > 1) {
+ $pages_html = '1 '.$pages_html;
+ }
+
+ if ($max_page < $pages - 1) {
+ $pages_html .= '
';
+ }
+ if ($max_page < $pages) {
+ $pages_html .= ''.$pages.' ';
+ }
+
+ $pn_class = 'pn';
+ if ($pages < 2) {
+ $pn_class .= ' no-nav';
+ if (!$count) {
+ $pn_class .= ' no-results';
+ }
+ }
+
+ $html = <<
+
+ {$pages_html}
+
+
+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 = '';
+ foreach ($this->svgDefs as $name => $icon) {
+ $content = file_get_contents(APP_ROOT.'/src/skins/svg/'.$name.'.svg');
+ $buf .= "$content ";
+ }
+ $buf .= ' ';
+ 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 = '';
+ return $html;
+ }
+
+ protected function jsLink(string $name): string {
+ list (, $bname) = $this->getStaticNameParts($name);
+ if (isDev()) {
+ $href = '/js.php?name='.urlencode($bname).'&v='.time();
+ } else {
+ $href = '/dist-js/'.$bname.'.js?v='.$this->getStaticVersion($name);
+ }
+ return '';
+ }
+
+ protected function cssLink(string $name, string $theme, &$bname = null): string {
+ list(, $bname) = $this->getStaticNameParts($name);
+
+ $config_name = 'css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css';
+
+ if (isDev()) {
+ $href = '/sass.php?name='.urlencode($bname).'&theme='.$theme.'&v='.time();
+ } else {
+ $version = $this->getStaticVersion($config_name);
+ $href = '/dist-css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css?v='.$version;
+ }
+
+ $id = 'style_'.$bname;
+ if ($theme == 'dark')
+ $id .= '_dark';
+
+ return ' getStaticIntegrityAttribute($config_name).'>';
+ }
+
+ protected function cssPrefetchLink(string $name): string {
+ $url = '/dist-css/'.$name.'.css?v='.$this->getStaticVersion('css/'.$name.'.css');
+ $integrity = $this->getStaticIntegrityAttribute('css/'.$name.'.css');
+ return ' ';
+ }
+
+ protected function getStaticNameParts(string $name): array {
+ $dname = dirname($name);
+ $bname = basename($name);
+ if (($pos = strrpos($bname, '.'))) {
+ $ext = substr($bname, $pos + 1);
+ $bname = substr($bname, 0, $pos);
+ } else {
+ $ext = '';
+ }
+ return [$dname, $bname, $ext];
+ }
+
+ protected function getStaticVersion(string $name): string {
+ global $config;
+ if (isDev())
+ return time();
+ if (str_starts_with($name, '/')) {
+ logWarning(__FUNCTION__.': '.$name.' starts with /');
+ $name = substr($name, 1);
+ }
+ return $config['static'][$name]['version'] ?? 'notfound';
+ }
+
+ protected function getStaticIntegrityAttribute(string $name): string {
+ if (isDev())
+ return '';
+ global $config;
+ return ' integrity="'.implode(' ', array_map(fn($hash_type) => $hash_type.'-'.$config['static'][$name]['integrity'][$hash_type], self::RESOURCE_INTEGRITY_HASHES)).'"';
+ }
+}
\ No newline at end of file
diff --git a/src/engine/skin/Meta.php b/src/engine/skin/Meta.php
new file mode 100644
index 0000000..852db41
--- /dev/null
+++ b/src/engine/skin/Meta.php
@@ -0,0 +1,67 @@
+ 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 = ' $v)
+ $s .= ' '.htmlescape($k).'="'.htmlescape($v).'"';
+ $s .= '/>';
+ $s .= "\n";
+ return $s;
+ }, $tags));
+ }
+}
\ No newline at end of file
diff --git a/src/engine/skin/Options.php b/src/engine/skin/Options.php
new file mode 100644
index 0000000..1d4a672
--- /dev/null
+++ b/src/engine/skin/Options.php
@@ -0,0 +1,15 @@
+ $value) {
+ $snake = strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1_$2', $prop));
+ $opts[$snake] = $value;
+ }
+ return $opts;
+ }
+}
\ No newline at end of file
diff --git a/src/engine/skin/ServiceSkin.php b/src/engine/skin/ServiceSkin.php
new file mode 100644
index 0000000..3209703
--- /dev/null
+++ b/src/engine/skin/ServiceSkin.php
@@ -0,0 +1,14 @@
+getNode('params'));
@@ -30,10 +31,10 @@ class JsTagNode extends \Twig\Node\Node {
}
$compiler
- ->write('skin::getInstance()->addJS($js);')
+ // ->write('$context[\'__skin_ref\']->addJS($js);')
+ ->write('$this->env->getRuntime(\''.JsTagRuntime::class.'\')->addJS($js);')
->raw(PHP_EOL)
->write('unset($js);')
->raw(PHP_EOL);
}
-
}
diff --git a/lib/TwigAddons/JsTagParamsNode.php b/src/engine/skin/TwigAddons/JsTagParamsNode.php
similarity index 71%
rename from lib/TwigAddons/JsTagParamsNode.php
rename to src/engine/skin/TwigAddons/JsTagParamsNode.php
index e0a07a3..7583076 100644
--- a/lib/TwigAddons/JsTagParamsNode.php
+++ b/src/engine/skin/TwigAddons/JsTagParamsNode.php
@@ -1,6 +1,6 @@
skin = $skin;
+ }
+
+ public function addJS(string $js): void {
+ $this->skin->addJS($js);
+ }
+}
\ No newline at end of file
diff --git a/lib/TwigAddons/JsTagTokenParser.php b/src/engine/skin/TwigAddons/JsTagTokenParser.php
similarity index 98%
rename from lib/TwigAddons/JsTagTokenParser.php
rename to src/engine/skin/TwigAddons/JsTagTokenParser.php
index 00a3e49..a9ce55c 100644
--- a/lib/TwigAddons/JsTagTokenParser.php
+++ b/src/engine/skin/TwigAddons/JsTagTokenParser.php
@@ -1,9 +1,11 @@
getLine();
$stream = $this->parser->getStream();
@@ -80,5 +82,4 @@ class JsTagTokenParser extends \Twig\TokenParser\AbstractTokenParser {
public function getTag() {
return 'js';
}
-
}
diff --git a/src/engine/skin/TwigAddons/JsTwigExtension.php b/src/engine/skin/TwigAddons/JsTwigExtension.php
new file mode 100644
index 0000000..2bb13fb
--- /dev/null
+++ b/src/engine/skin/TwigAddons/JsTwigExtension.php
@@ -0,0 +1,16 @@
+ \skin::getInstance()->getSVG($name),
+ new TwigFunction('svg', fn($name) => $this->skin->getSVG($name),
['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']]),
new TwigFunction('svgPreload', function(...$icons) {
- $skin = \skin::getInstance();
foreach ($icons as $icon)
- $skin->preloadSVG($icon);
+ $this->skin->preloadSVG($icon);
return null;
}),
- new TwigFunction('bc', fn(...$args) => \skin::getInstance()->renderBreadCrumbs(...$args),
+ new TwigFunction('bc', fn(...$args) => $this->skin->renderBreadCrumbs(...$args),
['is_safe' => ['html']]),
- new TwigFunction('pageNav', fn(...$args) => \skin::getInstance()->renderPageNav(...$args),
+ new TwigFunction('pageNav', fn(...$args) => $this->skin->renderPageNav(...$args),
['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() {
- return array(
+ return [
new TwigFilter('lang', function($key, array $args = []) {
- global $__lang;
array_walk($args, function(&$item, $key) {
$item = htmlescape($item);
});
array_unshift($args, $key);
- return call_user_func_array([$__lang, 'get'], $args);
+ return $this->skin->strings->get(...$args);
}, ['is_variadic' => true]),
new TwigFilter('hl', function($s, $keywords) {
@@ -49,19 +54,9 @@ class MyExtension extends AbstractExtension {
}),
new TwigFilter('plural', function($text, array $args = []) {
- global $__lang;
array_unshift($args, $text);
- return call_user_func_array([$__lang, 'num'], $args);
+ return $this->skin->strings->num(...$args);
}, ['is_variadic' => true]),
- );
+ ];
}
-
- public function getTokenParsers() {
- return [new JsTagTokenParser()];
- }
-
- public function getName() {
- return 'lang';
- }
-
}
diff --git a/src/engine_functions.php b/src/engine_functions.php
new file mode 100644
index 0000000..c1b7112
--- /dev/null
+++ b/src/engine_functions.php
@@ -0,0 +1,81 @@
+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;
+}
diff --git a/functions.php b/src/functions.php
similarity index 85%
rename from functions.php
rename to src/functions.php
index 1142135..e753bef 100644
--- a/functions.php
+++ b/src/functions.php
@@ -1,32 +1,5 @@
($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 {
if (is_array($s)) {
foreach ($s as $k => $v) {
@@ -38,11 +11,11 @@ function htmlescape(string|array $s): string|array {
}
function sizeString(int $size): string {
- $ks = array('B', 'KiB', 'MiB', 'GiB');
+ $ks = ['B', 'KiB', 'MiB', 'GiB'];
foreach ($ks as $i => $k) {
if ($size < pow(1024, $i + 1)) {
if ($i == 0)
- return $size . ' ' . $k;
+ return $size.' '.$k;
return round($size / pow(1024, $i), 2).' '.$k;
}
}
@@ -83,20 +56,20 @@ function detectImageType(string $filename) {
}
function transliterate(string $string): string {
- $roman = array(
+ $roman = [
'Sch', 'sch', 'Yo', 'Zh', 'Kh', 'Ts', 'Ch', 'Sh', 'Yu', 'ya', 'yo',
'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',
'', 'Y', '', 'E', 'a', 'b', 'v', 'g', 'd', 'e', 'z', 'i', 'y', 'k',
'l', 'm', 'n', 'o', 'p', 'r', 's', 't', 'u', 'f', '', 'y', '', 'e'
- );
- $cyrillic = array(
+ ];
+ $cyrillic = [
'Щ', 'щ', 'Ё', 'Ж', 'Х', 'Ц', 'Ч', 'Ш', 'Ю', 'я', 'ё', 'ж', 'х', 'ц',
'ч', 'ш', 'ю', 'я', 'А', 'Б', 'В', 'Г', 'Д', 'Е', 'З', 'И', 'Й', 'К',
'Л', 'М', 'Н', 'О', 'П', 'Р', 'С', 'Т', 'У', 'Ф', 'Ь', 'Ы', 'Ъ', 'Э',
'а', 'б', 'в', 'г', 'д', 'е', 'з', 'и', 'й', 'к', 'л', 'м', 'н', 'о',
'п', 'р', 'с', 'т', 'у', 'ф', 'ь', 'ы', 'ъ', 'э'
- );
+ ];
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);
}
-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 jsonDecode($json) { return json_decode($json, true); }
@@ -350,7 +304,7 @@ function highlightSubstring(string $s, string|array|null $keywords = []): string
return $buf;
}
-function formatTime($ts, array $opts = array()) {
+function formatTime($ts, array $opts = []) {
$default_opts = [
'date_only' => false,
'day_of_week' => false,
diff --git a/handlers/AdminHandler.php b/src/handlers/foreignone/AdminHandler.php
similarity index 65%
rename from handlers/AdminHandler.php
rename to src/handlers/foreignone/AdminHandler.php
index 3a1ef23..50b783f 100644
--- a/handlers/AdminHandler.php
+++ b/src/handlers/foreignone/AdminHandler.php
@@ -1,58 +1,72 @@
skin->addStatic('css/admin.css', 'js/admin.js');
$this->skin->exportStrings(['error']);
- $this->skin->setRenderOptions(['inside_admin_interface' => true]);
+ $this->skin->options->insideAdminInterface = true;
}
public function beforeDispatch(string $http_method, string $action) {
if ($action != 'login' && !isAdmin())
- self::forbidden();
- }
+ throw new Forbidden();
+ }
- public function GET_index() {
- //$admin_info = admin_current_info();
- $this->skin->setTitle('$admin_title');
- $this->skin->renderPage('admin_index.twig', [
- 'admin_login' => admin::getLogin(),
- 'logout_token' => self::getCSRF('logout'),
+ public function GET_index(): Response {
+ $this->skin->title = lang('admin_title');
+ return $this->skin->renderPage('admin_index.twig', [
+ 'admin_login' => Admin::getLogin(),
+ 'logout_token' => $this->getCSRF('logout'),
]);
}
- public function GET_login() {
+ public function GET_login(): Response {
if (isAdmin())
- self::redirect('/admin/');
- $this->skin->setTitle('$admin_title');
- $this->skin->renderPage('admin_login.twig', [
- 'form_token' => self::getCSRF('adminlogin'),
+ throw new Redirect('/admin/');
+ $this->skin->title = lang('admin_title');
+ return $this->skin->renderPage('admin_login.twig', [
+ 'form_token' => $this->getCSRF('adminlogin'),
]);
}
- public function POST_login() {
- self::checkCSRF('adminlogin');
+ public function POST_login(): Response {
+ $this->checkCSRF('adminlogin');
list($login, $password) = $this->input('login, password');
- admin::auth($login, $password)
- ? self::redirect('/admin/')
- : self::forbidden();
+ if (Admin::auth($login, $password))
+ throw new PermanentRedirect('/admin/');
+ throw new Forbidden('');
}
- public function GET_logout() {
- self::checkCSRF('logout');
- admin::logout();
- self::redirect('/admin/login/', HTTPCode::Found);
+ public function GET_logout(): Response {
+ $this->checkCSRF('logout');
+ Admin::logout();
+ 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)
= $this->input('i:ip, query, url_query, file_query, i:line_query, i:per_page');
if (!$per_page)
$per_page = 100;
- $db = DB();
+ $db = getDB();
$query = trim($query ?? '');
$url_query = trim($url_query ?? '');
@@ -93,7 +107,7 @@ class AdminHandler extends request_handler {
'short_months' => true,
]);
$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))
$row['errtype'] = $error_name;
$list[] = $row;
@@ -145,13 +159,13 @@ class AdminHandler extends request_handler {
$vars += [$query_var_name => $$query_var_name];
}
- $this->skin->setRenderOptions(['wide' => true]);
- $this->skin->setTitle('$admin_errors');
- $this->skin->renderPage('admin_errors.twig', $vars);
+ $this->skin->options->wide = true;
+ $this->skin->title = lang('admin_errors');
+ return $this->skin->renderPage('admin_errors.twig', $vars);
}
- public function GET_auth_log() {
- $db = DB();
+ public function GET_auth_log(): Response {
+ $db = getDB();
$count = (int)$db->result($db->query("SELECT COUNT(*) FROM admin_log"));
$per_page = 100;
list($page, $pages, $offset) = $this->getPage($per_page, $count);
@@ -174,18 +188,18 @@ class AdminHandler extends request_handler {
}, $list);
}
- $this->skin->setRenderOptions(['wide' => true]);
- $this->skin->setTitle('$admin_auth_log');
+ $this->skin->options->wide = true;
+ $this->skin->title = lang('admin_auth_log');
$this->skin->set([
'list' => $list,
'pn_page' => $page,
'pn_pages' => $pages
]);
- $this->skin->renderPage('admin_auth_log.twig');
+ return $this->skin->renderPage('admin_auth_log.twig');
}
- public function GET_actions_log() {
- $field_types = \AdminActions\Util\Logger::getFieldTypes();
+ public function GET_actions_log(): Response {
+ $field_types = \app\AdminActions\Util\Logger::getFieldTypes();
foreach ($field_types as $type_prefix => $type_data) {
for ($i = 1; $i <= $type_data['count']; $i++) {
$name = $type_prefix.'arg'.$i;
@@ -196,13 +210,13 @@ class AdminHandler extends request_handler {
$per_page = 100;
- $count = \AdminActions\Util\Logger::getRecordsCount();
+ $count = \app\AdminActions\Util\Logger::getRecordsCount();
list($page, $pages, $offset) = $this->getPage($per_page, $count);
$admin_ids = [];
$admin_logins = [];
- $records = \AdminActions\Util\Logger::getRecords($offset, $per_page);
+ $records = \app\AdminActions\Util\Logger::getRecords($offset, $per_page);
foreach ($records as $record) {
list($admin_id) = $record->getActorInfo();
if ($admin_id !== null)
@@ -210,7 +224,7 @@ class AdminHandler extends request_handler {
}
if (!empty($admin_ids))
- $admin_logins = admin::getLoginsById(array_keys($admin_ids));
+ $admin_logins = Admin::getLoginsById(array_keys($admin_ids));
$url = '/admin/actions-log/?';
@@ -222,37 +236,37 @@ class AdminHandler extends request_handler {
}
}
- $this->skin->setRenderOptions(['wide' => true]);
- $this->skin->setTitle('$admin_actions_log');
- $this->skin->renderPage('admin_actions_log.twig', [
+ $this->skin->options->wide = true;
+ $this->skin->title = lang('admin_actions_log');
+ return $this->skin->renderPage('admin_actions_log.twig', [
'list' => $records,
'pn_page' => $page,
'pn_pages' => $pages,
'admin_logins' => $admin_logins,
'url' => $url,
- 'action_types' => \AdminActions\Util\Logger::getActions(true),
+ 'action_types' => \app\AdminActions\Util\Logger::getActions(true),
]);
}
- public function GET_uploads() {
+ public function GET_uploads(): Response {
list($error) = $this->input('error');
- $uploads = uploads::getAllUploads();
+ $uploads = Upload::getAllUploads();
- $this->skin->setTitle('$blog_upload');
- $this->skin->renderPage('admin_uploads.twig', [
+ $this->skin->title = lang('blog_upload');
+ return $this->skin->renderPage('admin_uploads.twig', [
'error' => $error,
'uploads' => $uploads,
'langs' => PostLanguage::casesAsStrings(),
- 'form_token' => self::getCSRF('add_upload'),
+ 'form_token' => $this->getCSRF('add_upload'),
]);
}
- public function POST_uploads() {
- self::checkCSRF('add_upload');
+ public function POST_uploads(): Response {
+ $this->checkCSRF('add_upload');
list($custom_name, $note_en, $note_ru) = $this->input('name, note_en, note_ru');
if (!isset($_FILES['files']))
- self::redirect('/admin/uploads/?error='.urlencode('no file'));
+ throw new Redirect('/admin/uploads/?error='.urlencode('no file'));
$files = [];
for ($i = 0; $i < count($_FILES['files']['name']); $i++) {
@@ -273,56 +287,56 @@ class AdminHandler extends request_handler {
foreach ($files as $f) {
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'])
- self::redirect('/admin/uploads/?error='.urlencode('received empty file'));
+ throw new Redirect('/admin/uploads/?error='.urlencode('received empty file'));
$ext = extension($f['name']);
- if (!uploads::isExtensionAllowed($ext))
- self::redirect('/admin/uploads/?error='.urlencode('extension not allowed'));
+ if (!Upload::isExtensionAllowed($ext))
+ throw new Redirect('/admin/uploads/?error='.urlencode('extension not allowed'));
$name = $custom_name ?: $f['name'];
- $upload_id = uploads::add(
+ $upload_id = Upload::add(
$f['tmp_name'],
$name,
$note_en,
$note_ru);
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');
- $upload = uploads::get($id);
+ $upload = Upload::get($id);
if (!$upload)
- self::redirect('/admin/uploads/?error='.urlencode('upload not found'));
- self::checkCSRF('delupl'.$id);
- uploads::delete($id);
- admin::log(new \AdminActions\UploadsDelete($id));
- self::redirect('/admin/uploads/');
+ throw new Redirect('/admin/uploads/?error='.urlencode('upload not found'));
+ $this->checkCSRF('delupl'.$id);
+ Upload::delete($id);
+ Admin::logAction(new \app\AdminActions\UploadsDelete($id));
+ 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');
$lang = PostLanguage::tryFrom($lang);
if (!$lang)
- self::notFound();
+ throw new NotFound();
- $upload = uploads::get($id);
+ $upload = Upload::get($id);
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);
- $texts = posts::getTextsWithUpload($upload);
+ $texts = PostText::getTextsWithUpload($upload);
if (!empty($texts)) {
foreach ($texts as $text) {
$text->updateHtml();
@@ -330,12 +344,12 @@ class AdminHandler extends request_handler {
}
}
- admin::log(new \AdminActions\UploadsEditNote($id, $note, $lang->value));
- self::redirect('/admin/uploads/');
+ Admin::logAction(new \app\AdminActions\UploadsEditNote($id, $note, $lang->value));
+ throw new Redirect('/admin/uploads/');
}
- public function POST_ajax_md_preview() {
- self::ensureXhr();
+ public function POST_ajax_md_preview(): Response {
+ $this->ensureIsXHR();
list($md, $title, $use_image_previews, $lang, $is_page) = $this->input('md, title, b:use_image_previews, lang, b:is_page');
$lang = PostLanguage::tryFrom($lang);
if (!$lang)
@@ -344,32 +358,33 @@ class AdminHandler extends request_handler {
$md = '# '.$title."\n\n".$md;
$title = '';
}
- $html = markup::markdownToHtml($md, $use_image_previews, $lang);
+ $html = MarkupUtil::markdownToHtml($md, $use_image_previews, $lang);
$html = $this->skin->render('markdown_preview.twig', [
'unsafe_html' => $html,
'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');
- $page = pages::getByName($name);
+ $page = Page::getByName($name);
if ($page)
- self::redirect($page->getUrl(), code: HTTPCode::Found);
+ throw new Redirect($page->getUrl());
+
$this->skin->exportStrings('/^(err_)?pages_/');
$this->skin->exportStrings('/^(err_)?blog_/');
- $this->skin->setTitle(lang('pages_create_title', $name));
+ $this->skin->title = lang('pages_create_title', $name);
$this->setWidePageOptions();
$js_params = [
'pages' => true,
'edit' => false,
- 'token' => self::getCSRF('addpage'),
+ 'token' => $this->getCSRF('addpage'),
'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,
'short_name' => $name,
'title' => '',
@@ -380,13 +395,13 @@ class AdminHandler extends request_handler {
]);
}
- public function POST_page_add() {
- self::checkCSRF('addpage');
+ public function POST_page_add(): Response {
+ $this->checkCSRF('addpage');
list($name, $text, $title) = $this->input('short_name, text, title');
- $page = pages::getByName($name);
+ $page = Page::getByName($name);
if ($page)
- self::notFound();
+ throw new NotFound();
$error_code = null;
@@ -397,56 +412,52 @@ class AdminHandler extends request_handler {
}
if ($error_code)
- self::ajaxError(['code' => $error_code]);
+ return new AjaxError(['code' => $error_code]);
- if (!pages::add([
+ if (!Page::add([
'short_name' => $name,
'title' => $title,
'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);
- self::ajaxOk(['url' => $page->getUrl()]);
+ $page = Page::getByName($name);
+ return new AjaxOk(['url' => $page->getUrl()]);
}
- public function GET_page_delete() {
+ public function GET_page_delete(): Response {
list($name) = $this->input('short_name');
- $page = pages::getByName($name);
+ $page = Page::getByName($name);
if (!$page)
- self::notFound();
+ throw new NotFound();
$url = $page->getUrl();
- self::checkCSRF('delpage'.$page->shortName);
- pages::delete($page);
- admin::log(new \AdminActions\PageDelete($name));
- self::redirect($url, code: HTTPCode::Found);
+ $this->checkCSRF('delpage'.$page->shortName);
+ Page::delete($page);
+ Admin::logAction(new \app\AdminActions\PageDelete($name));
+ 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');
- $page = pages::getByName($short_name);
+ $page = Page::getByName($short_name);
if (!$page)
- self::notFound();
+ throw new NotFound();
$this->skin->exportStrings('/^(err_)?pages_/');
$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();
- $js_text = [
- 'text' => $page->md,
- 'title' => $page->title,
- ];
-
+
$parent = '';
if ($page->parentId) {
- $parent_page = pages::getById($page->parentId);
+ $parent_page = Page::getById($page->parentId);
if ($parent_page)
$parent = $parent_page->shortName;
}
@@ -454,7 +465,7 @@ class AdminHandler extends request_handler {
$js_params = [
'pages' => 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
'text' => [
'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,
'short_name' => $page->shortName,
'title' => $page->title,
@@ -476,15 +487,15 @@ class AdminHandler extends request_handler {
]);
}
- public function POST_page_edit() {
- self::ensureXhr();
+ public function POST_page_edit(): Response {
+ $this->ensureIsXHR();
list($short_name) = $this->input('short_name');
- $page = pages::getByName($short_name);
+ $page = Page::getByName($short_name);
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)
= $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)
- self::ajaxError(['code' => $error_code]);
+ return new AjaxError(['code' => $error_code]);
$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;
- 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([
'title' => $title,
'md' => $text,
@@ -518,13 +529,13 @@ class AdminHandler extends request_handler {
'parent_id' => $parent_id
]);
- admin::log(new \AdminActions\PageEdit($short_name, $new_short_name));
- self::ajaxOk(['url' => $page->getUrl().'edit/?saved=1']);
+ Admin::logAction(new \app\AdminActions\PageEdit($short_name, $new_short_name));
+ 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->setTitle('$blog_write');
+ $this->skin->title = lang('blog_write');
$this->setWidePageOptions();
$js_texts = [];
@@ -539,7 +550,7 @@ class AdminHandler extends request_handler {
$js_params = [
'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()),
- 'token' => self::getCSRF('post_add')
+ 'token' => $this->getCSRF('post_add')
];
$form_url = '/articles/write/';
@@ -548,8 +559,7 @@ class AdminHandler extends request_handler {
['text' => lang('blog_new_post')]
];
- $this->skin->renderPage('admin_post_form.twig', [
- // form data
+ return $this->skin->renderPage('admin_post_form.twig', [
'title' => '',
'text' => '',
'short_name' => '',
@@ -565,19 +575,23 @@ class AdminHandler extends request_handler {
]);
}
- public function POST_post_add() {
- self::ensureXhr();
- self::checkCSRF('post_add');
+ public function POST_post_add(): Response {
+ $this->ensureIsXHR();
+ $this->checkCSRF('post_add');
list($visibility_enabled, $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 = [];
$at_least_one_lang_is_written = false;
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 !== '') {
$lang_data[$lang->value] = [$title, $text, $keywords, $toc_enabled];
$at_least_one_lang_is_written = true;
@@ -591,9 +605,9 @@ class AdminHandler extends request_handler {
$error_code = 'no_short_name';
}
if ($error_code)
- self::ajaxError(['code' => $error_code]);
+ return new AjaxError(['code' => $error_code]);
- $post = posts::add([
+ $post = Post::add([
'visible' => $visibility_enabled,
'short_name' => $short_name,
'date' => $date,
@@ -601,7 +615,7 @@ class AdminHandler extends request_handler {
]);
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
$added_texts = []; // for admin actions logging, at the end
@@ -614,48 +628,48 @@ class AdminHandler extends request_handler {
keywords: $keywords,
toc: $toc_enabled))
) {
- posts::delete($post);
- self::ajaxError(['code' => 'db_err', 'message' => 'failed to add text language '.$lang]);
+ Post::delete($post);
+ return new AjaxError(['code' => 'db_err', 'message' => 'failed to add text language '.$lang]);
} else {
$added_texts[] = [$new_post_text->id, $lang];
}
}
- admin::log(new \AdminActions\PostCreate($post->id));
+ Admin::logAction(new \app\AdminActions\PostCreate($post->id));
foreach ($added_texts as $added_text) {
list($id, $lang) = $added_text;
- admin::log(new \AdminActions\PostTextCreate($id, $post->id, $lang));
+ Admin::logAction(new \app\AdminActions\PostTextCreate($id, $post->id, $lang));
}
// 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');
- $post = posts::getByName($name);
+ $post = Post::getByName($name);
if (!$post)
- self::notFound();
+ throw new NotFound();
$id = $post->id;
- self::checkCSRF('delpost'.$id);
- posts::delete($post);
- admin::log(new \AdminActions\PostDelete($id));
- self::redirect('/articles/', code: HTTPCode::Found);
+ $this->checkCSRF('delpost'.$id);
+ Post::delete($post);
+ Admin::logAction(new \app\AdminActions\PostDelete($id));
+ 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');
$lang = PostLanguage::from($lang);
- $post = posts::getByName($short_name);
+ $post = Post::getByName($short_name);
if (!$post)
- self::notFound();
+ throw new NotFound();
$texts = $post->getTexts();
if (!isset($texts[$lang->value]))
- self::notFound();
+ throw new NotFound();
$js_texts = [];
foreach (PostLanguage::cases() as $pl) {
@@ -681,7 +695,7 @@ class AdminHandler extends request_handler {
$this->skin->exportStrings('/^(err_)?blog_/');
$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();
$bc = [
@@ -691,14 +705,14 @@ class AdminHandler extends request_handler {
$js_params = [
'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()),
- 'token' => self::getCSRF('editpost'.$post->id),
+ 'token' => $this->getCSRF('editpost'.$post->id),
'edit' => true,
'id' => $post->id,
'texts' => $js_texts
];
$form_url = $post->getUrl().'edit/';
- $this->skin->renderPage('admin_post_form.twig', [
+ return $this->skin->renderPage('admin_post_form.twig', [
'is_edit' => true,
'post' => $post,
'title' => $text->title,
@@ -718,21 +732,24 @@ class AdminHandler extends request_handler {
]);
}
- public function POST_post_edit() {
- self::ensureXhr();
-
+ public function POST_post_edit(): Response {
+ $this->ensureIsXHR();
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)
- 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))
- self::ajaxError(['code' => 'no_short_name']);
+ return new AjaxError(['code' => 'no_short_name']);
foreach (explode(',', $langs) as $lang) {
$lang = PostLanguage::from($lang);
@@ -744,7 +761,7 @@ class AdminHandler extends request_handler {
else if (!$text)
$error_code = 'no_text';
if ($error_code)
- self::ajaxError(['code' => $error_code]);
+ return new AjaxError(['code' => $error_code]);
$pt = $post->getText($lang);
if (!$pt) {
@@ -756,9 +773,9 @@ class AdminHandler extends request_handler {
toc: $toc
);
if (!$pt)
- self::ajaxError(['code' => 'db_err']);
+ return new AjaxError(['code' => 'db_err']);
} 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([
'title' => $title,
'md' => $text,
@@ -777,27 +794,24 @@ class AdminHandler extends request_handler {
$post_data['short_name'] = $short_name;
$post->edit($post_data);
- admin::log(new \AdminActions\PostEdit($post->id));
- self::ajaxOk(['url' => $post->getUrl().'edit/?saved=1&lang='.$lang->value]);
+ Admin::logAction(new \app\AdminActions\PostEdit($post->id));
+ return new AjaxOk(['url' => $post->getUrl().'edit/?saved=1&lang='.$lang->value]);
}
- public function GET_books() {
- $this->skin->setTitle('$admin_books');
- $this->skin->renderPage('admin_books.twig');
+ public function GET_books(): Response {
+ $this->skin->title = lang('admin_books');
+ return $this->skin->renderPage('admin_books.twig');
}
protected static function _postEditValidateCommonData($date) {
$dt = DateTime::createFromFormat("Y-m-d", $date);
$date_is_valid = $dt && $dt->format("Y-m-d") === $date;
if (!$date_is_valid)
- self::ajaxError(['code' => 'no_date']);
+ throw new ParseFormException(code: 'no_date');
}
protected function setWidePageOptions(): void {
- $this->skin->setRenderOptions([
- 'full_width' => true,
- 'no_footer' => true
- ]);
+ $this->skin->options->fullWidth = true;
+ $this->skin->options->noFooter = true;
}
-
}
\ No newline at end of file
diff --git a/src/handlers/foreignone/BaseHandler.php b/src/handlers/foreignone/BaseHandler.php
new file mode 100644
index 0000000..29b9710
--- /dev/null
+++ b/src/handlers/foreignone/BaseHandler.php
@@ -0,0 +1,25 @@
+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()
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/src/handlers/foreignone/FilesHandler.php b/src/handlers/foreignone/FilesHandler.php
new file mode 100644
index 0000000..02f1fa7
--- /dev/null
+++ b/src/handlers/foreignone/FilesHandler.php
@@ -0,0 +1,216 @@
+ 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');
+ }
+}
\ No newline at end of file
diff --git a/handlers/MainHandler.php b/src/handlers/foreignone/MainHandler.php
similarity index 52%
rename from handlers/MainHandler.php
rename to src/handlers/foreignone/MainHandler.php
index 48aaea8..7964ad3 100644
--- a/handlers/MainHandler.php
+++ b/src/handlers/foreignone/MainHandler.php
@@ -1,66 +1,70 @@
skin->addMeta([
- 'og:type' => 'website',
- '@url' => 'https://'.$config['domain'].'/',
- '@title' => lang('meta_index_title'),
- '@description' => lang('meta_index_description'),
- '@image' => 'https://'.$config['domain'].'/img/4in1-preview.jpg'
- ]);
- $this->skin->setTitle('$site_title');
+ $this->skin->meta->title = lang('meta_index_title');
+ $this->skin->meta->description = lang('meta_index_description');
+ $this->skin->meta->url = 'https://'.$config['domain'].'/';
+ $this->skin->meta->image = 'https://'.$config['domain'].'/img/4in1-preview.jpg';
+ $this->skin->meta->setSocial('og:type', 'website');
+
+ $this->skin->options->isIndex = true;
+
$this->skin->set([
'posts' => $posts,
'posts_lang' => $posts_lang,
'versions' => $config['book_versions']
]);
- $this->skin->setRenderOptions(['is_index' => true]);
- $this->skin->renderPage('index.twig');
+ return $this->skin->renderPage('index.twig');
}
- public function GET_about() { self::redirect('/info/'); }
- public function GET_contacts() { self::redirect('/info/'); }
+ public function GET_about() { throw new PermanentRedirect('/info/'); }
+ public function GET_contacts() { throw new PermanentRedirect('/info/'); }
- public function GET_page() {
+ public function GET_page(): Response {
global $config;
list($name) = $this->input('name');
- $page = pages::getByName($name);
+ $page = Page::getByName($name);
if (!$page) {
if (isAdmin()) {
- $this->skin->setTitle($name);
- $this->skin->renderPage('admin_page_new.twig', [
+ $this->skin->title = $name;
+ return $this->skin->renderPage('admin_page_new.twig', [
'short_name' => $name
]);
}
- self::notFound();
+ throw new NotFound();
}
if (!isAdmin() && !$page->visible)
- self::notFound();
+ throw new NotFound();
$bc = null;
- $render_opts = [];
if ($page) {
- $this->skin->addMeta([
- '@url' => 'https://'.$config['domain'].$page->getUrl(),
- '@title' => $page->title,
- ]);
+ $this->skin->meta->url = 'https://'.$config['domain'].$page->getUrl();
+ $this->skin->meta->title = $page->title;
if ($page->parentId) {
$bc = [];
$parent = $page;
while ($parent?->parentId) {
- $parent = pages::getById($parent->parentId);
+ $parent = Page::getById($parent->parentId);
if ($parent)
$bc[] = ['url' => $parent->getUrl(), 'text' => $parent->title];
}
@@ -69,22 +73,21 @@ class MainHandler extends request_handler {
}
if ($page->shortName == 'info')
- $render_opts = ['head_section' => 'about'];
+ $this->skin->options->headSection = 'about';
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->setTitle($page ? $page->title : '???');
- $this->skin->renderPage('page.twig', [
+ $this->skin->title = $page ? $page->title : '???';
+ return $this->skin->renderPage('page.twig', [
'page' => $page,
- 'html' => $page->getHtml(isRetina(), themes::getUserTheme()),
+ 'html' => $page->getHtml(isRetina(), ThemesUtil::getUserTheme()),
'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;
list($name, $input_lang) = $this->input('name, lang');
@@ -92,23 +95,23 @@ class MainHandler extends request_handler {
try {
if ($input_lang)
$lang = PostLanguage::from($input_lang);
- } catch (ValueError $e) {
- self::notFound($e->getMessage());
+ } catch (\ValueError $e) {
+ throw new NotFound($e->getMessage());
}
if (!$lang)
$lang = PostLanguage::getDefault();
- $post = posts::getByName($name);
+ $post = Post::getByName($name);
if (!$post || (!$post->visible && !isAdmin()))
- self::notFound();
+ throw new NotFound();
if ($lang == PostLanguage::getDefault() && $input_lang == $lang->value)
- self::redirect($post->getUrl());
+ throw new PermanentRedirect($post->getUrl());
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())
- self::notFound();
+ throw new NotFound();
$pt = $post->getText($lang);
@@ -120,26 +123,24 @@ class MainHandler extends request_handler {
$other_langs[] = $pl->value;
}
- $meta = [
- '@title' => $pt->title,
- '@url' => $config['domain'].$post->getUrl(),
- '@description' => $pt->getDescriptionPreview(155)
- ];
+ $this->skin->meta->title = $pt->title;
+ $this->skin->meta->description = $pt->getDescriptionPreview(155);
+ $this->skin->meta->url = $config['domain'].$post->getUrl();
if ($pt->keywords)
- $meta['@keywords'] = $pt->keywords;
- $this->skin->addMeta($meta);
+ $this->skin->meta->keywords = $pt->keywords;
if (($img = $pt->getFirstImage()) !== null)
- $this->skin->addMeta(['@image' => $img->getDirectUrl()]);
+ $this->skin->meta->image = $img->getDirectUrl();
- $this->skin->setTitle($pt->title);
- $this->skin->setRenderOptions(['articles_lang' => $lang->value, 'wide' => $pt->hasTableOfContents()]);
- $this->skin->renderPage('post.twig', [
+ $this->skin->title = $pt->title;
+ $this->skin->options->articlesLang = $lang->value;
+ $this->skin->options->wide = $pt->hasTableOfContents();
+ return $this->skin->renderPage('post.twig', [
'post' => $post,
'pt' => $pt,
- 'html' => $pt->getHtml(isRetina(), themes::getUserTheme()),
+ 'html' => $pt->getHtml(isRetina(), ThemesUtil::getUserTheme()),
'selected_lang' => $lang->value,
'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()),
'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', [
'title' => lang('site_title'),
@@ -172,29 +173,26 @@ class MainHandler extends request_handler {
exit;
}
- public function GET_articles() {
+ public function GET_articles(): Response {
list($lang) = $this->input('lang');
if ($lang) {
$lang = PostLanguage::tryFrom($lang);
if (!$lang || $lang == PostLanguage::getDefault())
- self::redirect('/articles/');
+ throw new PermanentRedirect('/articles/');
} else {
$lang = PostLanguage::getDefault();
}
- $posts = posts::getList(
+ $posts = Post::getList(
include_hidden: isAdmin(),
filter_by_lang: $lang);
- $this->skin->setTitle('$articles');
- $this->skin->addMeta([
- '@description' => lang('blog_expl_'.$lang->value)
- ]);
- $this->skin->setRenderOptions(['head_section' => 'articles']);
- $this->skin->renderPage('articles.twig', [
+ $this->skin->title = lang('articles');
+ $this->skin->meta->description = lang('blog_expl_'.$lang->value);
+ $this->skin->options->headSection = 'articles';
+ return $this->skin->renderPage('articles.twig', [
'posts' => $posts,
'selected_lang' => $lang->value
]);
}
-
}
\ No newline at end of file
diff --git a/src/handlers/foreignone/ServicesHandler.php b/src/handlers/foreignone/ServicesHandler.php
new file mode 100644
index 0000000..382f161
--- /dev/null
+++ b/src/handlers/foreignone/ServicesHandler.php
@@ -0,0 +1,28 @@
+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]}");
+ }
+}
\ No newline at end of file
diff --git a/src/handlers/ic/BaseHandler.php b/src/handlers/ic/BaseHandler.php
new file mode 100644
index 0000000..80de7e6
--- /dev/null
+++ b/src/handlers/ic/BaseHandler.php
@@ -0,0 +1,25 @@
+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()
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/src/handlers/ic/MainHandler.php b/src/handlers/ic/MainHandler.php
new file mode 100644
index 0000000..f2d42c3
--- /dev/null
+++ b/src/handlers/ic/MainHandler.php
@@ -0,0 +1,13 @@
+skin->render('soon.twig'));
+ }
+}
\ No newline at end of file
diff --git a/init.php b/src/init.php
similarity index 53%
rename from init.php
rename to src/init.php
index fb3ce41..40b782a 100644
--- a/init.php
+++ b/src/init.php
@@ -8,31 +8,15 @@ date_default_timezone_set('Europe/Moscow');
mb_internal_encoding('UTF-8');
mb_regex_encoding('UTF-8');
-const APP_ROOT = __DIR__;
+define('APP_ROOT', dirname(__DIR__));
define('START_TIME', microtime(true));
set_include_path(get_include_path().PATH_SEPARATOR.APP_ROOT);
-spl_autoload_register(function($class) {
- if (str_contains($class, '\\'))
- $class = str_replace('\\', '/', $class);
-
- if ($class == 'model')
- $path = 'engine/model';
- else if (str_ends_with($class, 'Handler'))
- $path = 'handlers/'.$class;
- else
- $path = 'lib/'.$class;
-
- if (!is_file(APP_ROOT.'/'.$path.'.php'))
- return;
-
- require_once APP_ROOT.'/'.$path.'.php';
-});
-
if (!file_exists(APP_ROOT.'/config.yaml'))
die('Fatal: config.yaml not found');
+global $config;
$config = yaml_parse_file(APP_ROOT.'/config.yaml');
if ($config === false)
die('Fatal: failed to parse config.yaml');
@@ -41,48 +25,45 @@ if ($config === false)
umask($config['umask']);
require_once 'functions.php';
-require_once 'engine/mysql.php';
-require_once 'engine/router.php';
-require_once 'engine/request.php';
-require_once 'engine/logging.php';
+require_once 'engine_functions.php';
+require_once 'vendor/autoload.php';
+
+global $globalContext;
+$globalContext = engine\GlobalContext::getInstance();
try {
if (isCli()) {
- verifyHostname($config['domain']);
+ if (str_ends_with(__DIR__, 'www-dev'))
+ $globalContext->setIsDevelopmentEnvironment(true);
$_SERVER['HTTP_HOST'] = $config['domain'];
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
} else {
- verifyHostname();
+ // IE moment
+ if (($pos = strpos($_SERVER['HTTP_HOST'], ':')) !== false)
+ $_SERVER['HTTP_HOST'] = substr($_SERVER['HTTP_HOST'], 0, $pos);
+ $_SERVER['HTTP_HOST'] = strtolower($_SERVER['HTTP_HOST']);
if (array_key_exists('HTTP_X_REAL_IP', $_SERVER))
$_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_X_REAL_IP'];
-
- require_once 'engine/strings.php';
- require_once 'engine/skin.php';
- require_once 'lib/admin.php';
+ $globalContext->setIsDevelopmentEnvironment(str_ends_with(dirname($_SERVER['DOCUMENT_ROOT']), 'www-dev'));
}
} catch (RuntimeException $e) {
die('Fatal error: '.$e->getMessage());
}
-$__logger = isDev()
- ? new FileLogger(APP_ROOT.'/log/debug.log')
- : new DatabaseLogger();
-$__logger->enable();
+$logger = isDev()
+ ? new engine\logging\FileLogger(APP_ROOT.'/log/debug.log')
+ : new engine\logging\DatabaseLogger();
+$logger->enable();
+$globalContext->setLogger($logger);
+unset($logger);
if (!isDev()) {
if (file_exists(APP_ROOT.'/config-static.php'))
$config['static'] = require_once 'config-static.php';
else
- die('confic-static.php not found');
+ die('config-static.php not found');
// turn off errors output on production domains
error_reporting(0);
ini_set('display_errors', 0);
}
-
-if (!isCli()) {
- $__lang = Strings::getInstance();
- $__lang->load('main');
-}
-
-require 'vendor/autoload.php';
diff --git a/lib/admin.php b/src/lib/Admin.php
similarity index 89%
rename from lib/admin.php
rename to src/lib/Admin.php
index e3e2c7b..440a5d5 100644
--- a/lib/admin.php
+++ b/src/lib/Admin.php
@@ -1,7 +1,9 @@
result($db->query("SELECT COUNT(*) FROM admins WHERE login=? LIMIT 1", $login)) > 0;
}
public static function add(string $login, string $password): int {
- $db = DB();
+ $db = getDB();
$db->insert('admins', [
'login' => $login,
'password' => saltPassword($password),
@@ -28,7 +30,7 @@ class admin {
}
public static function delete(string $login): bool {
- $db = DB();
+ $db = getDB();
$id = self::getIdByLogin($login);
if (!$db->query("DELETE FROM admins WHERE login=?", $login)) return false;
if (!$db->query("DELETE FROM admin_auth WHERE admin_id=?", $id)) return false;
@@ -40,7 +42,7 @@ class admin {
* @return string[]
*/
public static function getLoginsById(array $ids): array {
- $db = DB();
+ $db = getDB();
$logins = [];
$q = $db->query("SELECT id, login FROM admins WHERE id IN (".implode(',', $ids).")");
while ($row = $db->fetch($q)) {
@@ -50,19 +52,19 @@ class admin {
}
protected static function getIdByLogin(string $login): ?int {
- $db = DB();
+ $db = getDB();
$q = $db->query("SELECT id FROM admins WHERE login=?", $login);
return $db->numRows($q) > 0 ? (int)$db->result($q) : null;
}
public static function setPassword(string $login, string $password): bool {
- $db = DB();
+ $db = getDB();
$db->query("UPDATE admins SET password=? WHERE login=?", saltPassword($password), $login);
return $db->affectedRows() > 0;
}
public static function auth(string $login, string $password): bool {
- $db = DB();
+ $db = getDB();
$salted_password = saltPassword($password);
$q = $db->query("SELECT id, active FROM admins WHERE login=? AND password=?", $login, $salted_password);
if (!$db->numRows($q)) {
@@ -108,15 +110,15 @@ class admin {
if (!isAdmin())
return;
- $db = DB();
+ $db = getDB();
$db->query("DELETE FROM admin_auth WHERE id=?", self::$authId);
self::unsetSessionData();
self::unsetCookie();
}
- public static function log(\AdminActions\BaseAction $action) {
- \AdminActions\Util\Logger::record($action);
+ public static function logAction(\app\AdminActions\BaseAction $action) {
+ \app\AdminActions\Util\Logger::record($action);
}
public static function check(): void {
@@ -124,7 +126,7 @@ class admin {
return;
$cookie = (string)$_COOKIE[self::ADMIN_COOKIE_NAME];
- $db = DB();
+ $db = getDB();
$time = time();
$q = $db->query("SELECT
admin_auth.id AS auth_id,
@@ -173,8 +175,15 @@ class admin {
self::$login = null;
}
- public static function getId(): ?int { return self::$id; }
- public static function getCSRFSalt(): ?string { return self::$csrfSalt; }
- public static function getLogin(): ?string { return self::$login; }
+ public static function getId(): ?int {
+ return self::$id;
+ }
+ public static function getCSRFSalt(): ?string {
+ return self::$csrfSalt;
+ }
+
+ public static function getLogin(): ?string {
+ return self::$login;
+ }
}
diff --git a/lib/AdminActions/BaseAction.php b/src/lib/AdminActions/BaseAction.php
similarity index 97%
rename from lib/AdminActions/BaseAction.php
rename to src/lib/AdminActions/BaseAction.php
index b2f0072..66308e8 100644
--- a/lib/AdminActions/BaseAction.php
+++ b/src/lib/AdminActions/BaseAction.php
@@ -1,9 +1,9 @@
\admin::getId(),
+ 'admin_id' => \app\Admin::getId(),
'ip' => !empty($_SERVER['REMOTE_ADDR']) ? ip2ulong($_SERVER['REMOTE_ADDR']) : 0,
];
}
@@ -48,14 +47,14 @@ class Logger {
}
}
- $db = DB();
+ $db = getDB();
$db->insert(self::TABLE, $data);
return $db->insertId();
}
public static function getRecordById(int $id): ?BaseAction {
- $db = DB();
+ $db = getDB();
$q = $db->query("SELECT * FROM ".self::TABLE." WHERE id=?", $id);
if (!$db->numRows($q))
return null;
@@ -65,7 +64,7 @@ class Logger {
public static function getRecordsCount(?array $admin_types = null,
?array $actions = null,
?array $arguments = null): int {
- $db = DB();
+ $db = getDB();
$sql = "SELECT COUNT(*) FROM ".self::TABLE;
$where = self::getSQLSelectConditions($admin_types, $actions, $arguments);
if ($where != '')
@@ -87,7 +86,7 @@ class Logger {
?array $admin_types = null,
?array $actions = null,
?array $arguments = null): array {
- $db = DB();
+ $db = getDB();
$sql = "SELECT * FROM ".self::TABLE;
$where = self::getSQLSelectConditions($admin_types, $actions, $arguments);
if ($where != '')
@@ -104,7 +103,7 @@ class Logger {
* @return BaseAction[]
*/
public static function getUserRecords(int $user_id, ?int $time_from, ?int $time_to): array {
- $db = DB();
+ $db = getDB();
$sql = "SELECT * FROM ".self::TABLE." WHERE admin_id={$user_id}";
if ($time_from && $time_to)
$sql .= " AND ts BETWEEN {$time_from} AND {$time_to} ";
@@ -116,15 +115,15 @@ class Logger {
?array $actions = null,
?array $arguments = null): string {
$wheres = [];
- $db = DB();
+ $db = getDB();
if (!empty($admin_types))
$wheres[] = "admin_type IN ('".implode("', '", $admin_types)."')";
if (!empty($actions)) {
$actions = array_map(
- /** @var BaseAction|int $action */
- fn($action) => is_string($action) ? $action::getActionId() : $action, $actions);
+ fn(BaseAction|int $action) => $action instanceof BaseAction ? $action::getActionId() : $action,
+ $actions);
$wheres[] = "action IN (".implode(',', $actions).")";
}
@@ -249,7 +248,7 @@ class Logger {
continue;
$class_name = substr($f, 0, strpos($f, '.'));
- $class = '\\AdminActions\\'.$class_name;
+ $class = '\\app\\AdminActions\\'.$class_name;
if (interface_exists($class) || !class_exists($class)) {
// logError(__METHOD__.': class '.$class.' not found');
@@ -314,5 +313,4 @@ class Logger {
}
return $types;
}
-
}
diff --git a/lib/cli.php b/src/lib/CliUtil.php
similarity index 92%
rename from lib/cli.php
rename to src/lib/CliUtil.php
index 8df2203..1cb887c 100644
--- a/lib/cli.php
+++ b/src/lib/CliUtil.php
@@ -1,7 +1,9 @@
usage();
if (empty($this->commands))
- cli::die("no commands added");
+ CliUtil::die("no commands added");
$func = $argv[1];
if (!isset($this->commands[$func]))
@@ -64,5 +66,4 @@ class cli {
public static function error($error): void {
fwrite(STDERR, "error: {$error}\n");
}
-
}
\ No newline at end of file
diff --git a/lib/markup.php b/src/lib/MarkupUtil.php
similarity index 67%
rename from lib/markup.php
rename to src/lib/MarkupUtil.php
index 29b73dd..765bfe1 100644
--- a/lib/markup.php
+++ b/src/lib/MarkupUtil.php
@@ -1,11 +1,15 @@
text($md);
@@ -25,15 +29,15 @@ class markup {
$html = preg_replace($re, '', $html);
$re = '/'.implode('|', array_map(fn($m) => '(?:'.$span_opening_tag.')?'.preg_quote($m, '/'), $matches[1])).'/';
$html = preg_replace_callback($re,
- function($match) use ($span_opening_tag, $reftitles_map) {
- if (str_starts_with($match[0], $span_opening_tag))
- return $match[0];
- if (!preg_match('/\[([io]?\d{1,2})]/', $match[0], $refmatch))
- return $match[0];
- $refname = $refmatch[1];
- $reftitle = $reftitles_map[$refname];
- return ''.$match[0].' ';
- }, $html);
+ function ($match) use ($span_opening_tag, $reftitles_map) {
+ if (str_starts_with($match[0], $span_opening_tag))
+ return $match[0];
+ if (!preg_match('/\[([io]?\d{1,2})]/', $match[0], $refmatch))
+ return $match[0];
+ $refname = $refmatch[1];
+ $reftitle = $reftitles_map[$refname];
+ return ''.$match[0].' ';
+ }, $html);
}
}
@@ -67,13 +71,12 @@ class markup {
$is_dark_theme = $user_theme === 'dark';
return preg_replace_callback(
'/('.preg_quote($config['uploads_path'], '/').'\/\w{8}\/)([ap])(\d+)x(\d+)(\.jpg)/',
- function($match) use ($is_retina, $is_dark_theme) {
+ function ($match) use ($is_retina, $is_dark_theme) {
$mult = $is_retina ? 2 : 1;
$is_alpha = $match[2] == 'a';
- return $match[1].$match[2].(intval($match[3])*$mult).'x'.(intval($match[4])*$mult).($is_alpha && $is_dark_theme ? '_dark' : '').$match[5];
+ return $match[1].$match[2].(intval($match[3]) * $mult).'x'.(intval($match[4]) * $mult).($is_alpha && $is_dark_theme ? '_dark' : '').$match[5];
},
$html
);
}
-
}
\ No newline at end of file
diff --git a/lib/MyParsedown.php b/src/lib/MyParsedown.php
similarity index 87%
rename from lib/MyParsedown.php
rename to src/lib/MyParsedown.php
index 4b5160c..0ec942c 100644
--- a/lib/MyParsedown.php
+++ b/src/lib/MyParsedown.php
@@ -1,14 +1,19 @@
strlen($matches[0]),
'element' => [
@@ -46,7 +52,7 @@ class MyParsedown extends ParsedownExtended {
unset($result['element']['text']);
- $result['element']['rawHtml'] = skin::getInstance()->render('markdown_fileupload.twig', [
+ $result['element']['rawHtml'] = $globalContext->getSkin()->render('markdown_fileupload.twig', [
'name' => $upload->name,
'direct_url' => $upload->getDirectUrl(),
'note' => $upload->noteRu,
@@ -58,6 +64,7 @@ class MyParsedown extends ParsedownExtended {
}
protected function inlineImage($excerpt) {
+ global $globalContext;
if (preg_match('/^{image:([\w]{8}),(.*?)}{\/image}/', $excerpt['text'], $matches)) {
$random_id = $matches[1];
@@ -82,7 +89,7 @@ class MyParsedown extends ParsedownExtended {
}
}
- $image = uploads::getUploadByRandomId($random_id);
+ $image = Upload::getUploadByRandomId($random_id);
$result = [
'extent' => strlen($matches[0]),
'element' => [
@@ -110,7 +117,7 @@ class MyParsedown extends ParsedownExtended {
unset($result['element']['text']);
- $result['element']['rawHtml'] = skin::getInstance()->render('markdown_image.twig', [
+ $result['element']['rawHtml'] = $globalContext->getSkin()->render('markdown_image.twig', [
'w' => $opts['w'],
'nolabel' => $opts['nolabel'],
'align' => $opts['align'],
@@ -119,7 +126,7 @@ class MyParsedown extends ParsedownExtended {
'url' => $image_url,
'direct_url' => $image->getDirectUrl(),
- 'unsafe_note' => markup::markdownToHtml(
+ 'unsafe_note' => MarkupUtil::markdownToHtml(
md: $this->lang !== null && $this->lang == PostLanguage::Russian ? $image->noteRu : $image->noteEn,
no_paragraph: true),
]);
@@ -129,6 +136,7 @@ class MyParsedown extends ParsedownExtended {
}
protected function inlineVideo($excerpt) {
+ global $globalContext;
if (preg_match('/^{video:([\w]{8})(?:,(.*?))?}{\/video}/', $excerpt['text'], $matches)) {
$random_id = $matches[1];
@@ -151,7 +159,7 @@ class MyParsedown extends ParsedownExtended {
}
}
- $video = uploads::getUploadByRandomId($random_id);
+ $video = Upload::getUploadByRandomId($random_id);
$result = [
'extent' => strlen($matches[0]),
'element' => [
@@ -169,7 +177,7 @@ class MyParsedown extends ParsedownExtended {
unset($result['element']['text']);
- $result['element']['rawHtml'] = skin::getInstance()->render('markdown_video.twig', [
+ $result['element']['rawHtml'] = $globalContext->getSkin()->render('markdown_video.twig', [
'url' => $video_url,
'w' => $opts['w'],
'h' => $opts['h']
@@ -219,5 +227,4 @@ class MyParsedown extends ParsedownExtended {
return parent::blockFencedCodeComplete($block);
}
-
}
diff --git a/lib/themes.php b/src/lib/ThemesUtil.php
similarity index 97%
rename from lib/themes.php
rename to src/lib/ThemesUtil.php
index 07a025a..c535ad8 100644
--- a/lib/themes.php
+++ b/src/lib/ThemesUtil.php
@@ -1,7 +1,9 @@
[
'bg' => 0x222222,
@@ -46,5 +48,4 @@ class themes {
public static function isUserSystemThemeDark(): bool {
return ($_COOKIE['theme-system-value'] ?? '') === 'dark';
}
-
}
\ No newline at end of file
diff --git a/src/lib/foreignone/ForeignOneSkin.php b/src/lib/foreignone/ForeignOneSkin.php
new file mode 100644
index 0000000..8a30ac0
--- /dev/null
+++ b/src/lib/foreignone/ForeignOneSkin.php
@@ -0,0 +1,103 @@
+title) && $this->title ? $this->title : lang('site_title');
+ if (!$this->options->isIndex)
+ $title = $title.' - '.lang('4in1');
+ return $title;
+ }
+
+ set(string $title) {
+ $this->title = $title;
+ }
+ }
+
+ public function __construct() {
+ parent::__construct();
+ $this->options = new ForeignOneSkinOptions();
+ }
+
+ protected function getTwigLoader(): LoaderInterface {
+ $twig_loader = new FilesystemLoader(APP_ROOT.'/src/skins/foreignone', APP_ROOT);
+ // $twig_loader->addPath(APP_ROOT.'/htdocs/svg', 'svg');
+ return $twig_loader;
+ }
+
+ public function renderPage(string $template, array $vars = []): Response {
+ $this->exportStrings(['4in1']);
+ $this->applyGlobals();
+
+ // render body first
+ $b = $this->renderBody($template, $vars);
+
+ // then everything else
+ $h = $this->renderHeader();
+ $f = $this->renderFooter();
+
+ return new HtmlResponse($h.$b.$f);
+ }
+
+ protected function renderHeader(): string {
+ global $config;
+
+ $body_class = [];
+ if ($this->options->fullWidth)
+ $body_class[] = 'full-width';
+ else if ($this->options->wide)
+ $body_class[] = 'wide';
+
+ $vars = [
+ 'title' => $this->title,
+ 'meta_html' => $this->meta->getHtml(),
+ 'static_html' => $this->getHeaderStaticTags(),
+ 'svg_html' => $this->getSVGTags(),
+ 'render_options' => $this->options->getOptions(),
+ 'app_config' => [
+ 'domain' => $config['domain'],
+ 'devMode' => $config['is_dev'],
+ 'cookieHost' => $config['cookie_host'],
+ ],
+ 'body_class' => $body_class,
+ 'theme' => ThemesUtil::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->getOptions(),
+ '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' => ThemesUtil::getUserTheme(),
+ ];
+ return $this->doRender('footer.twig', $footer_vars);
+ }
+}
\ No newline at end of file
diff --git a/src/lib/foreignone/ForeignOneSkinOptions.php b/src/lib/foreignone/ForeignOneSkinOptions.php
new file mode 100644
index 0000000..175ac45
--- /dev/null
+++ b/src/lib/foreignone/ForeignOneSkinOptions.php
@@ -0,0 +1,15 @@
+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'] = MarkupUtil::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 MarkupUtil::htmlImagesFix($this->html, $is_retina, $user_theme);
+ }
+
+ public function getUrl(): string {
+ return "/{$this->shortName}/";
+ }
+
+ public function updateHtml(): void {
+ $html = MarkupUtil::markdownToHtml($this->md);
+ $this->html = $html;
+ getDB()->query("UPDATE pages SET html=? WHERE short_name=?", $html, $this->shortName);
+ }
+
+
+ /**
+ * Static methods
+ */
+
+ public static function add(array $data): bool {
+ $db = getDB();
+ $data['ts'] = time();
+ $data['html'] = MarkupUtil::markdownToHtml($data['md']);
+ if (!$db->insert('pages', $data))
+ return false;
+ return true;
+ }
+
+ public static function delete(Page $page): void {
+ getDB()->query("DELETE FROM pages WHERE short_name=?", $page->shortName);
+ PreviousText::delete(PreviousText::TYPE_PAGE, $page->get_id());
+ }
+
+ public static function getById(int $id): ?Page {
+ $db = getDB();
+ $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 = getDB();
+ $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 = getDB();
+ return array_map(static::create_instance(...), $db->fetchAll($db->query("SELECT * FROM pages")));
+ }
+}
diff --git a/src/lib/foreignone/Post.php b/src/lib/foreignone/Post.php
new file mode 100644
index 0000000..8ab84ab
--- /dev/null
+++ b/src/lib/foreignone/Post.php
@@ -0,0 +1,217 @@
+ $title,
+ 'lang' => $lang->value,
+ 'post_id' => $this->id,
+ 'html' => $html,
+ 'text' => $text,
+ 'md' => $md,
+ 'toc' => $toc,
+ 'keywords' => $keywords,
+ ];
+
+ $db = getDB();
+ if (!$db->insert('posts_texts', $data))
+ return null;
+
+ $id = $db->insertId();
+
+ $post_text = PostText::get($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 = getDB();
+ $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();
+ return array_any($this->texts, fn($text) => $text->lang == $lang);
+ }
+
+ 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());
+ }
+
+
+ /**
+ * Static methods
+ */
+
+ public static function getCount(bool $include_hidden = false): int {
+ $db = getDB();
+ $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 = getDB();
+ $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 = getDB();
+ if (!$db->insert('posts', $data))
+ return null;
+ return self::get($db->insertId());
+ }
+
+ public static function delete(Post $post): void {
+ $db = getDB();
+ $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'];
+ PreviousText::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 = getDB();
+ $q = $db->query("SELECT * FROM posts WHERE id=?", $id);
+ return $db->numRows($q) ? new Post($db->fetch($q)) : null;
+ }
+
+ public static function getByName(string $short_name): ?Post {
+ $db = getDB();
+ $q = $db->query("SELECT * FROM posts WHERE short_name=?", $short_name);
+ return $db->numRows($q) ? new Post($db->fetch($q)) : null;
+ }
+
+}
\ No newline at end of file
diff --git a/lib/PostLanguage.php b/src/lib/foreignone/PostLanguage.php
similarity index 87%
rename from lib/PostLanguage.php
rename to src/lib/foreignone/PostLanguage.php
index ac5b5e7..137baca 100644
--- a/lib/PostLanguage.php
+++ b/src/lib/foreignone/PostLanguage.php
@@ -1,7 +1,9 @@
$v->value, self::cases());
}
-
}
\ No newline at end of file
diff --git a/lib/PostText.php b/src/lib/foreignone/PostText.php
similarity index 51%
rename from lib/PostText.php
rename to src/lib/foreignone/PostText.php
index 1796188..f0c47c2 100644
--- a/lib/PostText.php
+++ b/src/lib/foreignone/PostText.php
@@ -1,6 +1,13 @@
md) {
- $fields['html'] = markup::markdownToHtml($fields['md'], lang: $this->lang);
- $fields['text'] = markup::htmlToText($fields['html']);
+ $fields['html'] = MarkupUtil::markdownToHtml($fields['md'], lang: $this->lang);
+ $fields['text'] = MarkupUtil::htmlToText($fields['html']);
}
if ((isset($fields['toc']) && $fields['toc']) || $this->toc) {
- $fields['toc_html'] = markup::toc($fields['md']);
+ $fields['toc_html'] = MarkupUtil::toc($fields['md']);
}
parent::edit($fields);
@@ -29,33 +36,33 @@ class PostText extends model {
}
public function updateHtml(): void {
- $html = markup::markdownToHtml($this->md, lang: $this->lang);
+ $html = MarkupUtil::markdownToHtml($this->md, lang: $this->lang);
$this->html = $html;
- DB()->query("UPDATE posts_texts SET html=? WHERE id=?", $html, $this->id);
+ getDB()->query("UPDATE posts_texts SET html=? WHERE id=?", $html, $this->id);
}
public function updateText(): void {
- $html = markup::markdownToHtml($this->md, lang: $this->lang);
- $text = markup::htmlToText($html);
+ $html = MarkupUtil::markdownToHtml($this->md, lang: $this->lang);
+ $text = MarkupUtil::htmlToText($html);
$this->text = $text;
- DB()->query("UPDATE posts_texts SET text=? WHERE id=?", $text, $this->id);
+ getDB()->query("UPDATE posts_texts SET text=? WHERE id=?", $text, $this->id);
}
public function getDescriptionPreview(int $len): string {
if (mb_strlen($this->text) >= $len)
- return mb_substr($this->text, 0, $len-3).'...';
+ return mb_substr($this->text, 0, $len - 3).'...';
return $this->text;
}
public function getFirstImage(): ?Upload {
- if (!preg_match('/\{image:([\w]{8})/', $this->md, $match))
+ if (!preg_match('/\{image:(\w{8})/', $this->md, $match))
return null;
- return uploads::getUploadByRandomId($match[1]);
+ return Upload::getUploadByRandomId($match[1]);
}
public function getHtml(bool $is_retina, string $theme): string {
$html = $this->html;
- return markup::htmlImagesFix($html, $is_retina, $theme);
+ return MarkupUtil::htmlImagesFix($html, $is_retina, $theme);
}
public function getTableOfContentsHtml(): ?string {
@@ -69,11 +76,11 @@ class PostText extends model {
/**
* @param bool $update Whether to overwrite preview if already exists
* @return int
- * @throws Exception
+ * @throws \Exception
*/
public function updateImagePreviews(bool $update = false): int {
$images = [];
- if (!preg_match_all('/\{image:([\w]{8}),(.*?)}/', $this->md, $matches))
+ if (!preg_match_all('/\{image:(\w{8}),(.*?)}/', $this->md, $matches))
return 0;
for ($i = 0; $i < count($matches[0]); $i++) {
@@ -96,7 +103,7 @@ class PostText extends model {
return 0;
$images_affected = 0;
- $uploads = uploads::getUploadsByRandomId(array_keys($images), true);
+ $uploads = Upload::getUploadsByRandomId(array_keys($images), true);
foreach ($uploads as $upload_key => $u) {
if ($u === null) {
logError(__METHOD__.': upload '.$upload_key.' is null');
@@ -113,4 +120,52 @@ class PostText extends model {
return $images_affected;
}
+
+ /**
+ * Static methods
+ */
+
+ public static function get(int $text_id): ?PostText {
+ $db = getDB();
+ $q = $db->query("SELECT * FROM posts_texts WHERE id=?", $text_id);
+ return $db->numRows($q) ? new PostText($db->fetch($q)) : null;
+ }
+
+ public static function getPostTextsById(array $ids, bool $flat = false): array {
+ if (empty($ids))
+ return [];
+
+ $db = getDB();
+ $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 = getDB();
+ $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);
+ }
}
diff --git a/lib/previous_texts.php b/src/lib/foreignone/PreviousText.php
similarity index 59%
rename from lib/previous_texts.php
rename to src/lib/foreignone/PreviousText.php
index c4f22c8..f6c6c0a 100644
--- a/lib/previous_texts.php
+++ b/src/lib/foreignone/PreviousText.php
@@ -1,9 +1,30 @@
insert(PreviousText::DB_TABLE, [
'object_type' => $object_type,
'object_id' => $object_id,
@@ -21,7 +42,6 @@ class previous_texts {
$sql .= '=?';
$args[] = $object_id;
}
- DB()->query($sql, ...$args);
+ getDB()->query($sql, ...$args);
}
-
}
\ No newline at end of file
diff --git a/src/lib/foreignone/Upload.php b/src/lib/foreignone/Upload.php
new file mode 100644
index 0000000..43a97ec
--- /dev/null
+++ b/src/lib/foreignone/Upload.php
@@ -0,0 +1,329 @@
+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';
+ }
+
+ public function getSize(): string {
+ return sizeString($this->size);
+ }
+
+ public function getMarkdown(?string $options = null): string {
+ if ($this->isImage()) {
+ $md = '{image:'.$this->randomId.',w='.$this->imageW.',h='.$this->imageH.($options ? ','.$options : '').'}{/image}';
+ } else if ($this->isVideo()) {
+ $md = '{video:'.$this->randomId.($options ? ','.$options : '').'}{/video}';
+ } else {
+ $md = '{fileAttach:'.$this->randomId.($options ? ','.$options : '').'}{/fileAttach}';
+ }
+ $md .= ' ';
+ return $md;
+ }
+
+ public function setNote(PostLanguage $lang, string $note) {
+ $db = getDB();
+ $db->query("UPDATE uploads SET note_{$lang->value}=? WHERE id=?", $note, $this->id);
+ }
+
+ public function isImage(): bool {
+ return in_array(extension($this->name), self::IMAGE_EXTENSIONS);
+ }
+
+ // 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::VIDEO_EXTENSIONS);
+ }
+
+ 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 (ThemesUtil::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, ThemesUtil::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)));
+ }
+
+
+ /**
+ * Static methods
+ */
+
+ public static function getCount(): int {
+ $db = getDB();
+ 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 = getDB();
+ 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 = getDB();
+ $db->query("DELETE FROM uploads WHERE id=?", $id);
+
+ rrmdir($upload->getDirectory());
+ return true;
+ }
+
+ /**
+ * @return Upload[]
+ */
+ public static function getAllUploads(): array {
+ $db = getDB();
+ $q = $db->query("SELECT * FROM uploads ORDER BY id DESC");
+ return array_map(static::create_instance(...), $db->fetchAll($q));
+ }
+
+ public static function get(int $id): ?Upload {
+ $db = getDB();
+ $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 = getDB();
+ $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 = getDB();
+ $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 = getDB();
+ $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;
+ }
+ }
+
+ protected static function getNewUploadRandomId(): string {
+ $db = getDB();
+ do {
+ $random_id = strgen(8);
+ } while ($db->numRows($db->query("SELECT id FROM uploads WHERE random_id=?", $random_id)) > 0);
+ return $random_id;
+ }
+}
\ No newline at end of file
diff --git a/src/lib/foreignone/files/Archive.php b/src/lib/foreignone/files/Archive.php
new file mode 100644
index 0000000..147c6c5
--- /dev/null
+++ b/src/lib/foreignone/files/Archive.php
@@ -0,0 +1,47 @@
+archiveType->value;
+ }
+
+ public function getUrl(): string {
+ return '/files/'.$this->archiveType->value.'/';
+ }
+
+ public function getSize(): ?int {
+ return null;
+ }
+
+ public function getTitle(): string {
+ return lang("files_{$this->archiveType->value}_collection");
+ }
+
+ public function getMeta(?string $hl_matched = null): array {
+ return [];
+ }
+
+ public function isTargetBlank(): bool {
+ return false;
+ }
+
+ public function getSubtitle(): ?string {
+ return null;
+ }
+
+ public function getIcon(): string {
+ return 'folder';
+ }
+}
diff --git a/src/lib/foreignone/files/ArchiveType.php b/src/lib/foreignone/files/ArchiveType.php
new file mode 100644
index 0000000..9ea2de2
--- /dev/null
+++ b/src/lib/foreignone/files/ArchiveType.php
@@ -0,0 +1,49 @@
+ 'wff_collection',
+ self::MercureDeFrance => 'mdf_archive',
+ self::Baconiana => 'baconiana_archive',
+ };
+ }
+
+ public function getMySQLData(): array {
+ return match ($this) {
+ self::WilliamFriedman => ['wff_texts', 'wff_id'],
+ self::MercureDeFrance => ['mdf_texts', 'mdf_id'],
+ self::Baconiana => ['baconiana_texts', 'bcn_id'],
+ };
+ }
+
+ public function getItemsByIdGetter(): callable {
+ return match ($this) {
+ self::WilliamFriedman => WFFArchiveFile::getItemsById(...),
+ self::MercureDeFrance => MDFIssue::getIssuesById(...),
+ self::Baconiana => BaconianaIssue::getIssuesById(...),
+ };
+ }
+
+ public function getFolderGetter(): callable {
+ return match ($this) {
+ ArchiveType::WilliamFriedman => WFFArchiveFile::getFolder(...),
+ ArchiveType::Baconiana => BaconianaIssue::getFolder(...),
+ };
+ }
+
+ public function getListGetter(): callable {
+ return match ($this) {
+ self::WilliamFriedman => WFFArchiveFile::getList(...),
+ self::MercureDeFrance => MDFIssue::getAll(...),
+ self::Baconiana => BaconianaIssue::getList(...),
+ };
+ }
+}
diff --git a/src/lib/foreignone/files/BaconianaIssue.php b/src/lib/foreignone/files/BaconianaIssue.php
new file mode 100644
index 0000000..1aa0785
--- /dev/null
+++ b/src/lib/foreignone/files/BaconianaIssue.php
@@ -0,0 +1,142 @@
+title !== '')
+ return $this->title;
+
+ return ($this->jobc ? lang('baconiana_old_name') : lang('baconiana')).' №'.$this->issues;
+ }
+
+ public function isTargetBlank(): bool {
+ return $this->type == 'file';
+ }
+
+ public function getId(): string {
+ return $this->id;
+ }
+
+ public function getUrl(): string {
+ if ($this->type == 'folder') {
+ return '/files/'.ArchiveType::Baconiana->value.'/'.$this->id.'/';
+ }
+ global $config;
+ return 'https://'.$config['files_domain'].'/'.$this->path;
+ }
+
+ public function getMeta(?string $hl_matched = null): array {
+ $items = [];
+ if ($this->type == 'folder')
+ 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;
+ }
+
+ public function getSize(): ?int {
+ return $this->type == 'file' ? $this->size : null;
+ }
+
+ public function getIcon(): string {
+ return $this->type;
+ }
+
+ public function getFullText(): ?string {
+ $db = getDB();
+ $q = $db->query("SELECT text FROM baconiana_texts WHERE bcn_id=?", $this->id);
+ if (!$db->numRows($q))
+ return null;
+ return $db->result($q);
+ }
+
+
+ /**
+ * Static methods
+ */
+
+ /**
+ * @param int|null $parent_id
+ * @return BaconianaIssue[]
+ */
+ public static function getList(?int $parent_id = 0): array {
+ $db = getDB();
+ $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(static::create_instance(...), $db->fetchAll($q));
+ }
+
+ /**
+ * @param int[] $ids
+ * @return BaconianaIssue[]
+ */
+ public static function getIssuesById(array $ids): array {
+ $db = getDB();
+ $q = $db->query("SELECT * FROM baconiana_collection WHERE id IN (".implode(',', $ids).")");
+ return array_map(static::create_instance(...), $db->fetchAll($q));
+ }
+
+ /**
+ * @param int $folder_id
+ * @param bool $with_parents
+ * @return BaconianaIssue|BaconianaIssue[]|null
+ */
+ public static function getFolder(int $folder_id, bool $with_parents = false): static|array|null {
+ $db = getDB();
+ $q = $db->query("SELECT * FROM baconiana_collection WHERE id=?", $folder_id);
+ if (!$db->numRows($q))
+ return null;
+ $item = new BaconianaIssue($db->fetch($q));
+ if ($item->type != 'folder')
+ return null;
+ if ($with_parents) {
+ $items = [$item];
+ if ($item->parentId) {
+ $parents = static::getFolder($item->parentId, with_parents: true);
+ if ($parents !== null)
+ $items = array_merge($items, $parents);
+ }
+ return $items;
+ }
+ return $item;
+ }
+}
diff --git a/src/lib/foreignone/files/Book.php b/src/lib/foreignone/files/Book.php
new file mode 100644
index 0000000..d3532f7
--- /dev/null
+++ b/src/lib/foreignone/files/Book.php
@@ -0,0 +1,139 @@
+id;
+ }
+
+ public function getUrl(): string {
+ if ($this->type == 'folder' && !$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->type == 'folder' || !$this->author)
+ return null;
+ $buf = ''.htmlescape($this->author).' ';
+ if (!str_ends_with($this->author, '.'))
+ $buf .= '.';
+ $buf .= ' '.htmlescape($this->title).' ';
+ return $buf;
+ }
+
+ public function getTitle(): string {
+ return $this->title;
+ }
+
+ public function getMeta(?string $hl_matched = null): array {
+ if ($this->type == 'folder')
+ return [];
+
+ $items = [
+ sizeString($this->size),
+ strtoupper($this->getExtension())
+ ];
+
+ return [
+ 'inline' => false,
+ 'items' => $items
+ ];
+ }
+
+ protected function getExtension(): string {
+ return extension(basename($this->path));
+ }
+
+ public function isTargetBlank(): bool {
+ return $this->type == 'file' || $this->external;
+ }
+
+ public function getSubtitle(): ?string {
+ if (!$this->year && !$this->subtitle)
+ return null;
+ $buf = '(';
+ $buf .= $this->subtitle ?: $this->year;
+ $buf .= ')';
+ return $buf;
+ }
+
+ public function getSize(): ?int {
+ return $this->type == 'file' ? $this->size : null;
+ }
+
+ public function getIcon(): string {
+ if ($this->fileType == 'book')
+ return 'book';
+ return $this->type;
+ }
+
+
+ /**
+ * Static methods
+ */
+
+ /**
+ * @return Book[]
+ */
+ public static function getList(SectionType $section, int $parent_id = 0): array
+ {
+ $db = getDB();
+ $order_by = $section == SectionType::BOOKS_AND_ARTICLES
+ ? "type, ".($parent_id != 0 ? 'year, ': '')."author, title"
+ : "type, title";
+ $q = $db->query("SELECT * FROM books WHERE category=? AND parent_id=? ORDER BY $order_by",
+ $section->value, $parent_id);
+ return array_map(static::create_instance(...), $db->fetchAll($q));
+ }
+
+ /**
+ * @param int $id
+ * @param bool $with_parents
+ * @return static[]|static|null
+ */
+ public static function getFolder(int $id, bool $with_parents = false): static|array|null {
+ $db = getDB();
+ $q = $db->query("SELECT * FROM books WHERE id=?", $id);
+ if (!$db->numRows($q))
+ return null;
+ $item = new Book($db->fetch($q));
+ if ($item->type != 'folder')
+ return null;
+ if ($with_parents) {
+ $items = [$item];
+ if ($item->parentId) {
+ $parents = static::getFolder($item->parentId, with_parents: true);
+ if ($parents !== null)
+ $items = array_merge($items, $parents);
+ }
+ return $items;
+ }
+ return $item;
+ }
+}
diff --git a/lib/FilesItemInterface.php b/src/lib/foreignone/files/FileInterface.php
similarity index 70%
rename from lib/FilesItemInterface.php
rename to src/lib/foreignone/files/FileInterface.php
index 62e2421..ac3442c 100644
--- a/lib/FilesItemInterface.php
+++ b/src/lib/foreignone/files/FileInterface.php
@@ -1,15 +1,16 @@
issue}, {$this->getHumanFriendlyDate()}";
@@ -30,8 +35,14 @@ class MDFCollectionItem extends model implements FilesItemInterface {
return $dt->format('j M Y');
}
- public function isTargetBlank(): bool { return true; }
- public function getId(): string { return $this->id; }
+ public function isTargetBlank(): bool {
+ return true;
+ }
+
+ public function getId(): string {
+ return $this->id;
+ }
+
public function getUrl(): string {
global $config;
return 'https://'.$config['files_domain'].'/Mercure-de-France-OCR/'.$this->path;
@@ -80,4 +91,43 @@ class MDFCollectionItem extends model implements FilesItemInterface {
return null;
//return 'Vol. '.$this->getRomanVolume().', pp. '.$this->pageFrom.'-'.$this->pageTo;
}
+
+ public function getSize(): ?int {
+ return $this->type == 'file' ? $this->size : null;
+ }
+
+ public function getIcon(): string {
+ return $this->type;
+ }
+
+ public function getFullText(): ?string {
+ if ($this->type != 'file')
+ return null;
+ $db = getDB();
+ return $db->result($db->query("SELECT text FROM mdf_texts WHERE mdf_id=?", $this->id));
+ }
+
+
+ /**
+ * Static methods
+ */
+
+ /**
+ * @return MDFIssue[]
+ */
+ public static function getAll(): array {
+ $db = getDB();
+ $q = $db->query("SELECT * FROM mdf_collection ORDER BY `date`");
+ return array_map(static::create_instance(...), $db->fetchAll($q));
+ }
+
+ /**
+ * @param int[] $ids
+ * @return MDFIssue[]
+ */
+ public static function getIssuesById(array $ids): array {
+ $db = getDB();
+ $q = $db->query("SELECT * FROM mdf_collection WHERE id IN (".implode(',', $ids).")");
+ return array_map(static::create_instance(...), $db->fetchAll($q));
+ }
}
diff --git a/src/lib/foreignone/files/SectionType.php b/src/lib/foreignone/files/SectionType.php
new file mode 100644
index 0000000..01d7f41
--- /dev/null
+++ b/src/lib/foreignone/files/SectionType.php
@@ -0,0 +1,10 @@
+getMySQLData();
+
+ $results = [];
+ foreach ($ids as $id)
+ $results[$id] = null;
+
+ $db = getDB();
+
+ $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 searchArchive(ArchiveType $type,
+ string $q,
+ int $offset,
+ int $count): array
+ {
+ $index = $type->getSphinxIndex();
+ $query_filtered = SphinxUtil::mkquery($q);
+
+ $cl = SphinxUtil::getClient();
+ $cl->setLimits($offset, $count);
+ $cl->setMatchMode(SphinxClient::SPH_MATCH_EXTENDED);
+ $cl->setRankingMode(SphinxClient::SPH_RANK_PROXIMITY_BM25);
+
+ switch ($type) {
+ case ArchiveType::Baconiana:
+ $cl->setFieldWeights([
+ 'year' => 10,
+ 'issues' => 9,
+ 'text' => 8
+ ]);
+ $cl->setSortMode(SphinxClient::SPH_SORT_RELEVANCE);
+ break;
+
+ case ArchiveType::MercureDeFrance:
+ $cl->setFieldWeights([
+ 'date' => 10,
+ 'issue' => 9,
+ 'text' => 8
+ ]);
+ $cl->setSortMode(SphinxClient::SPH_SORT_RELEVANCE);
+ break;
+
+ case ArchiveType::WilliamFriedman:
+ $cl->setFieldWeights([
+ 'title' => 50,
+ 'document_id' => 60,
+ ]);
+ $cl->setSortMode(SphinxClient::SPH_SORT_EXTENDED, '@relevance DESC, is_folder DESC');
+ break;
+ }
+
+ // run search
+ $final_query = "$query_filtered";
+ $result = $cl->query($final_query, $index);
+ $error = $cl->getLastError();
+ $warning = $cl->getLastWarning();
+ if ($error)
+ logError(__METHOD__, $error);
+ if ($warning)
+ logWarning(__METHOD__, $warning);
+ if ($result === false)
+ return ['count' => 0, 'items' => []];
+
+ $total_found = (int)$result['total_found'];
+
+ $items = [];
+ if (!empty($result['matches']))
+ $items = $type->getItemsByIdGetter()(array_keys($result['matches']));
+
+ return ['count' => $total_found, 'items' => $items];
+ }
+
+ public static function reindexArchive(ArchiveType $type): void {
+ $index = $type->getSphinxIndex();
+ SphinxUtil::execute("TRUNCATE RTINDEX $index");
+
+ switch ($type) {
+ case ArchiveType::MercureDeFrance:
+ foreach (MDFIssue::getAll() as $item) {
+ $text = $item->getFullText();
+ SphinxUtil::execute("INSERT INTO $index (id, volume, issue, date, text) VALUES (?, ?, ?, ?, ?)",
+ $item->id, $item->volume, (string)$item->issue, $item->getHumanFriendlyDate(), $text);
+ }
+ break;
+
+ case ArchiveType::WilliamFriedman:
+ foreach (WFFArchiveFile::getAll() as $item) {
+ $text = $item->getFullText();
+ SphinxUtil::execute("INSERT INTO $index (id, document_id, title, text, is_folder, parent_id) VALUES (?, ?, ?, ?, ?, ?)",
+ $item->id, $item->getDocumentId(), $item->title, $text, (int)($item->type == 'folder'), $item->parentId);
+ }
+ break;
+
+ case ArchiveType::Baconiana:
+ foreach (BaconianaIssue::getList() as $item) {
+ $text = $item->getFullText();
+ if (!$text)
+ continue;
+ SphinxUtil::execute("INSERT INTO $index (id, title, year, text) VALUES (?, ?, ?, ?)",
+ $item->id, "$item->year ($item->issues)", $item->year, $text);
+ }
+ break;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/lib/foreignone/files/WFFArchiveFile.php b/src/lib/foreignone/files/WFFArchiveFile.php
new file mode 100644
index 0000000..298a372
--- /dev/null
+++ b/src/lib/foreignone/files/WFFArchiveFile.php
@@ -0,0 +1,165 @@
+id;
+ }
+
+ public function getTitle(): string {
+ return $this->title;
+ }
+
+ public function getDocumentId(): string {
+ return $this->type == 'folder' ? str_replace('_', ' ', basename($this->path)) : $this->documentId;
+ }
+
+ public function isTargetBlank(): bool {
+ return $this->type == 'file';
+ }
+
+ public function getSubtitle(): ?string {
+ return null;
+ }
+
+ public function getUrl(): string {
+ global $config;
+ return $this->type == 'folder'
+ ? "/files/wff/{$this->id}/"
+ : "https://{$config['files_domain']}/NSA Friedman Documents/{$this->path}";
+ }
+
+ public function getMeta(?string $hl_matched = null): array {
+ if ($this->type == 'folder') {
+ if (!$this->parentId)
+ return [];
+ return [
+ 'items' => [
+ highlightSubstring($this->getDocumentId(), $hl_matched),
+ langNum('files_count', $this->filesCount)
+ ]
+ ];
+ }
+ return [
+ 'inline' => false,
+ 'items' => [
+ highlightSubstring('Document '.$this->documentId, $hl_matched),
+ sizeString($this->size),
+ 'PDF'
+ ]
+ ];
+ }
+
+ public function getSize(): ?int {
+ return $this->type == 'file' ? $this->size : null;
+ }
+
+ public function getIcon(): string {
+ return $this->type; // it's either 'file' or 'folder'
+ }
+
+ public function getFullText(): ?string {
+ if ($this->type != 'file')
+ return null;
+ $db = getDB();
+ return $db->result($db->query("SELECT text FROM wff_texts WHERE wff_id=?", $this->id));
+ }
+
+
+ /**
+ * Static methods
+ */
+
+ /**
+ * @return WFFArchiveFile[]
+ */
+ public static function getAll(): array {
+ $db = getDB();
+ $q = $db->query("SELECT * FROM wff_collection");
+ return array_map(static::create_instance(...), $db->fetchAll($q));
+ }
+
+ /**
+ * @param int|int[]|null $parent_id
+ * @return array
+ */
+ public static function getList(int|array|null $parent_id = null): array {
+ $db = getDB();
+
+ $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(static::create_instance(...), $db->fetchAll($q));
+ }
+
+ /**
+ * @param int[] $ids
+ * @return WFFArchiveFile[]
+ */
+ public static function getItemsById(array $ids): array {
+ $db = getDB();
+ $q = $db->query("SELECT * FROM wff_collection WHERE id IN (".implode(',', $ids).")");
+ return array_map(static::create_instance(...), $db->fetchAll($q));
+ }
+
+ /**
+ * @param int $folder_id
+ * @param bool $with_parents
+ * @return static|static[]|null
+ */
+ public static function getFolder(int $folder_id, bool $with_parents = false): static|array|null
+ {
+ $db = getDB();
+ $q = $db->query("SELECT * FROM wff_collection WHERE id=?", $folder_id);
+ if (!$db->numRows($q))
+ return null;
+ $item = new WFFArchiveFile($db->fetch($q));
+ if ($item->type != 'folder')
+ return null;
+ if ($with_parents) {
+ $items = [$item];
+ if ($item->parentId) {
+ $parents = static::getFolder($item->parentId, with_parents: true);
+ if ($parents !== null)
+ $items = array_merge($items, $parents);
+ }
+ return $items;
+ }
+ return $item;
+ }
+}
diff --git a/src/lib/ic/InvisibleCollegeSkin.php b/src/lib/ic/InvisibleCollegeSkin.php
new file mode 100644
index 0000000..c048c67
--- /dev/null
+++ b/src/lib/ic/InvisibleCollegeSkin.php
@@ -0,0 +1,90 @@
+addPath(APP_ROOT.'/src/skins/foreignone', 'foreignone');
+ return $twig_loader;
+ }
+
+ /*
+ public function renderPage(string $template, array $vars = []): Response {
+ $this->exportStrings(['4in1']);
+ $this->applyGlobals();
+
+ // render body first
+ $b = $this->renderBody($template, $vars);
+
+ // then everything else
+ $h = $this->renderHeader();
+ $f = $this->renderFooter();
+
+ return new HtmlResponse($h.$b.$f);
+ }
+
+ protected function renderHeader(): string {
+ global $config;
+
+ $body_class = [];
+ if ($this->options->fullWidth)
+ $body_class[] = 'full-width';
+ else if ($this->options->wide)
+ $body_class[] = 'wide';
+
+ $vars = [
+ 'title' => $this->title,
+ 'meta_html' => $this->meta->getHtml(),
+ 'static_html' => $this->getHeaderStaticTags(),
+ 'svg_html' => $this->getSVGTags(),
+ 'render_options' => $this->options->getOptions(),
+ 'app_config' => [
+ 'domain' => $config['domain'],
+ 'devMode' => $config['is_dev'],
+ 'cookieHost' => $config['cookie_host'],
+ ],
+ 'body_class' => $body_class,
+ 'theme' => ThemesUtil::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->getOptions(),
+ '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' => ThemesUtil::getUserTheme(),
+ ];
+ return $this->doRender('footer.twig', $footer_vars);
+ }
+ */
+}
\ No newline at end of file
diff --git a/routes.php b/src/routes.php
similarity index 86%
rename from routes.php
rename to src/routes.php
index c4ae38c..ccaa596 100644
--- a/routes.php
+++ b/src/routes.php
@@ -1,12 +1,15 @@
(function() {
global $config;
- require_once 'lib/files.php';
+ // require_once 'lib/files.php';
- $files_collections = array_map(fn(FilesCollection $fn) => $fn->value, FilesCollection::cases());
- $coll_with_folder_support = [FilesCollection::WilliamFriedman->value, FilesCollection::Baconiana->value];
+ $files_collections = array_map(fn(ArchiveType $fn) => $fn->value, ArchiveType::cases());
+ $coll_with_folder_support = [ArchiveType::WilliamFriedman->value, ArchiveType::Baconiana->value];
$pagename_regex = '[a-zA-Z0-9-]+';
$wiki_root = $config['wiki_root'];
@@ -51,4 +54,13 @@ return (function() {
];
return $routes;
-})();
+})(),
+
+'ic' => (function() {
+ return [
+ 'Main' => [
+ '/' => 'index',
+ ],
+ ];
+})()
+];
diff --git a/src/skins/error/error.twig b/src/skins/error/error.twig
new file mode 100644
index 0000000..0fdb472
--- /dev/null
+++ b/src/skins/error/error.twig
@@ -0,0 +1,13 @@
+
+
+{{ title }}
+
+{{ title }}
+{% if message %}
+ {{ message }}
+ {% if stacktrace %}
+ {{ stacktrace }}
+ {% endif %}
+{% endif %}
+
+
\ No newline at end of file
diff --git a/src/skins/error/notfound.twig b/src/skins/error/notfound.twig
new file mode 100644
index 0000000..5254bf8
--- /dev/null
+++ b/src/skins/error/notfound.twig
@@ -0,0 +1,62 @@
+
+
+
+
+
+Not Found
+
+
+
+
+
+
diff --git a/src/skins/error/notfound_ic.twig b/src/skins/error/notfound_ic.twig
new file mode 100644
index 0000000..7dc7377
--- /dev/null
+++ b/src/skins/error/notfound_ic.twig
@@ -0,0 +1,64 @@
+
+
+
+
+
+Not Found
+
+
+
+
+
+
diff --git a/skin/admin_actions_log.twig b/src/skins/foreignone/admin_actions_log.twig
similarity index 100%
rename from skin/admin_actions_log.twig
rename to src/skins/foreignone/admin_actions_log.twig
diff --git a/skin/admin_auth_log.twig b/src/skins/foreignone/admin_auth_log.twig
similarity index 100%
rename from skin/admin_auth_log.twig
rename to src/skins/foreignone/admin_auth_log.twig
diff --git a/skin/admin_errors.twig b/src/skins/foreignone/admin_errors.twig
similarity index 100%
rename from skin/admin_errors.twig
rename to src/skins/foreignone/admin_errors.twig
diff --git a/skin/admin_index.twig b/src/skins/foreignone/admin_index.twig
similarity index 100%
rename from skin/admin_index.twig
rename to src/skins/foreignone/admin_index.twig
diff --git a/skin/admin_login.twig b/src/skins/foreignone/admin_login.twig
similarity index 100%
rename from skin/admin_login.twig
rename to src/skins/foreignone/admin_login.twig
diff --git a/skin/admin_page_form.twig b/src/skins/foreignone/admin_page_form.twig
similarity index 100%
rename from skin/admin_page_form.twig
rename to src/skins/foreignone/admin_page_form.twig
diff --git a/skin/admin_page_new.twig b/src/skins/foreignone/admin_page_new.twig
similarity index 100%
rename from skin/admin_page_new.twig
rename to src/skins/foreignone/admin_page_new.twig
diff --git a/skin/admin_post_form.twig b/src/skins/foreignone/admin_post_form.twig
similarity index 100%
rename from skin/admin_post_form.twig
rename to src/skins/foreignone/admin_post_form.twig
diff --git a/skin/admin_uploads.twig b/src/skins/foreignone/admin_uploads.twig
similarity index 100%
rename from skin/admin_uploads.twig
rename to src/skins/foreignone/admin_uploads.twig
diff --git a/skin/articles.twig b/src/skins/foreignone/articles.twig
similarity index 100%
rename from skin/articles.twig
rename to src/skins/foreignone/articles.twig
diff --git a/skin/articles_right_links.twig b/src/skins/foreignone/articles_right_links.twig
similarity index 100%
rename from skin/articles_right_links.twig
rename to src/skins/foreignone/articles_right_links.twig
diff --git a/skin/files_collection.twig b/src/skins/foreignone/files_collection.twig
similarity index 100%
rename from skin/files_collection.twig
rename to src/skins/foreignone/files_collection.twig
diff --git a/skin/files_file.twig b/src/skins/foreignone/files_file.twig
similarity index 68%
rename from skin/files_file.twig
rename to src/skins/foreignone/files_file.twig
index a10a5d5..ad01733 100644
--- a/skin/files_file.twig
+++ b/src/skins/foreignone/files_file.twig
@@ -11,33 +11,23 @@
{% import _self as macros %}
{% set subtitle = file.getSubtitle() %}
-{% set meta = file.getMeta(query) %}
+{% set meta = file.getMeta(search_query) %}
{% set title = file.getTitleHtml() %}
{% if not title %}
- {% set title = file.getTitle()|hl(query) %}
+ {% set title = file.getTitle()|hl(search_query) %}
{% endif %}
-
- {% if file.isBook() %}
- {{ svg('book_20') }}
- {% else %}
- {% if file.isFile() %}
- {{ svg('file_20') }}
- {% else %}
- {{ svg('folder_20') }}
- {% endif %}
- {% endif %}
-
-
+ {{ svg(file.getIcon()~'_20') }}
{{ title|raw }}
- {% if file.isFolder() and file.isTargetBlank() %}
+
+ {% if file.type == 'folder' and file.isTargetBlank() %}
{{ svg('arrow_up_right_out_square_outline_12') }}
{% endif %}
@@ -47,7 +37,7 @@
{% if meta.inline %}
{% for item in meta.items %}
-
{{ item }}
+
{{ item|raw }}
{% endfor %}
{% endif %}
@@ -55,13 +45,13 @@
{% if meta.items and not meta.inline %}
{% endif %}
{% if text_excerpts[file.getId()] %}
- {{ macros.excerptWithHighlight(text_excerpts[file.getId()]['index'], text_excerpts[file.getId()]['excerpt'], query) }}
+ {{ macros.excerptWithHighlight(text_excerpts[file.getId()]['index'], text_excerpts[file.getId()]['excerpt'], search_query) }}
{% endif %}
\ No newline at end of file
diff --git a/skin/files_folder.twig b/src/skins/foreignone/files_folder.twig
similarity index 100%
rename from skin/files_folder.twig
rename to src/skins/foreignone/files_folder.twig
diff --git a/skin/files_index.twig b/src/skins/foreignone/files_index.twig
similarity index 100%
rename from skin/files_index.twig
rename to src/skins/foreignone/files_index.twig
diff --git a/skin/files_list.twig b/src/skins/foreignone/files_list.twig
similarity index 100%
rename from skin/files_list.twig
rename to src/skins/foreignone/files_list.twig
diff --git a/skin/footer.twig b/src/skins/foreignone/footer.twig
similarity index 100%
rename from skin/footer.twig
rename to src/skins/foreignone/footer.twig
diff --git a/skin/header.twig b/src/skins/foreignone/header.twig
similarity index 100%
rename from skin/header.twig
rename to src/skins/foreignone/header.twig
diff --git a/skin/index.twig b/src/skins/foreignone/index.twig
similarity index 100%
rename from skin/index.twig
rename to src/skins/foreignone/index.twig
diff --git a/skin/markdown_fileupload.twig b/src/skins/foreignone/markdown_fileupload.twig
similarity index 100%
rename from skin/markdown_fileupload.twig
rename to src/skins/foreignone/markdown_fileupload.twig
diff --git a/skin/markdown_image.twig b/src/skins/foreignone/markdown_image.twig
similarity index 100%
rename from skin/markdown_image.twig
rename to src/skins/foreignone/markdown_image.twig
diff --git a/skin/markdown_preview.twig b/src/skins/foreignone/markdown_preview.twig
similarity index 100%
rename from skin/markdown_preview.twig
rename to src/skins/foreignone/markdown_preview.twig
diff --git a/skin/markdown_video.twig b/src/skins/foreignone/markdown_video.twig
similarity index 100%
rename from skin/markdown_video.twig
rename to src/skins/foreignone/markdown_video.twig
diff --git a/skin/page.twig b/src/skins/foreignone/page.twig
similarity index 100%
rename from skin/page.twig
rename to src/skins/foreignone/page.twig
diff --git a/skin/post.twig b/src/skins/foreignone/post.twig
similarity index 100%
rename from skin/post.twig
rename to src/skins/foreignone/post.twig
diff --git a/skin/rss.twig b/src/skins/foreignone/rss.twig
similarity index 100%
rename from skin/rss.twig
rename to src/skins/foreignone/rss.twig
diff --git a/skin/spinner.twig b/src/skins/foreignone/spinner.twig
similarity index 100%
rename from skin/spinner.twig
rename to src/skins/foreignone/spinner.twig
diff --git a/src/skins/ic/soon.twig b/src/skins/ic/soon.twig
new file mode 100644
index 0000000..a13b7f7
--- /dev/null
+++ b/src/skins/ic/soon.twig
@@ -0,0 +1,52 @@
+
+
+
+
+
+ I.C.
+
+
+
+
+
+
COMING SOON
+
+
+
diff --git a/skin/svg/arrow_up_right_out_square_outline_12.svg b/src/skins/svg/arrow_up_right_out_square_outline_12.svg
similarity index 100%
rename from skin/svg/arrow_up_right_out_square_outline_12.svg
rename to src/skins/svg/arrow_up_right_out_square_outline_12.svg
diff --git a/skin/svg/book_20.svg b/src/skins/svg/book_20.svg
similarity index 100%
rename from skin/svg/book_20.svg
rename to src/skins/svg/book_20.svg
diff --git a/skin/svg/clear_16.svg b/src/skins/svg/clear_16.svg
similarity index 100%
rename from skin/svg/clear_16.svg
rename to src/skins/svg/clear_16.svg
diff --git a/skin/svg/clear_20.svg b/src/skins/svg/clear_20.svg
similarity index 100%
rename from skin/svg/clear_20.svg
rename to src/skins/svg/clear_20.svg
diff --git a/skin/svg/college_20.svg b/src/skins/svg/college_20.svg
similarity index 100%
rename from skin/svg/college_20.svg
rename to src/skins/svg/college_20.svg
diff --git a/skin/svg/file_20.svg b/src/skins/svg/file_20.svg
similarity index 100%
rename from skin/svg/file_20.svg
rename to src/skins/svg/file_20.svg
diff --git a/skin/svg/folder_20.svg b/src/skins/svg/folder_20.svg
similarity index 100%
rename from skin/svg/folder_20.svg
rename to src/skins/svg/folder_20.svg
diff --git a/skin/svg/moon_auto_18.svg b/src/skins/svg/moon_auto_18.svg
similarity index 100%
rename from skin/svg/moon_auto_18.svg
rename to src/skins/svg/moon_auto_18.svg
diff --git a/skin/svg/moon_dark_18.svg b/src/skins/svg/moon_dark_18.svg
similarity index 100%
rename from skin/svg/moon_dark_18.svg
rename to src/skins/svg/moon_dark_18.svg
diff --git a/skin/svg/moon_light_18.svg b/src/skins/svg/moon_light_18.svg
similarity index 100%
rename from skin/svg/moon_light_18.svg
rename to src/skins/svg/moon_light_18.svg
diff --git a/skin/svg/search_20.svg b/src/skins/svg/search_20.svg
similarity index 100%
rename from skin/svg/search_20.svg
rename to src/skins/svg/search_20.svg
diff --git a/skin/svg/settings_28.svg b/src/skins/svg/settings_28.svg
similarity index 100%
rename from skin/svg/settings_28.svg
rename to src/skins/svg/settings_28.svg
diff --git a/strings/main.yaml b/src/strings/main.yaml
similarity index 100%
rename from strings/main.yaml
rename to src/strings/main.yaml
diff --git a/tools/cli_util.php b/tools/cli_util.php
index 384bca9..b4be2b9 100755
--- a/tools/cli_util.php
+++ b/tools/cli_util.php
@@ -1,38 +1,44 @@
#!/usr/bin/env php
on('admin-add', function() {
list($login, $password) = _get_admin_login_password_input();
- if (admin::exists($login))
- cli::die("Admin ".$login." already exists");
+ if (Admin::exists($login))
+ CliUtil::die("Admin ".$login." already exists");
- $id = admin::add($login, $password);
+ $id = Admin::add($login, $password);
echo "ok: id = $id\n";
})
->on('admin-delete', function() {
- $login = cli::input('Login: ');
- if (!admin::exists($login))
- cli::die("No such admin");
- if (!admin::delete($login))
- cli::die("Database error");
+ $login = CliUtil::input('Login: ');
+ if (!Admin::exists($login))
+ CliUtil::die("No such admin");
+ if (!Admin::delete($login))
+ CliUtil::die("Database error");
echo "ok\n";
})
->on('admin-set-password', function() {
list($login, $password) = _get_admin_login_password_input();
- echo admin::setPassword($login, $password) ? 'ok' : 'fail';
+ echo Admin::setPassword($login, $password) ? 'ok' : 'fail';
echo "\n";
})
->on('blog-erase', function() {
- $db = DB();
+ $db = getDB();
$tables = ['posts', 'posts_texts'];
foreach ($tables as $t) {
$db->query("TRUNCATE TABLE $t");
@@ -41,7 +47,7 @@ require_once 'lib/admin.php';
->on('posts-html', function() {
$kw = ['include_hidden' => true];
- $posts = posts::getList(0, posts::getCount(...$kw), ...$kw);
+ $posts = Post::getList(0, Post::getCount(...$kw), ...$kw);
foreach ($posts as $p) {
$texts = $p->getTexts();
foreach ($texts as $t) {
@@ -53,7 +59,7 @@ require_once 'lib/admin.php';
->on('posts-images', function() {
$kw = ['include_hidden' => true];
- $posts = posts::getList(0, posts::getCount(...$kw), ...$kw);
+ $posts = Post::getList(0, Post::getCount(...$kw), ...$kw);
foreach ($posts as $p) {
$texts = $p->getTexts();
foreach ($texts as $t) {
@@ -63,27 +69,26 @@ require_once 'lib/admin.php';
})
->on('pages-html', function() {
- $pages = Pages::getAll();
+ $pages = Page::getAll();
foreach ($pages as $p) {
$p->updateHtml();
}
})
->on('add-files-to-uploads', function() {
- $path = cli::input('Enter path: ');
+ $path = CliUtil::input('Enter path: ');
if (!file_exists($path))
- cli::die("file $path doesn't exists");
+ CliUtil::die("file $path doesn't exists");
$name = basename($path);
$ext = extension($name);
- $id = Uploads::add($path, $name, '');
+ $id = Upload::add($path, $name, '');
echo "upload id: $id\n";
})
-->on('collection-reindex', function() {
- require_once 'lib/files.php';
- $collections = array_map(fn($c) => $c->value, FilesCollection::cases());
- $s = cli::input('Enter collection to reindex (variants: '.implode(', ', $collections).': ');
- $c = FilesCollection::from($s);
+->on('archive-reindex', function() {
+ $archives = array_map(fn($c) => $c->value, ArchiveType::cases());
+ $s = CliUtil::input('Enter archive to reindex (variants: '.implode(', ', $archives).': ');
+ $c = ArchiveType::from($s);
$f = "{$s}_reindex";
echo "calling $f()... ";
call_user_func($f);
@@ -93,18 +98,18 @@ require_once 'lib/admin.php';
->run();
function _get_admin_login_password_input(): array {
- $login = cli::input('Login: ');
- $pwd1 = cli::silentInput("Password: ");
- $pwd2 = cli::silentInput("Again: ");
+ $login = CliUtil::input('Login: ');
+ $pwd1 = CliUtil::silentInput("Password: ");
+ $pwd2 = CliUtil::silentInput("Again: ");
if ($pwd1 != $pwd2)
- cli::die("Passwords do not match");
+ CliUtil::die("Passwords do not match");
if (trim($pwd1) == '')
- cli::die("Password can not be empty");
+ CliUtil::die("Password can not be empty");
- if (strlen($login) > admin::ADMIN_LOGIN_MAX_LENGTH)
- cli::die("Login is longer than max length (".admin::ADMIN_LOGIN_MAX_LENGTH.")");
+ if (strlen($login) > Admin::ADMIN_LOGIN_MAX_LENGTH)
+ CliUtil::die("Login is longer than max length (".Admin::ADMIN_LOGIN_MAX_LENGTH.")");
return [$login, $pwd1];
}
diff --git a/tools/import_article.php b/tools/import_article.php
index f3a17d6..9a4001c 100755
--- a/tools/import_article.php
+++ b/tools/import_article.php
@@ -1,7 +1,12 @@
#!/usr/bin/env php
'',
'visible' => false,
'short_name' => $options['short-name'],
@@ -19,7 +24,7 @@ $post = posts::add([
'source_url' => '',
]);
if (!$post)
- cli::die("failed to create post");
+ CliUtil::die("failed to create post");
foreach ($langs as $lang) {
$text = $post->addText(
@@ -29,8 +34,8 @@ foreach ($langs as $lang) {
keywords: '',
toc: false);
if (!$text) {
- posts::delete($post);
- cli::die("failed to create post text");
+ Post::delete($post);
+ CliUtil::die("failed to create post text");
}
}
@@ -63,14 +68,14 @@ function checkFile($file) {
function processImages($md) {
return preg_replace_callback(
- '/!\[.*?\]\((https?:\/\/[^\s)]+)\)/',
+ '/!\[.*?]\((https?:\/\/[^\s)]+)\)/',
function ($matches) {
$url = $matches[1];
$parsed_url = parse_url($url);
$clean_url = $parsed_url['scheme'] . '://' . $parsed_url['host'] . $parsed_url['path'];
- $upload = uploads::getUploadBySourceUrl($clean_url);
+ $upload = Upload::getUploadBySourceUrl($clean_url);
if (!$upload) {
$name = basename($clean_url);
$ext = extension($clean_url);
@@ -79,8 +84,8 @@ function processImages($md) {
logError('failed to download '.$clean_url.' to '.$tmp);
return $matches[0];
}
- $upload_id = uploads::add($tmp, $name, source_url: $clean_url);
- $upload = uploads::get($upload_id);
+ $upload_id = Upload::add($tmp, $name, source_url: $clean_url);
+ $upload = Upload::get($upload_id);
// $tmp file has already been deleted by uploads::add() at this point
} else {
logDebug('found existing upload with source_url='.$clean_url);