Joplin 只读项机制无写权限共享笔记在模型、同步与 UI 三层是如何落地的【免费下载链接】joplinJoplin - the privacy-focused note taking app with sync capabilities for Windows, macOS, Linux, Android and iOS.项目地址: https://gitcode.com/GitHub_Trending/jo/joplin本文围绕 Joplin 仓库中的设计规范文档 read_only.md 展开讲清楚 Joplin 中只读项read-only item的完整技术图景哪些数据对象支持只读、判定只读的具体条件、服务端如何返回isReadOnly错误码以及客户端在模型层、同步层、UI 层的防御式实现。读完本文你可以掌握在 Joplin Cloud 共享文件夹write 权限被禁用场景下一条只读规则从服务端到桌面端编辑器被逐层阻断的全过程并能定位到每一层的源码入口与对应测试用例。一、只读项是什么共享文件夹的无写权限场景Joplin 中的某些数据项item可以被标记为只读。支持该机制的对象类型有三类笔记Note文件夹Folder附件资源Resource目前该机制的实际使用场景是当一个 Joplin Cloud 文件夹被共享给其他用户且共享权限中禁用了 write写时被共享者本地的这些笔记、文件夹与资源即被视为只读项。需要强调的是只读不是数据库里的一个布尔字段而是一个运行时推导出来的状态由该项是否属于某个 share以及当前用户在该 share 中是否有写权限两个条件共同判定。这个设计决定了 Joplin 必须在多个层面做检查——服务端负责最终兜底客户端则在模型层、UI 层提前拦截并在同步层处理极端情况下仍然到达服务端的写请求。二、服务端层Joplin Cloud 如何拒绝写入服务端是只读约束的最终防线。当客户端尝试向一个只读共享写入数据时除非操作者是 share 的所有者Joplin Cloud 会直接拒绝请求返回 HTTP403 Forbidden响应体中附带机器可读的错误码{ code: isReadOnly }。客户端侧对应的错误码枚举定义在 errors.ts 中// packages/lib/errors.ts IsReadOnly isReadOnly,Joplin Server 端在 server/src/utils/errors.ts 中定义了同名错误码两端以此作为契约。客户端在传输层对这类错误做了非致命处理例如 file-api.ts 中对rejectedByTarget和isReadOnly错误码会返回false不视为需要重试或中断同步的致命错误从而把错误交给同步器按只读语义专门处理。三、模型层readOnly.ts 如何判定一个项是否只读规范文档指出lib/models/utils/readOnly.ts提供了一系列工具函数来判断某个项是否应被视为只读并且大部分只读处理逻辑集中在BaseItem中因此笔记、文件夹、资源的处理方式高度一致。对应源码为 readOnly.ts。3.1 前置快速退出needsShareReadOnlyChecks在真正判定之前needsShareReadOnlyChecks函数先做一轮廉价的快速退出检查避免对不适用场景做无谓查询export const needsShareReadOnlyChecks (itemType: ModelType, changeSource: number, shareState: ShareState, disableReadOnlyCheck false) { if (disableReadOnlyCheck) return false; if (!isJoplinServerVariant(Setting.value(sync.target))) return false; if (changeSource ItemChange.SOURCE_SYNC) return false; if (!Setting.value(sync.userId)) return false; if (![ModelType.Note, ModelType.Folder, ModelType.Resource].includes(itemType)) return false; if (!shareState) throw new Error(Share state must be provided); if (!shareState.shareInvitations.length) return false; return true; };从源码结构看只有当以下条件全部满足时才会执行只读检查同步目标是 Joplin Cloud / Joplin Server 一类的服务变体isJoplinServerVariant判断sync.target变更来源不是同步本身changeSource ! ItemChange.SOURCE_SYNC——同步下载数据时天然要跳过只读检查否则无法写入本地数据库用户已登录存在sync.userId对象类型是 Note、Folder 或 Resource 三者之一当前用户至少有一个共享邀请shareInvitations非空。3.2 核心判定itemIsReadOnlySyncitemIsReadOnlySync是判定函数本体接收一个最小化的项切片id、share_id、deleted_timeexport const itemIsReadOnlySync (itemType, changeSource, item, userId, shareState, sharePermissionCheckOnly false): boolean { // Item is in trash if (!sharePermissionCheckOnly item.deleted_time) return true; if (!needsShareReadOnlyChecks(itemType, changeSource, shareState)) return false; checkObjectHasProperties(item, [share_id]); // Item is not shared if (!item.share_id) return false; // Item belongs to the user const parentShare shareState.shares.find(s s.id item.share_id); if (parentShare parentShare.user?.id userId) return false; const shareUser shareState.shareInvitations.find(si si.share.id item.share_id); // Shouldnt happen if (!shareUser) return false; return !shareUser.can_write; };判定链路可以拆解为四步回收站检查只要项带有deleted_time已在回收站直接视为只读。源码注释说明这个函数最初是为共享权限设计的后来复用来表达回收站中的笔记只读这一语义因此多了sharePermissionCheckOnly开关——共享检查不需要deleted_time字段Resource 对象上根本没有这个字段非共享项直接放行share_id为空的项不受共享只读约束共享所有者放行若share_id对应的 share 的所有者就是当前用户parentShare.user?.id userId说明是自己发起的共享可以写入按邀请权限判定查找当前用户在该 share 下的邀请记录shareInvitations最终返回!shareUser.can_write——即只有受邀且未授予写权限的项才是只读的。此外还有一个异步包装itemIsReadOnly它通过BaseItem.loadItem只加载id、share_id、deleted_time三个字段后调用同步版函数供 UI 层按需查询。3.3 四种被拦截的操作规范文档明确列出了模型层处理的四种情况情况拦截函数行为修改只读项checkIfItemCanBeChanged抛出JoplinError(Cannot change or delete a read-only item: id, ErrorCode.IsReadOnly)删除只读项checkIfItemCanBeChanged同上修改与删除共用检查在只读项下添加子项checkIfItemCanBeAddedToFolder抛出JoplinError(Cannot add an item as a child of a read-only item, ErrorCode.IsReadOnly)修改只读资源文件内容BaseItem 层走同样的只读检查路径其中checkIfItemCanBeAddedToFolder有一个值得注意的细节它按parentId加载父文件夹后检查其只读状态若父文件夹不存在则跳过检查并记录警告。源码注释解释了这个历史原因——同步过程中项的下载顺序是随机的允许把笔记的parent_id指向一个尚未下载的文件夹即使该文件夹最终是只读的问题也会在同步阶段被解决。这些检查被统一注入到BaseItem的保存/删除路径中意味着三个模型Note、Folder、Resource无需各自重复实现。四、同步层为什么理论上不会发生的错误仍要兜底这是规范文档中最有设计思想的一部分。文档明确写道由于模型层和 UI 层已经拦截这些只读错误理论上永远不会发生但同步器仍然必须处理它们原因有二如果同步器无法处理只读错误同步会永久卡死用户的本地数据会与共享文件夹不一致且没有任何途径拿到最新数据。同步器对三种情形分别定义了恢复策略本地操作服务端响应同步器的恢复动作修改了本地只读项并尝试上传isReadOnly错误本地项被复制到冲突文件夹conflict folder远端项覆盖本地项删除了本地只读项并尝试删除远端项isReadOnly错误下载远端项恢复本地项把某项添加为只读文件夹的子项isReadOnly错误本地项被复制到冲突文件夹然后删除4.1 Synchronizer 中的错误捕获在 Synchronizer.ts 的上传阶段有两处关键的IsReadOnly捕获点// packages/lib/Synchronizer.ts资源 blob 上传路径 } else if (error error.code ErrorCode.IsReadOnly) { action getConflictType(local); itemIsReadOnly true; logger.info(Resource is readonly and cannot be modified - handling it as a conflict:, local); } // packages/lib/Synchronizer.ts项元数据上传路径 } else if (error error.code ErrorCode.IsReadOnly) { action getConflictType(local); itemIsReadOnly true; canSync false; }两处逻辑一致一旦上传收到isReadOnly错误就把当前操作改判为冲突动作NoteConflict/ResourceConflict/ItemConflict并置位itemIsReadOnly标志传给后续的冲突处理函数。此外同步开始前的Folder.updateAllShareIds/shareService.checkShareConsistency步骤若因只读项报错也会被捕获并仅记录错误日志而不中断同步——源码注释同样强调正常情况下 UI 应该拦截但如果 UI 有 bug不希望同步因此失败。4.2 handleConflictAction只读项的差异化冲突处理冲突处理统一收敛在 handleConflictAction.ts 中itemIsReadOnly参数在其中起决定性的差异化作用跳过自动合并普通笔记冲突若开启了自动合并auto-merge会尝试合并本地与远端的标题/正文差异但对只读项本地修改根本推不上去合并毫无意义源码注释写得很直白Skipped for content that cant be merged safely: read-only items (the local change cant be pushed)。因此自动合并条件中包含!itemIsReadOnly跳过冲突是否重要的判断mustHandleConflict的计算同样被!itemIsReadOnly前置短路只读项直接走创建冲突副本 远端覆盖本地的路径与文档描述的本地项复制到冲突文件夹远端项覆盖本地完全对应文件夹冲突不建冲突副本对于ItemConflict文件夹等非笔记项处理是直接以远端内容覆盖本地远端存在时或删除本地远端已删时不创建冲突副本——这也是文档所说把某项加到只读文件夹下时本地项复制到冲突文件夹后被删除之外文件夹自身修改被直接回滚的原因。4.3 测试用例印证上述行为在 Synchronizer.basics.test.ts 中有直接覆盖。测试通过synchronizer().testingHooks_ [itemIsReadOnly]这一测试钩子让 Synchronizer 在上传时主动抛出ErrorCode.IsReadOnly错误来模拟服务端拒绝it(should handle items that are read-only on the sync target, (async () { const folder await Folder.save({ title: folder }); const note await Note.save({ title: un, is_todo: 1, parent_id: folder.id }); await synchronizerStart(); await Note.save({ id: note.id, title: un mod }); synchronizer().testingHooks_ [itemIsReadOnly]; await synchronizerStart(); const noteReload await Note.load(note.id); expect(noteReload.title).toBe(note.title); // 本地被远端覆盖 const conflictNote (await Note.all()).find(n !!n.is_conflict); expect(conflictNote).toBeTruthy(); // 本地修改保留在冲突副本 expect(conflictNote.title).toBe(un mod); })); it(should revert local changes to read-only folders, (async () { // ... 修改文件夹并触发只读错误 const reloadedFolder await Folder.load(folder.id); expect(reloadedFolder.title).toBe(folder); // 标题被回滚 expect(reloadedFolder.share_id).toBe(); // Should not have created a conflict expect(await Folder.all()).toHaveLength(1); // 文件夹冲突不产生副本 }));这两组测试恰好印证了文档描述的两种恢复语义笔记类只读冲突本地副本进冲突文件夹 远端覆盖本地文件夹类只读冲突直接回滚本地修改、不产生冲突副本。五、UI 层编辑器与菜单的禁用规范文档指出 UI 层同样使用readOnly.ts判断项是否只读从而禁用菜单项、编辑器、命令等。在桌面端源码中可以看到这一机制的具体落点以笔记编辑器 NoteEditor.tsx 为例import { itemIsReadOnly } from joplin/lib/models/utils/readOnly; const [isReadOnly, setIsReadOnly] useStateboolean(false); // 打开笔记时查询只读状态 const result await itemIsReadOnly(BaseItem, ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, formNote.id, props.syncUserId, shareCache); // 编辑器输入控件根据该状态禁用 disabled: isReadOnly || reloadInProgress,即编辑器在加载笔记后调用与模型层同一个itemIsReadOnly工具函数查询状态并把富文本编辑区的disabled直接绑定到该状态。除了正文编辑器桌面端还会把只读状态传递给上下文菜单contextMenuUtils.ts 中定义了isReadOnly?: boolean参数由 CodeMirror 与 TinyMCE 两套编辑器的右键菜单各自消费以及笔记属性对话框 NotePropertiesDialog.tsx 中的相关操作项实现从入口上就不让用户发起会被拒绝的操作。移动端与 CLI 同样依赖packages/lib中的这套共享工具函数因此三端的只读判定逻辑保持一致。六、总结一条规则的四层防线把规范文档 read_only.md 的脉络与源码对应起来Joplin 的只读机制是一条清晰的纵深防御链服务端Joplin Cloud / Joplin Server唯一权威对无写权限的共享写入返回403 {code: isReadOnly}模型层readOnly.ts 基于share_idcan_write推导只读状态BaseItem统一在修改、删除、添加子项、改写资源内容四类路径上抛出IsReadOnly错误UI 层复用同一套判定函数禁用编辑器、菜单与命令让用户根本无法触发违规操作同步层作为最后的健壮性兜底把意外到达服务端的只读错误转化为可控的冲突恢复本地副本保留、远端覆盖本地保证同步永不卡死、本地数据最终与共享文件夹一致。理解这套机制的关键在于只读是一个由共享状态推导出来的临时属性而非持久化标记因此它必须出现在所有可能绕过 UI 的路径上插件 API、命令行、同步竞态这正是 Joplin 选择多层检查 可恢复冲突而非单一拦截点的原因。【免费下载链接】joplinJoplin - the privacy-focused note taking app with sync capabilities for Windows, macOS, Linux, Android and iOS.项目地址: https://gitcode.com/GitHub_Trending/jo/joplin创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
