mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
1065 lines
33 KiB
JavaScript
1065 lines
33 KiB
JavaScript
// Local filesystem access + streamed local<->remote transfers for the
|
|
// desktop app's dual-pane file manager.
|
|
//
|
|
// The renderer has no Node access (contextIsolation), so browsing the user's
|
|
// own disk and moving bytes between it and the backend has to happen here.
|
|
// Uploads/downloads are streamed through Electron's `net` stack against the
|
|
// same file-manager HTTP routes the renderer already uses, so nothing is ever
|
|
// buffered whole in memory.
|
|
//
|
|
// Trust boundary: the renderer never supplies a URL or headers. It names an
|
|
// origin ("local" | "remote") and a route from a fixed allowlist; the main
|
|
// process resolves the actual Termix backend URL and attaches credentials
|
|
// itself (session cookies for the embedded backend, the linked server's
|
|
// session). Nothing here can be pointed at another host.
|
|
|
|
const fs = require("fs");
|
|
const fsp = require("fs/promises");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
const { URL } = require("url");
|
|
|
|
const IPC = {
|
|
HOME: "local-fs:home",
|
|
LIST: "local-fs:list",
|
|
MKDIR: "local-fs:mkdir",
|
|
CREATE_FILE: "local-fs:create-file",
|
|
RENAME: "local-fs:rename",
|
|
TRASH: "local-fs:trash",
|
|
ENSURE_DIR: "local-fs:ensure-dir",
|
|
EXISTS: "local-fs:exists",
|
|
WALK: "local-fs:walk",
|
|
REVEAL: "local-fs:reveal",
|
|
OPEN: "local-fs:open",
|
|
UPLOAD: "local-transfer:upload",
|
|
DOWNLOAD: "local-transfer:download",
|
|
CANCEL: "local-transfer:cancel",
|
|
PROGRESS: "local-transfer:progress",
|
|
SET_API: "local-transfer:set-api",
|
|
};
|
|
|
|
const READ_CHUNK_BYTES = 1024 * 1024;
|
|
const PROGRESS_INTERVAL_MS = 150;
|
|
|
|
const activeTransfers = new Map();
|
|
|
|
function isAbsoluteLocalPath(candidate) {
|
|
return typeof candidate === "string" && path.isAbsolute(candidate);
|
|
}
|
|
|
|
function normalizeLocalPath(candidate) {
|
|
if (!isAbsoluteLocalPath(candidate)) {
|
|
throw new Error("A local absolute path is required");
|
|
}
|
|
return path.normalize(candidate);
|
|
}
|
|
|
|
function isHiddenEntry(name) {
|
|
return name.startsWith(".");
|
|
}
|
|
|
|
// A single path segment: no separators, not "." / "..", no NULs.
|
|
function requireEntryName(name, what) {
|
|
const safeName = String(name || "").trim();
|
|
if (
|
|
!safeName ||
|
|
safeName === "." ||
|
|
safeName === ".." ||
|
|
/[\/\\\0]/.test(safeName)
|
|
) {
|
|
throw new Error(`Invalid ${what}`);
|
|
}
|
|
return safeName;
|
|
}
|
|
|
|
// Asserts that `candidate` (already absolute) lies strictly inside `root`
|
|
// after normalisation on the *current* platform. `pathImpl` is injectable so
|
|
// the Windows rules can be exercised in tests on any OS. Traversal that
|
|
// survives normalisation ("..\\x" is a plain file name on POSIX but a parent
|
|
// reference on Windows) is caught here, as are absolute / drive-qualified
|
|
// names that path.join or path.resolve would let take over.
|
|
function assertWithinRoot(rootPath, candidate, pathImpl = path) {
|
|
if (typeof rootPath !== "string" || !rootPath.trim()) {
|
|
throw new LocalFileError("EINVAL", "A download folder is required");
|
|
}
|
|
const root = pathImpl.normalize(pathImpl.resolve(rootPath));
|
|
const target = pathImpl.normalize(pathImpl.resolve(candidate));
|
|
const rel = pathImpl.relative(root, target);
|
|
const escapes =
|
|
rel === "" ||
|
|
rel === ".." ||
|
|
rel.startsWith(`..${pathImpl.sep}`) ||
|
|
pathImpl.isAbsolute(rel) ||
|
|
// A different drive on Windows yields an absolute relative path; a UNC
|
|
// or drive-qualified segment must never survive either.
|
|
rel.split(pathImpl.sep).some((seg) => seg === ".." || seg === "");
|
|
if (escapes) {
|
|
throw new LocalFileError(
|
|
"EINVAL",
|
|
`"${pathImpl.basename(candidate)}" would be written outside the selected folder`,
|
|
);
|
|
}
|
|
return target;
|
|
}
|
|
|
|
// Resolve a deliberately selected linked root once, but never follow links
|
|
// supplied as descendants of a downloaded tree.
|
|
async function prepareDownloadPath(rootPath, candidate, directory = false) {
|
|
const target = assertWithinRoot(rootPath, normalizeLocalPath(candidate));
|
|
const selected = path.resolve(rootPath);
|
|
const realRoot = await fsp.realpath(selected);
|
|
if (!(await fsp.stat(realRoot)).isDirectory()) {
|
|
throw new LocalFileError("EINVAL", "The download root is not a directory");
|
|
}
|
|
const parts = path.relative(selected, target).split(path.sep);
|
|
let current = realRoot;
|
|
for (let i = 0; i < parts.length; i++) {
|
|
current = path.join(current, parts[i]);
|
|
const needsDirectory = directory || i < parts.length - 1;
|
|
if (needsDirectory) {
|
|
await fsp.mkdir(current).catch((error) => {
|
|
if (error.code !== "EEXIST") throw error;
|
|
});
|
|
}
|
|
const stat = await fsp.lstat(current).catch((error) => {
|
|
if (error.code === "ENOENT" && !needsDirectory) return null;
|
|
throw error;
|
|
});
|
|
if (stat?.isSymbolicLink()) {
|
|
throw new LocalFileError(
|
|
"EINVAL",
|
|
"Downloads cannot follow links inside the selected folder",
|
|
);
|
|
}
|
|
if (needsDirectory && !stat.isDirectory()) {
|
|
throw new LocalFileError(
|
|
"ENOTDIR",
|
|
"A download parent is not a directory",
|
|
);
|
|
}
|
|
}
|
|
return { root: realRoot, path: current };
|
|
}
|
|
|
|
async function pathExists(target) {
|
|
try {
|
|
await fsp.lstat(target);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function describeEntry(dirPath, dirent) {
|
|
const entryPath = path.join(dirPath, dirent.name);
|
|
let type = "file";
|
|
let size = 0;
|
|
let modifiedTimestamp;
|
|
let linkTarget;
|
|
|
|
try {
|
|
if (dirent.isSymbolicLink()) {
|
|
type = "link";
|
|
try {
|
|
linkTarget = await fsp.readlink(entryPath);
|
|
} catch {
|
|
// dangling or unreadable link
|
|
}
|
|
// Follow the link so a symlinked directory still navigates like one.
|
|
try {
|
|
const target = await fsp.stat(entryPath);
|
|
if (target.isDirectory()) type = "directory";
|
|
size = target.size;
|
|
modifiedTimestamp = target.mtimeMs;
|
|
} catch {
|
|
const own = await fsp.lstat(entryPath);
|
|
modifiedTimestamp = own.mtimeMs;
|
|
}
|
|
} else if (dirent.isDirectory()) {
|
|
type = "directory";
|
|
const stat = await fsp.stat(entryPath);
|
|
modifiedTimestamp = stat.mtimeMs;
|
|
} else {
|
|
const stat = await fsp.stat(entryPath);
|
|
size = stat.size;
|
|
modifiedTimestamp = stat.mtimeMs;
|
|
}
|
|
} catch {
|
|
// Unreadable entry: still list it so the user sees it exists.
|
|
}
|
|
|
|
return {
|
|
name: dirent.name,
|
|
path: entryPath,
|
|
type,
|
|
size,
|
|
modifiedTimestamp,
|
|
linkTarget,
|
|
hidden: isHiddenEntry(dirent.name),
|
|
};
|
|
}
|
|
|
|
async function listDirectory(dirPath) {
|
|
const resolved = normalizeLocalPath(dirPath);
|
|
const dirents = await fsp.readdir(resolved, { withFileTypes: true });
|
|
const entries = await Promise.all(
|
|
dirents.map((dirent) => describeEntry(resolved, dirent)),
|
|
);
|
|
const parent = path.dirname(resolved);
|
|
return {
|
|
path: resolved,
|
|
parent: parent === resolved ? null : parent,
|
|
entries,
|
|
};
|
|
}
|
|
|
|
// Expands a set of dropped local paths into the flat list of files (with
|
|
// paths relative to the drop root) plus any empty directories, mirroring what
|
|
// the browser's FileSystemEntry walker produces for OS drops.
|
|
async function walkPaths(rootPaths) {
|
|
const files = [];
|
|
const emptyDirs = [];
|
|
let totalBytes = 0;
|
|
|
|
async function walkDir(absDir, relDir) {
|
|
const dirents = await fsp.readdir(absDir, { withFileTypes: true });
|
|
if (dirents.length === 0) {
|
|
emptyDirs.push(relDir);
|
|
return;
|
|
}
|
|
for (const dirent of dirents) {
|
|
const abs = path.join(absDir, dirent.name);
|
|
const rel = `${relDir}/${dirent.name}`;
|
|
if (dirent.isDirectory()) {
|
|
await walkDir(abs, rel);
|
|
} else if (dirent.isFile()) {
|
|
const stat = await fsp.stat(abs);
|
|
files.push({ localPath: abs, relativePath: rel, size: stat.size });
|
|
totalBytes += stat.size;
|
|
} else if (dirent.isSymbolicLink()) {
|
|
// Upload what the link points at, if it is a regular file.
|
|
try {
|
|
const stat = await fsp.stat(abs);
|
|
if (stat.isFile()) {
|
|
files.push({ localPath: abs, relativePath: rel, size: stat.size });
|
|
totalBytes += stat.size;
|
|
}
|
|
} catch {
|
|
// dangling link: skip
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const rootPath of rootPaths) {
|
|
const abs = normalizeLocalPath(rootPath);
|
|
const stat = await fsp.stat(abs);
|
|
const name = path.basename(abs);
|
|
if (stat.isDirectory()) {
|
|
await walkDir(abs, name);
|
|
} else if (stat.isFile()) {
|
|
files.push({ localPath: abs, relativePath: name, size: stat.size });
|
|
totalBytes += stat.size;
|
|
}
|
|
}
|
|
|
|
return { files, emptyDirs, totalBytes };
|
|
}
|
|
|
|
function toHeaderMap(headers) {
|
|
const out = {};
|
|
if (!headers || typeof headers !== "object") return out;
|
|
for (const [key, value] of Object.entries(headers)) {
|
|
if (value === undefined || value === null) continue;
|
|
out[key] = String(value);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function makeProgressReporter(sender, transferId) {
|
|
let lastSentAt = 0;
|
|
return (transferred, total, force = false) => {
|
|
const now = Date.now();
|
|
if (!force && now - lastSentAt < PROGRESS_INTERVAL_MS) return;
|
|
lastSentAt = now;
|
|
if (sender.isDestroyed()) return;
|
|
sender.send(IPC.PROGRESS, { transferId, transferred, total });
|
|
};
|
|
}
|
|
|
|
function collectBody(response) {
|
|
return new Promise((resolve) => {
|
|
const chunks = [];
|
|
response.on("data", (chunk) => chunks.push(chunk));
|
|
response.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
response.on("error", () => resolve(""));
|
|
});
|
|
}
|
|
|
|
function describeHttpError(statusCode, bodyText) {
|
|
try {
|
|
const parsed = JSON.parse(bodyText);
|
|
if (parsed && typeof parsed.error === "string") return parsed.error;
|
|
if (parsed && typeof parsed.message === "string") return parsed.message;
|
|
} catch {
|
|
// not JSON
|
|
}
|
|
return bodyText?.trim() || `Request failed with status ${statusCode}`;
|
|
}
|
|
|
|
function writeToRequest(request, chunk) {
|
|
return new Promise((resolve, reject) => {
|
|
try {
|
|
request.write(chunk, () => resolve());
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
const TRANSFER_ROUTES = Object.freeze({
|
|
uploadFileStream: "/uploadFileStream",
|
|
downloadFileStream: "/downloadFileStream",
|
|
});
|
|
|
|
const DEVICE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
// A compact JWT (base64url segments joined by dots); anything else is refused.
|
|
const AUTH_TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
|
|
// The plugin that serves the two streaming routes tells us its /plugin-api/
|
|
// path, so nothing here names it. Only a /plugin-api/<id> path is accepted,
|
|
// which keeps every transfer on the backend's plugin routes.
|
|
const API_PATH_PATTERN = /^\/plugin-api\/[a-z0-9][a-z0-9-]*$/;
|
|
let localBackendPort = 30001;
|
|
|
|
function setLocalBackendPort(port) {
|
|
localBackendPort = port;
|
|
}
|
|
let registeredApiPath = null;
|
|
|
|
function setTransferApiPath(apiPath) {
|
|
if (typeof apiPath !== "string" || !API_PATH_PATTERN.test(apiPath)) {
|
|
throw new Error("Invalid transfer API path");
|
|
}
|
|
registeredApiPath = apiPath;
|
|
}
|
|
|
|
function normalizeHttpBase(candidate, what) {
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(String(candidate || ""));
|
|
} catch {
|
|
throw new Error(`${what} is not a valid URL`);
|
|
}
|
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
throw new Error(`${what} must use http or https`);
|
|
}
|
|
return parsed.toString().replace(/\/$/, "");
|
|
}
|
|
|
|
// Resolves where a transfer may go. Only the two file-manager streaming
|
|
// routes are reachable, and only on the embedded backend or the linked
|
|
// server; credentials come from the main process, not the caller.
|
|
function createTargetResolver({ localBaseUrl, getLinkedServer, apiPath }) {
|
|
const resolveApiPath = () => {
|
|
const resolved = apiPath ?? registeredApiPath;
|
|
if (!resolved)
|
|
throw new Error("Local file transfers are not available yet");
|
|
return resolved;
|
|
};
|
|
return function resolveTransferTarget({
|
|
origin,
|
|
route,
|
|
deviceId,
|
|
authToken,
|
|
} = {}) {
|
|
const routePath = TRANSFER_ROUTES[route];
|
|
if (!routePath) {
|
|
throw new Error(`Unknown transfer route: ${String(route)}`);
|
|
}
|
|
|
|
const headers = { "X-Electron-App": "true" };
|
|
if (deviceId !== undefined && deviceId !== null && deviceId !== "") {
|
|
if (typeof deviceId !== "string" || !DEVICE_ID_PATTERN.test(deviceId)) {
|
|
throw new Error("Invalid device id");
|
|
}
|
|
headers["X-Termix-Device-ID"] = deviceId;
|
|
}
|
|
|
|
if (origin === "local") {
|
|
// The desktop renderer authenticates against the embedded backend with
|
|
// the token it keeps in localStorage (see the Electron branch of the
|
|
// axios request interceptor), and the `jwt` cookie only exists for a
|
|
// while after an interactive login. Send that same token here, so a
|
|
// transfer does not depend on a cookie the rest of the app no longer
|
|
// needs; the session cookie still rides along as a fallback. The
|
|
// renderer already uses this token on every request it makes itself,
|
|
// so forwarding it grants nothing new, and the URL stays ours.
|
|
if (authToken !== undefined && authToken !== null && authToken !== "") {
|
|
if (
|
|
typeof authToken !== "string" ||
|
|
authToken.length > 8192 ||
|
|
!AUTH_TOKEN_PATTERN.test(authToken)
|
|
) {
|
|
throw new Error("Invalid auth token");
|
|
}
|
|
headers.Authorization = `Bearer ${authToken}`;
|
|
}
|
|
return {
|
|
url: `${normalizeHttpBase(
|
|
localBaseUrl ??
|
|
`http://localhost:${localBackendPort}${resolveApiPath()}`,
|
|
"Local backend URL",
|
|
)}${routePath}`,
|
|
headers,
|
|
};
|
|
}
|
|
|
|
if (origin === "remote") {
|
|
const linked =
|
|
typeof getLinkedServer === "function" ? getLinkedServer() : null;
|
|
if (!linked || !linked.serverUrl) {
|
|
throw new Error("This device is not linked to a server");
|
|
}
|
|
const base = normalizeHttpBase(linked.serverUrl, "Linked server URL");
|
|
if (linked.token) headers.Authorization = `Bearer ${linked.token}`;
|
|
return { url: `${base}${resolveApiPath()}${routePath}`, headers };
|
|
}
|
|
|
|
throw new Error(`Unknown transfer origin: ${String(origin)}`);
|
|
};
|
|
}
|
|
|
|
function createNetRequest(net, event, method, url) {
|
|
return net.request({
|
|
method,
|
|
url,
|
|
session: event.sender.session,
|
|
useSessionCookies: true,
|
|
});
|
|
}
|
|
|
|
// Streams one local file to the backend's multipart `uploadFileStream` route.
|
|
async function uploadLocalFile({ net, resolveTransferTarget }, event, options) {
|
|
const {
|
|
transferId,
|
|
origin,
|
|
deviceId,
|
|
authToken,
|
|
fields,
|
|
localPath,
|
|
fileName,
|
|
} = options || {};
|
|
|
|
if (!transferId || !localPath) {
|
|
throw new Error("Missing upload parameters");
|
|
}
|
|
const { url, headers } = resolveTransferTarget({
|
|
origin,
|
|
route: "uploadFileStream",
|
|
deviceId,
|
|
authToken,
|
|
});
|
|
|
|
const absPath = normalizeLocalPath(localPath);
|
|
const stat = await fsp.stat(absPath);
|
|
if (!stat.isFile()) {
|
|
throw new Error("Only regular files can be uploaded");
|
|
}
|
|
|
|
const boundary = `----TermixLocalUpload${Date.now()}${Math.random()
|
|
.toString(36)
|
|
.slice(2)}`;
|
|
const safeName = String(fileName || path.basename(absPath))
|
|
.replace(/[\r\n]/g, " ")
|
|
.replace(/"/g, "%22");
|
|
|
|
let preamble = "";
|
|
for (const [key, value] of Object.entries(fields || {})) {
|
|
if (value === undefined || value === null) continue;
|
|
preamble +=
|
|
`--${boundary}\r\n` +
|
|
`Content-Disposition: form-data; name="${key}"\r\n\r\n` +
|
|
`${String(value)}\r\n`;
|
|
}
|
|
preamble +=
|
|
`--${boundary}\r\n` +
|
|
`Content-Disposition: form-data; name="file"; filename="${safeName}"\r\n` +
|
|
`Content-Type: application/octet-stream\r\n\r\n`;
|
|
const epilogue = `\r\n--${boundary}--\r\n`;
|
|
|
|
const preambleBuffer = Buffer.from(preamble, "utf8");
|
|
const epilogueBuffer = Buffer.from(epilogue, "utf8");
|
|
|
|
const request = createNetRequest(net, event, "POST", url);
|
|
for (const [key, value] of Object.entries(toHeaderMap(headers))) {
|
|
request.setHeader(key, value);
|
|
}
|
|
request.setHeader(
|
|
"Content-Type",
|
|
`multipart/form-data; boundary=${boundary}`,
|
|
);
|
|
// Without chunked encoding Electron buffers the whole body in the main
|
|
// process before sending; chunked keeps memory flat for multi-GB files.
|
|
// Busboy on the backend parses the multipart stream incrementally either way.
|
|
request.chunkedEncoding = true;
|
|
|
|
const report = makeProgressReporter(event.sender, transferId);
|
|
const readStream = fs.createReadStream(absPath, {
|
|
highWaterMark: READ_CHUNK_BYTES,
|
|
});
|
|
|
|
const state = {
|
|
cancelled: false,
|
|
abort: () => {
|
|
state.cancelled = true;
|
|
readStream.destroy();
|
|
request.abort();
|
|
},
|
|
};
|
|
activeTransfers.set(transferId, state);
|
|
|
|
// The server may answer early (auth failure, missing session) while the
|
|
// body is still streaming; stop pushing bytes as soon as it does.
|
|
let responded = false;
|
|
const responsePromise = new Promise((resolve, reject) => {
|
|
request.on("response", async (response) => {
|
|
responded = true;
|
|
const bodyText = await collectBody(response);
|
|
resolve({ statusCode: response.statusCode, bodyText });
|
|
});
|
|
request.on("error", (error) => reject(error));
|
|
request.on("abort", () => reject(new Error("Transfer cancelled")));
|
|
});
|
|
// Avoid an unhandled rejection if we bail out before awaiting below.
|
|
responsePromise.catch(() => {});
|
|
|
|
// A write callback never fires once the request has errored or been
|
|
// aborted, so every write races against the response promise's rejection.
|
|
const write = (chunk) =>
|
|
Promise.race([writeToRequest(request, chunk), responsePromise]);
|
|
|
|
try {
|
|
await write(preambleBuffer);
|
|
let sent = 0;
|
|
for await (const chunk of readStream) {
|
|
if (state.cancelled) throw new Error("Transfer cancelled");
|
|
if (responded) break;
|
|
await write(chunk);
|
|
sent += chunk.length;
|
|
report(sent, stat.size);
|
|
}
|
|
if (!responded) {
|
|
await write(epilogueBuffer);
|
|
request.end();
|
|
}
|
|
|
|
const { statusCode, bodyText } = await responsePromise;
|
|
if (statusCode < 200 || statusCode >= 300) {
|
|
throw new Error(describeHttpError(statusCode, bodyText));
|
|
}
|
|
report(stat.size, stat.size, true);
|
|
return { success: true, bytes: stat.size };
|
|
} catch (error) {
|
|
readStream.destroy();
|
|
throw error;
|
|
} finally {
|
|
activeTransfers.delete(transferId);
|
|
}
|
|
}
|
|
|
|
class LocalFileError extends Error {
|
|
constructor(code, message) {
|
|
super(message);
|
|
this.name = "LocalFileError";
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
// Destinations with a download in flight, so two transfers can never race
|
|
// each other onto the same file.
|
|
const activeDestinations = new Set();
|
|
|
|
function partialPathFor(absDest, transferId) {
|
|
const token = String(transferId)
|
|
.replace(/[^A-Za-z0-9_-]/g, "")
|
|
.slice(0, 48);
|
|
return `${absDest}.${token || "transfer"}.termix-part`;
|
|
}
|
|
|
|
// File operations used to publish a download, injectable so the replace
|
|
// strategy can be tested against Windows-like semantics (where rename() onto
|
|
// an existing name fails) without running on Windows.
|
|
const defaultPublishFs = Object.freeze({
|
|
rename: (from, to) => fsp.rename(from, to),
|
|
link: (from, to) => fsp.link(from, to),
|
|
copyFileExcl: (from, to) =>
|
|
fsp.copyFile(from, to, fs.constants.COPYFILE_EXCL),
|
|
rm: (target) => fsp.rm(target, { force: true }),
|
|
lstat: (target) => fsp.lstat(target),
|
|
});
|
|
|
|
function replacedPathFor(absDest, transferId) {
|
|
const token = String(transferId)
|
|
.replace(/[^A-Za-z0-9_-]/g, "")
|
|
.slice(0, 48);
|
|
return `${absDest}.${token || "transfer"}.termix-replaced`;
|
|
}
|
|
|
|
// Puts `sourcePath` under `absDest` without ever replacing an existing file:
|
|
// link() is atomic and fails with EEXIST if the name is taken; filesystems
|
|
// without hard links fall back to an exclusive copy. The source is removed
|
|
// once the destination exists.
|
|
async function publishExclusive(io, sourcePath, absDest) {
|
|
const exists = () =>
|
|
new LocalFileError("EEXIST", `"${path.basename(absDest)}" already exists`);
|
|
try {
|
|
await io.link(sourcePath, absDest);
|
|
} catch (error) {
|
|
if (error && error.code === "EEXIST") throw exists();
|
|
try {
|
|
await io.copyFileExcl(sourcePath, absDest);
|
|
} catch (copyError) {
|
|
if (copyError && copyError.code === "EEXIST") throw exists();
|
|
throw copyError;
|
|
}
|
|
}
|
|
await io.rm(sourcePath);
|
|
}
|
|
|
|
// Replaces `absDest` with the finished partial. rename() over an existing
|
|
// file is not portable: POSIX replaces it, but on Windows the call commonly
|
|
// fails (EEXIST / EPERM, always when the file is open), so the swap never
|
|
// renames onto an occupied name. Instead:
|
|
// 1. move the current file aside to a transfer-unique sibling
|
|
// (renaming to a fresh name is safe everywhere);
|
|
// 2. publish the partial exclusively under the now-free name;
|
|
// 3. delete the aside copy.
|
|
// If step 1 fails nothing has changed and the caller gets EBUSY. If step 2
|
|
// fails the aside copy is moved back, so the original survives.
|
|
async function replaceExisting(io, partialPath, absDest, transferId) {
|
|
let current;
|
|
try {
|
|
current = await io.lstat(absDest);
|
|
} catch (error) {
|
|
if (error && error.code === "ENOENT") {
|
|
// Nothing to replace after all (the file went away meanwhile).
|
|
await publishExclusive(io, partialPath, absDest);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
if (current.isDirectory()) {
|
|
throw new LocalFileError(
|
|
"EISDIR",
|
|
`"${path.basename(absDest)}" is a folder and cannot be replaced by a file`,
|
|
);
|
|
}
|
|
|
|
const asidePath = replacedPathFor(absDest, transferId);
|
|
try {
|
|
await io.rename(absDest, asidePath);
|
|
} catch (error) {
|
|
if (
|
|
error &&
|
|
(error.code === "EPERM" ||
|
|
error.code === "EBUSY" ||
|
|
error.code === "EACCES")
|
|
) {
|
|
throw new LocalFileError(
|
|
"EBUSY",
|
|
`"${path.basename(absDest)}" is in use and could not be replaced`,
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
try {
|
|
await publishExclusive(io, partialPath, absDest);
|
|
} catch (error) {
|
|
// Put the original back under its name; the partial is cleaned up by
|
|
// the caller.
|
|
await io.rm(absDest).catch(() => {});
|
|
await io.rename(asidePath, absDest).catch(() => {});
|
|
throw error;
|
|
}
|
|
|
|
// The old contents are no longer reachable under the real name; removing
|
|
// the aside copy can still fail on Windows if another process holds it
|
|
// open, so retry once before giving up and leaving it for the user.
|
|
try {
|
|
await io.rm(asidePath);
|
|
} catch {
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
await io.rm(asidePath).catch((error) => {
|
|
console.warn(
|
|
`[local-files] replaced "${absDest}" but could not remove the previous copy at "${asidePath}": ${error && error.message}`,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Moves a finished partial file onto its final name. Without `overwrite` the
|
|
// publish is exclusive: an existing file is never replaced, even if it
|
|
// appeared while the download was running. With `overwrite` the existing file
|
|
// is swapped out in a way that also works on Windows.
|
|
async function publishDownload(
|
|
partialPath,
|
|
absDest,
|
|
overwrite,
|
|
transferId,
|
|
io = defaultPublishFs,
|
|
) {
|
|
if (overwrite) {
|
|
await replaceExisting(io, partialPath, absDest, transferId);
|
|
return;
|
|
}
|
|
await publishExclusive(io, partialPath, absDest);
|
|
}
|
|
|
|
// Streams one remote file (via the backend's `downloadFileStream` route) into
|
|
// a local destination. Bytes go to a transfer-unique temp sibling first and
|
|
// are published under the real name only on success, so a failed transfer
|
|
// never leaves a truncated file behind. Existing files are refused unless
|
|
// the caller explicitly asked to overwrite.
|
|
async function downloadToLocal(
|
|
{ net, resolveTransferTarget, publishFs },
|
|
event,
|
|
options,
|
|
) {
|
|
const {
|
|
transferId,
|
|
origin,
|
|
deviceId,
|
|
authToken,
|
|
body,
|
|
destPath,
|
|
rootPath,
|
|
expectedSize,
|
|
} = options || {};
|
|
const overwrite = options?.overwrite === true;
|
|
|
|
if (!transferId || !destPath) {
|
|
throw new Error("Missing download parameters");
|
|
}
|
|
const { url, headers } = resolveTransferTarget({
|
|
origin,
|
|
route: "downloadFileStream",
|
|
deviceId,
|
|
authToken,
|
|
});
|
|
|
|
// The renderer builds destPath from remote names; never trust that it
|
|
// stayed inside the folder the user picked.
|
|
const destination = await prepareDownloadPath(rootPath, destPath);
|
|
const absDest = destination.path;
|
|
if (activeDestinations.has(absDest)) {
|
|
throw new LocalFileError(
|
|
"EBUSY",
|
|
`"${path.basename(absDest)}" is already being downloaded`,
|
|
);
|
|
}
|
|
activeDestinations.add(absDest);
|
|
|
|
const partialPath = partialPathFor(
|
|
path.join(destination.root, path.basename(absDest)),
|
|
transferId,
|
|
);
|
|
const report = makeProgressReporter(event.sender, transferId);
|
|
const state = {
|
|
cancelled: false,
|
|
abort: () => {
|
|
state.cancelled = true;
|
|
request.abort();
|
|
},
|
|
};
|
|
let request = null;
|
|
|
|
try {
|
|
if (!overwrite && (await pathExists(absDest))) {
|
|
throw new LocalFileError(
|
|
"EEXIST",
|
|
`"${path.basename(absDest)}" already exists`,
|
|
);
|
|
}
|
|
|
|
request = createNetRequest(net, event, "POST", url);
|
|
for (const [key, value] of Object.entries(toHeaderMap(headers))) {
|
|
request.setHeader(key, value);
|
|
}
|
|
const payload = Buffer.from(JSON.stringify(body || {}), "utf8");
|
|
request.setHeader("Content-Type", "application/json");
|
|
// No explicit Content-Length: Electron's net module forbids apps from
|
|
// setting it (the request fails with net::ERR_INVALID_ARGUMENT) and
|
|
// computes it itself from the buffered body when chunked encoding is off.
|
|
activeTransfers.set(transferId, state);
|
|
|
|
await new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
const fail = (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
reject(error);
|
|
};
|
|
|
|
request.on("error", fail);
|
|
request.on("abort", () => fail(new Error("Transfer cancelled")));
|
|
request.on("response", (response) => {
|
|
const statusCode = response.statusCode;
|
|
if (statusCode < 200 || statusCode >= 300) {
|
|
collectBody(response).then((bodyText) =>
|
|
fail(new Error(describeHttpError(statusCode, bodyText))),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const lengthHeader = response.headers["content-length"];
|
|
const total =
|
|
Number(
|
|
Array.isArray(lengthHeader) ? lengthHeader[0] : lengthHeader,
|
|
) ||
|
|
Number(expectedSize) ||
|
|
undefined;
|
|
|
|
// "wx": the temp name is ours alone; refuse to reuse a stale one.
|
|
const writeStream = fs.createWriteStream(partialPath, { flags: "wx" });
|
|
let received = 0;
|
|
|
|
response.on("data", (chunk) => {
|
|
received += chunk.length;
|
|
const ok = writeStream.write(chunk);
|
|
if (!ok) {
|
|
response.pause();
|
|
writeStream.once("drain", () => response.resume());
|
|
}
|
|
report(received, total);
|
|
});
|
|
response.on("end", () => {
|
|
writeStream.end(() => {
|
|
report(received, total ?? received, true);
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve();
|
|
});
|
|
});
|
|
response.on("error", (error) => {
|
|
writeStream.destroy();
|
|
fail(error);
|
|
});
|
|
writeStream.on("error", (error) => {
|
|
request.abort();
|
|
fail(error);
|
|
});
|
|
});
|
|
|
|
request.end(payload);
|
|
});
|
|
|
|
await prepareDownloadPath(destination.root, absDest);
|
|
await publishDownload(
|
|
partialPath,
|
|
absDest,
|
|
overwrite,
|
|
transferId,
|
|
publishFs || defaultPublishFs,
|
|
);
|
|
return { success: true, path: absDest };
|
|
} catch (error) {
|
|
await fsp.rm(partialPath, { force: true }).catch(() => {});
|
|
throw error;
|
|
} finally {
|
|
activeTransfers.delete(transferId);
|
|
activeDestinations.delete(absDest);
|
|
}
|
|
}
|
|
|
|
function wrap(handler) {
|
|
return async (event, ...args) => {
|
|
try {
|
|
const result = await handler(event, ...args);
|
|
return { success: true, ...(result || {}) };
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error && error.message ? error.message : String(error),
|
|
code: error && error.code ? error.code : undefined,
|
|
};
|
|
}
|
|
};
|
|
}
|
|
|
|
// Builds the IPC handler map from injected dependencies so the whole boundary
|
|
// can be exercised in tests with a Node http shim instead of Electron.
|
|
function createLocalFileHandlers({
|
|
net,
|
|
shell,
|
|
getLinkedServer,
|
|
localBaseUrl,
|
|
publishFs,
|
|
}) {
|
|
if (!net || typeof net.request !== "function") {
|
|
throw new Error("createLocalFileHandlers requires a net implementation");
|
|
}
|
|
const resolveTransferTarget = createTargetResolver({
|
|
localBaseUrl,
|
|
getLinkedServer,
|
|
});
|
|
const deps = {
|
|
net,
|
|
resolveTransferTarget,
|
|
publishFs: publishFs || defaultPublishFs,
|
|
};
|
|
|
|
return {
|
|
[IPC.SET_API]: wrap(async (_event, apiPath) => {
|
|
setTransferApiPath(apiPath);
|
|
return {};
|
|
}),
|
|
|
|
[IPC.HOME]: wrap(async () => ({
|
|
home: os.homedir(),
|
|
separator: path.sep,
|
|
platform: process.platform,
|
|
})),
|
|
|
|
[IPC.LIST]: wrap(async (_event, dirPath) => listDirectory(dirPath)),
|
|
|
|
[IPC.MKDIR]: wrap(async (_event, parentPath, name) => {
|
|
const parent = normalizeLocalPath(parentPath);
|
|
const safeName = requireEntryName(name, "folder name");
|
|
const target = path.join(parent, safeName);
|
|
await fsp.mkdir(target);
|
|
return { path: target };
|
|
}),
|
|
|
|
[IPC.CREATE_FILE]: wrap(async (_event, parentPath, name) => {
|
|
const parent = normalizeLocalPath(parentPath);
|
|
const safeName = requireEntryName(name, "file name");
|
|
const target = path.join(parent, safeName);
|
|
// "wx" fails if the file already exists rather than truncating it.
|
|
await fsp.writeFile(target, "", { flag: "wx" });
|
|
return { path: target };
|
|
}),
|
|
|
|
[IPC.RENAME]: wrap(async (_event, oldPath, newName) => {
|
|
const source = normalizeLocalPath(oldPath);
|
|
const safeName = requireEntryName(newName, "name");
|
|
const target = path.join(path.dirname(source), safeName);
|
|
if (target === source) return { path: source };
|
|
if (await pathExists(target)) {
|
|
throw new LocalFileError("EEXIST", `"${safeName}" already exists`);
|
|
}
|
|
await fsp.rename(source, target);
|
|
return { path: target };
|
|
}),
|
|
|
|
// Moves entries to the OS trash (Finder Trash / Recycle Bin) rather than
|
|
// deleting outright, so a mis-click in the file manager is recoverable.
|
|
[IPC.TRASH]: wrap(async (_event, targetPaths) => {
|
|
if (!Array.isArray(targetPaths) || targetPaths.length === 0) {
|
|
throw new Error("No local paths provided");
|
|
}
|
|
const failed = [];
|
|
for (const candidate of targetPaths) {
|
|
const target = normalizeLocalPath(candidate);
|
|
try {
|
|
await shell.trashItem(target);
|
|
} catch (error) {
|
|
failed.push({
|
|
path: target,
|
|
error: error && error.message ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
return { trashed: targetPaths.length - failed.length, failed };
|
|
}),
|
|
|
|
[IPC.ENSURE_DIR]: wrap(async (_event, dirPath, rootPath) => {
|
|
let target = normalizeLocalPath(dirPath);
|
|
// Transfers pass the folder the user picked; the directory skeleton of
|
|
// a downloaded tree must stay inside it.
|
|
if (rootPath !== undefined) {
|
|
target = (await prepareDownloadPath(rootPath, target, true)).path;
|
|
} else {
|
|
await fsp.mkdir(target, { recursive: true });
|
|
}
|
|
return { path: target };
|
|
}),
|
|
|
|
// Which of the given paths already exist; lets the renderer ask about
|
|
// collisions before a batch download starts.
|
|
[IPC.EXISTS]: wrap(async (_event, targetPaths) => {
|
|
if (!Array.isArray(targetPaths)) {
|
|
throw new Error("Expected a list of paths");
|
|
}
|
|
const existing = [];
|
|
for (const candidate of targetPaths) {
|
|
const target = normalizeLocalPath(candidate);
|
|
if (await pathExists(target)) existing.push(target);
|
|
}
|
|
return { existing };
|
|
}),
|
|
|
|
[IPC.WALK]: wrap(async (_event, rootPaths) => {
|
|
if (!Array.isArray(rootPaths) || rootPaths.length === 0) {
|
|
throw new Error("No local paths provided");
|
|
}
|
|
return walkPaths(rootPaths);
|
|
}),
|
|
|
|
[IPC.REVEAL]: wrap(async (_event, targetPath) => {
|
|
shell.showItemInFolder(normalizeLocalPath(targetPath));
|
|
}),
|
|
|
|
[IPC.OPEN]: wrap(async (_event, targetPath) => {
|
|
const error = await shell.openPath(normalizeLocalPath(targetPath));
|
|
if (error) throw new Error(error);
|
|
}),
|
|
|
|
[IPC.UPLOAD]: wrap((event, options) =>
|
|
uploadLocalFile(deps, event, options),
|
|
),
|
|
|
|
[IPC.DOWNLOAD]: wrap((event, options) =>
|
|
downloadToLocal(deps, event, options),
|
|
),
|
|
|
|
[IPC.CANCEL]: wrap(async (_event, transferId) => {
|
|
const state = activeTransfers.get(transferId);
|
|
if (!state) return { cancelled: false };
|
|
state.abort();
|
|
return { cancelled: true };
|
|
}),
|
|
};
|
|
}
|
|
|
|
function registerLocalFileHandlers({ ipcMain, shell }) {
|
|
// Real Electron wiring; tests build the handlers directly instead.
|
|
const { net } = require("electron");
|
|
const { getLinkedServer } = require("./linked-server.cjs");
|
|
const handlers = createLocalFileHandlers({
|
|
net,
|
|
shell,
|
|
getLinkedServer,
|
|
});
|
|
for (const [channel, handler] of Object.entries(handlers)) {
|
|
ipcMain.handle(channel, handler);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
IPC,
|
|
TRANSFER_ROUTES,
|
|
registerLocalFileHandlers,
|
|
// exported for tests / reuse
|
|
createLocalFileHandlers,
|
|
createTargetResolver,
|
|
setTransferApiPath,
|
|
setLocalBackendPort,
|
|
publishDownload,
|
|
defaultPublishFs,
|
|
assertWithinRoot,
|
|
walkPaths,
|
|
listDirectory,
|
|
};
|