feat: move the terminal fully into the ssh-terminal plugin (B9)

This commit is contained in:
LukeGus committed 2026-09-23 21:41:22 -05:00
1 parent 535126fd1b
commit 79d77678c9
309 files changed
+41789 -14958

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 terminalRoutes from "./routes/terminal.js";
import sessionLogRoutes from "./routes/session-log-routes.js";
import sessionSharingRoutes from "../hosts/session-sharing/routes.js";
import collabRoutes from "../hosts/collab/routes.js";
@@ -1808,7 +1807,6 @@ app.use("/host", hostRoutes);
app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use("/ssh-auth", sshAuthRoutes);
app.use("/terminal", terminalRoutes);
app.use("/session_logs", sessionLogRoutes);
app.use("/session-sharing", sessionSharingRoutes);
app.use("/collab", collabRoutes);
+4 -38
View File
@@ -419,15 +419,8 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS command_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
command TEXT NOT NULL,
executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
-- command_history is owned by the ssh-terminal plugin, which adopts it
-- as p_ssh_terminal_command_history in its first migration.
CREATE TABLE IF NOT EXISTS host_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -858,35 +851,8 @@ async function initializeCompleteDatabase(): Promise<void> {
`);
try {
const timeoutRow = sqlite
.prepare(
"SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'",
)
.get() as { value: string } | undefined;
const timeoutMinutes = timeoutRow
? parseInt(timeoutRow.value, 10)
: 30;
const ttlMs =
!isNaN(timeoutMinutes) && timeoutMinutes > 0
? timeoutMinutes * 60_000
: 30 * 60_000;
const cutoff = new Date(Date.now() - ttlMs).toISOString();
const result = sqlite
.prepare("DELETE FROM user_open_tabs WHERE updated_at <= ?")
.run(cutoff);
if (result.changes > 0) {
databaseLogger.info("Expired open tabs cleared on startup", {
operation: "db_init_open_tabs_cleanup",
count: result.changes,
});
}
} catch (e) {
databaseLogger.warn("Could not clear expired open tabs on startup", {
operation: "db_init_open_tabs_cleanup_failed",
error: e,
});
}
// Expired open tabs are dropped when the tab list is read, against the
// terminal plugin's own session timeout.
try {
const result = sqlite
@@ -105,14 +105,9 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
table: "recent_activity",
columns: "user_id, timestamp",
},
{
name: "idx_command_history_user_host",
table: "command_history",
columns: "user_id, host_id",
},
// file_manager_recent, file_manager_pinned, file_manager_shortcuts and
// transfer_recent are owned by the file-manager plugin now; its adopted
// table definitions carry these same indexes across the rename.
// file_manager_recent, file_manager_pinned, file_manager_shortcuts,
// transfer_recent and command_history are owned by plugins now; their
// adopted table definitions carry these same indexes across the rename.
{
name: "idx_user_open_tabs_user_id",
table: "user_open_tabs",
-20
View File
@@ -515,26 +515,6 @@ export const recentActivity = mysqlTable(
],
);
export const commandHistory = mysqlTable(
"command_history",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
command: text("command").notNull(),
executedAt: text("executed_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
index("idx_command_history_user_host").on(table.userId, table.hostId),
],
);
export const hostAccess = mysqlTable(
"host_access",
{
-20
View File
@@ -516,26 +516,6 @@ export const recentActivity = pgTable(
],
);
export const commandHistory = pgTable(
"command_history",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
command: text("command").notNull(),
executedAt: text("executed_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_command_history_user_host").on(table.userId, table.hostId),
],
);
export const hostAccess = pgTable(
"host_access",
{
-20
View File
@@ -508,26 +508,6 @@ export const recentActivity = sqliteTable(
],
);
export const commandHistory = sqliteTable(
"command_history",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
command: text("command").notNull(),
executedAt: text("executed_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_command_history_user_host").on(table.userId, table.hostId),
],
);
export const hostAccess = sqliteTable(
"host_access",
{
@@ -1,160 +0,0 @@
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { commandHistory } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
export type CommandHistoryRecord = typeof commandHistory.$inferSelect;
export class CommandHistoryRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async create(
userId: string,
hostId: number,
command: string,
executedAt = new Date().toISOString(),
): Promise<CommandHistoryRecord> {
const [created] = await insertReturning(this.context, commandHistory, {
userId,
hostId,
command,
executedAt,
});
await this.afterWrite();
return created;
}
async listUniqueCommandsForHost(
userId: string,
hostId: number,
limit = 500,
): Promise<string[]> {
const rows = await this.context.drizzle
.select({
command: commandHistory.command,
maxExecutedAt: sql<number>`MAX(${commandHistory.executedAt})`,
})
.from(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostId),
),
)
.groupBy(commandHistory.command)
.orderBy(desc(sql`MAX(${commandHistory.executedAt})`))
.limit(limit);
return rows.map((row) => row.command);
}
async listCommandsForHost(
userId: string,
hostId: number,
limit = 200,
): Promise<string[]> {
const rows = await this.context.drizzle
.select({
id: commandHistory.id,
command: commandHistory.command,
})
.from(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostId),
),
)
.orderBy(desc(commandHistory.executedAt))
.limit(limit);
return rows.map((row) => row.command);
}
async deleteCommandForHost(
userId: string,
hostId: number,
command: string,
): Promise<number> {
const result = await this.context.drizzle
.delete(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostId),
eq(commandHistory.command, command),
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByUserAndHost(userId: string, hostId: number): Promise<number> {
const result = await this.context.drizzle
.delete(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostId),
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByHostId(hostId: number): Promise<number> {
const result = await this.context.drizzle
.delete(commandHistory)
.where(eq(commandHistory.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(commandHistory)
.where(inArray(commandHistory.hostId, hostIds));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(commandHistory)
.where(eq(commandHistory.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -9,7 +9,6 @@ import { NotificationChannelRepository } from "./notification-channel-repository
import { AutomationRepository } from "./automation-repository.js";
import { ApiKeyRepository } from "./api-key-repository.js";
import { AuditLogRepository } from "./audit-log-repository.js";
import { CommandHistoryRepository } from "./command-history-repository.js";
import { CredentialRepository } from "./credential-repository.js";
import { DashboardServiceLinkRepository } from "./dashboard-service-link-repository.js";
import { DismissedAlertRepository } from "./dismissed-alert-repository.js";
@@ -184,13 +183,6 @@ export function createCurrentAuditLogRepository(): AuditLogRepository {
);
}
export function createCurrentCommandHistoryRepository(): CommandHistoryRepository {
return new CommandHistoryRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("command_history_repository_write"),
);
}
export function createCurrentCredentialRepository(): CredentialRepository {
return new CredentialRepository(
createCurrentRepositoryContext(),
@@ -4,7 +4,6 @@ import {
createCurrentNotificationChannelRepository,
createCurrentApiKeyRepository,
createCurrentAuditLogRepository,
createCurrentCommandHistoryRepository,
createCurrentCredentialRepository,
createCurrentDashboardServiceLinkRepository,
createCurrentDismissedAlertRepository,
@@ -94,8 +93,6 @@ export async function deleteUserAndRelatedData(
await createCurrentHostFolderRepository().deleteByUserId(userId);
await createCurrentCommandHistoryRepository().deleteByUserId(userId);
await createCurrentHostHealthRepository().deleteByUserId(userId);
await createCurrentHostMetricsPreferenceRepository().deleteByUserId(userId);
await createCurrentHostSidebarPreferenceRepository().deleteByUserId(userId);
@@ -1,139 +0,0 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { sshLogger } from "../../utils/logger.js";
import { createCurrentCommandHistoryRepository } from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
export function registerHostCommandHistoryRoutes(
router: Router,
authenticateJWT: RequestHandler,
requireViewPermission: RequestHandler,
requireDataAccess: RequestHandler,
): void {
/**
* @openapi
* /host/command-history/{hostId}:
* get:
* summary: Get command history
* description: Retrieves the command history for a specific host.
* tags:
* - SSH
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of commands.
* 400:
* description: Invalid userId or hostId.
* 500:
* description: Failed to fetch command history.
*/
router.get(
"/command-history/:hostId",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostIdParam = Array.isArray(req.params.hostId)
? req.params.hostId[0]
: req.params.hostId;
const hostId = parseInt(hostIdParam, 10);
if (!isNonEmptyString(userId) || !hostId) {
sshLogger.warn("Invalid userId or hostId for command history fetch", {
operation: "command_history_fetch",
hostId,
userId,
});
return res.status(400).json({ error: "Invalid userId or hostId" });
}
try {
const history =
await createCurrentCommandHistoryRepository().listCommandsForHost(
userId,
hostId,
);
res.json(history);
} catch (err) {
sshLogger.error("Failed to fetch command history from database", err, {
operation: "command_history_fetch",
hostId,
userId,
});
res.status(500).json({ error: "Failed to fetch command history" });
}
},
);
/**
* @openapi
* /host/command-history:
* delete:
* summary: Delete command from history
* description: Deletes a specific command from the history of a host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* command:
* type: string
* responses:
* 200:
* description: Command deleted from history.
* 400:
* description: Invalid data.
* 500:
* description: Failed to delete command.
*/
router.delete(
"/command-history",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, command } = req.body;
if (!isNonEmptyString(userId) || !hostId || !command) {
sshLogger.warn("Invalid data for command history deletion", {
operation: "command_history_delete",
hostId,
userId,
});
return res.status(400).json({ error: "Invalid data" });
}
try {
await createCurrentCommandHistoryRepository().deleteCommandForHost(
userId,
hostId,
command,
);
res.json({ message: "Command deleted from history" });
} catch (err) {
sshLogger.error("Failed to delete command from history", err, {
operation: "command_history_delete",
hostId,
userId,
command,
});
res.status(500).json({ error: "Failed to delete command" });
}
},
);
}
@@ -3,7 +3,6 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import { databaseLogger, sshLogger } from "../../utils/logger.js";
import { pluginEvents, TOPICS } from "../../plugins/events.js";
import {
createCurrentCommandHistoryRepository,
createCurrentCredentialRepository,
createCurrentHostFolderRepository,
createCurrentFolderAccessRepository,
@@ -398,13 +397,9 @@ export function registerHostFolderRoutes(
const hostIds = hostsToDelete.map((host) => host.id);
if (hostIds.length > 0) {
// file manager recent/pinned/shortcuts and transfer_recent cascade
// on the host's refHost() foreign key, as the file-manager
// plugin's adopted tables.
await createCurrentCommandHistoryRepository().deleteByHostIds(
hostIds,
);
// 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.
await createCurrentSshCredentialUsageRepository().deleteByHostIds(
hostIds,
+5 -16
View File
@@ -16,7 +16,6 @@ import {
} from "../../hosts/credential-username.js";
import { notifyAutomationInternalEvent } from "../../hosts/automation-events.js";
import {
createCurrentCommandHistoryRepository,
createCurrentCredentialRepository,
createCurrentOpksshTokenRepository,
createCurrentRecentActivityRepository,
@@ -53,7 +52,6 @@ import { validateParentHostId } from "./host-parent-validation.js";
import { registerHostOpksshRoutes } from "./host-opkssh-routes.js";
import { registerHostStepCaRoutes } from "./host-step-ca-routes.js";
import { registerHostFolderRoutes } from "./host-folder-routes.js";
import { registerHostCommandHistoryRoutes } from "./host-command-history-routes.js";
import { registerHostAutostartRoutes } from "./host-autostart-routes.js";
import { registerHostInternalRoutes } from "./host-internal-routes.js";
import { registerHostNetworkRoutes } from "./host-network-routes.js";
@@ -2530,13 +2528,9 @@ router.delete(
const numericHostId = Number(hostId);
// file manager recent/pinned/shortcuts and transfer_recent cascade on
// the host's refHost() foreign key, as the file-manager plugin's
// adopted tables.
await createCurrentCommandHistoryRepository().deleteByHostId(
numericHostId,
);
// 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.
await createCurrentSshCredentialUsageRepository().deleteByHostId(
numericHostId,
@@ -2612,13 +2606,8 @@ router.delete(
);
// File manager recent/pinned/shortcuts and transfer/recent routes moved to
// the file-manager plugin, under /plugin-api/file-manager/.
registerHostCommandHistoryRoutes(
router,
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
);
// the file-manager plugin, under /plugin-api/file-manager/, and command
// history to the ssh-terminal plugin, under /plugin-api/ssh-terminal/.
async function resolveHostCredentials(
host: Record<string, unknown>,
+35 -19
View File
@@ -2,9 +2,8 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { sessionManager } from "../../hosts/terminal/session-manager.js";
import { liveTerminalSessions } from "../../hosts/live-terminal-sessions.js";
import {
getCurrentSettingValue,
createCurrentOpenTabRepository,
createCurrentSessionShareRepository,
} from "../repositories/factory.js";
@@ -26,17 +25,10 @@ const authenticateJWT = authManager.createAuthMiddleware();
*/
const DEFAULT_TAB_TTL_MINUTES = 30;
// Tabs live as long as the terminal keeps a detached session.
function getTabTtlMs(): number {
try {
const value = getCurrentSettingValue("terminal_session_timeout_minutes");
if (value) {
const minutes = parseInt(value, 10);
if (!isNaN(minutes) && minutes > 0) return minutes * 60_000;
}
} catch {
// DB not available, use default
}
return DEFAULT_TAB_TTL_MINUTES * 60_000;
const minutes = liveTerminalSessions.idleTimeoutMinutes();
return (minutes && minutes > 0 ? minutes : DEFAULT_TAB_TTL_MINUTES) * 60_000;
}
// Legacy tab types that were renamed. Normalize on read so previously saved
@@ -273,6 +265,33 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
}
});
/**
* @openapi
* /open-tabs/session-timeout:
* get:
* summary: Get how long a detached session is kept
* description: The terminal's idle timeout for detached sessions, which is also how long a saved open tab lives. 30 while the terminal is off.
* tags:
* - Open Tabs
* responses:
* 200:
* description: The timeout in minutes.
* content:
* application/json:
* schema:
* type: object
* properties:
* minutes:
* type: integer
*/
router.get(
"/session-timeout",
authenticateJWT,
(_req: Request, res: Response) => {
res.json({ minutes: getTabTtlMs() / 60_000 });
},
);
/**
* @openapi
* /open-tabs/active-sessions:
@@ -324,12 +343,12 @@ router.get(
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const ownSessions = sessionManager.getUserSessions(userId);
const ownSessions = liveTerminalSessions.listForUser(userId);
const result = ownSessions.map((s) => ({
sessionId: s.id,
hostId: s.hostId,
hostName: s.hostName,
tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null,
tabInstanceId: s.tabInstanceId,
isConnected: s.isConnected,
createdAt: s.createdAt,
isOwnSession: true,
@@ -344,16 +363,13 @@ router.get(
);
for (const share of sharedWithMe) {
if (share.protocol !== "ssh") continue;
const sharedSession = sessionManager.getSession(share.sessionId);
const sharedSession = liveTerminalSessions.getSession(share.sessionId);
if (!sharedSession || !sharedSession.isConnected) continue;
result.push({
sessionId: sharedSession.id,
hostId: sharedSession.hostId,
hostName: sharedSession.hostName,
tabInstanceId:
sharedSession.attachedTabInstanceId ??
sharedSession.tabInstanceId ??
null,
tabInstanceId: sharedSession.tabInstanceId,
isConnected: sharedSession.isConnected,
createdAt: sharedSession.createdAt,
isOwnSession: false,
@@ -1,264 +0,0 @@
import path from "path";
import { databaseLogger } from "../../utils/logger.js";
/**
* Terminal image storage modes.
*
* - `local`: always write to the backend's mapped local storage. Deterministic:
* never falls back to the remote SFTP path.
* - `remote-sftp`: always write to the connected terminal's SSH host over SFTP.
* Deterministic: never falls back to local storage.
* - `auto`: pick by capability only — remote SFTP when a connected terminal
* session exists, local storage otherwise. Configuration never influences
* this choice.
*/
export const TERMINAL_IMAGE_STORAGE_MODES = [
"auto",
"local",
"remote-sftp",
] as const;
export type TerminalImageStorageMode =
(typeof TERMINAL_IMAGE_STORAGE_MODES)[number];
export interface TerminalImageStorageSettings {
mode: TerminalImageStorageMode;
/** Absolute path on the Termix backend where local-mode files are written. */
localDir: string;
/**
* Absolute path handed to the terminal agent in local mode. This is the
* host-side view of `localDir` (e.g. /tmp mapped into the container); it is
* the only path ever exposed to callers.
*/
hostPath: string;
ttlMs: number;
maxCount: number;
maxBytes: number;
/** Both localDir and hostPath were explicitly configured and must be probed. */
localMappingConfigured: boolean;
}
/** Settings-table keys. Persisted values always win over environment. */
export const TERMINAL_IMAGE_STORAGE_KEYS = {
mode: "terminal_image_storage_mode",
localDir: "terminal_image_local_dir",
hostPath: "terminal_image_host_path",
ttlMs: "terminal_image_ttl_ms",
maxCount: "terminal_image_max_count",
maxBytes: "terminal_image_max_storage_bytes",
} as const;
/**
* Legacy environment variables from the original env-only configuration. They
* seed defaults only when no database value exists for the same field.
*/
export const TERMINAL_IMAGE_STORAGE_ENV = {
mode: "TERMIX_IMAGE_STORAGE_MODE",
localDir: "TERMIX_IMAGE_DIR",
hostPath: "TERMIX_IMAGE_HOST_PATH",
ttlMs: "TERMIX_IMAGE_TTL_MS",
maxCount: "TERMIX_MAX_IMAGE_COUNT",
maxBytes: "TERMIX_MAX_IMAGE_STORAGE_BYTES",
} as const;
export const DEFAULT_IMAGE_TTL_MS = 3_600_000;
export const DEFAULT_IMAGE_MAX_COUNT = 100;
export const DEFAULT_IMAGE_MAX_BYTES = 5_368_709_120;
export const DEFAULT_IMAGE_HOST_PATH = "/tmp/termix-image-v0";
export const MIN_IMAGE_MAX_BYTES = 1_048_576;
export function defaultImageLocalDir(env: NodeJS.ProcessEnv): string {
return path.resolve(
path.join(env.DATA_DIR || "./db/data", "termix-image-v0"),
);
}
export function parseTerminalImageStorageMode(
value: unknown,
): TerminalImageStorageMode | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
return (TERMINAL_IMAGE_STORAGE_MODES as readonly string[]).includes(
normalized,
)
? (normalized as TerminalImageStorageMode)
: null;
}
/**
* Local write directory must be an absolute path without NUL bytes. Relative
* values are rejected rather than resolved: a relative entry silently depends
* on the process cwd, which differs between Docker, systemd and dev runs.
*/
export function parseImageLocalDir(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed || hasUnsafePathSyntax(trimmed)) return null;
if (!path.isAbsolute(trimmed)) return null;
return path.resolve(trimmed);
}
/** Agent-visible path. Always POSIX-style and absolute. */
export function parseImageHostPath(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed || hasUnsafePathSyntax(trimmed)) return null;
if (!path.posix.isAbsolute(trimmed)) return null;
return path.posix.normalize(trimmed);
}
/**
* Numeric fields: unparseable values are rejected (caller falls through to the
* next source); parseable but out-of-range values are clamped, matching the
* original env-only behavior.
*/
function parseClampedInt(value: unknown, min: number): number | null {
if (typeof value !== "string" && typeof value !== "number") return null;
const parsed =
typeof value === "number" ? value : Number.parseInt(value.trim(), 10);
if (!Number.isFinite(parsed)) return null;
return Math.max(min, Math.trunc(parsed));
}
interface SettingSource {
get(key: string): Promise<string | null>;
}
function warnInvalid(key: string, source: string): void {
databaseLogger.warn("Ignoring invalid terminal image storage setting", {
operation: "terminal_image_storage_settings_invalid",
key,
source,
});
}
function hasUnsafePathSyntax(value: string): boolean {
return (
/[\u0000-\u001f\u007f]/.test(value) ||
value.split(/[\\/]+/).some((segment) => segment === "..") ||
value.includes("//") ||
value.includes("\\")
);
}
/**
* Resolves the effective image storage settings.
*
* Per-field precedence: a valid persisted database value wins; a legacy
* TERMIX_IMAGE_* variable seeds the default only when no database value
* exists; otherwise the built-in default applies. Invalid values are skipped
* with a warning and resolution falls through to the next source.
*
* Mode compatibility: with no mode in the database or environment, an explicit
* legacy local mapping (TERMIX_IMAGE_DIR) keeps old deployments on `local`;
* everything else defaults to `auto`.
*/
export async function resolveTerminalImageStorageSettings(
settings: SettingSource,
env: NodeJS.ProcessEnv = process.env,
): Promise<TerminalImageStorageSettings> {
async function pick<T>(
key: string,
envName: string,
parse: (value: unknown) => T | null,
fallback: T,
): Promise<T> {
const stored = await settings.get(key);
if (stored !== null) {
const parsed = parse(stored);
if (parsed !== null) return parsed;
warnInvalid(key, "database");
}
const fromEnv = env[envName];
if (fromEnv !== undefined) {
const parsed = parse(fromEnv);
if (parsed !== null) return parsed;
warnInvalid(key, "environment");
}
return fallback;
}
const dbModeRaw = await settings.get(TERMINAL_IMAGE_STORAGE_KEYS.mode);
let mode: TerminalImageStorageMode | null = null;
if (dbModeRaw !== null) {
mode = parseTerminalImageStorageMode(dbModeRaw);
if (mode === null)
warnInvalid(TERMINAL_IMAGE_STORAGE_KEYS.mode, "database");
}
if (mode === null) {
const envModeRaw = env[TERMINAL_IMAGE_STORAGE_ENV.mode];
if (envModeRaw !== undefined) {
mode = parseTerminalImageStorageMode(envModeRaw);
if (mode === null)
warnInvalid(TERMINAL_IMAGE_STORAGE_KEYS.mode, "environment");
}
}
if (mode === null) {
// Legacy deployments configured local storage purely through
// TERMIX_IMAGE_DIR; keep them on local mode unless a database value says
// otherwise.
mode =
env[TERMINAL_IMAGE_STORAGE_ENV.localDir] !== undefined ? "local" : "auto";
}
const legacyLocalDir = parseImageLocalDir(
env[TERMINAL_IMAGE_STORAGE_ENV.localDir],
);
const [localDir, hostPath, ttlMs, maxCount, maxBytes] = await Promise.all([
pick(
TERMINAL_IMAGE_STORAGE_KEYS.localDir,
TERMINAL_IMAGE_STORAGE_ENV.localDir,
parseImageLocalDir,
defaultImageLocalDir(env),
),
pick(
TERMINAL_IMAGE_STORAGE_KEYS.hostPath,
TERMINAL_IMAGE_STORAGE_ENV.hostPath,
parseImageHostPath,
legacyLocalDir ?? DEFAULT_IMAGE_HOST_PATH,
),
pick(
TERMINAL_IMAGE_STORAGE_KEYS.ttlMs,
TERMINAL_IMAGE_STORAGE_ENV.ttlMs,
(value) => parseClampedInt(value, 0),
DEFAULT_IMAGE_TTL_MS,
),
pick(
TERMINAL_IMAGE_STORAGE_KEYS.maxCount,
TERMINAL_IMAGE_STORAGE_ENV.maxCount,
(value) => parseClampedInt(value, 1),
DEFAULT_IMAGE_MAX_COUNT,
),
pick(
TERMINAL_IMAGE_STORAGE_KEYS.maxBytes,
TERMINAL_IMAGE_STORAGE_ENV.maxBytes,
(value) => parseClampedInt(value, MIN_IMAGE_MAX_BYTES),
DEFAULT_IMAGE_MAX_BYTES,
),
]);
const persistedLocalDir = await settings.get(
TERMINAL_IMAGE_STORAGE_KEYS.localDir,
);
const persistedHostPath = await settings.get(
TERMINAL_IMAGE_STORAGE_KEYS.hostPath,
);
const localMappingConfigured =
((persistedLocalDir !== null &&
parseImageLocalDir(persistedLocalDir) !== null) ||
parseImageLocalDir(env[TERMINAL_IMAGE_STORAGE_ENV.localDir]) !== null) &&
((persistedHostPath !== null &&
parseImageHostPath(persistedHostPath) !== null) ||
parseImageHostPath(env[TERMINAL_IMAGE_STORAGE_ENV.hostPath]) !== null ||
legacyLocalDir !== null);
return {
mode,
localDir,
hostPath,
ttlMs,
maxCount,
maxBytes,
localMappingConfigured,
};
}
@@ -1,675 +0,0 @@
import fs from "fs/promises";
import path from "path";
import { randomUUID } from "crypto";
import {
exceedsImageStorageLimit,
isExpiredImage,
isImageFilename,
} from "./terminal-image-utils.js";
import type { TerminalImageStorageSettings } from "./terminal-image-storage-settings.js";
/**
* Stable error codes for image storage failures. These are part of the upload
* route's contract; `IMAGE_REMOTE_WRITE_FAILED` predates this module and must
* not change.
*/
export type TerminalImageStorageErrorCode =
| "IMAGE_STORAGE_LIMIT_REACHED"
| "IMAGE_LOCAL_WRITE_FAILED"
| "IMAGE_LOCAL_INSPECTION_FAILED"
| "IMAGE_REMOTE_QUOTA_UNAVAILABLE"
| "IMAGE_REMOTE_WRITE_FAILED";
export class TerminalImageStorageError extends Error {
constructor(
readonly code: TerminalImageStorageErrorCode,
message: string,
readonly cause?: unknown,
) {
super(message);
this.name = "TerminalImageStorageError";
}
}
export interface StoredTerminalImage {
id: string;
filename: string;
/** Agent-visible path; never a backend-internal path. */
shellPath: string;
storage: "local" | "remote-sftp";
}
/**
* Mode selection for one upload. Explicit modes are deterministic — they are
* returned regardless of capability and the route reports the failure; only
* `auto` falls back, and only on capability (a connected terminal session
* with SFTP), never on configuration.
*/
export function selectImageStorageMode(
settings: Pick<
TerminalImageStorageSettings,
"mode" | "localMappingConfigured"
>,
capability: { remoteSftpAvailable: boolean; localHostVisible?: boolean },
): "local" | "remote-sftp" | "unavailable" {
if (settings.mode === "local") return "local";
if (settings.mode === "remote-sftp") return "remote-sftp";
if (settings.localMappingConfigured && capability.localHostVisible === true) {
return "local";
}
return capability.remoteSftpAvailable ? "remote-sftp" : "unavailable";
}
// Capacity checks and writes are serialized so concurrent uploads cannot
// bypass the count or byte limits.
let imageStorageQueue: Promise<unknown> = Promise.resolve();
function withImageStorageLock<T>(operation: () => Promise<T>): Promise<T> {
const result = imageStorageQueue.then(operation, operation);
imageStorageQueue = result.then(
() => undefined,
() => undefined,
);
return result;
}
async function cleanupExpiredImages(
localDir: string,
ttlMs: number,
): Promise<void> {
const entries = await fs.readdir(localDir, { withFileTypes: true });
const now = Date.now();
await Promise.all(
entries
.filter((entry) => entry.isFile() && isImageFilename(entry.name))
.map(async (entry) => {
const filePath = path.join(localDir, entry.name);
const stat = await fs.stat(filePath);
if (stat && isExpiredImage(stat.mtimeMs, now, ttlMs)) {
await fs.unlink(filePath).catch(() => undefined);
}
}),
);
}
async function getActiveImageStorageUsage(
localDir: string,
ttlMs: number,
): Promise<{ fileCount: number; totalBytes: number }> {
const entries = await fs.readdir(localDir, { withFileTypes: true });
const now = Date.now();
const stats = await Promise.all(
entries
.filter((entry) => entry.isFile() && isImageFilename(entry.name))
.map(async (entry) => {
const stat = await fs.stat(path.join(localDir, entry.name));
return !isExpiredImage(stat.mtimeMs, now, ttlMs) ? stat : null;
}),
);
return stats.reduce(
(usage, stat) => {
if (stat) {
usage.fileCount += 1;
usage.totalBytes += stat.size;
}
return usage;
},
{ fileCount: 0, totalBytes: 0 },
);
}
/**
* Local mapped-storage adapter. Enforces the TTL/count/byte policy and raises
* `IMAGE_STORAGE_LIMIT_REACHED` (HTTP 507 at the route) when the caps are hit.
* The returned shellPath is built from the agent-visible hostPath — the
* backend's own localDir is never exposed.
*/
export async function storeImageLocally(
image: Buffer,
settings: TerminalImageStorageSettings,
): Promise<StoredTerminalImage> {
return withImageStorageLock(async () => {
let usage: { fileCount: number; totalBytes: number };
try {
await fs.mkdir(settings.localDir, { recursive: true });
await cleanupExpiredImages(settings.localDir, settings.ttlMs);
usage = await getActiveImageStorageUsage(
settings.localDir,
settings.ttlMs,
);
} catch (error) {
throw new TerminalImageStorageError(
"IMAGE_LOCAL_INSPECTION_FAILED",
"Unable to inspect local image storage",
error,
);
}
if (
exceedsImageStorageLimit(
usage.fileCount,
usage.totalBytes,
image.length,
settings.maxCount,
settings.maxBytes,
)
) {
throw new TerminalImageStorageError(
"IMAGE_STORAGE_LIMIT_REACHED",
"Image storage limit reached",
);
}
const id = randomUUID();
const filename = `${id}.png`;
try {
await fs.writeFile(path.join(settings.localDir, filename), image);
} catch (error) {
await fs
.rm(path.join(settings.localDir, filename), { force: true })
.catch(() => undefined);
throw new TerminalImageStorageError(
"IMAGE_LOCAL_WRITE_FAILED",
"Failed to write image to local storage",
error,
);
}
return {
id,
filename,
shellPath: path.posix.join(settings.hostPath, filename),
storage: "local",
};
});
}
// Remote directory (on the SSH host the terminal is connected to) that
// uploaded/pasted images are written into. Always POSIX-style: this is a
// path on the remote shell, not on the Termix backend's own filesystem.
export const REMOTE_IMAGE_DIR = "/tmp/termix-images";
/** Minimal SFTP surface the remote adapter needs (satisfied by ssh2). */
export interface ImageSftpClient {
mkdir(
dir: string,
attrsOrCallback: { mode?: number } | ((err?: Error) => void),
callback?: (err?: Error) => void,
): void;
createWriteStream(
remotePath: string,
options?: { mode?: number },
): NodeJS.WritableStream;
stat?: (
dir: string,
callback: (
error: Error | undefined,
attrs?: { mode?: number; mtime?: number },
) => void,
) => void;
lstat?: (
dir: string,
callback: (
error: Error | undefined,
attrs?: { mode?: number; mtime?: number },
) => void,
) => void;
chmod?: (
dir: string,
mode: number,
callback: (error?: Error) => void,
) => void;
readdir?: (
dir: string,
callback: (error: Error | undefined, entries: ImageSftpEntry[]) => void,
) => void;
unlink?: (remotePath: string, callback: (error?: Error) => void) => void;
rmdir?: (dir: string, callback: (error?: Error) => void) => void;
end?: () => void;
}
interface ImageSftpEntry {
filename: string;
attrs?: { mtime?: number; size?: number };
}
export interface ImageSshExecClient {
exec(
command: string,
callback: (error: Error | undefined, stream?: ImageExecStream) => void,
): void;
}
interface ImageExecStream {
on(event: "close", listener: (code: number | null) => void): this;
on(event: "error", listener: (error: Error) => void): this;
resume(): void;
}
function quoteRemotePath(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function execBounded(
sshConn: ImageSshExecClient,
command: string,
timeoutMs = 3_000,
): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
const finish = (result: boolean) => {
if (settled) return;
settled = true;
resolve(result);
};
const timer = setTimeout(() => finish(false), timeoutMs);
sshConn.exec(command, (error, stream) => {
if (error || !stream) {
clearTimeout(timer);
finish(false);
return;
}
stream.on("close", (code) => {
clearTimeout(timer);
finish(code === 0);
});
stream.on("error", () => {
clearTimeout(timer);
finish(false);
});
stream.resume();
});
});
}
function withTimeout<T>(
operation: Promise<T>,
timeoutMs: number,
message: string,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
operation.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
},
);
});
}
/** Verify a configured local mapping from the currently connected SSH session. */
export async function probeLocalImageVisibility(
sshConn: ImageSshExecClient,
settings: Pick<TerminalImageStorageSettings, "localDir" | "hostPath">,
): Promise<boolean> {
const filename = `.termix-image-probe-${randomUUID()}`;
const localProbe = path.join(settings.localDir, filename);
const remoteProbe = path.posix.join(settings.hostPath, filename);
await fs.mkdir(settings.localDir, { recursive: true });
await fs.writeFile(localProbe, "termix-image-probe", { flag: "wx" });
try {
return await execBounded(
sshConn,
`test -f -- ${quoteRemotePath(remoteProbe)}`,
);
} finally {
await fs.unlink(localProbe).catch(() => undefined);
await execBounded(sshConn, `rm -f -- ${quoteRemotePath(remoteProbe)}`);
}
}
function sftpMkdir(sftp: ImageSftpClient, dir: string): Promise<void> {
return new Promise((resolve, reject) => {
sftp.mkdir(dir, { mode: 0o700 }, (err) => {
if (!err) {
resolve();
return;
}
const inspect = (sftp.lstat ?? sftp.stat)?.bind(sftp);
if (!inspect) {
reject(err);
return;
}
inspect(dir, (inspectError, attrs) => {
if (inspectError || !attrs) {
reject(inspectError ?? err);
return;
}
if (attrs.mode !== undefined && (attrs.mode & 0o170000) !== 0o040000) {
reject(new Error("Remote image path is not a directory"));
return;
}
if (!sftp.chmod) {
reject(
new Error("Remote image directory permissions cannot be verified"),
);
return;
}
sftp.chmod(dir, 0o700, (chmodError) => {
if (chmodError) reject(chmodError);
else resolve();
});
});
});
});
}
const REMOTE_IMAGE_LOCK_DIR = `${REMOTE_IMAGE_DIR}/.termix-write-lock`;
const REMOTE_IMAGE_LOCK_LEASE_MS = 60_000;
function waitForRemoteImageLock(
sftp: ImageSftpClient,
attempts = 60,
): Promise<() => Promise<void>> {
if (!sftp.rmdir) {
return Promise.reject(
new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Remote image storage lock cannot be verified",
),
);
}
return new Promise((resolve, reject) => {
let remaining = attempts;
const tryAcquire = () => {
sftp.mkdir(REMOTE_IMAGE_LOCK_DIR, { mode: 0o700 }, (error) => {
if (!error) {
resolve(
() =>
new Promise<void>((releaseResolve, releaseReject) => {
sftp.rmdir!(REMOTE_IMAGE_LOCK_DIR, (releaseError) =>
releaseError ? releaseReject(releaseError) : releaseResolve(),
);
}),
);
return;
}
const inspect = (sftp.lstat ?? sftp.stat)?.bind(sftp);
if (!inspect) {
reject(
new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Remote image storage lock cannot be verified",
error,
),
);
return;
}
inspect(REMOTE_IMAGE_LOCK_DIR, (inspectError, attrs) => {
const stale =
!inspectError &&
typeof attrs?.mtime === "number" &&
Date.now() - attrs.mtime * 1000 > REMOTE_IMAGE_LOCK_LEASE_MS;
if (stale) {
sftp.rmdir!(REMOTE_IMAGE_LOCK_DIR, (removeError) => {
if (removeError) {
if (--remaining <= 0) {
reject(
new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Stale remote image storage lock cannot be removed",
removeError,
),
);
return;
}
setTimeout(tryAcquire, 50);
return;
}
tryAcquire();
});
return;
}
if (--remaining <= 0) {
reject(
new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Remote image storage lock is unavailable",
error,
),
);
return;
}
setTimeout(tryAcquire, 50);
});
});
};
tryAcquire();
});
}
function sftpWriteFile(
sftp: ImageSftpClient,
remotePath: string,
data: Buffer,
timeoutMs = 10_000,
): Promise<void> {
return new Promise((resolve, reject) => {
const stream = sftp.createWriteStream(remotePath, {
mode: 0o600,
}) as NodeJS.WritableStream & {
destroy?: () => void;
};
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
stream.destroy?.();
reject(new Error("SFTP image write timed out"));
}, timeoutMs);
const finish = (error?: Error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (error) reject(error);
else resolve();
};
stream.on("error", (error: Error) => finish(error));
stream.on("close", () => finish());
stream.end(data);
});
}
async function cleanupExpiredRemoteImages(
sftp: ImageSftpClient,
ttlMs: number | undefined,
nowMs = Date.now(),
): Promise<void> {
if (!ttlMs || ttlMs <= 0 || !sftp.readdir || !sftp.unlink) return;
const entries = await new Promise<ImageSftpEntry[]>((resolve) => {
sftp.readdir!(REMOTE_IMAGE_DIR, (error, result) => {
resolve(error ? [] : result);
});
});
const cutoffSeconds = (nowMs - ttlMs) / 1000;
const uuidPng =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.png$/i;
await Promise.all(
entries
.filter(
(entry) =>
uuidPng.test(entry.filename) &&
typeof entry.attrs?.mtime === "number" &&
entry.attrs.mtime < cutoffSeconds,
)
.map((entry) =>
withTimeout(
new Promise<void>((resolve) => {
sftp.unlink!(`${REMOTE_IMAGE_DIR}/${entry.filename}`, () =>
resolve(),
);
}),
3_000,
"SFTP cleanup operation timed out",
).catch(() => undefined),
),
);
}
const REMOTE_UUID_PNG_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.png$/i;
async function enforceRemoteImageLimits(
sftp: ImageSftpClient,
imageBytes: number,
maxCount: number,
maxBytes: number,
): Promise<void> {
if (!sftp.readdir) {
throw new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Remote image storage limits cannot be verified",
);
}
let entries: ImageSftpEntry[];
try {
entries = await new Promise<ImageSftpEntry[]>((resolve, reject) => {
sftp.readdir!(REMOTE_IMAGE_DIR, (error, result) => {
if (error) reject(error);
else resolve(result);
});
});
} catch (error) {
throw new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Remote image storage limits cannot be verified",
error,
);
}
const images = entries.filter((entry) =>
REMOTE_UUID_PNG_PATTERN.test(entry.filename),
);
const totalBytes = images.reduce((sum, entry) => {
if (typeof entry.attrs?.size !== "number") {
throw new TerminalImageStorageError(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
"Remote image storage limits cannot be verified",
);
}
return sum + entry.attrs.size;
}, 0);
if (images.length >= maxCount || totalBytes + imageBytes > maxBytes) {
throw new TerminalImageStorageError(
"IMAGE_STORAGE_LIMIT_REACHED",
"Image storage limit reached",
);
}
}
async function storeImageViaSftpUnlocked(
sftp: ImageSftpClient,
image: Buffer,
options: {
writeTimeoutMs?: number;
ttlMs?: number;
maxCount?: number;
maxBytes?: number;
nowMs?: number;
} = {},
): Promise<StoredTerminalImage> {
const id = randomUUID();
const filename = `${id}.png`;
const remotePath = `${REMOTE_IMAGE_DIR}/${filename}`;
let releaseRemoteLock: (() => Promise<void>) | undefined;
let operationError: TerminalImageStorageError | undefined;
try {
await withTimeout(
sftpMkdir(sftp, REMOTE_IMAGE_DIR),
3_000,
"SFTP directory operation timed out",
);
releaseRemoteLock = await withTimeout(
waitForRemoteImageLock(sftp),
10_000,
"SFTP lock operation timed out",
);
await withTimeout(
cleanupExpiredRemoteImages(sftp, options.ttlMs, options.nowMs),
5_000,
"SFTP cleanup operation timed out",
);
if (options.maxCount !== undefined || options.maxBytes !== undefined) {
await withTimeout(
enforceRemoteImageLimits(
sftp,
image.length,
options.maxCount ?? 100,
options.maxBytes ?? 5_368_709_120,
),
5_000,
"SFTP quota operation timed out",
);
}
await sftpWriteFile(sftp, remotePath, image, options.writeTimeoutMs);
} catch (error) {
if (sftp.unlink) {
await withTimeout(
new Promise<void>((resolve) => {
sftp.unlink!(remotePath, () => resolve());
}),
3_000,
"SFTP cleanup operation timed out",
).catch(() => undefined);
}
operationError =
error instanceof TerminalImageStorageError
? error
: new TerminalImageStorageError(
"IMAGE_REMOTE_WRITE_FAILED",
"Failed to write image to the remote host",
error,
);
}
if (releaseRemoteLock) {
try {
await withTimeout(
releaseRemoteLock(),
3_000,
"SFTP lock release timed out",
);
} catch (releaseError) {
if (!operationError) {
if (sftp.unlink) {
await withTimeout(
new Promise<void>((resolve) => {
sftp.unlink!(remotePath, () => resolve());
}),
3_000,
"SFTP cleanup operation timed out",
).catch(() => undefined);
}
operationError = new TerminalImageStorageError(
"IMAGE_REMOTE_WRITE_FAILED",
"Failed to release remote image storage lock",
releaseError,
);
}
}
}
if (operationError) throw operationError;
return { id, filename, shellPath: remotePath, storage: "remote-sftp" };
}
export function storeImageViaSftp(
sftp: ImageSftpClient,
image: Buffer,
options: Parameters<typeof storeImageViaSftpUnlocked>[2] = {},
): Promise<StoredTerminalImage> {
return withImageStorageLock(() =>
withTimeout(
storeImageViaSftpUnlocked(sftp, image, options),
20_000,
"SFTP image operation timed out",
),
);
}
@@ -1,100 +0,0 @@
// Accepted decoded input formats; uploads are normalized to PNG by the route.
export const IMAGE_FORMAT_EXTENSIONS: Record<string, string> = {
avif: "avif",
gif: "gif",
heif: "heif",
jpeg: "jpg",
jp2: "jp2",
jxl: "jxl",
png: "png",
tiff: "tiff",
webp: "webp",
};
export function imageExtensionForFormat(
format: string | undefined,
): string | undefined {
return format ? IMAGE_FORMAT_EXTENSIONS[format] : undefined;
}
export const MAX_NORMALIZED_IMAGE_BYTES = 10 * 1024 * 1024;
export function exceedsNormalizedImageSize(
byteLength: number,
maxBytes = MAX_NORMALIZED_IMAGE_BYTES,
): boolean {
return byteLength > maxBytes;
}
export function createConcurrencyLimiter(
limit: number,
maxQueued = Number.POSITIVE_INFINITY,
): {
acquire: () => Promise<() => void>;
readonly active: number;
readonly queued: number;
} {
const max = Math.max(1, Math.floor(limit));
const queueLimit = Math.max(0, Math.floor(maxQueued));
let active = 0;
const waiters: Array<() => void> = [];
const startNext = () => {
if (active >= max || waiters.length === 0) return;
active += 1;
waiters.shift()!();
};
return {
acquire: () =>
new Promise<() => void>((resolve, reject) => {
if (active >= max && waiters.length >= queueLimit) {
reject(new Error("Concurrency admission queue is full"));
return;
}
waiters.push(() => {
let released = false;
resolve(() => {
if (released) return;
released = true;
active -= 1;
startNext();
});
});
startNext();
}),
get active() {
return active;
},
get queued() {
return waiters.length;
},
};
}
export const IMAGE_FILENAME_PATTERN = /^[0-9a-f-]{36}\.[a-z0-9]+$/i;
export function isImageFilename(filename: string): boolean {
return IMAGE_FILENAME_PATTERN.test(filename);
}
export function isExpiredImage(
modifiedAtMs: number,
nowMs: number,
ttlMs: number,
): boolean {
if (ttlMs <= 0) return false;
return nowMs - modifiedAtMs > ttlMs;
}
export function exceedsImageStorageLimit(
fileCount: number,
totalBytes: number,
incomingBytes: number,
maxFileCount: number,
maxStorageBytes: number,
): boolean {
return (
fileCount >= maxFileCount || totalBytes + incomingBytes > maxStorageBytes
);
}
-778
View File
@@ -1,778 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, {
type NextFunction,
type Request,
type Response,
} from "express";
import { randomUUID } from "crypto";
import multer from "multer";
import {
createConcurrencyLimiter,
exceedsNormalizedImageSize,
imageExtensionForFormat,
} from "./terminal-image-utils.js";
import { resolveTerminalImageStorageSettings } from "./terminal-image-storage-settings.js";
import {
selectImageStorageMode,
probeLocalImageVisibility,
storeImageLocally,
storeImageViaSftp,
TerminalImageStorageError,
type ImageSftpClient,
} from "./terminal-image-storage.js";
import { authLogger, databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { sessionManager } from "../../hosts/terminal/session-manager.js";
import {
createCurrentCommandHistoryRepository,
createCurrentHostResolutionRepository,
createCurrentSettingsRepository,
} from "../repositories/factory.js";
const router = express.Router();
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
// Browser image handoff for local terminal-agent workflows.
const imageUpload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 50 * 1024 * 1024,
fields: 4,
fieldSize: 64 * 1024,
files: 1,
parts: 5,
headerPairs: 200,
},
});
const imageUploadMiddleware = imageUpload.single("image");
type SharpFactory = typeof import("sharp").default;
let sharpFactory: SharpFactory | null = null;
let sharpLoadFailure: string | null = null;
// A static import of sharp takes backend startup down with it when the native
// binary for the running architecture is missing. Resolve on first use so only
// image upload is lost.
async function loadSharp(): Promise<SharpFactory> {
if (sharpFactory) return sharpFactory;
if (sharpLoadFailure !== null) throw new Error(sharpLoadFailure);
try {
sharpFactory = (await import("sharp")).default;
return sharpFactory;
} catch (error) {
sharpLoadFailure = getErrorMessage(error, "unknown");
throw new Error(sharpLoadFailure);
}
}
const imageProcessingLimiter = createConcurrencyLimiter(4, 4);
const imageMultipartAdmissionLimiter = createConcurrencyLimiter(4, 4);
let imageUploadSequence = 0;
async function handleImageUploadMiddleware(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
let releaseAdmission: (() => void) | undefined;
try {
releaseAdmission = await imageMultipartAdmissionLimiter.acquire();
} catch {
res.status(503).json({
error: "Image upload capacity is temporarily unavailable",
code: "IMAGE_UPLOAD_CAPACITY_EXCEEDED",
});
return;
}
imageUploadMiddleware(req, res, (error: unknown) => {
try {
if (!error) {
next();
return;
}
if (error instanceof multer.MulterError) {
databaseLogger.warn("Image upload multipart request rejected", {
operation: "terminal_image_upload_multipart_rejected",
code: error.code,
field: error.field,
contentType: req.headers["content-type"]?.split(";", 1)[0],
});
res.status(400).json({
error: "Image upload request rejected",
code: error.code,
field: error.field,
});
return;
}
databaseLogger.warn("Image upload multipart request malformed", {
operation: "terminal_image_upload_multipart_invalid",
contentType: req.headers["content-type"]?.split(";", 1)[0],
});
res.status(400).json({
error: "Malformed image upload request",
code: "IMAGE_MULTIPART_INVALID",
});
} finally {
releaseAdmission?.();
}
});
}
function findTerminalSession(userId: string, instanceId: string) {
return sessionManager
.getUserSessions(userId)
.find(
(session) =>
(session.attachedTabInstanceId ?? session.tabInstanceId) ===
instanceId && session.isConnected,
);
}
router.post(
"/image-upload",
authenticateJWT,
requireDataAccess,
handleImageUploadMiddleware,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const instanceId = req.body?.instanceId;
if (!req.file) {
return res.status(400).json({
error: "Image required",
code: "IMAGE_FILE_MISSING",
});
}
if (!isNonEmptyString(userId)) {
return res.status(400).json({
error: "Missing terminal session",
code: "IMAGE_SESSION_MISSING",
});
}
const requestId = randomUUID();
const sequence = ++imageUploadSequence;
const source =
req.body?.source === "file" || req.body?.source === "clipboard"
? req.body.source
: undefined;
const clientUploadTimestamp =
typeof req.body?.clientUploadTimestamp === "string" &&
!Number.isNaN(Date.parse(req.body.clientUploadTimestamp))
? req.body.clientUploadTimestamp
: undefined;
const serverReceivedAt = new Date().toISOString();
databaseLogger.info("Terminal image upload received", {
operation: "terminal_image_upload_received",
requestId,
sequence,
source,
clientUploadTimestamp,
serverReceivedAt,
bytes: req.file.size,
});
const storageSettings = await resolveTerminalImageStorageSettings(
createCurrentSettingsRepository(),
);
const session = isNonEmptyString(instanceId)
? findTerminalSession(userId, instanceId)
: undefined;
let localHostVisible = false;
if (storageSettings.localMappingConfigured && session?.sshConn) {
localHostVisible = await probeLocalImageVisibility(
session.sshConn,
storageSettings,
).catch(() => false);
}
const storageMode = selectImageStorageMode(storageSettings, {
remoteSftpAvailable: !!session?.sshConn,
localHostVisible,
});
if (storageMode === "unavailable") {
return res.status(503).json({
error: "Image storage is unavailable",
code: "IMAGE_STORAGE_UNAVAILABLE",
});
}
if (storageMode === "local" && !storageSettings.localMappingConfigured) {
return res.status(503).json({
error: "Local image storage is not configured",
code: "IMAGE_LOCAL_STORAGE_NOT_CONFIGURED",
});
}
if (storageMode === "remote-sftp") {
if (!isNonEmptyString(instanceId)) {
return res.status(400).json({
error: "Missing terminal session",
code: "IMAGE_SESSION_MISSING",
});
}
if (!session || !session.sshConn) {
return res.status(409).json({
error: "Terminal is not connected",
code: "IMAGE_TERMINAL_NOT_CONNECTED",
});
}
}
let sharp: SharpFactory;
try {
sharp = await loadSharp();
} catch (error) {
databaseLogger.error(
"Image processing unavailable: sharp failed to load",
error,
{
operation: "terminal_image_upload_sharp_unavailable",
reason: getErrorMessage(error, "unknown"),
},
);
return res.status(503).json({
error: "Image processing is unavailable on this installation",
code: "IMAGE_PROCESSING_UNAVAILABLE",
});
}
let normalizedImage: Buffer;
let releaseImageProcessingSlot: (() => void) | undefined;
try {
releaseImageProcessingSlot = await imageProcessingLimiter.acquire();
} catch {
return res.status(503).json({
error: "Image upload capacity is temporarily exhausted",
code: "IMAGE_UPLOAD_CAPACITY_EXCEEDED",
});
}
try {
const source = sharp(req.file.buffer, {
failOn: "error",
limitInputPixels: 40_000_000,
});
const { format } = await source.metadata();
if (!imageExtensionForFormat(format)) {
return res.status(400).json({
error: "Unsupported image format",
code: "IMAGE_FORMAT_UNSUPPORTED",
});
}
normalizedImage = await source.rotate().png().toBuffer();
if (exceedsNormalizedImageSize(normalizedImage.length)) {
return res.status(413).json({
error: "Normalized image is too large",
code: "IMAGE_NORMALIZED_SIZE_LIMIT",
});
}
} catch (error) {
databaseLogger.warn("Image upload failed image decoding", {
operation: "terminal_image_upload_decode",
mimeType: req.file.mimetype,
bytes: req.file.size,
reason: getErrorMessage(error, "unknown"),
});
return res.status(400).json({
error: "Invalid image data",
code: "IMAGE_DECODE_FAILED",
});
} finally {
releaseImageProcessingSlot?.();
}
let remoteSftp: ImageSftpClient | undefined;
try {
const stored =
storageMode === "remote-sftp"
? await (async () => {
remoteSftp = await new Promise<ImageSftpClient>(
(resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
settled = true;
reject(new Error("SFTP channel acquisition timed out"));
}, 3_000);
session!.sshConn!.sftp((err, sftp) => {
if (settled) {
sftp?.end?.();
return;
}
settled = true;
clearTimeout(timer);
if (err) return reject(err);
resolve(sftp);
});
},
);
return storeImageViaSftp(remoteSftp, normalizedImage, {
ttlMs: storageSettings.ttlMs,
maxCount: storageSettings.maxCount,
maxBytes: storageSettings.maxBytes,
});
})()
: await storeImageLocally(normalizedImage, storageSettings);
res.json(stored);
} catch (error) {
if (error instanceof TerminalImageStorageError) {
const status =
error.code === "IMAGE_STORAGE_LIMIT_REACHED"
? 507
: error.code === "IMAGE_REMOTE_WRITE_FAILED"
? 502
: error.code === "IMAGE_REMOTE_QUOTA_UNAVAILABLE"
? 503
: error.code === "IMAGE_LOCAL_INSPECTION_FAILED"
? 503
: 500;
databaseLogger.warn("Image upload storage write failed", {
operation:
error.code === "IMAGE_REMOTE_WRITE_FAILED"
? "terminal_image_upload_sftp_failed"
: "terminal_image_upload_local_failed",
code: error.code,
userId,
instanceId,
reason: getErrorMessage(error.cause ?? error, "unknown"),
});
return res.status(status).json({
error: error.message,
code: error.code,
});
}
databaseLogger.warn("Image upload failed to acquire remote channel", {
operation: "terminal_image_upload_sftp_failed",
code: "IMAGE_REMOTE_WRITE_FAILED",
userId,
instanceId,
reason: getErrorMessage(error, "unknown"),
});
return res.status(502).json({
error: "Failed to write image to the remote host",
code: "IMAGE_REMOTE_WRITE_FAILED",
});
} finally {
remoteSftp?.end?.();
}
},
);
/**
* @openapi
* /terminal/command_history:
* post:
* summary: Save command to history
* description: Saves a command to the command history for a specific host.
* tags:
* - Terminal
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* command:
* type: string
* responses:
* 201:
* description: Command saved successfully.
* 400:
* description: Missing required parameters.
* 500:
* description: Failed to save command.
*/
router.post(
"/command_history",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, command } = req.body;
if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) {
authLogger.warn("Invalid command history save request", {
operation: "command_history_save",
userId,
hasHostId: !!hostId,
hasCommand: !!command,
});
return res.status(400).json({ error: "Missing required parameters" });
}
const sensitivePatterns = [
/passw(or)?d/i,
/\bsecret\b/i,
/\btoken\b/i,
/\bapi.?key\b/i,
/PASS(WORD)?=/i,
/AWS_SECRET/i,
/mysql\b.*-p/i,
/sudo\s+-S\b/,
/htpasswd/i,
/sshpass/i,
/curl\b.*-u\s/i,
/export\b.*(?:PASSWORD|SECRET|TOKEN|KEY)=/i,
];
const trimmedCommand = command.trim();
if (sensitivePatterns.some((p: RegExp) => p.test(trimmedCommand))) {
return res.status(201).json({
id: 0,
userId,
hostId: parseInt(hostId, 10),
command: trimmedCommand,
executedAt: new Date().toISOString(),
});
}
const globalEnabled = await createCurrentSettingsRepository().getBoolean(
"command_history_enabled",
true,
);
if (!globalEnabled) {
return res.status(201).json({
id: 0,
userId,
hostId: parseInt(hostId, 10),
command: trimmedCommand,
executedAt: new Date().toISOString(),
});
}
const hostRecord =
await createCurrentHostResolutionRepository().findHostById(
parseInt(hostId, 10),
userId,
);
if (hostRecord?.enableCommandHistory === false) {
return res.status(201).json({
id: 0,
userId,
hostId: parseInt(hostId, 10),
command: trimmedCommand,
executedAt: new Date().toISOString(),
});
}
try {
const result = await createCurrentCommandHistoryRepository().create(
userId,
parseInt(hostId, 10),
trimmedCommand,
);
res.status(201).json(result);
} catch (err) {
authLogger.error("Failed to save command to history", err);
res.status(500).json({
error: getErrorMessage(err, "Failed to save command"),
});
}
},
);
/**
* @openapi
* /terminal/command_history/{hostId}:
* get:
* summary: Get command history
* description: Retrieves the command history for a specific host.
* tags:
* - Terminal
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of commands.
* 400:
* description: Invalid request parameters.
* 500:
* description: Failed to fetch history.
*/
router.get(
"/command_history/:hostId",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = Array.isArray(req.params.hostId)
? req.params.hostId[0]
: req.params.hostId;
const hostIdNum = parseInt(hostId, 10);
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
authLogger.warn("Invalid command history fetch request", {
userId,
hostId: hostIdNum,
});
return res.status(400).json({ error: "Invalid request parameters" });
}
try {
const uniqueCommands =
await createCurrentCommandHistoryRepository().listUniqueCommandsForHost(
userId,
hostIdNum,
);
res.json(uniqueCommands);
} catch (err) {
authLogger.error("Failed to fetch command history", err);
res.status(500).json({
error: getErrorMessage(err, "Failed to fetch history"),
});
}
},
);
/**
* @openapi
* /terminal/command_history/delete:
* post:
* summary: Delete a specific command from history
* description: Deletes a specific command from the history of a host.
* tags:
* - Terminal
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* command:
* type: string
* responses:
* 200:
* description: Command deleted successfully.
* 400:
* description: Missing required parameters.
* 500:
* description: Failed to delete command.
*/
router.post(
"/command_history/delete",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, command } = req.body;
if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) {
authLogger.warn("Invalid command delete request", {
operation: "command_history_delete",
userId,
hasHostId: !!hostId,
hasCommand: !!command,
});
return res.status(400).json({ error: "Missing required parameters" });
}
try {
const hostIdNum = parseInt(hostId, 10);
await createCurrentCommandHistoryRepository().deleteCommandForHost(
userId,
hostIdNum,
command.trim(),
);
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete command from history", err);
res.status(500).json({
error: getErrorMessage(err, "Failed to delete command"),
});
}
},
);
/**
* @openapi
* /terminal/command_history/{hostId}:
* delete:
* summary: Clear command history
* description: Clears the entire command history for a specific host.
* tags:
* - Terminal
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Command history cleared successfully.
* 400:
* description: Invalid request.
* 500:
* description: Failed to clear history.
*/
router.delete(
"/command_history/:hostId",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = Array.isArray(req.params.hostId)
? req.params.hostId[0]
: req.params.hostId;
const hostIdNum = parseInt(hostId, 10);
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
authLogger.warn("Invalid command history clear request");
return res.status(400).json({ error: "Invalid request" });
}
try {
await createCurrentCommandHistoryRepository().deleteByUserAndHost(
userId,
hostIdNum,
);
databaseLogger.info("Terminal history cleared", {
operation: "terminal_history_clear",
userId,
hostId: hostIdNum,
});
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to clear command history", err);
res.status(500).json({
error: getErrorMessage(err, "Failed to clear history"),
});
}
},
);
/**
* @openapi
* /terminal/session_settings:
* get:
* summary: Get session persistence settings
* description: Returns the session timeout and persistence enabled flag.
* tags:
* - Terminal
* responses:
* 200:
* description: Session settings.
* 500:
* description: Failed to fetch settings.
*/
router.get(
"/session_settings",
authenticateJWT,
async (_req: Request, res: Response) => {
try {
const settings = createCurrentSettingsRepository();
const timeoutValue = await settings.get(
"terminal_session_timeout_minutes",
);
const enabled = await settings.getBoolean(
"terminal_session_persistence_enabled",
true,
);
res.json({
timeoutMinutes: timeoutValue ? parseInt(timeoutValue, 10) : 30,
enabled,
});
} catch (err) {
authLogger.error("Failed to fetch session settings", err);
res.status(500).json({
error: getErrorMessage(err, "Failed to fetch settings"),
});
}
},
);
/**
* @openapi
* /terminal/session_settings:
* post:
* summary: Update session persistence settings
* description: Saves session timeout and persistence enabled flag.
* tags:
* - Terminal
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* timeoutMinutes:
* type: integer
* enabled:
* type: boolean
* responses:
* 200:
* description: Settings saved successfully.
* 400:
* description: Invalid parameters.
* 500:
* description: Failed to save settings.
*/
router.post(
"/session_settings",
authenticateJWT,
async (req: Request, res: Response) => {
const { timeoutMinutes, enabled } = req.body;
if (
timeoutMinutes !== undefined &&
(typeof timeoutMinutes !== "number" ||
timeoutMinutes < 1 ||
timeoutMinutes > 1440)
) {
return res
.status(400)
.json({ error: "timeoutMinutes must be between 1 and 1440" });
}
try {
const settings = createCurrentSettingsRepository();
if (timeoutMinutes !== undefined) {
await settings.set(
"terminal_session_timeout_minutes",
String(timeoutMinutes),
);
}
if (enabled !== undefined) {
await settings.set(
"terminal_session_persistence_enabled",
String(enabled),
);
}
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to save session settings", err);
res.status(500).json({
error: getErrorMessage(err, "Failed to save settings"),
});
}
},
);
export default router;
@@ -1,69 +0,0 @@
import type { RequestHandler, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import {
normalizeTouchInputSettings,
TOUCH_INPUT_SETTING_KEY,
validateTouchInputSettingsUpdate,
} from "../../../types/touch-input-settings.js";
import { authLogger } from "../../utils/logger.js";
import {
createCurrentSettingsRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
async function readSettings() {
const raw = await createCurrentSettingsRepository().get(
TOUCH_INPUT_SETTING_KEY,
);
if (!raw) return normalizeTouchInputSettings(null);
try {
return normalizeTouchInputSettings(JSON.parse(raw));
} catch {
return normalizeTouchInputSettings(null);
}
}
export function registerTouchInputSettingsRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
router.get("/touch-input-settings", authenticateJWT, async (_req, res) => {
try {
res.json(await readSettings());
} catch (error) {
authLogger.error("Failed to get touch input settings", error);
res.status(500).json({ error: "Failed to get touch input settings" });
}
});
router.patch("/touch-input-settings", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = userId
? await createCurrentUserRepository().findById(userId)
: null;
if (!user?.isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
const validationError = validateTouchInputSettingsUpdate(req.body);
if (validationError) {
return res.status(400).json({ error: validationError });
}
const current = await readSettings();
const merged = { ...current, ...req.body };
const mergedValidationError = validateTouchInputSettingsUpdate(merged);
if (mergedValidationError) {
return res.status(400).json({ error: mergedValidationError });
}
const next = normalizeTouchInputSettings(merged);
await createCurrentSettingsRepository().set(
TOUCH_INPUT_SETTING_KEY,
JSON.stringify(next),
);
res.json(next);
} catch (error) {
authLogger.error("Failed to update touch input settings", error);
res.status(500).json({ error: "Failed to update touch input settings" });
}
});
}
@@ -1,334 +0,0 @@
import type { Request, RequestHandler, Response, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { databaseLogger } from "../../utils/logger.js";
import { sessionManager } from "../../hosts/terminal/session-manager.js";
import { createCurrentSettingsRepository } from "../repositories/factory.js";
import {
MIN_IMAGE_MAX_BYTES,
TERMINAL_IMAGE_STORAGE_KEYS,
parseImageHostPath,
parseImageLocalDir,
parseTerminalImageStorageMode,
resolveTerminalImageStorageSettings,
type TerminalImageStorageSettings,
} from "./terminal-image-storage-settings.js";
import {
probeLocalImageVisibility,
selectImageStorageMode,
} from "./terminal-image-storage.js";
/**
* Admin-only terminal image storage settings.
*
* The public shape deliberately omits `localDir`: it is a backend-internal
* path and only the agent-visible `hostPath` may leave the server. No
* credentials or connection details are ever returned here.
*/
interface PublicImageStorageSettings {
mode: TerminalImageStorageSettings["mode"];
hostPath: string;
ttlMs: number;
maxCount: number;
maxBytes: number;
localMappingConfigured: boolean;
}
function toPublicSettings(
settings: TerminalImageStorageSettings,
): PublicImageStorageSettings {
return {
mode: settings.mode,
hostPath: settings.hostPath,
ttlMs: settings.ttlMs,
maxCount: settings.maxCount,
maxBytes: settings.maxBytes,
localMappingConfigured: settings.localMappingConfigured,
};
}
const PATCHABLE_FIELDS = [
"mode",
"localDir",
"hostPath",
"ttlMs",
"maxCount",
"maxBytes",
] as const;
type PatchableField = (typeof PATCHABLE_FIELDS)[number];
function invalidField(res: Response, field: string): void {
// Safe by construction: the field name is fixed, the rejected value and
// any backend path are never echoed back.
res.status(400).json({
error: `Invalid value for ${field}`,
code: "IMAGE_STORAGE_SETTINGS_INVALID",
field,
});
}
function parseIntegerField(value: unknown, min: number): number | null {
if (typeof value !== "number" || !Number.isInteger(value)) return null;
if (value < min) return null;
return value;
}
/**
* Validates a PATCH body and returns the settings-table writes it implies, or
* null after the response has already been failed with a 400.
*/
function buildImageStorageWrites(
body: unknown,
res: Response,
): Array<{ key: string; value: string }> | null {
if (typeof body !== "object" || body === null || Array.isArray(body)) {
res.status(400).json({
error: "Invalid request body",
code: "IMAGE_STORAGE_SETTINGS_INVALID",
});
return null;
}
for (const field of Object.keys(body)) {
if (!(PATCHABLE_FIELDS as readonly string[]).includes(field)) {
res.status(400).json({
error: `Unknown setting field: ${field}`,
code: "IMAGE_STORAGE_SETTINGS_UNKNOWN_FIELD",
field,
});
return null;
}
}
const input = body as Partial<Record<PatchableField, unknown>>;
const writes: Array<{ key: string; value: string }> = [];
if (input.mode !== undefined) {
const mode = parseTerminalImageStorageMode(input.mode);
if (mode === null) {
invalidField(res, "mode");
return null;
}
writes.push({ key: TERMINAL_IMAGE_STORAGE_KEYS.mode, value: mode });
}
if (input.localDir !== undefined) {
const localDir = parseImageLocalDir(input.localDir);
if (localDir === null) {
invalidField(res, "localDir");
return null;
}
writes.push({ key: TERMINAL_IMAGE_STORAGE_KEYS.localDir, value: localDir });
}
if (input.hostPath !== undefined) {
const hostPath = parseImageHostPath(input.hostPath);
if (hostPath === null) {
invalidField(res, "hostPath");
return null;
}
writes.push({
key: TERMINAL_IMAGE_STORAGE_KEYS.hostPath,
value: hostPath,
});
}
if (input.ttlMs !== undefined) {
const ttlMs = parseIntegerField(input.ttlMs, 0);
if (ttlMs === null) {
invalidField(res, "ttlMs");
return null;
}
writes.push({
key: TERMINAL_IMAGE_STORAGE_KEYS.ttlMs,
value: String(ttlMs),
});
}
if (input.maxCount !== undefined) {
const maxCount = parseIntegerField(input.maxCount, 1);
if (maxCount === null) {
invalidField(res, "maxCount");
return null;
}
writes.push({
key: TERMINAL_IMAGE_STORAGE_KEYS.maxCount,
value: String(maxCount),
});
}
if (input.maxBytes !== undefined) {
const maxBytes = parseIntegerField(input.maxBytes, MIN_IMAGE_MAX_BYTES);
if (maxBytes === null) {
invalidField(res, "maxBytes");
return null;
}
writes.push({
key: TERMINAL_IMAGE_STORAGE_KEYS.maxBytes,
value: String(maxBytes),
});
}
return writes;
}
export function registerUserImageStorageRoutes(
router: Router,
requireAdmin: RequestHandler,
): void {
/**
* @openapi
* /users/terminal-image-storage-settings:
* get:
* summary: Get terminal image storage settings (admin only)
* description: Returns the effective terminal image storage settings. The backend-internal localDir is never exposed; only the agent-visible hostPath is returned.
* tags:
* - Users
* responses:
* 200:
* description: Effective image storage settings.
* 401:
* description: Not authenticated.
* 403:
* description: Admin access required.
* 500:
* description: Failed to load settings.
*/
router.get(
"/terminal-image-storage-settings",
requireAdmin,
async (_req: Request, res: Response) => {
try {
const settings = await resolveTerminalImageStorageSettings(
createCurrentSettingsRepository(),
);
res.json(toPublicSettings(settings));
} catch (err) {
databaseLogger.error("Failed to load image storage settings", err);
res
.status(500)
.json({ error: "Failed to load image storage settings" });
}
},
);
/**
* @openapi
* /users/terminal-image-storage-settings:
* patch:
* summary: Update terminal image storage settings (admin only)
* description: Persists a partial update. Accepts only mode (auto, local, remote-sftp), localDir, hostPath, ttlMs, maxCount and maxBytes; invalid values are rejected with a 400.
* tags:
* - Users
* responses:
* 200:
* description: Updated effective settings.
* 400:
* description: Invalid or unknown setting field.
* 401:
* description: Not authenticated.
* 403:
* description: Admin access required.
* 500:
* description: Failed to save settings.
*/
router.patch(
"/terminal-image-storage-settings",
requireAdmin,
async (req: Request, res: Response) => {
const writes = buildImageStorageWrites(req.body, res);
if (writes === null) return;
try {
const settings = createCurrentSettingsRepository();
if (typeof settings.setMany !== "function") {
throw new Error("Atomic settings persistence is unavailable");
}
await settings.setMany(writes);
const resolved = await resolveTerminalImageStorageSettings(settings);
res.json(toPublicSettings(resolved));
} catch (err) {
databaseLogger.error("Failed to save image storage settings", err);
res
.status(500)
.json({ error: "Failed to save image storage settings" });
}
},
);
/**
* @openapi
* /users/terminal-image-storage-settings/test:
* post:
* summary: Test image storage visibility (admin only)
* description: Reports which storage mode an upload would take for one of the caller's already-connected terminal sessions. Uses the bounded local-mapping probe only; it never opens new connections.
* tags:
* - Users
* responses:
* 200:
* description: Visibility test result.
* 400:
* description: Missing terminal session instanceId.
* 401:
* description: Not authenticated.
* 403:
* description: Admin access required.
* 500:
* description: Failed to run the test.
*/
router.post(
"/terminal-image-storage-settings/test",
requireAdmin,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const instanceId = req.body?.instanceId;
if (typeof instanceId !== "string" || instanceId.trim().length === 0) {
return res.status(400).json({
error: "Missing terminal session",
code: "IMAGE_SESSION_MISSING",
});
}
try {
const settings = await resolveTerminalImageStorageSettings(
createCurrentSettingsRepository(),
);
const session = sessionManager
.getUserSessions(userId)
.find(
(candidate) =>
(candidate.attachedTabInstanceId ?? candidate.tabInstanceId) ===
instanceId && candidate.isConnected,
);
const remoteSftpAvailable = !!session?.sshConn;
let localHostVisible: boolean | null = null;
if (settings.localMappingConfigured && session?.sshConn) {
localHostVisible = await probeLocalImageVisibility(
session.sshConn,
settings,
).catch(() => false);
}
const selectedMode = selectImageStorageMode(settings, {
remoteSftpAvailable,
...(localHostVisible !== null ? { localHostVisible } : {}),
});
res.json({
mode: settings.mode,
connected: !!session,
remoteSftpAvailable,
localHostVisible,
selectedMode,
localMappingConfigured: settings.localMappingConfigured,
});
} catch (err) {
databaseLogger.error("Failed to test image storage visibility", err);
res
.status(500)
.json({ error: "Failed to test image storage visibility" });
}
},
);
}
@@ -338,110 +338,6 @@ export function registerUserSettingsRoutes(
}
});
/**
* @openapi
* /users/command-history-enabled:
* get:
* summary: Get command history enabled setting
* description: Returns whether command history recording is globally enabled.
* tags:
* - Users
* responses:
* 200:
* description: Command history enabled status.
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
*/
router.get("/command-history-enabled", authenticateJWT, async (_req, res) => {
try {
res.json({
enabled: await createCurrentSettingsRepository().getBoolean(
"command_history_enabled",
true,
),
});
} catch (err) {
authLogger.error("Failed to get command history enabled setting", err);
res
.status(500)
.json({ error: "Failed to get command history enabled setting" });
}
});
/**
* @openapi
* /users/command-history-enabled:
* patch:
* summary: Update command history enabled setting (admin only)
* description: Globally enables or disables command history recording for all hosts.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* responses:
* 200:
* description: Setting updated.
* 403:
* description: Not authorized.
* 500:
* description: Failed to update setting.
*/
router.patch(
"/command-history-enabled",
authenticateJWT,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const { enabled } = req.body;
if (typeof enabled !== "boolean") {
return res.status(400).json({ error: "enabled must be a boolean" });
}
await createCurrentSettingsRepository().set(
"command_history_enabled",
enabled ? "true" : "false",
);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "update_command_history_enabled",
resourceType: "setting",
details: JSON.stringify({ enabled }),
ipAddress,
userAgent,
success: true,
});
res.json({ enabled });
} catch (err) {
authLogger.error(
"Failed to update command history enabled setting",
err,
);
res
.status(500)
.json({ error: "Failed to update command history enabled setting" });
}
},
);
/**
* @openapi
* /users/analytics-enabled:
-4
View File
@@ -24,10 +24,8 @@ import {
validateLogoutToken,
} from "./user-oidc-utils.js";
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
import { registerUserImageStorageRoutes } from "./user-image-storage-routes.js";
import { registerBrandingRoutes } from "./branding-routes.js";
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
import { registerTouchInputSettingsRoutes } from "./touch-input-settings-routes.js";
import { registerAcmeSSLRoutes } from "./acme-ssl-routes.js";
import { registerUserTotpRoutes } from "./user-totp-routes.js";
import { registerUserWebAuthnRoutes } from "./user-webauthn-routes.js";
@@ -2126,11 +2124,9 @@ registerUserOidcAccountRoutes(router, {
});
registerUserSettingsRoutes(router, authenticateJWT);
registerTouchInputSettingsRoutes(router, authenticateJWT);
registerAcmeSSLRoutes(router, authenticateJWT);
registerUserApiKeyRoutes(router, requireAdmin);
registerUserImageStorageRoutes(router, requireAdmin);
registerBrandingRoutes(router, requireAdmin);
registerSSOProviderRoutes(router);