mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
411 lines
10 KiB
TypeScript
411 lines
10 KiB
TypeScript
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
|
|
|
/**
|
|
* The plugin's own /plugin-api/session-sharing client, bound in activate.
|
|
* Guest pages use it too: the two guest routes are public.
|
|
*/
|
|
let client: PluginApiClient | null = null;
|
|
|
|
export function bindApi(api: PluginApiClient | null): void {
|
|
client = api;
|
|
}
|
|
|
|
function api(): PluginApiClient {
|
|
if (!client) throw new Error("Session sharing is not running");
|
|
return client;
|
|
}
|
|
|
|
interface HttpError {
|
|
response?: { status?: number; data?: { error?: string } };
|
|
}
|
|
|
|
function statusOf(error: unknown): number | undefined {
|
|
return (error as HttpError)?.response?.status;
|
|
}
|
|
|
|
/** The server's own message, like core's handleApiError. */
|
|
function apiError(error: unknown, fallback: string): Error {
|
|
const message = (error as HttpError)?.response?.data?.error;
|
|
return new Error(message || fallback);
|
|
}
|
|
|
|
async function call<T>(
|
|
request: () => Promise<{ data: T }>,
|
|
fallback: string,
|
|
): Promise<T> {
|
|
try {
|
|
return (await request()).data;
|
|
} catch (error) {
|
|
throw apiError(error, fallback);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Guest share links
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface ResolvedShareLink {
|
|
protocol: "ssh" | "rdp" | "vnc" | "telnet";
|
|
permissionLevel: "read-only" | "read-write";
|
|
wsPath: string;
|
|
connectParams?: { token: string };
|
|
}
|
|
|
|
export type ShareLinkErrorKind = "not-found" | "rate-limited" | "unknown";
|
|
|
|
export class ShareLinkError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly kind: ShareLinkErrorKind,
|
|
) {
|
|
super(message);
|
|
this.name = "ShareLinkError";
|
|
}
|
|
}
|
|
|
|
export async function resolveShareLink(
|
|
linkToken: string,
|
|
): Promise<ResolvedShareLink> {
|
|
try {
|
|
const response = await api().get<ResolvedShareLink>(
|
|
`/resolve/${encodeURIComponent(linkToken)}`,
|
|
);
|
|
return response.data;
|
|
} catch (error) {
|
|
const status = statusOf(error);
|
|
if (status === 404) {
|
|
throw new ShareLinkError(
|
|
"Share link is invalid, expired, or revoked",
|
|
"not-found",
|
|
);
|
|
}
|
|
if (status === 429) {
|
|
throw new ShareLinkError(
|
|
"Too many attempts, please try again shortly",
|
|
"rate-limited",
|
|
);
|
|
}
|
|
throw new ShareLinkError("Failed to resolve share link", "unknown");
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Owner-side shares
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type SessionShareProtocol = "ssh" | "rdp" | "vnc" | "telnet";
|
|
export type SessionShareType = "link" | "user";
|
|
export type SessionSharePermissionLevel = "read-only" | "read-write";
|
|
|
|
export interface SessionShareRecord {
|
|
id: string;
|
|
hostId: number;
|
|
ownerUserId: string;
|
|
protocol: SessionShareProtocol;
|
|
sessionId: string;
|
|
tabInstanceId: string | null;
|
|
shareType: SessionShareType;
|
|
targetUserId: string | null;
|
|
linkToken: string | null;
|
|
permissionLevel: SessionSharePermissionLevel;
|
|
createdAt: string;
|
|
expiresAt: string;
|
|
revokedAt: string | null;
|
|
lastJoinedAt: string | null;
|
|
joinCount: number;
|
|
}
|
|
|
|
export interface CreateSessionShareRequest {
|
|
hostId: number;
|
|
sessionId: string;
|
|
tabInstanceId?: string;
|
|
protocol: SessionShareProtocol;
|
|
shareType: SessionShareType;
|
|
targetUserId?: string;
|
|
permissionLevel: SessionSharePermissionLevel;
|
|
expiryHours?: number;
|
|
}
|
|
|
|
export interface CreateSessionShareResponse {
|
|
shareId: string;
|
|
linkToken: string | null;
|
|
expiresAt: string;
|
|
}
|
|
|
|
export function createSessionShare(
|
|
request: CreateSessionShareRequest,
|
|
): Promise<CreateSessionShareResponse> {
|
|
return call(
|
|
() => api().post<CreateSessionShareResponse>("/create", request),
|
|
"Failed to create session share",
|
|
);
|
|
}
|
|
|
|
export function getActiveSessionShares(
|
|
hostId: number,
|
|
): Promise<{ shares: SessionShareRecord[] }> {
|
|
return call(
|
|
() => api().get<{ shares: SessionShareRecord[] }>(`/host/${hostId}/active`),
|
|
"Failed to fetch active session shares",
|
|
);
|
|
}
|
|
|
|
export function revokeSessionShare(shareId: string): Promise<unknown> {
|
|
return call(
|
|
() => api().delete(`/${encodeURIComponent(shareId)}`),
|
|
"Failed to revoke session share",
|
|
);
|
|
}
|
|
|
|
export interface DirectoryUser {
|
|
id: string;
|
|
username: string;
|
|
}
|
|
|
|
export interface DirectoryRole {
|
|
id: number;
|
|
name: string;
|
|
displayName: string | null;
|
|
}
|
|
|
|
/** Users and roles to share with or invite. */
|
|
export function getDirectory(): Promise<{
|
|
users: DirectoryUser[];
|
|
roles: DirectoryRole[];
|
|
}> {
|
|
return call(
|
|
() =>
|
|
api().get<{ users: DirectoryUser[]; roles: DirectoryRole[] }>(
|
|
"/directory",
|
|
),
|
|
"Failed to list users and roles",
|
|
);
|
|
}
|
|
|
|
/** Live sessions other users shared with the caller, as active-session rows. */
|
|
export function getSharedWithMe(): Promise<unknown[]> {
|
|
return call(
|
|
() => api().get<unknown[]>("/shared-with-me"),
|
|
"Failed to list shared sessions",
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Collab rooms
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface CollabRoom {
|
|
id: string;
|
|
name: string;
|
|
ownerUserId: string;
|
|
persistent: boolean;
|
|
presenterUserId: string | null;
|
|
stageProtocol: string | null;
|
|
stageHostId: number | null;
|
|
stageShareId: string | null;
|
|
guestLinkEnabled: boolean;
|
|
createdAt: string;
|
|
endedAt: string | null;
|
|
}
|
|
|
|
export interface CollabRoomMember {
|
|
userId: string;
|
|
username: string;
|
|
roomRole: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface CollabOnlineUser {
|
|
userId: string;
|
|
username: string;
|
|
}
|
|
|
|
export interface CollabStage {
|
|
presenterUserId: string | null;
|
|
protocol: "ssh" | "rdp" | "vnc" | "telnet" | null;
|
|
hostId: number | null;
|
|
shareId: string | null;
|
|
sessionId?: string;
|
|
controllerUserId?: string | null;
|
|
connectParams?: { token: string };
|
|
}
|
|
|
|
export interface CollabControlRequest {
|
|
userId: string;
|
|
username: string;
|
|
requestedAt: string;
|
|
}
|
|
|
|
export interface CollabRoomDetail {
|
|
room: CollabRoom;
|
|
me: string;
|
|
isHost: boolean;
|
|
members: CollabRoomMember[];
|
|
online: CollabOnlineUser[];
|
|
stage: CollabStage;
|
|
controllerUserId: string | null;
|
|
controlRequests: CollabControlRequest[];
|
|
/** Where live room events are served, as /plugin-ws/<id>/<path>. */
|
|
eventsWsPath: string;
|
|
}
|
|
|
|
const room = (roomId: string, suffix = "") =>
|
|
`/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<CollabRoomDetail> {
|
|
return call(
|
|
() => api().get<CollabRoomDetail>(room(roomId)),
|
|
"Failed to get room",
|
|
);
|
|
}
|
|
|
|
export async function inviteCollabMembers(
|
|
roomId: string,
|
|
targets: { userIds?: string[]; roleIds?: number[] },
|
|
): Promise<void> {
|
|
await call(
|
|
() => api().post(room(roomId, "/members"), targets),
|
|
"Failed to invite members",
|
|
);
|
|
}
|
|
|
|
export async function removeCollabMember(
|
|
roomId: string,
|
|
userId: string,
|
|
): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
await call(
|
|
() =>
|
|
api().delete(
|
|
room(roomId, `/control/requests/${encodeURIComponent(userId)}`),
|
|
),
|
|
"Failed to dismiss control request",
|
|
);
|
|
}
|
|
|
|
export async function endCollabRoom(roomId: string): Promise<void> {
|
|
await call(() => api().post(room(roomId, "/end")), "Failed to end room");
|
|
}
|
|
|
|
export async function deleteCollabRoom(roomId: string): Promise<void> {
|
|
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;
|
|
wsPath?: string;
|
|
connectParams?: { token: string };
|
|
}
|
|
|
|
/** Anonymous: guests poll this to follow the presenter. Throws on 404/429. */
|
|
export async function resolveCollabGuestStage(
|
|
token: string,
|
|
): Promise<{ roomName: string; stage: CollabGuestStage | null }> {
|
|
const response = await api().get<{
|
|
roomName: string;
|
|
stage: CollabGuestStage | null;
|
|
}>(`/guest/${encodeURIComponent(token)}`);
|
|
return response.data;
|
|
}
|