| @@ -202,8 +202,134 @@ | ||
| 202 | 202 | return whitelabelFields.join('&'); |
| 203 | 203 | } |
| 204 | 204 | |
| 205 | 205 | /** |
| 206 | + * White-label branding fields that are mirrored into the metasync.php | |
| 207 | + * plugin header. Keyed by the form input name. | |
| 208 | + */ | |
| 209 | + var brandingFieldConfigs = { | |
| 210 | + 'metasync_options[general][white_label_plugin_name]': { label: 'Plugin Name', url: false }, | |
| 211 | + 'metasync_options[general][white_label_plugin_description]': { label: 'Plugin Description', url: false }, | |
| 212 | + 'metasync_options[general][white_label_plugin_author]': { label: 'Author', url: false }, | |
| 213 | + 'metasync_options[general][white_label_plugin_author_uri]': { label: 'Author URL', url: true }, | |
| 214 | + 'metasync_options[general][white_label_plugin_uri]': { label: 'Plugin URL', url: true } | |
| 215 | + }; | |
| 216 | + | |
| 217 | + /** | |
| 218 | + * Client-side mirror of the server-side plugin-header contract for the | |
| 219 | + * white-label branding fields, for immediate feedback while typing or | |
| 220 | + * submitting. The server-side validation stays authoritative. | |
| 221 | + * | |
| 222 | + * @param {string} value Trimmed field value. | |
| 223 | + * @param {boolean} isUrl Whether the value must be an http(s) URL. | |
| 224 | + * @returns {string} Empty when valid, otherwise the problem description. | |
| 225 | + */ | |
| 226 | + function validateBrandingValue(value, isUrl) { | |
| 227 | + if (value === '') { | |
| 228 | + return ''; | |
| 229 | + } | |
| 230 | + if (/[\r\n]/.test(value)) { | |
| 231 | + return 'must be a single line'; | |
| 232 | + } | |
| 233 | + if (value.indexOf('*/') !== -1) { | |
| 234 | + return 'must not contain "*/"'; | |
| 235 | + } | |
| 236 | + if (/<\?php|\?>/i.test(value)) { | |
| 237 | + return 'must not contain PHP tags'; | |
| 238 | + } | |
| 239 | + // Control characters, line/paragraph separators, and invisible format | |
| 240 | + // characters (including the bidirectional marks used for brand spoofing). | |
| 241 | + // Unicode property escapes cover exactly the ranges the server checks | |
| 242 | + // (Cc, Cf, Zl, Zp) without literal control-character sequences. | |
| 243 | + try { | |
| 244 | + if (/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value)) { | |
| 245 | + return 'must not contain control or invisible characters'; | |
| 246 | + } | |
| 247 | + } catch (e) { | |
| 248 | + // Very old engines without Unicode property escapes: fall through, | |
| 249 | + // the server-side check remains authoritative. | |
| 250 | + } | |
| 251 | + // Mirror of the server allow-list: letters (incl. Unicode), marks, | |
| 252 | + // numbers, punctuation, symbols and space separators (incl. NBSP and | |
| 253 | + // the ideographic space, which the server-side contract accepts). | |
| 254 | + try { | |
| 255 | + if (!/^[\p{L}\p{M}\p{N}\p{P}\p{S}\p{Zs}]+$/u.test(value)) { | |
| 256 | + return 'contains characters that are not allowed'; | |
| 257 | + } | |
| 258 | + } catch (e) { | |
| 259 | + // Very old engines without Unicode property escapes: fall through, | |
| 260 | + // the server-side check remains authoritative. | |
| 261 | + } | |
| 262 | + if (isUrl) { | |
| 263 | + var parsed; | |
| 264 | + try { | |
| 265 | + parsed = new URL(value); | |
| 266 | + } catch (e) { | |
| 267 | + return 'must be a valid http:// or https:// address'; | |
| 268 | + } | |
| 269 | + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { | |
| 270 | + return 'must be a valid http:// or https:// address'; | |
| 271 | + } | |
| 272 | + } | |
| 273 | + return ''; | |
| 274 | + } | |
| 275 | + | |
| 276 | + /** | |
| 277 | + * Remove all inline white-label branding field errors. | |
| 278 | + */ | |
| 279 | + function clearBrandingFieldErrors() { | |
| 280 | + $('.metasync-branding-field-error').remove(); | |
| 281 | + } | |
| 282 | + | |
| 283 | + /** | |
| 284 | + * Show an inline error beside a branding input. Accepts either the full | |
| 285 | + * input name or the bare setting key (as returned by the server). | |
| 286 | + * | |
| 287 | + * @param {string} fieldKey Input name or white-label setting key. | |
| 288 | + * @param {string} message Error message to display. | |
| 289 | + */ | |
| 290 | + function showBrandingFieldError(fieldKey, message) { | |
| 291 | + var inputName = brandingFieldConfigs[fieldKey] | |
| 292 | + ? fieldKey | |
| 293 | + : 'metasync_options[general][' + fieldKey + ']'; | |
| 294 | + var input = $('input[name="' + inputName + '"]'); | |
| 295 | + if (input.length === 0) { | |
| 296 | + return; | |
| 297 | + } | |
| 298 | + input.last().after( | |
| 299 | + $('<p>').addClass('description metasync-branding-field-error') | |
| 300 | + .css({ color: 'var(--dashboard-error, #b32d2e)', fontWeight: 600, marginTop: '4px' }) | |
| 301 | + .text(message) | |
| 302 | + ); | |
| 303 | + } | |
| 304 | + | |
| 305 | + /** | |
| 306 | + * Validate every branding input currently in the DOM (both save paths — | |
| 307 | + * the form submit handler and the floating "Save Now" button — call this). | |
| 308 | + * Renders the inline per-field errors and returns the message list. | |
| 309 | + * | |
| 310 | + * @returns {string[]} Error messages; empty when everything is valid. | |
| 311 | + */ | |
| 312 | + function validateBrandingFieldsInDom() { | |
| 313 | + clearBrandingFieldErrors(); | |
| 314 | + var problems = []; | |
| 315 | + Object.keys(brandingFieldConfigs).forEach(function (inputName) { | |
| 316 | + var config = brandingFieldConfigs[inputName]; | |
| 317 | + var input = $('input[name="' + inputName + '"]'); | |
| 318 | + if (input.length === 0) { | |
| 319 | + return; | |
| 320 | + } | |
| 321 | + var problem = validateBrandingValue(String(input.val() || '').trim(), config.url); | |
| 322 | + if (problem) { | |
| 323 | + var fullMessage = config.label + ' ' + problem + '.'; | |
| 324 | + showBrandingFieldError(inputName, fullMessage); | |
| 325 | + problems.push(fullMessage); | |
| 326 | + } | |
| 327 | + }); | |
| 328 | + return problems; | |
| 329 | + } | |
| 330 | + | |
| 331 | + /** | |
| 206 | 332 | * Display notice message in plugin area |
| 207 | 333 | * @param {string} type - Notice type: 'success' or 'error' |
| 208 | 334 | * @param {string} title - Notice title |
| 209 | 335 | * @param {string} message - Notice message |
| @@ -271,9 +397,10 @@ | ||
| 271 | 397 | |
| 272 | 398 | function metasyncLGLogin(user, pass) { |
| 273 | 399 | jQuery.post(ajaxurl, { |
| 274 | 400 | action: 'metasync_lglogin', |
| 275 | - username: user, password: pass | |
| 401 | + username: user, password: pass, | |
| 402 | + nonce: metaSync.lglogin_nonce || '' | |
| 276 | 403 | }, function (response) { |
| 277 | 404 | if (typeof response.token !== 'undefined') { |
| 278 | 405 | $('#linkgraph_token').val(response.token); |
| 279 | 406 | $('#linkgraph_customer_id').val(response.customer_id); |
| @@ -920,9 +1047,18 @@ | ||
| 920 | 1047 | }, { |
| 921 | 1048 | text: '📚 Learn More About Registration', |
| 922 | 1049 | action: function () { |
| 923 | 1050 | var docDomain = metaSync.documentation_domain || 'https://searchatlas.com'; |
| 924 | - window.open(docDomain, '_blank'); | |
| 1051 | + try { | |
| 1052 | + var parsedDoc = new URL(docDomain); | |
| 1053 | + if (parsedDoc.protocol === 'https:' || parsedDoc.protocol === 'http:') { | |
| 1054 | + var a = document.createElement('a'); | |
| 1055 | + a.href = parsedDoc.href; | |
| 1056 | + a.target = '_blank'; | |
| 1057 | + a.rel = 'noopener'; | |
| 1058 | + a.click(); | |
| 1059 | + } | |
| 1060 | + } catch (e) { /* invalid URL */ } | |
| 925 | 1061 | } |
| 926 | 1062 | }, { |
| 927 | 1063 | text: '🔄 Try Authentication Again', |
| 928 | 1064 | action: function () { |
| @@ -1639,9 +1775,23 @@ | ||
| 1639 | 1775 | $('#' + id).val(''); |
| 1640 | 1776 | } |
| 1641 | 1777 | } |
| 1642 | 1778 | |
| 1779 | + function updateAnalyticsConsentStatus() { | |
| 1780 | + var $toggle = $('#metasync_analytics_opt_in'); | |
| 1781 | + var $status = $('.metasync-consent-status'); | |
| 1782 | + if (!$toggle.length || !$status.length) { | |
| 1783 | + return; | |
| 1784 | + } | |
| 1785 | + | |
| 1786 | + var label = $toggle.is(':checked') ? $status.data('enabled-label') : $status.data('disabled-label'); | |
| 1787 | + $status.text(label); | |
| 1788 | + } | |
| 1789 | + | |
| 1790 | + $(document).on('change', '#metasync_analytics_opt_in', updateAnalyticsConsentStatus); | |
| 1791 | + | |
| 1643 | 1792 | $(function () { |
| 1793 | + updateAnalyticsConsentStatus(); | |
| 1644 | 1794 | $('#addNewTime').on('click', function () { |
| 1645 | 1795 | $('#daysTime').append( |
| 1646 | 1796 | '<li>' + |
| 1647 | 1797 | '<select name="metasync_options[localseo][days][]">' + |
| @@ -1689,31 +1839,10 @@ | ||
| 1689 | 1839 | }); |
| 1690 | 1840 | $(document).on('click', '#number-delete', deleteNumber); |
| 1691 | 1841 | }); |
| 1692 | 1842 | |
| 1693 | - function deleteSourceUrl() { | |
| 1694 | - $(this).parent().remove(); | |
| 1695 | - } | |
| 1696 | 1843 | $(function () { |
| 1697 | - $('#addNewSourceUrl').on('click', function () { | |
| 1698 | - $('#source_urls').append( | |
| 1699 | - '<li>' + | |
| 1700 | - '<input type="text" class="regular-text" name="source_url[]">' + | |
| 1701 | - '<select name="search_type[]">' + | |
| 1702 | - '<option value="exact">Exact</option>' + | |
| 1703 | - '<option value="contain">Contain</option>' + | |
| 1704 | - '<option value="start">Start With</option>' + | |
| 1705 | - '<option value="end">End With</option>' + | |
| 1706 | - '</select>' + | |
| 1707 | - '<button id="source_url_delete">Remove</button>' + | |
| 1708 | - '</li>'); | |
| 1709 | - return; | |
| 1710 | - }); | |
| 1711 | - $(document).on('click', '#source_url_delete', deleteSourceUrl); | |
| 1712 | - }); | |
| 1713 | 1844 | |
| 1714 | - $(function () { | |
| 1715 | - | |
| 1716 | 1845 | setToken(); |
| 1717 | 1846 | |
| 1718 | 1847 | $('body').on('click', '#wp_metasync_sync', function (e) { |
| 1719 | 1848 | e.preventDefault(); |
| @@ -2179,9 +2308,10 @@ | ||
| 2179 | 2308 | url: ajaxurl, |
| 2180 | 2309 | type: 'GET', |
| 2181 | 2310 | data: { |
| 2182 | 2311 | action: 'metasync_clear_otto_cache', |
| 2183 | - clear_otto_cache: 1 | |
| 2312 | + clear_otto_cache: 1, | |
| 2313 | + nonce: metaSync.clear_otto_cache_nonce || '' | |
| 2184 | 2314 | }, |
| 2185 | 2315 | success: function (response) { |
| 2186 | 2316 | const now = new Date(); |
| 2187 | 2317 | $('#clear_otto_caches').text('Cache Cleared ' + now.toLocaleTimeString()); |
| @@ -2281,8 +2411,27 @@ | ||
| 2281 | 2411 | return true; // Let the form submit normally |
| 2282 | 2412 | } |
| 2283 | 2413 | |
| 2284 | 2414 | e.preventDefault(); // Prevent the default form submission for regular settings |
| 2415 | + | |
| 2416 | + // Immediate client-side validation of the white-label branding | |
| 2417 | + // fields (the server-side contract stays authoritative): block the | |
| 2418 | + // submit and show an error beside each invalid field instead of | |
| 2419 | + // letting an invalid value reach the server and get rejected there. | |
| 2420 | + var brandingValidationErrors = validateBrandingFieldsInDom(); | |
| 2421 | + if (brandingValidationErrors.length > 0) { | |
| 2422 | + var $brandingErrorNotice = $('<div>').addClass('notice notice-error metasync-error-wrap'); | |
| 2423 | + var $brandingErrorList = $('<ul>'); | |
| 2424 | + brandingValidationErrors.forEach(function (errorMessage) { | |
| 2425 | + $brandingErrorList.append($('<li>').text(errorMessage)); | |
| 2426 | + }); | |
| 2427 | + $brandingErrorNotice.append($brandingErrorList); | |
| 2428 | + $('.metasync-error-wrap').remove(); | |
| 2429 | + $('#metaSyncGeneralSetting').before($brandingErrorNotice); | |
| 2430 | + $('html, body').animate({ scrollTop: 0 }, 'slow'); | |
| 2431 | + return; | |
| 2432 | + } | |
| 2433 | + | |
| 2285 | 2434 | var actionField = $(this).find('input[name="action"]'); |
| 2286 | 2435 | var optionPage= $(this).find('input[name="option_page"]'); |
| 2287 | 2436 | var wpHttpReferer= $(this).find('input[name="_wp_http_referer"]'); |
| 2288 | 2437 | var wpnonce= $(this).find('input[name="_wpnonce"]'); |
| @@ -2358,12 +2507,13 @@ | ||
| 2358 | 2507 | } catch (e) { /* invalid URL */ } |
| 2359 | 2508 | }else { |
| 2360 | 2509 | // Handle error response |
| 2361 | 2510 | const errors = response.data?.errors || []; |
| 2511 | + const fieldErrors = response.data?.field_errors || {}; | |
| 2362 | 2512 | |
| 2363 | 2513 | // Build error notice using DOM methods to avoid XSS |
| 2364 | 2514 | var $errorNotice = $('<div>').addClass('notice notice-error metasync-error-wrap'); |
| 2365 | - if (Array.isArray(errors)) { | |
| 2515 | + if (Array.isArray(errors) && errors.length > 0) { | |
| 2366 | 2516 | var $ul = $('<ul>'); |
| 2367 | 2517 | errors.forEach(function (err) { |
| 2368 | 2518 | $ul.append($('<li>').text(err)); |
| 2369 | 2519 | }); |
| @@ -2375,8 +2525,15 @@ | ||
| 2375 | 2525 | |
| 2376 | 2526 | // Insert the error message before the form |
| 2377 | 2527 | $('#metaSyncGeneralSetting').before($errorNotice); |
| 2378 | 2528 | |
| 2529 | + // Field-specific feedback: show the server's per-field | |
| 2530 | + // error beside each rejected white-label branding input. | |
| 2531 | + clearBrandingFieldErrors(); | |
| 2532 | + Object.keys(fieldErrors).forEach(function (settingKey) { | |
| 2533 | + showBrandingFieldError(settingKey, fieldErrors[settingKey]); | |
| 2534 | + }); | |
| 2535 | + | |
| 2379 | 2536 | // Scroll to the top to ensure visibility |
| 2380 | 2537 | $('html, body').animate({ scrollTop: 0 }, 'slow'); |
| 2381 | 2538 | } |
| 2382 | 2539 | |
| @@ -2613,17 +2770,47 @@ | ||
| 2613 | 2770 | |
| 2614 | 2771 | // Save Changes function - use AJAX instead of form submission |
| 2615 | 2772 | window.saveChanges = function () { |
| 2616 | 2773 | isSaving = true; |
| 2617 | - | |
| 2618 | - // Completely remove the floating notification immediately when clicked | |
| 2774 | + | |
| 2775 | + var $form = $('#metaSyncGeneralSetting'); | |
| 2776 | + if ($form.length > 0) { | |
| 2777 | + // Immediate client-side validation of the white-label branding | |
| 2778 | + // fields — the floating save bypasses the form submit handler, | |
| 2779 | + // so the same contract is enforced here too. The server-side | |
| 2780 | + // validation stays authoritative. Runs BEFORE the notification | |
| 2781 | + // is removed so a blocked save keeps the persistent unsaved- | |
| 2782 | + // changes cue visible. | |
| 2783 | + var brandingProblems = validateBrandingFieldsInDom(); | |
| 2784 | + if (brandingProblems.length > 0) { | |
| 2785 | + var $brandingNotice = $('<div>').addClass('notice notice-error metasync-save-notice').css({ margin: '20px 0', padding: '12px' }); | |
| 2786 | + var $brandingList = $('<ul>'); | |
| 2787 | + brandingProblems.forEach(function (errorMessage) { | |
| 2788 | + $brandingList.append($('<li>').text(errorMessage)); | |
| 2789 | + }); | |
| 2790 | + $brandingNotice.append($brandingList); | |
| 2791 | + $('.metasync-save-notice').remove(); | |
| 2792 | + var $navWrapperForBranding = $('.metasync-nav-wrapper'); | |
| 2793 | + if ($navWrapperForBranding.length > 0) { | |
| 2794 | + $navWrapperForBranding.after($brandingNotice); | |
| 2795 | + } else { | |
| 2796 | + $('.metasync-dashboard-wrap').prepend($brandingNotice); | |
| 2797 | + } | |
| 2798 | + $('html, body').animate({ scrollTop: 0 }, 'slow'); | |
| 2799 | + isSaving = false; | |
| 2800 | + return; | |
| 2801 | + } | |
| 2802 | + } | |
| 2803 | + | |
| 2804 | + // Completely remove the floating notification immediately when the | |
| 2805 | + // save is actually proceeding (a blocked save left it in place). | |
| 2619 | 2806 | var $notification = $('.metasync-unsaved-notification'); |
| 2620 | 2807 | if ($notification.length > 0) { |
| 2621 | 2808 | $notification.remove(); // Completely remove from DOM, no animations |
| 2622 | 2809 | } |
| 2623 | - | |
| 2624 | - var $form = $('#metaSyncGeneralSetting'); | |
| 2810 | + | |
| 2625 | 2811 | if ($form.length > 0) { |
| 2812 | + | |
| 2626 | 2813 | // Get form data and submit via AJAX |
| 2627 | 2814 | var formData = $form.serialize(); |
| 2628 | 2815 | |
| 2629 | 2816 | // Ensure ALL whitelabel data is included using helper function |
| @@ -2714,8 +2901,9 @@ | ||
| 2714 | 2901 | } |
| 2715 | 2902 | } else { |
| 2716 | 2903 | // Handle validation errors |
| 2717 | 2904 | var errors = response.data && response.data.errors ? response.data.errors : []; |
| 2905 | + var fieldErrors = (response.data && response.data.field_errors) ? response.data.field_errors : {}; | |
| 2718 | 2906 | |
| 2719 | 2907 | // Build error notice using DOM methods to avoid XSS |
| 2720 | 2908 | var $errorNotice = $('<div>').addClass('notice notice-error metasync-error-wrap').css({ margin: '20px', padding: '12px' }); |
| 2721 | 2909 | if (Array.isArray(errors)) { |
| @@ -2739,9 +2927,16 @@ | ||
| 2739 | 2927 | } else { |
| 2740 | 2928 | // Fallback: insert at top of plugin content area |
| 2741 | 2929 | $('.metasync-dashboard-wrap').prepend($errorNotice); |
| 2742 | 2930 | } |
| 2743 | - | |
| 2931 | + | |
| 2932 | + // Field-specific feedback: show the server's per-field | |
| 2933 | + // error beside each rejected white-label branding input. | |
| 2934 | + clearBrandingFieldErrors(); | |
| 2935 | + Object.keys(fieldErrors).forEach(function (settingKey) { | |
| 2936 | + showBrandingFieldError(settingKey, fieldErrors[settingKey]); | |
| 2937 | + }); | |
| 2938 | + | |
| 2744 | 2939 | // Scroll to the error message for better visibility |
| 2745 | 2940 | $('html, body').animate({ scrollTop: 0 }, 'slow'); |
| 2746 | 2941 | |
| 2747 | 2942 | setTimeout(function () { |