From 2ac57463c01e55a7f9d0e91aa7860c9286fa6516 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 13 Jul 2026 18:34:53 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=85=AC=E5=BC=80=E4=BD=9C?= =?UTF-8?q?=E5=93=81=E7=A7=81=E6=9C=89=E8=B5=84=E4=BA=A7=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增公开可见作品的精确资产授权视图并覆盖十类玩法 改用受限 procedure 权威点查资产对象并在失败时关闭访问 移除资产对象全表订阅并统一 ACL 与编辑器引用查询 生成 SpacetimeDB 绑定并补齐回归测试、架构文档和项目记忆 --- .../shared-memory/decision-log.md | 11 + docs/project-memory/shared-memory/pitfalls.md | 10 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 17 +- server-rs/crates/api-server/src/assets.rs | 111 +++- .../crates/api-server/src/editor_project.rs | 8 +- .../crates/module-assets/src/commands.rs | 7 + server-rs/crates/module-assets/src/domain.rs | 181 ++++++ server-rs/crates/module-assets/src/lib.rs | 6 +- .../crates/spacetime-client/src/assets.rs | 92 ++- server-rs/crates/spacetime-client/src/lib.rs | 72 ++- .../crates/spacetime-client/src/mapper.rs | 3 +- .../spacetime-client/src/mapper/assets.rs | 65 +- .../spacetime-client/src/module_bindings.rs | 34 + .../asset_object_location_input_type.rs | 16 + ...asset_object_by_id_and_return_procedure.rs | 58 ++ ...object_by_location_and_return_procedure.rs | 59 ++ .../public_work_asset_read_grant_table.rs | 166 +++++ .../public_work_asset_read_grant_type.rs | 46 ++ .../src/asset_metadata/objects.rs | 148 +++++ server-rs/crates/spacetime-module/src/lib.rs | 2 + .../src/public_asset_access.rs | 606 ++++++++++++++++++ 21 files changed, 1615 insertions(+), 103 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/asset_object_location_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_asset_object_by_id_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_asset_object_by_location_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/public_work_asset_read_grant_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/public_work_asset_read_grant_type.rs create mode 100644 server-rs/crates/spacetime-module/src/public_asset_access.rs diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9d11b24a6..a669bd569 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4044,3 +4044,14 @@ - 决策:后台壳层固定为 `height: 100dvh` 并隐藏壳层 overflow;桌面侧边栏使用独立 `overflow-y: auto`,`.admin-main` 通过 `min-height: 0` 和 `overflow: hidden` 约束网格,`.admin-content` 使用 `min-height: 0; overflow: auto` 独立滚动。 - 响应式边界:小于等于 `980px` 时仍隐藏桌面侧边栏,主内容在视口高度内滚动,底部导航继续固定。 - 验证方式:`apps/admin-web/src/styles/admin.test.ts` 锁定壳层滚动契约;桌面浏览器滚动后应保持 `window.scrollY = 0`、侧边栏 `top = 0`,只改变 `.admin-content.scrollTop`;移动视口继续由 `.admin-content` 滚动。 + +## 2026-07-13 公开作品资产使用派生精确读授权 + +- 背景:资产 ACL 严格执行后,已登记为 `private` 的作品封面和正式资产不能再依赖 generated 前缀匿名读取;但公开作品仍需要允许访客读取它实际展示和运行的资产。 +- 决策:已登记 `asset_object` 继续保持 `private`,新增匿名派生 view `public_work_asset_read_grant`。view 只从 `Published + visible` 作品(`custom-world` 另要求未删除)正式发布快照中收集实际使用的资产,历史作品随 view 计算自动补齐;`spacetime-client` 将该 view 纳入每个池连接的长期订阅。 +- 授权边界:grant 携带作品 owner,API 只有在它与 `asset_object.owner_user_id` 一致,且 `asset_object_id` 或精确 `object_key` 命中时才允许匿名读取。隐藏、删除或取消发布会使 grant 自动消失;参考图、未选中候选图和 `generationInputs` 明确排除。Custom World 只遍历角色、地标、营地、章节和 opening CG 等已知正式根,不能递归 legacy payload 的未知预览 / 编辑字段。 +- 禁止项:不得通过放开 `generated-*` 前缀或批量把历史对象改为 `PublicRead` 修复公开作品,两种方式都会让作品可见性生命周期与资产授权脱节,并重新引入跨账号读取。 +- 影响范围:`module-assets` 公开资产授权判定、`spacetime-module` 跨玩法公开资产 view、`spacetime-client` 订阅 cache 与 `api-server` 资产读取 ACL。 +- 权威查询:`asset_object` 不进入 client 长期订阅。API 通过仅 runtime service identity 可调用的 procedure,按主键或 `(bucket, object_key)` 服务端索引读取事务内 metadata;只有位置查询明确返回不存在时才允许进入 legacy curated 前缀兼容,procedure 失败、超时或重复位置一律失败关闭。 +- 一致性:grant 通过长期订阅进入 API 本地 cache;隐藏、删除或取消发布提交后,在连接收到 view 更新时撤销。已签发 URL 仍受其短 TTL 约束,因此该能力不是对既有签名的瞬时吊销机制。 +- 验证方式:公开可见作品的正式资产可匿名读取;未选候选图、参考图、跨 owner 伪造 key 仍返回不存在;隐藏、删除或取消发布后 grant 随 view 更新撤销,再恢复公开可见时随 view 恢复。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 850512ad0..43d9cf796 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3006,3 +3006,13 @@ - 处理:共享弹窗统一记录 `pointerdown` 与 `pointerup` 的目标,只有按下和松开都发生在遮罩自身时才允许关闭。新增弹窗优先复用 `UnifiedModal`,不要继续复制只判断最终 `click` 目标的手写遮罩逻辑。 - 验证:回归测试同时覆盖“弹窗内按下、遮罩松开不关闭”和“遮罩按下、遮罩松开正常关闭”。 - 关联:`src/components/common/UnifiedModal.tsx`、`src/components/common/UnifiedModal.test.tsx`、`src/components/auth/PlatformAuthModalShell.test.tsx`。 + +## 公开作品资产不能用 generated 前缀或 PublicRead 批量放行 + +- 现象:资产 ACL 收紧后,公开页面读取其他作者作品资产集中返回 `404`;对象在 OSS 中真实存在,但已登记 `asset_object.access_policy = private`。 +- 原因:“作品公开”不等于“作者账号下所有 generated 对象永久公开”。只按 profile / session 关联也会误公开同会话的未选候选图、参考图或生成输入;批量改 `PublicRead` 则无法随作品隐藏、删除或取消发布自动撤销。 +- 处理:已登记对象继续保持 `private`,通过 `public_work_asset_read_grant` 只派生 `Published + visible`(`custom-world` 还必须未删除)正式发布快照实际使用资产的匿名读授权。API 必须同时校验 grant owner 与资产 owner 一致,以及 `asset_object_id` 或精确 `object_key` 命中;明确排除参考图、未选候选图和 `generationInputs`。Custom World 只能扫描角色、地标、营地、章节和 opening CG 等正式根,不能遍历 legacy payload 的未知根。历史作品交给 view 现算补齐,不做永久 ACL 数据补丁。 +- 负查询边界:不能从 `asset_object` 订阅 cache miss 推断 metadata 不存在;订阅失败或增量尚未到达会让已登记 private 对象误走 legacy 放行。资产定位必须通过受 runtime service identity 限制的 procedure 按 bucket / key 权威点查,失败时拒绝读取;同时不要在每个池连接订阅复制全量 private 资产表。 +- Remix 边界:拼图、Custom World 和大鱼现有 Remix 会把源资产引用复制到新 owner,但没有持久化不可伪造的资产来源。不得因此放宽跨 owner grant;源作品隐藏后仍公开的 Remix 资产,需要后续通过 Remix 时复制资产或持久化 provenance 解决。 +- 验证:资产 owner 本人仍可读;公开可见作品的正式资产可匿名读;跨 owner、只命中前缀、参考图、未选候选图和 `generationInputs` 仍返回不存在;作品隐藏、删除或取消发布后 grant 消失。 +- 关联:`server-rs/crates/spacetime-module/src/public_asset_access.rs`、`server-rs/crates/spacetime-client/src/assets.rs`、`server-rs/crates/api-server/src/assets.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 5e5c68170..a73c386c7 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -58,7 +58,7 @@ npm run check:server-rs-ddd - 认证与账号:`/api/auth/*`、`/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。 - 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。 - 平台基础能力:`/api/llm/*`、`/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。 -- 资产基础能力:`/api/assets/direct-upload-tickets`、`/api/assets/sts-upload-credentials`、`/api/assets/objects/*`、`/api/assets/read-url`、`/api/assets/read-bytes`,负责直传、确认、绑定和读取。两个读取入口共用同一授权函数,并先按配置 bucket 与精确 key 查询 `asset_object`:一旦存在 metadata,即使 key 命中 legacy 前缀,也必须按 `PublicRead` 或当前登录 owner 授权;只有同 bucket / key 未登记 metadata 的历史对象,才允许显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` curated 白名单后匿名兼容。任意未登记 `objectKey`、跨 owner 和匿名私有读取统一返回不存在,`read-bytes` 不得成为绕过 `read-url` 授权的同源代理。 +- 资产基础能力:`/api/assets/direct-upload-tickets`、`/api/assets/sts-upload-credentials`、`/api/assets/objects/*`、`/api/assets/read-url`、`/api/assets/read-bytes`,负责直传、确认、绑定和读取。两个读取入口共用同一授权函数,并通过受 runtime service identity 限制的 procedure 按配置 bucket 与精确 key 权威查询 `asset_object`;不得把订阅 cache miss 解释为对象未登记。一旦存在 metadata,即使 key 命中 legacy 前缀,也必须按 `PublicRead`、当前登录 owner,或同 owner 的 `public_work_asset_read_grant` 精确授权读取;只有同 bucket / key 的权威查询确认未登记时,才允许显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` curated 白名单后匿名兼容。已登记资产继续保持 `private`,公开作品只获得与正式发布快照生命周期一致的派生读授权,不得批量改为 `PublicRead` 或放开 `generated-*` 前缀。任意未登记 `objectKey`、跨 owner 和未获授权的匿名私有读取统一返回不存在,`read-bytes` 不得成为绕过 `read-url` 授权的同源代理。 - 外部 OpenAPI:`/api/external/v1/openapi.json`、`/api/external/v1/assets/direct-upload-tickets`、`/api/external/v1/assets/objects/confirm`、`/api/external/v1/assets/read-url`、`/api/external/v1/editor/*`,使用 Bearer API Key 鉴权;API Key 管理仍在登录态 `/api/profile/api-keys`,不进入外部 OpenAPI JSON。主站和 External 的 asset object confirm 都必须从已认证主体派生 owner,不能信任请求体 owner;同 bucket / key 已登记后不得改变 owner。 - 创作 / 游玩支撑能力:`/api/creation-entry/config`、`/api/ai/tasks*`、`/api/runtime/frontend-config`、`/api/runtime/chat/*`、`/api/runtime/settings`、`/api/runtime/save/snapshot`、`/api/profile/browse-history`、`/api/profile/save-archives*`、`/api/profile/play-stats`、`/api/assets/history`、`/api/assets/character-visual/*`、`/api/assets/character-animation/*`、`/api/assets/character-workflow-cache*`、`/api/assets/hyper3d/*`、`/api/runtime/custom-world/asset-studio/*`、`/api/editor/projects*`、`/api/editor/projects/{projectId}/agent-conversations`、`/api/editor/agent-conversations/{conversationId}*`。`/api/runtime/frontend-config` 由 `api-server` 从运行时环境变量下发非敏感 UI 开关;画板右侧 Agent 入口由 `GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR` 控制,默认关闭,前端不再读取 `VITE_*` 构建期变量决定生产显示。`/api/runtime/custom-world/asset-studio/*` 解析默认角色形象 / 动作提示词时可以在 OSS 缓存不可用或未配置时按无缓存返回默认提示;保存 workflow 缓存和真实素材读写仍必须要求 OSS 正常可用。 - 后台入口配置:`/admin/api/creation-entry/config`、`/admin/api/creation-entry/config/banners` 和 `/admin/api/creation-entry/config/interactions`。 @@ -172,7 +172,7 @@ npm run check:server-rs-ddd 4. 删除字段、改名、重排字段、改类型或修改字段属性前,必须先询问用户并确认迁移计划。 5. Vec 字段不要直接写无法 const 求值的 default;需要默认空集合时优先使用 `Option>` 加 `#[default(None::>)]`,业务层归一为空数组。 6. 运行态读表必须按已声明索引访问。只要 table 上存在覆盖查询前缀的 `#[index(...)]` 或主键 / unique accessor,列表、详情、快照组装和计数都先用对应 accessor `.filter(...)` / `.find(...)`,再在内存中处理索引无法覆盖的残余条件;不得用 `.iter().filter(...)` 扫整表替代现成索引。 -7. 面向公开列表的只读投影优先做成 public view / public 读模型表,并由 `api-server` 的 `spacetime-client` 长期订阅后读本地 cache。跨玩法公开作品统一主读模型是 `public_work_gallery_entry` 和 `public_work_detail_entry`;各玩法既有 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 保留为 source view 和兼容路径。短期不把作品列表整体交给浏览器前端直接订阅;不要让 HTTP 列表接口每次请求都调用 procedure 重新组装全量列表。需要请求时间窗口的轻量统计可订阅 `public_work_play_daily_stat` 后在 `api-server` 本地聚合,需要写入副作用的详情、点赞、游玩记录仍走玩法 procedure / reducer。前端不得直接订阅 `puzzle_work_profile`、`custom_world_profile` 等领域源表,也不得自己做 join、聚合或权限逻辑。首屏、排序、字段归一、权限降级和 HTTP fallback 由 `api-server` BFF 维持。 +7. 面向公开列表的只读投影优先做成 public view / public 读模型表,并由 `api-server` 的 `spacetime-client` 长期订阅后读本地 cache。跨玩法公开作品统一主读模型是 `public_work_gallery_entry` 和 `public_work_detail_entry`;公开作品资产读取授权投影是 `public_work_asset_read_grant`;各玩法既有 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 保留为 source view 和兼容路径。短期不把作品列表整体交给浏览器前端直接订阅;不要让 HTTP 列表接口每次请求都调用 procedure 重新组装全量列表。需要请求时间窗口的轻量统计可订阅 `public_work_play_daily_stat` 后在 `api-server` 本地聚合,需要写入副作用的详情、点赞、游玩记录仍走玩法 procedure / reducer。前端不得直接订阅 `puzzle_work_profile`、`custom_world_profile` 等领域源表,也不得自己做 join、聚合或权限逻辑。首屏、排序、字段归一、权限降级和 HTTP fallback 由 `api-server` BFF 维持。 8. 多列索引按 SpacetimeDB 绑定生成的元组参数直接传入,例如 `.filter((source_type, profile_id, played_day))`;前缀查询只传前缀元组,例如 `.filter((scope_kind, scope_id.as_str()))`。不要为了绕过类型问题退回整表遍历。 9. procedure result 必须返回 typed snapshot / typed value。`spacetime-client` mapper 不得再通过 `row_json/session_json/work_json/items_json/run_json/event_json/feedback_json: Option` 做跨层 JSON 字符串传输,也不得在 mapper 里反序列化旧 `*JsonRecord` 兼容结构。业务内部持久化字段如 `profile_payload_json`、`levels_json` 等不属于 procedure result 载荷例外,仍按各自表契约处理。 10. 修改后运行: @@ -309,7 +309,14 @@ npm run check:server-rs-ddd - Rust 结构体:`AssetObject` - 源码:`server-rs/crates/spacetime-module/src/asset_metadata/objects.rs` -- 说明:对象 metadata 以 bucket / key 标识正式对象及其 owner、访问策略。确认接口的 owner 必须来自登录会话或 External API Key 绑定的认证主体,不接受请求体指定 owner;同 bucket / key 首次登记后,重复 confirm 不得改变 owner。资产读取必须先查询同 bucket / key metadata,存在时严格执行 `PublicRead` / owner ACL;只有 metadata 不存在的历史对象才能进入 curated legacy 白名单兼容。 +- 说明:对象 metadata 以 bucket / key 标识正式对象及其 owner、访问策略。确认接口的 owner 必须来自登录会话或 External API Key 绑定的认证主体,不接受请求体指定 owner;同 bucket / key 首次登记后,重复 confirm 不得改变 owner。已登记对象默认并继续保持 `private`。API 通过 `get_asset_object_by_location_and_return` / `get_asset_object_by_id_and_return` 在服务端索引上权威查询 private table,procedure 只允许 runtime service identity 调用;`spacetime-client` 不订阅全量 `asset_object`。位置查询发现重复 bucket / key 时失败关闭,不能任选一条继续授权。只有权威位置查询返回不存在的历史对象才能进入 curated legacy 白名单兼容。 + +### SpacetimeDB view:`public_work_asset_read_grant` + +- Rust view:`public_work_asset_read_grant` +- 返回类型:`Vec` +- 源码:`server-rs/crates/spacetime-module/src/public_asset_access.rs` +- 说明:匿名公开派生授权投影,仅从 `Published + visible` 作品(`custom-world` 还必须满足未删除)的正式发布快照收集实际使用的已登记资产。每条 grant 都携带作品 owner,读取时必须与 `asset_object.owner_user_id` 一致,并且只能按 `asset_object_id` 或精确 `object_key` 命中;跨 owner、同前缀或相似 key 不构成授权。历史公开作品由 view 现算自动补齐;作品隐藏、删除或取消发布后 grant 随 client 收到 view 更新而消失,已签发 URL 继续受短 TTL 约束。投影明确排除参考图、未选中候选图和 `generationInputs`;Custom World 只扫描角色、地标、营地、章节和 opening CG 等正式根,不递归 legacy payload 未知字段,避免把预览、编辑输入或同会话其他私有资产扩大为公开资产。 ### `auth_identity` @@ -923,6 +930,7 @@ npm run check:server-rs-ddd - `SELECT * FROM public_work_gallery_entry` - `SELECT * FROM public_work_detail_entry` +- `SELECT * FROM public_work_asset_read_grant` - `SELECT * FROM bark_battle_gallery_view` - `SELECT * FROM puzzle_gallery_card_view` - `SELECT * FROM puzzle_clear_gallery_card_view` @@ -945,7 +953,8 @@ npm run check:server-rs-ddd - `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle-clear'` - `SELECT * FROM creation_entry_config` - `SELECT * FROM creation_entry_type_config` -- `SELECT * FROM asset_object` + +private `asset_object` 不进入长期订阅;安全判断通过受限 procedure 在服务端按主键或 `(bucket, object_key)` 索引读取事务内真相,避免全表复制、订阅失败和增量同步延迟把已登记私有对象误判为 legacy 未登记对象。 跨玩法公开作品列表 / 详情主读模型是 `public_work_gallery_entry` 与 `public_work_detail_entry`。拼图、自定义世界等旧玩法公开列表 HTTP 路由保留原响应 shape,由 BFF mapper 从统一 public cache 映射回当前 DTO;旧 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 继续作为 source view 和兼容缓存。各玩法的个人作品列表、详情、发布、点赞、游玩记录、Remix 和其它需要鉴权或写入副作用的路径继续走 procedure / reducer;不要为了公开列表性能把这些 owner-specific 或 mutation 语义混进 public view。 diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index f8479ab72..2f7684425 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -664,15 +664,31 @@ async fn authorize_asset_read_target( let asset_object = state .spacetime_client() - .get_asset_object_by_object_key(target.object_key.clone()) + .get_asset_object_by_location(module_assets::AssetObjectLocationInput { + bucket: configured_bucket.to_string(), + object_key: target.object_key.clone(), + }) .await .map_err(map_asset_read_authorization_error)?; if let Some(asset_object) = asset_object.as_ref() { + let public_work_granted = if asset_object.access_policy == AssetObjectAccessPolicy::Private + && !asset_object_owner_matches(asset_object, authorization) + && asset_object_storage_matches(asset_object, configured_bucket, &target.object_key) + { + state + .spacetime_client() + .is_asset_object_referenced_by_public_work(asset_object.clone()) + .await + .map_err(map_asset_read_authorization_error)? + } else { + false + }; return require_asset_object_read_access( asset_object, configured_bucket, target.object_key.as_str(), authorization, + public_work_granted, ); } @@ -689,16 +705,35 @@ fn require_asset_object_read_access( configured_bucket: &str, object_key: &str, authorization: &AssetReadAuthorization, + public_work_granted: bool, ) -> Result<(), AppError> { - let bucket_matches = asset_object.bucket.trim() == configured_bucket.trim(); - let key_matches = asset_object.object_key.trim().trim_start_matches('/') == object_key; - if !bucket_matches || !key_matches { + if !asset_object_storage_matches(asset_object, configured_bucket, object_key) { return Err(asset_read_not_found()); } - if asset_object.access_policy == AssetObjectAccessPolicy::PublicRead { + if asset_object.access_policy == AssetObjectAccessPolicy::PublicRead + || public_work_granted + || asset_object_owner_matches(asset_object, authorization) + { return Ok(()); } - let owner_matches = match authorization { + + Err(asset_read_not_found()) +} + +fn asset_object_storage_matches( + asset_object: &module_assets::AssetObjectRecord, + configured_bucket: &str, + object_key: &str, +) -> bool { + asset_object.bucket.trim() == configured_bucket.trim() + && asset_object.object_key.trim().trim_start_matches('/') == object_key +} + +fn asset_object_owner_matches( + asset_object: &module_assets::AssetObjectRecord, + authorization: &AssetReadAuthorization, +) -> bool { + match authorization { AssetReadAuthorization::Owner(owner_user_id) => asset_object .owner_user_id .as_deref() @@ -706,11 +741,6 @@ fn require_asset_object_read_access( .is_some_and(|value| value == owner_user_id.trim()), AssetReadAuthorization::Admin => true, AssetReadAuthorization::Anonymous => false, - }; - if owner_matches { - Ok(()) - } else { - Err(asset_read_not_found()) } } @@ -993,6 +1023,7 @@ mod tests { "genarrative-assets", record.object_key.as_str(), &super::AssetReadAuthorization::Owner("user-owner".to_string()), + false, ) .is_ok() ); @@ -1005,6 +1036,7 @@ mod tests { "genarrative-assets", record.object_key.as_str(), &authorization, + false, ) .expect_err("private asset should reject non-owner"); assert_eq!(error.status_code(), StatusCode::NOT_FOUND); @@ -1021,6 +1053,7 @@ mod tests { "genarrative-assets", record.object_key.as_str(), &super::AssetReadAuthorization::Anonymous, + false, ) .is_ok() ); @@ -1030,6 +1063,7 @@ mod tests { "another-bucket", record.object_key.as_str(), &super::AssetReadAuthorization::Anonymous, + false, ) .expect_err("bucket mismatch should be hidden") .status_code(), @@ -1037,6 +1071,37 @@ mod tests { ); } + #[test] + fn published_visible_work_reference_grants_private_asset_read() { + let record = asset_object_record( + module_assets::AssetObjectAccessPolicy::Private, + Some("user-owner"), + ); + + assert!( + super::require_asset_object_read_access( + &record, + "genarrative-assets", + record.object_key.as_str(), + &super::AssetReadAuthorization::Anonymous, + true, + ) + .is_ok() + ); + assert_eq!( + super::require_asset_object_read_access( + &record, + "another-bucket", + record.object_key.as_str(), + &super::AssetReadAuthorization::Anonymous, + true, + ) + .expect_err("public work grant must not bypass storage identity") + .status_code(), + StatusCode::NOT_FOUND + ); + } + #[test] fn legacy_public_fallback_only_accepts_curated_prefixes() { assert!(super::is_supported_legacy_public_object_key( @@ -1229,7 +1294,7 @@ mod tests { } #[tokio::test] - async fn read_url_returns_signed_legacy_public_object_url_when_oss_configured() { + async fn read_url_fails_closed_when_asset_metadata_authority_is_unavailable() { let config = AppConfig { oss_bucket: Some("genarrative-assets".to_string()), oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()), @@ -1252,7 +1317,7 @@ mod tests { .await .expect("request should succeed"); - assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); let body = response .into_body() @@ -1263,20 +1328,14 @@ mod tests { let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); - assert_eq!(payload["ok"], Value::Bool(true)); assert_eq!( - payload["data"]["read"]["objectKey"], - Value::String("generated-characters/hero_001/visual/asset_01/master.png".to_string()) - ); - assert!( - payload["data"]["read"]["signedUrl"] - .as_str() - .is_some_and(|value| value.contains("x-oss-signature-version=OSS4-HMAC-SHA256")) + payload["error"]["details"]["provider"], + Value::String("spacetimedb".to_string()) ); } #[tokio::test] - async fn read_url_accepts_legacy_public_path_for_transition() { + async fn legacy_transition_does_not_bypass_asset_metadata_authority() { let config = AppConfig { oss_bucket: Some("genarrative-assets".to_string()), oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()), @@ -1298,7 +1357,7 @@ mod tests { .await .expect("request should succeed"); - assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); let body = response .into_body() @@ -1310,10 +1369,8 @@ mod tests { serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!( - payload["read"]["objectKey"], - Value::String( - "generated-custom-world-scenes/profile_01/landmark_01/scene.png".to_string() - ) + payload["error"]["details"]["provider"], + Value::String("spacetimedb".to_string()) ); } diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 482d789e9..1f484b7d3 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -5818,9 +5818,15 @@ async fn ensure_editor_reference_object_key_owned( }) { return Ok(()); } + let Some(oss_client) = state.oss_client() else { + return Err(editor_reference_image_forbidden(object_key)); + }; if let Some(asset_object) = state .spacetime_client() - .get_asset_object_by_object_key(object_key.to_string()) + .get_asset_object_by_location(module_assets::AssetObjectLocationInput { + bucket: oss_client.config_bucket().to_string(), + object_key: object_key.to_string(), + }) .await .map_err(map_editor_project_error)? { diff --git a/server-rs/crates/module-assets/src/commands.rs b/server-rs/crates/module-assets/src/commands.rs index 0c3443f3b..5f58f676c 100644 --- a/server-rs/crates/module-assets/src/commands.rs +++ b/server-rs/crates/module-assets/src/commands.rs @@ -32,6 +32,13 @@ pub struct AssetHistoryListInput { pub limit: u32, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AssetObjectLocationInput { + pub bucket: String, + pub object_key: String, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct AssetObjectUpsertInput { diff --git a/server-rs/crates/module-assets/src/domain.rs b/server-rs/crates/module-assets/src/domain.rs index fba91cc0f..209943f20 100644 --- a/server-rs/crates/module-assets/src/domain.rs +++ b/server-rs/crates/module-assets/src/domain.rs @@ -99,6 +99,73 @@ pub struct AssetObjectRecord { pub updated_at: String, } +/// 已发布且可见作品派生出的精确资产读取授权。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PublicAssetReadGrant { + pub owner_user_id: String, + pub asset_object_id: Option, + pub object_key: Option, +} + +/// 为公开资产授权生成可由订阅缓存点查的 owner-scoped 主键。 +pub fn public_asset_read_grant_id(grant: &PublicAssetReadGrant) -> Option { + let owner_user_id = grant.owner_user_id.trim(); + if owner_user_id.is_empty() { + return None; + } + let owner_prefix = format!("owner:{}:{owner_user_id}", owner_user_id.len()); + + if let Some(asset_object_id) = grant + .asset_object_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Some(format!("{owner_prefix}|asset-object-id:{asset_object_id}")); + } + + grant + .object_key + .as_deref() + .map(str::trim) + .map(|value| value.trim_start_matches('/')) + .filter(|value| !value.is_empty()) + .map(|object_key| format!("{owner_prefix}|object-key:{object_key}")) +} + +/// 判断资产对象是否命中同 owner 公开作品派生出的精确授权。 +pub fn asset_object_matches_public_read_grant( + asset_object: &AssetObjectRecord, + grant: &PublicAssetReadGrant, +) -> bool { + let Some(asset_owner_user_id) = asset_object + .owner_user_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return false; + }; + let grant_owner_user_id = grant.owner_user_id.trim(); + if grant_owner_user_id.is_empty() || asset_owner_user_id != grant_owner_user_id { + return false; + } + + grant + .asset_object_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some_and(|value| value == asset_object.asset_object_id.trim()) + || grant + .object_key + .as_deref() + .map(str::trim) + .map(|value| value.trim_start_matches('/')) + .filter(|value| !value.is_empty()) + .is_some_and(|value| value == asset_object.object_key.trim().trim_start_matches('/')) +} + /// 面向 API 与前端展示的资产历史记录。 #[derive(Clone, Debug, PartialEq, Eq)] pub struct AssetHistoryEntryRecord { @@ -126,3 +193,117 @@ pub struct AssetEntityBindingRecord { pub created_at: String, pub updated_at: String, } + +#[cfg(test)] +mod tests { + use super::*; + + fn asset_object(owner_user_id: Option<&str>) -> AssetObjectRecord { + AssetObjectRecord { + asset_object_id: "assetobj_public_reference".to_string(), + bucket: "genarrative-assets".to_string(), + object_key: "generated-puzzle-assets/public-reference.png".to_string(), + access_policy: AssetObjectAccessPolicy::Private, + content_type: Some("image/png".to_string()), + content_length: 1, + content_hash: None, + version: 1, + source_job_id: None, + owner_user_id: owner_user_id.map(ToOwned::to_owned), + profile_id: None, + entity_id: Some("puzzle-session-001".to_string()), + asset_kind: "puzzle_cover_image".to_string(), + created_at: "2026-07-13T00:00:00Z".to_string(), + updated_at: "2026-07-13T00:00:00Z".to_string(), + } + } + + fn grant( + owner_user_id: &str, + asset_object_id: Option<&str>, + object_key: Option<&str>, + ) -> PublicAssetReadGrant { + PublicAssetReadGrant { + owner_user_id: owner_user_id.to_string(), + asset_object_id: asset_object_id.map(ToOwned::to_owned), + object_key: object_key.map(ToOwned::to_owned), + } + } + + #[test] + fn public_work_grant_authorizes_exact_historical_object_key() { + let asset = asset_object(Some("user-owner")); + + assert!(asset_object_matches_public_read_grant( + &asset, + &grant( + "user-owner", + None, + Some("generated-puzzle-assets/public-reference.png") + ) + )); + } + + #[test] + fn public_work_grant_authorizes_exact_asset_object_id() { + let asset = asset_object(Some("user-owner")); + + assert!(asset_object_matches_public_read_grant( + &asset, + &grant("user-owner", Some("assetobj_public_reference"), None) + )); + } + + #[test] + fn public_work_grant_rejects_cross_owner_and_unrelated_assets() { + let asset = asset_object(Some("user-owner")); + + assert!(!asset_object_matches_public_read_grant( + &asset, + &grant("user-other", Some("assetobj_public_reference"), None) + )); + assert!(!asset_object_matches_public_read_grant( + &asset, + &grant("user-owner", Some("assetobj_other"), None) + )); + assert!(!asset_object_matches_public_read_grant( + &asset_object(None), + &grant("user-owner", Some("assetobj_public_reference"), None) + )); + } + + #[test] + fn public_work_grant_id_is_owner_scoped_and_normalizes_object_key() { + assert_eq!( + public_asset_read_grant_id(&grant( + "user-owner", + Some("assetobj_public_reference"), + None + )), + Some("owner:10:user-owner|asset-object-id:assetobj_public_reference".to_string()) + ); + assert_eq!( + public_asset_read_grant_id(&grant( + "user-owner", + None, + Some("/generated-puzzle-assets/public-reference.png") + )), + Some( + "owner:10:user-owner|object-key:generated-puzzle-assets/public-reference.png" + .to_string() + ) + ); + assert_ne!( + public_asset_read_grant_id(&grant( + "user-owner", + Some("assetobj_public_reference"), + None + )), + public_asset_read_grant_id(&grant( + "user-other", + Some("assetobj_public_reference"), + None + )) + ); + } +} diff --git a/server-rs/crates/module-assets/src/lib.rs b/server-rs/crates/module-assets/src/lib.rs index 3c2e1ac27..2b76f42cf 100644 --- a/server-rs/crates/module-assets/src/lib.rs +++ b/server-rs/crates/module-assets/src/lib.rs @@ -17,13 +17,15 @@ pub use asset_object_service::{ AssetObjectService, ConfirmAssetObjectError, InMemoryAssetObjectStore, }; pub use commands::{ - AssetEntityBindingInput, AssetHistoryListInput, AssetObjectUpsertInput, ConfirmAssetObjectInput, + AssetEntityBindingInput, AssetHistoryListInput, AssetObjectLocationInput, + AssetObjectUpsertInput, ConfirmAssetObjectInput, }; pub use domain::{ ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingRecord, AssetEntityBindingSnapshot, AssetHistoryEntryRecord, AssetHistoryEntrySnapshot, AssetObjectAccessPolicy, AssetObjectRecord, AssetObjectUpsertSnapshot, - INITIAL_ASSET_OBJECT_VERSION, + INITIAL_ASSET_OBJECT_VERSION, PublicAssetReadGrant, asset_object_matches_public_read_grant, + public_asset_read_grant_id, }; pub use errors::AssetObjectFieldError; pub use events::{AssetDomainEvent, AssetEntityBindingChangedEvent, AssetObjectConfirmedEvent}; diff --git a/server-rs/crates/spacetime-client/src/assets.rs b/server-rs/crates/spacetime-client/src/assets.rs index 004197d93..83eb6bafd 100644 --- a/server-rs/crates/spacetime-client/src/assets.rs +++ b/server-rs/crates/spacetime-client/src/assets.rs @@ -50,29 +50,85 @@ impl SpacetimeClient { &self, asset_object_id: String, ) -> Result, SpacetimeClientError> { - self.read_after_connect("get_asset_object", move |connection| { - Ok(connection - .db() - .asset_object() - .asset_object_id() - .find(&asset_object_id) - .map(map_asset_object_row)) - }) + self.call_after_connect( + "get_asset_object_by_id_and_return", + move |connection, sender| { + connection + .procedures() + .get_asset_object_by_id_and_return_then(asset_object_id, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_optional_asset_object_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } - pub async fn get_asset_object_by_object_key( + pub async fn get_asset_object_by_location( &self, - object_key: String, + input: module_assets::AssetObjectLocationInput, ) -> Result, SpacetimeClientError> { - self.read_after_connect("get_asset_object_by_object_key", move |connection| { - Ok(connection - .db() - .asset_object() - .iter() - .find(|row| row.object_key == object_key) - .map(map_asset_object_row)) - }) + let procedure_input = input.into(); + self.call_after_connect( + "get_asset_object_by_location_and_return", + move |connection, sender| { + connection + .procedures() + .get_asset_object_by_location_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_optional_asset_object_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn is_asset_object_referenced_by_public_work( + &self, + asset_object: AssetObjectRecord, + ) -> Result { + let owner_user_id = asset_object.owner_user_id.clone().unwrap_or_default(); + let grant_candidates = [ + module_assets::PublicAssetReadGrant { + owner_user_id: owner_user_id.clone(), + asset_object_id: Some(asset_object.asset_object_id.clone()), + object_key: None, + }, + module_assets::PublicAssetReadGrant { + owner_user_id, + asset_object_id: None, + object_key: Some(asset_object.object_key.clone()), + }, + ]; + self.read_after_connect( + "is_asset_object_referenced_by_public_work", + move |connection| { + let grants = connection.db().public_work_asset_read_grant(); + Ok(grant_candidates.iter().any(|candidate| { + let Some(grant_id) = module_assets::public_asset_read_grant_id(candidate) + else { + return false; + }; + grants.grant_id().find(&grant_id).is_some_and(|row| { + module_assets::asset_object_matches_public_read_grant( + &asset_object, + &module_assets::PublicAssetReadGrant { + owner_user_id: row.owner_user_id, + asset_object_id: row.asset_object_id, + object_key: row.object_key, + }, + ) + }) + })) + }, + ) .await } diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 2ae6f57ca..68f998861 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -383,6 +383,36 @@ pub enum SpacetimeClientError { const DEFAULT_PROCEDURE_TIMEOUT: Duration = Duration::from_secs(30); const PUBLIC_WORK_PLAY_DAY_MICROS: i64 = 86_400_000_000; const PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS: i64 = 7; +const REQUIRED_CACHED_READ_MODEL_QUERIES: [&str; 13] = [ + "SELECT * FROM public_work_gallery_entry", + "SELECT * FROM public_work_detail_entry", + "SELECT * FROM public_work_asset_read_grant", + "SELECT * FROM bark_battle_gallery_view", + "SELECT * FROM puzzle_gallery_card_view", + "SELECT * FROM puzzle_clear_gallery_card_view", + "SELECT * FROM jump_hop_gallery_card_view", + "SELECT * FROM wooden_fish_gallery_card_view", + "SELECT * FROM custom_world_gallery_entry", + "SELECT * FROM match_3_d_gallery_view", + "SELECT * FROM square_hole_gallery_view", + "SELECT * FROM visual_novel_gallery_view", + "SELECT * FROM big_fish_gallery_view", +]; +const OPTIONAL_CACHED_READ_MODEL_QUERIES: [&str; 13] = [ + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle-clear'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'jump-hop'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'wooden-fish'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'custom-world'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'match3d'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'square-hole'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'bark-battle'", + "SELECT * FROM creation_entry_config", + "SELECT * FROM creation_entry_type_config", + "SELECT * FROM user_account", +]; type ProcedureResultSender = Arc>>>>; @@ -882,20 +912,7 @@ impl SpacetimeClient { operation_timeout: Duration, ) -> Result, SpacetimeStageError> { let mut subscriptions = Vec::new(); - for query in [ - "SELECT * FROM public_work_gallery_entry", - "SELECT * FROM public_work_detail_entry", - "SELECT * FROM bark_battle_gallery_view", - "SELECT * FROM puzzle_gallery_card_view", - "SELECT * FROM puzzle_clear_gallery_card_view", - "SELECT * FROM jump_hop_gallery_card_view", - "SELECT * FROM wooden_fish_gallery_card_view", - "SELECT * FROM custom_world_gallery_entry", - "SELECT * FROM match_3_d_gallery_view", - "SELECT * FROM square_hole_gallery_view", - "SELECT * FROM visual_novel_gallery_view", - "SELECT * FROM big_fish_gallery_view", - ] { + for query in REQUIRED_CACHED_READ_MODEL_QUERIES { let subscription = self .subscribe_cached_read_model_query( connection, @@ -908,22 +925,7 @@ impl SpacetimeClient { subscriptions.push(subscription); } - for query in [ - "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'", - "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle-clear'", - "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'jump-hop'", - "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'wooden-fish'", - "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'custom-world'", - "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'match3d'", - "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'square-hole'", - "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'", - "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'", - "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'bark-battle'", - "SELECT * FROM creation_entry_config", - "SELECT * FROM creation_entry_type_config", - "SELECT * FROM user_account", - "SELECT * FROM asset_object", - ] { + for query in OPTIONAL_CACHED_READ_MODEL_QUERIES { if let Ok(subscription) = self .subscribe_cached_read_model_query( connection, @@ -1202,6 +1204,16 @@ mod tests { )); } + #[test] + fn required_read_model_subscriptions_include_public_asset_grants() { + assert!( + REQUIRED_CACHED_READ_MODEL_QUERIES + .contains(&"SELECT * FROM public_work_asset_read_grant") + ); + assert!(!REQUIRED_CACHED_READ_MODEL_QUERIES.contains(&"SELECT * FROM asset_object")); + assert!(!OPTIONAL_CACHED_READ_MODEL_QUERIES.contains(&"SELECT * FROM asset_object")); + } + fn test_client(pool_size: u32, procedure_timeout: Duration) -> SpacetimeClient { SpacetimeClient::new(SpacetimeClientConfig { // 指向本机不可达端口:测试只验证连接池行为,不需要真实 SpacetimeDB。 diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index c29d71cde..492ccfe6b 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -191,7 +191,8 @@ pub use self::wooden_fish::{ pub(crate) use self::ai::map_ai_task_procedure_result; pub(crate) use self::assets::{ - map_asset_object_row, map_entity_binding_procedure_result, map_procedure_result, + map_entity_binding_procedure_result, map_optional_asset_object_procedure_result, + map_procedure_result, }; pub(crate) use self::auth::{ map_auth_store_projection_procedure_result, map_auth_store_projection_sync_procedure_result, diff --git a/server-rs/crates/spacetime-client/src/mapper/assets.rs b/server-rs/crates/spacetime-client/src/mapper/assets.rs index 8c46ab82c..551df82d3 100644 --- a/server-rs/crates/spacetime-client/src/mapper/assets.rs +++ b/server-rs/crates/spacetime-client/src/mapper/assets.rs @@ -46,6 +46,15 @@ impl From for AssetHistoryListInput { } } +impl From for AssetObjectLocationInput { + fn from(input: module_assets::AssetObjectLocationInput) -> Self { + Self { + bucket: input.bucket, + object_key: input.object_key, + } + } +} + pub(crate) fn map_procedure_result( result: AssetObjectProcedureResult, ) -> Result { @@ -60,6 +69,42 @@ pub(crate) fn map_procedure_result( Ok(build_asset_object_record(map_snapshot(snapshot))) } +pub(crate) fn map_optional_asset_object_procedure_result( + result: AssetObjectProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result + .record + .map(map_snapshot) + .map(build_asset_object_record)) +} + +#[cfg(test)] +mod asset_object_procedure_result_tests { + use super::*; + + #[test] + fn optional_asset_lookup_distinguishes_missing_record_from_failure() { + let missing = map_optional_asset_object_procedure_result(AssetObjectProcedureResult { + ok: true, + record: None, + error_message: None, + }) + .expect("authoritative missing record should be a successful lookup"); + assert!(missing.is_none()); + + let failed = map_optional_asset_object_procedure_result(AssetObjectProcedureResult { + ok: false, + record: None, + error_message: Some("runtime identity 不匹配".to_string()), + }); + assert!(failed.is_err()); + } +} + pub(crate) fn map_entity_binding_procedure_result( result: AssetEntityBindingProcedureResult, ) -> Result { @@ -115,26 +160,6 @@ pub(crate) fn map_snapshot( } } -pub(crate) fn map_asset_object_row(row: AssetObject) -> AssetObjectRecord { - build_asset_object_record(module_assets::AssetObjectUpsertSnapshot { - asset_object_id: row.asset_object_id, - bucket: row.bucket, - object_key: row.object_key, - access_policy: map_access_policy_back(row.access_policy), - content_type: row.content_type, - content_length: row.content_length, - content_hash: row.content_hash, - version: row.version, - source_job_id: row.source_job_id, - owner_user_id: row.owner_user_id, - profile_id: row.profile_id, - entity_id: row.entity_id, - asset_kind: row.asset_kind, - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - }) -} - pub(crate) fn map_access_policy( value: AssetObjectAccessPolicy, ) -> crate::module_bindings::AssetObjectAccessPolicy { diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index c0db8c832..4d208bd54 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -94,6 +94,7 @@ pub mod asset_history_entry_snapshot_type; pub mod asset_history_list_input_type; pub mod asset_history_list_result_type; pub mod asset_object_access_policy_type; +pub mod asset_object_location_input_type; pub mod asset_object_procedure_result_type; pub mod asset_object_table; pub mod asset_object_type; @@ -516,6 +517,8 @@ pub mod finish_match_3_d_time_up_procedure; pub mod finish_square_hole_time_up_procedure; pub mod finish_wooden_fish_run_procedure; pub mod generate_big_fish_asset_procedure; +pub mod get_asset_object_by_id_and_return_procedure; +pub mod get_asset_object_by_location_and_return_procedure; pub mod get_bark_battle_run_procedure; pub mod get_bark_battle_runtime_config_procedure; pub mod get_battle_state_procedure; @@ -775,6 +778,8 @@ pub mod profile_wallet_config_table; pub mod profile_wallet_config_type; pub mod profile_wallet_ledger_table; pub mod profile_wallet_ledger_type; +pub mod public_work_asset_read_grant_table; +pub mod public_work_asset_read_grant_type; pub mod public_work_detail_entry_table; pub mod public_work_detail_entry_type; pub mod public_work_gallery_entry_table; @@ -1432,6 +1437,7 @@ pub use asset_history_entry_snapshot_type::AssetHistoryEntrySnapshot; pub use asset_history_list_input_type::AssetHistoryListInput; pub use asset_history_list_result_type::AssetHistoryListResult; pub use asset_object_access_policy_type::AssetObjectAccessPolicy; +pub use asset_object_location_input_type::AssetObjectLocationInput; pub use asset_object_procedure_result_type::AssetObjectProcedureResult; pub use asset_object_table::*; pub use asset_object_type::AssetObject; @@ -1854,6 +1860,8 @@ pub use finish_match_3_d_time_up_procedure::finish_match_3_d_time_up; pub use finish_square_hole_time_up_procedure::finish_square_hole_time_up; pub use finish_wooden_fish_run_procedure::finish_wooden_fish_run; pub use generate_big_fish_asset_procedure::generate_big_fish_asset; +pub use get_asset_object_by_id_and_return_procedure::get_asset_object_by_id_and_return; +pub use get_asset_object_by_location_and_return_procedure::get_asset_object_by_location_and_return; pub use get_bark_battle_run_procedure::get_bark_battle_run; pub use get_bark_battle_runtime_config_procedure::get_bark_battle_runtime_config; pub use get_battle_state_procedure::get_battle_state; @@ -2113,6 +2121,8 @@ pub use profile_wallet_config_table::*; pub use profile_wallet_config_type::ProfileWalletConfig; pub use profile_wallet_ledger_table::*; pub use profile_wallet_ledger_type::ProfileWalletLedger; +pub use public_work_asset_read_grant_table::*; +pub use public_work_asset_read_grant_type::PublicWorkAssetReadGrant; pub use public_work_detail_entry_table::*; pub use public_work_detail_entry_type::PublicWorkDetailEntry; pub use public_work_gallery_entry_table::*; @@ -3058,6 +3068,7 @@ pub struct DbUpdate { profile_task_reward_claim: __sdk::TableUpdate, profile_wallet_config: __sdk::TableUpdate, profile_wallet_ledger: __sdk::TableUpdate, + public_work_asset_read_grant: __sdk::TableUpdate, public_work_detail_entry: __sdk::TableUpdate, public_work_gallery_entry: __sdk::TableUpdate, public_work_like: __sdk::TableUpdate, @@ -3415,6 +3426,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "profile_wallet_ledger" => db_update.profile_wallet_ledger.append( profile_wallet_ledger_table::parse_table_update(table_update)?, ), + "public_work_asset_read_grant" => db_update.public_work_asset_read_grant.append( + public_work_asset_read_grant_table::parse_table_update(table_update)?, + ), "public_work_detail_entry" => db_update.public_work_detail_entry.append( public_work_detail_entry_table::parse_table_update(table_update)?, ), @@ -4258,6 +4272,12 @@ impl __sdk::DbUpdate for DbUpdate { "match_3_d_gallery_view", &self.match_3_d_gallery_view, ); + diff.public_work_asset_read_grant = cache + .apply_diff_to_table::( + "public_work_asset_read_grant", + &self.public_work_asset_read_grant, + ) + .with_updates_by_pk(|row| &row.grant_id); diff.public_work_detail_entry = cache.apply_diff_to_table::( "public_work_detail_entry", &self.public_work_detail_entry, @@ -4577,6 +4597,9 @@ impl __sdk::DbUpdate for DbUpdate { "profile_wallet_ledger" => db_update .profile_wallet_ledger .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "public_work_asset_read_grant" => db_update + .public_work_asset_read_grant + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "public_work_detail_entry" => db_update .public_work_detail_entry .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -5007,6 +5030,9 @@ impl __sdk::DbUpdate for DbUpdate { "profile_wallet_ledger" => db_update .profile_wallet_ledger .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "public_work_asset_read_grant" => db_update + .public_work_asset_read_grant + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "public_work_detail_entry" => db_update .public_work_detail_entry .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -5264,6 +5290,7 @@ pub struct AppliedDiff<'r> { profile_task_reward_claim: __sdk::TableAppliedDiff<'r, ProfileTaskRewardClaim>, profile_wallet_config: __sdk::TableAppliedDiff<'r, ProfileWalletConfig>, profile_wallet_ledger: __sdk::TableAppliedDiff<'r, ProfileWalletLedger>, + public_work_asset_read_grant: __sdk::TableAppliedDiff<'r, PublicWorkAssetReadGrant>, public_work_detail_entry: __sdk::TableAppliedDiff<'r, PublicWorkDetailEntry>, public_work_gallery_entry: __sdk::TableAppliedDiff<'r, PublicWorkGalleryEntry>, public_work_like: __sdk::TableAppliedDiff<'r, PublicWorkLike>, @@ -5765,6 +5792,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.profile_wallet_ledger, event, ); + callbacks.invoke_table_row_callbacks::( + "public_work_asset_read_grant", + &self.public_work_asset_read_grant, + event, + ); callbacks.invoke_table_row_callbacks::( "public_work_detail_entry", &self.public_work_detail_entry, @@ -6752,6 +6784,7 @@ impl __sdk::SpacetimeModule for RemoteModule { profile_task_reward_claim_table::register_table(client_cache); profile_wallet_config_table::register_table(client_cache); profile_wallet_ledger_table::register_table(client_cache); + public_work_asset_read_grant_table::register_table(client_cache); public_work_detail_entry_table::register_table(client_cache); public_work_gallery_entry_table::register_table(client_cache); public_work_like_table::register_table(client_cache); @@ -6893,6 +6926,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "profile_task_reward_claim", "profile_wallet_config", "profile_wallet_ledger", + "public_work_asset_read_grant", "public_work_detail_entry", "public_work_gallery_entry", "public_work_like", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/asset_object_location_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/asset_object_location_input_type.rs new file mode 100644 index 000000000..54319aea6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/asset_object_location_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AssetObjectLocationInput { + pub bucket: String, + pub object_key: String, +} + +impl __sdk::InModule for AssetObjectLocationInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_asset_object_by_id_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_asset_object_by_id_and_return_procedure.rs new file mode 100644 index 000000000..b1d165b00 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_asset_object_by_id_and_return_procedure.rs @@ -0,0 +1,58 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_object_procedure_result_type::AssetObjectProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetAssetObjectByIdAndReturnArgs { + pub asset_object_id: String, +} + +impl __sdk::InModule for GetAssetObjectByIdAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_asset_object_by_id_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_asset_object_by_id_and_return { + fn get_asset_object_by_id_and_return(&self, asset_object_id: String) { + self.get_asset_object_by_id_and_return_then(asset_object_id, |_, _| {}); + } + + fn get_asset_object_by_id_and_return_then( + &self, + asset_object_id: String, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_asset_object_by_id_and_return for super::RemoteProcedures { + fn get_asset_object_by_id_and_return_then( + &self, + asset_object_id: String, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, AssetObjectProcedureResult>( + "get_asset_object_by_id_and_return", + GetAssetObjectByIdAndReturnArgs { asset_object_id }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_asset_object_by_location_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_asset_object_by_location_and_return_procedure.rs new file mode 100644 index 000000000..9254cc5c9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_asset_object_by_location_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_object_location_input_type::AssetObjectLocationInput; +use super::asset_object_procedure_result_type::AssetObjectProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetAssetObjectByLocationAndReturnArgs { + pub input: AssetObjectLocationInput, +} + +impl __sdk::InModule for GetAssetObjectByLocationAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_asset_object_by_location_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_asset_object_by_location_and_return { + fn get_asset_object_by_location_and_return(&self, input: AssetObjectLocationInput) { + self.get_asset_object_by_location_and_return_then(input, |_, _| {}); + } + + fn get_asset_object_by_location_and_return_then( + &self, + input: AssetObjectLocationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_asset_object_by_location_and_return for super::RemoteProcedures { + fn get_asset_object_by_location_and_return_then( + &self, + input: AssetObjectLocationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, AssetObjectProcedureResult>( + "get_asset_object_by_location_and_return", + GetAssetObjectByLocationAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/public_work_asset_read_grant_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/public_work_asset_read_grant_table.rs new file mode 100644 index 000000000..d35245795 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/public_work_asset_read_grant_table.rs @@ -0,0 +1,166 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::public_work_asset_read_grant_type::PublicWorkAssetReadGrant; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `public_work_asset_read_grant`. +/// +/// Obtain a handle from the [`PublicWorkAssetReadGrantTableAccess::public_work_asset_read_grant`] method on [`super::RemoteTables`], +/// like `ctx.db.public_work_asset_read_grant()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.public_work_asset_read_grant().on_insert(...)`. +pub struct PublicWorkAssetReadGrantTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `public_work_asset_read_grant`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait PublicWorkAssetReadGrantTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`PublicWorkAssetReadGrantTableHandle`], which mediates access to the table `public_work_asset_read_grant`. + fn public_work_asset_read_grant(&self) -> PublicWorkAssetReadGrantTableHandle<'_>; +} + +impl PublicWorkAssetReadGrantTableAccess for super::RemoteTables { + fn public_work_asset_read_grant(&self) -> PublicWorkAssetReadGrantTableHandle<'_> { + PublicWorkAssetReadGrantTableHandle { + imp: self + .imp + .get_table::("public_work_asset_read_grant"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct PublicWorkAssetReadGrantInsertCallbackId(__sdk::CallbackId); +pub struct PublicWorkAssetReadGrantDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for PublicWorkAssetReadGrantTableHandle<'ctx> { + type Row = PublicWorkAssetReadGrant; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = PublicWorkAssetReadGrantInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PublicWorkAssetReadGrantInsertCallbackId { + PublicWorkAssetReadGrantInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PublicWorkAssetReadGrantInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = PublicWorkAssetReadGrantDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PublicWorkAssetReadGrantDeleteCallbackId { + PublicWorkAssetReadGrantDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PublicWorkAssetReadGrantDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct PublicWorkAssetReadGrantUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for PublicWorkAssetReadGrantTableHandle<'ctx> { + type UpdateCallbackId = PublicWorkAssetReadGrantUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> PublicWorkAssetReadGrantUpdateCallbackId { + PublicWorkAssetReadGrantUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: PublicWorkAssetReadGrantUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `grant_id` unique index on the table `public_work_asset_read_grant`, +/// which allows point queries on the field of the same name +/// via the [`PublicWorkAssetReadGrantGrantIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.public_work_asset_read_grant().grant_id().find(...)`. +pub struct PublicWorkAssetReadGrantGrantIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> PublicWorkAssetReadGrantTableHandle<'ctx> { + /// Get a handle on the `grant_id` unique index on the table `public_work_asset_read_grant`. + pub fn grant_id(&self) -> PublicWorkAssetReadGrantGrantIdUnique<'ctx> { + PublicWorkAssetReadGrantGrantIdUnique { + imp: self.imp.get_unique_constraint::("grant_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> PublicWorkAssetReadGrantGrantIdUnique<'ctx> { + /// Find the subscribed row whose `grant_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("public_work_asset_read_grant"); + _table.add_unique_constraint::("grant_id", |row| &row.grant_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `PublicWorkAssetReadGrant`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait public_work_asset_read_grantQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `PublicWorkAssetReadGrant`. + fn public_work_asset_read_grant( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl public_work_asset_read_grantQueryTableAccess for __sdk::QueryTableAccessor { + fn public_work_asset_read_grant( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("public_work_asset_read_grant") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/public_work_asset_read_grant_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/public_work_asset_read_grant_type.rs new file mode 100644 index 000000000..73710250f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/public_work_asset_read_grant_type.rs @@ -0,0 +1,46 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PublicWorkAssetReadGrant { + pub grant_id: String, + pub source_type: String, + pub profile_id: String, + pub owner_user_id: String, + pub asset_object_id: Option, + pub object_key: Option, +} + +impl __sdk::InModule for PublicWorkAssetReadGrant { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `PublicWorkAssetReadGrant`. +/// +/// Provides typed access to columns for query building. +pub struct PublicWorkAssetReadGrantCols { + pub grant_id: __sdk::__query_builder::Col, + pub source_type: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub asset_object_id: __sdk::__query_builder::Col>, + pub object_key: __sdk::__query_builder::Col>, +} + +impl __sdk::__query_builder::HasCols for PublicWorkAssetReadGrant { + type Cols = PublicWorkAssetReadGrantCols; + fn cols(table_name: &'static str) -> Self::Cols { + PublicWorkAssetReadGrantCols { + grant_id: __sdk::__query_builder::Col::new(table_name, "grant_id"), + source_type: __sdk::__query_builder::Col::new(table_name, "source_type"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + asset_object_id: __sdk::__query_builder::Col::new(table_name, "asset_object_id"), + object_key: __sdk::__query_builder::Col::new(table_name, "object_key"), + } + } +} diff --git a/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs b/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs index 825e4059a..39f085e82 100644 --- a/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs +++ b/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs @@ -96,6 +96,66 @@ pub fn confirm_asset_object_and_return( } } +// ACL 等安全判断必须读取事务内真相,不能把订阅 cache miss 当成对象不存在。 +#[spacetimedb::procedure] +pub fn get_asset_object_by_location_and_return( + ctx: &mut ProcedureContext, + input: AssetObjectLocationInput, +) -> AssetObjectProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + find_asset_object_by_location(tx, &input) + }) { + Ok(record) => AssetObjectProcedureResult { + ok: true, + record, + error_message: None, + }, + Err(message) => AssetObjectProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn get_asset_object_by_id_and_return( + ctx: &mut ProcedureContext, + asset_object_id: String, +) -> AssetObjectProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + let asset_object_id = asset_object_id.trim(); + if asset_object_id.is_empty() { + return Err("asset_object_id 不能为空".to_string()); + } + Ok(tx + .db + .asset_object() + .asset_object_id() + .find(&asset_object_id.to_string()) + .map(asset_object_snapshot)) + }) { + Ok(record) => AssetObjectProcedureResult { + ok: true, + record, + error_message: None, + }, + Err(message) => AssetObjectProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + // 历史素材只返回编辑器复用所需的脱敏字段,asset_object 本表继续保持 private。 #[spacetimedb::procedure] pub fn list_asset_history_and_return( @@ -197,6 +257,54 @@ pub(crate) fn upsert_asset_object( Ok(snapshot) } +fn find_asset_object_by_location( + ctx: &ReducerContext, + input: &AssetObjectLocationInput, +) -> Result, String> { + let bucket = input.bucket.trim(); + let object_key = input.object_key.trim().trim_start_matches('/'); + if bucket.is_empty() || object_key.is_empty() { + return Err("bucket 与 object_key 不能为空".to_string()); + } + + let mut matches = ctx + .db + .asset_object() + .by_bucket_object_key() + .filter((bucket, object_key)); + resolve_unique_asset_object_location(&mut matches) +} + +fn resolve_unique_asset_object_location( + matches: &mut impl Iterator, +) -> Result, String> { + let record = matches.next(); + if matches.next().is_some() { + return Err("asset_object 的 bucket + object_key 存在重复记录".to_string()); + } + Ok(record.map(asset_object_snapshot)) +} + +fn asset_object_snapshot(row: AssetObject) -> AssetObjectUpsertSnapshot { + AssetObjectUpsertSnapshot { + asset_object_id: row.asset_object_id, + bucket: row.bucket, + object_key: row.object_key, + access_policy: row.access_policy, + content_type: row.content_type, + content_length: row.content_length, + content_hash: row.content_hash, + version: row.version, + source_job_id: row.source_job_id, + owner_user_id: row.owner_user_id, + profile_id: row.profile_id, + entity_id: row.entity_id, + asset_kind: row.asset_kind, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + fn require_matching_asset_object_owner( existing_owner_user_id: Option<&str>, next_owner_user_id: Option<&str>, @@ -333,6 +441,46 @@ pub(crate) fn emit_asset_object_confirmed_event( mod tests { use super::*; + fn asset_object(asset_object_id: &str) -> AssetObject { + AssetObject { + asset_object_id: asset_object_id.to_string(), + bucket: "genarrative-assets".to_string(), + object_key: "generated-puzzle-assets/published.png".to_string(), + access_policy: AssetObjectAccessPolicy::Private, + content_type: Some("image/png".to_string()), + content_length: 1, + content_hash: None, + version: 1, + source_job_id: None, + owner_user_id: Some("owner-a".to_string()), + profile_id: Some("profile-a".to_string()), + entity_id: None, + asset_kind: "puzzle_cover_image".to_string(), + created_at: Timestamp::from_micros_since_unix_epoch(1), + updated_at: Timestamp::from_micros_since_unix_epoch(1), + } + } + + #[test] + fn authoritative_location_lookup_fails_closed_on_duplicate_rows() { + let record = asset_object("assetobj-first"); + let mut one = vec![record].into_iter(); + assert_eq!( + resolve_unique_asset_object_location(&mut one) + .expect("one exact row should resolve") + .expect("one exact row should exist") + .asset_object_id, + "assetobj-first" + ); + + let mut duplicates = vec![ + asset_object("assetobj-first"), + asset_object("assetobj-second"), + ] + .into_iter(); + assert!(resolve_unique_asset_object_location(&mut duplicates).is_err()); + } + #[test] fn registered_asset_object_owner_is_immutable() { assert!(require_matching_asset_object_owner(Some("owner-a"), Some("owner-a")).is_ok()); diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index a5f26d6fd..9f596ffcc 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -39,6 +39,7 @@ mod gameplay; mod jump_hop; mod match3d; mod migration; +mod public_asset_access; mod public_work; mod puzzle; mod puzzle_clear; @@ -63,6 +64,7 @@ pub use gameplay::*; pub use jump_hop::*; pub use match3d::*; pub use migration::*; +pub use public_asset_access::*; pub use public_work::*; pub use puzzle_clear::*; pub use runtime::*; diff --git a/server-rs/crates/spacetime-module/src/public_asset_access.rs b/server-rs/crates/spacetime-module/src/public_asset_access.rs new file mode 100644 index 000000000..895201aee --- /dev/null +++ b/server-rs/crates/spacetime-module/src/public_asset_access.rs @@ -0,0 +1,606 @@ +use crate::puzzle::puzzle_work_profile__view; +use crate::*; +use module_puzzle::PuzzlePublicationStatus; +use spacetimedb::AnonymousViewContext; +use std::collections::BTreeMap; + +const SOURCE_TYPE_PUZZLE: &str = "puzzle"; +const SOURCE_TYPE_PUZZLE_CLEAR: &str = "puzzle-clear"; +const SOURCE_TYPE_CUSTOM_WORLD: &str = "custom-world"; +const SOURCE_TYPE_JUMP_HOP: &str = "jump-hop"; +const SOURCE_TYPE_WOODEN_FISH: &str = "wooden-fish"; +const SOURCE_TYPE_MATCH3D: &str = "match3d"; +const SOURCE_TYPE_SQUARE_HOLE: &str = "square-hole"; +const SOURCE_TYPE_VISUAL_NOVEL: &str = "visual-novel"; +const SOURCE_TYPE_BIG_FISH: &str = "big-fish"; +const SOURCE_TYPE_BARK_BATTLE: &str = "bark-battle"; + +/// 已发布且可见作品派生出的精确资产读取授权。 +/// +/// 资产对象本身继续保持 private;view 只列出公开快照实际使用的对象 ID / key。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct PublicWorkAssetReadGrant { + pub grant_id: String, + pub source_type: String, + pub profile_id: String, + pub owner_user_id: String, + pub asset_object_id: Option, + pub object_key: Option, +} + +struct PublicWorkAssetGrantScope<'a> { + source_type: &'a str, + profile_id: &'a str, + owner_user_id: &'a str, +} + +/// 跨玩法公开资产授权投影。 +/// +/// 历史作品随 view 首次计算自动补齐;隐藏、删除或取消发布后对应授权自动消失。 +#[spacetimedb::view(accessor = public_work_asset_read_grant, public, primary_key = grant_id)] +pub fn public_work_asset_read_grant(ctx: &AnonymousViewContext) -> Vec { + let mut grants = BTreeMap::new(); + + for row in ctx + .db + .puzzle_work_profile() + .by_puzzle_work_publication_status() + .filter(PuzzlePublicationStatus::Published) + .filter(|row| row.visible) + { + let scope = PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_PUZZLE, + profile_id: &row.profile_id, + owner_user_id: &row.owner_user_id, + }; + insert_src_grant(&mut grants, &scope, row.cover_image_src.as_deref()); + collect_json_grants(&mut grants, &scope, &row.levels_json); + } + + for row in ctx + .db + .puzzle_clear_work_profile() + .by_puzzle_clear_work_publication_status() + .filter(PUZZLE_CLEAR_PUBLICATION_PUBLISHED) + .filter(|row| row.visible) + { + let scope = PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_PUZZLE_CLEAR, + profile_id: &row.profile_id, + owner_user_id: &row.owner_user_id, + }; + insert_src_grant(&mut grants, &scope, Some(&row.cover_image_src)); + insert_src_grant(&mut grants, &scope, Some(&row.card_back_image_src)); + for raw in [ + &row.board_background_asset_json, + &row.atlas_asset_json, + &row.card_assets_json, + ] { + collect_json_grants(&mut grants, &scope, raw); + } + } + + for row in ctx + .db + .custom_world_profile() + .by_custom_world_profile_publication_status() + .filter(CustomWorldPublicationStatus::Published) + .filter(|row| row.visible && row.deleted_at.is_none()) + { + let scope = PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_CUSTOM_WORLD, + profile_id: &row.profile_id, + owner_user_id: &row.owner_user_id, + }; + insert_src_grant(&mut grants, &scope, row.cover_image_src.as_deref()); + collect_custom_world_json_grants(&mut grants, &scope, &row.profile_payload_json); + } + + for row in ctx + .db + .jump_hop_work_profile() + .by_jump_hop_work_publication_status() + .filter(JUMP_HOP_PUBLICATION_PUBLISHED) + .filter(|row| row.visible) + { + let scope = PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_JUMP_HOP, + profile_id: &row.profile_id, + owner_user_id: &row.owner_user_id, + }; + insert_src_grant(&mut grants, &scope, Some(&row.cover_image_src)); + insert_src_grant(&mut grants, &scope, Some(&row.cover_composite)); + for raw in [ + Some(row.character_asset_json.as_str()), + Some(row.tile_atlas_asset_json.as_str()), + Some(row.tile_assets_json.as_str()), + row.back_button_asset_json.as_deref(), + ] + .into_iter() + .flatten() + { + collect_json_grants(&mut grants, &scope, raw); + } + } + + for row in ctx + .db + .wooden_fish_work_profile() + .by_wooden_fish_work_publication_status() + .filter(WOODEN_FISH_PUBLICATION_PUBLISHED) + .filter(|row| row.visible) + { + let scope = PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_WOODEN_FISH, + profile_id: &row.profile_id, + owner_user_id: &row.owner_user_id, + }; + insert_src_grant(&mut grants, &scope, Some(&row.cover_image_src)); + for raw in [ + Some(row.hit_object_asset_json.as_str()), + Some(row.hit_sound_asset_json.as_str()), + row.background_asset_json.as_deref(), + row.back_button_asset_json.as_deref(), + ] + .into_iter() + .flatten() + { + collect_json_grants(&mut grants, &scope, raw); + } + } + + for row in ctx + .db + .match_3_d_work_profile() + .by_match3d_work_publication_status() + .filter(MATCH3D_PUBLICATION_PUBLISHED) + .filter(|row| row.visible) + { + let scope = PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_MATCH3D, + profile_id: &row.profile_id, + owner_user_id: &row.owner_user_id, + }; + insert_src_grant(&mut grants, &scope, Some(&row.cover_image_src)); + collect_json_grants(&mut grants, &scope, &row.config_json); + if let Some(raw) = row.generated_item_assets_json.as_deref() { + collect_json_grants(&mut grants, &scope, raw); + } + } + + for row in ctx + .db + .square_hole_work_profile() + .by_square_hole_work_publication_status() + .filter(SQUARE_HOLE_PUBLICATION_PUBLISHED) + .filter(|row| row.visible) + { + let scope = PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_SQUARE_HOLE, + profile_id: &row.profile_id, + owner_user_id: &row.owner_user_id, + }; + insert_src_grant(&mut grants, &scope, Some(&row.cover_image_src)); + collect_json_grants(&mut grants, &scope, &row.config_json); + } + + for row in ctx + .db + .visual_novel_work_profile() + .by_visual_novel_work_publication_status() + .filter(VISUAL_NOVEL_PUBLICATION_PUBLISHED) + .filter(|row| row.visible) + { + let scope = PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_VISUAL_NOVEL, + profile_id: &row.profile_id, + owner_user_id: &row.owner_user_id, + }; + insert_src_grant(&mut grants, &scope, Some(&row.cover_image_src)); + collect_json_grants(&mut grants, &scope, &row.draft_json); + } + + for row in ctx + .db + .big_fish_creation_session() + .by_big_fish_session_stage() + .filter(BigFishCreationStage::Published) + .filter(|row| row.visible) + { + let scope = PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_BIG_FISH, + profile_id: &row.session_id, + owner_user_id: &row.owner_user_id, + }; + for slot in ctx + .db + .big_fish_asset_slot() + .by_big_fish_asset_session_id() + .filter(&row.session_id) + { + insert_src_grant(&mut grants, &scope, slot.asset_url.as_deref()); + } + } + + for row in ctx + .db + .bark_battle_published_config() + .by_bark_battle_published_owner_user_id() + .filter(""..) + .filter(|row| row.visible) + { + let scope = PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_BARK_BATTLE, + profile_id: &row.work_id, + owner_user_id: &row.owner_user_id, + }; + collect_json_grants(&mut grants, &scope, &row.published_snapshot_json); + } + + grants.into_values().collect() +} + +fn collect_json_grants( + grants: &mut BTreeMap, + scope: &PublicWorkAssetGrantScope<'_>, + raw: &str, +) { + let Ok(value) = serde_json::from_str::(raw) else { + return; + }; + collect_json_value_grants(grants, scope, &value); +} + +fn collect_custom_world_json_grants( + grants: &mut BTreeMap, + scope: &PublicWorkAssetGrantScope<'_>, + raw: &str, +) { + let Ok(JsonValue::Object(profile)) = serde_json::from_str::(raw) else { + return; + }; + + insert_src_grant( + grants, + scope, + profile.get("coverImageSrc").and_then(JsonValue::as_str), + ); + for key in [ + "cover", + "camp", + "playableNpcs", + "storyNpcs", + "landmarks", + "chapters", + "sceneChapters", + "sceneChapterBlueprints", + "openingCg", + ] { + if let Some(value) = profile.get(key) { + collect_json_value_grants(grants, scope, value); + } + } +} + +fn collect_json_value_grants( + grants: &mut BTreeMap, + scope: &PublicWorkAssetGrantScope<'_>, + value: &JsonValue, +) { + match value { + JsonValue::Array(items) => { + for item in items { + collect_json_value_grants(grants, scope, item); + } + } + JsonValue::Object(fields) => { + for (key, value) in fields { + let normalized_key = normalize_json_key(key); + if should_skip_public_asset_field(&normalized_key) { + continue; + } + if normalized_key.ends_with("assetobjectid") { + if let Some(value) = value.as_str() { + insert_asset_object_id_grant(grants, scope, Some(value)); + } + continue; + } + if normalized_key.ends_with("objectkey") { + if let Some(value) = value.as_str() { + insert_object_key_grant(grants, scope, Some(value)); + } + continue; + } + if is_public_asset_src_field(&normalized_key) { + if let Some(value) = value.as_str() { + insert_src_grant(grants, scope, Some(value)); + } + continue; + } + collect_json_value_grants(grants, scope, value); + } + } + _ => {} + } +} + +fn normalize_json_key(key: &str) -> String { + key.chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn should_skip_public_asset_field(normalized_key: &str) -> bool { + normalized_key.contains("reference") + || normalized_key == "candidates" + || normalized_key == "generationinputs" + || normalized_key == "sourceassetids" + || normalized_key.starts_with("sourceimage") +} + +fn is_public_asset_src_field(normalized_key: &str) -> bool { + normalized_key.ends_with("imagesrc") + || normalized_key.ends_with("audiosrc") + || normalized_key.ends_with("videosrc") + || normalized_key == "musicsrc" + || normalized_key == "ambientsoundsrc" + || normalized_key == "modelsrc" + || normalized_key.ends_with("asseturl") +} + +fn insert_asset_object_id_grant( + grants: &mut BTreeMap, + scope: &PublicWorkAssetGrantScope<'_>, + value: Option<&str>, +) { + let Some(asset_object_id) = normalize_non_empty(value) else { + return; + }; + insert_grant(grants, scope, Some(asset_object_id), None); +} + +fn insert_object_key_grant( + grants: &mut BTreeMap, + scope: &PublicWorkAssetGrantScope<'_>, + value: Option<&str>, +) { + let Some(object_key) = normalize_explicit_object_key(value) else { + return; + }; + insert_grant(grants, scope, None, Some(object_key)); +} + +fn insert_src_grant( + grants: &mut BTreeMap, + scope: &PublicWorkAssetGrantScope<'_>, + value: Option<&str>, +) { + let Some(object_key) = normalize_generated_asset_src(value) else { + return; + }; + insert_grant(grants, scope, None, Some(object_key)); +} + +fn insert_grant( + grants: &mut BTreeMap, + scope: &PublicWorkAssetGrantScope<'_>, + asset_object_id: Option, + object_key: Option, +) { + let domain_grant = module_assets::PublicAssetReadGrant { + owner_user_id: scope.owner_user_id.to_string(), + asset_object_id: asset_object_id.clone(), + object_key: object_key.clone(), + }; + let Some(grant_id) = module_assets::public_asset_read_grant_id(&domain_grant) else { + return; + }; + grants + .entry(grant_id.clone()) + .or_insert_with(|| PublicWorkAssetReadGrant { + grant_id, + source_type: scope.source_type.to_string(), + profile_id: scope.profile_id.to_string(), + owner_user_id: scope.owner_user_id.to_string(), + asset_object_id, + object_key, + }); +} + +fn normalize_non_empty(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn normalize_explicit_object_key(value: Option<&str>) -> Option { + let value = value?.trim().trim_start_matches('/'); + if value.is_empty() + || value.starts_with("data:") + || value.starts_with("blob:") + || value.starts_with("builtin:") + || value.starts_with("http://") + || value.starts_with("https://") + { + return None; + } + Some(value.to_string()) +} + +fn normalize_generated_asset_src(value: Option<&str>) -> Option { + let value = value?.trim(); + let object_key = if value.starts_with("http://") || value.starts_with("https://") { + let generated_path_index = value.find("/generated-")?; + &value[generated_path_index + 1..] + } else { + value.trim_start_matches('/') + }; + object_key + .starts_with("generated-") + .then(|| object_key.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn collect(raw: &str) -> Vec { + let mut grants = BTreeMap::new(); + collect_json_grants( + &mut grants, + &PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_PUZZLE, + profile_id: "puzzle-profile-001", + owner_user_id: "user-owner", + }, + raw, + ); + grants.into_values().collect() + } + + fn collect_custom_world(raw: &str) -> Vec { + let mut grants = BTreeMap::new(); + collect_custom_world_json_grants( + &mut grants, + &PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_CUSTOM_WORLD, + profile_id: "custom-world-profile-001", + owner_user_id: "user-owner", + }, + raw, + ); + grants.into_values().collect() + } + + #[test] + fn published_asset_collector_excludes_references_candidates_and_generation_inputs() { + let grants = collect( + r#"{ + "cover": { + "assetObjectId": "assetobj_cover", + "imageObjectKey": "generated-puzzle-assets/formal-cover.png" + }, + "backgroundMusic": { + "audioSrc": "/generated-audio/formal.mp3" + }, + "sceneAudio": { + "musicSrc": "/generated-audio/scene-music.mp3", + "ambientSoundSrc": "/generated-audio/scene-ambient.mp3" + }, + "modelSrc": "/generated-match3d-assets/formal-model.glb", + "pictureReference": { + "imageObjectKey": "generated-puzzle-assets/private-reference.png", + "musicSrc": "/generated-audio/private-reference.mp3" + }, + "candidates": [ + { "imageSrc": "/generated-puzzle-assets/unselected.png" } + ], + "generationInputs": { + "imageSrc": "/generated-puzzle-assets/private-input.png" + } + }"#, + ); + + assert!( + grants + .iter() + .any(|grant| { grant.asset_object_id.as_deref() == Some("assetobj_cover") }) + ); + assert!(grants.iter().any(|grant| { + grant.object_key.as_deref() == Some("generated-puzzle-assets/formal-cover.png") + })); + assert!( + grants + .iter() + .any(|grant| { grant.object_key.as_deref() == Some("generated-audio/formal.mp3") }) + ); + assert!(grants.iter().any(|grant| { + grant.object_key.as_deref() == Some("generated-audio/scene-music.mp3") + })); + assert!(grants.iter().any(|grant| { + grant.object_key.as_deref() == Some("generated-audio/scene-ambient.mp3") + })); + assert!(grants.iter().any(|grant| { + grant.object_key.as_deref() == Some("generated-match3d-assets/formal-model.glb") + })); + assert!(!grants.iter().any(|grant| { + grant + .object_key + .as_deref() + .is_some_and(|value| value.contains("private") || value.contains("unselected")) + })); + } + + #[test] + fn published_asset_collector_normalizes_generated_oss_urls() { + let grants = collect( + r#"{ + "imageSrc": "https://bucket.oss-cn.test.aliyuncs.com/generated-puzzle-assets/formal.png" + }"#, + ); + + assert_eq!( + grants[0].object_key.as_deref(), + Some("generated-puzzle-assets/formal.png") + ); + } + + #[test] + fn custom_world_collector_only_reads_known_published_structures() { + let grants = collect_custom_world( + r#"{ + "cover": { "imageSrc": "/generated-custom-world/formal-cover.png" }, + "playableNpcs": [ + { "imageSrc": "/generated-custom-world/formal-role.png" } + ], + "sceneChapterBlueprints": [ + { "acts": [{ "backgroundImageSrc": "/generated-custom-world/formal-scene.png" }] } + ], + "preview": { + "imageSrc": "/generated-custom-world/private-preview.png", + "objectKey": "generated-custom-world/private-preview-object.png" + }, + "editorState": { + "assetObjectId": "assetobj_private_editor_state" + } + }"#, + ); + + assert_eq!(grants.len(), 3); + assert!(grants.iter().all(|grant| { + grant + .object_key + .as_deref() + .is_some_and(|value| value.contains("formal")) + })); + } + + #[test] + fn published_asset_collector_deduplicates_reused_asset_per_owner() { + let mut grants = BTreeMap::new(); + let first_scope = PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_PUZZLE, + profile_id: "puzzle-profile-001", + owner_user_id: "user-owner", + }; + let second_scope = PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_PUZZLE, + profile_id: "puzzle-profile-002", + owner_user_id: "user-owner", + }; + insert_src_grant( + &mut grants, + &first_scope, + Some("/generated-puzzle-assets/reused.png"), + ); + insert_src_grant( + &mut grants, + &second_scope, + Some("/generated-puzzle-assets/reused.png"), + ); + + assert_eq!(grants.len(), 1); + } +}