Merge branch 'rhymix:master' into master

This commit is contained in:
Lastorder 2026-05-12 14:16:40 +09:00 committed by GitHub
commit 1eab5cdfab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 456 additions and 394 deletions

View file

@ -36,7 +36,7 @@
var content = textNode.nodeValue;
var dummy = $('<span>');
content = content.replace(/</g, '&lt;').replace(/>/g, '&gt;');
content = content.escape();
content = content.replace(url_regex, function(match, p1, offset, string) {
var match;
var suffix = '';
@ -107,5 +107,5 @@
$this.attr("target", "_blank");
}
});
})(jQuery);

View file

@ -182,7 +182,7 @@ var initPhotoSwipeFromDOM = function(gallerySelector) {
captionEl.children[0].innerText = '';
return false;
}
captionEl.children[0].innerHTML = item.title;
captionEl.children[0].innerText = item.title;
return true;
},

View file

@ -347,7 +347,6 @@ class Context
if (self::$_current_request->getRouteOption('enable_session'))
{
session_cache_limiter('');
Rhymix\Framework\Session::checkSSO($site_module_info);
Rhymix\Framework\Session::start(false);
}
if (self::$_current_request->getRouteOption('cache_control'))

View file

@ -1355,6 +1355,7 @@ class ModuleHandler extends Handler
catch (Rhymix\Framework\Exception $e)
{
$output = new BaseObject(-2, $e->getMessage());
$output->add('rx_error_location', $e->getUserFileAndLine());
}
if ($trigger_name !== 'common.flushDebugInfo')
@ -1391,6 +1392,7 @@ class ModuleHandler extends Handler
catch (Rhymix\Framework\Exception $e)
{
$output = new BaseObject(-2, $e->getMessage());
$output->add('rx_error_location', $e->getUserFileAndLine());
}
if ($trigger_name !== 'common.writeSlowlog')

View file

@ -210,9 +210,10 @@ class ModuleObject extends BaseObject
return;
}
// Set admin layout
if(preg_match('/^disp[A-Z][a-z0-9\_]+Admin/', $this->act))
// Special treatment for admin actions
if(preg_match('/^disp(?:Admin[A-Z]|[A-Z][a-z0-9\_]+Admin)/', $this->act))
{
// Set admin layout
if(config('view.manager_layout') === 'admin')
{
$this->setLayoutPath('modules/admin/tpl');
@ -223,6 +224,16 @@ class ModuleObject extends BaseObject
$oTemplate = new Rhymix\Framework\Template('modules/admin/tpl', '_admin_common.html');
$oTemplate->compile();
}
// Refresh session
if (!isset($_SESSION['RHYMIX']['admin_accessed']) && !headers_sent())
{
if (!isset($_SESSION['RHYMIX']['last_refresh']) || $_SESSION['RHYMIX']['last_refresh'] < time() - 10)
{
$_SESSION['RHYMIX']['admin_accessed'] = \RX_TIME;
Rhymix\Framework\Session::refresh(true);
}
}
}
// Execute init
@ -805,6 +816,10 @@ class ModuleObject extends BaseObject
{
$this->setError($triggerOutput->getError());
$this->setMessage($triggerOutput->getMessage());
if ($triggerOutput->get('rx_error_location'))
{
$this->add('rx_error_location', $triggerOutput->get('rx_error_location'));
}
return FALSE;
}
@ -846,6 +861,10 @@ class ModuleObject extends BaseObject
{
$this->setError($triggerOutput->getError());
$this->setMessage($triggerOutput->getMessage());
if ($triggerOutput->get('rx_error_location'))
{
$this->add('rx_error_location', $triggerOutput->get('rx_error_location'));
}
return false;
}

View file

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

View file

@ -150,7 +150,6 @@ return array(
'regexp' => '',
],
'use_rewrite' => true,
'use_sso' => false,
'other' => [
'proxy' => null,
],

View file

@ -19,6 +19,11 @@ class Exception extends \Exception
public function getUserFileAndLine(): string
{
$regexp = '!^' . preg_quote(\RX_BASEDIR, '!') . '(?:classes|common)/!';
if (!preg_match($regexp, $this->getFile()))
{
return $this->getFile() . ':' . $this->getLine();
}
$trace = $this->getTrace();
foreach ($trace as $frame)
{

View file

@ -79,7 +79,7 @@ class Session
ini_set('session.use_cookies', 1);
ini_set('session.use_only_cookies', 1);
ini_set('session.use_strict_mode', 1);
ini_set('session.cookie_samesite', $samesite ? 1 : 0);
ini_set('session.cookie_samesite', $samesite);
session_set_cookie_params($lifetime, $path, $domain, $secure, $httponly);
session_name($session_name = Config::get('session.name') ?: session_name());
@ -113,7 +113,11 @@ class Session
}
// Check if the session needs to be refreshed.
if (!$must_create && !isset($_SESSION['RHYMIX']['domains'][$alt_domain]['started']) || $_SESSION['RHYMIX']['domains'][$alt_domain]['started'] < time() - $refresh_interval)
if (!$must_create && (!isset($_SESSION['RHYMIX']['last_refresh']) || $_SESSION['RHYMIX']['last_refresh'] < time() - $refresh_interval))
{
$must_refresh = true;
}
if (!$must_create && isset($_SESSION['RHYMIX']['next_refresh']) && $_SESSION['RHYMIX']['next_refresh'] === true)
{
$must_refresh = true;
}
@ -127,8 +131,9 @@ class Session
}
// If this is not a GET request, do not refresh now.
if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'GET')
if ($must_refresh && (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'GET'))
{
$_SESSION['RHYMIX']['next_refresh'] = true;
$must_refresh = false;
}
@ -237,107 +242,6 @@ class Session
}
}
/**
* Check if this session needs to be shared with another site with SSO.
*
* This method uses more or less the same logic as XE's SSO mechanism.
* It may need to be changed to a more secure mechanism later.
*
* @param object $site_module_info
* @return void
*/
public static function checkSSO(object $site_module_info): void
{
// Abort if SSO is disabled, the visitor is a robot, or this is not a typical GET request.
if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'GET' || !config('use_sso') || UA::isRobot() || in_array(\Context::get('act'), array('rss', 'atom')))
{
return;
}
// Get the current site information.
$is_default_domain = ($site_module_info->domain_srl == 0);
if (!$is_default_domain)
{
$current_domain = $site_module_info->domain;
$current_url = URL::getCurrentUrl();
$default_domain = \ModuleModel::getDefaultDomainInfo();
$default_url = \Context::getDefaultUrl($default_domain);
}
// Step 1: if the current site is not the default site, send SSO validation request to the default site.
if(!$is_default_domain && !\Context::get('sso_response') && $_COOKIE['sso'] !== md5($current_domain))
{
// Set sso cookie to prevent multiple simultaneous SSO validation requests.
Cookie::set('sso', md5($current_domain), array(
'expires' => 0,
'path' => '/',
'domain' => null,
'secure' => !!config('session.use_ssl'),
'httponly' => true,
'samesite' => config('session.samesite'),
));
// Redirect to the default site.
$sso_request = Security::encrypt($current_url);
header('Location:' . URL::modifyURL($default_url, array('sso_request' => $sso_request)));
exit;
}
// Step 2: receive and process SSO validation request at the default site.
if($is_default_domain && \Context::get('sso_request'))
{
// Get the URL of the origin site
$sso_request = Security::decrypt(\Context::get('sso_request'));
if (!$sso_request || !preg_match('!^https?://!', $sso_request))
{
\Context::displayErrorPage('SSO Error', 'ERR_INVALID_SSO_REQUEST', 400);
exit;
}
if (!URL::isInternalUrl($sso_request) || !URL::isInternalURL($_SERVER['HTTP_REFERER'] ?? ''))
{
\Context::displayErrorPage('SSO Error', 'ERR_INVALID_SSO_REQUEST', 400);
exit;
}
// Encrypt the session ID.
self::start(true);
$sso_response = Security::encrypt(session_id());
// Redirect back to the origin site.
header('Location: ' . URL::modifyURL($sso_request, array('sso_response' => $sso_response)));
self::close();
exit;
}
// Step 3: back at the origin site, set session ID to be the same as the default site.
if(!$is_default_domain && \Context::get('sso_response'))
{
// Check SSO response
$sso_response = Security::decrypt(\Context::get('sso_response'));
if ($sso_response === false)
{
\Context::displayErrorPage('SSO Error', 'ERR_INVALID_SSO_RESPONSE', 400);
exit;
}
// Check that the response was given by the default site (to prevent session fixation CSRF).
if(isset($_SERVER['HTTP_REFERER']) && !URL::isInternalURL($_SERVER['HTTP_REFERER']))
{
\Context::displayErrorPage('SSO Error', 'ERR_INVALID_SSO_RESPONSE', 400);
exit;
}
// Set the session ID.
session_id($sso_response);
self::start(true, false);
// Finally, redirect to the originally requested URL.
header('Location: ' . URL::getCurrentURL(array('sso_response' => null)));
self::close();
exit;
}
}
/**
* Create the data structure for a new Rhymix session.
*
@ -351,6 +255,8 @@ class Session
$_SESSION['RHYMIX'] = array();
$_SESSION['RHYMIX']['login'] = false;
$_SESSION['RHYMIX']['last_login'] = false;
$_SESSION['RHYMIX']['last_refresh'] = time();
$_SESSION['RHYMIX']['next_refresh'] = false;
$_SESSION['RHYMIX']['autologin_key'] = false;
$_SESSION['RHYMIX']['ipaddress'] = $_SESSION['ipaddress'] = \RX_CLIENT_IP;
$_SESSION['RHYMIX']['useragent'] = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '';
@ -420,7 +326,10 @@ class Session
);
// Update the domain initialization timestamp.
$_SESSION['RHYMIX']['domains'][$alt_domain]['started'] = time();
if (!isset($_SESSION['RHYMIX']['domains'][$alt_domain]['started']))
{
$_SESSION['RHYMIX']['domains'][$alt_domain]['started'] = time();
}
if (!isset($_SESSION['RHYMIX']['domains'][$alt_domain]['trusted']))
{
$_SESSION['RHYMIX']['domains'][$alt_domain]['trusted'] = 0;
@ -429,8 +338,11 @@ class Session
// Refresh the main session cookie and the autologin key.
if ($refresh_cookie)
{
$_SESSION['RHYMIX']['last_refresh'] = time();
$_SESSION['RHYMIX']['next_refresh'] = false;
self::destroyCookiesFromConflictingDomains(array(session_name()));
Cookie::set(session_name(), session_id(), $options);
//Cookie::set(session_name(), session_id(), $options);
session_regenerate_id(true);
if (self::$_autologin_key = self::_getAutologinKey())
{
self::setAutologinKeys(substr(self::$_autologin_key, 0, 24), substr(self::$_autologin_key, 24, 24));
@ -534,7 +446,7 @@ class Session
if ($refresh)
{
self::checkLoginStatusCookie();
return self::refresh(true);
return $_SESSION['RHYMIX']['next_refresh'] = true;
}
else
{
@ -1048,7 +960,7 @@ class Session
$path = Config::get('session.path') ?: ini_get('session.cookie_path');
$secure = (\RX_SSL && config('session.use_ssl')) ? true : false;
$httponly = Config::get('session.httponly') ?? true;
$samesite = config('session.samesite');
$samesite = config('session.samesite') ?: '';
return array($lifetime, $refresh, $domain, $path, $secure, $httponly, $samesite);
}

View file

@ -873,6 +873,45 @@ class Storage
}
}
/**
* Prevent access to a directory by creating .htaccess and index.html files in it.
*
* This is a best-effort measure only, and depends on web server configuration.
* It is recommended to use proper server configuration to protect sensitive directories.
*
* @param string $dirname
* @return bool
*/
public static function protectDirectory(string $dirname): bool
{
$dirname = rtrim($dirname, '/\\');
if (!self::isDirectory($dirname) || !self::isWritable($dirname))
{
return false;
}
$result = self::write($dirname . '/index.html', '');
if (!$result)
{
return false;
}
$result = self::write($dirname . '/.htaccess', preg_replace('/\\t/', '', <<<END
<IfModule authz_core_module>
Require all denied
</IfModule>
<IfModule !authz_core_module>
Order deny,allow
Deny from all
</IfModule>
END));
if (!$result)
{
return false;
}
return true;
}
/**
* Get the current umask.
*

View file

@ -51,28 +51,41 @@ class FileContentFilter
}
// Check other image files.
if (in_array($ext, array('jpg', 'jpeg', 'png', 'gif')) && $mime_type !== false && $mime_type !== 'image')
if (preg_match('/^(jpe?g|png|gif|webp)$/', $ext))
{
fclose($fp);
return false;
if ($mime_type !== false && $mime_type !== 'image')
{
fclose($fp);
return false;
}
if ($mime_type === 'image' && $is_xml && $image_info = @getimagesize($file))
{
if ($image_info[0] && $image_info[1] && !empty($image_info[2]))
{
$skip_xml = true;
}
}
}
// Check audio and video files.
if (preg_match('/(wm[va]|mpe?g|avi|flv|mp[1-4]|as[fx]|wav|midi?|moo?v|qt|r[am]{1,2}|m4v)$/', $file) && $mime_type !== false && $mime_type !== 'audio' && $mime_type !== 'video')
if (preg_match('/^(wm[va]|mpe?g|avi|flv|mp[1-4]|as[fx]|wav|midi?|moo?v|qt|r[am]{1,2}|m4v)$/', $ext))
{
fclose($fp);
return false;
if ($mime_type !== false && $mime_type !== 'audio' && $mime_type !== 'video')
{
fclose($fp);
return false;
}
}
// Check XML files.
if (($ext === 'xml' || $is_xml) && !self::_checkXML($fp, 0, $filesize))
if (($ext === 'xml' || ($is_xml && !$skip_xml)) && !self::_checkXML($fp, 0, $filesize))
{
fclose($fp);
return false;
}
// Check HTML files.
if (($ext === 'html' || $ext === 'shtml' || $ext === 'xhtml' || $ext === 'phtml' || ($is_xml && !$skip_xml)) && !self::_checkHTML($fp, 0, $filesize))
if ((preg_match('/html$/', $ext) || ($is_xml && !$skip_xml)) && !self::_checkHTML($fp, 0, $filesize))
{
fclose($fp);
return false;

View file

@ -242,7 +242,6 @@ class ConfigParser
$config['file']['umask'] = Storage::recommendUmask();
$config['mobile']['enabled'] = ($db_info->use_mobile_view ?? 'N') === 'N' ? false : true;
$config['use_rewrite'] = ($db_info->use_rewrite ?? 'N') === 'Y' ? true : false;
$config['use_sso'] = ($db_info->use_sso ?? 'N') === 'Y' ? true : false;
// Copy other configuration.
unset($db_info->master_db, $db_info->slave_db);

View file

@ -61,7 +61,7 @@ class addonAdminModel extends addon
else
{
$package = $oAutoinstallModel->getInstalledPackages($packageSrl);
$addonList[$key]->need_update = $package[$packageSrl]->need_update;
$addonList[$key]->need_update = $package[$packageSrl]->need_update ?? null;
}
// get easyinstall update url

View file

@ -58,7 +58,7 @@
<block cond="count($addon_info->extra_vars ?: [])">
<block loop="$addon_info->extra_vars => $id, $var">
<block cond="$group != $var->group">
<block cond="!empty($group) && $group != $var->group">
<h2 style="margin-top:50px;">{$var->group}</h2>
{@$group = $var->group}
</block>
@ -103,9 +103,9 @@
<block loop="$mid_list => $module_category_srl, $modules">
<!--@if(is_array($mid_list) && count($mid_list) > 1)-->
<fieldset>
<legend><!--@if($modules->title)-->{$modules->title}<!--@else-->{$lang->none_category}<!--@end--></legend>
<legend><!--@if(!empty($modules->title))-->{$modules->title}<!--@else-->{$lang->none_category}<!--@end--></legend>
<!--@end-->
<label loop="$modules->list => $key, $val">
<label cond="!empty($modules->list)" loop="$modules->list => $key, $val">
<input type="checkbox" value="{$key}" name="mid_list[]" id="chk_mid_list_{$key}" checked="checked"|cond="in_array($key, $addon_info->mid_list ?? [])"/>
{$key} ({$val->browser_title})
</label>

View file

@ -76,7 +76,7 @@ class Advanced extends Base
// Thumbnail settings
$oDocumentModel = getModel('document');
$config = $oDocumentModel->getDocumentConfig();
Context::set('thumbnail_target', $config->thumbnail_target ?: 'all');
Context::set('thumbnail_target', $config->thumbnail_target ?: 'attachment');
Context::set('thumbnail_type', $config->thumbnail_type ?: 'fill');
Context::set('thumbnail_quality', $config->thumbnail_quality ?: 75);
if ($config->thumbnail_type === 'none')
@ -190,7 +190,7 @@ class Advanced extends Base
// Thumbnail settings
$oDocumentModel = getModel('document');
$document_config = $oDocumentModel->getDocumentConfig();
$document_config->thumbnail_target = $vars->thumbnail_target ?: 'all';
$document_config->thumbnail_target = $vars->thumbnail_target ?: 'attachment';
$document_config->thumbnail_type = $vars->thumbnail_type ?: 'fill';
$document_config->thumbnail_quality = intval($vars->thumbnail_quality) ?: 75;
$oModuleController = getController('module');

View file

@ -205,7 +205,6 @@ class Domains extends Base
// Save system config.
Config::set('url.unregistered_domain_action', $vars->unregistered_domain_action);
Config::set('use_sso', $vars->use_sso === 'Y');
if (!Config::save())
{
throw new Exception('msg_failed_to_save_config');

View file

@ -116,7 +116,7 @@ $lang->cmd_multidomain_configuration = 'Multidomain Configuration';
$lang->cmd_unregistered_domain_action = 'Unconfigured Domains';
$lang->cmd_unregistered_domain_redirect_301 = '301 Redirect to Default Domain (Recommended)';
$lang->cmd_unregistered_domain_redirect_302 = '302 Redirect to Default Domain';
$lang->cmd_unregistered_domain_display = 'Display Main Screen as Usual';
$lang->cmd_unregistered_domain_display = 'Allow Access (Not Recommended)';
$lang->cmd_unregistered_domain_block = '404 Not Found';
$lang->cmd_delete_domain = 'Would you like to delete this domain?';
$lang->about_use_ssl = 'Caution: an SSL certificate must be installed before activating this setting. Otherwise you may be unable to access your site afterward.';
@ -338,8 +338,9 @@ $lang->mobile_viewport = 'Mobile viewport Setting';
$lang->about_mobile_viewport = 'The settings entered above will be output in a &lt;meta name=&quot;viewport&quot;&gt; tag for mobile visitors.';
$lang->restore_default_viewport = 'Restore Default';
$lang->thumbnail_target = 'Extract Thumbnail From';
$lang->thumbnail_target_all = 'All images';
$lang->thumbnail_target_content = 'All embedded images, including external images';
$lang->thumbnail_target_attachment = 'Attached images only';
$lang->about_thumbnail_target = 'Using external images to generate thumbnails may cause various issues with respect to page load speed, copyright, and security. Please be careful.';
$lang->thumbnail_type = 'Thumbnail Type';
$lang->input_header_script = 'Header Script';
$lang->detail_input_header_script = 'Content added here will be printed at the top of every page, except the admin module.';
@ -370,8 +371,6 @@ $lang->site_default_color_scheme_options = array(
'light' => 'Light mode only',
'dark' => 'Dark mode only',
);
$lang->use_sso = 'Use <abbr title="Single Sign On">SSO</abbr>?';
$lang->about_use_sso = 'Logging into one domain will automatically log the user into all domains.<br>Do not rely on this feature, as it will be removed in the future.';
$lang->about_arrange_session = 'Do you want to clean up old session data?';
$lang->cmd_clear_session = 'Session cleanup';
$lang->save = 'Save';

View file

@ -41,7 +41,6 @@ $lang->about_server_ports = '80 de HTTP, HTTPS al puerto 443 si se utiliza otro
$lang->about_db_session = 'This setting will use PHP session used for authentication as DB. For the Websites which do not use web server frequently, you can uncheck this setting to improve response time. However, session DB will make it difficult to get current users, so you cannot use related functions.';
$lang->trash = 'Basura';
$lang->timezone = 'Huso horario';
$lang->about_use_sso = 'SSO will enable users to sign in just once for both default and virtual site. You will need this only if you are using virtual sites.';
$lang->modify = 'Modificar';
$lang->ftp_form_title = 'Datos de conexión para FTP';
$lang->ftp = 'FTP';

View file

@ -43,7 +43,6 @@ $lang->thumbnail_crop = 'Rogner';
$lang->thumbnail_ratio = 'Proportion';
$lang->trash = 'Poubelle';
$lang->timezone = 'Fuseau horaire';
$lang->about_use_sso = 'SSO will enable users to sign in just once for both default and virtual site. You will need this only if you are using virtual sites.';
$lang->modify = 'Modifier';
$lang->ftp = 'FTP';
$lang->msg_ftp_no_directory = 'Succeed to connect to the host via FTP. However, can not read any directory list informaiton. Check the server configurations.';

View file

@ -138,8 +138,6 @@ $lang->allow_use_favicon = 'ファビコン設定';
$lang->about_use_favicon = '16 x 16 サイズの<em>*.ico</em> ファイルのみ登録できます。';
$lang->allow_use_mobile_icon = '待受画面のアイコン設定';
$lang->detail_use_mobile_icon = '57 x 57 、または114 x 114 サイズの<em>*.png</em> ファイルのみ登録できます。';
$lang->use_sso = '<abbr title="Single Sign On">SSO</abbr>を使用';
$lang->about_use_sso = 'ユーザが一度のログインで基本サイトと仮想サイトに同時にログインされる機能です。 仮想サイトの機能を使用してない場合、設定する必要がありません。';
$lang->about_arrange_session = 'セッションを整理しますか?';
$lang->cmd_clear_session = 'セッション整理';
$lang->save = '保存';

View file

@ -115,7 +115,7 @@ $lang->cmd_multidomain_configuration = '멀티도메인 기능 설정';
$lang->cmd_unregistered_domain_action = '설정하지 않은 도메인 처리';
$lang->cmd_unregistered_domain_redirect_301 = '기본 도메인으로 301 Redirect (권장)';
$lang->cmd_unregistered_domain_redirect_302 = '기본 도메인으로 302 Redirect';
$lang->cmd_unregistered_domain_display = '메인 화면 표시';
$lang->cmd_unregistered_domain_display = '접속 허용 (권장하지 않음)';
$lang->cmd_unregistered_domain_block = '404 Not Found 오류 표시';
$lang->cmd_delete_domain = '이 도메인을 삭제하시겠습니까?';
$lang->about_use_ssl = 'SSL 인증서가 설치되지 않은 상태에서 HTTPS 사용을 시도하면 접속이 되지 않을 수 있으니 주의하시기 바랍니다.';
@ -334,8 +334,9 @@ $lang->mobile_viewport = '모바일 viewport 설정';
$lang->about_mobile_viewport = '모바일 접속시 여기 입력한 내용이 &lt;meta name=&quot;viewport&quot;&gt; 태그로 출력됩니다.';
$lang->restore_default_viewport = '기본값 복원';
$lang->thumbnail_target = '썸네일 생성 대상';
$lang->thumbnail_target_all = '모든 이미지 (외부 이미지 포함)';
$lang->thumbnail_target_content = '모든 이미지 (외부 이미지 포함)';
$lang->thumbnail_target_attachment = '첨부된 이미지';
$lang->about_thumbnail_target = '외부 이미지를 사용하여 썸네일을 생성하는 경우 페이지 로딩 속도, 저작권, 보안 등 다양한 문제가 발생할 수 있으니 주의하시기 바랍니다.';
$lang->thumbnail_type = '썸네일 생성 방식';
$lang->input_header_script = '상단(헤더) 스크립트';
$lang->detail_input_header_script = '모든 페이지의 최상단에 코드를 삽입합니다. 관리자 화면에는 적용되지 않습니다.';
@ -366,8 +367,6 @@ $lang->site_default_color_scheme_options = array(
'light' => '밝은 색상 고정',
'dark' => '어두운 색상 고정',
);
$lang->use_sso = '<abbr title="Single Sign On">SSO</abbr> 사용';
$lang->about_use_sso = '한 번만 로그인하면 모든 도메인에 로그인되도록 합니다.<br>이 기능은 폐기 예정이니 의존하지 마시기 바랍니다.';
$lang->about_arrange_session = '세션을 정리하시겠습니까?';
$lang->cmd_clear_session = '세션 정리';
$lang->save = '저장';

View file

@ -100,8 +100,6 @@ $lang->allow_use_favicon = 'Favicon\'u kullanmak istiyor musunuz?';
$lang->about_use_favicon = '16 x 16 boyutunda<em>*.Ico</em> dosyalar yüklenebilir.';
$lang->allow_use_mobile_icon = 'Mobil Ana Ekran Simgesini kullanmak istiyor musunuz?';
$lang->detail_use_mobile_icon = '57 x 57 veya 114 x 114 boyutu<em>*.Png</em> dosyalar yüklenebilir.';
$lang->use_sso = '<abbr title="Single Sign On">SSO</abbr> kullanmak ister misiniz?';
$lang->about_use_sso = 'SSO kullanıcıları, geçreli ya da sanal siteye bir kere kayıt olmakla, ikisinden de yararlandıracaktır. Bu, size sadece sanal websiteler kullandığınız durumda lazım olacaktır.';
$lang->about_arrange_session = 'Bu oturumu temizlemek istiyor musunuz?';
$lang->cmd_clear_session = 'Oturum temizleme';
$lang->save = 'Kayıt';

View file

@ -96,7 +96,6 @@ $lang->allow_use_favicon = '是否启用自定义favicon?';
$lang->about_use_favicon = '请上传16*16像素的<em>*.ico</em>文件.';
$lang->allow_use_mobile_icon = '是否启用移动版屏幕图标?';
$lang->detail_use_mobile_icon = '请上传57*57像素或者114*114像素的<em>*.png</em>文件.';
$lang->use_sso = '是否启用通行证<abbr title="Single Sign On">SSO</abbr>?';
$lang->about_arrange_session = '你想要整理session吗';
$lang->cmd_clear_session = '整理sessioon';
$lang->save = '保存';

View file

@ -58,7 +58,6 @@ $lang->thumbnail_crop = '裁切';
$lang->thumbnail_ratio = '比例';
$lang->trash = '垃圾';
$lang->timezone = '時區';
$lang->about_use_sso = '此功能可讓用戶只需登入一次即可訪問多個網站。 使用虛擬網站,這將會是很重要的功能。';
$lang->move = '搬移';
$lang->modify = '修改';
$lang->restore = '復原';

View file

@ -66,7 +66,7 @@ class AdminMenu
$lang = \Rhymix\Framework\Cache::get('admin_menu_langs:' . Context::getLangType());
}
if ($lang === null)
if ($lang === null || !is_array($lang))
{
$lang = [];
$installed_module_list = ModuleModel::getModulesXmlInfo();

View file

@ -126,18 +126,19 @@
<div class="x_control-group">
<label class="x_control-label">{$lang->thumbnail_target}</label>
<div class="x_controls">
<label for="thumbnail_target_all" class="x_inline">
<input type="radio" name="thumbnail_target" id="thumbnail_target_all" value="all" checked="checked"|cond="$thumbnail_target == 'all' || !$thumbnail_target" />
{$lang->thumbnail_target_all}
</label>
<label for="thumbnail_target_attachment" class="x_inline">
<input type="radio" name="thumbnail_target" id="thumbnail_target_attachment" value="attachment" checked="checked"|cond="$thumbnail_target == 'attachment'" />
<input type="radio" name="thumbnail_target" id="thumbnail_target_attachment" value="attachment" checked="checked"|cond="$thumbnail_target == 'attachment' || !$thumbnail_target" />
{$lang->thumbnail_target_attachment}
</label>
<label for="thumbnail_target_content" class="x_inline">
<input type="radio" name="thumbnail_target" id="thumbnail_target_content" value="content" checked="checked"|cond="$thumbnail_target == 'content'" />
{$lang->thumbnail_target_content}
</label>
<label for="thumbnail_target_none" class="x_inline">
<input type="radio" name="thumbnail_target" id="thumbnail_target_none" value="none" checked="checked"|cond="$thumbnail_target == 'none'" />
{$lang->thumbnail_none}
</label>
<p class="x_help-block">{$lang->about_thumbnail_target}</p>
</div>
</div>
<div class="x_control-group">

View file

@ -6,7 +6,7 @@
<p>{$XE_VALIDATOR_MESSAGE}</p>
</div>
<section class="section">
<table id="domain_list" class="x_table x_table-striped x_table-hover dsTg">
<caption>
<div class="x_pull-right x_btn-group">
@ -103,15 +103,6 @@
</label>
</div>
</div>
<div class="x_control-group">
<label class="x_control-label">{$lang->use_sso}</label>
<div class="x_controls">
<label for="use_sso_y" class="x_inline"><input type="radio" name="use_sso" id="use_sso_y" value="Y" checked="checked"|cond="config('use_sso')" /> {$lang->cmd_yes}</label>
<label for="use_sso_n" class="x_inline"><input type="radio" name="use_sso" id="use_sso_n" value="N" checked="checked"|cond="!config('use_sso')" /> {$lang->cmd_no}</label>
<br />
<p class="x_help-block">{$lang->about_use_sso}</p>
</div>
</div>
<div class="x_clearfix btnArea">
<div class="x_pull-right">
<button type="submit" class="x_btn x_btn-primary">{$lang->cmd_save}</button>

View file

@ -397,7 +397,7 @@ class autoinstallModel extends autoinstall
}
$ftp_info = Context::getFTPInfo();
if(!$ftp_info->ftp_root_path)
if(empty($ftp_info->ftp_root_path))
{
return;
}

View file

@ -141,7 +141,7 @@ class BoardView extends Board
*/
if(!$this->grant->access || !$this->grant->list)
{
$this->dispBoardMessage($this->user->isMember() ? 'msg_not_permitted' : 'msg_not_logged');
return $this->dispBoardMessage($this->user->isMember() ? 'msg_not_permitted' : 'msg_not_logged');
}
/**

View file

@ -924,28 +924,34 @@ class CommentItem extends BaseObject
$target_src = Context::getRequestUri().$target_src;
}
$tmp_file = sprintf('./files/cache/tmp/%d', md5(rand(111111, 999999) . $this->comment_srl));
if(!is_dir('./files/cache/tmp'))
$tmp_file = sprintf('./files/cache/tmp/%s', Rhymix\Framework\Security::getRandom(32));
if (!Rhymix\Framework\Storage::exists(\RX_BASEDIR . 'files/cache/tmp'))
{
FileHandler::makeDir('./files/cache/tmp');
Rhymix\Framework\Storage::createDirectory(\RX_BASEDIR . 'files/cache/tmp');
}
if (!Rhymix\Framework\Storage::exists(\RX_BASEDIR . 'files/cache/tmp/.htaccess'))
{
Rhymix\Framework\Storage::protectDirectory(\RX_BASEDIR . 'files/cache/tmp');
}
FileHandler::getRemoteFile($target_src, $tmp_file);
if(!file_exists($tmp_file))
if (!Rhymix\Framework\Storage::exists($tmp_file))
{
continue;
}
else
{
if($is_img = @getimagesize($tmp_file))
if ($is_img = @getimagesize($tmp_file))
{
list($_w, $_h, $_t, $_a) = $is_img;
if($_w < ($external_image_min_width) && ($height === 'auto' || $_h < ($external_image_min_height)))
{
Rhymix\Framework\Storage::delete($tmp_file);
continue;
}
}
else
{
Rhymix\Framework\Storage::delete($tmp_file);
continue;
}
$source_file = $tmp_file;

View file

@ -27,6 +27,7 @@ class CommunicationController extends communication
}
$args = new stdClass();
$args->member_srl = $this->user->member_srl;
$args->allow_message = Context::get('allow_message');
if(!in_array($args->allow_message, array('Y', 'N', 'F')))
@ -34,7 +35,11 @@ class CommunicationController extends communication
$args->allow_message = 'Y';
}
$args->member_srl = $this->user->member_srl;
$config = CommunicationModel::getConfig();
if ($config->enable_friend !== 'Y' && $args->allow_message === 'F')
{
$args->allow_message = 'Y';
}
$output = executeQuery('communication.updateAllowMessage', $args);
if(!$output->toBool())
@ -117,7 +122,14 @@ class CommunicationController extends communication
{
if(!$oCommunicationModel->isFriend($receiver_member_info->member_srl))
{
throw new Rhymix\Framework\Exception('msg_allow_message_to_friend');
if ($config->enable_friend === 'Y')
{
throw new Rhymix\Framework\Exception('msg_allow_message_to_friend');
}
else
{
throw new Rhymix\Framework\Exception('msg_disallow_message');
}
}
}
else if($receiver_member_info->allow_message == 'N')

View file

@ -127,6 +127,13 @@ class CommunicationView extends communication
$page = max(1, intval(Context::get('page')));
$output = $oCommunicationModel->getMessages($message_type, $columnList, $search_target, $search_keyword, $page);
$allow_message_type = lang('allow_message_type')->getArrayCopy();
if ($this->config->allow_friend === 'N')
{
unset($allow_message_type['F']);
}
Context::set('allow_message_type', $allow_message_type);
// set a template file
Context::set('total_count', $output->total_count);
Context::set('total_page', $output->total_page);

View file

@ -15,7 +15,7 @@
<input type="hidden" name="act" value="procCommunicationUpdateAllowMessage" />
<input type="hidden" name="message_type" value="{$message_type}" />
<select name="allow_message" style="margin:0">
<option loop="$lang->allow_message_type => $key,$val" value="{$key}" selected="selected"|cond="$logged_info->allow_message==$key">{$val}</option>
<option loop="$allow_message_type => $key,$val" value="{$key}" selected="selected"|cond="$logged_info->allow_message==$key">{$val}</option>
</select>
<input type="submit" value="{$lang->cmd_save}" class="btn">
</form>

View file

@ -2557,12 +2557,24 @@ class DocumentController extends Document
$obj->list_order = $obj->category_srl = getNextSequence();
}
$output = ModuleHandler::triggerCall('document.insertCategory', 'before', $obj);
if (!$output->toBool())
{
return $output;
}
$output = executeQuery('document.insertCategory', $obj);
if($output->toBool())
if ($output->toBool())
{
$output->add('category_srl', $obj->category_srl);
$this->makeCategoryFile($obj->module_srl);
}
else
{
return $output;
}
ModuleHandler::triggerCall('document.insertCategory', 'after', $obj);
return $output;
}
@ -2606,7 +2618,10 @@ class DocumentController extends Document
$args->category_srl = $category_srl;
$args->{$mode} = $document_count;
$output = executeQuery('document.updateCategoryCount', $args);
if($output->toBool()) $this->makeCategoryFile($module_srl);
if ($output->toBool())
{
$this->makeCategoryFile($module_srl);
}
return $output;
}
@ -2618,8 +2633,23 @@ class DocumentController extends Document
*/
function updateCategory($obj)
{
$output = ModuleHandler::triggerCall('document.updateCategory', 'before', $obj);
if (!$output->toBool())
{
return $output;
}
$output = executeQuery('document.updateCategory', $obj);
if($output->toBool()) $this->makeCategoryFile($obj->module_srl);
if ($output->toBool())
{
$this->makeCategoryFile($obj->module_srl);
}
else
{
return $output;
}
ModuleHandler::triggerCall('document.updateCategory', 'after', $obj);
return $output;
}
@ -2633,43 +2663,53 @@ class DocumentController extends Document
$args = new stdClass();
$args->category_srl = $category_srl;
$category_info = DocumentModel::getCategory($category_srl);
// Display an error that the category cannot be deleted if it has a child
// Check if the category has any children.
$output = executeQuery('document.getChildCategoryCount', $args);
if(!$output->toBool()) return $output;
if($output->data->count>0) return new BaseObject(-1, 'msg_cannot_delete_for_child');
// Delete a category information
$output = executeQuery('document.deleteCategory', $args);
if(!$output->toBool()) return $output;
$this->makeCategoryFile($category_info->module_srl);
// remove cache
$page = 0;
while(true)
if (!$output->toBool())
{
$args = new stdClass();
$args->category_srl = $category_srl;
$args->list_count = 100;
$args->page = ++$page;
$output = executeQuery('document.getDocumentList', $args, array('document_srl'));
if($output->data == array())
{
break;
}
foreach($output->data as $val)
{
self::clearDocumentCache($val->document_srl);
}
return $output;
}
if ($output->data->count > 0)
{
return new BaseObject(-1, 'msg_cannot_delete_for_child');
}
// Update category_srl of the documents in the same category to 0
// Call trigger (before)
$output = ModuleHandler::triggerCall('document.deleteCategory', 'before', $args);
if (!$output->toBool())
{
return $output;
}
// Delete the category.
$output = executeQuery('document.deleteCategory', $args);
if ($output->toBool())
{
$this->makeCategoryFile($category_info->module_srl);
}
else
{
return $output;
}
// Documents in the deleted category will be moved to category 0.
$args = new stdClass();
$args->target_category_srl = 0;
$args->source_category_srl = $category_srl;
$output = executeQuery('document.updateDocumentCategory', $args);
if ($output->toBool())
{
Rhymix\Framework\Cache::clearGroup('document_item');
$GLOBALS['XE_DOCUMENT_LIST'] = [];
}
else
{
return $output;
}
// Call trigger (after)
ModuleHandler::triggerCall('document.deleteCategory', 'after', $args);
return $output;
}

View file

@ -1257,28 +1257,34 @@ class DocumentItem extends BaseObject
$target_src = Context::getRequestUri().$target_src;
}
$tmp_file = sprintf('./files/cache/tmp/%d', md5(rand(111111,999999).$this->document_srl));
if(!is_dir('./files/cache/tmp'))
$tmp_file = sprintf('./files/cache/tmp/%s', Rhymix\Framework\Security::getRandom(32));
if (!Rhymix\Framework\Storage::exists(\RX_BASEDIR . 'files/cache/tmp'))
{
FileHandler::makeDir('./files/cache/tmp');
Rhymix\Framework\Storage::createDirectory(\RX_BASEDIR . 'files/cache/tmp');
}
if (!Rhymix\Framework\Storage::exists(\RX_BASEDIR . 'files/cache/tmp/.htaccess'))
{
Rhymix\Framework\Storage::protectDirectory(\RX_BASEDIR . 'files/cache/tmp');
}
FileHandler::getRemoteFile($target_src, $tmp_file);
if(!file_exists($tmp_file))
if (!Rhymix\Framework\Storage::exists($tmp_file))
{
continue;
}
else
{
if($is_img = @getimagesize($tmp_file))
if ($is_img = @getimagesize($tmp_file))
{
list($_w, $_h, $_t, $_a) = $is_img;
if($_w < ($external_image_min_width) && ($height === 'auto' || $_h < ($external_image_min_height)))
{
Rhymix\Framework\Storage::delete($tmp_file);
continue;
}
}
else
{
Rhymix\Framework\Storage::delete($tmp_file);
continue;
}
$source_file = $tmp_file;

View file

@ -147,7 +147,7 @@ class DocumentModel extends Document
/**
* Get a document.
*
*
* @param int $document_srl
* @param bool $is_admin
* @param bool $load_extra_vars
@ -179,7 +179,7 @@ class DocumentModel extends Document
/**
* Create a blank document.
*
*
* @param int $module_srl
* @return DocumentItem
*/
@ -690,37 +690,37 @@ class DocumentModel extends Document
$opt->isExtraVars = $sort_check->isExtraVars;
$opt->isExtraVarsSortAsNumber = $sort_check->isExtraVarsSortAsNumber;
$args = new stdClass();
self::_setSearchOption($opt, $args, $query_id, $use_division);
if($sort_check->isExtraVars || !$opt->list_count)
if ($sort_check->isExtraVars || !$opt->list_count)
{
return 1;
}
else
{
if($sort_check->sort_index === 'list_order' || $sort_check->sort_index === 'update_order')
$args->sort_index = preg_replace('/^documents\./', '', $args->sort_index ?? 'list_order');
if ($args->sort_index === 'list_order' || $args->sort_index === 'update_order')
{
if($args->order_type === 'desc')
if ($args->order_type === 'desc')
{
$args->{'rev_' . $sort_check->sort_index} = $oDocument->get($sort_check->sort_index);
$args->{'rev_' . $args->sort_index} = $oDocument->get($args->sort_index);
}
else
{
$args->{$sort_check->sort_index} = $oDocument->get($sort_check->sort_index);
$args->{$args->sort_index} = $oDocument->get($args->sort_index);
}
}
elseif($sort_check->sort_index === 'regdate')
elseif ($args->sort_index === 'regdate')
{
if($args->order_type === 'asc')
if ($args->order_type === 'asc')
{
$args->{'rev_' . $sort_check->sort_index} = $oDocument->get($sort_check->sort_index);
$args->{'rev_' . $args->sort_index} = $oDocument->get($args->sort_index);
}
else
{
$args->{$sort_check->sort_index} = $oDocument->get($sort_check->sort_index);
$args->{$args->sort_index} = $oDocument->get($args->sort_index);
}
}
else
{
@ -728,7 +728,6 @@ class DocumentModel extends Document
}
}
// Guhanhu total number of the article search page
$output = executeQuery($query_id . 'Page', $args);
$count = $output->data->count;
$page = (int)(($count-1)/$opt->list_count)+1;
@ -1037,9 +1036,9 @@ class DocumentModel extends Document
{
self::$_config = ModuleModel::getModuleConfig('document') ?: new stdClass;
}
if (!isset(self::$_config->thumbnail_target))
if (!isset(self::$_config->thumbnail_target) || self::$_config->thumbnail_target === 'all')
{
self::$_config->thumbnail_target = 'all';
self::$_config->thumbnail_target = 'attachment';
}
if (!isset(self::$_config->thumbnail_type))
{

View file

@ -21,6 +21,8 @@
<condition operation="more" column="documents.list_order" var="rev_list_order" filter="number" pipe="and" />
<condition operation="less" column="documents.update_order" var="update_order" filter="number" pipe="and" />
<condition operation="more" column="documents.update_order" var="rev_update_order" filter="number" pipe="and" />
<condition operation="less" column="documents.regdate" var="rev_regdate" filter="number" pipe="and" />
<condition operation="more" column="documents.regdate" var="regdate" filter="number" pipe="and" />
</group>
</conditions>
</query>

View file

@ -25,6 +25,8 @@
<condition operation="more" column="documents.list_order" var="rev_list_order" filter="number" pipe="and" />
<condition operation="less" column="documents.update_order" var="update_order" filter="number" pipe="and" />
<condition operation="more" column="documents.update_order" var="rev_update_order" filter="number" pipe="and" />
<condition operation="less" column="documents.regdate" var="rev_regdate" filter="number" pipe="and" />
<condition operation="more" column="documents.regdate" var="regdate" filter="number" pipe="and" />
</group>
</conditions>
</query>

View file

@ -19,6 +19,8 @@
<condition operation="more" column="documents.list_order" var="rev_list_order" filter="number" pipe="and" />
<condition operation="less" column="documents.update_order" var="update_order" filter="number" pipe="and" />
<condition operation="more" column="documents.update_order" var="rev_update_order" filter="number" pipe="and" />
<condition operation="less" column="documents.regdate" var="rev_regdate" filter="number" pipe="and" />
<condition operation="more" column="documents.regdate" var="regdate" filter="number" pipe="and" />
</group>
</conditions>
</query>

View file

@ -28,7 +28,7 @@
<div class="x_controls">
<span class="x_input-append">
<input type="hidden" name="target_module_srl" id="target_module" value="{$module_srl}" />
<input type="text" name="_target_module" id="_target_module" value="{$mid}({$browser_title})" readonly="readonly" />
<input type="text" name="_target_module" id="_target_module" value="{$browser_title} ({$mid})" readonly="readonly" />
<a href="{getUrl('','module','module','act','dispModuleSelectList','id','target_module','type','single')}" onclick="popopen(this.href,'ModuleSelect');return false;" class="x_btn">{$lang->cmd_select}</a>
</span>
</div>

View file

@ -54,15 +54,36 @@ xe.lang.msg_empty_search_keyword = '{$lang->msg_empty_search_keyword}';
<td class="title">
<!--@if(isset($module_list[$oDocument->get('module_srl')]))-->
<!--@if($module_list[$oDocument->get('module_srl')]->domain_srl == -1 || $module_list[$oDocument->get('module_srl')]->domain_srl == $site_module_info->domain_srl)-->
<a href="{$oDocument->getPermanentUrl()}" target="_blank"><!--@if(trim($oDocument->getTitleText()) !== '')-->{escape($oDocument->getTitleText(), false)}<!--@else--><em>{$lang->no_title_document}</em><!--@end--></a>
- <a href="{getUrl('', 'mid', $module_list[$oDocument->get('module_srl')]->mid)}" target="_blank">{$module_list[$oDocument->get('module_srl')]->browser_title}</a>
{@ $document_link = $oDocument->getPermanentUrl()}
{@ $module_link = getUrl('', 'mid', $module_list[$oDocument->get('module_srl')]->mid)}
<!--@else-->
<a href="{ModuleModel::getDomainByModuleSrl($oDocument->get('module_srl'))}{getUrl('', 'mid', $module_list[$oDocument->get('module_srl')]->mid, 'document_srl', $oDocument->document_srl)}" target="_blank"><!--@if(trim($oDocument->getTitleText()) !== '')-->{escape($oDocument->getTitleText(), false)}<!--@else--><em>{$lang->no_title_document}</em><!--@end--></a>
- <a href="{ModuleModel::getDomainByModuleSrl($oDocument->get('module_srl'))}{getUrl('', 'mid', $module_list[$oDocument->get('module_srl')]->mid)}" target="_blank">{$module_list[$oDocument->get('module_srl')]->browser_title}</a>
{@ $document_link = ModuleModel::getDomainByModuleSrl($oDocument->get('module_srl')) . getUrl('', 'mid', $module_list[$oDocument->get('module_srl')]->mid, 'document_srl', $oDocument->document_srl)}
{@ $module_link = ModuleModel::getDomainByModuleSrl($oDocument->get('module_srl')) . getUrl('', 'mid', $module_list[$oDocument->get('module_srl')]->mid)}
<!--@endif-->
<!--@else-->
<a href="{$oDocument->getPermanentUrl()}" target="_blank"><!--@if(trim($oDocument->getTitleText()) !== '')-->{escape($oDocument->getTitleText(), false)}<!--@else--><em>{$lang->no_title_document}</em><!--@end--></a>
{@ $document_link = $oDocument->getPermanentUrl()}
{@ $module_link = null}
<!--@endif-->
<!--@if($document_link && $oDocument->get('status') !== 'TEMP')-->
<a href="{$document_link}" target="_blank">
<!--@if(trim($oDocument->getTitleText()) !== '')-->
{escape($oDocument->getTitleText(), false)}
<!--@else-->
<em>{$lang->no_title_document}</em>
<!--@end-->
</a>
<!--@else-->
<span style="cursor:not-allowed">
<!--@if(trim($oDocument->getTitleText()) !== '')-->
{escape($oDocument->getTitleText(), false)}
<!--@else-->
<em>{$lang->no_title_document}</em>
<!--@end-->
</span>
<!--@endif-->
<!--@if($module_link)-->
- <a href="{$module_link}" target="_blank">{$module_list[$oDocument->get('module_srl')]->browser_title}</a>
<!--@end-->
</td>
<td class="nowr">
<span cond="$oDocument->get('member_srl') <= 0">{$oDocument->getNickName()}</span>

View file

@ -302,19 +302,8 @@ class FileAdminController extends File
$download_grant = Context::get('download_grant');
$config->download_grant = is_array($download_grant) ? array_values($download_grant) : array($download_grant);
// Create pre-conversion whitelist
$config->pre_conversion_types = [];
foreach ($config->image_autoconv ?? [] as $source_type => $target_type)
{
if ($target_type && $target_type !== true)
{
$config->pre_conversion_types[] = $source_type;
if ($source_type === 'jpg')
{
$config->pre_conversion_types[] = 'jpeg';
}
}
}
// Unset pre-conversion type setting #2707
unset($config->pre_conversion_types);
// Update
$oModuleController = getController('module');

View file

@ -607,9 +607,7 @@ class FileController extends File
{
// Basic variable setting(upload_target_srl and module_srl set)
$editor_sequence = Context::get('editor_sequence');
$file_srl = Context::get('file_srl');
$file_srls = Context::get('file_srls');
if($file_srls) $file_srl = $file_srls;
$file_srls = explode(',', Context::get('file_srls') ?: Context::get('file_srl'));
// Exit a session if there is neither upload permission nor information
if (!$_SESSION['upload_info'][$editor_sequence]->enabled)
@ -623,26 +621,43 @@ class FileController extends File
}
$module_srl = $_SESSION['upload_info'][$editor_sequence]->module_srl ?? 0;
$srls = explode(',',$file_srl);
if(!count($srls)) return;
for($i=0;$i<count($srls);$i++)
$valid_file_srls = [];
foreach ($file_srls as $file_srl)
{
$srl = (int)$srls[$i];
if(!$srl) continue;
$file_srl = (int)$file_srl;
if (!$file_srl)
{
continue;
}
$args = new stdClass;
$args->file_srl = $srl;
$args->file_srl = $file_srl;
$output = executeQuery('file.getFile', $args);
if(!$output->toBool()) continue;
if (!$output->toBool())
{
continue;
}
$file_info = $output->data;
if(!$file_info || $file_info->upload_target_srl != $upload_target_srl) continue;
if (!$file_info || $file_info->upload_target_srl != $upload_target_srl)
{
continue;
}
//if($module_srl && $file_info->module_srl != $module_srl) continue;
if(!FileModel::isDeletable($file_info)) continue;
$output = $this->deleteFile($file_srl);
if (!FileModel::isDeletable($file_info))
{
continue;
}
$valid_file_srls[] = $file_srl;
}
if (!count($valid_file_srls))
{
return;
}
$output = $this->deleteFile($valid_file_srls);
// Add upload status (getFileList)
try
{

View file

@ -360,7 +360,10 @@ class FileModel extends File
$module_config = ModuleModel::getModulePartConfig('file', $module_srl);
foreach((array)$module_config as $key => $value)
{
$config->$key = $value;
if ($value !== null)
{
$config->$key = $value;
}
}
}

View file

@ -2,7 +2,6 @@
<module>
<grants />
<actions>
<action name="dispLayoutPreview" type="view" permission="root" meta-noindex="true" />
<action name="dispLayoutPreviewWithModule" type="view" permission="root" meta-noindex="true" />
<action name="getLayoutInstanceListForJSONP" type="model" permission="root" />

View file

@ -83,7 +83,7 @@ class LayoutAdminView extends Layout
$layout_list = array();
foreach($_layout_list as $item)
{
if(!$layout_list[$item->layout])
if(empty($layout_list[$item->layout]))
{
$layout_list[$item->layout] = array();
$layout_info = LayoutModel::getLayoutInfo($item->layout, null, $type);

View file

@ -161,6 +161,7 @@ class LayoutModel extends Layout
}
}
$isCreateInstance = false;
if($layout)
{
if(count($instanceList) < 1 && isset($downloadedList[$layout]))

View file

@ -301,6 +301,9 @@ class LayoutView extends Layout
Context::set('skin_type', null);
Context::set('skin_vars', null);
// Set dummy variable
Context::set('layout_info', Context::get('layout_info') ?: new stdClass());
// Proc module
$oModule = $oModuleHandler->procModule();
if(!$oModule->toBool())
@ -314,76 +317,6 @@ class LayoutView extends Layout
return $handler->toDoc($oModule);
}
/**
* Preview a layout
* @return void|Object (void : success, Object : fail)
*/
function dispLayoutPreview()
{
if(!Rhymix\Framework\Security::checkCSRF())
{
throw new Rhymix\Framework\Exceptions\InvalidRequest;
}
// admin check
// this act is admin view but in normal view because do not load admin css/js files
$logged_info = Context::get('logged_info');
if($logged_info->is_admin != 'Y') throw new Rhymix\Framework\Exceptions\InvalidRequest;
$layout_srl = Context::get('layout_srl');
$code = Context::get('code');
$code_css = Context::get('code_css');
if(!$layout_srl || !$code) throw new Rhymix\Framework\Exceptions\InvalidRequest;
// Get the layout information
$oLayoutModel = getModel('layout');
$layout_info = $oLayoutModel->getLayout($layout_srl);
if(!$layout_info) throw new Rhymix\Framework\Exceptions\InvalidRequest;
// Separately handle the layout if its type is faceoff
if($layout_info && $layout_info->type == 'faceoff') $oLayoutModel->doActivateFaceOff($layout_info);
// Apply CSS directly
Context::addHtmlHeader("<style type=\"text/css\" charset=\"UTF-8\">".$code_css."</style>");
// Set names and values of extra_vars to $layout_info
if($layout_info->extra_var_count)
{
foreach($layout_info->extra_var as $var_id => $val)
{
$layout_info->{$var_id} = $val->value;
}
}
// menu in layout information becomes an argument for Context:: set
if($layout_info->menu_count)
{
foreach($layout_info->menu as $menu_id => $menu)
{
$menu->php_file = FileHandler::getRealPath($menu->php_file);
if(FileHandler::exists($menu->php_file)) include($menu->php_file);
Context::set($menu_id, $menu);
}
}
Context::set('layout_info', $layout_info);
Context::set('content', lang('layout_preview_content'));
// Temporary save the codes
$edited_layout_file = RX_BASEDIR . 'files/cache/layout/tmp.tpl';
FileHandler::writeFile($edited_layout_file, $code);
// Compile
$oTemplate = TemplateHandler::getInstance();
$layout_path = $layout_info->path;
$layout_file = 'layout';
$layout_tpl = $oTemplate->compile($layout_path, $layout_file, $edited_layout_file);
Context::set('layout','none');
// Convert widgets and others
Context::set('layout_tpl', $layout_tpl);
// Delete Temporary Files
FileHandler::removeFile($edited_layout_file);
$this->setTemplateFile('layout_preview');
}
private function getRealLayoutFile($layoutSrl)
{
$oLayoutModel = getModel('layout');

View file

@ -11,11 +11,11 @@
</h1>
</div>
<div cond="$layout == 'faceoff'" class="x_alert x_alert-block">
<div cond="isset($layout) && $layout == 'faceoff'" class="x_alert x_alert-block">
<p>{$lang->faceoff_migration[0]}</p>
<p><a href="#faceoff_migration_info" class="modalAnchor">{$lang->faceoff_migration[1]}</a></p>
</div>
<section cond="$layout == 'faceoff'" id="faceoff_migration_info" class="x_modal">
<section cond="isset($layout) && $layout == 'faceoff'" id="faceoff_migration_info" class="x_modal">
<div class="x_modal-header">
<h1>{$lang->faceoff_migration[1]}</h1>
</div>

View file

@ -21,9 +21,9 @@
<tr>
<!--@if($layout->title)-->
<td class="title">
<p><a href="{getUrl('act', 'dispLayoutAdminInstanceList', 'type', $type, 'layout', $layout->layout)}" style="font-weight:bold;color:#000">{$layout->title}</a></p>
<p><a href="{getUrl('act', 'dispLayoutAdminInstanceList', 'type', $type ?? null, 'layout', $layout->layout)}" style="font-weight:bold;color:#000">{$layout->title}</a></p>
<p>{$layout->description}</p>
<p cond="$layout->need_update == 'Y'" class="update">
<p cond="isset($layout->need_update) && $layout->need_update == 'Y'" class="update">
{$lang->msg_avail_easy_update} <a href="{$layout->update_url}&amp;return_url={urlencode(getRequestUriByServerEnviroment())}">{$lang->msg_do_you_like_update}</a>
</p>
</td>

View file

@ -1,16 +1,3 @@
function doPreviewLayoutCode()
{
var $form = jQuery('#fo_layout'), $act = $form.find('input[name=act]');
var og_act = $act.val();
$form.attr('target', '_LayoutPreview');
$act.val('dispLayoutPreview');
$form.submit();
$form.removeAttr('target');
$act.val(og_act);
}
$(function() {
$('.reset_layout').on('click', function(e) {
var msg = $(this).data('confirmationMsg');

View file

@ -70,16 +70,6 @@ function addLayoutCopyInputbox()
(function($){
/* preview layout */
function doPreviewLayoutCode(layout_srl) {
var fo = $('#fo_layout');
var act = fo.find('input[name=act]:first').val();
fo.attr('target', '_LayoutPreview').find('input[name=act]').val('dispLayoutAdminPreview');
fo.submit();
//.removeAttr('target').find('input[name=act]').val(act);
}
window.doPreviewLayoutCode = doPreviewLayoutCode;
/* restore layout code */
function doResetLayoutCode(layout_srl) {
procFilter($('#fo_layout')[0], reset_layout_code);

View file

@ -102,7 +102,6 @@
<button type="submit" class="x_btn reset_layout" name="mode" value="reset" data-confirmation-msg="{$lang->layout_reset_confirmation}">{$lang->cmd_reset}</button>
</span>
<span class="x_pull-right x_btn-group">
<button type="button" class="x_btn" onclick="doPreviewLayoutCode()">{$lang->cmd_preview}</button>
<button type="submit" class="x_btn x_btn-primary" name="mode" value="save">{$lang->cmd_save}</button>
</span>
</div>

View file

@ -1,6 +1,6 @@
<ul class="x_nav x_nav-tabs">
<li class="x_active"|cond="$type != 'M'"><a href="{getUrl('act', 'dispLayoutAdminInstalledList', 'type', 'P')}">PC ({$pcLayoutCount})</a></li>
<li class="x_active"|cond="$type == 'M'"><a href="{getUrl('act', 'dispLayoutAdminInstalledList', 'type', 'M')}">Mobile ({$mobileLayoutCount})</a></li>
<li class="x_active"|cond="!isset($type) || $type != 'M'"><a href="{getUrl('act', 'dispLayoutAdminInstalledList', 'type', 'P')}">PC ({$pcLayoutCount})</a></li>
<li class="x_active"|cond="isset($type) && $type == 'M'"><a href="{getUrl('act', 'dispLayoutAdminInstalledList', 'type', 'M')}">Mobile ({$mobileLayoutCount})</a></li>
</ul>
<div>
<a class="active"|cond="$act == 'dispLayoutAdminAllInstanceList'" href="{getUrl('act', 'dispLayoutAdminAllInstanceList', 'layout_srl', '')}">{$lang->instance_layout}</a> <i>|</i>

View file

@ -316,6 +316,7 @@ $lang->change_password_date = 'Password renewal cycle';
$lang->about_change_password_date = 'If you set a value to this, you will be notified to change your password periodically. (If set to 0, disabled)';
$lang->msg_change_password_date = 'You have not changed the password during %s days. For personal information protection, you need to change the password.';
$lang->about_login_trial_limit = 'Limit the number of login attempts in a short time from the same IP address.';
$lang->about_login_failure_except_ip = 'You can specify IP addresses or ranges that are exempt from the login attempt limit.<br>Enter one IP address or range per line.';
$lang->msg_kr_address = 'Search for the name of eup, myeon or dong of your address.';
$lang->msg_kr_address_etc = 'Enter the rest of your address.';
$lang->cmd_search_again = 'Search again';
@ -381,6 +382,7 @@ $lang->group = 'Group';
$lang->retrieve_password = 'Retrieve password';
$lang->excess_ip_access_count = 'There were too much login attempts from your device in a short time. You can not log in for %s.';
$lang->enable_login_fail_report = 'Login failure';
$lang->login_failure_except_ip = 'Login failure except IP';
$lang->login_fail_report = 'Login failure report.';
$lang->login_fail_report_contents = '<h2>There is recorded login failures.</h2>%1$s<hr /><p>* This notification is shown once.<br />* This message contains login failure records, before a ID login success.<br />Sending: %2$s</p>';
$lang->all_group = 'Entire Group';

View file

@ -319,7 +319,8 @@ $lang->msg_invalid_symbol_in_nickname = '닉네임에 사용할 수 없는 특
$lang->change_password_date = '비밀번호 갱신주기';
$lang->about_change_password_date = '일정 기간이 지나면 비밀번호를 변경하도록 유도하는 기능입니다. 사용하지 않으려면 0을 입력하십시오.';
$lang->msg_change_password_date = '%s일 동안 비밀번호를 변경하지 않았습니다. 개인정보 보호를 위하여 비밀번호를 변경해야 합니다.';
$lang->about_login_trial_limit = '짧은 시간 동안 하나의 아이피(IP)에서 시도할 수 있는 로그인 횟수에 제한을 둡니다.';
$lang->about_login_trial_limit = '짧은 시간 동안 하나의 IP에서 시도할 수 있는 로그인 횟수에 제한을 둡니다.';
$lang->about_login_failure_except_ip = '로그인 횟수 제한에서 예외로 할 IP 주소 또는 대역을 지정할 수 있습니다.<br>IP 주소 또는 대역을 한 줄에 하나씩 입력하세요.';
$lang->msg_kr_address = '읍, 면, 동 이름으로 검색하세요.';
$lang->msg_kr_address_etc = '나머지 주소(번지)를 입력하세요.';
$lang->cmd_search_again = '다시 검색';
@ -384,7 +385,8 @@ $lang->msg_success_modify_email_address = '이메일 주소가 정상적으로
$lang->group = '그룹';
$lang->retrieve_password = '비밀번호 찾기';
$lang->excess_ip_access_count = '로그인 가능 횟수를 초과했습니다. %s 간 로그인할 수 없습니다.';
$lang->enable_login_fail_report = '계정 무한 대입 방지 사용';
$lang->enable_login_fail_report = '계정 무한 대입 방지';
$lang->login_failure_except_ip = '로그인 횟수 예외 IP';
$lang->login_fail_report = '로그인 실패 기록 보고 입니다.';
$lang->login_fail_report_contents = '<h2>로그인 실패 기록을 알려드립니다.</h2>%1$s<hr /><p>* 비밀번호를 틀리는 등의 일이 없었는데 이 메시지를 보신다면, 계정 관리에 유의 바랍니다.<br />* 이 메시지는 로그인이 성공한 순간 누적 로그인 실패 기록이 많을 경우, 로그인 성공 이전 실패 기록을 모아서 발송합니다.<br />발송 시각: %2$s</p>';
$lang->all_group = '그룹전체';

View file

@ -596,6 +596,7 @@ class MemberAdminController extends Member
'enable_login_fail_report',
'max_error_count',
'max_error_count_time',
'login_failure_except_ip',
'login_invalidate_other_sessions',
'after_login_url',
'after_logout_url'
@ -640,6 +641,14 @@ class MemberAdminController extends Member
$args->change_password_date = 0;
}
if($args->login_failure_except_ip)
{
$args->login_failure_except_ip = array_map('trim', explode("\n", $args->login_failure_except_ip));
$args->login_failure_except_ip = array_filter($args->login_failure_except_ip, function($val) {
return $val !== '';
});
}
if(!trim(strip_tags($args->after_login_url)))
{
$args->after_login_url = NULL;

View file

@ -2638,20 +2638,21 @@ class MemberController extends Member
$args = new stdClass;
$args->ipaddress = \RX_CLIENT_IP;
$output = executeQuery('member.getLoginCountByIp', $args);
$errorCount = $output->data->count;
if($errorCount >= $config->max_error_count)
if ($output->data->count >= $config->max_error_count)
{
$last_update = strtotime($output->data->last_update);
$term = intval($_SERVER['REQUEST_TIME']-$last_update);
if($term < $config->max_error_count_time)
$last_update = ztime($output->data->last_update);
$term = intval(\RX_TIME - $last_update);
if ($term < $config->max_error_count_time)
{
$term = $config->max_error_count_time - $term;
if($term < 60) $term = intval($term).lang('unit_sec');
elseif(60 <= $term && $term < 3600) $term = intval($term/60).lang('unit_min');
elseif(3600 <= $term && $term < 86400) $term = intval($term/3600).lang('unit_hour');
else $term = intval($term/86400).lang('unit_day');
return new BaseObject(-1, sprintf(lang('excess_ip_access_count'), $term));
if (!$config->login_failure_except_ip || !Rhymix\Framework\Filters\IpFilter::inRanges(\RX_CLIENT_IP, $config->login_failure_except_ip))
{
$term = $config->max_error_count_time - $term;
if($term < 60) $term = intval($term).lang('unit_sec');
elseif(60 <= $term && $term < 3600) $term = intval($term/60).lang('unit_min');
elseif(3600 <= $term && $term < 86400) $term = intval($term/3600).lang('unit_hour');
else $term = intval($term/86400).lang('unit_day');
return new BaseObject(-1, sprintf(lang('excess_ip_access_count'), $term));
}
}
else
{

View file

@ -121,6 +121,7 @@ class MemberModel extends Member
$config->enable_login_fail_report = $config->enable_login_fail_report ?? 'Y';
$config->max_error_count = $config->max_error_count ?? 10;
$config->max_error_count_time = $config->max_error_count_time ?? 300;
$config->login_failure_except_ip = $config->login_failure_except_ip ?? [];
$config->login_invalidate_other_sessions = $config->login_invalidate_other_sessions ?? 'N';
$config->after_login_url = $config->after_login_url ?? null;
$config->after_logout_url = $config->after_logout_url ?? null;

View file

@ -394,6 +394,15 @@ class MemberView extends Member
// Set a copy of the agreement for compatibility with old skins
$member_config->agreement = $member_config->agreements[1]->content ?? '';
// Check whether friends are enabled in the communication module.
$comm_config = CommunicationModel::getConfig();
if ($comm_config->enable_friend !== 'Y')
{
$allow_message_type = lang('member.allow_message_type');
unset($allow_message_type['F']);
$GLOBALS['lang']->set('member.allow_message_type', $allow_message_type);
}
// Set a template file
self::setMemberPageBrowserTitle(lang('cmd_signup'));
$this->setTemplateFile('signup_form');
@ -517,6 +526,15 @@ class MemberView extends Member
$this->addExtraFormValidatorMessage();
// Check whether friends are enabled in the communication module.
$comm_config = CommunicationModel::getConfig();
if ($comm_config->enable_friend !== 'Y')
{
$allow_message_type = lang('member.allow_message_type');
unset($allow_message_type['F']);
$GLOBALS['lang']->set('member.allow_message_type', $allow_message_type);
}
// Set a template file
self::setMemberPageBrowserTitle(lang('cmd_modify_member_info'));
$this->setTemplateFile('modify_info');

View file

@ -86,7 +86,7 @@
<label for="nickname_spaces"><input type="checkbox" name="nickname_spaces" id="nickname_spaces" value="Y" checked="checked"|cond="$config->nickname_spaces == 'Y'" /> {$lang->cmd_nickname_allow_spaces}</label>
</div>
</div>
<div class="x_control-group" style="display:none"|cond="$config->allow_duplicate_nickname != 'Y'">
<div class="x_control-group">
<div class="x_control-label">{$lang->cmd_allow_duplicate_nickname}</div>
<div class="x_controls">
<label class="x_inline" for="allow_duplicate_nickname_yes"><input type="radio" name="allow_duplicate_nickname" id="allow_duplicate_nickname_yes" value="Y" checked="checked"|cond="$config->allow_duplicate_nickname == 'Y'" /> {$lang->cmd_yes}</label>

View file

@ -43,6 +43,13 @@
<p class="x_help-block">{$lang->about_login_trial_limit}</p>
</div>
</div>
<div class="x_control-group">
<label class="x_control-label" for="login_failure_except_ip">{$lang->login_failure_except_ip}</label>
<div class="x_controls">
<textarea id="login_failure_except_ip" name="login_failure_except_ip">{implode("\n", $config->login_failure_except_ip ?? [])}</textarea>
<p class="x_help-block">{$lang->about_login_failure_except_ip}</p>
</div>
</div>
<div class="x_control-group">
<label class="x_control-label">{$lang->cmd_login_invalidate_other_sessions}</label>
<div class="x_controls">

View file

@ -44,7 +44,7 @@ class ModuleAdminView extends Module
// get easyinstall need update
$packageSrl = $oAutoinstallModel->getPackageSrlByPath($val->path);
$package = $oAutoinstallModel->getInstalledPackages($packageSrl);
$module_list[$key]->need_autoinstall_update = $package[$packageSrl]->need_update;
$module_list[$key]->need_autoinstall_update = $package[$packageSrl]->need_update ?? null;
// get easyinstall update url
if($module_list[$key]->need_autoinstall_update == 'Y')

View file

@ -64,10 +64,10 @@ class ModuleView extends Module
$obj = new stdClass;
$obj->module_srl = $val->module_srl;
$obj->browser_title = $val->browser_title;
$obj->browser_title = Context::replaceUserLang($val->browser_title);
$mid_list[$val->module]->list[$val->category ?: 0][$val->mid] = $obj;
$mid_list[$val->module]->title = ModuleModel::getModuleInfoXml($val->module)->title;
$mid_list[$val->module]->title = Context::replaceUserLang(ModuleModel::getModuleInfoXml($val->module)->title);
}
Context::set('mid_list', $mid_list);

View file

@ -68,7 +68,7 @@
<!--@end-->
</td>
<td>
<p loop="$val->attributes => $name, $value">
<p cond="!empty($val->attributes)" loop="$val->attributes => $name, $value">
{htmlspecialchars($name, ENT_COMPAT | ENT_HTML401, 'UTF-8', false)}: {$value}
</p>
</td>
@ -78,7 +78,7 @@
</tr>
</tbody>
</table>
<form cond="$page_navigation" action="./" class="x_pagination x_pagination-centered" no-error-return-url="true">
<input type="hidden" name="module" value="{$module}" />
<input type="hidden" name="act" value="{$act}" />
@ -111,7 +111,7 @@
<button type="submit" class="x_add-on">Go</button>
</span>
</li>
</block>
<li class="x_disabled"|cond="$page == $page_navigation->last_page"><a href="{getUrl('page', $page_navigation->last_page)}" title="{$page_navigation->last_page}">{$lang->last_page} &raquo;</a></li>

View file

@ -23,16 +23,16 @@ class PageAdminController extends Page
$args = Context::getRequestVars();
$args->module = 'page';
$args->mid = $args->page_name; //because if mid is empty in context, set start page mid
$args->path = (!$args->path) ? '' : $args->path;
$args->mpath = (!$args->mpath) ? '' : $args->mpath;
if (preg_match('!\bfiles/cache/!i', $args->path))
$args->path = isset($args->path) ? strval($args->path) : '';
$args->mpath = isset($args->mpath) ? strval($args->mpath) : '';
if (!self::_isAllowedExternalPath($args->path))
{
$this->setError(-1);
$this->setMessage('msg_invalid_opage_pc_path');
$this->setRedirectUrl(Context::get('success_return_url'));
return;
}
if (preg_match('!\bfiles/cache/!i', $args->mpath))
if (!self::_isAllowedExternalPath($args->mpath))
{
$this->setError(-1);
$this->setMessage('msg_invalid_opage_mobile_path');
@ -383,6 +383,42 @@ class PageAdminController extends Page
// 성공 메세지 등록
$this->setMessage($msg_code);
}
/**
* Check if the path to an external page is valid.
*
* @param string $path
* @return bool
*/
protected static function _isAllowedExternalPath(string $path): bool
{
// Normalize the directory separator.
$path = str_replace('\\', '/', $path);
// Check for forbidden paths.
if (preg_match('!(?:^|/)files/(?:attach|cache|config|debug|env|member_extra_info|ruleset|site_design|thumbnails)/!i', $path))
{
return false;
}
// Check for forbidden extensions.
if (preg_match('!(?:image|audio|video)/!i', Rhymix\Framework\MIME::getTypeByFilename($path)))
{
return false;
}
// Run the check again after resolving any symbolic links.
if (!preg_match('!^https?://!i', $path) && file_exists($path))
{
$realpath = realpath($path);
if ($realpath !== $path && !self::_isAllowedExternalPath($realpath))
{
return false;
}
}
return true;
}
}
/* End of file page.admin.controller.php */
/* Location: ./modules/page/page.admin.controller.php */

View file

@ -29,7 +29,7 @@
<!--@if($widget->version === 'RX_VERSION' && Context::isDefaultPlugin($widget->widget, 'widget'))-->
<img src="{\RX_BASEURL}common/img/icon.png" class="core_symbol" alt="Rhymix Core" title="Rhymix Core" />
<!--@else-->
<span style="color:#aaa"|cond="$widget->isBlacklisted">{$widget->version}</span>
<span style="color:#aaa"|cond="!empty($widget->isBlacklisted)">{$widget->version}</span>
<!--@endif-->
</td>
<td class="rx_detail_marks">

View file

@ -98,11 +98,6 @@ class SessionTest extends \Codeception\Test\Unit
$this->assertTrue(Rhymix\Framework\Session::isStarted());
}
public function testCheckSSO()
{
$this->assertNull(Rhymix\Framework\Session::checkSSO(new stdClass));
}
public function testRefresh()
{
$_SERVER['REQUEST_METHOD'] = 'GET';

View file

@ -346,6 +346,16 @@ class StorageTest extends \Codeception\Test\Unit
$this->assertFalse(Rhymix\Framework\Storage::deleteDirectory($nonexistent));
}
public function testProtectDirectory()
{
$dir = \RX_BASEDIR . 'tests/_output/protectdir';
mkdir($dir);
$this->assertTrue(Rhymix\Framework\Storage::protectDirectory($dir));
$this->assertTrue(file_exists($dir . '/index.html'));
$this->assertTrue(file_exists($dir . '/.htaccess'));
$this->assertStringContainsString('Require all denied', file_get_contents($dir . '/.htaccess'));
}
public function testDeleteDirectoryKeepRoot()
{
$sourcedir = \RX_BASEDIR . 'tests/_output/sourcedir';