From 4edbeb8f2da99c3e80b8226dda39b51607db1829 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=85=AC=E6=98=8E?=
<83812544+Ed1s0nZ@users.noreply.github.com>
Date: Sun, 7 Jun 2026 20:15:44 +0800
Subject: [PATCH] Add files via upload
---
web/static/css/c2.css | 60 +++++
web/static/i18n/en-US.json | 10 +
web/static/i18n/zh-CN.json | 10 +
web/static/js/c2.js | 520 +++++++++++++++++++++++++++++++++++--
4 files changed, 579 insertions(+), 21 deletions(-)
diff --git a/web/static/css/c2.css b/web/static/css/c2.css
index b6881b6c..0aedb3e2 100644
--- a/web/static/css/c2.css
+++ b/web/static/css/c2.css
@@ -772,6 +772,66 @@
border: 1px solid var(--c2-border);
}
+#c2-file-upload-btn.is-disabled,
+#c2-file-upload-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ color: var(--c2-text-dim, #94a3b8);
+ border-color: var(--c2-border, #e2e8f0);
+}
+
+.c2-file-upload-hint {
+ font-size: 12px;
+ color: #b45309;
+ background: rgba(245, 158, 11, 0.08);
+ border: 1px solid rgba(245, 158, 11, 0.25);
+ border-radius: var(--c2-radius-xs, 4px);
+ padding: 8px 12px;
+ margin: -8px 0 12px;
+ line-height: 1.5;
+ word-break: break-word;
+}
+
+.c2-file-upload-hint[hidden] {
+ display: none !important;
+}
+
+.c2-file-upload-progress {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: -8px 0 12px;
+ padding: 0 4px;
+}
+
+.c2-file-upload-progress[hidden] {
+ display: none !important;
+}
+
+.c2-file-upload-progress-track {
+ flex: 1;
+ height: 4px;
+ background: var(--c2-border);
+ border-radius: 2px;
+ overflow: hidden;
+}
+
+.c2-file-upload-progress-fill {
+ height: 100%;
+ width: 0;
+ background: var(--c2-accent, #3b82f6);
+ transition: width 0.2s ease;
+}
+
+.c2-file-upload-progress-label {
+ font-size: 11px;
+ color: var(--c2-text-dim);
+ white-space: nowrap;
+ max-width: 220px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
.c2-file-list {
background: var(--c2-surface);
border-radius: var(--c2-radius);
diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json
index d5194a5c..7241012a 100644
--- a/web/static/i18n/en-US.json
+++ b/web/static/i18n/en-US.json
@@ -2546,6 +2546,15 @@
"files": {
"parent": "Parent",
"refresh": "Refresh",
+ "upload": "Upload",
+ "uploading": "Uploading {{name}} · {{percent}}%",
+ "uploadOk": "Uploaded",
+ "uploadQueued": "Upload task queued",
+ "uploadPendingApproval": "Upload task pending HITL approval",
+ "uploadUnsupported": "Upload is not supported for this session",
+ "uploadCurlBeacon": "Curl beacons cannot upload files; use an HTTP Beacon",
+ "uploadTcpShell": "This is a TCP reverse shell (bash/nc): commands and download only. For upload, reconnect with: (1) a compiled CSB1 Beacon on the same listener, or (2) an HTTP/HTTPS Beacon.",
+ "uploadTcpReverse": "This is a TCP reverse shell (bash/nc): commands and download only. For upload, reconnect with: (1) a compiled CSB1 Beacon on the same listener, or (2) an HTTP/HTTPS Beacon.",
"loading": "Loading…",
"timeout": "Timed out loading files",
"emptyDir": "Empty directory",
@@ -2555,6 +2564,7 @@
"colActions": "Actions",
"open": "Open",
"download": "Download",
+ "downloadOk": "Downloaded",
"failed": "Failed"
},
"listeners": {
diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json
index c2dcd649..d7ee6d96 100644
--- a/web/static/i18n/zh-CN.json
+++ b/web/static/i18n/zh-CN.json
@@ -2535,6 +2535,15 @@
"files": {
"parent": "上级目录",
"refresh": "刷新",
+ "upload": "上传",
+ "uploading": "正在上传 {{name}} · {{percent}}%",
+ "uploadOk": "上传成功",
+ "uploadQueued": "上传任务已入队",
+ "uploadPendingApproval": "上传任务待人机协同审批",
+ "uploadUnsupported": "当前会话不支持上传",
+ "uploadCurlBeacon": "Curl 轻量信标不支持文件上传,请使用 HTTP Beacon",
+ "uploadTcpShell": "当前为 TCP 反弹 Shell(bash/nc),仅支持命令与下载。上传请改用:① 同一监听器下编译 CSB1 Beacon,或 ② HTTP/HTTPS Beacon 重新上线。",
+ "uploadTcpReverse": "当前为 TCP 反弹 Shell(bash/nc),仅支持命令与下载。上传请改用:① 同一监听器下编译 CSB1 Beacon,或 ② HTTP/HTTPS Beacon 重新上线。",
"loading": "加载中…",
"timeout": "加载文件超时",
"emptyDir": "空目录",
@@ -2544,6 +2553,7 @@
"colActions": "操作",
"open": "打开",
"download": "下载",
+ "downloadOk": "下载成功",
"failed": "失败"
},
"listeners": {
diff --git a/web/static/js/c2.js b/web/static/js/c2.js
index eec7da7d..a263a3d4 100644
--- a/web/static/js/c2.js
+++ b/web/static/js/c2.js
@@ -33,8 +33,10 @@
terminalBusy: false,
terminalQueue: [],
// 文件管理
- currentPath: '/',
+ currentPath: '.',
+ implantPwd: null,
fileList: [],
+ fileUploadBusy: false,
// 任务轮询
taskPollInterval: null,
};
@@ -325,6 +327,7 @@
break;
case 'c2-sessions':
C2.loadSessions();
+ C2.ensureListenersLoaded();
break;
case 'c2-tasks':
C2.loadTasks();
@@ -345,6 +348,16 @@
// 监听器管理
// ============================================================================
+ C2.ensureListenersLoaded = function() {
+ if (C2.listeners && C2.listeners.length > 0) {
+ return Promise.resolve(C2.listeners);
+ }
+ return apiRequest('GET', `${API_BASE}/listeners`).then(function(data) {
+ C2.listeners = (data && data.listeners) || [];
+ return C2.listeners;
+ });
+ };
+
C2.loadListeners = function() {
Promise.all([
apiRequest('GET', `${API_BASE}/listeners`),
@@ -778,6 +791,8 @@
C2.selectSession = function(id) {
C2.selectedSessionId = id;
+ C2.implantPwd = null;
+ C2.currentPath = '.';
C2.renderSessions();
C2.renderSessionDetail(id);
};
@@ -822,10 +837,17 @@
-
+
+
+
/
+
+
@@ -869,6 +891,10 @@
if (!isCurlBeacon) C2.initTerminal();
C2.loadFileList(s.id, '.');
C2.loadSessionTasks(s.id);
+ C2.updateFileUploadButton(s);
+ C2.ensureListenersLoaded().then(function() {
+ C2.updateFileUploadButton(s);
+ });
}, 50);
};
@@ -1420,10 +1446,183 @@
// 文件管理
// ============================================================================
+ C2.normalizeFilePath = function(path) {
+ var p = path == null ? '.' : String(path).trim();
+ if (!p || p === '/') return '.';
+ p = p.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/+$/, '');
+ return p || '.';
+ };
+
+ C2.joinFilePath = function(base, name) {
+ var b = C2.normalizeFilePath(base);
+ var n = String(name || '').trim().replace(/\\/g, '/').replace(/^\/+/, '');
+ if (!n) return b;
+ if (b === '.' || b === '/') return n;
+ return b + '/' + n;
+ };
+
+ /** 将相对浏览路径解析为 implant 工作目录下的绝对路径 */
+ C2.resolvePathAgainstPwd = function(pwd, rel) {
+ var base = String(pwd || '').trim().replace(/\\/g, '/').replace(/\/+$/, '');
+ if (!base) base = '/';
+ if (!base.startsWith('/')) base = '/' + base;
+ var parts = String(rel || '.').replace(/\\/g, '/').split('/');
+ var stack = base === '/' ? [] : base.split('/').filter(Boolean);
+ for (var i = 0; i < parts.length; i++) {
+ var p = parts[i];
+ if (!p || p === '.') continue;
+ if (p === '..') {
+ if (stack.length) stack.pop();
+ } else {
+ stack.push(p);
+ }
+ }
+ return '/' + stack.join('/');
+ };
+
+ C2.resolveRemotePath = function(browsePath, filename) {
+ var joined = C2.joinFilePath(browsePath || '.', filename);
+ if (!C2.implantPwd) return joined;
+ return C2.resolvePathAgainstPwd(C2.implantPwd, joined);
+ };
+
+ C2.updateFileBreadcrumb = function(browsePath) {
+ var breadcrumb = document.getElementById('c2-current-path');
+ if (!breadcrumb) return;
+ var rel = C2.normalizeFilePath(browsePath || '.');
+ if (C2.implantPwd) {
+ breadcrumb.textContent = C2.resolvePathAgainstPwd(C2.implantPwd, rel);
+ breadcrumb.title = rel;
+ } else {
+ breadcrumb.textContent = rel;
+ breadcrumb.title = '';
+ }
+ };
+
+ C2.parseLsLine = function(line) {
+ var trimmed = String(line || '').trim();
+ if (!trimmed || /^total\s+\d+/i.test(trimmed)) return null;
+
+ // Beacon 结构化输出:type\tmode\tsize\tname
+ var beaconParts = trimmed.split('\t');
+ if (beaconParts.length >= 4) {
+ var bName = beaconParts.slice(3).join('\t').trim();
+ var bMode = beaconParts[1].trim();
+ var bType = beaconParts[0].trim();
+ if (bName && bName !== '.' && bName !== '..') {
+ return {
+ mode: bMode || bType,
+ size: beaconParts[2].trim(),
+ name: bName,
+ isDir: bType.charAt(0) === 'd' || bMode.charAt(0) === 'd'
+ };
+ }
+ return null;
+ }
+
+ // 原生 ls -l 输出
+ var m = trimmed.match(/^(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.+)$/);
+ if (!m) return null;
+ var name = m[9].trim();
+ var arrow = name.indexOf(' -> ');
+ if (arrow > 0) name = name.slice(0, arrow).trim();
+ if (!name || name === '.' || name === '..') return null;
+ return {
+ mode: m[1],
+ size: m[5],
+ name: name,
+ isDir: m[1].charAt(0) === 'd'
+ };
+ };
+
+ C2.isDownloadShellError = function(text) {
+ var lower = String(text || '').toLowerCase();
+ return lower.indexOf('c2_download_err:') >= 0 ||
+ lower.indexOf('no such file') >= 0 ||
+ lower.indexOf('permission denied') >= 0 ||
+ lower.indexOf('is a directory') >= 0 ||
+ lower.indexOf('cannot open') >= 0 ||
+ lower.indexOf('not a regular file') >= 0;
+ };
+
+ C2.refreshImplantPwd = function(sessionId, callback) {
+ if (!sessionId) {
+ if (callback) callback();
+ return;
+ }
+ apiRequest('POST', `${API_BASE}/tasks`, {
+ session_id: sessionId,
+ task_type: 'pwd',
+ payload: {}
+ }).then(function(data) {
+ if (data.error) {
+ if (callback) callback();
+ return;
+ }
+ var taskId = data.task && data.task.id ? data.task.id : data.task_id;
+ if (!taskId) {
+ if (callback) callback();
+ return;
+ }
+ C2.waitForImplantPwd(taskId, callback);
+ }).catch(function() {
+ if (callback) callback();
+ });
+ };
+
+ C2.waitForImplantPwd = function(taskId, callback) {
+ var attempts = 0;
+ var poll = function() {
+ if (++attempts > 30) {
+ if (callback) callback();
+ return;
+ }
+ apiRequest('GET', `${API_BASE}/tasks/${taskId}`).then(function(data) {
+ var task = data.task;
+ if (task && task.status === 'success' && task.resultText) {
+ C2.implantPwd = String(task.resultText).trim().split('\n').pop().trim();
+ C2.updateFileBreadcrumb(C2.currentPath);
+ if (callback) callback();
+ } else if (task && task.status === 'failed') {
+ if (callback) callback();
+ } else {
+ setTimeout(poll, 300);
+ }
+ });
+ };
+ poll();
+ };
+
+ C2.getParentFilePath = function(path) {
+ var p = C2.normalizeFilePath(path);
+ if (p === '.' || p === '/') return '.';
+ var idx = p.lastIndexOf('/');
+ if (idx < 0) return '.';
+ var parent = p.slice(0, idx);
+ return parent || '.';
+ };
+
+ C2.goToParentDirectory = function() {
+ var parent = C2.getParentFilePath(C2.currentPath || '.');
+ C2.loadFileList(null, parent);
+ };
+
+ C2.openDirectory = function(name) {
+ var next = C2.joinFilePath(C2.currentPath || '.', name);
+ C2.loadFileList(null, next);
+ };
+
C2.loadFileList = function(sessionId, path) {
+ // 兼容误传:仅传路径时(如旧版 loadFileList('..'))自动纠正
+ if (sessionId && path == null && typeof sessionId === 'string' &&
+ (sessionId === '..' || sessionId === '.' || sessionId.indexOf('/') >= 0)) {
+ path = sessionId;
+ sessionId = null;
+ }
if (!sessionId) sessionId = C2.selectedSessionId;
if (!sessionId) return;
if (!path) path = C2.currentPath || '.';
+ path = C2.normalizeFilePath(path);
const container = document.getElementById('c2-file-list');
const breadcrumb = document.getElementById('c2-current-path');
@@ -1455,9 +1654,9 @@
const task = data.task;
if (task && task.status === 'success') {
C2.currentPath = path;
- const breadcrumb = document.getElementById('c2-current-path');
- if (breadcrumb) breadcrumb.textContent = path;
+ C2.updateFileBreadcrumb(path);
C2.renderFileList(task.resultText || '');
+ C2.refreshImplantPwd(sessionId);
} else if (task && task.status === 'failed') {
if (container) container.innerHTML = `
${escapeHtml(task.error || c2t('c2.files.failed'))}
`;
} else {
@@ -1472,8 +1671,10 @@
const container = document.getElementById('c2-file-list');
if (!container) return;
- const lines = output.split('\n').filter(l => l.trim());
- if (lines.length === 0) {
+ const entries = output.split('\n')
+ .map(C2.parseLsLine)
+ .filter(function(entry) { return entry != null; });
+ if (entries.length === 0) {
container.innerHTML = '
' + escapeHtml(c2t('c2.files.emptyDir')) + '
';
return;
}
@@ -1489,22 +1690,19 @@
- ${lines.map(line => {
- const parts = line.split(/\s+/);
- const name = parts[parts.length - 1] || line;
- const isDir = line.startsWith('d') || parts[0]?.startsWith?.('d');
+ ${entries.map(function(entry) {
return `
|
- ${isDir ? '📁' : '📄'}
- ${escapeHtml(name)}
+ ${entry.isDir ? '📁' : '📄'}
+ ${escapeHtml(entry.name)}
|
- ${parts[parts.length - 5] || '-'} |
- ${parts[parts.length - 4] || '-'} |
+ ${escapeHtml(entry.size)} |
+ ${escapeHtml(entry.mode)} |
- ${isDir
- ? ``
- : ``
+ ${entry.isDir
+ ? ``
+ : ``
}
|
@@ -1519,17 +1717,297 @@
C2.loadFileList(null, C2.currentPath);
};
+ C2.sessionTransport = function(session) {
+ if (!session || !session.metadata) return '';
+ return String(session.metadata.transport || '').toLowerCase();
+ };
+
+ C2.sessionSupportsUpload = function(session) {
+ if (!session) {
+ return { supported: false, reasonKey: 'c2.files.uploadUnsupported' };
+ }
+ if (session.implantUuid && String(session.implantUuid).indexOf('curl_') === 0) {
+ return { supported: false, reasonKey: 'c2.files.uploadCurlBeacon' };
+ }
+ var transport = C2.sessionTransport(session);
+ // 编译 Beacon:HTTP/HTTPS/TCP(CSB1) 均走二进制/结构化协议,支持 upload
+ if (transport === 'tcp_beacon' || transport === 'http_beacon' || transport === 'https_beacon') {
+ return { supported: true, reasonKey: '' };
+ }
+ // 经典 TCP 反弹 Shell(bash/nc,metadata.transport=tcp_reverse)
+ if (transport === 'tcp_reverse' || (session.hostname && String(session.hostname).indexOf('tcp_') === 0)) {
+ return { supported: false, reasonKey: 'c2.files.uploadTcpShell' };
+ }
+ return { supported: true, reasonKey: '' };
+ };
+
+ C2.updateFileUploadButton = function(session) {
+ if (!session && C2.selectedSessionId) {
+ session = C2.sessions.find(function(s) { return s.id === C2.selectedSessionId; });
+ }
+ var btn = document.getElementById('c2-file-upload-btn');
+ if (!btn) return;
+ var cap = C2.sessionSupportsUpload(session);
+ btn.disabled = !cap.supported || !!C2.fileUploadBusy;
+ btn.title = cap.supported ? c2t('c2.files.upload') : c2t(cap.reasonKey);
+ if (!cap.supported) {
+ btn.classList.add('is-disabled');
+ } else {
+ btn.classList.remove('is-disabled');
+ }
+ var hint = document.getElementById('c2-file-upload-hint');
+ if (hint) {
+ if (!cap.supported) {
+ hint.hidden = false;
+ hint.textContent = c2t(cap.reasonKey);
+ } else {
+ hint.hidden = true;
+ hint.textContent = '';
+ }
+ }
+ };
+
+ C2.setFileUploadProgress = function(visible, percent, filename) {
+ var row = document.getElementById('c2-file-upload-progress');
+ if (!row) return;
+ if (!visible) {
+ row.hidden = true;
+ return;
+ }
+ row.hidden = false;
+ var fill = document.getElementById('c2-file-upload-progress-fill');
+ var label = document.getElementById('c2-file-upload-progress-label');
+ if (fill) fill.style.width = Math.max(0, Math.min(100, percent || 0)) + '%';
+ if (label) {
+ label.textContent = c2t('c2.files.uploading', { name: filename || '', percent: percent || 0 });
+ }
+ };
+
+ C2.openFileUploadPicker = function() {
+ if (!C2.selectedSessionId || C2.fileUploadBusy) return;
+ var session = C2.sessions.find(function(s) { return s.id === C2.selectedSessionId; });
+ var cap = C2.sessionSupportsUpload(session);
+ if (!cap.supported) {
+ showToast(c2t(cap.reasonKey), 'warn');
+ return;
+ }
+ var inp = document.getElementById('c2-file-upload-input');
+ if (inp) inp.click();
+ };
+
+ C2.onC2FileUploadPick = function(ev) {
+ var input = ev && ev.target;
+ var file = input && input.files && input.files[0];
+ if (!file) return;
+ if (input) input.value = '';
+ C2.uploadFileToImplant(file);
+ };
+
+ C2.uploadFileToImplant = function(file) {
+ if (!C2.selectedSessionId || C2.fileUploadBusy || !file) return;
+ var sessionId = C2.selectedSessionId;
+ var remotePath = C2.resolveRemotePath(C2.currentPath || '.', file.name);
+ var uploadUrl = API_BASE + '/files/upload';
+
+ C2.fileUploadBusy = true;
+ C2.updateFileUploadButton();
+ C2.setFileUploadProgress(true, 0, file.name);
+
+ var form = new FormData();
+ form.append('session_id', sessionId);
+ form.append('remote_path', remotePath);
+ form.append('file', file);
+
+ var uploadPromise;
+ if (typeof apiUploadWithProgress === 'function') {
+ uploadPromise = apiUploadWithProgress(uploadUrl, form, {
+ onProgress: function(p) {
+ C2.setFileUploadProgress(true, Math.min(p.percent || 0, 50), file.name);
+ }
+ });
+ } else if (typeof apiFetch === 'function') {
+ uploadPromise = apiFetch(uploadUrl, { method: 'POST', body: form });
+ } else {
+ uploadPromise = fetch(uploadUrl, { method: 'POST', body: form });
+ }
+
+ uploadPromise.then(function(res) {
+ if (!res.ok) {
+ return res.text().then(function(text) {
+ throw new Error(text || c2t('c2.files.failed'));
+ });
+ }
+ return res.json();
+ }).then(function(uploadData) {
+ var fileId = uploadData && uploadData.file_id;
+ if (!fileId) throw new Error(c2t('c2.files.failed'));
+ C2.setFileUploadProgress(true, 55, file.name);
+ return apiRequest('POST', API_BASE + '/tasks', {
+ session_id: sessionId,
+ task_type: 'upload',
+ payload: { remote_path: remotePath, file_id: fileId }
+ });
+ }).then(function(taskData) {
+ if (taskData && taskData.error) throw new Error(taskData.error);
+ var taskId = taskData.task && taskData.task.id ? taskData.task.id : taskData.task_id;
+ if (!taskId) {
+ showToast(c2t('c2.files.uploadQueued'), 'success');
+ C2.fileUploadBusy = false;
+ C2.setFileUploadProgress(false);
+ C2.updateFileUploadButton();
+ return;
+ }
+ if (taskData.task && taskData.task.approvalStatus === 'pending') {
+ showToast(c2t('c2.files.uploadPendingApproval'), 'info');
+ }
+ C2.waitForFileUpload(taskId, file.name);
+ }).catch(function(err) {
+ showToast((err && err.message) || c2t('c2.files.failed'), 'error');
+ C2.fileUploadBusy = false;
+ C2.setFileUploadProgress(false);
+ C2.updateFileUploadButton();
+ });
+ };
+
+ C2.waitForFileUpload = function(taskId, filename) {
+ var attempts = 0;
+ var check = function() {
+ if (++attempts > 120) {
+ showToast(c2t('c2.files.timeout'), 'error');
+ C2.fileUploadBusy = false;
+ C2.setFileUploadProgress(false);
+ C2.updateFileUploadButton();
+ return;
+ }
+ apiRequest('GET', API_BASE + '/tasks/' + taskId).then(function(data) {
+ var task = data.task;
+ if (task && task.approvalStatus === 'pending' && task.status === 'queued') {
+ C2.setFileUploadProgress(true, 60, filename);
+ setTimeout(check, 1000);
+ return;
+ }
+ if (task && task.status === 'success') {
+ C2.setFileUploadProgress(true, 100, filename);
+ showToast(c2t('c2.files.uploadOk'), 'success');
+ C2.fileUploadBusy = false;
+ setTimeout(function() { C2.setFileUploadProgress(false); }, 400);
+ C2.updateFileUploadButton();
+ C2.refreshFiles();
+ } else if (task && task.status === 'failed') {
+ showToast(task.error || task.resultText || c2t('c2.files.failed'), 'error');
+ C2.fileUploadBusy = false;
+ C2.setFileUploadProgress(false);
+ C2.updateFileUploadButton();
+ } else {
+ var pct = 60 + Math.min(35, Math.floor(attempts / 3));
+ C2.setFileUploadProgress(true, pct, filename);
+ setTimeout(check, 500);
+ }
+ }).catch(function() {
+ C2.fileUploadBusy = false;
+ C2.setFileUploadProgress(false);
+ C2.updateFileUploadButton();
+ });
+ };
+ check();
+ };
+
+ C2.saveDownloadBlob = function(blob, filename) {
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ URL.revokeObjectURL(a.href);
+ };
+
+ C2.saveDownloadContent = function(content, filename) {
+ const text = String(content || '');
+ if (C2.isDownloadShellError(text)) {
+ throw new Error(text.trim() || c2t('c2.files.failed'));
+ }
+ const b64 = text.replace(/\s/g, '');
+ let bytes;
+ try {
+ if (/^[A-Za-z0-9+/=]+$/.test(b64) && b64.length > 0) {
+ const binary = atob(b64);
+ bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ } else {
+ bytes = new TextEncoder().encode(text);
+ }
+ } catch (e) {
+ bytes = new TextEncoder().encode(text);
+ }
+ C2.saveDownloadBlob(new Blob([bytes], { type: 'application/octet-stream' }), filename);
+ };
+
+ C2.fetchTaskResultFile = function(taskId, filename) {
+ const url = `${API_BASE}/tasks/${taskId}/result-file`;
+ const fetchFn = (typeof apiFetch === 'function') ? apiFetch : fetch;
+ fetchFn(url).then(resp => {
+ if (!resp.ok) throw new Error('download failed: ' + resp.status);
+ return resp.blob();
+ }).then(blob => {
+ C2.saveDownloadBlob(blob, filename);
+ }).catch(err => {
+ showToast((err && err.message) || c2t('c2.files.failed'), 'error');
+ });
+ };
+
+ C2.waitForFileDownload = function(taskId, filename) {
+ let attempts = 0;
+ const check = () => {
+ if (++attempts > 120) {
+ showToast(c2t('c2.files.timeout'), 'error');
+ return;
+ }
+ apiRequest('GET', `${API_BASE}/tasks/${taskId}`).then(data => {
+ const task = data.task;
+ if (task && task.status === 'success') {
+ if (task.resultBlobPath) {
+ C2.fetchTaskResultFile(taskId, filename);
+ } else if (task.resultText != null) {
+ try {
+ C2.saveDownloadContent(task.resultText, filename);
+ showToast(c2t('c2.files.downloadOk'), 'success');
+ } catch (err) {
+ showToast((err && err.message) || c2t('c2.files.failed'), 'error');
+ }
+ } else {
+ C2.saveDownloadBlob(new Blob([], { type: 'application/octet-stream' }), filename);
+ showToast(c2t('c2.files.downloadOk'), 'success');
+ }
+ } else if (task && task.status === 'failed') {
+ showToast(task.error || task.resultText || c2t('c2.files.failed'), 'error');
+ } else {
+ setTimeout(check, 500);
+ }
+ });
+ };
+ check();
+ };
+
C2.downloadFile = function(filename) {
if (!C2.selectedSessionId) return;
- const remotePath = C2.currentPath === '/' ? '/' + filename : C2.currentPath + '/' + filename;
-
+ const remotePath = C2.resolveRemotePath(C2.currentPath || '.', filename);
+
apiRequest('POST', `${API_BASE}/tasks`, {
session_id: C2.selectedSessionId,
task_type: 'download',
payload: { remote_path: remotePath }
}).then(data => {
- if (data.error) showToast(data.error, 'error');
- else showToast(c2t('c2.payloads.toastDownloadQueued'), 'success');
+ if (data.error) {
+ showToast(data.error, 'error');
+ return;
+ }
+ const taskId = data.task?.id || data.task_id;
+ if (!taskId) {
+ showToast(c2t('c2.payloads.toastDownloadQueued'), 'success');
+ return;
+ }
+ C2.waitForFileDownload(taskId, filename);
});
};