mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 15:45:23 +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;
|
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(
|
export function registerFileContentRoutes(
|
||||||
app: Express,
|
app: Express,
|
||||||
{ ctx, sshSessions, verifySessionOwnership }: FileContentRoutesDeps,
|
{ ctx, sshSessions, verifySessionOwnership }: FileContentRoutesDeps,
|
||||||
@@ -435,16 +450,22 @@ export function registerFileContentRoutes(
|
|||||||
* type: string
|
* type: string
|
||||||
* content:
|
* content:
|
||||||
* type: string
|
* type: string
|
||||||
|
* encoding:
|
||||||
|
* type: string
|
||||||
|
* enum: [utf8, base64]
|
||||||
|
* description: How content is encoded. Defaults to utf8.
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: File written successfully.
|
* description: File written successfully.
|
||||||
|
* 403:
|
||||||
|
* description: Permission denied; needsSudo is set when a sudo password would help.
|
||||||
* 400:
|
* 400:
|
||||||
* description: Missing required parameters or SSH connection not established.
|
* description: Missing required parameters or SSH connection not established.
|
||||||
* 500:
|
* 500:
|
||||||
* description: Failed to write file.
|
* description: Failed to write file.
|
||||||
*/
|
*/
|
||||||
app.post("/writeFile", async (req, res) => {
|
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 sshConn = sshSessions[sessionId];
|
||||||
const userId = ctx.currentActor()!;
|
const userId = ctx.currentActor()!;
|
||||||
|
|
||||||
@@ -558,24 +579,9 @@ export function registerFileContentRoutes(
|
|||||||
);
|
);
|
||||||
getSessionSftp(sshConn)
|
getSessionSftp(sshConn)
|
||||||
.then((sftp) => {
|
.then((sftp) => {
|
||||||
let fileBuffer;
|
let fileBuffer: Buffer;
|
||||||
try {
|
try {
|
||||||
if (typeof content === "string") {
|
fileBuffer = decodeWriteContent(content, encoding);
|
||||||
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);
|
|
||||||
}
|
|
||||||
} catch (bufferErr) {
|
} catch (bufferErr) {
|
||||||
ctx.log.error("Buffer conversion error:", bufferErr as Error);
|
ctx.log.error("Buffer conversion error:", bufferErr as Error);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
@@ -685,25 +691,12 @@ export function registerFileContentRoutes(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
let contentBuffer: Buffer;
|
const contentBuffer = decodeWriteContent(content, encoding);
|
||||||
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 base64Content = contentBuffer.toString("base64");
|
const base64Content = contentBuffer.toString("base64");
|
||||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
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) => {
|
execChannel(sshConn, writeCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -735,6 +728,8 @@ export function registerFileContentRoutes(
|
|||||||
ctx.log.error("Fallback write stderr error:", stderrErr);
|
ctx.log.error("Fallback write stderr error:", stderrErr);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
stream.end(contentBuffer);
|
||||||
|
|
||||||
stream.on("close", (code) => {
|
stream.on("close", (code) => {
|
||||||
if (outputData.includes("SUCCESS")) {
|
if (outputData.includes("SUCCESS")) {
|
||||||
restoreOriginalMode(null, () => {
|
restoreOriginalMode(null, () => {
|
||||||
@@ -788,7 +783,7 @@ export function registerFileContentRoutes(
|
|||||||
`Fallback write failed with code ${code}: ${errorData}`,
|
`Fallback write failed with code ${code}: ${errorData}`,
|
||||||
);
|
);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({
|
res.status(isPermDenied ? 403 : 500).json({
|
||||||
error: `Write failed: ${errorData}`,
|
error: `Write failed: ${errorData}`,
|
||||||
needsSudo: isPermDenied,
|
needsSudo: isPermDenied,
|
||||||
toast: {
|
toast: {
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import {
|
|||||||
writeSSHFile,
|
writeSSHFile,
|
||||||
getSSHStatus,
|
getSSHStatus,
|
||||||
connectSSH,
|
connectSSH,
|
||||||
|
setSudoPassword,
|
||||||
} from "../api/ssh-file-operations-api";
|
} from "../api/ssh-file-operations-api";
|
||||||
|
import { SudoPasswordDialog } from "../SudoPasswordDialog.tsx";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { DownloadProgressToast } from "./DownloadProgressToast";
|
import { DownloadProgressToast } from "./DownloadProgressToast";
|
||||||
import type { SSHHost } from "../host-types";
|
import type { SSHHost } from "../host-types";
|
||||||
@@ -298,6 +300,21 @@ export function FileWindow({
|
|||||||
loadFileContent();
|
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) => {
|
const handleSave = async (newContent: string) => {
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
@@ -317,13 +334,22 @@ export function FileWindow({
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error("Failed to save file:", error);
|
console.error("Failed to save file:", error);
|
||||||
|
|
||||||
const err = error as { message?: string };
|
const err = error as {
|
||||||
if (
|
message?: string;
|
||||||
|
response?: { data?: { needsSudo?: boolean } };
|
||||||
|
};
|
||||||
|
if (err.response?.data?.needsSudo) {
|
||||||
|
setSudoSaveContent(newContent);
|
||||||
|
} else if (
|
||||||
err.message?.includes("connection") ||
|
err.message?.includes("connection") ||
|
||||||
err.message?.includes("established")
|
err.message?.includes("established")
|
||||||
) {
|
) {
|
||||||
toast.error(
|
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 {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
@@ -563,6 +589,13 @@ export function FileWindow({
|
|||||||
}
|
}
|
||||||
onMediaDimensionsChange={handleMediaDimensionsChange}
|
onMediaDimensionsChange={handleMediaDimensionsChange}
|
||||||
/>
|
/>
|
||||||
|
<SudoPasswordDialog
|
||||||
|
open={sudoSaveContent !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setSudoSaveContent(null);
|
||||||
|
}}
|
||||||
|
onSubmit={(password) => void handleSudoSave(password)}
|
||||||
|
/>
|
||||||
</DraggableWindow>
|
</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