/** * 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 * @return void */ Rhymix.redirectToUrl = function(url) { if (this.isCurrentUrl(url)) { window.location.href = url; window.location.reload(); } else { window.location.href = url; } }; /** * 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 success * @param function error * @return void */ Rhymix.ajax = function(action, params, success, error) { // Extract module and act let isFormData = params instanceof FormData; let module, act; if (!action) { 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; } } else { action = action.split('.'); params = params || {}; params.module = module = action[0]; params.act = act = action[1]; action = action.join('.'); } // Add action to URL if the current rewrite level supports it let 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; } // Generate AJAX parameters const args = { 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: function(data, textStatus, xhr) { Rhymix._ajaxSuccessHandler(xhr, textStatus, action, data, params, success, error); }, error: function(xhr, textStatus, errorThrown) { Rhymix._ajaxErrorHandler(xhr, textStatus, action, url, params, success, error); } }; // Send the AJAX request try { $.ajax(args); } catch(e) { alert(e); } }; /** * Default success handler for AJAX requests * * @param object xhr * @param string textStatus * @param string action * @param object data * @param object params * @param function success * @param function errror * @return void */ Rhymix._ajaxSuccessHandler = function(xhr, textStatus, action, data, params, success, error) { // Add debug information. if (data._rx_debug) { data._rx_debug.page_title = "AJAX : " + action; if (this.addDebugData) { this.addDebugData(data._rx_debug); } else { this.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) { // If an error callback is defined, call it. Abort if it returns false. if ($.isFunction(error) && error(data, xhr) === false) { return; } // If an error message was supplied, display it. if (data.message) { let msg = data.message.replace(/\\n/g, "\n"); if (data.errorDetail) { msg += "\n\n" + data.errorDetail; } alert(msg); return; } // Rhymix should never return an error code without a message, but if someone does, we handle it here. let msg = 'AJAX error: ' + (action || 'form submission') + "\n\n" + xhr.responseText; if (msg.length > 1000) { msg = msg.substring(0, 1000) + '...'; } console.error(msg.trim().replace(/\n+/g, "\n")); if (this.showAjaxErrors.indexOf('ALL') >= 0 || this.showAjaxErrors.indexOf(xhr.status) >= 0) { alert(msg.trim()); } return; } // If a success callback was defined, call it. if ($.isFunction(success)) { success(data, xhr); return; } // If the response contains a redirect URL, follow the redirect. if (data.redirect_url) { this.redirectToUrl(data.redirect_url.replace(/&/g, '&')); return; } }; /** * Default error handler for AJAX requests * * @param object xhr * @param string textStatus * @param string action * @param string url * @param object params * @param function success * @param function errror * @return void */ Rhymix._ajaxErrorHandler = function(xhr, textStatus, action, url, params, success, error) { // If the user is navigating away, don't do anything. if (xhr.status == 0 && this.unloading) { return; } // If the response contains valid JSON, call the success callback instead. if (xhr.status >= 400 && xhr.responseText) { let data; try { data = JSON.parse(xhr.responseText); } catch (e) { } if (data && typeof data.error !== 'undefined') { this._ajaxSuccessHandler(xhr, textStatus, action, data, params, success, error); return; } } // If an error callback is defined, call it. Abort if it returns false. if ($.isFunction(error)) { let fakedata = { error: -3, message: textStatus }; if (error(fakedata, xhr) === false) { return; } } // Otherwise, generate a simple error message. let error_info, msg; if (xhr.status == 0) { error_info = 'Connection failed: ' + url + "\n\n" + (xhr.responseText || ''); } else { error_info = 'Response code: ' + xhr.status + "\n\n" + (xhr.responseText || ''); } msg = 'AJAX error: ' + (action || 'form submission') + "\n\n" + error_info; if (msg.length > 1000) { msg = msg.substring(0, 1000) + '...'; } // Print the error message. console.error(msg.trim().replace(/\n+/g, "\n")); if (this.showAjaxErrors.indexOf('ALL') >= 0 || this.showAjaxErrors.indexOf(xhr.status) >= 0) { alert(msg.trim()); } }; /** * Submit a form using AJAX instead of navigating away * * @param HTMLElement form * @param function success * @param function error * @return void */ Rhymix.ajaxForm = function(form, success, error) { const $form = $(form); // Get success and error callback functions. if (typeof success === 'undefined') { success = $form.data('callbackSuccess'); if (success && $.isFunction(success)) { // no-op } else if (success && window[success] && $.isFunction(window[success])) { success = window[success]; } else { success = function(data) { if (data.message && data.message !== 'success') { alert(data.message); } if (data.redirect_url) { Rhymix.redirectToUrl(data.redirect_url.replace(/&/g, '&')); } }; } } if (typeof error === 'undefined') { error = $form.data('callbackError'); if (error && $.isFunction(error)) { // no-op } else if (error && window[error] && $.isFunction(window[error])) { error = window[error]; } else { error = null; } } this.ajax(null, new FormData($form[0]), success, 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 (!window[name]) { 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 = $('