mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)
This commit is contained in:
1 parent
f896e121e2
commit
e3eada899c
3 files changed
+87
-38
No files matched your search
@@ -31,6 +31,21 @@ const parseByteOffset = (value: string | undefined): number | null => {
|
||||
return parsed;
|
||||
};
|
||||
|
||||
/**
|
||||
* The editor sends text. Binary callers say so with encoding "base64";
|
||||
* guessing breaks text that happens to be valid base64.
|
||||
*/
|
||||
export function decodeWriteContent(
|
||||
content: unknown,
|
||||
encoding?: unknown,
|
||||
): Buffer {
|
||||
if (Buffer.isBuffer(content)) return content;
|
||||
if (typeof content === "string") {
|
||||
return Buffer.from(content, encoding === "base64" ? "base64" : "utf8");
|
||||
}
|
||||
return Buffer.from(content as ArrayLike<number>);
|
||||
}
|
||||
|
||||
export function registerFileContentRoutes(
|
||||
app: Express,
|
||||
{ ctx, sshSessions, verifySessionOwnership }: FileContentRoutesDeps,
|
||||
@@ -435,16 +450,22 @@ export function registerFileContentRoutes(
|
||||
* type: string
|
||||
* content:
|
||||
* type: string
|
||||
* encoding:
|
||||
* type: string
|
||||
* enum: [utf8, base64]
|
||||
* description: How content is encoded. Defaults to utf8.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: File written successfully.
|
||||
* 403:
|
||||
* description: Permission denied; needsSudo is set when a sudo password would help.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 500:
|
||||
* description: Failed to write file.
|
||||
*/
|
||||
app.post("/writeFile", async (req, res) => {
|
||||
const { sessionId, path: filePath, content } = req.body;
|
||||
const { sessionId, path: filePath, content, encoding } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = ctx.currentActor()!;
|
||||
|
||||
@@ -558,24 +579,9 @@ export function registerFileContentRoutes(
|
||||
);
|
||||
getSessionSftp(sshConn)
|
||||
.then((sftp) => {
|
||||
let fileBuffer;
|
||||
let fileBuffer: Buffer;
|
||||
try {
|
||||
if (typeof content === "string") {
|
||||
try {
|
||||
const testBuffer = Buffer.from(content, "base64");
|
||||
if (testBuffer.toString("base64") === content) {
|
||||
fileBuffer = testBuffer;
|
||||
} else {
|
||||
fileBuffer = Buffer.from(content, "utf8");
|
||||
}
|
||||
} catch {
|
||||
fileBuffer = Buffer.from(content, "utf8");
|
||||
}
|
||||
} else if (Buffer.isBuffer(content)) {
|
||||
fileBuffer = content;
|
||||
} else {
|
||||
fileBuffer = Buffer.from(content);
|
||||
}
|
||||
fileBuffer = decodeWriteContent(content, encoding);
|
||||
} catch (bufferErr) {
|
||||
ctx.log.error("Buffer conversion error:", bufferErr as Error);
|
||||
if (!res.headersSent) {
|
||||
@@ -685,25 +691,12 @@ export function registerFileContentRoutes(
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let contentBuffer: Buffer;
|
||||
if (typeof content === "string") {
|
||||
try {
|
||||
contentBuffer = Buffer.from(content, "base64");
|
||||
if (contentBuffer.toString("base64") !== content) {
|
||||
contentBuffer = Buffer.from(content, "utf8");
|
||||
}
|
||||
} catch {
|
||||
contentBuffer = Buffer.from(content, "utf8");
|
||||
}
|
||||
} else if (Buffer.isBuffer(content)) {
|
||||
contentBuffer = content;
|
||||
} else {
|
||||
contentBuffer = Buffer.from(content);
|
||||
}
|
||||
const contentBuffer = decodeWriteContent(content, encoding);
|
||||
const base64Content = contentBuffer.toString("base64");
|
||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const writeCommand = `echo '${base64Content}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
|
||||
// Content goes through stdin; a command line argument caps it at 128 KB.
|
||||
const writeCommand = `cat > '${escapedPath}' && echo "SUCCESS"`;
|
||||
|
||||
execChannel(sshConn, writeCommand, (err, stream) => {
|
||||
if (err) {
|
||||
@@ -735,6 +728,8 @@ export function registerFileContentRoutes(
|
||||
ctx.log.error("Fallback write stderr error:", stderrErr);
|
||||
});
|
||||
|
||||
stream.end(contentBuffer);
|
||||
|
||||
stream.on("close", (code) => {
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
restoreOriginalMode(null, () => {
|
||||
@@ -788,7 +783,7 @@ export function registerFileContentRoutes(
|
||||
`Fallback write failed with code ${code}: ${errorData}`,
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
res.status(isPermDenied ? 403 : 500).json({
|
||||
error: `Write failed: ${errorData}`,
|
||||
needsSudo: isPermDenied,
|
||||
toast: {
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
writeSSHFile,
|
||||
getSSHStatus,
|
||||
connectSSH,
|
||||
setSudoPassword,
|
||||
} from "../api/ssh-file-operations-api";
|
||||
import { SudoPasswordDialog } from "../SudoPasswordDialog.tsx";
|
||||
import { toast } from "sonner";
|
||||
import { DownloadProgressToast } from "./DownloadProgressToast";
|
||||
import type { SSHHost } from "../host-types";
|
||||
@@ -298,6 +300,21 @@ export function FileWindow({
|
||||
loadFileContent();
|
||||
};
|
||||
|
||||
const [sudoSaveContent, setSudoSaveContent] = useState<string | null>(null);
|
||||
|
||||
const handleSudoSave = async (password: string) => {
|
||||
const pending = sudoSaveContent;
|
||||
setSudoSaveContent(null);
|
||||
if (pending === null) return;
|
||||
try {
|
||||
await setSudoPassword(sshSessionId, password);
|
||||
} catch {
|
||||
toast.error(t("fileManager.sudoAuthFailed"));
|
||||
return;
|
||||
}
|
||||
await handleSave(pending);
|
||||
};
|
||||
|
||||
const handleSave = async (newContent: string) => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
@@ -317,13 +334,22 @@ export function FileWindow({
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to save file:", error);
|
||||
|
||||
const err = error as { message?: string };
|
||||
if (
|
||||
const err = error as {
|
||||
message?: string;
|
||||
response?: { data?: { needsSudo?: boolean } };
|
||||
};
|
||||
if (err.response?.data?.needsSudo) {
|
||||
setSudoSaveContent(newContent);
|
||||
} else if (
|
||||
err.message?.includes("connection") ||
|
||||
err.message?.includes("established")
|
||||
) {
|
||||
toast.error(
|
||||
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
|
||||
t("fileManager.sshConnectionFailed", {
|
||||
name: sshHost.name,
|
||||
ip: sshHost.ip,
|
||||
port: sshHost.port,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
@@ -563,6 +589,13 @@ export function FileWindow({
|
||||
}
|
||||
onMediaDimensionsChange={handleMediaDimensionsChange}
|
||||
/>
|
||||
<SudoPasswordDialog
|
||||
open={sudoSaveContent !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSudoSaveContent(null);
|
||||
}}
|
||||
onSubmit={(password) => void handleSudoSave(password)}
|
||||
/>
|
||||
</DraggableWindow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decodeWriteContent } from "../../src/backend/content-routes.js";
|
||||
|
||||
describe("decodeWriteContent", () => {
|
||||
it("keeps text that happens to be valid base64", () => {
|
||||
expect(decodeWriteContent("test").toString("utf8")).toBe("test");
|
||||
expect(decodeWriteContent("abcd1234").toString("utf8")).toBe("abcd1234");
|
||||
});
|
||||
|
||||
it("decodes base64 only when asked", () => {
|
||||
const encoded = Buffer.from("local x = 1\n").toString("base64");
|
||||
expect(decodeWriteContent(encoded, "base64").toString("utf8")).toBe(
|
||||
"local x = 1\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes buffers through", () => {
|
||||
const buffer = Buffer.from([0, 1, 2]);
|
||||
expect(decodeWriteContent(buffer)).toBe(buffer);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user