mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
fix: upload files to redirected RDP drives (#1356)
This commit is contained in:
@@ -331,6 +331,11 @@ services:
|
|||||||
- termix-data:/app/data
|
- termix-data:/app/data
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
|
GUACD_HOST: "guacd"
|
||||||
|
GUACD_TUNNEL_HOST: "termix"
|
||||||
|
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
||||||
|
# guacd, not the Termix container, reads and writes redirected-drive files.
|
||||||
|
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
|
||||||
depends_on:
|
depends_on:
|
||||||
- guacd
|
- guacd
|
||||||
networks:
|
networks:
|
||||||
@@ -340,8 +345,10 @@ services:
|
|||||||
image: guacamole/guacd:1.6.0
|
image: guacamole/guacd:1.6.0
|
||||||
container_name: guacd
|
container_name: guacd
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
volumes:
|
||||||
- "4822:4822"
|
# The official guacd image runs as a non-root user. Keep the drive path
|
||||||
|
# in this writable shared volume instead of bind-mounting /drive.
|
||||||
|
- termix-data:/termix-data
|
||||||
networks:
|
networks:
|
||||||
- termix-net
|
- termix-net
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import { resolveConnectionOrigin } from "@/lib/connection-origin.ts";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx";
|
import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx";
|
||||||
import { GuacamoleFileBrowser } from "@/features/guacamole/GuacamoleFileBrowser.tsx";
|
import { GuacamoleFileBrowser } from "@/features/guacamole/GuacamoleFileBrowser.tsx";
|
||||||
|
import { describeUploadError } from "@/features/guacamole/guacamole-filesystem.ts";
|
||||||
|
import { canUploadToRdpDrive } from "@/features/guacamole/guacamole-file-drop.ts";
|
||||||
import { Button } from "@/components/button.tsx";
|
import { Button } from "@/components/button.tsx";
|
||||||
import { Input } from "@/components/input.tsx";
|
import { Input } from "@/components/input.tsx";
|
||||||
import { PasswordInput } from "@/components/password-input.tsx";
|
import { PasswordInput } from "@/components/password-input.tsx";
|
||||||
@@ -212,12 +214,36 @@ const GuacamoleAppInner = React.forwardRef<
|
|||||||
const allowUpload = guacConfig.disableUpload !== true;
|
const allowUpload = guacConfig.disableUpload !== true;
|
||||||
const allowDownload = guacConfig.disableDownload !== true;
|
const allowDownload = guacConfig.disableDownload !== true;
|
||||||
|
|
||||||
// A dropped file has nowhere to go until the browser is showing the target
|
// Prefer the browsable filesystem's current directory. guacd may expose the
|
||||||
// directory, so opening it is part of accepting the drop.
|
// RDP drive only through the connection-level file stream, in which case the
|
||||||
const handleDropFiles = useCallback((files: File[]) => {
|
// standard direct upload still lands in the redirected drive.
|
||||||
|
const handleDropFiles = useCallback(
|
||||||
|
(files: File[]) => {
|
||||||
|
if (filesystem) {
|
||||||
setPendingUploads(files);
|
setPendingUploads(files);
|
||||||
setFileBrowserOpen(true);
|
setFileBrowserOpen(true);
|
||||||
}, []);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const display = displayRef.current;
|
||||||
|
if (!display) throw new Error("RDP session is not ready");
|
||||||
|
await display.uploadFile(file);
|
||||||
|
toast.success(t("guacamole.files.uploaded", { name: file.name }));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
describeUploadError(error, (key) =>
|
||||||
|
t(`guacamole.files.${key}`, { name: file.name }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
[filesystem, t],
|
||||||
|
);
|
||||||
|
|
||||||
const handleDropUnavailable = useCallback(() => {
|
const handleDropUnavailable = useCallback(() => {
|
||||||
toast.error(
|
toast.error(
|
||||||
@@ -526,7 +552,11 @@ const GuacamoleAppInner = React.forwardRef<
|
|||||||
}}
|
}}
|
||||||
isVisible={isVisible}
|
isVisible={isVisible}
|
||||||
touchMode={touchMode}
|
touchMode={touchMode}
|
||||||
allowUpload={allowUpload && filesystem !== null}
|
allowUpload={canUploadToRdpDrive(
|
||||||
|
allowUpload,
|
||||||
|
guacConfig.enableDrive === true,
|
||||||
|
filesystem !== null,
|
||||||
|
)}
|
||||||
onConnect={() => setIsDisplayReady(true)}
|
onConnect={() => setIsDisplayReady(true)}
|
||||||
onError={(err) => {
|
onError={(err) => {
|
||||||
setConnectionError(err);
|
setConnectionError(err);
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ import {
|
|||||||
getFileDropDisposition,
|
getFileDropDisposition,
|
||||||
hasDraggedFiles,
|
hasDraggedFiles,
|
||||||
} from "./guacamole-file-drop.ts";
|
} from "./guacamole-file-drop.ts";
|
||||||
|
import {
|
||||||
|
uploadFileToClient,
|
||||||
|
type GuacamoleFileStreamClient,
|
||||||
|
} from "./guacamole-filesystem.ts";
|
||||||
import { guacStateToStage } from "@/components/connection/connection-status.ts";
|
import { guacStateToStage } from "@/components/connection/connection-status.ts";
|
||||||
import type { ConnectionStage } from "@/types/connection-log.ts";
|
import type { ConnectionStage } from "@/types/connection-log.ts";
|
||||||
import { clampGuacamoleZoom, stepGuacamoleZoom } from "./guacamole-zoom.ts";
|
import { clampGuacamoleZoom, stepGuacamoleZoom } from "./guacamole-zoom.ts";
|
||||||
@@ -53,6 +57,7 @@ export interface GuacamoleDisplayHandle {
|
|||||||
sendMouse: (x: number, y: number, buttonMask: number) => void;
|
sendMouse: (x: number, y: number, buttonMask: number) => void;
|
||||||
setClipboard: (data: string) => void;
|
setClipboard: (data: string) => void;
|
||||||
getFilesystem: () => Guacamole.Object | null;
|
getFilesystem: () => Guacamole.Object | null;
|
||||||
|
uploadFile: (file: File) => Promise<void>;
|
||||||
zoomIn: () => number;
|
zoomIn: () => number;
|
||||||
zoomOut: () => number;
|
zoomOut: () => number;
|
||||||
resetZoom: () => number;
|
resetZoom: () => number;
|
||||||
@@ -193,6 +198,14 @@ export const GuacamoleDisplay = forwardRef<
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
getFilesystem: () => filesystemRef.current,
|
getFilesystem: () => filesystemRef.current,
|
||||||
|
uploadFile: (file: File) => {
|
||||||
|
const client = clientRef.current;
|
||||||
|
if (!client) return Promise.reject(new Error("RDP session is not ready"));
|
||||||
|
return uploadFileToClient(
|
||||||
|
client as unknown as GuacamoleFileStreamClient,
|
||||||
|
file,
|
||||||
|
);
|
||||||
|
},
|
||||||
zoomIn: () => applyZoom(stepGuacamoleZoom(zoomRef.current, 1)),
|
zoomIn: () => applyZoom(stepGuacamoleZoom(zoomRef.current, 1)),
|
||||||
zoomOut: () => applyZoom(stepGuacamoleZoom(zoomRef.current, -1)),
|
zoomOut: () => applyZoom(stepGuacamoleZoom(zoomRef.current, -1)),
|
||||||
resetZoom: () => applyZoom(1),
|
resetZoom: () => applyZoom(1),
|
||||||
|
|||||||
@@ -12,3 +12,11 @@ export function getFileDropDisposition(
|
|||||||
if (!hasDraggedFiles(types) || fileCount === 0) return "ignore";
|
if (!hasDraggedFiles(types) || fileCount === 0) return "ignore";
|
||||||
return canUpload ? "upload" : "reject";
|
return canUpload ? "upload" : "reject";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function canUploadToRdpDrive(
|
||||||
|
uploadAllowed: boolean,
|
||||||
|
driveEnabled: boolean,
|
||||||
|
hasFilesystem: boolean,
|
||||||
|
): boolean {
|
||||||
|
return uploadAllowed && (driveEnabled || hasFilesystem);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import Guacamole from "guacamole-common-js";
|
import Guacamole from "guacamole-common-js";
|
||||||
|
|
||||||
|
// The bundled Guacamole runtime exposes Client.createFileStream(), but the
|
||||||
|
// package's older TypeScript declaration omits it.
|
||||||
|
export interface GuacamoleFileStreamClient {
|
||||||
|
createFileStream(mimetype: string, filename: string): Guacamole.OutputStream;
|
||||||
|
}
|
||||||
|
|
||||||
// The root stream of a Guacamole.Object maps stream name to mimetype; a stream
|
// The root stream of a Guacamole.Object maps stream name to mimetype; a stream
|
||||||
// carrying that same mimetype is itself a directory.
|
// carrying that same mimetype is itself a directory.
|
||||||
export const STREAM_INDEX_MIMETYPE =
|
export const STREAM_INDEX_MIMETYPE =
|
||||||
@@ -123,12 +129,45 @@ export function uploadFile(
|
|||||||
file: File,
|
file: File,
|
||||||
onProgress?: (sent: number, total: number) => void,
|
onProgress?: (sent: number, total: number) => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return writeUpload(
|
||||||
const path = joinPath(directory, file.name);
|
filesystem.createOutputStream(
|
||||||
const stream = filesystem.createOutputStream(
|
|
||||||
file.type || "application/octet-stream",
|
file.type || "application/octet-stream",
|
||||||
path,
|
joinPath(directory, file.name),
|
||||||
|
),
|
||||||
|
file,
|
||||||
|
onProgress,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads through Guacamole's connection-level file stream. RDP exposes this
|
||||||
|
* path even when guacd does not advertise a browsable filesystem object, so
|
||||||
|
* display drops must not depend on `Client.onfilesystem` having fired.
|
||||||
|
*/
|
||||||
|
export function uploadFileToClient(
|
||||||
|
client: GuacamoleFileStreamClient,
|
||||||
|
file: File,
|
||||||
|
onProgress?: (sent: number, total: number) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
return writeUpload(createClientFileStream(client, file), file, onProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createClientFileStream(
|
||||||
|
client: GuacamoleFileStreamClient,
|
||||||
|
file: File,
|
||||||
|
): Guacamole.OutputStream {
|
||||||
|
return client.createFileStream(
|
||||||
|
file.type || "application/octet-stream",
|
||||||
|
file.name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeUpload(
|
||||||
|
stream: Guacamole.OutputStream,
|
||||||
|
file: File,
|
||||||
|
onProgress?: (sent: number, total: number) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
const writer = new Guacamole.BlobWriter(stream);
|
const writer = new Guacamole.BlobWriter(stream);
|
||||||
|
|
||||||
// A rejected blob stops the writer without firing onerror or oncomplete —
|
// A rejected blob stops the writer without firing onerror or oncomplete —
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ describe("GuacamoleToolbar Windows key", () => {
|
|||||||
sendMouse: vi.fn(),
|
sendMouse: vi.fn(),
|
||||||
setClipboard: vi.fn(),
|
setClipboard: vi.fn(),
|
||||||
getFilesystem: () => null,
|
getFilesystem: () => null,
|
||||||
|
uploadFile: async () => {},
|
||||||
zoomIn: vi.fn(() => 1.25),
|
zoomIn: vi.fn(() => 1.25),
|
||||||
zoomOut: vi.fn(() => 0.75),
|
zoomOut: vi.fn(() => 0.75),
|
||||||
resetZoom: vi.fn(() => 1),
|
resetZoom: vi.fn(() => 1),
|
||||||
@@ -60,6 +61,7 @@ describe("GuacamoleToolbar Windows key", () => {
|
|||||||
sendMouse: vi.fn(),
|
sendMouse: vi.fn(),
|
||||||
setClipboard: vi.fn(),
|
setClipboard: vi.fn(),
|
||||||
getFilesystem: () => null,
|
getFilesystem: () => null,
|
||||||
|
uploadFile: async () => {},
|
||||||
zoomIn,
|
zoomIn,
|
||||||
zoomOut,
|
zoomOut,
|
||||||
resetZoom,
|
resetZoom,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
canUploadToRdpDrive,
|
||||||
getFileDropDisposition,
|
getFileDropDisposition,
|
||||||
hasDraggedFiles,
|
hasDraggedFiles,
|
||||||
} from "@/features/guacamole/guacamole-file-drop.ts";
|
} from "@/features/guacamole/guacamole-file-drop.ts";
|
||||||
@@ -14,4 +15,11 @@ describe("Guacamole file drop", () => {
|
|||||||
expect(getFileDropDisposition(["Files"], 1, false)).toBe("reject");
|
expect(getFileDropDisposition(["Files"], 1, false)).toBe("reject");
|
||||||
expect(getFileDropDisposition(["Files"], 1, true)).toBe("upload");
|
expect(getFileDropDisposition(["Files"], 1, true)).toBe("upload");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts an enabled RDP drive before a filesystem object is advertised", () => {
|
||||||
|
expect(canUploadToRdpDrive(true, true, false)).toBe(true);
|
||||||
|
expect(canUploadToRdpDrive(true, false, true)).toBe(true);
|
||||||
|
expect(canUploadToRdpDrive(true, false, false)).toBe(false);
|
||||||
|
expect(canUploadToRdpDrive(false, true, true)).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
STREAM_INDEX_MIMETYPE,
|
STREAM_INDEX_MIMETYPE,
|
||||||
basename,
|
basename,
|
||||||
@@ -6,7 +6,10 @@ import {
|
|||||||
joinPath,
|
joinPath,
|
||||||
parentPath,
|
parentPath,
|
||||||
parseDirectoryIndex,
|
parseDirectoryIndex,
|
||||||
|
createClientFileStream,
|
||||||
|
type GuacamoleFileStreamClient,
|
||||||
} from "../../../features/guacamole/guacamole-filesystem.js";
|
} from "../../../features/guacamole/guacamole-filesystem.js";
|
||||||
|
import type Guacamole from "guacamole-common-js";
|
||||||
|
|
||||||
describe("path helpers", () => {
|
describe("path helpers", () => {
|
||||||
it("joins onto the root without doubling the separator", () => {
|
it("joins onto the root without doubling the separator", () => {
|
||||||
@@ -86,3 +89,19 @@ describe("parseDirectoryIndex", () => {
|
|||||||
expect(parseDirectoryIndex("{}", "/")).toEqual([]);
|
expect(parseDirectoryIndex("{}", "/")).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("direct RDP upload", () => {
|
||||||
|
it("opens a connection-level file stream when no filesystem object is available", () => {
|
||||||
|
const stream = {} as Guacamole.OutputStream;
|
||||||
|
const client = {
|
||||||
|
createFileStream: vi.fn(() => stream),
|
||||||
|
} as GuacamoleFileStreamClient;
|
||||||
|
const file = new File(["hello"], "notes.txt", { type: "text/plain" });
|
||||||
|
|
||||||
|
expect(createClientFileStream(client, file)).toBe(stream);
|
||||||
|
expect(client.createFileStream).toHaveBeenCalledWith(
|
||||||
|
"text/plain",
|
||||||
|
"notes.txt",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user