Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion frontend/src/components/Form/LocationsFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -44,6 +45,24 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
setFormField(newValues);
};

const handlePaste = (idx: number, e: ClipboardEvent<HTMLInputElement>) => {
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);
Expand Down Expand Up @@ -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)}
/>
</div>
</div>
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/modals/ProxyHostModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 ? (
<div className="invalid-feedback">
Expand Down
58 changes: 58 additions & 0 deletions frontend/src/modules/ForwardUrl.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
46 changes: 46 additions & 0 deletions frontend/src/modules/ForwardUrl.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};