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
|
||||
environment:
|
||||
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:
|
||||
- guacd
|
||||
networks:
|
||||
@@ -340,8 +345,10 @@ services:
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4822:4822"
|
||||
volumes:
|
||||
# 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:
|
||||
- termix-net
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ import { resolveConnectionOrigin } from "@/lib/connection-origin.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.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 { Input } from "@/components/input.tsx";
|
||||
import { PasswordInput } from "@/components/password-input.tsx";
|
||||
@@ -212,12 +214,36 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
const allowUpload = guacConfig.disableUpload !== true;
|
||||
const allowDownload = guacConfig.disableDownload !== true;
|
||||
|
||||
// A dropped file has nowhere to go until the browser is showing the target
|
||||
// directory, so opening it is part of accepting the drop.
|
||||
const handleDropFiles = useCallback((files: File[]) => {
|
||||
// Prefer the browsable filesystem's current directory. guacd may expose the
|
||||
// RDP drive only through the connection-level file stream, in which case the
|
||||
// standard direct upload still lands in the redirected drive.
|
||||
const handleDropFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
if (filesystem) {
|
||||
setPendingUploads(files);
|
||||
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(() => {
|
||||
toast.error(
|
||||
@@ -526,7 +552,11 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
}}
|
||||
isVisible={isVisible}
|
||||
touchMode={touchMode}
|
||||
allowUpload={allowUpload && filesystem !== null}
|
||||
allowUpload={canUploadToRdpDrive(
|
||||
allowUpload,
|
||||
guacConfig.enableDrive === true,
|
||||
filesystem !== null,
|
||||
)}
|
||||
onConnect={() => setIsDisplayReady(true)}
|
||||
onError={(err) => {
|
||||
setConnectionError(err);
|
||||
|
||||
@@ -25,6 +25,10 @@ import {
|
||||
getFileDropDisposition,
|
||||
hasDraggedFiles,
|
||||
} from "./guacamole-file-drop.ts";
|
||||
import {
|
||||
uploadFileToClient,
|
||||
type GuacamoleFileStreamClient,
|
||||
} from "./guacamole-filesystem.ts";
|
||||
import { guacStateToStage } from "@/components/connection/connection-status.ts";
|
||||
import type { ConnectionStage } from "@/types/connection-log.ts";
|
||||
import { clampGuacamoleZoom, stepGuacamoleZoom } from "./guacamole-zoom.ts";
|
||||
@@ -53,6 +57,7 @@ export interface GuacamoleDisplayHandle {
|
||||
sendMouse: (x: number, y: number, buttonMask: number) => void;
|
||||
setClipboard: (data: string) => void;
|
||||
getFilesystem: () => Guacamole.Object | null;
|
||||
uploadFile: (file: File) => Promise<void>;
|
||||
zoomIn: () => number;
|
||||
zoomOut: () => number;
|
||||
resetZoom: () => number;
|
||||
@@ -193,6 +198,14 @@ export const GuacamoleDisplay = forwardRef<
|
||||
}
|
||||
},
|
||||
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)),
|
||||
zoomOut: () => applyZoom(stepGuacamoleZoom(zoomRef.current, -1)),
|
||||
resetZoom: () => applyZoom(1),
|
||||
|
||||
@@ -12,3 +12,11 @@ export function getFileDropDisposition(
|
||||
if (!hasDraggedFiles(types) || fileCount === 0) return "ignore";
|
||||
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";
|
||||
|
||||
// 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
|
||||
// carrying that same mimetype is itself a directory.
|
||||
export const STREAM_INDEX_MIMETYPE =
|
||||
@@ -123,12 +129,45 @@ export function uploadFile(
|
||||
file: File,
|
||||
onProgress?: (sent: number, total: number) => void,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const path = joinPath(directory, file.name);
|
||||
const stream = filesystem.createOutputStream(
|
||||
return writeUpload(
|
||||
filesystem.createOutputStream(
|
||||
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);
|
||||
|
||||
// A rejected blob stops the writer without firing onerror or oncomplete —
|
||||
|
||||
@@ -19,6 +19,7 @@ describe("GuacamoleToolbar Windows key", () => {
|
||||
sendMouse: vi.fn(),
|
||||
setClipboard: vi.fn(),
|
||||
getFilesystem: () => null,
|
||||
uploadFile: async () => {},
|
||||
zoomIn: vi.fn(() => 1.25),
|
||||
zoomOut: vi.fn(() => 0.75),
|
||||
resetZoom: vi.fn(() => 1),
|
||||
@@ -60,6 +61,7 @@ describe("GuacamoleToolbar Windows key", () => {
|
||||
sendMouse: vi.fn(),
|
||||
setClipboard: vi.fn(),
|
||||
getFilesystem: () => null,
|
||||
uploadFile: async () => {},
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
resetZoom,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canUploadToRdpDrive,
|
||||
getFileDropDisposition,
|
||||
hasDraggedFiles,
|
||||
} 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, 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 {
|
||||
STREAM_INDEX_MIMETYPE,
|
||||
basename,
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
joinPath,
|
||||
parentPath,
|
||||
parseDirectoryIndex,
|
||||
createClientFileStream,
|
||||
type GuacamoleFileStreamClient,
|
||||
} from "../../../features/guacamole/guacamole-filesystem.js";
|
||||
import type Guacamole from "guacamole-common-js";
|
||||
|
||||
describe("path helpers", () => {
|
||||
it("joins onto the root without doubling the separator", () => {
|
||||
@@ -86,3 +89,19 @@ describe("parseDirectoryIndex", () => {
|
||||
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