fix(media): apply the Searcher to every media_libraries query - #1102
Conversation
The media Builder's Searcher hook only filtered the listing funnel (mediaLibraryFilter). Every query addressing rows by id or parent id bypassed it, so an app using the Searcher for data isolation still leaked and mutated rows outside the configured scope: - chooseFile attached any media id to a field - the cropper loaded and overwrote any image by id - the Move-to dialog folder tree and its expansion listed all folders, and the move target was not validated - breadcrumbs resolved arbitrary parent ids, folder tiles counted foreign children - rename / update-description read rows by id - doDelete deleted the raw request ids and reparented children of deleted folders regardless of scope - the model's generic presets CRUD event funcs (Edit, Update, DoDelete, DetailingDrawer) address rows by primary key through the DataOperator Route all of them through a new Builder.scopedDB, and validate upload / new-folder / move targets with Builder.folderIsVisible. Without a Searcher configured every guard is inert, so existing apps keep their current behavior; the only deliberate changes there are that event funcs no longer proceed with a zero-value MediaLibrary when the record is missing (wrapFirst previously let rename / update-description Save a zero-id object, inserting a garbage row). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Overall Grade |
Security Reliability Complexity Hygiene |
Code Review Summary
| Analyzer | Status | Updated (UTC) | Details |
|---|---|---|---|
| Go | Jul 29, 2026 9:53a.m. | Review ↗ |
Important
AI Review is run only on demand for your team. We're only showing results of static analysis review right now. To trigger AI Review, comment @deepsourcebot review on this thread.
Codecov Report❌ Patch coverage is
... and 1 file with indirect coverage changes 🚀 New features to boost your workflow:
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…earcher Both doc comments read as unconditional visibility guarantees. Say what they actually guarantee: parity with what the searcher gives the listing, and that a CurrentUserID-only builder keeps its listing-only filter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
zhangshanwen
left a comment
There was a problem hiding this comment.
审查了完整 diff,在分支上跑通了 go build ./...、go vet ./media/... 和 go test ./media(14 个测试全过),另外写了两个临时探针验证了下面第 2、3 条,均已证实。
总体判断: 问题定位准确,用 scopedDB 把查询集中收口的方向是对的。建议合并前先处理三点 —— 一处是与本次修复无关的公开行为变更(mm.Detailing()),另两处是新代码在 join 型 searcher 下会静默做错事。细节见行内评论。
行内评论覆盖不到的两点补充在这里:
MediaBoxSetterFunc 没有守卫 — media/media_box.go:100
PR 描述把 chooseFile 列为「attach any media id to a field」,但真正写入字段的是这个 setter,它只把表单提交的 JSON 扫进来,完全不校验 id。chooseFile 的修复仍然有价值(它才是泄漏对方行 URL/文件名/描述的地方),但对已经知道 URL 的人来说,attach 本身依然做得到。可以不改,但建议在描述里说明,避免把保证说得过强。
loadImageCropper 新加的提前 return r, nil 没有测试 — media/cropper.go:52
TestCropImageScoped 只覆盖了 cropImage;loadImageCropper 这条早返回是本 PR 明确列出的行为变更之一,值得补一个用例。
另外想说:已有测试质量不错,TestDoDeleteScopedMixedBatch 和 TestMoveToFolderScoped 里「移回根目录」那个用例,正是最该被钉住的两个点。
| return in(obj, id, ctx) | ||
| } | ||
| }) | ||
| mm.Detailing().WrapFetchFunc(func(in presets.FetchFunc) presets.FetchFunc { |
There was a problem hiding this comment.
[建议合并前修复] mm.Detailing() 不是 getter,这里会静默给 media-library 模型打开 detailing。
presets/detailing.go:60 里会设 mb.hasDetailing = true。我用探针验证过:b.GetPresetsModelBuilder().Info().HasDetailing() 在 main 上是 false,在这个分支上变成 true。对所有安装 media 的应用的影响:
presets/presets.go:1213会新挂一条路由GET {prefix}/media-libraries/{id}(还会多打一行mounted url日志)Detailing()不传参时会把mb.editing.FieldNames()复制进 detailing 的字段列表(presets/detailing.go:56-57)presets/listing_compo.go:403-409会把行点击行为从actions.Edit切成 detail 链接/抽屉
这些都不是本 PR 想要的效果,属于对外可见的行为/路由变更。建议在 presets 侧加一个不翻转 hasDetailing 的访问器,或者只在应用确实启用了 detailing 时才装这个守卫。
There was a problem hiding this comment.
已在 a9524eb 修复。在 presets 加了 ModelBuilder.GetDetailing(),只返回 detailing builder、不碰 hasDetailing,这里改用它。TestInstallDoesNotEnableDetailing 断言安装后 HasDetailing() 仍为 false。
守卫本身仍然必要:newDetailing 无条件接上 detailing.FetchFunc(dataOperator.Fetch),registerDefaultEventFuncs 也无条件注册 presets_DetailingDrawer,所以不管 detailing 有没有启用,这个 fetch 都可达。
| if len(deleteFolderIDS) > 0 { | ||
| if dbErr = tx. | ||
| Model(&media_library.MediaLibrary{}). | ||
| if dbErr = mb.scopedDB(tx, ctx). |
There was a problem hiding this comment.
[建议合并前修复] 这个 UPDATE 会静默丢掉基于 Joins 的 searcher。
scopedDB 现在被用来构造 UPDATE,但 GORM 只在 query 回调里消费 Statement.Joins。我用 db.Joins("join owners on owners.user_id = media_libraries.user_id") 这种 searcher 实测,发出的 SQL 是:
UPDATE "media_libraries" SET "parent_id"=0, "updated_at"='...'
WHERE parent_id in (1) AND "media_libraries"."deleted_at" IS NULL没有 join、没有隔离条件、也不报错 —— 被删目录下属于别的 tenant 的子行照样被 reparent 了。这正是本 PR 要消除的那类漏洞,在修复代码里又出现了一次。
下面几行的 DELETE 已经是安全写法(先用带 scope 的 SELECT 解析出可见 id,再按显式 id 列表删除),这个 UPDATE 建议照同样的模式改:先 scopedDB 查出可见的子行 id,再按 id 列表 Update。
TestDoDeleteReparentScoped 之所以能过,只是因为测试用的 searcher 是个纯 Where。
There was a problem hiding this comment.
已在 a9524eb 修复。reparent 改成先用 scoped 查询解析出可见的子行 id,再按 id 列表 UPDATE,和下面的 DELETE 同一套路:
var childIDs []uint64
mb.scopedDB(tx, ctx).Where(qualified("parent_id")+" in ?", deleteFolderIDS).Pluck(qualified("id"), &childIDs)
tx.Model(&media_library.MediaLibrary{}).Where(qualified("id")+" in ?", childIDs).Update("parent_id", 0)TestJoinSearcherScoped 现在用 Joins 型 searcher 跑完整流程,断言 foreign 子行的 parent 不被改动。
| return true | ||
| } | ||
| var count int64 | ||
| if err := b.scopedDB(b.db, ctx).Where("id = ? and folder = true", folderID).Count(&count).Error; err != nil { |
There was a problem hiding this comment.
[建议合并前修复] 未加表名限定的 id 会让 join 型 searcher 直接报错,而失败表现是「静默拒绝」。recordIsVisible(第 108 行)同样问题。
实测 SQL:
SELECT count(*) FROM "media_libraries" join owners on ... WHERE (id = 1 and folder = true)
-- ERROR: column reference "id" is ambiguous (SQLSTATE 42702)两个 helper 都把错误吞掉直接 return false,所以探针里 folderIsVisible(ownFolder) 返回了 false:往自己的目录上传、建子目录、任何移动操作都会被当成「record not found」拒掉;recordIsVisible 则会拒掉所有 presets 的 Edit/Update/Delete/Detail。
注意这个歧义是新引入的:原有的 mediaLibraryFilter 从来没按 id 过滤过,它用的是 folder/parent_id/selected_type/created_at,碰撞概率远低于 id。
两点建议:
- 给列名加限定,写成
"media_libraries"."id"、"media_libraries"."folder"。folderComponent、folderGroupsComponents、doDelete、wrapFirst里新增的裸id/parent_id条件同理。 - 把 searcher 的错误记日志而不是吞掉 —— 现在一报错就退化成硬拒绝,且没有任何诊断信息。
There was a problem hiding this comment.
已在 a9524eb 修复,写法在 95a4652 调整为内联字面量。
本 PR 引入的每一条裸条件都加上了表名限定 —— 两个可见性 helper,加上 folderComponent、folderGroupsComponents、doDelete、wrapFirst:
Where("media_libraries.id not in ? and media_libraries.parent_id = ? and media_libraries.folder = true", idS, record.ID)一开始是用一个 qualified() helper 拼接的,后来去掉了 —— 直接写成完整的 SQL 片段更好读。用的是不带引号的 media_libraries.id,Postgres 下小写标识符与 "media_libraries"."id" 等价。GORM 从主键生成的条件(First(&m, id))本身已带表名限定,所以没动。
第 2 点建议(记日志)我做成了另一种形式:两个 helper 改为返回 (bool, error),调用方向上传播。media 包里没有 logger,抛 error 同样能暴露诊断信息,也更符合 Go 风格 —— 如果你倾向真的记日志,我可以再改。歧义这个 case 由 TestJoinSearcherScoped 覆盖。
| // applied, so by-ID lookups and folder-tree queries see the same subset of rows | ||
| // the searcher gives the listing. Without a searcher the query is unscoped, | ||
| // preserving the historical behavior — including for a builder that only sets | ||
| // CurrentUserID, whose listing-only user_id filter is deliberately not extended |
There was a problem hiding this comment.
[值得考虑] 只配了 CurrentUserID 的应用,漏洞原样保留。
media/filechooser.go:553-557 在没有 searcher 时会退回 Where("user_id = ?", mb.currentUserID(ctx)),但 scopedDB 完全忽略 currentUserID。所以只配 CurrentUserID 的应用会得到「列表按用户过滤、按 id 访问全局可达」的组合 —— 正是本 PR 要修的同一类泄漏。
doc comment 里已经说明这是有意为之,作为兼容性取舍站得住。但既然 PR 标题写的是 "every media_libraries query",建议在 media/README.md 里明确写出:CurrentUserID 是列表过滤器,不是隔离边界,要做数据隔离请用 Searcher。
There was a problem hiding this comment.
已在 a9524eb 补文档:media/README.md 新增 “Scoping the media library” 一节,明确写出 Searcher 是隔离边界、CurrentUserID 只是列表过滤器(行仍可按 id 访问),并列出 searcher 不覆盖的部分(MediaBoxSetterFunc、应用自己写的查询)。
| func configList(b *presets.Builder, mb *Builder) { | ||
| mm := b.Model(&media_library.MediaLibrary{}).Label("Media Library").MenuIcon("mdi-image") | ||
| mb.mb = mm | ||
| configScopedCRUD(mb, mm) |
There was a problem hiding this comment.
[值得考虑] 列表的 SearchFunc 仍未收口。
presets/model.go:143 把 mm.Listing().Searcher 接到了通用的 dataOperator.Search。本 PR 包了 Editing/Detailing 的 fetch/save/delete,但没有包 Listing().WrapSearchFunc。
而 ListingCompo 在 presets/listing_compo.go:32 被全局注册为 stateful actionable 类型,因此即使这里的 listing PageFunc 被自定义覆盖,它的 reload/分页/搜索 action 仍可被派发到这个模型上,从而通过未收口的 operator 枚举全部行。
建议要么给 listing 也加上 scope(用 mb.scopedDB 起手写一个 SearchFunc),要么在注释里明确写清为什么这条路径不可达。
There was a problem hiding this comment.
确认可达,已在 a9524eb 修复。我对运行中的应用 dispatch 了 __dispatch_stateful_action__,payload 是 {"compo_type":"*presets.ListingCompo","injector":"media_libraries","method":"OnReload"},返回结果里包含了全部行的行链接,其中有当前 scope 之外的行。
现在 configScopedCRUD 会包 Listing().WrapSearchFunc,加一条把 id 限制在 scoped 查询里的 SQLCondition,用子查询形式传入,这样 Joins 型 searcher 也能保住:
params.SQLConditions = append(params.SQLConditions, &presets.SQLCondition{
Query: qualified("id") + " in (?)",
Args: []interface{}{mb.scopedDB(mb.db, ctx).Select(qualified("id"))},
})TestListingCompoReloadScoped 钉住这个行为。
| return | ||
| } | ||
| old := wrapFirst(mb, ctx, &r) | ||
| old, _ := wrapFirst(mb, ctx, &r) |
There was a problem hiding this comment.
[nit] 这里(以及 rename 里的第 627 行)只是为了拿变更前的快照,却重复跑了一遍完全相同的查询。
在修改前写 old := obj 就够了 —— 只改 Description/FileName 这两个字段,浅拷贝足够 —— 省一次查询,也不用丢弃 ok。
There was a problem hiding this comment.
已在 a9524eb 修复 —— 两处都改成在修改前 old := obj。只有 Description / FileName 会变,浅拷贝足够 activity 做 diff,省掉一次查询,也不用再丢弃 ok。
| panic(err) | ||
| } | ||
| // Delete only the rows the scoped query returned, not the raw request ids. | ||
| deleteIDs = deleteIDs[:0] |
There was a problem hiding this comment.
[nit] 复用 deleteIDs[:0] 逻辑是正确的,但这个变量在生命周期内先后代表两种含义(请求里的原始 id → scope 过滤后可见的 id)。单开一个 visibleIDs 会更好读。
There was a problem hiding this comment.
已在 a9524eb 修复 —— 请求里的原始 id 是 requestIDs,scope 过滤后的是 visibleIDs,不再复用同一个变量。
| require.Zero(t, reload(t, ownFile.ID).ParentId, "moving to root must keep working") | ||
| } | ||
|
|
||
| func TestUploadAndCreateFolderRejectForeignParent(t *testing.T) { |
There was a problem hiding this comment.
[nit] 测试名叫 TestUploadAndCreateFolderRejectForeignParent,但实际只跑了 createFolder;uploadFile 新加的 folderIsVisible 检查没有覆盖(需要构造 multipart body)。
建议要么补上 upload 的用例,要么把名字改成只反映 createFolder。
There was a problem hiding this comment.
已在 a9524eb 修复 —— 改名为 TestCreateFolderRejectsForeignParent,并新增 TestUploadFileRejectsForeignParent。不需要构造 multipart:folderIsVisible 的检查在 MustUnmarshalForm 之前,所以空请求就能覆盖这个守卫。
| require.True(t, b.folderIsVisible(ctx, foreignFolder.ID)) | ||
| require.True(t, b.folderIsVisible(ctx, 4242), "a stale folder id stays acceptable") | ||
| require.True(t, b.recordIsVisible(ctx, fmt.Sprint(foreignFile.ID))) | ||
| require.True(t, b.recordIsVisible(ctx, "4242")) |
There was a problem hiding this comment.
[nit] 整个测试文件里没有端到端覆盖 configScopedCRUD。
recordIsVisible 只被直接断言过,而且只在这里「无 searcher」的场景下。但 presets CRUD(Edit / Update / DoDelete / DetailingDrawer)这条路径恰恰是 PR 描述里最先强调的修复点,建议补一个走 mm.Editing().Fetcher/Saver/Deleter 的用例,确认带 searcher 时越权 id 会被拒。
There was a problem hiding this comment.
已在 a9524eb 修复 —— 新增 TestPresetsCRUDEventsScoped,通过 presets.Builder.ServeHTTP 驱动 presets_DoDelete 和 presets_DetailingDrawer,断言 foreign 行不会被删也不会被读回,而 own 行仍然能正常删除。
- presets: add ModelBuilder.GetDetailing so the media model's detailing event funcs can be guarded without calling Detailing(), which sets hasDetailing and would mount a detail route and change listing row behavior for every app installing media. - doDelete: resolve the children of deleted folders through the scoped query and reparent them by id. GORM only applies a searcher's Joins to SELECTs, so scoping the UPDATE directly dropped the condition and reparented other scopes' rows. - Scope the listing's generic search too. ListingCompo actions stay dispatchable even though the media library replaces the listing page, so its reload/paging/search enumerated every row. - Table-qualify the raw id / parent_id / folder conditions. A searcher adding a Joins clause made them ambiguous, and both visibility helpers turned that error into a silent deny, rejecting operations on rows the request owns. - folderIsVisible / recordIsVisible now return the error instead of swallowing it. - updateDescription / rename: snapshot the row in memory rather than querying it twice. - doDelete: name the scoped id set visibleIDs. - README: document that Searcher is the isolation boundary and CurrentUserID is not, and what a searcher does not cover. Tests: join-based searcher, presets CRUD events end to end, ListingCompo reload, detailing stays disabled, loadImageCropper early return, upload into a foreign parent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
已推 a9524eb + 2494e30 + 95a4652,逐条对应: 建议合并前修复
值得考虑
nit —— 新增测试: |
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The qualified() helper obscured what the conditions actually are; the literals read as plain SQL fragments. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Problem
media.Builder.Searcheris the only hook an app has to scope the media library (multi-tenant isolation, per-team libraries, …), but it was applied in exactly one place:mediaLibraryFilter, which backs the listing grid, the chooser dialog, search and pagination.Every other query in the package addressed rows by id or parent id straight off the request and never consulted the searcher. With a searcher configured for data isolation, a request could still reach rows outside its scope:
chooseFile(filechooser.go)loadImageCropper/cropImage(cropper.go)folderGroupsComponents(media_box.go)moveToFolderparentFolders(filechooser.go)folderComponent(filechooser.go)wrapFirst→ rename / update-descriptiondoDeleteEdit/Update/DoDelete/DetailingDrawerDataOperatorChange
Builder.scopedDB(db, ctx)— amedia_librariesquery with the searcher applied. Every read/write listed above now goes through it.Builder.folderIsVisible(ctx, id)— validates an upload / new-folder / move target: root is always allowed, any other folder only when the searcher lets the request see it.Builder.recordIsVisible(ctx, id)— backs newWrapFetchFunc/WrapSaveFunc/WrapDeleteFuncguards on the model's editing and detailing builders, and aWrapSearchFuncguard on its listing, so the generic CRUD and listing events cannot address or enumerate out-of-scope rows. Detailing is reached through the newpresets.ModelBuilder.GetDetailing()so that installing media does not enable detailing.doDeletenow deletes only the ids its scoped read returned, and scopes the child-reparentingUPDATE.Not covered by a Searcher
MediaBoxSetterFuncpersists whateverMediaBoxJSON a form submits, so someone who already knows a URL can still attach it to a field without reading the row. Scoping the reads does not change that, and this PR does not attempt to. Both this and app-side queries againstmedia_librariesare called out inmedia/README.md.Backward compatibility
With no
Searcherconfigured every guard is inert —scopedDBadds no predicate and both*IsVisiblehelpers return true, so behavior is unchanged.TestNoSearcherKeepsEveryIDReachablepins this (foreign ids, stale folder ids and a move to a foreign folder all still work).Two deliberate changes apply in that case as well, both of the same class — an event func no longer proceeds with a zero-value
MediaLibrarywhen the record is missing:wrapFirstgained anokreturn. Previously anErrRecordNotFoundleft a zero-id object thatrename/updateDescriptionthenSaved, inserting a garbage row.chooseFileandloadImageCropperreturn early on a missing record instead of rendering a chooser/cropper for a zero-value row.Tests
New
media/scope_test.go(14 tests,gormxtest suite likeactivity): by-id lookups, folder tree, folder child counts, breadcrumbs,wrapFirst, rename, delete (incl. a mixed own+foreign batch and folder-child reparenting), move-to-folder (incl. move-to-root), foreign-parent upload/new-folder rejection,chooseFile,cropImage, and the no-searcher compatibility contract.🤖 Generated with Claude Code