feat: add upload quarantine scanning and ui toasts

This commit is contained in:
JOJO 2025-11-24 14:31:13 +08:00
parent 7c7038b5c9
commit 75a7febcd1
12 changed files with 868 additions and 33 deletions

View File

@ -39,6 +39,7 @@ from . import memory as _memory
from . import ocr as _ocr from . import ocr as _ocr
from . import todo as _todo from . import todo as _todo
from . import auth as _auth from . import auth as _auth
from . import uploads as _uploads
from . import sub_agent as _sub_agent from . import sub_agent as _sub_agent
from .api import * from .api import *
@ -52,10 +53,11 @@ from .memory import *
from .ocr import * from .ocr import *
from .todo import * from .todo import *
from .auth import * from .auth import *
from .uploads import *
from .sub_agent import * from .sub_agent import *
__all__ = [] __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__", []) __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

61
config/uploads.py Normal file
View File

@ -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",
]

278
modules/upload_security.py Normal file
View File

@ -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",
]

View File

@ -15,6 +15,7 @@ from config import (
INVITE_CODES_FILE, INVITE_CODES_FILE,
USER_SPACE_DIR, USER_SPACE_DIR,
USERS_DB_FILE, USERS_DB_FILE,
UPLOAD_QUARANTINE_SUBDIR,
) )
from modules.personalization_manager import ensure_personalization_config from modules.personalization_manager import ensure_personalization_config
@ -37,6 +38,7 @@ class UserWorkspace:
data_dir: Path data_dir: Path
logs_dir: Path logs_dir: Path
uploads_dir: Path uploads_dir: Path
quarantine_dir: Path
class UserManager: class UserManager:
@ -131,6 +133,12 @@ class UserManager:
(data_dir / "backups").mkdir(parents=True, exist_ok=True) (data_dir / "backups").mkdir(parents=True, exist_ok=True)
ensure_personalization_config(data_dir) 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( return UserWorkspace(
username=username, username=username,
root=root, root=root,
@ -138,6 +146,7 @@ class UserManager:
data_dir=data_dir, data_dir=data_dir,
logs_dir=logs_dir, logs_dir=logs_dir,
uploads_dir=uploads_dir, uploads_dir=uploads_dir,
quarantine_dir=quarantine_dir,
) )
def list_invite_codes(self): def list_invite_codes(self):

View File

@ -322,6 +322,10 @@ async function bootstrapApp() {
usageQuotaTimer: null, usageQuotaTimer: null,
quotaToast: null, quotaToast: null,
quotaToastTimer: null, quotaToastTimer: null,
toastQueue: [],
nextToastId: 1,
pendingConfirmResolver: null,
confirmDialog: null,
// 对话压缩状态 // 对话压缩状态
compressing: false, compressing: false,
@ -640,7 +644,11 @@ async function bootstrapApp() {
} catch (err) { } catch (err) {
message = await response.text(); message = await response.text();
} }
alert(`下载失败: ${message}`); this.pushToast({
title: '下载失败',
message: message || '无法完成下载',
type: 'error'
});
return; return;
} }
@ -656,7 +664,11 @@ async function bootstrapApp() {
URL.revokeObjectURL(href); URL.revokeObjectURL(href);
} catch (error) { } catch (error) {
console.error('下载失败:', error); console.error('下载失败:', error);
alert(`下载失败: ${error.message || error}`); this.pushToast({
title: '下载失败',
message: error.message || String(error),
type: 'error'
});
} finally { } finally {
this.hideContextMenu(); this.hideContextMenu();
} }
@ -1973,11 +1985,19 @@ async function bootstrapApp() {
} else { } else {
console.error('对话加载失败:', result.message); console.error('对话加载失败:', result.message);
alert(`加载对话失败: ${result.message}`); this.pushToast({
title: '加载对话失败',
message: result.message || '服务器未返回成功状态',
type: 'error'
});
} }
} catch (error) { } catch (error) {
console.error('加载对话异常:', 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 { } else {
console.error('创建对话失败:', result.message); console.error('创建对话失败:', result.message);
alert(`创建对话失败: ${result.message}`); this.pushToast({
title: '创建对话失败',
message: result.message || '服务器未返回成功状态',
type: 'error'
});
} }
} catch (error) { } catch (error) {
console.error('创建对话异常:', error); console.error('创建对话异常:', error);
alert(`创建对话异常: ${error.message}`); this.pushToast({
title: '创建对话异常',
message: error.message || String(error),
type: 'error'
});
} }
}, },
async deleteConversation(conversationId) { async deleteConversation(conversationId) {
if (!confirm('确定要删除这个对话吗?删除后无法恢复。')) { const confirmed = await this.confirmAction({
title: '删除对话',
message: '确定要删除这个对话吗?删除后无法恢复。',
confirmText: '删除',
cancelText: '取消'
});
if (!confirmed) {
return; return;
} }
@ -2385,11 +2419,19 @@ async function bootstrapApp() {
} else { } else {
console.error('删除对话失败:', result.message); console.error('删除对话失败:', result.message);
alert(`删除对话失败: ${result.message}`); this.pushToast({
title: '删除对话失败',
message: result.message || '服务器未返回成功状态',
type: 'error'
});
} }
} catch (error) { } catch (error) {
console.error('删除对话异常:', 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(); await this.loadConversationsList();
} else { } else {
const message = result.message || result.error || '复制失败'; const message = result.message || result.error || '复制失败';
alert(`复制失败: ${message}`); this.pushToast({
title: '复制对话失败',
message,
type: 'error'
});
} }
} catch (error) { } catch (error) {
console.error('复制对话异常:', 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'; window.location.href = '/login';
} catch (error) { } catch (error) {
console.error('退出登录失败:', 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; this.personalizationLoaded = true;
} catch (error) { } catch (error) {
this.personalizationError = error.message || '加载失败'; this.personalizationError = error.message || '加载失败';
alert(`加载个性化配置失败:${this.personalizationError}`); this.pushToast({
title: '加载个性化配置失败',
message: this.personalizationError,
type: 'error'
});
} finally { } finally {
this.personalizationLoading = false; this.personalizationLoading = false;
} }
@ -2593,7 +2651,11 @@ async function bootstrapApp() {
}, 3000); }, 3000);
} catch (error) { } catch (error) {
this.personalizationError = error.message || '保存失败'; this.personalizationError = error.message || '保存失败';
alert(`保存个性化配置失败:${this.personalizationError}`); this.pushToast({
title: '保存个性化配置失败',
message: this.personalizationError,
type: 'error'
});
} finally { } finally {
this.personalizationSaving = false; this.personalizationSaving = false;
} }
@ -2604,7 +2666,11 @@ async function bootstrapApp() {
return; return;
} }
if (this.personalForm.considerations.length >= this.personalizationMaxConsiderations) { if (this.personalForm.considerations.length >= this.personalizationMaxConsiderations) {
alert(`最多添加 ${this.personalizationMaxConsiderations} 条信息`); this.pushToast({
title: '提示',
message: `最多添加 ${this.personalizationMaxConsiderations} 条信息`,
type: 'warning'
});
return; return;
} }
this.personalForm.considerations = [ this.personalForm.considerations = [
@ -2677,7 +2743,11 @@ async function bootstrapApp() {
} }
} catch (error) { } catch (error) {
console.error('切换思考模式失败:', 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; this.uploading = true;
const toastId = this.pushToast({
title: '上传文件',
message: `正在上传 ${file.name}...`,
type: 'info',
duration: null
});
try { try {
const formData = new FormData(); const formData = new FormData();
@ -2911,6 +2987,13 @@ async function bootstrapApp() {
body: formData body: formData
}); });
if (toastId) {
this.updateToast(toastId, {
message: `文件已上传,正在执行安全扫描...`,
type: 'info'
});
}
let result = {}; let result = {};
try { try {
result = await response.json(); result = await response.json();
@ -2924,10 +3007,48 @@ async function bootstrapApp() {
} }
await this.refreshFileTree(); 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) { } catch (error) {
console.error('文件上传失败:', 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 { } finally {
this.uploading = false; this.uploading = false;
this.resetFileInput(); this.resetFileInput();
@ -2995,15 +3116,25 @@ async function bootstrapApp() {
} }
}, },
clearChat() { async clearChat() {
if (confirm('确定要清除所有对话记录吗?')) { const confirmed = await this.confirmAction({
title: '清除对话',
message: '确定要清除所有对话记录吗?该操作不可撤销。',
confirmText: '清除',
cancelText: '取消'
});
if (confirmed) {
this.socket.emit('send_command', { command: '/clear' }); this.socket.emit('send_command', { command: '/clear' });
} }
}, },
async compressConversation() { async compressConversation() {
if (!this.currentConversationId) { if (!this.currentConversationId) {
alert('当前没有可压缩的对话。'); this.pushToast({
title: '无法压缩',
message: '当前没有可压缩的对话。',
type: 'info'
});
return; return;
} }
@ -3011,7 +3142,12 @@ async function bootstrapApp() {
return; return;
} }
const confirmed = confirm('确定要压缩当前对话记录吗?压缩后会生成新的对话副本。'); const confirmed = await this.confirmAction({
title: '压缩对话',
message: '确定要压缩当前对话记录吗?压缩后会生成新的对话副本。',
confirmText: '压缩',
cancelText: '取消'
});
if (!confirmed) { if (!confirmed) {
return; return;
} }
@ -3033,11 +3169,19 @@ async function bootstrapApp() {
console.log('对话压缩完成:', result); console.log('对话压缩完成:', result);
} else { } else {
const message = result.message || result.error || '压缩失败'; const message = result.message || result.error || '压缩失败';
alert(`压缩失败: ${message}`); this.pushToast({
title: '压缩失败',
message,
type: 'error'
});
} }
} catch (error) { } catch (error) {
console.error('压缩对话异常:', error); console.error('压缩对话异常:', error);
alert(`压缩对话异常: ${error.message}`); this.pushToast({
title: '压缩对话异常',
message: error.message || '请稍后重试',
type: 'error'
});
} finally { } finally {
this.compressing = false; this.compressing = false;
} }
@ -4100,6 +4244,95 @@ async function bootstrapApp() {
}, 5000); }, 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() { containerStatusText() {
if (!this.containerStatus) { if (!this.containerStatus) {
return '未知'; return '未知';

View File

@ -35,8 +35,51 @@
<transition name="quota-toast-fade"> <transition name="quota-toast-fade">
<div class="quota-toast" v-if="quotaToast"> <div class="quota-toast" v-if="quotaToast">
<span class="quota-toast-label">{{ quotaToast.message }}</span> <span class="quota-toast-label">{{ quotaToast.message }}</span>
<button type="button"
class="toast-close"
aria-label="关闭通知"
@click="dismissQuotaToast">
×
</button>
</div> </div>
</transition> </transition>
<div class="toast-stack" :class="{ 'toast-stack--empty': toastQueue.length === 0 }">
<transition-group name="quota-toast-fade" tag="div">
<div class="app-toast"
v-for="toast in toastQueue"
:key="toast.id"
:class="['app-toast', toast.type ? `app-toast--${toast.type}` : '']">
<div class="app-toast-body">
<div v-if="toast.title" class="app-toast-title">{{ toast.title }}</div>
<div class="app-toast-message">{{ toast.message }}</div>
</div>
<button v-if="toast.closable !== false"
type="button"
class="toast-close"
@click="dismissToast(toast.id)">
×
</button>
</div>
</transition-group>
</div>
<div class="confirm-overlay"
v-if="confirmDialog && confirmDialog.visible"
@click.self="handleConfirm(false)">
<div class="confirm-modal">
<div class="confirm-title">{{ confirmDialog.title || '确认操作' }}</div>
<div class="confirm-message">{{ confirmDialog.message }}</div>
<div class="confirm-actions">
<button type="button" class="confirm-button" @click="handleConfirm(false)">
{{ confirmDialog.cancelText || '取消' }}
</button>
<button type="button"
class="confirm-button confirm-button--primary"
@click="handleConfirm(true)">
{{ confirmDialog.confirmText || '确认' }}
</button>
</div>
</div>
</div>
<!-- Loading indicator --> <!-- Loading indicator -->
<div v-if="!isConnected && messages.length === 0" style="text-align: center; padding: 50px;"> <div v-if="!isConnected && messages.length === 0" style="text-align: center; padding: 50px;">
<h2>正在连接服务器...</h2> <h2>正在连接服务器...</h2>

View File

@ -2697,10 +2697,14 @@ o-files {
max-width: 320px; max-width: 320px;
color: var(--claude-text); color: var(--claude-text);
font-weight: 500; font-weight: 500;
display: flex;
align-items: center;
gap: 12px;
} }
.quota-toast-label { .quota-toast-label {
display: block; display: block;
flex: 1;
} }
.quota-toast-fade-enter-active, .quota-toast-fade-enter-active,
@ -2714,6 +2718,136 @@ o-files {
transform: translateX(20px); 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 { .status-pill--running {
background: rgba(118, 176, 134, 0.18); background: rgba(118, 176, 134, 0.18);
color: var(--claude-success); color: var(--claude-success);

View File

@ -15,6 +15,7 @@ from config import (
INVITE_CODES_FILE, INVITE_CODES_FILE,
USER_SPACE_DIR, USER_SPACE_DIR,
USERS_DB_FILE, USERS_DB_FILE,
UPLOAD_QUARANTINE_SUBDIR,
) )
@ -36,6 +37,7 @@ class UserWorkspace:
data_dir: Path data_dir: Path
logs_dir: Path logs_dir: Path
uploads_dir: Path uploads_dir: Path
quarantine_dir: Path
class UserManager: class UserManager:
@ -129,6 +131,12 @@ class UserManager:
(data_dir / "conversations").mkdir(parents=True, exist_ok=True) (data_dir / "conversations").mkdir(parents=True, exist_ok=True)
(data_dir / "backups").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( return UserWorkspace(
username=username, username=username,
root=root, root=root,
@ -136,6 +144,7 @@ class UserManager:
data_dir=data_dir, data_dir=data_dir,
logs_dir=logs_dir, logs_dir=logs_dir,
uploads_dir=uploads_dir, uploads_dir=uploads_dir,
quarantine_dir=quarantine_dir,
) )
def list_invite_codes(self): def list_invite_codes(self):

View File

@ -0,0 +1,6 @@
# Upload Sample Files
- `disallowed_payload.exe`:伪造的可执行占位文件,可用于验证上传白名单/后缀校验是否会拒绝。
- `eicar_test.com`:标准 EICAR 测试串,可用于验证 ClamAV 病毒扫描是否命中。
请勿将这些文件投入生产,只在本地或沙箱环境测试上传流程。

View File

@ -0,0 +1 @@
This is a dummy executable placeholder used to trigger the extension whitelist logic.

View File

@ -0,0 +1 @@
X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*

View File

@ -51,6 +51,7 @@ from config import (
) )
from modules.user_manager import UserManager, UserWorkspace from modules.user_manager import UserManager, UserWorkspace
from modules.gui_file_manager import GuiFileManager from modules.gui_file_manager import GuiFileManager
from modules.upload_security import UploadQuarantineManager, UploadSecurityError
from modules.personalization_manager import ( from modules.personalization_manager import (
load_personalization_config, load_personalization_config,
save_personalization_config, save_personalization_config,
@ -503,6 +504,22 @@ def get_gui_manager(workspace: UserWorkspace) -> GuiFileManager:
return GuiFileManager(str(workspace.project_path)) 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]: def ensure_conversation_loaded(terminal: WebTerminal, conversation_id: Optional[str], thinking_mode: bool) -> Tuple[str, bool]:
"""确保终端加载指定对话,若无则创建新的""" """确保终端加载指定对话,若无则创建新的"""
created_new = False created_new = False
@ -1233,13 +1250,33 @@ def gui_upload_entry(terminal: WebTerminal, workspace: UserWorkspace, username:
return jsonify({"success": False, "error": str(exc)}), 400 return jsonify({"success": False, "error": str(exc)}), 400
try: try:
file_obj.save(target_path) relative_path = manager._to_relative(target_path)
except Exception as exc: 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({ return jsonify({
"success": True, "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 counter += 1
try: 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: except Exception as exc:
return jsonify({ return jsonify({
"success": False, "success": False,
"error": f"保存文件失败: {exc}" "error": f"保存文件失败: {exc}"
}), 500 }), 500
relative_path = str(final_path.relative_to(workspace.project_path)) metadata = result.get("metadata", {})
print(f"{OUTPUT_FORMATS['file']} 上传文件: {relative_path}") print(f"{OUTPUT_FORMATS['file']} 上传文件: {relative_path}")
return jsonify({ return jsonify({
"success": True, "success": True,
"path": relative_path, "path": relative_path,
"filename": final_path.name, "filename": final_path.name,
"folder": target_folder_relative "folder": target_folder_relative,
"scan": metadata.get("scan"),
"sha256": metadata.get("sha256"),
"size": metadata.get("size"),
}) })