From ec7c2c28353faf5640e68f777ce5b5e378cf9f45 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Wed, 30 Sep 2026 22:54:23 +0800 Subject: [PATCH] fix(session-sharing): use the linked server for desktop meetings and links (#1507) --- plugins/remote-desktop/src/frontend/index.tsx | 4 +- plugins/session-sharing/locales/en.json | 5 +- .../src/backend/repositories.ts | 11 + .../src/backend/room-routes.ts | 26 +++ .../src/frontend/CollabPanel.tsx | 8 +- .../src/frontend/CollabRoomTab.tsx | 94 ++++---- plugins/session-sharing/src/frontend/api.ts | 142 ------------ .../session-sharing/src/frontend/index.tsx | 122 ++++++++-- .../src/frontend/meeting-api.ts | 208 ++++++++++++++++++ .../src/frontend/meeting-backend.ts | 44 ++++ .../session-sharing/src/frontend/shared.tsx | 20 +- .../session-sharing/tests/backend/helpers.ts | 16 +- .../tests/backend/rooms.test.ts | 22 ++ .../tests/frontend/meeting-backend.test.ts | 80 +++++++ .../tests/frontend/meeting-transport.test.ts | 29 +++ 15 files changed, 621 insertions(+), 210 deletions(-) create mode 100644 plugins/session-sharing/src/frontend/meeting-api.ts create mode 100644 plugins/session-sharing/src/frontend/meeting-backend.ts create mode 100644 plugins/session-sharing/tests/frontend/meeting-backend.test.ts create mode 100644 plugins/session-sharing/tests/frontend/meeting-transport.test.ts diff --git a/plugins/remote-desktop/src/frontend/index.tsx b/plugins/remote-desktop/src/frontend/index.tsx index 5fde6243f..8bbc2fb62 100644 --- a/plugins/remote-desktop/src/frontend/index.tsx +++ b/plugins/remote-desktop/src/frontend/index.tsx @@ -118,12 +118,14 @@ function RemoteDesktopStandalone({ hostId, view }: StandaloneViewProps) { /** What collab rooms and shared-session links draw a stream with. */ function RemoteDisplay({ token, + connectionOrigin, protocol, isVisible, onConnect, onError, }: { token: string; + connectionOrigin?: "local" | "remote"; protocol: Protocol; isVisible: boolean; onConnect?: () => void; @@ -131,7 +133,7 @@ function RemoteDisplay({ }) { return ( { + const { hosts } = await refs(); + const rows = await ( + await client() + ) + .select({ id: hosts.id }) + .from(hosts) + .where(eq(hosts.syncId, syncId)); + return rows.map((row: { id: number }) => row.id); + }, + async hostExists(hostId: number): Promise { const { hosts } = await refs(); const rows = await ( diff --git a/plugins/session-sharing/src/backend/room-routes.ts b/plugins/session-sharing/src/backend/room-routes.ts index 565eb09f4..fe6c6f54b 100644 --- a/plugins/session-sharing/src/backend/room-routes.ts +++ b/plugins/session-sharing/src/backend/room-routes.ts @@ -200,6 +200,32 @@ export function registerRoomRoutes( } = actions; const requireUse = ctx.rbac.require("use") as never; + router.get( + "/meeting-host/:syncId", + requireUse, + async (req: Request, res: Response) => { + if (!ctx.currentActor()) + return res.status(401).json({ error: "Authentication required" }); + try { + for (const id of await directory.hostIdsBySyncId( + String(req.params.syncId), + )) { + if ((await ctx.hosts.checkAccess(id, "connect")).hasAccess) { + return res.json({ id }); + } + } + return res.status(404).json({ + error: + "The host must be synced to the meeting server before presenting", + }); + } catch { + return res + .status(500) + .json({ error: "Failed to resolve meeting host" }); + } + }, + ); + const audit = ( req: Request, action: string, diff --git a/plugins/session-sharing/src/frontend/CollabPanel.tsx b/plugins/session-sharing/src/frontend/CollabPanel.tsx index 385e5903f..ff750a585 100644 --- a/plugins/session-sharing/src/frontend/CollabPanel.tsx +++ b/plugins/session-sharing/src/frontend/CollabPanel.tsx @@ -12,15 +12,19 @@ import { DialogHeader, DialogTitle, } from "@termix/plugin-sdk/ui"; -import { createCollabRoom, listCollabRooms, type CollabRoom } from "./api"; +import type { CollabRoom } from "./api"; +import type { MeetingBackend } from "./meeting-backend"; import { getErrorMessage } from "./shared"; export function CollabPanel({ onOpenRoom, + backend, }: { onOpenRoom: (room: CollabRoom) => void; + backend: MeetingBackend; }) { const { t } = useTranslation(); + const { createCollabRoom, listCollabRooms } = backend.api; const [rooms, setRooms] = useState([]); const [loading, setLoading] = useState(true); const [createOpen, setCreateOpen] = useState(false); @@ -37,7 +41,7 @@ export function CollabPanel({ } finally { setLoading(false); } - }, []); + }, [backend]); useEffect(() => { void refresh(); diff --git a/plugins/session-sharing/src/frontend/CollabRoomTab.tsx b/plugins/session-sharing/src/frontend/CollabRoomTab.tsx index b6b505e10..6595e24cc 100644 --- a/plugins/session-sharing/src/frontend/CollabRoomTab.tsx +++ b/plugins/session-sharing/src/frontend/CollabRoomTab.tsx @@ -34,36 +34,16 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@termix/plugin-sdk/ui"; -import { - Input, - PluginComponent, - resolveConnectionOrigin, -} from "@termix/plugin-sdk/ui"; +import { Input, PluginComponent } from "@termix/plugin-sdk/ui"; import { CollabMembersSidebar } from "./CollabMembersSidebar"; import { RemoteDisplay, createRemoteSessionToken, getErrorMessage, - wsUrlForPath, + wsTargetForPath, } from "./shared"; -import { - deleteCollabRoom, - endCollabRoom, - dismissCollabControlRequest, - getCollabRoom, - getCollabStage, - inviteCollabMembers, - presentCollabStage, - requestCollabStageControl, - removeCollabMember, - setCollabGuestLink, - setCollabStageControl, - stopCollabStage, - getDirectory, - type CollabRoomDetail, - type CollabStage, - type DirectoryRole, -} from "./api"; +import type { CollabRoomDetail, CollabStage, DirectoryRole } from "./api"; +import { meetingGuestUrl, type MeetingBackend } from "./meeting-backend"; type SharedProtocol = "ssh" | "rdp" | "vnc" | "telnet"; @@ -84,9 +64,10 @@ const POLL_FALLBACK_MS = 15000; // The terminal socket the ssh-terminal plugin serves. Authentication rides on // the jwt cookie the way every terminal WS connection does. -async function roomEventsWsUrl(roomId: string): Promise { - const { eventsWsPath } = await getCollabRoom(roomId); - return wsUrlForPath(eventsWsPath); +async function roomEventsWsUrl(roomId: string, backend: MeetingBackend) { + const { eventsWsPath } = await backend.api.getCollabRoom(roomId); + await backend.assertCurrent(); + return wsTargetForPath(eventsWsPath, backend.origin); } type PresentDraft = @@ -105,11 +86,28 @@ type PresentChoice = { export function CollabRoomTab({ roomId, isVisible, + backend, }: { + backend: MeetingBackend; roomId?: string; isVisible: boolean; }) { const { t } = useTranslation(); + const { + deleteCollabRoom, + endCollabRoom, + dismissCollabControlRequest, + getCollabRoom, + getCollabStage, + inviteCollabMembers, + presentCollabStage, + requestCollabStageControl, + removeCollabMember, + setCollabGuestLink, + setCollabStageControl, + stopCollabStage, + getDirectory, + } = backend.api; const [detail, setDetail] = useState(null); const [stage, setStage] = useState(null); const [ended, setEnded] = useState(false); @@ -190,7 +188,7 @@ export function CollabRoomTab({ setLoadError(getErrorMessage(error)); } } - }, [roomId]); + }, [roomId, backend]); useEffect(() => { void refresh(); @@ -216,10 +214,10 @@ export function CollabRoomTab({ const connect = async () => { if (cancelled) return; try { - const url = await roomEventsWsUrl(roomId); + const url = await roomEventsWsUrl(roomId, backend); if (cancelled) return; if (!url) throw new Error("No terminal endpoint is available"); - ws = new WebSocket(url); + ws = new WebSocket(url.url, url.protocols); } catch { startPolling(); reconnectTimer = setTimeout(() => void connect(), 5000); @@ -359,19 +357,21 @@ export function CollabRoomTab({ if (!roomId) return; setPresentOpen(false); try { + await backend.assertCurrent(); + if (backend.origin === "remote") { + if (!host.syncId) throw new Error(t("collab.hostNotSynced")); + const resolved = await backend.api.resolveHost(host.syncId); + host = { ...host, id: String(resolved.id), connectionOrigin: "remote" }; + } else { + host = { ...host, connectionOrigin: "local" }; + } if (protocol === "ssh") { setDraft({ protocol, host }); return; } const response = await createRemoteSessionToken( Number(host.id), - await resolveConnectionOrigin( - { - connectionOrigin: host.connectionOrigin as - "local" | "remote" | undefined, - }, - { defaultRemote: true }, - ), + backend.origin, protocol, ); if (!response?.connectionId) { @@ -465,8 +465,16 @@ export function CollabRoomTab({ } } - function guestLinkUrl(token: string) { - return `${window.location.origin}${window.location.pathname}?view=collab-guest&token=${token}`; + async function copyGuestLink(token: string) { + try { + await backend.assertCurrent(); + const url = meetingGuestUrl(backend.publicUrl, token); + if (!url) throw new Error(t("collab.linkRequiresServer")); + await navigator.clipboard.writeText(url); + toast.success(t("collab.linkCopied")); + } catch (error) { + toast.error(getErrorMessage(error)); + } } async function handleInvite() { @@ -648,10 +656,7 @@ export function CollabRoomTab({ variant="outline" className="h-8 text-xs" onClick={() => { - void navigator.clipboard - .writeText(guestLinkUrl(guestLinkToken)) - .then(() => toast.success(t("collab.linkCopied"))) - .catch((error) => toast.error(getErrorMessage(error))); + void copyGuestLink(guestLinkToken); }} > {t("collab.copyLink")} @@ -706,6 +711,7 @@ export function CollabRoomTab({ /> ) : ( ) : stage.connectParams?.token ? ( - `/rooms/${encodeURIComponent(roomId)}${suffix}`; - -export function listCollabRooms(): Promise<{ rooms: CollabRoom[] }> { - return call( - () => api().get<{ rooms: CollabRoom[] }>("/rooms"), - "Failed to list rooms", - ); -} - -export function createCollabRoom( - name: string, - persistent: boolean, -): Promise<{ room: CollabRoom }> { - return call( - () => api().post<{ room: CollabRoom }>("/rooms", { name, persistent }), - "Failed to create room", - ); -} - -export function getCollabRoom(roomId: string): Promise { - return call( - () => api().get(room(roomId)), - "Failed to get room", - ); -} - -export async function inviteCollabMembers( - roomId: string, - targets: { userIds?: string[]; roleIds?: number[] }, -): Promise { - await call( - () => api().post(room(roomId, "/members"), targets), - "Failed to invite members", - ); -} - -export async function removeCollabMember( - roomId: string, - userId: string, -): Promise { - await call( - () => api().delete(room(roomId, `/members/${encodeURIComponent(userId)}`)), - "Failed to remove member", - ); -} - -export function presentCollabStage( - roomId: string, - input: { protocol: string; sessionId: string; hostId: number }, -): Promise<{ stage: CollabStage }> { - return call( - () => api().post<{ stage: CollabStage }>(room(roomId, "/present"), input), - "Failed to start presenting", - ); -} - -export async function stopCollabStage(roomId: string): Promise { - await call( - () => api().post(room(roomId, "/stop")), - "Failed to stop presenting", - ); -} - -export function getCollabStage( - roomId: string, -): Promise<{ stage: CollabStage | null }> { - return call( - () => api().get<{ stage: CollabStage | null }>(room(roomId, "/stage")), - "Failed to resolve stage", - ); -} - -export async function setCollabStageControl( - roomId: string, - userId: string | null, -): Promise { - await call( - () => api().post(room(roomId, "/control"), { userId }), - "Failed to change stage control", - ); -} - -export function requestCollabStageControl( - roomId: string, -): Promise<{ request: CollabControlRequest }> { - return call( - () => - api().post<{ request: CollabControlRequest }>( - room(roomId, "/control/request"), - ), - "Failed to request control", - ); -} - -export function listCollabControlRequests( - roomId: string, -): Promise<{ requests: CollabControlRequest[] }> { - return call( - () => - api().get<{ requests: CollabControlRequest[] }>( - room(roomId, "/control/requests"), - ), - "Failed to list control requests", - ); -} - -export async function dismissCollabControlRequest( - roomId: string, - userId: string, -): Promise { - await call( - () => - api().delete( - room(roomId, `/control/requests/${encodeURIComponent(userId)}`), - ), - "Failed to dismiss control request", - ); -} - -export async function endCollabRoom(roomId: string): Promise { - await call(() => api().post(room(roomId, "/end")), "Failed to end room"); -} - -export async function deleteCollabRoom(roomId: string): Promise { - await call(() => api().delete(room(roomId)), "Failed to delete room"); -} - -export function setCollabGuestLink( - roomId: string, - enabled: boolean, -): Promise<{ guestLinkToken: string | null }> { - return call( - () => - api().post<{ guestLinkToken: string | null }>( - room(roomId, "/guest-link"), - { enabled }, - ), - "Failed to update guest link", - ); -} - export interface CollabGuestStage { protocol: "ssh" | "rdp" | "vnc" | "telnet"; shareId: string; diff --git a/plugins/session-sharing/src/frontend/index.tsx b/plugins/session-sharing/src/frontend/index.tsx index 523985cb6..2bfd69fc2 100644 --- a/plugins/session-sharing/src/frontend/index.tsx +++ b/plugins/session-sharing/src/frontend/index.tsx @@ -1,6 +1,12 @@ // The terminal surfaces need xterm's own stylesheet. import "@xterm/xterm/css/xterm.css"; -import { useEffect, type ComponentType } from "react"; +import { + useEffect, + useRef, + useState, + type ReactNode, + type ComponentType, +} from "react"; import { Presentation, Share2 } from "lucide-react"; import { toast } from "sonner"; import { @@ -11,7 +17,7 @@ import { type TabProps, type TermixApp, } from "@termix/plugin-sdk/frontend"; -import { bindApi, listCollabRooms } from "./api"; +import { bindApi } from "./api"; import { SharedWithMeSection } from "./SharedWithMeSection"; import CollabGuestView from "./CollabGuestView"; import { CollabPanel } from "./CollabPanel"; @@ -26,6 +32,73 @@ import { } from "./share-dialog"; import { REMOTE_DISPLAY_SLOT } from "./shared"; +import { connectMeetings, type MeetingBackend } from "./meeting-backend"; + +let meetingApp: TermixApp; + +function MeetingScope({ + children, + serverKey, + pin = false, +}: { + children: (backend: MeetingBackend) => ReactNode; + serverKey?: string; + pin?: boolean; +}) { + const [backend, setBackend] = useState(null); + const [error, setError] = useState(null); + const pinned = useRef(serverKey); + useEffect(() => { + let cancelled = false; + let sequence = 0; + const refresh = async () => { + const request = ++sequence; + try { + const next = await connectMeetings(meetingApp); + if (cancelled || request !== sequence) return; + if ( + pin && + pinned.current !== undefined && + pinned.current !== next.key + ) { + setBackend(null); + setError(meetingApp.t("collab.serverChanged")); + return; + } + if (pin) pinned.current ??= next.key; + setError(null); + setBackend((previous) => + previous?.key === next.key ? previous : next, + ); + } catch (failure) { + if (!cancelled) { + setBackend(null); + setError(String(failure)); + } + } + }; + void refresh(); + const dispose = meetingApp.desktop.onRemoteServerChange(() => { + void refresh(); + }); + return () => { + cancelled = true; + dispose(); + }; + }, [serverKey, pin]); + if (error) + return ( +

+ {error} +

+ ); + return backend ? ( +
+ {children(backend)} +
+ ) : null; +} + const COLLAB = "collab"; const SHARE_ACTION = "session-sharing.share"; const SEEN_ROOMS_KEY = "termix:collab-rooms-seen"; @@ -38,25 +111,39 @@ function GuestView({ view }: StandaloneViewProps) { function CollabRoomTabView({ tab, isVisible }: TabProps) { const roomId = tab.data?.roomId; + const serverKey = + typeof tab.data?.meetingServerKey === "string" + ? tab.data.meetingServerKey + : undefined; return ( - + + {(backend) => ( + + )} + ); } function CollabPanelView({ shell }: PanelProps) { return ( - - shell.openTab(null, COLLAB, { - label: room.name, - forceNewTab: true, - data: { roomId: room.id }, - }) - } - /> + + {(backend) => ( + + shell.openTab(null, COLLAB, { + label: room.name, + forceNewTab: true, + data: { roomId: room.id, meetingServerKey: backend.key }, + }) + } + /> + )} + ); } @@ -76,7 +163,9 @@ function createInviteWatcher(app: TermixApp): ComponentType { let cancelled = false; const check = async () => { try { - const { rooms = [] } = await listCollabRooms(); + const { rooms = [] } = await ( + await connectMeetings(app) + ).api.listCollabRooms(); if (cancelled) return; let seen: string[] = []; try { @@ -142,6 +231,7 @@ function isRemoteDesktopContext( } export function activate(app: TermixApp): void { + meetingApp = app; bindApi(app.api); app.onDispose(() => { closeShareDialog(); diff --git a/plugins/session-sharing/src/frontend/meeting-api.ts b/plugins/session-sharing/src/frontend/meeting-api.ts new file mode 100644 index 000000000..957c82ddc --- /dev/null +++ b/plugins/session-sharing/src/frontend/meeting-api.ts @@ -0,0 +1,208 @@ +import type { PluginApiClient } from "@termix/plugin-sdk/frontend"; +import type { + CollabRoom, + CollabRoomDetail, + CollabStage, + CollabControlRequest, + DirectoryUser, + DirectoryRole, +} from "./api"; + +export function createMeetingApi( + client: PluginApiClient, + assertCurrent: () => Promise, +) { + const api = () => client; + async function call( + request: () => Promise<{ data: T }>, + fallback: string, + ): Promise { + await assertCurrent(); + try { + return (await request()).data; + } catch (error) { + const response = error as { response?: { data?: { error?: string } } }; + throw new Error(response.response?.data?.error || fallback); + } + } + const room = (roomId: string, suffix = "") => + `/rooms/${encodeURIComponent(roomId)}${suffix}`; + + function listCollabRooms(): Promise<{ rooms: CollabRoom[] }> { + return call( + () => api().get<{ rooms: CollabRoom[] }>("/rooms"), + "Failed to list rooms", + ); + } + + function createCollabRoom( + name: string, + persistent: boolean, + ): Promise<{ room: CollabRoom }> { + return call( + () => api().post<{ room: CollabRoom }>("/rooms", { name, persistent }), + "Failed to create room", + ); + } + + function getCollabRoom(roomId: string): Promise { + return call( + () => api().get(room(roomId)), + "Failed to get room", + ); + } + + async function inviteCollabMembers( + roomId: string, + targets: { userIds?: string[]; roleIds?: number[] }, + ): Promise { + await call( + () => api().post(room(roomId, "/members"), targets), + "Failed to invite members", + ); + } + + async function removeCollabMember( + roomId: string, + userId: string, + ): Promise { + await call( + () => + api().delete(room(roomId, `/members/${encodeURIComponent(userId)}`)), + "Failed to remove member", + ); + } + + function presentCollabStage( + roomId: string, + input: { protocol: string; sessionId: string; hostId: number }, + ): Promise<{ stage: CollabStage }> { + return call( + () => api().post<{ stage: CollabStage }>(room(roomId, "/present"), input), + "Failed to start presenting", + ); + } + + async function stopCollabStage(roomId: string): Promise { + await call( + () => api().post(room(roomId, "/stop")), + "Failed to stop presenting", + ); + } + + function getCollabStage( + roomId: string, + ): Promise<{ stage: CollabStage | null }> { + return call( + () => api().get<{ stage: CollabStage | null }>(room(roomId, "/stage")), + "Failed to resolve stage", + ); + } + + async function setCollabStageControl( + roomId: string, + userId: string | null, + ): Promise { + await call( + () => api().post(room(roomId, "/control"), { userId }), + "Failed to change stage control", + ); + } + + function requestCollabStageControl( + roomId: string, + ): Promise<{ request: CollabControlRequest }> { + return call( + () => + api().post<{ request: CollabControlRequest }>( + room(roomId, "/control/request"), + ), + "Failed to request control", + ); + } + + function listCollabControlRequests( + roomId: string, + ): Promise<{ requests: CollabControlRequest[] }> { + return call( + () => + api().get<{ requests: CollabControlRequest[] }>( + room(roomId, "/control/requests"), + ), + "Failed to list control requests", + ); + } + + async function dismissCollabControlRequest( + roomId: string, + userId: string, + ): Promise { + await call( + () => + api().delete( + room(roomId, `/control/requests/${encodeURIComponent(userId)}`), + ), + "Failed to dismiss control request", + ); + } + + async function endCollabRoom(roomId: string): Promise { + await call(() => api().post(room(roomId, "/end")), "Failed to end room"); + } + + async function deleteCollabRoom(roomId: string): Promise { + await call(() => api().delete(room(roomId)), "Failed to delete room"); + } + + function setCollabGuestLink( + roomId: string, + enabled: boolean, + ): Promise<{ guestLinkToken: string | null }> { + return call( + () => + api().post<{ guestLinkToken: string | null }>( + room(roomId, "/guest-link"), + { enabled }, + ), + "Failed to update guest link", + ); + } + + function getDirectory() { + return call( + () => + client.get<{ users: DirectoryUser[]; roles: DirectoryRole[] }>( + "/directory", + ), + "Failed to list users and roles", + ); + } + function resolveHost(syncId: string) { + return call( + () => + client.get<{ id: number }>( + `/meeting-host/${encodeURIComponent(syncId)}`, + ), + "The host must be synced to the meeting server before presenting", + ); + } + return { + listCollabRooms, + createCollabRoom, + getCollabRoom, + inviteCollabMembers, + removeCollabMember, + presentCollabStage, + stopCollabStage, + getCollabStage, + setCollabStageControl, + requestCollabStageControl, + listCollabControlRequests, + dismissCollabControlRequest, + endCollabRoom, + deleteCollabRoom, + setCollabGuestLink, + getDirectory, + resolveHost, + }; +} diff --git a/plugins/session-sharing/src/frontend/meeting-backend.ts b/plugins/session-sharing/src/frontend/meeting-backend.ts new file mode 100644 index 000000000..6dfe04c9d --- /dev/null +++ b/plugins/session-sharing/src/frontend/meeting-backend.ts @@ -0,0 +1,44 @@ +import type { TermixApp } from "@termix/plugin-sdk/frontend"; +import { createMeetingApi } from "./meeting-api"; + +export type MeetingBackend = Awaited>; + +/** A room, its hosts and its sockets must all belong to the same backend. */ +export async function connectMeetings(app: TermixApp) { + const remoteUrl = app.desktop.available + ? await app.desktop.remoteServerUrl() + : null; + const key = remoteUrl?.replace(/\/+$/, "") || "local"; + const origin = remoteUrl ? ("remote" as const) : ("local" as const); + const publicUrl = + remoteUrl || (!app.desktop.available ? window.location.href : null); + const assertCurrent = async () => { + const current = app.desktop.available + ? await app.desktop.remoteServerUrl() + : null; + if ((current?.replace(/\/+$/, "") || "local") !== key) { + throw new Error(app.t("collab.serverChanged")); + } + }; + return { + key, + origin, + publicUrl, + assertCurrent, + api: createMeetingApi(app.apiFor(origin), assertCurrent), + }; +} + +export function meetingGuestUrl( + publicUrl: string | null, + 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("token", token); + return url.toString(); +} diff --git a/plugins/session-sharing/src/frontend/shared.tsx b/plugins/session-sharing/src/frontend/shared.tsx index 99212881a..75572a399 100644 --- a/plugins/session-sharing/src/frontend/shared.tsx +++ b/plugins/session-sharing/src/frontend/shared.tsx @@ -17,6 +17,7 @@ export const REMOTE_SESSION_TOKEN_ACTION = "session.remoteDisplay.token"; export interface RemoteDisplayProps { token: string; + connectionOrigin?: "local" | "remote"; protocol: string; isVisible: boolean; onConnect?: () => void; @@ -65,14 +66,25 @@ export async function createRemoteSessionToken( } /** A socket URL for a /plugin-ws//?query path the server handed out. */ -export async function wsUrlForPath(wsPath: string): Promise { +export async function wsTargetForPath( + wsPath: string, + origin: "local" | "remote" = "local", +) { const [pathname, query] = wsPath.split("?", 2); const match = /^\/plugin-ws\/([^/]+)(\/.*)$/.exec(pathname); if (!match) return null; - const target = await pluginWsUrl(match[1], match[2]); + const target = await pluginWsUrl(match[1], match[2], { origin }); if (!target) return null; - if (!query) return target.url; - return `${target.url}${target.url.includes("?") ? "&" : "?"}${query}`; + return { + ...target, + url: query + ? `${target.url}${target.url.includes("?") ? "&" : "?"}${query}` + : target.url, + }; +} + +export async function wsUrlForPath(wsPath: string): Promise { + return (await wsTargetForPath(wsPath))?.url ?? null; } export function getErrorMessage(error: unknown): string { diff --git a/plugins/session-sharing/tests/backend/helpers.ts b/plugins/session-sharing/tests/backend/helpers.ts index 85f7c88b8..2e6832ee3 100644 --- a/plugins/session-sharing/tests/backend/helpers.ts +++ b/plugins/session-sharing/tests/backend/helpers.ts @@ -1,4 +1,5 @@ import http from "node:http"; +import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core"; import type { AddressInfo } from "node:net"; import { fileURLToPath } from "node:url"; import express, { type Router } from "express"; @@ -85,7 +86,20 @@ export async function startServer( settings?: Record; } = {}, ): Promise { - const db = await createTestDb(pluginDir); + const db = await createTestDb(pluginDir, { + before: (sqlite) => { + sqlite.exec("ALTER TABLE ssh_data ADD COLUMN sync_id TEXT"); + }, + }); + const refs = await db.database.refs(); + db.database.refs = async () => + ({ + ...refs, + hosts: sqliteTable("ssh_data", { + id: integer("id").primaryKey(), + syncId: text("sync_id"), + }), + }) as Awaited>; for (const user of options.users ?? ["alice", "bob", "carol"]) { db.sqlite .prepare("INSERT INTO users (id, username, is_admin) VALUES (?, ?, ?)") diff --git a/plugins/session-sharing/tests/backend/rooms.test.ts b/plugins/session-sharing/tests/backend/rooms.test.ts index b6c5a21a2..9f9a76302 100644 --- a/plugins/session-sharing/tests/backend/rooms.test.ts +++ b/plugins/session-sharing/tests/backend/rooms.test.ts @@ -28,6 +28,28 @@ async function present(s: TestServer, roomId: string, user = "alice") { } describe("collab rooms", () => { + it("resolves a synced host to the server ID only with connect access", async () => { + server = await startServer({ hostIds: [41, 42], accessibleHostIds: [42] }); + server.db.sqlite.exec("UPDATE ssh_data SET sync_id = 'shared-host'"); + expect(await server.request("GET", "/meeting-host/shared-host")).toEqual({ + status: 200, + body: { id: 42 }, + }); + server.db.sqlite.exec( + "UPDATE ssh_data SET sync_id = 'private-host' WHERE id = 41", + ); + expect( + (await server.request("GET", "/meeting-host/private-host")).status, + ).toBe(404); + expect( + (await server.request("GET", "/meeting-host/missing-host")).status, + ).toBe(404); + expect( + (await server.request("GET", "/meeting-host/shared-host", { user: null })) + .status, + ).toBe(401); + }); + it("creates a room with the creator as host and hides the guest token", async () => { server = await startServer(); const res = await server.request("POST", "/rooms", { diff --git a/plugins/session-sharing/tests/frontend/meeting-backend.test.ts b/plugins/session-sharing/tests/frontend/meeting-backend.test.ts new file mode 100644 index 000000000..0f1d37341 --- /dev/null +++ b/plugins/session-sharing/tests/frontend/meeting-backend.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PluginApiClient, TermixApp } from "@termix/plugin-sdk/frontend"; +import { + connectMeetings, + meetingGuestUrl, +} from "../../src/frontend/meeting-backend"; + +function client(): PluginApiClient { + return Object.fromEntries( + ["get", "post", "put", "patch", "delete"].map((method) => [ + method, + vi.fn(async () => ({ data: { rooms: [] } })), + ]), + ) as unknown as PluginApiClient; +} +afterEach(() => vi.restoreAllMocks()); + +describe("meeting backend", () => { + it("routes rooms, invites and host resolution to the linked server and refuses a later switch", async () => { + const local = client(); + const remote = client(); + let url: string | null = "https://termix.example/base/"; + const app = { + desktop: { available: true, remoteServerUrl: async () => url }, + apiFor: (origin: string) => (origin === "remote" ? remote : local), + t: (key: string) => key, + } as unknown as TermixApp; + const backend = await connectMeetings(app); + expect(backend.origin).toBe("remote"); + await backend.api.listCollabRooms(); + await backend.api.createCollabRoom("Standup", true); + await backend.api.getDirectory(); + await backend.api.resolveHost("stable-host-id"); + await backend.api.presentCollabStage("room", { + hostId: 99, + sessionId: "remote-session", + protocol: "ssh", + }); + expect(local.get).not.toHaveBeenCalled(); + expect(local.post).not.toHaveBeenCalled(); + expect(remote.get).toHaveBeenCalledWith("/meeting-host/stable-host-id"); + expect(remote.post).toHaveBeenCalledWith("/rooms/room/present", { + hostId: 99, + sessionId: "remote-session", + protocol: "ssh", + }); + expect(meetingGuestUrl(backend.publicUrl, "a+b&c")).toBe( + "https://termix.example/base/?view=collab-guest&token=a%2Bb%26c", + ); + url = "https://other.example"; + await expect(backend.api.deleteCollabRoom("room")).rejects.toThrow( + "collab.serverChanged", + ); + expect(remote.delete).not.toHaveBeenCalled(); + url = null; + await expect(backend.api.getCollabRoom("room")).rejects.toThrow( + "collab.serverChanged", + ); + }); + + it("keeps standalone rooms local without generating a file URL", async () => { + const local = client(); + const backend = await connectMeetings({ + desktop: { available: true, remoteServerUrl: async () => null }, + apiFor: () => local, + t: (key: string) => key, + } as unknown as TermixApp); + expect(backend.origin).toBe("local"); + await backend.api.listCollabRooms(); + expect(local.get).toHaveBeenCalledWith("/rooms"); + expect(meetingGuestUrl(backend.publicUrl, "token")).toBeNull(); + expect(meetingGuestUrl("file:///app/index.html", "token")).toBeNull(); + }); + + it("preserves web deployment paths while replacing old query and fragment values", () => { + expect( + meetingGuestUrl("https://termix.example/app/?view=other#old", "token"), + ).toBe("https://termix.example/app/?view=collab-guest&token=token"); + }); +}); diff --git a/plugins/session-sharing/tests/frontend/meeting-transport.test.ts b/plugins/session-sharing/tests/frontend/meeting-transport.test.ts new file mode 100644 index 000000000..166dd355b --- /dev/null +++ b/plugins/session-sharing/tests/frontend/meeting-transport.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from "vitest"; + +const { pluginWsUrl } = vi.hoisted(() => ({ pluginWsUrl: vi.fn() })); +vi.mock("@termix/plugin-sdk/ui", () => ({ pluginWsUrl })); +import { wsTargetForPath } from "../../src/frontend/shared"; + +describe("meeting event transport", () => { + it("preserves remote authentication protocols and event query parameters", async () => { + pluginWsUrl.mockResolvedValue({ + url: "wss://server/base/plugin-ws/ssh-terminal/terminal", + protocols: ["jwt", "session-token"], + }); + expect( + await wsTargetForPath( + "/plugin-ws/ssh-terminal/terminal?room=abc", + "remote", + ), + ).toEqual({ + url: "wss://server/base/plugin-ws/ssh-terminal/terminal?room=abc", + protocols: ["jwt", "session-token"], + }); + expect(pluginWsUrl).toHaveBeenCalledWith("ssh-terminal", "/terminal", { + origin: "remote", + }); + expect( + await wsTargetForPath("https://another-server/ws", "remote"), + ).toBeNull(); + }); +});