mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
343 lines
11 KiB
TypeScript
343 lines
11 KiB
TypeScript
import { ORCA_BROWSER_BLANK_URL } from './constants'
|
|
|
|
const LOCAL_ADDRESS_PATTERN =
|
|
/^(?:localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[[0-9a-f:]+\])(?::\d+)?(?:[/?#].*)?$/i
|
|
|
|
// Why: bare words like "react hooks" should trigger a search, but inputs that
|
|
// look like domain names ("example.com", "foo.bar/path") should navigate directly.
|
|
// A single-word input containing a dot with a valid TLD-like suffix is treated as
|
|
// a URL attempt, not a search query.
|
|
const LOOKS_LIKE_URL_PATTERN = /^[^\s]+\.[a-z]{2,}(\/.*)?$/i
|
|
const WINDOWS_ABSOLUTE_PATH_PATTERN = /^[A-Za-z]:[\\/].*$/
|
|
const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\s\\/]+[\\/][^\\/]+(?:[\\/].*)?$/
|
|
const UNIX_ABSOLUTE_PATH_PATTERN = /^\/.*$/
|
|
|
|
export type SearchEngine = 'google' | 'duckduckgo' | 'bing' | 'kagi'
|
|
|
|
export type SearchUrlOptions = {
|
|
kagiSessionLink?: string | null
|
|
}
|
|
|
|
export const SEARCH_ENGINE_LABELS: Record<SearchEngine, string> = {
|
|
google: 'Google',
|
|
duckduckgo: 'DuckDuckGo',
|
|
bing: 'Bing',
|
|
kagi: 'Kagi'
|
|
}
|
|
|
|
const SEARCH_ENGINE_URLS: Record<SearchEngine, string> = {
|
|
google: 'https://www.google.com/search?q=',
|
|
duckduckgo: 'https://duckduckgo.com/?q=',
|
|
bing: 'https://www.bing.com/search?q=',
|
|
kagi: 'https://kagi.com/search?q='
|
|
}
|
|
|
|
export const DEFAULT_SEARCH_ENGINE: SearchEngine = 'google'
|
|
|
|
export function classifySchemeLessLocalDevAddress(rawInput: string): URL | null {
|
|
const trimmed = rawInput.trim()
|
|
if (!LOCAL_ADDRESS_PATTERN.test(trimmed)) {
|
|
return null
|
|
}
|
|
try {
|
|
return new URL(`http://${trimmed}`)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function normalizeCertificateHostname(hostname: string): string {
|
|
const lower = hostname.trim().toLowerCase()
|
|
const unbracketed = lower.startsWith('[') && lower.endsWith(']') ? lower.slice(1, -1) : lower
|
|
return unbracketed.endsWith('.') ? unbracketed.slice(0, -1) : unbracketed
|
|
}
|
|
|
|
function isValidDnsName(name: string): boolean {
|
|
if (name.length === 0 || name.length > 253) {
|
|
return false
|
|
}
|
|
return name
|
|
.split('.')
|
|
.every(
|
|
(label) =>
|
|
label.length > 0 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)
|
|
)
|
|
}
|
|
|
|
function isIpv4Loopback(hostname: string): boolean {
|
|
const octets = hostname.split('.')
|
|
if (octets.length !== 4 || octets.some((octet) => !/^\d{1,3}$/.test(octet))) {
|
|
return false
|
|
}
|
|
const values = octets.map(Number)
|
|
return (
|
|
values[0] === 127 &&
|
|
values.every((value, index) => value >= 0 && value <= 255 && octets[index] === String(value))
|
|
)
|
|
}
|
|
|
|
export function isEligibleLocalCertificateHost(hostname: string): boolean {
|
|
const normalized = normalizeCertificateHostname(hostname)
|
|
if (normalized === '::1' || isIpv4Loopback(normalized)) {
|
|
return true
|
|
}
|
|
if (!isValidDnsName(normalized)) {
|
|
return false
|
|
}
|
|
return normalized === 'localhost' || normalized.endsWith('.localhost')
|
|
}
|
|
|
|
function isWildcardBindHost(hostname: string): boolean {
|
|
const normalized = normalizeCertificateHostname(hostname)
|
|
return normalized === '0.0.0.0' || normalized === '::'
|
|
}
|
|
|
|
export function toHttpsRecoveryUrl(rawUrl: string): string | null {
|
|
try {
|
|
const parsed = new URL(rawUrl)
|
|
if (parsed.protocol !== 'http:' || !isEligibleLocalCertificateHost(parsed.hostname)) {
|
|
return null
|
|
}
|
|
parsed.protocol = 'https:'
|
|
return parsed.toString()
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function toSecureCertificateEndpoint(rawUrl: string): string | null {
|
|
try {
|
|
const parsed = new URL(rawUrl)
|
|
if (parsed.protocol !== 'https:' && parsed.protocol !== 'wss:') {
|
|
return null
|
|
}
|
|
const normalizedHostname = normalizeCertificateHostname(parsed.hostname)
|
|
if (!normalizedHostname) {
|
|
return null
|
|
}
|
|
const endpointHost = normalizedHostname.includes(':')
|
|
? `[${normalizedHostname}]`
|
|
: normalizedHostname
|
|
return `https://${endpointHost}:${parsed.port || '443'}`
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Why: a remote-owned browser page's loopback URL is unreachable from the
|
|
// desktop system browser, so Open Externally is offered only for publicly
|
|
// reachable (non-loopback) failure URLs.
|
|
export function resolveRemoteFailureExternalUrl(rawUrl: string): string | null {
|
|
try {
|
|
const parsed = new URL(rawUrl)
|
|
if (isWildcardBindHost(parsed.hostname) || isEligibleLocalCertificateHost(parsed.hostname)) {
|
|
return null
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
return normalizeExternalBrowserUrl(rawUrl)
|
|
}
|
|
|
|
export function normalizeKagiSessionLink(rawLink: string): string | null {
|
|
const trimmed = rawLink.trim()
|
|
if (!trimmed) {
|
|
return null
|
|
}
|
|
try {
|
|
const parsed = new URL(trimmed)
|
|
const hostname = parsed.hostname.toLowerCase()
|
|
const token = parsed.searchParams.get('token')?.trim()
|
|
// Why: reject user-info credentials and non-default ports so a hostile
|
|
// paste cannot smuggle alternate auth or a redirected origin into the
|
|
// saved session URL. Accept /search and /search/ since Kagi's settings
|
|
// page emits both.
|
|
const pathOk = parsed.pathname === '/search' || parsed.pathname === '/search/'
|
|
if (
|
|
parsed.protocol !== 'https:' ||
|
|
(hostname !== 'kagi.com' && hostname !== 'www.kagi.com') ||
|
|
!pathOk ||
|
|
parsed.username !== '' ||
|
|
parsed.password !== '' ||
|
|
parsed.port !== '' ||
|
|
!token
|
|
) {
|
|
return null
|
|
}
|
|
parsed.searchParams.delete('q')
|
|
// Why: collapse any duplicate token params so we don't echo two bearer
|
|
// values back to Kagi on every search.
|
|
parsed.searchParams.set('token', token)
|
|
parsed.hash = ''
|
|
return parsed.toString()
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function redactKagiSessionToken(rawUrl: string): string {
|
|
try {
|
|
const parsed = new URL(rawUrl)
|
|
const hostname = parsed.hostname.toLowerCase()
|
|
if (
|
|
parsed.protocol === 'https:' &&
|
|
(hostname === 'kagi.com' || hostname === 'www.kagi.com') &&
|
|
(parsed.pathname === '/search' || parsed.pathname === '/search/') &&
|
|
parsed.searchParams.has('token')
|
|
) {
|
|
// Why: Kagi private-session links carry an account bearer token. Strip it
|
|
// before URLs reach display, history, or persisted browser-tab state.
|
|
parsed.searchParams.delete('token')
|
|
return parsed.toString()
|
|
}
|
|
} catch {
|
|
// Keep non-URL inputs unchanged.
|
|
}
|
|
return rawUrl
|
|
}
|
|
|
|
function buildKagiSessionSearchUrl(
|
|
query: string,
|
|
sessionLink: string | null | undefined
|
|
): string | null {
|
|
if (!sessionLink) {
|
|
return null
|
|
}
|
|
const normalized = normalizeKagiSessionLink(sessionLink)
|
|
if (!normalized) {
|
|
return null
|
|
}
|
|
const parsed = new URL(normalized)
|
|
parsed.searchParams.set('q', query)
|
|
return parsed.toString()
|
|
}
|
|
|
|
export function buildSearchUrl(
|
|
query: string,
|
|
engine: SearchEngine = DEFAULT_SEARCH_ENGINE,
|
|
options: SearchUrlOptions = {}
|
|
): string {
|
|
if (engine === 'kagi') {
|
|
const sessionSearchUrl = buildKagiSessionSearchUrl(query, options.kagiSessionLink)
|
|
if (sessionSearchUrl) {
|
|
return sessionSearchUrl
|
|
}
|
|
}
|
|
return `${SEARCH_ENGINE_URLS[engine]}${encodeURIComponent(query.toWellFormed())}`
|
|
}
|
|
|
|
export function looksLikeSearchQuery(input: string): boolean {
|
|
if (input.includes(' ')) {
|
|
return true
|
|
}
|
|
if (LOOKS_LIKE_URL_PATTERN.test(input)) {
|
|
return false
|
|
}
|
|
if (input.includes('.') || input.includes(':')) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
/** True for input shaped like an absolute filesystem path (POSIX, Windows drive, or UNC). */
|
|
export function isAbsoluteFilesystemPathInput(input: string): boolean {
|
|
return (
|
|
UNIX_ABSOLUTE_PATH_PATTERN.test(input) ||
|
|
WINDOWS_ABSOLUTE_PATH_PATTERN.test(input) ||
|
|
WINDOWS_UNC_PATH_PATTERN.test(input)
|
|
)
|
|
}
|
|
|
|
function absolutePathToFileUrl(filePath: string): string {
|
|
const normalizedPath = filePath.replaceAll('\\', '/')
|
|
const segments = normalizedPath.split('/').map((segment, index) => {
|
|
if (index === 0 && /^[A-Za-z]:$/.test(segment)) {
|
|
return segment
|
|
}
|
|
return encodeURIComponent(segment)
|
|
})
|
|
return normalizedPath.startsWith('/')
|
|
? `file://${segments.join('/')}`
|
|
: `file:///${segments.join('/')}`
|
|
}
|
|
|
|
function windowsUncPathToFileUrl(filePath: string): string {
|
|
const normalizedPath = filePath.replaceAll('\\', '/').replace(/^\/+/, '')
|
|
const [host, ...pathSegments] = normalizedPath.split('/')
|
|
return `file://${host}/${pathSegments.map(encodeURIComponent).join('/')}`
|
|
}
|
|
|
|
export function normalizeBrowserNavigationUrl(
|
|
rawUrl: string,
|
|
searchEngine?: SearchEngine | null,
|
|
options: SearchUrlOptions = {}
|
|
): string | null {
|
|
const trimmed = rawUrl.trim()
|
|
if (trimmed.length === 0 || trimmed === 'about:blank' || trimmed === ORCA_BROWSER_BLANK_URL) {
|
|
return ORCA_BROWSER_BLANK_URL
|
|
}
|
|
|
|
const localDevAddress = classifySchemeLessLocalDevAddress(trimmed)
|
|
if (localDevAddress) {
|
|
return localDevAddress.toString()
|
|
}
|
|
|
|
if (WINDOWS_UNC_PATH_PATTERN.test(trimmed)) {
|
|
return windowsUncPathToFileUrl(trimmed)
|
|
}
|
|
|
|
if (UNIX_ABSOLUTE_PATH_PATTERN.test(trimmed) || WINDOWS_ABSOLUTE_PATH_PATTERN.test(trimmed)) {
|
|
return absolutePathToFileUrl(trimmed)
|
|
}
|
|
|
|
try {
|
|
const parsed = new URL(trimmed)
|
|
// Why: file:// is allowed so the browser pane can render local files the
|
|
// user already has access to via the editor (e.g. "Open Preview to the
|
|
// Side" on an HTML file). The guest webview is still sandboxed
|
|
// (nodeIntegration off, contextIsolation on, webSecurity on; see
|
|
// createMainWindow.ts will-attach-webview), so the loaded page cannot
|
|
// escalate privileges. Other non-web schemes (javascript:, arbitrary
|
|
// data: URIs) remain rejected.
|
|
return parsed.protocol === 'http:' ||
|
|
parsed.protocol === 'https:' ||
|
|
parsed.protocol === 'file:'
|
|
? parsed.toString()
|
|
: null
|
|
} catch {
|
|
// Why: search fallback is opt-in. The main process calls this function for
|
|
// URL validation (will-attach-webview, will-navigate) where non-URL text
|
|
// must be rejected, not converted to a search query. Only the address bar
|
|
// passes a search engine to enable the fallback.
|
|
const searchEnabled = searchEngine !== undefined
|
|
try {
|
|
const withScheme = new URL(`https://${trimmed}`)
|
|
if (!searchEnabled || !looksLikeSearchQuery(trimmed)) {
|
|
return withScheme.toString()
|
|
}
|
|
} catch {
|
|
// Not a valid URL even with https:// prefix
|
|
}
|
|
|
|
if (!searchEnabled) {
|
|
return null
|
|
}
|
|
return buildSearchUrl(trimmed, searchEngine ?? DEFAULT_SEARCH_ENGINE, options)
|
|
}
|
|
}
|
|
|
|
export function normalizeExternalBrowserUrl(rawUrl: string): string | null {
|
|
const normalized = normalizeBrowserNavigationUrl(rawUrl)
|
|
if (normalized === null || normalized === ORCA_BROWSER_BLANK_URL) {
|
|
return null
|
|
}
|
|
// Why: external-link opening (shell.openExternal, will-navigate) must only
|
|
// hand off http(s) targets to the OS. file:// is allowed for the in-app
|
|
// browser pane (local HTML preview), but forwarding it to openExternal
|
|
// would let a remote page smuggle arbitrary file paths into Finder/Explorer.
|
|
if (normalized.startsWith('file:')) {
|
|
return null
|
|
}
|
|
return normalized
|
|
}
|