diff --git a/.env.prod.example b/.env.prod.example index acdafd8..cd62026 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -48,8 +48,6 @@ DEFAULT_BOT_SYSTEM_TIMEZONE=Asia/Shanghai # Panel access protection (deployment secret, not stored in sys_setting) PANEL_ACCESS_PASSWORD=change_me_panel_password -WORKSPACE_PREVIEW_SIGNING_SECRET=change_me_workspace_preview_signing_secret -WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS=3600 # Browser credential requests must use an explicit CORS allowlist (deployment security setting). # If frontend and backend are served under the same origin via nginx `/api` proxy, diff --git a/backend/.env.example b/backend/.env.example index 842c3b3..6c1affe 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -26,8 +26,6 @@ REDIS_DEFAULT_TTL=60 # Optional panel-level access password for all backend API/WS calls. PANEL_ACCESS_PASSWORD= -WORKSPACE_PREVIEW_SIGNING_SECRET= -WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS=3600 # Explicit CORS allowlist for browser credential requests. # For local development, the backend defaults to common Vite dev origins. diff --git a/backend/api/workspace_router.py b/backend/api/workspace_router.py index aa372c0..c694937 100644 --- a/backend/api/workspace_router.py +++ b/backend/api/workspace_router.py @@ -6,6 +6,7 @@ from sqlmodel import Session from core.database import get_session from models.bot import BotInstance from schemas.system import WorkspaceFileUpdateRequest, WorkspacePreviewUrlRequest +from services.platform_system_settings_service import get_workspace_preview_token_ttl_seconds from services.workspace_service import ( create_workspace_html_preview_url, get_workspace_tree_data, @@ -77,10 +78,13 @@ def create_workspace_preview_url( bot = session.get(BotInstance, bot_id) if not bot: raise HTTPException(status_code=404, detail="Bot not found") + ttl_seconds = payload.ttl_seconds + if ttl_seconds is None: + ttl_seconds = get_workspace_preview_token_ttl_seconds(session) return create_workspace_html_preview_url( bot_id=bot_id, path=payload.path, - ttl_seconds=payload.ttl_seconds, + ttl_seconds=ttl_seconds, ) @router.get("/api/bots/{bot_id}/workspace/download") diff --git a/backend/core/database.py b/backend/core/database.py index 3c9e631..e90092c 100644 --- a/backend/core/database.py +++ b/backend/core/database.py @@ -42,19 +42,6 @@ REQUIRED_TABLES = ( "topic_item", ) -REQUIRED_SYS_SETTING_KEYS = ( - "page_size", - "chat_pull_page_size", - "auth_token_ttl_hours", - "auth_token_max_active", - "upload_max_mb", - "allowed_attachment_extensions", - "workspace_download_extensions", - "speech_enabled", - "activity_event_retention_days", -) - - def _validate_required_tables() -> None: inspector = inspect(engine) missing = [table_name for table_name in REQUIRED_TABLES if not inspector.has_table(table_name)] @@ -65,30 +52,14 @@ def _validate_required_tables() -> None: "Run scripts/init-full-db.sh or apply scripts/sql/create-tables.sql before starting the backend." ) - -def _validate_required_sys_settings() -> None: - placeholders = ", ".join(f":k{i}" for i, _ in enumerate(REQUIRED_SYS_SETTING_KEYS)) - params = {f"k{i}": key for i, key in enumerate(REQUIRED_SYS_SETTING_KEYS)} - with engine.connect() as conn: - rows = conn.execute( - text(f'SELECT key FROM "{SYS_SETTING_TABLE}" WHERE key IN ({placeholders})'), - params, - ).scalars().all() - present = {str(row or "").strip() for row in rows if str(row or "").strip()} - missing = [key for key in REQUIRED_SYS_SETTING_KEYS if key not in present] - if missing: - raise RuntimeError( - "Database seed data is not initialized. " - f"Missing sys_setting keys: {', '.join(missing)}. " - "Run scripts/init-full-db.sh or apply scripts/sql/init-data.sql before starting the backend." - ) - - def init_database() -> None: with engine.connect() as conn: conn.execute(text("SELECT 1")) _validate_required_tables() - _validate_required_sys_settings() + from services.platform_system_settings_service import validate_required_system_settings + + with Session(engine) as session: + validate_required_system_settings(session) def get_session(): diff --git a/backend/core/settings.py b/backend/core/settings.py index 6935592..c8b5e53 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -189,15 +189,6 @@ DEFAULT_PAGE_SIZE: Final[int] = 10 DEFAULT_CHAT_PULL_PAGE_SIZE: Final[int] = 60 DEFAULT_AUTH_TOKEN_TTL_HOURS: Final[int] = _env_int("AUTH_TOKEN_TTL_HOURS", 24, 1, 720) DEFAULT_AUTH_TOKEN_MAX_ACTIVE: Final[int] = _env_int("AUTH_TOKEN_MAX_ACTIVE", 2, 1, 20) -WORKSPACE_PREVIEW_SIGNING_SECRET: Final[str] = str( - os.getenv("WORKSPACE_PREVIEW_SIGNING_SECRET") or DATABASE_URL -).strip() -WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS: Final[int] = _env_int( - "WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS", - 3600, - 60, - 86400, -) DEFAULT_BOT_SYSTEM_TIMEZONE: Final[str] = str( os.getenv("DEFAULT_BOT_SYSTEM_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai" ).strip() or "Asia/Shanghai" diff --git a/backend/services/platform_settings_core.py b/backend/services/platform_settings_core.py index 9767728..8948917 100644 --- a/backend/services/platform_settings_core.py +++ b/backend/services/platform_settings_core.py @@ -19,7 +19,9 @@ from schemas.platform import SystemSettingItem DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS: tuple[str, ...] = () DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS = 7 +DEFAULT_WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS = 3600 ACTIVITY_EVENT_RETENTION_SETTING_KEY = "activity_event_retention_days" +WORKSPACE_PREVIEW_TOKEN_TTL_SETTING_KEY = "workspace_preview_token_ttl_seconds" SETTING_KEYS = ( "page_size", "chat_pull_page_size", @@ -30,7 +32,7 @@ SETTING_KEYS = ( "workspace_download_extensions", "speech_enabled", ) -PROTECTED_SETTING_KEYS = set(SETTING_KEYS) | {ACTIVITY_EVENT_RETENTION_SETTING_KEY} +PROTECTED_SETTING_KEYS = set(SETTING_KEYS) | {ACTIVITY_EVENT_RETENTION_SETTING_KEY, WORKSPACE_PREVIEW_TOKEN_TTL_SETTING_KEY} SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = { "page_size": { "name": "分页大小", @@ -95,6 +97,15 @@ SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = { "is_public": False, "sort_order": 30, }, + WORKSPACE_PREVIEW_TOKEN_TTL_SETTING_KEY: { + "name": "工作区预览 Token 过期秒数", + "category": "workspace", + "description": "HTML 预览地址中临时访问 Token 的默认有效时长,单位秒。", + "value_type": "integer", + "value": DEFAULT_WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS, + "is_public": False, + "sort_order": 31, + }, "speech_enabled": { "name": "语音识别开关", "category": "speech", diff --git a/backend/services/platform_system_settings_service.py b/backend/services/platform_system_settings_service.py index 9549327..925f351 100644 --- a/backend/services/platform_system_settings_service.py +++ b/backend/services/platform_system_settings_service.py @@ -8,6 +8,7 @@ from services.platform_settings_core import ( ACTIVITY_EVENT_RETENTION_SETTING_KEY, PROTECTED_SETTING_KEYS, SYSTEM_SETTING_DEFINITIONS, + WORKSPACE_PREVIEW_TOKEN_TTL_SETTING_KEY, _normalize_setting_key, _read_setting_value, _setting_item_from_row, @@ -30,15 +31,19 @@ def _prune_deprecated_system_settings(session: Session) -> None: session.commit() -def validate_required_system_settings(session: Session) -> None: - _prune_deprecated_system_settings(session) +def _missing_required_system_settings(session: Session) -> List[str]: stmt = select(PlatformSetting.key).where(PlatformSetting.key.in_(REQUIRED_SYSTEM_SETTING_KEYS)) present = { str(key or "").strip() for key in session.exec(stmt).all() if str(key or "").strip() } - missing = [key for key in REQUIRED_SYSTEM_SETTING_KEYS if key not in present] + return [key for key in REQUIRED_SYSTEM_SETTING_KEYS if key not in present] + + +def validate_required_system_settings(session: Session) -> None: + _prune_deprecated_system_settings(session) + missing = _missing_required_system_settings(session) if missing: raise RuntimeError( "Database seed data is not initialized. " @@ -118,3 +123,22 @@ def get_activity_event_retention_days(session: Session) -> int: "Fix the row manually or reapply scripts/sql/init-data.sql." ) from exc return max(1, min(3650, value)) + + +def get_workspace_preview_token_ttl_seconds(session: Session) -> int: + validate_required_system_settings(session) + row = session.get(PlatformSetting, WORKSPACE_PREVIEW_TOKEN_TTL_SETTING_KEY) + if row is None: + raise RuntimeError( + "Database seed data is not initialized. " + f"Missing sys_setting key: {WORKSPACE_PREVIEW_TOKEN_TTL_SETTING_KEY}. " + "Run scripts/init-full-db.sh or apply scripts/sql/init-data.sql before starting the backend." + ) + try: + value = int(_read_setting_value(row)) + except Exception as exc: + raise RuntimeError( + f"sys_setting value is invalid for key: {WORKSPACE_PREVIEW_TOKEN_TTL_SETTING_KEY}. " + "Fix the row manually or reapply scripts/sql/init-data.sql." + ) from exc + return max(60, value) diff --git a/backend/services/workspace_preview_token_service.py b/backend/services/workspace_preview_token_service.py index 070cb13..0396520 100644 --- a/backend/services/workspace_preview_token_service.py +++ b/backend/services/workspace_preview_token_service.py @@ -5,9 +5,11 @@ import json import time from typing import Any, Dict, Optional -from core.settings import WORKSPACE_PREVIEW_SIGNING_SECRET, WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS +from core.settings import DATABASE_URL HTML_PREVIEW_EXTENSIONS = {".html", ".htm"} +DEFAULT_WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS = 3600 +_WORKSPACE_PREVIEW_SIGNING_KEY = DATABASE_URL def _b64url_encode(raw: bytes) -> str: @@ -31,7 +33,7 @@ def is_html_preview_path(path: str) -> bool: def create_workspace_preview_token(bot_id: str, path: str, ttl_seconds: Optional[int] = None) -> Dict[str, Any]: normalized_bot_id = str(bot_id or "").strip() normalized_path = normalize_workspace_preview_path(path) - ttl = max(60, min(int(ttl_seconds or WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS), 86400)) + ttl = max(60, int(ttl_seconds or DEFAULT_WORKSPACE_PREVIEW_TOKEN_TTL_SECONDS)) expires_at = int(time.time()) + ttl payload = { "bot_id": normalized_bot_id, @@ -42,7 +44,7 @@ def create_workspace_preview_token(bot_id: str, path: str, ttl_seconds: Optional payload_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8") body = _b64url_encode(payload_json) signature = hmac.new( - WORKSPACE_PREVIEW_SIGNING_SECRET.encode("utf-8"), + _WORKSPACE_PREVIEW_SIGNING_KEY.encode("utf-8"), body.encode("ascii"), hashlib.sha256, ).digest() @@ -59,7 +61,7 @@ def resolve_workspace_preview_token(token: str) -> Optional[Dict[str, Any]]: return None body, signature_raw = raw_token.split(".", 1) expected_signature = hmac.new( - WORKSPACE_PREVIEW_SIGNING_SECRET.encode("utf-8"), + _WORKSPACE_PREVIEW_SIGNING_KEY.encode("utf-8"), body.encode("ascii"), hashlib.sha256, ).digest() diff --git a/frontend/src/modules/dashboard/components/DashboardChannelConfigModal.tsx b/frontend/src/modules/dashboard/components/DashboardChannelConfigModal.tsx index 8cf4664..3e2ea6c 100644 --- a/frontend/src/modules/dashboard/components/DashboardChannelConfigModal.tsx +++ b/frontend/src/modules/dashboard/components/DashboardChannelConfigModal.tsx @@ -487,25 +487,28 @@ export function ChannelConfigModal({
{summary}
- - void onRemoveChannel(channel)} - tooltip={labels.remove} - aria-label={labels.remove} - > - - + {!dashboardChannel ? ( + <> + + void onRemoveChannel(channel)} + tooltip={labels.remove} + aria-label={labels.remove} + > + + + + ) : null} onToggleExpandedChannel(uiKey)} diff --git a/frontend/src/modules/dashboard/components/DashboardChatComposer.tsx b/frontend/src/modules/dashboard/components/DashboardChatComposer.tsx index e0fc307..e484358 100644 --- a/frontend/src/modules/dashboard/components/DashboardChatComposer.tsx +++ b/frontend/src/modules/dashboard/components/DashboardChatComposer.tsx @@ -1,5 +1,5 @@ import { ArrowUp, ChevronLeft, Clock3, Command, Download, Eye, FileText, Mic, Paperclip, Plus, RefreshCw, RotateCcw, Square, X } from 'lucide-react'; -import type { ChangeEventHandler, KeyboardEventHandler, RefObject } from 'react'; +import { useRef, useState, type ChangeEventHandler, type DragEvent, type DragEventHandler, type KeyboardEventHandler, type RefObject } from 'react'; import { LucentIconButton } from '../../../components/lucent/LucentIconButton'; import { normalizeAssistantMessageText } from '../../../shared/text/messageText'; @@ -26,6 +26,7 @@ interface DashboardChatComposerProps { filePickerRef: RefObject; allowedAttachmentExtensions: string[]; onPickAttachments: ChangeEventHandler; + onDropAttachments: (files: FileList) => Promise | void; controlCommandPanelOpen: boolean; controlCommandPanelRef: RefObject; onToggleControlCommandPanel: () => void; @@ -125,6 +126,7 @@ export function DashboardChatComposer({ filePickerRef, allowedAttachmentExtensions, onPickAttachments, + onDropAttachments, controlCommandPanelOpen, controlCommandPanelRef, onToggleControlCommandPanel, @@ -159,6 +161,46 @@ export function DashboardChatComposer({ }: DashboardChatComposerProps) { const showInterruptSubmitAction = submitActionMode === 'interrupt'; const hasComposerDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply); + const [isDraggingAttachments, setIsDraggingAttachments] = useState(false); + const dragDepthRef = useRef(0); + const canDropAttachments = canChat && !isUploadingAttachments && !isVoiceRecording && !isVoiceTranscribing; + const hasDraggedFiles = (event: DragEvent) => + Array.from(event.dataTransfer?.types || []).includes('Files'); + + const handleAttachmentDragEnter: DragEventHandler = (event) => { + if (!hasDraggedFiles(event)) return; + event.preventDefault(); + event.stopPropagation(); + if (!canDropAttachments) return; + dragDepthRef.current += 1; + setIsDraggingAttachments(true); + }; + + const handleAttachmentDragOver: DragEventHandler = (event) => { + if (!hasDraggedFiles(event)) return; + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = canDropAttachments ? 'copy' : 'none'; + if (canDropAttachments) setIsDraggingAttachments(true); + }; + + const handleAttachmentDragLeave: DragEventHandler = (event) => { + if (!hasDraggedFiles(event)) return; + event.preventDefault(); + event.stopPropagation(); + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setIsDraggingAttachments(false); + }; + + const handleAttachmentDrop: DragEventHandler = (event) => { + if (!hasDraggedFiles(event)) return; + event.preventDefault(); + event.stopPropagation(); + dragDepthRef.current = 0; + setIsDraggingAttachments(false); + if (!canDropAttachments || event.dataTransfer.files.length === 0) return; + void onDropAttachments(event.dataTransfer.files); + }; return ( <> @@ -209,7 +251,13 @@ export function DashboardChatComposer({
) : null} -
+
+ {isDraggingAttachments ? ( + + ) : null}