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

-2
View File
@@ -10,7 +10,6 @@ import hostRoutes from "./routes/host.js";
import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
import sshAuthRoutes from "./routes/ssh-auth-routes.js";
import sessionLogRoutes from "./routes/session-log-routes.js";
import rbacRoutes from "./routes/rbac.js";
import openTabsRoutes from "./routes/open-tabs.js";
import userPreferencesRoutes from "./routes/user-preferences.js";
@@ -1805,7 +1804,6 @@ app.use("/host", hostRoutes);
app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use("/ssh-auth", sshAuthRoutes);
app.use("/session_logs", sessionLogRoutes);
app.use("/rbac", rbacRoutes);
app.use("/open-tabs", openTabsRoutes);
app.use("/user-preferences", userPreferencesRoutes);
-32
View File
@@ -479,27 +479,6 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS session_recordings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL,
user_id TEXT,
username TEXT,
access_id INTEGER,
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at TEXT,
duration INTEGER,
commands TEXT,
dangerous_actions TEXT,
recording_path TEXT,
protocol TEXT NOT NULL DEFAULT 'ssh',
format TEXT NOT NULL DEFAULT 'text',
terminated_by_owner INTEGER DEFAULT 0,
termination_reason TEXT,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL,
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS secret_sources (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
@@ -937,17 +916,6 @@ const relaxPluginGrantGrantedBy = () => {
};
const migrateSchema = () => {
addColumnIfNotExists(
"session_recordings",
"protocol",
"TEXT NOT NULL DEFAULT 'ssh'",
);
addColumnIfNotExists(
"session_recordings",
"format",
"TEXT NOT NULL DEFAULT 'text'",
);
addColumnIfNotExists("user_preferences", "theme", "TEXT");
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
@@ -124,17 +124,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
columns: "user_id",
},
// Recordings and live session sharing.
{
name: "idx_session_recordings_user_started",
table: "session_recordings",
columns: "user_id, started_at",
},
{
name: "idx_session_recordings_host",
table: "session_recordings",
columns: "host_id",
},
// Automations.
{
-42
View File
@@ -719,48 +719,6 @@ export const auditLogs = mysqlTable(
],
);
export const sessionRecordings = mysqlTable(
"session_recordings",
{
id: int("id").autoincrement().primaryKey(),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// Nullable on purpose: a recording is evidence about the host as much as the
// person, so it outlives the account. username keeps it attributable.
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }),
username: text("username"),
accessId: int("access_id").references(() => hostAccess.id, {
onDelete: "set null",
}),
startedAt: varchar("started_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
endedAt: text("ended_at"),
duration: int("duration"),
commands: text("commands"),
dangerousActions: text("dangerous_actions"),
recordingPath: text("recording_path"),
protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"),
format: text("format").notNull().default("text"),
terminatedByOwner: boolean("terminated_by_owner").default(false),
terminationReason: text("termination_reason"),
},
// Listed newest-first per user, and audited per host.
(table) => [
index("idx_session_recordings_user_started").on(
table.userId,
table.startedAt,
),
index("idx_session_recordings_host").on(table.hostId),
],
);
export const opksshTokens = mysqlTable(
"opkssh_tokens",
{
-42
View File
@@ -720,48 +720,6 @@ export const auditLogs = pgTable(
],
);
export const sessionRecordings = pgTable(
"session_recordings",
{
id: serial("id").primaryKey(),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// Nullable on purpose: a recording is evidence about the host as much as the
// person, so it outlives the account. username keeps it attributable.
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }),
username: text("username"),
accessId: integer("access_id").references(() => hostAccess.id, {
onDelete: "set null",
}),
startedAt: varchar("started_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
endedAt: text("ended_at"),
duration: integer("duration"),
commands: text("commands"),
dangerousActions: text("dangerous_actions"),
recordingPath: text("recording_path"),
protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"),
format: text("format").notNull().default("text"),
terminatedByOwner: boolean("terminated_by_owner").default(false),
terminationReason: text("termination_reason"),
},
// Listed newest-first per user, and audited per host.
(table) => [
index("idx_session_recordings_user_started").on(
table.userId,
table.startedAt,
),
index("idx_session_recordings_host").on(table.hostId),
],
);
export const opksshTokens = pgTable(
"opkssh_tokens",
{
-44
View File
@@ -712,50 +712,6 @@ export const auditLogs = sqliteTable(
],
);
export const sessionRecordings = sqliteTable(
"session_recordings",
{
id: integer("id").primaryKey({ autoIncrement: true }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// Nullable on purpose: a recording is evidence about the host as much as the
// person, so it outlives the account. username keeps it attributable.
userId: text("user_id").references(() => users.id, { onDelete: "set null" }),
username: text("username"),
accessId: integer("access_id").references(() => hostAccess.id, {
onDelete: "set null",
}),
startedAt: text("started_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
endedAt: text("ended_at"),
duration: integer("duration"),
commands: text("commands"),
dangerousActions: text("dangerous_actions"),
recordingPath: text("recording_path"),
protocol: text("protocol").notNull().default("ssh"),
format: text("format").notNull().default("text"),
terminatedByOwner: integer("terminated_by_owner", {
mode: "boolean",
}).default(false),
terminationReason: text("termination_reason"),
},
// Listed newest-first per user, and audited per host.
(table) => [
index("idx_session_recordings_user_started").on(
table.userId,
table.startedAt,
),
index("idx_session_recordings_host").on(table.hostId),
],
);
export const opksshTokens = sqliteTable(
"opkssh_tokens",
{
@@ -37,7 +37,6 @@ import { RbacAccessRepository } from "./rbac-access-repository.js";
import { RbacPermissionRepository } from "./rbac-permission-repository.js";
import { RecentActivityRepository } from "./recent-activity-repository.js";
import { RoleRepository } from "./role-repository.js";
import { SessionRecordingRepository } from "./session-recording-repository.js";
import { SessionRepository } from "./session-repository.js";
import { SecretSourceRepository } from "./secret-source-repository.js";
import { CredentialAccessRepository } from "./credential-access-repository.js";
@@ -400,13 +399,6 @@ export function createCurrentRoleRepository(): RoleRepository {
);
}
export function createCurrentSessionRecordingRepository(): SessionRecordingRepository {
return new SessionRecordingRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("session_recording_repository_write"),
);
}
export function createCurrentSessionRepository(): SessionRepository {
return new SessionRepository(
createCurrentRepositoryContext(),
@@ -1,262 +0,0 @@
import { and, desc, eq, inArray, lt } from "drizzle-orm";
import { hosts, sessionRecordings } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
export type SessionRecordingRecord = typeof sessionRecordings.$inferSelect;
export interface SessionRecordingCreateInput {
hostId: number;
userId: string;
startedAt: string;
endedAt?: string | null;
duration?: number | null;
commands?: string | null;
dangerousActions?: string | null;
recordingPath?: string | null;
protocol?: string | null;
format?: string | null;
terminatedByOwner?: boolean | null;
terminationReason?: string | null;
}
export interface SessionRecordingListRecord {
id: number;
hostId: number;
userId: string;
startedAt: string;
endedAt: string | null;
duration: number | null;
recordingPath: string | null;
protocol: string;
format: string | null;
hostName: string | null;
hostIp: string | null;
}
export interface SessionRecordingPathRecord {
id: number;
recordingPath: string | null;
}
export class SessionRecordingRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async create(
input: SessionRecordingCreateInput,
): Promise<SessionRecordingRecord> {
const [created] = await insertReturning(
this.context,
sessionRecordings,
input,
);
await this.afterWrite();
return created;
}
async updateEnded(
id: number,
input: {
endedAt: string;
duration: number | null;
terminatedByOwner?: boolean;
terminationReason?: string;
},
): Promise<void> {
await this.context.drizzle
.update(sessionRecordings)
.set(input)
.where(eq(sessionRecordings.id, id));
await this.afterWrite();
}
async findById(id: number): Promise<SessionRecordingRecord | null> {
const rows = await this.context.drizzle
.select()
.from(sessionRecordings)
.where(eq(sessionRecordings.id, id))
.limit(1);
return rows[0] ?? null;
}
async findPathById(id: number): Promise<
| (SessionRecordingPathRecord & {
userId: string;
format?: string | null;
})
| null
> {
const rows = await this.context.drizzle
.select({
id: sessionRecordings.id,
recordingPath: sessionRecordings.recordingPath,
userId: sessionRecordings.userId,
format: sessionRecordings.format,
})
.from(sessionRecordings)
.where(eq(sessionRecordings.id, id))
.limit(1);
return rows[0] ?? null;
}
async listByUserIdWithHost(
userId: string,
): Promise<SessionRecordingListRecord[]> {
return this.context.drizzle
.select({
id: sessionRecordings.id,
hostId: sessionRecordings.hostId,
userId: sessionRecordings.userId,
startedAt: sessionRecordings.startedAt,
endedAt: sessionRecordings.endedAt,
duration: sessionRecordings.duration,
recordingPath: sessionRecordings.recordingPath,
protocol: sessionRecordings.protocol,
format: sessionRecordings.format,
hostName: hosts.name,
hostIp: hosts.ip,
})
.from(sessionRecordings)
.leftJoin(hosts, eq(sessionRecordings.hostId, hosts.id))
.where(eq(sessionRecordings.userId, userId))
.orderBy(desc(sessionRecordings.startedAt));
}
async findByIdForUser(
userId: string,
id: number,
): Promise<SessionRecordingRecord | null> {
const rows = await this.context.drizzle
.select()
.from(sessionRecordings)
.where(
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
)
.limit(1);
return rows[0] ?? null;
}
async findPathByIdForUser(
userId: string,
id: number,
): Promise<SessionRecordingPathRecord | null> {
const rows = await this.context.drizzle
.select({
id: sessionRecordings.id,
recordingPath: sessionRecordings.recordingPath,
})
.from(sessionRecordings)
.where(
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
)
.limit(1);
return rows[0] ?? null;
}
async listPathsOlderThan(
cutoff: string,
): Promise<SessionRecordingPathRecord[]> {
return this.context.drizzle
.select({
id: sessionRecordings.id,
recordingPath: sessionRecordings.recordingPath,
})
.from(sessionRecordings)
.where(lt(sessionRecordings.startedAt, cutoff));
}
async deleteById(id: number): Promise<boolean> {
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(eq(sessionRecordings.id, id));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result) > 0;
}
async deleteForUser(userId: string, id: number): Promise<boolean> {
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result) > 0;
}
/**
* Detaches recordings from a user being deleted instead of removing them.
* A recording is evidence about the host as much as about the person, and the
* file stays on disk regardless — deleting only the row would orphan it.
*/
async anonymizeByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.update(sessionRecordings)
.set({ userId: null })
.where(eq(sessionRecordings.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(eq(sessionRecordings.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByHostId(hostId: number): Promise<number> {
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(eq(sessionRecordings.hostId, hostId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByHostIds(hostIds: number[]): Promise<number> {
if (hostIds.length === 0) {
return 0;
}
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(inArray(sessionRecordings.hostId, hostIds));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -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;