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

'.$span_opening_tag.'$1 $3

', $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 + + + +
+ +

Page 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 + + + +
+ +

Page 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 %}
{% for item in meta.items %} -
{{ item }}
+
{{ item|raw }}
{% endfor %}
{% 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. + + + +
+ simurgh +

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