From f8b05de5bbb06bc9ef67022412cf37b875fbac43 Mon Sep 17 00:00:00 2001 From: grasfer Date: Thu, 16 Jul 2026 16:39:02 +0200 Subject: [PATCH] Auto-split pasted URLs into scheme/host/port on forward host fields Pasting e.g. http://192.168.5.150:8096 into the Forward Hostname / IP field of the proxy host form (and custom locations) now fills the scheme and port fields and leaves just the host, instead of dumping the whole URL into the host field. Co-Authored-By: Claude Fable 5 --- .../src/components/Form/LocationsFields.tsx | 22 ++++++- frontend/src/modals/ProxyHostModal.tsx | 22 +++++++ frontend/src/modules/ForwardUrl.test.ts | 58 +++++++++++++++++++ frontend/src/modules/ForwardUrl.ts | 46 +++++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 frontend/src/modules/ForwardUrl.test.ts create mode 100644 frontend/src/modules/ForwardUrl.ts 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, + }; +};