fix: add sudo support for listFiles and improve permission error handling (#512)
* feat: add sudo support for file manager operations * fix: add sudo support for listFiles and improve permission error handling --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
This commit was merged in pull request #512.
This commit is contained in:
@@ -1512,8 +1512,34 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on("close", (code) => {
|
stream.on("close", (code) => {
|
||||||
sshConn.activeOperations--;
|
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
|
const isPermissionDenied =
|
||||||
|
errorData.toLowerCase().includes("permission denied") ||
|
||||||
|
errorData.toLowerCase().includes("access denied");
|
||||||
|
|
||||||
|
if (isPermissionDenied) {
|
||||||
|
// If we have sudo password, retry with sudo
|
||||||
|
if (sshConn.sudoPassword) {
|
||||||
|
fileLogger.info(
|
||||||
|
`Permission denied for listFiles, retrying with sudo: ${sshPath}`,
|
||||||
|
);
|
||||||
|
tryWithSudo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No sudo password - tell frontend to request one
|
||||||
|
sshConn.activeOperations--;
|
||||||
|
fileLogger.warn(
|
||||||
|
`Permission denied for listFiles, sudo required: ${sshPath}`,
|
||||||
|
);
|
||||||
|
return res.status(403).json({
|
||||||
|
error: `Permission denied: Cannot access ${sshPath}`,
|
||||||
|
needsSudo: true,
|
||||||
|
path: sshPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sshConn.activeOperations--;
|
||||||
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()}`,
|
||||||
);
|
);
|
||||||
@@ -1521,6 +1547,7 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
|||||||
.status(500)
|
.status(500)
|
||||||
.json({ error: `Command failed: ${errorData}` });
|
.json({ error: `Command failed: ${errorData}` });
|
||||||
}
|
}
|
||||||
|
sshConn.activeOperations--;
|
||||||
|
|
||||||
const lines = data.split("\n").filter((line) => line.trim());
|
const lines = data.split("\n").filter((line) => line.trim());
|
||||||
const files = [];
|
const files = [];
|
||||||
@@ -1578,6 +1605,127 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tryWithSudo = () => {
|
||||||
|
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
||||||
|
const escapedPassword = sshConn.sudoPassword!.replace(/'/g, "'\"'\"'");
|
||||||
|
const sudoCommand = `echo '${escapedPassword}' | sudo -S ls -la '${escapedPath}' 2>&1`;
|
||||||
|
|
||||||
|
sshConn.client.exec(sudoCommand, (err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
sshConn.activeOperations--;
|
||||||
|
fileLogger.error("SSH sudo listFiles error:", err);
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = "";
|
||||||
|
let errorData = "";
|
||||||
|
|
||||||
|
stream.on("data", (chunk: Buffer) => {
|
||||||
|
data += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.stderr.on("data", (chunk: Buffer) => {
|
||||||
|
errorData += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("close", (code) => {
|
||||||
|
sshConn.activeOperations--;
|
||||||
|
|
||||||
|
// Filter out sudo password prompt from output
|
||||||
|
data = data.replace(/\[sudo\] password for .+?:\s*/g, "");
|
||||||
|
|
||||||
|
// Check for sudo authentication failure
|
||||||
|
if (
|
||||||
|
data.toLowerCase().includes("sorry, try again") ||
|
||||||
|
data.toLowerCase().includes("incorrect password") ||
|
||||||
|
errorData.toLowerCase().includes("sorry, try again")
|
||||||
|
) {
|
||||||
|
// Clear invalid sudo password
|
||||||
|
sshConn.sudoPassword = undefined;
|
||||||
|
return res.status(403).json({
|
||||||
|
error: "Sudo authentication failed. Please try again.",
|
||||||
|
needsSudo: true,
|
||||||
|
sudoFailed: true,
|
||||||
|
path: sshPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code !== 0 && !data.trim()) {
|
||||||
|
fileLogger.error(
|
||||||
|
`SSH sudo listFiles failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||||
|
);
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: `Sudo command failed: ${errorData || data}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = data.split("\n").filter((line) => line.trim());
|
||||||
|
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 (let i = 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const parts = line.split(/\s+/);
|
||||||
|
if (parts.length >= 9) {
|
||||||
|
const permissions = parts[0];
|
||||||
|
const owner = parts[2];
|
||||||
|
const group = parts[3];
|
||||||
|
const size = parseInt(parts[4], 10);
|
||||||
|
|
||||||
|
let dateStr = "";
|
||||||
|
const nameStartIndex = 8;
|
||||||
|
|
||||||
|
if (parts[5] && parts[6] && parts[7]) {
|
||||||
|
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = parts.slice(nameStartIndex).join(" ");
|
||||||
|
const isDirectory = permissions.startsWith("d");
|
||||||
|
const isLink = permissions.startsWith("l");
|
||||||
|
|
||||||
|
if (name === "." || name === "..") continue;
|
||||||
|
|
||||||
|
let actualName = name;
|
||||||
|
let linkTarget = undefined;
|
||||||
|
if (isLink && name.includes(" -> ")) {
|
||||||
|
const linkParts = name.split(" -> ");
|
||||||
|
actualName = linkParts[0];
|
||||||
|
linkTarget = linkParts[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
name: actualName,
|
||||||
|
type: isDirectory ? "directory" : isLink ? "link" : "file",
|
||||||
|
size: isDirectory ? undefined : size,
|
||||||
|
modified: dateStr,
|
||||||
|
permissions,
|
||||||
|
owner,
|
||||||
|
group,
|
||||||
|
linkTarget,
|
||||||
|
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
|
||||||
|
executable:
|
||||||
|
!isDirectory && !isLink
|
||||||
|
? isExecutableFile(permissions, actualName)
|
||||||
|
: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ files, path: sshPath });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
trySFTP();
|
trySFTP();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1371,6 +1371,10 @@
|
|||||||
"downloadSuccess": "File downloaded successfully",
|
"downloadSuccess": "File downloaded successfully",
|
||||||
"downloadFailed": "File download failed",
|
"downloadFailed": "File download failed",
|
||||||
"permissionDenied": "Permission denied",
|
"permissionDenied": "Permission denied",
|
||||||
|
"sudoAuthFailed": "Sudo authentication failed. Please check your password.",
|
||||||
|
"accessDirectory": "access this directory",
|
||||||
|
"deleteOperation": "delete these items",
|
||||||
|
"sudoOperationFailed": "Sudo operation failed",
|
||||||
"checkDockerLogs": "Check the Docker logs for detailed error information",
|
"checkDockerLogs": "Check the Docker logs for detailed error information",
|
||||||
"internalServerError": "Internal server error occurred",
|
"internalServerError": "Internal server error occurred",
|
||||||
"serverError": "Server Error",
|
"serverError": "Server Error",
|
||||||
|
|||||||
@@ -1370,7 +1370,11 @@
|
|||||||
"uploadFailed": "文件上傳失敗",
|
"uploadFailed": "文件上傳失敗",
|
||||||
"downloadSuccess": "文件下載成功",
|
"downloadSuccess": "文件下載成功",
|
||||||
"downloadFailed": "文件下載失敗",
|
"downloadFailed": "文件下載失敗",
|
||||||
"permissionDenied": "沒有權限",
|
"permissionDenied": "没有权限",
|
||||||
|
"sudoAuthFailed": "Sudo 认证失败,请检查密码",
|
||||||
|
"accessDirectory": "访问此目录",
|
||||||
|
"deleteOperation": "删除这些项目",
|
||||||
|
"sudoOperationFailed": "Sudo 操作失败",
|
||||||
"checkDockerLogs": "查看 Docker 日誌以取得詳細的錯誤訊息",
|
"checkDockerLogs": "查看 Docker 日誌以取得詳細的錯誤訊息",
|
||||||
"internalServerError": "發生內部伺服器錯誤",
|
"internalServerError": "發生內部伺服器錯誤",
|
||||||
"serverError": "伺服器錯誤",
|
"serverError": "伺服器錯誤",
|
||||||
|
|||||||
@@ -166,10 +166,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [sudoDialogOpen, setSudoDialogOpen] = useState(false);
|
const [sudoDialogOpen, setSudoDialogOpen] = useState(false);
|
||||||
const [pendingSudoOperation, setPendingSudoOperation] = useState<{
|
const [pendingSudoOperation, setPendingSudoOperation] = useState<
|
||||||
type: "delete";
|
| { type: "delete"; files: FileItem[] }
|
||||||
files: FileItem[];
|
| { type: "navigate"; path: string }
|
||||||
} | null>(null);
|
| null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const { selectedFiles, clearSelection, setSelection } = useFileSelection();
|
const { selectedFiles, clearSelection, setSelection } = useFileSelection();
|
||||||
|
|
||||||
@@ -400,14 +401,14 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadDirectory = useCallback(
|
const loadDirectory = useCallback(
|
||||||
async (path: string) => {
|
async (path: string): Promise<boolean> => {
|
||||||
if (!sshSessionId) {
|
if (!sshSessionId) {
|
||||||
console.error("Cannot load directory: no SSH session ID");
|
console.error("Cannot load directory: no SSH session ID");
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading && currentLoadingPathRef.current !== path) {
|
if (isLoading && currentLoadingPathRef.current !== path) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentLoadingPathRef.current = path;
|
currentLoadingPathRef.current = path;
|
||||||
@@ -419,7 +420,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
const response = await listSSHFiles(sshSessionId, path);
|
const response = await listSSHFiles(sshSessionId, path);
|
||||||
|
|
||||||
if (currentLoadingPathRef.current !== path) {
|
if (currentLoadingPathRef.current !== path) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = Array.isArray(response)
|
const files = Array.isArray(response)
|
||||||
@@ -428,29 +429,55 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
|
|
||||||
setFiles(files);
|
setFiles(files);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
|
return true;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (currentLoadingPathRef.current === path) {
|
if (currentLoadingPathRef.current === path) {
|
||||||
|
const axiosError = error as {
|
||||||
|
response?: {
|
||||||
|
status?: number;
|
||||||
|
data?: { needsSudo?: boolean; error?: string; sudoFailed?: boolean };
|
||||||
|
};
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if this is a permission denied error that needs sudo
|
||||||
|
if (axiosError.response?.data?.needsSudo) {
|
||||||
|
console.log("Permission denied, sudo required for:", path);
|
||||||
|
|
||||||
|
// Only show dialog if not already in a sudo retry flow
|
||||||
|
if (!sudoDialogOpen) {
|
||||||
|
setPendingSudoOperation({ type: "navigate", path });
|
||||||
|
setSudoDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (axiosError.response.data.sudoFailed) {
|
||||||
|
toast.error(t("fileManager.sudoAuthFailed"));
|
||||||
|
} else {
|
||||||
|
toast.error(t("fileManager.permissionDenied"));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
console.error("Failed to load directory:", error);
|
console.error("Failed to load directory:", error);
|
||||||
|
|
||||||
|
// Show more specific error message
|
||||||
|
const errorMessage =
|
||||||
|
axiosError.response?.data?.error || axiosError.message || String(error);
|
||||||
|
|
||||||
if (initialLoadDoneRef.current) {
|
if (initialLoadDoneRef.current) {
|
||||||
toast.error(
|
toast.error(t("fileManager.failedToLoadDirectory") + ": " + errorMessage);
|
||||||
t("fileManager.failedToLoadDirectory") +
|
|
||||||
": " +
|
|
||||||
(error.message || error),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
error.message?.includes("connection") ||
|
errorMessage?.includes("connection") ||
|
||||||
error.message?.includes("SSH")
|
errorMessage?.includes("SSH")
|
||||||
) {
|
) {
|
||||||
handleCloseWithError(
|
handleCloseWithError(
|
||||||
t("fileManager.failedToLoadDirectory") +
|
t("fileManager.failedToLoadDirectory") + ": " + errorMessage,
|
||||||
": " +
|
|
||||||
(error.message || error),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
if (currentLoadingPathRef.current === path) {
|
if (currentLoadingPathRef.current === path) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -458,7 +485,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[sshSessionId, isLoading, clearSelection, t],
|
[sshSessionId, isLoading, clearSelection, t, sudoDialogOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const debouncedLoadDirectory = useCallback(
|
const debouncedLoadDirectory = useCallback(
|
||||||
@@ -778,14 +805,35 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
);
|
);
|
||||||
handleRefreshDirectory();
|
handleRefreshDirectory();
|
||||||
clearSelection();
|
clearSelection();
|
||||||
|
} else if (pendingSudoOperation.type === "navigate") {
|
||||||
|
// Retry navigation with sudo password now set
|
||||||
|
const success = await loadDirectory(pendingSudoOperation.path);
|
||||||
|
if (success) {
|
||||||
|
setCurrentPath(pendingSudoOperation.path);
|
||||||
|
setPendingSudoOperation(null);
|
||||||
|
}
|
||||||
|
// If failed, loadDirectory already handles showing the error/dialog
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setPendingSudoOperation(null);
|
setPendingSudoOperation(null);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const axiosError = error as { message?: string };
|
const axiosError = error as {
|
||||||
|
response?: { data?: { needsSudo?: boolean; sudoFailed?: boolean } };
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// If sudo auth failed, keep dialog open for retry
|
||||||
|
if (axiosError.response?.data?.sudoFailed) {
|
||||||
|
toast.error(t("fileManager.sudoAuthFailed"));
|
||||||
|
setSudoDialogOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
toast.error(
|
toast.error(
|
||||||
axiosError.message || t("fileManager.sudoOperationFailed"),
|
axiosError.message || t("fileManager.sudoOperationFailed"),
|
||||||
);
|
);
|
||||||
|
setPendingSudoOperation(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2236,7 +2284,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
operation={
|
operation={
|
||||||
pendingSudoOperation?.type === "delete"
|
pendingSudoOperation?.type === "delete"
|
||||||
? t("fileManager.deleteOperation")
|
? t("fileManager.deleteOperation")
|
||||||
: undefined
|
: pendingSudoOperation?.type === "navigate"
|
||||||
|
? t("fileManager.accessDirectory")
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user