1import type { BrowserSearchEngine } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
3
4import { isLocalLoopbackHost } from "./hostClassification.ts";
5
6const TAB_ID_PREFIX = "tab_";
7let nextPreviewTabSequence = 0;
8
9export function previewPdfAssetUrl(rawUrl: string, httpBaseUrl: string | null): string | null {
10 if (httpBaseUrl === null) return null;
11 try {
12 const url = new URL(rawUrl);
13 const baseUrl = new URL(httpBaseUrl);
14 if (
15 (url.protocol !== "http:" && url.protocol !== "https:") ||
16 !url.pathname.startsWith("/api/assets/") ||
17 !/\.pdf$/i.test(url.pathname)
18 ) {
19 return null;
20 }
21 if (
22 url.origin !== baseUrl.origin &&
23 !isLocalLoopbackHost(url.hostname) &&
24 url.hostname !== "0.0.0.0"
25 ) {
26 return url.href;
27 }
28 // The browser may run on the host's loopback address. Fetch signed bytes
29 // through this client's environment connection, including remote routes.
30 return new URL(`${url.pathname}${url.search}${url.hash}`, baseUrl).href;
31 } catch {
32 return null;
33 }
34}
35
36export function newPreviewTabId(): string {
37 nextPreviewTabSequence += 1;
38 return `${TAB_ID_PREFIX}${nextPreviewTabSequence.toString(36)}`;
39}
40
41const LOOPBACK_HOSTS: ReadonlySet<string> = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]);
42
43export const LSOF_LOCAL_HOST_TOKENS: ReadonlySet<string> = new Set([
44 ...LOOPBACK_HOSTS,
45 "*",
46 "[::]",
47 "[::1]",
48]);
49
50const LOOPBACK_PREFIX_PATTERN = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\])(?::|\/|$)/i;
51
52export function isLoopbackHost(host: string): boolean {
53 if (LOOPBACK_HOSTS.has(host)) return true;
54 if (host === "[::1]") return true;
55 return false;
56}
57
58export class PreviewUrlNormalizationError extends Schema.TaggedError<PreviewUrlNormalizationError>()(
59 "PreviewUrlNormalizationError",
60 {
61 inputLength: Schema.Number,
62 reason: Schema.Literals(["empty", "parse", "unsupported-protocol"]),
63 protocol: Schema.optional(Schema.String),
64 cause: Schema.optional(Schema.Defect()),
65 },
66) {
67 override get message(): string {
68 const protocol = this.protocol === undefined ? "" : `: ${this.protocol}`;
69 return `Invalid preview URL (${this.reason}${protocol}; input length ${this.inputLength}). Use an http(s) URL or a schemeless host such as localhost:5173. With preview_open, omit url when creating a new tab to open a blank tab.`;
70 }
71}
72
73export const isPreviewUrlNormalizationError = Schema.is(PreviewUrlNormalizationError);
74
75function previewUrlProtocol(rawUrl: string): string | undefined {
76 return /^([A-Za-z][A-Za-z\d+.-]*):/.exec(rawUrl)?.[1]?.toLowerCase().concat(":");
77}
78
79const ADDRESS_HOST_PATTERN = /^(?:localhost|\d{1,3}(?:\.\d{1,3}){3}|\[[\da-f:.]+\])$/i;
80const BARE_HOST_PORT_PATTERN = /^[^/?#:@]+:\d+(?:[/?#]|$)/;
81const URL_SCHEME_WITH_AUTHORITY_PATTERN = /^[a-z][a-z\d+.-]*:\/\//i;
82const KNOWN_NON_WEB_SCHEMES: ReadonlySet<string> = new Set([
83 "about:",
84 "blob:",
85 "chrome:",
86 "data:",
87 "file:",
88 "ftp:",
89 "javascript:",
90 "mailto:",
91 "sms:",
92 "ssh:",
93 "tel:",
94 "view-source:",
95 "ws:",
96 "wss:",
97]);
98
99/** Address bar search URL templates; `%s` is replaced with the encoded query. */
100const BROWSER_SEARCH_URL_TEMPLATES = {
101 duckduckgo: "https://duckduckgo.com/?q=%s",
102 google: "https://www.google.com/search?q=%s",
103 bing: "https://www.bing.com/search?q=%s",
104 brave: "https://search.brave.com/search?q=%s",
105 kagi: "https://kagi.com/search?q=%s",
106 ecosia: "https://www.ecosia.org/search?q=%s",
107} satisfies Record<Exclude<BrowserSearchEngine, "custom">, string>;
108
109function fillSearchUrlTemplate(template: string, query: string): string {
110 return template.replaceAll("%s", encodeURIComponent(query));
111}
112
113/**
114 * Whether a custom search URL is an http(s) URL with `%s` where the query goes.
115 * `%s` in the host or credentials is rejected: the query would never reach a
116 * search page, and a query with spaces would make the host unreachable.
117 */
118export function isValidSearchUrlTemplate(template: string): boolean {
119 if (!template.includes("%s")) return false;
120 try {
121 // Two queries land on different authorities only when `%s` sits before the path.
122 const authority = (url: URL) => `${url.username}:${url.password}@${url.host}`;
123 const url = new URL(fillSearchUrlTemplate(template, "a"));
124 const other = new URL(fillSearchUrlTemplate(template, "b"));
125 return (
126 (url.protocol === "http:" || url.protocol === "https:") && authority(url) === authority(other)
127 );
128 } catch {
129 return false;
130 }
131}
132
133/**
134 * The search URL template for the chosen engine. An unusable custom template
135 * falls back to DuckDuckGo so a query typed in the address bar always searches.
136 */
137export function resolveSearchUrlTemplate(settings: {
138 readonly browserSearchEngine: BrowserSearchEngine;
139 readonly browserCustomSearchUrl: string;
140}): string {
141 if (settings.browserSearchEngine !== "custom") {
142 return BROWSER_SEARCH_URL_TEMPLATES[settings.browserSearchEngine];
143 }
144 const custom = settings.browserCustomSearchUrl.trim();
145 return isValidSearchUrlTemplate(custom) ? custom : BROWSER_SEARCH_URL_TEMPLATES.duckduckgo;
146}
147
148export function resolveAddressBarInput(
149 rawInput: string,
150 searchUrlTemplate: string = BROWSER_SEARCH_URL_TEMPLATES.duckduckgo,
151): string {
152 const trimmed = rawInput.trim();
153 if (URL_SCHEME_WITH_AUTHORITY_PATTERN.test(trimmed) || trimmed.length === 0)
154 return normalizePreviewUrl(trimmed);
155 if (!/\s/.test(trimmed)) {
156 // Reject explicit schemes before URL normalization can misread mailto as bare-host credentials.
157 const protocol = previewUrlProtocol(trimmed);
158 if (
159 protocol !== undefined &&
160 (KNOWN_NON_WEB_SCHEMES.has(protocol) || !BARE_HOST_PORT_PATTERN.test(trimmed))
161 ) {
162 if (protocol !== "http:" && protocol !== "https:") {
163 throw new PreviewUrlNormalizationError({
164 inputLength: rawInput.length,
165 reason: "unsupported-protocol",
166 protocol,
167 });
168 }
169 return normalizePreviewUrl(trimmed);
170 }
171 const authority = trimmed.split(/[/?#]/, 1)[0] ?? "";
172 const host = authority.replace(/^[^@]*@/, "").replace(/:\d+$/, "");
173 const hasPort = host.length < authority.replace(/^[^@]*@/, "").length;
174 if (hasPort || ADDRESS_HOST_PATTERN.test(host) || /^[^.]+(?:\.[^.]+)+\.?$/.test(host)) {
175 try {
176 return normalizePreviewUrl(trimmed);
177 } catch {}
178 }
179 }
180 return fillSearchUrlTemplate(searchUrlTemplate, trimmed);
181}
182
183export function normalizePreviewUrl(rawUrl: string): string {
184 const trimmed = rawUrl.trim();
185 if (trimmed.length === 0) {
186 throw new PreviewUrlNormalizationError({ inputLength: rawUrl.length, reason: "empty" });
187 }
188 const explicitProtocol = previewUrlProtocol(trimmed);
189 if (explicitProtocol !== undefined && KNOWN_NON_WEB_SCHEMES.has(explicitProtocol)) {
190 throw new PreviewUrlNormalizationError({
191 inputLength: rawUrl.length,
192 reason: "unsupported-protocol",
193 protocol: explicitProtocol,
194 });
195 }
196 const useHttp = LOOPBACK_PREFIX_PATTERN.test(trimmed);
197 const candidate = URL_SCHEME_WITH_AUTHORITY_PATTERN.test(trimmed)
198 ? trimmed
199 : `${useHttp ? "http" : "https"}://${trimmed}`;
200 let parsed: URL;
201 try {
202 parsed = new URL(candidate);
203 } catch (cause) {
204 throw new PreviewUrlNormalizationError({
205 inputLength: rawUrl.length,
206 reason: "parse",
207 protocol: previewUrlProtocol(candidate),
208 cause,
209 });
210 }
211 if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
212 throw new PreviewUrlNormalizationError({
213 inputLength: rawUrl.length,
214 reason: "unsupported-protocol",
215 protocol: parsed.protocol,
216 });
217 }
218 return parsed.href;
219}