Merge branch 'rhymix:master' into master

This commit is contained in:
Lastorder 2025-05-02 17:07:35 +09:00 committed by GitHub
commit d327bb1926
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 588 additions and 221 deletions

View file

@ -5,7 +5,7 @@
"license": "GPL-2.0-or-later",
"type": "project",
"authors": [
{ "name": "Rhymix Developers and Contributors", "email": "devops@rhymix.org" },
{ "name": "Poesis Inc. and Contributors", "email": "devops@rhymix.org" },
{ "name": "NAVER", "email": "developers@xpressengine.com" }
],
"config": {

View file

@ -3,7 +3,7 @@
/**
* RX_VERSION is the version number of the Rhymix CMS.
*/
define('RX_VERSION', '2.1.21');
define('RX_VERSION', '2.1.23');
/**
* RX_MICROTIME is the startup time of the current script, in microseconds since the Unix epoch.

View file

@ -945,6 +945,31 @@ class Template
return count($args) ? in_array((string)$validator_id, $args, true) : true;
}
/**
* Check if the current visitor is using a mobile device for v2.
*
* @return bool
*/
protected function _v2_isMobile(): bool
{
return UA::isMobile() && (config('mobile.tablets') || !UA::isTablet());
}
/**
* Contextual escape function for v2.
*
* @param string $str
* @return string
*/
protected function _v2_escape($str): string
{
switch ($this->config->context)
{
case 'JS': return escape_js(strval($str));
default: return escape(strval($str));
}
}
/**
* Lang shortcut for v2.
*

View file

@ -450,14 +450,28 @@ class VariableBase
}
// Check minimum and maximum lengths.
$length = is_scalar($value) ? iconv_strlen($value, 'UTF-8') : (is_countable($value) ? count($value) : 1);
if (isset($this->minlength) && $this->minlength > 0 && $length < $this->minlength)
$length = null;
if (isset($this->minlength) && $this->minlength > 0)
{
throw new \Rhymix\Framework\Exceptions\QueryError('Variable ' . $this->var . ' for column ' . $column . ' must contain no less than ' . $this->minlength . ' characters');
if ($length === null)
{
$length = is_scalar($value) ? mb_strlen($value, 'UTF-8') : (is_countable($value) ? count($value) : 1);
}
if ($length < $this->minlength)
{
throw new \Rhymix\Framework\Exceptions\QueryError('Variable ' . $this->var . ' for column ' . $column . ' must contain no less than ' . $this->minlength . ' characters');
}
}
if (isset($this->maxlength) && $this->maxlength > 0 && $length > $this->maxlength)
if (isset($this->maxlength) && $this->maxlength > 0)
{
throw new \Rhymix\Framework\Exceptions\QueryError('Variable ' . $this->var . ' for column ' . $column . ' must contain no more than ' . $this->maxlength . ' characters');
if ($length === null)
{
$length = is_scalar($value) ? mb_strlen($value, 'UTF-8') : (is_countable($value) ? count($value) : 1);
}
if ($length > $this->maxlength)
{
throw new \Rhymix\Framework\Exceptions\QueryError('Variable ' . $this->var . ' for column ' . $column . ' must contain no more than ' . $this->maxlength . ' characters');
}
}
}

View file

@ -104,8 +104,8 @@ class TemplateParser_v2
'cannot' => ['if ($this->_v2_checkCapability(2, %s)):', 'endif;'],
'canany' => ['if ($this->_v2_checkCapability(3, %s)):', 'endif;'],
'guest' => ['if (!$this->user->isMember()):', 'endif;'],
'desktop' => ["if (!\\Context::get('m')):", 'endif;'],
'mobile' => ["if (\\Context::get('m')):", 'endif;'],
'desktop' => ['if (!$this->_v2_isMobile()):', 'endif;'],
'mobile' => ['if ($this->_v2_isMobile()):', 'endif;'],
'env' => ['if (!empty($_ENV[%s])):', 'endif;'],
'else' => ['else:'],
'elseif' => ['elseif (%s):'],
@ -179,20 +179,45 @@ class TemplateParser_v2
*/
protected function _addContextSwitches(string $content): string
{
return preg_replace_callback('#(<script\b([^>]*)|</script)#i', function($match) {
// Inline styles.
$content = preg_replace_callback('#(?<=\s)(style=")([^"]*?)"#i', function($match) {
return $match[1] . '<?php $this->config->context = \'CSS\'; ?>' . $match[2] . '<?php $this->config->context = \'HTML\'; ?>"';
}, $content);
// Inline scripts.
$content = preg_replace_callback('#(?<=\s)(href="javascript:|on[a-z]+=")([^"]*?)"#i', function($match) {
return $match[1] . '<?php $this->config->context = \'JS\'; ?>' . $match[2] . '<?php $this->config->context = \'HTML\'; ?>"';
}, $content);
// <style> tags.
$content = preg_replace_callback('#(<style\b([^>]*)|</style)#i', function($match) {
if (substr($match[1], 1, 1) === '/')
{
return '<?php $this->config->context = "HTML"; ?>' . $match[1];
return '<?php $this->config->context = \'HTML\'; ?>' . $match[1];
}
else
{
return $match[1] . '<?php $this->config->context = \'CSS\'; ?>';
}
}, $content);
// <script> tags that aren't links.
$content = preg_replace_callback('#(<script\b([^>]*)|</script)#i', function($match) {
if (substr($match[1], 1, 1) === '/')
{
return '<?php $this->config->context = \'HTML\'; ?>' . $match[1];
}
elseif (!str_contains($match[2] ?? '', 'src="'))
{
return $match[1] . '<?php $this->config->context = "JS"; ?>';
return $match[1] . '<?php $this->config->context = \'JS\'; ?>';
}
else
{
return $match[0];
}
}, $content);
return $content;
}
/**
@ -203,7 +228,7 @@ class TemplateParser_v2
*/
protected static function _removeContextSwitches(string $content): string
{
return preg_replace('#<\?php \$this->config->context = "[A-Z]+"; \?>#', '', $content);
return preg_replace('#<\?php \$this->config->context = \'[A-Z]+\'; \?>#', '', $content);
}
/**
@ -235,7 +260,7 @@ class TemplateParser_v2
$basepath = \RX_BASEURL . $this->template->relative_dirname;
// Convert all src and srcset attributes.
$regexp = '#(<(?:img|audio|video|script|input|source|link)\s[^>]*)(src|srcset|poster)="([^"]+)"#';
$regexp = '#(<(?:img|audio|video|script|input|source|link)\s[^>]*)(?<=\s)(src|srcset|poster)="([^"]+)"#';
$content = preg_replace_callback($regexp, function($match) use ($basepath) {
if ($match[2] !== 'srcset')
{
@ -735,6 +760,7 @@ class TemplateParser_v2
* @dd($var, $var, ...)
* @stack('name')
* @url(['mid' => $mid, 'act' => $act])
* @widget('name', $args)
*
* @param string $content
* @return string
@ -748,7 +774,7 @@ class TemplateParser_v2
// Insert JSON, lang codes, and dumps.
$parentheses = self::_getRegexpForParentheses(2);
$content = preg_replace_callback('#(?<!@)@(json|lang|dump|stack|url)\x20?('. $parentheses . ')#', function($match) {
$content = preg_replace_callback('#(?<!@)@(json|lang|dump|dd|stack|url|widget)\x20?('. $parentheses . ')#', function($match) {
$args = self::_convertVariableScope(substr($match[2], 1, -1));
switch ($match[1])
{
@ -757,7 +783,7 @@ class TemplateParser_v2
'json_encode(%s, self::$_json_options2) : ' .
'htmlspecialchars(json_encode(%s, self::$_json_options), \ENT_QUOTES, \'UTF-8\', false); ?>', $args, $args);
case 'lang':
return sprintf('<?php echo $this->config->context === \'JS\' ? escape_js($this->_v2_lang(%s)) : $this->_v2_lang(%s); ?>', $args, $args);
return sprintf('<?php echo $this->config->context === \'HTML\' ? $this->_v2_lang(%s) : $this->_v2_escape($this->_v2_lang(%s)); ?>', $args, $args);
case 'dump':
return sprintf('<?php ob_start(); var_dump(%s); \$__dump = ob_get_clean(); echo rtrim(\$__dump); ?>', $args);
case 'dd':
@ -765,7 +791,9 @@ class TemplateParser_v2
case 'stack':
return sprintf('<?php echo implode("\n", self::\$_stacks[%s] ?? []) . "\n"; ?>', $args);
case 'url':
return sprintf('<?php echo $this->config->context === \'JS\' ? escape_js(getNotEncodedUrl(%s)) : getUrl(%s); ?>', $args, $args);
return sprintf('<?php echo $this->config->context === \'HTML\' ? getUrl(%s) : $this->_v2_escape(getNotEncodedUrl(%s)); ?>', $args, $args);
case 'widget':
return sprintf('<?php echo \WidgetController::getInstance()->execute(%s); ?>', $args);
default:
return $match[0];
}
@ -797,6 +825,15 @@ class TemplateParser_v2
return $this->_arrangeOutputFilters($match);
}, $content);
// Exclude {single} curly braces in non-HTML contexts.
$content = preg_replace_callback('#(<\?php \$this->config->context = \'(?:CSS|JS)\'; \?>)(.*?)(<\?php \$this->config->context = \'HTML\'; \?>)#s', function($match) {
$match[2] = preg_replace_callback('#(?<!\{)\{(?!\s)([^{}]+?)\}#', function($m) {
$warning = preg_match('#^\$\w#', $m[1]) ? '<?php trigger_error("Template v1 syntax not allowed in CSS/JS context", \E_USER_WARNING); ?>' : '';
return '&#x1B;&#x7B;' . $warning . $m[1] . '&#x1B;&#x7D;';
}, $match[2]);
return $match[1] . $match[2] . $match[3];
}, $content);
// Convert {single} curly braces.
$content = preg_replace_callback('#(?<!\{)\{(?!\s)([^{}]+?)\}#', [$this, '_arrangeOutputFilters'], $content);
@ -943,11 +980,11 @@ class TemplateParser_v2
switch($option)
{
case 'autocontext':
return "\$this->config->context === 'JS' ? escape_js({$str2}) : htmlspecialchars({$str}, \ENT_QUOTES, 'UTF-8', false)";
return "\$this->config->context === 'HTML' ? htmlspecialchars({$str}, \ENT_QUOTES, 'UTF-8', false) : \$this->_v2_escape({$str2})";
case 'autocontext_json':
return "\$this->config->context === 'JS' ? {$str2} : htmlspecialchars({$str}, \ENT_QUOTES, 'UTF-8', false)";
case 'autocontext_lang':
return "\$this->config->context === 'JS' ? escape_js({$str2}) : ({$str})";
return "\$this->config->context === 'HTML' ? ({$str}) : \$this->_v2_escape({$str2})";
case 'autoescape':
return "htmlspecialchars({$str}, \ENT_QUOTES, 'UTF-8', false)";
case 'autolang':

View file

@ -3,7 +3,7 @@
/**
* Function library for Rhymix
*
* Copyright (c) Rhymix Developers and Contributors
* Copyright (c) Poesis Inc. and Contributors
*/
/**
@ -205,7 +205,7 @@ function escape($str, bool $double_escape = true, bool $except_lang_code = false
*/
function escape_css(string $str): string
{
return preg_replace('/[^a-zA-Z0-9_.#\/-]/', '', (string)$str);
return preg_replace('/[^a-zA-Z0-9_.,#%\/\'()\x20-]/', '', (string)$str);
}
/**
@ -680,7 +680,14 @@ function utf8_mbencode($str): string
*/
function utf8_normalize_spaces($str, bool $multiline = false): string
{
return $multiline ? preg_replace('/((?!\x0A)[\pZ\pC])+/u', ' ', (string)$str) : preg_replace('/[\pZ\pC]+/u', ' ', (string)$str);
if ($multiline)
{
return preg_replace(['/((?!\x0A)[\pZ\pC])+/u', '/\x20*\x0A\x20*/'], [' ', "\n"], (string)$str);
}
else
{
return preg_replace('/[\pZ\pC]+/u', ' ', (string)$str);
}
}
/**