Skip to content
This repository has been archived by the owner on Mar 13, 2024. It is now read-only.

MM-30090 Open managed resource links in a new tab #7024

Merged
merged 6 commits into from
Nov 5, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions components/admin_console/admin_definition.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,16 @@ const AdminDefinition = {
help_text_default: 'When true, any outgoing HTTPS requests will accept unverified, self-signed certificates. For example, outgoing webhooks to a server with a self-signed TLS certificate, using any domain, will be allowed. Note that this makes these connections susceptible to man-in-the-middle attacks.',
isDisabled: it.not(it.userHasWritePermissionOnResource('environment')),
},
{
type: Constants.SettingsTypes.TYPE_TEXT,
key: 'ServiceSettings.ManagedResourcePaths',
label: t('admin.service.managedResourcePaths'),
label_default: 'Managed Resource Paths:',
help_text: t('admin.service.managedResourcePathsDescription'),
help_text_default: 'A comma-separated list of paths on the Mattermost server that are managed by another service. See [here](!https://docs.mattermost.com/install/desktop-managed-resources.html) for more information.',
help_text_markdown: true,
isDisabled: it.not(it.userHasWritePermissionOnResource('environment')),
},
{
type: Constants.SettingsTypes.TYPE_BUTTON,
action: reloadConfig,
Expand Down
3 changes: 2 additions & 1 deletion components/markdown/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {createSelector} from 'reselect';

import {Preferences} from 'mattermost-redux/constants';
import {getChannelsNameMapInCurrentTeam} from 'mattermost-redux/selectors/entities/channels';
import {getAutolinkedUrlSchemes, getConfig} from 'mattermost-redux/selectors/entities/general';
import {getAutolinkedUrlSchemes, getConfig, getManagedResourcePaths} from 'mattermost-redux/selectors/entities/general';
import {getBool} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
import {getAllUserMentionKeys} from 'mattermost-redux/selectors/entities/search';
Expand Down Expand Up @@ -48,6 +48,7 @@ function makeMapStateToProps() {
autolinkedUrlSchemes: getAutolinkedUrlSchemes(state),
channelNamesMap: getChannelNamesMap(state, ownProps),
enableFormatting: getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'formatting', true),
managedResourcePaths: getManagedResourcePaths(state),
mentionKeys: ownProps.mentionKeys || getAllUserMentionKeys(state),
siteURL: getSiteURL(),
team: getCurrentTeam(state),
Expand Down
8 changes: 7 additions & 1 deletion components/markdown/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ type Props = {
*/
isRHS?: boolean;

/*
* An array of paths on the server that are managed by another server
*/
managedResourcePaths?: string[];

/*
* An array of words that can be used to mention a user
*/
Expand All @@ -47,7 +52,7 @@ type Props = {
/*
* Any additional text formatting options to be used
*/
options: TextFormattingOptions;
options: Partial<TextFormattingOptions>;

/*
* The root Site URL for the page
Expand Down Expand Up @@ -125,6 +130,7 @@ export default class Markdown extends React.PureComponent<Props> {
proxyImages: this.props.hasImageProxy && this.props.proxyImages,
team: this.props.team,
minimumHashtagLength: this.props.minimumHashtagLength,
managedResourcePaths: this.props.managedResourcePaths,
}, this.props.options);

const htmlFormattedText = formatText(this.props.message, options, this.props.emojiMap);
Expand Down
2 changes: 2 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1677,6 +1677,8 @@
"admin.service.listenAddress": "Listen Address:",
"admin.service.listenDescription": "The address and port to which to bind and listen. Specifying \":8065\" will bind to all network interfaces. Specifying \"127.0.0.1:8065\" will only bind to the network interface having that IP address. If you choose a port of a lower level (called \"system ports\" or \"well-known ports\", in the range of 0-1023), you must have permissions to bind to that port. On Linux you can use: \"sudo setcap cap_net_bind_service=+ep ./bin/mattermost\" to allow Mattermost to bind to well-known ports.",
"admin.service.listenExample": "E.g.: \":8065\"",
"admin.service.managedResourcePaths": "Managed Resource Paths:",
"admin.service.managedResourcePathsDescription": "A comma-separated list of paths on the Mattermost server that are managed by another service. See [here](!https://docs.mattermost.com/install/desktop-managed-resources.html) for more information.",
"admin.service.mfaDesc": "When true, users with AD/LDAP or email login can add multi-factor authentication to their account using Google Authenticator.",
"admin.service.mfaTitle": "Enable Multi-factor Authentication:",
"admin.service.minimumHashtagLengthDescription": "Minimum number of characters in a hashtag. This must be greater than or equal to 2. MySQL databases must be configured to support searching strings shorter than three characters, [see documentation](!https://dev.mysql.com/doc/refman/8.0/en/fulltext-fine-tuning.html).",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"localforage-observable": "2.0.1",
"mark.js": "8.11.1",
"marked": "github:mattermost/marked#87769262aa02e1784570f61f4f962050e07cc335",
"mattermost-redux": "github:mattermost/mattermost-redux#aba4df6b300ad4aaf8e0adb734ff549bf236d110",
"mattermost-redux": "github:mattermost/mattermost-redux#5aa938486c49d76f55953dca7890fb6f4ad601b7",
"moment-timezone": "0.5.31",
"p-queue": "6.6.1",
"pdfjs-dist": "2.1.266",
Expand Down
44 changes: 44 additions & 0 deletions utils/markdown/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,4 +169,48 @@ this is long text this is long text this is long text this is long text this is
const plugin = format('[plugin](/reiciendis-0/plugins/example))', {siteURL: 'http://localhost'});
expect(plugin).toContain('<a class="theme markdown__link" href="/reiciendis-0/plugins/example" rel="noreferrer" data-link="/reiciendis-0/plugins/example">plugin</a>');
});

describe('should correctly open links in the current tab based on whether they are handled by the web app', () => {
for (const testCase of [
{name: 'regular link', link: 'https://example.com', handled: false},
{name: 'www link', link: 'www.example.com', handled: false},

{name: 'link to a channel', link: 'http://localhost/team/channels/foo', handled: true},
{name: 'link to a DM', link: 'http://localhost/team/messages/@bar', handled: true},
{name: 'link to the system console', link: 'http://localhost/admin_console', handled: true},
{name: 'permalink', link: 'http://localhost/reiciendis-0/pl/b3hrs3brjjn7fk4kge3xmeuffc', handled: true},
{name: 'link to a specific system console page', link: 'http://localhost/admin_console/plugins/plugin_com.github.matterpoll.matterpoll', handled: true},

{name: 'relative link', link: '/', handled: true},
{name: 'relative link to a channel', link: '/reiciendis-0/channels/b3hrs3brjjn7fk4kge3xmeuffc', handled: true},
{name: 'relative link to a DM', link: '/reiciendis-0/messages/b3hrs3brjjn7fk4kge3xmeuffc', handled: true},
{name: 'relative permalink', link: '/reiciendis-0/pl/b3hrs3brjjn7fk4kge3xmeuffc', handled: true},
{name: 'relative link to the system console', link: '/admin_console', handled: true},
{name: 'relative link to a specific system console page', link: '/admin_console/plugins/plugin_com.github.matterpoll.matterpoll', handled: true},

{name: 'link to a plugin-handled path', link: 'http://localhost/plugins/example', handled: false},
{name: 'link to a file attachment public link', link: 'http://localhost/files/o6eujqkmjfd138ykpzmsmc131y/public?h=j5nPX8JlgUeNVMOB3dLXwyG_jlxlSw4nSgZmegXfpHw', handled: false},

{name: 'relative link to a plugin-handled path', link: '/plugins/example', handled: false},
{name: 'relative link to a file attachment public link', link: '/files/o6eujqkmjfd138ykpzmsmc131y/public?h=j5nPX8JlgUeNVMOB3dLXwyG_jlxlSw4nSgZmegXfpHw', handled: false},

{name: 'link to a managed resource', link: 'http://localhost/trusted/jitsi', options: {managedResourcePaths: ['trusted']}, handled: false},
{name: 'relative link to a managed resource', link: '/trusted/jitsi', options: {managedResourcePaths: ['trusted']}, handled: false},
{name: 'link that is not to a managed resource', link: 'http://localhost/trusted/jitsi', options: {managedResourcePaths: ['jitsi']}, handled: true},
hmhealey marked this conversation as resolved.
Show resolved Hide resolved
]) {
test(testCase.name, () => {
const options = {
siteURL: 'http://localhost',
...testCase.options,
};
const output = format(`[link](${testCase.link})`, options);

if (testCase.handled) {
expect(output).not.toContain('target="_blank"');
} else {
expect(output).toContain('rel="noreferrer" target="_blank"');
}
});
}
});
});
43 changes: 26 additions & 17 deletions utils/markdown/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export default class Renderer extends marked.Renderer {
private emojiMap: EmojiMap;
public constructor(
options: MarkedOptions,
formattingOptions = {},
formattingOptions: TextFormatting.TextFormattingOptions,
emojiMap = new EmojiMap(new Map()),
) {
super(options);
Expand Down Expand Up @@ -207,29 +207,38 @@ export default class Renderer extends marked.Renderer {

output += `" href="${outHref}" rel="noreferrer"`;

const pluginURL = `${this.formattingOptions.siteURL}/plugins`;
const fileURL = `${this.formattingOptions.siteURL}/files`;
const isInternalLink = outHref.startsWith(this.formattingOptions.siteURL || '') || outHref.startsWith('/');

// Any link that begins with siteURL should be opened inside the app, except when rooted
// at /plugins, which is logically "outside the app" despite being hosted by a plugin,
// or /files, which should be launched "outside the app".
let internalLink = outHref.startsWith(this.formattingOptions.siteURL || '') && !outHref.startsWith(pluginURL) && !outHref.startsWith(fileURL);
let openInNewTab;
if (isInternalLink) {
const path = outHref.startsWith('/') ? outHref : outHref.substring(this.formattingOptions.siteURL?.length || 0);

// special case for team invite links, channel links, and permalinks that are inside the app
const pattern = new RegExp(
'^(' +
TextFormatting.escapeRegex(this.formattingOptions.siteURL) +
')?\\/(?:signup_user_complete|admin_console|[^\\/]+\\/(?:pl|channels|messages|plugins))\\/',
);
internalLink = internalLink || pattern.test(outHref);
// Paths managed by plugins and public file links aren't handled by the web app
const unhandledPaths = [
'plugins',
'files',
];

// Paths managed by another service shouldn't be handled by the web app either
if (this.formattingOptions.managedResourcePaths) {
for (const managedPath of this.formattingOptions.managedResourcePaths) {
unhandledPaths.push(TextFormatting.escapeRegex(managedPath));
}
}

if (internalLink && this.formattingOptions.siteURL) {
openInNewTab = unhandledPaths.some((unhandledPath) => new RegExp('^/' + unhandledPath + '\\b').test(path));
} else {
// All links outside of Mattermost should be opened in a new tab
openInNewTab = true;
}

if (openInNewTab || !this.formattingOptions.siteURL) {
output += ' target="_blank"';
} else {
output += ` data-link="${outHref.replace(
this.formattingOptions.siteURL,
'',
)}"`;
} else {
output += ' target="_blank"';
}

if (title) {
Expand Down
8 changes: 8 additions & 0 deletions utils/text_formatting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,14 @@ interface TextFormattingOptionsBase {
*/
autolinkedUrlSchemes: string[];

/*
* An array of paths on the server that are managed by another server. Any path provided will be treated as an
* external link that will not by handled by react-router.
*
* Defaults to an empty array.
*/
managedResourcePaths: string[];

/**
* A custom renderer object to use in the formatWithRenderer function.
*
Expand Down