mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25: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,51 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { LiveSessions } from "./live.js";
|
||||
import type { RateLimiter } from "./rate-limit.js";
|
||||
import type {
|
||||
Directory,
|
||||
RoomRepository,
|
||||
ShareRepository,
|
||||
} from "./repositories.js";
|
||||
import type { CollabRoomHub } from "./room-hub.js";
|
||||
import type { CollabRuntimeStore } from "./runtime-store.js";
|
||||
|
||||
/** Everything the routes and services share, built once in activate. */
|
||||
export interface SharingDeps {
|
||||
ctx: PluginContext;
|
||||
shares: ShareRepository;
|
||||
rooms: RoomRepository;
|
||||
directory: Directory;
|
||||
live: LiveSessions;
|
||||
hub: CollabRoomHub;
|
||||
store: CollabRuntimeStore;
|
||||
/** /resolve/:linkToken, 30 a minute per IP. */
|
||||
resolveLimiter: RateLimiter;
|
||||
/** Room guest links, 60 a minute per IP. */
|
||||
guestLimiter: RateLimiter;
|
||||
isSharingEnabledForHost: (hostId: number) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export const PROTOCOLS = ["ssh", "rdp", "vnc", "telnet"] as const;
|
||||
|
||||
/** Where SSH shares are joined: the terminal's own socket. */
|
||||
export const TERMINAL_WS_PATH = "/plugin-ws/ssh-terminal/terminal";
|
||||
/** Where remote desktop viewers connect. */
|
||||
export const DISPLAY_WS_PATH = "/plugin-ws/remote-desktop/display";
|
||||
|
||||
export function clientIp(req: {
|
||||
ip?: string;
|
||||
socket?: { remoteAddress?: string };
|
||||
}): string {
|
||||
return req.ip || req.socket?.remoteAddress || "unknown";
|
||||
}
|
||||
|
||||
export function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
/** The acting user, which core set from the authenticated request. */
|
||||
export function actorOf(ctx: PluginContext): string {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) throw new Error("No acting user");
|
||||
return userId;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { SharingDeps } from "./deps.js";
|
||||
import { createLiveSessions, isSharingEnabledForHost } from "./live.js";
|
||||
import { createRateLimiter } from "./rate-limit.js";
|
||||
import {
|
||||
createDirectory,
|
||||
createRoomRepository,
|
||||
createShareRepository,
|
||||
type CoreRefs,
|
||||
type Tables,
|
||||
} from "./repositories.js";
|
||||
import { CollabRoomHub } from "./room-hub.js";
|
||||
import { createRoomActions, registerRoomRoutes } from "./room-routes.js";
|
||||
import { CollabRuntimeStore } from "./runtime-store.js";
|
||||
import {
|
||||
SESSION_GUESTS_KEY,
|
||||
createSessionGuests,
|
||||
createSessionSharingService,
|
||||
} from "./services.js";
|
||||
import { registerShareRoutes } from "./share-routes.js";
|
||||
import { roomMembers, rooms, shareParticipants, shares } from "./tables.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const tables: Tables = {
|
||||
shares: await ctx.db.define(shares),
|
||||
participants: await ctx.db.define(shareParticipants),
|
||||
rooms: await ctx.db.define(rooms),
|
||||
members: await ctx.db.define(roomMembers),
|
||||
};
|
||||
const refs = () => ctx.db.refs<CoreRefs>();
|
||||
|
||||
const directory = createDirectory(ctx.db, refs);
|
||||
const store = new CollabRuntimeStore(ctx.log);
|
||||
const hub = new CollabRoomHub(store);
|
||||
const resolveLimiter = createRateLimiter({
|
||||
windowMs: 60_000,
|
||||
maxAttempts: 30,
|
||||
});
|
||||
const guestLimiter = createRateLimiter({ windowMs: 60_000, maxAttempts: 60 });
|
||||
ctx.disposables.add(() => {
|
||||
hub.dispose();
|
||||
resolveLimiter.dispose();
|
||||
guestLimiter.dispose();
|
||||
return store.close();
|
||||
});
|
||||
|
||||
const deps: SharingDeps = {
|
||||
ctx,
|
||||
shares: createShareRepository(ctx.db, tables),
|
||||
rooms: createRoomRepository(ctx.db, tables, refs),
|
||||
directory,
|
||||
live: createLiveSessions(ctx),
|
||||
hub,
|
||||
store,
|
||||
resolveLimiter,
|
||||
guestLimiter,
|
||||
isSharingEnabledForHost: (hostId) =>
|
||||
isSharingEnabledForHost(ctx, directory.hostExists, hostId),
|
||||
};
|
||||
|
||||
const actions = createRoomActions(deps);
|
||||
store.onEvent(actions.onRuntimeEvent);
|
||||
|
||||
// Guest links carry their own token, checked in the handlers.
|
||||
const router = ctx.http.router<Router>({
|
||||
public: ["/resolve/:linkToken", "/guest/:token"],
|
||||
});
|
||||
registerShareRoutes(router, deps);
|
||||
registerRoomRoutes(router, deps, actions);
|
||||
|
||||
ctx.services.provide("sessions.sharing", createSessionSharingService(deps));
|
||||
ctx.registry.provide(SESSION_GUESTS_KEY, createSessionGuests(deps));
|
||||
|
||||
ctx.log.info("Session sharing mounted at /plugin-api/session-sharing");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
// Everything above was registered through ctx and is disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { LiveProtocol } from "./repositories.js";
|
||||
|
||||
export const LIVE_SESSIONS_SERVICE = "sessions.live";
|
||||
|
||||
/** What every sessions.live provider reports about one session. */
|
||||
export interface LiveSessionInfo {
|
||||
id: string;
|
||||
userId: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
isConnected: boolean;
|
||||
createdAt: number;
|
||||
tabInstanceId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* sessions.live v1, keyed by session type. ssh-terminal provides "ssh";
|
||||
* remote-desktop provides "rdp", "vnc" and "telnet". Through a service handle
|
||||
* every method answers with a promise. The control methods are the terminal's
|
||||
* (a remote desktop viewer cannot be kicked or handed control), and
|
||||
* createViewerToken is remote desktop's: a join token for a viewer.
|
||||
*/
|
||||
export interface LiveSessionProvider {
|
||||
getSession: (sessionId: string) => LiveSessionInfo | null;
|
||||
ownerEndSession?: (sessionId: string, reason: string) => void;
|
||||
disconnectParticipants?: (
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
options: { reason: string; userId?: string | null },
|
||||
) => number;
|
||||
setRoomShareControl?: (
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
controllerUserId: string | null,
|
||||
) => void;
|
||||
createViewerToken?: (sessionId: string, readOnly: boolean) => string;
|
||||
}
|
||||
|
||||
type Async<T> = {
|
||||
[K in keyof T]-?: T[K] extends ((...args: infer A) => infer R) | undefined
|
||||
? (...args: A) => Promise<R>
|
||||
: never;
|
||||
};
|
||||
|
||||
export type LiveSessions = ReturnType<typeof createLiveSessions>;
|
||||
|
||||
/**
|
||||
* Reaches live sessions through sessions.live as the acting user. A caller
|
||||
* with no actor (a public guest route) passes the share owner, who owns the
|
||||
* session being looked at.
|
||||
*/
|
||||
export function createLiveSessions(ctx: PluginContext) {
|
||||
async function run<T>(
|
||||
asUser: string | undefined,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (asUser && ctx.currentActor() !== asUser) {
|
||||
return ctx.asUser(asUser, fn);
|
||||
}
|
||||
return fn();
|
||||
}
|
||||
|
||||
function provider(protocol: string): Async<LiveSessionProvider> | null {
|
||||
const handle = ctx.services.get<Async<LiveSessionProvider>>(
|
||||
LIVE_SESSIONS_SERVICE,
|
||||
{ provider: protocol },
|
||||
);
|
||||
return "getSession" in handle ? handle : null;
|
||||
}
|
||||
|
||||
async function getSession(
|
||||
protocol: string,
|
||||
sessionId: string,
|
||||
asUser?: string,
|
||||
): Promise<LiveSessionInfo | null> {
|
||||
const live = provider(protocol);
|
||||
if (!live) return null;
|
||||
try {
|
||||
return (await run(asUser, () => live.getSession(sessionId))) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getSession,
|
||||
|
||||
async isLive(
|
||||
protocol: string,
|
||||
sessionId: string,
|
||||
asUser?: string,
|
||||
): Promise<boolean> {
|
||||
const session = await getSession(protocol, sessionId, asUser);
|
||||
return !!session?.isConnected;
|
||||
},
|
||||
|
||||
async isOwnedBy(
|
||||
protocol: LiveProtocol,
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const session = await getSession(protocol, sessionId);
|
||||
return !!session && session.isConnected && session.userId === userId;
|
||||
},
|
||||
|
||||
/** Ends the session for every guest. Best effort, like before. */
|
||||
async ownerEndSession(
|
||||
protocol: string,
|
||||
sessionId: string,
|
||||
reason: string,
|
||||
asUser?: string,
|
||||
): Promise<void> {
|
||||
const live = provider(protocol);
|
||||
if (!live || !("ownerEndSession" in live)) return;
|
||||
await run(asUser, () => live.ownerEndSession(sessionId, reason)).catch(
|
||||
() => {},
|
||||
);
|
||||
},
|
||||
|
||||
async disconnectParticipants(
|
||||
protocol: string,
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
options: { reason: string; userId?: string | null },
|
||||
asUser?: string,
|
||||
): Promise<void> {
|
||||
const live = provider(protocol);
|
||||
if (!live || !("disconnectParticipants" in live)) return;
|
||||
await run(asUser, () =>
|
||||
live.disconnectParticipants(sessionId, shareId, options),
|
||||
).catch(() => {});
|
||||
},
|
||||
|
||||
async setRoomShareControl(
|
||||
protocol: string,
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
controllerUserId: string | null,
|
||||
asUser?: string,
|
||||
): Promise<void> {
|
||||
const live = provider(protocol);
|
||||
if (!live || !("setRoomShareControl" in live)) return;
|
||||
await run(asUser, () =>
|
||||
live.setRoomShareControl(sessionId, shareId, controllerUserId),
|
||||
).catch(() => {});
|
||||
},
|
||||
|
||||
/** A viewer token for a remote desktop session. Throws while unavailable. */
|
||||
async createViewerToken(
|
||||
protocol: string,
|
||||
sessionId: string,
|
||||
readOnly: boolean,
|
||||
asUser?: string,
|
||||
): Promise<string> {
|
||||
const live = provider(protocol);
|
||||
if (!live || !("createViewerToken" in live)) {
|
||||
throw new Error(`No ${protocol} session provider is running`);
|
||||
}
|
||||
return run(asUser, () => live.createViewerToken(sessionId, readOnly));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether sessions on a host may be shared: the admin switch first, then the
|
||||
* host's own setting. Both default to on. A host that does not exist cannot
|
||||
* be shared.
|
||||
*/
|
||||
export async function isSharingEnabledForHost(
|
||||
ctx: PluginContext,
|
||||
hostExists: (hostId: number) => Promise<boolean>,
|
||||
hostId: number,
|
||||
): Promise<boolean> {
|
||||
if ((await ctx.settings.get<boolean>("globallyEnabled")) === false) {
|
||||
return false;
|
||||
}
|
||||
if (!(await hostExists(hostId))) return false;
|
||||
return (
|
||||
(await ctx.settings.getHost<boolean>(hostId, "allowSessionSharing")) !==
|
||||
false
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* A per-IP fixed window, for the public guest endpoints. `dispose` stops the
|
||||
* sweep that forgets old windows.
|
||||
*/
|
||||
export function createRateLimiter(options: {
|
||||
windowMs: number;
|
||||
maxAttempts: number;
|
||||
}) {
|
||||
const attempts = new Map<string, { count: number; windowStart: number }>();
|
||||
const sweep = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, entry] of attempts) {
|
||||
if (now - entry.windowStart > options.windowMs) attempts.delete(ip);
|
||||
}
|
||||
}, 5 * options.windowMs);
|
||||
sweep.unref();
|
||||
|
||||
return {
|
||||
/** Counts an attempt and says whether this IP is over the limit. */
|
||||
isLimited(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const entry = attempts.get(ip);
|
||||
if (!entry || now - entry.windowStart > options.windowMs) {
|
||||
attempts.set(ip, { count: 1, windowStart: now });
|
||||
return false;
|
||||
}
|
||||
entry.count += 1;
|
||||
return entry.count > options.maxAttempts;
|
||||
},
|
||||
dispose(): void {
|
||||
clearInterval(sweep);
|
||||
attempts.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type RateLimiter = ReturnType<typeof createRateLimiter>;
|
||||
@@ -0,0 +1,580 @@
|
||||
import { and, desc, eq, gt, inArray, isNull } from "drizzle-orm";
|
||||
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// Tables come from ctx.db.define, which the SDK hands back untyped, and the
|
||||
// drizzle handle is the server's own. Typed at this module's edge instead.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export type ShareType = "link" | "user" | "room";
|
||||
export type PermissionLevel = "read-only" | "read-write";
|
||||
export type LiveProtocol = "ssh" | "rdp" | "vnc" | "telnet";
|
||||
|
||||
export interface ShareRecord {
|
||||
id: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: string;
|
||||
sessionId: string;
|
||||
tabInstanceId: string | null;
|
||||
shareType: string;
|
||||
targetUserId: string | null;
|
||||
linkToken: string | null;
|
||||
permissionLevel: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
revokedAt: string | null;
|
||||
lastJoinedAt: string | null;
|
||||
joinCount: number;
|
||||
}
|
||||
|
||||
export interface ShareCreateInput {
|
||||
id: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: string;
|
||||
sessionId: string;
|
||||
tabInstanceId?: string | null;
|
||||
shareType: ShareType;
|
||||
targetUserId?: string | null;
|
||||
linkToken?: string | null;
|
||||
permissionLevel: PermissionLevel;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface RoomRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerUserId: string;
|
||||
persistent: boolean;
|
||||
presenterUserId: string | null;
|
||||
stageProtocol: string | null;
|
||||
stageHostId: number | null;
|
||||
stageShareId: string | null;
|
||||
guestLinkToken: string | null;
|
||||
createdAt: string;
|
||||
endedAt: string | null;
|
||||
}
|
||||
|
||||
export interface RoomMemberRecord {
|
||||
id: number;
|
||||
roomId: string;
|
||||
userId: string;
|
||||
roomRole: string;
|
||||
addedBy: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RoomMemberWithUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
roomRole: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RoomStage {
|
||||
presenterUserId: string | null;
|
||||
stageProtocol: string | null;
|
||||
stageHostId: number | null;
|
||||
stageShareId: string | null;
|
||||
}
|
||||
|
||||
export interface Tables {
|
||||
shares: Table;
|
||||
participants: Table;
|
||||
rooms: Table;
|
||||
members: Table;
|
||||
}
|
||||
|
||||
export interface CoreRefs {
|
||||
users: Table;
|
||||
hosts: Table;
|
||||
roles: Table;
|
||||
userRoles: Table;
|
||||
}
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
/** Not revoked and not expired. Expiry is compared as ISO text, as before. */
|
||||
function activeShare(shares: Table, now: string) {
|
||||
return and(isNull(shares.revokedAt), gt(shares.expiresAt, now));
|
||||
}
|
||||
|
||||
export type ShareRepository = ReturnType<typeof createShareRepository>;
|
||||
|
||||
export function createShareRepository(db: PluginDatabase, tables: Tables) {
|
||||
const client = () => db.client<Drizzle>();
|
||||
const { shares, participants } = tables;
|
||||
|
||||
async function findById(id: string): Promise<ShareRecord | null> {
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select()
|
||||
.from(shares)
|
||||
.where(eq(shares.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
findById,
|
||||
|
||||
async create(input: ShareCreateInput): Promise<ShareRecord> {
|
||||
await (await client()).insert(shares).values({
|
||||
id: input.id,
|
||||
hostId: input.hostId,
|
||||
ownerUserId: input.ownerUserId,
|
||||
protocol: input.protocol,
|
||||
sessionId: input.sessionId,
|
||||
tabInstanceId: input.tabInstanceId ?? null,
|
||||
shareType: input.shareType,
|
||||
targetUserId: input.targetUserId ?? null,
|
||||
linkToken: input.linkToken ?? null,
|
||||
permissionLevel: input.permissionLevel,
|
||||
expiresAt: input.expiresAt,
|
||||
});
|
||||
await db.persist();
|
||||
return (await findById(input.id))!;
|
||||
},
|
||||
|
||||
async findActiveById(
|
||||
id: string,
|
||||
now = nowIso(),
|
||||
): Promise<ShareRecord | null> {
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select()
|
||||
.from(shares)
|
||||
.where(and(eq(shares.id, id), activeShare(shares, now)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
async findByLinkToken(
|
||||
linkToken: string,
|
||||
now = nowIso(),
|
||||
): Promise<ShareRecord | null> {
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select()
|
||||
.from(shares)
|
||||
.where(and(eq(shares.linkToken, linkToken), activeShare(shares, now)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
async findActiveSharesForHost(
|
||||
hostId: number,
|
||||
ownerUserId: string,
|
||||
now = nowIso(),
|
||||
): Promise<ShareRecord[]> {
|
||||
return (await client())
|
||||
.select()
|
||||
.from(shares)
|
||||
.where(
|
||||
and(
|
||||
eq(shares.hostId, hostId),
|
||||
eq(shares.ownerUserId, ownerUserId),
|
||||
activeShare(shares, now),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
/** Active user shares aimed at this user. */
|
||||
async findSharesTargetingUser(
|
||||
userId: string,
|
||||
now = nowIso(),
|
||||
): Promise<ShareRecord[]> {
|
||||
return (await client())
|
||||
.select()
|
||||
.from(shares)
|
||||
.where(
|
||||
and(
|
||||
eq(shares.shareType, "user"),
|
||||
eq(shares.targetUserId, userId),
|
||||
activeShare(shares, now),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
/** Revokes a share the given user owns. False when it is not theirs. */
|
||||
async revoke(shareId: string, ownerUserId: string): Promise<boolean> {
|
||||
const share = await findById(shareId);
|
||||
if (!share || share.ownerUserId !== ownerUserId) return false;
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.update(shares)
|
||||
.set({ revokedAt: nowIso() })
|
||||
.where(eq(shares.id, shareId));
|
||||
await db.persist();
|
||||
return true;
|
||||
},
|
||||
|
||||
async revokeAny(shareId: string): Promise<boolean> {
|
||||
if (!(await findById(shareId))) return false;
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.update(shares)
|
||||
.set({ revokedAt: nowIso() })
|
||||
.where(eq(shares.id, shareId));
|
||||
await db.persist();
|
||||
return true;
|
||||
},
|
||||
|
||||
async touchUsage(shareId: string): Promise<void> {
|
||||
const current = await findById(shareId);
|
||||
if (!current) return;
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.update(shares)
|
||||
.set({
|
||||
lastJoinedAt: nowIso(),
|
||||
joinCount: (current.joinCount ?? 0) + 1,
|
||||
})
|
||||
.where(eq(shares.id, shareId));
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async recordParticipantJoin(
|
||||
shareId: string,
|
||||
userId: string | null,
|
||||
guestLabel: string | null,
|
||||
): Promise<void> {
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.insert(participants)
|
||||
.values({ shareId, userId, guestLabel });
|
||||
await db.persist();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type RoomRepository = ReturnType<typeof createRoomRepository>;
|
||||
|
||||
export function createRoomRepository(
|
||||
db: PluginDatabase,
|
||||
tables: Tables,
|
||||
refs: () => Promise<CoreRefs>,
|
||||
) {
|
||||
const client = () => db.client<Drizzle>();
|
||||
const { rooms, members } = tables;
|
||||
|
||||
async function findById(id: string): Promise<RoomRecord | null> {
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select()
|
||||
.from(rooms)
|
||||
.where(eq(rooms.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function findMember(
|
||||
roomId: string,
|
||||
userId: string,
|
||||
): Promise<RoomMemberRecord | null> {
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select()
|
||||
.from(members)
|
||||
.where(and(eq(members.roomId, roomId), eq(members.userId, userId)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function updateStage(roomId: string, stage: RoomStage): Promise<void> {
|
||||
await (await client()).update(rooms).set(stage).where(eq(rooms.id, roomId));
|
||||
await db.persist();
|
||||
}
|
||||
|
||||
return {
|
||||
findById,
|
||||
findMember,
|
||||
updateStage,
|
||||
|
||||
async createRoom(input: {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerUserId: string;
|
||||
persistent: boolean;
|
||||
}): Promise<RoomRecord> {
|
||||
await (await client()).insert(rooms).values(input);
|
||||
await db.persist();
|
||||
return (await findById(input.id))!;
|
||||
},
|
||||
|
||||
/** The live room whose stage points at this share, if any. */
|
||||
async findByStageShareId(shareId: string): Promise<RoomRecord | null> {
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select()
|
||||
.from(rooms)
|
||||
.where(and(eq(rooms.stageShareId, shareId), isNull(rooms.endedAt)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
async findByGuestToken(token: string): Promise<RoomRecord | null> {
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select()
|
||||
.from(rooms)
|
||||
.where(and(eq(rooms.guestLinkToken, token), isNull(rooms.endedAt)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
async setGuestToken(roomId: string, token: string | null): Promise<void> {
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.update(rooms)
|
||||
.set({ guestLinkToken: token })
|
||||
.where(eq(rooms.id, roomId));
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async listForUser(userId: string): Promise<RoomRecord[]> {
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select({ room: rooms })
|
||||
.from(members)
|
||||
.innerJoin(rooms, eq(members.roomId, rooms.id))
|
||||
.where(and(eq(members.userId, userId), isNull(rooms.endedAt)))
|
||||
.orderBy(desc(rooms.createdAt));
|
||||
return rows.map((row: { room: RoomRecord }) => row.room);
|
||||
},
|
||||
|
||||
async addMember(input: {
|
||||
roomId: string;
|
||||
userId: string;
|
||||
roomRole: "host" | "member";
|
||||
addedBy: string | null;
|
||||
}): Promise<boolean> {
|
||||
if (await findMember(input.roomId, input.userId)) return false;
|
||||
await (await client()).insert(members).values(input);
|
||||
await db.persist();
|
||||
return true;
|
||||
},
|
||||
|
||||
async removeMember(roomId: string, userId: string): Promise<void> {
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.delete(members)
|
||||
.where(and(eq(members.roomId, roomId), eq(members.userId, userId)));
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async listMembers(roomId: string): Promise<RoomMemberWithUser[]> {
|
||||
const rows: RoomMemberRecord[] = await (
|
||||
await client()
|
||||
)
|
||||
.select()
|
||||
.from(members)
|
||||
.where(eq(members.roomId, roomId));
|
||||
const names = await usernames(rows.map((row) => row.userId));
|
||||
return rows
|
||||
.filter((row) => names.has(row.userId))
|
||||
.map((row) => ({
|
||||
userId: row.userId,
|
||||
username: names.get(row.userId)!,
|
||||
roomRole: row.roomRole,
|
||||
createdAt: row.createdAt,
|
||||
}))
|
||||
.sort((a, b) => a.username.localeCompare(b.username));
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets the stage only if it still points at `expectedShareId`, so two
|
||||
* members taking the stage at once cannot both win.
|
||||
*/
|
||||
async replaceStage(
|
||||
roomId: string,
|
||||
expectedShareId: string | null,
|
||||
stage: RoomStage,
|
||||
): Promise<boolean> {
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.update(rooms)
|
||||
.set(stage)
|
||||
.where(
|
||||
and(
|
||||
eq(rooms.id, roomId),
|
||||
expectedShareId
|
||||
? eq(rooms.stageShareId, expectedShareId)
|
||||
: isNull(rooms.stageShareId),
|
||||
),
|
||||
);
|
||||
const after = await findById(roomId);
|
||||
const changed = !!after && after.stageShareId === stage.stageShareId;
|
||||
if (changed) await db.persist();
|
||||
return changed;
|
||||
},
|
||||
|
||||
async clearStage(roomId: string): Promise<void> {
|
||||
await updateStage(roomId, {
|
||||
presenterUserId: null,
|
||||
stageProtocol: null,
|
||||
stageHostId: null,
|
||||
stageShareId: null,
|
||||
});
|
||||
},
|
||||
|
||||
async endRoom(roomId: string): Promise<void> {
|
||||
await (
|
||||
await client()
|
||||
)
|
||||
.update(rooms)
|
||||
.set({
|
||||
endedAt: nowIso(),
|
||||
presenterUserId: null,
|
||||
stageProtocol: null,
|
||||
stageHostId: null,
|
||||
stageShareId: null,
|
||||
})
|
||||
.where(eq(rooms.id, roomId));
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async deleteRoom(roomId: string): Promise<void> {
|
||||
const drizzle = await client();
|
||||
// Not left to the foreign key: SQLite only cascades with foreign_keys on.
|
||||
await drizzle.delete(members).where(eq(members.roomId, roomId));
|
||||
await drizzle.delete(rooms).where(eq(rooms.id, roomId));
|
||||
await db.persist();
|
||||
},
|
||||
};
|
||||
|
||||
async function usernames(ids: string[]): Promise<Map<string, string>> {
|
||||
if (ids.length === 0) return new Map();
|
||||
const { users } = await refs();
|
||||
const rows: Array<{ id: string; username: string }> = await (
|
||||
await client()
|
||||
)
|
||||
.select({ id: users.id, username: users.username })
|
||||
.from(users)
|
||||
.where(inArray(users.id, ids));
|
||||
return new Map(rows.map((row) => [row.id, row.username]));
|
||||
}
|
||||
}
|
||||
|
||||
export type Directory = ReturnType<typeof createDirectory>;
|
||||
|
||||
/** Users and roles, read through ctx.db.refs, for names and invites. */
|
||||
export function createDirectory(
|
||||
db: PluginDatabase,
|
||||
refs: () => Promise<CoreRefs>,
|
||||
) {
|
||||
const client = () => db.client<Drizzle>();
|
||||
|
||||
return {
|
||||
async username(userId: string): Promise<string> {
|
||||
const { users } = await refs();
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select({ username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
return rows[0]?.username ?? userId;
|
||||
},
|
||||
|
||||
async userExists(userId: string): Promise<boolean> {
|
||||
const { users } = await refs();
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
},
|
||||
|
||||
async isAdmin(userId: string): Promise<boolean> {
|
||||
const { users } = await refs();
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select({ isAdmin: users.isAdmin })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
return !!rows[0]?.isAdmin;
|
||||
},
|
||||
|
||||
async hostExists(hostId: number): Promise<boolean> {
|
||||
const { hosts } = await refs();
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, hostId))
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
},
|
||||
|
||||
async roleExists(roleId: number): Promise<boolean> {
|
||||
const { roles } = await refs();
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select({ id: roles.id })
|
||||
.from(roles)
|
||||
.where(eq(roles.id, roleId))
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
},
|
||||
|
||||
async roleUserIds(roleId: number): Promise<string[]> {
|
||||
const { userRoles } = await refs();
|
||||
const rows: Array<{ userId: string }> = await (
|
||||
await client()
|
||||
)
|
||||
.select({ userId: userRoles.userId })
|
||||
.from(userRoles)
|
||||
.where(eq(userRoles.roleId, roleId));
|
||||
return rows.map((row) => row.userId);
|
||||
},
|
||||
|
||||
async listUsers(): Promise<Array<{ id: string; username: string }>> {
|
||||
const { users } = await refs();
|
||||
return (await client())
|
||||
.select({ id: users.id, username: users.username })
|
||||
.from(users)
|
||||
.orderBy(users.username);
|
||||
},
|
||||
|
||||
async listRoles(): Promise<
|
||||
Array<{ id: number; name: string; displayName: string | null }>
|
||||
> {
|
||||
const { roles } = await refs();
|
||||
return (await client())
|
||||
.select({
|
||||
id: roles.id,
|
||||
name: roles.name,
|
||||
displayName: roles.displayName,
|
||||
})
|
||||
.from(roles)
|
||||
.orderBy(roles.name);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { WebSocket } from "ws";
|
||||
import type { CollabRuntimeStore } from "./runtime-store.js";
|
||||
|
||||
const PRESENCE_HEARTBEAT_MS = 15_000;
|
||||
|
||||
export interface CollabRoomClient {
|
||||
ws: WebSocket;
|
||||
userId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local WebSocket fan-out plus the runtime store's pub/sub for room events and
|
||||
* presence across instances. The terminal socket feeds subscribe and
|
||||
* unsubscribe through the sessions.sharing service.
|
||||
*/
|
||||
export class CollabRoomHub {
|
||||
private rooms = new Map<string, Set<CollabRoomClient>>();
|
||||
private readonly heartbeat: NodeJS.Timeout;
|
||||
|
||||
constructor(private readonly store: CollabRuntimeStore) {
|
||||
store.onEvent((roomId, message) => {
|
||||
if (
|
||||
"type" in message &&
|
||||
typeof message.type === "string" &&
|
||||
message.type.startsWith("collab_internal_")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.broadcastLocal(roomId, message);
|
||||
});
|
||||
this.heartbeat = setInterval(() => {
|
||||
for (const roomId of this.rooms.keys()) void this.refreshPresence(roomId);
|
||||
}, PRESENCE_HEARTBEAT_MS);
|
||||
this.heartbeat.unref();
|
||||
}
|
||||
|
||||
subscribe(roomId: string, client: CollabRoomClient): void {
|
||||
let clients = this.rooms.get(roomId);
|
||||
if (!clients) {
|
||||
clients = new Set();
|
||||
this.rooms.set(roomId, clients);
|
||||
}
|
||||
for (const existing of clients) {
|
||||
if (existing.ws === client.ws) return;
|
||||
}
|
||||
clients.add(client);
|
||||
void this.refreshPresence(roomId);
|
||||
}
|
||||
|
||||
/** Drops the socket from one room, or from every room when roomId is omitted. */
|
||||
unsubscribe(ws: WebSocket, roomId?: string): void {
|
||||
for (const [id, clients] of this.rooms) {
|
||||
if (roomId && id !== roomId) continue;
|
||||
let removed = false;
|
||||
for (const client of clients) {
|
||||
if (client.ws === ws) {
|
||||
clients.delete(client);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
if (clients.size === 0) this.rooms.delete(id);
|
||||
if (removed) void this.refreshPresence(id);
|
||||
}
|
||||
}
|
||||
|
||||
broadcast(roomId: string, message: object): void {
|
||||
this.broadcastLocal(roomId, message);
|
||||
void this.store.publish(roomId, message);
|
||||
}
|
||||
|
||||
async onlineUsers(
|
||||
roomId: string,
|
||||
): Promise<Array<{ userId: string; username: string }>> {
|
||||
return this.store.onlineUsers(roomId, this.localOnlineUsers(roomId));
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
clearInterval(this.heartbeat);
|
||||
this.rooms.clear();
|
||||
}
|
||||
|
||||
private broadcastLocal(roomId: string, message: object): void {
|
||||
const clients = this.rooms.get(roomId);
|
||||
if (!clients) return;
|
||||
const payload = JSON.stringify(message);
|
||||
for (const client of clients) {
|
||||
if (client.ws.readyState !== client.ws.OPEN) continue;
|
||||
try {
|
||||
client.ws.send(payload);
|
||||
} catch {
|
||||
/* keep broadcasting to the rest */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private localOnlineUsers(
|
||||
roomId: string,
|
||||
): Array<{ userId: string; username: string }> {
|
||||
const clients = this.rooms.get(roomId);
|
||||
if (!clients) return [];
|
||||
const seen = new Map<string, string>();
|
||||
for (const client of clients) {
|
||||
seen.set(client.userId, client.username);
|
||||
}
|
||||
return Array.from(seen, ([userId, username]) => ({ userId, username }));
|
||||
}
|
||||
|
||||
private async refreshPresence(roomId: string): Promise<void> {
|
||||
const localUsers = this.localOnlineUsers(roomId);
|
||||
await this.store.updatePresence(roomId, localUsers);
|
||||
this.broadcast(roomId, {
|
||||
type: "collab_online",
|
||||
roomId,
|
||||
users: await this.onlineUsers(roomId),
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,289 @@
|
||||
import crypto from "node:crypto";
|
||||
import { createClient } from "redis";
|
||||
import type { PluginLogger } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export interface CollabControlRequest {
|
||||
userId: string;
|
||||
username: string;
|
||||
requestedAt: string;
|
||||
}
|
||||
|
||||
interface PresenceEntry {
|
||||
instanceId: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface EventEnvelope {
|
||||
source: string;
|
||||
roomId: string;
|
||||
message: object;
|
||||
}
|
||||
|
||||
const KEY_PREFIX = process.env.TERMIX_REDIS_PREFIX?.trim() || "termix:collab";
|
||||
const EVENT_CHANNEL = `${KEY_PREFIX}:events`;
|
||||
const PRESENCE_TTL_MS = 45_000;
|
||||
const STATE_TTL_SECONDS = 12 * 60 * 60;
|
||||
const CONNECT_RETRY_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Room state that has to be shared between Termix instances: who controls a
|
||||
* stage, pending control requests, presence and room events. Redis-backed when
|
||||
* REDIS_URL is set, with local state for a single instance.
|
||||
*/
|
||||
export class CollabRuntimeStore {
|
||||
constructor(private readonly log: PluginLogger) {}
|
||||
|
||||
private readonly instanceId = crypto.randomUUID();
|
||||
private publisher: ReturnType<typeof createClient> | null = null;
|
||||
private subscriber: ReturnType<typeof createClient> | null = null;
|
||||
private connecting: Promise<boolean> | null = null;
|
||||
private nextConnectAttempt = 0;
|
||||
private eventListeners = new Set<(roomId: string, message: object) => void>();
|
||||
private localControllers = new Map<string, string>();
|
||||
private localRequests = new Map<string, Map<string, CollabControlRequest>>();
|
||||
private presenceMembers = new Map<string, Set<string>>();
|
||||
|
||||
onEvent(listener: (roomId: string, message: object) => void): void {
|
||||
this.eventListeners.add(listener);
|
||||
void this.ensureConnected();
|
||||
}
|
||||
|
||||
async publish(roomId: string, message: object): Promise<void> {
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||
await this.publisher
|
||||
.publish(
|
||||
EVENT_CHANNEL,
|
||||
JSON.stringify({ source: this.instanceId, roomId, message }),
|
||||
)
|
||||
.catch((error) => this.logRedisFailure("publish", error));
|
||||
}
|
||||
|
||||
async getController(roomId: string): Promise<string | null> {
|
||||
if (await this.ensureConnected()) {
|
||||
const value = await this.publisher
|
||||
?.get(`${KEY_PREFIX}:controller:${roomId}`)
|
||||
.catch((error) => {
|
||||
this.logRedisFailure("get_controller", error);
|
||||
return undefined;
|
||||
});
|
||||
if (value !== undefined) return value;
|
||||
}
|
||||
return this.localControllers.get(roomId) ?? null;
|
||||
}
|
||||
|
||||
async setController(roomId: string, userId: string | null): Promise<void> {
|
||||
if (userId) this.localControllers.set(roomId, userId);
|
||||
else this.localControllers.delete(roomId);
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||
const key = `${KEY_PREFIX}:controller:${roomId}`;
|
||||
const operation = userId
|
||||
? this.publisher.set(key, userId, { EX: STATE_TTL_SECONDS })
|
||||
: this.publisher.del(key);
|
||||
await operation.catch((error) =>
|
||||
this.logRedisFailure("set_controller", error),
|
||||
);
|
||||
}
|
||||
|
||||
async listRequests(roomId: string): Promise<CollabControlRequest[]> {
|
||||
if (await this.ensureConnected()) {
|
||||
const values = await this.publisher
|
||||
?.hVals(`${KEY_PREFIX}:requests:${roomId}`)
|
||||
.catch((error) => {
|
||||
this.logRedisFailure("list_requests", error);
|
||||
return null;
|
||||
});
|
||||
if (values) {
|
||||
return values
|
||||
.flatMap((value) => this.parse<CollabControlRequest>(value) ?? [])
|
||||
.sort((a, b) => a.requestedAt.localeCompare(b.requestedAt));
|
||||
}
|
||||
}
|
||||
return Array.from(this.localRequests.get(roomId)?.values() ?? []).sort(
|
||||
(a, b) => a.requestedAt.localeCompare(b.requestedAt),
|
||||
);
|
||||
}
|
||||
|
||||
async upsertRequest(
|
||||
roomId: string,
|
||||
request: CollabControlRequest,
|
||||
): Promise<void> {
|
||||
let requests = this.localRequests.get(roomId);
|
||||
if (!requests) {
|
||||
requests = new Map();
|
||||
this.localRequests.set(roomId, requests);
|
||||
}
|
||||
requests.set(request.userId, request);
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||
const key = `${KEY_PREFIX}:requests:${roomId}`;
|
||||
await this.publisher
|
||||
.multi()
|
||||
.hSet(key, request.userId, JSON.stringify(request))
|
||||
.expire(key, STATE_TTL_SECONDS)
|
||||
.exec()
|
||||
.catch((error) => this.logRedisFailure("upsert_request", error));
|
||||
}
|
||||
|
||||
async removeRequest(roomId: string, userId: string): Promise<void> {
|
||||
const requests = this.localRequests.get(roomId);
|
||||
requests?.delete(userId);
|
||||
if (requests?.size === 0) this.localRequests.delete(roomId);
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||
await this.publisher
|
||||
.hDel(`${KEY_PREFIX}:requests:${roomId}`, userId)
|
||||
.catch((error) => this.logRedisFailure("remove_request", error));
|
||||
}
|
||||
|
||||
async clearRequests(roomId: string): Promise<void> {
|
||||
this.localRequests.delete(roomId);
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||
await this.publisher
|
||||
.del(`${KEY_PREFIX}:requests:${roomId}`)
|
||||
.catch((error) => this.logRedisFailure("clear_requests", error));
|
||||
}
|
||||
|
||||
async updatePresence(
|
||||
roomId: string,
|
||||
users: Array<{ userId: string; username: string }>,
|
||||
): Promise<void> {
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return;
|
||||
const key = `${KEY_PREFIX}:presence:${roomId}`;
|
||||
const previous = this.presenceMembers.get(roomId) ?? new Set<string>();
|
||||
const expiresAt = Date.now() + PRESENCE_TTL_MS;
|
||||
const current = new Set(
|
||||
users.map((user) =>
|
||||
JSON.stringify({
|
||||
instanceId: this.instanceId,
|
||||
...user,
|
||||
} satisfies PresenceEntry),
|
||||
),
|
||||
);
|
||||
const transaction = this.publisher.multi();
|
||||
if (previous.size > 0) transaction.zRem(key, Array.from(previous));
|
||||
for (const member of current) {
|
||||
transaction.zAdd(key, { score: expiresAt, value: member });
|
||||
}
|
||||
transaction.expire(key, Math.ceil(PRESENCE_TTL_MS / 1000) * 2);
|
||||
await transaction
|
||||
.exec()
|
||||
.then(() => {
|
||||
if (current.size > 0) this.presenceMembers.set(roomId, current);
|
||||
else this.presenceMembers.delete(roomId);
|
||||
})
|
||||
.catch((error) => this.logRedisFailure("update_presence", error));
|
||||
}
|
||||
|
||||
async onlineUsers(
|
||||
roomId: string,
|
||||
localUsers: Array<{ userId: string; username: string }>,
|
||||
): Promise<Array<{ userId: string; username: string }>> {
|
||||
if (!(await this.ensureConnected()) || !this.publisher) return localUsers;
|
||||
const key = `${KEY_PREFIX}:presence:${roomId}`;
|
||||
const now = Date.now();
|
||||
const transaction = this.publisher
|
||||
.multi()
|
||||
.zRemRangeByScore(key, 0, now)
|
||||
.zRangeByScore(key, now + 1, "+inf");
|
||||
const result = await transaction.exec().catch((error) => {
|
||||
this.logRedisFailure("online_users", error);
|
||||
return null;
|
||||
});
|
||||
const members = result?.[1] as string[] | undefined;
|
||||
if (!members) return localUsers;
|
||||
const seen = new Map<string, string>();
|
||||
for (const member of members) {
|
||||
const entry = this.parse<PresenceEntry>(member);
|
||||
if (entry) seen.set(entry.userId, entry.username);
|
||||
}
|
||||
return Array.from(seen, ([userId, username]) => ({ userId, username }));
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.eventListeners.clear();
|
||||
this.nextConnectAttempt = Number.POSITIVE_INFINITY;
|
||||
await Promise.allSettled([
|
||||
this.publisher?.isOpen ? this.publisher.quit() : Promise.resolve(),
|
||||
this.subscriber?.isOpen ? this.subscriber.quit() : Promise.resolve(),
|
||||
]);
|
||||
this.publisher = null;
|
||||
this.subscriber = null;
|
||||
}
|
||||
|
||||
private async ensureConnected(): Promise<boolean> {
|
||||
const url = process.env.REDIS_URL?.trim();
|
||||
if (!url) return false;
|
||||
if (this.publisher?.isReady && this.subscriber?.isReady) return true;
|
||||
if (Date.now() < this.nextConnectAttempt) return false;
|
||||
if (this.connecting) return this.connecting;
|
||||
this.connecting = this.connect(url).finally(() => {
|
||||
this.connecting = null;
|
||||
});
|
||||
return this.connecting;
|
||||
}
|
||||
|
||||
private async connect(url: string): Promise<boolean> {
|
||||
try {
|
||||
this.publisher = createClient({
|
||||
url,
|
||||
socket: { connectTimeout: 1500, reconnectStrategy: false },
|
||||
});
|
||||
this.subscriber = this.publisher.duplicate();
|
||||
this.publisher.on("error", (error) =>
|
||||
this.logRedisFailure("client", error),
|
||||
);
|
||||
this.subscriber.on("error", (error) =>
|
||||
this.logRedisFailure("subscriber", error),
|
||||
);
|
||||
await Promise.all([this.publisher.connect(), this.subscriber.connect()]);
|
||||
await this.subscriber.subscribe(EVENT_CHANNEL, (raw) => {
|
||||
const envelope = this.parse<EventEnvelope>(raw);
|
||||
if (
|
||||
!envelope ||
|
||||
typeof envelope.source !== "string" ||
|
||||
typeof envelope.roomId !== "string" ||
|
||||
!envelope.message ||
|
||||
typeof envelope.message !== "object" ||
|
||||
envelope.source === this.instanceId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
for (const listener of this.eventListeners) {
|
||||
try {
|
||||
listener(envelope.roomId, envelope.message);
|
||||
} catch (error) {
|
||||
this.logRedisFailure("event_listener", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
this.log.info("Collaboration Redis runtime connected");
|
||||
this.nextConnectAttempt = 0;
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.nextConnectAttempt = Date.now() + CONNECT_RETRY_MS;
|
||||
this.logRedisFailure("connect", error);
|
||||
await Promise.allSettled([
|
||||
this.publisher?.disconnect(),
|
||||
this.subscriber?.disconnect(),
|
||||
]);
|
||||
this.publisher = null;
|
||||
this.subscriber = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private parse<T>(value: string): T | null {
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private logRedisFailure(operation: string, error: unknown): void {
|
||||
this.log.warn(
|
||||
`Collaboration Redis runtime unavailable (${operation}), using local state: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { WebSocket } from "ws";
|
||||
import type { SharingDeps } from "./deps.js";
|
||||
import type { ShareRecord } from "./repositories.js";
|
||||
|
||||
/**
|
||||
* What the terminal consumes. The shapes match
|
||||
* plugins/ssh-terminal/src/backend/services.ts, which is the consumer's copy.
|
||||
*/
|
||||
export interface SharedSessionRef {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
permissionLevel: "read-write" | "read-only";
|
||||
}
|
||||
|
||||
/** sessions.sharing v1: signed-in joins. Every call runs as the acting user. */
|
||||
export interface SessionSharingV1 {
|
||||
authorizeJoin: (
|
||||
shareId: string,
|
||||
) => Promise<{ share: SharedSessionRef; displayName: string } | null>;
|
||||
recordJoin: (shareId: string) => Promise<void>;
|
||||
subscribeRoom: (roomId: string, socket: unknown) => Promise<boolean>;
|
||||
unsubscribeRoom: (socket: unknown, roomId?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest joins through a share link or a room guest link, on ctx.registry as
|
||||
* SESSION_GUESTS_KEY. A guest has no user, so a service's per-call
|
||||
* permission check cannot apply: the token is the authority.
|
||||
*/
|
||||
export interface SessionGuestsV1 {
|
||||
resolve: (request: {
|
||||
shareToken?: string;
|
||||
roomGuestToken?: string;
|
||||
clientIp: string;
|
||||
}) => Promise<
|
||||
{ ok: true; share: SharedSessionRef } | { ok: false; reason: string }
|
||||
>;
|
||||
recordJoin: (shareId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const SESSION_GUESTS_KEY = "sessions.sharing.guests";
|
||||
|
||||
function toRef(share: ShareRecord): SharedSessionRef {
|
||||
return {
|
||||
id: share.id,
|
||||
sessionId: share.sessionId,
|
||||
permissionLevel:
|
||||
share.permissionLevel === "read-write" ? "read-write" : "read-only",
|
||||
};
|
||||
}
|
||||
|
||||
export function createSessionSharingService(
|
||||
deps: SharingDeps,
|
||||
): SessionSharingV1 {
|
||||
const { ctx, shares, rooms, directory, hub } = deps;
|
||||
|
||||
return {
|
||||
async authorizeJoin(shareId) {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) return null;
|
||||
const share = await shares.findActiveById(shareId);
|
||||
if (!share || share.protocol !== "ssh") return null;
|
||||
|
||||
if (share.shareType === "user") {
|
||||
// A user share needs its target, who can still reach the host.
|
||||
if (share.targetUserId !== userId) return null;
|
||||
const access = await ctx.hosts.checkAccess(share.hostId, "connect");
|
||||
if (!access.hasAccess) return null;
|
||||
} else if (share.shareType === "room") {
|
||||
// Room membership is the authorization for a room stage: it is
|
||||
// read-only and never exposes host credentials or config.
|
||||
const room = await rooms.findByStageShareId(share.id);
|
||||
if (!room || !(await rooms.findMember(room.id, userId))) return null;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
share: toRef(share),
|
||||
displayName: await directory.username(userId),
|
||||
};
|
||||
},
|
||||
|
||||
async recordJoin(shareId) {
|
||||
const userId = ctx.currentActor() ?? null;
|
||||
await shares.touchUsage(shareId);
|
||||
await shares.recordParticipantJoin(shareId, userId, null);
|
||||
},
|
||||
|
||||
async subscribeRoom(roomId, socket) {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) return false;
|
||||
const room = await rooms.findById(roomId);
|
||||
if (!room || room.endedAt) return false;
|
||||
if (!(await rooms.findMember(roomId, userId))) return false;
|
||||
hub.subscribe(roomId, {
|
||||
ws: socket as WebSocket,
|
||||
userId,
|
||||
username: await directory.username(userId),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
async unsubscribeRoom(socket, roomId) {
|
||||
hub.unsubscribe(socket as WebSocket, roomId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createSessionGuests(deps: SharingDeps): SessionGuestsV1 {
|
||||
const { shares, rooms, guestLimiter } = deps;
|
||||
|
||||
/** Sharing still allowed, and an SSH share: the only kind the socket joins. */
|
||||
async function joinable(
|
||||
share: ShareRecord,
|
||||
): Promise<
|
||||
{ ok: true; share: SharedSessionRef } | { ok: false; reason: string }
|
||||
> {
|
||||
if (share.protocol !== "ssh") {
|
||||
return { ok: false, reason: "Unsupported share protocol" };
|
||||
}
|
||||
if (!(await deps.isSharingEnabledForHost(share.hostId))) {
|
||||
return { ok: false, reason: "Session sharing is disabled for this host" };
|
||||
}
|
||||
return { ok: true, share: toRef(share) };
|
||||
}
|
||||
|
||||
return {
|
||||
async resolve({ shareToken, roomGuestToken, clientIp }) {
|
||||
if (shareToken) {
|
||||
const share = await shares.findByLinkToken(shareToken);
|
||||
if (!share || share.shareType !== "link") {
|
||||
return { ok: false, reason: "Invalid or expired share link" };
|
||||
}
|
||||
return joinable(share);
|
||||
}
|
||||
|
||||
if (roomGuestToken) {
|
||||
if (guestLimiter.isLimited(clientIp)) {
|
||||
return { ok: false, reason: "Too many requests" };
|
||||
}
|
||||
const room = await rooms.findByGuestToken(roomGuestToken);
|
||||
const share = room?.stageShareId
|
||||
? await shares.findActiveById(room.stageShareId)
|
||||
: null;
|
||||
if (!share) return { ok: false, reason: "Nothing is being presented" };
|
||||
return joinable(share);
|
||||
}
|
||||
|
||||
return { ok: false, reason: "Authentication required" };
|
||||
},
|
||||
|
||||
async recordJoin(shareId) {
|
||||
await shares.touchUsage(shareId);
|
||||
await shares.recordParticipantJoin(shareId, null, "Guest");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import {
|
||||
DISPLAY_WS_PATH,
|
||||
PROTOCOLS,
|
||||
TERMINAL_WS_PATH,
|
||||
actorOf,
|
||||
clientIp,
|
||||
type SharingDeps,
|
||||
} from "./deps.js";
|
||||
import type { LiveProtocol, PermissionLevel } from "./repositories.js";
|
||||
|
||||
const DEFAULT_EXPIRY_HOURS = 24;
|
||||
const MAX_EXPIRY_HOURS = 24 * 30;
|
||||
|
||||
function computeExpiresAt(expiryHours: number | undefined): string {
|
||||
const hours = Math.min(
|
||||
Math.max(expiryHours ?? DEFAULT_EXPIRY_HOURS, 1),
|
||||
MAX_EXPIRY_HOURS,
|
||||
);
|
||||
return new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* The share routes. `/resolve/:linkToken` is public (an anonymous guest link);
|
||||
* the rest run behind core auth and the plugin's `use` permission, which the
|
||||
* caller applies to the router first.
|
||||
*/
|
||||
export function registerShareRoutes(router: Router, deps: SharingDeps): void {
|
||||
const { ctx, shares, directory, live, resolveLimiter } = deps;
|
||||
const requireUse = ctx.rbac.require("use") as never;
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/session-sharing/resolve/{linkToken}:
|
||||
* get:
|
||||
* summary: Resolve a guest share link
|
||||
* description: Public, unauthenticated endpoint for anonymous share-link guests. Never returns host name, IP, username, or hostId. Rate-limited per IP.
|
||||
* tags:
|
||||
* - Session Sharing
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: linkToken
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Resolved share connection info
|
||||
* 404:
|
||||
* description: Link not found, expired, revoked, or sharing disabled
|
||||
* 429:
|
||||
* description: Too many requests
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.get("/resolve/:linkToken", async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (resolveLimiter.isLimited(clientIp(req))) {
|
||||
return res.status(429).json({ error: "Too many requests" });
|
||||
}
|
||||
|
||||
const linkToken = String(req.params.linkToken);
|
||||
const share = await shares.findByLinkToken(linkToken);
|
||||
if (!share || share.shareType !== "link") {
|
||||
return res.status(404).json({ error: "Link not found or expired" });
|
||||
}
|
||||
if (!(await deps.isSharingEnabledForHost(share.hostId))) {
|
||||
return res.status(404).json({ error: "Link not found or expired" });
|
||||
}
|
||||
|
||||
const protocol = share.protocol as LiveProtocol;
|
||||
if (!(await live.isLive(protocol, share.sessionId, share.ownerUserId))) {
|
||||
return res.status(404).json({ error: "Session is no longer active" });
|
||||
}
|
||||
|
||||
// Field by field on purpose: anonymous guests never see the host's
|
||||
// name, address, username or id.
|
||||
const response: {
|
||||
protocol: LiveProtocol;
|
||||
permissionLevel: PermissionLevel;
|
||||
wsPath: string;
|
||||
connectParams?: Record<string, string>;
|
||||
} = {
|
||||
protocol,
|
||||
permissionLevel: share.permissionLevel as PermissionLevel,
|
||||
// Advisory only: the client builds its own socket URL per deployment.
|
||||
wsPath:
|
||||
protocol === "ssh"
|
||||
? `${TERMINAL_WS_PATH}?shareToken=${encodeURIComponent(linkToken)}`
|
||||
: DISPLAY_WS_PATH,
|
||||
};
|
||||
|
||||
if (protocol !== "ssh") {
|
||||
response.connectParams = {
|
||||
token: await live.createViewerToken(
|
||||
protocol,
|
||||
share.sessionId,
|
||||
share.permissionLevel === "read-only",
|
||||
share.ownerUserId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await shares.touchUsage(share.id);
|
||||
await shares.recordParticipantJoin(share.id, null, "Guest");
|
||||
} catch {
|
||||
// Bookkeeping never fails the resolve.
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
ctx.log.error(
|
||||
"Failed to resolve session share link",
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
res.status(500).json({ error: "Failed to resolve share link" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/session-sharing/create:
|
||||
* post:
|
||||
* summary: Create a session share (link or targeted user)
|
||||
* description: Mints a share grant for a live terminal/RDP/VNC/Telnet session. Caller must own the live session.
|
||||
* tags:
|
||||
* - Session Sharing
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - hostId
|
||||
* - sessionId
|
||||
* - protocol
|
||||
* - shareType
|
||||
* - permissionLevel
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* sessionId:
|
||||
* type: string
|
||||
* tabInstanceId:
|
||||
* type: string
|
||||
* protocol:
|
||||
* type: string
|
||||
* enum: [ssh, rdp, vnc, telnet]
|
||||
* shareType:
|
||||
* type: string
|
||||
* enum: [link, user]
|
||||
* targetUserId:
|
||||
* type: string
|
||||
* permissionLevel:
|
||||
* type: string
|
||||
* enum: [read-only, read-write]
|
||||
* expiryHours:
|
||||
* type: number
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Share created
|
||||
* 400:
|
||||
* description: Invalid request
|
||||
* 403:
|
||||
* description: Sharing disabled, or caller does not own the session
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.post("/create", requireUse, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = actorOf(ctx);
|
||||
const {
|
||||
hostId,
|
||||
sessionId,
|
||||
tabInstanceId,
|
||||
protocol,
|
||||
shareType,
|
||||
targetUserId,
|
||||
permissionLevel,
|
||||
expiryHours,
|
||||
} = req.body ?? {};
|
||||
|
||||
if (
|
||||
!hostId ||
|
||||
!sessionId ||
|
||||
!protocol ||
|
||||
!shareType ||
|
||||
!permissionLevel
|
||||
) {
|
||||
return res.status(400).json({ error: "Missing required fields" });
|
||||
}
|
||||
if (!PROTOCOLS.includes(protocol)) {
|
||||
return res.status(400).json({ error: "Invalid protocol" });
|
||||
}
|
||||
if (!["link", "user"].includes(shareType)) {
|
||||
return res.status(400).json({ error: "Invalid shareType" });
|
||||
}
|
||||
if (!["read-only", "read-write"].includes(permissionLevel)) {
|
||||
return res.status(400).json({ error: "Invalid permissionLevel" });
|
||||
}
|
||||
if (shareType === "user" && !targetUserId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "targetUserId is required for user shares" });
|
||||
}
|
||||
|
||||
const numericHostId = Number(hostId);
|
||||
if (!(await deps.isSharingEnabledForHost(numericHostId))) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Session sharing is disabled for this host" });
|
||||
}
|
||||
if (!(await live.isOwnedBy(protocol, String(sessionId), userId))) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "You do not own this live session" });
|
||||
}
|
||||
|
||||
if (shareType === "user") {
|
||||
const target = String(targetUserId);
|
||||
const access = (await directory.userExists(target))
|
||||
? await ctx.asUser(target, () =>
|
||||
ctx.hosts.checkAccess(numericHostId, "connect"),
|
||||
)
|
||||
: { hasAccess: false };
|
||||
if (!access.hasAccess) {
|
||||
return res.status(403).json({
|
||||
error: "Target user does not have access to this host",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const created = await shares.create({
|
||||
id: crypto.randomUUID(),
|
||||
hostId: numericHostId,
|
||||
ownerUserId: userId,
|
||||
protocol,
|
||||
sessionId: String(sessionId),
|
||||
tabInstanceId: tabInstanceId ?? null,
|
||||
shareType,
|
||||
targetUserId: shareType === "user" ? String(targetUserId) : null,
|
||||
linkToken:
|
||||
shareType === "link"
|
||||
? crypto.randomBytes(24).toString("base64url")
|
||||
: null,
|
||||
permissionLevel,
|
||||
expiresAt: computeExpiresAt(expiryHours),
|
||||
});
|
||||
|
||||
res.json({
|
||||
shareId: created.id,
|
||||
linkToken: created.linkToken,
|
||||
expiresAt: created.expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.log.error(
|
||||
"Failed to create session share",
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
res.status(500).json({ error: "Failed to create session share" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/session-sharing/host/{hostId}/active:
|
||||
* get:
|
||||
* summary: List active session shares for a host
|
||||
* description: Returns active (non-revoked, non-expired) shares owned by the caller for the given host.
|
||||
* tags:
|
||||
* - Session Sharing
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of active shares
|
||||
* 400:
|
||||
* description: Invalid host id
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.get(
|
||||
"/host/:hostId/active",
|
||||
requireUse,
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = actorOf(ctx);
|
||||
const hostId = Number.parseInt(String(req.params.hostId), 10);
|
||||
if (!hostId || Number.isNaN(hostId)) {
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
}
|
||||
res.json({
|
||||
shares: await shares.findActiveSharesForHost(hostId, userId),
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.log.error(
|
||||
"Failed to list session shares",
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
res.status(500).json({ error: "Failed to list session shares" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/session-sharing/shared-with-me:
|
||||
* get:
|
||||
* summary: List live SSH sessions other users shared with the caller
|
||||
* description: Active user shares aimed at the caller whose session is still live, in the shape of /open-tabs/active-sessions.
|
||||
* tags:
|
||||
* - Session Sharing
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Shared sessions
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.get(
|
||||
"/shared-with-me",
|
||||
requireUse,
|
||||
async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = actorOf(ctx);
|
||||
const result = [];
|
||||
for (const share of await shares.findSharesTargetingUser(userId)) {
|
||||
if (share.protocol !== "ssh") continue;
|
||||
const session = await live.getSession("ssh", share.sessionId);
|
||||
if (!session?.isConnected) continue;
|
||||
result.push({
|
||||
sessionId: session.id,
|
||||
hostId: session.hostId,
|
||||
hostName: session.hostName,
|
||||
tabInstanceId: session.tabInstanceId,
|
||||
isConnected: session.isConnected,
|
||||
createdAt: session.createdAt,
|
||||
isOwnSession: false,
|
||||
sharedByUsername: await directory.username(share.ownerUserId),
|
||||
permissionLevel: share.permissionLevel,
|
||||
shareId: share.id,
|
||||
});
|
||||
}
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
ctx.log.error(
|
||||
"Failed to list shared sessions",
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
res.status(500).json({ error: "Failed to list shared sessions" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/session-sharing/directory:
|
||||
* get:
|
||||
* summary: Users and roles to share with or invite
|
||||
* tags:
|
||||
* - Session Sharing
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Users (id, username) and roles (id, name, displayName)
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.get("/directory", requireUse, async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({
|
||||
users: await directory.listUsers(),
|
||||
roles: await directory.listRoles(),
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.log.error(
|
||||
"Failed to list users and roles",
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
res.status(500).json({ error: "Failed to list users and roles" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/session-sharing/{shareId}:
|
||||
* delete:
|
||||
* summary: Revoke a session share
|
||||
* description: Revokes a share. Owner or admin only. Best-effort kick of live SSH participants; remote desktop viewers are not force-disconnected.
|
||||
* tags:
|
||||
* - Session Sharing
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: shareId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Share revoked
|
||||
* 403:
|
||||
* description: Not authorized to revoke this share
|
||||
* 404:
|
||||
* description: Share not found
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.delete(
|
||||
"/:shareId",
|
||||
requireUse,
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = actorOf(ctx);
|
||||
const shareId = String(req.params.shareId);
|
||||
const share = await shares.findById(shareId);
|
||||
if (!share) {
|
||||
return res.status(404).json({ error: "Share not found" });
|
||||
}
|
||||
|
||||
let revoked = await shares.revoke(shareId, userId);
|
||||
if (!revoked && (await directory.isAdmin(userId))) {
|
||||
revoked = await shares.revokeAny(shareId);
|
||||
}
|
||||
if (!revoked) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Not authorized to revoke this share" });
|
||||
}
|
||||
|
||||
// guacamole-lite has no kick, so a revoked remote desktop link only
|
||||
// blocks new resolves until the viewer's own socket ends.
|
||||
if (share.protocol === "ssh") {
|
||||
await live.ownerEndSession(
|
||||
"ssh",
|
||||
share.sessionId,
|
||||
"Session share revoked by owner",
|
||||
share.ownerUserId,
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
ctx.log.error(
|
||||
"Failed to revoke session share",
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
res.status(500).json({ error: "Failed to revoke session share" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/session-sharing/{shareId}/end:
|
||||
* post:
|
||||
* summary: End a shared session for all participants
|
||||
* description: Owner-only. Terminates the underlying session and notifies joined participants. Remote desktop viewers are not force-disconnected.
|
||||
* tags:
|
||||
* - Session Sharing
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: shareId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session ended
|
||||
* 403:
|
||||
* description: Not the owner of this share
|
||||
* 404:
|
||||
* description: Share not found
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.post(
|
||||
"/:shareId/end",
|
||||
requireUse,
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = actorOf(ctx);
|
||||
const share = await shares.findById(String(req.params.shareId));
|
||||
if (!share) {
|
||||
return res.status(404).json({ error: "Share not found" });
|
||||
}
|
||||
if (share.ownerUserId !== userId) {
|
||||
return res.status(403).json({ error: "Not the owner of this share" });
|
||||
}
|
||||
if (share.protocol === "ssh") {
|
||||
await live.ownerEndSession(
|
||||
"ssh",
|
||||
share.sessionId,
|
||||
"Session ended by owner",
|
||||
);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
ctx.log.error(
|
||||
"Failed to end shared session",
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
res.status(500).json({ error: "Failed to end shared session" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
boolean,
|
||||
defineTable,
|
||||
id,
|
||||
integer,
|
||||
refHost,
|
||||
refUser,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/*
|
||||
* Adopted from core's session_shares, session_share_participants,
|
||||
* collab_rooms and collab_room_members, so column and index names are the
|
||||
* legacy ones and the rename carries every row across.
|
||||
*
|
||||
* Columns that point at a user or host but may be null or set null on delete,
|
||||
* and the links between these four tables, are plain columns here: the SDK
|
||||
* only references users and ssh_data, cascading. The adoption migrations keep
|
||||
* the legacy FOREIGN KEY clauses by hand, as fleets does.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A grant to join one live session: a link for anonymous guests, a user, or
|
||||
* a collab room's stage ("room").
|
||||
*/
|
||||
export const shares = adoptLegacyTable(
|
||||
"session_shares",
|
||||
defineTable(
|
||||
"shares",
|
||||
{
|
||||
id: varchar().primaryKey(),
|
||||
hostId: refHost(),
|
||||
ownerUserId: refUser(),
|
||||
protocol: text().notNull(),
|
||||
// The terminal's session id for SSH, guacd's connection id otherwise.
|
||||
// Neither is a row, so there is no foreign key.
|
||||
sessionId: varchar().notNull(),
|
||||
tabInstanceId: text(),
|
||||
shareType: text().notNull(),
|
||||
targetUserId: varchar(),
|
||||
linkToken: varchar().unique(),
|
||||
permissionLevel: text().notNull().default("read-only"),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
expiresAt: text().notNull(),
|
||||
revokedAt: text(),
|
||||
lastJoinedAt: text(),
|
||||
joinCount: integer().notNull().default(0),
|
||||
},
|
||||
{
|
||||
indexes: [
|
||||
{ name: "idx_session_shares_session_id", columns: ["sessionId"] },
|
||||
{ name: "idx_session_shares_host_id", columns: ["hostId"] },
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/** Who joined through a share, for the owner's record. */
|
||||
export const shareParticipants = adoptLegacyTable(
|
||||
"session_share_participants",
|
||||
defineTable("share_participants", {
|
||||
id: id(),
|
||||
shareId: varchar().notNull(),
|
||||
userId: varchar(),
|
||||
guestLabel: text(),
|
||||
joinedAt: timestamp().notNull().defaultNow(),
|
||||
leftAt: text(),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* A collaboration room: members watching one stage, the live session the
|
||||
* current presenter shows through a "room" share.
|
||||
*/
|
||||
export const rooms = adoptLegacyTable(
|
||||
"collab_rooms",
|
||||
defineTable(
|
||||
"rooms",
|
||||
{
|
||||
id: varchar().primaryKey(),
|
||||
name: text().notNull(),
|
||||
ownerUserId: refUser(),
|
||||
persistent: boolean().notNull().default(false),
|
||||
presenterUserId: varchar(),
|
||||
stageProtocol: text(),
|
||||
stageHostId: integer(),
|
||||
stageShareId: varchar(),
|
||||
guestLinkToken: varchar(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
endedAt: text(),
|
||||
},
|
||||
{
|
||||
indexes: [{ name: "idx_collab_rooms_owner", columns: ["ownerUserId"] }],
|
||||
uniques: [
|
||||
{ name: "idx_collab_rooms_guest_token", columns: ["guestLinkToken"] },
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const roomMembers = adoptLegacyTable(
|
||||
"collab_room_members",
|
||||
defineTable(
|
||||
"room_members",
|
||||
{
|
||||
id: id(),
|
||||
roomId: varchar().notNull(),
|
||||
userId: refUser(),
|
||||
roomRole: text().notNull().default("member"),
|
||||
addedBy: varchar(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{
|
||||
uniques: [
|
||||
{
|
||||
name: "idx_collab_room_members_room_user",
|
||||
columns: ["roomId", "userId"],
|
||||
},
|
||||
],
|
||||
indexes: [{ name: "idx_collab_room_members_user", columns: ["userId"] }],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const tables = [shares, shareParticipants, rooms, roomMembers];
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { AlertCircle, Eye, Presentation, Users } from "lucide-react";
|
||||
import { RemoteDisplay } from "./shared";
|
||||
import {
|
||||
GuestTerminalView,
|
||||
type SessionParticipantInfo,
|
||||
} from "./SharedSessionView";
|
||||
import { resolveCollabGuestStage, type CollabGuestStage } from "./api";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
|
||||
/**
|
||||
* Anonymous guest page for a collab room (?view=collab-guest&token=...).
|
||||
* Guests have no account and no event channel, so they poll the public
|
||||
* resolve endpoint and remount the viewer whenever the stage share changes.
|
||||
*/
|
||||
export default function CollabGuestView() {
|
||||
const { t } = useTranslation();
|
||||
const token = new URLSearchParams(window.location.search).get("token");
|
||||
const [roomName, setRoomName] = useState<string | null>(null);
|
||||
const [stage, setStage] = useState<CollabGuestStage | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [participantCount, setParticipantCount] = useState(0);
|
||||
const stageShareIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError(t("collab.guest.linkInvalid"));
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const result = await resolveCollabGuestStage(token);
|
||||
if (cancelled) return;
|
||||
setRoomName(result.roomName);
|
||||
setError(null);
|
||||
const nextShareId = result.stage?.shareId ?? null;
|
||||
// Tokens are minted per resolve; only swap the viewer on a real change.
|
||||
if (nextShareId !== stageShareIdRef.current) {
|
||||
stageShareIdRef.current = nextShareId;
|
||||
setStage(result.stage);
|
||||
setParticipantCount(0);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setError(t("collab.guest.linkInvalid"));
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
const timer = setInterval(() => void poll(), POLL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [token, t]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex flex-col"
|
||||
style={{ backgroundColor: "var(--bg-base)", color: "var(--foreground)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border text-sm shrink-0">
|
||||
<Presentation className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="font-semibold truncate min-w-0">
|
||||
{roomName ?? t("collab.guest.title")}
|
||||
</span>
|
||||
<div className="flex items-center gap-3 shrink-0 ml-auto text-xs text-muted-foreground">
|
||||
{participantCount >= 2 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="size-3.5" />
|
||||
{participantCount}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="size-3.5" />
|
||||
{t("sessionSharing.guestView.readOnlyBadge")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex-1 min-h-0">
|
||||
{error ? (
|
||||
<Note icon={<AlertCircle className="size-8" />} text={error} />
|
||||
) : !stage ? (
|
||||
<Note
|
||||
icon={<Presentation className="size-8" />}
|
||||
text={t("collab.guest.waiting")}
|
||||
/>
|
||||
) : stage.protocol === "ssh" ? (
|
||||
<GuestTerminalView
|
||||
key={stage.shareId}
|
||||
share={{ permissionLevel: "read-only" }}
|
||||
wsPath={stage.wsPath ?? ""}
|
||||
hideBadges
|
||||
onParticipantsChange={(participants: SessionParticipantInfo[]) =>
|
||||
setParticipantCount(participants.length)
|
||||
}
|
||||
/>
|
||||
) : stage.connectParams?.token ? (
|
||||
<RemoteDisplay
|
||||
key={stage.shareId}
|
||||
token={stage.connectParams.token}
|
||||
protocol={stage.protocol}
|
||||
isVisible
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Note({ icon, text }: { icon: React.ReactNode; text: string }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
{icon}
|
||||
<p className="text-sm">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
Check,
|
||||
Crown,
|
||||
Hand,
|
||||
MonitorUp,
|
||||
MousePointerClick,
|
||||
UserMinus,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@termix/plugin-sdk/ui";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type { CollabControlRequest, CollabRoomDetail } from "./api";
|
||||
|
||||
interface CollabMembersSidebarProps {
|
||||
detail: CollabRoomDetail;
|
||||
onClose: () => void;
|
||||
onInvite: () => void;
|
||||
onControl: (userId: string | null) => Promise<void>;
|
||||
onDismissRequest: (userId: string) => Promise<void>;
|
||||
onRemoveMember: (userId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function CollabMembersSidebar({
|
||||
detail,
|
||||
onClose,
|
||||
onInvite,
|
||||
onControl,
|
||||
onDismissRequest,
|
||||
onRemoveMember,
|
||||
}: CollabMembersSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const [removing, setRemoving] = useState<{
|
||||
userId: string;
|
||||
username: string;
|
||||
} | null>(null);
|
||||
const [busyUserId, setBusyUserId] = useState<string | null>(null);
|
||||
const onlineIds = new Set(detail.online.map((user) => user.userId));
|
||||
const presenterUserId = detail.stage.presenterUserId;
|
||||
const canManageControl = detail.isHost || presenterUserId === detail.me;
|
||||
const canControl =
|
||||
canManageControl &&
|
||||
detail.stage.protocol === "ssh" &&
|
||||
!!detail.stage.shareId;
|
||||
|
||||
async function run(userId: string, action: () => Promise<void>) {
|
||||
setBusyUserId(userId);
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
setBusyUserId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function grant(request: CollabControlRequest) {
|
||||
await run(request.userId, async () => {
|
||||
await onControl(request.userId);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
id="collab-members-sidebar"
|
||||
aria-label={t("collab.membersPanel")}
|
||||
className="absolute inset-y-0 right-0 z-30 flex h-full w-[min(20rem,100%)] shrink-0 flex-col border-l border-border bg-background shadow-xl lg:static lg:z-auto lg:shadow-none"
|
||||
>
|
||||
<header className="flex h-11 items-center gap-2 border-b border-border px-3">
|
||||
<Users className="size-4 text-muted-foreground" />
|
||||
<h2 className="flex-1 text-sm font-semibold">
|
||||
{t("collab.membersWithCount", { count: detail.members.length })}
|
||||
</h2>
|
||||
{detail.isHost && (
|
||||
<Button size="sm" variant="outline" onClick={onInvite}>
|
||||
{t("collab.invite")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={t("common.close")}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{canManageControl && (
|
||||
<section className="border-b border-border p-3" aria-live="polite">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Hand className="size-3.5 text-muted-foreground" />
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide">
|
||||
{t("collab.controlQueue")}
|
||||
</h3>
|
||||
{detail.controlRequests.length > 0 && (
|
||||
<Badge variant="default" className="ml-auto text-xs">
|
||||
{detail.controlRequests.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{detail.controlRequests.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("collab.noControlRequests")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{detail.controlRequests.map((request) => (
|
||||
<div
|
||||
key={request.userId}
|
||||
className="flex items-center gap-2 border border-border p-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium">
|
||||
{request.username}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(request.requestedAt).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
aria-label={t("collab.grantControlTo", {
|
||||
name: request.username,
|
||||
})}
|
||||
disabled={busyUserId === request.userId}
|
||||
onClick={() => void grant(request)}
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
aria-label={t("collab.dismissControlRequest", {
|
||||
name: request.username,
|
||||
})}
|
||||
disabled={busyUserId === request.userId}
|
||||
onClick={() =>
|
||||
void run(request.userId, () =>
|
||||
onDismissRequest(request.userId),
|
||||
)
|
||||
}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
<h3 className="sr-only">{t("collab.members")}</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
{detail.members.map((member) => {
|
||||
const isOnline = onlineIds.has(member.userId);
|
||||
const isPresenter = member.userId === presenterUserId;
|
||||
const hasControl = member.userId === detail.controllerUserId;
|
||||
return (
|
||||
<div
|
||||
key={member.userId}
|
||||
className="group flex min-h-11 items-center gap-2 border border-transparent px-2 py-1.5 hover:border-border hover:bg-muted/40"
|
||||
>
|
||||
<span
|
||||
className={`size-2 shrink-0 rounded-full ${
|
||||
isOnline ? "bg-green-500" : "bg-muted-foreground/30"
|
||||
}`}
|
||||
aria-label={t(isOnline ? "collab.online" : "collab.offline")}
|
||||
role="img"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium">
|
||||
{member.username}
|
||||
{member.userId === detail.me && (
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
{t("collab.you")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{member.roomRole === "host" && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Crown className="size-3" /> {t("collab.hostBadge")}
|
||||
</span>
|
||||
)}
|
||||
{isPresenter && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MonitorUp className="size-3" />
|
||||
{t("collab.presenterBadge")}
|
||||
</span>
|
||||
)}
|
||||
{hasControl && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MousePointerClick className="size-3" />
|
||||
{t("collab.controlBadge")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{canControl && !isPresenter && (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant={hasControl ? "default" : "ghost"}
|
||||
aria-label={t(
|
||||
hasControl
|
||||
? "collab.revokeControl"
|
||||
: "collab.grantControlTo",
|
||||
{ name: member.username },
|
||||
)}
|
||||
disabled={busyUserId === member.userId}
|
||||
onClick={() =>
|
||||
void run(member.userId, () =>
|
||||
onControl(hasControl ? null : member.userId),
|
||||
)
|
||||
}
|
||||
>
|
||||
<MousePointerClick className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{detail.isHost && member.userId !== detail.room.ownerUserId && (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={t("collab.removeMember", {
|
||||
name: member.username,
|
||||
})}
|
||||
onClick={() =>
|
||||
setRemoving({
|
||||
userId: member.userId,
|
||||
username: member.username,
|
||||
})
|
||||
}
|
||||
>
|
||||
<UserMinus className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(removing)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRemoving(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("collab.removeMemberTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("collab.removeMemberDescription", {
|
||||
name: removing?.username,
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => {
|
||||
if (removing) void onRemoveMember(removing.userId);
|
||||
}}
|
||||
>
|
||||
{t("collab.deleteMember")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, Plus, Presentation, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import { Input } from "@termix/plugin-sdk/ui";
|
||||
import { Badge } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { createCollabRoom, listCollabRooms, type CollabRoom } from "./api";
|
||||
import { getErrorMessage } from "./shared";
|
||||
|
||||
export function CollabPanel({
|
||||
onOpenRoom,
|
||||
}: {
|
||||
onOpenRoom: (room: CollabRoom) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [rooms, setRooms] = useState<CollabRoom[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [persistent, setPersistent] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const result = await listCollabRooms();
|
||||
setRooms(result.rooms ?? []);
|
||||
} catch {
|
||||
/* the list stays as-is */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const timer = window.setInterval(() => void refresh(), 30000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refresh]);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!name.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const { room } = await createCollabRoom(name.trim(), persistent);
|
||||
toast.success(t("collab.created"));
|
||||
setCreateOpen(false);
|
||||
setName("");
|
||||
setPersistent(false);
|
||||
await refresh();
|
||||
onOpenRoom(room);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 text-xs flex-1 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand font-semibold hover:bg-accent-brand/20"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
{t("collab.createRoom")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-2"
|
||||
onClick={() => void refresh()}
|
||||
aria-label={t("common.refresh")}
|
||||
title={t("common.refresh")}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-6">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : rooms.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground px-1 py-4 text-center">
|
||||
{t("collab.noRooms")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{rooms.map((room) => (
|
||||
<button
|
||||
key={room.id}
|
||||
type="button"
|
||||
onClick={() => onOpenRoom(room)}
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-left border border-border hover:bg-muted/50"
|
||||
>
|
||||
<Presentation className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-xs truncate">{room.name}</span>
|
||||
{room.presenterUserId && (
|
||||
<span
|
||||
className="size-1.5 rounded-full bg-red-500 shrink-0"
|
||||
role="img"
|
||||
aria-label={t("collab.presenterBadge")}
|
||||
title={t("collab.presenterBadge")}
|
||||
/>
|
||||
)}
|
||||
{room.persistent && (
|
||||
<Badge variant="outline" className="text-[9px] px-1 py-0">
|
||||
{t("collab.persistentRoom")}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("collab.createRoom")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label htmlFor="collab-room-name" className="text-xs font-medium">
|
||||
{t("collab.roomName")}
|
||||
</label>
|
||||
<Input
|
||||
id="collab-room-name"
|
||||
placeholder={t("collab.roomName")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleCreate();
|
||||
}}
|
||||
/>
|
||||
<label className="flex items-start gap-2 text-xs cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={persistent}
|
||||
onChange={(e) => setPersistent(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">
|
||||
{t("collab.persistentRoom")}
|
||||
</span>
|
||||
<br />
|
||||
<span className="text-muted-foreground">
|
||||
{t("collab.persistentRoomHint")}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCreateOpen(false)}
|
||||
disabled={creating}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={creating || !name.trim()}
|
||||
>
|
||||
{creating && <Loader2 className="size-3.5 mr-1 animate-spin" />}
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,429 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Check, Copy, Link2, Search, Shield, User, Users } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import { Input } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
createSessionShare,
|
||||
getActiveSessionShares,
|
||||
revokeSessionShare,
|
||||
type SessionShareProtocol,
|
||||
type SessionSharePermissionLevel,
|
||||
type SessionShareRecord,
|
||||
getDirectory,
|
||||
} from "./api";
|
||||
|
||||
const EXPIRY_PRESETS = [
|
||||
{ key: "oneHour", hours: 1 },
|
||||
{ key: "oneDay", hours: 24 },
|
||||
{ key: "sevenDays", hours: 24 * 7 },
|
||||
{ key: "thirtyDays", hours: 24 * 30 },
|
||||
{ key: "custom", hours: undefined },
|
||||
] as const;
|
||||
|
||||
type ExpiryPresetKey = (typeof EXPIRY_PRESETS)[number]["key"];
|
||||
|
||||
export function ShareSessionModal({
|
||||
open,
|
||||
onClose,
|
||||
hostId,
|
||||
sessionId,
|
||||
protocol,
|
||||
tabInstanceId,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
hostId: number;
|
||||
sessionId: string | null;
|
||||
protocol: SessionShareProtocol;
|
||||
tabInstanceId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [mode, setMode] = useState<"link" | "user">("link");
|
||||
const [permissionLevel, setPermissionLevel] =
|
||||
useState<SessionSharePermissionLevel>("read-only");
|
||||
const [expiryPreset, setExpiryPreset] = useState<ExpiryPresetKey>("oneDay");
|
||||
const [customHours, setCustomHours] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [users, setUsers] = useState<{ id: string; username: string }[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [createdLink, setCreatedLink] = useState<string | null>(null);
|
||||
const [shares, setShares] = useState<SessionShareRecord[]>([]);
|
||||
const [sharesLoaded, setSharesLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setMode("link");
|
||||
setPermissionLevel("read-only");
|
||||
setExpiryPreset("oneDay");
|
||||
setCustomHours("");
|
||||
setSearch("");
|
||||
setSelectedUserId(null);
|
||||
setCreatedLink(null);
|
||||
setSharesLoaded(false);
|
||||
setShares([]);
|
||||
}, [open, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || sharesLoaded) return;
|
||||
setSharesLoaded(true);
|
||||
Promise.all([
|
||||
getDirectory().catch(() => ({ users: [] })),
|
||||
getActiveSessionShares(hostId).catch(() => ({ shares: [] })),
|
||||
]).then(([usersRes, sharesRes]) => {
|
||||
setUsers(
|
||||
(usersRes.users ?? []).map((u) => ({
|
||||
id: String(u.id),
|
||||
username: u.username,
|
||||
})),
|
||||
);
|
||||
setShares(sharesRes.shares ?? []);
|
||||
});
|
||||
}, [open, hostId, sharesLoaded]);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return q
|
||||
? users.filter((u) => u.username.toLowerCase().includes(q))
|
||||
: users;
|
||||
}, [users, search]);
|
||||
|
||||
const expiryHours = (() => {
|
||||
if (expiryPreset === "custom") {
|
||||
const hours = Number(customHours);
|
||||
return Number.isFinite(hours) && hours > 0 ? hours : undefined;
|
||||
}
|
||||
return EXPIRY_PRESETS.find((p) => p.key === expiryPreset)?.hours;
|
||||
})();
|
||||
|
||||
async function refreshShares() {
|
||||
try {
|
||||
const res = await getActiveSessionShares(hostId);
|
||||
setShares(res.shares ?? []);
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!sessionId) return;
|
||||
if (mode === "user" && !selectedUserId) return;
|
||||
if (expiryPreset === "custom" && !expiryHours) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await createSessionShare({
|
||||
hostId,
|
||||
sessionId,
|
||||
tabInstanceId,
|
||||
protocol,
|
||||
shareType: mode,
|
||||
targetUserId:
|
||||
mode === "user" ? (selectedUserId ?? undefined) : undefined,
|
||||
permissionLevel,
|
||||
expiryHours,
|
||||
});
|
||||
|
||||
if (mode === "link" && result.linkToken) {
|
||||
const url = `${window.location.origin}${window.location.pathname}?view=shared&token=${result.linkToken}`;
|
||||
setCreatedLink(url);
|
||||
toast.success(t("sessionSharing.linkCreated"));
|
||||
} else {
|
||||
toast.success(t("sessionSharing.shareCreated"));
|
||||
setSelectedUserId(null);
|
||||
}
|
||||
await refreshShares();
|
||||
} catch (error) {
|
||||
const status = (error as { status?: number })?.status;
|
||||
if (mode === "user" && status === 403) {
|
||||
toast.error(t("sessionSharing.userLacksHostAccess"));
|
||||
} else {
|
||||
toast.error(t("sessionSharing.shareFailed"));
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyLink() {
|
||||
if (!createdLink) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdLink);
|
||||
toast.success(t("sessionSharing.linkCopied"));
|
||||
} catch {
|
||||
// clipboard API unavailable, ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(shareId: string) {
|
||||
try {
|
||||
await revokeSessionShare(shareId);
|
||||
setShares((prev) => prev.filter((s) => s.id !== shareId));
|
||||
toast.success(t("sessionSharing.revoked"));
|
||||
} catch {
|
||||
toast.error(t("sessionSharing.revokeFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sessionSharing.modalTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === "link"
|
||||
? t("sessionSharing.linkModeDescription")
|
||||
: t("sessionSharing.userModeDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-1.5">
|
||||
{(["link", "user"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => {
|
||||
setMode(m);
|
||||
setCreatedLink(null);
|
||||
}}
|
||||
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${mode === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{m === "link" ? (
|
||||
<Link2 className="size-3 shrink-0" />
|
||||
) : (
|
||||
<User className="size-3 shrink-0" />
|
||||
)}
|
||||
{m === "link"
|
||||
? t("sessionSharing.modeTab.link")
|
||||
: t("sessionSharing.modeTab.user")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mode === "user" && (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50" />
|
||||
<Input
|
||||
placeholder={t("sessionSharing.searchUsersPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col border border-border h-28 overflow-y-auto">
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground/50 text-center">
|
||||
{t("sessionSharing.noUsersFound")}
|
||||
</div>
|
||||
) : (
|
||||
filteredUsers.map((user) => {
|
||||
const isSelected = selectedUserId === user.id;
|
||||
return (
|
||||
<button
|
||||
key={user.id}
|
||||
onClick={() => setSelectedUserId(user.id)}
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs text-left border-b border-border/50 last:border-0 transition-colors shrink-0 ${isSelected ? "bg-accent-brand/10 text-accent-brand" : "hover:bg-muted/40"}`}
|
||||
>
|
||||
<div
|
||||
className={`size-3.5 border flex items-center justify-center shrink-0 transition-colors ${isSelected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<Check className="size-2.5 text-background" />
|
||||
)}
|
||||
</div>
|
||||
<User className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="truncate">{user.username}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col gap-1 flex-1 min-w-0">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("sessionSharing.permissionLevel.label")}
|
||||
</span>
|
||||
<select
|
||||
value={permissionLevel}
|
||||
onChange={(e) =>
|
||||
setPermissionLevel(
|
||||
e.target.value as SessionSharePermissionLevel,
|
||||
)
|
||||
}
|
||||
className="h-8 w-full px-2.5 text-xs border border-border bg-background hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<option value="read-only">
|
||||
{t("sessionSharing.permissionLevel.readOnly")}
|
||||
</option>
|
||||
<option value="read-write">
|
||||
{t("sessionSharing.permissionLevel.readWrite")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex flex-col gap-1 shrink-0">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-left">
|
||||
{t("sessionSharing.expiryLabel")}
|
||||
</span>
|
||||
<span className="h-8 flex items-center justify-center px-2.5 text-xs border border-border hover:bg-muted/40 transition-colors whitespace-nowrap">
|
||||
{t(`hosts.sharing.expiry.${expiryPreset}`)}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="text-xs">
|
||||
{EXPIRY_PRESETS.map((preset) => (
|
||||
<DropdownMenuItem
|
||||
key={preset.key}
|
||||
onClick={() => setExpiryPreset(preset.key)}
|
||||
>
|
||||
{expiryPreset === preset.key ? (
|
||||
<Check className="size-3 mr-1.5" />
|
||||
) : (
|
||||
<span className="size-3 mr-1.5" />
|
||||
)}
|
||||
{t(`hosts.sharing.expiry.${preset.key}`)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{expiryPreset === "custom" && (
|
||||
<Input
|
||||
type="number"
|
||||
autoFocus
|
||||
placeholder={t("sessionSharing.customHoursPlaceholder")}
|
||||
value={customHours}
|
||||
onChange={(e) => setCustomHours(e.target.value)}
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||
{permissionLevel === "read-only"
|
||||
? t("sessionSharing.permissionLevel.readOnlyDescription")
|
||||
: t("sessionSharing.permissionLevel.readWriteDescription")}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
disabled={
|
||||
!sessionId ||
|
||||
submitting ||
|
||||
(mode === "user" && !selectedUserId) ||
|
||||
(expiryPreset === "custom" && !expiryHours)
|
||||
}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{mode === "link"
|
||||
? t("sessionSharing.createLinkButton")
|
||||
: t("sessionSharing.createShareButton")}
|
||||
</Button>
|
||||
|
||||
{createdLink && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input readOnly value={createdLink} className="text-xs" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={handleCopyLink}
|
||||
title={t("sessionSharing.copyLink")}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-0 border-t border-border pt-2">
|
||||
<div className="flex items-center gap-1.5 pb-2 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
<Users className="size-3.5" />
|
||||
{t("sessionSharing.activeShares")}
|
||||
{shares.length > 0 && (
|
||||
<span className="text-muted-foreground/40">
|
||||
({shares.length})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col max-h-40 overflow-y-auto">
|
||||
{shares.length === 0 && (
|
||||
<div className="px-1 py-4 text-xs text-muted-foreground/50 text-center">
|
||||
{t("sessionSharing.noActiveShares")}
|
||||
</div>
|
||||
)}
|
||||
{shares.map((share) => {
|
||||
const targetUser = users.find(
|
||||
(u) => u.id === share.targetUserId,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={share.id}
|
||||
className="flex items-center justify-between gap-2 py-2 border-b border-border/60 last:border-0 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{share.shareType === "link" ? (
|
||||
<Link2 className="size-3 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<Shield className="size-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="truncate font-semibold">
|
||||
{share.shareType === "link"
|
||||
? t("sessionSharing.linkShareBadge")
|
||||
: t("sessionSharing.userShareBadge", {
|
||||
username:
|
||||
targetUser?.username ??
|
||||
share.targetUserId ??
|
||||
"?",
|
||||
})}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{share.permissionLevel === "read-write"
|
||||
? t("sessionSharing.permissionLevel.readWrite")
|
||||
: t("sessionSharing.permissionLevel.readOnly")}
|
||||
{" · "}
|
||||
{t("sessionSharing.expiresAt", {
|
||||
date: new Date(share.expiresAt).toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10 shrink-0"
|
||||
onClick={() => handleRevoke(share.id)}
|
||||
>
|
||||
{t("sessionSharing.revoke")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { useXTerm } from "react-xtermjs";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { AlertCircle, Eye, Users } from "lucide-react";
|
||||
import {
|
||||
resolveShareLink,
|
||||
type ResolvedShareLink,
|
||||
type ShareLinkErrorKind,
|
||||
} from "./api";
|
||||
import { Loader, RemoteDisplay, wsUrlForPath } from "./shared";
|
||||
|
||||
const PING_INTERVAL_MS = 30000;
|
||||
|
||||
interface TerminalWsMessage {
|
||||
type: string;
|
||||
data?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// A shared session link always resolves against the desktop app's embedded
|
||||
// local backend: joining a session on someone else's remote server is not
|
||||
// supported from the desktop app.
|
||||
async function resolveTerminalWsUrl(wsPath: string): Promise<string> {
|
||||
const url = await wsUrlForPath(wsPath);
|
||||
if (!url) throw new Error("No terminal endpoint is available");
|
||||
return url;
|
||||
}
|
||||
|
||||
export interface SessionParticipantInfo {
|
||||
isOwner: boolean;
|
||||
permissionLevel: "read-write" | "read-only";
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
function ParticipantsBadge({
|
||||
participants,
|
||||
ownerLabel,
|
||||
}: {
|
||||
participants: SessionParticipantInfo[];
|
||||
ownerLabel: string;
|
||||
}) {
|
||||
if (participants.length < 2) return null;
|
||||
const names = participants
|
||||
.map((participant) =>
|
||||
participant.isOwner ? ownerLabel : (participant.label ?? "?"),
|
||||
)
|
||||
.join(", ");
|
||||
return (
|
||||
<div
|
||||
className="absolute top-3 left-3 z-20 flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: "var(--bg-elevated, rgba(0,0,0,0.6))",
|
||||
color: "var(--foreground)",
|
||||
border: "1px solid var(--border-base)",
|
||||
}}
|
||||
title={names}
|
||||
>
|
||||
<Users className="size-3.5" />
|
||||
{participants.length}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadOnlyBadge({ label }: { label: string }) {
|
||||
return (
|
||||
<div
|
||||
className="absolute top-3 right-3 z-20 flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: "var(--bg-elevated, rgba(0,0,0,0.6))",
|
||||
color: "var(--foreground)",
|
||||
border: "1px solid var(--border-base)",
|
||||
}}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CenteredMessage({
|
||||
icon,
|
||||
message,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
message: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center h-full gap-4 w-full"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
{icon}
|
||||
<p
|
||||
className="text-sm font-semibold text-center max-w-xs"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GuestTerminalView({
|
||||
share,
|
||||
wsPath,
|
||||
hideBadges = false,
|
||||
onParticipantsChange,
|
||||
}: {
|
||||
share: Pick<ResolvedShareLink, "permissionLevel">;
|
||||
/** The socket path the backend handed out, query string included. */
|
||||
wsPath: string;
|
||||
/** Suppress the built-in overlay badges when the host page renders its own. */
|
||||
hideBadges?: boolean;
|
||||
onParticipantsChange?: (participants: SessionParticipantInfo[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||
const [ended, setEnded] = useState<string | null>(null);
|
||||
const [participants, setParticipants] = useState<SessionParticipantInfo[]>(
|
||||
[],
|
||||
);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const onParticipantsChangeRef = useRef(onParticipantsChange);
|
||||
onParticipantsChangeRef.current = onParticipantsChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal || !xtermRef.current) return;
|
||||
|
||||
terminal.options.theme = { background: "#0c0d0b" };
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(xtermRef.current);
|
||||
fitAddon.fit();
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => fitAddon.fit());
|
||||
resizeObserver.observe(xtermRef.current);
|
||||
|
||||
let cancelled = false;
|
||||
let ws: WebSocket | null = null;
|
||||
|
||||
resolveTerminalWsUrl(wsPath).then((url) => {
|
||||
if (cancelled) return;
|
||||
ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
}, PING_INTERVAL_MS);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
let msg: TerminalWsMessage;
|
||||
try {
|
||||
msg = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case "data":
|
||||
if (typeof msg.data === "string") terminal.write(msg.data);
|
||||
break;
|
||||
case "participants":
|
||||
if (Array.isArray(msg.participants)) {
|
||||
const next = msg.participants as SessionParticipantInfo[];
|
||||
setParticipants(next);
|
||||
onParticipantsChangeRef.current?.(next);
|
||||
}
|
||||
break;
|
||||
case "sessionExpired":
|
||||
case "sessionTerminatedByOwner":
|
||||
case "session_ended":
|
||||
setEnded(t("sessionSharing.guestView.sessionEnded"));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setEnded((prev) => prev ?? t("sessionSharing.guestView.sessionEnded"));
|
||||
};
|
||||
|
||||
if (share.permissionLevel === "read-write") {
|
||||
terminal.onData((data) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
resizeObserver.disconnect();
|
||||
if (pingIntervalRef.current) clearInterval(pingIntervalRef.current);
|
||||
ws?.close();
|
||||
wsRef.current = null;
|
||||
};
|
||||
// Deliberately runs once terminal mounts - share/token/permission are stable for the view's lifetime.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [terminal, wsPath]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
{!hideBadges && (
|
||||
<>
|
||||
<ParticipantsBadge
|
||||
participants={participants}
|
||||
ownerLabel={t("sessionSharing.guestView.ownerLabel")}
|
||||
/>
|
||||
{share.permissionLevel === "read-only" && (
|
||||
<ReadOnlyBadge
|
||||
label={t("sessionSharing.guestView.readOnlyBadge")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{ended && (
|
||||
<div
|
||||
className="absolute inset-0 z-30 flex items-center justify-center"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<CenteredMessage
|
||||
icon={
|
||||
<AlertCircle
|
||||
className="size-10"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
}
|
||||
message={ended}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div ref={xtermRef} className="w-full h-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GuestGuacamoleView({ share }: { share: ResolvedShareLink }) {
|
||||
const { t } = useTranslation();
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
|
||||
if (!share.connectParams?.token) {
|
||||
return (
|
||||
<CenteredMessage
|
||||
icon={
|
||||
<AlertCircle
|
||||
className="size-10"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
}
|
||||
message={t("sessionSharing.guestView.linkInvalid")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
{share.permissionLevel === "read-only" && (
|
||||
<ReadOnlyBadge label={t("sessionSharing.guestView.readOnlyBadge")} />
|
||||
)}
|
||||
{connectionError && (
|
||||
<div
|
||||
className="absolute inset-0 z-30 flex items-center justify-center"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<CenteredMessage
|
||||
icon={
|
||||
<AlertCircle
|
||||
className="size-10"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
}
|
||||
message={connectionError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<RemoteDisplay
|
||||
token={share.connectParams.token}
|
||||
protocol={share.protocol}
|
||||
isVisible={true}
|
||||
onError={(err) => setConnectionError(err)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SharedSessionView() {
|
||||
const { t } = useTranslation();
|
||||
const [share, setShare] = useState<ResolvedShareLink | null>(null);
|
||||
const [linkToken, setLinkToken] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const token = params.get("token");
|
||||
if (!token) {
|
||||
setError(t("sessionSharing.guestView.linkInvalid"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLinkToken(token);
|
||||
|
||||
resolveShareLink(token)
|
||||
.then((resolved) => setShare(resolved))
|
||||
.catch((err) => {
|
||||
const kind = (err as { kind?: ShareLinkErrorKind })?.kind;
|
||||
if (kind === "rate-limited") {
|
||||
setError(t("sessionSharing.guestView.rateLimited"));
|
||||
} else {
|
||||
setError(t("sessionSharing.guestView.linkInvalid"));
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex flex-col"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<div className="relative flex-1 min-h-0">
|
||||
{loading && <Loader message={t("sessionSharing.guestView.loading")} />}
|
||||
{!loading && error && (
|
||||
<CenteredMessage
|
||||
icon={
|
||||
<AlertCircle
|
||||
className="size-10"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
}
|
||||
message={error}
|
||||
/>
|
||||
)}
|
||||
{!loading &&
|
||||
!error &&
|
||||
share &&
|
||||
linkToken &&
|
||||
(share.protocol === "ssh" ? (
|
||||
<GuestTerminalView share={share} wsPath={share.wsPath} />
|
||||
) : (
|
||||
<GuestGuacamoleView share={share} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useEffect, type ComponentType } from "react";
|
||||
import { Presentation, Share2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
useCurrentUser,
|
||||
usePermission,
|
||||
type PanelProps,
|
||||
type StandaloneViewProps,
|
||||
type TabProps,
|
||||
type TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { bindApi, getSharedWithMe, listCollabRooms } from "./api";
|
||||
import CollabGuestView from "./CollabGuestView";
|
||||
import { CollabPanel } from "./CollabPanel";
|
||||
import { CollabRoomTab } from "./CollabRoomTab";
|
||||
import SharedSessionView from "./SharedSessionView";
|
||||
import {
|
||||
ShareDialogHost,
|
||||
closeShareDialog,
|
||||
openShareDialog,
|
||||
type ShareTarget,
|
||||
} from "./share-dialog";
|
||||
import { REMOTE_DISPLAY_SLOT } from "./shared";
|
||||
|
||||
const COLLAB = "collab";
|
||||
const SHARE_ACTION = "session-sharing.share";
|
||||
const SHARED_WITH_ME_ACTION = "sessions.sharedWithMe";
|
||||
const SEEN_ROOMS_KEY = "termix:collab-rooms-seen";
|
||||
const INVITE_POLL_MS = 60_000;
|
||||
|
||||
/** `?view=shared` and `?view=collab-guest`, the two anonymous guest links. */
|
||||
function GuestView({ view }: StandaloneViewProps) {
|
||||
return view === "shared" ? <SharedSessionView /> : <CollabGuestView />;
|
||||
}
|
||||
|
||||
function CollabRoomTabView({ tab, isVisible }: TabProps) {
|
||||
const roomId = tab.data?.roomId;
|
||||
return (
|
||||
<CollabRoomTab
|
||||
roomId={typeof roomId === "string" ? roomId : undefined}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CollabPanelView({ shell }: PanelProps) {
|
||||
return (
|
||||
<CollabPanel
|
||||
onOpenRoom={(room) =>
|
||||
shell.openTab(null, COLLAB, {
|
||||
label: room.name,
|
||||
forceNewTab: true,
|
||||
data: { roomId: room.id },
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rooms are discovered by polling, so a room this browser has never shown
|
||||
* gets one toast with an Open action. The first poll after login only
|
||||
* records what already exists.
|
||||
*/
|
||||
function createInviteWatcher(app: TermixApp): ComponentType {
|
||||
return function InviteWatcher() {
|
||||
const user = useCurrentUser();
|
||||
const allowed = usePermission("use");
|
||||
const userId = user?.userId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || !allowed) return;
|
||||
let cancelled = false;
|
||||
const check = async () => {
|
||||
try {
|
||||
const { rooms = [] } = await listCollabRooms();
|
||||
if (cancelled) return;
|
||||
let seen: string[] = [];
|
||||
try {
|
||||
seen = JSON.parse(localStorage.getItem(SEEN_ROOMS_KEY) ?? "[]");
|
||||
} catch {
|
||||
seen = [];
|
||||
}
|
||||
const seenSet = new Set(seen);
|
||||
const fresh = rooms.filter((room) => !seenSet.has(room.id));
|
||||
if (fresh.length === 0) return;
|
||||
localStorage.setItem(
|
||||
SEEN_ROOMS_KEY,
|
||||
JSON.stringify([...seenSet, ...fresh.map((room) => room.id)]),
|
||||
);
|
||||
if (seen.length === 0) return;
|
||||
for (const room of fresh) {
|
||||
if (room.ownerUserId === userId) continue;
|
||||
toast(app.t("collab.invitedTo", { name: room.name }), {
|
||||
action: {
|
||||
label: app.t("collab.openRoom"),
|
||||
onClick: () => app.tabs.openRailView(COLLAB),
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* next poll */
|
||||
}
|
||||
};
|
||||
void check();
|
||||
const timer = setInterval(() => void check(), INVITE_POLL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [userId, allowed]);
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/** A terminal toolbar action is invoked with the terminal's slot API. */
|
||||
interface TerminalSlotApi {
|
||||
getShareTarget?: () => ShareTarget | null;
|
||||
}
|
||||
|
||||
/** A remote desktop toolbar action is invoked with the session it shows. */
|
||||
interface RemoteDesktopToolbarContext {
|
||||
hostId: number;
|
||||
sessionId: string | null;
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
tabInstanceId?: string;
|
||||
}
|
||||
|
||||
function isRemoteDesktopContext(
|
||||
value: unknown,
|
||||
): value is RemoteDesktopToolbarContext {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === "object" &&
|
||||
"protocol" in value &&
|
||||
"hostId" in value
|
||||
);
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
bindApi(app.api);
|
||||
app.onDispose(() => {
|
||||
closeShareDialog();
|
||||
bindApi(null);
|
||||
});
|
||||
|
||||
// Rooms and share links draw remote desktop streams through this slot.
|
||||
app.declareActionSlot({ id: REMOTE_DISPLAY_SLOT, accepts: ["component"] });
|
||||
|
||||
// Guest pages open the tab type's standalone view by ?view= name.
|
||||
const guestViews = {
|
||||
standalone: GuestView,
|
||||
standaloneViews: ["shared", "collab-guest"],
|
||||
};
|
||||
|
||||
if (app.guest) {
|
||||
app.registerTab(COLLAB, () => null, { hostless: true, ...guestViews });
|
||||
return;
|
||||
}
|
||||
|
||||
app.registerRailItem({
|
||||
id: COLLAB,
|
||||
icon: Presentation,
|
||||
titleKey: "nav.collab",
|
||||
kind: "panel",
|
||||
after: "connections",
|
||||
separatorAfter: true,
|
||||
permission: "use",
|
||||
});
|
||||
app.registerPanel(COLLAB, CollabPanelView);
|
||||
app.registerTab(COLLAB, CollabRoomTabView, {
|
||||
icon: Presentation,
|
||||
titleKey: "nav.collab",
|
||||
hostless: true,
|
||||
multiInstance: true,
|
||||
...guestViews,
|
||||
});
|
||||
|
||||
app.registerAction(
|
||||
SHARE_ACTION,
|
||||
(context: unknown) => {
|
||||
let target: ShareTarget | null = null;
|
||||
if (isRemoteDesktopContext(context)) {
|
||||
target = context.sessionId
|
||||
? {
|
||||
hostId: context.hostId,
|
||||
sessionId: context.sessionId,
|
||||
protocol: context.protocol,
|
||||
tabInstanceId: context.tabInstanceId,
|
||||
}
|
||||
: null;
|
||||
} else {
|
||||
target = (context as TerminalSlotApi)?.getShareTarget?.() ?? null;
|
||||
}
|
||||
if (target) openShareDialog(target);
|
||||
else toast.error(app.t("sessionSharing.notReadyToShare"));
|
||||
},
|
||||
{ permission: "use" },
|
||||
);
|
||||
for (const slot of ["terminal.toolbar", "remote-desktop.toolbar"]) {
|
||||
app.registerSlotContribution(slot, {
|
||||
actionId: SHARE_ACTION,
|
||||
titleKey: "sessionSharing.shareButton",
|
||||
icon: Share2,
|
||||
kind: "button",
|
||||
});
|
||||
}
|
||||
|
||||
app.registerSlotContribution("shell.overlay", {
|
||||
actionId: "session-sharing.shareDialog",
|
||||
titleKey: "sessionSharing.shareButton",
|
||||
kind: "component",
|
||||
component: ShareDialogHost as ComponentType<Record<string, unknown>>,
|
||||
});
|
||||
app.registerSlotContribution("shell.overlay", {
|
||||
actionId: "session-sharing.inviteWatcher",
|
||||
titleKey: "nav.collab",
|
||||
kind: "component",
|
||||
component: createInviteWatcher(app) as ComponentType<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
});
|
||||
|
||||
// Core's active connections list asks for sessions shared with the user.
|
||||
app.registerAction(SHARED_WITH_ME_ACTION, () => getSharedWithMe(), {
|
||||
permission: "use",
|
||||
});
|
||||
}
|
||||
|
||||
export function deactivate(): void {
|
||||
closeShareDialog();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { ShareSessionModal } from "./ShareSessionModal";
|
||||
import type { SessionShareProtocol } from "./api";
|
||||
|
||||
/** What the share dialog needs to share one live session. */
|
||||
export interface ShareTarget {
|
||||
hostId: number;
|
||||
sessionId: string;
|
||||
protocol: SessionShareProtocol;
|
||||
tabInstanceId?: string;
|
||||
}
|
||||
|
||||
let current: ShareTarget | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
/** Opens the share dialog, which the shell overlay slot keeps mounted. */
|
||||
export function openShareDialog(target: ShareTarget): void {
|
||||
current = target;
|
||||
emit();
|
||||
}
|
||||
|
||||
export function closeShareDialog(): void {
|
||||
current = null;
|
||||
emit();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Rendered once in "shell.overlay"; shows the dialog when a toolbar asks. */
|
||||
export function ShareDialogHost() {
|
||||
const target = useSyncExternalStore(subscribe, () => current);
|
||||
if (!target) return null;
|
||||
return (
|
||||
<ShareSessionModal
|
||||
open
|
||||
onClose={closeShareDialog}
|
||||
hostId={target.hostId}
|
||||
sessionId={target.sessionId}
|
||||
protocol={target.protocol}
|
||||
tabInstanceId={target.tabInstanceId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
invokeAction,
|
||||
useSlotContributions,
|
||||
useTranslation,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { pluginWsUrl } from "@termix/plugin-sdk/ui";
|
||||
|
||||
/**
|
||||
* A remote desktop stream drawn by whichever plugin provides one. Rooms and
|
||||
* share links show RDP, VNC and Telnet without knowing how they are drawn:
|
||||
* the provider contributes a component to this slot and answers the token
|
||||
* action below.
|
||||
*/
|
||||
export const REMOTE_DISPLAY_SLOT = "session.remoteDisplay";
|
||||
export const REMOTE_SESSION_TOKEN_ACTION = "session.remoteDisplay.token";
|
||||
|
||||
export interface RemoteDisplayProps {
|
||||
token: string;
|
||||
protocol: string;
|
||||
isVisible: boolean;
|
||||
onConnect?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export function RemoteDisplay(props: RemoteDisplayProps) {
|
||||
const { t } = useTranslation();
|
||||
const contributions = useSlotContributions(REMOTE_DISPLAY_SLOT);
|
||||
const provider = useMemo(
|
||||
() =>
|
||||
contributions.find(
|
||||
(contribution) =>
|
||||
contribution.kind === "component" && contribution.component,
|
||||
),
|
||||
[contributions],
|
||||
);
|
||||
if (!provider?.component) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground">
|
||||
{t("sessionSharing.remoteDisplayUnavailable")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const Display = provider.component;
|
||||
return <Display {...(props as unknown as Record<string, unknown>)} />;
|
||||
}
|
||||
|
||||
export interface RemoteSessionToken {
|
||||
token: string;
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
/** Mints a session token for presenting a host, through the provider. */
|
||||
export async function createRemoteSessionToken(
|
||||
hostId: number,
|
||||
origin: unknown,
|
||||
protocol: string,
|
||||
): Promise<RemoteSessionToken | null> {
|
||||
return ((await invokeAction(
|
||||
REMOTE_SESSION_TOKEN_ACTION,
|
||||
hostId,
|
||||
origin,
|
||||
protocol,
|
||||
)) ?? null) as RemoteSessionToken | null;
|
||||
}
|
||||
|
||||
/** A socket URL for a /plugin-ws/<id>/<path>?query path the server handed out. */
|
||||
export async function wsUrlForPath(wsPath: string): Promise<string | null> {
|
||||
const [pathname, query] = wsPath.split("?", 2);
|
||||
const match = /^\/plugin-ws\/([^/]+)(\/.*)$/.exec(pathname);
|
||||
if (!match) return null;
|
||||
const target = await pluginWsUrl(match[1], match[2]);
|
||||
if (!target) return null;
|
||||
if (!query) return target.url;
|
||||
return `${target.url}${target.url.includes("?") ? "&" : "?"}${query}`;
|
||||
}
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return typeof error === "string" ? error : String(error);
|
||||
}
|
||||
|
||||
export function Loader({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-background">
|
||||
<div className="size-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user