Files
remoteconn-gitea/apps/miniprogram/utils/remoteDirectory.js
2026-03-21 18:57:10 +08:00

417 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* global console, module, require, clearTimeout, setTimeout */
/* eslint-disable no-control-regex */
const { createGatewayClient } = require("./gateway");
const { validateServerForConnect } = require("./serverValidation");
const { resolveSocketDomainHint } = require("./socketDomain");
/**
* 认证参数与 terminal 页保持一致,避免多处行为分叉。
*/
function normalizeAuthType(value) {
const authType = String(value || "").trim();
if (authType === "privateKey" || authType === "certificate") return authType;
return "password";
}
function resolveCredential(server, prefix) {
const source = server && typeof server === "object" ? server : {};
const fieldPrefix = String(prefix || "");
const authType = normalizeAuthType(
fieldPrefix ? source.jumpHost && source.jumpHost.authType : source.authType
);
const passwordKey = fieldPrefix ? `${fieldPrefix}Password` : "password";
const privateKeyKey = fieldPrefix ? `${fieldPrefix}PrivateKey` : "privateKey";
const passphraseKey = fieldPrefix ? `${fieldPrefix}Passphrase` : "passphrase";
const certificateKey = fieldPrefix ? `${fieldPrefix}Certificate` : "certificate";
if (authType === "privateKey") {
return {
type: "privateKey",
privateKey: String(source[privateKeyKey] || ""),
passphrase: String(source[passphraseKey] || "")
};
}
if (authType === "certificate") {
return {
type: "certificate",
privateKey: String(source[privateKeyKey] || ""),
passphrase: String(source[passphraseKey] || ""),
certificate: String(source[certificateKey] || "")
};
}
return {
type: "password",
password: String(source[passwordKey] || "")
};
}
function normalizeHomePath(input) {
const value = String(input || "").trim();
if (!value || value === "~") return "~";
if (!value.startsWith("~/")) return "~";
const body = value
.slice(2)
.split("/")
.map((part) => part.trim())
.filter((part) => part && part !== "." && part !== "..")
.join("/");
return body ? `~/${body}` : "~";
}
function quoteForShellSingle(value) {
return `'${String(value || "").replace(/'/g, `'"'"'`)}'`;
}
function buildListCommand(path, beginMarker, endMarker) {
const normalized = normalizeHomePath(path);
const relative = normalized === "~" ? "" : normalized.slice(2);
const relQuoted = quoteForShellSingle(relative);
const beginQuoted = quoteForShellSingle(beginMarker);
const endQuoted = quoteForShellSingle(endMarker);
// 外层仅执行 `sh -lc '...'`,避免依赖远端默认 shell 对 `A=B cmd` 前缀赋值语法的支持。
// 目录列举优先 command ls -1tp按修改时间从新到旧且绕过 alias再回退 find。
const script = [
`__RC_BEGIN=${beginQuoted}`,
`__RC_END=${endQuoted}`,
`__RC_REL=${relQuoted}`,
'if [ -z "$__RC_REL" ]; then __RC_DIR="$HOME"; else __RC_DIR="$HOME/$__RC_REL"; fi',
'printf "%s\\n" "$__RC_BEGIN"',
'if ! cd "$__RC_DIR" 2>/dev/null; then printf "__RCERR__CD__\\n"; printf "%s\\n" "$__RC_END"; exit 0; fi',
"if command -v ls >/dev/null 2>&1; then command ls -1tp 2>/dev/null | sed -n '/\\/$/s#/$##p' | sed '/^\\./d'; elif command -v find >/dev/null 2>&1; then find . -mindepth 1 -maxdepth 1 -type d ! -name '.*' -print 2>/dev/null | sed 's#^\\./##'; fi",
'printf "%s\\n" "$__RC_END"'
].join("; ");
const scriptQuoted = quoteForShellSingle(script);
return `sh -lc ${scriptQuoted}\n`;
}
function createStdoutCollector(beginMarker, endMarker) {
let buffer = "";
let done = false;
let collecting = false;
const extractedLines = [];
let beginSignalSeen = false;
function sanitizeChunk(input) {
return sanitizeTerminalChunk(input);
}
function consume(rawChunk) {
if (done) return;
buffer += sanitizeChunk(rawChunk);
if (!beginSignalSeen && buffer.includes(beginMarker)) {
beginSignalSeen = true;
}
// 必须按“整行”识别 begin/end 标记,避免命令回显折行时把标记误判成目录项。
while (!done) {
const newlineAt = buffer.indexOf("\n");
if (newlineAt < 0) break;
const rawLine = buffer.slice(0, newlineAt);
buffer = buffer.slice(newlineAt + 1);
const line = rawLine.replace(/\r/g, "");
const trimmed = line.trim();
if (!collecting) {
if (trimmed === beginMarker) {
collecting = true;
beginSignalSeen = true;
}
continue;
}
if (trimmed === endMarker) {
done = true;
break;
}
extractedLines.push(line);
}
}
function isDone() {
return done;
}
function hasBeginMarker() {
return collecting || done;
}
function hasBeginSignal() {
return beginSignalSeen || collecting || done;
}
function getLines() {
return extractedLines.map((line) => line.trim()).filter((line) => !!line);
}
return { consume, isDone, hasBeginMarker, hasBeginSignal, getLines };
}
function sanitizeTerminalChunk(input) {
return String(input || "")
.replace(/\r/g, "")
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, "")
.replace(/\u001bP[\s\S]*?\u001b\\/g, "")
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "")
.replace(/\u001b[@-~]/g, "");
}
function toErrorMessage(error, fallback) {
if (!error) return fallback;
if (typeof error === "string") return error;
if (typeof error.message === "string" && error.message.trim()) return error.message.trim();
if (typeof error.errMsg === "string" && error.errMsg.trim()) return error.errMsg.trim();
return fallback;
}
function isLikelyErrorLine(line) {
const text = String(line || "");
if (!text) return false;
return /^(?:ls|find|sh):|not found|no such file|permission denied/i.test(text);
}
function isInternalMarkerLine(line) {
const text = String(line || "");
const trimmed = text.trim();
if (!trimmed) return false;
if (/__RC_/i.test(trimmed)) return true;
if (/~\/__RC_/i.test(trimmed)) return true;
if (/(?:^|\s)gavin\b.*\b(?:%|#|\$)\b.*__RC_/i.test(trimmed)) return true;
const normalized = trimmed
.replace(/[\u001b\u009b][[()\]#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, "")
.replace(/[^a-zA-Z0-9_]/g, "")
.toUpperCase();
if (!normalized) return false;
if (normalized.includes("RCDIRBEGIN")) return true;
if (normalized.includes("RCDIREND")) return true;
if (normalized.includes("RCBEGIN")) return true;
if (normalized.includes("RCEND")) return true;
if (normalized.includes("RCREL")) return true;
if (normalized.includes("RCERR")) return true;
if (normalized.includes("LIST_REMOTEDIRECTORIES_DONE".replace(/[^A-Z0-9_]/g, ""))) return true;
if (!text) return false;
return false;
}
/**
* 通过一次短连接从远端目录读取子目录列表。
* 返回值仅包含“目录名”,不包含完整路径。
*/
function listRemoteDirectories(options) {
const params = options && typeof options === "object" ? options : {};
const server = params.server || {};
const opsConfig = params.opsConfig || {};
const targetPath = normalizeHomePath(params.path || "~");
const requestId = `dir_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
const requestStartedAt = Date.now();
const connectTimeoutMs = Number(opsConfig.gatewayConnectTimeoutMs);
const timeoutMs = Number(params.timeoutMs);
const maxWaitMs =
Number.isFinite(timeoutMs) && timeoutMs >= 1000
? timeoutMs
: Number.isFinite(connectTimeoutMs) && connectTimeoutMs >= 1000
? connectTimeoutMs + 10000
: 22000;
const beginMarker = `__RC_DIR_BEGIN_${Date.now()}_${Math.random().toString(36).slice(2, 8)}__`;
const endMarker = `__RC_DIR_END_${Date.now()}_${Math.random().toString(36).slice(2, 8)}__`;
const collector = createStdoutCollector(beginMarker, endMarker);
const command = buildListCommand(targetPath, beginMarker, endMarker);
console.info(
`[目录请求] 开始 id=${requestId} host=${String(server.host || "").trim() || "-"} ` +
`port=${Number(server.port) || 22} user=${String(server.username || "").trim() || "-"} path=${targetPath}`
);
return new Promise((resolve, reject) => {
let settled = false;
let connected = false;
let timer = null;
let commandAttempts = 0;
// 目录读取改为单次发送,避免重试导致重复 ls 拉长耗时。
const maxCommandAttempts = 1;
let sawFirstOutput = false;
const diagnosticLines = [];
function rememberDiagnostics(rawChunk) {
const lines = sanitizeTerminalChunk(rawChunk)
.split("\n")
.map((line) => line.trim())
.filter((line) => !!line)
.filter((line) => !isInternalMarkerLine(line));
lines.forEach((line) => {
if (diagnosticLines.includes(line)) return;
diagnosticLines.push(line);
});
if (diagnosticLines.length > 6) {
diagnosticLines.splice(0, diagnosticLines.length - 6);
}
}
function sendCommandAttempt(trigger) {
if (settled) return;
if (!connected) return;
if (collector.isDone()) return;
if (commandAttempts >= maxCommandAttempts) return;
commandAttempts += 1;
console.info(
`[目录请求] 发送命令 id=${requestId} attempt=${commandAttempts}/${maxCommandAttempts} ` +
`trigger=${String(trigger || "unknown")} elapsed=${Date.now() - requestStartedAt}ms`
);
try {
client.sendStdin(command);
} catch {
finishReject(new Error("目录命令发送失败"));
return;
}
}
const client = createGatewayClient({
gatewayUrl: opsConfig.gatewayUrl,
gatewayToken: opsConfig.gatewayToken,
connectTimeoutMs: opsConfig.gatewayConnectTimeoutMs,
onFrame: (frame) => {
if (settled) return;
if (
frame.type === "control" &&
frame.payload?.action === "connected" &&
!frame.payload?.fingerprint
) {
connected = true;
console.info(`[目录请求] 已连接 id=${requestId} elapsed=${Date.now() - requestStartedAt}ms`);
// connected 事件里立即首发,避免定时器抖动导致额外等待。
sendCommandAttempt("connected");
return;
}
if (frame.type === "control" && frame.payload?.action === "connected" && frame.payload?.fingerprint) {
return;
}
if (frame.type === "stdout" || frame.type === "stderr") {
const chunkText = frame.payload?.data || "";
if (!collector.hasBeginSignal()) {
rememberDiagnostics(chunkText);
}
if (!sawFirstOutput && String(chunkText || "").trim()) {
sawFirstOutput = true;
console.info(`[目录请求] 首包输出 id=${requestId} elapsed=${Date.now() - requestStartedAt}ms`);
// 若连接后尚未发出目录命令,首包输出到达时立即触发一次发送。
if (connected && commandAttempts === 0 && !collector.isDone()) {
sendCommandAttempt("first_output");
}
}
const beforeDone = collector.isDone();
collector.consume(chunkText);
const afterDone = collector.isDone();
if (beforeDone || !afterDone) return;
if (!collector.isDone()) return;
const rawLines = collector.getLines();
const hasCdError = rawLines.some((line) => line.trim() === "__RCERR__CD__");
if (hasCdError) {
finishReject(new Error(`目录不存在或无权限:${targetPath}`));
return;
}
const names = rawLines
.map((line) => line.trim())
.filter((line) => line && line !== "." && line !== "..")
.filter((line) => !isInternalMarkerLine(line))
.filter((line) => !line.startsWith("."))
.filter((line) => !isLikelyErrorLine(line))
.filter((line, index, arr) => arr.indexOf(line) === index);
console.info(
`[目录请求] 完成 id=${requestId} path=${targetPath} count=${names.length} ` +
`attempts=${commandAttempts} elapsed=${Date.now() - requestStartedAt}ms`
);
finishResolve(names);
return;
}
if (frame.type === "error") {
finishReject(new Error(frame.payload?.message || "网关错误"));
return;
}
if (frame.type === "control" && frame.payload?.action === "disconnect") {
const reason = String(frame.payload?.reason || "").trim();
if (!collector.isDone()) {
finishReject(new Error(reason ? `连接已断开: ${reason}` : "连接已断开"));
}
}
},
onClose: () => {
if (settled || collector.isDone()) return;
finishReject(new Error(connected ? "连接中断" : "网关连接失败"));
},
onError: (error) => {
if (settled) return;
finishReject(new Error(toErrorMessage(error, "网关连接失败")));
}
});
function cleanup() {
if (timer) {
clearTimeout(timer);
timer = null;
}
try {
client.disconnect("list_remote_directories_done");
} catch {
// ignore
}
}
function finishResolve(payload) {
if (settled) return;
settled = true;
cleanup();
resolve(payload);
}
function finishReject(error) {
if (settled) return;
settled = true;
console.warn(
`[目录请求] 失败 id=${requestId} path=${targetPath} attempts=${commandAttempts} ` +
`elapsed=${Date.now() - requestStartedAt}ms reason=${toErrorMessage(error, "unknown")}`
);
cleanup();
reject(error);
}
timer = setTimeout(() => {
const diagnostic = diagnosticLines.find((line) => !isLikelyErrorLine(line)) || diagnosticLines[0] || "";
if (diagnostic) {
finishReject(new Error(`目录读取超时,远端返回:${diagnostic}`));
return;
}
finishReject(new Error("目录读取超时,请稍后重试"));
}, maxWaitMs);
client
.connect({
host: server.jumpHost && server.jumpHost.enabled ? server.jumpHost.host : server.host,
port: server.jumpHost && server.jumpHost.enabled ? Number(server.jumpHost.port) || 22 : server.port,
username: server.jumpHost && server.jumpHost.enabled ? server.jumpHost.username : server.username,
credential:
server.jumpHost && server.jumpHost.enabled
? resolveCredential(server, "jump")
: resolveCredential(server),
...(server.jumpHost && server.jumpHost.enabled
? {
jumpHost: {
host: server.host,
port: server.port,
username: server.username,
credential: resolveCredential(server)
}
}
: {}),
cols: 80,
rows: 24
})
.catch((error) => {
finishReject(new Error(toErrorMessage(error, "网关连接失败")));
});
});
}
module.exports = {
listRemoteDirectories,
normalizeHomePath,
validateServerForConnect,
resolveSocketDomainHint
};