mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: turn session recording into a plugin (B13)
This commit is contained in:
1 parent
a276f6898c
commit
0af2c94983
147 files changed
+25915
-2074
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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;
|
||||
@@ -250,6 +250,20 @@ export function createPluginContext(
|
||||
{ action: "kv_list" },
|
||||
);
|
||||
|
||||
const filesDataDir = guarded(
|
||||
manifest,
|
||||
"files:own",
|
||||
async () => {
|
||||
const fs = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
const base = process.env.DATA_DIR ?? "./db/data";
|
||||
const dir = path.join(base, "plugins", pluginId);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
return dir;
|
||||
},
|
||||
{ action: "files_data_dir" },
|
||||
);
|
||||
|
||||
const dbDefine = guarded(
|
||||
manifest,
|
||||
"db:own",
|
||||
@@ -422,6 +436,10 @@ export function createPluginContext(
|
||||
list: () => kvList(),
|
||||
},
|
||||
|
||||
files: {
|
||||
dataDir: () => filesDataDir(),
|
||||
},
|
||||
|
||||
registry: {
|
||||
provide: (key, value) => {
|
||||
registry.provide(key, value);
|
||||
|
||||
@@ -47,6 +47,13 @@ export const TOPICS = {
|
||||
* the row is not gone, only unrecoverable, and should go with it.
|
||||
*/
|
||||
userDataWiped: "user.data_wiped",
|
||||
/**
|
||||
* A user account was deleted. For a plugin whose rows should outlive the
|
||||
* account (evidence, audit trail) rather than cascade with a refUser()
|
||||
* column: it keeps a plain userId column and anonymizes its own rows here,
|
||||
* the way core's own session recordings did before this event existed.
|
||||
*/
|
||||
userDeleted: "user.deleted",
|
||||
} as const;
|
||||
|
||||
export type EventListener = (payload: unknown) => void;
|
||||
|
||||
@@ -373,6 +373,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
const { runSessionSharingSettingsMigration } =
|
||||
await import("./utils/crypto-migration/session-sharing-settings-migration.js");
|
||||
await runSessionSharingSettingsMigration();
|
||||
|
||||
const { runSessionRecordingSettingsMigration } =
|
||||
await import("./utils/crypto-migration/session-recording-settings-migration.js");
|
||||
await runSessionRecordingSettingsMigration();
|
||||
} catch (error) {
|
||||
systemLogger.warn("Plugin runtime failed to initialize", {
|
||||
operation: "plugin_init",
|
||||
|
||||
@@ -7,10 +7,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
/**
|
||||
* `migrateSchema()` carried a `SELECT id FROM <table> LIMIT 1` probe for a
|
||||
* number of tables that the primary bootstrap already creates. The probe never
|
||||
* threw, so the `CREATE TABLE IF NOT EXISTS` in its catch never ran — and two
|
||||
* threw, so the `CREATE TABLE IF NOT EXISTS` in its catch never ran — and one
|
||||
* of those unreachable copies had drifted away from the real definition
|
||||
* (`sessions` had lost `ON DELETE CASCADE`; `session_recordings` still had the
|
||||
* pre-#1128 `user_id NOT NULL` with `ON DELETE CASCADE` and no `username`).
|
||||
* (`sessions` had lost `ON DELETE CASCADE`).
|
||||
*
|
||||
* They are gone now. What has to stay true is that the bootstrap alone
|
||||
* produces every one of those tables, from an empty database and from a
|
||||
@@ -27,7 +26,6 @@ describe("bootstrap creates the tables the removed probes covered", () => {
|
||||
"roles",
|
||||
"user_roles",
|
||||
"audit_logs",
|
||||
"session_recordings",
|
||||
"api_keys",
|
||||
];
|
||||
|
||||
@@ -99,16 +97,6 @@ describe("bootstrap creates the tables the removed probes covered", () => {
|
||||
await db.initializeDatabase();
|
||||
const sqlite = db.getSqlite();
|
||||
|
||||
// session_recordings: nullable user_id with the attribution kept, per
|
||||
// "audit trails survive the account" — not the NOT NULL + CASCADE the
|
||||
// dead copy still carried.
|
||||
const columns = sqlite
|
||||
.prepare("PRAGMA table_info(session_recordings)")
|
||||
.all() as Array<{ name: string; notnull: number }>;
|
||||
const userId = columns.find((c) => c.name === "user_id");
|
||||
expect(userId?.notnull).toBe(0);
|
||||
expect(columns.some((c) => c.name === "username")).toBe(true);
|
||||
|
||||
// sessions: the dead copy had dropped ON DELETE CASCADE.
|
||||
const sql = sqlite
|
||||
.prepare(
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { SessionRecordingRepository } from "../../../database/repositories/session-recording-repository.js";
|
||||
|
||||
describe("SessionRecordingRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<SessionRecordingRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||
VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.2', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.3', 22, 'root', 'password');
|
||||
`);
|
||||
|
||||
return new SessionRecordingRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates and lists session recordings with host metadata", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const first = await repo.create({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
startedAt: "2026-06-27T00:00:00.000Z",
|
||||
endedAt: "2026-06-27T00:01:00.000Z",
|
||||
duration: 60,
|
||||
recordingPath: "/tmp/one.log",
|
||||
});
|
||||
await repo.create({
|
||||
userId: "user-1",
|
||||
hostId: 2,
|
||||
startedAt: "2026-06-27T00:02:00.000Z",
|
||||
recordingPath: "/tmp/two.log",
|
||||
});
|
||||
await repo.create({
|
||||
userId: "user-2",
|
||||
hostId: 3,
|
||||
startedAt: "2026-06-27T00:03:00.000Z",
|
||||
});
|
||||
|
||||
expect(first).toMatchObject({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
duration: 60,
|
||||
recordingPath: "/tmp/one.log",
|
||||
});
|
||||
|
||||
const rows = await repo.listByUserIdWithHost("user-1");
|
||||
expect(rows.map((row) => row.hostName)).toEqual(["two", "one"]);
|
||||
expect(rows[0]).toMatchObject({
|
||||
hostIp: "10.0.0.2",
|
||||
recordingPath: "/tmp/two.log",
|
||||
protocol: "ssh",
|
||||
format: "text",
|
||||
});
|
||||
});
|
||||
|
||||
it("finds paths and prunes old recordings", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const old = await repo.create({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
recordingPath: "/tmp/old.log",
|
||||
});
|
||||
const current = await repo.create({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
startedAt: "2026-06-27T00:00:00.000Z",
|
||||
recordingPath: "/tmp/current.log",
|
||||
});
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(await repo.findPathByIdForUser("user-2", old.id)).toBeNull();
|
||||
expect(await repo.findPathByIdForUser("user-1", old.id)).toMatchObject({
|
||||
recordingPath: "/tmp/old.log",
|
||||
});
|
||||
expect(await repo.listPathsOlderThan("2026-02-01T00:00:00.000Z")).toEqual([
|
||||
{ id: old.id, recordingPath: "/tmp/old.log" },
|
||||
]);
|
||||
|
||||
expect(await repo.deleteById(old.id)).toBe(true);
|
||||
expect(await repo.deleteById(old.id)).toBe(false);
|
||||
expect(await repo.findByIdForUser("user-1", current.id)).toMatchObject({
|
||||
id: current.id,
|
||||
});
|
||||
expect(writeCount).toBe(3);
|
||||
});
|
||||
|
||||
it("deletes recordings by user and host references", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const first = await repo.create({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
startedAt: "2026-06-27T00:00:00.000Z",
|
||||
});
|
||||
await repo.create({
|
||||
userId: "user-1",
|
||||
hostId: 2,
|
||||
startedAt: "2026-06-27T00:01:00.000Z",
|
||||
});
|
||||
await repo.create({
|
||||
userId: "user-2",
|
||||
hostId: 3,
|
||||
startedAt: "2026-06-27T00:02:00.000Z",
|
||||
});
|
||||
expect(writeCount).toBe(3);
|
||||
|
||||
expect(await repo.deleteForUser("user-2", first.id)).toBe(false);
|
||||
expect(await repo.deleteForUser("user-1", first.id)).toBe(true);
|
||||
expect(await repo.deleteByHostIds([])).toBe(0);
|
||||
expect(await repo.deleteByHostIds([2])).toBe(1);
|
||||
expect(writeCount).toBe(5);
|
||||
|
||||
expect(await repo.deleteByUserId("user-2")).toBe(1);
|
||||
expect(await repo.deleteByHostId(3)).toBe(0);
|
||||
expect(writeCount).toBe(6);
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import path from "path";
|
||||
|
||||
// Stub db, logger, fs, and AuthManager before importing the route module
|
||||
const mockSelect = vi.fn();
|
||||
const mockDelete = vi.fn();
|
||||
const mockInsert = vi.fn();
|
||||
|
||||
vi.mock("../../../database/db/index.js", () => ({
|
||||
db: {
|
||||
select: mockSelect,
|
||||
delete: mockDelete,
|
||||
insert: mockInsert,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
apiLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() => (_req: unknown, _res: unknown, next: () => void) =>
|
||||
next(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// The route module calls PermissionManager.getInstance() at import time and
|
||||
// pulls in the repository factory, which loads the drizzle schema and the
|
||||
// better-sqlite3 native binding. Importing that tree costs seconds under a
|
||||
// concurrent full run — enough to blow the 5s test timeout — and none of it is
|
||||
// under test here.
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
canAccessHost: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSessionRecordingRepository: vi.fn(),
|
||||
createCurrentSettingsRepository: vi.fn(),
|
||||
getCurrentSettingValue: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockReadFile = vi.fn();
|
||||
const mockStat = vi.fn();
|
||||
const mockUnlink = vi.fn();
|
||||
const mockExistsSync = vi.fn();
|
||||
|
||||
vi.mock("fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("fs")>();
|
||||
return {
|
||||
...actual,
|
||||
promises: { readFile: mockReadFile, unlink: mockUnlink },
|
||||
existsSync: mockExistsSync,
|
||||
statSync: mockStat,
|
||||
};
|
||||
});
|
||||
|
||||
// Build a chainable drizzle-like query stub
|
||||
function makeChain(resolveValue: unknown) {
|
||||
const chain: Record<string, unknown> = {};
|
||||
const methods = [
|
||||
"from",
|
||||
"leftJoin",
|
||||
"where",
|
||||
"orderBy",
|
||||
"limit",
|
||||
"set",
|
||||
"values",
|
||||
];
|
||||
for (const m of methods) {
|
||||
chain[m] = vi.fn(() => chain);
|
||||
}
|
||||
(chain as unknown as Promise<unknown>).then = (cb: (v: unknown) => unknown) =>
|
||||
Promise.resolve(resolveValue).then(cb);
|
||||
(chain as unknown as Promise<unknown>).catch = (
|
||||
cb: (e: unknown) => unknown,
|
||||
) => Promise.resolve(resolveValue).catch(cb);
|
||||
return chain;
|
||||
}
|
||||
|
||||
describe("session-log-routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.DATA_DIR = "/data";
|
||||
});
|
||||
|
||||
describe("GET / - list logs", () => {
|
||||
it("returns logs for the authenticated user with file size", async () => {
|
||||
const rows = [
|
||||
{
|
||||
id: 1,
|
||||
hostId: 10,
|
||||
userId: "u1",
|
||||
startedAt: "2026-01-01T00:00:00Z",
|
||||
endedAt: "2026-01-01T00:05:00Z",
|
||||
duration: 300,
|
||||
recordingPath: "/data/session_logs/u1/abc.log",
|
||||
hostName: "my-server",
|
||||
hostIp: "10.0.0.1",
|
||||
},
|
||||
];
|
||||
const chain = makeChain(rows);
|
||||
mockSelect.mockReturnValue(chain);
|
||||
mockStat.mockReturnValue({ size: 4096 });
|
||||
|
||||
// Directly call the route handler extracted from the module
|
||||
const { default: router } =
|
||||
await import("../../../database/routes/session-log-routes.js");
|
||||
expect(router).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("path traversal guard", () => {
|
||||
it("rejects paths outside the allowed session_logs directory", () => {
|
||||
const allowedBase = path.resolve("/data", "session_logs");
|
||||
const malicious = path.resolve("/data/session_logs/../../etc/passwd");
|
||||
expect(malicious.startsWith(allowedBase)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a legitimate session log path", () => {
|
||||
const allowedBase = path.resolve("/data", "session_logs");
|
||||
const valid = path.resolve("/data/session_logs/user1/abc.log");
|
||||
expect(valid.startsWith(allowedBase)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatters (pure logic)", () => {
|
||||
it("stat returns size when file exists", () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockStat.mockReturnValue({ size: 1234 });
|
||||
const exists = mockExistsSync("/some/file.log");
|
||||
const { size } = mockStat("/some/file.log");
|
||||
expect(exists).toBe(true);
|
||||
expect(size).toBe(1234);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* The session logging host-column migration.
|
||||
*
|
||||
* The legacy column defaulted to true, the plugin's enable switch defaults
|
||||
* to false, so every host needs an explicit row, not just the ones that
|
||||
* turned it off. Running it twice must not duplicate or clobber what it
|
||||
* moved.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface HostRow {
|
||||
id: number;
|
||||
enable_session_logging: boolean | null;
|
||||
}
|
||||
|
||||
interface SettingsRow {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const hostRows: HostRow[] = [];
|
||||
const settingsRows: SettingsRow[] = [];
|
||||
|
||||
const find = (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
settingsRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
|
||||
const pluginSettingsRepository = {
|
||||
get: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(pluginId, scope, scopeId, key) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(pluginId, scope, scopeId, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
return;
|
||||
}
|
||||
settingsRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({
|
||||
all: async () => hostRows,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { runSessionRecordingSettingsMigration } =
|
||||
await import("../../utils/crypto-migration/session-recording-settings-migration.js");
|
||||
|
||||
function storedValue(hostId: number, key: string): unknown {
|
||||
const row = settingsRows.find(
|
||||
(entry) => entry.scopeId === String(hostId) && entry.key === key,
|
||||
);
|
||||
return row?.value === null || row?.value === undefined
|
||||
? undefined
|
||||
: JSON.parse(row.value);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
hostRows.length = 0;
|
||||
settingsRows.length = 0;
|
||||
});
|
||||
|
||||
describe("runSessionRecordingSettingsMigration", () => {
|
||||
it("moves an enabled host's switch into plugin settings", async () => {
|
||||
hostRows.push({ id: 1, enable_session_logging: true });
|
||||
|
||||
const result = await runSessionRecordingSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(1);
|
||||
expect(storedValue(1, "enableSessionRecording")).toBe(true);
|
||||
expect(
|
||||
settingsRows.every((row) => row.pluginId === "session-recording"),
|
||||
).toBe(true);
|
||||
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
|
||||
});
|
||||
|
||||
it("moves a disabled host's switch too, since the defaults flipped", async () => {
|
||||
hostRows.push({ id: 3, enable_session_logging: false });
|
||||
|
||||
const result = await runSessionRecordingSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(1);
|
||||
expect(storedValue(3, "enableSessionRecording")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a null legacy value as on, matching the old column default", async () => {
|
||||
hostRows.push({ id: 4, enable_session_logging: null });
|
||||
|
||||
const result = await runSessionRecordingSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(1);
|
||||
expect(storedValue(4, "enableSessionRecording")).toBe(true);
|
||||
});
|
||||
|
||||
it("does nothing on a fresh install", async () => {
|
||||
const result = await runSessionRecordingSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(0);
|
||||
expect(settingsRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("is idempotent: a second run changes nothing", async () => {
|
||||
hostRows.push({ id: 2, enable_session_logging: true });
|
||||
|
||||
await runSessionRecordingSettingsMigration();
|
||||
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
|
||||
|
||||
const second = await runSessionRecordingSettingsMigration();
|
||||
|
||||
expect(second.moved).toBe(0);
|
||||
expect(second.skipped).toBe(1);
|
||||
expect(settingsRows).toEqual(afterFirst);
|
||||
});
|
||||
|
||||
it("does not overwrite a value the plugin already saved", async () => {
|
||||
hostRows.push({ id: 5, enable_session_logging: true });
|
||||
await pluginSettingsRepository.set(
|
||||
"session-recording",
|
||||
"host",
|
||||
"5",
|
||||
"enableSessionRecording",
|
||||
"false",
|
||||
);
|
||||
|
||||
const result = await runSessionRecordingSettingsMigration();
|
||||
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(storedValue(5, "enableSessionRecording")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Moves the session logging host column into the session-recording plugin's
|
||||
* host-scope settings, before the column is dropped from ssh_data.
|
||||
*
|
||||
* The legacy column defaulted to true; the plugin's enable switch defaults to
|
||||
* false like every other plugin's. So unlike a migration whose old and new
|
||||
* defaults agree, every host needs an explicit row here, not just the ones
|
||||
* that turned the feature off.
|
||||
*
|
||||
* Idempotent: skips a host that already has a session-recording settings row,
|
||||
* so it is safe to run on every boot.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import { getDb } from "../../database/db/index.js";
|
||||
import { createCurrentPluginSettingsRepository } from "../../database/repositories/factory.js";
|
||||
|
||||
export interface SessionRecordingSettingsMigrationResult {
|
||||
moved: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
interface LegacySessionLoggingRow {
|
||||
id: number;
|
||||
enable_session_logging: number | boolean | null;
|
||||
}
|
||||
|
||||
export async function runSessionRecordingSettingsMigration(): Promise<SessionRecordingSettingsMigrationResult> {
|
||||
const result: SessionRecordingSettingsMigrationResult = {
|
||||
moved: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
const drizzleDb = getDb();
|
||||
// Raw SQL, not the typed schema, so this keeps working once schema.ts
|
||||
// stops declaring this column.
|
||||
const rows = await drizzleDb.all<LegacySessionLoggingRow>(sql`
|
||||
SELECT id, enable_session_logging FROM ssh_data
|
||||
`);
|
||||
|
||||
if (rows.length === 0) return result;
|
||||
|
||||
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
|
||||
|
||||
for (const row of rows) {
|
||||
const scopeId = String(row.id);
|
||||
const existing = await pluginSettingsRepository.get(
|
||||
"session-recording",
|
||||
"host",
|
||||
scopeId,
|
||||
"enableSessionRecording",
|
||||
);
|
||||
if (existing && existing.value !== null) {
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await pluginSettingsRepository.set(
|
||||
"session-recording",
|
||||
"host",
|
||||
scopeId,
|
||||
"enableSessionRecording",
|
||||
JSON.stringify(row.enable_session_logging !== false),
|
||||
);
|
||||
result.moved++;
|
||||
}
|
||||
|
||||
if (result.moved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved session recording settings for ${result.moved} host(s) into plugin settings`,
|
||||
{
|
||||
operation: "session_recording_settings_migration",
|
||||
moved: result.moved,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// A failed migration must not stop the backend. Hosts simply keep the
|
||||
// plugin's own default (off) until this runs successfully.
|
||||
databaseLogger.warn("Session recording settings migration failed", {
|
||||
operation: "session_recording_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in new issue
Block a user