327 lines
13 KiB
Python
327 lines
13 KiB
Python
"""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,
|
||
)
|