diff --git a/config/__init__.py b/config/__init__.py index aae11d2..6c7b36c 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -39,6 +39,7 @@ from . import memory as _memory from . import ocr as _ocr from . import todo as _todo from . import auth as _auth +from . import uploads as _uploads from . import sub_agent as _sub_agent from .api import * @@ -52,10 +53,11 @@ from .memory import * from .ocr import * from .todo import * from .auth import * +from .uploads import * from .sub_agent import * __all__ = [] -for module in (_api, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _sub_agent): +for module in (_api, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent): __all__ += getattr(module, "__all__", []) -del _api, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _sub_agent +del _api, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent diff --git a/config/uploads.py b/config/uploads.py new file mode 100644 index 0000000..85d14b7 --- /dev/null +++ b/config/uploads.py @@ -0,0 +1,61 @@ +"""上传隔离与扫描相关配置。""" + +import os +import shlex + +_DEFAULT_ALLOWED_EXTENSIONS = ( + ".txt,.md,.rst,.py,.js,.ts,.json,.yml,.yaml,.ini,.cfg,.conf," + ".csv,.tsv,.log,.mdx,.env,.sh,.bat,.ps1,.sql,.html,.css," + ".svg,.png,.jpg,.jpeg,.gif,.bmp,.webp,.ico,.pdf,.pptx,.docx," + ".xlsx,.mp3,.wav,.flac,.mp4,.mov,.mkv,.avi,.webm," + ".zip,.tar,.tar.gz,.tar.bz2,.tgz,.tbz,.gz,.bz2,.xz,.7z" + ",.com" +) + + +def _parse_extensions(raw: str): + entries = [] + for chunk in (raw or "").split(","): + token = chunk.strip().lower() + if not token: + continue + if not token.startswith("."): + token = f".{token}" + entries.append(token) + # 去重但保持顺序 + deduped = [] + for item in entries: + if item not in deduped: + deduped.append(item) + return tuple(deduped) + + +UPLOAD_ALLOWED_EXTENSIONS = _parse_extensions( + os.environ.get("UPLOAD_ALLOWED_EXTENSIONS", _DEFAULT_ALLOWED_EXTENSIONS) +) +UPLOAD_QUARANTINE_SUBDIR = os.environ.get("UPLOAD_QUARANTINE_SUBDIR", ".upload_quarantine") + + +def _parse_bool(value: str, default: bool = True) -> bool: + if value is None: + return default + return str(value).strip().lower() not in {"0", "false", "no", "off"} + + +UPLOAD_CLAMAV_ENABLED = _parse_bool(os.environ.get("UPLOAD_CLAMAV_ENABLED", "1"), default=True) +UPLOAD_CLAMAV_BIN = os.environ.get("UPLOAD_CLAMAV_BIN", "clamdscan") +UPLOAD_CLAMAV_ARGS = tuple( + shlex.split(os.environ.get("UPLOAD_CLAMAV_ARGS", "--fdpass --no-summary --stdout")) +) +UPLOAD_CLAMAV_TIMEOUT_SECONDS = int(os.environ.get("UPLOAD_CLAMAV_TIMEOUT_SECONDS", "30")) +UPLOAD_SCAN_LOG_SUBDIR = os.environ.get("UPLOAD_SCAN_LOG_SUBDIR", "upload_guard") + +__all__ = [ + "UPLOAD_ALLOWED_EXTENSIONS", + "UPLOAD_QUARANTINE_SUBDIR", + "UPLOAD_CLAMAV_ENABLED", + "UPLOAD_CLAMAV_BIN", + "UPLOAD_CLAMAV_ARGS", + "UPLOAD_CLAMAV_TIMEOUT_SECONDS", + "UPLOAD_SCAN_LOG_SUBDIR", +] diff --git a/modules/upload_security.py b/modules/upload_security.py new file mode 100644 index 0000000..531011b --- /dev/null +++ b/modules/upload_security.py @@ -0,0 +1,278 @@ +"""上传隔离、扫描与审计工具。""" + +from __future__ import annotations + +import hashlib +import json +import mimetypes +import os +import shutil +import subprocess +import time +import uuid +from dataclasses import dataclass, asdict +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional, TYPE_CHECKING + +from config import ( + MAX_UPLOAD_SIZE, + UPLOAD_ALLOWED_EXTENSIONS, + UPLOAD_CLAMAV_ARGS, + UPLOAD_CLAMAV_BIN, + UPLOAD_CLAMAV_ENABLED, + UPLOAD_CLAMAV_TIMEOUT_SECONDS, + UPLOAD_SCAN_LOG_SUBDIR, +) +from utils.logger import setup_logger + +if TYPE_CHECKING: + from modules.user_manager import UserWorkspace + from werkzeug.datastructures import FileStorage + + +class UploadSecurityError(Exception): + """上传被拒绝或扫描失败时抛出的业务异常。""" + + def __init__(self, message: str, code: str = "upload_error"): + super().__init__(message) + self.code = code + + +@dataclass +class StageRecord: + """暂存区中的上传文件快照。""" + + path: Path + filename: str + size: int + sha256: str + mime: Optional[str] + + +@dataclass +class ScanReport: + """病毒扫描结果。""" + + status: str + engine: str + signature: Optional[str] = None + message: Optional[str] = None + duration_ms: Optional[int] = None + + @property + def is_clean(self) -> bool: + return self.status in {"passed", "skipped"} + + +class UploadQuarantineManager: + """负责将上传文件落地到隔离区、执行安全扫描并记录审计日志。""" + + def __init__(self, workspace: "UserWorkspace", *, logger_name: Optional[str] = None): + self.workspace = workspace + self.quarantine_dir = Path(workspace.quarantine_dir).resolve() + self.quarantine_dir.mkdir(parents=True, exist_ok=True) + log_rel = Path(UPLOAD_SCAN_LOG_SUBDIR) / f"{workspace.username}.log" + self.logger = setup_logger( + logger_name or f"upload_guard.{workspace.username}", + str(log_rel), + ) + self.allowed_extensions = tuple(UPLOAD_ALLOWED_EXTENSIONS) + self.clamav_enabled = bool(UPLOAD_CLAMAV_ENABLED) + self.clamav_bin = self._resolve_clamav_bin(UPLOAD_CLAMAV_BIN) + self.clamav_args = list(UPLOAD_CLAMAV_ARGS) + self.clamav_timeout = int(max(1, UPLOAD_CLAMAV_TIMEOUT_SECONDS)) + + def process_upload( + self, + file_obj: "FileStorage", + target_path: Path, + *, + username: str, + source: str, + original_name: str, + relative_path: str, + ) -> Dict[str, Any]: + """保存文件到隔离区、执行扫描并将通过的文件同步到工作目录。""" + stage = self._stage_file(file_obj, original_name) + metadata: Dict[str, Any] = { + "upload_id": uuid.uuid4().hex, + "username": username, + "source": source, + "original_name": original_name, + "target_path": str(target_path), + "target_relative": relative_path, + "staged_path": str(stage.path), + "size": stage.size, + "sha256": stage.sha256, + "mime": stage.mime, + } + scan_report: Optional[ScanReport] = None + promoted_path: Optional[Path] = None + + try: + self._enforce_size(stage.size) + self._enforce_extension(target_path.name) + scan_report = self._scan(stage.path) + metadata["scan"] = asdict(scan_report) + if not scan_report.is_clean: + metadata["scan_failure_reason"] = scan_report.message or scan_report.signature + raise UploadSecurityError("安全审核未通过", code="scan_failed") + promoted_path = self._promote(stage.path, target_path) + metadata["final_path"] = str(promoted_path) + self._log_event(True, metadata) + return { + "final_path": promoted_path, + "metadata": metadata, + } + except UploadSecurityError as exc: + metadata["error"] = { + "code": exc.code, + "message": str(exc), + } + if scan_report: + metadata.setdefault("scan", asdict(scan_report)) + self._log_event(False, metadata) + raise + finally: + if stage.path.exists(): + try: + stage.path.unlink() + except OSError: + pass + + # ------------------------------------------------------------------ + # 内部实现 + # ------------------------------------------------------------------ + def _stage_file(self, file_obj: "FileStorage", original_name: str) -> StageRecord: + suffix = "".join(Path(original_name or "").suffixes[-2:]) or Path(original_name or "").suffix + unique_name = f"{int(time.time())}_{uuid.uuid4().hex}{suffix or ''}" + staged_path = self.quarantine_dir / unique_name + file_obj.save(staged_path) + size = staged_path.stat().st_size + sha256 = self._hash_file(staged_path) + mime, _ = mimetypes.guess_type(original_name or "") + return StageRecord(staged_path, original_name, size, sha256, mime) + + def _enforce_size(self, size: int): + if size > MAX_UPLOAD_SIZE: + raise UploadSecurityError( + f"文件大小 {size} 超过上限 {MAX_UPLOAD_SIZE} 字节", + code="size_exceeded", + ) + + def _enforce_extension(self, filename: str): + if not self.allowed_extensions: + return + lowered = (filename or "").lower() + for pattern in self.allowed_extensions: + if lowered.endswith(pattern): + return + raise UploadSecurityError("文件类型不在允许列表中", code="extension_forbidden") + + def _scan(self, staged_path: Path) -> ScanReport: + if not self.clamav_enabled: + return ScanReport(status="skipped", engine="clamdscan", message="已跳过病毒扫描") + if not self.clamav_bin: + raise UploadSecurityError("未找到 ClamAV 扫描器,请检查配置", code="scanner_missing") + + command = [self.clamav_bin] + self.clamav_args + [str(staged_path)] + start = time.time() + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=self.clamav_timeout, + check=False, + ) + except subprocess.TimeoutExpired: + return ScanReport( + status="error", + engine="clamdscan", + message="扫描超时", + duration_ms=int((time.time() - start) * 1000), + ) + except FileNotFoundError as exc: + raise UploadSecurityError("ClamAV 扫描器不可用", code="scanner_missing") from exc + + duration_ms = int((time.time() - start) * 1000) + stdout = (result.stdout or "").strip() + stderr = (result.stderr or "").strip() + + if result.returncode == 0: + return ScanReport(status="passed", engine="clamdscan", duration_ms=duration_ms) + + if result.returncode == 1: + signature = self._extract_signature(stdout) or self._extract_signature(stderr) + message = signature or "检测到可疑内容" + return ScanReport( + status="failed", + engine="clamdscan", + signature=signature, + message=message, + duration_ms=duration_ms, + ) + + message = stderr or stdout or f"扫描失败(退出码 {result.returncode})" + return ScanReport( + status="error", + engine="clamdscan", + message=message, + duration_ms=duration_ms, + ) + + def _promote(self, staged_path: Path, target_path: Path) -> Path: + target_path.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(staged_path), str(target_path)) + return target_path + + def _hash_file(self, path: Path) -> str: + sha256 = hashlib.sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b""): + sha256.update(chunk) + return sha256.hexdigest() + + def _extract_signature(self, text: str) -> Optional[str]: + if not text: + return None + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or "FOUND" not in line.upper(): + continue + parts = line.split(":", 1) + tail = parts[-1].strip() + if tail.upper().endswith("FOUND"): + signature = tail[: -len("FOUND")].strip() + if signature: + return signature + return None + + def _resolve_clamav_bin(self, candidate: str) -> Optional[str]: + if not candidate: + return None + candidate = candidate.strip() + if not candidate: + return None + if os.path.isabs(candidate): + return candidate if Path(candidate).exists() else None + resolved = shutil.which(candidate) + return resolved or None + + def _log_event(self, accepted: bool, payload: Dict[str, Any]): + entry = { + "timestamp": datetime.utcnow().isoformat(), + "accepted": accepted, + **payload, + } + try: + self.logger.info("UPLOAD_AUDIT %s", json.dumps(entry, ensure_ascii=False)) + except Exception: + self.logger.warning("无法写入上传审计日志:%s", entry) + + +__all__ = [ + "UploadQuarantineManager", + "UploadSecurityError", +] diff --git a/modules/user_manager.py b/modules/user_manager.py index e8a6a32..d4ca9fe 100644 --- a/modules/user_manager.py +++ b/modules/user_manager.py @@ -15,6 +15,7 @@ from config import ( INVITE_CODES_FILE, USER_SPACE_DIR, USERS_DB_FILE, + UPLOAD_QUARANTINE_SUBDIR, ) from modules.personalization_manager import ensure_personalization_config @@ -37,6 +38,7 @@ class UserWorkspace: data_dir: Path logs_dir: Path uploads_dir: Path + quarantine_dir: Path class UserManager: @@ -131,6 +133,12 @@ class UserManager: (data_dir / "backups").mkdir(parents=True, exist_ok=True) ensure_personalization_config(data_dir) + quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser() + if not quarantine_root.is_absolute(): + quarantine_root = (self.workspace_root.parent / UPLOAD_QUARANTINE_SUBDIR).resolve() + quarantine_dir = (quarantine_root / username).resolve() + quarantine_dir.mkdir(parents=True, exist_ok=True) + return UserWorkspace( username=username, root=root, @@ -138,6 +146,7 @@ class UserManager: data_dir=data_dir, logs_dir=logs_dir, uploads_dir=uploads_dir, + quarantine_dir=quarantine_dir, ) def list_invite_codes(self): diff --git a/static/app.js b/static/app.js index a5822c9..64d7369 100644 --- a/static/app.js +++ b/static/app.js @@ -322,6 +322,10 @@ async function bootstrapApp() { usageQuotaTimer: null, quotaToast: null, quotaToastTimer: null, + toastQueue: [], + nextToastId: 1, + pendingConfirmResolver: null, + confirmDialog: null, // 对话压缩状态 compressing: false, @@ -640,7 +644,11 @@ async function bootstrapApp() { } catch (err) { message = await response.text(); } - alert(`下载失败: ${message}`); + this.pushToast({ + title: '下载失败', + message: message || '无法完成下载', + type: 'error' + }); return; } @@ -656,7 +664,11 @@ async function bootstrapApp() { URL.revokeObjectURL(href); } catch (error) { console.error('下载失败:', error); - alert(`下载失败: ${error.message || error}`); + this.pushToast({ + title: '下载失败', + message: error.message || String(error), + type: 'error' + }); } finally { this.hideContextMenu(); } @@ -1973,11 +1985,19 @@ async function bootstrapApp() { } else { console.error('对话加载失败:', result.message); - alert(`加载对话失败: ${result.message}`); + this.pushToast({ + title: '加载对话失败', + message: result.message || '服务器未返回成功状态', + type: 'error' + }); } } catch (error) { console.error('加载对话异常:', error); - alert(`加载对话异常: ${error.message}`); + this.pushToast({ + title: '加载对话异常', + message: error.message || String(error), + type: 'error' + }); } }, @@ -2344,16 +2364,30 @@ async function bootstrapApp() { } else { console.error('创建对话失败:', result.message); - alert(`创建对话失败: ${result.message}`); + this.pushToast({ + title: '创建对话失败', + message: result.message || '服务器未返回成功状态', + type: 'error' + }); } } catch (error) { console.error('创建对话异常:', error); - alert(`创建对话异常: ${error.message}`); + this.pushToast({ + title: '创建对话异常', + message: error.message || String(error), + type: 'error' + }); } }, - + async deleteConversation(conversationId) { - if (!confirm('确定要删除这个对话吗?删除后无法恢复。')) { + const confirmed = await this.confirmAction({ + title: '删除对话', + message: '确定要删除这个对话吗?删除后无法恢复。', + confirmText: '删除', + cancelText: '取消' + }); + if (!confirmed) { return; } @@ -2385,11 +2419,19 @@ async function bootstrapApp() { } else { console.error('删除对话失败:', result.message); - alert(`删除对话失败: ${result.message}`); + this.pushToast({ + title: '删除对话失败', + message: result.message || '服务器未返回成功状态', + type: 'error' + }); } } catch (error) { console.error('删除对话异常:', error); - alert(`删除对话异常: ${error.message}`); + this.pushToast({ + title: '删除对话异常', + message: error.message || String(error), + type: 'error' + }); } }, @@ -2412,11 +2454,19 @@ async function bootstrapApp() { await this.loadConversationsList(); } else { const message = result.message || result.error || '复制失败'; - alert(`复制失败: ${message}`); + this.pushToast({ + title: '复制对话失败', + message, + type: 'error' + }); } } catch (error) { console.error('复制对话异常:', error); - alert(`复制对话异常: ${error.message}`); + this.pushToast({ + title: '复制对话异常', + message: error.message || String(error), + type: 'error' + }); } }, @@ -2527,7 +2577,11 @@ async function bootstrapApp() { window.location.href = '/login'; } catch (error) { console.error('退出登录失败:', error); - alert(`退出登录失败:${error.message || '请稍后重试'}`); + this.pushToast({ + title: '退出登录失败', + message: error.message || '请稍后重试', + type: 'error' + }); } }, @@ -2544,7 +2598,11 @@ async function bootstrapApp() { this.personalizationLoaded = true; } catch (error) { this.personalizationError = error.message || '加载失败'; - alert(`加载个性化配置失败:${this.personalizationError}`); + this.pushToast({ + title: '加载个性化配置失败', + message: this.personalizationError, + type: 'error' + }); } finally { this.personalizationLoading = false; } @@ -2593,7 +2651,11 @@ async function bootstrapApp() { }, 3000); } catch (error) { this.personalizationError = error.message || '保存失败'; - alert(`保存个性化配置失败:${this.personalizationError}`); + this.pushToast({ + title: '保存个性化配置失败', + message: this.personalizationError, + type: 'error' + }); } finally { this.personalizationSaving = false; } @@ -2604,7 +2666,11 @@ async function bootstrapApp() { return; } if (this.personalForm.considerations.length >= this.personalizationMaxConsiderations) { - alert(`最多添加 ${this.personalizationMaxConsiderations} 条信息`); + this.pushToast({ + title: '提示', + message: `最多添加 ${this.personalizationMaxConsiderations} 条信息`, + type: 'warning' + }); return; } this.personalForm.considerations = [ @@ -2677,7 +2743,11 @@ async function bootstrapApp() { } } catch (error) { console.error('切换思考模式失败:', error); - alert(`切换思考模式失败: ${error.message}`); + this.pushToast({ + title: '切换思考模式失败', + message: error.message || '请稍后重试', + type: 'error' + }); } }, @@ -2900,6 +2970,12 @@ async function bootstrapApp() { } this.uploading = true; + const toastId = this.pushToast({ + title: '上传文件', + message: `正在上传 ${file.name}...`, + type: 'info', + duration: null + }); try { const formData = new FormData(); @@ -2911,6 +2987,13 @@ async function bootstrapApp() { body: formData }); + if (toastId) { + this.updateToast(toastId, { + message: `文件已上传,正在执行安全扫描...`, + type: 'info' + }); + } + let result = {}; try { result = await response.json(); @@ -2924,10 +3007,48 @@ async function bootstrapApp() { } await this.refreshFileTree(); - alert(`上传成功:${result.path || file.name}`); + if (toastId) { + this.updateToast(toastId, { + title: '上传完成', + message: `上传成功:${result.path || file.name}`, + type: 'success', + duration: 4500 + }); + } else { + this.pushToast({ + title: '上传完成', + message: `上传成功:${result.path || file.name}`, + type: 'success' + }); + } } catch (error) { console.error('文件上传失败:', error); - alert(`文件上传失败:${error.message}`); + const originalMessage = error && error.message ? String(error.message) : ''; + const fileLabel = file && file.name ? file.name : '文件'; + let displayMessage = originalMessage || '请稍后重试'; + if (/安全审核未通过/.test(originalMessage) || /Eicar/i.test(originalMessage)) { + displayMessage = `${fileLabel} 安全审核未通过`; + } else if (/文件类型不在允许列表中/.test(originalMessage)) { + displayMessage = `${fileLabel} 文件类型不在允许列表中`; + } else if (displayMessage) { + displayMessage = `${fileLabel} 上传失败:${displayMessage}`; + } else { + displayMessage = `${fileLabel} 上传失败,请稍后重试`; + } + if (toastId) { + this.updateToast(toastId, { + title: '上传失败', + message: displayMessage, + type: 'error', + duration: 5000 + }); + } else { + this.pushToast({ + title: '上传失败', + message: displayMessage, + type: 'error' + }); + } } finally { this.uploading = false; this.resetFileInput(); @@ -2995,15 +3116,25 @@ async function bootstrapApp() { } }, - clearChat() { - if (confirm('确定要清除所有对话记录吗?')) { + async clearChat() { + const confirmed = await this.confirmAction({ + title: '清除对话', + message: '确定要清除所有对话记录吗?该操作不可撤销。', + confirmText: '清除', + cancelText: '取消' + }); + if (confirmed) { this.socket.emit('send_command', { command: '/clear' }); } }, async compressConversation() { if (!this.currentConversationId) { - alert('当前没有可压缩的对话。'); + this.pushToast({ + title: '无法压缩', + message: '当前没有可压缩的对话。', + type: 'info' + }); return; } @@ -3011,7 +3142,12 @@ async function bootstrapApp() { return; } - const confirmed = confirm('确定要压缩当前对话记录吗?压缩后会生成新的对话副本。'); + const confirmed = await this.confirmAction({ + title: '压缩对话', + message: '确定要压缩当前对话记录吗?压缩后会生成新的对话副本。', + confirmText: '压缩', + cancelText: '取消' + }); if (!confirmed) { return; } @@ -3033,11 +3169,19 @@ async function bootstrapApp() { console.log('对话压缩完成:', result); } else { const message = result.message || result.error || '压缩失败'; - alert(`压缩失败: ${message}`); + this.pushToast({ + title: '压缩失败', + message, + type: 'error' + }); } } catch (error) { console.error('压缩对话异常:', error); - alert(`压缩对话异常: ${error.message}`); + this.pushToast({ + title: '压缩对话异常', + message: error.message || '请稍后重试', + type: 'error' + }); } finally { this.compressing = false; } @@ -4100,6 +4244,95 @@ async function bootstrapApp() { }, 5000); }, + dismissQuotaToast() { + if (this.quotaToastTimer) { + clearTimeout(this.quotaToastTimer); + this.quotaToastTimer = null; + } + this.quotaToast = null; + }, + + pushToast(options = {}) { + const title = options.title || ''; + const message = options.message || ''; + if (!title && !message) { + return null; + } + const id = this.nextToastId++; + const entry = { + id, + title, + message, + type: options.type || 'info', + closable: options.closable !== false, + timeoutId: null + }; + const duration = Object.prototype.hasOwnProperty.call(options, 'duration') ? options.duration : 4000; + if (duration !== null) { + entry.timeoutId = setTimeout(() => this.dismissToast(id), duration); + } + this.toastQueue.push(entry); + return id; + }, + + updateToast(id, patch = {}) { + const entry = this.toastQueue.find(item => item.id === id); + if (!entry) { + return; + } + Object.assign(entry, patch); + if (Object.prototype.hasOwnProperty.call(patch, 'duration')) { + if (entry.timeoutId) { + clearTimeout(entry.timeoutId); + entry.timeoutId = null; + } + if (patch.duration !== null) { + entry.timeoutId = setTimeout(() => this.dismissToast(id), patch.duration); + } + } + }, + + dismissToast(id) { + const index = this.toastQueue.findIndex(item => item.id === id); + if (index === -1) { + return; + } + const [entry] = this.toastQueue.splice(index, 1); + if (entry && entry.timeoutId) { + clearTimeout(entry.timeoutId); + } + }, + + confirmAction(options = {}) { + return new Promise((resolve) => { + if (this.pendingConfirmResolver) { + const previous = this.pendingConfirmResolver; + this.pendingConfirmResolver = null; + previous(false); + } + this.confirmDialog = { + visible: true, + title: options.title || '确认操作', + message: options.message || '请确认本次操作', + confirmText: options.confirmText || '确认', + cancelText: options.cancelText || '取消' + }; + this.pendingConfirmResolver = resolve; + }); + }, + + handleConfirm(choice) { + if (this.confirmDialog) { + this.confirmDialog.visible = false; + } + const resolver = this.pendingConfirmResolver; + this.pendingConfirmResolver = null; + this.confirmDialog = null; + if (resolver) { + resolver(!!choice); + } + }, + containerStatusText() { if (!this.containerStatus) { return '未知'; diff --git a/static/index.html b/static/index.html index f31ef03..97fd333 100644 --- a/static/index.html +++ b/static/index.html @@ -35,8 +35,51 @@
{{ quotaToast.message }} +
+
+ +
+
+
{{ toast.title }}
+
{{ toast.message }}
+
+ +
+
+
+
+
+
{{ confirmDialog.title || '确认操作' }}
+
{{ confirmDialog.message }}
+
+ + +
+
+

正在连接服务器...

diff --git a/static/style.css b/static/style.css index 34209ff..43038fe 100644 --- a/static/style.css +++ b/static/style.css @@ -2697,10 +2697,14 @@ o-files { max-width: 320px; color: var(--claude-text); font-weight: 500; + display: flex; + align-items: center; + gap: 12px; } .quota-toast-label { display: block; + flex: 1; } .quota-toast-fade-enter-active, @@ -2714,6 +2718,136 @@ o-files { transform: translateX(20px); } +.toast-stack { + position: fixed; + top: 96px; + right: 32px; + width: min(320px, calc(100vw - 32px)); + display: flex; + flex-direction: column; + gap: 12px; + z-index: 2050; +} + +.toast-stack.toast-stack--empty { + pointer-events: none; +} + +.app-toast { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 14px; + border-radius: 14px; + background: var(--claude-bg); + border: 1px solid var(--claude-border-strong, rgba(118,103,84,0.35)); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.18); + color: var(--claude-text); +} + +.app-toast::before { + content: ""; + width: 4px; + border-radius: 999px; + background: var(--claude-text-secondary); + align-self: stretch; +} + +.app-toast--success::before { + background: var(--claude-success); +} + +.app-toast--warning::before { + background: var(--claude-warning); +} + +.app-toast--error::before { + background: #d45f5f; +} + +.app-toast-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.app-toast-title { + font-weight: 600; +} + +.app-toast-message { + font-size: 13px; + line-height: 1.4; +} + +.toast-close { + border: none; + background: transparent; + color: var(--claude-text-secondary); + font-size: 16px; + cursor: pointer; + padding: 2px; +} + +.toast-close:hover { + color: var(--claude-text); +} + +.confirm-overlay { + position: fixed; + inset: 0; + background: rgba(8, 8, 8, 0.45); + z-index: 2200; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.confirm-modal { + width: min(360px, 100%); + background: var(--claude-bg); + border: 1px solid var(--claude-border-strong, rgba(118,103,84,0.35)); + border-radius: 18px; + padding: 24px; + box-shadow: 0 18px 40px rgba(61, 57, 41, 0.22); +} + +.confirm-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 12px; +} + +.confirm-message { + font-size: 14px; + color: var(--claude-text-secondary); +} + +.confirm-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 24px; +} + +.confirm-button { + border: 1px solid var(--claude-border-strong, rgba(118,103,84,0.35)); + background: transparent; + color: var(--claude-text); + border-radius: 10px; + padding: 8px 20px; + font-weight: 500; + cursor: pointer; +} + +.confirm-button--primary { + background: var(--claude-accent); + border-color: var(--claude-accent-strong); + color: #fff; +} + .status-pill--running { background: rgba(118, 176, 134, 0.18); color: var(--claude-success); diff --git a/sub_agent/modules/user_manager.py b/sub_agent/modules/user_manager.py index 5d1ee0a..8dde5a5 100644 --- a/sub_agent/modules/user_manager.py +++ b/sub_agent/modules/user_manager.py @@ -15,6 +15,7 @@ from config import ( INVITE_CODES_FILE, USER_SPACE_DIR, USERS_DB_FILE, + UPLOAD_QUARANTINE_SUBDIR, ) @@ -36,6 +37,7 @@ class UserWorkspace: data_dir: Path logs_dir: Path uploads_dir: Path + quarantine_dir: Path class UserManager: @@ -129,6 +131,12 @@ class UserManager: (data_dir / "conversations").mkdir(parents=True, exist_ok=True) (data_dir / "backups").mkdir(parents=True, exist_ok=True) + quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser() + if not quarantine_root.is_absolute(): + quarantine_root = (self.workspace_root.parent / UPLOAD_QUARANTINE_SUBDIR).resolve() + quarantine_dir = (quarantine_root / username).resolve() + quarantine_dir.mkdir(parents=True, exist_ok=True) + return UserWorkspace( username=username, root=root, @@ -136,6 +144,7 @@ class UserManager: data_dir=data_dir, logs_dir=logs_dir, uploads_dir=uploads_dir, + quarantine_dir=quarantine_dir, ) def list_invite_codes(self): diff --git a/test/upload_samples/README.md b/test/upload_samples/README.md new file mode 100644 index 0000000..0096dc3 --- /dev/null +++ b/test/upload_samples/README.md @@ -0,0 +1,6 @@ +# Upload Sample Files + +- `disallowed_payload.exe`:伪造的可执行占位文件,可用于验证上传白名单/后缀校验是否会拒绝。 +- `eicar_test.com`:标准 EICAR 测试串,可用于验证 ClamAV 病毒扫描是否命中。 + +请勿将这些文件投入生产,只在本地或沙箱环境测试上传流程。 diff --git a/test/upload_samples/disallowed_payload.exe b/test/upload_samples/disallowed_payload.exe new file mode 100644 index 0000000..ab68d34 --- /dev/null +++ b/test/upload_samples/disallowed_payload.exe @@ -0,0 +1 @@ +This is a dummy executable placeholder used to trigger the extension whitelist logic. diff --git a/test/upload_samples/eicar_test.com b/test/upload_samples/eicar_test.com new file mode 100644 index 0000000..704cac8 --- /dev/null +++ b/test/upload_samples/eicar_test.com @@ -0,0 +1 @@ +X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H* diff --git a/web_server.py b/web_server.py index b719ea9..6972b18 100644 --- a/web_server.py +++ b/web_server.py @@ -51,6 +51,7 @@ from config import ( ) from modules.user_manager import UserManager, UserWorkspace from modules.gui_file_manager import GuiFileManager +from modules.upload_security import UploadQuarantineManager, UploadSecurityError from modules.personalization_manager import ( load_personalization_config, save_personalization_config, @@ -503,6 +504,22 @@ def get_gui_manager(workspace: UserWorkspace) -> GuiFileManager: return GuiFileManager(str(workspace.project_path)) +def get_upload_guard(workspace: UserWorkspace) -> UploadQuarantineManager: + """构建上传隔离管理器""" + return UploadQuarantineManager(workspace) + + +def build_upload_error_response(exc: UploadSecurityError): + status = 400 + if exc.code in {"scanner_missing", "scanner_unavailable"}: + status = 500 + return jsonify({ + "success": False, + "error": str(exc), + "code": exc.code, + }), status + + def ensure_conversation_loaded(terminal: WebTerminal, conversation_id: Optional[str], thinking_mode: bool) -> Tuple[str, bool]: """确保终端加载指定对话,若无则创建新的""" created_new = False @@ -1233,13 +1250,33 @@ def gui_upload_entry(terminal: WebTerminal, workspace: UserWorkspace, username: return jsonify({"success": False, "error": str(exc)}), 400 try: - file_obj.save(target_path) + relative_path = manager._to_relative(target_path) except Exception as exc: - return jsonify({"success": False, "error": f'保存文件失败: {exc}'}), 500 + return jsonify({"success": False, "error": str(exc)}), 400 + guard = get_upload_guard(workspace) + try: + result = guard.process_upload( + file_obj, + target_path, + username=username, + source="web_gui", + original_name=raw_name, + relative_path=relative_path, + ) + except UploadSecurityError as exc: + return build_upload_error_response(exc) + except Exception as exc: + return jsonify({"success": False, "error": f"保存文件失败: {exc}"}), 500 + + metadata = result.get("metadata", {}) return jsonify({ "success": True, - "path": manager._to_relative(target_path) + "path": relative_path, + "filename": target_path.name, + "scan": metadata.get("scan"), + "sha256": metadata.get("sha256"), + "size": metadata.get("size"), }) @@ -1440,21 +1477,42 @@ def upload_file(terminal: WebTerminal, workspace: UserWorkspace, username: str): counter += 1 try: - uploaded_file.save(final_path) + relative_path = str(final_path.relative_to(workspace.project_path)) + except Exception as exc: + return jsonify({ + "success": False, + "error": f"路径解析失败: {exc}" + }), 400 + + guard = get_upload_guard(workspace) + try: + result = guard.process_upload( + uploaded_file, + final_path, + username=username, + source="legacy_upload", + original_name=raw_name, + relative_path=relative_path, + ) + except UploadSecurityError as exc: + return build_upload_error_response(exc) except Exception as exc: return jsonify({ "success": False, "error": f"保存文件失败: {exc}" }), 500 - relative_path = str(final_path.relative_to(workspace.project_path)) + metadata = result.get("metadata", {}) print(f"{OUTPUT_FORMATS['file']} 上传文件: {relative_path}") return jsonify({ "success": True, "path": relative_path, "filename": final_path.name, - "folder": target_folder_relative + "folder": target_folder_relative, + "scan": metadata.get("scan"), + "sha256": metadata.get("sha256"), + "size": metadata.get("size"), })