From b83cf88837d589ae82a41fffb68dd82855aff2e5 Mon Sep 17 00:00:00 2001 From: Kosta Harlan Date: Wed, 17 Oct 2018 13:35:57 -0400 Subject: [PATCH] Introduce Special:RedirectExternal Special:RedirectExternal is an unlisted special page that accepts a URL as the first argument, and redirects the user to that page. Example: Special:RedirectExternal/https://mediawiki.org At the moment, this is intended to be used by the GrowthExperiments project in order to track outbound visits to certain external links. But it could be extended in the future to provide parameters for showing a message to the user before redirecting, or explicitly requiring a user to click on the link, which could help improve security when users follow on-wiki links to off-wiki sites. Bug: T207115 Change-Id: I822af14a84569aab22249e2f16a662a60e60f76a --- autoload.php | 1 + includes/specialpage/SpecialPageFactory.php | 1 + includes/specials/SpecialRedirectExternal.php | 69 +++++++++++++++++++ languages/i18n/en.json | 3 + languages/i18n/qqq.json | 3 + languages/messages/MessagesEn.php | 1 + .../specials/SpecialRedirectExternalTest.php | 49 +++++++++++++ 7 files changed, 127 insertions(+) create mode 100644 includes/specials/SpecialRedirectExternal.php create mode 100644 tests/phpunit/includes/specials/SpecialRedirectExternalTest.php diff --git a/autoload.php b/autoload.php index 0f92ccbde0..22ddaf8e58 100644 --- a/autoload.php +++ b/autoload.php @@ -1396,6 +1396,7 @@ $wgAutoloadLocalClasses = [ 'SpecialRecentChanges' => __DIR__ . '/includes/specials/SpecialRecentchanges.php', 'SpecialRecentChangesLinked' => __DIR__ . '/includes/specials/SpecialRecentchangeslinked.php', 'SpecialRedirect' => __DIR__ . '/includes/specials/SpecialRedirect.php', + 'SpecialRedirectExternal' => __DIR__ . '/includes/specials/SpecialRedirectExternal.php', 'SpecialRedirectToSpecial' => __DIR__ . '/includes/specialpage/RedirectSpecialPage.php', 'SpecialRemoveCredentials' => __DIR__ . '/includes/specials/SpecialRemoveCredentials.php', 'SpecialResetTokens' => __DIR__ . '/includes/specials/SpecialResetTokens.php', diff --git a/includes/specialpage/SpecialPageFactory.php b/includes/specialpage/SpecialPageFactory.php index 013ceb24e5..f29d265a38 100644 --- a/includes/specialpage/SpecialPageFactory.php +++ b/includes/specialpage/SpecialPageFactory.php @@ -202,6 +202,7 @@ class SpecialPageFactory { 'AllMyUploads' => \SpecialAllMyUploads::class, 'PermanentLink' => \SpecialPermanentLink::class, 'Redirect' => \SpecialRedirect::class, + 'RedirectExternal' => \SpecialRedirectExternal::class, 'Revisiondelete' => \SpecialRevisionDelete::class, 'RunJobs' => \SpecialRunJobs::class, 'Specialpages' => \SpecialSpecialpages::class, diff --git a/includes/specials/SpecialRedirectExternal.php b/includes/specials/SpecialRedirectExternal.php new file mode 100644 index 0000000000..41a03ed1f2 --- /dev/null +++ b/includes/specials/SpecialRedirectExternal.php @@ -0,0 +1,69 @@ +dispatch( $url ); + if ( $dispatch->getStatusValue()->isGood() ) { + $this->getOutput()->redirect( $url ); + return true; + } + throw new HttpError( 400, $dispatch->getMessage() ); + } + + /** + * @param string $url + * @return Status + */ + public function dispatch( $url ) { + if ( !$url ) { + return Status::newFatal( 'redirectexternal-no-url' ); + } + $url = filter_var( $url, FILTER_SANITIZE_URL ); + if ( !filter_var( $url, FILTER_VALIDATE_URL ) ) { + return Status::newFatal( 'redirectexternal-invalid-url', $url ); + } + return Status::newGood(); + } +} diff --git a/languages/i18n/en.json b/languages/i18n/en.json index ea6305423b..3ce047cff1 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -3746,6 +3746,9 @@ "lag-warn-normal": "Changes newer than $1 {{PLURAL:$1|second|seconds}} may not be shown in this list.", "lag-warn-high": "Due to high database server lag, changes newer than $1 {{PLURAL:$1|second|seconds}} may not be shown in this list.", "editwatchlist-summary": "", + "redirectexternal-summary": "", + "redirectexternal-invalid-url": "$1 is not a valid URL", + "redirectexternal-no-url": "No argument was provided to Special:RedirectExternal", "watchlistedit-normal-title": "Edit watchlist", "watchlistedit-normal-legend": "Remove titles from watchlist", "watchlistedit-normal-explain": "Titles on your watchlist are shown below.\nTo remove a title, check the box next to it, and click \"{{int:Watchlistedit-normal-submit}}\".\nYou can also [[Special:EditWatchlist/raw|edit the raw list]].", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index a17cfca049..0c50f40e56 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -2185,6 +2185,9 @@ "nimagelinks": "Used on [[Special:MostLinkedFiles]] to indicate how often a specific file is used.\n\nParameters:\n* $1 - number of pages\nSee also:\n* {{msg-mw|Ntransclusions}}", "ntransclusions": "Used on [[Special:MostTranscludedPages]] to indicate how often a template is in use.\n\nParameters:\n* $1 - number of pages\nSee also:\n* {{msg-mw|Nimagelinks}}", "specialpage-empty": "Used on a special page when there is no data. For example on [[Special:Unusedimages]] when all images are used.", + "redirectexternal-summary": "{{doc-specialpagessummary|redirectexternal}}", + "redirectexternal-invalid-url": "Error message shown when the argument to [[Special:RedirectExternal]] is an invalid URL.\n\nParameters:\n* $1 - The first URL argument to Special:RedirectExternal", + "redirectexternal-no-url": "Error message shown when no argument is supplied to [[Special:RedirectExternal]]", "lonelypages": "{{doc-special|LonelyPages}}", "lonelypages-summary": "{{doc-specialpagesummary|lonelypages}}", "lonelypagestext": "Text displayed in [[Special:LonelyPages]]", diff --git a/languages/messages/MessagesEn.php b/languages/messages/MessagesEn.php index 7a7370f81e..e78f00329f 100644 --- a/languages/messages/MessagesEn.php +++ b/languages/messages/MessagesEn.php @@ -482,6 +482,7 @@ $specialPageAliases = [ 'Recentchanges' => [ 'RecentChanges' ], 'Recentchangeslinked' => [ 'RecentChangesLinked', 'RelatedChanges' ], 'Redirect' => [ 'Redirect' ], + 'RedirectExternal' => [ 'RedirectExternal' ], 'RemoveCredentials' => [ 'RemoveCredentials' ], 'ResetTokens' => [ 'ResetTokens' ], 'Revisiondelete' => [ 'RevisionDelete' ], diff --git a/tests/phpunit/includes/specials/SpecialRedirectExternalTest.php b/tests/phpunit/includes/specials/SpecialRedirectExternalTest.php new file mode 100644 index 0000000000..ab5b2cda76 --- /dev/null +++ b/tests/phpunit/includes/specials/SpecialRedirectExternalTest.php @@ -0,0 +1,49 @@ +assertEquals( $expectedStatus, $page->dispatch( $url )->isGood() ); + } + + /** + * @throws HttpError + * @expectedException HttpError + * @expectedExceptionMessage asdf is not a valid URL + * @covers SpecialRedirectExternal::execute + */ + public function testExecuteInvalidUrl() { + $page = new SpecialRedirectExternal(); + $page->execute( 'asdf' ); + } + + /** + * @throws HttpError + * @covers SpecialRedirectExternal::execute + */ + public function testValidUrl() { + $page = new SpecialRedirectExternal(); + $this->assertTrue( $page->execute( 'https://www.mediawiki.org' ) ); + } + + public static function provideDispatch() { + return [ + [ 'asdf', false ], + [ null, false ], + [ 'https://www.mediawiki.org?test=1', true ], + ]; + } +} -- 2.20.1