diff --git a/plugins/file-manager/src/backend/content-routes.ts b/plugins/file-manager/src/backend/content-routes.ts index 52627730f..f8a285bf2 100644 --- a/plugins/file-manager/src/backend/content-routes.ts +++ b/plugins/file-manager/src/backend/content-routes.ts @@ -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); +} + 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: { diff --git a/plugins/file-manager/src/frontend/components/FileWindow.tsx b/plugins/file-manager/src/frontend/components/FileWindow.tsx index 4946a276c..2761e509b 100644 --- a/plugins/file-manager/src/frontend/components/FileWindow.tsx +++ b/plugins/file-manager/src/frontend/components/FileWindow.tsx @@ -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(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} /> + { + if (!open) setSudoSaveContent(null); + }} + onSubmit={(password) => void handleSudoSave(password)} + /> ); } diff --git a/plugins/file-manager/tests/backend/write-content.test.ts b/plugins/file-manager/tests/backend/write-content.test.ts new file mode 100644 index 000000000..bfa03ac31 --- /dev/null +++ b/plugins/file-manager/tests/backend/write-content.test.ts @@ -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); + }); +});