diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22f2f79f1..938956a87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: PHP Lint & Codeception on: [ push, pull_request ] jobs: build: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: fail-fast: false matrix: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 93fd25c06..2951a7f63 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,3 +4,4 @@ - [이슈 및 PR 작성 방법](https://rhymix.org/manual/contrib/github) - [코딩 규칙](https://rhymix.org/manual/contrib/coding-standards) +- [GPL: 개발자, 디자이너, 사용자 등의 권리와 의무](https://rhymix.org/manual/contrib/license) diff --git a/classes/context/Context.class.php b/classes/context/Context.class.php index 6b6d23905..1d5dcc37c 100644 --- a/classes/context/Context.class.php +++ b/classes/context/Context.class.php @@ -773,9 +773,10 @@ class Context } if (count($vars)) { - $title = trim(trim(preg_replace_callback('/\\$(\w+)/', function($matches) use($vars) { + $title = trim(preg_replace_callback('/\\$(\w+)/', function($matches) use($vars) { return isset($vars[strtolower($matches[1])]) ? $vars[strtolower($matches[1])] : $matches[0]; - }, $title), ' -')); + }, $title)); + $title = preg_replace('/(-\s+)+-/', '-', trim($title, ' -')); } self::$_instance->browser_title = $title; } @@ -1368,7 +1369,9 @@ class Context $file['tmp_name'] = $val['tmp_name'][$i]; $file['error'] = $val['error'][$i]; $file['size'] = $val['size'][$i]; - $files[] = $file; + + $subkey = (is_int($i) || ctype_digit($i)) ? intval($i) : preg_replace('/[^a-z0-9:+=_-]/i', '', (string)$i); + $files[$subkey] = $file; } if(count($files)) { diff --git a/classes/display/HTMLDisplayHandler.php b/classes/display/HTMLDisplayHandler.php index ad1ab48c2..52e5f697d 100644 --- a/classes/display/HTMLDisplayHandler.php +++ b/classes/display/HTMLDisplayHandler.php @@ -573,19 +573,32 @@ class HTMLDisplayHandler foreach ($document_files as $file) { - if ($file->isvalid !== 'Y' || !preg_match('/\.(?:bmp|gif|jpe?g|png|webp)$/i', $file->uploaded_filename)) + if ($file->isvalid !== 'Y' || !preg_match('/\.(?:bmp|gif|jpe?g|png|webp|mp4)$/i', $file->uploaded_filename)) { continue; } - list($width, $height) = @getimagesize($file->uploaded_filename); - if ($width < 100 && $height < 100) + if (str_starts_with($file->mime_type, 'video/')) { - continue; + if ($file->thumbnail_filename) + { + list($width, $height) = @getimagesize($file->thumbnail_filename); + if ($width >= 100 || $height >= 100) + { + $document_images[] = array('filepath' => $file->thumbnail_filename, 'width' => $width, 'height' => $height); + break; + } + } + } + else + { + list($width, $height) = @getimagesize($file->uploaded_filename); + if ($width >= 100 || $height >= 100) + { + $document_images[] = array('filepath' => $file->uploaded_filename, 'width' => $width, 'height' => $height); + break; + } } - - $document_images[] = array('filepath' => $file->uploaded_filename, 'width' => $width, 'height' => $height); - break; } } Rhymix\Framework\Cache::set("seo:document_images:$document_srl", $document_images); diff --git a/classes/display/JSONDisplayHandler.php b/classes/display/JSONDisplayHandler.php index 72dc6f02d..eb09bad3a 100644 --- a/classes/display/JSONDisplayHandler.php +++ b/classes/display/JSONDisplayHandler.php @@ -15,7 +15,16 @@ class JSONDisplayHandler $variables['message'] = $oModule->getMessage(); self::_convertCompat($variables, Context::getRequestMethod()); - return json_encode($variables) . "\n"; + $result = json_encode($variables) . "\n"; + if (json_last_error() != \JSON_ERROR_NONE) + { + trigger_error('JSON encoding error: ' . json_last_error_msg(), E_USER_WARNING); + return json_encode([ + 'error' => -1, + 'message' => 'JSON encoding error', + ]) . "\n"; + } + return $result; } /** diff --git a/classes/module/ModuleHandler.class.php b/classes/module/ModuleHandler.class.php index b7c893b95..7d518befa 100644 --- a/classes/module/ModuleHandler.class.php +++ b/classes/module/ModuleHandler.class.php @@ -703,7 +703,7 @@ class ModuleHandler extends Handler $procResult = $oModule->proc(); $methodList = array('XMLRPC' => 1, 'JSON' => 1, 'JS_CALLBACK' => 1); - if(!$oModule->stop_proc && !isset($methodList[Context::getRequestMethod()]) && !isset($_SERVER['HTTP_X_AJAX_TARGET']) && !isset($_POST['_rx_ajax_form'])) + if(!$oModule->stop_proc && !isset($methodList[Context::getRequestMethod()])) { $error = $oModule->getError(); $message = $oModule->getMessage(); @@ -1014,28 +1014,6 @@ class ModuleHandler extends Handler $methodList = array('XMLRPC' => 1, 'JSON' => 1, 'JS_CALLBACK' => 1); if(!isset($methodList[Context::getRequestMethod()])) { - // Handle iframe form submissions. - $ajax_form_target = strval($_SERVER['HTTP_X_AJAX_TARGET'] ?? ($_POST['_rx_ajax_form'] ?? '')); - if($ajax_form_target !== '' && starts_with('_rx_temp_iframe_', $ajax_form_target)) - { - $data = []; - if ($this->error) - { - $data['error'] = -1; - $data['message'] = lang($this->error); - } - else - { - $data['error'] = $oModule->error; - $data['message'] = lang($oModule->message); - } - $data = array_merge($data, $oModule->getVariables()); - - ob_end_clean(); - echo sprintf('', json_encode($ajax_form_target), json_encode($data)); - return; - } - // Handle redirects. if($oModule->getRedirectUrl()) { @@ -1122,7 +1100,7 @@ class ModuleHandler extends Handler if($layout_info) { // Input extra_vars into $layout_info - if($layout_info->extra_var_count) + if(isset($layout_info->extra_var_count) && $layout_info->extra_var_count) { foreach($layout_info->extra_var as $var_id => $val) @@ -1138,7 +1116,7 @@ class ModuleHandler extends Handler } } // Set menus into context - if($layout_info->menu_count) + if(isset($layout_info->menu_count) && $layout_info->menu_count) { $oMenuAdminController = getAdminController('menu'); $homeMenuCacheFile = null; diff --git a/classes/object/Object.class.php b/classes/object/Object.class.php index 728e0acb3..6c574b4be 100644 --- a/classes/object/Object.class.php +++ b/classes/object/Object.class.php @@ -210,8 +210,7 @@ class BaseObject */ public function add($key, $val) { - $this->variables[$key] = $val; - return $this; + return $this->set($key, $val); } /** diff --git a/common/composer.json b/common/composer.json index 6eec88035..73bd37b07 100644 --- a/common/composer.json +++ b/common/composer.json @@ -33,6 +33,7 @@ "ezyang/htmlpurifier": "4.16.*", "google/auth": "1.26.*", "guzzlehttp/guzzle": "7.8.*", + "guzzlehttp/psr7": "2.6.*", "jbbcode/jbbcode": "1.4.*", "leafo/lessphp": "dev-master", "league/html-to-markdown": "5.1.*", diff --git a/common/framework/DB.php b/common/framework/DB.php index 03d3b0a85..62d6d31ef 100644 --- a/common/framework/DB.php +++ b/common/framework/DB.php @@ -463,7 +463,9 @@ class DB if ($this->isError()) { - return $this->getError(); + $output = $this->getError(); + $output->add('_count', $query_string); + return $output; } else { @@ -474,11 +476,13 @@ class DB catch (Exceptions\DBError $e) { $output = $this->setError(-1, $e->getMessage()); + $output->add('_count', $query_string); return $output; } catch (\PDOException $e) { $output = $this->setError(-1, $e->getMessage()); + $output->add('_count', $query_string); return $output; } diff --git a/common/framework/Mail.php b/common/framework/Mail.php index 7502c6df9..bcf9b62a3 100644 --- a/common/framework/Mail.php +++ b/common/framework/Mail.php @@ -560,6 +560,13 @@ class Mail */ public function send(): bool { + // If queue is enabled, send asynchronously. + if (config('queue.enabled') && !defined('RXQUEUE_CRON')) + { + Queue::addTask(self::class . '::' . 'sendAsync', $this); + return true; + } + // Get caller information. $backtrace = debug_backtrace(0); if(count($backtrace) && isset($backtrace[0]['file'])) @@ -600,6 +607,17 @@ class Mail return $this->sent; } + /** + * Send an email asynchronously (for Queue integration). + * + * @param self $mail + * @return void + */ + public static function sendAsync(self $mail): void + { + $mail->send(); + } + /** * Check if the message was sent. * diff --git a/common/framework/Push.php b/common/framework/Push.php index 547a2295e..bc37cd2c7 100644 --- a/common/framework/Push.php +++ b/common/framework/Push.php @@ -402,6 +402,13 @@ class Push */ public function send(): bool { + // If queue is enabled, send asynchronously. + if (config('queue.enabled') && !defined('RXQUEUE_CRON')) + { + Queue::addTask(self::class . '::' . 'sendAsync', $this); + return true; + } + // Get caller information. $backtrace = debug_backtrace(0); if(count($backtrace) && isset($backtrace[0]['file'])) @@ -464,6 +471,17 @@ class Push return $this->sent > 0 ? true : false; } + /** + * Send asynchronously (for Queue integration). + * + * @param self $sms + * @return void + */ + public static function sendAsync(self $push): void + { + $push->send(); + } + /** * Get the device token * diff --git a/common/framework/Queue.php b/common/framework/Queue.php new file mode 100644 index 000000000..56410e13f --- /dev/null +++ b/common/framework/Queue.php @@ -0,0 +1,302 @@ + $class_name::getName(), + 'required' => $class_name::getRequiredConfig(), + 'optional' => $class_name::getOptionalConfig(), + ]; + } + } + foreach (self::$_drivers as $driver_name => $driver) + { + if ($driver->isSupported()) + { + $result[$driver_name] = [ + 'name' => $driver->getName(), + 'required' => $driver->getRequiredConfig(), + 'optional' => $driver->getOptionalConfig(), + ]; + } + } + ksort($result); + return $result; + } + + /** + * Add a task. + * + * The handler can be in one of the following formats: + * - Global function, e.g. myHandler + * - ClassName::staticMethodName + * - ClassName::getInstance()->methodName + * - new ClassName()->methodName + * + * Once identified and/or instantiated, the handler will be passed $args + * and $options, in that order. Each of them must be a single object. + * + * It is strongly recommended that you write a dedicated method to handle + * queued tasks, rather than reusing an existing method with a potentially + * incompatible structure. If you must to call an existing method, + * you should consider writing a wrapper. + * + * Any value returned by the handler will be discarded. If you throw an + * exception, it may be logged, but it will not cause a fatal error. + * + * @param string $handler + * @param ?object $args + * @param ?object $options + * @return int + */ + public static function addTask(string $handler, ?object $args = null, ?object $options = null): int + { + $driver_name = config('queue.driver'); + if (!$driver_name) + { + throw new Exceptions\FeatureDisabled('Queue not configured'); + } + + $driver = self::getDriver($driver_name); + if (!$driver) + { + throw new Exceptions\FeatureDisabled('Queue not configured'); + } + + return $driver->addTask($handler, $args, $options); + } + + /** + * Get the first task to execute immediately. + * + * If no tasks are pending, this method will return null. + * Detailed scheduling of tasks will be handled by each driver. + * + * @param int $blocking + * @return ?object + */ + public static function getTask(int $blocking = 0): ?object + { + $driver_name = config('queue.driver'); + if (!$driver_name) + { + throw new Exceptions\FeatureDisabled('Queue not configured'); + } + + $driver = self::getDriver($driver_name); + if (!$driver) + { + throw new Exceptions\FeatureDisabled('Queue not configured'); + } + + return $driver->getTask($blocking); + } + + /** + * Check the process key. + * + * @param string $key + * @return bool + */ + public static function checkKey(string $key): bool + { + return config('queue.key') === $key; + } + + /** + * Process the queue. + * + * This will usually be called by a separate script, run every minute + * through an external scheduler such as crontab or systemd. + * + * If you are on a shared hosting service, you may also call a URL + * using a "web cron" service provider. + * + * @param int $timeout + * @return void + */ + public static function process(int $timeout): void + { + // This part will run in a loop until timeout. + $process_start_time = microtime(true); + while (true) + { + // Get a task from the driver. + $loop_start_time = microtime(true); + $task = self::getTask(1); + + // Wait 1 second and loop back. + if ($task) + { + // Find the handler for the task. + $task->handler = trim($task->handler, '\\()'); + $handler = null; + try + { + if (preg_match('/^(?:\\\\)?([\\\\\\w]+)::(\\w+)$/', $task->handler, $matches)) + { + $class_name = '\\' . $matches[1]; + $method_name = $matches[2]; + if (class_exists($class_name) && method_exists($class_name, $method_name)) + { + $handler = [$class_name, $method_name]; + } + else + { + error_log('RxQueue: task handler not found: ' . $task->handler); + } + } + elseif (preg_match('/^(?:\\\\)?([\\\\\\w]+)::(\\w+)(?:\(\))?->(\\w+)$/', $task->handler, $matches)) + { + $class_name = '\\' . $matches[1]; + $initializer_name = $matches[2]; + $method_name = $matches[3]; + if (class_exists($class_name) && method_exists($class_name, $initializer_name)) + { + $obj = $class_name::$initializer_name(); + if (method_exists($obj, $method_name)) + { + $handler = [$obj, $method_name]; + } + else + { + error_log('RxQueue: task handler not found: ' . $task->handler); + } + } + else + { + error_log('RxQueue: task handler not found: ' . $task->handler); + } + } + elseif (preg_match('/^new (?:\\\\)?([\\\\\\w]+)(?:\(\))?->(\\w+)$/', $task->handler, $matches)) + { + $class_name = '\\' . $matches[1]; + $method_name = $matches[2]; + if (class_exists($class_name) && method_exists($class_name, $method_name)) + { + $obj = new $class_name(); + $handler = [$obj, $method_name]; + } + else + { + error_log('RxQueue: task handler not found: ' . $task->handler); + } + } + else + { + if (function_exists('\\' . $task->handler)) + { + $handler = '\\' . $task->handler; + } + else + { + error_log('RxQueue: task handler not found: ' . $task->handler); + } + } + } + catch (\Throwable $th) + { + error_log(vsprintf('RxQueue: task handler %s could not be accessed: %s in %s:%d', [ + $task->handler, + get_class($th), + $th->getFile(), + $th->getLine(), + ])); + } + + // Call the handler. + try + { + if ($handler) + { + call_user_func($handler, $task->args, $task->options); + } + } + catch (\Throwable $th) + { + error_log(vsprintf('RxQueue: task handler %s threw %s in %s:%d', [ + $task->handler, + get_class($th), + $th->getFile(), + $th->getLine(), + ])); + } + } + + // If the timeout is imminent, break the loop. + $process_elapsed_time = microtime(true) - $process_start_time; + if ($process_elapsed_time > $timeout - 2) + { + break; + } + + // If there was no task, wait 1 second to make sure that the loop isn't too tight. + $loop_elapsed_time = microtime(true) - $loop_start_time; + if (!$task && $loop_elapsed_time < 1) + { + usleep(intval((1 - $loop_elapsed_time) * 1000000)); + } + } + } +} diff --git a/common/framework/SMS.php b/common/framework/SMS.php index 0d347bc83..4bb65fd60 100644 --- a/common/framework/SMS.php +++ b/common/framework/SMS.php @@ -511,6 +511,13 @@ class SMS */ public function send(): bool { + // If queue is enabled, send asynchronously. + if (config('queue.enabled') && !defined('RXQUEUE_CRON')) + { + Queue::addTask(self::class . '::' . 'sendAsync', $this); + return true; + } + // Get caller information. $backtrace = debug_backtrace(0); if(count($backtrace) && isset($backtrace[0]['file'])) @@ -566,6 +573,17 @@ class SMS return $this->sent; } + /** + * Send an SMS asynchronously (for Queue integration). + * + * @param self $sms + * @return void + */ + public static function sendAsync(self $sms): void + { + $sms->send(); + } + /** * Check if the message was sent. * diff --git a/common/framework/UA.php b/common/framework/UA.php index 136a44ed5..14e14b87b 100644 --- a/common/framework/UA.php +++ b/common/framework/UA.php @@ -171,7 +171,7 @@ class UA } // Look for common search engine names and the 'bot' keyword. - if (preg_match('/bot|spider|crawler|archiver|wget|curl|php|slurp|wordpress|facebook|teoma|yeti|daum|apachebench|mediapartners-google|[(<+]https?:|@/i', $ua)) + if (preg_match('/bot|spider|crawler|archiver|wget|curl|php|slurp|wordpress|facebook|external(agent|fetcher)|teoma|yeti|daum|apachebench|googleother|mediapartners-google|[(<+]https?:|@/i', $ua)) { return self::$_robot_cache[$ua] = true; } diff --git a/common/framework/drivers/QueueInterface.php b/common/framework/drivers/QueueInterface.php new file mode 100644 index 000000000..71d71a452 --- /dev/null +++ b/common/framework/drivers/QueueInterface.php @@ -0,0 +1,71 @@ +prepare('INSERT INTO task_queue (handler, args, options) VALUES (?, ?, ?)'); + $result = $stmt->execute([$handler, serialize($args), serialize($options)]); + return $result ? $oDB->getInsertID() : 0; + } + + /** + * Get the first task. + * + * @param int $blocking + * @return ?object + */ + public function getTask(int $blocking = 0): ?object + { + $oDB = RFDB::getInstance(); + $oDB->beginTransaction(); + $stmt = $oDB->query('SELECT * FROM task_queue ORDER BY id LIMIT 1 FOR UPDATE'); + $result = $stmt->fetchObject(); + $stmt->closeCursor(); + + if ($result) + { + $stmt = $oDB->prepare('DELETE FROM task_queue WHERE id = ?'); + $stmt->execute([$result->id]); + $oDB->commit(); + + $result->args = unserialize($result->args); + $result->options = unserialize($result->options); + return $result; + } + else + { + $oDB->commit(); + return null; + } + } +} diff --git a/common/framework/drivers/queue/dummy.php b/common/framework/drivers/queue/dummy.php new file mode 100644 index 000000000..0abb74792 --- /dev/null +++ b/common/framework/drivers/queue/dummy.php @@ -0,0 +1,119 @@ +_dummy_queue = (object)[ + 'handler' => $handler, + 'args' => $args, + 'options' => $options, + ]; + return 0; + } + + /** + * Get the first task. + * + * @param int $blocking + * @return ?object + */ + public function getTask(int $blocking = 0): ?object + { + $result = $this->_dummy_queue; + $this->_dummy_queue = null; + return $result; + } +} diff --git a/common/framework/drivers/queue/redis.php b/common/framework/drivers/queue/redis.php new file mode 100644 index 000000000..58c122184 --- /dev/null +++ b/common/framework/drivers/queue/redis.php @@ -0,0 +1,197 @@ +connect($config['host'], $config['port'] ?? 6379); + if (isset($config['user']) || isset($config['pass'])) + { + $auth = []; + if (isset($config['user']) && $config['user']) $auth[] = $config['user']; + if (isset($config['pass']) && $config['pass']) $auth[] = $config['pass']; + $test->auth(count($auth) > 1 ? $auth : $auth[0]); + } + if (isset($config['dbnum'])) + { + $test->select(intval($config['dbnum'])); + } + return true; + } + catch (\Throwable $th) + { + return false; + } + } + + /** + * Constructor. + * + * @param array $config + */ + public function __construct(array $config) + { + try + { + $this->_conn = new \Redis; + $this->_conn->connect($config['host'], $config['port'] ?? 6379); + if (isset($config['user']) || isset($config['pass'])) + { + $auth = []; + if (isset($config['user']) && $config['user']) $auth[] = $config['user']; + if (isset($config['pass']) && $config['pass']) $auth[] = $config['pass']; + $this->_conn->auth(count($auth) > 1 ? $auth : $auth[0]); + } + if (isset($config['dbnum'])) + { + $this->_conn->select(intval($config['dbnum'])); + } + $this->_key = 'rxQueue_' . substr(hash_hmac('sha1', 'rxQueue_', config('crypto.authentication_key')), 0, 24); + } + catch (\RedisException $e) + { + $this->_conn = null; + } + } + + /** + * Add a task. + * + * @param string $handler + * @param ?object $args + * @param ?object $options + * @return int + */ + public function addTask(string $handler, ?object $args = null, ?object $options = null): int + { + $value = serialize((object)[ + 'handler' => $handler, + 'args' => $args, + 'options' => $options, + ]); + + if ($this->_conn) + { + $result = $this->_conn->rPush($this->_key, $value); + return intval($result); + } + else + { + return 0; + } + } + + /** + * Get the first task. + * + * @param int $blocking + * @return ?object + */ + public function getTask(int $blocking = 0): ?object + { + if ($this->_conn) + { + if ($blocking > 0) + { + $result = $this->_conn->blpop($this->_key, $blocking); + if (is_array($result) && isset($result[1])) + { + return unserialize($result[1]); + } + else + { + return null; + } + } + else + { + $result = $this->_conn->lpop($this->_key); + if ($result) + { + return unserialize($result); + } + else + { + return null; + } + } + } + else + { + return null; + } + } +} diff --git a/common/framework/drivers/sms/solapi.php b/common/framework/drivers/sms/solapi.php index 9cef5dbd7..b87f8e6cf 100644 --- a/common/framework/drivers/sms/solapi.php +++ b/common/framework/drivers/sms/solapi.php @@ -132,6 +132,11 @@ class SolAPI extends Base implements \Rhymix\Framework\Drivers\SMSInterface } } + foreach ($original->getExtraVars() as $key => $value) + { + $options->$key = $value; + } + $data['messages'][] = $options; } } diff --git a/common/framework/filters/FilenameFilter.php b/common/framework/filters/FilenameFilter.php index a07b997b0..228f9227c 100644 --- a/common/framework/filters/FilenameFilter.php +++ b/common/framework/filters/FilenameFilter.php @@ -15,6 +15,9 @@ class FilenameFilter */ public static function clean(string $filename): string { + // Clean up unnecessary encodings. + $filename = strtr($filename, ['&' => '&', ''' => "'"]); + // Replace dangerous characters with safe alternatives, maintaining meaning as much as possible. $illegal = array('\\', '/', '<', '>', '{', '}', ':', ';', '|', '"', '~', '`', '$', '%', '^', '*', '?'); $replace = array('', '', '(', ')', '(', ')', '_', ',', '_', '', '_', '\'', '_', '_', '_', '', ''); @@ -31,9 +34,6 @@ class FilenameFilter $filename = preg_replace('/__+/', '_', $filename); $filename = preg_replace('/\.\.+/', '.', $filename); - // Clean up unnecessary encodings. - $filename = strtr($filename, array('&' => '&')); - // Change .php files to .phps to make them non-executable. if (strtolower(substr($filename, strlen($filename) - 4)) === '.php') { diff --git a/common/js/plugins/jquery.fileupload/js/main.js b/common/js/plugins/jquery.fileupload/js/main.js index 645f115ef..8acdb7b53 100644 --- a/common/js/plugins/jquery.fileupload/js/main.js +++ b/common/js/plugins/jquery.fileupload/js/main.js @@ -353,12 +353,12 @@ unselectNonImageFiles: function() {}, generateHtml: function($container, file) { - var filename = String(file.source_filename); + var filename = String(file.source_filename).escape(); var html = ''; var data = $container.data(); if (filename.match(/\.(gif|jpe?g|png|webp)$/i)) { - html = '' + file.source_filename + ''; } else if (filename.match(/\.(mp3|wav|ogg|flac|aac)$/i)) { @@ -388,7 +388,7 @@ } if (html === '') { - html += '' + file.source_filename + '\n'; + html += '' + filename + '\n'; } return html; @@ -405,7 +405,6 @@ try { var textarea = _getCkeContainer(data.editorSequence).find('.cke_source'); var editor = _getCkeInstance(data.editorSequence); - console.log(textarea, editor.mode); if (textarea.length && editor.mode == 'source') { var caretPosition = textarea[0].selectionStart; var currentText = textarea[0].value; @@ -516,7 +515,7 @@ $container.data(data); file.thumbnail_url = file.download_url; - file.source_filename = file.source_filename.replace("&", "&"); + file.source_filename = file.source_filename.replace("&", "&").replace("'", "'"); if(file.thumbnail_filename) { file.thumbnail_url = file.thumbnail_filename; diff --git a/common/js/plugins/ui.tree/jquery.simple.tree.js b/common/js/plugins/ui.tree/jquery.simple.tree.js index f8eb9d225..9fcdb7c8a 100644 --- a/common/js/plugins/ui.tree/jquery.simple.tree.js +++ b/common/js/plugins/ui.tree/jquery.simple.tree.js @@ -132,7 +132,6 @@ $.fn.simpleTree = function(opt){ } }); } - } }; TREE.setTreeNodes = function(obj, useParent){ @@ -226,10 +225,7 @@ $.fn.simpleTree = function(opt){ trigger.click(function(event){ TREE.nodeToggle(node); }); - if(!$.browser.msie) - { - trigger.css('float','left'); - } + trigger.css('float','left'); }; TREE.dragStart = function(event){ var LI = $(event.data.LI); diff --git a/common/js/xml_handler.js b/common/js/xml_handler.js index c69bd0f4f..340251e79 100644 --- a/common/js/xml_handler.js +++ b/common/js/xml_handler.js @@ -214,11 +214,13 @@ var url = XE.URI(request_uri).pathname(); var action_parts = action.split('.'); var request_info; - if (action === 'raw') { + + if (params instanceof FormData) { + request_info = (params.get('module') || params.get('mid')) + '.' + params.get('act'); + } else if (action === 'raw') { request_info = 'RAW FORM SUBMISSION'; } else { params = params ? ($.isArray(params) ? arr2obj(params) : params) : {}; - //if (action_parts.length != 2) return; params.module = action_parts[0]; params.act = action_parts[1]; request_info = params.module + "." + params.act; @@ -342,7 +344,7 @@ // Send the AJAX request. try { - $.ajax({ + var args = { type: "POST", dataType: "json", url: url, @@ -351,7 +353,11 @@ headers : headers, success : successHandler, error : errorHandler - }); + }; + if (params instanceof FormData) { + args.contentType = false; + } + $.ajax(args); } catch(e) { alert(e); return; @@ -376,7 +382,6 @@ * Function for AJAX submission of arbitrary forms. */ XE.ajaxForm = function(form, callback_success, callback_error) { - // Abort if the form already has a 'target' attribute. form = $(form); // Get success and error callback functions. if (typeof callback_success === 'undefined') { @@ -402,43 +407,10 @@ callback_error = null; } } - // Set _rx_ajax_form flag - if (!form.find('input[name=_rx_ajax_form]').size()) { - form.append(''); - setTimeout(function() { - form.find('input[name=_rx_ajax_form]').remove(); - }, 1000); - } - // If the form has file uploads, use a hidden iframe to submit. Otherwise use exec_json. - var has_files = form.find('input[type=file][name!=Filedata]').size(); - if (has_files) { - var iframe_id = '_rx_temp_iframe_' + (new Date()).getTime(); - $('').appendTo($(document.body)); - form.attr('method', 'POST').attr('enctype', 'multipart/form-data').attr('target', iframe_id); - form.find('input[name=_rx_ajax_form]').val(iframe_id); - window.XE.handleIframeResponse = function(iframe_id, data) { - if (data.error) { - if (callback_error) { - callback_error(data); - } else { - alert(data.message); - } - } else { - callback_success(data); - } - if (iframe_id.match(/^_rx_temp_iframe_[0-9]+$/)) { - $('iframe#' + iframe_id).remove(); - } - }; - setTimeout(function() { - form.removeAttr('target'); - }, 1000); - form.submit(); - } else { - window.exec_json('raw', form.serialize(), callback_success, callback_error); - } + window.exec_json('raw', new FormData(form[0]), callback_success, callback_error); }; $(document).on('submit', 'form.rx_ajax', function(event) { + // Abort if the form already has a 'target' attribute. if (!$(this).attr('target')) { event.preventDefault(); XE.ajaxForm(this); diff --git a/common/js/xml_js_filter.js b/common/js/xml_js_filter.js index 3c16bf8b1..414c175d8 100644 --- a/common/js/xml_js_filter.js +++ b/common/js/xml_js_filter.js @@ -415,6 +415,7 @@ function legacy_filter(filter_name, form, module, act, callback, responses, conf args[0] = filter_name; args[1] = function(f) { + var hasFile = false; var params = {}, res = [], elms = f.elements, data = $(f).serializeArray(); $.each(data, function(i, field) { var v = $.trim(field.value), n = field.name; @@ -435,11 +436,20 @@ function legacy_filter(filter_name, form, module, act, callback, responses, conf } }); + $(f).find('input[type=file][name^=extra_vars]').each(function() { + if (this.files && this.files[0]) { + params[this.name] = this.files[0]; + hasFile = true; + } + }); + if (confirm_msg && !confirm(confirm_msg)) return false; - //exec_xml(module, act, params, callback, responses, params, form); + params['module'] = module; + params['act'] = act; params['_rx_ajax_compat'] = 'XMLRPC'; - exec_json(module + '.' + act, params, function(result) { + + var callback_wrapper = function(result) { if (!result) { result = {}; } @@ -454,7 +464,17 @@ function legacy_filter(filter_name, form, module, act, callback, responses, conf }); callback(filtered_result, responses, params, form); } - }); + }; + + if (!hasFile) { + exec_json(module + '.' + act, params, callback_wrapper); + } else { + var fd = new FormData(); + for (let key in params) { + fd.append(key, params[key]); + } + exec_json('raw', fd, callback_wrapper); + } }; v.cast('ADD_CALLBACK', args); diff --git a/common/lang/en.php b/common/lang/en.php index 5447dd55d..ba6fd677d 100644 --- a/common/lang/en.php +++ b/common/lang/en.php @@ -328,6 +328,8 @@ $lang->column_type_list['language'] = 'Language'; $lang->column_type_list['date'] = 'Date'; $lang->column_type_list['time'] = 'Time'; $lang->column_type_list['timezone'] = 'Time zone'; +$lang->column_type_list['number'] = 'Number'; +$lang->column_type_list['file'] = 'File upload'; $lang->column_name = 'Column Name'; $lang->column_title = 'Column Title'; $lang->default_value = 'Default Value'; diff --git a/common/lang/ko.php b/common/lang/ko.php index e2a844cfd..592855710 100644 --- a/common/lang/ko.php +++ b/common/lang/ko.php @@ -330,6 +330,8 @@ $lang->column_type_list['language'] = '언어'; $lang->column_type_list['date'] = '날짜'; $lang->column_type_list['time'] = '시간'; $lang->column_type_list['timezone'] = '표준 시간대'; +$lang->column_type_list['number'] = '숫자'; +$lang->column_type_list['file'] = '파일 업로드'; $lang->column_name = '입력항목 이름'; $lang->column_title = '입력항목 제목'; $lang->default_value = '기본값'; diff --git a/common/scripts/cron.php b/common/scripts/cron.php new file mode 100644 index 000000000..563f71c70 --- /dev/null +++ b/common/scripts/cron.php @@ -0,0 +1,91 @@ + 1 && function_exists('pcntl_fork') && function_exists('pcntl_waitpid')) +{ + // This array will keep a dictionary of subprocesses. + $pids = []; + + // The database connection must be closed before forking. + Rhymix\Framework\DB::getInstance()->disconnect(); + Rhymix\Framework\Debug::disable(); + + // Create the required number of subprocesses. + for ($i = 0; $i < $process_count; $i++) + { + $pid = pcntl_fork(); + if ($pid > 0) + { + $pids[$pid] = true; + usleep(200000); + } + elseif ($pid == 0) + { + Rhymix\Framework\Queue::process($timeout); + exit; + } + else + { + error_log('RxQueue: could not fork!'); + exit; + } + } + + // The parent process waits for its children to finish. + while (count($pids)) + { + $pid = pcntl_waitpid(-1, $status, \WNOHANG); + if ($pid) + { + unset($pids[$pid]); + } + usleep(200000); + } +} +else +{ + Rhymix\Framework\Queue::process($timeout); +} diff --git a/modules/addon/tpl/addon_list.html b/modules/addon/tpl/addon_list.html index 0bd1f0c99..cd3b702fa 100644 --- a/modules/addon/tpl/addon_list.html +++ b/modules/addon/tpl/addon_list.html @@ -27,7 +27,6 @@ {$lang->cmd_setup} PC Mobile - {$lang->cmd_delete} @@ -61,7 +60,6 @@ - {$lang->cmd_delete} diff --git a/modules/admin/conf/module.xml b/modules/admin/conf/module.xml index 6d1481ca8..6c3b57e87 100644 --- a/modules/admin/conf/module.xml +++ b/modules/admin/conf/module.xml @@ -34,6 +34,8 @@ + + diff --git a/modules/admin/controllers/systemconfig/Queue.php b/modules/admin/controllers/systemconfig/Queue.php new file mode 100644 index 000000000..1422dbbc5 --- /dev/null +++ b/modules/admin/controllers/systemconfig/Queue.php @@ -0,0 +1,135 @@ + '127.0.0.1', + 'port' => '6379', + 'dbnum' => 0, + ]); + } + + $this->setTemplateFile('config_queue'); + } + + /** + * Update notification configuration. + */ + public function procAdminUpdateQueue() + { + $vars = Context::getRequestVars(); + + // Enabled? + $enabled = $vars->queue_enabled === 'Y'; + + // Validate the driver. + $drivers = RFQueue::getSupportedDrivers(); + $driver = trim($vars->queue_driver); + if (!array_key_exists($driver, $drivers)) + { + throw new Exception('msg_queue_driver_not_found'); + } + if ($enabled && (!$driver || $driver === 'dummy')) + { + throw new Exception('msg_queue_driver_cannot_be_dummy'); + } + + // Validate required and optional driver settings. + $driver_config = []; + foreach ($drivers[$driver]['required'] as $conf_name) + { + $conf_value = trim($vars->{'queue_' . $driver . '_' . $conf_name} ?? ''); + if ($conf_value === '') + { + throw new Exception('msg_queue_invalid_config'); + } + $driver_config[$conf_name] = $conf_value === '' ? null : $conf_value; + } + foreach ($drivers[$driver]['optional'] as $conf_name) + { + $conf_value = trim($vars->{'queue_' . $driver . '_' . $conf_name} ?? ''); + $driver_config[$conf_name] = $conf_value === '' ? null : $conf_value; + } + + // Validate the interval. + $interval = intval($vars->queue_interval ?? 1); + if ($interval < 1 || $interval > 10) + { + throw new Exception('msg_queue_invalid_interval'); + } + + // Validate the process count. + $process_count = intval($vars->queue_process_count ?? 1); + if ($process_count < 1 || $process_count > 10) + { + throw new Exception('msg_queue_invalid_process_count'); + } + + // Validate the key. + $key = trim($vars->queue_key ?? ''); + if (strlen($key) < 16 || !ctype_alnum($key)) + { + throw new Exception('msg_queue_invalid_key'); + } + + // Validate actual operation of the driver. + $driver_class = '\\Rhymix\\Framework\\Drivers\\Queue\\' . $driver; + if (!class_exists($driver_class) || !$driver_class::isSupported()) + { + throw new Exception('msg_queue_driver_not_found'); + } + if (!$driver_class::validateConfig($driver_config)) + { + throw new Exception('msg_queue_driver_not_usable'); + } + + + // Save system config. + Config::set("queue.enabled", $enabled); + Config::set("queue.driver", $driver); + Config::set("queue.interval", $interval); + Config::set("queue.process_count", $process_count); + Config::set("queue.key", $key); + Config::set("queue.$driver", $driver_config); + if (!Config::save()) + { + throw new Exception('msg_failed_to_save_config'); + } + + $this->setMessage('success_updated'); + $this->setRedirectUrl(Context::get('success_return_url') ?: getNotEncodedUrl('', 'module', 'admin', 'act', 'dispAdminConfigQueue')); + } +} diff --git a/modules/admin/lang/en.php b/modules/admin/lang/en.php index fd86f1d05..a2ff0abbb 100644 --- a/modules/admin/lang/en.php +++ b/modules/admin/lang/en.php @@ -8,6 +8,7 @@ $lang->subtitle_security = 'Security'; $lang->subtitle_advanced = 'Advanced'; $lang->subtitle_domains = 'Domains'; $lang->subtitle_debug = 'Debug'; +$lang->subtitle_queue = 'Async Queue'; $lang->subtitle_seo = 'SEO Settings'; $lang->subtitle_etc = 'Other Settings'; $lang->current_state = 'Current state'; @@ -268,7 +269,7 @@ $lang->about_seo_main_title = 'This format will be used for the title of the mai $lang->seo_subpage_title = 'Subpage Title'; $lang->about_seo_subpage_title = 'This format will be used for the title of lists and other major components of your website.
In additions to the variables above, you can use $PAGE.'; $lang->seo_document_title = 'Document Page Title'; -$lang->about_seo_document_title = 'This format will be used for the title of individual documents.
In additions to the variables above, you can use $DOCUMENT_TITLE.'; +$lang->about_seo_document_title = 'This format will be used for the title of individual documents.
In additions to the variables above, you can use $CATEGORY and $DOCUMENT_TITLE.'; $lang->site_meta_keywords = 'SEO Keywords'; $lang->about_site_meta_keywords = 'These keywords will be used on pages that do not have their own keywords.'; $lang->site_meta_description = 'SEO Description'; @@ -282,6 +283,36 @@ $lang->og_extract_images_fallback = 'Use site default image only'; $lang->og_extract_hashtags = 'Extract Hashtags from Document'; $lang->og_use_nick_name = 'Include Author Name'; $lang->og_use_timestamps = 'Include Timestamps'; +$lang->cmd_queue_description = 'Improve response times by processing time-consuming tasks, such as sending notifications, asynchronously.
This is an experimental feature. It may not be stable depending on your hosting environment.'; +$lang->cmd_queue_enabled = 'Use Task Queue'; +$lang->cmd_queue_enabled_help = 'The task queue will stop accepting new tasks if you uncheck the above.'; +$lang->cmd_queue_driver = 'Queue Driver'; +$lang->cmd_queue_driver_help = 'Select the driver for the task queue that suits your hosting environment and website needs.
Some drivers such as Redis will need the corresponding program to be installed on the server.'; +$lang->cmd_queue_interval = 'Calling Interval'; +$lang->cmd_queue_interval_help = 'Use a scheduler such as crontab or systemd timer to call the script on a set interval.
All tasks are processed as soon as possible regardless of the interval, but a short interval means quick recovery from any error.
For web-based cron, this should not exceed the max_execution_time setting in php.ini.
The max_execution_time on this server is %d seconds.'; +$lang->cmd_queue_process_count = 'Process Count'; +$lang->cmd_queue_process_count_help = 'Use multiple processes to increase throughput. This may increase server load significantly.
Keep a value of 1 unless you have a high-performance dedicated server.
Multiprocessing is not supported when using web-based cron.'; +$lang->cmd_queue_call_script = 'Processing Script'; +$lang->cmd_queue_webcron_key = 'Webcron Auth Key'; +$lang->cmd_queue_config_keys['host'] = 'Host'; +$lang->cmd_queue_config_keys['port'] = 'Port'; +$lang->cmd_queue_config_keys['user'] = 'User'; +$lang->cmd_queue_config_keys['pass'] = 'Password'; +$lang->cmd_queue_config_keys['dbnum'] = 'DB Number'; +$lang->msg_queue_instructions['same_as_php'] = '(same as PHP)'; +$lang->msg_queue_instructions['crontab1'] = 'Log into the server as the %s account and run crontab -e to paste the following content into your crontab. (DO NOT run it as root!)
The %s directory in the example should be replaced with a path where logs can be recorded.'; +$lang->msg_queue_instructions['crontab2'] = 'If you change the calling interval below, the crontab interval must be adjusted accordingly.'; +$lang->msg_queue_instructions['webcron'] = 'Configure an external cron service to make a GET request to the following URL every minute, or following the interval set below.
Check your logs to make sure that the cron service is reaching your website.'; +$lang->msg_queue_instructions['systemd1'] = 'Put the following content in /etc/systemd/system/rhymix-queue.service'; +$lang->msg_queue_instructions['systemd2'] = 'Put the following content in /etc/systemd/system/rhymix-queue.timer'; +$lang->msg_queue_instructions['systemd3'] = 'Execute the following commands to enable the timer, and monitor your journal to make sure that it is operating as scheduled.'; +$lang->msg_queue_driver_not_found = 'The selected task queue driver is not supported on this server.'; +$lang->msg_queue_driver_not_usable = 'The selected task queue driver failed to initialize using the configuration values you entered.'; +$lang->msg_queue_driver_cannot_be_dummy = 'In otder to use the task queue, you must select a driver other than "Not use"'; +$lang->msg_queue_invalid_config = 'Missing or invalid configuration for the selected queue driver.'; +$lang->msg_queue_invalid_interval = 'The calling interval must be between 1 and 10 minutes.'; +$lang->msg_queue_invalid_process_count = 'The process count must be between 1 and 10.'; +$lang->msg_queue_invalid_key = 'The webcron auth key must be at least 16 characters long, and only consist of alphanumeric characters.'; $lang->autoinstall = 'EasyInstall'; $lang->last_week = 'Last Week'; $lang->this_week = 'This Week'; diff --git a/modules/admin/lang/ko.php b/modules/admin/lang/ko.php index 2f754dac0..7fd335612 100644 --- a/modules/admin/lang/ko.php +++ b/modules/admin/lang/ko.php @@ -7,6 +7,7 @@ $lang->subtitle_notification = '알림 설정'; $lang->subtitle_security = '보안 설정'; $lang->subtitle_advanced = '고급 설정'; $lang->subtitle_debug = '디버그 설정'; +$lang->subtitle_queue = '비동기 작업'; $lang->subtitle_seo = 'SEO 설정'; $lang->subtitle_etc = '기타'; $lang->current_state = '현황'; @@ -264,7 +265,7 @@ $lang->about_seo_main_title = '사이트 메인 화면에 표시되는 제목 $lang->seo_subpage_title = '서브페이지 제목'; $lang->about_seo_subpage_title = '문서 목록, 페이지 등 주요 메뉴를 방문하면 표시되는 제목 형태입니다. 위의 변수들과 함께 $PAGE (페이지)도 사용할 수 있습니다.'; $lang->seo_document_title = '개별 문서 페이지 제목'; -$lang->about_seo_document_title = '게시물을 읽는 화면에서 표시되는 제목 형태입니다. 위에 변수들과 함께 $DOCUMENT_TITLE (문서 제목)도 사용할 수 있습니다.'; +$lang->about_seo_document_title = '게시물을 읽는 화면에서 표시되는 제목 형태입니다. 위에 변수들과 함께 $CATEGORY (카테고리명), $DOCUMENT_TITLE (문서 제목)도 사용할 수 있습니다.'; $lang->site_meta_keywords = 'SEO 키워드'; $lang->about_site_meta_keywords = '별도의 키워드를 지정하지 않은 페이지에서는 이 키워드 목록이 표시됩니다.'; $lang->site_meta_description = 'SEO 설명'; @@ -278,6 +279,36 @@ $lang->og_extract_images_fallback = '사이트 대표 이미지 사용'; $lang->og_extract_hashtags = '본문에서 해시태그 추출'; $lang->og_use_nick_name = '글 작성자 이름 표시'; $lang->og_use_timestamps = '글 작성/수정 시각 표시'; +$lang->cmd_queue_description = '메일 발송, 푸시알림 등 시간이 오래 걸리거나 외부 서비스와 연동하는 작업을 비동기 처리하여 응답 속도를 개선합니다.
실험적인 기능입니다. 호스팅 환경에 따라서는 안정적으로 작동하지 않을 수도 있습니다.'; +$lang->cmd_queue_enabled = '비동기 작업 사용'; +$lang->cmd_queue_enabled_help = '체크를 해제하면 더이상 작업을 접수하지 않습니다.'; +$lang->cmd_queue_driver = '비동기 드라이버'; +$lang->cmd_queue_driver_help = '비동기 작업을 관리할 방법을 설정합니다. 호스팅 환경과 사이트의 필요에 맞추어 선택하세요.
Redis 등 일부 드라이버는 서버에 해당 기능이 설치되어 있어야 사용할 수 있습니다.'; +$lang->cmd_queue_interval = '호출 간격'; +$lang->cmd_queue_interval_help = 'crontab, systemd timer, 웹크론 등을 사용하여 일정한 주기로 스크립트를 호출해 주십시오.
모든 비동기 작업은 호출 간격과 무관하게 실시간으로 처리되나, 호출 간격이 짧으면 장애 발생시 신속하게 복구됩니다.
웹크론 사용시에는 php.ini의 실행 시간 제한을 초과하지 않는 것이 좋습니다.
이 서버의 max_execution_time은 %d초로 설정되어 있습니다.'; +$lang->cmd_queue_process_count = '프로세스 갯수'; +$lang->cmd_queue_process_count_help = '여러 개의 프로세스를 동시에 실행하여 처리 용량을 늘립니다. 서버 부하가 증가할 수 있습니다.
고성능 단독서버가 아니라면 1을 유지하시기 바랍니다.
웹크론으로 호출한 경우에는 멀티프로세싱을 지원하지 않습니다.'; +$lang->cmd_queue_call_script = '작업 처리 스크립트'; +$lang->cmd_queue_webcron_key = '웹크론 인증키'; +$lang->cmd_queue_config_keys['host'] = '호스트'; +$lang->cmd_queue_config_keys['port'] = '포트'; +$lang->cmd_queue_config_keys['user'] = '아이디'; +$lang->cmd_queue_config_keys['pass'] = '암호'; +$lang->cmd_queue_config_keys['dbnum'] = 'DB 번호'; +$lang->msg_queue_instructions['same_as_php'] = 'PHP를 실행하는 계정과 동일한'; +$lang->msg_queue_instructions['crontab1'] = '%s 계정으로 서버에 로그인하여 crontab -e 명령을 실행한 후, 아래의 내용을 붙여넣으십시오. (root 권한으로 실행하지 마십시오.)
예제의 %s 디렉토리는 로그를 기록할 수 있는 경로로 변경하여 사용하십시오.'; +$lang->msg_queue_instructions['crontab2'] = '스크립트 호출 간격을 변경할 경우, 설정에 맞추어 crontab 실행 간격도 조절하여야 합니다.'; +$lang->msg_queue_instructions['webcron'] = '아래의 URL을 1분 간격 또는 아래에서 설정한 호출 간격에 맞추어 GET으로 호출하도록 합니다.
웹크론 서비스가 방화벽이나 CDN 등에 의해 차단되지 않도록 주의하고, 정상적으로 호출되는지 서버 로그를 확인하십시오.'; +$lang->msg_queue_instructions['systemd1'] = '/etc/systemd/system/rhymix-queue.service 파일에 아래와 같은 내용을 넣습니다.'; +$lang->msg_queue_instructions['systemd2'] = '/etc/systemd/system/rhymix-queue.timer 파일에 아래와 같은 내용을 넣습니다.'; +$lang->msg_queue_instructions['systemd3'] = '아래의 명령을 실행하여 타이머를 활성화하고, 정상 작동하는지 모니터링하십시오.'; +$lang->msg_queue_driver_not_found = '이 서버에서 지원하지 않는 비동기 드라이버입니다.'; +$lang->msg_queue_driver_not_usable = '입력하신 정보로 비동기 드라이버와 연동하는 데 실패했습니다. 드라이버 설정을 확인해 주십시오.'; +$lang->msg_queue_driver_cannot_be_dummy = '비동기 작업을 사용하려면 "미사용" 이외의 드라이버를 선택해야 합니다.'; +$lang->msg_queue_invalid_config = '비동기 드라이버의 필수 설정이 누락되었습니다.'; +$lang->msg_queue_invalid_interval = '호출 간격은 1~10분 이내여야 합니다.'; +$lang->msg_queue_invalid_process_count = '프로세스 갯수는 1~10개 이내여야 합니다.'; +$lang->msg_queue_invalid_key = '웹크론 인증키는 16자 이상으로, 알파벳 대소문자와 숫자만으로 이루어져야 합니다.'; $lang->autoinstall = '쉬운 설치'; $lang->last_week = '지난주'; $lang->this_week = '이번주'; diff --git a/modules/admin/tpl/config_header.html b/modules/admin/tpl/config_header.html index a8e7e7a1c..84e8b3a25 100644 --- a/modules/admin/tpl/config_header.html +++ b/modules/admin/tpl/config_header.html @@ -12,5 +12,6 @@
  • {$lang->subtitle_advanced}
  • {$lang->subtitle_debug}
  • {$lang->subtitle_seo}
  • +
  • {$lang->subtitle_queue}
  • {$lang->subtitle_sitelock}
  • diff --git a/modules/admin/tpl/config_queue.html b/modules/admin/tpl/config_queue.html new file mode 100644 index 000000000..eda100a42 --- /dev/null +++ b/modules/admin/tpl/config_queue.html @@ -0,0 +1,195 @@ + + + + + + +
    +

    {$lang->cmd_queue_description}

    +
    + +
    +

    {$XE_VALIDATOR_MESSAGE}

    +
    + + + +
    + + + + +
    + +

    {$lang->subtitle_queue}

    + +
    + +
    + +
    +

    {$lang->cmd_queue_enabled_help}

    +
    +
    + +
    + +
    + +

    {$lang->cmd_queue_driver_help}

    +
    +
    + + + + {@ $conf_names = array_merge($driver_definition['required'], $driver_definition['optional'])} + + + + {@ $conf_value = escape(config("queue.$driver_name.$conf_name"))} + {@ $text_keys = ['host', 'user']} + {@ $number_keys = ['port', 'dbnum']} + {@ $password_keys = ['pass']} + + +
    + +
    + +
    +
    + + + +
    + +
    + +
    +
    + + + +
    + +
    + +
    +
    + + + + + + +
    + +
    + +

    {$lang->cmd_queue_call_script}

    + +
    + +
    + {@ + if (function_exists('posix_getpwuid') && function_exists('posix_getuid')): + $user_info = posix_getpwuid(posix_getuid()); + if (!empty($user_info['dir'])): + $user_info['dir'] .= DIRECTORY_SEPARATOR; + endif; + else: + $user_info = []; + $user_info['name'] = $lang->msg_queue_instructions['same_as_php']; + endif; + } +

    + {sprintf($lang->msg_queue_instructions['crontab1'], $user_info['name'] ?? 'PHP', $user_info['dir'] . 'logs')|noescape} +

    +
    * * * * * php {\RX_BASEDIR}index.php common.cron >> {$user_info['dir']}logs{\DIRECTORY_SEPARATOR}queue.log 2>&1
    +

    + {$lang->msg_queue_instructions['crontab2']|noescape} +

    +
    +
    +

    + {$lang->msg_queue_instructions['webcron']|noescape} +

    +
    {getFullUrl('')}common/scripts/cron.php?key={config('queue.key')}
    +
    +
    +

    + {$lang->msg_queue_instructions['systemd1']|noescape} +

    +
    [Unit]
    +Description=Rhymix Queue Service
    +
    +[Service]
    +ExecStart=php {\RX_BASEDIR}index.php common.cron
    +User={$user_info['name']}
    +

    + {$lang->msg_queue_instructions['systemd2']|noescape} +

    +
    [Unit]
    +Description=Rhymix Queue Timer
    +
    +[Timer]
    +OnCalendar=*-*-* *:*:00
    +Unit=rhymix-queue.service
    +
    +[Install]
    +WantedBy=multi-user.target
    +

    + {$lang->msg_queue_instructions['systemd3']|noescape} +

    +
    systemctl daemon-reload
    +systemctl start rhymix-queue.timer
    +systemctl enable rhymix-queue.timer
    +
    +
    + +
    + +
    + +
    +
    + +
    + +
    + + {$lang->unit_min} +
    +

    {sprintf($lang->cmd_queue_interval_help, ini_get('max_execution_time'))|noescape}

    +
    +
    + +
    + +
    + +

    {$lang->cmd_queue_process_count_help}

    +
    +
    + +
    + +
    +
    + +
    +
    +
    diff --git a/modules/admin/tpl/css/queue_config.scss b/modules/admin/tpl/css/queue_config.scss new file mode 100644 index 000000000..c11f8df01 --- /dev/null +++ b/modules/admin/tpl/css/queue_config.scss @@ -0,0 +1,27 @@ +.queue-script-setup { + .qss-content { + display: none; + &.active { + display: block; + border: 1px solid #ddd; + border-top: 0; + margin-top: -20px; + padding: 20px 12px 10px 12px; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + margin-bottom: 20px; + } + .qss-instruction { + margin-bottom: 10px; + code { + color: #333; + border: 0; + background-color: transparent; + padding: 0 1px; + } + } + .pre { + margin-bottom: 10px; + } + } +} diff --git a/modules/admin/tpl/js/queue_config.js b/modules/admin/tpl/js/queue_config.js new file mode 100644 index 000000000..f29291b52 --- /dev/null +++ b/modules/admin/tpl/js/queue_config.js @@ -0,0 +1,33 @@ +(function($) { + $(function() { + + $("#queue_driver").on("change", function() { + const selected_driver = $(this).val(); + $(this).parents("section").find("div.x_control-group.hidden-by-default, p.x_help-block.hidden-by-default").each(function() { + if ($(this).hasClass("show-for-" + selected_driver)) { + $(this).show(); + } else { + $(this).hide(); + } + }); + }).triggerHandler("change"); + + $("#queue_key").on('change keyup paste', function() { + const key = encodeURIComponent(String($(this).val())); + $('.webcron-url').text($('.webcron-url').text().replace(/\?key=[a-zA-Z0-9]+/g, '?key=' + key)); + }); + + const qss = $('.queue-script-setup'); + const qss_tabs = qss.find('.qss-tabs'); + const qss_content = qss.find('.qss-content'); + qss_tabs.on('click', 'a', function(event) { + const selected_tab = $(this).data('value'); + qss_tabs.find('li').removeClass('x_active'); + $(this).parent().addClass('x_active'); + qss_content.removeClass('active'); + qss_content.filter('.' + selected_tab).addClass('active'); + event.preventDefault(); + }); + + }); +})(jQuery); diff --git a/modules/board/board.controller.php b/modules/board/board.controller.php index 4d11d8bb5..580bd11b1 100644 --- a/modules/board/board.controller.php +++ b/modules/board/board.controller.php @@ -63,6 +63,17 @@ class BoardController extends Board throw new Rhymix\Framework\Exception('msg_content_too_long'); } + // Return error if content conains excessively large data URLs. + $inline_data_url_limit = ($this->module_info->inline_data_url_limit ?: 64) * 1024; + preg_match_all('!src="\s*(data:[^,]*,[a-z0-9+/=%$!._-]+)!i', (string)$obj->content, $matches); + foreach ($matches[1] as $match) + { + if (strlen($match) > $inline_data_url_limit) + { + throw new Rhymix\Framework\Exception('msg_data_url_restricted'); + } + } + // Check category $category_list = DocumentModel::getCategoryList($this->module_srl); if (count($category_list) > 0) @@ -472,6 +483,17 @@ class BoardController extends Board throw new Rhymix\Framework\Exception('msg_content_too_long'); } + // Return error if content conains excessively large data URLs. + $inline_data_url_limit = ($this->module_info->inline_data_url_limit ?: 64) * 1024; + preg_match_all('!src="\s*(data:[^,]*,[a-z0-9+/=%$!._-]+)!i', (string)$obj->content, $matches); + foreach ($matches[1] as $match) + { + if (strlen($match) > $inline_data_url_limit) + { + throw new Rhymix\Framework\Exception('msg_data_url_restricted'); + } + } + if(!$this->module_info->use_status) $this->module_info->use_status = 'PUBLIC'; if(!is_array($this->module_info->use_status)) { diff --git a/modules/board/board.view.php b/modules/board/board.view.php index 5ed22321e..4969b3f72 100644 --- a/modules/board/board.view.php +++ b/modules/board/board.view.php @@ -200,6 +200,12 @@ class BoardView extends Board // list $this->dispBoardContentList(); + // Board features + $oDocument = Context::get('oDocument'); + $document_module_srl = ($oDocument && $oDocument->isExists()) ? $oDocument->get('module_srl') : $this->module_srl; + $board_features = Rhymix\Modules\Board\Models\Features::fromModuleInfo($this->module_info, $document_module_srl); + Context::set('board_features', $board_features); + /** * add javascript filters **/ @@ -226,7 +232,23 @@ class BoardView extends Board return; } - Context::set('category_list', DocumentModel::getCategoryList($this->module_srl)); + // Get category list for documents belong to other modules. (i.e. submodule in combined board) + if (empty($this->include_modules)) + { + $category_list = DocumentModel::getCategoryList($this->module_srl); + } + else + { + $category_list = DocumentModel::getCategoryList($this->module_srl); + foreach ($this->include_modules as $module_srl) + { + if ($module_srl != $this->module_srl) + { + $category_list += DocumentModel::getCategoryList($module_srl); + } + } + } + Context::set('category_list', $category_list); $oSecurity = new Security(); $oSecurity->encodeHTML('category_list.', 'category_list.childs.'); @@ -323,11 +345,13 @@ class BoardView extends Board Context::setCanonicalURL($oDocument->getPermanentUrl()); $seo_title = config('seo.document_title') ?: '$SITE_TITLE - $DOCUMENT_TITLE'; $seo_title = Context::replaceUserLang($seo_title); + $category_list = Context::get('category_list'); Context::setBrowserTitle($seo_title, array( 'site_title' => Context::getSiteTitle(), 'site_subtitle' => Context::getSiteSubtitle(), 'subpage_title' => $this->module_info->browser_title, 'document_title' => $oDocument->getTitleText(), + 'category' => ($oDocument->get('category_srl') && isset($category_list[$oDocument->get('category_srl')])) ? $category_list[$oDocument->get('category_srl')]->title : '', 'page' => Context::get('page') ?: 1, )); @@ -548,7 +572,7 @@ class BoardView extends Board // set the current page of documents $document_srl = Context::get('document_srl'); - if($document_srl && $this->module_info->skip_bottom_list_for_robot === 'Y' && isCrawler()) + if($document_srl && $this->module_info->skip_bottom_list_for_robot !== 'N' && isCrawler()) { Context::set('page', $args->page = null); } @@ -1126,6 +1150,17 @@ class BoardView extends Board return $this->dispBoardMessage('msg_not_founded', 404); } + // Check thread depth + $comment_config = ModuleModel::getModulePartConfig('comment', $this->module_srl); + if (isset($comment_config->max_thread_depth) && $comment_config->max_thread_depth > 0) + { + $parent_depth = CommentModel::getCommentDepth($parent_srl); + if ($parent_depth + 2 > $comment_config->max_thread_depth) + { + return $this->dispBoardMessage('msg_exceeds_max_thread_depth'); + } + } + // Check allow comment $oDocument = DocumentModel::getDocument($oSourceComment->get('document_srl')); if(!$oDocument->allowComment()) diff --git a/modules/board/conf/module.xml b/modules/board/conf/module.xml index cdcec2fab..717fb91cb 100644 --- a/modules/board/conf/module.xml +++ b/modules/board/conf/module.xml @@ -58,6 +58,7 @@ + diff --git a/modules/board/lang/en.php b/modules/board/lang/en.php index ec3ad2bc5..c26790353 100644 --- a/modules/board/lang/en.php +++ b/modules/board/lang/en.php @@ -15,6 +15,7 @@ $lang->last_post = 'Last post'; $lang->board_management = 'Board Management'; $lang->search_result = 'Search Result'; $lang->consultation = 'Consultation'; +$lang->use_consultation = 'Use as Consultation Board'; $lang->secret = 'Secret'; $lang->thisissecret = 'This is a secret article.'; $lang->admin_mail = 'Administrator\'s Mail'; @@ -36,7 +37,7 @@ $lang->about_use_anonymous_part2 = 'It is more useful if you also hide the nickn $lang->about_anonymous_except_admin = 'The administrator\'s nickname will not be hidden.'; $lang->about_anonymous_name = 'You can customize the anonymous name that is displayed instead of the author\'s nickname.
    $NUM will be replaced with a random number that is unique to each member. (e.g. anon_$NUM → anon_12345678)
    $DAILYNUM will be replaced with a random number that is unique to each member but changes every day.
    $DOCNUM will be replaced with a random number that is unique to each member and changes from document to document.
    $DOCDAILYNUM will be replaced with a random number that is unique to each member and changes every day from document to document.
    You can append a number to each variable, like $DAILYNUM:5 to control the number of digits from 1 to 8.
    To use hexadecimal digits that include some alphabets, use STR instead of NUM.'; $lang->about_board = 'This module is for creating and managing boards.'; -$lang->about_consultation = 'Non-administrator members would see their own articles. Non-members would not be able to write articles when using consultation.'; +$lang->about_consultation = 'Members who are not maangers will only see their own articles.
    When this feature is enabled, non-members cannot read or write any articles on this board.'; $lang->about_secret = 'Users will be able to write secret articles or comments.'; $lang->about_admin_mail = 'A mail will be sent when an article or comment is submitted. Mails can be sent to mutiple mail addresses if connecting addresses with commas(,).'; $lang->about_list_config = 'If using list-style skin, you may arrange items to display. However, this feature might not be availble for non-official skins. If you double-click target items and display items, then you can add / remove them'; @@ -63,8 +64,10 @@ $lang->protect_regdate = 'Update/Delete Time Limit'; $lang->filter_specialchars = 'Block Abuse of Unicode Symbols'; $lang->document_length_limit = 'Limit Document Size'; $lang->comment_length_limit = 'Limit Comment Size'; -$lang->about_document_length_limit = 'Restrict documents that are too large. This limit may be triggered by copying and pasting a web page that contains a lot of unnecessary tags.'; -$lang->about_comment_length_limit = 'Restrict comments that are too large.'; +$lang->inline_data_url_limit = 'Limit Data URLs'; +$lang->about_document_length_limit = 'Restrict documents that are too large. This limit may be triggered by pasting content that contains a lot of unnecessary markup.
    This setting has no effect on the administrator and board managers.'; +$lang->about_comment_length_limit = 'Restrict comments that are too large.
    This setting has no effect on the administrator and board managers.'; +$lang->about_inline_data_url_limit = 'Restrict data: URLs that can be used to evade file size limits or cause processing issues.
    This setting also applies to the administrator and board managers.'; $lang->update_order_on_comment = 'Update Document on New Comment'; $lang->about_update_order_on_comment = 'When a new comment is posted, update the update timestamp of the parent document. This is needed for forums.'; $lang->about_filter_specialchars = 'Prevent use of excessive Unicode accents, RLO characters, and other symbols that hinder readability.'; @@ -83,6 +86,7 @@ $lang->msg_protect_regdate_document = 'You cannot update or delete a document af $lang->msg_protect_regdate_comment = 'You cannot update or delete a comment after %d days.'; $lang->msg_dont_have_update_log = 'This document has no update log.'; $lang->msg_content_too_long = 'The content is too long.'; +$lang->msg_data_url_restricted = 'The content has been restricted due to excessively large data URLs (such as inline images).'; $lang->original_letter = 'Original'; $lang->msg_warning_update_log = 'Warning! This can massively increase the size of your database.'; $lang->comment_delete_message = 'Leave Placeholder for Deleted Comment'; diff --git a/modules/board/lang/ko.php b/modules/board/lang/ko.php index 89c6d2bd8..778004a5f 100644 --- a/modules/board/lang/ko.php +++ b/modules/board/lang/ko.php @@ -15,6 +15,7 @@ $lang->last_post = '최종 글'; $lang->board_management = '게시판 관리'; $lang->search_result = '검색결과'; $lang->consultation = '상담 기능'; +$lang->use_consultation = '상담 기능 사용'; $lang->secret = '비밀글 기능'; $lang->thisissecret = '비밀글입니다.'; $lang->admin_mail = '관리자 메일'; @@ -37,7 +38,7 @@ $lang->about_use_anonymous_part2 = '스킨 설정에서 글쓴이 정보 등을 $lang->about_anonymous_except_admin = '관리권한이 있는 회원은 익명으로 표시되지 않도록 합니다.'; $lang->about_anonymous_name = '익명 기능을 사용할 때 표시할 익명 닉네임을 정할 수 있습니다.
    $NUM을 사용하면 회원마다 고유한 난수를 부여할 수 있습니다. (예: 익명_$NUM → 익명_12345678)
    $DAILYNUM을 사용하면 매일 난수가 변경되고, $DOCNUM을 사용하면 문서마다 변경됩니다.
    $DOCDAILYNUM을 사용하면 문서마다 각각, 그리고 매일 변경됩니다.
    각 변수 뒤에 $DAILYNUM:5와 같이 1~8 숫자를 붙여 자릿수를 조정할 수 있습니다.
    NUM 대신 STR를 사용하면 일부 알파벳이 포함된 16진수를 사용합니다.'; $lang->about_board = '게시판을 생성하고 관리할 수 있습니다.'; -$lang->about_consultation = '상담 기능은 관리권한이 없는 회원은 자신이 쓴 글만 보이도록 하는 기능입니다. 단 상담기능 사용시 비회원 글쓰기는 자동으로 금지됩니다.'; +$lang->about_consultation = '관리 권한이 없는 회원은 자신이 쓴 글만 보이도록 합니다.
    상담 기능 사용시 비회원 글쓰기는 금지됩니다.'; $lang->about_secret = '게시판 및 댓글의 비밀글 기능을 사용할 수 있도록 합니다.'; $lang->about_admin_mail = '글이나 댓글이 등록될때 등록된 메일주소로 메일이 발송됩니다. 콤마(,)로 연결시 다수의 메일주소로 발송할 수 있습니다.'; $lang->about_list_config = '게시판의 목록형식 사용시 원하는 항목들로 배치를 할 수 있습니다. 단 스킨에서 지원하지 않는 경우 불가능합니다. 대상항목/ 표시항목의 항목을 더블클릭하면 추가/ 제거가 됩니다.'; @@ -64,8 +65,10 @@ $lang->protect_regdate = '기간 제한 기능'; $lang->filter_specialchars = '유니코드 특수문자 오남용 금지'; $lang->document_length_limit = '문서 길이 제한'; $lang->comment_length_limit = '댓글 길이 제한'; -$lang->about_document_length_limit = '지나치게 용량이 큰 글을 작성하지 못하도록 합니다. 지저분한 태그가 많이 붙은 글을 붙여넣기하면 제한을 초과할 수도 있습니다.'; -$lang->about_comment_length_limit = '지나치게 용량이 큰 댓글을 작성하지 못하도록 합니다.'; +$lang->inline_data_url_limit = 'Data URL 제한'; +$lang->about_document_length_limit = '지나치게 용량이 큰 글을 작성하지 못하도록 합니다. 지저분한 태그가 많이 붙은 글을 붙여넣으면 제한을 초과할 수도 있습니다.
    관리자에게는 적용되지 않습니다.'; +$lang->about_comment_length_limit = '지나치게 용량이 큰 댓글을 작성하지 못하도록 합니다.
    관리자에게는 적용되지 않습니다.'; +$lang->about_inline_data_url_limit = 'data: URL을 사용하여 첨부 제한을 우회하거나 처리 장애를 일으키는 내용을 제한합니다.
    이 설정은 관리자에게도 적용됩니다.'; $lang->update_order_on_comment = '댓글 작성시 글 수정 시각 갱신'; $lang->about_update_order_on_comment = '댓글이 작성되면 해당 글의 수정 시각을 갱신합니다. 포럼형 게시판, 최근 댓글 표시 기능 등에 필요합니다.'; $lang->about_filter_specialchars = '가독성에 악영향을 주는 과도한 유니코드 악센트 기호의 조합, RLO 문자 등의 사용을 금지합니다.'; @@ -82,8 +85,9 @@ $lang->msg_protect_regdate_document = '%d일 이상 지난 글은 수정 또는 $lang->msg_protect_regdate_comment = '%d일 이상 지난 댓글은 수정 또는 삭제할 수 없습니다.'; $lang->msg_dont_have_update_log = '업데이트 로그가 기록되어 있지 않은 게시글입니다.'; $lang->msg_content_too_long = '내용이 너무 깁니다.'; +$lang->msg_data_url_restricted = 'Data URL 분량이 너무 많아서 작성이 제한되었습니다.'; $lang->original_letter = '원본글'; -$lang->msg_warning_update_log = '주의! 사용시 디비가 많이 늘어날 수 있습니다.'; +$lang->msg_warning_update_log = '주의! 사용시 DB 용량이 많이 늘어날 수 있습니다.'; $lang->reason_update = '수정한 이유'; $lang->msg_no_update_id = '업데이트 고유 번호는 필수입니다.'; $lang->msg_no_update_log = '업데이트 로그가 존재하지 않습니다.'; diff --git a/modules/board/models/Features.php b/modules/board/models/Features.php new file mode 100644 index 000000000..6bbc567ec --- /dev/null +++ b/modules/board/models/Features.php @@ -0,0 +1,127 @@ +document = new \stdClass; + $this->comment = new \stdClass; + } + + /** + * Get board features from module_srl. + * + * @param int $module_srl + * @param ?int $document_module_srl + * @return self + */ + public static function fromModuleSrl(int $module_srl, ?int $document_module_srl = null): self + { + $module_info = ModuleModel::getModuleInfoByModuleSrl($module_srl) ?: new \stdClass; + return self::fromModuleInfo($module_info, $document_module_srl); + } + + /** + * Get board features from an already created module info object. + * + * @param object $module_info + * @param ?int $document_module_srl + * @return self + */ + public static function fromModuleInfo(object $module_info, ?int $document_module_srl = null): self + { + if (!$document_module_srl) + { + $document_module_srl = $module_info->module_srl; + } + $document_config = ModuleModel::getModulePartConfig('document', $document_module_srl); + $comment_config = ModuleModel::getModulePartConfig('comment', $document_module_srl); + $features = new self; + + // Document features + $features->document->vote_up = ($document_config->use_vote_up ?? 'Y') !== 'N'; + $features->document->vote_down = ($document_config->use_vote_down ?? 'Y') !== 'N'; + $features->document->vote_log = ($document_config->use_vote_up ?? 'Y') === 'S' || ($document_config->use_vote_down ?? 'Y') === 'S'; + if (isset($document_config->allow_vote_cancel)) + { + $features->document->cancel_vote = $document_config->allow_vote_cancel === 'Y'; + } + else + { + $features->document->cancel_vote = ($module_info->cancel_vote ?? 'N') === 'Y'; + } + if (isset($document_config->allow_vote_non_member)) + { + $features->document->non_member_vote = $document_config->allow_vote_non_member === 'Y'; + } + else + { + $features->document->non_member_vote = ($module_info->non_login_vote ?? 'N') === 'Y'; + } + $features->document->report = true; + if (isset($document_config->allow_declare_cancel)) + { + $features->document->cancel_report = $document_config->allow_declare_cancel === 'Y'; + } + else + { + $features->document->cancel_report = ($module_info->cancel_vote ?? 'N') === 'Y'; + } + $features->document->history = ($document_config->use_history ?? 'N') === 'Y'; + + // Comment features + $features->comment->vote_up = ($comment_config->use_vote_up ?? 'Y') !== 'N'; + $features->comment->vote_down = ($comment_config->use_vote_down ?? 'Y') !== 'N'; + $features->comment->vote_log = ($comment_config->use_vote_up ?? 'Y') === 'S' || ($comment_config->use_vote_down ?? 'Y') === 'S'; + if (isset($comment_config->allow_vote_cancel)) + { + $features->comment->cancel_vote = $comment_config->allow_vote_cancel === 'Y'; + } + else + { + $features->comment->cancel_vote = ($module_info->cancel_vote ?? 'N') === 'Y'; + } + if (isset($comment_config->allow_vote_non_member)) + { + $features->comment->non_member_vote = $comment_config->allow_vote_non_member === 'Y'; + } + else + { + $features->comment->non_member_vote = ($module_info->non_login_vote ?? 'N') === 'Y'; + } + $features->comment->report = true; + if (isset($comment_config->allow_declare_cancel)) + { + $features->comment->cancel_report = $comment_config->allow_declare_cancel === 'Y'; + } + else + { + $features->comment->cancel_report = ($module_info->cancel_vote ?? 'N') === 'Y'; + } + $features->comment->max_thread_depth = $comment_config->max_thread_depth ?? 0; + $features->comment->default_page = $comment_config->default_page ?? 'last'; + + return $features; + } +} diff --git a/modules/board/skins/xedition/_comment.html b/modules/board/skins/xedition/_comment.html index 2b286a578..c87c379dc 100644 --- a/modules/board/skins/xedition/_comment.html +++ b/modules/board/skins/xedition/_comment.html @@ -38,12 +38,20 @@

    + {$lang->cmd_comment_vote_user} + + {$lang->cmd_vote}{$comment->get('voted_count')} {$lang->cmd_vote}{$comment->get('voted_count')} + + {$lang->cmd_vote_down}{$comment->get('blamed_count')} {$lang->cmd_vote_down}{$comment->get('blamed_count')} + + {$lang->cmd_reply} + {$lang->cmd_modify} {$lang->cmd_delete} {$lang->cmd_comment_do} diff --git a/modules/board/skins/xedition/_read.html b/modules/board/skins/xedition/_read.html index 385f19b40..29fb9de98 100644 --- a/modules/board/skins/xedition/_read.html +++ b/modules/board/skins/xedition/_read.html @@ -83,11 +83,11 @@

    + {$lang->cmd_document_vote_user} + + {$lang->update_log} + {$lang->cmd_modify} {$lang->cmd_delete} diff --git a/modules/board/tpl/board_insert.html b/modules/board/tpl/board_insert.html index 3d47c552f..584f53bf1 100644 --- a/modules/board/tpl/board_insert.html +++ b/modules/board/tpl/board_insert.html @@ -246,7 +246,7 @@ {$lang->unit_day}

    {$lang->about_customize_bottom_list}

    @@ -289,10 +289,18 @@

    {$lang->about_comment_length_limit}

    +
    + +
    + KB +

    {$lang->about_inline_data_url_limit}

    +
    +
    - + +

    {$lang->about_consultation}

    diff --git a/modules/comment/comment.controller.php b/modules/comment/comment.controller.php index c5f2e2287..e6da1b9d4 100644 --- a/modules/comment/comment.controller.php +++ b/modules/comment/comment.controller.php @@ -735,6 +735,17 @@ class CommentController extends Comment $list_args->head = $parent->head; $list_args->depth = $parent->depth + 1; + // Check max thread depth. + $comment_config = ModuleModel::getModulePartConfig('comment', $obj->module_srl); + if (isset($comment_config->max_thread_depth) && $comment_config->max_thread_depth > 0) + { + if ($list_args->depth + 1 > $comment_config->max_thread_depth) + { + $oDB->rollback(); + return new BaseObject(-1, 'msg_exceeds_max_thread_depth'); + } + } + // if the depth of comments is less than 2, execute insert. if($list_args->depth < 2) { @@ -1554,23 +1565,23 @@ class CommentController extends Comment /** * delete declared comment, log - * @param array|string $commentSrls : srls string (ex: 1, 2,56, 88) + * @param object $args should contain comment_srl * @return void */ - function _deleteDeclaredComments($commentSrls) + function _deleteDeclaredComments($args) { - executeQuery('comment.deleteDeclaredComments', $commentSrls); - executeQuery('comment.deleteCommentDeclaredLog', $commentSrls); + executeQuery('comment.deleteDeclaredComments', $args); + executeQuery('comment.deleteDeclaredCommentLog', $args); } /** * delete voted comment log - * @param array|string $commentSrls : srls string (ex: 1, 2,56, 88) + * @param object $args should contain comment_srl * @return void */ - function _deleteVotedComments($commentSrls) + function _deleteVotedComments($args) { - executeQuery('comment.deleteCommentVotedLog', $commentSrls); + executeQuery('comment.deleteCommentVotedLog', $args); } /** @@ -2042,6 +2053,7 @@ class CommentController extends Comment { $comment_config->comment_page_count = 10; } + $comment_config->max_thread_depth = (int)Context::get('max_thread_depth') ?: 0; $comment_config->default_page = Context::get('default_page'); if($comment_config->default_page !== 'first') diff --git a/modules/comment/comment.model.php b/modules/comment/comment.model.php index ab309686f..2be007f07 100644 --- a/modules/comment/comment.model.php +++ b/modules/comment/comment.model.php @@ -246,6 +246,18 @@ class CommentModel extends Comment return $result; } + /** + * Get comment depth. + * + * @param int $comment_srl + * @return ?int + */ + public static function getCommentDepth(int $comment_srl): ?int + { + $output = executeQuery('comment.getCommentDepth', ['comment_srl' => $comment_srl]); + return isset($output->data->depth) ? (int)$output->data->depth : null; + } + /** * Get the total number of comments in corresponding with document_srl. * @param int $document_srl diff --git a/modules/comment/lang/en.php b/modules/comment/lang/en.php index 1b2b14b20..273c5dfad 100644 --- a/modules/comment/lang/en.php +++ b/modules/comment/lang/en.php @@ -12,8 +12,11 @@ $lang->comment_default_page_first = 'First page'; $lang->comment_default_page_last = 'Last page'; $lang->about_comment_count = 'Set the number of comments to show on each page.'; $lang->about_comment_page_count = 'Set the number of pagination links to show at the bottom.'; +$lang->max_thread_depth = 'Maximum Thread Depth'; +$lang->about_max_thread_depth = '0: Unlimited, 1: No replies, 2 or more: Allow replies'; $lang->msg_cart_is_null = 'Please select an article to delete.'; $lang->msg_checked_comment_is_deleted = '%d comment(s) is(are) successfully deleted.'; +$lang->msg_exceeds_max_thread_depth = 'You have exceeded the maximum thread depth. Please leave a reply elsewhere in the thread.'; $lang->search_target_list['content'] = 'Content'; $lang->search_target_list['user_id'] = 'ID'; $lang->search_target_list['user_name'] = 'Name'; diff --git a/modules/comment/lang/ko.php b/modules/comment/lang/ko.php index 1fb8e2adf..a5a6005ed 100644 --- a/modules/comment/lang/ko.php +++ b/modules/comment/lang/ko.php @@ -12,8 +12,11 @@ $lang->comment_default_page_first = '첫 페이지'; $lang->comment_default_page_last = '마지막 페이지'; $lang->about_comment_count = '댓글을 정해진 수 만큼만 표시하고, 그 이상일 경우 페이지 번호를 표시해서 이동할 수 있게 합니다.'; $lang->about_comment_page_count = '하단에 표시할 페이지 링크 수를 설정할 수 있습니다.'; +$lang->max_thread_depth = '대댓글 최대 깊이'; +$lang->about_max_thread_depth = '0: 무제한, 1: 대댓글 금지, 2 이상: 대댓글 허용'; $lang->msg_cart_is_null = '삭제할 글을 선택해주세요.'; $lang->msg_checked_comment_is_deleted = '%d개의 댓글을 삭제했습니다.'; +$lang->msg_exceeds_max_thread_depth = '대댓글 최대 깊이를 초과했습니다. 다른 자리에 댓글을 남겨 주세요.'; $lang->search_target_list['content'] = '내용'; $lang->search_target_list['user_id'] = '아이디'; $lang->search_target_list['user_name'] = '이름'; diff --git a/modules/comment/queries/getCommentDepth.xml b/modules/comment/queries/getCommentDepth.xml new file mode 100644 index 000000000..9748581b8 --- /dev/null +++ b/modules/comment/queries/getCommentDepth.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/modules/comment/tpl/comment_module_config.html b/modules/comment/tpl/comment_module_config.html index 8bcb5e997..ace3077e9 100644 --- a/modules/comment/tpl/comment_module_config.html +++ b/modules/comment/tpl/comment_module_config.html @@ -19,6 +19,13 @@

    {$lang->about_comment_page_count}

    +
    + +
    + +

    {$lang->about_max_thread_depth}

    +
    +
    diff --git a/modules/document/document.admin.controller.php b/modules/document/document.admin.controller.php index 6df5bc780..a38e3a00c 100644 --- a/modules/document/document.admin.controller.php +++ b/modules/document/document.admin.controller.php @@ -174,8 +174,14 @@ class DocumentAdminController extends Document $var_idx = Context::get('var_idx'); $name = Context::get('name'); $type = Context::get('type'); - $is_required = Context::get('is_required'); - $default = Context::get('default'); + $is_required = Context::get('is_required') === 'Y' ? 'Y' : 'N'; + $is_strict = Context::get('is_strict') === 'Y' ? 'Y' : 'N'; + $default = trim(utf8_clean(Context::get('default'))); + $options = trim(utf8_clean(Context::get('options'))); + if ($options !== '') + { + $options = array_map('trim', explode("\n", $options)); + } $desc = Context::get('desc') ? Context::get('desc') : ''; $search = Context::get('search'); $eid = Context::get('eid'); @@ -201,8 +207,11 @@ class DocumentAdminController extends Document } // insert or update - $oDocumentController = getController('document'); - $output = $oDocumentController->insertDocumentExtraKey($module_srl, $var_idx, $name, $type, $is_required, $search, $default, $desc, $eid); + $oDocumentController = DocumentController::getInstance(); + $output = $oDocumentController->insertDocumentExtraKey( + $module_srl, $var_idx, $name, $type, $is_required, $search, + $default, $desc, $eid, $is_strict, $options + ); if(!$output->toBool()) return $output; $this->setMessage('success_registed'); diff --git a/modules/document/document.class.php b/modules/document/document.class.php index 5a2fe043c..a359851d1 100644 --- a/modules/document/document.class.php +++ b/modules/document/document.class.php @@ -109,6 +109,10 @@ class Document extends ModuleObject if(!$oDB->isColumnExists('document_categories', 'is_default')) return true; if(!$oDB->isIndexExists('document_categories', 'idx_list_order')) return true; + // 2024.10.08 Add columns to document_extra_keys table + if(!$oDB->isColumnExists('document_extra_keys', 'var_is_strict')) return true; + if(!$oDB->isColumnExists('document_extra_keys', 'var_options')) return true; + return false; } @@ -219,6 +223,16 @@ class Document extends ModuleObject { $oDB->addIndex('document_categories', 'idx_list_order', array('list_order')); } + + // 2024.10.08 Add columns to document_extra_keys table + if(!$oDB->isColumnExists('document_extra_keys', 'var_is_strict')) + { + $oDB->addColumn('document_extra_keys', 'var_is_strict', 'char', '1', 'N', true, 'var_is_required'); + } + if(!$oDB->isColumnExists('document_extra_keys', 'var_options')) + { + $oDB->addColumn('document_extra_keys', 'var_options', 'text', '', '', false, 'var_default'); + } } /** diff --git a/modules/document/document.controller.php b/modules/document/document.controller.php index 393359709..5ed99d565 100644 --- a/modules/document/document.controller.php +++ b/modules/document/document.controller.php @@ -818,7 +818,11 @@ class DocumentController extends Document if(isset($obj->{'extra_vars'.$idx})) { $tmp = $obj->{'extra_vars'.$idx}; - if(is_array($tmp)) + if ($extra_item->type === 'file') + { + $value = $tmp; + } + elseif (is_array($tmp)) { $value = implode('|@|', $tmp); } @@ -831,7 +835,37 @@ class DocumentController extends Document { $value = trim($obj->{$extra_item->name}); } - if($value == NULL) continue; + + // Validate and process the extra value. + if ($value == NULL && $manual_inserted) + { + continue; + } + else + { + if (!$manual_inserted) + { + $ev_output = $extra_item->validate($value); + if ($ev_output && !$output->toBool()) + { + $oDB->rollback(); + return $ev_output; + } + } + + // Handle extra vars that support file upload. + if ($extra_item->type === 'file' && is_array($value)) + { + $ev_output = $extra_item->uploadFile($value, $obj->document_srl, 'doc'); + if (!$ev_output->toBool()) + { + $oDB->rollback(); + return $ev_output; + } + $value = $ev_output->get('file_srl'); + } + } + $extra_vars[$extra_item->name] = $value; $this->insertDocumentExtraVar($obj->module_srl, $obj->document_srl, $idx, $value, $extra_item->eid); } @@ -1153,7 +1187,10 @@ class DocumentController extends Document $extra_vars = array(); if(Context::get('act')!='procFileDelete') { + // Get a copy of current extra vars before deleting all existing data. + $old_extra_vars = DocumentModel::getExtraVars($obj->module_srl, $obj->document_srl); $this->deleteDocumentExtraVars($source_obj->get('module_srl'), $obj->document_srl, null, Context::getLangType()); + // Insert extra variables if the document successfully inserted. $extra_keys = DocumentModel::getExtraKeys($obj->module_srl); if(count($extra_keys)) @@ -1164,13 +1201,98 @@ class DocumentController extends Document if(isset($obj->{'extra_vars'.$idx})) { $tmp = $obj->{'extra_vars'.$idx}; - if(is_array($tmp)) + if ($extra_item->type === 'file') + { + $value = $tmp; + } + elseif (is_array($tmp)) + { $value = implode('|@|', $tmp); + } else + { $value = trim($tmp); + } + } + elseif (isset($obj->{$extra_item->name})) + { + $value = trim($obj->{$extra_item->name}); + } + + // Validate and process the extra value. + if ($value == NULL && $manual_updated && $extra_item->type !== 'file') + { + continue; + } + else + { + // Check for required and strict values. + if (!$manual_updated) + { + $ev_output = $extra_item->validate($value, $old_extra_vars[$idx]->value ?? null); + if ($ev_output && !$ev_output->toBool()) + { + $oDB->rollback(); + return $ev_output; + } + } + + // Handle extra vars that support file upload. + if ($extra_item->type === 'file') + { + // New upload + if (is_array($value) && isset($value['name'])) + { + // Delete old file + if (isset($old_extra_vars[$idx]->value)) + { + $fc_output = FileController::getInstance()->deleteFile($old_extra_vars[$idx]->value); + if (!$fc_output->toBool()) + { + $oDB->rollback(); + return $fc_output; + } + } + // Insert new file + $ev_output = $extra_item->uploadFile($value, $obj->document_srl, 'doc'); + if (!$ev_output->toBool()) + { + $oDB->rollback(); + return $ev_output; + } + $value = $ev_output->get('file_srl'); + } + // Delete current file + elseif (isset($obj->{'_delete_extra_vars'.$idx}) && $obj->{'_delete_extra_vars'.$idx} === 'Y') + { + if (isset($old_extra_vars[$idx]->value)) + { + // Check if deletion is allowed + $ev_output = $extra_item->validate(null); + if (!$ev_output->toBool()) + { + $oDB->rollback(); + return $ev_output; + } + // Delete old file + $fc_output = FileController::getInstance()->deleteFile($old_extra_vars[$idx]->value); + if (!$fc_output->toBool()) + { + $oDB->rollback(); + return $fc_output; + } + } + } + // Leave current file unchanged + elseif (!$value) + { + if (isset($old_extra_vars[$idx]->value)) + { + $value = $old_extra_vars[$idx]->value; + } + } + } } - else if(isset($obj->{$extra_item->name})) $value = trim($obj->{$extra_item->name}); - if($value == NULL) continue; $extra_vars[$extra_item->name] = $value; $this->insertDocumentExtraVar($obj->module_srl, $obj->document_srl, $idx, $value, $extra_item->eid); } @@ -1611,11 +1733,16 @@ class DocumentController extends Document * @param string $var_default * @param string $var_desc * @param int $eid + * @param string $var_is_strict + * @param array $var_options * @return object */ - function insertDocumentExtraKey($module_srl, $var_idx, $var_name, $var_type, $var_is_required = 'N', $var_search = 'N', $var_default = '', $var_desc = '', $eid = 0) + function insertDocumentExtraKey($module_srl, $var_idx, $var_name, $var_type, $var_is_required = 'N', $var_search = 'N', $var_default = '', $var_desc = '', $eid = 0, $var_is_strict = 'N', $var_options = null) { - if(!$module_srl || !$var_idx || !$var_name || !$var_type || !$eid) return new BaseObject(-1, 'msg_invalid_request'); + if (!$module_srl || !$var_idx || !$var_name || !$var_type || !$eid) + { + return new BaseObject(-1, 'msg_invalid_request'); + } $obj = new stdClass(); $obj->module_srl = $module_srl; @@ -1623,8 +1750,10 @@ class DocumentController extends Document $obj->var_name = $var_name; $obj->var_type = $var_type; $obj->var_is_required = $var_is_required=='Y'?'Y':'N'; + $obj->var_is_strict = $var_is_strict=='Y'?'Y':'N'; $obj->var_search = $var_search=='Y'?'Y':'N'; $obj->var_default = $var_default; + $obj->var_options = $var_options ? json_encode($var_options, \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES) : null; $obj->var_desc = $var_desc; $obj->eid = $eid; @@ -2554,8 +2683,6 @@ class DocumentController extends Document $js_code[] = 'var validator = xe.getApp("validator")[0];'; $js_code[] = 'if(!validator) return false;'; - $logged_info = Context::get('logged_info'); - foreach($extra_keys as $idx => $val) { $idx = $val->idx; @@ -2563,9 +2690,11 @@ class DocumentController extends Document { $idx .= '[]'; } - $name = str_ireplace(array('name); - $js_code[] = sprintf('validator.cast("ADD_MESSAGE", ["extra_vars%s","%s"]);', $idx, $name); - if($val->is_required == 'Y') $js_code[] = sprintf('validator.cast("ADD_EXTRA_FIELD", ["extra_vars%s", { required:true }]);', $idx); + $js_code[] = sprintf('validator.cast("ADD_MESSAGE", ["extra_vars%s", %s]);', $idx, var_export($val->name, true)); + if($val->is_required == 'Y' && $val->type !== 'file') + { + $js_code[] = sprintf('validator.cast("ADD_EXTRA_FIELD", ["extra_vars%s", { required:true }]);', $idx); + } } $js_code[] = '})(jQuery);'; @@ -3654,7 +3783,7 @@ Content; { foreach($documentExtraKeys AS $extraItem) { - $this->insertDocumentExtraKey($value, $extraItem->idx, $extraItem->name, $extraItem->type, $extraItem->is_required , $extraItem->search , $extraItem->default , $extraItem->desc, $extraItem->eid) ; + $this->insertDocumentExtraKey($value, $extraItem->idx, $extraItem->name, $extraItem->type, $extraItem->is_required , $extraItem->search , $extraItem->default , $extraItem->desc, $extraItem->eid, $extraItem->is_strict, $extraItem->options); } } } diff --git a/modules/document/document.item.php b/modules/document/document.item.php index 7507ee434..4f83caf43 100644 --- a/modules/document/document.item.php +++ b/modules/document/document.item.php @@ -965,7 +965,7 @@ class DocumentItem extends BaseObject return $this->get('comment_count'); } - function getComments() + function getComments(?int $page = null) { if(!$this->getCommentCount()) { @@ -979,7 +979,7 @@ class DocumentItem extends BaseObject // cpage is a number of comment pages $cpageStr = sprintf('%d_cpage', $this->document_srl); - $cpage = Context::get($cpageStr); + $cpage = $page ? $page : Context::get($cpageStr); if(!$cpage) { $cpage = Context::get('cpage'); diff --git a/modules/document/queries/getDocumentExtraKeys.xml b/modules/document/queries/getDocumentExtraKeys.xml index bd0ad59cb..c708322ea 100644 --- a/modules/document/queries/getDocumentExtraKeys.xml +++ b/modules/document/queries/getDocumentExtraKeys.xml @@ -8,10 +8,12 @@ + + - + diff --git a/modules/document/queries/insertDocumentExtraKey.xml b/modules/document/queries/insertDocumentExtraKey.xml index e9c4878ab..6c0983add 100644 --- a/modules/document/queries/insertDocumentExtraKey.xml +++ b/modules/document/queries/insertDocumentExtraKey.xml @@ -1,16 +1,18 @@ - -
    - - - - - - - - - - - - + +
    + + + + + + + + + + + + + + diff --git a/modules/document/queries/updateDocumentExtraKey.xml b/modules/document/queries/updateDocumentExtraKey.xml index c4d693f15..ce470af09 100644 --- a/modules/document/queries/updateDocumentExtraKey.xml +++ b/modules/document/queries/updateDocumentExtraKey.xml @@ -1,18 +1,20 @@ - -
    - - - - - - - - - - - - - - + +
    + + + + + + + + + + + + + + + + diff --git a/modules/document/schemas/document_extra_keys.xml b/modules/document/schemas/document_extra_keys.xml index 5334e1882..e54a5c544 100644 --- a/modules/document/schemas/document_extra_keys.xml +++ b/modules/document/schemas/document_extra_keys.xml @@ -4,8 +4,10 @@ + +
    diff --git a/modules/document/tpl/extra_keys.html b/modules/document/tpl/extra_keys.html index 6a364f33a..c3f9d56e7 100644 --- a/modules/document/tpl/extra_keys.html +++ b/modules/document/tpl/extra_keys.html @@ -49,6 +49,14 @@
    +
    + +
    + + +

    {$lang->about_extra_vars_is_strict}

    +
    +
    @@ -56,6 +64,13 @@

    {$lang->about_extra_vars_default_value}

    +
    + +
    + +

    {$lang->about_extra_vars_options}

    +
    +
    diff --git a/modules/editor/editor.model.php b/modules/editor/editor.model.php index 989eaf037..aba1ed1a6 100644 --- a/modules/editor/editor.model.php +++ b/modules/editor/editor.model.php @@ -262,7 +262,7 @@ class EditorModel extends Editor $file_config->allowed_chunk_size = 0; } - Context::set('file_config',$file_config); + Context::set('file_config', $file_config); // Configure upload status such as file size $upload_status = FileModel::getUploadStatus(); @@ -293,7 +293,7 @@ class EditorModel extends Editor // Check if the file already exists if ($upload_target_srl) { - $files_count = FileModel::getFilesCount($upload_target_srl); + $files_count = FileModel::getFilesCount($upload_target_srl, $option->upload_target_type ?? null); } } Context::set('files_count', (int)$files_count); diff --git a/modules/editor/skins/simpleeditor/js/simpleeditor.js b/modules/editor/skins/simpleeditor/js/simpleeditor.js index 45dc31479..fa5f00d6a 100644 --- a/modules/editor/skins/simpleeditor/js/simpleeditor.js +++ b/modules/editor/skins/simpleeditor/js/simpleeditor.js @@ -145,6 +145,9 @@ }); // Simulate CKEditor for file upload integration. + window._getCkeContainer = function(editor_sequence) { + return $('#simpleeditor_instance_' + editor_sequence); + }; window._getCkeInstance = function(editor_sequence) { var instance = $('#simpleeditor_instance_' + editor_sequence); return { diff --git a/modules/extravar/models/Value.php b/modules/extravar/models/Value.php index 7bfcdde61..091e2ab99 100644 --- a/modules/extravar/models/Value.php +++ b/modules/extravar/models/Value.php @@ -2,6 +2,11 @@ namespace Rhymix\Modules\Extravar\Models; +use BaseObject; +use Context; +use FileController; +use FileHandler; +use FileModel; use ModuleModel; use Rhymix\Framework\DateTime; use Rhymix\Framework\i18n; @@ -25,7 +30,9 @@ class Value public $name = ''; public $desc = ''; public $default = null; + public $options = null; public $is_required = 'N'; + public $is_strict = 'N'; public $is_disabled = 'N'; public $is_readonly = 'N'; public $search = 'N'; @@ -55,6 +62,15 @@ class Value 'kr_zip' => true, ]; + /** + * List of types that can have options. + */ + public const OPTION_TYPES = [ + 'checkbox' => true, + 'radio' => true, + 'select' => true, + ]; + /** * Constructor for compatibility with legacy ExtraItem class. * @@ -68,8 +84,11 @@ class Value * @param string $search (Y, N) * @param string $value * @param string $eid + * @param string $parent_type + * @param string $is_strict + * @param string $options */ - function __construct(int $module_srl, int $idx, string $name, string $type = 'text', $default = null, $desc = '', $is_required = 'N', $search = 'N', $value = null, string $eid = '') + function __construct(int $module_srl, int $idx, string $name, string $type = 'text', $default = null, $desc = '', $is_required = 'N', $search = 'N', $value = null, $eid = '', $parent_type = 'document', $is_strict = '', $options = null) { if (!$idx) { @@ -80,11 +99,14 @@ class Value $this->idx = $idx; $this->eid = $eid; $this->type = $type; + $this->parent_type = $parent_type; $this->value = $value; $this->name = $name; $this->desc = $desc; $this->default = $default; + $this->options = $options ? json_decode($options) : null; $this->is_required = $is_required; + $this->is_strict = $is_strict; $this->search = $search; } @@ -139,6 +161,162 @@ class Value return $template->compile(); } + /** + * Get the default value. + * + * @return mixed + */ + public function getDefaultValue() + { + if (!$this->canHaveOptions()) + { + return $this->default; + } + elseif (is_array($this->options)) + { + return $this->default; + } + else + { + return null; + } + } + + /** + * Get options specified by the administrator. + * + * @return array + */ + public function getOptions(): array + { + if (!$this->canHaveOptions()) + { + return $this->options ?? []; + } + + if (is_array($this->options)) + { + return $this->options; + } + elseif ($this->default) + { + return is_array($this->default) ? $this->default : explode(',', $this->default); + } + else + { + return []; + } + } + + /** + * Check if the current value can have options. + * + * @return bool + */ + public function canHaveOptions(): bool + { + return isset(self::OPTION_TYPES[$this->type]); + } + + /** + * Check if the current value is an array type. + * + * @return bool + */ + public function isArrayType(): bool + { + return isset(self::ARRAY_TYPES[$this->type]); + } + + /** + * Validate a value. + * + * @param mixed $value + * @param mixed $old_value + * @return ?BaseObject + */ + public function validate($value, $old_value = null): ?BaseObject + { + // Take legacy encoding into consideration. + if (is_array($value)) + { + $is_array = true; + $values = $value; + } + elseif (str_contains($value, '|@|')) + { + $is_array = true; + $values = explode('|@|', $value); + } + else + { + $is_array = false; + $values = [$value]; + } + + // Check if a required value is empty. + if ($this->is_required === 'Y') + { + if ($this->type === 'file' && !$value && $old_value) + { + $value = $old_value; + $values = (array)$old_value; + } + if ($is_array && trim(implode('', $values)) === '') + { + return new BaseObject(-1, sprintf(lang('common.filter.isnull'), Context::replaceUserLang($this->name))); + } + if (!$is_array && trim(strval($value)) === '') + { + return new BaseObject(-1, sprintf(lang('common.filter.isnull'), Context::replaceUserLang($this->name))); + } + } + + // Check if a strict value is not one of the specified options. + if ($this->is_strict === 'Y' && $value) + { + if ($this->canHaveOptions()) + { + $options = $this->getOptions(); + foreach ($values as $v) + { + if (!in_array($v, $options)) + { + return new BaseObject(-1, sprintf(lang('common.filter.equalto'), Context::replaceUserLang($this->name))); + } + } + } + elseif ($this->isArrayType()) + { + if (!$is_array) + { + return new BaseObject(-1, sprintf(lang('common.filter.equalto'), Context::replaceUserLang($this->name))); + } + } + } + + return null; + } + + /** + * Upload a file. + * + * @param array $file + * @param int $target_srl + * @param string $target_type + * @return BaseObject + */ + public function uploadFile(array $file, int $target_srl, string $target_type): BaseObject + { + $oFileController = FileController::getInstance(); + $output = $oFileController->insertFile($file, $this->module_srl, $target_srl); + if ($output->toBool()) + { + $oFileController->setFilesValid($target_srl, "ev:$target_type", $output->get('file_srl')); + } + return $output; + } + /** * Get the next temporary ID. * @@ -185,14 +363,24 @@ class Value { $values = $value; } + elseif (preg_match('/^[\[\{].*[\]\}]$/', $value)) + { + $values = json_decode($value, true); + if (!is_array($values)) + { + $values = []; + } + } elseif (str_contains($value, '|@|')) { $values = explode('|@|', $value); } + /* elseif (str_contains($value, ',') && $type !== 'kr_zip') { $values = explode(',', $value); } + */ else { $values = [$value]; @@ -213,6 +401,12 @@ class Value return escape($value, false); } + // Process the file upload type. + if ($type === 'file') + { + return $value ? intval($value) : null; + } + // Escape and return all other types. return escape($value, false); } @@ -269,7 +463,7 @@ class Value case 'email': return sprintf('%s', $value, $value); case 'kr_zip': - return is_array($value) ? implode(' ', $value) : $value; + return is_array($value) ? trim(implode(' ', $value)) : $value; case 'country': $country = i18n::listCountries()[$value] ?? ''; if ($country) @@ -287,6 +481,23 @@ class Value return sprintf('%s-%s-%s', substr($value, 0, 4), substr($value, 4, 2), substr($value, 6, 2)); case 'timezone': return DateTime::getTimezoneList()[$value] ?? ''; + case 'file': + if ($value) + { + $file = FileModel::getFile($value); + if ($file) + { + return sprintf('%s (%s)', \RX_BASEURL . ltrim($file->download_url, './'), $file->source_filename, FileHandler::filesize($file->file_size)); + } + else + { + return ''; + } + } + else + { + return ''; + } default: return $value; } diff --git a/modules/extravar/models/ValueCollection.php b/modules/extravar/models/ValueCollection.php index dd9183c6b..0627fffcf 100644 --- a/modules/extravar/models/ValueCollection.php +++ b/modules/extravar/models/ValueCollection.php @@ -50,7 +50,7 @@ class ValueCollection foreach ($keys as $val) { - $this->keys[$val->idx] = new Value($val->module_srl, $val->idx, $val->name, $val->type, $val->default, $val->desc, $val->is_required, $val->search, $val->value ?? null, $val->eid, $val->parent_type ?? 'document'); + $this->keys[$val->idx] = new Value($val->module_srl, $val->idx, $val->name, $val->type, $val->default, $val->desc, $val->is_required, $val->search, $val->value ?? null, $val->eid, $val->parent_type ?? 'document', $val->is_strict, $val->options); } } diff --git a/modules/extravar/skins/default/assets/file_upload.js b/modules/extravar/skins/default/assets/file_upload.js new file mode 100644 index 000000000..878ac15af --- /dev/null +++ b/modules/extravar/skins/default/assets/file_upload.js @@ -0,0 +1,26 @@ +'use strict'; + +(function($) { + $(function() { + $('button.evFileRemover').on('click', function() { + const container = $(this).parents('.ev_file_upload'); + container.find('span.filename').text(''); + container.find('span.filesize').text(''); + container.find('input[type=hidden][name^=_delete_]').val('Y'); + container.find('input[type=file]').val(''); + }); + $('input.rx_ev_file').on('change', function() { + const container = $(this).parents('.ev_file_upload'); + const max_size = parseInt($(this).data('allowedFilesize'), 10); + const file_count = this.files.length; + for (let i = 0; i < file_count; i++) { + if (max_size && this.files[i].size > max_size) { + alert($(this).data('msgFilesize')); + $(this).val(''); + return; + } + } + container.find('input[type=hidden][name^=_delete_]').val('N'); + }); + }); +})(jQuery); diff --git a/modules/extravar/skins/default/form.blade.php b/modules/extravar/skins/default/form.blade.php index f1a387a80..3f7453430 100644 --- a/modules/extravar/skins/default/form.blade.php +++ b/modules/extravar/skins/default/form.blade.php @@ -41,6 +41,9 @@ @case ('time') @include ('form_types/datetime') @break + @case ('file') + @include ('form_types/file_upload') + @break @default @include ('form_types/text') @endswitch diff --git a/modules/extravar/skins/default/form_types/checkbox.blade.php b/modules/extravar/skins/default/form_types/checkbox.blade.php index 0d99d20c9..7087263a2 100644 --- a/modules/extravar/skins/default/form_types/checkbox.blade.php +++ b/modules/extravar/skins/default/form_types/checkbox.blade.php @@ -1,10 +1,15 @@ +@php + $has_value = is_array($value); + $default_value = $definition->getDefaultValue(); +@endphp + @if ($parent_type === 'member')
    - @foreach ($default ?? [] as $v) + @foreach ($definition->getOptions() as $v) @php $column_suffix = $type === 'checkbox' ? '[]' : ''; $tempid = $definition->getNextTempID(); - $is_checked = is_array($value) && in_array(trim($v), $value); + $is_checked = $has_value ? in_array($v, $value) : ($v === $default_value); @endphp
    diff --git a/modules/extravar/skins/default/form_types/select.blade.php b/modules/extravar/skins/default/form_types/select.blade.php index 38677093a..8f08b3abd 100644 --- a/modules/extravar/skins/default/form_types/select.blade.php +++ b/modules/extravar/skins/default/form_types/select.blade.php @@ -1,10 +1,15 @@ +@php + $has_value = is_array($value); + $default_value = $definition->getDefaultValue(); +@endphp + diff --git a/modules/extravar/skins/default/form_types/text.blade.php b/modules/extravar/skins/default/form_types/text.blade.php index 3b8338e98..8a089168a 100644 --- a/modules/extravar/skins/default/form_types/text.blade.php +++ b/modules/extravar/skins/default/form_types/text.blade.php @@ -2,7 +2,7 @@ is_required)) @disabled(toBool($definition->is_disabled)) @readonly(toBool($definition->is_readonly)) @@ -11,7 +11,16 @@ is_required)) + @disabled(toBool($definition->is_disabled)) + @readonly(toBool($definition->is_readonly)) + /> +@elseif ($type === 'number') + is_required)) @disabled(toBool($definition->is_disabled)) @readonly(toBool($definition->is_readonly)) diff --git a/modules/extravar/skins/default/form_types/textarea.blade.php b/modules/extravar/skins/default/form_types/textarea.blade.php index 779bc7b67..8a2cb1e44 100644 --- a/modules/extravar/skins/default/form_types/textarea.blade.php +++ b/modules/extravar/skins/default/form_types/textarea.blade.php @@ -4,4 +4,4 @@ @required(toBool($definition->is_required)) @disabled(toBool($definition->is_disabled)) @readonly(toBool($definition->is_readonly)) - rows="8" cols="42">{{ $value }} + rows="8" cols="42">{{ $value !== null ? $value : $definition->getDefaultValue() }} diff --git a/modules/file/file.admin.view.php b/modules/file/file.admin.view.php index 6a5a4788a..f1b371e74 100644 --- a/modules/file/file.admin.view.php +++ b/modules/file/file.admin.view.php @@ -127,9 +127,9 @@ class FileAdminView extends File } } - if (in_array($file->upload_target_type, ['doc', 'com'])) + if (in_array($file->upload_target_type, ['doc', 'com', 'ev:doc', 'ev:com'])) { - $var = $file->upload_target_type . '_srls'; + $var = str_replace('ev:', '', $file->upload_target_type) . '_srls'; if (!in_array($target_srl, $$var)) { $$var[] = $target_srl; @@ -187,7 +187,7 @@ class FileAdminView extends File foreach($file_list as $srl => $file) { - if($file->upload_target_type == 'com') + if($file->upload_target_type === 'com' || $file->upload_target_type === 'ev:com') { $file_list[$srl]->target_document_srl = $comment_list[$file->upload_target_srl]->document_srl; } @@ -224,6 +224,7 @@ class FileAdminView extends File Context::set('config', $config); Context::set('is_ffmpeg', function_exists('exec') && !empty($config->ffmpeg_command) && Rhymix\Framework\Storage::isExecutable($config->ffmpeg_command) && !empty($config->ffprobe_command) && Rhymix\Framework\Storage::isExecutable($config->ffprobe_command)); Context::set('is_magick', function_exists('exec') && !empty($config->magick_command) && Rhymix\Framework\Storage::isExecutable($config->magick_command)); + Context::set('is_exec_available', function_exists('exec')); // Set a template file $this->setTemplatePath($this->module_path.'tpl'); diff --git a/modules/file/file.controller.php b/modules/file/file.controller.php index 31fcee19c..915ddf64b 100644 --- a/modules/file/file.controller.php +++ b/modules/file/file.controller.php @@ -799,16 +799,22 @@ class FileController extends File * By changing its state to valid when a document is inserted, it prevents from being considered as a unnecessary file * * @param int $upload_target_srl + * @param ?string $upload_target_type + * @param ?array $file_srl * @return BaseObject */ - function setFilesValid($upload_target_srl, $upload_target_type = null) + function setFilesValid($upload_target_srl, $upload_target_type = null, $file_srl = null) { $args = new stdClass(); $args->upload_target_srl = $upload_target_srl; $args->old_isvalid = 'N'; - if($upload_target_type) + if ($upload_target_type) { - $args->upload_target_type = substr($upload_target_type, 0, 3); + $args->upload_target_type = $upload_target_type; + } + if ($file_srl) + { + $args->file_srl = $file_srl; } $output = executeQuery('file.updateFileValid', $args); $output->add('updated_file_count', intval(DB::getInstance()->getAffectedRows())); diff --git a/modules/file/file.model.php b/modules/file/file.model.php index 4781038d0..947205301 100644 --- a/modules/file/file.model.php +++ b/modules/file/file.model.php @@ -32,7 +32,7 @@ class FileModel extends File // Get uploaded files if($upload_target_srl) { - if (!$upload_target_type || $upload_target_type === 'document') + if (!$upload_target_type || $upload_target_type === 'doc' || $upload_target_type === 'document') { $oDocument = DocumentModel::getDocument($upload_target_srl); } @@ -44,7 +44,7 @@ class FileModel extends File // Check permissions of the comment if(!$oDocument || !$oDocument->isExists()) { - if (!$upload_target_type || $upload_target_type === 'comment') + if (!$upload_target_type || $upload_target_type === 'com' || $upload_target_type === 'comment') { $oComment = CommentModel::getComment($upload_target_srl); if($oComment->isExists()) @@ -81,7 +81,9 @@ class FileModel extends File } // Set file list - foreach(self::getFiles($upload_target_srl) as $file_info) + $filter_type = $_SESSION['upload_info'][$editor_sequence]->upload_target_type ?? null; + $files = self::getFiles($upload_target_srl, [], 'file_srl', false, $filter_type); + foreach ($files as $file_info) { $obj = new stdClass; $obj->file_srl = $file_info->file_srl; @@ -208,7 +210,7 @@ class FileModel extends File /** * Check if the file is indexable - * @param object $filename + * @param string $filename * @param object $file_module_config * @return bool */ @@ -288,12 +290,17 @@ class FileModel extends File * Return number of attachments which belongs to a specific document * * @param int $upload_target_srl The sequence to get a number of files + * @param ?string $upload_target_type * @return int Returns a number of files */ - public static function getFilesCount($upload_target_srl) + public static function getFilesCount($upload_target_srl, $upload_target_type = null) { $args = new stdClass(); $args->upload_target_srl = $upload_target_srl; + if ($upload_target_type) + { + $args->upload_target_type = $upload_target_type; + } $output = executeQuery('file.getFilesCount', $args); return (int)$output->data->count; } @@ -430,12 +437,20 @@ class FileModel extends File * @param string $sortIndex The column that used as sort index * @return array Returns array of object that contains file information. If no result returns null. */ - public static function getFiles($upload_target_srl, $columnList = array(), $sortIndex = 'file_srl', $ckValid = false) + public static function getFiles($upload_target_srl, $columnList = array(), $sortIndex = 'file_srl', $valid_files_only = false, $upload_target_type = null) { $args = new stdClass(); $args->upload_target_srl = $upload_target_srl; $args->sort_index = $sortIndex; - if($ckValid) $args->isvalid = 'Y'; + if ($valid_files_only) + { + $args->isvalid = 'Y'; + } + if ($upload_target_type) + { + $args->upload_target_type = $upload_target_type; + } + $output = executeQueryArray('file.getFiles', $args, $columnList); if(!$output->data) { @@ -457,9 +472,12 @@ class FileModel extends File * * @return object Returns a file configuration of current module. If user is admin, returns PHP's max file size and allow all file types. */ - public static function getUploadConfig() + public static function getUploadConfig($module_srl = 0) { - $module_srl = Context::get('module_srl') ?: (Context::get('current_module_info')->module_srl ?? 0); + if (!$module_srl) + { + $module_srl = Context::get('module_srl') ?: (Context::get('current_module_info')->module_srl ?? 0); + } $config = self::getFileConfig($module_srl); if (Rhymix\Framework\Session::isAdmin()) { diff --git a/modules/file/lang/en.php b/modules/file/lang/en.php index 7fd2b2630..1fd6d25fd 100644 --- a/modules/file/lang/en.php +++ b/modules/file/lang/en.php @@ -128,6 +128,7 @@ $lang->ffprobe_path = 'Absolute Path to ffprobe'; $lang->magick_path = 'Absolute Path to magick'; $lang->about_ffmpeg_path = 'Rhymix uses ffmpeg to convert video files.'; $lang->about_magick_path = 'Rhymix uses magick to convert newer image formats such as AVIF and HEIC.
    Note that the \'convert\' command from previous versions of ImageMagick doesn\'t support these formats.
    The latest version can be downloaded from their official site.'; +$lang->msg_cannot_use_exec = 'The exec() function is disabled on this server.'; $lang->msg_cannot_use_ffmpeg = 'In order to use this feature, PHP must be able to execute \'ffmpeg\' and \'ffprobe\' commands.'; $lang->msg_cannot_use_exif = 'In order to use this feature, PHP must be installed with the \'exif\' extension.'; $lang->msg_need_magick = 'In order to handle AVIF and HEIC formats, PHP must be able to execute the \'magick\' command from ImageMagick 7.x or higher.'; diff --git a/modules/file/lang/ko.php b/modules/file/lang/ko.php index 53ee8e429..192a2649f 100644 --- a/modules/file/lang/ko.php +++ b/modules/file/lang/ko.php @@ -131,6 +131,7 @@ $lang->ffprobe_path = 'ffprobe 절대경로'; $lang->magick_path = 'magick 절대경로'; $lang->about_ffmpeg_path = '동영상 변환에 사용합니다.'; $lang->about_magick_path = 'AVIF, HEIC 등 일부 이미지 변환에 사용합니다.
    구 버전 ImageMagick의 convert 명령은 이러한 포맷을 지원하지 않습니다.
    새 버전은 공식 사이트에서 다운받을 수 있습니다.'; +$lang->msg_cannot_use_exec = '이 서버에서 exec() 함수를 사용할 수 없습니다.'; $lang->msg_cannot_use_ffmpeg = '이 기능을 사용하려면 PHP에서 ffmpeg 및 ffprobe 명령을 실행할 수 있어야 합니다.'; $lang->msg_cannot_use_exif = '이 기능을 사용하려면 PHP exif 확장모듈이 필요합니다.'; $lang->msg_need_magick = 'AVIF, HEIC 변환을 위해서는 PHP에서 ImageMagick 7.x 이상의 magick 명령을 실행할 수 있어야 합니다.'; diff --git a/modules/file/queries/getFiles.xml b/modules/file/queries/getFiles.xml index d0e326fb3..a5b0877b4 100644 --- a/modules/file/queries/getFiles.xml +++ b/modules/file/queries/getFiles.xml @@ -4,6 +4,7 @@ + diff --git a/modules/file/queries/getFilesCount.xml b/modules/file/queries/getFilesCount.xml index 81a257cd1..df0f7b6a8 100644 --- a/modules/file/queries/getFilesCount.xml +++ b/modules/file/queries/getFilesCount.xml @@ -7,6 +7,7 @@ + diff --git a/modules/file/queries/updateFileValid.xml b/modules/file/queries/updateFileValid.xml index 8e17a211a..52a807d4c 100644 --- a/modules/file/queries/updateFileValid.xml +++ b/modules/file/queries/updateFileValid.xml @@ -1,13 +1,14 @@ - - - - + +
    + + - - - - - - + + + + + + + diff --git a/modules/file/tpl/file_list.html b/modules/file/tpl/file_list.html index 317349770..a4ca4a1d3 100644 --- a/modules/file/tpl/file_list.html +++ b/modules/file/tpl/file_list.html @@ -57,12 +57,12 @@ xe.lang.msg_empty_search_keyword = '{$lang->msg_empty_search_keyword}'; - + {@ $document_srl = $val->target_document_srl} {@ $move_uri = getUrl('', 'document_srl', $document_srl).'#comment_'.$val->upload_target_srl} - + {@ $document_srl = $val->upload_target_srl} - {@ $move_uri = getUrl('', 'document_srl', $document_srl)} + {@ $move_uri = getUrl('', 'mid', $module_list[$val->module_srl]->mid, 'document_srl', $document_srl)} {@ $cur_upload_target_srl = $val->upload_target_srl} @@ -78,12 +78,12 @@ xe.lang.msg_empty_search_keyword = '{$lang->msg_empty_search_keyword}'; [{$lang->module}] [{$lang->member_message}] - [{$lang->cmd_temp_save}] - [{$lang->cmd_trash}] + [{$lang->cmd_temp_save}] + [{$lang->cmd_trash}] {$module_list[$val->module_srl]->browser_title} - + - {$document_list[$document_srl]->getTitle()}{$document_list[$document_srl]->getTitle()} @@ -97,7 +97,7 @@ xe.lang.msg_empty_search_keyword = '{$lang->msg_empty_search_keyword}'; - {htmlspecialchars($val->source_filename, ENT_COMPAT | ENT_HTML401, 'UTF-8', false)} + {escape($val->source_filename, false)} - @@ -53,7 +52,6 @@ - diff --git a/modules/member/member.admin.model.php b/modules/member/member.admin.model.php index a699cc35d..4a3b9c23e 100644 --- a/modules/member/member.admin.model.php +++ b/modules/member/member.admin.model.php @@ -307,6 +307,10 @@ class MemberAdminModel extends Member $id_list = implode(',',$list); Context::set('id_list',$id_list); + $extravar_types = lang('common.column_type_list'); + unset($extravar_types['file']); + Context::set('extravar_types', $extravar_types); + $oTemplate = TemplateHandler::getInstance(); $tpl = $oTemplate->compile($this->module_path.'tpl', 'insert_join_form'); diff --git a/modules/member/member.class.php b/modules/member/member.class.php index a191c16d2..32a8c3701 100644 --- a/modules/member/member.class.php +++ b/modules/member/member.class.php @@ -657,8 +657,7 @@ class Member extends ModuleObject if($error == 0) return new BaseObject($error, $message); // Create a member model object - $oMemberModel = getModel('member'); - $config = $oMemberModel->getMemberConfig(); + $config = MemberModel::getMemberConfig(); // Check if there is recoding table. $oDB = DB::getInstance(); @@ -681,8 +680,6 @@ class Member extends ModuleObject { $args->count = 1; } - unset($oMemberModel); - unset($config); $output = executeQuery('member.updateLoginCountByIp', $args); } else @@ -714,6 +711,10 @@ class Member extends ModuleObject { //update $content = unserialize($output->data->content); + if (is_array($content) && count($content) >= 250) + { + $content = array_slice($content, -200); + } $content[] = array(\RX_CLIENT_IP, lang($message), \RX_TIME); $args->content = serialize($content); $output = executeQuery('member.updateLoginCountHistoryByMemberSrl', $args); diff --git a/modules/member/member.controller.php b/modules/member/member.controller.php index 516c2856a..105d8dcc5 100644 --- a/modules/member/member.controller.php +++ b/modules/member/member.controller.php @@ -2575,7 +2575,7 @@ class MemberController extends Member } else { - $refused_reason = $member_info->refused_reason ? ('
    ' . lang('refused_reason') . ': ' . $member_info->refused_reason) : ''; + $refused_reason = $member_info->refused_reason ? ("\n" . lang('refused_reason') . ': ' . $member_info->refused_reason) : ''; return new BaseObject(-1, lang('msg_user_denied') . $refused_reason); } } @@ -2583,7 +2583,7 @@ class MemberController extends Member // Notify if user is limited if($member_info->limit_date && substr($member_info->limit_date,0,8) >= date("Ymd")) { - $limited_reason = $member_info->limited_reason ? ('
    ' . lang('refused_reason') . ': ' . $member_info->limited_reason) : ''; + $limited_reason = $member_info->limited_reason ? ("\n" . lang('refused_reason') . ': ' . $member_info->limited_reason) : ''; return new BaseObject(-9, sprintf(lang('msg_user_limited'), zdate($member_info->limit_date,"Y-m-d")) . $limited_reason); } diff --git a/modules/member/member.view.php b/modules/member/member.view.php index a61dbc74e..2731ad480 100644 --- a/modules/member/member.view.php +++ b/modules/member/member.view.php @@ -74,7 +74,7 @@ class MemberView extends Member { $this->member_config = MemberModel::getMemberConfig(); } - if (!$this->member_config->mid || !$this->member_config->force_mid) + if (empty($this->member_config->mid) || empty($this->member_config->force_mid)) { return true; } diff --git a/modules/member/tpl/insert_join_form.html b/modules/member/tpl/insert_join_form.html index 91afce924..18f2a4c21 100644 --- a/modules/member/tpl/insert_join_form.html +++ b/modules/member/tpl/insert_join_form.html @@ -22,7 +22,7 @@
    diff --git a/modules/member/tpl/signup_config.html b/modules/member/tpl/signup_config.html index f65fddb76..f6d490b51 100644 --- a/modules/member/tpl/signup_config.html +++ b/modules/member/tpl/signup_config.html @@ -251,8 +251,8 @@ diff --git a/modules/menu/lang/ja.php b/modules/menu/lang/ja.php index abe4fee6b..25c50838b 100644 --- a/modules/menu/lang/ja.php +++ b/modules/menu/lang/ja.php @@ -126,7 +126,6 @@ $lang->go_to_site_design_setup = 'サイトでフォルトデザイン設定ペ $lang->about_menu_type_in_default = 'メニュータイプ変更のためにメニューを削除後再作成が必要です。'; $lang->how_to_modify_menu = '作成したメニューは「メニュー修正」で修正できます。'; $lang->can_drag_menu = 'サイトマップでメニューをドラッグしても位置変更ができます。'; -$lang->good_to_duplicate_layout = 'レイアウト設定変更時、「コピーして作成」ブタンをクリックしてコピーを作成して設定を変更することが望ましいです。'; $lang->img_uploaded = 'ブタンイメージが登録されました。'; $lang->img_deleted = 'ブタンイメージが削除されました。'; $lang->do_not_display_again = '再び表示しない。'; diff --git a/modules/menu/lang/ko.php b/modules/menu/lang/ko.php index a58037505..6c390565b 100644 --- a/modules/menu/lang/ko.php +++ b/modules/menu/lang/ko.php @@ -134,7 +134,6 @@ $lang->go_to_site_design_setup = '사이트 기본 디자인 설정 페이지로 $lang->about_menu_type_in_default = '메뉴 타입 변경을 위해서는 메뉴를 삭제 후 재생성 해야 됩니다.'; $lang->how_to_modify_menu = '생성한 메뉴는 [메뉴 수정]에서 수정 할 수 있습니다.'; $lang->can_drag_menu = '사이트 맵에서 메뉴를 드래그 해서도 위치를 변경 할 수 있습니다.'; -$lang->good_to_duplicate_layout = '레이아웃 설정 변경 시, [복사본 생성] 버튼을 눌러 복사본을 만들어 설정을 변경 하는 것이 좋습니다.'; $lang->img_uploaded = '버튼 이미지가 등록 됐습니다.'; $lang->img_deleted = '버튼 이미지가 삭제 됐습니다.'; $lang->do_not_display_again = '다시 보지 않기.'; diff --git a/modules/menu/tpl/sitemap.html b/modules/menu/tpl/sitemap.html index 891f3cfbf..126795823 100644 --- a/modules/menu/tpl/sitemap.html +++ b/modules/menu/tpl/sitemap.html @@ -498,7 +498,6 @@ @@ -523,16 +522,6 @@ - -
    {FileHandler::filesize($val->file_size)} {$val->download_count} diff --git a/modules/file/tpl/upload_config.html b/modules/file/tpl/upload_config.html index 724094a40..f13a544fa 100644 --- a/modules/file/tpl/upload_config.html +++ b/modules/file/tpl/upload_config.html @@ -253,6 +253,7 @@

    {$lang->about_ffmpeg_path}

    +

    {$lang->msg_cannot_use_exec}

    @@ -260,6 +261,7 @@

    {$lang->about_ffmpeg_path}

    +

    {$lang->msg_cannot_use_exec}

    @@ -267,6 +269,7 @@

    {$lang->about_magick_path}

    +

    {$lang->msg_cannot_use_exec}

    diff --git a/modules/krzip/tpl/template.daumapi.html b/modules/krzip/tpl/template.daumapi.html index 367392b0f..1270cbd00 100644 --- a/modules/krzip/tpl/template.daumapi.html +++ b/modules/krzip/tpl/template.daumapi.html @@ -15,7 +15,8 @@
    - + +
    @@ -32,9 +33,10 @@ diff --git a/modules/krzip/tpl/template.epostapi.html b/modules/krzip/tpl/template.epostapi.html index c001af6c3..f6835d723 100644 --- a/modules/krzip/tpl/template.epostapi.html +++ b/modules/krzip/tpl/template.epostapi.html @@ -13,7 +13,8 @@
    - + +
    @@ -29,7 +30,8 @@ diff --git a/modules/krzip/tpl/template.postcodify.html b/modules/krzip/tpl/template.postcodify.html index 71a5ac6a7..c8b1a2e2c 100644 --- a/modules/krzip/tpl/template.postcodify.html +++ b/modules/krzip/tpl/template.postcodify.html @@ -14,7 +14,8 @@
    - + +
    @@ -30,7 +31,8 @@ diff --git a/modules/layout/conf/module.xml b/modules/layout/conf/module.xml index 60b2ea8f3..b5278c856 100644 --- a/modules/layout/conf/module.xml +++ b/modules/layout/conf/module.xml @@ -5,7 +5,7 @@ - + @@ -14,11 +14,10 @@ - + - - + diff --git a/modules/layout/layout.admin.model.php b/modules/layout/layout.admin.model.php index ced111288..547f49e90 100644 --- a/modules/layout/layout.admin.model.php +++ b/modules/layout/layout.admin.model.php @@ -92,73 +92,6 @@ class LayoutAdminModel extends Layout Context::set('selected_layout', $layout_info); } - public function getLayoutAdminSetHTMLCSS() - { - // Set the layout with its information - $layout_srl = Context::get('layout_srl'); - // Get layout information - $oLayoutModel = getModel('layout'); - $layout_info = $oLayoutModel->getLayout($layout_srl); - // Error appears if there is no layout information is registered - if(!$layout_info) - { - return $this->dispLayoutAdminInstalledList(); - } - - // Get Layout Code - if($oLayoutModel->useDefaultLayout($layout_info->layout_srl)) - { - $layout_file = $oLayoutModel->getDefaultLayoutHtml($layout_info->layout); - $layout_css_file = $oLayoutModel->getDefaultLayoutCss($layout_info->layout); - } - else - { - $layout_file = $oLayoutModel->getUserLayoutHtml($layout_info->layout_srl); - $layout_css_file = $oLayoutModel->getUserLayoutCss($layout_info->layout_srl); - - if(!file_exists($layout_file)) $layout_file = $layout_info->path . 'layout.html'; - if(!file_exists($layout_css_file)) $layout_css_file = $layout_info->path . 'layout.css'; - } - - if(file_exists($layout_css_file)) - { - $layout_code_css = FileHandler::readFile($layout_css_file); - Context::set('layout_code_css', $layout_code_css); - } - - $layout_code = FileHandler::readFile($layout_file); - Context::set('layout_code', $layout_code); - - // set User Images - $layout_image_list = $oLayoutModel->getUserLayoutImageList($layout_info->layout_srl); - Context::set('layout_image_list', $layout_image_list); - - $layout_image_path = $oLayoutModel->getUserLayoutImagePath($layout_info->layout_srl); - Context::set('layout_image_path', $layout_image_path); - // Set widget list - $oWidgetModel = getModel('widget'); - $widget_list = $oWidgetModel->getDownloadedWidgetList(); - Context::set('widget_list', $widget_list); - - $security = new Security($layout_info); - $layout_info = $security->encodeHTML('.', '.author..'); - Context::set('selected_layout', $layout_info); - - //Security - $security = new Security(); - $security->encodeHTML('layout_list..'); - $security->encodeHTML('layout_list..author..'); - - $security = new Security(); - $security->encodeHTML('layout_code_css', 'layout_code', 'widget_list..title'); - - $script = ''; - $oTemplate = TemplateHandler::getInstance(); - $html = $oTemplate->compile($this->module_path.'tpl/', 'layout_html_css_view'); - - $this->add('html', $script.$html); - } - public function getLayoutAdminSiteDefaultLayout() { $type = Context::get('type'); diff --git a/modules/layout/tpl/installed_layout_list.html b/modules/layout/tpl/installed_layout_list.html index f0eb233d3..343e51ee7 100644 --- a/modules/layout/tpl/installed_layout_list.html +++ b/modules/layout/tpl/installed_layout_list.html @@ -14,7 +14,6 @@
    {$lang->version} {$lang->author} {$lang->path}{$lang->cmd_delete}
    - - {$layout->path}{$lang->cmd_delete}
    - - + + {$item->description} {$lang->cmd_edit} | {$lang->cmd_delete}
    + + + + + +
    diff --git a/modules/module/tpl/module_list.html b/modules/module/tpl/module_list.html index 04a131af9..1f4ad3cc2 100644 --- a/modules/module/tpl/module_list.html +++ b/modules/module/tpl/module_list.html @@ -17,7 +17,6 @@ {$lang->version} {$lang->author} {$lang->path} - {$lang->cmd_delete} @@ -55,9 +54,6 @@ {$val->path} - - {$lang->cmd_delete} - diff --git a/modules/ncenterlite/conf/module.xml b/modules/ncenterlite/conf/module.xml index d2cb396f5..a4e64461d 100644 --- a/modules/ncenterlite/conf/module.xml +++ b/modules/ncenterlite/conf/module.xml @@ -37,6 +37,7 @@ + diff --git a/modules/ncenterlite/ncenterlite.controller.php b/modules/ncenterlite/ncenterlite.controller.php index 5a50c47f2..21207e972 100644 --- a/modules/ncenterlite/ncenterlite.controller.php +++ b/modules/ncenterlite/ncenterlite.controller.php @@ -1046,6 +1046,31 @@ class NcenterliteController extends Ncenterlite } } + public function triggerAfterGetComments($comment_list) + { + if (Context::get('act') === 'dispBoardCommentPage' && $comment_list) + { + $config = NcenterliteModel::getConfig(); + $document_srl = Context::get('document_srl'); + $logged_info = Context::get('logged_info'); + + if ($document_srl && $config->document_read == 'Y' && $logged_info && $logged_info->member_srl) + { + $args = new stdClass; + $args->member_srl = $logged_info->member_srl; + $args->target_srl = array_values(array_map(function($comment) { + return $comment->comment_srl; + }, $comment_list)); + + $output = executeQuery('ncenterlite.updateNotifyReadedByTargetSrl', $args); + if ($output->toBool() && DB::getInstance()->getAffectedRows()) + { + $this->removeFlagFile($args->member_srl); + } + } + } + } + function triggerBeforeDisplay(&$output_display) { // Don't show notification panel in popups, iframes, admin dashboard, etc. diff --git a/modules/rss/lang/en.php b/modules/rss/lang/en.php index be3aef2b7..d82d026e0 100644 --- a/modules/rss/lang/en.php +++ b/modules/rss/lang/en.php @@ -6,6 +6,7 @@ $lang->feed_copyright = 'Copyright'; $lang->feed_document_count = 'Number of articles per page'; $lang->feed_image = 'Feed Image'; $lang->rss_type = 'RSS feed type'; +$lang->module_feed_management = 'Feeds for Each Module'; $lang->open_rss = 'Open RSS'; $lang->open_rss_types['Y'] = 'Open all'; $lang->open_rss_types['H'] = 'Open summary'; diff --git a/modules/rss/lang/ko.php b/modules/rss/lang/ko.php index 58f3a8b1e..ea6dec7e2 100644 --- a/modules/rss/lang/ko.php +++ b/modules/rss/lang/ko.php @@ -6,6 +6,7 @@ $lang->feed_copyright = '저작권'; $lang->feed_document_count = '한 페이지당 글 수'; $lang->feed_image = '피드 이미지'; $lang->rss_type = '출력할 피드(Feed) 형식'; +$lang->module_feed_management = '모듈별 피드 관리'; $lang->open_rss = '피드(Feed) 공개'; $lang->open_rss_types['Y'] = '전문 공개 '; $lang->open_rss_types['H'] = '요약 공개'; diff --git a/modules/rss/rss.admin.view.php b/modules/rss/rss.admin.view.php index a61d33acb..00e29c6d8 100644 --- a/modules/rss/rss.admin.view.php +++ b/modules/rss/rss.admin.view.php @@ -36,6 +36,7 @@ class RssAdminView extends Rss $module_info = ModuleModel::getModuleInfoByModuleSrl($module_srl); $args = new stdClass; + $args->browser_title = $module_info->browser_title; $args->mid = $module_info->mid; $args->url = $oRssModel->getRssURL('rss', $module_info->mid); $args->open_feed = $module_config->open_rss; diff --git a/modules/rss/tpl/rss_admin_index.html b/modules/rss/tpl/rss_admin_index.html index 6cb43c3a9..4d5d8e9f8 100644 --- a/modules/rss/tpl/rss_admin_index.html +++ b/modules/rss/tpl/rss_admin_index.html @@ -42,7 +42,7 @@
    -
    +
    image
    @@ -68,12 +68,12 @@
    -

    {$lang->feed} {$lang->cmd_management}

    +

    {$lang->module_feed_management}

    - + @@ -86,7 +86,8 @@
    - {$module_config->mid} + {$module_config->browser_title} +
    {$module_config->mid}
    @@ -110,7 +111,7 @@
    - +
    diff --git a/modules/widget/tpl/downloaded_widget_list.html b/modules/widget/tpl/downloaded_widget_list.html index e8dcc6930..047258383 100644 --- a/modules/widget/tpl/downloaded_widget_list.html +++ b/modules/widget/tpl/downloaded_widget_list.html @@ -14,7 +14,6 @@ {$lang->author} {$lang->path} {$lang->cmd_generate_code} - {$lang->cmd_delete} @@ -41,7 +40,6 @@ {$widget->path} {$lang->cmd_generate_code} - {$lang->cmd_delete} diff --git a/tests/unit/framework/QueueTest.php b/tests/unit/framework/QueueTest.php new file mode 100644 index 000000000..5019a7da3 --- /dev/null +++ b/tests/unit/framework/QueueTest.php @@ -0,0 +1,23 @@ + 'bar']; + $options = (object)['key' => 'val']; + + Rhymix\Framework\Queue::addTask($handler, $args, $options); + + $output = Rhymix\Framework\Queue::getTask(); + $this->assertEquals('myfunc', $output->handler); + $this->assertEquals('bar', $output->args->foo); + $this->assertEquals('val', $output->options->key); + + $output = Rhymix\Framework\Queue::getTask(); + $this->assertNull($output); + } +} diff --git a/tests/unit/framework/UATest.php b/tests/unit/framework/UATest.php index 018c89f82..a20ff65b0 100644 --- a/tests/unit/framework/UATest.php +++ b/tests/unit/framework/UATest.php @@ -40,6 +40,11 @@ class UATest extends \Codeception\Test\Unit $this->assertTrue(Rhymix\Framework\UA::isRobot('Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)')); $this->assertTrue(Rhymix\Framework\UA::isRobot('Googlebot/2.1 (+http://www.googlebot.com/bot.html)')); $this->assertTrue(Rhymix\Framework\UA::isRobot('Yeti/1.0 (NHN Corp.; http://help.naver.com/robots/)')); + $this->assertTrue(Rhymix\Framework\UA::isRobot('Random user agent (+https://url.com)')); + $this->assertTrue(Rhymix\Framework\UA::isRobot('facebookexternalhit/1.1')); + $this->assertTrue(Rhymix\Framework\UA::isRobot('meta-externalfetcher/1.1')); + $this->assertTrue(Rhymix\Framework\UA::isRobot('ia_archiver-web.archive.org')); + $this->assertTrue(Rhymix\Framework\UA::isRobot('GoogleOther')); // Not robot $this->assertFalse(Rhymix\Framework\UA::isRobot('Mozilla/5.0 (iPhone; CPU iPhone OS 6_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A5376e Safari/8536.25')); diff --git a/widgets/content/conf/info.xml b/widgets/content/conf/info.xml index 81bb551cb..3c36c0fca 100644 --- a/widgets/content/conf/info.xml +++ b/widgets/content/conf/info.xml @@ -731,20 +731,31 @@ Тип миниатюры 縮圖建立方式 Küçük resim türü - 썸네일 생성 방법을 선택할 수 있습니다. (crop : 꽉 채우기, ratio : 비율 맞추기) - サムネールの生成方法を選択します(Crop : 刈り込み, Ratio : 比率)。 - 可以选择缩略图生成方式。 (crop : 裁减, ratio : 比例) + 썸네일 생성 방법을 선택할 수 있습니다. + サムネールの生成方法を選択します。 + 可以选择缩略图生成方式。 Thumbnail Type may be set here. Có thể đặt kiểu hình nhỏ tại đây. Тип миниатюры может быть установлен здесь. - 可選擇縮圖建立方式。(crop : 裁減, ratio : 比例) + 可選擇縮圖建立方式。 Küçük resim türünü burada ayarlayabilirsiniz. + + fill + 비율 유지하며 가득 채움 (권장) + Fill (Recommended) + Fill (刈り込み) + Fill (裁减) + Đổ đầy + Fill (Обрезание) + Fill (裁減) + Doldurmak + crop - Crop (채우기) + 비율 유지하며 잘라내기 + Crop Crop (刈り込み) Crop (裁减) - Crop Hình cắt Crop (Обрезание) Crop (裁減) @@ -752,10 +763,10 @@ ratio - Ratio (비율 맞추기) + 비율 유지하며 잘리지 않도록 함 + Letterbox Ratio (比率) Ratio (比例) - Ratio Tỉ lệ Ratio (Отношение) Ratio (比例) diff --git a/widgets/content/content.class.php b/widgets/content/content.class.php index 6555251b1..54e281f8f 100644 --- a/widgets/content/content.class.php +++ b/widgets/content/content.class.php @@ -39,7 +39,7 @@ class content extends WidgetHandler // Display time of the latest post if(!$args->duration_new) $args->duration_new = 12; // How to create thumbnails - if(!$args->thumbnail_type) $args->thumbnail_type = 'crop'; + if(!$args->thumbnail_type) $args->thumbnail_type = 'fill'; // Horizontal size of thumbnails if(!$args->thumbnail_width) $args->thumbnail_width = 100; // Vertical size of thumbnails @@ -358,7 +358,7 @@ class content extends WidgetHandler $attribute = $oDocument->getObjectVars(); $browser_title = $args->module_srls_info[$attribute->module_srl]->browser_title; $domain = $args->module_srls_info[$attribute->module_srl]->domain; - $category = $category_lists[$attribute->module_srl]->text; + $category = isset($category_lists[$attribute->module_srl]) ? $category_lists[$attribute->module_srl]->text : ''; $content = $oDocument->getSummary($args->content_cut_size); $url = sprintf('%s#%s', $oDocument->getPermanentUrl(), $oDocument->getCommentCount()); $thumbnail = $oDocument->getThumbnail($args->thumbnail_width,$args->thumbnail_height,$args->thumbnail_type);