feat: collaboration rooms with switchable presenter (#1328)

* feat: add collaboration rooms with switchable presenter

Rooms are a group of members watching one stage - the live SSH/RDP/VNC
session the current presenter shares. Any member can take over the
stage; the host can invite, force-stop and end the meeting. Stages
reuse session_shares (new room share type), so gating, recording,
expiry and the global sharing toggle all apply unchanged.

* feat: add stage control handoff to collaboration rooms

The presenter or host can grant any member write access to the live
stage and take it back; members can raise a hand to ask. SSH flips the
participant's permission on the live gate; RDP/VNC re-mint the viewer's
join token. Control clears on every stage switch.

* feat: guest links, role invites and invite awareness for collab rooms

- Anonymous guest link per room (host toggles/rotates), followed by
  polling the public resolve endpoint; SSH guests join over the terminal
  WS with roomGuestToken, guac guests get read-only join tokens
- Invite by role (expands to current members, snapshot semantics)
- Toast when a room you were invited to appears
- Stale stages are cleared lazily when the presenter is gone
- Telnet presenting, expired-tab fallback, documented single-instance
  and guac-kick limits
- Tests for the collab routes, room hub, share access and control flip

* fix: keep remote desktop collaboration read-only
This commit is contained in:
ZacharyZcR
2026-08-25 00:56:04 +08:00
committed by GitHub
parent d35458f78b
commit 81d79cc89b
50 changed files with 56698 additions and 81 deletions
+14 -58
View File
@@ -5,13 +5,13 @@ import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { sshLogger } from "../../utils/logger.js";
import { sessionManager } from "../terminal/session-manager.js";
import { getGuacSessionInfo } from "../guacamole/guacamole-server.js";
import { GuacamoleTokenService } from "../guacamole/token-service.js";
import {
createCurrentSessionShareRepository,
createCurrentSettingsRepository,
createCurrentHostResolutionRepository,
} from "../../database/repositories/factory.js";
isLiveSession,
isLiveSessionOwnedBy,
isSharingEnabledForHost,
} from "./live-sessions.js";
import { GuacamoleTokenService } from "../guacamole/token-service.js";
import { createCurrentSessionShareRepository } from "../../database/repositories/factory.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
@@ -30,6 +30,14 @@ interface ResolveRateEntry {
windowStart: number;
}
const resolveAttempts = new Map<string, ResolveRateEntry>();
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();
}
const RESOLVE_WINDOW_MS = 60 * 1000;
const RESOLVE_MAX_ATTEMPTS = 30;
@@ -55,58 +63,6 @@ setInterval(
5 * 60 * 1000,
);
async function isSharingEnabledForHost(hostId: number): Promise<{
enabled: boolean;
hostOwnerId: string | null;
}> {
const globalEnabled = await createCurrentSettingsRepository().getBoolean(
"session_sharing_globally_enabled",
true,
);
if (!globalEnabled) return { enabled: false, hostOwnerId: null };
const hostResolutionRepository = createCurrentHostResolutionRepository();
const hostOwnerId = await hostResolutionRepository.findHostOwnerId(hostId);
if (!hostOwnerId) return { enabled: false, hostOwnerId: null };
const host = await hostResolutionRepository.findHostById(hostId, hostOwnerId);
if (!host) return { enabled: false, hostOwnerId: null };
return {
enabled: host.allowSessionSharing !== false,
hostOwnerId,
};
}
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();
}
function isLiveSessionOwnedBy(
protocol: Protocol,
sessionId: string,
userId: string,
): boolean {
if (protocol === "ssh") {
const session = sessionManager.getSession(sessionId);
return !!session && session.isConnected && session.userId === userId;
}
const info = getGuacSessionInfo(sessionId);
return !!info && info.ownerUserId === userId;
}
function isLiveSession(protocol: Protocol, sessionId: string): boolean {
if (protocol === "ssh") {
const session = sessionManager.getSession(sessionId);
return !!session && session.isConnected;
}
return !!getGuacSessionInfo(sessionId);
}
/**
* @openapi
* /session-sharing/create: