mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: move session sharing and collab rooms into a plugin (B12)
This commit is contained in:
1 parent
791d8656a1
commit
a276f6898c
207 files changed
+35771
-11072
No files matched your search
@@ -0,0 +1,410 @@
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user