mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/AbuseFilter.git
synced 2024-11-23 13:46:48 +00:00
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:
parent
968359fe75
commit
7db0e05aeb
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
|
44
includes/Hooks/Handlers/EditPageHandler.php
Normal file
44
includes/Hooks/Handlers/EditPageHandler.php
Normal 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' );
|
||||
}
|
||||
}
|
||||
}
|
15
modules/wikieditor/.eslintrc.json
Normal file
15
modules/wikieditor/.eslintrc.json
Normal 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"
|
||||
}
|
||||
}
|
172
modules/wikieditor/ext.abuseFilter.wikiEditor.js
Normal file
172
modules/wikieditor/ext.abuseFilter.wikiEditor.js
Normal 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 );
|
5
modules/wikieditor/ext.abuseFilter.wikiEditor.less
Normal file
5
modules/wikieditor/ext.abuseFilter.wikiEditor.less
Normal file
|
@ -0,0 +1,5 @@
|
|||
.mw-abusefilter-blocked-domains-notif-review-link {
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
margin-top: 15px;
|
||||
}
|
Loading…
Reference in a new issue