diff --git a/frontend/src/components/Form/LocationsFields.tsx b/frontend/src/components/Form/LocationsFields.tsx index 4240b1f986..5d1816b04e 100644 --- a/frontend/src/components/Form/LocationsFields.tsx +++ b/frontend/src/components/Form/LocationsFields.tsx @@ -2,9 +2,10 @@ import { IconSettings } from "@tabler/icons-react"; import CodeEditor from "@uiw/react-textarea-code-editor"; import cn from "classnames"; import { useFormikContext } from "formik"; -import { useState } from "react"; +import { type ClipboardEvent, useState } from "react"; import type { ProxyLocation } from "src/api/backend"; import { intl, T } from "src/locale"; +import { parseForwardUrl } from "src/modules/ForwardUrl"; import styles from "./LocationsFields.module.css"; interface Props { @@ -44,6 +45,24 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) { setFormField(newValues); }; + const handlePaste = (idx: number, e: ClipboardEvent) => { + const parsed = parseForwardUrl(e.clipboardData.getData("text")); + if (!parsed) return; + e.preventDefault(); + const newValues = values.map((v: ProxyLocation, i: number) => + i === idx + ? { + ...v, + forwardHost: parsed.host + (parsed.path || ""), + forwardPort: parsed.port, + forwardScheme: parsed.scheme || v.forwardScheme, + } + : v, + ); + setValues(newValues); + setFormField(newValues); + }; + const setFormField = (newValues: ProxyLocation[]) => { const filtered = newValues.filter((v: ProxyLocation) => v?.path?.trim() !== ""); setFieldValue(name, filtered); @@ -119,6 +138,7 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) { placeholder="eg: 10.0.0.1/path/" value={item.forwardHost} onChange={(e) => handleChange(idx, "forwardHost", e.target.value)} + onPaste={(e) => handlePaste(idx, e)} /> diff --git a/frontend/src/modals/ProxyHostModal.tsx b/frontend/src/modals/ProxyHostModal.tsx index 3227be51bb..c79a0b1a4f 100644 --- a/frontend/src/modals/ProxyHostModal.tsx +++ b/frontend/src/modals/ProxyHostModal.tsx @@ -18,6 +18,7 @@ import { } from "src/components"; import { useProxyHost, useSetProxyHost, useUser } from "src/hooks"; import { T } from "src/locale"; +import { parseForwardUrl } from "src/modules/ForwardUrl"; import { MANAGE, PROXY_HOSTS } from "src/modules/Permissions"; import { validateNumber, validateString } from "src/modules/Validations"; import { showObjectSuccess } from "src/notifications"; @@ -210,6 +211,27 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => { required placeholder="example.com" {...field} + onPaste={(e) => { + const parsed = parseForwardUrl( + e.clipboardData.getData("text"), + ); + if (!parsed) return; + e.preventDefault(); + form.setFieldValue( + "forwardHost", + parsed.host, + ); + form.setFieldValue( + "forwardPort", + parsed.port, + ); + if (parsed.scheme) { + form.setFieldValue( + "forwardScheme", + parsed.scheme, + ); + } + }} /> {form.errors.forwardHost ? (
diff --git a/frontend/src/modules/ForwardUrl.test.ts b/frontend/src/modules/ForwardUrl.test.ts new file mode 100644 index 0000000000..864c2d8300 --- /dev/null +++ b/frontend/src/modules/ForwardUrl.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { parseForwardUrl } from "./ForwardUrl"; + +describe("parseForwardUrl", () => { + it("splits a full url", () => { + expect(parseForwardUrl("http://192.168.5.150:8096")).toEqual({ + scheme: "http", + host: "192.168.5.150", + port: 8096, + path: undefined, + }); + }); + + it("splits https and defaults the port", () => { + expect(parseForwardUrl("https://example.com")).toEqual({ + scheme: "https", + host: "example.com", + port: 443, + path: undefined, + }); + }); + + it("splits host:port without touching the scheme", () => { + expect(parseForwardUrl("192.168.5.150:8096")).toEqual({ + scheme: undefined, + host: "192.168.5.150", + port: 8096, + path: undefined, + }); + }); + + it("keeps the path separate", () => { + expect(parseForwardUrl("http://example.com:8080/web/index.html")).toEqual({ + scheme: "http", + host: "example.com", + port: 8080, + path: "/web/index.html", + }); + }); + + it("keeps the query string with the path", () => { + expect(parseForwardUrl("http://example.com:8080/web?x=1")?.path).toEqual("/web?x=1"); + }); + + it("rejects input the url parser would rewrite into a different host", () => { + // these would otherwise become ip addresses the user never typed + expect(parseForwardUrl("5000:8080")).toBeNull(); + expect(parseForwardUrl("192.168.1:8080")).toBeNull(); + }); + + it("ignores things that are not worth splitting", () => { + expect(parseForwardUrl("example.com")).toBeNull(); + expect(parseForwardUrl("192.168.5.150")).toBeNull(); + expect(parseForwardUrl("ftp://example.com:21")).toBeNull(); + expect(parseForwardUrl("not a url")).toBeNull(); + expect(parseForwardUrl("")).toBeNull(); + }); +}); diff --git a/frontend/src/modules/ForwardUrl.ts b/frontend/src/modules/ForwardUrl.ts new file mode 100644 index 0000000000..5082484db9 --- /dev/null +++ b/frontend/src/modules/ForwardUrl.ts @@ -0,0 +1,46 @@ +interface ParsedForwardUrl { + scheme?: "http" | "https"; + host: string; + port: number; + path?: string; +} + +/** + * Parses pasted text like "http://192.168.5.150:8096/path" so the forward + * scheme/host/port fields can be filled in one go. Returns null when the text + * isn't worth splitting (plain hostname, unsupported scheme, not a url) so the + * default paste can happen instead. + */ +export const parseForwardUrl = (text: string): ParsedForwardUrl | null => { + const trimmed = text.trim(); + if (!trimmed || /\s/.test(trimmed)) { + return null; + } + const hasScheme = trimmed.includes("://"); + let url: URL; + try { + url = new URL(hasScheme ? trimmed : `http://${trimmed}`); + } catch { + return null; + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + return null; + } + if (!hasScheme && !url.port) { + // just a hostname, nothing to split + return null; + } + if (!trimmed.toLowerCase().includes(url.hostname)) { + // the URL parser rewrote the hostname (eg "5000:8080" becomes ip + // "0.0.19.136") - don't silently fill in something the user never typed + return null; + } + const scheme = url.protocol === "https:" ? "https" : "http"; + const path = url.pathname + url.search; + return { + scheme: hasScheme ? scheme : undefined, + host: url.hostname, + port: url.port ? Number.parseInt(url.port, 10) : scheme === "https" ? 443 : 80, + path: path !== "/" ? path : undefined, + }; +};