fix(web): tolerant polling, objectURL cleanup, throttled pointermove
- home/detail video pollers no longer clearInterval on a single transient error (give up only after 10 consecutive failures), matching the agent page - agent page creates preview objectURLs inside useEffect so each has a matching revoke under strict-mode double-invocation - login pointermove throttled via rAF and skipped on coarse pointers - source-analysis.html: changelog entry for this hardening pass Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1310,6 +1310,22 @@ ProductRefStateItem {
|
||||
<h2>变更记录</h2>
|
||||
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
||||
<div class="changelog">
|
||||
<article class="change">
|
||||
<header>
|
||||
<h3>2026-05-30 · 稳定性 / 安全加固(子进程超时、SSRF、并发锁、上传持久化、轮询容错)</h3>
|
||||
<span class="tag blue">API</span>
|
||||
<span class="tag violet">Canvas</span>
|
||||
<span class="tag orange">Bugfix</span>
|
||||
<span class="tag green">Security</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<p><strong>问题:</strong>只读审查发现一批可复现隐患:<code>run()</code> 子进程(yt-dlp/ffmpeg/ffprobe)无超时会让 job 永挂 <code>downloading</code> 并泄漏进程;<code>create_job</code> 的源链接未校验,可被 <code>file://</code>/内网地址做 SSRF 与本地文件读取;视频队列多线程并发改同一 job 状态无锁互相覆盖;画布上传视频用 <code>blob:</code> URL,重载后视频丢失;首页/详情页轮询一次网络抖动就永久停。</p>
|
||||
<p><strong>改动(后端 <code>api/main.py</code> / <code>api/db.py</code>):</strong><code>run()</code> 增加 timeout(下载 <code>DOWNLOAD_TIMEOUT_SECONDS</code> 默认 600s,其余 300s),超时 kill 并标 <code>failed</code>;新增 <code>validate_source_url()</code>——只允许 http(s)、拒绝私有/环回/链路本地 IP、域名走 <code>SOURCE_URL_ALLOWED_HOSTS</code> 白名单(默认主流短视频平台);新增 per-job <code>RLock</code>,<code>save_state</code>/<code>update</code>/<code>update_generated_video</code> 及 retry 的 check-and-set 全部在锁内;<code>db.py</code> 改用 <code>psycopg_pool</code> 连接池、写失败由 <code>logging.error</code> 暴露;只读 GET 媒体路由改用不创建目录的 <code>job_path()</code>;多处 <code>Image.open()</code> 改 <code>with</code> 防 fd 泄漏。</p>
|
||||
<p><strong>改动(前端画布 <code>web/canvas-app/src/</code>):</strong><code>VideoNode.vue</code> 上传改走后端 <code>/jobs/upload</code> 拿稳定 URL(新增 <code>uploadCanvasVideo</code>),<code>cleanNodeForStorage</code> 同时剥离 <code>blob:</code>;<code>useCachedMediaUrl.js</code> 用真实 <code>blob.size</code> 统计缓存(修复 chunked 视频 size=0 让 LRU 失效)、catch 路径补 token 竞态校验;<code>useApi.js</code> 读参考图补 <code>credentials</code>、移除与 Canvas 层重复的节点级视频轮询;<code>request.js</code> timeout 改 60s + <code>withCredentials</code>;删除 <code>api/video.js</code> 中忽略 taskId 的死代码。</p>
|
||||
<p><strong>改动(Next 首页 <code>web/app/</code>):</strong>首页/详情页视频轮询改为容错(连续失败 10 次才停);<code>agent</code> 页预览 ObjectURL 创建移入 <code>useEffect</code> 确保配对 revoke;登录页 <code>pointermove</code> 用 rAF 节流并跳过 coarse 指针。飞书自动跳转行为按确认保留不动。</p>
|
||||
<p><strong>影响 / 验证:</strong>新增后端依赖 <code>psycopg-pool</code>(已写入 <code>api/requirements.txt</code>,未装时自动回退按调用建连);新增可选 env:<code>DOWNLOAD_TIMEOUT_SECONDS</code>、<code>SOURCE_URL_ALLOWED_HOSTS</code>、<code>DB_POOL_MAX_SIZE</code>。本地 <code>py_compile</code> 与 <code>pnpm build</code>(canvas + next)通过。描述需求时:源链接受白名单约束(新平台需加 <code>SOURCE_URL_ALLOWED_HOSTS</code>);画布上传视频现在持久化为后端 <code>/api/jobs/...</code> 地址。</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="change">
|
||||
<header>
|
||||
<h3>2026-05-27 · 图片 API 改为运行时可配置并接入 Ark Seedream</h3>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
CheckCircle2,
|
||||
@@ -73,8 +73,14 @@ export default function AgentPage() {
|
||||
const [error, setError] = useState("")
|
||||
const terminalRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const previews = useMemo(() => files.map((file) => ({ file, url: URL.createObjectURL(file) })), [files])
|
||||
useEffect(() => () => previews.forEach((item) => URL.revokeObjectURL(item.url)), [previews])
|
||||
// create object URLs inside the effect (not during render) so every URL has a
|
||||
// matching revoke even under React strict-mode double-invocation
|
||||
const [previews, setPreviews] = useState<{ file: File; url: string }[]>([])
|
||||
useEffect(() => {
|
||||
const next = files.map((file) => ({ file, url: URL.createObjectURL(file) }))
|
||||
setPreviews(next)
|
||||
return () => next.forEach((item) => URL.revokeObjectURL(item.url))
|
||||
}, [files])
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/agent-runs?limit=8`, { cache: "no-store" })
|
||||
|
||||
@@ -116,11 +116,16 @@ export default function DetailPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!job || !runningVideo) return
|
||||
let failures = 0
|
||||
const timer = window.setInterval(async () => {
|
||||
try {
|
||||
setJob(await getJob(job.id))
|
||||
failures = 0
|
||||
} catch {
|
||||
window.clearInterval(timer)
|
||||
// one transient 5xx / network blip must not freeze progress forever;
|
||||
// only give up after sustained failures
|
||||
failures += 1
|
||||
if (failures >= 10) window.clearInterval(timer)
|
||||
}
|
||||
}, 2600)
|
||||
return () => window.clearInterval(timer)
|
||||
|
||||
@@ -61,15 +61,26 @@ export default function LoginPage() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// skip touch / coarse pointers — the eye-follow effect is pointless there and
|
||||
// would thrash state (and battery) on scroll-driven pointer events
|
||||
if (typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches) return
|
||||
let frame = 0
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
if (frame) return // coalesce to at most one state update per animation frame
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = 0
|
||||
const centerX = window.innerWidth / 2
|
||||
const centerY = window.innerHeight / 2
|
||||
const nextX = Math.max(-1, Math.min(1, (event.clientX - centerX) / centerX))
|
||||
const nextY = Math.max(-1, Math.min(1, (event.clientY - centerY) / centerY))
|
||||
setEyeOffset({ x: nextX * 8, y: nextY * 5.5 })
|
||||
})
|
||||
}
|
||||
window.addEventListener("pointermove", onPointerMove)
|
||||
return () => window.removeEventListener("pointermove", onPointerMove)
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onPointerMove)
|
||||
if (frame) window.cancelAnimationFrame(frame)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const disabled = status === "loading" || status === "success"
|
||||
|
||||
@@ -191,11 +191,16 @@ export default function Home() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!job || !runningVideo) return
|
||||
let failures = 0
|
||||
const timer = window.setInterval(async () => {
|
||||
try {
|
||||
setJob(await getJob(job.id))
|
||||
failures = 0
|
||||
} catch {
|
||||
window.clearInterval(timer)
|
||||
// one transient 5xx / network blip must not freeze progress forever;
|
||||
// only give up after sustained failures
|
||||
failures += 1
|
||||
if (failures >= 10) window.clearInterval(timer)
|
||||
}
|
||||
}, 2600)
|
||||
return () => window.clearInterval(timer)
|
||||
|
||||
Reference in New Issue
Block a user