diff --git a/.project-docs/30-worklog/current-state.md b/.project-docs/30-worklog/current-state.md index 45361e7a..3f44495a 100644 --- a/.project-docs/30-worklog/current-state.md +++ b/.project-docs/30-worklog/current-state.md @@ -11,6 +11,7 @@ This file is the integrated default-branch snapshot. Feature tasks record progre - `4013edc` / `3b799af`: integrated per-user Code/Canvas/Learning/Robot entry policy from Works Square, projected by Electron Main as four booleans and enforced before disabled module routes initialize. - `01bee31`: enabled AI Learning course catalog/generation/download/playback, Main-owned cloud/runtime bridges, verified external OpenMAIC player-artifact packaging, account profile reuse, removal of the transient `game-engine` Skill, and project-root `planning-with-files` output from the authoritative remote main. The merge hardens this with strict DTO/error projection, account-isolated local state, bounded same-origin downloads/packages, a nonce-protected account-bound player HTTP session, and an exact-source/origin single-document iframe bridge. - `26b52d7`: Canvas Prompt Museum, editable server-priced generation Quotes, project deletion/task-detail workflow, cloud-default Canvas development entry, and Chinese-only UI consolidation from the authoritative remote main. Its transient bundled `game-engine` Skill is superseded by `01bee31`. +- `f8d82e6`: Prompt Museum media rendering now accepts only the server-controlled relative media route, fetches it through a Main-owned bounded Works-authenticated proxy with one refresh retry, and keeps credential-free HTTPS CDN media direct. Renderer-side validation and card-local placeholders cover invalid or failed media; attribution URLs remain optional. - `c1326a2`: Guided Hotspot Binding now scans bounded open `Xiaozhi-*` candidates and connects the user-selected hotspot inside the page through Main-owned Windows WLAN and macOS CoreWLAN/CoreLocation adapters; system Wi-Fi remains fallback, exact `=0` rollback and firmware/cloud contracts are unchanged. - `b78fc07`: Guided Hotspot Binding is enabled by default in Electron Main, with exact environment value `0` as rollback and direct six-digit fallback on capability-read failure; firmware and Host/cloud contracts are unchanged. - `b7a1590` / `14afe4a`: initial firmware-zero-change Guided Hotspot Binding V1 implementation and decision used a Main-owned default-off capability, fixed portal action, in-memory Renderer journey, and existing six-digit Binding contract; `b78fc07` above supersedes only that default. @@ -37,7 +38,7 @@ This file is the integrated default-branch snapshot. Feature tasks record progre AI 绘画的一个 Workspace 可包含多条 Conversation。消息、Brief、Quote 和 `turnRevision` 随 Conversation 隔离;生成任务和资产保持 Workspace 级共享。图片 Brief 支持文生图,以及从当前项目已完成作品或本地上传中选择一张参考图继续生成;视频复用同一选择器绑定首帧。两条路径都通过现有 Workspace Asset 契约提交一个真实 Asset ID。每条 Conversation 使用服务端持久 Agent Gateway Session;连接正常时命令、Run 与设计事件共用双向 WebSocket,只有发送、断连或 ACK 超时等传输故障才以同一 `client_command_id` 回退 REST,结构化业务错误不重复提交且未知上游文本由 Main 脱敏。确认栏允许编辑服务端最终 Prompt 与 generation options,每次修改都由服务端 Quote 重算设计点,确认时提交最新原值;客户端不推算供应商或积分价格。任务详情可预览/下载结果。侧栏删除项目要求完整输入项目名,删除当前项目后切换到最近更新的剩余项目;服务端删除/结算语义仍由 Works Square 契约负责。确认生成会按 Quote 对账 Workspace 任务;任务已经落库但 Run 随后失败时仍恢复任务列表,内部对账失败不覆盖当前 UI 错误,同时 Conversation 写入继续受 Workspace-load 与 Conversation-selection generation 保护。 -Canvas 侧栏提供“获取灵感”进入 Prompt Museum。列表、筛选、分页、详情、作者/来源/许可证和图片地址全部由服务端经 Main-owned Host API 提供,客户端不打包静态数据集;“使用此 Prompt”只把原文带回当前 Canvas 输入框,不自动发送。该模块不是投稿、点赞、评论或排行榜社区。客户端契约已就绪,但不据此宣称 Works Square 内容后台和生产数据已经部署。`pnpm run dev` 现在默认使用云端 Canvas 适配器,本地适配器只能通过显式开发命令启用;产品 UI 只保留中文。 +Canvas 侧栏提供“获取灵感”进入 Prompt Museum。列表、筛选、分页、详情、作者/来源/许可证和图片地址全部由服务端经 Main-owned Host API 提供,客户端不打包静态数据集;服务端相对媒体只允许固定 `/api/image-prompt-museum/{entry}/media/{thumbnail|number}` 形状,并由 Main 注入 Works Bearer、执行一次 401 刷新、可信 raster MIME 与 10 MiB 上限后转为 Renderer data URL;credential-free HTTPS CDN 图片保持直连。图片失败只显示卡片内占位,不阻断卡片或详情;缺少来源 URL 时显示纯文本。“使用此 Prompt”只把原文带回当前 Canvas 输入框,不自动发送。该模块不是投稿、点赞、评论或排行榜社区。客户端契约已就绪,但不据此宣称 Works Square 内容后台和生产数据已经部署。`pnpm run dev` 现在默认使用云端 Canvas 适配器,本地适配器只能通过显式开发命令启用;产品 UI 只保留中文。 Makelore 在会话恢复、登录和刷新后由 Electron Main 请求 Works `/api/auth/me`,Renderer 只获得 Code、Canvas、Learning、Robot 四个布尔权限。缺失 `module_access` 或任一字段时默认开启;服务端 `design` 显式映射客户端 `painting`。被关闭的模块卡片置灰且不可点击,根路由、深层路由和别名路由均在 `MainLayout` 或模块初始化前阻断。Code provider 等待认证权限加载完成;权限查询返回终止性 `401` 时同时清理 Main 和 Renderer 会话。`/settings` 是全局设置,不受 Code 入口策略阻断。该机制只是客户端入口策略,不代替服务端 API 授权。 @@ -57,6 +58,7 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选 - 2026-08-17: Created merge commit `4013edc` for the reviewed per-user module-entry policy source tip `3b799af`. Main exposes only four booleans from `/api/auth/me`; missing fields remain enabled, `design` maps to `painting`, disabled root/deep/alias routes stop before module initialization, Code provider startup waits for policy hydration, terminal `401` clears both session layers, and global settings remains reachable. - 2026-08-17: Integrated remote `01bee31`: Learning is enabled with course browsing, strict bounded generation materials, verified atomic course installation, multi-module playback, Main-owned Agent/ASR/runtime bridges, and a manifest-verified external OpenMAIC player artifact. Merge review added account-isolated generation/library/player state, fixed-binding token/fetch/401 guards, passive-only course media with hardened responses, pre-existing active-registration checks before side-effect-free identity resolution, nonce-protected single-document player sessions, and a recoverable deep-link profile error gate. At that integration checkpoint, publishing used a coverless first create, existing draft/published were version-only, and races failed closed without cover/PATCH side effects; project-cover source `145a6ce` and matching server merge `0cedfc4` above supersede only the coverless-first-create limitation. The transient `game-engine` Skill was removed and `planning-with-files` writes its files to the project root. Production Works/player-artifact/signed-package acceptance remains pending. - 2026-08-16: Integrated remote `26b52d7`: Canvas now has server-backed Prompt Museum navigation, editable server-repriced generation Quotes, task result details/downloads, guarded project deletion, cloud-default development, and Chinese-only UI. That tip briefly bundled `game-engine`; authoritative successor `01bee31` removed it. Client integration is verified separately from production Prompt Museum data/backend deployment. +- 2026-08-18: Integrated Prompt Museum media rendering from `f8d82e6`: relative protected media is fetched through Main with bounded trusted-raster validation and one 401 refresh, HTTPS media remains direct, invalid/failed images are card-local placeholders, and missing attribution URLs render without broken links. Focused unit/Electron E2E, typecheck, scoped lint, and Vite build passed; real Works/CDN production smoke remains pending. - 2026-08-16: Integrated Windows/macOS in-page Robot hotspot discovery, explicit selection, connection, and exact-current-SSID verification behind the existing default-on guided capability. Candidate IDs are bounded and short-lived, native diagnostics stay in Main, system settings remain fallback, and firmware/Portal/Binding contracts are unchanged. - 2026-08-16: Enabled the existing Guided Hotspot Binding journey by default after explicit product confirmation. Exact `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` remains the operational rollback; fixed portal ownership, direct-code fallback, security warnings, firmware-zero-change, and Binding-without-online semantics are preserved. - 2026-08-16: Initially implemented ADR-002's Robot onboarding V1 without changing firmware, behind a default-off Main capability and fixed portal opener. The later `b78fc07` decision above changes only the default; the same firmware/issuer/native-opener/physical evidence remains outstanding. @@ -94,6 +96,7 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选 ## Risky Areas - 四模块权限只控制 Makelore 客户端入口和初始化,不是 API 授权边界。不得因卡片置灰或路由阻断而放宽 Works/模块服务端的身份与权限校验;旧服务端缺少对象/字段时默认开启是显式兼容策略。 +- Prompt Museum 相对媒体必须保持固定的服务端路径并由 Main 处理;如果服务端增加媒体变体,需同步维护 entry/path 语法、Works Bearer 所有权、单次刷新、10 MiB 限制、可信 raster MIME 白名单与 Renderer data URL 校验。HTTPS 直连媒体必须继续无凭据,图片失败必须局限在卡片/详情视图。 - Learning 的课程目录、生成、Agent、ASR 与 runtime 都依赖真实 Works 权益和服务端契约;本地课程归档与播放器 artifact 必须在信任前完成边界、大小与摘要校验。账号分区/epoch、fixed-binding token+fetch guards、同源重定向、512 MiB 上限、player nonce、exact source/origin 与单文档 bridge 边界不可放宽;不得把模块/场景自报身份当成 aggregate 课程权益,也不得把上游错误、Token、内部 URL 或本地归档路径投影到 Renderer。 - Works Project 首次封面已由服务端源 `407c883`(本地 merge `0cedfc4`)提供单请求原子绑定与失败补偿,客户端源 `145a6ce` 因此要求首次发布上传 PNG/JPEG/WebP 封面;部署、安装包和真实账号/对象存储 smoke 仍未完成。服务端仍没有已有 metadata 的 revision/ETag 与 draft-only 条件写,因此已有 draft/published 继续只允许 version-only,客户端不得以无条件 PATCH 替代。 - Guided Hotspot Binding 默认开启并提供未经认证的热点扫描/显式连接,但当前 Hotspot/portal 仍是开放 SoftAP + 明文 HTTP,且精确出货镜像、激活码发行契约、签名 macOS、Windows 真机与完整整链尚未验证。界面必须保留环境警告,异常发布可用精确环境值 `0` 回滚;不得把 SSID 前缀宣称为可信设备发现、自动认领或在线证明。 @@ -118,4 +121,4 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选 ## Last Updated -2026-08-17 +2026-08-18 diff --git a/.project-docs/30-worklog/tasks/20260818-museum-media-main-8c2d.md b/.project-docs/30-worklog/tasks/20260818-museum-media-main-8c2d.md new file mode 100644 index 00000000..2d1bdb19 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260818-museum-media-main-8c2d.md @@ -0,0 +1,40 @@ +# Task: Publish Prompt Museum media fix on main + +## Identity + +- Task ID: 20260818-museum-media-main-8c2d +- Mode: Integration +- Branch: main +- Worktree: D:\Datas\OthersProjects\makelore +- Base commit: 11b19832a35477d2136c6ea953dd9c408fd84816 +- Owner: developer +- Status: Completed + +## Scope + +- Publish the completed Prompt Museum media-rendering change from integration commit `22bee1d` onto the occupied `main` worktree, preserving the source code/tests and accepted canonical documentation updates. +- Keep the source feature task record out of the canonical tree; integration records remain task-scoped and the source worktree remains read-only. + +## Intent And Constraints + +- Preserve the Main-owned fixed relative media route, Works Bearer refresh behavior, 10 MiB/trusted-raster limits, Renderer data-URL validation, direct HTTPS images, and local failure placeholders. +- The merge must not claim production Works/CDN, real-account, signed-package, or deployment acceptance; it must leave `main` clean and the integration lock releasable. + +## Outcome + +- Merged integration commit `22bee1d` into `main` with an explicit non-fast-forward merge; the final tree contains the Prompt Museum Main proxy, shared DTO, Renderer media conversion/page behavior, focused tests, and canonical state/evidence/commitment updates. +- Removed only the intermediate integration task record from the merge result; this final task record is the canonical integration note. + +## Verification + +- `git merge --no-ff --no-commit 22bee1d`: PASS; automatic merge had no conflicts. +- Focused Prompt Museum verification inherited from the independently reviewed source: 3 files / 27 unit tests, typecheck, scoped ESLint, Vite build, targeted Electron E2E 1/1, `git diff --check`, and final Sol review PASS. +- Main task-aware drift check and final clean-worktree check will run before releasing ownership. + +## Follow-ups + +- Real Works account, deployed media endpoint/CDN, and signed-package smoke remain release follow-ups. + +## Promotion Candidates + +- `current-state.md`, `50-evidence/evidence-index.md`, and `80-commitments/commitments.md` now record the bounded Main proxy and the remaining production acceptance obligation. diff --git a/.project-docs/50-evidence/evidence-index.md b/.project-docs/50-evidence/evidence-index.md index 5a2694f4..d123523f 100644 --- a/.project-docs/50-evidence/evidence-index.md +++ b/.project-docs/50-evidence/evidence-index.md @@ -9,6 +9,7 @@ Use this index for searchable, traceable evidence records. | 2026-08-17 | Makelore 用户级四模块入口策略 | 客户端实现与独立复审通过;服务端部署、新包与真实账号 smoke 待验收 | 源任务 `20260817-makelore-module-access-6f2a91c4`、源 tip `3b799af` | 4 files / 69 focused tests、175 files / 2047 full tests、TypeScript、scoped ESLint、Renderer/Main/Preload production build 与最终独立 Sol review PASS。覆盖 Main 四布尔安全投影、缺失默认开启、`design` → `painting`、卡片置灰不可点、根/深层/别名路由初始化前阻断、Code policy hydration、terminal `401` 双层清会话和全局 `/settings`;不据此宣称 API 授权或生产发布已验收 | | 2026-08-17 | 远程 `01bee31` Learning 主线集成与安全收口 | 合并树自动化与独立双轴复审通过;真实 Works、固定生产播放器 artifact 与签名安装包待验收 | 远程提交 `01bee31`、集成任务 `20260813-sync-push-main-9c2f71` | pnpm 10.33.4 frozen install、26 files / 418 pre-review focused、175 files / 1944 pre-review full;四轮审查修复后统一 9 files / 161、最终 175 files / 2028 full、typecheck、lint(0 errors / 7 existing warnings)、Renderer/Main/Preload build 与最终 Electron E2E 4/4 通过(更早跨模块选择 9/9 亦通过);第五轮 Standards/Spec 最终复审 PASS、无 P0-P3。最终树严格投影 generation IPC;分离显式 player registration 与无副作用 identity resolve;manifest 只接受相对 `audio|media|fonts`,权威 root 注入单一 module 前缀,consumer 生成 URL 已穿过 registered ZIP 的真实 HTTP/Woff2/MIME/security-header 测试;保持 fixed-binding 账号/Token/fetch/401 guard、512 MiB/ZIP/同源 5 跳下载、nonce player、exact-source/origin bridge 与可恢复 profile gate。该 checkpoint 因当时缺少 revision/cover cleanup 合同采用 coverless first-create、existing version-only、竞态 fail-closed;当前客户端 `145a6ce` 与服务端 `407c883` / `0cedfc4` 已仅替换首次 coverless 限制,不据此宣称生产服务或签名包已验收 | | 2026-08-16 | 远程 `26b52d7` Canvas/Prompt Museum 主线集成 | 合并树自动化验证通过;真实服务端内容、计费与删除结算待验收 | 远程提交 `26b52d7`、集成任务 `20260813-sync-push-main-9c2f71` | 主工作区及独立临时目录 clean frozen install(955 packages)、16 files / 284 focused、Prompt Museum 3 files / 13、161 files / 1850 full、typecheck、lint、Renderer/Main/Preload build、Electron E2E 6/6 与文档门禁通过。合并额外修复 lock override 实际签名依赖图、Prompt Museum 未知错误脱敏/严格 DTO+HTTPS 投影/401 refresh,以及过期 E2E 断言;不据此宣称 Museum 后台审核数据、Quote 真实计费或 Workspace 删除结算已部署 | +| 2026-08-18 | Prompt Museum 相对媒体渲染修复 | 客户端实现与独立复审通过;真实 Works/CDN 媒体端点待验收 | 源任务 `20260818-prompt-museum-client-4f7a`、源提交 `f8d82e6`、集成任务 `20260818-museum-media-integration-6b7e` | Main 仅代理固定相对媒体路径,注入 Works Bearer 并最多刷新一次,限制 10 MiB 与可信 raster MIME;Renderer 验证 JSON/base64 并生成 data URL,HTTPS 图片保持直连,失败显示卡片内占位,缺少 attribution URL 不生成坏链接。3 files / 27 focused tests、typecheck、scoped ESLint、Vite build 与定向 Electron E2E 1/1 通过;未执行真实账号、生产媒体/CDN 或安装包 smoke | | 2026-08-16 | Robot Windows/macOS 配网页内热点连接 | 实现与本地自动化验证通过;双平台实机发布证据待完成 | 源提交 `c1326a2`、ADR-003、集成任务 `20260813-sync-push-main-9c2f71` | 4 files / 132 focused tests、157 files / 1796 full tests、typecheck、lint、Renderer/Main/Preload build 与独立 Standards/Spec review 通过;Electron 40.10.6 加载 Koffi/wlanapi 成功,Windows 权限拒绝安全投影通过。签名 macOS x64/arm64、Windows Robot 真机和真实 Host/native Electron E2E 未完成,不得据此宣称双平台硬件验收 | | 2026-08-11 | AI 设计多会话客户端集成 | 本地功能验证通过;仓库基线仍有既有失败 | `30-worklog/tasks/20260811-merge-all-code-a7c91e.md`、`4980894`、`03dae62` | 8 files / 116 focused tests、typecheck、changed-file ESLint、production build 与新增 Electron E2E 通过;生产 migration 0033/API 尚待验收,全量 lint/unit/E2E 的既有失败已单独记录 | | 2026-08-10 | 客户端静态发布唯一链路 | 本地验证通过 | `30-worklog/tasks/20260810-static-release-only-a91c.md`、`4df0477`、`8dd99c1` | 227 项聚焦回归、typecheck、Vite/Electron build、实际 Electron E2E 与 13 files / 21 tests 收集通过;不等同于真实生产部署验收 | diff --git a/.project-docs/80-commitments/commitments.md b/.project-docs/80-commitments/commitments.md index 89404e9e..1e8faa03 100644 --- a/.project-docs/80-commitments/commitments.md +++ b/.project-docs/80-commitments/commitments.md @@ -8,7 +8,7 @@ Track future-facing memory: promised follow-ups, unfinished loops, timed checks, | 2026-08-17 | 验收用户级四模块入口策略的真实发布链 | 发布包含源 tip `3b799af` 的 Makelore 安装包前 | Works 服务端/客户端/发布集成 | Pending | 部署 `module_access` migration 和 `/api/auth/me` API,重新打包并安装 Makelore;使用真实账号依次关闭 Code、Canvas、Learning、Robot,重启后验证卡片置灰/不可点、根/深层/别名路由阻断、`/settings` 仍可访问和 terminal `401` 退出;另行以直接 API 请求确认服务端授权,不以 UI 置灰代替 | | 2026-08-16 | 验收 AI Learning 的真实 Works、课程包与 production Stage 发布链 | 发布包含 `01bee31` Learning 行为的安装包前 | 客户端/服务端/发布集成 | Pending | 使用真实账号核对课程广场、单课生成(无材料/5 文件边界)、取消/恢复/finalize、同源最多 5 跳下载、512 MiB archive 上限、大小/SHA-256/原子安装、账号切换隔离、离线多模块播放、进度、Agent、ASR、PBL/评分权益;以固定 URL/SHA-256 的 player artifact 构建并完成 Windows 与签名 macOS 安装包 smoke,验证 packaged Chromium 的 loopback cookie/nonce,不以客户端单测替代生产验收 | | 2026-08-17 | 验收首次项目封面并补齐已有资料条件写 | 发布包含源 `145a6ce` 的 Makelore 前,或启用 draft/published metadata 编辑前 | Works 服务端/客户端发布集成 | Partial / Pending | 首次封面已由服务端源 `407c883` 与客户端源 `145a6ce` 通过单请求绑定和失败补偿落地;仍需先部署服务端、重新打包客户端,并以真实对象存储/账号验证 404→create 409、cover 失败和版本阻断。已有资料编辑继续等待 metadata revision/ETag 与 draft-only 条件写;在此之前保持 existing version-only。 | -| 2026-08-16 | 验收 Prompt Museum 与 Canvas 删除/重报价的真实服务端链路 | 发布包含 `26b52d7` Canvas 行为的安装包前 | 客户端/服务端集成 | Pending | 使用真实 Works 账号核对 Museum 列表/详情/分页/筛选/署名/CDN/Prompt 回填;核对最终 Prompt/options 重报价和确认设计点;删除 Workspace 后确认软删除可见性、未提交任务取消/预留积分释放、已运行任务结算。保留 Main 错误脱敏和严格 DTO/HTTPS 投影,不以客户端回归替代服务端验收 | +| 2026-08-16 | 验收 Prompt Museum 与 Canvas 删除/重报价的真实服务端链路 | 发布包含 `26b52d7` Canvas 行为的安装包前 | 客户端/服务端集成 | Pending | 使用真实 Works 账号核对 Museum 列表/详情/分页/筛选/署名/CDN/Prompt 回填,并确认固定相对媒体路径可经 Main Bearer 代理、401 刷新、10 MiB/可信 raster MIME 边界后在 Renderer 展示;核对最终 Prompt/options 重报价和确认设计点;删除 Workspace 后确认软删除可见性、未提交任务取消/预留积分释放、已运行任务结算。保留 Main 错误脱敏和严格 DTO/HTTPS 投影,不以客户端回归替代服务端验收 | | 2026-08-16 | 验收 default-on Robot Guided Hotspot Binding 的 Windows/macOS 真实设备链路 | 下一份包含页面内热点连接行为的安装包发布前 | 客户端/硬件/服务端集成 | Pending | 核对精确出货固件与固定 Portal、六位码发行/消费语义;执行 Windows Robot 真机扫描/连接、签名 macOS x64/arm64 CoreLocation/CoreWLAN/worker/ASAR/Koffi smoke,以及真实 Host/native Electron 端到端配网+Binding;保留 `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` 回滚并记录支持矩阵 | | 2026-08-10 | 完成客户端提交到 App `play_url` 播放的真实生产整链验收 | source+built+contract 服务端协议、OSS immutable Release、CDN/Edge 与 App 消费链成组集成后 | 客户端/服务端集成 | Pending | 使用真实账号执行小游戏和小程序创建、客户端本地构建与同字节预检、双归档提交、服务端逐字节校验/不可变 Release 固化、运营批准、CDN 发布、App 播放与监控核对;如需不可绕过 runtime gate,另行设计可信 verifier | | 2026-08-12 | Windows 发布流水线保留固定 npm 运行时产物门禁 | 每次生成正式 Windows 安装器时 | 客户端发布 | Pending | 运行 `pnpm verify:publish-runtime` 和 `pnpm verify:artifact:win`;当前 223,547,912-byte / SHA-256 `08A0BB7BC66EE4AD8B120E8955B149951CD86AF53CC966C7973AD4D77A5815C1` 安装器仅为本地证据,尚未发布 | diff --git a/electron/api/routes/image-prompt-museum.ts b/electron/api/routes/image-prompt-museum.ts index 14ebdc74..6408a5da 100644 --- a/electron/api/routes/image-prompt-museum.ts +++ b/electron/api/routes/image-prompt-museum.ts @@ -14,6 +14,16 @@ const MAX_FACET_ITEMS = 256; const MAX_CATEGORIES = 32; const MAX_IMAGES = 16; const MAX_VARIABLES = 64; +const MAX_MEDIA_BYTES = 10 * 1024 * 1024; +const MEDIA_URL_PATTERN = /^\/api\/image-prompt-museum\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/media\/(?:thumbnail|[0-9]+)$/; +const LOCAL_MEDIA_PATH_PATTERN = /^\/api\/works\/image-prompt-museum\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/media\/(thumbnail|[0-9]+)$/; +const TRUSTED_MEDIA_MIME_TYPES = new Set([ + 'image/avif', + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/webp', +]); type PromptMuseumRouteDependencies = { fetchImpl?: typeof fetch; @@ -22,11 +32,17 @@ type PromptMuseumRouteDependencies = { }; function isMuseumPath(pathname: string): boolean { - return pathname === LOCAL_ROOT || /^\/api\/works\/image-prompt-museum\/[^/]+$/.test(pathname); + return pathname === LOCAL_ROOT + || /^\/api\/works\/image-prompt-museum\/[^/]+$/.test(pathname) + || LOCAL_MEDIA_PATH_PATTERN.test(pathname); } function upstreamPath(pathname: string): string | null { if (pathname === LOCAL_ROOT) return UPSTREAM_ROOT; + const mediaMatch = LOCAL_MEDIA_PATH_PATTERN.exec(pathname); + if (mediaMatch) { + return `${UPSTREAM_ROOT}/${mediaMatch[1]}/media/${mediaMatch[2]}`; + } const entryId = pathname.slice(`${LOCAL_ROOT}/`.length); if (!entryId) return null; return `${UPSTREAM_ROOT}/${encodeURIComponent(decodeURIComponent(entryId))}`; @@ -80,6 +96,12 @@ function httpsUrl(value: unknown): string { return parsed.toString(); } +function imageUrl(value: unknown): string { + const raw = boundedString(value, 2048); + if (MEDIA_URL_PATTERN.test(raw)) return raw; + return httpsUrl(raw); +} + function nullableHttpsUrl(value: unknown): string | null | undefined { if (value === undefined) return undefined; if (value === null) return null; @@ -116,13 +138,43 @@ function boundedArray(value: unknown, maximum: number): unknown[] { function projectImage(value: unknown): Record { const image = asRecord(value); return { - url: httpsUrl(image.url), + url: imageUrl(image.url), width: positiveInteger(image.width, 32_768), height: positiveInteger(image.height, 32_768), alt: boundedString(image.alt, 500), }; } +function mediaMimeType(response: Response): string | null { + const mimeType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase(); + return mimeType && TRUSTED_MEDIA_MIME_TYPES.has(mimeType) ? mimeType : null; +} + +async function readBoundedMedia(response: Response): Promise { + const declaredLength = Number(response.headers.get('content-length')); + if (Number.isFinite(declaredLength) && declaredLength > MAX_MEDIA_BYTES) { + await response.body?.cancel().catch(() => undefined); + return null; + } + if (!response.body) return null; + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_MEDIA_BYTES) { + await reader.cancel().catch(() => undefined); + return null; + } + chunks.push(value); + } + if (size === 0) return null; + return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))); +} + function projectCategory(value: unknown): Record { const category = asRecord(value); const group = boundedString(category.group, 32); @@ -142,6 +194,7 @@ function projectAttribution(value: unknown): Record { const source = asRecord(attribution.source); const license = asRecord(attribution.license); const authorUrl = nullableHttpsUrl(author.url); + const sourceUrl = nullableHttpsUrl(source.url); const licenseUrl = nullableHttpsUrl(license.url); return { author: { @@ -150,7 +203,7 @@ function projectAttribution(value: unknown): Record { }, source: { name: boundedString(source.name, 300), - url: httpsUrl(source.url), + ...(sourceUrl === undefined ? {} : { url: sourceUrl }), }, license: { name: boundedString(license.name, 300), @@ -310,6 +363,7 @@ export function createImagePromptMuseumRouteHandler( } try { + const isMediaRequest = LOCAL_MEDIA_PATH_PATTERN.test(url.pathname); const token = await getAccessToken({ fetchImpl }); if (!token) { sendJson(res, 401, { @@ -326,7 +380,7 @@ export function createImagePromptMuseumRouteHandler( { method: 'GET', headers: { - Accept: 'application/json', + Accept: isMediaRequest ? 'image/avif,image/webp,image/png,image/jpeg,image/gif' : 'application/json', Authorization: `Bearer ${accessToken}`, }, redirect: 'manual', @@ -344,12 +398,30 @@ export function createImagePromptMuseumRouteHandler( response = await request(refreshed); } - const payload = await readPayload(response); if (!response.ok) { + await response.body?.cancel().catch(() => undefined); sendSafeError(res, response.status); return true; } + if (isMediaRequest) { + const mimeType = mediaMimeType(response); + if (!mimeType) { + await response.body?.cancel().catch(() => undefined); + sendInvalidResponse(res); + return true; + } + const bytes = await readBoundedMedia(response); + if (!bytes) { + sendInvalidResponse(res); + return true; + } + sendJson(res, 200, { dataBase64: bytes.toString('base64'), mimeType }); + return true; + } + + const payload = await readPayload(response); + if (payload === null) { sendInvalidResponse(res); return true; diff --git a/shared/image-prompt-museum.ts b/shared/image-prompt-museum.ts index 61255c43..f57adaed 100644 --- a/shared/image-prompt-museum.ts +++ b/shared/image-prompt-museum.ts @@ -40,7 +40,7 @@ export type PromptMuseumAuthor = { export type PromptMuseumSource = { name: string; - url: string; + url?: string | null; }; export type PromptMuseumLicense = { diff --git a/src/lib/image-prompt-museum.ts b/src/lib/image-prompt-museum.ts index af0e6ddf..e7721702 100644 --- a/src/lib/image-prompt-museum.ts +++ b/src/lib/image-prompt-museum.ts @@ -15,6 +15,21 @@ type PromptMuseumEnvelope = { data?: T; }; +type PromptMuseumMedia = { + dataBase64: string; + mimeType: string; +}; + +const PROMPT_MUSEUM_MEDIA_URL_PATTERN = /^\/api\/image-prompt-museum\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/media\/(?:thumbnail|[0-9]+)$/; +const PROMPT_MUSEUM_MEDIA_MIME_TYPES = new Set([ + 'image/avif', + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/webp', +]); +const MAX_MEDIA_BASE64_LENGTH = Math.ceil((10 * 1024 * 1024) / 3) * 4; + export class PromptMuseumApiError extends Error { readonly status: number; readonly code: string; @@ -92,3 +107,23 @@ export async function fetchPromptMuseumPage(query: PromptMuseumListQuery = {}): export async function fetchPromptMuseumEntry(entryId: string): Promise { return await requestData(`${IMAGE_PROMPT_MUSEUM_API_PATH}/${encodeURIComponent(entryId)}`); } + +export async function fetchPromptMuseumMedia(mediaUrl: string): Promise { + if (!PROMPT_MUSEUM_MEDIA_URL_PATTERN.test(mediaUrl)) { + throw new PromptMuseumApiError(400, 'PROMPT_MUSEUM_INVALID_MEDIA_URL', '提示词图片地址无效'); + } + const localPath = mediaUrl.replace('/api/image-prompt-museum/', `${IMAGE_PROMPT_MUSEUM_API_PATH}/`); + const payload = await hostApiFetch(localPath); + const mimeType = typeof payload?.mimeType === 'string' ? payload.mimeType.trim().toLowerCase() : ''; + const dataBase64 = typeof payload?.dataBase64 === 'string' ? payload.dataBase64 : ''; + if ( + !PROMPT_MUSEUM_MEDIA_MIME_TYPES.has(mimeType) + || !dataBase64 + || dataBase64.length > MAX_MEDIA_BASE64_LENGTH + || !/^[A-Za-z0-9+/]*={0,2}$/.test(dataBase64) + || dataBase64.length % 4 === 1 + ) { + throw new PromptMuseumApiError(502, 'PROMPT_MUSEUM_INVALID_MEDIA_RESPONSE', '提示词图片返回了无效数据'); + } + return `data:${mimeType};base64,${dataBase64}`; +} diff --git a/src/pages/ImagePromptMuseum/index.tsx b/src/pages/ImagePromptMuseum/index.tsx index 6edca506..b95cd1e1 100644 --- a/src/pages/ImagePromptMuseum/index.tsx +++ b/src/pages/ImagePromptMuseum/index.tsx @@ -26,6 +26,7 @@ import { } from '@/components/ui/sheet'; import { fetchPromptMuseumEntry, + fetchPromptMuseumMedia, fetchPromptMuseumPage, PromptMuseumApiError, } from '@/lib/image-prompt-museum'; @@ -84,6 +85,17 @@ function errorMessage(error: unknown): string { return '获取灵感暂时不可用,请稍后再试'; } +function directHttpsUrl(value: string): string | null { + try { + const parsed = new URL(value); + return parsed.protocol === 'https:' && !parsed.username && !parsed.password + ? parsed.toString() + : null; + } catch { + return null; + } +} + function MuseumImage({ image, className, @@ -93,22 +105,52 @@ function MuseumImage({ className?: string; sizes?: string; }) { - const [failed, setFailed] = useState(false); - if (failed || !image.url) { + const directSource = directHttpsUrl(image.url); + const [failedUrl, setFailedUrl] = useState(null); + const [mediaState, setMediaState] = useState<{ + imageUrl: string; + source: string | null; + failed: boolean; + }>({ imageUrl: '', source: null, failed: false }); + + useEffect(() => { + if (directHttpsUrl(image.url)) return; + let cancelled = false; + void fetchPromptMuseumMedia(image.url).then( + (dataUrl) => { + if (!cancelled) setMediaState({ imageUrl: image.url, source: dataUrl, failed: false }); + }, + () => { + if (!cancelled) setMediaState({ imageUrl: image.url, source: null, failed: true }); + }, + ); + return () => { + cancelled = true; + }; + }, [image.url]); + + const relativeState = mediaState.imageUrl === image.url ? mediaState : null; + const source = directSource ?? relativeState?.source ?? null; + const failed = failedUrl === image.url || relativeState?.failed === true; + + if (failed || !source) { return ( -
+
); } return ( {image.alt} setFailed(true)} + onError={() => setFailedUrl(image.url)} /> ); } @@ -227,15 +269,17 @@ function AttributionBlock({ entry }: { entry: PromptMuseumEntry }) {
来源
- - {entry.attribution.source.name} - + {entry.attribution.source.url ? ( + + {entry.attribution.source.name} + + ) : entry.attribution.source.name}
diff --git a/tests/e2e/image-workspace-conversations.spec.ts b/tests/e2e/image-workspace-conversations.spec.ts index 0886eb59..c39cbb60 100644 --- a/tests/e2e/image-workspace-conversations.spec.ts +++ b/tests/e2e/image-workspace-conversations.spec.ts @@ -1,6 +1,94 @@ import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; test.describe('AI Design workspace', () => { + test('keeps Prompt Museum cards usable when relative media fails', async ({ launchElectronApp }) => { + test.setTimeout(90_000); + const app = await launchElectronApp({ + imageWorkspaceMode: 'local', + skipSetup: true, + }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); + await page.getByTestId('ai-module-option-painting').click(); + const imageSidebar = page.getByTestId('sidebar-image-workspace'); + await expect(imageSidebar.getByTestId('sidebar-open-image-prompt-museum')).toBeVisible(); + + await app.evaluate(({ ipcMain }) => { + const image = { + url: '/api/image-prompt-museum/e2e-entry/media/thumbnail', + width: 1200, + height: 900, + alt: 'E2E museum thumbnail', + }; + const card = { + id: 'e2e-entry', + slug: 'e2e-entry', + title: '相对媒体 E2E', + summary: '验证图片失败不会阻断卡片。', + thumbnail: image, + categories: [], + model: { id: 'e2e-model', name: 'E2E Model' }, + language: 'zh-CN', + attribution: { + author: { name: 'E2E 作者' }, + source: { name: 'E2E 来源' }, + license: { name: '测试许可', attributionText: 'E2E attribution' }, + }, + publishedAt: '2026-08-18T00:00:00.000Z', + updatedAt: '2026-08-18T00:00:00.000Z', + }; + const respond = (json: unknown) => ({ + ok: true, + data: { status: 200, ok: true, json }, + }); + + ipcMain.removeHandler('hostapi:fetch'); + ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string }) => { + const path = request.path ?? ''; + if (path === '/api/works/image-prompt-museum') { + return respond({ + success: true, + data: { + items: [card], + facets: { useCases: [], styles: [], subjects: [] }, + nextCursor: null, + total: 1, + }, + }); + } + if (path === '/api/works/image-prompt-museum/e2e-entry') { + return respond({ + success: true, + data: { + ...card, + prompt: 'Create a resilient image card.', + variables: [], + images: [image], + requiresReferenceImages: false, + }, + }); + } + if (path === '/api/works/image-prompt-museum/e2e-entry/media/thumbnail') { + return respond({ mimeType: 'text/html', dataBase64: 'PGgxPnVuc2FmZTwvaDE+' }); + } + return { ok: false, error: { message: `Unexpected E2E Host API request: ${path}` } }; + }); + }); + + await imageSidebar.getByTestId('sidebar-open-image-prompt-museum').click(); + const cardButton = page.getByRole('button', { name: '查看 相对媒体 E2E' }); + await expect(cardButton).toBeVisible(); + await expect(page.getByLabel('E2E museum thumbnail加载失败')).toBeVisible(); + await expect(cardButton).toBeEnabled(); + await cardButton.click(); + await expect(page.getByRole('button', { name: '关闭详情' })).toBeVisible(); + } finally { + await closeElectronApp(app); + } + }); + test('keeps the conversation and generation task panes full height', async ({ launchElectronApp }) => { test.setTimeout(150_000); const app = await launchElectronApp({ diff --git a/tests/unit/image-prompt-museum-api.test.ts b/tests/unit/image-prompt-museum-api.test.ts index 438bb4d0..288eaf26 100644 --- a/tests/unit/image-prompt-museum-api.test.ts +++ b/tests/unit/image-prompt-museum-api.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { hostApiFetch } from '@/lib/host-api'; import { fetchPromptMuseumEntry, + fetchPromptMuseumMedia, fetchPromptMuseumPage, PromptMuseumApiError, } from '@/lib/image-prompt-museum'; @@ -65,4 +66,38 @@ describe('Prompt Museum renderer API boundary', () => { ); }); + it('loads a controlled relative media path through Main and creates a data URL', async () => { + hostApiFetchMock.mockResolvedValueOnce({ mimeType: 'image/webp', dataBase64: 'AQID' }); + + await expect(fetchPromptMuseumMedia( + '/api/image-prompt-museum/entry-1:en/media/thumbnail', + )).resolves.toBe('data:image/webp;base64,AQID'); + expect(hostApiFetchMock).toHaveBeenCalledWith( + '/api/works/image-prompt-museum/entry-1:en/media/thumbnail', + ); + }); + + it.each([ + 'https://cdn.example/image.webp', + '/api/image-prompt-museum/../media/thumbnail', + '/api/image-prompt-museum/entry/media/original', + ])('rejects unsafe media URL %s before IPC', async (url) => { + await expect(fetchPromptMuseumMedia(url)).rejects.toMatchObject({ + status: 400, + code: 'PROMPT_MUSEUM_INVALID_MEDIA_URL', + }); + expect(hostApiFetchMock).not.toHaveBeenCalled(); + }); + + it('rejects an untrusted Main media payload', async () => { + hostApiFetchMock.mockResolvedValueOnce({ mimeType: 'text/html', dataBase64: 'PGgxPg==' }); + + await expect(fetchPromptMuseumMedia( + '/api/image-prompt-museum/entry/media/0', + )).rejects.toMatchObject({ + status: 502, + code: 'PROMPT_MUSEUM_INVALID_MEDIA_RESPONSE', + }); + }); + }); diff --git a/tests/unit/image-prompt-museum-page.test.tsx b/tests/unit/image-prompt-museum-page.test.tsx index 13de88bc..fb301799 100644 --- a/tests/unit/image-prompt-museum-page.test.tsx +++ b/tests/unit/image-prompt-museum-page.test.tsx @@ -7,6 +7,7 @@ import type { PromptMuseumEntry, PromptMuseumPage } from '../../shared/image-pro const fetchPromptMuseumPageMock = vi.hoisted(() => vi.fn()); const fetchPromptMuseumEntryMock = vi.hoisted(() => vi.fn()); +const fetchPromptMuseumMediaMock = vi.hoisted(() => vi.fn()); vi.mock('@/lib/image-prompt-museum', async (importOriginal) => { const actual = await importOriginal(); @@ -14,6 +15,7 @@ vi.mock('@/lib/image-prompt-museum', async (importOriginal) => { ...actual, fetchPromptMuseumPage: (...args: unknown[]) => fetchPromptMuseumPageMock(...args), fetchPromptMuseumEntry: (...args: unknown[]) => fetchPromptMuseumEntryMock(...args), + fetchPromptMuseumMedia: (...args: unknown[]) => fetchPromptMuseumMediaMock(...args), }; }); @@ -66,6 +68,7 @@ describe('Prompt Museum page', () => { useImagePromptMuseumStore.getState().clearPendingPrompt(); fetchPromptMuseumPageMock.mockResolvedValue(pageFixture); fetchPromptMuseumEntryMock.mockResolvedValue(entryFixture); + fetchPromptMuseumMediaMock.mockResolvedValue('data:image/webp;base64,AQID'); }); it('keeps the inspiration filters compact and below the native title bar', async () => { @@ -126,4 +129,78 @@ describe('Prompt Museum page', () => { expect.objectContaining({ cursor: 'cursor-two' }), ); }); + + it('renders source attribution as text when the server omits its optional URL', async () => { + fetchPromptMuseumEntryMock.mockResolvedValueOnce({ + ...entryFixture, + attribution: { + ...entryFixture.attribution, + source: { name: 'PromptHero' }, + }, + }); + + render( + + + } /> + + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '查看 编辑感产品海报' })); + expect(await screen.findByText('PromptHero')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'PromptHero' })).not.toBeInTheDocument(); + }); + + it('loads relative media through Main and renders its data URL', async () => { + fetchPromptMuseumPageMock.mockResolvedValueOnce({ + ...pageFixture, + items: [{ + ...pageFixture.items[0], + thumbnail: { + ...pageFixture.items[0].thumbnail, + url: '/api/image-prompt-museum/prompt-one/media/thumbnail', + }, + }], + }); + + render( + + + } /> + + , + ); + + const image = await screen.findByRole('img', { name: '编辑感产品海报示例' }); + expect(image).toHaveAttribute('src', 'data:image/webp;base64,AQID'); + expect(fetchPromptMuseumMediaMock).toHaveBeenCalledWith( + '/api/image-prompt-museum/prompt-one/media/thumbnail', + ); + }); + + it('keeps a card usable when its relative image fails', async () => { + fetchPromptMuseumMediaMock.mockRejectedValueOnce(new Error('image unavailable')); + fetchPromptMuseumPageMock.mockResolvedValueOnce({ + ...pageFixture, + items: [{ + ...pageFixture.items[0], + thumbnail: { + ...pageFixture.items[0].thumbnail, + url: '/api/image-prompt-museum/prompt-one/media/thumbnail', + }, + }], + }); + + render( + + + } /> + + , + ); + + expect(await screen.findByLabelText('编辑感产品海报示例加载失败')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '查看 编辑感产品海报' })).toBeEnabled(); + }); }); diff --git a/tests/unit/image-prompt-museum-route.test.ts b/tests/unit/image-prompt-museum-route.test.ts index 4d6f1432..bd21aa40 100644 --- a/tests/unit/image-prompt-museum-route.test.ts +++ b/tests/unit/image-prompt-museum-route.test.ts @@ -1,6 +1,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createImagePromptMuseumRouteHandler } from '@electron/api/routes/image-prompt-museum'; +import type { PromptMuseumCard } from '../../shared/image-prompt-museum'; function createResponse() { const chunks: string[] = []; @@ -19,6 +20,26 @@ function createResponse() { }; } +function card(imageUrl = '/api/image-prompt-museum/prompt-one/media/thumbnail'): PromptMuseumCard { + return { + id: 'prompt-one', + slug: 'prompt-one', + title: '示例', + summary: '示例摘要', + thumbnail: { url: imageUrl, width: 1200, height: 900, alt: '示例' }, + categories: [], + model: { id: 'image-model', name: 'Image Model' }, + language: 'zh-CN', + attribution: { + author: { name: '作者' }, + source: { name: '来源', url: 'https://example.com/source' }, + license: { name: 'CC BY 4.0', attributionText: '作者 / 来源 / CC BY 4.0' }, + }, + publishedAt: '2026-08-01T00:00:00Z', + updatedAt: '2026-08-01T00:00:00Z', + }; +} + describe('Prompt Museum Main route boundary', () => { const fetchImpl = vi.fn(); const getAccessToken = vi.fn(); @@ -74,6 +95,165 @@ describe('Prompt Museum Main route boundary', () => { expect(response.json).toMatchObject({ success: true }); }); + it('accepts controlled relative media URLs in list and detail DTOs', async () => { + getAccessToken.mockResolvedValue('works-token'); + fetchImpl + .mockResolvedValueOnce(new Response(JSON.stringify({ + success: true, + data: { + items: [card()], + facets: { useCases: [], styles: [], subjects: [] }, + nextCursor: null, + }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + success: true, + data: { + ...card(), + prompt: 'Create an image', + variables: [], + images: [{ url: '/api/image-prompt-museum/prompt-one/media/0', width: 1200, height: 900, alt: '详情' }], + requiresReferenceImages: false, + }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken }); + const listResponse = createResponse(); + const detailResponse = createResponse(); + + await handler( + { method: 'GET' } as IncomingMessage, + listResponse.res, + new URL('http://127.0.0.1/api/works/image-prompt-museum'), + {} as never, + ); + await handler( + { method: 'GET' } as IncomingMessage, + detailResponse.res, + new URL('http://127.0.0.1/api/works/image-prompt-museum/prompt-one'), + {} as never, + ); + + expect(listResponse.json).toMatchObject({ + success: true, + data: { items: [{ thumbnail: { url: '/api/image-prompt-museum/prompt-one/media/thumbnail' } }] }, + }); + expect(detailResponse.json).toMatchObject({ + success: true, + data: { images: [{ url: '/api/image-prompt-museum/prompt-one/media/0' }] }, + }); + }); + + it('accepts list attribution sources with a missing or null optional URL', async () => { + getAccessToken.mockResolvedValue('works-token'); + const itemWithoutUrl = card(); + itemWithoutUrl.attribution.source = { name: 'PromptHero' }; + const itemWithNullUrl = card(); + itemWithNullUrl.id = 'prompt-two'; + itemWithNullUrl.attribution.source = { name: 'Community archive', url: null }; + fetchImpl.mockResolvedValueOnce(new Response(JSON.stringify({ + success: true, + data: { + items: [itemWithoutUrl, itemWithNullUrl], + facets: { useCases: [], styles: [], subjects: [] }, + nextCursor: null, + }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken }); + const response = createResponse(); + + await handler( + { method: 'GET' } as IncomingMessage, + response.res, + new URL('http://127.0.0.1/api/works/image-prompt-museum'), + {} as never, + ); + + expect(response.res.statusCode).toBe(200); + expect(response.json).toMatchObject({ + success: true, + data: { items: [ + { attribution: { source: { name: 'PromptHero' } } }, + { attribution: { source: { name: 'Community archive', url: null } } }, + ] }, + }); + const sources = (response.json.data as { items: Array<{ attribution: { source: unknown } }> }) + .items.map((item) => item.attribution.source); + expect(sources).toEqual([ + { name: 'PromptHero' }, + { name: 'Community archive', url: null }, + ]); + }); + + it('proxies a fixed media path with Bearer auth and returns only bounded raster data', async () => { + getAccessToken.mockResolvedValue('works-token'); + fetchImpl.mockResolvedValue(new Response(Uint8Array.from([1, 2, 3]), { + status: 200, + headers: { 'Content-Type': 'image/png; charset=binary', 'Content-Length': '3' }, + })); + const handler = createImagePromptMuseumRouteHandler({ + fetchImpl, + getAccessToken, + apiBaseUrl: 'https://square.example', + }); + const response = createResponse(); + + await handler( + { method: 'GET' } as IncomingMessage, + response.res, + new URL('http://127.0.0.1/api/works/image-prompt-museum/entry-1:en/media/thumbnail'), + {} as never, + ); + + expect(fetchImpl).toHaveBeenCalledWith( + 'https://square.example/api/image-prompt-museum/entry-1:en/media/thumbnail', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer works-token' }), + redirect: 'manual', + }), + ); + expect(response.json).toEqual({ dataBase64: 'AQID', mimeType: 'image/png' }); + }); + + it.each([ + ['non-image media', { 'Content-Type': 'text/html' }], + ['oversized media', { 'Content-Type': 'image/webp', 'Content-Length': String(10 * 1024 * 1024 + 1) }], + ])('rejects %s responses', async (_name, headers) => { + getAccessToken.mockResolvedValue('works-token'); + fetchImpl.mockResolvedValue(new Response('unsafe', { status: 200, headers })); + const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken }); + const response = createResponse(); + + await handler( + { method: 'GET' } as IncomingMessage, + response.res, + new URL('http://127.0.0.1/api/works/image-prompt-museum/prompt-one/media/0'), + {} as never, + ); + + expect(response.res.statusCode).toBe(502); + expect(response.json).toMatchObject({ code: 'PROMPT_MUSEUM_INVALID_RESPONSE' }); + }); + + it('does not claim unsafe or unknown media paths', async () => { + const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken }); + const unsafeResponse = createResponse(); + const unknownResponse = createResponse(); + + await expect(handler( + { method: 'GET' } as IncomingMessage, + unsafeResponse.res, + new URL('http://127.0.0.1/api/works/image-prompt-museum/../media/thumbnail'), + {} as never, + )).resolves.toBe(false); + await expect(handler( + { method: 'GET' } as IncomingMessage, + unknownResponse.res, + new URL('http://127.0.0.1/api/works/image-prompt-museum/prompt-one/media/original'), + {} as never, + )).resolves.toBe(false); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it('returns a stable auth error without calling the upstream service', async () => { getAccessToken.mockResolvedValue(null); const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken }); @@ -215,23 +395,7 @@ describe('Prompt Museum Main route boundary', () => { fetchImpl.mockResolvedValueOnce(new Response(JSON.stringify({ success: true, data: { - items: [{ - id: 'prompt-one', - slug: 'prompt-one', - title: '示例', - summary: '示例摘要', - thumbnail: { url: 'http://internal.example/secret.png', width: 1200, height: 900, alt: '示例' }, - categories: [], - model: { id: 'image-model', name: 'Image Model' }, - language: 'zh-CN', - attribution: { - author: { name: '作者' }, - source: { name: '来源', url: 'https://example.com/source' }, - license: { name: 'CC BY 4.0', attributionText: '作者 / 来源 / CC BY 4.0' }, - }, - publishedAt: '2026-08-01T00:00:00Z', - updatedAt: '2026-08-01T00:00:00Z', - }], + items: [card('http://internal.example/secret.png')], facets: { useCases: [], styles: [], subjects: [] }, nextCursor: null, },