From c67c6c3e6c128e4745f66e5bc72bed35b81338b0 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Wed, 11 Mar 2026 00:53:54 +0800 Subject: [PATCH] v0.1.4 --- backend/main.py | 50 ++--- frontend/src/App.tsx | 66 +++++- frontend/src/hooks/useBotsSync.ts | 7 +- .../modules/dashboard/BotDashboardModule.tsx | 192 +++--------------- frontend/src/utils/botAccess.ts | 36 +--- 5 files changed, 113 insertions(+), 238 deletions(-) diff --git a/backend/main.py b/backend/main.py index d350237..8f6c527 100644 --- a/backend/main.py +++ b/backend/main.py @@ -292,7 +292,6 @@ class WSConnectionManager: manager = WSConnectionManager() -BOT_ACCESS_PASSWORD_HEADER = "x-bot-password" PANEL_ACCESS_PASSWORD_HEADER = "x-panel-password" @@ -313,14 +312,6 @@ def _extract_bot_id_from_api_path(path: str) -> Optional[str]: return str(decoded).strip() or None -def _get_supplied_bot_password_http(request: Request) -> str: - header_value = str(request.headers.get(BOT_ACCESS_PASSWORD_HEADER) or "").strip() - if header_value: - return header_value - query_value = str(request.query_params.get("access_password") or "").strip() - return query_value - - def _get_supplied_panel_password_http(request: Request) -> str: header_value = str(request.headers.get(PANEL_ACCESS_PASSWORD_HEADER) or "").strip() if header_value: @@ -341,8 +332,9 @@ def _validate_panel_access_password(supplied: str) -> Optional[str]: return None -def _is_panel_protected_api_path(path: str) -> bool: +def _is_panel_protected_api_path(path: str, method: str = "GET") -> bool: raw = str(path or "").strip() + verb = str(method or "GET").strip().upper() if not raw.startswith("/api/"): return False if raw in { @@ -352,21 +344,28 @@ def _is_panel_protected_api_path(path: str) -> bool: "/api/health/cache", }: return False - if _is_bot_panel_management_api_path(raw): + if _is_bot_panel_management_api_path(raw, verb): return True - # Bot-scoped content/chat APIs are protected by the bot's own access password only. + # Other bot-scoped APIs are not protected by panel password. if _extract_bot_id_from_api_path(raw): return False return True -def _is_bot_panel_management_api_path(path: str) -> bool: +def _is_bot_panel_management_api_path(path: str, method: str = "GET") -> bool: raw = str(path or "").strip() + verb = str(method or "GET").strip().upper() if not raw.startswith("/api/bots/"): return False - if not _extract_bot_id_from_api_path(raw): + bot_id = _extract_bot_id_from_api_path(raw) + if not bot_id: return False - return raw.endswith("/start") or raw.endswith("/stop") or raw.endswith("/deactivate") or raw == f"/api/bots/{_extract_bot_id_from_api_path(raw)}" + return ( + raw.endswith("/start") + or raw.endswith("/stop") + or raw.endswith("/deactivate") + or (verb in {"PUT", "DELETE"} and raw == f"/api/bots/{bot_id}") + ) @app.middleware("http") @@ -374,7 +373,7 @@ async def bot_access_password_guard(request: Request, call_next): if request.method.upper() == "OPTIONS": return await call_next(request) - if _is_panel_protected_api_path(request.url.path): + if _is_panel_protected_api_path(request.url.path, request.method): panel_error = _validate_panel_access_password(_get_supplied_panel_password_http(request)) if panel_error: return JSONResponse(status_code=401, content={"detail": panel_error}) @@ -387,14 +386,6 @@ async def bot_access_password_guard(request: Request, call_next): bot = session.get(BotInstance, bot_id) if not bot: return JSONResponse(status_code=404, content={"detail": "Bot not found"}) - configured_password = str(bot.access_password or "").strip() - if configured_password: - supplied = _get_supplied_bot_password_http(request) - if not supplied: - return JSONResponse(status_code=401, content={"detail": "Bot access password required"}) - if supplied != configured_password: - return JSONResponse(status_code=401, content={"detail": "Invalid bot access password"}) - return await call_next(request) @@ -2721,17 +2712,6 @@ async def websocket_endpoint(websocket: WebSocket, bot_id: str): if not bot: await websocket.close(code=4404, reason="Bot not found") return - configured_password = str(bot.access_password or "").strip() - if configured_password: - supplied = str( - websocket.headers.get(BOT_ACCESS_PASSWORD_HEADER) or websocket.query_params.get("access_password") or "" - ).strip() - if not supplied: - await websocket.close(code=4401, reason="Bot access password required") - return - if supplied != configured_password: - await websocket.close(code=4401, reason="Invalid bot access password") - return await manager.connect(bot_id, websocket) docker_manager.ensure_monitor(bot_id, docker_callback) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 03003a8..bc228b7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -25,26 +25,45 @@ function AuthenticatedApp({ const { theme, setTheme, locale, setLocale, activeBots } = useAppStore(); const [showImageFactory, setShowImageFactory] = useState(false); const [showCreateWizard, setShowCreateWizard] = useState(false); + const [singleBotPassword, setSingleBotPassword] = useState(''); + const [singleBotPasswordError, setSingleBotPasswordError] = useState(''); + const [singleBotUnlocked, setSingleBotUnlocked] = useState(false); useBotsSync(forcedBotId); const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn }); const isSingleBotCompactView = compactMode && Boolean(String(forcedBotId || '').trim()); const [headerCollapsed, setHeaderCollapsed] = useState(isSingleBotCompactView); + const forced = String(forcedBotId || '').trim(); + const forcedBot = forced ? activeBots[forced] : undefined; + const shouldPromptSingleBotPassword = Boolean(forced && forcedBot?.has_access_password && !singleBotUnlocked); useEffect(() => { - const forced = String(forcedBotId || '').trim(); if (!forced) { document.title = t.title; return; } - const bot = activeBots[forced]; - const botName = String(bot?.name || '').trim(); + const botName = String(forcedBot?.name || '').trim(); document.title = botName ? `${t.title} - ${botName}` : `${t.title} - ${forced}`; - }, [activeBots, t.title, forcedBotId]); + }, [forced, forcedBot?.name, t.title]); useEffect(() => { setHeaderCollapsed(isSingleBotCompactView); }, [isSingleBotCompactView, forcedBotId]); + useEffect(() => { + setSingleBotUnlocked(false); + setSingleBotPassword(''); + setSingleBotPasswordError(''); + }, [forced]); + + const unlockSingleBot = () => { + if (!String(singleBotPassword || '').trim()) { + setSingleBotPasswordError(locale === 'zh' ? '请输入 Bot 密码。' : 'Enter the bot password.'); + return; + } + setSingleBotPasswordError(''); + setSingleBotUnlocked(true); + }; + return (
@@ -180,6 +199,36 @@ function AuthenticatedApp({
)} + + {shouldPromptSingleBotPassword ? ( +
+
event.stopPropagation()}> + Nanobot +

{forcedBot?.name || forced}

+

{locale === 'zh' ? '请输入该 Bot 的访问密码后继续。' : 'Enter the bot password to continue.'}

+
+ { + setSingleBotPassword(event.target.value); + if (singleBotPasswordError) setSingleBotPasswordError(''); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') unlockSingleBot(); + }} + placeholder={locale === 'zh' ? 'Bot 密码' : 'Bot password'} + autoFocus + /> + {singleBotPasswordError ?
{singleBotPasswordError}
: null} + +
+
+
+ ) : null} ); } @@ -217,8 +266,15 @@ function PanelLoginGate({ const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [submitting, setSubmitting] = useState(false); + const bypassPanelGate = Boolean(String(urlView.forcedBotId || '').trim()); useEffect(() => { + if (bypassPanelGate) { + setRequired(false); + setAuthenticated(true); + setChecking(false); + return; + } let alive = true; const boot = async () => { try { @@ -259,7 +315,7 @@ function PanelLoginGate({ return () => { alive = false; }; - }, [locale]); + }, [bypassPanelGate, locale]); const onSubmit = async () => { const next = String(password || '').trim(); diff --git a/frontend/src/hooks/useBotsSync.ts b/frontend/src/hooks/useBotsSync.ts index 026d1cd..20573f2 100644 --- a/frontend/src/hooks/useBotsSync.ts +++ b/frontend/src/hooks/useBotsSync.ts @@ -7,7 +7,7 @@ import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgr import { pickLocale } from '../i18n'; import { botsSyncZhCn } from '../i18n/bots-sync.zh-cn'; import { botsSyncEn } from '../i18n/bots-sync.en'; -import { buildMonitorWsUrl, getBotAccessPassword } from '../utils/botAccess'; +import { buildMonitorWsUrl } from '../utils/botAccess'; function normalizeState(v: string): 'THINKING' | 'TOOL_CALL' | 'SUCCESS' | 'ERROR' | 'INFO' { const s = (v || '').toUpperCase(); @@ -122,8 +122,6 @@ export function useBotsSync(forcedBotId?: string) { botIds.forEach((botId) => { if (hydratedMessagesRef.current[botId]) return; - const bot = activeBots[botId]; - if (bot?.has_access_password && !getBotAccessPassword(botId)) return; hydratedMessagesRef.current[botId] = true; void (async () => { try { @@ -177,9 +175,6 @@ export function useBotsSync(forcedBotId?: string) { if (bot.docker_status !== 'RUNNING') { return; } - if (bot.has_access_password && !getBotAccessPassword(bot.id)) { - return; - } if (socketsRef.current[bot.id]) { return; } diff --git a/frontend/src/modules/dashboard/BotDashboardModule.tsx b/frontend/src/modules/dashboard/BotDashboardModule.tsx index d4edfae..7adba47 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.tsx +++ b/frontend/src/modules/dashboard/BotDashboardModule.tsx @@ -18,7 +18,6 @@ import { dashboardZhCn } from '../../i18n/dashboard.zh-cn'; import { dashboardEn } from '../../i18n/dashboard.en'; import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider'; import { LucentIconButton } from '../../components/lucent/LucentIconButton'; -import { clearBotAccessPassword, getBotAccessPassword, isBotUnauthorizedError, setBotAccessPassword } from '../../utils/botAccess'; interface BotDashboardModuleProps { onOpenCreateWizard?: () => void; @@ -649,6 +648,8 @@ export function BotDashboardModule({ const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false); const [workspaceHoverCard, setWorkspaceHoverCard] = useState(null); const runtimeMenuRef = useRef(null); + const botOrderRef = useRef>({}); + const nextBotOrderRef = useRef(1); const applyEditFormFromBot = useCallback((bot?: any) => { if (!bot) return; setProviderTestResult(''); @@ -914,13 +915,37 @@ export function BotDashboardModule({ storage_gb: '10', }); + useEffect(() => { + const ordered = Object.values(activeBots).sort((a, b) => { + const aCreated = parseBotTimestamp(a.created_at); + const bCreated = parseBotTimestamp(b.created_at); + if (aCreated !== bCreated) return aCreated - bCreated; + return String(a.id || '').localeCompare(String(b.id || '')); + }); + + ordered.forEach((bot) => { + const id = String(bot.id || '').trim(); + if (!id) return; + if (botOrderRef.current[id] !== undefined) return; + botOrderRef.current[id] = nextBotOrderRef.current; + nextBotOrderRef.current += 1; + }); + + const alive = new Set(ordered.map((bot) => String(bot.id || '').trim()).filter(Boolean)); + Object.keys(botOrderRef.current).forEach((id) => { + if (!alive.has(id)) delete botOrderRef.current[id]; + }); + }, [activeBots]); + const bots = useMemo( () => Object.values(activeBots).sort((a, b) => { - const aCreated = parseBotTimestamp(a.created_at); - const bCreated = parseBotTimestamp(b.created_at); - if (aCreated !== bCreated) return aCreated - bCreated; - return String(a.id || '').localeCompare(String(b.id || '')); + const aId = String(a.id || '').trim(); + const bId = String(b.id || '').trim(); + const aOrder = botOrderRef.current[aId] ?? Number.MAX_SAFE_INTEGER; + const bOrder = botOrderRef.current[bId] ?? Number.MAX_SAFE_INTEGER; + if (aOrder !== bOrder) return aOrder - bOrder; + return aId.localeCompare(bId); }), [activeBots], ); @@ -947,97 +972,6 @@ export function BotDashboardModule({ const noteLocale = pickLocale(locale, { 'zh-cn': 'zh-cn' as const, en: 'en' as const }); const t = pickLocale(locale, { 'zh-cn': dashboardZhCn, en: dashboardEn }); const lc = isZh ? channelsZhCn : channelsEn; - const botAccessCheckRef = useRef | undefined>>({}); - const botPasswordResolverRef = useRef<((value: string | null) => void) | null>(null); - const [botPasswordDialog, setBotPasswordDialog] = useState<{ - open: boolean; - botName: string; - invalid: boolean; - value: string; - }>({ - open: false, - botName: '', - invalid: false, - value: '', - }); - - const promptForBotPassword = (botName: string, invalid: boolean): Promise => { - setBotPasswordDialog({ - open: true, - botName, - invalid, - value: '', - }); - return new Promise((resolve) => { - botPasswordResolverRef.current = resolve; - }); - }; - - const closeBotPasswordDialog = (value: string | null) => { - const resolver = botPasswordResolverRef.current; - botPasswordResolverRef.current = null; - setBotPasswordDialog((prev) => ({ ...prev, open: false, value: '' })); - if (resolver) resolver(value && String(value).trim() ? String(value).trim() : null); - }; - - const verifyBotPassword = async (botId: string): Promise => { - await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/channels`); - return true; - }; - - const ensureBotAccess = async (botId: string): Promise => { - const normalizedBotId = String(botId || '').trim(); - if (!normalizedBotId) return false; - const bot = activeBots[normalizedBotId]; - if (!bot?.has_access_password) return true; - - const inFlight = botAccessCheckRef.current[normalizedBotId]; - if (inFlight) return inFlight; - - const checkPromise = (async () => { - const botName = String(bot.name || bot.id || normalizedBotId).trim(); - let askForNewPassword = false; - for (let attempt = 0; attempt < 3; attempt += 1) { - let password = getBotAccessPassword(normalizedBotId); - if (!password || askForNewPassword) { - const input = await promptForBotPassword(botName, askForNewPassword); - if (input === null) { - notify(isZh ? '已取消密码输入,无法访问该机器人。' : 'Password input cancelled. Bot access blocked.', { - tone: 'warning', - }); - return false; - } - setBotAccessPassword(normalizedBotId, input); - password = input; - } - if (!password) { - askForNewPassword = true; - continue; - } - - try { - await verifyBotPassword(normalizedBotId); - return true; - } catch (error: any) { - if (isBotUnauthorizedError(error, normalizedBotId)) { - clearBotAccessPassword(normalizedBotId); - askForNewPassword = true; - notify(isZh ? '访问密码错误,请重试。' : 'Access password is invalid. Please retry.', { tone: 'warning' }); - continue; - } - throw error; - } - } - return false; - })(); - - botAccessCheckRef.current[normalizedBotId] = checkPromise; - try { - return await checkPromise; - } finally { - delete botAccessCheckRef.current[normalizedBotId]; - } - }; const baseImageOptions = useMemo(() => { const readyTags = new Set( availableImages @@ -2447,8 +2381,7 @@ export function BotDashboardModule({ let cancelled = false; const loadAll = async () => { try { - const granted = await ensureBotAccess(selectedBotId); - if (!granted || cancelled) return; + if (cancelled) return; await Promise.all([ loadWorkspaceTree(selectedBotId, ''), loadCronJobs(selectedBotId), @@ -2457,15 +2390,6 @@ export function BotDashboardModule({ ]); } catch (error: any) { const detail = String(error?.response?.data?.detail || '').trim(); - if (isBotUnauthorizedError(error, selectedBotId)) { - clearBotAccessPassword(selectedBotId); - if (!cancelled) { - notify(isZh ? '访问密码校验失败,请重新进入该机器人。' : 'Bot password check failed. Reopen the bot and retry.', { - tone: 'error', - }); - } - return; - } if (!cancelled && detail) { notify(detail, { tone: 'error' }); } @@ -2584,14 +2508,6 @@ export function BotDashboardModule({ } await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${targetBotId}`, payload); - if (mode === 'base') { - const nextPassword = String(editForm.access_password || '').trim(); - if (nextPassword) { - setBotAccessPassword(targetBotId, nextPassword); - } else { - clearBotAccessPassword(targetBotId); - } - } await refresh(); setShowBaseModal(false); setShowParamModal(false); @@ -4214,52 +4130,6 @@ export function BotDashboardModule({ ) : null} - {botPasswordDialog.open ? ( -
closeBotPasswordDialog(null)}> -
event.stopPropagation()}> -
-
-

- {botPasswordDialog.invalid - ? (isZh ? `访问密码错误:${botPasswordDialog.botName}` : `Invalid access password: ${botPasswordDialog.botName}`) - : (isZh ? `请输入访问密码:${botPasswordDialog.botName}` : `Enter access password for ${botPasswordDialog.botName}`)} -

-
-
- closeBotPasswordDialog(null)} tooltip={isZh ? '关闭' : 'Close'} aria-label={isZh ? '关闭' : 'Close'}> - - -
-
-
- - setBotPasswordDialog((prev) => ({ - ...prev, - value: event.target.value, - })) - } - onKeyDown={(event) => { - if (event.key === 'Enter') closeBotPasswordDialog(botPasswordDialog.value); - }} - placeholder={isZh ? '输入 Bot 访问密码' : 'Enter bot access password'} - /> -
- - -
-
-
-
- ) : null} ); } diff --git a/frontend/src/utils/botAccess.ts b/frontend/src/utils/botAccess.ts index ca2868b..2c06fc9 100644 --- a/frontend/src/utils/botAccess.ts +++ b/frontend/src/utils/botAccess.ts @@ -1,8 +1,6 @@ import axios from 'axios'; import { appendPanelAccessPassword } from './panelAccess'; -const BOT_PASSWORD_HEADER = 'X-Bot-Password'; - let initialized = false; const memoryMap = new Map(); @@ -63,41 +61,17 @@ export function clearAllBotAccessPasswords(): void { } export function isBotUnauthorizedError(error: any, botId?: string): boolean { - if (!axios.isAxiosError(error)) return false; - if (Number(error.response?.status) !== 401) return false; - const detail = String(error.response?.data?.detail || '').trim().toLowerCase(); - if (!detail.includes('bot access password')) return false; - if (!botId) return true; - - const fromConfig = extractBotIdFromApiPath(String(error.config?.url || '')); - const fromRequest = extractBotIdFromApiPath(String(error.request?.responseURL || '')); - const expected = normalizeBotId(botId); - return expected === fromConfig || expected === fromRequest; + void error; + void botId; + return false; } export function buildMonitorWsUrl(base: string, botId: string): string { - const target = appendPanelAccessPassword(`${String(base || '').replace(/\/$/, '')}/${encodeURIComponent(botId)}`); - const password = getBotAccessPassword(botId); - if (!password) return target; - const joiner = target.includes('?') ? '&' : '?'; - return `${target}${joiner}access_password=${encodeURIComponent(password)}`; + return appendPanelAccessPassword(`${String(base || '').replace(/\/$/, '')}/${encodeURIComponent(botId)}`); } export function setupBotAccessAuth(): void { if (initialized) return; initialized = true; - - axios.interceptors.request.use((config) => { - const botId = extractBotIdFromApiPath(String(config.url || '')); - if (!botId) return config; - const password = getBotAccessPassword(botId); - if (!password) return config; - - const headers = config.headers || {}; - if (!(BOT_PASSWORD_HEADER in (headers as Record))) { - (headers as Record)[BOT_PASSWORD_HEADER] = password; - config.headers = headers; - } - return config; - }); + void axios; }