"""Web App 站点产物 API:能力探测 / 站点列表 / 构建 / 预览签名 / dist 静态托管。 设计要点(降级优先): - 所有线程级接口先做 capability 闸门,不可用时返回结构化 ``{"available": false}`` (HTTP 200),绝不抛 5xx,绝不影响既有功能; - 静态托管是**唯一**允许 HTML inline 的出口:签名令牌 + dist/ 根限定 + CSP(按请求真实 origin 显式声明,兼容 iframe sandbox 不透明 origin)+ X-Content-Type-Options 三重边界;artifacts 接口既有的强制 attachment 不受影响。 """ from __future__ import annotations import asyncio import logging from fastapi import APIRouter, HTTPException, Request from fastapi.responses import FileResponse from app.gateway.authz import require_permission from app.gateway.path_utils import aresolve_thread_virtual_path from deerflow.config import get_app_config from deerflow.config.paths import get_paths from deerflow.runtime.thread_paths import aresolve_path_user_id from deerflow.webapp.builder import get_site_build_runner from deerflow.webapp.engine import ( SITE_NAME_RE_OK, capability_status, find_sites, guess_site_mime, ) from deerflow.webapp.security import mint_preview_token, verify_preview_token logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", tags=["web-app-sites"]) _VIRTUAL_OUTPUTS = "mnt/user-data/outputs" # CSP 用显式 origin 而非 'self':iframe sandbox(无 allow-same-origin)下文档 # origin 为 null,'self' 匹配不到任何资源;显式 URL 源按字面匹配不受影响。 # img/connect 放开 http(s) 供内网 GeoServer 瓦片 / PMTiles 之用;脚本严格同源。 _CSP_TEMPLATE = ( "default-src {origin} data: blob:; " "script-src {origin}; " "style-src {origin} 'unsafe-inline'; " "img-src {origin} data: blob: https: http:; " "font-src {origin} data:; " "connect-src {origin} data: blob: https: http:; " "worker-src {origin} blob:; " "object-src 'none'; " "base-uri {origin}" ) def _unavailable_payload() -> dict: cap = capability_status() reasons = [f"{name}: {item.get('detail', '')}" for name, item in cap.checks.items() if not item.get("ok")] return {"available": False, "enabled": cap.enabled, "reasons": reasons, "checks": cap.checks} def _validate_site_name(site_name: str) -> str: if not SITE_NAME_RE_OK.match(site_name): raise HTTPException(status_code=400, detail=f"非法站点目录名: {site_name!r}") return site_name @router.get( "/web-app/capability", summary="Web App Capability", description="探测站点产物模式是否可用(配置开关 / 模板 / 预置依赖 / node)。", ) async def web_app_capability() -> dict: cap = capability_status() return { "available": cap.available, "enabled": cap.enabled, "template_dir": cap.template_dir, "node_bin": cap.node_bin, "checks": cap.checks, } @router.get( "/threads/{thread_id}/sites", summary="List Web App Sites", description="列出该对话 outputs/ 下带 site.json 的站点及构建状态。", ) @require_permission("threads", "read", owner_check=True) async def list_sites(thread_id: str, request: Request) -> dict: if not capability_status().available: return _unavailable_payload() outputs_dir = await aresolve_thread_virtual_path(thread_id, _VIRTUAL_OUTPUTS) runner = get_site_build_runner() sites = find_sites(outputs_dir) for site in sites: job = runner.get_job(thread_id, site["name"]) if job is not None: site["build_job"] = { "job_id": job.job_id, "status": job.status, "started_at": job.started_at, "finished_at": job.finished_at, "error": job.error, } return {"available": True, "sites": sites} @router.post( "/threads/{thread_id}/sites/{site_name}/build", status_code=202, summary="Build Web App Site", description="触发站点 vite build(同一站点并发幂等:运行中则返回当前任务)。", ) @require_permission("threads", "write", owner_check=True) async def build_site(thread_id: str, site_name: str, request: Request) -> dict: if not capability_status().available: return _unavailable_payload() _validate_site_name(site_name) outputs_dir = await aresolve_thread_virtual_path(thread_id, _VIRTUAL_OUTPUTS) site_dir = outputs_dir / site_name if not (site_dir / "site.json").is_file(): raise HTTPException(status_code=404, detail=f"站点不存在: {site_name}") job = await get_site_build_runner().start_build(thread_id, site_name, site_dir) return {"available": True, "job_id": job.job_id, "status": job.status, "started_at": job.started_at} @router.get( "/threads/{thread_id}/sites/{site_name}/build-status", summary="Web App Site Build Status", description="查询站点最近一次构建任务状态(内存任务 + 落盘 .build-result.json)。", ) @require_permission("threads", "read", owner_check=True) async def site_build_status(thread_id: str, site_name: str, request: Request) -> dict: _validate_site_name(site_name) outputs_dir = await aresolve_thread_virtual_path(thread_id, _VIRTUAL_OUTPUTS) site_dir = outputs_dir / site_name job = get_site_build_runner().get_job(thread_id, site_name) if job is not None: return { "available": True, "job_id": job.job_id, "status": job.status, "started_at": job.started_at, "finished_at": job.finished_at, "error": job.error, "result": job.result, } last_build = None result_path = site_dir / ".build-result.json" if result_path.is_file(): import json try: last_build = json.loads(result_path.read_text(encoding="utf-8")) except (OSError, ValueError): last_build = None return {"available": True, "status": "idle", "last_build": last_build} @router.post( "/threads/{thread_id}/sites/{site_name}/preview-url", summary="Mint Web App Preview URL", description="签发站点 dist/ 静态托管的短时效签名 URL(iframe 可直接引用)。", ) @require_permission("threads", "read", owner_check=True) async def mint_site_preview_url(thread_id: str, site_name: str, request: Request) -> dict: if not capability_status().available: return _unavailable_payload() _validate_site_name(site_name) outputs_dir = await aresolve_thread_virtual_path(thread_id, _VIRTUAL_OUTPUTS) site_dir = outputs_dir / site_name if not (site_dir / "site.json").is_file(): raise HTTPException(status_code=404, detail=f"站点不存在: {site_name}") ttl = get_app_config().web_app.preview_token_ttl_seconds token, expires_at = mint_preview_token(thread_id, site_name, ttl) return { "available": True, "url_path": f"/api/public/site-preview/{token}", "token": token, "expires_at": expires_at, } # ───────────────────────────────────────────────────────────────────────────── # 跨对话站点 gallery(当前用户所有普通会话的 outputs 扫描;入口页数据源) # ───────────────────────────────────────────────────────────────────────────── _GALLERY_THREAD_BATCH = 100 _GALLERY_SCAN_CONCURRENCY = 8 async def _current_user_thread_rows(request: Request) -> list[dict]: """分页拉取当前用户的全部普通会话(对齐 artifact-library 的枚举语义)。""" from app.gateway.deps import get_thread_store store = get_thread_store(request) rows: list[dict] = [] seen: set[str] = set() offset = 0 while True: batch = await store.search(limit=_GALLERY_THREAD_BATCH, offset=offset, exclude_system=True) if not batch: break added = 0 for row in batch: thread_id = str(row.get("thread_id") or "") if not thread_id or thread_id in seen: continue seen.add(thread_id) rows.append(row) added += 1 if len(batch) < _GALLERY_THREAD_BATCH or added == 0: break offset += len(batch) return rows async def _scan_user_sites(request: Request) -> list[dict]: """扫描当前用户所有会话的 outputs/,返回带 thread 归属的站点列表。""" rows = await _current_user_thread_rows(request) paths = get_paths() semaphore = asyncio.Semaphore(_GALLERY_SCAN_CONCURRENCY) async def scan_thread(row: dict) -> list[dict]: thread_id = str(row.get("thread_id") or "") if not thread_id: return [] async with semaphore: try: path_user_id = await aresolve_path_user_id(thread_id) outputs_dir = paths.sandbox_outputs_dir(thread_id, user_id=path_user_id) sites = await asyncio.to_thread(find_sites, outputs_dir) except Exception: logger.warning("Failed to scan web-app sites for thread %s", thread_id, exc_info=True) return [] title = str(row.get("display_name") or "新对话") for site in sites: site["thread_id"] = thread_id site["thread_title"] = title return sites nested = await asyncio.gather(*(scan_thread(row) for row in rows)) items = [site for group in nested for site in group] items.sort(key=lambda s: (-(s.get("manifest_mtime") or 0), str(s.get("thread_id") or ""), str(s.get("name") or ""))) return items @router.get( "/web-app/sites", summary="List All Web App Sites", description="跨对话扫描当前用户所有会话生成的站点(capability 不可用时返回结构化不可用)。", ) @require_permission("threads", "read") async def list_all_sites(request: Request) -> dict: if not capability_status().available: return _unavailable_payload() sites = await _scan_user_sites(request) runner = get_site_build_runner() for site in sites: job = runner.get_job(str(site.get("thread_id") or ""), str(site.get("name") or "")) if job is not None: site["build_job"] = { "job_id": job.job_id, "status": job.status, "started_at": job.started_at, "finished_at": job.finished_at, "error": job.error, } return {"available": True, "sites": sites} # ───────────────────────────────────────────────────────────────────────────── # 公开静态托管(/api/public/ 前缀在 AuthMiddleware 白名单内;令牌即授权) # ───────────────────────────────────────────────────────────────────────────── @router.get( "/public/site-preview/{token}", summary="Serve Site Root", description="站点 dist/ 根:返回 index.html。", include_in_schema=False, ) async def serve_site_root(token: str, request: Request) -> FileResponse: return await _serve_site_file_async(request, token, "index.html") @router.get( "/public/site-preview/{token}/{file_path:path}", summary="Serve Site Asset", description="签名令牌授权的站点 dist/ 静态文件托管(HTML inline + CSP)。", ) async def serve_site_asset(token: str, file_path: str, request: Request) -> FileResponse: return await _serve_site_file_async(request, token, file_path) async def _serve_site_file_async(request: Request, token: str, file_path: str) -> FileResponse: try: thread_id, site_name = verify_preview_token(token) except ValueError as exc: raise HTTPException(status_code=403, detail=f"预览令牌无效: {exc}") from exc _validate_site_name(site_name) normalized = file_path.strip().replace("\\", "/") if normalized in ("", ".", "/"): normalized = "index.html" parts = [p for p in normalized.split("/") if p != ""] if any(p in ("..", ".") or p.startswith(".") for p in parts): raise HTTPException(status_code=400, detail="非法文件路径") virtual_path = f"{_VIRTUAL_OUTPUTS}/{site_name}/dist/{'/'.join(parts)}" actual_path = await aresolve_thread_virtual_path(thread_id, virtual_path) if not actual_path.is_file(): raise HTTPException(status_code=404, detail="站点文件不存在") origin = str(request.base_url).rstrip("/") headers = { "Cache-Control": "no-cache", "X-Content-Type-Options": "nosniff", } if actual_path.suffix.lower() in (".html", ".htm", ".svg"): headers["Content-Security-Policy"] = _CSP_TEMPLATE.format(origin=origin) return FileResponse( path=actual_path, media_type=guess_site_mime(actual_path), headers=headers, )