diff --git a/backend/main.py b/backend/main.py
index 8f6c527..ee006ca 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -13,7 +13,7 @@ from urllib.parse import unquote
import httpx
from pydantic import BaseModel
from fastapi import Depends, FastAPI, File, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect
-from fastapi.responses import FileResponse, JSONResponse
+from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from sqlmodel import Session, select
@@ -138,6 +138,10 @@ class BotEnvParamsUpdateRequest(BaseModel):
env_params: Optional[Dict[str, str]] = None
+class BotPageAuthLoginRequest(BaseModel):
+ password: str
+
+
class CommandRequest(BaseModel):
command: Optional[str] = None
attachments: Optional[List[str]] = None
@@ -1670,6 +1674,24 @@ def get_bot_detail(bot_id: str, session: Session = Depends(get_session)):
return row
+@app.post("/api/bots/{bot_id}/auth/login")
+def login_bot_page(bot_id: str, payload: BotPageAuthLoginRequest, session: Session = Depends(get_session)):
+ bot = session.get(BotInstance, bot_id)
+ if not bot:
+ raise HTTPException(status_code=404, detail="Bot not found")
+
+ configured = str(bot.access_password or "").strip()
+ if not configured:
+ return {"ok": True, "enabled": False, "bot_id": bot_id}
+
+ candidate = str(payload.password or "").strip()
+ if not candidate:
+ raise HTTPException(status_code=401, detail="Bot access password required")
+ if candidate != configured:
+ raise HTTPException(status_code=401, detail="Invalid bot access password")
+ return {"ok": True, "enabled": True, "bot_id": bot_id}
+
+
@app.get("/api/bots/{bot_id}/resources")
def get_bot_resources(bot_id: str, session: Session = Depends(get_session)):
bot = session.get(BotInstance, bot_id)
@@ -2538,7 +2560,59 @@ def read_workspace_file(
}
-def _serve_workspace_file(bot_id: str, path: str, download: bool, session: Session):
+def _stream_file_range(target: str, start: int, end: int, chunk_size: int = 1024 * 1024):
+ with open(target, "rb") as fh:
+ fh.seek(start)
+ remaining = end - start + 1
+ while remaining > 0:
+ chunk = fh.read(min(chunk_size, remaining))
+ if not chunk:
+ break
+ remaining -= len(chunk)
+ yield chunk
+
+
+def _build_ranged_workspace_response(target: str, media_type: str, range_header: str):
+ file_size = os.path.getsize(target)
+ range_match = re.match(r"bytes=(\d*)-(\d*)", range_header.strip())
+ if not range_match:
+ raise HTTPException(status_code=416, detail="Invalid range")
+
+ start_raw, end_raw = range_match.groups()
+ if start_raw == "" and end_raw == "":
+ raise HTTPException(status_code=416, detail="Invalid range")
+
+ if start_raw == "":
+ length = int(end_raw)
+ if length <= 0:
+ raise HTTPException(status_code=416, detail="Invalid range")
+ start = max(file_size - length, 0)
+ end = file_size - 1
+ else:
+ start = int(start_raw)
+ end = int(end_raw) if end_raw else file_size - 1
+
+ if start >= file_size or start < 0:
+ raise HTTPException(status_code=416, detail="Requested range not satisfiable")
+ end = min(end, file_size - 1)
+ if end < start:
+ raise HTTPException(status_code=416, detail="Requested range not satisfiable")
+
+ content_length = end - start + 1
+ headers = {
+ "Accept-Ranges": "bytes",
+ "Content-Range": f"bytes {start}-{end}/{file_size}",
+ "Content-Length": str(content_length),
+ }
+ return StreamingResponse(
+ _stream_file_range(target, start, end),
+ status_code=206,
+ media_type=media_type or "application/octet-stream",
+ headers=headers,
+ )
+
+
+def _serve_workspace_file(bot_id: str, path: str, download: bool, request: Request, session: Session):
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
@@ -2548,13 +2622,19 @@ def _serve_workspace_file(bot_id: str, path: str, download: bool, session: Sessi
raise HTTPException(status_code=404, detail="File not found")
media_type, _ = mimetypes.guess_type(target)
+ range_header = request.headers.get("range", "")
+ if range_header and not download:
+ return _build_ranged_workspace_response(target, media_type or "application/octet-stream", range_header)
+
+ common_headers = {"Accept-Ranges": "bytes"}
if download:
return FileResponse(
target,
media_type=media_type or "application/octet-stream",
filename=os.path.basename(target),
+ headers=common_headers,
)
- return FileResponse(target, media_type=media_type or "application/octet-stream")
+ return FileResponse(target, media_type=media_type or "application/octet-stream", headers=common_headers)
@app.get("/api/bots/{bot_id}/cron/jobs")
@@ -2618,9 +2698,10 @@ def download_workspace_file(
bot_id: str,
path: str,
download: bool = False,
+ request: Request = None,
session: Session = Depends(get_session),
):
- return _serve_workspace_file(bot_id=bot_id, path=path, download=download, session=session)
+ return _serve_workspace_file(bot_id=bot_id, path=path, download=download, request=request, session=session)
@app.get("/public/bots/{bot_id}/workspace/download")
@@ -2628,9 +2709,10 @@ def public_download_workspace_file(
bot_id: str,
path: str,
download: bool = False,
+ request: Request = None,
session: Session = Depends(get_session),
):
- return _serve_workspace_file(bot_id=bot_id, path=path, download=download, session=session)
+ return _serve_workspace_file(bot_id=bot_id, path=path, download=download, request=request, session=session)
@app.post("/api/bots/{bot_id}/workspace/upload")
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index bc228b7..72ddf3e 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -15,6 +15,10 @@ import { LucentTooltip } from './components/lucent/LucentTooltip';
import { clearPanelAccessPassword, getPanelAccessPassword, setPanelAccessPassword } from './utils/panelAccess';
import './App.css';
+function getSingleBotPasswordKey(botId: string) {
+ return `nanobot-bot-page-password:${String(botId || '').trim()}`;
+}
+
function AuthenticatedApp({
forcedBotId,
compactMode,
@@ -28,6 +32,7 @@ function AuthenticatedApp({
const [singleBotPassword, setSingleBotPassword] = useState('');
const [singleBotPasswordError, setSingleBotPasswordError] = useState('');
const [singleBotUnlocked, setSingleBotUnlocked] = useState(false);
+ const [singleBotSubmitting, setSingleBotSubmitting] = useState(false);
useBotsSync(forcedBotId);
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
const isSingleBotCompactView = compactMode && Boolean(String(forcedBotId || '').trim());
@@ -55,13 +60,54 @@ function AuthenticatedApp({
setSingleBotPasswordError('');
}, [forced]);
- const unlockSingleBot = () => {
- if (!String(singleBotPassword || '').trim()) {
+ useEffect(() => {
+ if (!forced || !forcedBot?.has_access_password || singleBotUnlocked) return;
+ const stored = typeof window !== 'undefined' ? window.sessionStorage.getItem(getSingleBotPasswordKey(forced)) || '' : '';
+ if (!stored) return;
+ let alive = true;
+ const boot = async () => {
+ try {
+ await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forced)}/auth/login`, { password: stored });
+ if (!alive) return;
+ setSingleBotUnlocked(true);
+ setSingleBotPassword('');
+ setSingleBotPasswordError('');
+ } catch {
+ if (typeof window !== 'undefined') window.sessionStorage.removeItem(getSingleBotPasswordKey(forced));
+ if (!alive) return;
+ setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重新输入。' : 'Invalid bot password. Please try again.');
+ }
+ };
+ void boot();
+ return () => {
+ alive = false;
+ };
+ }, [forced, forcedBot?.has_access_password, locale, singleBotUnlocked]);
+
+ const unlockSingleBot = async () => {
+ const entered = String(singleBotPassword || '').trim();
+ if (!entered) {
setSingleBotPasswordError(locale === 'zh' ? '请输入 Bot 密码。' : 'Enter the bot password.');
return;
}
- setSingleBotPasswordError('');
- setSingleBotUnlocked(true);
+ if (!forced) return;
+ setSingleBotSubmitting(true);
+ try {
+ await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forced)}/auth/login`, { password: entered });
+ if (typeof window !== 'undefined') {
+ window.sessionStorage.setItem(getSingleBotPasswordKey(forced), entered);
+ }
+ setSingleBotPasswordError('');
+ setSingleBotUnlocked(true);
+ setSingleBotPassword('');
+ } catch {
+ if (typeof window !== 'undefined') {
+ window.sessionStorage.removeItem(getSingleBotPasswordKey(forced));
+ }
+ setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重试。' : 'Invalid bot password. Please try again.');
+ } finally {
+ setSingleBotSubmitting(false);
+ }
};
return (
@@ -216,14 +262,14 @@ function AuthenticatedApp({
if (singleBotPasswordError) setSingleBotPasswordError('');
}}
onKeyDown={(event) => {
- if (event.key === 'Enter') unlockSingleBot();
+ if (event.key === 'Enter') void unlockSingleBot();
}}
placeholder={locale === 'zh' ? 'Bot 密码' : 'Bot password'}
autoFocus
/>
{singleBotPasswordError ?
{singleBotPasswordError}
: null}
-