mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35: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
@@ -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;
|
||||
Reference in new issue
Block a user