mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
1471 lines
44 KiB
TypeScript
1471 lines
44 KiB
TypeScript
import crypto from "node:crypto";
|
|
import type { Request, Response, Router } from "express";
|
|
import {
|
|
PROTOCOLS,
|
|
TERMINAL_WS_PATH,
|
|
actorOf,
|
|
clientIp,
|
|
isNonEmptyString,
|
|
type SharingDeps,
|
|
} from "./deps.js";
|
|
import type { LiveProtocol, RoomRecord } from "./repositories.js";
|
|
import type { CollabControlRequest } from "./runtime-store.js";
|
|
|
|
/*
|
|
* Known limits, shared with session sharing:
|
|
* - Redis synchronizes collaboration events and ephemeral state across
|
|
* instances, but live session transports still need WebSocket affinity.
|
|
* - Remote desktop stages stay read-only because guacamole-lite cannot revoke
|
|
* a writable viewer without disconnecting the whole shared session.
|
|
*/
|
|
|
|
const STAGE_SHARE_EXPIRY_HOURS = 12;
|
|
const MAX_INVITE_TARGETS = 200;
|
|
const CONTROL_REQUEST_COOLDOWN_MS = 5000;
|
|
|
|
function publicRoom(room: RoomRecord) {
|
|
const { guestLinkToken: secret, ...safeRoom } = room;
|
|
return { ...safeRoom, guestLinkEnabled: Boolean(secret) };
|
|
}
|
|
|
|
function stagePayload(room: RoomRecord) {
|
|
return {
|
|
presenterUserId: room.presenterUserId,
|
|
protocol: room.stageProtocol,
|
|
hostId: room.stageHostId,
|
|
shareId: room.stageShareId,
|
|
};
|
|
}
|
|
|
|
/** The pieces of room behaviour the routes and the runtime events share. */
|
|
export function createRoomActions(deps: SharingDeps) {
|
|
const { shares, rooms, live, hub, store } = deps;
|
|
|
|
async function requireRoomMember(
|
|
roomId: string,
|
|
userId: string,
|
|
): Promise<{ room: RoomRecord; isHost: boolean } | null> {
|
|
const room = await rooms.findById(roomId);
|
|
if (!room || room.endedAt) return null;
|
|
const member = await rooms.findMember(roomId, userId);
|
|
if (!member) return null;
|
|
return { room, isHost: member.roomRole === "host" };
|
|
}
|
|
|
|
async function revokeStageShare(room: RoomRecord): Promise<void> {
|
|
if (!room.stageShareId) return;
|
|
const share = await shares.findActiveById(room.stageShareId);
|
|
if (!share) return;
|
|
if (!(await shares.revokeAny(room.stageShareId))) {
|
|
throw new Error("Failed to revoke the active stage share");
|
|
}
|
|
if (share.protocol === "ssh") {
|
|
await live.disconnectParticipants(
|
|
"ssh",
|
|
share.sessionId,
|
|
share.id,
|
|
{ reason: "The collaboration stage ended" },
|
|
share.ownerUserId,
|
|
);
|
|
void store.publish(room.id, {
|
|
type: "collab_internal_stage_revoked",
|
|
sessionId: share.sessionId,
|
|
shareId: share.id,
|
|
ownerUserId: share.ownerUserId,
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Sets the controller everywhere it lives: the store, the live gate, the hub. */
|
|
async function applyStageControl(
|
|
room: RoomRecord,
|
|
controllerUserId: string | null,
|
|
): Promise<void> {
|
|
await store.setController(room.id, controllerUserId);
|
|
if (room.stageShareId && room.stageProtocol === "ssh") {
|
|
const share = await shares
|
|
.findActiveById(room.stageShareId)
|
|
.catch(() => null);
|
|
if (share) {
|
|
await live.setRoomShareControl(
|
|
"ssh",
|
|
share.sessionId,
|
|
share.id,
|
|
controllerUserId,
|
|
share.ownerUserId,
|
|
);
|
|
}
|
|
}
|
|
hub.broadcast(room.id, {
|
|
type: "collab_control_changed",
|
|
roomId: room.id,
|
|
controllerUserId,
|
|
});
|
|
}
|
|
|
|
async function clearStaleStage(roomId: string): Promise<void> {
|
|
await store.setController(roomId, null);
|
|
await rooms.clearStage(roomId);
|
|
hub.broadcast(roomId, {
|
|
type: "collab_stage_changed",
|
|
roomId,
|
|
stage: null,
|
|
});
|
|
}
|
|
|
|
/** The stage's share, when it is still active and its session still live. */
|
|
async function liveStageShare(room: RoomRecord) {
|
|
if (!room.stageShareId || !room.stageProtocol) return null;
|
|
const share = await shares.findActiveById(room.stageShareId);
|
|
if (!share) return null;
|
|
const alive = await live.isLive(
|
|
room.stageProtocol,
|
|
share.sessionId,
|
|
share.ownerUserId,
|
|
);
|
|
return alive ? share : null;
|
|
}
|
|
|
|
/** Room events from other instances that touch live sessions here. */
|
|
function onRuntimeEvent(roomId: string, message: object): void {
|
|
const event = message as Record<string, unknown>;
|
|
if (event.type === "collab_control_changed") {
|
|
const controllerUserId =
|
|
typeof event.controllerUserId === "string"
|
|
? event.controllerUserId
|
|
: null;
|
|
void rooms
|
|
.findById(roomId)
|
|
.then(async (room) => {
|
|
if (!room?.stageShareId || room.stageProtocol !== "ssh") return;
|
|
const share = await shares.findActiveById(room.stageShareId);
|
|
if (share) {
|
|
await live.setRoomShareControl(
|
|
"ssh",
|
|
share.sessionId,
|
|
share.id,
|
|
controllerUserId,
|
|
share.ownerUserId,
|
|
);
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
if (
|
|
event.type === "collab_internal_stage_revoked" &&
|
|
typeof event.sessionId === "string" &&
|
|
typeof event.shareId === "string" &&
|
|
typeof event.ownerUserId === "string"
|
|
) {
|
|
void live.disconnectParticipants(
|
|
"ssh",
|
|
event.sessionId,
|
|
event.shareId,
|
|
{ reason: "The collaboration stage ended" },
|
|
event.ownerUserId,
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
requireRoomMember,
|
|
revokeStageShare,
|
|
applyStageControl,
|
|
clearStaleStage,
|
|
liveStageShare,
|
|
onRuntimeEvent,
|
|
};
|
|
}
|
|
|
|
export type RoomActions = ReturnType<typeof createRoomActions>;
|
|
|
|
/**
|
|
* Collaboration rooms. `/guest/:token` is public (an anonymous room guest
|
|
* link); everything else needs the plugin's `use` permission and room
|
|
* membership.
|
|
*/
|
|
export function registerRoomRoutes(
|
|
router: Router,
|
|
deps: SharingDeps,
|
|
actions: RoomActions,
|
|
): void {
|
|
const { ctx, shares, rooms, directory, live, hub, store, guestLimiter } =
|
|
deps;
|
|
const {
|
|
requireRoomMember,
|
|
revokeStageShare,
|
|
applyStageControl,
|
|
clearStaleStage,
|
|
liveStageShare,
|
|
} = actions;
|
|
const requireUse = ctx.rbac.require("use") as never;
|
|
|
|
router.get(
|
|
"/meeting-host/:syncId",
|
|
requireUse,
|
|
async (req: Request, res: Response) => {
|
|
if (!ctx.currentActor())
|
|
return res.status(401).json({ error: "Authentication required" });
|
|
try {
|
|
for (const id of await directory.hostIdsBySyncId(
|
|
String(req.params.syncId),
|
|
)) {
|
|
if ((await ctx.hosts.checkAccess(id, "connect")).hasAccess) {
|
|
return res.json({ id });
|
|
}
|
|
}
|
|
return res.status(404).json({
|
|
error:
|
|
"The host must be synced to the meeting server before presenting",
|
|
});
|
|
} catch {
|
|
return res
|
|
.status(500)
|
|
.json({ error: "Failed to resolve meeting host" });
|
|
}
|
|
},
|
|
);
|
|
|
|
const audit = (
|
|
req: Request,
|
|
action: string,
|
|
room: { id: string; name: string },
|
|
details?: Record<string, unknown>,
|
|
) =>
|
|
ctx.audit.record({
|
|
request: req,
|
|
action,
|
|
resourceType: "collab_room",
|
|
resourceId: room.id,
|
|
resourceName: room.name,
|
|
details: details ? JSON.stringify(details) : undefined,
|
|
success: true,
|
|
});
|
|
|
|
const fail = (res: Response, operation: string, message: string) => {
|
|
return (error: unknown) => {
|
|
ctx.log.error(
|
|
`${operation}: ${message}`,
|
|
error instanceof Error ? error : undefined,
|
|
);
|
|
res.status(500).json({ error: message });
|
|
};
|
|
};
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/session-sharing/rooms:
|
|
* post:
|
|
* summary: Create a collaboration room
|
|
* tags:
|
|
* - Collab
|
|
* security:
|
|
* - bearerAuth: []
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [name]
|
|
* properties:
|
|
* name:
|
|
* type: string
|
|
* persistent:
|
|
* type: boolean
|
|
* responses:
|
|
* 200:
|
|
* description: The new room
|
|
* 400:
|
|
* description: Missing or too long name
|
|
*/
|
|
router.post("/rooms", requireUse, async (req: Request, res: Response) => {
|
|
const userId = actorOf(ctx);
|
|
const { name, persistent } = req.body ?? {};
|
|
if (!isNonEmptyString(name) || name.trim().length > 120) {
|
|
return res.status(400).json({ error: "Room name is required" });
|
|
}
|
|
try {
|
|
const room = await rooms.createRoom({
|
|
id: crypto.randomUUID(),
|
|
name: name.trim(),
|
|
ownerUserId: userId,
|
|
persistent: persistent === true,
|
|
});
|
|
await rooms.addMember({
|
|
roomId: room.id,
|
|
userId,
|
|
roomRole: "host",
|
|
addedBy: userId,
|
|
});
|
|
await audit(req, "collab_room_create", room);
|
|
res.json({ room: publicRoom(room) });
|
|
} catch (error) {
|
|
fail(res, "collab_room_create_error", "Failed to create room")(error);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/session-sharing/rooms:
|
|
* get:
|
|
* summary: List rooms the caller belongs to
|
|
* tags:
|
|
* - Collab
|
|
* security:
|
|
* - bearerAuth: []
|
|
* responses:
|
|
* 200:
|
|
* description: Rooms, without their guest tokens
|
|
*/
|
|
router.get("/rooms", requireUse, async (_req: Request, res: Response) => {
|
|
try {
|
|
const list = await rooms.listForUser(actorOf(ctx));
|
|
res.json({ rooms: list.map(publicRoom) });
|
|
} catch (error) {
|
|
fail(res, "collab_room_list_error", "Failed to list rooms")(error);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/session-sharing/rooms/{id}:
|
|
* get:
|
|
* summary: Get a room with members, online users and stage state
|
|
* tags:
|
|
* - Collab
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: The room
|
|
* 404:
|
|
* description: Not a member, or no such room
|
|
*/
|
|
router.get("/rooms/:id", requireUse, async (req: Request, res: Response) => {
|
|
const userId = actorOf(ctx);
|
|
const roomId = String(req.params.id);
|
|
try {
|
|
let access = await requireRoomMember(roomId, userId);
|
|
if (!access) return res.status(404).json({ error: "Room not found" });
|
|
|
|
// The presenter's own client drives the stage locally and never asks
|
|
// for it, so this is where a presenter whose session died is noticed.
|
|
if (access.room.stageShareId && !(await liveStageShare(access.room))) {
|
|
await clearStaleStage(roomId);
|
|
access = await requireRoomMember(roomId, userId);
|
|
if (!access) return res.status(404).json({ error: "Room not found" });
|
|
}
|
|
|
|
const members = await rooms.listMembers(roomId);
|
|
const stageShare = await liveStageShare(access.room);
|
|
const guests =
|
|
stageShare && access.room.stageProtocol
|
|
? await live.listGuests(
|
|
access.room.stageProtocol,
|
|
stageShare.sessionId,
|
|
stageShare.id,
|
|
stageShare.ownerUserId,
|
|
)
|
|
: [];
|
|
const mayReview = access.isHost || access.room.presenterUserId === userId;
|
|
const controlRequests = await store.listRequests(roomId);
|
|
res.json({
|
|
room: publicRoom(access.room),
|
|
me: userId,
|
|
isHost: access.isHost,
|
|
members,
|
|
online: await hub.onlineUsers(roomId),
|
|
stage: stagePayload(access.room),
|
|
guests,
|
|
controllerUserId: await store.getController(roomId),
|
|
controlRequests: mayReview
|
|
? controlRequests
|
|
: controlRequests.filter((request) => request.userId === userId),
|
|
// Room events ride on the terminal socket.
|
|
eventsWsPath: TERMINAL_WS_PATH,
|
|
});
|
|
} catch (error) {
|
|
fail(res, "collab_room_get_error", "Failed to get room")(error);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/session-sharing/rooms/{id}/members:
|
|
* post:
|
|
* summary: Invite users to a room (host only)
|
|
* description: Roles expand to their current members, a snapshot.
|
|
* tags:
|
|
* - Collab
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* userIds:
|
|
* type: array
|
|
* items:
|
|
* type: string
|
|
* roleIds:
|
|
* type: array
|
|
* items:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Invited
|
|
* 403:
|
|
* description: Not the host
|
|
* 404:
|
|
* description: Room, user or role not found
|
|
*/
|
|
router.post(
|
|
"/rooms/:id/members",
|
|
requireUse,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actorOf(ctx);
|
|
const roomId = String(req.params.id);
|
|
const { userIds = [], roleIds = [] } = req.body ?? {};
|
|
|
|
if (
|
|
!Array.isArray(userIds) ||
|
|
userIds.some((id) => !isNonEmptyString(id)) ||
|
|
!Array.isArray(roleIds) ||
|
|
roleIds.some((id) => !Number.isInteger(id)) ||
|
|
(userIds.length === 0 && roleIds.length === 0)
|
|
) {
|
|
return res.status(400).json({
|
|
error: "userIds (user ids) or roleIds (integers) are required",
|
|
});
|
|
}
|
|
if (userIds.length + roleIds.length > MAX_INVITE_TARGETS) {
|
|
return res.status(400).json({ error: "Too many invite targets" });
|
|
}
|
|
|
|
try {
|
|
const access = await requireRoomMember(roomId, userId);
|
|
if (!access) return res.status(404).json({ error: "Room not found" });
|
|
if (!access.isHost) {
|
|
return res.status(403).json({ error: "Only the host can invite" });
|
|
}
|
|
|
|
for (const targetId of userIds as string[]) {
|
|
if (!(await directory.userExists(targetId))) {
|
|
return res.status(404).json({ error: "User not found", targetId });
|
|
}
|
|
}
|
|
const expanded = new Set<string>(userIds as string[]);
|
|
for (const roleId of roleIds as number[]) {
|
|
if (!(await directory.roleExists(roleId))) {
|
|
return res.status(404).json({ error: "Role not found", roleId });
|
|
}
|
|
for (const memberId of await directory.roleUserIds(roleId)) {
|
|
expanded.add(memberId);
|
|
}
|
|
}
|
|
for (const targetId of expanded) {
|
|
await rooms.addMember({
|
|
roomId,
|
|
userId: targetId,
|
|
roomRole: "member",
|
|
addedBy: userId,
|
|
});
|
|
}
|
|
|
|
await audit(req, "collab_room_invite", access.room, {
|
|
memberCount: expanded.size,
|
|
});
|
|
hub.broadcast(roomId, { type: "collab_members_changed", roomId });
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
fail(
|
|
res,
|
|
"collab_room_invite_error",
|
|
"Failed to invite members",
|
|
)(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/session-sharing/rooms/{id}/members/{userId}:
|
|
* delete:
|
|
* summary: Remove a member (host), or leave the room (self)
|
|
* tags:
|
|
* - Collab
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* - in: path
|
|
* name: userId
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Removed
|
|
* 400:
|
|
* description: The owner cannot be removed
|
|
* 403:
|
|
* description: Only the host can remove others
|
|
*/
|
|
router.delete(
|
|
"/rooms/:id/members/:userId",
|
|
requireUse,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actorOf(ctx);
|
|
const roomId = String(req.params.id);
|
|
const targetId = String(req.params.userId);
|
|
try {
|
|
const access = await requireRoomMember(roomId, userId);
|
|
if (!access) return res.status(404).json({ error: "Room not found" });
|
|
if (targetId !== userId && !access.isHost) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: "Only the host can remove members" });
|
|
}
|
|
if (targetId === access.room.ownerUserId) {
|
|
return res.status(400).json({ error: "The owner cannot be removed" });
|
|
}
|
|
|
|
await rooms.removeMember(roomId, targetId);
|
|
await store.removeRequest(roomId, targetId);
|
|
|
|
if (access.room.stageShareId && access.room.stageProtocol === "ssh") {
|
|
const share = await shares.findActiveById(access.room.stageShareId);
|
|
if (share) {
|
|
await live.disconnectParticipants(
|
|
"ssh",
|
|
share.sessionId,
|
|
share.id,
|
|
{
|
|
userId: targetId,
|
|
reason: "You were removed from the collaboration room",
|
|
},
|
|
share.ownerUserId,
|
|
);
|
|
}
|
|
}
|
|
|
|
if ((await store.getController(roomId)) === targetId) {
|
|
await applyStageControl(access.room, null);
|
|
}
|
|
|
|
if (access.room.presenterUserId === targetId) {
|
|
await revokeStageShare(access.room);
|
|
await rooms.clearStage(roomId);
|
|
hub.broadcast(roomId, {
|
|
type: "collab_stage_changed",
|
|
roomId,
|
|
stage: null,
|
|
});
|
|
}
|
|
|
|
await audit(
|
|
req,
|
|
targetId === userId
|
|
? "collab_room_leave"
|
|
: "collab_room_remove_member",
|
|
access.room,
|
|
{ targetUserId: targetId },
|
|
);
|
|
hub.broadcast(roomId, { type: "collab_members_changed", roomId });
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
fail(
|
|
res,
|
|
"collab_room_remove_member_error",
|
|
"Failed to remove member",
|
|
)(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/session-sharing/rooms/{id}/present:
|
|
* post:
|
|
* summary: Take the stage with one of your live sessions
|
|
* description: Any member may take over the stage; the previous stage share is revoked. The caller must own the live session and sharing must be enabled for the host.
|
|
* tags:
|
|
* - Collab
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [protocol, sessionId, hostId]
|
|
* properties:
|
|
* protocol:
|
|
* type: string
|
|
* enum: [ssh, rdp, vnc, telnet]
|
|
* sessionId:
|
|
* type: string
|
|
* hostId:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: The new stage
|
|
* 403:
|
|
* description: Sharing disabled, or not the session's owner
|
|
* 409:
|
|
* description: The stage changed meanwhile
|
|
*/
|
|
router.post(
|
|
"/rooms/:id/present",
|
|
requireUse,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actorOf(ctx);
|
|
const roomId = String(req.params.id);
|
|
const { protocol, sessionId, hostId } = req.body ?? {};
|
|
|
|
if (!PROTOCOLS.includes(protocol)) {
|
|
return res.status(400).json({ error: "Invalid protocol" });
|
|
}
|
|
if (!isNonEmptyString(sessionId) || !Number.isInteger(Number(hostId))) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "sessionId and hostId are required" });
|
|
}
|
|
|
|
try {
|
|
const access = await requireRoomMember(roomId, userId);
|
|
if (!access) return res.status(404).json({ error: "Room not found" });
|
|
|
|
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 as LiveProtocol,
|
|
String(sessionId),
|
|
userId,
|
|
))
|
|
) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: "You do not own this live session" });
|
|
}
|
|
|
|
const share = await shares.create({
|
|
id: crypto.randomUUID(),
|
|
hostId: numericHostId,
|
|
ownerUserId: userId,
|
|
protocol,
|
|
sessionId: String(sessionId),
|
|
shareType: "room",
|
|
permissionLevel: "read-only",
|
|
expiresAt: new Date(
|
|
Date.now() + STAGE_SHARE_EXPIRY_HOURS * 60 * 60 * 1000,
|
|
).toISOString(),
|
|
});
|
|
|
|
await revokeStageShare(access.room);
|
|
const replaced = await rooms.replaceStage(
|
|
roomId,
|
|
access.room.stageShareId,
|
|
{
|
|
presenterUserId: userId,
|
|
stageProtocol: protocol,
|
|
stageHostId: numericHostId,
|
|
stageShareId: share.id,
|
|
},
|
|
);
|
|
if (!replaced) {
|
|
await shares.revokeAny(share.id);
|
|
return res
|
|
.status(409)
|
|
.json({ error: "The stage changed; try again" });
|
|
}
|
|
await store.setController(roomId, null);
|
|
await store.clearRequests(roomId);
|
|
|
|
const stage = {
|
|
presenterUserId: userId,
|
|
protocol,
|
|
hostId: numericHostId,
|
|
shareId: share.id,
|
|
};
|
|
hub.broadcast(roomId, { type: "collab_stage_changed", roomId, stage });
|
|
await audit(req, "collab_room_present", access.room, {
|
|
protocol,
|
|
hostId: numericHostId,
|
|
});
|
|
res.json({ stage });
|
|
} catch (error) {
|
|
fail(
|
|
res,
|
|
"collab_room_present_error",
|
|
"Failed to start presenting",
|
|
)(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/session-sharing/rooms/{id}/stop:
|
|
* post:
|
|
* summary: Stop presenting (presenter or host)
|
|
* tags:
|
|
* - Collab
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Stage cleared
|
|
* 403:
|
|
* description: Not the presenter or host
|
|
*/
|
|
router.post(
|
|
"/rooms/:id/stop",
|
|
requireUse,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actorOf(ctx);
|
|
const roomId = String(req.params.id);
|
|
try {
|
|
const access = await requireRoomMember(roomId, userId);
|
|
if (!access) return res.status(404).json({ error: "Room not found" });
|
|
if (access.room.presenterUserId !== userId && !access.isHost) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: "Only the presenter or host can stop the stage" });
|
|
}
|
|
|
|
await revokeStageShare(access.room);
|
|
await store.setController(roomId, null);
|
|
await store.clearRequests(roomId);
|
|
await rooms.clearStage(roomId);
|
|
hub.broadcast(roomId, {
|
|
type: "collab_stage_changed",
|
|
roomId,
|
|
stage: null,
|
|
});
|
|
await audit(req, "collab_room_stop_presenting", access.room);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
fail(res, "collab_room_stop_error", "Failed to stop presenting")(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/session-sharing/rooms/{id}/stage:
|
|
* get:
|
|
* summary: Get connect info for the current stage (members only)
|
|
* description: SSH stages are joined over the terminal socket by shareId; remote desktop stages get a freshly minted read-only viewer token.
|
|
* tags:
|
|
* - Collab
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: The stage, or null
|
|
*/
|
|
router.get(
|
|
"/rooms/:id/stage",
|
|
requireUse,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actorOf(ctx);
|
|
const roomId = String(req.params.id);
|
|
try {
|
|
const access = await requireRoomMember(roomId, userId);
|
|
if (!access) return res.status(404).json({ error: "Room not found" });
|
|
const { room } = access;
|
|
if (!room.stageShareId || !room.stageProtocol) {
|
|
return res.json({ stage: null });
|
|
}
|
|
|
|
const share = await liveStageShare(room);
|
|
if (!share) {
|
|
// The presenter is gone (expired share or dead session).
|
|
await clearStaleStage(roomId);
|
|
return res.json({ stage: null });
|
|
}
|
|
|
|
const stage: Record<string, unknown> = {
|
|
...stagePayload(room),
|
|
sessionId: share.sessionId,
|
|
controllerUserId: await store.getController(roomId),
|
|
};
|
|
if (room.stageProtocol !== "ssh") {
|
|
stage.connectParams = {
|
|
token: await live.createViewerToken(
|
|
room.stageProtocol,
|
|
share.sessionId,
|
|
true,
|
|
share.ownerUserId,
|
|
),
|
|
};
|
|
}
|
|
res.json({ stage });
|
|
} catch (error) {
|
|
fail(res, "collab_room_stage_error", "Failed to resolve stage")(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/session-sharing/rooms/{id}/control:
|
|
* post:
|
|
* summary: Grant or revoke stage control (presenter or host)
|
|
* description: Grants a member write access to the current SSH stage, or revokes it with a null userId. The controller may also release control themselves.
|
|
* tags:
|
|
* - Collab
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* userId:
|
|
* type: string
|
|
* nullable: true
|
|
* responses:
|
|
* 200:
|
|
* description: The new controller
|
|
* 400:
|
|
* description: Nothing presented, or a remote desktop stage
|
|
* 403:
|
|
* description: Not the presenter or host
|
|
*/
|
|
router.post(
|
|
"/rooms/:id/control",
|
|
requireUse,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actorOf(ctx);
|
|
const roomId = String(req.params.id);
|
|
const { userId: targetId } = req.body ?? {};
|
|
if (targetId !== null && !isNonEmptyString(targetId)) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "userId must be a user id or null" });
|
|
}
|
|
|
|
try {
|
|
const access = await requireRoomMember(roomId, userId);
|
|
if (!access) return res.status(404).json({ error: "Room not found" });
|
|
if (!access.room.stageShareId) {
|
|
return res.status(400).json({ error: "Nothing is being presented" });
|
|
}
|
|
if (access.room.stageProtocol !== "ssh") {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Remote desktop stages are read-only" });
|
|
}
|
|
|
|
const releasingOwn =
|
|
targetId === null && (await store.getController(roomId)) === userId;
|
|
const mayGrant =
|
|
access.isHost || access.room.presenterUserId === userId;
|
|
if (!mayGrant && !releasingOwn) {
|
|
return res.status(403).json({
|
|
error: "Only the presenter or host can change stage control",
|
|
});
|
|
}
|
|
if (targetId && !(await rooms.findMember(roomId, targetId))) {
|
|
return res.status(404).json({ error: "Member not found" });
|
|
}
|
|
|
|
await applyStageControl(access.room, targetId);
|
|
if (targetId) {
|
|
await store.removeRequest(roomId, targetId);
|
|
hub.broadcast(roomId, {
|
|
type: "collab_control_requests_changed",
|
|
roomId,
|
|
});
|
|
}
|
|
await audit(
|
|
req,
|
|
targetId ? "collab_control_grant" : "collab_control_revoke",
|
|
access.room,
|
|
{ targetUserId: targetId },
|
|
);
|
|
res.json({ controllerUserId: targetId });
|
|
} catch (error) {
|
|
fail(
|
|
res,
|
|
"collab_control_error",
|
|
"Failed to change stage control",
|
|
)(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/session-sharing/rooms/{id}/control/request:
|
|
* post:
|
|
* summary: Ask the presenter for stage control (hand raise)
|
|
* tags:
|
|
* - Collab
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: The request
|
|
* 429:
|
|
* description: Asked too recently
|
|
*/
|
|
router.post(
|
|
"/rooms/:id/control/request",
|
|
requireUse,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actorOf(ctx);
|
|
const roomId = String(req.params.id);
|
|
try {
|
|
const access = await requireRoomMember(roomId, userId);
|
|
if (!access) return res.status(404).json({ error: "Room not found" });
|
|
if (!access.room.stageShareId) {
|
|
return res.status(400).json({ error: "Nothing is being presented" });
|
|
}
|
|
if (access.room.stageProtocol !== "ssh") {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Remote desktop stages are read-only" });
|
|
}
|
|
const existing = (await store.listRequests(roomId)).find(
|
|
(request) => request.userId === userId,
|
|
);
|
|
if (
|
|
existing &&
|
|
Date.now() - Date.parse(existing.requestedAt) <
|
|
CONTROL_REQUEST_COOLDOWN_MS
|
|
) {
|
|
return res
|
|
.status(429)
|
|
.json({ error: "Control was already requested" });
|
|
}
|
|
const request: CollabControlRequest = {
|
|
userId,
|
|
username: await directory.username(userId),
|
|
requestedAt: new Date().toISOString(),
|
|
};
|
|
await store.upsertRequest(roomId, request);
|
|
hub.broadcast(roomId, {
|
|
type: "collab_control_requested",
|
|
roomId,
|
|
...request,
|
|
});
|
|
await audit(req, "collab_control_request", access.room);
|
|
res.json({ request });
|
|
} catch (error) {
|
|
fail(
|
|
res,
|
|
"collab_control_request_error",
|
|
"Failed to request control",
|
|
)(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/session-sharing/rooms/{id}/control/requests:
|
|
* get:
|
|
* summary: List pending control requests (presenter or host)
|
|
* tags:
|
|
* - Collab
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Pending requests
|
|
* 403:
|
|
* description: Not the presenter or host
|
|
*/
|
|
router.get(
|
|
"/rooms/:id/control/requests",
|
|
requireUse,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actorOf(ctx);
|
|
const roomId = String(req.params.id);
|
|
try {
|
|
const access = await requireRoomMember(roomId, userId);
|
|
if (!access) return res.status(404).json({ error: "Room not found" });
|
|
if (!access.isHost && access.room.presenterUserId !== userId) {
|
|
return res.status(403).json({
|
|
error: "Only the presenter or host can review control requests",
|
|
});
|
|
}
|
|
res.json({ requests: await store.listRequests(roomId) });
|
|
} catch (error) {
|
|
fail(
|
|
res,
|
|
"collab_control_requests_list_error",
|
|
"Failed to list control requests",
|
|
)(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/session-sharing/rooms/{id}/control/requests/{userId}:
|
|
* delete:
|
|
* summary: Dismiss a control request (presenter or host), or cancel your own
|
|
* tags:
|
|
* - Collab
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* - in: path
|
|
* name: userId
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Dismissed
|
|
* 403:
|
|
* description: Not allowed to dismiss it
|
|
*/
|
|
router.delete(
|
|
"/rooms/:id/control/requests/:userId",
|
|
requireUse,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actorOf(ctx);
|
|
const roomId = String(req.params.id);
|
|
const targetId = String(req.params.userId);
|
|
try {
|
|
const access = await requireRoomMember(roomId, userId);
|
|
if (!access) return res.status(404).json({ error: "Room not found" });
|
|
const mayReview =
|
|
access.isHost || access.room.presenterUserId === userId;
|
|
if (!mayReview && targetId !== userId) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: "Not allowed to dismiss request" });
|
|
}
|
|
await store.removeRequest(roomId, targetId);
|
|
hub.broadcast(roomId, {
|
|
type: "collab_control_requests_changed",
|
|
roomId,
|
|
});
|
|
await audit(
|
|
req,
|
|
targetId === userId
|
|
? "collab_control_request_cancel"
|
|
: "collab_control_request_dismiss",
|
|
access.room,
|
|
{ targetUserId: targetId },
|
|
);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
fail(
|
|
res,
|
|
"collab_control_request_dismiss_error",
|
|
"Failed to dismiss control request",
|
|
)(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/session-sharing/rooms/{id}/guests/remove:
|
|
* post:
|
|
* summary: Disconnect every anonymous guest watching the stage (host only)
|
|
* description: The guest link stays valid, so a guest may open it again.
|
|
* tags:
|
|
* - Collab
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Guests disconnected
|
|
* 403:
|
|
* description: Not the host
|
|
* 404:
|
|
* description: Not a member, or no such room
|
|
*/
|
|
router.post(
|
|
"/rooms/:id/guests/remove",
|
|
requireUse,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actorOf(ctx);
|
|
const roomId = String(req.params.id);
|
|
try {
|
|
const access = await requireRoomMember(roomId, userId);
|
|
if (!access) return res.status(404).json({ error: "Room not found" });
|
|
if (!access.isHost) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: "Only the host can remove guests" });
|
|
}
|
|
const share = await liveStageShare(access.room);
|
|
if (share && access.room.stageProtocol) {
|
|
await live.disconnectParticipants(
|
|
access.room.stageProtocol,
|
|
share.sessionId,
|
|
share.id,
|
|
{ userId: null, reason: "The host removed the guests" },
|
|
share.ownerUserId,
|
|
);
|
|
}
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
fail(
|
|
res,
|
|
"collab_guests_remove_error",
|
|
"Failed to remove guests",
|
|
)(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/session-sharing/rooms/{id}/guest-link:
|
|
* post:
|
|
* summary: Enable, rotate or disable the room's anonymous guest link (host only)
|
|
* tags:
|
|
* - Collab
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [enabled]
|
|
* properties:
|
|
* enabled:
|
|
* type: boolean
|
|
* responses:
|
|
* 200:
|
|
* description: The new token, or null when disabled
|
|
* 403:
|
|
* description: Not the host
|
|
*/
|
|
router.post(
|
|
"/rooms/:id/guest-link",
|
|
requireUse,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actorOf(ctx);
|
|
const roomId = String(req.params.id);
|
|
const { enabled } = req.body ?? {};
|
|
if (typeof enabled !== "boolean") {
|
|
return res.status(400).json({ error: "enabled must be a boolean" });
|
|
}
|
|
try {
|
|
const access = await requireRoomMember(roomId, userId);
|
|
if (!access) return res.status(404).json({ error: "Room not found" });
|
|
if (!access.isHost) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: "Only the host can manage the guest link" });
|
|
}
|
|
const token = enabled
|
|
? crypto.randomBytes(24).toString("base64url")
|
|
: null;
|
|
await rooms.setGuestToken(roomId, token);
|
|
|
|
// A rotated or disabled link drops the guests who came in on the old one.
|
|
if (
|
|
(!enabled || access.room.guestLinkToken) &&
|
|
access.room.stageShareId &&
|
|
access.room.stageProtocol === "ssh"
|
|
) {
|
|
const share = await shares.findActiveById(access.room.stageShareId);
|
|
if (share) {
|
|
await live.disconnectParticipants(
|
|
"ssh",
|
|
share.sessionId,
|
|
share.id,
|
|
{
|
|
userId: null,
|
|
reason: enabled
|
|
? "The guest link was rotated"
|
|
: "The guest link was disabled",
|
|
},
|
|
share.ownerUserId,
|
|
);
|
|
}
|
|
}
|
|
|
|
await audit(
|
|
req,
|
|
enabled ? "collab_guest_link_enable" : "collab_guest_link_disable",
|
|
access.room,
|
|
);
|
|
res.json({ guestLinkToken: token });
|
|
} catch (error) {
|
|
fail(
|
|
res,
|
|
"collab_guest_link_error",
|
|
"Failed to update guest link",
|
|
)(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/session-sharing/guest/{token}:
|
|
* get:
|
|
* summary: Resolve a room's current stage for an anonymous guest
|
|
* description: Public, rate-limited per IP. Guests poll this to follow presenter switches. Never returns host details; SSH stages are joined over the terminal socket with roomGuestToken, remote desktop stages get a read-only viewer token.
|
|
* tags:
|
|
* - Collab
|
|
* parameters:
|
|
* - in: path
|
|
* name: token
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Room name and the stage, or a null stage
|
|
* 404:
|
|
* description: Link not found
|
|
* 429:
|
|
* description: Too many requests
|
|
*/
|
|
router.get("/guest/:token", async (req: Request, res: Response) => {
|
|
if (guestLimiter.isLimited(clientIp(req))) {
|
|
return res.status(429).json({ error: "Too many requests" });
|
|
}
|
|
const token = String(req.params.token);
|
|
try {
|
|
const room = await rooms.findByGuestToken(token);
|
|
if (!room) return res.status(404).json({ error: "Link not found" });
|
|
|
|
const response: Record<string, unknown> = {
|
|
roomName: room.name,
|
|
stage: null,
|
|
};
|
|
const share = await liveStageShare(room);
|
|
if (
|
|
share &&
|
|
room.stageProtocol &&
|
|
(await deps.isSharingEnabledForHost(share.hostId))
|
|
) {
|
|
const protocol = room.stageProtocol as LiveProtocol;
|
|
response.stage = {
|
|
protocol,
|
|
shareId: share.id,
|
|
...(protocol === "ssh"
|
|
? {
|
|
wsPath: `${TERMINAL_WS_PATH}?roomGuestToken=${encodeURIComponent(token)}`,
|
|
}
|
|
: {
|
|
connectParams: {
|
|
token: await live.createViewerToken(
|
|
protocol,
|
|
share.sessionId,
|
|
true,
|
|
share.ownerUserId,
|
|
),
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
res.json(response);
|
|
} catch (error) {
|
|
fail(
|
|
res,
|
|
"collab_guest_resolve_error",
|
|
"Failed to resolve guest link",
|
|
)(error);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/session-sharing/rooms/{id}/end:
|
|
* post:
|
|
* summary: End the meeting (host only)
|
|
* description: Clears the stage. One-off rooms are ended for good; persistent rooms stay listed for reuse.
|
|
* tags:
|
|
* - Collab
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Ended
|
|
* 403:
|
|
* description: Not the host
|
|
*/
|
|
router.post(
|
|
"/rooms/:id/end",
|
|
requireUse,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actorOf(ctx);
|
|
const roomId = String(req.params.id);
|
|
try {
|
|
const access = await requireRoomMember(roomId, userId);
|
|
if (!access) return res.status(404).json({ error: "Room not found" });
|
|
if (!access.isHost) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: "Only the host can end the room" });
|
|
}
|
|
|
|
await revokeStageShare(access.room);
|
|
await store.setController(roomId, null);
|
|
await store.clearRequests(roomId);
|
|
if (access.room.persistent) {
|
|
await rooms.setGuestToken(roomId, null);
|
|
await rooms.clearStage(roomId);
|
|
hub.broadcast(roomId, {
|
|
type: "collab_stage_changed",
|
|
roomId,
|
|
stage: null,
|
|
});
|
|
} else {
|
|
await rooms.endRoom(roomId);
|
|
hub.broadcast(roomId, { type: "collab_room_ended", roomId });
|
|
}
|
|
await audit(req, "collab_room_end", access.room);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
fail(res, "collab_room_end_error", "Failed to end room")(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/session-sharing/rooms/{id}:
|
|
* delete:
|
|
* summary: Permanently delete a room (host only)
|
|
* description: Revokes the active stage and removes the room and its membership. This cannot be undone.
|
|
* tags:
|
|
* - Collab
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Deleted
|
|
* 403:
|
|
* description: Not the host
|
|
*/
|
|
router.delete(
|
|
"/rooms/:id",
|
|
requireUse,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actorOf(ctx);
|
|
const roomId = String(req.params.id);
|
|
try {
|
|
const access = await requireRoomMember(roomId, userId);
|
|
if (!access) return res.status(404).json({ error: "Room not found" });
|
|
if (!access.isHost) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: "Only the host can delete the room" });
|
|
}
|
|
|
|
await revokeStageShare(access.room);
|
|
await store.setController(roomId, null);
|
|
await store.clearRequests(roomId);
|
|
hub.broadcast(roomId, { type: "collab_room_ended", roomId });
|
|
await rooms.deleteRoom(roomId);
|
|
await audit(req, "collab_room_delete", access.room);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
fail(res, "collab_room_delete_error", "Failed to delete room")(error);
|
|
}
|
|
},
|
|
);
|
|
}
|