feat: turn session recording into a plugin (B13)

This commit is contained in:
LukeGus committed 2026-09-24 00:09:48 -05:00
1 parent a276f6898c
commit 0af2c94983
147 files changed
+25915 -2074

No files matched your search

@@ -22,7 +22,6 @@ import {
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentSessionRepository,
createCurrentSessionRecordingRepository,
createCurrentSettingsRepository,
createCurrentSharedHostSecretsRepository,
createCurrentSshCredentialUsageRepository,
@@ -61,9 +60,11 @@ export async function deleteUserAndRelatedData(
userId,
);
// Retained rather than deleted: these outlive the account by design.
// See anonymizeByUserId on each repository.
await createCurrentSessionRecordingRepository().anonymizeByUserId(userId);
// session_recordings is retained rather than deleted, by design: it
// outlives the account. The session-recording plugin listens for
// user.deleted and anonymizes its own rows instead of cascading.
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
pluginEvents.emit(TOPICS.userDeleted, { userId });
await createCurrentRbacAccessRepository().deleteHostAccessForUserReferences(
userId,
@@ -9,7 +9,6 @@ import {
createCurrentRecentActivityRepository,
createCurrentRbacAccessRepository,
createCurrentSshCredentialUsageRepository,
createCurrentSessionRecordingRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
@@ -397,9 +396,10 @@ export function registerHostFolderRoutes(
const hostIds = hostsToDelete.map((host) => host.id);
if (hostIds.length > 0) {
// file manager recent/pinned/shortcuts, transfer_recent and command
// history cascade on the host's refHost() foreign key, as the
// file-manager and ssh-terminal plugins' adopted tables.
// file manager recent/pinned/shortcuts, transfer_recent, command
// history and session recordings cascade on the host's refHost()
// foreign key, as the file-manager, ssh-terminal and
// session-recording plugins' adopted tables.
await createCurrentSshCredentialUsageRepository().deleteByHostIds(
hostIds,
@@ -412,10 +412,6 @@ export function registerHostFolderRoutes(
await createCurrentRbacAccessRepository().deleteHostAccessForHosts(
hostIds,
);
await createCurrentSessionRecordingRepository().deleteByHostIds(
hostIds,
);
}
const { hostSyncIds, folderSyncIds } =
+4 -8
View File
@@ -20,7 +20,6 @@ import {
createCurrentOpksshTokenRepository,
createCurrentRecentActivityRepository,
createCurrentSshCredentialUsageRepository,
createCurrentSessionRecordingRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentHostResolutionRepository,
@@ -2524,9 +2523,10 @@ router.delete(
const numericHostId = Number(hostId);
// file manager recent/pinned/shortcuts, transfer_recent and command
// history cascade on the host's refHost() foreign key, as the
// file-manager and ssh-terminal plugins' adopted tables.
// file manager recent/pinned/shortcuts, transfer_recent, command
// history and session recordings cascade on the host's refHost()
// foreign key, as the file-manager, ssh-terminal and
// session-recording plugins' adopted tables.
await createCurrentSshCredentialUsageRepository().deleteByHostId(
numericHostId,
@@ -2540,10 +2540,6 @@ router.delete(
numericHostId,
);
await createCurrentSessionRecordingRepository().deleteByHostId(
numericHostId,
);
await createCurrentHostRepository().deleteForUser(userId, numericHostId);
if (hostToDelete.syncId) {
await createCurrentSyncTombstoneRepository().record(
@@ -1,362 +0,0 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import fs from "fs";
import path from "path";
import { apiLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import {
createCurrentSessionRecordingRepository,
createCurrentSettingsRepository,
getCurrentSettingValue,
} from "../repositories/factory.js";
const router = express.Router();
const DATA_DIR = process.env.DATA_DIR ?? "./db/data";
const permissionManager = PermissionManager.getInstance();
function isAllowedRecordingPath(filePath: string): boolean {
const resolved = path.resolve(filePath);
return ["session_logs", "session_recordings"].some((directory) => {
const base = `${path.resolve(DATA_DIR, directory)}${path.sep}`;
return resolved.startsWith(base);
});
}
function getRetentionDays(): number {
const envDays = parseInt(
process.env.SESSION_RECORDING_RETENTION_DAYS || "",
10,
);
try {
const configured = parseInt(
getCurrentSettingValue("session_recording_retention_days") || "",
10,
);
if (configured >= 1 && configured <= 3650) return configured;
} catch {
// use environment/default below
}
return envDays >= 1 && envDays <= 3650 ? envDays : 30;
}
async function canAccessRecording(
userId: string,
ownerId: string,
): Promise<boolean> {
return userId === ownerId || permissionManager.isAdmin(userId);
}
async function pruneOldLogs(): Promise<void> {
try {
const cutoff = new Date(
Date.now() - getRetentionDays() * 24 * 60 * 60 * 1000,
).toISOString();
const sessionRecordingRepository =
createCurrentSessionRecordingRepository();
const old = await sessionRecordingRepository.listPathsOlderThan(cutoff);
for (const row of old) {
if (row.recordingPath) {
const resolved = path.resolve(row.recordingPath);
if (isAllowedRecordingPath(resolved) && fs.existsSync(resolved)) {
await fs.promises.unlink(resolved).catch(() => {});
}
}
await sessionRecordingRepository.deleteById(row.id);
}
if (old.length > 0) {
apiLogger.info(`Pruned ${old.length} old session log(s)`, {
operation: "session_log_prune",
count: old.length,
});
}
} catch (err) {
apiLogger.warn("Failed to prune old session logs", {
operation: "session_log_prune_error",
error: err instanceof Error ? err.message : String(err),
});
}
}
// Run prune once at startup, then every 24 hours
pruneOldLogs();
setInterval(pruneOldLogs, 24 * 60 * 60 * 1000).unref();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
/**
* @openapi
* /session_logs:
* get:
* summary: List session logs
* description: Returns all terminal session recordings for the authenticated user.
* tags:
* - Session Logs
* responses:
* 200:
* description: List of session recordings.
* 500:
* description: Failed to fetch session logs.
*/
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows =
await createCurrentSessionRecordingRepository().listByUserIdWithHost(
userId,
);
const records = rows.map((row) => {
let sizeBytes: number | null = null;
if (row.recordingPath) {
try {
sizeBytes = fs.statSync(row.recordingPath).size;
} catch {
// file may have been removed
}
}
return { ...row, sizeBytes };
});
res.json({ logs: records });
} catch (error) {
apiLogger.error("Failed to fetch session logs", error, {
operation: "session_logs_list",
userId,
});
res.status(500).json({ error: "Failed to fetch session logs" });
}
});
router.get(
"/retention",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!(await permissionManager.isAdmin(userId))) {
return res.status(403).json({ error: "Admin access required" });
}
res.json({ retentionDays: getRetentionDays() });
},
);
router.put(
"/retention",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!(await permissionManager.isAdmin(userId))) {
return res.status(403).json({ error: "Admin access required" });
}
const retentionDays = Number(req.body?.retentionDays);
if (
!Number.isInteger(retentionDays) ||
retentionDays < 1 ||
retentionDays > 3650
) {
return res
.status(400)
.json({ error: "Retention must be between 1 and 3650 days" });
}
await createCurrentSettingsRepository().upsert(
"session_recording_retention_days",
String(retentionDays),
);
void pruneOldLogs();
res.json({ retentionDays });
},
);
/**
* @openapi
* /session_logs/{id}:
* get:
* summary: Get session log metadata
* description: Returns metadata for a single session recording.
* tags:
* - Session Logs
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Session recording metadata.
* 403:
* description: Not authorized.
* 404:
* description: Session log not found.
* 500:
* description: Failed to fetch session log.
*/
router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const rawId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const id = parseInt(rawId, 10);
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
try {
const row = await createCurrentSessionRecordingRepository().findByIdForUser(
userId,
id,
);
if (!row) return res.status(404).json({ error: "Not found" });
res.json({ log: row });
} catch (error) {
apiLogger.error("Failed to fetch session log", error, {
operation: "session_log_get",
userId,
id,
});
res.status(500).json({ error: "Failed to fetch session log" });
}
});
/**
* @openapi
* /session_logs/{id}/content:
* get:
* summary: Get session log content
* description: Returns the raw text content of a session log file.
* tags:
* - Session Logs
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Raw log text.
* 403:
* description: Not authorized.
* 404:
* description: Session log or file not found.
* 500:
* description: Failed to read session log.
*/
router.get(
"/:id/content",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const rawId = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
const id = parseInt(rawId, 10);
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
try {
const row =
await createCurrentSessionRecordingRepository().findPathByIdForUser(
userId,
id,
);
if (!row) return res.status(404).json({ error: "Not found" });
const filePath = row.recordingPath;
if (!filePath)
return res.status(404).json({ error: "No recording file" });
const resolvedPath = path.resolve(filePath);
if (!isAllowedRecordingPath(resolvedPath)) {
return res.status(403).json({ error: "Forbidden" });
}
if (!fs.existsSync(resolvedPath)) {
return res.status(404).json({ error: "File not found" });
}
const content = await fs.promises.readFile(resolvedPath);
const format =
(row as { format?: string | null }).format ??
(row.recordingPath?.endsWith(".cast") ? "asciicast" : "text");
const contentType =
format === "guacamole"
? "application/vnd.apache.guacamole.recording"
: format === "asciicast"
? "application/x-asciicast"
: "text/plain";
res.type(contentType).send(content);
} catch (error) {
apiLogger.error("Failed to read session log content", error, {
operation: "session_log_content",
userId,
id,
});
res.status(500).json({ error: "Failed to read session log" });
}
},
);
/**
* @openapi
* /session_logs/{id}:
* delete:
* summary: Delete session log
* description: Deletes a session recording and its log file.
* tags:
* - Session Logs
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Session log deleted.
* 403:
* description: Not authorized.
* 404:
* description: Session log not found.
* 500:
* description: Failed to delete session log.
*/
router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const rawId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const id = parseInt(rawId, 10);
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
try {
const sessionRecordingRepository =
createCurrentSessionRecordingRepository();
const row = await sessionRecordingRepository.findPathByIdForUser(
userId,
id,
);
if (!row) return res.status(404).json({ error: "Not found" });
const filePath = row.recordingPath;
await sessionRecordingRepository.deleteForUser(userId, id);
if (filePath) {
const resolvedPath = path.resolve(filePath);
if (isAllowedRecordingPath(resolvedPath) && fs.existsSync(resolvedPath)) {
await fs.promises.unlink(resolvedPath).catch(() => {});
}
}
res.json({ success: true });
} catch (error) {
apiLogger.error("Failed to delete session log", error, {
operation: "session_log_delete",
userId,
id,
});
res.status(500).json({ error: "Failed to delete session log" });
}
});
export default router;