import fs from "node:fs/promises"; import path from "node:path"; import type { PluginContext } from "@termix/plugin-sdk/backend"; import type { SessionRecordingRepository } from "./repository.js"; import { adoptRecordingFile } from "./files.js"; /** * recordings.writer v1, the service ssh-terminal calls to record sessions. * 1.1 added enabledFor, for a caller that records outside this plugin. * * One call per already-batched chunk (ssh-terminal coalesces on a 300ms * trailing edge, issue #1049): mkdir and writeFile on the first append, * appendFile after that, never per chunk. */ export interface RecordingSink { append: (chunk: string) => Promise; persist: (summary: { endedAt: number; durationSeconds: number; terminatedByOwner: boolean; terminationReason: string | null; }) => Promise; discard: () => void; } export interface RecordingsWriterV1 { /** * Whether the host's recording switch is on, for a caller that has to set * up recording before a session exists (remote desktop tells guacd). */ enabledFor: (hostId: number) => Promise; /** Null when recording is off for this user or host. */ open: (meta: { sessionId: string; hostId: number; userId: string; protocol: "ssh"; format: "asciicast"; startedAt: number; }) => Promise; /** * Inserts a row for a recording a caller already wrote to disk itself * (remote desktop's guacd recordings), rather than streaming through * open()/append(). The caller already decided to record. The file is * moved under this plugin's data folder, where playback reads it. */ createFinished: (input: { hostId: number; userId: string; startedAt: string; endedAt: string; duration: number | null; recordingPath: string; protocol: string; format: string; }) => Promise<{ id: number }>; } export function createRecordingsWriter( ctx: PluginContext, repository: SessionRecordingRepository, ): RecordingsWriterV1 { const enabledFor = async (hostId: number) => (await ctx.settings.getHost(hostId, "enableSessionRecording")) !== false; return { enabledFor, async open(meta) { if (!(await enabledFor(meta.hostId))) return null; const dataDir = await ctx.files.dataDir(); const dir = path.join(dataDir, "session_logs", meta.userId); const filePath = path.join(dir, `${meta.sessionId}.cast`); let wroteFirst = false; let discarded = false; const username = await repository.usernameFor(meta.userId); const row = await repository.create({ hostId: meta.hostId, userId: meta.userId, username, startedAt: new Date(meta.startedAt).toISOString(), recordingPath: filePath, protocol: meta.protocol, format: meta.format, }); return { async append(chunk: string) { if (discarded) return; if (!wroteFirst) { await fs.mkdir(dir, { recursive: true }); await fs.writeFile(filePath, chunk); wroteFirst = true; } else { await fs.appendFile(filePath, chunk); } }, async persist(summary) { if (discarded) return; await repository.updateEnded(row.id, { endedAt: new Date(summary.endedAt).toISOString(), duration: summary.durationSeconds, terminatedByOwner: summary.terminatedByOwner, terminationReason: summary.terminationReason, }); }, discard() { discarded = true; void repository.deleteById(row.id).catch(() => {}); }, }; }, async createFinished(input) { const username = await repository.usernameFor(input.userId); const recordingPath = await adoptRecordingFile( input.recordingPath, await ctx.files.dataDir(), ).catch(() => input.recordingPath); const row = await repository.create({ ...input, recordingPath, username, }); return { id: row.id }; }, }; }