diff --git a/classes/context/Context.class.php b/classes/context/Context.class.php index 8e608862e..b5bed2667 100644 --- a/classes/context/Context.class.php +++ b/classes/context/Context.class.php @@ -318,10 +318,10 @@ class Context } } - $lang_type = preg_replace('/[^a-zA-Z0-9_-]/', '', $lang_type); + $lang_type = preg_replace('/[^a-zA-Z0-9_-]/', '', $lang_type ?? ''); if ($set_lang_cookie) { - setcookie('lang_type', $lang_type, time() + 86400 * 365, \RX_BASEURL, null, !!config('session.use_ssl_cookies')); + setcookie('lang_type', $lang_type, time() + 86400 * 365, \RX_BASEURL, '', !!config('session.use_ssl_cookies')); } if(!$lang_type || !isset($enabled_langs[$lang_type])) @@ -745,7 +745,7 @@ class Context { return ''; } - return escape(self::replaceUserLang(self::$_instance->browser_title), false); + return escape(self::replaceUserLang(self::$_instance->browser_title, true), false); } /** @@ -758,7 +758,7 @@ class Context $domain_info = self::get('site_module_info'); if ($domain_info && $domain_info->settings && $domain_info->settings->title) { - return escape(self::replaceUserLang($domain_info->settings->title), false); + return escape(self::replaceUserLang($domain_info->settings->title, true), false); } else { @@ -776,7 +776,7 @@ class Context $domain_info = self::get('site_module_info'); if ($domain_info && $domain_info->settings && $domain_info->settings->subtitle) { - return escape(self::replaceUserLang($domain_info->settings->subtitle), false); + return escape(self::replaceUserLang($domain_info->settings->subtitle, true), false); } else { @@ -886,9 +886,13 @@ class Context } /** - * @brief Replace user-defined language codes + * Replace user-defined language codes + * + * @param string $string + * @param bool $fix_double_escape + * @return string */ - public static function replaceUserLang($output) + public static function replaceUserLang($string, $fix_double_escape = false) { static $lang = null; if($lang === null) @@ -900,7 +904,15 @@ class Context } } - return preg_replace_callback('/\$user_lang->([a-zA-Z0-9\_]+)/', function($matches) use($lang) { + if ($fix_double_escape) + { + $regexp = '/\$user_lang-(?:>|>)([a-zA-Z0-9\_]+)/'; + } + else + { + $regexp = '/\$user_lang->([a-zA-Z0-9\_]+)/'; + } + return preg_replace_callback($regexp, function($matches) use($lang) { if(isset($lang[$matches[1]]) && !self::get($matches[1])) { return $lang[$matches[1]]; @@ -909,7 +921,7 @@ class Context { return $matches[1]; } - }, $output); + }, $string); } /** @@ -1680,11 +1692,18 @@ class Context $get_vars = array(); foreach ($args_list[0] as $key => $val) { - $val = trim($val); - if ($val !== '') + if (is_array($val)) { $get_vars[$key] = $val; } + else + { + $val = trim(strval($val)); + if ($val !== '') + { + $get_vars[$key] = $val; + } + } } } // Otherwise, use alternating members of $args_list as keys and values, respectively. @@ -1814,7 +1833,7 @@ class Context // Check HTTP Request if(!isset($_SERVER['SERVER_PROTOCOL'])) { - return; + return self::getDefaultUrl(); } $site_module_info = self::get('site_module_info'); @@ -2774,7 +2793,7 @@ class Context { self::$_instance->meta_tags[$name] = array( 'is_http_equiv' => (bool)$is_http_equiv, - 'content' => self::replaceUserLang($content), + 'content' => self::replaceUserLang($content, true), ); } diff --git a/classes/db/DB.class.php b/classes/db/DB.class.php index 21bb0595f..393d34779 100644 --- a/classes/db/DB.class.php +++ b/classes/db/DB.class.php @@ -1,3 +1,6 @@ gzhandler_enable) - { - $this->gz_enabled = TRUE; - } - // Extract contents to display by the response method $responseMethod = Context::getResponseMethod(); if(Context::get('xeVirtualRequestMethod') == 'xml') @@ -122,21 +115,21 @@ class DisplayHandler extends Handler } } - // disable gzip if output already exists + // Print security-related headers. + if($header_value = config('security.x_frame_options')) + { + header('X-Frame-Options: ' . $header_value); + } + if($header_value = config('security.x_content_type_options')) + { + header('X-Content-Type-Options: ' . $header_value); + } + + // flush output buffer while (ob_get_level()) { ob_end_flush(); } - if(headers_sent()) - { - $this->gz_enabled = FALSE; - } - - // enable gzip using zlib extension - if($this->gz_enabled) - { - ini_set('zlib.output_compression', true); - } // call a trigger after display self::$response_size = $this->content_size = strlen($output); diff --git a/classes/mobile/Mobile.class.php b/classes/mobile/Mobile.class.php index c3e857c1e..186f7eccb 100644 --- a/classes/mobile/Mobile.class.php +++ b/classes/mobile/Mobile.class.php @@ -46,7 +46,7 @@ class Mobile $m = Context::get('m'); $cookie = isset($_COOKIE['rx_uatype']) ? $_COOKIE['rx_uatype'] : null; $uahash = base64_encode_urlsafe(md5($_SERVER['HTTP_USER_AGENT'] ?? '', true)); - if (strncmp($cookie, $uahash . ':', strlen($uahash) + 1) !== 0) + if (strncmp($cookie ?? '', $uahash . ':', strlen($uahash) + 1) !== 0) { $cookie = null; } @@ -72,7 +72,7 @@ class Mobile $uatype = $uahash . ':' . (self::$_ismobile ? '1' : '0'); if ($cookie !== $uatype) { - setcookie('rx_uatype', $uatype, 0, \RX_BASEURL, null, !!config('session.use_ssl_cookies')); + setcookie('rx_uatype', $uatype, 0, \RX_BASEURL, '', !!config('session.use_ssl_cookies')); $_COOKIE['rx_uatype'] = $uatype; } diff --git a/classes/module/ModuleHandler.class.php b/classes/module/ModuleHandler.class.php index c69d8f641..cb78aa4cd 100644 --- a/classes/module/ModuleHandler.class.php +++ b/classes/module/ModuleHandler.class.php @@ -193,6 +193,15 @@ class ModuleHandler extends Handler if(!$module_info && $this->mid) { $module_info = ModuleModel::getModuleInfoByMid($this->mid); + if($module_info && isset($module_info->domain_srl) && $module_info->domain_srl > -1) + { + if($module_info->domain_srl != $site_module_info->domain_srl) + { + $this->error = 'msg_module_is_not_exists'; + $this->httpStatusCode = 404; + return true; + } + } } // Set module info as the default module for the domain. @@ -983,7 +992,7 @@ class ModuleHandler extends Handler } // If connection to DB has a problem even though it's not install module, set error - if($this->module != 'install' && !DB::getInstance()->isConnected()) + if($this->module != 'install' && !DB::getInstance()->getHandle()) { $this->error = 'msg_dbconnect_failed'; } diff --git a/classes/module/ModuleObject.class.php b/classes/module/ModuleObject.class.php index d9bb9d894..b882609db 100644 --- a/classes/module/ModuleObject.class.php +++ b/classes/module/ModuleObject.class.php @@ -595,6 +595,100 @@ class ModuleObject extends BaseObject return $this->layout_path; } + /** + * Automatically set layout and template path based on skin settings. + * + * @param string $type 'P' or 'M' + * @param object $config + * @return void + */ + public function setLayoutAndTemplatePaths($type, $config) + { + // Set the layout path. + if ($type === 'P') + { + $layout_srl = $config->layout_srl ?? 0; + if ($layout_srl > 0) + { + $layout_info = LayoutModel::getInstance()->getLayout($layout_srl); + if($layout_info) + { + $this->module_info->layout_srl = $layout_srl; + $this->setLayoutPath($layout_info->path); + } + } + } + else + { + $layout_srl = $config->mlayout_srl ?? 0; + if ($layout_srl == -2) + { + $layout_srl = $config->layout_srl ?: -1; + if ($layout_srl == -1) + { + $layout_srl = LayoutAdminModel::getInstance()->getSiteDefaultLayout('P'); + } + } + elseif ($layout_srl == -1) + { + $layout_srl = LayoutAdminModel::getInstance()->getSiteDefaultLayout('M'); + } + + $layout_info = LayoutModel::getInstance()->getLayout($layout_srl); + if($layout_info) + { + $this->module_info->mlayout_srl = $layout_srl; + $this->setLayoutPath($layout_info->path); + } + } + + // Set the skin path. + if ($type === 'P') + { + $skin = ($config->skin ?? '') ?: 'default'; + if ($skin === '/USE_DEFAULT/') + { + $skin = ModuleModel::getModuleDefaultSkin($this->module, 'P') ?: 'default'; + } + $template_path = sprintf('%sskins/%s', $this->module_path, $skin); + if (!Rhymix\Framework\Storage::exists($template_path)) + { + $template_path = sprintf('%sskins/%s', $this->module_path, 'default'); + } + } + else + { + $mskin = ($config->mskin ?? '') ?: 'default'; + if ($mskin === '/USE_DEFAULT/') + { + $mskin = ModuleModel::getModuleDefaultSkin($this->module, 'M') ?: 'default'; + } + + if($mskin === '/USE_RESPONSIVE/') + { + $skin = ($config->skin ?? '') ?: 'default'; + if ($skin === '/USE_DEFAULT/') + { + $skin = ModuleModel::getModuleDefaultSkin($this->module, 'P') ?: 'default'; + } + $template_path = sprintf('%sskins/%s', $this->module_path, $skin); + if (!Rhymix\Framework\Storage::exists($template_path)) + { + $template_path = sprintf('%sskins/%s', $this->module_path, 'default'); + } + } + else + { + $template_path = sprintf('%sm.skins/%s', $this->module_path, $mskin); + if (!Rhymix\Framework\Storage::exists($template_path)) + { + $template_path = sprintf("%sm.skins/%s/", $this->module_path, 'default'); + } + } + } + $this->setTemplatePath($template_path); + } + /** * excute the member method specified by $act variable * @return bool @@ -641,37 +735,11 @@ class ModuleObject extends BaseObject // Set module skin if(isset($this->module_info->skin) && $this->module_info->module === $this->module && strpos($this->act, 'Admin') === false) { - $skin_type = $is_mobile ? 'M' : 'P'; - $skin_key = $is_mobile ? 'mskin' : 'skin'; - $skin_dir = $is_mobile ? 'm.skins' : 'skins'; - $module_skin = $this->module_info->{$skin_key} ?: '/USE_DEFAULT/'; - $use_default_skin = $this->module_info->{'is_' . $skin_key . '_fix'} === 'N'; - - // Set default skin + $use_default_skin = $this->module_info->{$is_mobile ? 'is_mskin_fix' : 'is_skin_fix'} === 'N'; if(!$this->getTemplatePath() || $use_default_skin) { - if($module_skin === '/USE_DEFAULT/') - { - $module_skin = ModuleModel::getModuleDefaultSkin($this->module, $skin_type); - $this->module_info->{$skin_key} = $module_skin; - } - if($module_skin === '/USE_RESPONSIVE/') - { - $skin_dir = 'skins'; - $module_skin = $this->module_info->skin ?: '/USE_DEFAULT/'; - if($module_skin === '/USE_DEFAULT/') - { - $module_skin = ModuleModel::getModuleDefaultSkin($this->module, 'P'); - } - } - if(!is_dir(sprintf('%s%s/%s', $this->module_path, $skin_dir, $module_skin))) - { - $module_skin = 'default'; - } - $this->setTemplatePath(sprintf('%s%s/%s', $this->module_path, $skin_dir, $module_skin)); + $this->setLayoutAndTemplatePaths($is_mobile ? 'M' : 'P', $this->module_info); } - - // Set skin variable ModuleModel::syncSkinInfoToModuleInfo($this->module_info); Context::set('module_info', $this->module_info); } diff --git a/classes/template/TemplateHandler.class.php b/classes/template/TemplateHandler.class.php index bc8abfc49..851e21049 100644 --- a/classes/template/TemplateHandler.class.php +++ b/classes/template/TemplateHandler.class.php @@ -16,6 +16,7 @@ class TemplateHandler private $config = NULL; private $skipTags = NULL; private $handler_mtime = 0; + private $delay_compile = 0; private static $rootTpl = NULL; /** @@ -32,6 +33,7 @@ class TemplateHandler ini_set('pcre.jit', false); $this->config = new stdClass; $this->handler_mtime = filemtime(__FILE__); + $this->delay_compile = config('view.delay_compile') ?? 0; $this->user = Rhymix\Framework\Session::getMemberInfo(); } @@ -161,13 +163,27 @@ class TemplateHandler self::$rootTpl = $this->file; } - $latest_mtime = max(filemtime($this->file), $this->handler_mtime); + // Don't try to compile files that are less than 1 second old + $filemtime = filemtime($this->file); + if ($filemtime > time() - $this->delay_compile) + { + $latest_mtime = $this->handler_mtime; + } + else + { + $latest_mtime = max($filemtime, $this->handler_mtime); + } // make compiled file if(!file_exists($this->compiled_file) || filemtime($this->compiled_file) < $latest_mtime) { $buff = $this->parse(); - if(Rhymix\Framework\Storage::write($this->compiled_file, $buff) === false) + if($buff === null && file_exists($this->compiled_file)) + { + $error_message = 'Template compile failed: Source file is unreadable: ' . $this->file; + trigger_error($error_message, \E_USER_WARNING); + } + elseif(Rhymix\Framework\Storage::write($this->compiled_file, $buff) === false) { $tmpfilename = tempnam(sys_get_temp_dir(), 'rx-compiled'); if($tmpfilename === false || Rhymix\Framework\Storage::write($tmpfilename, $buff) === false) @@ -245,7 +261,11 @@ class TemplateHandler } // read tpl file - $buff = FileHandler::readFile($this->file); + $buff = Rhymix\Framework\Storage::read($this->file); + if ($buff === false) + { + return; + } } // HTML tags to skip @@ -1029,7 +1049,7 @@ class TemplateHandler { if (preg_match('/^\$[\\\\\w\[\]\'":>-]+$/i', $str)) { - $str = "$str ?? ''"; + $str = preg_match('/^\$lang->/', $str) ? $str : "$str ?? ''"; } switch($escape_option) diff --git a/common/constants.php b/common/constants.php index c41c47102..c5c850128 100644 --- a/common/constants.php +++ b/common/constants.php @@ -3,7 +3,7 @@ /** * RX_VERSION is the version number of the Rhymix CMS. */ -define('RX_VERSION', '2.1.0-dev'); +define('RX_VERSION', '2.1.1'); /** * RX_MICROTIME is the startup time of the current script, in microseconds since the Unix epoch. diff --git a/common/defaults/blacklist.php b/common/defaults/blacklist.php index eb0241e39..c99583abc 100644 --- a/common/defaults/blacklist.php +++ b/common/defaults/blacklist.php @@ -2,11 +2,11 @@ /** * Rhymix Default Blacklist for Deprecated Plugins - * + * * Copyright (c) Rhymix Developers and Contributors */ return array( - + // Addons 'addon' => array( 'autolang' => true, @@ -16,11 +16,12 @@ return array( 'fix_mysql_utf8' => true, 'jquerycdn' => true, 'member_communication' => true, + 'mobile' => true, 'session_shield' => true, 'smartphone' => true, 'zipperupper' => true, ), - + // Modules 'module' => array( 'auto_login' => true, @@ -30,9 +31,9 @@ return array( 'seo' => true, 'trackback' => true, ), - + // Widgets 'widget' => array( - + ), ); diff --git a/common/defaults/config.php b/common/defaults/config.php index df7da8db8..e786796ad 100644 --- a/common/defaults/config.php +++ b/common/defaults/config.php @@ -61,6 +61,7 @@ return array( 'use_keys' => false, 'use_ssl' => false, 'use_ssl_cookies' => false, + 'samesite' => 'Lax', 'domain' => null, 'path' => null, 'lifetime' => 0, @@ -77,9 +78,9 @@ return array( 'manager_layout' => 'module', 'minify_scripts' => 'common', 'concat_scripts' => 'none', + 'delay_compile' => 0, 'jquery_version' => 2, 'server_push' => false, - 'use_gzip' => false, ), 'admin' => array( 'allow' => array(), @@ -126,6 +127,8 @@ return array( 'robot_user_agents' => array(), 'check_csrf_token' => false, 'nofollow' => false, + 'x_frame_options' => 'SAMEORIGIN', + 'x_content_type_options' => 'nosniff', ), 'mobile' => array( 'enabled' => true, diff --git a/common/defaults/reserved.php b/common/defaults/reserved.php index f789ce5c3..8f9500381 100644 --- a/common/defaults/reserved.php +++ b/common/defaults/reserved.php @@ -2,7 +2,7 @@ /** * Reserved words for Rhymix - * + * * Copyright (c) Rhymix Developers and Contributors */ return array( @@ -12,6 +12,7 @@ return array( 'admin' => true, 'module' => true, 'module_srl' => true, + 'member' => true, 'member_srl' => true, 'menu_srl' => true, 'menu_item_srl' => true, diff --git a/common/framework/DB.php b/common/framework/DB.php index 6b3d17d15..bc0a2c250 100644 --- a/common/framework/DB.php +++ b/common/framework/DB.php @@ -101,40 +101,66 @@ class DB return; } + // Cache the debug comment setting. + $this->_debug_queries = in_array('queries', Config::get('debug.display_content') ?: []); + $this->_debug_comment = !!config('debug.query_comment'); + $this->_debug_full_stack = !!Config::get('debug.query_full_stack'); + // Connect to the DB. + $this->connect($config); + } + + /** + * Connect to the database. + * + * @param array $config + * @return void + */ + public function connect(array $config): void + { + // Assemble the DSN and default options. $dsn = 'mysql:host=' . $config['host']; $dsn .= (isset($config['port']) && $config['port'] != 3306) ? (';port=' . $config['port']) : ''; $dsn .= ';dbname=' . $config['database']; $dsn .= ';charset=' . $this->_charset; - class_exists('\Rhymix\Framework\Helpers\DBStmtHelper'); $options = array( \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, \PDO::ATTR_EMULATE_PREPARES => false, \PDO::ATTR_STATEMENT_CLASS => array('\Rhymix\Framework\Helpers\DBStmtHelper'), \PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false, ); + + // Preload the statement helper class. + class_exists('\Rhymix\Framework\Helpers\DBStmtHelper'); + try { $this->_handle = new Helpers\DBHelper($dsn, $config['user'], $config['pass'], $options); - $this->_handle->setType($type); + $this->_handle->setType($this->_type); } catch (\PDOException $e) { throw new Exceptions\DBError($e->getMessage()); } + } - // Cache the debug comment setting. - $this->_debug_queries = in_array('queries', Config::get('debug.display_content') ?: []); - $this->_debug_comment = !!config('debug.query_comment'); - $this->_debug_full_stack = !!Config::get('debug.query_full_stack'); + /** + * Disconnect from the database. + * + * @return void + */ + public function disconnect(): void + { + $this->_handle = null; + unset(self::$_instances[$this->_type]); } /** * Get the PDO handle for direct manipulation. * - * @return Helpers\DBHelper + * @return ?Helpers\DBHelper */ - public function getHandle(): Helpers\DBHelper + public function getHandle(): ?Helpers\DBHelper { return $this->_handle; } @@ -360,7 +386,7 @@ class DB } elseif ($query->type === 'SELECT') { - $result = $this->_fetch($this->_last_stmt, $last_index, $result_type, $result_class); + $result = $this->fetch($this->_last_stmt, $last_index, $result_type, $result_class); } else { @@ -473,40 +499,16 @@ class DB return $output; } - /** - * Execute a literal query string. - * - * This method should not be public, as it starts with an underscore. - * But since there are many legacy apps that rely on it, we will leave it public. - * - * @param string $query_string - * @return Helpers\DBStmtHelper - */ - public function _query($query_string) - { - if ($this->_debug_comment) - { - $query_string .= "\n" . sprintf('/* _query() %s */', \RX_CLIENT_IP); - } - - $this->_last_stmt = null; - $this->_last_stmt = $this->_handle->query($query_string); - return $this->_last_stmt; - } - /** * Fetch results from a query. * - * This method should not be public, as it starts with an underscore. - * But since there are many legacy apps that rely on it, we will leave it public. - * * @param \PDOStatement $stmt * @param int $last_index * @param string $result_type * @param string $result_class * @return mixed */ - public function _fetch($stmt, $last_index = 0, $result_type = 'auto', $result_class = '') + public function fetch($stmt, $last_index = 0, $result_type = 'auto', $result_class = '') { if (!($stmt instanceof \PDOStatement)) { @@ -748,7 +750,7 @@ class DB public function isTableExists(string $table_name): bool { $stmt = $this->_handle->query(sprintf("SHOW TABLES LIKE '%s'", $this->addQuotes($this->_prefix . $table_name))); - $result = $this->_fetch($stmt); + $result = $this->fetch($stmt); return $result ? true : false; } @@ -800,7 +802,7 @@ class DB public function isColumnExists(string $table_name, string $column_name): bool { $stmt = $this->_handle->query(sprintf("SHOW FIELDS FROM `%s` WHERE Field = '%s'", $this->addQuotes($this->_prefix . $table_name), $this->addQuotes($column_name))); - $result = $this->_fetch($stmt); + $result = $this->fetch($stmt); return $result ? true : false; } @@ -946,7 +948,7 @@ class DB { // If column information is not found, return false. $stmt = $this->_handle->query(sprintf("SHOW FIELDS FROM `%s` WHERE Field = '%s'", $this->addQuotes($this->_prefix . $table_name), $this->addQuotes($column_name))); - $column_info = $this->_fetch($stmt); + $column_info = $this->fetch($stmt); if (!$column_info) { return false; @@ -986,7 +988,7 @@ class DB public function isIndexExists(string $table_name, string $index_name): bool { $stmt = $this->_handle->query(sprintf("SHOW INDEX FROM `%s` WHERE Key_name = '%s'", $this->addQuotes($this->_prefix . $table_name), $this->addQuotes($index_name))); - $result = $this->_fetch($stmt); + $result = $this->fetch($stmt); return $result ? true : false; } @@ -1104,7 +1106,7 @@ class DB */ public function getBestSupportedCharset(): string { - $output = $this->_fetch($this->_handle->query("SHOW CHARACTER SET LIKE 'utf8%'"), 1); + $output = $this->fetch($this->_handle->query("SHOW CHARACTER SET LIKE 'utf8%'"), 1); $utf8mb4_support = ($output && count(array_filter($output, function($row) { return $row->Charset === 'utf8mb4'; }))); @@ -1285,6 +1287,42 @@ class DB * ==================== KEPT FOR COMPATIBILITY WITH XE ==================== */ + /** + * Execute a literal query string. + * + * Use query() instead, or call methods directly on the handle. + * + * @deprecated + * @param string $query_string + * @return Helpers\DBStmtHelper + */ + public function _query($query_string) + { + if ($this->_debug_comment) + { + $query_string .= "\n" . sprintf('/* _query() %s */', \RX_CLIENT_IP); + } + + $this->_last_stmt = null; + $this->_last_stmt = $this->_handle->query($query_string); + return $this->_last_stmt; + } + + /** + * Fetch results from a query. + * + * Use query() and fetch() instead. + * + * @deprecated + * @param \PDOStatement $stmt + * @param int $last_index + * @return mixed + */ + public function _fetch($stmt, $last_index = 0) + { + return $this->fetch($stmt, $last_index); + } + /** * Old alias to getInstance(). * @@ -1392,7 +1430,7 @@ class DB */ public function isConnected(): bool { - return true; + return $this->_handle ? true : false; } /** @@ -1458,7 +1496,7 @@ class DB { return 0; } - public function _getConnection(): \PDO + public function _getConnection(): ?Helpers\DBHelper { return $this->getHandle(); } diff --git a/common/framework/Password.php b/common/framework/Password.php index 07a1a1f0c..0049758fe 100644 --- a/common/framework/Password.php +++ b/common/framework/Password.php @@ -138,6 +138,33 @@ class Password return $algorithm; } + /** + * Get the current default hashing algorithm, unless it will produce + * hashes that are longer than 60 characters. + * + * In that case, this method returns the next best supported algorithm + * that produces 60-character (or shorter) hashes. This helps maintain + * compatibility with old tables that still have varchar(60) columns. + * + * @return string + */ + public static function getBackwardCompatibleAlgorithm() + { + $algorithm = self::getDefaultAlgorithm(); + if (!in_array($algorithm, ['bcrypt', 'pbkdf2', 'sha1', 'md5'])) + { + $candidates = self::getSupportedAlgorithms(); + foreach ($candidates as $algorithm) + { + if (in_array($algorithm, ['bcrypt', 'pbkdf2', 'sha1', 'md5'])) + { + return $algorithm; + } + } + } + return $algorithm; + } + /** * Get the currently configured work factor for bcrypt and other adjustable algorithms. * diff --git a/common/framework/Session.php b/common/framework/Session.php index ad6392b9e..be51d52e2 100644 --- a/common/framework/Session.php +++ b/common/framework/Session.php @@ -480,7 +480,7 @@ class Session public static function refresh($set_session_cookie = false) { // Get session parameters. - $domain = self::getDomain() ?: preg_replace('/:\\d+$/', '', strtolower($_SERVER['HTTP_HOST'])); + $domain = self::getDomain() ?: preg_replace('/:\\d+$/', '', strtolower($_SERVER['HTTP_HOST'] ?? '')); // Set the domain initialization timestamp. if (!isset($_SESSION['RHYMIX']['keys'][$domain]['started'])) @@ -722,12 +722,12 @@ class Session // Check member information to see if denied or limited. $member_info = \MemberModel::getMemberInfo($member_srl); - if ($member_info->denied === 'Y') + if (!empty($member_info->denied) && $member_info->denied === 'Y') { trigger_error('Session is invalid for member_srl=' . intval($_SESSION['RHYMIX']['login']) . ' (denied)', \E_USER_WARNING); return false; } - if ($member_info->limit_date && substr($member_info->limit_date, 0, 8) >= date('Ymd')) + if (!empty($member_info->limit_date) && substr($member_info->limit_date, 0, 8) >= date('Ymd')) { trigger_error('Session is invalid for member_srl=' . intval($_SESSION['RHYMIX']['login']) . ' (limited)', \E_USER_WARNING); return false; @@ -844,7 +844,7 @@ class Session */ public static function getDomain() { - if (self::$_domain || (self::$_domain = ltrim(Config::get('session.domain'), '.'))) + if (self::$_domain || (self::$_domain = ltrim(Config::get('session.domain') ?? '', '.'))) { return self::$_domain; } @@ -1172,7 +1172,7 @@ class Session { // Get session parameters. list($lifetime, $refresh_interval, $domain, $path, $secure, $samesite) = self::_getParams(); - $alt_domain = $domain ?: preg_replace('/:\\d+$/', '', strtolower($_SERVER['HTTP_HOST'])); + $alt_domain = $domain ?: preg_replace('/:\\d+$/', '', strtolower($_SERVER['HTTP_HOST'] ?? '')); $lifetime = $lifetime ? ($lifetime + time()) : 0; $options = array( 'expires' => $lifetime, @@ -1261,7 +1261,7 @@ class Session * @param string $domain (optional) * @return bool */ - protected static function _unsetCookie($name, $path = null, $domain = null) + protected static function _unsetCookie($name, $path = '', $domain = '') { $result = setcookie($name, 'deleted', time() - (86400 * 366), $path, $domain, false, false); if ($result) diff --git a/common/framework/UA.php b/common/framework/UA.php index 9ee264dce..521b707db 100644 --- a/common/framework/UA.php +++ b/common/framework/UA.php @@ -468,12 +468,12 @@ class UA if (in_array($color_scheme, ['light', 'dark'])) { $_COOKIE['rx_color_scheme'] = $color_scheme; - setcookie('rx_color_scheme', $color_scheme, time() + 86400 * 365, \RX_BASEURL, null, !!config('session.use_ssl_cookies')); + setcookie('rx_color_scheme', $color_scheme, time() + 86400 * 365, \RX_BASEURL, '', !!config('session.use_ssl_cookies')); } else { unset($_COOKIE['rx_color_scheme']); - setcookie('rx_color_scheme', 'deleted', time() - 86400, \RX_BASEURL, null); + setcookie('rx_color_scheme', 'deleted', time() - 86400, \RX_BASEURL); } } } diff --git a/common/framework/parsers/ConfigParser.php b/common/framework/parsers/ConfigParser.php index e19f5ec72..4a7921670 100644 --- a/common/framework/parsers/ConfigParser.php +++ b/common/framework/parsers/ConfigParser.php @@ -206,7 +206,6 @@ class ConfigParser // Convert view configuration. $config['view']['minify_scripts'] = $db_info->minify_scripts ?: 'common'; - $config['view']['use_gzip'] = (defined('__OB_GZHANDLER_ENABLE__') && constant('__OB_GZHANDLER_ENABLE__')); // Convert admin IP whitelist. if (isset($db_info->admin_ip_list) && is_array($db_info->admin_ip_list) && count($db_info->admin_ip_list)) diff --git a/common/legacy.php b/common/legacy.php index c7056c52f..6a35e076c 100644 --- a/common/legacy.php +++ b/common/legacy.php @@ -110,8 +110,7 @@ function getMobile($module_name) /** * Create a wap instance of the module * - * @param string $module_name The module name to get a wap instance - * @return mixed Module wap class instance + * @deprecated */ function getWAP($module_name) { @@ -1129,6 +1128,7 @@ function recurciveExposureCheck(&$menu) if(!$value['isShow']) { unset($menu[$key]); + continue; } if(is_array($value['list']) && count($value['list']) > 0) { diff --git a/common/scripts/update_all_modules.php b/common/scripts/update_all_modules.php index 2f9c88dd8..768fc8e77 100644 --- a/common/scripts/update_all_modules.php +++ b/common/scripts/update_all_modules.php @@ -2,7 +2,7 @@ /** * This script updates all modules. - * + * * Running this script on the CLI is better than clicking 'update' in the * admin dashboard because some module updates may take a long time. */ @@ -47,10 +47,13 @@ $oInstallAdminController = getAdminController('install'); foreach ($need_update as $module) { echo 'Updating ' . $module . '...' . PHP_EOL; - $oModule = getModule($module, 'class'); - if ($oModule) + $oModule = ModuleModel::getModuleInstallClass($module); + if (is_object($oModule) && method_exists($oModule, 'checkUpdate') && method_exists($oModule, 'moduleUpdate')) { - $oModule->moduleUpdate(); + if ($oModule->checkUpdate()) + { + return $oModule->moduleUpdate(); + } } } diff --git a/common/tpl/common_layout.html b/common/tpl/common_layout.html index 885be45a7..a52702fe0 100644 --- a/common/tpl/common_layout.html +++ b/common/tpl/common_layout.html @@ -10,7 +10,7 @@ - + {Context::getBrowserTitle()} @@ -45,7 +45,7 @@ - + - + '); + $html = '
'; + $html = sprintf($html, escape(self::$config->site_key), self::$config->theme ?: 'auto', self::$config->size ?: 'normal', implode(',', array_keys($this->_target_actions))); + Context::addHtmlFooter($html); + } + } + + public function setTargetActions(array $target_actions) + { + $this->_target_actions = $target_actions; + } + + public function isTargetAction(string $action): bool + { + return isset($this->_target_actions[$action]); + } + + public function __toString() + { + return sprintf('
', self::$instances_inserted++); + } +} diff --git a/modules/spamfilter/lang/en.php b/modules/spamfilter/lang/en.php index feca70102..0f91ae245 100644 --- a/modules/spamfilter/lang/en.php +++ b/modules/spamfilter/lang/en.php @@ -64,7 +64,7 @@ $lang->recaptcha_target_everyone = 'Everyone'; $lang->recaptcha_target_frequency = 'Frequency'; $lang->recaptcha_target_first_time_only = 'First Time Only'; $lang->recaptcha_target_every_time = 'Every Time'; -$lang->msg_recaptcha_connection_error = 'An error occurred while connecting to the reCAPTCHA verification server.'; -$lang->msg_recaptcha_server_error = 'An error occurred while verifying your reCAPTCHA response.'; -$lang->msg_recaptcha_invalid_response = 'Please check reCAPTCHA.'; -$lang->msg_recaptcha_keys_not_set = 'Please fill in your reCAPTCHA Site Key and Secret Key.'; +$lang->msg_recaptcha_connection_error = 'An error occurred while connecting to the CAPTCHA verification server.'; +$lang->msg_recaptcha_server_error = 'An error occurred while verifying your CAPTCHA response.'; +$lang->msg_recaptcha_invalid_response = 'Please check the CAPTCHA.'; +$lang->msg_recaptcha_keys_not_set = 'Please fill in your CAPTCHA Site Key and Secret Key.'; diff --git a/modules/spamfilter/lang/ko.php b/modules/spamfilter/lang/ko.php index 28aebdd84..1f95d6b41 100644 --- a/modules/spamfilter/lang/ko.php +++ b/modules/spamfilter/lang/ko.php @@ -64,7 +64,7 @@ $lang->recaptcha_target_everyone = '모든 사용자'; $lang->recaptcha_target_frequency = '캡챠 사용 빈도'; $lang->recaptcha_target_first_time_only = '최초 1회만 사용'; $lang->recaptcha_target_every_time = '매번 사용'; -$lang->msg_recaptcha_connection_error = 'reCAPTCHA 스팸방지 서버에 접속하는 도중 오류가 발생했습니다.'; -$lang->msg_recaptcha_server_error = 'reCAPTCHA 스팸방지 서버와 통신하는 도중 오류가 발생했습니다.'; -$lang->msg_recaptcha_invalid_response = 'reCAPTCHA 스팸방지 기능을 체크해 주십시오.'; -$lang->msg_recaptcha_keys_not_set = 'reCAPTCHA Site Key 및 Secret Key를 입력하여 주십시오.'; +$lang->msg_recaptcha_connection_error = '스팸방지 CAPTCHA 서버에 접속하는 도중 오류가 발생했습니다.'; +$lang->msg_recaptcha_server_error = '스팸방지 CAPTCHA 서버와 통신하는 도중 오류가 발생했습니다.'; +$lang->msg_recaptcha_invalid_response = '스팸방지 기능을 체크해 주십시오.'; +$lang->msg_recaptcha_keys_not_set = 'CAPTCHA Site Key 및 Secret Key를 입력하여 주십시오.'; diff --git a/modules/spamfilter/spamfilter.admin.controller.php b/modules/spamfilter/spamfilter.admin.controller.php index 0e8aba43b..1558e3da2 100644 --- a/modules/spamfilter/spamfilter.admin.controller.php +++ b/modules/spamfilter/spamfilter.admin.controller.php @@ -77,7 +77,7 @@ class spamfilterAdminController extends spamfilter { $config->captcha = new stdClass; } - $config->captcha->type = $vars->captcha_type === 'recaptcha' ? 'recaptcha' : 'none'; + $config->captcha->type = in_array($vars->captcha_type, ['recaptcha', 'turnstile']) ? $vars->captcha_type : 'none'; $config->captcha->site_key = escape(utf8_trim($vars->site_key)); $config->captcha->secret_key = escape(utf8_trim($vars->secret_key)); if ($config->captcha->type !== 'none' && (!$config->captcha->site_key || !$config->captcha->secret_key)) diff --git a/modules/spamfilter/spamfilter.controller.php b/modules/spamfilter/spamfilter.controller.php index a6a885778..d326335f8 100644 --- a/modules/spamfilter/spamfilter.controller.php +++ b/modules/spamfilter/spamfilter.controller.php @@ -225,23 +225,7 @@ class spamfilterController extends spamfilter function triggerCheckCaptcha(&$obj) { $config = ModuleModel::getModuleConfig('spamfilter'); - if (!isset($config) || !isset($config->captcha) || $config->captcha->type !== 'recaptcha' || !$config->captcha->site_key || !$config->captcha->secret_key) - { - return; - } - if ($this->user->is_admin === 'Y') - { - return; - } - if ($config->captcha->target_users !== 'everyone' && $this->user->member_srl) - { - return; - } - if ($config->captcha->target_frequency !== 'every_time' && isset($_SESSION['recaptcha_authenticated']) && $_SESSION['recaptcha_authenticated']) - { - return; - } - if (!$config->captcha->target_devices[Mobile::isFromMobilePhone() ? 'mobile' : 'pc']) + if (!SpamfilterModel::isCaptchaEnabled()) { return; } @@ -260,16 +244,16 @@ class spamfilterController extends spamfilter if (count($target_actions)) { - include_once __DIR__ . '/spamfilter.lib.php'; - spamfilter_reCAPTCHA::init($config->captcha); + $captcha_class = 'Rhymix\\Modules\\Spamfilter\\Captcha\\' . $config->captcha->type; + $captcha_class::init($config->captcha); if (strncasecmp('proc', $obj->act, 4) === 0) { - spamfilter_reCAPTCHA::check(); + $captcha_class::check(); } else { - $captcha = new spamfilter_reCAPTCHA(); + $captcha = new $captcha_class(); $captcha->setTargetActions($target_actions); $captcha->addScripts(); Context::set('captcha', $captcha); diff --git a/modules/spamfilter/spamfilter.model.php b/modules/spamfilter/spamfilter.model.php index 3dc4df1ef..1ff9b3bd4 100644 --- a/modules/spamfilter/spamfilter.model.php +++ b/modules/spamfilter/spamfilter.model.php @@ -208,6 +208,59 @@ class spamfilterModel extends spamfilter return new BaseObject(); } + /** + * Check if CAPTCHA is enabled + * + * @return bool + */ + public static function isCaptchaEnabled($target_action = null) + { + $config = ModuleModel::getModuleConfig('spamfilter'); + $user = Context::get('logged_info'); + if (!isset($config) || !isset($config->captcha) || !in_array($config->captcha->type, ['recaptcha', 'turnstile']) || !$config->captcha->site_key || !$config->captcha->secret_key) + { + return false; + } + if ($user->is_admin === 'Y') + { + return false; + } + if ($config->captcha->target_users !== 'everyone' && $user->member_srl) + { + return false; + } + if ($config->captcha->target_frequency !== 'every_time' && isset($_SESSION['recaptcha_authenticated']) && $_SESSION['recaptcha_authenticated']) + { + return false; + } + if (!$config->captcha->target_devices[Mobile::isFromMobilePhone() ? 'mobile' : 'pc']) + { + return false; + } + if ($target_action && !$config->captcha->target_actions[$target_action]) + { + return false; + } + return true; + } + + /** + * Get a CAPTCHA instance. + * + * @return object + */ + public static function getCaptcha($target_action) + { + $config = ModuleModel::getModuleConfig('spamfilter'); + $captcha_class = 'Rhymix\\Modules\\Spamfilter\\Captcha\\' . $config->captcha->type; + $captcha_class::init($config->captcha); + + $captcha = new $captcha_class(); + $captcha->setTargetActions([$target_action => true]); + $captcha->addScripts(); + return $captcha; + } + /** * @brief Check if the trackbacks have already been registered to a particular article */ diff --git a/modules/spamfilter/tpl/config_captcha.html b/modules/spamfilter/tpl/config_captcha.html index 4b13f7678..831d9a859 100644 --- a/modules/spamfilter/tpl/config_captcha.html +++ b/modules/spamfilter/tpl/config_captcha.html @@ -14,6 +14,7 @@

{$lang->about_captcha_position}

diff --git a/modules/spamfilter/tpl/js/turnstile.js b/modules/spamfilter/tpl/js/turnstile.js new file mode 100644 index 000000000..39a7d4fbf --- /dev/null +++ b/modules/spamfilter/tpl/js/turnstile.js @@ -0,0 +1,60 @@ + +function turnstileCallback() { + var recaptcha_config = $("#turnstile-config"); + var recaptcha_instances = $(".turnstile-captcha"); + var recaptcha_instance_id = 1; + var recaptcha_targets = String(recaptcha_config.data("targets")).split(","); + + if (recaptcha_instances.length === 0) { + var autoinsert_candidates = $("form").filter(function() { + var actinput = $("input[name='act']", this); + if (actinput.length && actinput.val()) { + var act = String(actinput.val()); + if (act.match(/^procMemberInsert$/i) && recaptcha_targets.indexOf("signup") > -1) { + return true; + } + if (act.match(/^procMemberLogin$/i) && recaptcha_targets.indexOf("login") > -1) { + return true; + } + if (act.match(/^procMember(FindAccount|ResendAuthMail)$/i) && recaptcha_targets.indexOf("recovery") > -1) { + return true; + } + if (act.match(/^proc[A-Z][a-zA-Z0-9_]+InsertDocument$/i) && recaptcha_targets.indexOf("document") > -1) { + return true; + } + if (act.match(/^proc[A-Z][a-zA-Z0-9_]+InsertComment$/i) && recaptcha_targets.indexOf("comment") > -1) { + return true; + } + } + var procfilter = $(this).attr("onsubmit"); + if (procfilter && procfilter.match(/procFilter\b.+\binsert/i) && (recaptcha_targets.indexOf("document") > -1 || recaptcha_targets.indexOf("comment") > -1)) { + return true; + } + return false; + }); + autoinsert_candidates.each(function() { + var new_instance = $('
'); + new_instance.attr("id", "turnstile-instance-" + recaptcha_instance_id++); + var autoinsert_point = $(this).find("button[type='submit'],input[type='submit']").parent(); + if (autoinsert_point.size()) { + new_instance.insertBefore(autoinsert_point); + } else { + new_instance.appendTo($(this)); + } + }); + var recaptcha_instances = $(".turnstile-captcha"); + } + + recaptcha_instances.each(function() { + var instance = $(this); + var theme = recaptcha_config.data("theme"); + if (theme === 'auto') { + theme = getColorScheme(); + } + grecaptcha.render(`#${instance.attr("id")}`, { + sitekey: recaptcha_config.data("sitekey"), + size: recaptcha_config.data("size"), + theme: theme + }); + }); +} diff --git a/tests/unit/classes/TemplateHandlerTest.php b/tests/unit/classes/TemplateHandlerTest.php index 2bff4a567..41e587fe0 100644 --- a/tests/unit/classes/TemplateHandlerTest.php +++ b/tests/unit/classes/TemplateHandlerTest.php @@ -211,7 +211,7 @@ class TemplateHandlerTest extends \Codeception\TestCase\Test // issue 512 - ignores array( '
{$lang->sl_show_topimgtext}
', - '?>
sl_show_topimgtext ?? \'\' ?>
' + '?>
sl_show_topimgtext ?>
' ), // issue 584 array( diff --git a/tests/unit/framework/DBTest.php b/tests/unit/framework/DBTest.php index 4dcabb718..ed9eb8ef3 100644 --- a/tests/unit/framework/DBTest.php +++ b/tests/unit/framework/DBTest.php @@ -13,11 +13,27 @@ class DBTest extends \Codeception\TestCase\Test $oDB = Rhymix\Framework\DB::getInstance(); $this->assertTrue($oDB instanceof Rhymix\Framework\DB); $this->assertEquals($oDB, \DB::getInstance()); - $this->assertTrue(\DB::getInstance() instanceof \DB); - $this->assertTrue($oDB->isConnected()); + $this->assertTrue(\DB::getInstance() instanceof Rhymix\Framework\DB); $this->assertTrue($oDB->getHandle() instanceof Rhymix\Framework\Helpers\DBHelper); } + public function testConnectDisconnect() + { + $oDB = Rhymix\Framework\DB::getInstance('master'); + $this->assertTrue(is_object($oDB->getHandle())); + + $oDB->disconnect(); + $this->assertTrue(is_null($oDB->getHandle())); + $this->assertFalse($oDB->isConnected()); + + $oDB->connect(config('db.master')); + $this->assertTrue(is_object($oDB->getHandle())); + $this->assertTrue($oDB->isConnected()); + + $oDB = Rhymix\Framework\DB::getInstance('master'); + $this->assertTrue(is_object($oDB->getHandle())); + } + public function testCompatProperties() { $oDB = Rhymix\Framework\DB::getInstance(); diff --git a/tests/unit/functions/LegacyTest.php b/tests/unit/functions/LegacyTest.php index 3f38e4e6b..8b94bb633 100644 --- a/tests/unit/functions/LegacyTest.php +++ b/tests/unit/functions/LegacyTest.php @@ -2,6 +2,11 @@ class LegacyTest extends \Codeception\TestCase\Test { + public function _before() + { + Context::init(); + } + public function testGetModule() { $this->assertTrue(getModule('board', 'controller') instanceof BoardController); @@ -16,13 +21,12 @@ class LegacyTest extends \Codeception\TestCase\Test $this->assertTrue(getView('board') instanceof BoardView); $this->assertTrue(getAPI('board') instanceof BoardApi); $this->assertTrue(getMobile('board') instanceof BoardMobile); - $this->assertTrue(getWAP('board') instanceof BoardWap); $this->assertTrue(getClass('board') instanceof Board); } public function testGetNextSequence() { - if (!DB::getInstance()->isConnected()) + if (!DB::getInstance()->getHandle()) { return; } @@ -54,6 +58,22 @@ class LegacyTest extends \Codeception\TestCase\Test * - getScriptPath() * - getRequestUriByServerEnviroment() */ + + // Legacy format + $this->assertStringContainsString('foo=bar', getUrl('foo', 'bar')); + $this->assertStringContainsString('?foo=bar&rhy=mix', getUrl('', 'foo', 'bar', 'rhy', 'mix')); + $this->assertStringContainsString('?foo=bar&rhy=mix', getUrl('', 'foo', 'bar', 'rhy', 'mix', 'empty', '', 'keys', null)); + $this->assertStringContainsString('?foo=bar&rhy=mix', getNotEncodedUrl('', 'foo', 'bar', 'rhy', 'mix')); + + // Array format + $this->assertStringContainsString('?foo=bar&rhy=mix', getUrl(['foo' => 'bar', 'rhy' => 'mix', 'empty' => false])); + $this->assertStringContainsString('?foo=bar&rhy=mix', getNotEncodedUrl(['foo' => 'bar', 'rhy' => 'mix'])); + $this->assertStringContainsString('?foo=bar', getNotEncodedUrl(['foo' => 'bar', 'rhymix' => []])); + + // Nested arrays #2123 + $this->assertStringContainsString('?foo=bar&rhy[0]=mix&rhy[1]=xe', urldecode(getNotEncodedUrl(['foo' => 'bar', 'rhy' => ['mix', 'xe']]))); + $this->assertStringContainsString('?foo=bar&rhy[x]=mix&rhy[y]=xe', urldecode(getNotEncodedUrl(['foo' => 'bar', 'rhy' => ['x' => 'mix', 'y' => 'xe']]))); + $this->assertStringContainsString('?foo=bar&rhy[x][0]=mix&rhy[x][1]=xe', urldecode(getNotEncodedUrl(['foo' => 'bar', 'rhy' => ['x' => ['mix', 'xe']]]))); } public function testIsSiteID()