mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-01 08:05:23 +00:00
feat(file-manager): default to the remote login directory (#1512)
This commit is contained in:
1 parent
e69029fbf7
commit
fdb2f74259
6 files changed
+195
-9
No files matched your search
@@ -0,0 +1,35 @@
|
|||||||
|
import { execChannel, getSessionSftp, type SSHSession } from "./session.js";
|
||||||
|
|
||||||
|
/** The SFTP login directory also respects custom homes and chroot roots. */
|
||||||
|
export async function resolveHomeDirectory(
|
||||||
|
session: SSHSession,
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
const sftp = await getSessionSftp(session);
|
||||||
|
return await new Promise<string>((resolve, reject) => {
|
||||||
|
sftp.realpath(".", (error, path) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else if (!path) reject(new Error("Empty SFTP login directory"));
|
||||||
|
else resolve(path);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Keep the existing shell-only file browser usable without SFTP.
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
execChannel(session, "pwd -P", (error, stream) => {
|
||||||
|
if (error) return reject(error);
|
||||||
|
let output = "";
|
||||||
|
stream.on("data", (chunk: Buffer) => {
|
||||||
|
output += chunk.toString();
|
||||||
|
});
|
||||||
|
stream.on("error", reject);
|
||||||
|
stream.on("close", (code: number) => {
|
||||||
|
const path = output.replace(/[\r\n]+$/, "");
|
||||||
|
if (code !== 0 || !path.startsWith("/")) {
|
||||||
|
reject(new Error("Could not resolve the remote login directory"));
|
||||||
|
} else resolve(path);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { resolveHomeDirectory } from "./home-directory.js";
|
||||||
import { getErrorMessage } from "./error-message.js";
|
import { getErrorMessage } from "./error-message.js";
|
||||||
import type { Express } from "express";
|
import type { Express } from "express";
|
||||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||||
@@ -52,10 +53,10 @@ export function registerFileListingRoutes(
|
|||||||
* 500:
|
* 500:
|
||||||
* description: Failed to list files.
|
* description: Failed to list files.
|
||||||
*/
|
*/
|
||||||
app.get("/listFiles", (req, res) => {
|
app.get("/listFiles", async (req, res) => {
|
||||||
const sessionId = req.query.sessionId as string;
|
const sessionId = req.query.sessionId as string;
|
||||||
const sshConn = sshSessions[sessionId];
|
const sshConn = sshSessions[sessionId];
|
||||||
const sshPath = (req.query.path as string) || "/";
|
let sshPath = (req.query.path as string) || ".";
|
||||||
const userId = ctx.currentActor()!;
|
const userId = ctx.currentActor()!;
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
@@ -70,6 +71,16 @@ export function registerFileListingRoutes(
|
|||||||
return res.status(403).json({ error: "Session access denied" });
|
return res.status(403).json({ error: "Session access denied" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sshPath === ".") {
|
||||||
|
try {
|
||||||
|
sshPath = await resolveHomeDirectory(sshConn);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({
|
||||||
|
error: getErrorMessage(error, "Could not resolve home directory"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Drop concurrent requests for the same session+path — each would open
|
// Drop concurrent requests for the same session+path — each would open
|
||||||
// a new SSH channel and can exceed the server's per-connection channel limit.
|
// a new SSH channel and can exceed the server's per-connection channel limit.
|
||||||
const listKey = `${sessionId}:${sshPath}`;
|
const listKey = `${sessionId}:${sshPath}`;
|
||||||
|
|||||||
@@ -155,19 +155,19 @@ function FileManagerContent({
|
|||||||
const [currentPath, setCurrentPath] = useState(
|
const [currentPath, setCurrentPath] = useState(
|
||||||
initialPath ||
|
initialPath ||
|
||||||
fileManagerHostSetting(initialHost, "defaultPath", "") ||
|
fileManagerHostSetting(initialHost, "defaultPath", "") ||
|
||||||
"/",
|
".",
|
||||||
);
|
);
|
||||||
const currentPathRef = useRef(currentPath);
|
const currentPathRef = useRef(currentPath);
|
||||||
currentPathRef.current = currentPath;
|
currentPathRef.current = currentPath;
|
||||||
const lastSuccessfulPathRef = useRef(
|
const lastSuccessfulPathRef = useRef(
|
||||||
initialPath ||
|
initialPath ||
|
||||||
fileManagerHostSetting(initialHost, "defaultPath", "") ||
|
fileManagerHostSetting(initialHost, "defaultPath", "") ||
|
||||||
"/",
|
".",
|
||||||
);
|
);
|
||||||
const [navHistory, setNavHistory] = useState<string[]>([
|
const [navHistory, setNavHistory] = useState<string[]>([
|
||||||
initialPath ||
|
initialPath ||
|
||||||
fileManagerHostSetting(initialHost, "defaultPath", "") ||
|
fileManagerHostSetting(initialHost, "defaultPath", "") ||
|
||||||
"/",
|
".",
|
||||||
]);
|
]);
|
||||||
const [navIndex, setNavIndex] = useState(0);
|
const [navIndex, setNavIndex] = useState(0);
|
||||||
const [files, setFiles] = useState<FileItem[]>([]);
|
const [files, setFiles] = useState<FileItem[]>([]);
|
||||||
@@ -689,7 +689,12 @@ function FileManagerContent({
|
|||||||
? response
|
? response
|
||||||
: response?.files || [];
|
: response?.files || [];
|
||||||
setFiles(files);
|
setFiles(files);
|
||||||
lastSuccessfulPathRef.current = currentPath;
|
const listedPath = response.path || currentPath;
|
||||||
|
setCurrentPath(listedPath);
|
||||||
|
setNavHistory((history) =>
|
||||||
|
history.map((path) => (path === "." ? listedPath : path)),
|
||||||
|
);
|
||||||
|
lastSuccessfulPathRef.current = listedPath;
|
||||||
clearSelection();
|
clearSelection();
|
||||||
initialLoadDoneRef.current = true;
|
initialLoadDoneRef.current = true;
|
||||||
|
|
||||||
@@ -799,7 +804,14 @@ function FileManagerContent({
|
|||||||
: response?.files || [];
|
: response?.files || [];
|
||||||
|
|
||||||
setFiles(files);
|
setFiles(files);
|
||||||
lastSuccessfulPathRef.current = resolvedPath;
|
const listedPath = response.path || resolvedPath;
|
||||||
|
if (listedPath !== resolvedPath) {
|
||||||
|
setCurrentPath(listedPath);
|
||||||
|
setNavHistory((history) =>
|
||||||
|
history.map((entry) => (entry === path ? listedPath : entry)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lastSuccessfulPathRef.current = listedPath;
|
||||||
clearSelection();
|
clearSelection();
|
||||||
return true;
|
return true;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@@ -374,11 +374,11 @@ function RemotePane({
|
|||||||
...current,
|
...current,
|
||||||
connectionState: "ready",
|
connectionState: "ready",
|
||||||
sessionId: result.sessionId || String(host.id),
|
sessionId: result.sessionId || String(host.id),
|
||||||
path: fileManagerHostSetting(host, "defaultPath", "") || "/",
|
path: fileManagerHostSetting(host, "defaultPath", "") || ".",
|
||||||
}));
|
}));
|
||||||
await loadRemotePath(
|
await loadRemotePath(
|
||||||
result.sessionId || String(host.id),
|
result.sessionId || String(host.id),
|
||||||
fileManagerHostSetting(host, "defaultPath", "") || "/",
|
fileManagerHostSetting(host, "defaultPath", "") || ".",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[loadRemotePath, setPane],
|
[loadRemotePath, setPane],
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import type { Express, Request, Response } from "express";
|
||||||
|
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { registerFileListingRoutes } from "../../src/backend/list-routes.js";
|
||||||
|
import {
|
||||||
|
execChannel,
|
||||||
|
getSessionSftp,
|
||||||
|
type SSHSession,
|
||||||
|
} from "../../src/backend/session.js";
|
||||||
|
import { resolveHomeDirectory } from "../../src/backend/home-directory.js";
|
||||||
|
|
||||||
|
vi.mock("../../src/backend/session.js", () => ({
|
||||||
|
getSessionSftp: vi.fn(),
|
||||||
|
execChannel: vi.fn(),
|
||||||
|
}));
|
||||||
|
beforeEach(() => vi.resetAllMocks());
|
||||||
|
|
||||||
|
function setup(home: string, owned = true) {
|
||||||
|
const sftp = {
|
||||||
|
realpath: vi.fn((_path, done) => done(null, home)),
|
||||||
|
readdir: vi.fn((_path, done) => done(null, [])),
|
||||||
|
};
|
||||||
|
vi.mocked(getSessionSftp).mockResolvedValue(sftp as never);
|
||||||
|
let handler!: (req: Request, res: Response) => Promise<unknown>;
|
||||||
|
registerFileListingRoutes(
|
||||||
|
{
|
||||||
|
get: (_path, cb) => {
|
||||||
|
handler = cb;
|
||||||
|
},
|
||||||
|
} as Express,
|
||||||
|
{
|
||||||
|
ctx: {
|
||||||
|
currentActor: () => "user",
|
||||||
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||||
|
} as unknown as PluginContext,
|
||||||
|
sshSessions: {
|
||||||
|
s: { isConnected: true, activeOperations: 0 } as SSHSession,
|
||||||
|
},
|
||||||
|
activeListRequests: {},
|
||||||
|
verifySessionOwnership: () => owned,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const res = { on: vi.fn(), status: vi.fn().mockReturnThis(), json: vi.fn() };
|
||||||
|
return {
|
||||||
|
sftp,
|
||||||
|
res,
|
||||||
|
list: (path?: string) =>
|
||||||
|
handler(
|
||||||
|
{ query: { sessionId: "s", path } } as unknown as Request,
|
||||||
|
res as unknown as Response,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("remote login directory", () => {
|
||||||
|
it.each(["/root", "/srv/users/alice", "/"])(
|
||||||
|
"lists the actual SFTP directory %s",
|
||||||
|
async (home) => {
|
||||||
|
const { sftp, res, list } = setup(home);
|
||||||
|
await list(".");
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ files: [], path: home }),
|
||||||
|
);
|
||||||
|
expect(sftp.realpath).toHaveBeenCalledWith(".", expect.any(Function));
|
||||||
|
expect(sftp.readdir).toHaveBeenCalledWith(home, expect.any(Function));
|
||||||
|
expect(execChannel).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
it("preserves an explicit root instead of replacing it with home", async () => {
|
||||||
|
const { sftp, res, list } = setup("/home/alice");
|
||||||
|
await list("/");
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ files: [], path: "/" }),
|
||||||
|
);
|
||||||
|
expect(sftp.realpath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("checks ownership before discovering the home directory", async () => {
|
||||||
|
const { res, list } = setup("/home/alice", false);
|
||||||
|
await list(".");
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(getSessionSftp).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("falls back to the shell login directory when SFTP is unavailable", async () => {
|
||||||
|
vi.mocked(getSessionSftp).mockRejectedValue(new Error("SFTP unavailable"));
|
||||||
|
vi.mocked(execChannel).mockImplementation((_session, command, done) => {
|
||||||
|
expect(command).toBe("pwd -P");
|
||||||
|
const stream = new EventEmitter();
|
||||||
|
done(undefined as never, stream as never);
|
||||||
|
queueMicrotask(() => {
|
||||||
|
stream.emit("data", Buffer.from("/srv/alice\n"));
|
||||||
|
stream.emit("close", 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await expect(resolveHomeDirectory({} as SSHSession)).resolves.toBe(
|
||||||
|
"/srv/alice",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -124,6 +124,35 @@ async function selectHosts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("SftpTransferTab", () => {
|
describe("SftpTransferTab", () => {
|
||||||
|
it("opens the server login directory when no default path is configured", async () => {
|
||||||
|
const hosts = await api.getSSHHosts();
|
||||||
|
api.getSSHHosts.mockResolvedValue(
|
||||||
|
hosts.map((host: object) => ({ ...host, pluginSettings: {} })),
|
||||||
|
);
|
||||||
|
api.browseSSHDirectory.mockImplementation(
|
||||||
|
async (sessionId: string, path: string) => ({
|
||||||
|
status: "ok",
|
||||||
|
path: path === "." ? `/srv/home-${sessionId}` : path,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: `remote-${sessionId}.txt`,
|
||||||
|
type: "file",
|
||||||
|
path: `/srv/home-${sessionId}/remote-${sessionId}.txt`,
|
||||||
|
size: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
render(<SftpTransferTab />);
|
||||||
|
await selectHosts();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(api.browseSSHDirectory).toHaveBeenCalledWith("1", ".");
|
||||||
|
expect(api.browseSSHDirectory).toHaveBeenCalledWith("2", ".");
|
||||||
|
});
|
||||||
|
expect(await screen.findByDisplayValue("/srv/home-1")).toBeTruthy();
|
||||||
|
expect(await screen.findByDisplayValue("/srv/home-2")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("loads file manager-enabled hosts into both host pickers", async () => {
|
it("loads file manager-enabled hosts into both host pickers", async () => {
|
||||||
render(<SftpTransferTab />);
|
render(<SftpTransferTab />);
|
||||||
const selects = await screen.findAllByRole("combobox");
|
const selects = await screen.findAllByRole("combobox");
|
||||||
|
|||||||
Reference in new issue
Block a user