480 lines
15 KiB
PHP
480 lines
15 KiB
PHP
<?php
|
|
|
|
require_once 'lib/themes.php';
|
|
|
|
const RESOURCE_INTEGRITY_HASHES = ['sha256', 'sha384', 'sha512'];
|
|
|
|
$SkinState = new class {
|
|
public array $lang = [];
|
|
public string $title = 'title';
|
|
public array $meta = [];
|
|
public array $options = [
|
|
'full_width' => false,
|
|
'wide' => false,
|
|
'logo_path_map' => [],
|
|
'logo_link_map' => [],
|
|
'is_index' => false,
|
|
'head_section' => null,
|
|
'articles_lang' => null,
|
|
'inside_admin_interface' => false,
|
|
];
|
|
public array $static = [];
|
|
public array $svg_defs = [];
|
|
};
|
|
|
|
function render($f, ...$vars): void {
|
|
global $SkinState, $config;
|
|
|
|
add_skin_strings(['4in1']);
|
|
|
|
$ctx = skin(substr($f, 0, ($pos = strrpos(str_replace('/', '\\', $f), '\\'))));
|
|
$body = call_user_func_array([$ctx, substr($f, $pos + 1)], $vars);
|
|
if (is_array($body))
|
|
list($body, $js) = $body;
|
|
else
|
|
$js = null;
|
|
|
|
$theme = getUserTheme();
|
|
if ($theme != 'auto' && !themeExists($theme))
|
|
$theme = 'auto';
|
|
|
|
$is_system_theme_dark = $theme == 'auto' && isUserSystemThemeDark();
|
|
|
|
$layout_ctx = skin('base');
|
|
|
|
$lang = [];
|
|
foreach ($SkinState->lang as $key)
|
|
$lang[$key] = lang($key);
|
|
$lang = !empty($lang) ? jsonEncode($lang) : '';
|
|
|
|
$title = $SkinState->title;
|
|
if (!$SkinState->options['is_index'])
|
|
$title = lang('4in1').' - '.$title;
|
|
|
|
$html = $layout_ctx->layout(
|
|
static: $SkinState->static,
|
|
theme: $theme,
|
|
is_system_theme_dark: $is_system_theme_dark,
|
|
title: $title,
|
|
opts: $SkinState->options,
|
|
js: $js,
|
|
meta: $SkinState->meta,
|
|
unsafe_lang: $lang,
|
|
unsafe_body: $body,
|
|
exec_time: exectime(),
|
|
admin_email: $config['admin_email'],
|
|
svg_defs: $SkinState->svg_defs
|
|
);
|
|
echo $html;
|
|
exit;
|
|
}
|
|
|
|
function set_title(string $title): void {
|
|
global $SkinState;
|
|
if (str_starts_with($title, '$'))
|
|
$title = lang(substr($title, 1));
|
|
else if (str_starts_with($title, '\\$'))
|
|
$title = substr($title, 1);
|
|
$SkinState->title = $title;
|
|
}
|
|
|
|
function set_skin_opts(array $options) {
|
|
global $SkinState;
|
|
$SkinState->options = array_merge($SkinState->options, $options);
|
|
}
|
|
|
|
function add_skin_strings(array $keys): void {
|
|
global $SkinState;
|
|
$SkinState->lang = array_merge($SkinState->lang, $keys);
|
|
}
|
|
|
|
function add_skin_strings_re(string $re): void {
|
|
global $__lang;
|
|
add_skin_strings($__lang->search($re));
|
|
}
|
|
|
|
function add_static(string ...$files): void {
|
|
global $SkinState;
|
|
foreach ($files as $file)
|
|
$SkinState->static[] = $file;
|
|
}
|
|
|
|
function add_meta(array $data) {
|
|
global $SkinState;
|
|
static $twitter_limits = [
|
|
'title' => 70,
|
|
'description' => 200
|
|
];
|
|
$real_meta = [];
|
|
$add_og_twitter = function($key, $value) use (&$real_meta, $twitter_limits) {
|
|
foreach (['og', 'twitter'] as $social) {
|
|
if ($social == 'twitter' && isset($twitter_limits[$key])) {
|
|
if (mb_strlen($value) > $twitter_limits[$key])
|
|
$value = mb_substr($value, 0, $twitter_limits[$key]-3).'...';
|
|
}
|
|
$real_meta[] = [
|
|
$social == 'twitter' ? 'name' : 'property' => $social.':'.$key,
|
|
'content' => $value
|
|
];
|
|
}
|
|
};
|
|
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;
|
|
}
|
|
}
|
|
$SkinState->meta = array_merge($SkinState->meta, $real_meta);
|
|
}
|
|
|
|
|
|
class SkinContext {
|
|
|
|
protected string $ns;
|
|
protected array $data = [];
|
|
|
|
function __construct(string $namespace) {
|
|
$this->ns = $namespace;
|
|
require_once APP_ROOT.str_replace('\\', DIRECTORY_SEPARATOR, $namespace).'.phps';
|
|
}
|
|
|
|
function __call($name, array $arguments) {
|
|
$plain_args = array_is_list($arguments);
|
|
|
|
$fn = $this->ns.'\\'.$name;
|
|
$refl = new ReflectionFunction($fn);
|
|
$fparams = $refl->getParameters();
|
|
$fparams_required_count = 0;
|
|
foreach ($fparams as $param) {
|
|
if (!$param->isDefaultValueAvailable())
|
|
$fparams_required_count++;
|
|
}
|
|
$given_count = count($arguments)+1;
|
|
assert($given_count >= $fparams_required_count && $given_count <= count($fparams),
|
|
"$fn: invalid number of arguments (function has ".$fparams_required_count." required arguments".(count($fparams) != $fparams_required_count ? ' and '.count($fparams).' total arguments' : '').", received ".(count($arguments) + 1).")");
|
|
|
|
foreach ($fparams as $n => $param) {
|
|
if ($n == 0)
|
|
continue; // skip $ctx
|
|
|
|
$key = $plain_args ? $n - 1 : $param->name;
|
|
if (!$plain_args && !array_key_exists($param->name, $arguments)) {
|
|
if (!$param->isDefaultValueAvailable())
|
|
throw new InvalidArgumentException('argument '.$param->name.' not found');
|
|
else
|
|
continue;
|
|
}
|
|
|
|
if ($plain_args && !isset($arguments[$key]))
|
|
break;
|
|
|
|
if (is_string($arguments[$key]) || $arguments[$key] instanceof SkinString) {
|
|
if (is_string($arguments[$key]))
|
|
$arguments[$key] = new SkinString($arguments[$key]);
|
|
|
|
if (($pos = strpos($param->name, '_')) !== false) {
|
|
$mod_type = match (substr($param->name, 0, $pos)) {
|
|
'unsafe' => SkinStringModificationType::RAW,
|
|
'urlencoded' => SkinStringModificationType::URL,
|
|
'jsonencoded' => SkinStringModificationType::JSON,
|
|
'addslashes' => SkinStringModificationType::ADDSLASHES,
|
|
'nl2br' => SkinStringModificationType::NL2BR,
|
|
default => SkinStringModificationType::HTML
|
|
};
|
|
} else {
|
|
$mod_type = SkinStringModificationType::HTML;
|
|
}
|
|
$arguments[$key]->setModType($mod_type);
|
|
}
|
|
}
|
|
|
|
array_unshift($arguments, $this);
|
|
return call_user_func_array($fn, $arguments);
|
|
}
|
|
|
|
function &__get(string $name) {
|
|
$fn = $this->ns.'\\'.$name;
|
|
if (function_exists($fn)) {
|
|
$f = [$this, $name];
|
|
return $f;
|
|
}
|
|
|
|
if (array_key_exists($name, $this->data))
|
|
return $this->data[$name];
|
|
}
|
|
|
|
function __set(string $name, $value) {
|
|
$this->data[$name] = $value;
|
|
}
|
|
|
|
function if_not($cond, $callback, ...$args) {
|
|
return $this->_if_condition(!$cond, $callback, ...$args);
|
|
}
|
|
|
|
function if_true($cond, $callback, ...$args) {
|
|
return $this->_if_condition($cond, $callback, ...$args);
|
|
}
|
|
|
|
function if_admin($callback, ...$args) {
|
|
return $this->_if_condition(is_admin(), $callback, ...$args);
|
|
}
|
|
|
|
function if_dev($callback, ...$args) {
|
|
return $this->_if_condition(is_dev(), $callback, ...$args);
|
|
}
|
|
|
|
function if_then_else($cond, $cb1, $cb2) {
|
|
return $cond ? $this->_return_callback($cb1) : $this->_return_callback($cb2);
|
|
}
|
|
|
|
function csrf($key): string {
|
|
return csrf_get($key);
|
|
}
|
|
|
|
function bc(array $items, ?string $style = null, bool $mt = false): string {
|
|
static $chevron = '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M7.47 4.217a.75.75 0 0 0 0 1.06L12.185 10 7.469 14.72a.75.75 0 1 0 1.062 1.06l5.245-5.25a.75.75 0 0 0 0-1.061L8.531 4.218a.75.75 0 0 0-1.061-.001z" fill="currentColor"/></svg>';
|
|
|
|
$buf = implode(array_map(function(array $i) use ($chevron): string {
|
|
$buf = '';
|
|
$has_url = array_key_exists('url', $i);
|
|
|
|
if ($has_url)
|
|
$buf .= '<a class="bc-item" href="'.htmlescape($i['url']).'">';
|
|
else
|
|
$buf .= '<span class="bc-item">';
|
|
$buf .= htmlescape($i['text']);
|
|
|
|
if ($has_url)
|
|
$buf .= ' <span class="bc-arrow">'.$chevron.'</span></a>';
|
|
else
|
|
$buf .= '</span>';
|
|
|
|
return $buf;
|
|
}, $items));
|
|
$class = 'bc';
|
|
if ($mt)
|
|
$class .= ' mt';
|
|
return '<div class="'.$class.'"'.($style ? ' style="'.$style.'"' : '').'>'.$buf.'</div>';
|
|
}
|
|
|
|
function pagenav(int $page, int $pages, string $link_template, ?array $opts = null) {
|
|
if ($opts === null) {
|
|
$count = 0;
|
|
} else {
|
|
$opts = array_merge([
|
|
'count' => 0,
|
|
], $opts);
|
|
$count = $opts['count'];
|
|
}
|
|
|
|
$min_page = max(1, $page-2);
|
|
$max_page = min($pages, $page+2);
|
|
|
|
$pages_html = '';
|
|
$base_class = 'pn-button no-hover no-select no-drag is-page';
|
|
for ($p = $min_page; $p <= $max_page; $p++) {
|
|
$class = $base_class;
|
|
if ($p == $page) {
|
|
$class .= ' is-page-cur';
|
|
}
|
|
|
|
$pages_html .= '<a class="'.$class.'" href="'.htmlescape(self::_page_nav_get_link($p, $link_template)).'" data-page="'.$p.'" draggable="false">'.$p.'</a>';
|
|
}
|
|
|
|
if ($min_page > 2) {
|
|
$pages_html = '<div class="pn-button-sep no-select no-drag"> </div>'.$pages_html;
|
|
}
|
|
if ($min_page > 1) {
|
|
$pages_html = '<a class="'.$base_class.'" href="'.htmlescape(self::_page_nav_get_link(1, $link_template)).'" data-page="1" draggable="false">1</a>'.$pages_html;
|
|
}
|
|
|
|
if ($max_page < $pages-1) {
|
|
$pages_html .= '<div class="pn-button-sep no-select no-drag"> </div>';
|
|
}
|
|
if ($max_page < $pages) {
|
|
$pages_html .= '<a class="'.$base_class.'" href="'.htmlescape(self::_page_nav_get_link($pages, $link_template)).'" data-page="'.$pages.'" draggable="false">'.$pages.'</a>';
|
|
}
|
|
|
|
$pn_class = 'pn';
|
|
if ($pages < 2) {
|
|
$pn_class .= ' no-nav';
|
|
if (!$count) {
|
|
$pn_class .= ' no-results';
|
|
}
|
|
}
|
|
|
|
$html = <<<HTML
|
|
<div class="{$pn_class}">
|
|
<div class="pn-buttons clearfix">
|
|
{$pages_html}
|
|
</div>
|
|
</div>
|
|
HTML;
|
|
|
|
return $html;
|
|
}
|
|
|
|
protected static function _page_nav_get_link($page, $link_template) {
|
|
return is_callable($link_template)
|
|
? $link_template($page)
|
|
: str_replace('{page}', $page, $link_template);
|
|
}
|
|
|
|
protected function _if_condition($condition, $callback, ...$args) {
|
|
if (is_string($condition) || $condition instanceof Stringable)
|
|
$condition = (string)$condition !== '';
|
|
if ($condition)
|
|
return $this->_return_callback($callback, $args);
|
|
return '';
|
|
}
|
|
|
|
protected function _return_callback($callback, $args = []) {
|
|
if (is_callable($callback))
|
|
return call_user_func_array($callback, $args);
|
|
else if (is_string($callback))
|
|
return $callback;
|
|
}
|
|
|
|
function for_each(array $iterable, callable $callback) {
|
|
$html = '';
|
|
foreach ($iterable as $k => $v)
|
|
$html .= call_user_func($callback, $v, $k);
|
|
return $html;
|
|
}
|
|
|
|
function lang(...$args): string {
|
|
return htmlescape($this->langRaw(...$args));
|
|
}
|
|
|
|
function lang_num(...$args): string {
|
|
return htmlescape(lang_num(...$args));
|
|
}
|
|
|
|
function langRaw(string $key, ...$args) {
|
|
$val = lang($key);
|
|
return empty($args) ? $val : sprintf($val, ...$args);
|
|
}
|
|
|
|
}
|
|
|
|
class SVGSkinContext extends SkinContext {
|
|
|
|
function __construct() {
|
|
parent::__construct('\\skin\\icons');
|
|
}
|
|
|
|
function __call($name, array $arguments) {
|
|
global $SkinState;
|
|
|
|
$already_defined = isset($SkinState->svg_defs[$name]);
|
|
if (!array_is_list($arguments)) {
|
|
$in_place = isset($arguments['in_place']) && $arguments['in_place'] === true;
|
|
$preload_symbol = isset($arguments['preload_symbol']) && $arguments['preload_symbol'] === true;
|
|
} else {
|
|
$in_place = false;
|
|
$preload_symbol = false;
|
|
}
|
|
|
|
if ($already_defined && $preload_symbol)
|
|
return null;
|
|
|
|
if ($in_place || !$already_defined) {
|
|
if (!preg_match_all('/\d+/', $name, $matches))
|
|
throw new InvalidArgumentException('icon name '.$name.' is invalid, it should follow following pattern: $name_$size[_$size]');
|
|
$size = array_slice($matches[0], -2);
|
|
$width = $size[0];
|
|
$height = $size[1] ?? $size[0];
|
|
}
|
|
|
|
if (!$in_place && (!$already_defined || $preload_symbol)) {
|
|
$SkinState->svg_defs[$name] = [
|
|
'svg' => parent::__call($name, !$preload_symbol ? $arguments : []),
|
|
'width' => $width,
|
|
'height' => $height
|
|
];
|
|
}
|
|
|
|
if ($preload_symbol)
|
|
return null;
|
|
|
|
if ($already_defined && !isset($width)) {
|
|
$width = $SkinState->svg_defs[$name]['width'];
|
|
$height = $SkinState->svg_defs[$name]['height'];
|
|
}
|
|
|
|
if ($in_place) {
|
|
$content = parent::__call($name, []);
|
|
return <<<SVG
|
|
<svg id="svgicon_{$name}" width="{$width}" height="{$height}" fill="currentColor" viewBox="0 0 {$width} {$height}">{$content}</svg>
|
|
SVG;
|
|
} else {
|
|
return <<<SVG
|
|
<svg width="{$width}" height="{$height}"><use xlink:href="#svgicon_{$name}"></use></svg>
|
|
SVG;
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
function skin($name): SkinContext {
|
|
static $cache = [];
|
|
if (!isset($cache[$name])) {
|
|
$cache[$name] = new SkinContext('\\skin\\'.$name);
|
|
}
|
|
return $cache[$name];
|
|
}
|
|
|
|
function svg(): SVGSkinContext {
|
|
static $svg = null;
|
|
if ($svg === null)
|
|
$svg = new SVGSkinContext();
|
|
return $svg;
|
|
}
|
|
|
|
enum SkinStringModificationType {
|
|
case RAW;
|
|
case URL;
|
|
case HTML;
|
|
case JSON;
|
|
case ADDSLASHES;
|
|
case NL2BR;
|
|
}
|
|
|
|
class SkinString implements Stringable {
|
|
protected SkinStringModificationType $modType;
|
|
|
|
function __construct(protected string $string) {}
|
|
function setModType(SkinStringModificationType $modType) { $this->modType = $modType; }
|
|
|
|
function __toString(): string {
|
|
return match ($this->modType) {
|
|
SkinStringModificationType::HTML => htmlescape($this->string),
|
|
SkinStringModificationType::URL => urlencode($this->string),
|
|
SkinStringModificationType::JSON => jsonEncode($this->string),
|
|
SkinStringModificationType::ADDSLASHES => addslashes($this->string),
|
|
SkinStringModificationType::NL2BR => nl2br(htmlescape($this->string)),
|
|
default => $this->string,
|
|
};
|
|
}
|
|
}
|