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 <https://w.wiki/7ZsE> 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
This commit is contained in:
MusikAnimal 2023-09-26 18:25:32 -04:00 committed by Samtar
parent 968359fe75
commit 7db0e05aeb
7 changed files with 275 additions and 3 deletions

View file

@ -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,

View file

@ -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 <strong>$1</strong> 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"
}

View file

@ -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."
}

View file

@ -0,0 +1,44 @@
<?php
namespace MediaWiki\Extension\AbuseFilter\Hooks\Handlers;
use MediaWiki\Config\Config;
use MediaWiki\EditPage\EditPage;
use MediaWiki\Hook\EditPage__showEditForm_initialHook;
use MediaWiki\Output\OutputPage;
use MobileContext;
class EditPageHandler implements EditPage__showEditForm_initialHook {
private bool $notificationsEnabled;
/** @phan-suppress-next-line PhanUndeclaredTypeProperty */
private ?MobileContext $mobileContext;
/**
* @param Config $config
* @param MobileContext|null $mobileContext
*/
public function __construct(
Config $config,
// @phan-suppress-next-line PhanUndeclaredTypeParameter
?MobileContext $mobileContext
) {
$this->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' );
}
}
}

View file

@ -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"
}
}

View file

@ -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 = $( '<a>' )
.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 = $( '<p>' ).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 );

View file

@ -0,0 +1,5 @@
.mw-abusefilter-blocked-domains-notif-review-link {
display: inline-block;
font-weight: bold;
margin-top: 15px;
}