Files
native/src/lib/open-url.ts
T
Linus Rath 455608f9f0 fix: translate push setup errors and the open-link prompt
The Notifications pane shows a push setup failure as "<phase>: <message>"
with a translated phase but an English message, and the open-link
confirmation and the new-mail notification title were English too.
They now go through the locale store's t() (translate() in the
background task), with the technical detail passed as an argument.
2026-09-24 20:49:55 +02:00

77 lines
2.8 KiB
TypeScript

// Guarded `Linking.openURL` for URIs that come from server data (contact
// cards, calendar invitations, mail bodies). An unvalidated scheme is an
// attack surface: `intent://…#Intent;component=…;end` launches arbitrary
// exported activities on Android, `file://`/`content://` reach local files,
// third-party deep links do whatever the receiving app does. Only the
// schemes a mail client legitimately hands off are allowed.
import { Alert, Linking } from 'react-native';
import { t } from '../stores/locale-store';
const ALLOWED_SCHEMES = new Set(['https', 'http', 'mailto', 'tel', 'sms', 'geo', 'webcal', 'webcals']);
export function schemeOf(uri: string): string | null {
const m = /^\s*([a-z][a-z0-9+.-]*):/i.exec(uri);
return m ? m[1].toLowerCase() : null;
}
/** True when `uri` uses a scheme the app may hand to the OS. */
export function isSafeExternalUrl(uri: string | null | undefined): uri is string {
if (!uri) return false;
const scheme = schemeOf(uri);
if (!scheme) return false;
if (!ALLOWED_SCHEMES.has(scheme)) return false;
// Reject anything with control characters - header/URL injection bait.
// eslint-disable-next-line no-control-regex
return !/[-]/.test(uri);
}
export function hostOf(uri: string): string {
const m = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]+)/i.exec(uri);
return m ? m[1] : '';
}
export interface OpenExternalOptions {
/**
* Ask before leaving the app, showing the destination host. Use for links
* that arrived in someone else's data (invitation video links, vCard URLs).
*/
confirm?: boolean;
confirmTitle?: string;
confirmMessage?: (host: string) => string;
cancelLabel?: string;
openLabel?: string;
}
/**
* Open `uri` with the OS handler when its scheme is allowed. Resolves to
* false when the URI was refused or the user cancelled.
*/
export async function openExternalUrl(uri: string, opts: OpenExternalOptions = {}): Promise<boolean> {
const value = uri.trim();
if (!isSafeExternalUrl(value)) return false;
if (opts.confirm) {
const host = hostOf(value) || value;
const ok = await new Promise<boolean>((resolve) => {
Alert.alert(
opts.confirmTitle ?? t('common.open_link_title', 'Open link?'),
opts.confirmMessage
? opts.confirmMessage(host)
: t('common.open_link_message', 'This will open {host} outside the app.', { host }),
[
{ text: opts.cancelLabel ?? t('common.cancel', 'Cancel'), style: 'cancel', onPress: () => resolve(false) },
{ text: opts.openLabel ?? t('common.open', 'Open'), onPress: () => resolve(true) },
],
{ cancelable: true, onDismiss: () => resolve(false) },
);
});
if (!ok) return false;
}
try {
await Linking.openURL(value);
return true;
} catch {
return false;
}
}