feat: add upload quarantine scanning and ui toasts
This commit is contained in:
parent
7c7038b5c9
commit
75a7febcd1
@ -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
61
config/uploads.py
Normal 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
278
modules/upload_security.py
Normal 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",
|
||||||
|
]
|
||||||
@ -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):
|
||||||
|
|||||||
281
static/app.js
281
static/app.js
@ -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 '未知';
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
134
static/style.css
134
static/style.css
@ -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);
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
6
test/upload_samples/README.md
Normal file
6
test/upload_samples/README.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Upload Sample Files
|
||||||
|
|
||||||
|
- `disallowed_payload.exe`:伪造的可执行占位文件,可用于验证上传白名单/后缀校验是否会拒绝。
|
||||||
|
- `eicar_test.com`:标准 EICAR 测试串,可用于验证 ClamAV 病毒扫描是否命中。
|
||||||
|
|
||||||
|
请勿将这些文件投入生产,只在本地或沙箱环境测试上传流程。
|
||||||
1
test/upload_samples/disallowed_payload.exe
Normal file
1
test/upload_samples/disallowed_payload.exe
Normal file
@ -0,0 +1 @@
|
|||||||
|
This is a dummy executable placeholder used to trigger the extension whitelist logic.
|
||||||
1
test/upload_samples/eicar_test.com
Normal file
1
test/upload_samples/eicar_test.com
Normal file
@ -0,0 +1 @@
|
|||||||
|
X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*
|
||||||
@ -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"),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user