fix: use SFTP readdir for file listing to support non-Linux systems (#495)
The file manager now uses SFTP readdir as the primary method for listing files, with ls -la as a fallback. This enables compatibility with MikroTik RouterOS and other non-Linux systems that don't have standard shell commands. Fixes #317
This commit was merged in pull request #495.
This commit is contained in:
@@ -44,6 +44,58 @@ function isExecutableFile(permissions: string, fileName: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function modeToPermissions(mode: number): string {
|
||||||
|
const S_IFDIR = 0o040000;
|
||||||
|
const S_IFLNK = 0o120000;
|
||||||
|
const S_IFMT = 0o170000;
|
||||||
|
|
||||||
|
const type = mode & S_IFMT;
|
||||||
|
const prefix = type === S_IFDIR ? "d" : type === S_IFLNK ? "l" : "-";
|
||||||
|
|
||||||
|
const perms = [
|
||||||
|
mode & 0o400 ? "r" : "-",
|
||||||
|
mode & 0o200 ? "w" : "-",
|
||||||
|
mode & 0o100 ? "x" : "-",
|
||||||
|
mode & 0o040 ? "r" : "-",
|
||||||
|
mode & 0o020 ? "w" : "-",
|
||||||
|
mode & 0o010 ? "x" : "-",
|
||||||
|
mode & 0o004 ? "r" : "-",
|
||||||
|
mode & 0o002 ? "w" : "-",
|
||||||
|
mode & 0o001 ? "x" : "-",
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
return prefix + perms;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMtime(mtime: number): string {
|
||||||
|
const date = new Date(mtime * 1000);
|
||||||
|
const months = [
|
||||||
|
"Jan",
|
||||||
|
"Feb",
|
||||||
|
"Mar",
|
||||||
|
"Apr",
|
||||||
|
"May",
|
||||||
|
"Jun",
|
||||||
|
"Jul",
|
||||||
|
"Aug",
|
||||||
|
"Sep",
|
||||||
|
"Oct",
|
||||||
|
"Nov",
|
||||||
|
"Dec",
|
||||||
|
];
|
||||||
|
const month = months[date.getMonth()];
|
||||||
|
const day = date.getDate().toString().padStart(2, " ");
|
||||||
|
const now = new Date();
|
||||||
|
const sixMonthsAgo = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
if (date > sixMonthsAgo) {
|
||||||
|
const hours = date.getHours().toString().padStart(2, "0");
|
||||||
|
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||||
|
return `${month} ${day} ${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
return `${month} ${day} ${date.getFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
@@ -1152,6 +1204,100 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
|||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
sshConn.activeOperations++;
|
sshConn.activeOperations++;
|
||||||
|
|
||||||
|
const trySFTP = () => {
|
||||||
|
try {
|
||||||
|
sshConn.client.sftp((err, sftp) => {
|
||||||
|
if (err) {
|
||||||
|
fileLogger.warn(
|
||||||
|
`SFTP failed for listFiles, trying fallback: ${err.message}`,
|
||||||
|
);
|
||||||
|
tryFallbackMethod();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sftp.readdir(sshPath, (readdirErr, list) => {
|
||||||
|
if (readdirErr) {
|
||||||
|
fileLogger.warn(
|
||||||
|
`SFTP readdir failed, trying fallback: ${readdirErr.message}`,
|
||||||
|
);
|
||||||
|
tryFallbackMethod();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const symlinks: Array<{ index: number; path: string }> = [];
|
||||||
|
const files: Array<{
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
size: number | undefined;
|
||||||
|
modified: string;
|
||||||
|
permissions: string;
|
||||||
|
owner: string;
|
||||||
|
group: string;
|
||||||
|
linkTarget: string | undefined;
|
||||||
|
path: string;
|
||||||
|
executable: boolean;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const entry of list) {
|
||||||
|
if (entry.filename === "." || entry.filename === "..") continue;
|
||||||
|
|
||||||
|
const attrs = entry.attrs;
|
||||||
|
const permissions = modeToPermissions(attrs.mode);
|
||||||
|
const isDirectory = attrs.isDirectory();
|
||||||
|
const isLink = attrs.isSymbolicLink();
|
||||||
|
|
||||||
|
const fileEntry = {
|
||||||
|
name: entry.filename,
|
||||||
|
type: isDirectory ? "directory" : isLink ? "link" : "file",
|
||||||
|
size: isDirectory ? undefined : attrs.size,
|
||||||
|
modified: formatMtime(attrs.mtime),
|
||||||
|
permissions,
|
||||||
|
owner: String(attrs.uid),
|
||||||
|
group: String(attrs.gid),
|
||||||
|
linkTarget: undefined as string | undefined,
|
||||||
|
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${entry.filename}`,
|
||||||
|
executable:
|
||||||
|
!isDirectory && !isLink
|
||||||
|
? isExecutableFile(permissions, entry.filename)
|
||||||
|
: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLink) {
|
||||||
|
symlinks.push({ index: files.length, path: fileEntry.path });
|
||||||
|
}
|
||||||
|
|
||||||
|
files.push(fileEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (symlinks.length === 0) {
|
||||||
|
sshConn.activeOperations--;
|
||||||
|
return res.json({ files, path: sshPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved = 0;
|
||||||
|
for (const link of symlinks) {
|
||||||
|
sftp.readlink(link.path, (linkErr, target) => {
|
||||||
|
resolved++;
|
||||||
|
if (!linkErr && target) {
|
||||||
|
files[link.index].linkTarget = target;
|
||||||
|
}
|
||||||
|
if (resolved === symlinks.length) {
|
||||||
|
sshConn.activeOperations--;
|
||||||
|
res.json({ files, path: sshPath });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (sftpErr: unknown) {
|
||||||
|
const errMsg =
|
||||||
|
sftpErr instanceof Error ? sftpErr.message : "Unknown error";
|
||||||
|
fileLogger.warn(`SFTP connection error, trying fallback: ${errMsg}`);
|
||||||
|
tryFallbackMethod();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tryFallbackMethod = () => {
|
||||||
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
||||||
sshConn.client.exec(`command ls -la '${escapedPath}'`, (err, stream) => {
|
sshConn.client.exec(`command ls -la '${escapedPath}'`, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -1177,7 +1323,9 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
|||||||
fileLogger.error(
|
fileLogger.error(
|
||||||
`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||||
);
|
);
|
||||||
return res.status(500).json({ error: `Command failed: ${errorData}` });
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: `Command failed: ${errorData}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = data.split("\n").filter((line) => line.trim());
|
const lines = data.split("\n").filter((line) => line.trim());
|
||||||
@@ -1234,6 +1382,9 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
|||||||
res.json({ files, path: sshPath });
|
res.json({ files, path: sshPath });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
trySFTP();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {
|
app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user