import type { PluginContext } from "@termix/plugin-sdk/backend"; import { promisifySftpMkdir, promisifySftpReaddir, promisifySftpStat, promisifySftpUnlink, promisifySftpRmdir, } from "./sftp-promisify.js"; const DEFAULT_DIR_MODE = 0o755; import type { SSHSession } from "./session.js"; /** What other plugins get from ctx.services.get("files.sftp", {userId}). */ export interface FilesService { list: ( hostId: number, path: string, ) => Promise< Array<{ name: string; type: "file" | "directory" | "link"; size?: number }> >; read: (hostId: number, path: string) => Promise; write: (hostId: number, path: string, content: string) => Promise; stat: ( hostId: number, path: string, ) => Promise<{ size: number; isDirectory: boolean; mtime: number }>; delete: (hostId: number, path: string) => Promise; mkdir: (hostId: number, path: string) => Promise; } /** * A short-lived SFTP connection for a service call, outside the browse * session map: a caller reaches this from any request the acting user makes, * not necessarily one that has an open file-manager tab. */ async function withServiceSftp( ctx: PluginContext, hostId: number, fn: (sftp: import("ssh2").SFTPWrapper) => Promise, ): Promise { return ctx.ssh.withConnection( hostId, { pool: "file-manager-service", purpose: "file-manager" }, (client) => new Promise((resolve, reject) => { client.sftp(async (err, sftp) => { if (err) return reject(err); try { resolve(await fn(sftp)); } catch (error) { reject(error); } }); }), ); } export function createFilesService( ctx: PluginContext, deps: { sshSessions: Record; verifySessionOwnership: (session: SSHSession, userId: string) => boolean; }, ): FilesService { void deps; return { async list(hostId, path) { return withServiceSftp(ctx, hostId, async (sftp) => { const entries = await promisifySftpReaddir(sftp, path); return entries .filter((e) => e.filename !== "." && e.filename !== "..") .map((e) => ({ name: e.filename, type: e.attrs.isDirectory() ? ("directory" as const) : e.attrs.isSymbolicLink() ? ("link" as const) : ("file" as const), size: e.attrs.isDirectory() ? undefined : e.attrs.size, })); }); }, async read(hostId, path) { return withServiceSftp( ctx, hostId, (sftp) => new Promise((resolve, reject) => { const chunks: Buffer[] = []; const stream = sftp.createReadStream(path); stream.on("data", (chunk: Buffer) => chunks.push(chunk)); stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")), ); stream.on("error", reject); }), ); }, async write(hostId, path, content) { await withServiceSftp( ctx, hostId, (sftp) => new Promise((resolve, reject) => { const stream = sftp.createWriteStream(path, { flags: "w" }); stream.on("close", () => resolve()); stream.on("error", reject); stream.end(content, "utf8"); }), ); }, async stat(hostId, path) { return withServiceSftp(ctx, hostId, async (sftp) => { const stats = await promisifySftpStat(sftp, path); return { size: stats.size, isDirectory: stats.isDirectory(), mtime: stats.mtime, }; }); }, async delete(hostId, path) { await withServiceSftp(ctx, hostId, async (sftp) => { const stats = await promisifySftpStat(sftp, path); if (stats.isDirectory()) { await promisifySftpRmdir(sftp, path); } else { await promisifySftpUnlink(sftp, path); } }); }, async mkdir(hostId, path) { await withServiceSftp(ctx, hostId, (sftp) => promisifySftpMkdir(sftp, path, DEFAULT_DIR_MODE), ); }, }; }