/** * Common JavaScript library for Rhymix, based on XpressEngine 1.x */ /** * ============= * Rhymix object * ============= */ const Rhymix = window.Rhymix = { addedDocument: [], langCodes: {}, loadedPopupMenus: [], openWindowList: {}, currentDebugData: null, pendingDebugData: [], showAjaxErrors: ['ALL'], unloading: false, modal: {}, state: {} }; /** * Check if the current device is a mobile device. * * @return bool */ Rhymix.isMobile = function() { return String(navigator.userAgent).match(/mobile/i); }; /** * Get the current color scheme * * @return string */ Rhymix.getColorScheme = function() { if ($('body').hasClass('color_scheme_dark')) { return 'dark'; } else { return 'light'; } }; /** * Set the color scheme * * @param string color_scheme * @return void */ Rhymix.setColorScheme = function(color_scheme) { if (color_scheme === 'dark' || color_scheme === 'light') { $('body').addClass('color_scheme_' + color_scheme).removeClass('color_scheme_' + (color_scheme === 'dark' ? 'light' : 'dark')); this.cookie.set('rx_color_scheme', color_scheme, { path: this.URI(default_url).pathname(), expires: 365 }); } else { this.cookie.remove('rx_color_scheme', { path: this.URI(default_url).pathname() }); color_scheme = (window.matchMedia && window.matchMedia('(prefers-color-scheme:dark)').matches) ? 'dark' : 'light'; $('body').addClass('color_scheme_' + color_scheme).removeClass('color_scheme_' + (color_scheme === 'dark' ? 'light' : 'dark')); } }; /** * Automatically detect the color scheme * * @return void */ Rhymix.detectColorScheme = function() { // Return if a color scheme is already selected. const body_element = $('body'); if(body_element.hasClass('color_scheme_light') || body_element.hasClass('color_scheme_dark')) { return; } // Detect the cookie. let color_scheme = this.cookie.get('rx_color_scheme'); // Detect the device color scheme. let match_media = window.matchMedia ? window.matchMedia('(prefers-color-scheme:dark)') : null; if (color_scheme !== 'light' && color_scheme !== 'dark') { color_scheme = (match_media && match_media.matches) ? 'dark' : 'light'; } // Set the body class according to the detected color scheme. body_element.addClass('color_scheme_' + color_scheme); // Add an event listener to detect changes to the device color scheme. match_media && match_media.addListener && match_media.addListener(function(e) { if (e.matches) { body_element.removeClass('color_scheme_light').addClass('color_scheme_dark'); } else { body_element.removeClass('color_scheme_dark').addClass('color_scheme_light'); } }); }; /** * Get the language * * @return string */ Rhymix.getLangType = function() { return window.current_lang; }; /** * Set the language * * @param string lang_type * @return void */ Rhymix.setLangType = function(lang_type) { const baseurl = this.getBaseUrl(); if (baseurl !== '/') { this.cookie.remove('lang_type', { path: '/' }); } this.cookie.set('lang_type', lang_type, { path: baseurl, expires: 365 }); }; /** * Get CSRF token for this document * * @return string|null */ Rhymix.getCSRFToken = function() { return $("meta[name='csrf-token']").attr("content"); }; /** * Set CSRF token for this document * * @param string token * @return void */ Rhymix.setCSRFToken = function(token) { $("meta[name='csrf-token']").attr("content", token); }; /** * Get the current rewrite level * * @return int */ Rhymix.getRewriteLevel = function() { return window.rewrite_level; }; /** * Get the base URL relative to the current origin * * @return string */ Rhymix.getBaseUrl = function() { if (!this.state.baseUrl) { this.state.baseUrl = this.URI(default_url).pathname(); } return this.state.baseUrl; }; /** * Get the full default URL * * @return string */ Rhymix.getDefaultUrl = function() { return window.default_url; }; /** * Get the current page's long URL * * @return string */ Rhymix.getCurrentUrl = function() { return window.current_url; }; /** * Get the current page prefix (mid) * * @return string */ Rhymix.getCurrentUrlPrefix = function() { return window.current_mid; }; /** * Check if a URL is identical to the current page URL except for the hash * * @param string url * @return bool */ Rhymix.isCurrentUrl = function(url) { const absolute_url = window.location.href; const relative_url = window.location.pathname + window.location.search; return url === absolute_url || url === relative_url || url.indexOf(absolute_url.replace(/#.+$/, "") + "#") === 0 || url.indexOf(relative_url.replace(/#.+$/, "") + "#") === 0; }; /** * Check if two URLs belong to the same origin * * @param string url1 * @param string url2 * @return bool */ Rhymix.isSameOrigin = function(url1, url2) { if(!url1 || !url2) { return false; } if (url1.match(/^\.?\/[^\/]*/) || url2.match(/^\.?\/[^\/]*/)) { return true; } if (url1.match(/^(https?:)?\/\/[^\/]*[^a-z0-9\/.:_-]/i) || url2.match(/^(https?:)?\/\/[^\/]*[^a-z0-9\/.:_-]/i)) { return false; } try { url1 = this.URI(url1).normalizePort().normalizeHostname().normalizePathname().origin(); url2 = this.URI(url2).normalizePort().normalizeHostname().normalizePathname().origin(); return (url1 === url2) ? true : false; } catch (err) { return false; } } /** * Check if a URL belongs to the same host as the current page * * Note that this function does not check the protocol. * It is therefore a weaker check than isSameOrigin(). * * @param string url * @return bool */ Rhymix.isSameHost = function(url) { if (typeof url !== 'string') { return false; } if (url.match(/^\.?\/[^\/]/)) { return true; } if (url.match(/^\w+:[^\/]*$/) || url.match(/^(https?:)?\/\/[^\/]*[^a-z0-9\/.:_-]/i)) { return false; } if (!this.state.partialOrigin) { let uri = this.URI(window.request_uri).normalizePort().normalizeHostname().normalizePathname(); this.state.partialOrigin = uri.hostname() + uri.directory(); } try { let target_url = this.URI(url).normalizePort().normalizeHostname().normalizePathname(); if (target_url.is('urn')) { return false; } if (!target_url.hostname()) { target_url = target_url.absoluteTo(window.request_uri); } target_url = target_url.hostname() + target_url.directory(); return target_url.indexOf(this.state.partialOrigin) === 0; } catch(err) { return false; } }; /** * Redirect to a URL, but reload instead if the target is the same as the current page * * @param string url * @param int delay * @return void */ Rhymix.redirectToUrl = function(url, delay) { const callback = function() { if (Rhymix.isCurrentUrl(url)) { window.location.href = url; window.location.reload(); } else { window.location.href = url; } }; if (delay) { this.pendingRedirect = setTimeout(callback, delay); } else { callback(); } }; /** * Cancel any pending redirect * * @return bool */ Rhymix.cancelPendingRedirect = function() { if (this.pendingRedirect) { clearTimeout(this.pendingRedirect); this.pendingRedirect = null; return true; } else { return false; } }; /** * Open a new window and focus it * * @param string url * @param string target * @param string features * @return void */ Rhymix.openWindow = function(url, target, features) { // Fill default values if (typeof target === 'undefined') { target = '_blank'; } if (typeof features === 'undefined') { features = ''; } // Close any existing window with the same target name try { if (target !== '_blank' && this.openWindowList[target]) { this.openWindowList[target].close(); delete this.openWindowList[target]; } } catch(e) {} // Open using Blankshield if the target is a different site if (!this.isSameHost(url)) { window.blankshield.open(url, target, features); } else { const win = window.open(url, target, features); win.focus(); if (target !== '_blank') { this.openWindowList[target] = win; } } }; /** * Open a popup with standard features, for backward compatibility * * @param string url * @param string target * @return void */ Rhymix.openPopup = function(url, target) { const features = 'width=800,height=600,toolbars=no,scrollbars=yes,resizable=yes'; this.openWindow(url, target, features); }; /** * Save background scroll position * * @param bool pushState * @return void */ Rhymix.modal.saveBackgroundPosition = function(modal_id, pushState) { const body = $(document.body); if (!body.data('rx_scroll_position')) { body.data('rx_scroll_position', { left: $(window).scrollLeft(), top: $(window).scrollTop() }); } body.addClass('rx_modal_open'); if (pushState) { history.pushState({ modal: modal_id }, '', location.href); } }; /** * Open an HTML element as a modal * * @param string id * @return void */ Rhymix.modal.open = function(id) { this.saveBackgroundPosition(id, true); $('#' + id).addClass('active'); }; /** * Open an iframe as a modal * * @param string url * @param string target * @return void */ Rhymix.modal.openIframe = function(url, target) { const iframe = document.createElement('iframe'); const iframe_sequence = String(Date.now()) + Math.round(Math.random() * 1000000); const iframe_id = '_rx_iframe_' + iframe_sequence; iframe.setAttribute('id', iframe_id); iframe.setAttribute('class', 'rx_modal'); iframe.setAttribute('name', target || ('_rx_iframe_' + iframe_sequence)) iframe.setAttribute('src', url + '&iframe_sequence=' + iframe_sequence); iframe.setAttribute('width', '100%'); iframe.setAttribute('height', '100%'); iframe.setAttribute('style', 'position:fixed; top:0; left:0; width:100%; height:100%; border:0; z-index:999999999; background-color: #fff; overflow-y:auto'); this.saveBackgroundPosition(iframe_id, true); $(document.body).append(iframe); }; /** * Close currently open modal * * @param string id * @return void */ Rhymix.modal.close = function(id) { history.back(); /* if (typeof id === 'string') { $('#' + id).remove(); } else { $('.rx_modal').remove(); } */ }; /** * Make an AJAX request * * @param string action * @param object params * @param function callback_success * @param function callback_error * @return Promise */ Rhymix.ajax = function(action, params, callback_success, callback_error) { // Extract module and act let isFormData = params instanceof FormData; let module, act, url, promise; if (action) { if (typeof action === 'string' && action.match(/^[a-z0-9_]+\.[a-z0-9_]+$/i)) { let parts = action.split('.'); params = params || {}; params.module = module = parts[0]; params.act = act = parts[1]; } else { url = action; action = null; } } else { if (isFormData) { module = params.get('module'); act = params.get('act'); if (module && act) { action = module + '.' + act; } else if (act) { action = act; } else { action = null; } } else { action = null; } } // Add action to URL if the current rewrite level supports it if (!url) { url = this.URI(window.request_uri).pathname() + 'index.php'; if (act) { url = url + '?act=' + act; } /* if (this.getRewriteLevel() >= 2 && action !== null) { url = url + '_' + action.replace('.', '/'); } else { url = url + 'index.php'; } */ } // Add a CSRF token to the header, and remove it from the parameters const headers = { 'X-CSRF-Token': getCSRFToken() }; if (isFormData && params.has('_rx_csrf_token') && params.get('_rx_csrf_token') === headers['X-CSRF-Token']) { params.delete('_rx_csrf_token'); } if (typeof params._rx_csrf_token !== 'undefined' && params._rx_csrf_token === headers['X-CSRF-Token']) { delete params._rx_csrf_token; } // Create and return a Promise for this AJAX request return promise = new Promise(function(resolve, reject) { // Define the success wrapper. const successWrapper = function(data, textStatus, xhr) { // Add debug information. if (data._rx_debug) { data._rx_debug.page_title = "AJAX : " + action; if (Rhymix.addDebugData) { Rhymix.addDebugData(data._rx_debug); } else { Rhymix.pendingDebugData.push(data._rx_debug); } } // If the response contains a Rhymix error code, display the error message. if (typeof data.error !== 'undefined' && data.error != 0) { return errorWrapper(data, textStatus, xhr); } // If a success callback was defined, call it. if (typeof callback_success === 'function') { callback_success(data, xhr); resolve(data); return; } // If the response contains a redirect URL, follow the redirect. // This can be canceled by Rhymix.cancelPendingRedirect() within 100 milliseconds. if (data.redirect_url) { Rhymix.redirectToUrl(data.redirect_url.replace(/&/g, '&'), 100); } // Resolve the promise with the response data. resolve(data); }; // Define the error wrapper. const errorWrapper = function(data, textStatus, xhr) { // If an error callback is defined, call it. // The promise will still be rejected, but silently. if (typeof callback_error === 'function') { callback_error(data, xhr); promise.catch(function(dummy) { }); let dummy = new Error('Rhymix.ajax() error already handled by callback function'); dummy._rx_ajax_error = true; dummy.cause = data; dummy.details = ''; dummy.xhr = xhr; reject(dummy); return; } // Otherwise, generate a generic error message. let error_message = 'AJAX error: ' + (action || 'form submission'); let error_details = ''; if (data.error != 0 && data.message) { error_message = data.message.replace(/\\n/g, "\n"); if (data.errorDetail) { error_details = data.errorDetail; } } else if (xhr.status == 0) { error_details = 'Connection failed: ' + url + "\n\n" + (xhr.responseText || ''); } else { error_details = (xhr.responseText || ''); } if (error_details.length > 1000) { error_details = error_details.substring(0, 1000) + '...'; } // Reject the promise with an error object. // If uncaught, this will be handled by the 'unhandledrejection' event listener. const err = new Error(error_message); err._rx_ajax_error = true; err.cause = data; err.details = error_details; err.xhr = xhr; reject(err); }; // Pass off to jQuery with another wrapper around the success and error wrappers. // This allows us to handle HTTP 400+ error codes with valid JSON responses. $.ajax({ type: 'POST', dataType: 'json', url: url, data: isFormData ? params : JSON.stringify(params), contentType: isFormData ? false : 'application/json; charset=UTF-8', processData: false, headers: headers, success: successWrapper, error: function(xhr, textStatus, errorThrown) { if (xhr.status == 0 && Rhymix.unloading) { return; } if (xhr.status >= 400 && xhr.responseText) { try { let data = JSON.parse(xhr.responseText); if (data) { successWrapper(data, textStatus, xhr); return; } } catch (e) { } } errorWrapper({ error: 0, message: textStatus }, textStatus, xhr); } }); }); }; /** * Submit a form using AJAX instead of navigating away * * @param HTMLElement form * @param function callback_success * @param function callback_error * @return void */ Rhymix.ajaxForm = function(form, callback_success, callback_error) { const $form = $(form); // Get success and error callback functions. if (typeof callback_success === 'undefined') { callback_success = $form.data('callbackSuccess'); if (callback_success && typeof callback_success === 'function') { // no-op } else if (callback_success && window[callback_success] && typeof window[callback_success] === 'function') { callback_success = window[callback_success]; } else { callback_success = function(data) { if (data.message && data.message !== 'success') { rhymix_alert(data.message, data.redirect_url); } if (data.redirect_url) { Rhymix.redirectToUrl(data.redirect_url.replace(/&/g, '&')); } }; } } if (typeof callback_error === 'undefined') { callback_error = $form.data('callbackError'); if (callback_error && typeof callback_error === 'function') { // no-op } else if (callback_error && window[callback_error] && typeof window[callback_error] === 'function') { callback_error = window[callback_error]; } else { callback_error = null; } } this.ajax(null, new FormData($form[0]), callback_success, callback_error); }; /** * Toggle all checkboxes that have the same name * * This is a legacy function. Do not write new code that relies on it. * * @param string name * @return void */ Rhymix.checkboxToggleAll = function(name) { if (typeof name === 'undefined') { name='cart'; } let options = { wrap : null, checked : 'toggle', doClick : false }; if (arguments.length == 1) { if(typeof(arguments[0]) == 'string') { name = arguments[0]; } else { $.extend(options, arguments[0] || {}); name = 'cart'; } } else { name = arguments[0]; $.extend(options, arguments[1] || {}); } if (options.doClick === true) { options.checked = null; } if (typeof options.wrap === 'string') { options.wrap = '#' + options.wrap; } let obj; if (options.wrap) { obj = $(options.wrap).find('input[name="'+name+'"]:checkbox'); } else { obj = $('input[name="'+name+'"]:checkbox'); } if (options.checked === 'toggle') { obj.each(function() { $(this).prop('checked', $(this).prop('checked') ? false : true); }); } else { if(options.doClick === true) { obj.click(); } else { obj.prop('checked', options.checked); } } }; /** * Display a popup menu for members, documents, etc. * * @param object ret_obj * @param object response_tags * @param object params * @return void */ Rhymix.displayPopupMenu = function(ret_obj, response_tags, params) { const menu_id = params.menu_id; const menus = ret_obj.menus; let html = ""; if (this.loadedPopupMenus[menu_id]) { html = this.loadedPopupMenus[menu_id]; } else { if (menus) { let item = menus.item || menus; if (typeof item.length === 'undefined' || item.length < 1) { item = new Array(item); } if (item.length) { for (let i = 0; i < item.length; i++) { var url = item[i].url; var str = item[i].str; var classname = item[i]['class']; var icon = item[i].icon; var target = item[i].target; // Convert self to _self #2154 if (target === 'self') { target = '_self'; } var actmatch = url.match(/\bact=(\w+)/) || url.match(/\b((?:disp|proc)\w+)/); var act = actmatch ? actmatch[1] : null; var classText = 'class="' + (classname ? classname : (act ? (act + ' ') : '')); var styleText = ""; var click_str = ""; var matches = []; if (target === 'popup') { if (this.isMobile()) { click_str = 'onclick="openModalIframe(this.href, \''+target+'\'); return false;"'; } else { click_str = 'onclick="popopen(this.href, \''+target+'\'); return false;"'; } classText += 'popup '; } else if (target === 'javascript') { click_str = 'onclick="'+url+'; return false; "'; classText += 'javascript '; url = '#'; } else if (target.match(/^_(self|blank|parent|top)$/)) { click_str = 'target="' + target + '"'; classText += 'frame_' + target + ' '; } else if (matches = target.match(/^i?frame:([a-zA-Z0-9_]+)$/)) { click_str = 'target="' + matches[1] + '"'; classText += 'frame_' + matches[1] + ' '; } else { click_str = 'target="_blank"'; } classText = classText.trim() + '" '; html += '
  • '+str+'
  • '; } } } this.loadedPopupMenus[menu_id] = html; } /* 레이어 출력 */ if (html) { const area = $('#popup_menu_area').html(''); const areaOffset = {top:params.page_y, left:params.page_x}; if (area.outerHeight()+areaOffset.top > $(window).height()+$(window).scrollTop()) { areaOffset.top = $(window).height() - area.outerHeight() + $(window).scrollTop(); } if (area.outerWidth()+areaOffset.left > $(window).width()+$(window).scrollLeft()) { areaOffset.left = $(window).width() - area.outerWidth() + $(window).scrollLeft(); } area.css({ top:areaOffset.top, left:areaOffset.left }).show().focus(); } }; /** * Format file size * * @param int size * @return string */ Rhymix.filesizeFormat = function(size) { if (size < 2) { return size + 'Byte'; } if (size < 1024) { return size + 'Bytes'; } if (size < 1048576) { return (size / 1024).toFixed(1) + 'KB'; } if (size < 1073741824) { return (size / 1048576).toFixed(2) + 'MB'; } if (size < 1099511627776) { return (size / 1073741824).toFixed(2) + 'GB'; } return (size / 1099511627776).toFixed(2) + 'TB'; }; /** * Get or set a lang code * * @param string key * @param string val * @return string|void */ Rhymix.lang = function(key, val) { if (typeof val === 'undefined') { return this.langCodes[key] || key; } else { return this.langCodes[key] = val; } }; // Add aliases to loaded libraries Rhymix.cookie = window.Cookies; Rhymix.URI = window.URI; Rhymix.URITemplate = window.URITemplate; Rhymix.SecondLevelDomains = window.SecondLevelDomains; Rhymix.IPv6 = window.IPv6; // Set window properties for backward compatibility const XE = window.XE = Rhymix; /** * ============================ * Document ready event handler * ============================ */ $(function() { /** * Inject CSRF token to all POST forms */ $('form[method]').filter(function() { return String($(this).attr('method')).toUpperCase() == 'POST'; }).addCSRFTokenToForm(); $(document).on('submit', 'form[method=post]', $.fn.addCSRFTokenToForm); $(document).on('focus', 'input,select,textarea', function() { $(this).parents('form[method]').filter(function() { return String($(this).attr('method')).toUpperCase() == 'POST'; }).addCSRFTokenToForm(); }); /** * Reverse tabnapping protection * * Automatically add rel="noopener" to any external link with target="_blank" * This is not required in most modern browsers. * https://caniuse.com/mdn-html_elements_a_implicit_noopener */ const noopenerRequired = (function() { const isChromeBased = navigator.userAgent.match(/Chrome\/([0-9]+)/); if (isChromeBased && parseInt(isChromeBased[1], 10) >= 72) { return false; } const isAppleWebKit = navigator.userAgent.match(/AppleWebKit\/([0-9]+)/); if (isAppleWebKit && parseInt(isAppleWebKit[1], 10) >= 605) { return false; } const isFirefox = navigator.userAgent.match(/Firefox\/([0-9]+)/); if (isFirefox && parseInt(isFirefox[1], 10) >= 79) { return false; } return true; })(); $('a[target]').each(function() { const $this = $(this); const href = String($this.attr('href')).trim(); const target = String($this.attr('target')).trim(); if (!href || !target || target === '_top' || target === '_self' || target === '_parent') { return; } if (!Rhymix.isSameHost(href)) { let rel = $this.attr('rel'); rel = (typeof rel === 'undefined') ? '' : String(rel); if (!rel.match(/\bnoopener\b/)) { $this.attr('rel', $.trim(rel + ' noopener')); } } }); $(document).on('click', 'a[target]', function(event) { const $this = $(this); const href = String($this.attr('href')).trim(); const target = String($this.attr('target')).trim(); if (!href || !target || target === '_top' || target === '_self' || target === '_parent') { return; } if (!Rhymix.isSameHost(href)) { let rel = $this.attr('rel'); rel = (typeof rel === 'undefined') ? '' : String(rel); if (!rel.match(/\bnoopener\b/)) { $this.attr('rel', $.trim(rel + ' noopener')); } if (noopenerRequired) { event.preventDefault(); blankshield.open(href); } } }); /** * Enforce max filesize on file uploaeds */ $(document).on('change', 'input[type=file]', function() { const max_filesize = $(this).data('max-filesize'); if (!max_filesize) { return; } const files = $(this).get(0).files; if (!files || !files[0]) { return; } if (files[0].size > max_filesize) { this.value = ''; const error = String($(this).data('max-filesize-error')); alert(error.replace('%s', Rhymix.filesizeFormat(max_filesize))); } }); /** * Intercept form submission and handle them with AJAX */ $(document).on('submit', 'form.rx_ajax', function(event) { if (!$(this).attr('target')) { event.preventDefault(); Rhymix.ajaxForm(this); } }); /** * Prevent repeated click on submit button */ $(document).on('click', 'input[type="submit"],button[type="submit"]', function(e) { const timeout = 3000; setTimeout(function() { $(this).prop('disabled', true); }, 100); setTimeout(function() { $(this).prop('disabled', false); }, timeout); }); /** * Display a popup menu for members, documents, etc. */ $(document).on('click', function(e) { var $area = $('#popup_menu_area'); if (!$area.length) { $area = $('