CH-J Server ManagerSSH के ज़रिए सर्वर प्रबंधन
मेनू
प्रकाशित सोर्स

CH-J Server Manager

किसी विशेष ऐप्लिकेशन रिलीज़ की डायरेक्टरी और फ़ाइलें देखें।

सोर्स ZIP डाउनलोड करें
CH-J / Server Manager 377 फ़ाइलें
core-20261019T111616Z-160a00bfcddc जोड़ा गया: · बिल्ड किया गया:
remoteFileService.js जोड़ा गया: तारीख दर्ज नहीं 32.9 KB · 587 पंक्तियाँ
कोड फ़ाइल डाउनलोड करें
1"use strict";
3const crypto = require("node:crypto");
4const fs = require("node:fs");
5const os = require("node:os");
6const path = require("node:path");
7const archiver = require("archiver");
9const MAX_TEXT_BYTES = 25 * 1024 * 1024;
10const MAX_TRANSFER_BYTES = 16 * 1024 * 1024 * 1024;
11const MAX_BATCH_ENTRIES = 10000;
12const MAX_SELECTED_ENTRIES = 100;
13const ARCHIVE_FORMATS = new Set(["zip", "tar", "tar.gz"]);
14const 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.
26const 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"]);
28const 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" });
30function fileError(code, message, extra = {}) {
31 const error = new Error(`${code}: ${message}`);
32 return Object.assign(error, { code }, extra);
35// Normalizes SFTP status errors to FILE_* codes; already classified errors pass through.
36function 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.");
42// Only these option fields reach the privileged helper; the plugin cannot set timeouts or other internals.
43function 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;
51function 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);
57function call(sftp, method, ...args) {
58 return new Promise((resolve, reject) => {
59 sftp[method](...args, (error, result) => error ? reject(error) : resolve(result));
60 });
63function 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;
69function 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);
75function 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]);
80class 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) || 0
111 };
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: true
131 })));
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 }
586module.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-2564f120a88dd917ef5c724cf559b178b155410cc3eb9ad9fe60596e61e93b60504

CH-J Proprietary Software License 1.14 · सोर्स CH-J Proprietary Software License 1.14 के तहत उपलब्ध कराया जाता है। इसकी उपलब्धता लाइसेंस की शर्तें नहीं बदलती और न ही अतिरिक्त अधिकार देती है।

आर्काइव SHA-256a9605c0870a223b6a8542bb0705edd51d8b04f3069582653417a8397fabcc09a
फ़ाइलें