mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(links): warn on unhandled middle clicks and desktop links (#3311)
This commit is contained in:
1 parent
ebf91ccc76
commit
bef20cba85
9 files changed
+71
-69
No files matched your search
@@ -366,7 +366,6 @@ export const AttachmentGridItem: FC<AttachmentGridItemProps> = observer(
|
||||
onClick={handleClick}
|
||||
onMouseEnter={scheduleViewerWarm}
|
||||
onMouseLeave={cancelViewerWarm}
|
||||
onMouseDown={openInBrowser.onMouseDown}
|
||||
onAuxClick={openInBrowser.onAuxClick}
|
||||
onKeyDown={handleClick}
|
||||
aria-label={ariaLabel}
|
||||
|
||||
@@ -343,7 +343,6 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
|
||||
className={styles.imagePreviewHandler}
|
||||
aria-label={ariaLabel}
|
||||
onClick={openImagePreview}
|
||||
onMouseDown={openInBrowser.onMouseDown}
|
||||
onAuxClick={openInBrowser.onAuxClick}
|
||||
onKeyDown={openImagePreview}
|
||||
onMouseEnter={onViewerWarmEnter}
|
||||
|
||||
@@ -233,7 +233,6 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
|
||||
className={styles.imagePreviewHandler}
|
||||
aria-label={i18n._(OPEN_IMAGE_IN_FULL_VIEW_DESCRIPTOR)}
|
||||
onClick={openImagePreview}
|
||||
onMouseDown={openInBrowser.onMouseDown}
|
||||
onAuxClick={openInBrowser.onAuxClick}
|
||||
onKeyDown={openImagePreview}
|
||||
onMouseEnter={onViewerWarmEnter}
|
||||
|
||||
@@ -31,11 +31,9 @@ import {
|
||||
type RendererProps,
|
||||
} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
|
||||
import {ExternalLinkWarningModal} from '@app/features/messaging/components/modals/ExternalLinkWarningModal';
|
||||
import {
|
||||
MIDDLE_MOUSE_BUTTON,
|
||||
useOpenInBrowserOnMiddleClick,
|
||||
} from '@app/features/messaging/hooks/useOpenInBrowserOnMiddleClick';
|
||||
import {useOpenInBrowserOnMiddleClick} from '@app/features/messaging/hooks/useOpenInBrowserOnMiddleClick';
|
||||
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
|
||||
import {MIDDLE_MOUSE_BUTTON} from '@app/features/messaging/utils/ExternalLinkUtils';
|
||||
import {goToMessage} from '@app/features/messaging/utils/MessageNavigator';
|
||||
import type {LinkNode} from '@app/features/messaging/utils/markdown/parser/Nodes';
|
||||
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
|
||||
|
||||
@@ -1,27 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {openExternalUrlWithWarning} from '@app/features/messaging/utils/ExternalLinkUtils';
|
||||
import {handleExternalLinkAuxClick} from '@app/features/messaging/utils/ExternalLinkUtils';
|
||||
import type React from 'react';
|
||||
import {useMemo} from 'react';
|
||||
|
||||
export const MIDDLE_MOUSE_BUTTON = 1;
|
||||
|
||||
interface MiddleClickOpenHandlers {
|
||||
onMouseDown: (event: React.MouseEvent) => void;
|
||||
onAuxClick: (event: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function useOpenInBrowserOnMiddleClick(url: string | null | undefined, enabled = true): MiddleClickOpenHandlers {
|
||||
return useMemo<MiddleClickOpenHandlers>(
|
||||
() => ({
|
||||
onMouseDown: (event) => {
|
||||
if (event.button !== MIDDLE_MOUSE_BUTTON || !enabled || !url) return;
|
||||
},
|
||||
onAuxClick: (event) => {
|
||||
if (event.button !== MIDDLE_MOUSE_BUTTON || !enabled || !url) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openExternalUrlWithWarning(url);
|
||||
if (enabled) handleExternalLinkAuxClick(event, url);
|
||||
},
|
||||
}),
|
||||
[url, enabled],
|
||||
|
||||
@@ -2,31 +2,79 @@
|
||||
|
||||
import {openOAuthAuthorizeModalFromUrl} from '@app/features/auth/commands/OAuthAuthorizeModalCommands';
|
||||
import {ExternalLinkWarningModal} from '@app/features/messaging/components/modals/ExternalLinkWarningModal';
|
||||
import {unwrapDesktopLocalResourceURL} from '@app/features/messaging/utils/DesktopLocalResourceTarget';
|
||||
import TrustedDomain from '@app/features/trusted_domain/state/TrustedDomain';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {openExternalUrl} from '@app/features/ui/utils/NativeUtils';
|
||||
import {getSafeExternalUrl, isDesktop, openExternalUrl} from '@app/features/ui/utils/NativeUtils';
|
||||
import type React from 'react';
|
||||
|
||||
export function openExternalUrlWithWarning(url: string): void {
|
||||
if (openOAuthAuthorizeModalFromUrl(url)) {
|
||||
return;
|
||||
}
|
||||
let hostname: string | null = null;
|
||||
export const MIDDLE_MOUSE_BUTTON = 1;
|
||||
|
||||
let externalLinkInterceptorAttached = false;
|
||||
|
||||
function isTrustedExternalUrl(url: string): boolean {
|
||||
try {
|
||||
hostname = new URL(url).hostname;
|
||||
return TrustedDomain.isTrustedDomain(new URL(url).hostname);
|
||||
} catch {
|
||||
hostname = null;
|
||||
return false;
|
||||
}
|
||||
if (hostname && TrustedDomain.isTrustedDomain(hostname)) {
|
||||
void openExternalUrl(url);
|
||||
}
|
||||
|
||||
export function openUrlInBrowserWithWarning(url: string): void {
|
||||
const target = unwrapDesktopLocalResourceURL(url);
|
||||
if (isTrustedExternalUrl(target)) {
|
||||
void openExternalUrl(target);
|
||||
return;
|
||||
}
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<ExternalLinkWarningModal
|
||||
url={url}
|
||||
url={target}
|
||||
data-flx="messaging.external-link-utils.open-external-url-with-warning.external-link-warning-modal"
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
export function openExternalUrlWithWarning(url: string): void {
|
||||
if (openOAuthAuthorizeModalFromUrl(url)) {
|
||||
return;
|
||||
}
|
||||
openUrlInBrowserWithWarning(url);
|
||||
}
|
||||
|
||||
export function handleExternalLinkAuxClick(event: React.MouseEvent, url: string | null | undefined): void {
|
||||
if (event.button !== MIDDLE_MOUSE_BUTTON || !url) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openUrlInBrowserWithWarning(url);
|
||||
}
|
||||
|
||||
function findUnhandledExternalAnchorHref(event: MouseEvent): string | null {
|
||||
if (event.defaultPrevented) return null;
|
||||
const target = event.target as Element | null;
|
||||
const anchor = target?.closest?.('a[target="_blank"]');
|
||||
const href = anchor?.getAttribute('href') ?? null;
|
||||
return getSafeExternalUrl(href) ? href : null;
|
||||
}
|
||||
|
||||
export function attachExternalLinkInterceptor(): () => void {
|
||||
if (!isDesktop() || externalLinkInterceptorAttached) return () => undefined;
|
||||
const handler = (event: MouseEvent) => {
|
||||
const expectedButton = event.type === 'click' ? 0 : MIDDLE_MOUSE_BUTTON;
|
||||
if (event.button !== expectedButton) return;
|
||||
const href = findUnhandledExternalAnchorHref(event);
|
||||
if (href == null) return;
|
||||
event.preventDefault();
|
||||
openUrlInBrowserWithWarning(href);
|
||||
};
|
||||
document.addEventListener('click', handler);
|
||||
document.addEventListener('auxclick', handler);
|
||||
externalLinkInterceptorAttached = true;
|
||||
return () => {
|
||||
document.removeEventListener('click', handler);
|
||||
document.removeEventListener('auxclick', handler);
|
||||
externalLinkInterceptorAttached = false;
|
||||
};
|
||||
}
|
||||
@@ -150,8 +150,6 @@ export function supportsDesktopScreenShareAudioCapture(): boolean {
|
||||
return electronApi.platform === 'win32' || electronApi.platform === 'linux' || electronApi.platform === 'darwin';
|
||||
}
|
||||
|
||||
let externalLinkHandlerAttached = false;
|
||||
|
||||
const BLOCKED_EXTERNAL_URL_PROTOCOLS = new Set([
|
||||
'file:',
|
||||
'javascript:',
|
||||
@@ -165,7 +163,7 @@ const BLOCKED_EXTERNAL_URL_PROTOCOLS = new Set([
|
||||
]);
|
||||
const EXPLICIT_URL_PROTOCOL_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/;
|
||||
|
||||
const getSafeExternalUrl = (href: string | null): string | null => {
|
||||
export function getSafeExternalUrl(href: string | null): string | null {
|
||||
if (!href) return null;
|
||||
const trimmed = unwrapDesktopLocalResourceURL(href.trim());
|
||||
if (!EXPLICIT_URL_PROTOCOL_PATTERN.test(trimmed)) return null;
|
||||
@@ -176,7 +174,7 @@ const getSafeExternalUrl = (href: string | null): string | null => {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function navigateToExternalURL(url: string): Promise<void> {
|
||||
const safeUrl = getSafeExternalUrl(url);
|
||||
@@ -237,40 +235,6 @@ export function handleExternalLinkClick(
|
||||
void openExternalUrl(url);
|
||||
}
|
||||
|
||||
export function attachExternalLinkInterceptor() {
|
||||
if (!isDesktop() || externalLinkHandlerAttached) return () => undefined;
|
||||
const handler = (event: MouseEvent) => {
|
||||
if (event.defaultPrevented) return;
|
||||
if (event.button !== 0) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
const anchor = target?.closest?.('a[target="_blank"]') as HTMLAnchorElement | null;
|
||||
if (!anchor) return;
|
||||
const href = anchor.getAttribute('href');
|
||||
if (!getSafeExternalUrl(href)) return;
|
||||
event.preventDefault();
|
||||
void openExternalUrl(href ?? '');
|
||||
};
|
||||
const auxHandler = (event: MouseEvent) => {
|
||||
if (event.defaultPrevented) return;
|
||||
if (event.button !== 1) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
const anchor = target?.closest?.('a[target="_blank"]') as HTMLAnchorElement | null;
|
||||
if (!anchor) return;
|
||||
const href = anchor.getAttribute('href');
|
||||
if (!getSafeExternalUrl(href)) return;
|
||||
event.preventDefault();
|
||||
void openExternalUrl(href ?? '');
|
||||
};
|
||||
document.addEventListener('click', handler);
|
||||
document.addEventListener('auxclick', auxHandler);
|
||||
externalLinkHandlerAttached = true;
|
||||
return () => {
|
||||
document.removeEventListener('click', handler);
|
||||
document.removeEventListener('auxclick', auxHandler);
|
||||
externalLinkHandlerAttached = false;
|
||||
};
|
||||
}
|
||||
|
||||
export type NativeDownloadOutcome = 'success' | 'canceled' | 'checksum-mismatch' | 'failed' | 'unavailable';
|
||||
|
||||
export async function downloadWithNative(options: {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import {getCachedDateTimeFormat} from '@app/features/i18n/utils/IntlCache';
|
||||
import {SafeMarkdown} from '@app/features/messaging/components/markdown';
|
||||
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
|
||||
import {openExternalUrlWithWarning} from '@app/features/messaging/utils/ExternalLinkUtils';
|
||||
import {handleExternalLinkAuxClick, openExternalUrlWithWarning} from '@app/features/messaging/utils/ExternalLinkUtils';
|
||||
import StreamerMode from '@app/features/streamer_mode/state/StreamerMode';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import markupStyles from '@app/features/theme/styles/Markup.module.css';
|
||||
@@ -469,6 +469,7 @@ const ConnectionCard: React.FC<{
|
||||
rel="noopener noreferrer"
|
||||
className={styles.connectionCard}
|
||||
onClick={(e) => onLinkClick(e, url)}
|
||||
onAuxClick={(e) => handleExternalLinkAuxClick(e, url)}
|
||||
data-flx="user.user-profile-shared.connection-card.connection-card.link-click"
|
||||
>
|
||||
{icon}
|
||||
@@ -494,6 +495,7 @@ const ConnectionCard: React.FC<{
|
||||
rel="noopener noreferrer"
|
||||
className={styles.connectionExternalLink}
|
||||
onClick={(e) => onLinkClick(e, url)}
|
||||
onAuxClick={(e) => handleExternalLinkAuxClick(e, url)}
|
||||
data-flx="user.user-profile-shared.connection-card.connection-external-link.link-click"
|
||||
>
|
||||
<ArrowSquareOutIcon
|
||||
@@ -577,6 +579,7 @@ export const UserProfileConnections: React.FC<{
|
||||
rel="noopener noreferrer"
|
||||
className={styles.connectionCompactIcon}
|
||||
onClick={(e) => handleConnectionClick(e, url)}
|
||||
onAuxClick={(e) => handleExternalLinkAuxClick(e, url)}
|
||||
data-flx="user.user-profile-shared.user-profile-connections.connection-compact-icon.connection-click"
|
||||
>
|
||||
{connection.type === ConnectionTypes.BLUESKY ? (
|
||||
|
||||
Reference in new issue
Block a user