Files
Termix/plugins/web-endpoint/frontend/WebEndpointTab.tsx
T

228 lines
7.8 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Globe, RotateCw } from "lucide-react";
import { toast } from "sonner";
import {
currentTunnelHost,
resolveWebEndpointUrl,
webEndpointRefusalReason,
type WebEndpointRefusalReason,
} from "./web-endpoint-url";
import {
allowInvalidCertificateForOrigin,
openWebEndpointExternally,
openWebEndpointTunnel,
requireNumericHostId,
} from "./web-endpoint-api";
import { copyToClipboard } from "@/lib/clipboard";
import { isElectron } from "@/lib/electron";
import { Button } from "@/components/button";
import type { Host } from "@/types/ui-types";
const REFUSAL_MESSAGES: Record<WebEndpointRefusalReason, string> = {
"loopback-bind-on-remote-backend": "webEndpoint.tunnelUnreachableFromBrowser",
"shares-session-cookie-with-termix": "webEndpoint.tunnelSharesSessionCookie",
"direct-shares-session-cookie": "webEndpoint.directSharesSessionCookie",
};
/**
* Renders one web endpoint in an iframe.
*
* Takes the whole host plus the endpoint's ID -- not the endpoint object --
* so a tab whose endpoint was deleted while it was open (or that never matched
* one, if the host's webUiConfig raced the tab's mount) can be detected here
* and shown a plain "no longer exists" message instead of throwing on
* endpoint.scheme.
*/
export function WebEndpointTab({
host,
endpointId,
}: {
host: Host;
endpointId?: string;
}) {
const { t } = useTranslation();
const endpoint = (host.webUiConfig?.endpoints ?? []).find(
(candidate) => candidate.id === endpointId,
);
const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
/**
* Bumped on every resolution that lands, and folded into the iframe's key
* alongside the URL. A direct endpoint's URL never changes, and a live
* tunnel returns the SAME port on every open -- so the URL alone is not
* enough to key on: React bails out of a same-value setState, the key would
* not change, and Reload would silently do nothing in the two most common
* cases.
*/
const [generation, setGeneration] = useState(0);
/**
* Guards two in-flight resolutions completing out of order -- a
* double-clicked Reload, or a manual reload racing the mount effect. Only
* the most recently started resolution may apply its result.
*/
const resolveGenerationRef = useRef(0);
const resolve = useCallback(async () => {
if (!endpoint) return;
const mine = ++resolveGenerationRef.current;
setError(null);
try {
// Checked BEFORE the open call, not after. A tunnel must not be opened
// from a browser when a loopback bind on a remote backend would leave
// nothing to connect to, or when the tunnel URL would land on the page's
// own host string. A DIRECT endpoint on the same host that serves Termix
// leaks the session cookie exactly the same way -- cookies ignore the
// port -- so it is refused here too, before any navigation, rather than
// framing the URL and leaking on the first request.
const refusal = webEndpointRefusalReason(endpoint, isElectron(), host.ip);
if (refusal) {
throw new Error(t(REFUSAL_MESSAGES[refusal]));
}
if (!("credentialless" in HTMLIFrameElement.prototype)) {
throw new Error(t("webEndpoint.isolationUnavailable"));
}
// Always re-resolved rather than reloading the frame: the backend closes
// an idle tunnel after ten minutes and re-binds a fresh kernel-assigned
// port, so a cached port may be gone.
const localPort =
endpoint.access === "tunnel"
? await openWebEndpointTunnel(
requireNumericHostId(host.id),
endpoint.id,
)
: undefined;
const resolved = resolveWebEndpointUrl({
hostAddress: host.ip,
endpoint,
localPort,
// Non-null for a tunnel by the time we get here: a null separated host
// is exactly what the refusal above catches.
tunnelHost: currentTunnelHost(isElectron()) ?? undefined,
});
if (endpoint.access === "direct" && endpoint.ignoreCert) {
await allowInvalidCertificateForOrigin(new URL(resolved).origin);
}
if (mine !== resolveGenerationRef.current) return;
setUrl(resolved);
setGeneration((g) => g + 1);
} catch (caught) {
if (mine !== resolveGenerationRef.current) return;
setError(caught instanceof Error ? caught.message : String(caught));
}
// Depends on the endpoint's FIELDS, not the object: host.webUiConfig is
// re-derived on every render of the host list, so a dependency on
// `endpoint` itself would reopen the tunnel on every parent render.
// bindHost and localPort are included because the refusal guard reads them.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
host.id,
host.ip,
endpoint?.id,
endpoint?.access,
endpoint?.scheme,
endpoint?.port,
endpoint?.path,
endpoint?.ignoreCert,
endpoint?.bindHost,
endpoint?.localPort,
]);
useEffect(() => {
void resolve();
}, [resolve]);
if (!endpoint) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-xs text-muted-foreground">
<Globe className="size-5 opacity-60" />
<p>{t("webEndpoint.notFound")}</p>
</div>
);
}
if (error) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-xs">
<Globe className="size-5 opacity-60" />
<p className="text-center">{error}</p>
<Button variant="outline" size="sm" onClick={() => void resolve()}>
{t("webEndpoint.retry")}
</Button>
</div>
);
}
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b border-border px-2 py-1 text-xs">
<Button
variant="ghost"
size="icon-sm"
onClick={() => void resolve()}
title={t("webEndpoint.reload")}
>
<RotateCw />
</Button>
<span className="truncate text-muted-foreground">{url ?? ""}</span>
<Button
variant="ghost"
size="xs"
className="ml-auto"
onClick={() => {
if (!url) return;
void copyToClipboard(url).then((ok) => {
if (ok) toast.success(t("webEndpoint.urlCopied"));
else toast.error(t("webEndpoint.copyFailed"));
});
}}
>
{t("webEndpoint.copyUrl")}
</Button>
{isElectron() && (
<Button
variant="ghost"
size="xs"
onClick={() => {
void openWebEndpointExternally(host, endpoint).catch((error) =>
toast.error(error.message),
);
}}
>
{t("webEndpoint.openIsolatedWindow")}
</Button>
)}
</div>
<p className="px-2 py-1 text-xs text-muted-foreground">
{t("webEndpoint.isolationNotice")}
</p>
{url && (
// Keyed on generation as well as url: a re-resolved tunnel usually
// returns the SAME port, so url alone would not change and React would
// never remount the frame.
<iframe
key={`${generation}:${url}`}
title={endpoint.label}
ref={(frame) => {
if (!frame) return;
// Set isolation before the first navigation, including redirects.
(
frame as HTMLIFrameElement & { credentialless: boolean }
).credentialless = true;
frame.src = url;
}}
sandbox="allow-scripts allow-forms"
referrerPolicy="no-referrer"
className="h-full w-full flex-1 border-0"
/>
)}
</div>
);
}