diff --git a/.github/workflows/webapp-ci.yml b/.github/workflows/webapp-ci.yml index 7a2a611efba..65fa9ab9f8d 100644 --- a/.github/workflows/webapp-ci.yml +++ b/.github/workflows/webapp-ci.yml @@ -45,6 +45,23 @@ jobs: run: | npm run i18n-extract:check + check-external-links: + needs: check-lint + runs-on: ubuntu-24.04 + timeout-minutes: 15 + defaults: + run: + working-directory: webapp + steps: + - name: ci/checkout-repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: ci/setup + uses: ./.github/actions/webapp-setup + - name: ci/check-external-links + run: | + set -o pipefail + npm run check-external-links -- --markdown | tee -a $GITHUB_STEP_SUMMARY + check-types: needs: check-lint runs-on: ubuntu-24.04 diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index 63726119391..55da234e18e 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -3261,7 +3261,7 @@ const AdminDefinition: AdminDefinitionType = { featureName: 'burn_on_read', title: defineMessage({id: 'admin.burn_on_read_feature_discovery.title', defaultMessage: 'Send burn-on-read messages that are automatically deleted after being read'}), description: defineMessage({id: 'admin.burn_on_read_feature_discovery.description', defaultMessage: 'With Mattermost Enterprise Advanced, users can send transient messages that are automatically deleted a fixed time after they are read by a recipient.'}), - learnMoreURL: 'https://docs.mattermost.com/deployment/burn-on-read-messages.html', + learnMoreURL: 'https://docs.mattermost.com/end-user-guide/collaborate/send-messages.html#send-burn-on-read-messages', svgImage: BurnOnReadSVG, }, }, diff --git a/webapp/channels/src/components/admin_console/feature_discovery/features/attribute_based_access_control.tsx b/webapp/channels/src/components/admin_console/feature_discovery/features/attribute_based_access_control.tsx index 9c1f5e93280..63d58c23673 100644 --- a/webapp/channels/src/components/admin_console/feature_discovery/features/attribute_based_access_control.tsx +++ b/webapp/channels/src/components/admin_console/feature_discovery/features/attribute_based_access_control.tsx @@ -23,7 +23,7 @@ const AttributeBasedAccessControlFeatureDiscovery: React.FC = () => { id: 'admin.attribute_based_access_control_feature_discovery.desc', defaultMessage: 'Create policies containing access rules based on user attributes and apply them to channels and other resources within Mattermost.', })} - learnMoreURL='https://docs.mattermost.com/deployment/' + learnMoreURL='https://docs.mattermost.com/administration-guide/manage/admin/attribute-based-access-control.html' featureDiscoveryImage={ { expect(firstHref).toBe(secondHref); expect(firstParams).toBe(secondParams); }); + it('do not substitute %20 on query params', () => { const url = 'https://www.mattermost.com/some/url?subject=hello%20world'; const {result: {current: [href]}} = renderHookWithContext(() => useExternalLink(url), getBaseState()); expect(href).toContain('subject=hello%20world'); }); + + it('do not error on invalid URLs', () => { + const invalidUrl = 'not a valid url'; + const {result: {current: [href, queryParams]}} = renderHookWithContext(() => useExternalLink(invalidUrl), getBaseState()); + expect(href).toBe(invalidUrl); + expect(queryParams).toEqual({}); + }); + + it('do not modify arbitrary links that happen to include mattermost.com', () => { + const invalidUrl = 'https://example.com/mattermost.com'; + const {result: {current: [href, queryParams]}} = renderHookWithContext(() => useExternalLink(invalidUrl), getBaseState()); + expect(href).toBe(invalidUrl); + expect(queryParams).toEqual({}); + }); + + it('do not modify mailto links on mattermost.com', () => { + const mailtoUrl = 'mailto:support@mattermost.com'; + const {result: {current: [href, queryParams]}} = renderHookWithContext(() => useExternalLink(mailtoUrl), getBaseState()); + expect(href).toBe(mailtoUrl); + expect(queryParams).toEqual({}); + }); }); diff --git a/webapp/channels/src/components/common/hooks/use_external_link.ts b/webapp/channels/src/components/common/hooks/use_external_link.ts index 4e980a60cc6..b05606ea0cc 100644 --- a/webapp/channels/src/components/common/hooks/use_external_link.ts +++ b/webapp/channels/src/components/common/hooks/use_external_link.ts @@ -17,6 +17,17 @@ export type ExternalLinkQueryParams = { userId?: string; } +/** + * useExternalLink is used when linking outside of the MM server to add extra tracking parameters when linking to any + * page on mattermost.com (such as our docs or marketing websites). When passed any URL that isn't on mattermost.com, + * it returns the original URL unmodified. + * + * @param href The external URL being linked to + * @param location The location of the link within the app + * @param overwriteQueryParams + * @return {[string, Record]} A tuple containing the URL (whether or not it was modified) and all query + * parameters on that link (either pre-existing or added by this hook) + */ export function useExternalLink(href: string, location: string = '', overwriteQueryParams: ExternalLinkQueryParams = {}): [string, Record] { const userId = useSelector(getCurrentUserId); const config = useSelector(getConfig); @@ -28,11 +39,20 @@ export function useExternalLink(href: string, location: string = '', overwriteQu const isCloud = useSelector((state: GlobalState) => getLicense(state)?.Cloud === 'true'); return useMemo(() => { - if (!href?.includes('mattermost.com') || href?.startsWith('mailto:')) { + let parsedUrl; + try { + parsedUrl = new URL(href); + } catch { return [href, {}]; } - const parsedUrl = new URL(href); + if (parsedUrl.hostname !== 'mattermost.com' && !parsedUrl.hostname.endsWith('.mattermost.com')) { + return [href, {}]; + } + + if (parsedUrl.protocol === 'mailto:') { + return [href, {}]; + } // Determine edition type (enterprise vs team) const isEnterpriseReady = config?.BuildEnterpriseReady === 'true'; diff --git a/webapp/channels/src/components/help/commands.tsx b/webapp/channels/src/components/help/commands.tsx index 5ed269a8965..74265609aab 100644 --- a/webapp/channels/src/components/help/commands.tsx +++ b/webapp/channels/src/components/help/commands.tsx @@ -52,7 +52,7 @@ const HelpCommands = (): JSX.Element => { values={{ link: (chunks: React.ReactNode) => ( {chunks} diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 55e00eb8346..461d7e8d31e 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -1026,7 +1026,7 @@ export const AboutLinks = { }; export const CloudLinks = { - BILLING_DOCS: 'https://docs.mattermost.com/pl/cloud-billing', + BILLING_DOCS: 'https://docs.mattermost.com/product-overview/cloud-subscriptions.html', PRICING: 'https://mattermost.com/pl/pricing/', PRORATED_PAYMENT: 'https://mattermost.com/pl/mattermost-cloud-prorate-documentation', DEPLOYMENT_OPTIONS: 'https://mattermost.com/deploy/', @@ -1074,7 +1074,7 @@ export const DocLinks = { SETUP_LDAP: 'https://mattermost.com/pl/setup-ldap', SETUP_PERFORMANCE_MONITORING: 'https://mattermost.com/pl/setup-performance-monitoring', SETUP_PUSH_NOTIFICATIONS: 'https://mattermost.com/pl/setup-push-notifications', - SETUP_SAML: 'https://docs.mattermost.com/pl/setup-saml', + SETUP_SAML: 'https://docs.mattermost.com/administration-guide/onboard/sso-saml.html', SHARE_LINKS_TO_MESSAGES: 'https://mattermost.com/pl/share-links-to-messages', SITE_URL: 'https://mattermost.com/pl/configure-site-url', SSL_CERTIFICATE: 'https://mattermost.com/pl/setup-ssl-client-certificate', diff --git a/webapp/package.json b/webapp/package.json index bfded014daa..6d1eff50a6c 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -19,7 +19,8 @@ "i18n-extract": "npm run i18n-extract --workspaces --if-present", "i18n-extract:check": "npm run i18n-extract:check --workspaces --if-present", "clean": "npm run clean --workspaces --if-present && rm -rf node_modules .parcel-cache", - "gen-lang-imports": "node scripts/gen_lang_imports.mjs" + "gen-lang-imports": "node scripts/gen_lang_imports.mjs", + "check-external-links": "node scripts/check-external-links.mjs" }, "dependencies": { "@mattermost/compass-icons": "0.1.53", diff --git a/webapp/scripts/check-external-links.mjs b/webapp/scripts/check-external-links.mjs new file mode 100644 index 00000000000..c353ad8aad2 --- /dev/null +++ b/webapp/scripts/check-external-links.mjs @@ -0,0 +1,404 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable no-console */ + +import fs from 'node:fs'; +import path from 'node:path'; + +import chalk from 'chalk'; + +const MATTERMOST_URL_PATTERN = /https?:\/\/(?:[a-z0-9-]+\.)*mattermost\.com(?:[/?#][^"'\s<>()]*)?/gi; + +const PERMALINK_PATTERN = /^https?:\/\/(www\.)?mattermost\.com\/pl\//; + +const PUSH_SERVER_PATTERN = /^https?:\/\/(([a-z0-9-]+\.)?push|hpns-[a-z]+)\.mattermost\.com(\/|$)/; +const ROOT_DOMAIN_PATTERN = /^https?:\/\/(www\.)?mattermost\.com\/?$/; + +const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx']; + +const DIRECTORIES_TO_SCAN = [ + 'channels/src', + 'platform/client/src', + 'platform/components/src', + 'platform/mattermost-redux/src', +]; + +function getAllSourceFiles(dir, excludeTests = true) { + const files = []; + + function walk(currentDir) { + if (!fs.existsSync(currentDir)) { + return; + } + + const entries = fs.readdirSync(currentDir, {withFileTypes: true}); + + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name); + + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'coverage') { + continue; + } + walk(fullPath); + } else if (entry.isFile()) { + const ext = path.extname(entry.name); + if (!SOURCE_EXTENSIONS.includes(ext)) { + continue; + } + + if (excludeTests && (entry.name.includes('.test.') || entry.name.includes('.spec.'))) { + continue; + } + + files.push(fullPath); + } + } + } + + walk(dir); + return files; +} + +function extractUrls(filePath) { + const content = fs.readFileSync(filePath, 'utf-8'); + const matches = content.match(MATTERMOST_URL_PATTERN) || []; + + return matches.map((url) => { + let cleanUrl = url; + cleanUrl = cleanUrl.replace(/[',;)}\]]+$/, ''); + cleanUrl = cleanUrl.replace(/\\n$/, ''); + return cleanUrl; + }); +} + +function findAllMattermostUrls(rootDir, excludeTests = true) { + const urlMap = new Map(); + + for (const dir of DIRECTORIES_TO_SCAN) { + const fullDir = path.join(rootDir, dir); + const files = getAllSourceFiles(fullDir, excludeTests); + + for (const file of files) { + const urls = extractUrls(file); + for (const url of urls) { + if (!urlMap.has(url)) { + urlMap.set(url, []); + } + urlMap.get(url).push(path.relative(rootDir, file)); + } + } + } + + return urlMap; +} + +function isValidPermalink(url) { + return PERMALINK_PATTERN.test(url) || + ROOT_DOMAIN_PATTERN.test(url) || + PUSH_SERVER_PATTERN.test(url); +} + +function findNonPermalinkUrls(urlMap) { + const invalid = []; + for (const [url, files] of urlMap) { + if (!isValidPermalink(url)) { + invalid.push({url, files}); + } + } + return invalid; +} + +const FETCH_HEADERS = { + 'User-Agent': 'Mozilla/5.0 (compatible; MattermostLinkChecker/1.0; +https://github.com/mattermost/mattermost)', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', +}; + +const MAX_REDIRECTS = 10; + +async function checkUrl(url, retries = 2) { + for (let attempt = 0; attempt <= retries; attempt++) { + try { + return await checkUrlWithRedirects(url); + } catch (error) { + if (attempt === retries) { + return { + url, + status: 0, + ok: false, + error: error.message, + }; + } + await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1))); + } + } +} + +async function checkUrlWithRedirects(originalUrl) { + let currentUrl = originalUrl; + + for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) { + const response = await fetch(currentUrl, { + method: 'HEAD', + redirect: 'manual', + headers: FETCH_HEADERS, + signal: AbortSignal.timeout(10000), + }); + + if (response.status === 405 || response.status === 403) { + const getResponse = await fetch(currentUrl, { + method: 'GET', + redirect: 'manual', + headers: FETCH_HEADERS, + signal: AbortSignal.timeout(10000), + }); + return processResponse(originalUrl, getResponse); + } + + const result = processResponse(originalUrl, response); + + if (!result.redirect) { + return result; + } + + const location = response.headers.get('location'); + if (!location) { + return { + url: originalUrl, + status: response.status, + ok: false, + error: 'Redirect without Location header', + }; + } + + currentUrl = new URL(location, currentUrl).href; + } + + return { + url: originalUrl, + status: 0, + ok: false, + error: 'Too many redirects', + }; +} + +function processResponse(originalUrl, response) { + const {status} = response; + const cfHeader = response.headers.get('cf-mitigated'); + const isRedirect = status >= 300 && status < 400; + + if (cfHeader && (isRedirect || status === 403 || status === 503)) { + return {url: originalUrl, status, ok: true}; + } + + if (status === 301) { + return {url: originalUrl, status, ok: true}; + } + + if (isRedirect) { + return {url: originalUrl, status, redirect: true}; + } + + if (response.ok) { + return {url: originalUrl, status, ok: true}; + } + + return {url: originalUrl, status, ok: false}; +} + +function shouldSkipUrlCheck(url) { + return PUSH_SERVER_PATTERN.test(url); +} + +async function checkUrls(urls, concurrency = 5, silentProgress = false) { + const results = []; + const urlList = Array.from(urls.keys()).filter((url) => !shouldSkipUrlCheck(url)); + + for (let i = 0; i < urlList.length; i += concurrency) { + const batch = urlList.slice(i, i + concurrency); + const batchResults = await Promise.all(batch.map((url) => checkUrl(url))); + results.push(...batchResults); + + if (!silentProgress) { + const completed = Math.min(i + concurrency, urlList.length); + process.stderr.write(`\rChecking URLs: ${completed}/${urlList.length}`); + } + } + if (!silentProgress) { + process.stderr.write('\n'); + } + + return results; +} + +function printResults(results, urlMap, nonPermalinkUrls) { + const broken = results.filter((r) => !r.ok); + const working = results.filter((r) => r.ok); + + console.log('\n' + chalk.bold('=== External Link Check Results ===\n')); + + console.log(chalk.green(`✓ ${working.length} URLs are accessible`)); + if (broken.length > 0) { + console.log(chalk.red(`✗ ${broken.length} URLs are broken`)); + } + if (nonPermalinkUrls.length > 0) { + console.log(chalk.yellow(`⚠ ${nonPermalinkUrls.length} URLs are not using permalink format (warning)`)); + } + console.log(); + + if (broken.length > 0) { + console.log(chalk.red.bold('Broken URLs:\n')); + for (const result of broken) { + const statusText = result.error ? `Error: ${result.error}` : `HTTP ${result.status}`; + console.log(chalk.red(` ${result.url}`)); + console.log(chalk.gray(` Status: ${statusText}`)); + console.log(chalk.gray(` Found in:`)); + for (const file of urlMap.get(result.url)) { + console.log(chalk.gray(` - ${file}`)); + } + console.log(); + } + } + + if (nonPermalinkUrls.length > 0) { + console.log(chalk.yellow.bold('URLs not using permalink format (warning):\n')); + console.log(chalk.gray(' All mattermost.com links should route via https://mattermost.com/pl/\n')); + for (const item of nonPermalinkUrls) { + console.log(chalk.yellow(` ${item.url}`)); + console.log(chalk.gray(` Found in:`)); + for (const file of item.files) { + console.log(chalk.gray(` - ${file}`)); + } + console.log(); + } + } + + if (broken.length === 0 && nonPermalinkUrls.length === 0) { + console.log(chalk.green.bold(`✓ All URLs are valid and accessible\n`)); + } + + return broken.length > 0 ? 1 : 0; +} + +function generateMarkdownSummary(results, urlMap, nonPermalinkUrls) { + const broken = results.filter((r) => !r.ok); + const working = results.filter((r) => r.ok); + + const lines = []; + + lines.push('## External Link Check Results\n'); + + if (broken.length === 0 && nonPermalinkUrls.length === 0) { + lines.push(`✅ **All ${working.length} mattermost.com URLs are valid and accessible**\n`); + return lines.join('\n'); + } + + lines.push(`| Status | Count |`); + lines.push(`|--------|-------|`); + lines.push(`| ✅ Working | ${working.length} |`); + if (broken.length > 0) { + lines.push(`| ❌ Broken | ${broken.length} |`); + } + if (nonPermalinkUrls.length > 0) { + lines.push(`| ⚠️ Missing /pl/ prefix (warning) | ${nonPermalinkUrls.length} |`); + } + lines.push(''); + + if (broken.length > 0) { + lines.push('### Broken URLs\n'); + lines.push('| URL | Status | Files |'); + lines.push('|-----|--------|-------|'); + + for (const result of broken) { + const statusText = result.error ? `Error: ${result.error}` : `HTTP ${result.status}`; + const files = urlMap.get(result.url).map((f) => `\`${f}\``).join(', '); + lines.push(`| ${result.url} | ${statusText} | ${files} |`); + } + lines.push(''); + } + + if (nonPermalinkUrls.length > 0) { + lines.push('### ⚠️ URLs Missing Permalink Format (warning)\n'); + lines.push('> All mattermost.com links should route via `https://mattermost.com/pl/`\n'); + lines.push('
\nShow URLs\n'); + lines.push('| URL | Files |'); + lines.push('|-----|-------|'); + + for (const item of nonPermalinkUrls) { + const files = item.files.map((f) => `\`${f}\``).join(', '); + lines.push(`| ${item.url} | ${files} |`); + } + lines.push('\n
'); + lines.push(''); + } + + return lines.join('\n'); +} + +async function main() { + const args = process.argv.slice(2); + const includeTests = args.includes('--include-tests'); + const jsonOutput = args.includes('--json'); + const markdownOutput = args.includes('--markdown'); + + const rootDir = process.cwd(); + + if (!markdownOutput) { + console.log(chalk.inverse.bold(' Checking mattermost.com links in webapp... ') + '\n'); + + if (includeTests) { + console.log(chalk.yellow('Including test files in scan\n')); + } + } + + const urlMap = findAllMattermostUrls(rootDir, !includeTests); + + if (!markdownOutput) { + console.log(`Found ${chalk.bold(urlMap.size)} unique mattermost.com URLs\n`); + } + + if (urlMap.size === 0) { + if (markdownOutput) { + console.log('## External Link Check Results\n\n⚠️ No URLs found to check'); + } else { + console.log(chalk.yellow('No URLs found to check')); + } + return 0; + } + + const nonPermalinkUrls = findNonPermalinkUrls(urlMap); + const results = await checkUrls(urlMap, 5, markdownOutput); + const brokenCount = results.filter((r) => !r.ok).length; + + if (markdownOutput) { + console.log(generateMarkdownSummary(results, urlMap, nonPermalinkUrls)); + return brokenCount > 0 ? 1 : 0; + } + + if (jsonOutput) { + const output = { + total: results.length, + working: results.filter((r) => r.ok).length, + broken: results.filter((r) => !r.ok).map((r) => ({ + url: r.url, + status: r.status, + error: r.error, + files: urlMap.get(r.url), + })), + nonPermalink: nonPermalinkUrls, + }; + console.log(JSON.stringify(output, null, 2)); + return brokenCount > 0 ? 1 : 0; + } + + return printResults(results, urlMap, nonPermalinkUrls); +} + +main().then((exitCode) => { + process.exitCode = exitCode; +}).catch((error) => { + console.error(chalk.red('Error:'), error); + process.exitCode = 1; +});