Files

327 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
)