mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-19 10:01:12 +00:00
fix: build desktop session share links on the linked server (#1350)
This commit is contained in:
1 parent
d30875483d
commit
ab43563247
11 files changed
+153
-29
No files matched your search
@@ -639,6 +639,8 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
sessionId: guacamoleConnectionId,
|
||||
protocol: resolvedProtocol,
|
||||
tabInstanceId: tabId,
|
||||
origin:
|
||||
sessionLookup?.origin === "remote" ? "remote" : "local",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ export interface RemoteDesktopToolbarContext {
|
||||
sessionId: string | null;
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
tabInstanceId?: string;
|
||||
/** The backend the session runs on. */
|
||||
origin?: "local" | "remote";
|
||||
}
|
||||
|
||||
const MODIFIER_KEYSYMS = {
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"searchUsersPlaceholder": "Search users...",
|
||||
"noUsersFound": "No users found",
|
||||
"linkCreated": "Share link created",
|
||||
"linkNeedsServer": "Share links only work for sessions on a Termix server. Connect this host through your linked server to share it with a link.",
|
||||
"linkCopied": "Link copied to clipboard",
|
||||
"copyLink": "Copy link",
|
||||
"shareCreated": "Session shared. It will appear in their Connections tab.",
|
||||
|
||||
@@ -25,7 +25,9 @@ import {
|
||||
type SessionSharePermissionLevel,
|
||||
type SessionShareRecord,
|
||||
getDirectory,
|
||||
type SessionOrigin,
|
||||
} from "./api";
|
||||
import { guestViewUrl } from "./meeting-backend";
|
||||
|
||||
const EXPIRY_PRESETS = [
|
||||
{ key: "oneHour", hours: 1 },
|
||||
@@ -44,6 +46,8 @@ export function ShareSessionModal({
|
||||
sessionId,
|
||||
protocol,
|
||||
tabInstanceId,
|
||||
origin,
|
||||
resolvePublicUrl,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -51,6 +55,8 @@ export function ShareSessionModal({
|
||||
sessionId: string | null;
|
||||
protocol: SessionShareProtocol;
|
||||
tabInstanceId?: string;
|
||||
origin?: SessionOrigin;
|
||||
resolvePublicUrl?: (origin?: SessionOrigin) => Promise<string | null>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [mode, setMode] = useState<"link" | "user">("link");
|
||||
@@ -83,8 +89,8 @@ export function ShareSessionModal({
|
||||
if (!open || sharesLoaded) return;
|
||||
setSharesLoaded(true);
|
||||
Promise.all([
|
||||
getDirectory().catch(() => ({ users: [] })),
|
||||
getActiveSessionShares(hostId).catch(() => ({ shares: [] })),
|
||||
getDirectory(origin).catch(() => ({ users: [] })),
|
||||
getActiveSessionShares(hostId, origin).catch(() => ({ shares: [] })),
|
||||
]).then(([usersRes, sharesRes]) => {
|
||||
setUsers(
|
||||
(usersRes.users ?? []).map((u) => ({
|
||||
@@ -94,7 +100,7 @@ export function ShareSessionModal({
|
||||
);
|
||||
setShares(sharesRes.shares ?? []);
|
||||
});
|
||||
}, [open, hostId, sharesLoaded]);
|
||||
}, [open, hostId, origin, sharesLoaded]);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
@@ -113,7 +119,7 @@ export function ShareSessionModal({
|
||||
|
||||
async function refreshShares() {
|
||||
try {
|
||||
const res = await getActiveSessionShares(hostId);
|
||||
const res = await getActiveSessionShares(hostId, origin);
|
||||
setShares(res.shares ?? []);
|
||||
} catch {
|
||||
// silently ignore
|
||||
@@ -127,21 +133,33 @@ export function ShareSessionModal({
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await createSessionShare({
|
||||
hostId,
|
||||
sessionId,
|
||||
tabInstanceId,
|
||||
protocol,
|
||||
shareType: mode,
|
||||
targetUserId:
|
||||
mode === "user" ? (selectedUserId ?? undefined) : undefined,
|
||||
permissionLevel,
|
||||
expiryHours,
|
||||
});
|
||||
const base =
|
||||
mode === "link"
|
||||
? resolvePublicUrl
|
||||
? await resolvePublicUrl(origin)
|
||||
: window.location.href
|
||||
: null;
|
||||
if (mode === "link" && !guestViewUrl(base, "shared", "check")) {
|
||||
toast.error(t("sessionSharing.linkNeedsServer"));
|
||||
return;
|
||||
}
|
||||
const result = await createSessionShare(
|
||||
{
|
||||
hostId,
|
||||
sessionId,
|
||||
tabInstanceId,
|
||||
protocol,
|
||||
shareType: mode,
|
||||
targetUserId:
|
||||
mode === "user" ? (selectedUserId ?? undefined) : undefined,
|
||||
permissionLevel,
|
||||
expiryHours,
|
||||
},
|
||||
origin,
|
||||
);
|
||||
|
||||
if (mode === "link" && result.linkToken) {
|
||||
const url = `${window.location.origin}${window.location.pathname}?view=shared&token=${result.linkToken}`;
|
||||
setCreatedLink(url);
|
||||
setCreatedLink(guestViewUrl(base, "shared", result.linkToken));
|
||||
toast.success(t("sessionSharing.linkCreated"));
|
||||
} else {
|
||||
toast.success(t("sessionSharing.shareCreated"));
|
||||
@@ -172,7 +190,7 @@ export function ShareSessionModal({
|
||||
|
||||
async function handleRevoke(shareId: string) {
|
||||
try {
|
||||
await revokeSessionShare(shareId);
|
||||
await revokeSessionShare(shareId, origin);
|
||||
setShares((prev) => prev.filter((s) => s.id !== shareId));
|
||||
toast.success(t("sessionSharing.revoked"));
|
||||
} catch {
|
||||
|
||||
@@ -5,16 +5,24 @@ import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
* Guest pages use it too: the two guest routes are public.
|
||||
*/
|
||||
let client: PluginApiClient | null = null;
|
||||
let clientFor: ((origin?: unknown) => PluginApiClient) | null = null;
|
||||
|
||||
export function bindApi(api: PluginApiClient | null): void {
|
||||
export function bindApi(
|
||||
api: PluginApiClient | null,
|
||||
apiFor?: (origin?: unknown) => PluginApiClient,
|
||||
): void {
|
||||
client = api;
|
||||
clientFor = api ? (apiFor ?? null) : null;
|
||||
}
|
||||
|
||||
function api(): PluginApiClient {
|
||||
/** The session lives on the backend its tab connected through. */
|
||||
function api(origin?: SessionOrigin): PluginApiClient {
|
||||
if (!client) throw new Error("Session sharing is not running");
|
||||
return client;
|
||||
return origin && clientFor ? clientFor(origin) : client;
|
||||
}
|
||||
|
||||
export type SessionOrigin = "local" | "remote";
|
||||
|
||||
interface HttpError {
|
||||
response?: { status?: number; data?: { error?: string } };
|
||||
}
|
||||
@@ -134,25 +142,33 @@ export interface CreateSessionShareResponse {
|
||||
|
||||
export function createSessionShare(
|
||||
request: CreateSessionShareRequest,
|
||||
origin?: SessionOrigin,
|
||||
): Promise<CreateSessionShareResponse> {
|
||||
return call(
|
||||
() => api().post<CreateSessionShareResponse>("/create", request),
|
||||
() => api(origin).post<CreateSessionShareResponse>("/create", request),
|
||||
"Failed to create session share",
|
||||
);
|
||||
}
|
||||
|
||||
export function getActiveSessionShares(
|
||||
hostId: number,
|
||||
origin?: SessionOrigin,
|
||||
): Promise<{ shares: SessionShareRecord[] }> {
|
||||
return call(
|
||||
() => api().get<{ shares: SessionShareRecord[] }>(`/host/${hostId}/active`),
|
||||
() =>
|
||||
api(origin).get<{ shares: SessionShareRecord[] }>(
|
||||
`/host/${hostId}/active`,
|
||||
),
|
||||
"Failed to fetch active session shares",
|
||||
);
|
||||
}
|
||||
|
||||
export function revokeSessionShare(shareId: string): Promise<unknown> {
|
||||
export function revokeSessionShare(
|
||||
shareId: string,
|
||||
origin?: SessionOrigin,
|
||||
): Promise<unknown> {
|
||||
return call(
|
||||
() => api().delete(`/${encodeURIComponent(shareId)}`),
|
||||
() => api(origin).delete(`/${encodeURIComponent(shareId)}`),
|
||||
"Failed to revoke session share",
|
||||
);
|
||||
}
|
||||
@@ -169,13 +185,13 @@ export interface DirectoryRole {
|
||||
}
|
||||
|
||||
/** Users and roles to share with or invite. */
|
||||
export function getDirectory(): Promise<{
|
||||
export function getDirectory(origin?: SessionOrigin): Promise<{
|
||||
users: DirectoryUser[];
|
||||
roles: DirectoryRole[];
|
||||
}> {
|
||||
return call(
|
||||
() =>
|
||||
api().get<{ users: DirectoryUser[]; roles: DirectoryRole[] }>(
|
||||
api(origin).get<{ users: DirectoryUser[]; roles: DirectoryRole[] }>(
|
||||
"/directory",
|
||||
),
|
||||
"Failed to list users and roles",
|
||||
|
||||
@@ -26,6 +26,7 @@ import SharedSessionView from "./SharedSessionView";
|
||||
import { ParticipantsOverlay } from "./ParticipantsOverlay";
|
||||
import {
|
||||
ShareDialogHost,
|
||||
bindShareApp,
|
||||
closeShareDialog,
|
||||
openShareDialog,
|
||||
type ShareTarget,
|
||||
@@ -217,6 +218,7 @@ interface RemoteDesktopToolbarContext {
|
||||
sessionId: string | null;
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
tabInstanceId?: string;
|
||||
origin?: "local" | "remote";
|
||||
}
|
||||
|
||||
function isRemoteDesktopContext(
|
||||
@@ -232,10 +234,12 @@ function isRemoteDesktopContext(
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
meetingApp = app;
|
||||
bindApi(app.api);
|
||||
bindApi(app.api, app.apiFor);
|
||||
bindShareApp(app);
|
||||
app.onDispose(() => {
|
||||
closeShareDialog();
|
||||
bindApi(null);
|
||||
bindShareApp(null);
|
||||
});
|
||||
|
||||
// Rooms and share links draw remote desktop streams through this slot.
|
||||
@@ -281,6 +285,7 @@ export function activate(app: TermixApp): void {
|
||||
sessionId: context.sessionId,
|
||||
protocol: context.protocol,
|
||||
tabInstanceId: context.tabInstanceId,
|
||||
origin: context.origin,
|
||||
}
|
||||
: null;
|
||||
} else {
|
||||
|
||||
@@ -32,13 +32,32 @@ export async function connectMeetings(app: TermixApp) {
|
||||
export function meetingGuestUrl(
|
||||
publicUrl: string | null,
|
||||
token: string,
|
||||
): string | null {
|
||||
return guestViewUrl(publicUrl, "collab-guest", token);
|
||||
}
|
||||
|
||||
/** A guest link on a server others can open, never a file:// desktop page. */
|
||||
export function guestViewUrl(
|
||||
publicUrl: string | null,
|
||||
view: string,
|
||||
token: string,
|
||||
): string | null {
|
||||
if (!publicUrl) return null;
|
||||
const url = new URL(publicUrl);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
url.searchParams.set("view", "collab-guest");
|
||||
url.searchParams.set("view", view);
|
||||
url.searchParams.set("token", token);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/** Where guests reach a session that runs on the given backend. */
|
||||
export async function sessionPublicUrl(
|
||||
app: TermixApp,
|
||||
origin: "local" | "remote" | undefined,
|
||||
): Promise<string | null> {
|
||||
if (!app.desktop.available) return window.location.href;
|
||||
if (origin !== "remote") return null;
|
||||
return (await app.desktop.remoteServerUrl()) || null;
|
||||
}
|
||||
@@ -1,6 +1,21 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import { ShareSessionModal } from "./ShareSessionModal";
|
||||
import type { SessionShareProtocol } from "./api";
|
||||
import { sessionPublicUrl } from "./meeting-backend";
|
||||
|
||||
let shareApp: TermixApp | null = null;
|
||||
|
||||
export function bindShareApp(app: TermixApp | null): void {
|
||||
shareApp = app;
|
||||
}
|
||||
|
||||
function resolveShareBase(
|
||||
origin: "local" | "remote" | undefined,
|
||||
): Promise<string | null> {
|
||||
if (!shareApp) return Promise.resolve(window.location.href);
|
||||
return sessionPublicUrl(shareApp, origin);
|
||||
}
|
||||
|
||||
/** What the share dialog needs to share one live session. */
|
||||
export interface ShareTarget {
|
||||
@@ -8,6 +23,8 @@ export interface ShareTarget {
|
||||
sessionId: string;
|
||||
protocol: SessionShareProtocol;
|
||||
tabInstanceId?: string;
|
||||
/** The backend the session runs on; a desktop can be on either. */
|
||||
origin?: "local" | "remote";
|
||||
}
|
||||
|
||||
let current: ShareTarget | null = null;
|
||||
@@ -45,6 +62,8 @@ export function ShareDialogHost() {
|
||||
sessionId={target.sessionId}
|
||||
protocol={target.protocol}
|
||||
tabInstanceId={target.tabInstanceId}
|
||||
origin={target.origin}
|
||||
resolvePublicUrl={resolveShareBase}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
guestViewUrl,
|
||||
sessionPublicUrl,
|
||||
} from "../../src/frontend/meeting-backend";
|
||||
|
||||
function desktop(url: string | null): TermixApp {
|
||||
return {
|
||||
desktop: { available: true, remoteServerUrl: async () => url },
|
||||
} as unknown as TermixApp;
|
||||
}
|
||||
|
||||
describe("session share links", () => {
|
||||
it("builds the link on the linked server for a remote session", async () => {
|
||||
const base = await sessionPublicUrl(
|
||||
desktop("https://termix.example/"),
|
||||
"remote",
|
||||
);
|
||||
expect(guestViewUrl(base, "shared", "tok")).toBe(
|
||||
"https://termix.example/?view=shared&token=tok",
|
||||
);
|
||||
});
|
||||
|
||||
it("gives no link for a session on the desktop's own backend", async () => {
|
||||
expect(
|
||||
await sessionPublicUrl(desktop("https://termix.example/"), "local"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
guestViewUrl("file:///C:/app/index.html", "shared", "tok"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the page address in a browser", async () => {
|
||||
const app = { desktop: { available: false } } as unknown as TermixApp;
|
||||
expect(await sessionPublicUrl(app, undefined)).toBe(window.location.href);
|
||||
});
|
||||
});
|
||||
@@ -281,6 +281,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const webSocketRef = useRef<WebSocket | null>(null);
|
||||
const terminalInputDisposableRef = useRef<{ dispose(): void } | null>(null);
|
||||
const localEchoRef = useRef<TerminalLocalEcho | null>(null);
|
||||
const sessionOriginRef = useRef<"local" | "remote">("local");
|
||||
const customKeybindingsRef = useRef<CustomKeybinding[]>([]);
|
||||
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const wasDisconnectedBySSH = useRef(false);
|
||||
@@ -785,6 +786,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
sessionId: sessionIdRef.current,
|
||||
protocol: "ssh" as const,
|
||||
tabInstanceId: hostConfig.instanceId,
|
||||
origin: sessionOriginRef.current,
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
@@ -1642,6 +1644,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
"local" | "remote" | null | undefined,
|
||||
})
|
||||
: "local";
|
||||
sessionOriginRef.current = origin === "remote" ? "remote" : "local";
|
||||
const resolvedUrl = await pluginWsUrl("ssh-terminal", "/terminal", {
|
||||
origin,
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface TerminalSlotApi {
|
||||
sessionId: string;
|
||||
protocol: "ssh";
|
||||
tabInstanceId?: string;
|
||||
origin?: "local" | "remote";
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user