mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
feat: move the terminal fully into the ssh-terminal plugin (B9)
This commit is contained in:
1 parent
535126fd1b
commit
79d77678c9
309 files changed
+41789
-14958
No files matched your search
@@ -0,0 +1,262 @@
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
|
||||
export interface SettingSource {
|
||||
get(key: string): Promise<string | null>;
|
||||
/** Told about a stored or environment value that was ignored. */
|
||||
warn?: (message: string) => void;
|
||||
}
|
||||
|
||||
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> {
|
||||
const warnInvalid = (key: string, source: string) =>
|
||||
settings.warn?.(
|
||||
`Ignoring invalid terminal image storage setting ${key} from ${source}`,
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
exceedsImageStorageLimit,
|
||||
isExpiredImage,
|
||||
isImageFilename,
|
||||
} from "./image-utils.js";
|
||||
import type { TerminalImageStorageSettings } from "./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",
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// 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
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user