diff --git a/includes/MinervaHooks.php b/includes/MinervaHooks.php index dd37aa3..93ddbf9 100644 --- a/includes/MinervaHooks.php +++ b/includes/MinervaHooks.php @@ -122,11 +122,15 @@ class MinervaHooks { "resources/skins.minerva.scripts/page-issues/page/PageIssueLink.js", "resources/skins.minerva.scripts/page-issues/page/pageIssueFormatter.js", 'resources/skins.minerva.scripts/pageIssues.js', + 'resources/skins.minerva.scripts/UriUtil.js', + 'resources/skins.minerva.scripts/TitleUtil.js', // test files 'tests/qunit/skins.minerva.scripts/downloadPageAction.test.js', 'tests/qunit/skins.minerva.scripts/pageIssuesParser.test.js', 'tests/qunit/skins.minerva.scripts/AB.test.js', 'tests/qunit/skins.minerva.scripts/pageIssues.test.js', + 'tests/qunit/skins.minerva.scripts/UriUtil.test.js', + 'tests/qunit/skins.minerva.scripts/TitleUtil.test.js', 'tests/qunit/skins.minerva.notifications.badge/NotificationBadge.test.js' ], ]; diff --git a/resources/skins.minerva.scripts/TitleUtil.js b/resources/skins.minerva.scripts/TitleUtil.js new file mode 100644 index 0000000..868eb18 --- /dev/null +++ b/resources/skins.minerva.scripts/TitleUtil.js @@ -0,0 +1,138 @@ +// Someone has to maintain this wherever it lives. If it live in Core, it becomes a public API. +// If it lives in some client-side target of mediawiki-title that accepts a MediaWiki config instead +// of a SiteInfo, it still becomes a public API. If it lives where used, it becomes a copy and paste +// implementation where each copy can deviate but deletion is easy. See additional discussion in +// T218358 and I95b08e77eece5cd4dae62f6f237d492d6b0fe42b. +( function ( M ) { + var UriUtil = M.require( 'skins.minerva.scripts/UriUtil' ); + + /** + * Returns the decoded wiki page title referenced by the passed link as a string when parsable. + * The title query parameter is returned, if present. Otherwise, a heuristic is used to attempt + * to extract the title from the path. + * + * The API is the source of truth for page titles. This function should only be used in + * circumstances where the API cannot be consulted. + * + * Assuming the current page is on metawiki, consider the following example links and + * `newFromUri()` outputs: + * + * https://meta.wikimedia.org/wiki/Foo → Foo (path title) + * http://meta.wikimedia.org/wiki/Foo → Foo (mismatching protocol) + * /wiki/Foo → Foo (relative URI) + * /w/index.php?title=Foo → Foo (title query parameter) + * /wiki/Talk:Foo → Talk:Foo (non-main namespace URI) + * /wiki/Foo bar → Foo_bar (name with spaces) + * /wiki/Foo%20bar → Foo_bar (name with percent encoded spaces) + * /wiki/Foo+bar → Foo+bar (name with +) + * /w/index.php?title=Foo%2bbar → Foo+bar (query parameter with +) + * / → null (mismatching article path) + * /wiki/index.php?title=Foo → null (mismatching script path) + * https://archive.org/ → null (mismatching host) + * https://foo.wikimedia.org/ → null (mismatching host) + * https://en.wikipedia.org/wiki/Bar → null (mismatching host) + * + * This function invokes `Uri.isInternal()` to validate that this link is assuredly a local + * wiki link and that the internal usage of both the title query parameter and value of + * wgArticlePath are relevant. + * + * This function doesn't throw. `null` is returned for any unparseable input. + * + * @param {mw.Uri|Object|string} [uri] Passed to Uri. + * @param {Object|boolean} [options] Passed to Uri. + * @param {Object|boolean} [options.validateReadOnlyLink] If true, only links that would show a + * page for reading are considered. E.g., `/wiki/Foo` and `/w/index.php?title=Foo` would + * validate but `/w/index.php?title=Foo&action=bar` would not. + * @return {mw.Title|null} A Title or `null`. + */ + function newFromUri( uri, options ) { + var + mwUri, + regExp, + matches, + title; + + try { + // uri may or may not be a Uri but the Uri constructor accepts a Uri parameter. + mwUri = new mw.Uri( uri, options ); + } catch ( e ) { + return null; + } + + if ( !UriUtil.isInternal( mwUri ) ) { + return null; + } + + if ( ( options || {} ).validateReadOnlyLink && !isReadOnlyUri( mwUri ) ) { + // An unknown query parameter is used. This may not be a read-only link. + return null; + } + + if ( mwUri.query.title ) { + // True if input starts with wgScriptPath. + regExp = new RegExp( '^' + mw.RegExp.escape( mw.config.get( 'wgScriptPath' ) ) + '/' ); + + // URL has a nonempty `title` query parameter like `/w/index.php?title=Foo`. The script + // path should match. + matches = regExp.test( mwUri.path ); + if ( !matches ) { + return null; + } + + // The parameter was already decoded at Uri construction. + title = mwUri.query.title; + } else { + // True if input starts with wgArticlePath and ends with a nonempty page title. The + // first matching group (index 1) is the page title. + regExp = new RegExp( '^' + mw.RegExp.escape( mw.config.get( 'wgArticlePath' ) ).replace( '\\$1', '(.+)' ) ); + + // No title query parameter is present so the URL may be "pretty" like `/wiki/Foo`. + // `Uri.path` should not contain query parameters or a fragment, as is assumed in + // `Uri.getRelativePath()`. Try to isolate the title. + matches = regExp.exec( mwUri.path ); + if ( !matches || !matches[ 1 ] ) { + return null; + } + + try { + // `Uri.path` was not previously decoded, as is assumed in `Uri.getRelativePath()`, + // and decoding may now fail. Do not use `Uri.decode()` which is designed to be + // paired with `Uri.encode()` and replaces `+` characters with spaces. + title = decodeURIComponent( matches[ 1 ] ); + } catch ( e ) { + return null; + } + } + + // Append the fragment, if present. + title += mwUri.fragment ? '#' + mwUri.fragment : ''; + + return mw.Title.newFromText( title ); + } + + /** + * Validates that the passed link is for reading. + * + * The following links return true: + * /wiki/Foo + * /w/index.php?title=Foo + * /w/index.php?oldid=123 + * + * The following links return false: + * /w/index.php?title=Foo&action=bar + * + * @private + * @static + * @method isReadOnlyUri + * @param {mw.Uri} uri A Uri to an internal wiki page. + * @return {boolean} True if uri has no query parameters or only known parameters for reading. + */ + function isReadOnlyUri( uri ) { + var length = Object.keys( uri.query ).length; + return length === ( ( 'oldid' in uri.query ? 1 : 0 ) + ( 'title' in uri.query ? 1 : 0 ) ); + } + + M.define( 'skins.minerva.scripts/TitleUtil', { + newFromUri: newFromUri + } ); +}( mw.mobileFrontend ) ); diff --git a/resources/skins.minerva.scripts/UriUtil.js b/resources/skins.minerva.scripts/UriUtil.js new file mode 100644 index 0000000..2def8b8 --- /dev/null +++ b/resources/skins.minerva.scripts/UriUtil.js @@ -0,0 +1,34 @@ +( function ( M ) { + /** + * Compares the default Uri host, usually `window.location.host`, and `mw.Uri.host`. Equivalence + * tests internal linkage, a mismatch may indicate an external link. Interwiki links are + * considered external. + * + * This function only indicates internal in the sense of being on the same host or not. It has + * no knowledge of [[Link]] vs [Link] links. + * + * On https://meta.wikimedia.org/wiki/Foo, the following links would be considered *internal* + * and return `true`: + * + * https://meta.wikimedia.org/ + * https://meta.wikimedia.org/wiki/Bar + * https://meta.wikimedia.org/w/index.php?title=Bar + * + * Similarly, the following links would be considered *not* internal and return `false`: + * + * https://archive.org/ + * https://foo.wikimedia.org/ + * https://en.wikipedia.org/ + * https://en.wikipedia.org/wiki/Bar + * + * @param {mw.Uri} uri + * @return {boolean} + */ + function isInternal( uri ) { + return uri.host === mw.Uri().host; + } + + M.define( 'skins.minerva.scripts/UriUtil', { + isInternal: isInternal + } ); +}( mw.mobileFrontend ) ); diff --git a/resources/skins.minerva.scripts/init.js b/resources/skins.minerva.scripts/init.js index 09fab7f..d1147ff 100644 --- a/resources/skins.minerva.scripts/init.js +++ b/resources/skins.minerva.scripts/init.js @@ -5,6 +5,7 @@ toast = mobile.toast, time = mobile.time, skin = M.require( 'mobile.init/skin' ), + TitleUtil = M.require( 'skins.minerva.scripts/TitleUtil' ), issues = M.require( 'skins.minerva.scripts/pageIssues' ), downloadPageAction = M.require( 'skins.minerva.scripts/downloadPageAction' ), loader = mobile.rlModuleLoader, @@ -16,9 +17,11 @@ Anchor = mobile.Anchor, overlayManager = OverlayManager.getSingleton(), page = M.getCurrentPage(), + redLinks = page.getRedLinks(), api = new mw.Api(), thumbs = page.getThumbnails(), - eventBus = mobile.eventBusSingleton; + eventBus = mobile.eventBusSingleton, + namespaceIDs = mw.config.get( 'wgNamespaceIds' ); /** * Event handler for clicking on an image thumbnail @@ -255,16 +258,78 @@ } } + /** + * Tests a URL to determine if it links to a local User namespace page or not. + * + * Assuming the current page visited is hosted on metawiki, the following examples would return + * true: + * + * https://meta.wikimedia.org/wiki/User:Foo + * /wiki/User:Foo + * /wiki/User:Nonexistent_user_page + * + * The following examples return false: + * + * https://en.wikipedia.org/wiki/User:Foo + * /wiki/Foo + * /wiki/User_talk:Foo + * + * @param {string} url + * @return {boolean} + */ + function isUserUri( url ) { + var + title = TitleUtil.newFromUri( url ), + namespace = title ? title.getNamespaceId() : undefined; + return namespace === namespaceIDs.user; + } + + /** + * Strip the edit action from red links to nonexistent User namespace pages. + * @return {void} + */ + function initUserRedLinks() { + redLinks.filter( function ( _, element ) { + // Filter out non-User namespace pages. + return isUserUri( element.href ); + } ).each( function ( _, element ) { + var uri = new mw.Uri( element.href ); + if ( uri.query.action !== 'edit' ) { + // Nothing to strip. + return; + } + + // Strip the action. + delete uri.query.action; + + // Update the element with the new link. + element.href = uri.toString(); + } ); + } + /** * Initialize red links call-to-action * * Upon clicking a red link, show an interstitial CTA explaining that the page doesn't exist * with a button to create it, rather than directly navigate to the edit form. * + * Special case T201339: following a red link to a user or user talk page should not prompt for + * its creation. The reasoning is that user pages should be created by their owners and it's far + * more common that non-owners follow a user's red linked user page to consider their + * contributions, account age, or other activity. + * + * For example, a user adds a section to a Talk page and signs their contribution (which creates + * a link to their user page whether exists or not). If the user page does not exist, that link + * will be red. In both cases, another user follows this link, not to edit create a page for + * that user but to obtain information on them. + * * @ignore */ function initRedlinksCta() { - page.getRedLinks().on( 'click', function ( ev ) { + redLinks.filter( function ( _, element ) { + // Filter out local User namespace pages. + return !isUserUri( element.href ); + } ).on( 'click', function ( ev ) { var drawerOptions = { progressiveButton: new Button( { progressive: true, @@ -337,6 +402,7 @@ initHistoryLink( $( '.last-modifier-tagline a' ) ); appendDownloadButton(); initRedlinksCta(); + initUserRedLinks(); initEditLink(); // Setup the issues banner on the page // Pages which dont exist (id 0) cannot have issues diff --git a/skin.json b/skin.json index 18b23f7..1fbee67 100644 --- a/skin.json +++ b/skin.json @@ -380,7 +380,9 @@ "skins.minerva.icons.images.scripts", "mediawiki.util", "mediawiki.router", + "mediawiki.RegExp", "mediawiki.Title", + "mediawiki.Uri", "mobile.startup", "mediawiki.user", "mediawiki.storage", @@ -427,6 +429,8 @@ "resources/skins.minerva.scripts/page-issues/page/PageIssueLink.js", "resources/skins.minerva.scripts/page-issues/page/pageIssueFormatter.js", "resources/skins.minerva.scripts/pageIssues.js", + "resources/skins.minerva.scripts/UriUtil.js", + "resources/skins.minerva.scripts/TitleUtil.js", "resources/skins.minerva.scripts/init.js", "resources/skins.minerva.scripts/initLogging.js", "resources/skins.minerva.scripts/mobileRedirect.js", diff --git a/tests/qunit/skins.minerva.scripts/TitleUtil.test.js b/tests/qunit/skins.minerva.scripts/TitleUtil.test.js new file mode 100644 index 0000000..1fb211f --- /dev/null +++ b/tests/qunit/skins.minerva.scripts/TitleUtil.test.js @@ -0,0 +1,229 @@ +( function ( M ) { + var TitleUtil = M.require( 'skins.minerva.scripts/TitleUtil' ); + + QUnit.module( 'Minerva TitleUtil', QUnit.newMwEnvironment( { + setup: function () { + this.mwUriOrg = mw.Uri; + mw.Uri = mw.UriRelative( 'https://meta.wikimedia.org/w/index.php' ); + }, + teardown: function () { + mw.Uri = this.mwUriOrg; + delete this.mwUriOrg; + }, + config: { + wgArticlePath: '/wiki/$1', + wgScriptPath: '/w' + } + } ) ); + + QUnit.test( '.newFromUri()', function ( assert ) { + [ '', 'https://meta.wikimedia.org' ].forEach( function ( authority, index ) { + var + indexMsg = 'case ' + index + ' ', + authorityMsg = ' authority="' + authority + '"', + validateReadOnlyLink = { validateReadOnlyLink: true }; + assert.strictEqual( + TitleUtil.newFromUri( authority + '/w/index.php?title=Title' ).getPrefixedDb(), + 'Title', + indexMsg + 'title is in query parameter' + authorityMsg + ); + assert.strictEqual( + TitleUtil.newFromUri( authority + '/wiki/Title' ).getPrefixedDb(), + 'Title', + indexMsg + 'title is in path' + authorityMsg + ); + assert.strictEqual( + TitleUtil.newFromUri( authority + '/foo/bar/wiki/Title' ), + null, + indexMsg + 'title is not in nonmatching path' + authorityMsg + ); + assert.strictEqual( + TitleUtil.newFromUri( authority + '/wiki/%E6%B8%AC%E8%A9%A6' ).getPrefixedDb(), + '測試', + indexMsg + 'title is decoded' + authorityMsg + ); + assert.strictEqual( + TitleUtil.newFromUri( authority + '/wiki/Foo bar' ).getPrefixedDb(), + 'Foo_bar', + indexMsg + 'title with space is decoded' + authorityMsg + ); + assert.strictEqual( + TitleUtil.newFromUri( authority + '/wiki/Foo%20bar' ).getPrefixedDb(), + 'Foo_bar', + indexMsg + 'title with encoded space is decoded' + authorityMsg + ); + assert.strictEqual( + TitleUtil.newFromUri( authority + '/w/index.php?title=Title#fragment' ).getPrefixedDb(), + 'Title', + indexMsg + 'fragment is omitted from query title' + authorityMsg + ); + assert.strictEqual( + TitleUtil.newFromUri( authority + '/wiki/Title#fragment' ).getPrefixedDb(), + 'Title', + indexMsg + 'fragment is omitted from path title' + authorityMsg + ); + assert.strictEqual( + TitleUtil.newFromUri( authority + '/w/index.php?title=Title#fragment' ).getFragment(), + 'fragment', + indexMsg + 'fragment is present after query parameter' + authorityMsg + ); + assert.strictEqual( + TitleUtil.newFromUri( authority + '/wiki/Title#fragment' ).getFragment(), + 'fragment', + indexMsg + 'fragment is present after path' + authorityMsg + ); + assert.strictEqual( + TitleUtil.newFromUri( authority + '/w/index.php?title=Title#foo%20bar' ).getFragment(), + 'foo bar', + indexMsg + 'fragment is decoded' + authorityMsg + ); + + assert.strictEqual( + TitleUtil.newFromUri( authority + '/w/index.php?title=Title', validateReadOnlyLink ).getPrefixedDb(), + 'Title', + indexMsg + 'query title is read-only' + authorityMsg + ); + assert.strictEqual( + TitleUtil.newFromUri( authority + '/wiki/Title', validateReadOnlyLink ).getPrefixedDb(), + 'Title', + indexMsg + 'path title is read-only' + authorityMsg + ); + assert.strictEqual( + TitleUtil.newFromUri( authority + '/w/index.php?title=Title&oldid=123', validateReadOnlyLink ).getPrefixedDb(), + 'Title', + indexMsg + 'query title with revision is read-only' + authorityMsg + ); + assert.strictEqual( + TitleUtil.newFromUri( authority + '/w/index.php?title=Title¶m', validateReadOnlyLink ), + null, + indexMsg + 'query title with unknown parameter is not read-only' + authorityMsg + ); + } ); + + // Bad or odd inputs. + [ + '%', null, undefined, '', ' ', '/', {}, '\\', '/wiki/%', '/w/index.php?title=%' + ].forEach( function ( input, index ) { + assert.strictEqual( + TitleUtil.newFromUri( input ), + null, + 'Case ' + index + ' no Title in bad input input="' + input + '"' + ); + } ); + + // Parameters are passed to Uri's constructor. + assert.strictEqual( + TitleUtil.newFromUri( { protocol: 'https', + host: 'meta.wikimedia.org', + path: '/wiki/Title' } ).getPrefixedDb(), + 'Title', + 'title is in Uri parameters' + ); + + // A Uri itself can be passed. + assert.strictEqual( + TitleUtil.newFromUri( new mw.Uri( 'https://meta.wikimedia.org/wiki/Title' ) ).getPrefixedDb(), + 'Title', + 'title is in Uri' + ); + + // JSDoc examples. + // https://meta.wikimedia.org/wiki/Foo → Foo (path title) + assert.strictEqual( + TitleUtil.newFromUri( 'https://meta.wikimedia.org/wiki/Foo' ).getPrefixedDb(), + 'Foo', + 'path title' + ); + + // http://meta.wikimedia.org/wiki/Foo → Foo (mismatching protocol) + assert.strictEqual( + TitleUtil.newFromUri( 'http://meta.wikimedia.org/wiki/Foo' ).getPrefixedDb(), + 'Foo', + 'mismatching protocol' + ); + + // /wiki/Foo → Foo (relative URI) + assert.strictEqual( + TitleUtil.newFromUri( '/wiki/Foo' ).getPrefixedDb(), + 'Foo', + 'relative URI' + ); + + // /w/index.php?title=Foo → Foo (title query parameter) + assert.strictEqual( + TitleUtil.newFromUri( '/w/index.php?title=Foo' ).getPrefixedDb(), + 'Foo', + 'title query parameter' + ); + + // /wiki/Talk:Foo → Talk:Foo (non-main namespace URI) + assert.strictEqual( + TitleUtil.newFromUri( '/wiki/Talk:Foo' ).getPrefixedDb(), + 'Talk:Foo', + 'non-main namespace URI' + ); + + // /wiki/Foo bar → Foo_bar (name with spaces) + assert.strictEqual( + TitleUtil.newFromUri( '/wiki/Foo bar' ).getPrefixedDb(), + 'Foo_bar', + 'name with spaces' + ); + + // /wiki/Foo%20bar → Foo_bar (name with percent encoded spaces) + assert.strictEqual( + TitleUtil.newFromUri( '/wiki/Foo%20bar' ).getPrefixedDb(), + 'Foo_bar', + 'name with percent encoded spaces' + ); + + // /wiki/Foo+bar → Foo+bar (name with +) + assert.strictEqual( + TitleUtil.newFromUri( '/wiki/Foo+bar' ).getPrefixedDb(), + 'Foo+bar', + 'name with +' + ); + + // /w/index.php?title=Foo%2bbar → Foo+bar (query parameter with +) + assert.strictEqual( + TitleUtil.newFromUri( '/w/index.php?title=Foo%2bbar' ).getPrefixedDb(), + 'Foo+bar', + 'query parameter with +' + ); + + // / → null (mismatching article path) + assert.strictEqual( + TitleUtil.newFromUri( '/' ), + null, + 'mismatching article path' + ); + + // /wiki/index.php?title=Foo → null (mismatching script path) + assert.strictEqual( + TitleUtil.newFromUri( '/wiki/index.php?title=Foo' ), + null, + 'mismatching script path' + ); + + // https://archive.org/ → null (mismatching host) + assert.strictEqual( + TitleUtil.newFromUri( 'https://archive.org/' ), + null, + 'mismatching host (0)' + ); + + // https://foo.wikimedia.org/ → null (mismatching host) + assert.strictEqual( + TitleUtil.newFromUri( 'https://foo.wikimedia.org/' ), + null, + 'mismatching host (1)' + ); + + // https://en.wikipedia.org/wiki/Bar → null (mismatching host) + assert.strictEqual( + TitleUtil.newFromUri( 'https://en.wikipedia.org/wiki/Bar' ), + null, + 'mismatching host (2)' + ); + } ); +}( mw.mobileFrontend ) ); diff --git a/tests/qunit/skins.minerva.scripts/UriUtil.test.js b/tests/qunit/skins.minerva.scripts/UriUtil.test.js new file mode 100644 index 0000000..95d9a47 --- /dev/null +++ b/tests/qunit/skins.minerva.scripts/UriUtil.test.js @@ -0,0 +1,47 @@ +( function ( M ) { + var UriUtil = M.require( 'skins.minerva.scripts/UriUtil' ); + + QUnit.module( 'Minerva UriUtil', QUnit.newMwEnvironment( { + setup: function () { + this.mwUriOrg = mw.Uri; + mw.Uri = mw.UriRelative( 'https://meta.wikimedia.org/w/index.php' ); + }, + teardown: function () { + mw.Uri = this.mwUriOrg; + delete this.mwUriOrg; + } + } ) ); + + QUnit.test( '.isInternal()', function ( assert ) { + assert.strictEqual( + UriUtil.isInternal( new mw.Uri( '/relative' ) ), + true, + 'relative URLs are internal' + ); + assert.strictEqual( + UriUtil.isInternal( new mw.Uri( 'http://meta.wikimedia.org/' ) ), + true, + 'matching hosts are internal' + ); + assert.strictEqual( + UriUtil.isInternal( new mw.Uri( 'https:/meta.wikimedia.org/' ) ), + true, + 'protocol is irrelevant' + ); + assert.strictEqual( + UriUtil.isInternal( new mw.Uri( 'https://meta.wikimedia.org/path' ) ), + true, + 'path is irrelevant' + ); + assert.strictEqual( + UriUtil.isInternal( new mw.Uri( 'https://archive.org/' ) ), + false, + 'external links are not internal' + ); + assert.strictEqual( + UriUtil.isInternal( new mw.Uri( 'https://www.meta.wikimedia.org/' ) ), + false, + 'differing subdomains are not internal' + ); + } ); +}( mw.mobileFrontend ) );