diff --git a/docs/source-analysis.html b/docs/source-analysis.html index b70cd8d..3946871 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -1310,6 +1310,22 @@ ProductRefStateItem {

变更记录

这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。

+
+
+

2026-05-30 · 稳定性 / 安全加固(子进程超时、SSRF、并发锁、上传持久化、轮询容错)

+ API + Canvas + Bugfix + Security +
+
+

问题:只读审查发现一批可复现隐患:run() 子进程(yt-dlp/ffmpeg/ffprobe)无超时会让 job 永挂 downloading 并泄漏进程;create_job 的源链接未校验,可被 file:///内网地址做 SSRF 与本地文件读取;视频队列多线程并发改同一 job 状态无锁互相覆盖;画布上传视频用 blob: URL,重载后视频丢失;首页/详情页轮询一次网络抖动就永久停。

+

改动(后端 api/main.py / api/db.py):run() 增加 timeout(下载 DOWNLOAD_TIMEOUT_SECONDS 默认 600s,其余 300s),超时 kill 并标 failed;新增 validate_source_url()——只允许 http(s)、拒绝私有/环回/链路本地 IP、域名走 SOURCE_URL_ALLOWED_HOSTS 白名单(默认主流短视频平台);新增 per-job RLocksave_state/update/update_generated_video 及 retry 的 check-and-set 全部在锁内;db.py 改用 psycopg_pool 连接池、写失败由 logging.error 暴露;只读 GET 媒体路由改用不创建目录的 job_path();多处 Image.open()with 防 fd 泄漏。

+

改动(前端画布 web/canvas-app/src/):VideoNode.vue 上传改走后端 /jobs/upload 拿稳定 URL(新增 uploadCanvasVideo),cleanNodeForStorage 同时剥离 blob:useCachedMediaUrl.js 用真实 blob.size 统计缓存(修复 chunked 视频 size=0 让 LRU 失效)、catch 路径补 token 竞态校验;useApi.js 读参考图补 credentials、移除与 Canvas 层重复的节点级视频轮询;request.js timeout 改 60s + withCredentials;删除 api/video.js 中忽略 taskId 的死代码。

+

改动(Next 首页 web/app/):首页/详情页视频轮询改为容错(连续失败 10 次才停);agent 页预览 ObjectURL 创建移入 useEffect 确保配对 revoke;登录页 pointermove 用 rAF 节流并跳过 coarse 指针。飞书自动跳转行为按确认保留不动。

+

影响 / 验证:新增后端依赖 psycopg-pool(已写入 api/requirements.txt,未装时自动回退按调用建连);新增可选 env:DOWNLOAD_TIMEOUT_SECONDSSOURCE_URL_ALLOWED_HOSTSDB_POOL_MAX_SIZE。本地 py_compilepnpm build(canvas + next)通过。描述需求时:源链接受白名单约束(新平台需加 SOURCE_URL_ALLOWED_HOSTS);画布上传视频现在持久化为后端 /api/jobs/... 地址。

+
+

2026-05-27 · 图片 API 改为运行时可配置并接入 Ark Seedream

diff --git a/web/app/agent/page.tsx b/web/app/agent/page.tsx index a4cccb6..2ccf435 100644 --- a/web/app/agent/page.tsx +++ b/web/app/agent/page.tsx @@ -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(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" }) diff --git a/web/app/detail/page.tsx b/web/app/detail/page.tsx index 0540569..f57ebdb 100644 --- a/web/app/detail/page.tsx +++ b/web/app/detail/page.tsx @@ -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) diff --git a/web/app/login/page.tsx b/web/app/login/page.tsx index f310d25..7ce5151 100644 --- a/web/app/login/page.tsx +++ b/web/app/login/page.tsx @@ -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) => { - 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 }) + 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" diff --git a/web/app/page.tsx b/web/app/page.tsx index f21f330..4100ea4 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -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)