From 7db0e05aeb58aa9816a9b74b42c884a49c5e3465 Mon Sep 17 00:00:00 2001 From: MusikAnimal Date: Tue, 26 Sep 2023 18:25:32 -0400 Subject: [PATCH] Show notification when editor links to a blocked domain This leverages the new BlockedExternalDomains system that is now part of AbuseFilter. It notifies editors in realtime if a link they add is blocked. See https://w.wiki/7ZsF for more information. BlockedExternalDomains is slated to have its own API tantamount to the action=spamblacklist endpoint, after which case this code will need to be updated. In the meantime, it's meant to serve as a minimal viable product for the CWS 2023 wish for wikitext users. The new $wgAbuseFilterBlockedExternalDomainsNotification configuration setting controls the availability of this feature. A similar feature for VisaulEditor is tracked at T276857 Bug: T347435 Change-Id: I7eae55f12da9ee58be5786bfc153e549b09598e7 --- extension.json | 33 +++- i18n/en.json | 4 +- i18n/qqq.json | 5 +- includes/Hooks/Handlers/EditPageHandler.php | 44 +++++ modules/wikieditor/.eslintrc.json | 15 ++ .../wikieditor/ext.abuseFilter.wikiEditor.js | 172 ++++++++++++++++++ .../ext.abuseFilter.wikiEditor.less | 5 + 7 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 includes/Hooks/Handlers/EditPageHandler.php create mode 100644 modules/wikieditor/.eslintrc.json create mode 100644 modules/wikieditor/ext.abuseFilter.wikiEditor.js create mode 100644 modules/wikieditor/ext.abuseFilter.wikiEditor.less diff --git a/extension.json b/extension.json index a25aa334c..8aa552539 100644 --- a/extension.json +++ b/extension.json @@ -315,6 +315,23 @@ "desktop", "mobile" ] + }, + "ext.abuseFilter.wikiEditor": { + "packageFiles": [ + "wikieditor/ext.abuseFilter.wikiEditor.js" + ], + "styles": [ + "wikieditor/ext.abuseFilter.wikiEditor.less" + ], + "dependencies": [ + "mediawiki.Title", + "mediawiki.jqueryMsg", + "mediawiki.notification" + ], + "messages": [ + "abusefilter-blocked-domains-notif-body", + "abusefilter-blocked-domains-notif-review-link" + ] } }, "attributes": { @@ -398,6 +415,15 @@ "services": [ "MainConfig" ] + }, + "EditPage": { + "class": "MediaWiki\\Extension\\AbuseFilter\\Hooks\\Handlers\\EditPageHandler", + "services": [ + "MainConfig" + ], + "optional_services": [ + "MobileFrontend.Context" + ] } }, "Hooks": { @@ -423,7 +449,8 @@ "UserMergeAccountFields": "UserMerge", "BeforeCreateEchoEvent": "Echo", "ParserOutputStashForEdit": "FilteredActions", - "JsonValidateSave": "EditPermission" + "JsonValidateSave": "EditPermission", + "EditPage::showEditForm:initial": "EditPage" }, "ServiceWiringFiles": [ "includes/ServiceWiring.php" @@ -570,6 +597,10 @@ "AbuseFilterEnableBlockedExternalDomain": { "value": false, "description": "Temporary config value to disable Special:BlockedExternalDomains" + }, + "AbuseFilterBlockedExternalDomainsNotifications": { + "value": false, + "description": "Feature flag to enable realtime notifications when an editor types a link to a blocked external domain." } }, "load_composer_autoloader": true, diff --git a/i18n/en.json b/i18n/en.json index f1a2d1b42..17bad20c9 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -604,5 +604,7 @@ "action-abusefilter-modify-blocked-external-domains": "create or modify what external domains are blocked from being linked", "right-abusefilter-bypass-blocked-external-domains": "Bypass blocked external domains", "action-abusefilter-bypass-blocked-external-domains": "bypass blocked external domain", - "abusefilter-blocked-domains-cannot-edit-directly": "Create or modify what external domains are blocked from being linked must be done through [[Special:BlockedExternalDomains|the special page]]." + "abusefilter-blocked-domains-cannot-edit-directly": "Create or modify what external domains are blocked from being linked must be done through [[Special:BlockedExternalDomains|the special page]].", + "abusefilter-blocked-domains-notif-body": "You added a link to the domain $1 which is forbidden from being used on this site. Please remove or replace it with a different URL.", + "abusefilter-blocked-domains-notif-review-link": "Review link" } diff --git a/i18n/qqq.json b/i18n/qqq.json index 376c97b9d..8bb83f3e2 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -29,6 +29,7 @@ "Meno25", "Mormegil", "MuratTheTurkish", + "MusikAnimal", "Nemo bis", "Patriot Kur", "Phjtieudoc", @@ -647,5 +648,7 @@ "action-abusefilter-modify-blocked-external-domains": "{{doc-action|abusefilter-modify-blocked-external-domains}}", "right-abusefilter-bypass-blocked-external-domains": "{{doc-right|abusefilter-bypass-blocked-external-domains}}", "action-abusefilter-bypass-blocked-external-domains": "{{doc-action|abusefilter-bypass-blocked-external-domains}}", - "abusefilter-blocked-domains-cannot-edit-directly": "Error message shown when someone tries to edit the list of blocked domains directly and bypass the Special page." + "abusefilter-blocked-domains-cannot-edit-directly": "Error message shown when someone tries to edit the list of blocked domains directly and bypass the Special page.", + "abusefilter-blocked-domains-notif-body": "Warning shown when an editor types a URL to a blocked external domain. Parameters:\n* $1 - the domain name.", + "abusefilter-blocked-domains-notif-review-link": "Label for the 'Review link' link in the notification popup. When clicked, the relevant wikitext that links to a blocked domain is selected. The word 'review' in this context is a verb." } diff --git a/includes/Hooks/Handlers/EditPageHandler.php b/includes/Hooks/Handlers/EditPageHandler.php new file mode 100644 index 000000000..f6fb00cab --- /dev/null +++ b/includes/Hooks/Handlers/EditPageHandler.php @@ -0,0 +1,44 @@ +notificationsEnabled = $config->get( 'AbuseFilterBlockedExternalDomainsNotifications' ); + $this->mobileContext = $mobileContext; + } + + /** + * @param EditPage $editor + * @param OutputPage $out + */ + public function onEditPage__showEditForm_initial( $editor, $out ): void { + if ( !$this->notificationsEnabled ) { + return; + } + // @phan-suppress-next-line PhanUndeclaredClassMethod + $isMobileView = $this->mobileContext && $this->mobileContext->shouldDisplayMobileView(); + if ( !$isMobileView ) { + $out->addModules( 'ext.abuseFilter.wikiEditor' ); + } + } +} diff --git a/modules/wikieditor/.eslintrc.json b/modules/wikieditor/.eslintrc.json new file mode 100644 index 000000000..573588ee0 --- /dev/null +++ b/modules/wikieditor/.eslintrc.json @@ -0,0 +1,15 @@ +{ + "root": true, + "extends": [ + "wikimedia/client-es6", + "wikimedia/mediawiki", + "wikimedia/jquery" + ], + "env": { + "browser": true + }, + "rules": { + "no-implicit-globals": "off", + "es-x/no-array-prototype-includes": "off" + } +} diff --git a/modules/wikieditor/ext.abuseFilter.wikiEditor.js b/modules/wikieditor/ext.abuseFilter.wikiEditor.js new file mode 100644 index 000000000..2fd25ca8e --- /dev/null +++ b/modules/wikieditor/ext.abuseFilter.wikiEditor.js @@ -0,0 +1,172 @@ +let $textarea, + blockedExternalDomains, + wikiEditorCtx; + +/** + * Fetch and cache the contents of the blocked external domains JSON page. + * + * @return {Promise} + */ +function loadBlockedExternalDomains() { + if ( blockedExternalDomains !== undefined ) { + return Promise.resolve( blockedExternalDomains ); + } + + const title = new mw.Title( 'BlockedExternalDomains.json', mw.config.get( 'wgNamespaceIds' ).mediawiki ); + return fetch( title.getUrl( { action: 'raw' } ) ) + .then( ( res ) => res.json() ) + .then( ( entries ) => { + blockedExternalDomains = entries.map( ( entry ) => entry.domain ); + return blockedExternalDomains; + } ) + .catch( () => { + // Silently fail, say if MediaWiki:BlockedExternalDomains.json is missing or invalid JSON. + blockedExternalDomains = []; + } ); +} + +/** + * Get a URL object for the given URL string. + * As of September 2023, the static URL.canParse() is still not supported in most modern browsers. + * + * @param {string} urlStr + * @return {URL|false} URL object or false if it could not be parsed. + */ +function parseUrl( urlStr ) { + try { + return new URL( urlStr ); + } catch ( e ) { + return false; + } +} + +/** + * Click handler for the 'Review link' link. + * + * @param {Event} e + */ +function reviewClickHandler( e ) { + const start = e.data.cursorPosition - e.data.wikitext.length, + end = e.data.cursorPosition, + selectedContent = $textarea.textSelection( 'getContents' ) + .slice( start, end ); + + if ( selectedContent !== e.data.wikitext ) { + // Abort if wikitext has changed since the notification was shown. + return; + } + + e.preventDefault(); + $textarea.trigger( 'focus' ); + $textarea.textSelection( 'setSelection', { start, end } ); + + // Open the WikiEditor link insertion dialog, double-checking that it still exists (T271457) + if ( wikiEditorCtx && $.wikiEditor && $.wikiEditor.modules && $.wikiEditor.modules.dialogs ) { + $.wikiEditor.modules.dialogs.api.openDialog( wikiEditorCtx, 'insert-link' ); + e.data.notification.close(); + } +} + +/** + * Issue a notification of type 'warn'. + * + * @param {string} wikitext + * @param {URL} url + * @param {number} cursorPosition + */ +function showWarning( wikitext, url, cursorPosition ) { + const $reviewLink = $( '' ) + .prop( 'href', url.href ) + .prop( 'target', '_blank' ) + .text( mw.msg( 'abusefilter-blocked-domains-notif-review-link' ) ) + .addClass( 'mw-abusefilter-blocked-domains-notif-review-link' ); + const $content = $( '

' ).append( + mw.message( 'abusefilter-blocked-domains-notif-body', [ url.hostname ] ).parse() + ); + const notification = mw.notification.notify( [ $content, $reviewLink ], { + autoHideSeconds: 'long', + type: 'warn', + classes: 'mw-abusefilter-blocked-domains-notif', + tag: 'mw-abusefilter-blocked-domains-notif' + } ); + + $reviewLink.on( + 'click', + { url, wikitext, cursorPosition, notification }, + reviewClickHandler + ); +} + +/** + * Query the blocked domains list and check the given URL against it. + * If there's a match, a warning is displayed to the user. + * + * @param {string} wikitext + * @param {string} urlStr + * @param {number} cursorPosition + */ +function checkIfBlocked( wikitext, urlStr, cursorPosition ) { + const url = parseUrl( urlStr ); + if ( !url ) { + // Likely an invalid URL. + return; + } + loadBlockedExternalDomains().then( () => { + if ( blockedExternalDomains.includes( url.hostname ) ) { + showWarning( wikitext, url, cursorPosition ); + } + } ); +} + +/** + * (Re-)add the keyup listener to the textarea. + * + * @param {jQuery|CodeMirror} [editor] + * @param {string} [event] + */ +function addEditorListener( editor = $textarea, event = 'input.blockedexternaldomains' ) { + editor.off( event ); + editor.on( event, () => { + const cursorPosition = $textarea.textSelection( 'getCaretPosition' ), + context = $textarea.textSelection( 'getContents' ) + .slice( 0, cursorPosition ), + // TODO: somehow use the same regex as the MediaWiki parser + matches = /.*\b\[?(https?:\/\/[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))(?:.*?])?$/.exec( context ); + + if ( matches ) { + checkIfBlocked( matches[ 0 ], matches[ 1 ], cursorPosition ); + } + } ); +} + +/** + * Script entrypoint. + * + * @param {jQuery} $form + */ +function init( $form ) { + $textarea = $form.find( '#wpTextbox1' ); + + /** + * Skin doesn't support this clientside solution if the textarea is not present in page. + * We also want to use the JavaScript URL API, so IE is not supported. + */ + if ( !$textarea.length || !( 'URL' in window ) ) { + return; + } + + addEditorListener(); + + // WikiEditor integration; causes the 'Review link' link to open the link insertion dialog. + mw.hook( 'wikiEditor.toolbarReady' ).add( function ( $wikiEditorTextarea ) { + wikiEditorCtx = $wikiEditorTextarea.data( 'wikiEditor-context' ); + } ); + + // CodeMirror integration. + mw.hook( 'ext.CodeMirror.switch' ).add( function ( _enabled, $editor ) { + $textarea = $editor; + addEditorListener( $editor[ 0 ].CodeMirror, 'change' ); + } ); +} + +mw.hook( 'wikipage.editform' ).add( init ); diff --git a/modules/wikieditor/ext.abuseFilter.wikiEditor.less b/modules/wikieditor/ext.abuseFilter.wikiEditor.less new file mode 100644 index 000000000..8dce3c920 --- /dev/null +++ b/modules/wikieditor/ext.abuseFilter.wikiEditor.less @@ -0,0 +1,5 @@ +.mw-abusefilter-blocked-domains-notif-review-link { + display: inline-block; + font-weight: bold; + margin-top: 15px; +}