diff --git a/src/wp-admin/admin-ajax.php b/src/wp-admin/admin-ajax.php index 58719dc05f..07f3394a4e 100644 --- a/src/wp-admin/admin-ajax.php +++ b/src/wp-admin/admin-ajax.php @@ -64,7 +64,7 @@ $core_actions_post = array( 'parse-media-shortcode', 'destroy-sessions', 'install-plugin', 'update-plugin', 'press-this-save-post', 'press-this-add-category', 'crop-image', 'generate-password', 'save-wporg-username', 'delete-plugin', 'search-plugins', 'search-install-plugins', 'activate-plugin', 'update-theme', 'delete-theme', - 'install-theme', + 'install-theme', 'test_url', ); // Deprecated diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index 6a80f06b72..7df3c61678 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -3832,3 +3832,45 @@ function wp_ajax_search_install_plugins() { wp_send_json_success( $status ); } + +/** + * Ajax handler for testing if an URL exists. Used in the editor. + * + * @since 4.6.0 + */ +function wp_ajax_test_url() { + if ( ! current_user_can( 'edit_posts' ) || ! wp_verify_nonce( $_POST['nonce'], 'wp-test-url' ) ) { + wp_send_json_error(); + } + + $href = esc_url_raw( $_POST['href'] ); + + // Relative URL + if ( strpos( $href, '//' ) !== 0 && in_array( $href[0], array( '/', '#', '?' ), true ) ) { + $href = get_bloginfo( 'url' ) . $href; + } + + $response = wp_safe_remote_get( $href, array( + 'timeout' => 15, + // Use an explicit user-agent + 'user-agent' => 'WordPress URL Test', + ) ); + + $message = null; + + if ( is_wp_error( $response ) ) { + $error = $response->get_error_message(); + + if ( strpos( $message, 'resolve host' ) !== false ) { + $message = array( 'error' => __( 'Invalid host name.' ) ); + } + + wp_send_json_error( $message ); + } + + if ( wp_remote_retrieve_response_code( $response ) === 404 ) { + wp_send_json_error( array( 'error' => __( 'Not found, HTTP error 404.' ) ) ); + } + + wp_send_json_success(); +} diff --git a/src/wp-includes/class-wp-editor.php b/src/wp-includes/class-wp-editor.php index 395705f26e..af541f2cf9 100644 --- a/src/wp-includes/class-wp-editor.php +++ b/src/wp-includes/class-wp-editor.php @@ -1063,6 +1063,7 @@ final class _WP_Editors { 'Ctrl + letter:' => __( 'Ctrl + letter:' ), 'Letter' => __( 'Letter' ), 'Action' => __( 'Action' ), + 'Invalid host name.' => __( 'Invalid host name.' ), 'To move focus to other buttons use Tab or the arrow keys. To return focus to the editor press Escape or use one of the buttons.' => __( 'To move focus to other buttons use Tab or the arrow keys. To return focus to the editor press Escape or use one of the buttons.' ), 'When starting a new paragraph with one of these formatting shortcuts followed by a space, the formatting will be applied automatically. Press Backspace or Escape to undo.' => @@ -1283,8 +1284,15 @@ final class _WP_Editors { '; + } + + if ( $has_wplink || in_array( 'link', self::$qt_buttons, true ) ) { self::wp_link_dialog(); + } /** * Fires after any core TinyMCE editor instances are created. diff --git a/src/wp-includes/css/editor.css b/src/wp-includes/css/editor.css index 27dd2f2cfc..e8d946db6a 100644 --- a/src/wp-includes/css/editor.css +++ b/src/wp-includes/css/editor.css @@ -1757,6 +1757,14 @@ div.wp-link-preview a { cursor: pointer; } +div.wp-link-preview a.wplink-url-error { + color: #a00; +} + +div.wp-link-preview a.wplink-url-error:hover { + color: #f00; +} + div.wp-link-input { float: left; margin: 2px; diff --git a/src/wp-includes/js/tinymce/plugins/wplink/plugin.js b/src/wp-includes/js/tinymce/plugins/wplink/plugin.js index e08e5fa508..76d2ec479c 100644 --- a/src/wp-includes/js/tinymce/plugins/wplink/plugin.js +++ b/src/wp-includes/js/tinymce/plugins/wplink/plugin.js @@ -93,6 +93,7 @@ var doingUndoRedo; var doingUndoRedoTimer; var $ = window.jQuery; + var urlErrors = {}; function getSelectedLink() { var href, html, @@ -131,11 +132,62 @@ } function removePlaceholderStrings( content, dataAttr ) { - if ( dataAttr ) { - content = content.replace( / data-wplink-edit="true"/g, '' ); + return content.replace( /(]+>)([\s\S]*?)<\/a>/g, function( all, tag, text ) { + if ( tag.indexOf( ' href="_wp_link_placeholder"' ) > -1 ) { + return text; + } + + if ( dataAttr ) { + tag = tag.replace( / data-wplink-edit="true"/g, '' ); + } + + tag = tag.replace( / data-wplink-url-error="true"/g, '' ); + + return tag + text + ''; + }); + } + + function checkLink( node ) { + var $link = editor.$( node ); + var href = $link.attr( 'href' ); + + if ( ! href || typeof $ === 'undefined' ) { + return; } - return content.replace( /]*?href="_wp_link_placeholder"[^>]*>([\s\S]+)<\/a>/g, '$1' ); + // Early check + if ( /^http/i.test( href ) && ! /\.[a-z]{2,63}(\/|$)/i.test( href ) ) { + urlErrors[href] = tinymce.translate( 'Invalid host name.' ); + } + + if ( urlErrors.hasOwnProperty( href ) ) { + $link.attr( 'data-wplink-url-error', 'true' ); + return; + } else { + $link.removeAttr( 'data-wplink-url-error' ); + } + + $.post( + window.ajaxurl, { + action: 'test_url', + nonce: $( '#_wplink_urltest_nonce' ).val(), + href: href + }, + 'json' + ).done( function( response ) { + if ( response.success ) { + return; + } + + if ( response.data && response.data.error ) { + urlErrors[href] = response.data.error; + $link.attr( 'data-wplink-url-error', 'true' ); + + if ( toolbar && toolbar.visible() ) { + toolbar.$el.find( '.wp-link-preview a' ).addClass( 'wplink-url-error' ).attr( 'title', editor.dom.encode( response.data.error ) ); + } + } + }); } editor.on( 'preinit', function() { @@ -231,6 +283,8 @@ if ( ! tinymce.trim( linkNode.innerHTML ) ) { editor.$( linkNode ).text( text || href ); } + + checkLink( linkNode ); } inputInstance.reset(); @@ -473,7 +527,7 @@ editor.on( 'wptoolbar', function( event ) { var linkNode = editor.dom.getParent( event.element, 'a' ), - $linkNode, href, edit; + $linkNode, href, edit, title; if ( typeof window.wpLink !== 'undefined' && window.wpLink.modalOpen ) { editToolbar.tempHide = true; @@ -498,6 +552,13 @@ previewInstance.setURL( href ); event.element = linkNode; event.toolbar = toolbar; + title = urlErrors.hasOwnProperty( href ) ? editor.dom.encode( urlErrors[ href ] ) : null; + + if ( $linkNode.attr( 'data-wplink-url-error' ) === 'true' ) { + toolbar.$el.find( '.wp-link-preview a' ).addClass( 'wplink-url-error' ).attr( 'title', title ); + } else { + toolbar.$el.find( '.wp-link-preview a' ).removeClass( 'wplink-url-error' ).attr( 'title', null ); + } } } } ); @@ -555,7 +616,8 @@ close: function() { editToolbar.tempHide = false; editor.execCommand( 'wp_link_cancel' ); - } + }, + checkLink: checkLink }; } ); } )( window.tinymce ); diff --git a/src/wp-includes/js/tinymce/skins/wordpress/wp-content.css b/src/wp-includes/js/tinymce/skins/wordpress/wp-content.css index eeb9aa1446..e2716ca789 100644 --- a/src/wp-includes/js/tinymce/skins/wordpress/wp-content.css +++ b/src/wp-includes/js/tinymce/skins/wordpress/wp-content.css @@ -205,6 +205,13 @@ audio { visibility: hidden; } +a[data-wplink-url-error], +a[data-wplink-url-error]:hover, +a[data-wplink-url-error]:focus { + outline: 2px dotted #dc3232; + position: relative; +} + /** * WP Views */ diff --git a/src/wp-includes/js/wplink.js b/src/wp-includes/js/wplink.js index e3f93dab5f..836f446e88 100644 --- a/src/wp-includes/js/wplink.js +++ b/src/wp-includes/js/wplink.js @@ -434,6 +434,10 @@ var wpLink; editor.focus(); editor.nodeChanged(); + if ( link && editor.plugins.wplink ) { + editor.plugins.wplink.checkLink( link ); + } + // Audible confirmation message when a link has been inserted in the Editor. wp.a11y.speak( wpLinkL10n.linkInserted ); },