Paskelbtas šaltinis
Atsisiųsti šaltinį ZIP
CH-J Server Manager
Naršyti aplankus ir failus konkrečiai programai išleisti.
CH-J / Server Manager
377 failai
core-20261019T111616Z-160a00bfcddc
Pridėta: · Pastatytas:
1
"use strict";3
const crypto = require("node:crypto");4
const fs = require("node:fs");5
const os = require("node:os");6
const path = require("node:path");7
const archiver = require("archiver");9
const MAX_TEXT_BYTES = 25 * 1024 * 1024;10
const MAX_TRANSFER_BYTES = 16 * 1024 * 1024 * 1024;11
const MAX_BATCH_ENTRIES = 10000;12
const MAX_SELECTED_ENTRIES = 100;13
const ARCHIVE_FORMATS = new Set(["zip", "tar", "tar.gz"]);14
const DEFINITE_EDITOR_FAILURES = new Set([15
"FILE_CONFLICT", "FILE_PERMISSION_DENIED", "FILE_NOT_FOUND", "FILE_UNSAFE_PATH", "FILE_UNSAFE_TYPE_OR_LINK",16
"FILE_UNSAFE_WORKSPACE", "FILE_UNSAFE_PRIVILEGED_DIRECTORY", "FILE_UPLOAD_INCOMPLETE", "FILE_TOO_LARGE",17
"FILE_METADATA_UNSUPPORTED", "FILE_REMOTE_IO_FAILED", "FILE_SUDO_FAILED", "FILE_DEPENDENCY_UNAVAILABLE",18
"FILE_SUDO_PASSWORD_INVALID", "FILE_SUDO_AUTHORIZATION_REQUIRED", "FILE_LINUX_REQUIRED",19
"FILE_HARDLINK_UNSUPPORTED", "FILE_METADATA_PERMISSION", "FILE_NO_SPACE", "FILE_READ_ONLY_FILESYSTEM",20
"FILE_SYMLINK_TARGET_MISSING", "FILE_SYMLINK_LOOP", "FILE_BUSY",21
// sudo refused before the helper started (shared PrivilegedExecutor codes).22
"SUDO_PASSWORD_REQUIRED", "SUDO_PASSWORD_INCORRECT", "SUDO_NOT_PERMITTED", "SUDO_TTY_REQUIRED", "SUDO_AUTH_FAILED",23
"SUDO_UNAVAILABLE", "SUDO_FAILED", "SUDO_PASSWORD_INVALID"24
]);25
// Removing these directly (even with sudo) is refused; their content can still be managed.26
const PROTECTED_PATHS = new Set(["/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib32", "/lib64", "/libx32", "/media", "/mnt",27
"/opt", "/proc", "/root", "/run", "/sbin", "/snap", "/srv", "/sys", "/tmp", "/usr", "/var"]);28
const SFTP_STATUS_CODES = Object.freeze({ 2: "FILE_NOT_FOUND", 3: "FILE_PERMISSION_DENIED", 6: "SESSION_NOT_CONNECTED", 7: "SESSION_NOT_CONNECTED", 8: "FILE_OPERATION_UNSUPPORTED" });30
function fileError(code, message, extra = {}) {31
const error = new Error(`${code}: ${message}`);32
return Object.assign(error, { code }, extra);33
}35
// Normalizes SFTP status errors to FILE_* codes; already classified errors pass through.36
function sftpError(error, message) {37
if (/^(FILE|SUDO|SESSION)_[A-Z_]+$/.test(String(error?.code || ""))) return error;38
const code = SFTP_STATUS_CODES[error?.code] || (error?.code === 4 ? "FILE_REMOTE_IO_FAILED" : "FILE_REMOTE_IO_FAILED");39
return fileError(code, message || error?.message || "Remote file operation failed.");40
}42
// Only these option fields reach the privileged helper; the plugin cannot set timeouts or other internals.43
function authorization(options = {}) {44
const result = {};45
if (options.sudo === true) result.sudo = true;46
if (options.sudoPassword !== undefined && options.sudoPassword !== null && options.sudoPassword !== "") result.sudoPassword = String(options.sudoPassword);47
if (options.rememberSudo === true) result.rememberSudo = true;48
return result;49
}51
function normalizeRemotePath(value) {52
const raw = String(value || "").trim();53
if (!raw || raw.length > 4096 || !raw.startsWith("/") || /[\0\r\n]/.test(raw)) throw new Error("Remote path must be an absolute path.");54
return path.posix.normalize(raw);55
}57
function call(sftp, method, ...args) {58
return new Promise((resolve, reject) => {59
sftp[method](...args, (error, result) => error ? reject(error) : resolve(result));60
});61
}63
function safeEntryName(value) {64
const name = String(value || "");65
if (!name || name === "." || name === ".." || /[\/\\\0]/.test(name)) throw new Error("Remote entry name cannot be represented safely on the local filesystem.");66
return name;67
}69
function selectedPaths(result) {70
if (!result || result.canceled) return [];71
const values = Array.isArray(result.paths) ? result.paths : result.path ? [result.path] : [];72
return [...new Set(values.map(String).filter(Boolean))].slice(0, MAX_SELECTED_ENTRIES + 1);73
}75
function sortEntries(entries) {76
const order = { directory: 0, file: 1, symlink: 2 };77
return entries.sort((left, right) => order[left.type] === order[right.type] ? left.name.localeCompare(right.name) : order[left.type] - order[right.type]);78
}80
class RemoteFileService {81
constructor(options) {82
this.sessionManager = options.sessionManager;83
this.selectUploadPath = options.selectUploadPath;84
this.selectDownloadPath = options.selectDownloadPath;85
this.selectDownloadDirectory = options.selectDownloadDirectory;86
this.selectArchivePath = options.selectArchivePath;87
this.privilegedExecutor = options.privilegedExecutor;88
this.logger = options.logger;89
this.edits = new Map();90
this.legacyEdits = new Map();91
}93
async list(sessionId, remotePath, options = {}) {94
const target = normalizeRemotePath(remotePath || "/");95
if (options.sudo === true) return this._privilegedList(sessionId, target, options);96
return this._withSftp(sessionId, async (sftp) => {97
let entries;98
try { entries = await call(sftp, "readdir", target); }99
catch (error) { throw sftpError(error, `Directory ${target} cannot be listed.`); }100
const result = (entries || []).filter((entry) => entry.filename && entry.filename !== "." && entry.filename !== "..").map((entry) => {101
const attrs = entry.attrs || {};102
const isDirectory = typeof attrs.isDirectory === "function" && attrs.isDirectory();103
const isSymbolicLink = typeof attrs.isSymbolicLink === "function" && attrs.isSymbolicLink();104
return {105
name: String(entry.filename),106
path: path.posix.join(target, entry.filename),107
type: isDirectory ? "directory" : isSymbolicLink ? "symlink" : "file",108
size: Math.max(0, Number(attrs.size) || 0),109
modifiedAt: Number(attrs.mtime) > 0 ? new Date(Number(attrs.mtime) * 1000).toISOString() : null,110
mode: Number(attrs.mode) || 0111
};112
});113
// A link to a directory must be navigable, a link to a file editable (the target is edited).114
await Promise.all(result.filter((entry) => entry.type === "symlink").slice(0, 500).map(async (entry) => {115
const attrs = await call(sftp, "stat", entry.path).catch(() => null);116
entry.targetType = !attrs ? "missing" : typeof attrs.isDirectory === "function" && attrs.isDirectory() ? "directory" : "file";117
if (attrs && entry.targetType === "file") entry.size = Math.max(0, Number(attrs.size) || 0);118
}));119
return sortEntries(result);120
});121
}123
async _privilegedList(sessionId, target, options) {124
const value = await this.helper(sessionId, { operation: "listdir", path: target }, { ...options, sudo: true });125
return sortEntries((value.entries || []).map((entry) => ({126
name: String(entry.name), path: path.posix.join(target, String(entry.name)),127
type: entry.type === "directory" ? "directory" : entry.type === "symlink" ? "symlink" : "file",128
targetType: entry.targetType, size: Math.max(0, Number(entry.size) || 0),129
modifiedAt: Number(entry.mtime) > 0 ? new Date(Number(entry.mtime) * 1000).toISOString() : null,130
mode: Number(entry.mode) || 0, privileged: true131
})));132
}134
// Shared fixed helper (remoteEditor.py) for editor, privileged file operations and transfers.135
helper(sessionId, request, options = {}, internal = {}) {136
return this.sessionManager.remoteEditor(sessionId, request, { ...authorization(options), ...internal });137
}139
async privileges(sessionId) {140
if (!this.privilegedExecutor) throw fileError("FILE_PRIVILEGES_UNAVAILABLE", "Privilege detection is unavailable.");141
const privilege = await this.privilegedExecutor.probe(sessionId);142
return { ...privilege, sudoCached: this.privilegedExecutor.hasCachedPassword?.(sessionId) === true };143
}145
_editorPath(value) {146
const raw = String(value || "");147
if (!raw || raw.length > 4096 || !raw.startsWith("/") || /[\0\r\n]/.test(raw)) throw new Error("Remote path must be an absolute path.");148
if (raw.split("/").some((part) => part === "." || part === "..")) throw new Error("Editor paths cannot contain traversal components.");149
return path.posix.normalize(raw);150
}152
_identity(sessionId) {153
const session = this.sessionManager.list().find((entry) => entry.sessionId === sessionId && entry.state === "connected");154
if (!session) throw new Error("The editor session is not connected.");155
return JSON.stringify([sessionId, session.profileId, session.host, session.port, session.username]);156
}158
async readText(sessionId, remotePath, options = {}, owner = "core") {159
const target = this._editorPath(remotePath);160
const identity = this._identity(sessionId);161
const value = await this.sessionManager.remoteEditor(sessionId, { operation: "read", path: target }, authorization(options));162
const data = Buffer.from(value.data, "base64");163
if (data.length > MAX_TEXT_BYTES) throw new Error(`Text editor limit is ${MAX_TEXT_BYTES} bytes.`);164
let text;165
try { text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(data); }166
catch { throw new Error("The selected file is not valid UTF-8 text."); }167
if (crypto.createHash("sha256").update(data).digest("hex") !== value.baseline.hash) throw new Error("Remote editor content verification failed.");168
if (this.edits.size >= 1000) throw new Error("Close unused editor documents before opening more files.");169
const editId = crypto.randomBytes(16).toString("hex");170
this.edits.set(editId, { target, identity, owner, baseline: value.baseline, resolvedPath: value.resolvedPath, busy: false, uncertain: false });171
const key = JSON.stringify([owner, identity, target]);172
// A legacy call has no document identifier. Once two documents are open,173
// choosing either baseline would silently defeat conflict detection.174
this.legacyEdits.set(key, this.legacyEdits.has(key) ? null : editId);175
return { path: target, text, size: data.length, editId, baselineHash: value.baseline.hash, metadata: value.baseline,176
resolvedPath: value.resolvedPath || target, link: value.link === true, directSave: value.directSave !== false, privileged: options.sudo === true,177
modifiedAt: new Date(Number(BigInt(value.baseline.mtimeNs) / 1000000n)).toISOString() };178
}180
async writeText(sessionId, remotePath, text, options = {}, owner = "core") {181
const target = this._editorPath(remotePath);182
const data = Buffer.from(String(text ?? ""), "utf8");183
if (data.length > MAX_TEXT_BYTES) throw new Error(`Text editor limit is ${MAX_TEXT_BYTES} bytes.`);184
const identity = this._identity(sessionId);185
const legacyId = this.legacyEdits.get(JSON.stringify([owner, identity, target]));186
if (!options.editId && legacyId === null) {187
const error = new Error("FILE_EDIT_ID_REQUIRED: Multiple documents are open for this path; pass the editId returned by readText.");188
error.code = "FILE_EDIT_ID_REQUIRED";189
throw error;190
}191
const editId = options.editId || legacyId;192
const edit = this.edits.get(editId);193
if (!edit || edit.target !== target || edit.identity !== identity || edit.owner !== owner) throw new Error("Reopen this file to capture an editor baseline before saving.");194
if (edit.busy || edit.uncertain) {195
const error = new Error("FILE_RESULT_UNKNOWN: Inspect recovery before starting another save.");196
error.code = "FILE_RESULT_UNKNOWN";197
throw error;198
}199
edit.busy = true;200
let recovery;201
let submitted = false;202
try {203
const hash = crypto.createHash("sha256").update(data).digest("hex");204
const id = crypto.randomBytes(16).toString("hex");205
const resolved = edit.resolvedPath ? { resolvedPath: edit.resolvedPath } : {};206
recovery = await this.sessionManager.remoteEditor(sessionId, { operation: "prepare", id, path: target, baseline: edit.baseline, hash, ...resolved });207
await this._uploadEditorCopy(sessionId, recovery.temporary, data);208
submitted = true;209
const saved = await this.sessionManager.remoteEditor(sessionId, {210
operation: "finalize", id, uid: recovery.uid, path: target, baseline: edit.baseline, hash, ...resolved, ...(options.backup === true ? { backup: true } : {})211
}, authorization(options));212
if (saved.status !== "saved" || saved.hash !== hash || saved.baseline?.hash !== hash) {213
const error = new Error("Remote save could not be confirmed."); error.code = "FILE_RESULT_UNKNOWN"; throw error;214
}215
edit.baseline = saved.baseline;216
let recoveryAvailable = false;217
try { await this.sessionManager.remoteEditor(sessionId, { operation: "cleanup", id, uid: recovery.uid }, authorization(options)); }218
catch { recoveryAvailable = true; }219
this.logger?.info("Remote editor save confirmed.", { sessionId, path: target, resolvedPath: saved.resolvedPath, privileged: options.sudo === true, bytes: data.length });220
return { path: target, size: data.length, status: "saved", editId, baselineHash: hash, recoveryId: id, recoveryAvailable,221
resolvedPath: saved.resolvedPath || target, link: saved.link === true, backupPath: saved.backupPath || null };222
} catch (error) {223
const knownFailure = DEFINITE_EDITOR_FAILURES.has(error.code);224
edit.uncertain = submitted && !knownFailure;225
error.code = edit.uncertain ? "FILE_RESULT_UNKNOWN" : error.code || "FILE_UPLOAD_FAILED";226
error.recoveryId = recovery?.recoveryId;227
error.recoveryPath = recovery?.temporary;228
error.status = edit.uncertain ? "unknown" : "failed";229
error.message = `${error.code}: Save was not confirmed. Keep editor content and inspect recovery${recovery ? " (" + recovery.recoveryId + ")" : ""}.`;230
this.logger?.warn("Remote editor save not confirmed.", { sessionId, path: target, code: error.code, status: error.status, privileged: options.sudo === true });231
throw error;232
} finally {233
edit.busy = false;234
if (edit.dispose) this.closeText(editId, owner);235
}236
}238
async saveText(sessionId, remotePath, text, options = {}, owner = "core") {239
try { return { ok: true, value: await this.writeText(sessionId, remotePath, text, options, owner) }; }240
catch (error) { return { ok: false, error: { code: error.code || "FILE_SAVE_FAILED", message: error.message,241
status: error.status || "failed", recoveryId: error.recoveryId, recoveryPath: error.recoveryPath } }; }242
}244
_uploadEditorCopy(sessionId, temporary, data) {245
return new Promise((resolve, reject) => {246
let sftp;247
let settled = false;248
const finish = (error) => {249
if (settled) return;250
settled = true;251
clearTimeout(timer);252
try { sftp?.end?.(); } catch {}253
if (error) reject(error); else resolve();254
};255
const timer = setTimeout(() => finish(Object.assign(new Error("Editor upload timed out."), { code: "FILE_UPLOAD_TIMEOUT" })), 60000);256
timer.unref?.();257
Promise.resolve().then(() => this.sessionManager.openSftp(sessionId)).then((channel) => {258
if (settled) { try { channel.end?.(); } catch {} return; }259
sftp = channel;260
call(sftp, "writeFile", temporary, data, { flag: "wx", mode: 0o600 }).then(() => finish(), finish);261
}, finish);262
});263
}265
async _recovery(sessionId, operation, recoveryId, options = {}) {266
const request = { operation, id: recoveryId };267
if (options.sudo === true) {268
const identity = await this.sessionManager.remoteEditor(sessionId, { operation: "identity" });269
request.uid = identity.uid;270
}271
return this.sessionManager.remoteEditor(sessionId, request, authorization(options));272
}274
listRecovery(sessionId, options = {}) {275
return this._recovery(sessionId, "list", undefined, options);276
}278
async readRecovery(sessionId, recoveryId, options = {}) {279
const result = await this._recovery(sessionId, "inspect", recoveryId, options);280
const data = Buffer.from(result.data, "base64");281
if (!result.complete || data.length > MAX_TEXT_BYTES || crypto.createHash("sha256").update(data).digest("hex") !== result.hash) {282
throw new Error("FILE_RECOVERY_UNVERIFIED: Recovery content could not be verified.");283
}284
let text;285
try { text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(data); }286
catch { throw new Error("Recovery file is not valid UTF-8 text."); }287
const { data: _data, ...summary } = result;288
return { ...summary, text };289
}291
cleanupRecovery(sessionId, recoveryId, options = {}) {292
return this._recovery(sessionId, "cleanup", recoveryId, options);293
}295
closeText(editId, owner = "core") {296
const edit = this.edits.get(editId);297
if (!edit || edit.owner !== owner || edit.busy) return { closed: false };298
this.edits.delete(editId);299
const key = JSON.stringify([owner, edit.identity, edit.target]);300
const remaining = [...this.edits].filter(([, other]) => other.owner === owner && other.identity === edit.identity && other.target === edit.target);301
if (!remaining.length) this.legacyEdits.delete(key);302
else this.legacyEdits.set(key, remaining.length === 1 ? remaining[0][0] : null);303
return { closed: true };304
}306
cleanupPlugin(owner) {307
for (const [id, edit] of this.edits) {308
if (edit.owner !== owner) continue;309
if (edit.busy) edit.dispose = true;310
else this.closeText(id, owner);311
}312
}314
async createFile(sessionId, remotePath, options = {}) {315
const target = normalizeRemotePath(remotePath);316
if (target === "/") throw new Error("The remote root cannot be a file.");317
if (options.sudo === true) return this._privilegedMutation(sessionId, { operation: "create", path: target }, options, "Remote file created with privileges.", { path: target, size: 0 });318
return this._withSftp(sessionId, async (sftp) => {319
try { await call(sftp, "writeFile", target, Buffer.alloc(0), { flag: "wx", mode: 0o644 }); }320
catch (error) { throw await this._mutationError(sftp, error, target, `File ${target} could not be created.`); }321
this.logger?.warn("Remote file created.", { sessionId, path: target });322
return { path: target, size: 0 };323
});324
}326
async mkdir(sessionId, remotePath, options = {}) {327
const target = normalizeRemotePath(remotePath);328
if (target === "/") throw new Error("The remote root already exists.");329
if (options.sudo === true) return this._privilegedMutation(sessionId, { operation: "mkdir", path: target }, options, "Remote directory created with privileges.", { path: target });330
return this._withSftp(sessionId, async (sftp) => {331
try { await call(sftp, "mkdir", target, { mode: 0o755 }); }332
catch (error) { throw await this._mutationError(sftp, error, target, `Directory ${target} could not be created.`); }333
this.logger?.warn("Remote directory created.", { sessionId, path: target });334
return { path: target };335
});336
}338
async rename(sessionId, fromPath, toPath, options = {}) {339
const from = normalizeRemotePath(fromPath); const to = normalizeRemotePath(toPath);340
if (from === "/" || to === "/") throw new Error("The remote root cannot be renamed.");341
if (PROTECTED_PATHS.has(from)) throw fileError("FILE_PROTECTED_PATH", `${from} is a protected system directory.`);342
if (options.sudo === true) return this._privilegedMutation(sessionId, { operation: "rename", from, to }, options, "Remote entry renamed with privileges.", { from, to });343
return this._withSftp(sessionId, async (sftp) => {344
try { await call(sftp, "rename", from, to); }345
catch (error) { throw await this._mutationError(sftp, error, to, `${from} could not be renamed to ${to}.`); }346
this.logger?.warn("Remote entry renamed.", { sessionId, from, to });347
return { from, to };348
});349
}351
async remove(sessionId, remotePath, options = {}) {352
const target = normalizeRemotePath(remotePath);353
if (target === "/") throw new Error("The remote root cannot be removed.");354
if (PROTECTED_PATHS.has(target)) throw fileError("FILE_PROTECTED_PATH", `${target} is a protected system directory.`);355
if (options.sudo === true) {356
const value = await this._privilegedMutation(sessionId, { operation: "remove", path: target }, options, "Remote entry removed with privileges.");357
return { path: target, type: value.type === "directory" ? "directory" : "file" };358
}359
return this._withSftp(sessionId, async (sftp) => {360
try {361
const attrs = await call(sftp, "lstat", target);362
const isDirectory = typeof attrs?.isDirectory === "function" && attrs.isDirectory();363
await call(sftp, isDirectory ? "rmdir" : "unlink", target);364
this.logger?.warn("Remote entry removed.", { sessionId, path: target, type: isDirectory ? "directory" : "file" });365
return { path: target, type: isDirectory ? "directory" : "file" };366
} catch (error) { throw sftpError(error, `${target} could not be removed.`); }367
});368
}370
async removeMany(sessionId, remotePaths, recursive = false, options = {}) {371
const targets = this._normalizeSelection(remotePaths);372
if (targets.some((target) => target === "/")) throw new Error("The remote root cannot be removed.");373
const protectedTarget = targets.find((target) => PROTECTED_PATHS.has(target));374
if (protectedTarget) throw fileError("FILE_PROTECTED_PATH", `${protectedTarget} is a protected system directory.`);375
const ordered = [...targets].sort((left, right) => right.length - left.length);376
if (options.sudo === true) {377
let entries = 0;378
for (const target of ordered) entries += Number((await this.helper(sessionId, { operation: "remove", path: target, recursive: Boolean(recursive) }, { ...options, sudo: true })).entries) || 1;379
this.logger?.warn("Remote entries removed with privileges.", { sessionId, selected: targets.length, entries, recursive: Boolean(recursive) });380
return { selected: targets.length, entries };381
}382
return this._withSftp(sessionId, async (sftp) => {383
const state = { entries: 0, bytes: 0 };384
try { for (const target of ordered) await this._removeRemoteEntry(sftp, target, Boolean(recursive), state); }385
catch (error) { throw sftpError(error, "Remote entries could not be removed."); }386
this.logger?.warn("Remote entries removed.", { sessionId, selected: targets.length, entries: state.entries, recursive: Boolean(recursive) });387
return { selected: targets.length, entries: state.entries };388
});389
}391
async _privilegedMutation(sessionId, request, options, logMessage, result) {392
const value = await this.helper(sessionId, request, { ...options, sudo: true });393
this.logger?.warn(logMessage, { sessionId, operation: request.operation, path: request.path || request.from, to: request.to });394
return result || value;395
}397
// OpenSSH reports an existing target as a generic failure; tell the user what actually happened.398
async _mutationError(sftp, error, target, message) {399
if (error?.code === 4 || error?.code === 11) {400
const existing = await call(sftp, "lstat", target).catch(() => null);401
if (existing) return fileError("FILE_EXISTS", `${target} already exists.`);402
}403
return sftpError(error, message);404
}406
async upload(sessionId, remoteDirectory) {407
const directory = normalizeRemotePath(remoteDirectory || "/");408
if (typeof this.selectUploadPath !== "function") throw new Error("Upload dialog is unavailable.");409
const selected = await this.selectUploadPath();410
const localPaths = selectedPaths(selected);411
if (!localPaths.length) return { canceled: true, items: [] };412
if (localPaths.length > MAX_SELECTED_ENTRIES) throw new Error(`At most ${MAX_SELECTED_ENTRIES} entries can be uploaded at once.`);413
return this._withSftp(sessionId, async (sftp) => {414
const state = { entries: 0, bytes: 0 };415
const items = [];416
for (const localPath of localPaths) {417
const target = path.posix.join(directory, path.basename(localPath));418
const result = await this._uploadLocalEntry(sftp, localPath, target, state);419
items.push({ path: target, type: result.type, size: result.size });420
}421
this.logger?.info("Entries uploaded through Core.", { sessionId, directory, entries: state.entries, bytes: state.bytes });422
return { canceled: false, items, entries: state.entries, size: state.bytes };423
});424
}426
async download(sessionId, remotePath) {427
const target = normalizeRemotePath(remotePath);428
if (typeof this.selectDownloadPath !== "function") throw new Error("Download dialog is unavailable.");429
return this._withSftp(sessionId, async (sftp) => {430
const attrs = await call(sftp, "stat", target);431
if (typeof attrs?.isDirectory === "function" && attrs.isDirectory()) throw new Error("Directory download is not available in this first File Manager version.");432
const size = Math.max(0, Number(attrs?.size) || 0);433
if (size > MAX_TRANSFER_BYTES) throw new Error("Remote file exceeds the transfer size limit.");434
const selected = await this.selectDownloadPath(path.posix.basename(target));435
if (!selected || selected.canceled || !selected.path) return { canceled: true };436
await call(sftp, "fastGet", target, selected.path);437
this.logger?.info("File downloaded through Core.", { sessionId, path: target, bytes: size });438
return { canceled: false, path: target, size };439
});440
}442
async downloadMany(sessionId, remotePaths) {443
const targets = this._normalizeSelection(remotePaths);444
if (typeof this.selectDownloadDirectory !== "function") throw new Error("Download directory dialog is unavailable.");445
const selected = await this.selectDownloadDirectory();446
if (!selected || selected.canceled || !selected.path) return { canceled: true, items: [] };447
return this._withSftp(sessionId, async (sftp) => {448
const state = { entries: 0, bytes: 0 };449
const items = [];450
for (const target of targets) {451
if (target === "/") throw new Error("Downloading the entire remote root is not allowed in one batch.");452
const localPath = path.join(selected.path, safeEntryName(path.posix.basename(target)));453
const result = await this._downloadRemoteEntry(sftp, target, localPath, state);454
items.push({ path: target, type: result.type, size: result.size });455
}456
this.logger?.info("Entries downloaded through Core.", { sessionId, entries: state.entries, bytes: state.bytes });457
return { canceled: false, items, entries: state.entries, size: state.bytes };458
});459
}461
async downloadArchive(sessionId, remotePaths, requestedFormat) {462
const targets = this._normalizeSelection(remotePaths);463
if (targets.some((target) => target === "/")) throw new Error("Archiving the entire remote root is not allowed.");464
const format = String(requestedFormat || "zip").toLowerCase() === "tgz" ? "tar.gz" : String(requestedFormat || "zip").toLowerCase();465
if (!ARCHIVE_FORMATS.has(format)) throw new Error("Supported archive formats are zip, tar and tar.gz.");466
if (typeof this.selectArchivePath !== "function") throw new Error("Archive save dialog is unavailable.");467
const baseName = targets.length === 1 ? safeEntryName(path.posix.basename(targets[0])) : "remote-selection";468
const selected = await this.selectArchivePath(`${baseName}.${format}`, format);469
if (!selected || selected.canceled || !selected.path) return { canceled: true };470
const temporaryRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "chj-file-archive-"));471
try {472
const transfer = await this._withSftp(sessionId, async (sftp) => {473
const state = { entries: 0, bytes: 0 };474
for (const target of targets) {475
await this._downloadRemoteEntry(sftp, target, path.join(temporaryRoot, safeEntryName(path.posix.basename(target))), state);476
}477
return state;478
});479
const archiveSize = await this._createArchive(temporaryRoot, selected.path, format);480
this.logger?.info("Remote selection archived through Core.", { sessionId, format, entries: transfer.entries, sourceBytes: transfer.bytes, archiveBytes: archiveSize });481
return { canceled: false, path: selected.path, format, entries: transfer.entries, sourceSize: transfer.bytes, size: archiveSize };482
} finally {483
await fs.promises.rm(temporaryRoot, { recursive: true, force: true }).catch(() => {});484
}485
}487
_normalizeSelection(remotePaths) {488
const values = Array.isArray(remotePaths) ? remotePaths : [remotePaths];489
const targets = [...new Set(values.filter((value) => value !== undefined && value !== null).map(normalizeRemotePath))];490
if (!targets.length || targets.length > MAX_SELECTED_ENTRIES) throw new Error(`Select between 1 and ${MAX_SELECTED_ENTRIES} remote entries.`);491
return targets;492
}494
_countTransfer(state, bytes = 0) {495
state.entries += 1;496
state.bytes += Math.max(0, Number(bytes) || 0);497
if (state.entries > MAX_BATCH_ENTRIES) throw new Error(`Transfer exceeds the ${MAX_BATCH_ENTRIES} entry limit.`);498
if (state.bytes > MAX_TRANSFER_BYTES) throw new Error("Transfer exceeds the aggregate size limit.");499
}501
async _uploadLocalEntry(sftp, localPath, remotePath, state) {502
const attrs = await fs.promises.lstat(localPath);503
if (attrs.isSymbolicLink()) throw new Error("Uploading symbolic links is not supported.");504
if (attrs.isDirectory()) {505
this._countTransfer(state);506
try { await call(sftp, "mkdir", remotePath, { mode: 0o755 }); }507
catch (error) {508
const existing = await call(sftp, "stat", remotePath).catch(() => null);509
if (!existing || typeof existing.isDirectory !== "function" || !existing.isDirectory()) throw error;510
}511
for (const entry of await fs.promises.readdir(localPath, { withFileTypes: true })) {512
const name = safeEntryName(entry.name);513
await this._uploadLocalEntry(sftp, path.join(localPath, name), path.posix.join(remotePath, name), state);514
}515
return { type: "directory", size: 0 };516
}517
if (!attrs.isFile()) throw new Error("Only regular files and directories can be uploaded.");518
this._countTransfer(state, attrs.size);519
const temporary = path.posix.join(path.posix.dirname(remotePath), `.${path.posix.basename(remotePath)}.chj-upload-${crypto.randomBytes(6).toString("hex")}.tmp`);520
try {521
await call(sftp, "fastPut", localPath, temporary);522
await call(sftp, "rename", temporary, remotePath);523
} catch (error) {524
try { await call(sftp, "unlink", temporary); } catch {}525
throw error;526
}527
return { type: "file", size: attrs.size };528
}530
async _downloadRemoteEntry(sftp, remotePath, localPath, state) {531
const attrs = await call(sftp, "lstat", remotePath);532
if (typeof attrs?.isSymbolicLink === "function" && attrs.isSymbolicLink()) throw new Error(`Symbolic links are not included in recursive downloads: ${remotePath}`);533
if (typeof attrs?.isDirectory === "function" && attrs.isDirectory()) {534
this._countTransfer(state);535
await fs.promises.mkdir(localPath, { recursive: false, mode: 0o700 }).catch((error) => { if (error.code !== "EEXIST") throw error; });536
for (const entry of await call(sftp, "readdir", remotePath) || []) {537
const name = safeEntryName(entry.filename);538
await this._downloadRemoteEntry(sftp, path.posix.join(remotePath, name), path.join(localPath, name), state);539
}540
return { type: "directory", size: 0 };541
}542
const size = Math.max(0, Number(attrs?.size) || 0);543
this._countTransfer(state, size);544
await fs.promises.mkdir(path.dirname(localPath), { recursive: true, mode: 0o700 });545
const temporary = path.join(path.dirname(localPath), `.${path.basename(localPath)}.chj-download-${crypto.randomBytes(6).toString("hex")}.tmp`);546
try {547
await call(sftp, "fastGet", remotePath, temporary);548
await fs.promises.rename(temporary, localPath);549
} catch (error) {550
await fs.promises.unlink(temporary).catch(() => {});551
throw error;552
}553
return { type: "file", size };554
}556
async _removeRemoteEntry(sftp, remotePath, recursive, state) {557
const attrs = await call(sftp, "lstat", remotePath);558
this._countTransfer(state, Number(attrs?.size) || 0);559
if (typeof attrs?.isDirectory === "function" && attrs.isDirectory()) {560
if (recursive) for (const entry of await call(sftp, "readdir", remotePath) || []) await this._removeRemoteEntry(sftp, path.posix.join(remotePath, safeEntryName(entry.filename)), true, state);561
await call(sftp, "rmdir", remotePath);562
} else await call(sftp, "unlink", remotePath);563
}565
_createArchive(sourceRoot, destination, format) {566
return new Promise((resolve, reject) => {567
const output = fs.createWriteStream(destination, { flags: "w", mode: 0o600 });568
const archive = format === "zip" ? archiver("zip", { zlib: { level: 9 } }) : archiver("tar", { gzip: format === "tar.gz", gzipOptions: { level: 9 } });569
let settled = false;570
const fail = (error) => { if (settled) return; settled = true; try { archive.destroy(); } catch {} reject(error); };571
output.on("close", () => { if (!settled) { settled = true; resolve(Number(archive.pointer()) || 0); } });572
output.on("error", fail); archive.on("warning", fail); archive.on("error", fail);573
archive.pipe(output);574
archive.directory(sourceRoot, false);575
Promise.resolve(archive.finalize()).catch(fail);576
});577
}579
async _withSftp(sessionId, action) {580
const sftp = await this.sessionManager.openSftp(sessionId);581
try { return await action(sftp); }582
finally { try { sftp.end?.(); } catch {} }583
}584
}586
module.exports = { ARCHIVE_FORMATS, DEFINITE_EDITOR_FAILURES, MAX_BATCH_ENTRIES, MAX_TEXT_BYTES, MAX_TRANSFER_BYTES, PROTECTED_PATHS, RemoteFileService,587
authorization, fileError, normalizeRemotePath, safeEntryName, sftpError };SHA-256
4f120a88dd917ef5c724cf559b178b155410cc3eb9ad9fe60596e61e93b60504CH-J Proprietary Software License 1.14 · Šaltinis pateiktas pagal CH-J Proprietary Software License 1.14. Jo prieinamumas nekeičia licencijos sąlygų ir nesuteikia papildomų teisių.
Archyvas SHA-256
a9605c0870a223b6a8542bb0705edd51d8b04f3069582653417a8397fabcc09a