From f89de5044ecbc109e92960f249384ec90599b4a3 Mon Sep 17 00:00:00 2001 From: liubw Date: Fri, 13 Nov 2026 18:37:55 +0800 Subject: [PATCH] fix: make library recovery and attachments portable Use system file dialogs in Flatpak, preserve damaged libraries during backup recovery, and checkpoint external edits before saving. Resolve managed attachments by database identity and add recovery and portability regressions. --- .github/workflows/flatpak.yml | 18 +- CMakeLists.txt | 18 +- README.md | 2 +- docs/backup-import.md | 2 + docs/portable-recovery.md | 47 ++ qml/LibraryActions.qml | 46 +- qml/Main.qml | 21 +- qml/NoteReadingView.qml | 18 +- qml/NotesFilePicker.qml | 199 +++++++++ qml/SourceEditor.qml | 10 +- src/EditorExport.cpp | 23 +- src/NotesController.cpp | 87 +++- src/NotesController.h | 10 +- src/NotesLibrary.cpp | 5 + src/main.cpp | 82 +++- src/storage/ManagedAssets.h | 239 +++++++++++ src/storage/ManagedLibrary.cpp | 176 ++++++++ src/storage/ManagedLibrary.h | 24 ++ src/storage/ManagedStore.cpp | 686 ++++++++++++++++++++++++------ src/storage/ManagedStore.h | 8 +- src/storage/NotesRepository.cpp | 44 +- src/storage/RepositoryTestHooks.h | 6 + tests/ManagedAssetsTest.cpp | 308 ++++++++++++++ tests/ManagedPortabilityTest.cpp | 210 +++++++++ tests/ManagedRecoveryTest.cpp | 413 ++++++++++++++++++ tests/NotesControllerTest.cpp | 124 +++++- tests/chrome/tst_filepicker.qml | 279 ++++++++++++ 27 files changed, 2877 insertions(+), 228 deletions(-) create mode 100644 docs/portable-recovery.md create mode 100644 qml/NotesFilePicker.qml create mode 100644 src/storage/ManagedAssets.h create mode 100644 src/storage/ManagedLibrary.cpp create mode 100644 src/storage/ManagedLibrary.h create mode 100644 tests/ManagedAssetsTest.cpp create mode 100644 tests/ManagedPortabilityTest.cpp create mode 100644 tests/ManagedRecoveryTest.cpp create mode 100644 tests/chrome/tst_filepicker.qml diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml index 2e53d1f..9b036e1 100644 --- a/.github/workflows/flatpak.yml +++ b/.github/workflows/flatpak.yml @@ -48,9 +48,7 @@ jobs: - name: Run targeted regressions inside SDK run: | repository_test=$(find "$PWD/tmp/state/build" -type f -name notes-repository-test -print -quit) - controller_test=$(find "$PWD/tmp/state/build" -type f -name notes-controller-test -print -quit) test -n "$repository_test" - test -n "$controller_test" flatpak-builder --run tmp/build tmp/org.nextks.notes.json "$repository_test" \ managedSandboxLockRecoversAndRejectsConcurrentWriters \ managedFolderSummariesTrackNotesAndPreserveCreation managedLegacyFoldersGainOrderWithoutLosingIcons \ @@ -58,10 +56,18 @@ jobs: managedDirectNoteDeletionIsPermanentAndScoped managedDirectNoteDeletionPreservesFailedItems \ managedDirectFolderDeletionIsPermanentAndScoped managedDirectFolderDeletionProtectsRecoveryDrafts \ 2>&1 | tee tmp/evidence/repository-tests.log - flatpak-builder --run tmp/build tmp/org.nextks.notes.json env QT_QPA_PLATFORM=offscreen "$controller_test" \ - directNoteDeletionSavesBeforeRemovingCurrentDocument failedDirectNoteDeletionKeepsCurrentDocument \ - directFolderDeletionDrainsAndClearsTheOpenDocument failedDirectFolderDeletionKeepsTheOpenDocument \ - 2>&1 | tee tmp/evidence/controller-tests.log + for suite in managed-assets managed-recovery managed-portability notes-controller notes-chrome; do + binary=$(find "$PWD/tmp/state/build" -type f -name "$suite-test" -print -quit) + test -n "$binary" + arguments=() + if [ "$suite" = notes-chrome ]; then + qml_tests=$(find "$PWD/tmp/state/build" -type d -path '*/tests/chrome' -print -quit) + test -n "$qml_tests" + arguments=(-input "$qml_tests") + fi + flatpak-builder --run tmp/build tmp/org.nextks.notes.json env QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software "$binary" "${arguments[@]}" \ + 2>&1 | tee "tmp/evidence/$suite-tests.log" + done - name: Export bundle and exact release plan run: | /usr/bin/python3 packaging/flatpak/export-release.py --repo tmp/repo --output dist --revision "$(git rev-parse HEAD)" diff --git a/CMakeLists.txt b/CMakeLists.txt index 93d5852..0194ae2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,13 +15,14 @@ add_subdirectory("${KOS_UI_SOURCE_DIR}/shared/qml" kos-ui) qt_add_library(nextnotes-storage STATIC src/storage/MarkdownFile.cpp src/storage/MarkdownFile.h src/storage/NotesRepository.cpp src/storage/NotesRepository.h - src/storage/ManagedStore.cpp src/storage/ManagedStore.h) + src/storage/ManagedStore.cpp src/storage/ManagedStore.h + src/storage/ManagedLibrary.cpp src/storage/ManagedLibrary.h src/storage/ManagedAssets.h) target_include_directories(nextnotes-storage PUBLIC src/storage) target_link_libraries(nextnotes-storage PUBLIC Qt6::Core SQLite::SQLite3) qt_add_executable(nextnotes src/main.cpp src/EditorSession.cpp src/EditorSessionWeb.cpp src/EditorSession.h src/NotesController.cpp src/NotesLibrary.cpp src/EditorExport.cpp src/PdfExport.cpp src/NotesOrganizer.cpp src/NotesKnowledge.cpp src/NotesController.h) target_sources(nextnotes PRIVATE src/EditorRenderLifecycle.cpp src/EditorRenderLifecycle.h) qt_add_qml_module(nextnotes URI NextNotes VERSION 1.0 RESOURCE_PREFIX /qt/qml - QML_FILES qml/FolderBrowser.qml qml/NotesBrowser.qml qml/NotesSelectionBox.qml qml/FolderIcons.js qml/PopupPlacement.js qml/NotesPopover.qml qml/NotesContentPopover.qml qml/NotesSettings.qml qml/LibraryActions.qml qml/NotesWindowControls.qml qml/KnowledgePanel.qml qml/RecycleBin.qml qml/Main.qml qml/NoteNameEditor.qml qml/NotesOrganizer.qml qml/SourceEditor.qml qml/EditorTools.qml qml/CommandRegistry.qml qml/NotesTokens.qml qml/NoteReadingView.qml) + QML_FILES qml/FolderBrowser.qml qml/NotesBrowser.qml qml/NotesSelectionBox.qml qml/FolderIcons.js qml/PopupPlacement.js qml/NotesPopover.qml qml/NotesContentPopover.qml qml/NotesSettings.qml qml/NotesFilePicker.qml qml/LibraryActions.qml qml/NotesWindowControls.qml qml/KnowledgePanel.qml qml/RecycleBin.qml qml/Main.qml qml/NoteNameEditor.qml qml/NotesOrganizer.qml qml/SourceEditor.qml qml/EditorTools.qml qml/CommandRegistry.qml qml/NotesTokens.qml qml/NoteReadingView.qml) qt_add_resources(nextnotes window-controls PREFIX /nextnotes/platform BASE qml FILES qml/NativeWindowControls.qml) qt_add_resources(nextnotes app-icon PREFIX /nextnotes/icons BASE packaging FILES packaging/org.nextks.notes.svg) file(GLOB_RECURSE EDITOR_ASSETS CONFIGURE_DEPENDS web/editor/dist/* web/editor/vendor/*) @@ -33,11 +34,11 @@ if(NEXTNOTES_BUILD_TESTS) enable_testing() qt_add_executable(notes-chrome-test tests/chrome/main.cpp) qt_add_qml_module(notes-chrome-test URI NextNotes.ChromeTests VERSION 1.0 RESOURCE_PREFIX /qt/qml - QML_FILES qml/NotesWindowControls.qml qml/NotesOrganizer.qml qml/FolderIcons.js qml/NotesTokens.qml qml/FolderBrowser.qml qml/NotesBrowser.qml qml/NotesSettings.qml + QML_FILES qml/NotesFilePicker.qml qml/NotesWindowControls.qml qml/NotesOrganizer.qml qml/FolderIcons.js qml/NotesTokens.qml qml/FolderBrowser.qml qml/NotesBrowser.qml qml/NotesSettings.qml qml/NotesPopover.qml qml/NotesContentPopover.qml qml/PopupPlacement.js) qt_add_resources(notes-chrome-test test-window-controls PREFIX /nextnotes/platform BASE qml FILES qml/NativeWindowControls.qml) target_compile_definitions(notes-chrome-test PRIVATE QUICK_TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/tests/chrome") - target_link_libraries(notes-chrome-test PRIVATE Kos::Ui Kos::UiPlugin Qt6::QuickTest Qt6::QuickControls2) + target_link_libraries(notes-chrome-test PRIVATE Kos::Ui Kos::UiPlugin Qt6::QuickTest Qt6::QuickControls2 Qt6::QuickDialogs2) add_test(NAME notes-chrome COMMAND notes-chrome-test) set_tests_properties(notes-chrome PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software") qt_add_executable(editor-render-test tests/render/main.cpp src/EditorRenderLifecycle.cpp src/EditorRenderLifecycle.h) @@ -50,6 +51,15 @@ if(NEXTNOTES_BUILD_TESTS) qt_add_executable(notes-repository-test tests/NotesRepositoryTest.cpp) target_link_libraries(notes-repository-test PRIVATE nextnotes-storage Qt6::Test) add_test(NAME notes-repository COMMAND notes-repository-test) + qt_add_executable(managed-assets-test tests/ManagedAssetsTest.cpp) + target_link_libraries(managed-assets-test PRIVATE nextnotes-storage Qt6::Test) + add_test(NAME managed-assets COMMAND managed-assets-test) + qt_add_executable(managed-recovery-test tests/ManagedRecoveryTest.cpp) + target_link_libraries(managed-recovery-test PRIVATE nextnotes-storage Qt6::Test) + add_test(NAME managed-recovery COMMAND managed-recovery-test) + qt_add_executable(managed-portability-test tests/ManagedPortabilityTest.cpp) + target_link_libraries(managed-portability-test PRIVATE nextnotes-storage Qt6::Test) + add_test(NAME managed-portability COMMAND managed-portability-test) qt_add_executable(notes-controller-test tests/NotesControllerTest.cpp src/NotesController.cpp src/NotesLibrary.cpp src/EditorExport.cpp src/PdfExport.cpp src/NotesOrganizer.cpp src/NotesKnowledge.cpp src/NotesController.h src/EditorSession.cpp src/EditorSessionWeb.cpp src/EditorSession.h) target_include_directories(notes-controller-test PRIVATE src) target_link_libraries(notes-controller-test PRIVATE nextnotes-storage Qt6::Test Qt6::Quick Qt6::Gui Qt6::WebEngineCore) diff --git a/README.md b/README.md index a439740..3618fd8 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 面向 NextKde 的原生 Qt Quick 笔记应用:SQLite 管理内部 Markdown 笔记、三栏导航、所见即所得编辑、700ms 自动保存、中文搜索、收藏、虚拟文件夹和最近删除。首次打开创建空的内部笔记空间,不扫描个人目录、不创建演示数据。正文支持附件导入与可恢复清理;支持安全移动/重命名、非空文件夹整理和应用内 Markdown 链接导航;现支持笔记历史、搜索索引、标签/属性、双链补全与反链、智能文件夹、模板和每日笔记。 -当前存储与导入方向以 [SQLite 管理说明](docs/managed-notes.md) 为准;下文旧文件仓库说明保留为历史实现记录。 +当前存储与导入方向以 [SQLite 管理说明](docs/managed-notes.md) 为准;文件授权、附件跨位置恢复及数据库故障恢复见 [恢复说明与验证状态](docs/portable-recovery.md)。下文旧文件仓库说明保留为历史实现记录。 产品依据:[产品规格](docs/PRODUCT_SPEC.zh-CN.md)。正文功能、存储边界和 VM 验证记录见[正文编辑说明](docs/body-editor.md),笔记整理与批量接口见[整理操作说明](docs/organizer.md)。实现边界、编辑器比较、命令表、最小文件约定和组件差距:[架构记录](docs/architecture.md)。本次编辑器验证范围见下文,不代表全部产品验收。 diff --git a/docs/backup-import.md b/docs/backup-import.md index daba3b4..5e89299 100644 --- a/docs/backup-import.md +++ b/docs/backup-import.md @@ -14,6 +14,8 @@ 技术依据:[SQLite Backup API](https://www.sqlite.org/backup.html)。格式目前为应用标识与版本受校验的 SQLite 文件;不是 Markdown 导出的替代文件格式。 +2026-11-13 开发更新:当前数据库无法初始化时,启动页可从完整备份恢复到同级新空间,保留原目录与数据库侧车文件。Flatpak 的文件选择、旧绝对附件链接兼容和外部文件失败草稿机制,见 [恢复说明与验证状态](portable-recovery.md);下方人工运行记录仍对应 2026-11-06,不代表这些新增路径已通过 GUI 验收。 + ## 批量导入 导入仍需先预览来源、目标、数量、目录结构和重复策略。扫描、内容检查、准备和写入显示阶段及已处理数量。读取阶段可停止;确认导入后也可停止。操作在存储工作线程执行,取消使用线程间原子标记,不等待工作线程处理另一条排队命令。 diff --git a/docs/portable-recovery.md b/docs/portable-recovery.md new file mode 100644 index 0000000..bf02bb9 --- /dev/null +++ b/docs/portable-recovery.md @@ -0,0 +1,47 @@ +# 文件授权、附件迁移与故障恢复 + +2026-11-13。适用于本次开发分支;验证状态见末尾。 + +## 文件选择与 Flatpak + +Flatpak 内的导入文件、导入文件夹、外部 Markdown、附件、Markdown 导出目录、完整备份及 HTML/PDF 输出,统一使用 Qt Quick Dialogs 的系统文件对话框。原生安装继续使用应用内选择器。正式包沿用 `QT_QPA_PLATFORMTHEME=xdgdesktopportal`,不增加 home/host 文件系统权限。 + +保存时在建议文件名中提供扩展名,使用系统对话框返回的原始 URL。若最终名称缺少要求的扩展名,提示重新选择,不在授权后追加扩展名写到另一个位置。取消不会发起读写,过期对话框回调不完成新的请求。写文件仍使用 QSaveFile,禁止退化为直接覆盖。备份先在私有位置生成一致 SQLite 快照,再写入所选位置;恢复先复制所选文件到私有位置进行校验。 + +单个 Markdown 文件的授权不包含同目录的图片。收集带相对附件的笔记时,应选择“从文件夹收集”;无法读取的资源保留原链接并报告。Qt 可能在系统对话框服务不可用时回退到内部对话框,因此组件测试不能证明 portal 已工作,必须在正式运行时观察实际 FileChooser 请求。Qt 6.11 的 portal 实现还存在程序主动关闭不发送 Request.Close 的限制;应用会取消本地请求并忽略迟到结果,但系统窗口可能仍需用户关闭。 + +## 跨位置恢复附件 + +新附件使用相对、百分号编码的 URL。数据库中 `assets/.<扩展名>` 对应的 BLOB 是权威内容;磁盘 assets 目录是可再生缓存。 + +旧正文中的绝对 `file:` / `nkasset:` 链接只提供附件身份:必须精确匹配当前数据库拥有的路径与摘要,才能读取当前空间的附件。旧磁盘位置不会被读取。当前缓存需通过目录范围、符号链接、大小及实际内容摘要检查;编辑器获得的是已校验字节。外部 Markdown 只使用自身目录范围,不沿用内部空间的旧路径映射。普通文件/目录链接与图片读取分开校验,不把图片的 32 MiB 限制套用到普通外部链接。Linux 目录遍历使用 O_PATH,允许读取经过可搜索但不可列出的父目录下的已知文件。 + +恢复不批量改写正文、历史、草稿及其摘要。历史正文保留的 `../assets/...` 在移动后按数据库身份定位;它不授予跨目录读取权限。创建副本、应用模板和 Markdown 导出时,按目标位置重写受支持的附件链接并保留 URL 查询与片段。Markdown 导出直接读取 BLOB,即使缓存缺失或被更改也可生成原附件。HTML 资源链接及未知附件保留原文并在导出完成窗口列出警告。 + +附件清理同时检查当前笔记、最近删除、历史、恢复草稿、模板及内存草稿中的引用,防止当前正文不再引用后提前回收仍可恢复的附件。 + +## 当前数据库无法打开时恢复 + +初始化失败后,启动页提供“从完整备份恢复”。在私有副本中检查现有数据库及侧车文件,避免校验过程修改故障现场。所选备份需通过应用标识、版本、完整性、外键、受控结构、路径与附件摘要校验。 + +预检查还会拒绝被普通文件占用的缓存目录及缺少 rowid 的不兼容历史表。正常打开时,附件缓存写入失败作为警告报告,不把已经打开的数据库误报为初始化失败;故障恢复的新空间则必须完整生成缓存后才能发布。 + +恢复先在同级 `Library-restored-` 准备完整可打开空间及附件缓存,再通过稳定入口目录中的 `active-library.json` 发布新位置。原数据库、WAL、SHM 等文件保留,原锁文件 inode 不替换。下次启动仍从原入口读取活动位置,并同时保护入口与活动空间的实例锁。 + +发布前失败保留原活动位置;发布后若目录同步无法确认,会明确提示关闭后重开,并阻止继续写入,不声称切换未发生。若当前空间本来可正常打开,则继续使用已有恢复机制:先生成保护备份,再恢复所选内容。 + +## 外部文件保存失败 + +保存外部 Markdown 前,先在内部空间独立提交恢复草稿。外部文件发生冲突、删除或写入失败时,本地输入仍可从草稿恢复;冲突后的继续输入也会自动更新恢复草稿,不覆盖外部版本。 + +草稿提交失败时,界面明确显示仅在内存中保留,关闭仍受保护。迟到的旧修订回包不会把最新输入标记为已持久保存。外部写入成功但草稿清理失败时,保留新文件摘要并报告清理警告,避免下一次保存误判为外部冲突。 + +## 验证状态 + +测试均使用合成数据和临时空间,不使用个人笔记库。新增 `managed-assets`、`managed-recovery`、`managed-portability`,并扩展 controller 与 QML 文件选择器回归;已有 Flatpak CI 已登记这些套件及完整 controller/chrome 回归,尚未触发新 CI 运行。覆盖跨根目录恢复、旧路径不可用、附件 BLOB 导出、历史/草稿引用、损坏数据库原字节保留、锁 inode、恢复发布中断、外部冲突及草稿持久性。 + +此前候选已通过原生 Qt 6.10.2 与 KDE 6.11 SDK 编译,以 `org.nextks.notes.RecoveryTest` 安装于隔离 VM;运行权限没有 home/host 授权。该快照的 repository、managed-assets、managed-recovery、editor-session 四组通过;notes-chrome 出现 5 个失败,notes-controller 因旧测试假定而崩溃。随后修正了 URL 比较、对话框取消竞态及过时测试;这些修正与新 portability 测试尚未重跑,不能视为通过。 + +源码快照已传至 VM 的 `/home/amao/nknotes-recovery-20261113/source-final.tar.gz`,SHA-256 为 `3f4b36acca3c9bae78915b5391c97589436106f0108572956024b7b837bed52e`。自动审批拒绝解压及构建,理由是缺少用户对该主机写入与构建的直接授权。后续静态复核修正、对应回归、文档及 CI 路径调整不包含在该归档中;重跑前应重新冻结最终源码。 + +实际 Flatpak 图形验收也尚未执行:自动审批拒绝了远程测试夹具写入与应用启动。尚无 FileChooser D-Bus 请求、实际点击、截图或 portal 保存结果证据,组件测试与 SDK 编译不替代这些验收。本次仅提交源码供审查,尚未发布新版本或更新用户安装。 diff --git a/qml/LibraryActions.qml b/qml/LibraryActions.qml index d7edb23..7e43d7d 100644 --- a/qml/LibraryActions.qml +++ b/qml/LibraryActions.qml @@ -17,6 +17,7 @@ Item { property string exportFolder: "" property url exportDirectory property int exportCount: 0 + property var exportWarnings: [] property url restoreSource property bool backupWorking: false property string operationError: "" @@ -25,7 +26,7 @@ Item { signal browseRequested() function openImportMenu(anchor) { menu.openFromButton(anchor) } function showImportHistory() { controller.inspectImports(); history.open() } - function createBackup() { backupPicker.selectedFile = String(backupPicker.currentFolder) + "/" + encodeURIComponent("笔记备份-" + Qt.formatDateTime(new Date(), "yyyyMMdd-HHmmss") + ".nkbackup"); backupPicker.open() } + function createBackup() { backupPicker.selectedFile = String(backupPicker.currentFolder).replace(/\/$/, "") + "/" + encodeURIComponent("笔记备份-" + Qt.formatDateTime(new Date(), "yyyyMMdd-HHmmss") + ".nkbackup"); backupPicker.open() } function restoreBackup() { restorePicker.open() } function importLegacy() { destination = ""; preview(["file://" + controller.legacyLibrary.split("/").map(encodeURIComponent).join("/")]) } function openExternalFile() { externalPicker.open() } @@ -42,7 +43,7 @@ Item { else transfer.close() } function onLibraryOperationFailed(message) { root.backupWorking = false; transfer.close(); review.close(); root.operationError = message; operationErrorDialog.open() } - function onNotesExported(directory, count) { root.exportDirectory = directory; root.exportCount = count; exportDone.open() } + function onNotesExported(directory, count, warnings) { root.exportDirectory = directory; root.exportCount = count; root.exportWarnings = warnings || []; exportDone.open() } function onBackupFinished() { root.backupWorking = false; transfer.close(); backupInfo.open(); root.browseRequested() } function onImportPreviewReady() { root.sources = Array.from(root.controller.importPlan.sources || []) @@ -52,30 +53,42 @@ Item { } function onImportFinished() { root.report = root.controller.importReport; review.close(); if (root.report.cancelled || (root.report.failed || 0) > 0 || (root.report.skipped || 0) > 0 || (root.report.warnings || []).length) result.open(); root.browseRequested() } } - LiquidFilePicker { + NotesFilePicker { + systemFileDialogs: root.controller.systemFileDialogs + onFailed: function(message) { root.operationError = message; operationErrorDialog.open() } sourceItem: root.sceneItem; acceptText: "选择"; rejectText: "取消" locationPlaceholder: "输入文件夹路径"; fileNamePlaceholder: "文件名"; emptyText: "此文件夹中没有可选文件" overwriteTitle: "替换这个文件?"; overwriteText: "目标位置已有同名文件,替换后将覆盖原内容。"; overwriteAcceptText: "替换"; unavailableText: "文件夹不存在或无法访问"; id: filePicker; title: "选择要导入的笔记"; mode: "openFiles"; nameFilters: ["*.md", "*.MD"]; onAccepted: root.preview(selectedFiles) } - LiquidFilePicker { + NotesFilePicker { + systemFileDialogs: root.controller.systemFileDialogs + onFailed: function(message) { root.operationError = message; operationErrorDialog.open() } sourceItem: root.sceneItem; acceptText: "选择"; rejectText: "取消" locationPlaceholder: "输入文件夹路径"; fileNamePlaceholder: "文件名"; emptyText: "此文件夹中没有可选文件" overwriteTitle: "替换这个文件?"; overwriteText: "目标位置已有同名文件,替换后将覆盖原内容。"; overwriteAcceptText: "替换"; unavailableText: "文件夹不存在或无法访问"; mode: "folder"; id: folderPicker; title: "选择要收集的文件夹"; onAccepted: root.preview([selectedFolder]) } - LiquidFilePicker { + NotesFilePicker { + systemFileDialogs: root.controller.systemFileDialogs + onFailed: function(message) { root.operationError = message; operationErrorDialog.open() } sourceItem: root.sceneItem; acceptText: "选择"; rejectText: "取消" locationPlaceholder: "输入文件夹路径"; fileNamePlaceholder: "文件名"; emptyText: "此文件夹中没有可选文件" overwriteTitle: "替换这个文件?"; overwriteText: "目标位置已有同名文件,替换后将覆盖原内容。"; overwriteAcceptText: "替换"; unavailableText: "文件夹不存在或无法访问"; id: externalPicker; title: "打开外部 Markdown"; nameFilters: ["*.md", "*.MD"]; onAccepted: root.controller.openExternal(selectedFile) } - LiquidFilePicker { + NotesFilePicker { + systemFileDialogs: root.controller.systemFileDialogs + onFailed: function(message) { root.operationError = message; operationErrorDialog.open() } sourceItem: root.sceneItem; acceptText: "选择"; rejectText: "取消" locationPlaceholder: "输入文件夹路径"; fileNamePlaceholder: "文件名"; emptyText: "此文件夹中没有可选文件" overwriteTitle: "替换这个文件?"; overwriteText: "目标位置已有同名文件,替换后将覆盖原内容。"; overwriteAcceptText: "替换"; unavailableText: "文件夹不存在或无法访问"; mode: "folder"; id: exportPicker; title: "选择导出位置"; onAccepted: root.controller.exportNotes(selectedFolder, root.exportPaths, root.exportFolder) } - LiquidFilePicker { + NotesFilePicker { + systemFileDialogs: root.controller.systemFileDialogs + onFailed: function(message) { root.operationError = message; operationErrorDialog.open() } id: backupPicker; sourceItem: root.sceneItem; title: "创建完整备份"; mode: "saveFile" acceptText: "保存备份"; rejectText: "取消"; defaultSuffix: "nkbackup"; nameFilters: ["*.nkbackup"] locationPlaceholder: "输入文件夹路径"; fileNamePlaceholder: "备份名称"; emptyText: "此文件夹中没有备份" overwriteTitle: "替换这个备份?"; overwriteText: "同名备份将被替换。"; overwriteAcceptText: "替换"; unavailableText: "文件夹不存在或无法访问" onAccepted: { root.backupWorking = true; root.controller.createBackup(selectedFile) } } - LiquidFilePicker { + NotesFilePicker { + systemFileDialogs: root.controller.systemFileDialogs + onFailed: function(message) { root.operationError = message; operationErrorDialog.open() } id: restorePicker; sourceItem: root.sceneItem; title: "选择笔记备份"; mode: "openFile" acceptText: "继续"; rejectText: "取消"; nameFilters: ["*.nkbackup"] locationPlaceholder: "输入文件夹路径"; emptyText: "此文件夹中没有备份"; unavailableText: "文件夹不存在或无法访问" @@ -88,7 +101,7 @@ Item { acceptEnabled: !root.controller.busy contentItem: ScrollView { id: restoreReviewScroll; clip: true; contentWidth: availableWidth - Label { width: restoreReviewScroll.availableWidth; text: "将用备份替换当前笔记空间,包括附件、历史和最近删除。恢复前会自动保存当前空间的完整副本。外部 Markdown 文件不受影响。\n\n" + decodeURIComponent(String(root.restoreSource).split("/").pop()); textFormat: Text.PlainText; wrapMode: Text.Wrap; color: AppTheme.text } + Label { width: restoreReviewScroll.availableWidth; text: (root.controller.recoveryAvailable ? "当前笔记空间无法打开。将先保留原空间目录,再从备份恢复笔记、附件、历史和最近删除。原空间不会被覆盖,外部 Markdown 文件不受影响。" : "将用备份替换当前笔记空间,包括附件、历史和最近删除。恢复前会自动保存当前空间的完整副本。外部 Markdown 文件不受影响。") + "\n\n" + decodeURIComponent(String(root.restoreSource).split("/").pop()); textFormat: Text.PlainText; wrapMode: Text.Wrap; color: AppTheme.text } } onAccepted: { root.backupWorking = true; root.controller.restoreBackup(root.restoreSource) } } @@ -119,6 +132,11 @@ Item { Label { Layout.fillWidth: true; text: "已导出 " + root.exportCount + " 篇笔记。"; wrapMode: Text.Wrap; color: AppTheme.text } Label { Layout.fillWidth: true; text: "保存位置"; color: AppTheme.mutedText; font.pixelSize: 12 } Label { Layout.fillWidth: true; text: decodeURIComponent(String(root.exportDirectory).replace(/^file:\/\//, "")); textFormat: Text.PlainText; wrapMode: Text.WrapAnywhere; color: AppTheme.mutedText; font.pixelSize: 12 } + Label { Layout.fillWidth: true; visible: root.exportWarnings.length > 0; text: "部分链接未能完整导出,原文已保留:"; wrapMode: Text.Wrap; color: AppTheme.text } + Repeater { + model: root.exportWarnings + Label { required property var modelData; Layout.fillWidth: true; text: (modelData.path ? modelData.path + ":" : "") + modelData.message; textFormat: Text.PlainText; wrapMode: Text.WrapAnywhere; color: AppTheme.mutedText; font.pixelSize: 12 } + } LiquidActionButton { text: "查看导出内容"; materialForm: true; onClicked: { Qt.openUrlExternally(root.exportDirectory); exportDone.close() } } } } @@ -129,12 +147,14 @@ Item { contentItem: Label { text: root.operationError; textFormat: Text.PlainText; wrapMode: Text.Wrap; color: AppTheme.text } } LiquidDialog { - id: backupInfo; sourceItem: root.sceneItem; title: root.controller.backupResult.safetyCopy ? "备份已恢复" : "备份已保存" + id: backupInfo; sourceItem: root.sceneItem + readonly property bool restored: !!(root.controller.backupResult.safetyCopy || root.controller.backupResult.preservedDirectory) + title: restored ? "备份已恢复" : "备份已保存" standardButtons: Dialog.Close; closeText: "完成" contentItem: ColumnLayout { spacing: 12 - Label { Layout.fillWidth: true; wrapMode: Text.Wrap; text: root.controller.backupResult.safetyCopy ? "已恢复笔记、附件、历史和最近删除。恢复前的内容保存在:" : "完整备份包含笔记、文件夹、标签、附件、历史、导入记录和最近删除。保存位置:"; color: AppTheme.text } - Label { Layout.fillWidth: true; wrapMode: Text.WrapAnywhere; textFormat: Text.PlainText; text: root.controller.backupResult.safetyCopy || root.controller.backupResult.file || ""; color: AppTheme.mutedText } + Label { Layout.fillWidth: true; wrapMode: Text.Wrap; text: root.controller.backupResult.preservedDirectory ? "已恢复笔记、附件、历史和最近删除。原笔记空间的完整目录保留在:" : root.controller.backupResult.safetyCopy ? "已恢复笔记、附件、历史和最近删除。恢复前的内容保存在:" : "完整备份包含笔记、文件夹、标签、附件、历史、导入记录和最近删除。保存位置:"; color: AppTheme.text } + Label { Layout.fillWidth: true; wrapMode: Text.WrapAnywhere; textFormat: Text.PlainText; text: root.controller.backupResult.preservedDirectory || root.controller.backupResult.safetyCopy || root.controller.backupResult.file || ""; color: AppTheme.mutedText } Label { Layout.fillWidth: true; wrapMode: Text.Wrap; visible: (root.controller.backupResult.cacheFailures || 0) > 0; text: "部分附件缓存未能刷新。附件原件仍在备份和数据库中,请检查磁盘空间后重新打开应用。"; color: AppTheme.mutedText } } } @@ -174,7 +194,7 @@ Item { } LiquidCheckBox { visible: !!root.controller.importPlan.hasFolders; Layout.fillWidth: true; textWrapMode: Text.WordWrap; text: "保留文件夹结构"; checked: root.preserveStructure; enabled: !root.controller.busy; onClicked: { root.preserveStructure = checked; root.controller.previewImport(root.sources, root.destination, checked) } } LiquidCheckBox { Layout.fillWidth: true; textWrapMode: Text.WordWrap; visible: (root.controller.importPlan.duplicates || 0) > 0; text: "保留重复副本(发现 " + (root.controller.importPlan.duplicates || 0) + " 篇已导入笔记)"; checked: root.keepDuplicates; enabled: !root.controller.busy; onClicked: root.keepDuplicates = checked } - Label { Layout.fillWidth: true; text: "关联附件会一并收集。创建独立副本,不修改原文件,也不自动同步后续修改。"; wrapMode: Text.Wrap; color: AppTheme.mutedText; font.pixelSize: 12 } + Label { Layout.fillWidth: true; text: "会收集可访问的关联附件。需要一起导入相对路径的附件时,请使用“从文件夹收集”。创建独立副本,不修改原文件,也不自动同步后续修改。"; wrapMode: Text.Wrap; color: AppTheme.mutedText; font.pixelSize: 12 } Label { Layout.fillWidth: true; visible: !!(root.controller.importPlan.problems || []).length; text: (root.controller.importPlan.problems || []).length + " 项无法读取或格式不支持,完成后可查看详情。"; wrapMode: Text.Wrap; color: AppTheme.mutedText; font.pixelSize: 12 } } } diff --git a/qml/Main.qml b/qml/Main.qml index d3fa577..e044984 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -740,13 +740,24 @@ ApplicationWindow { } Connections { target: session; function onStateChanged() { if (root.pendingFormat) Qt.callLater(root.deliverFormat) } } LiquidBackdrop { id: readingBackdrop; sourceItem: readingScene; registerAsDefault: false } - KosEmptyState { + Column { anchors.centerIn: parent; width: Math.min(420, root.width - 64) visible: !root.hasLibrary - symbol: ""; title: "让想法有处可归" - description: "写下想法,让灵感与日常记录有处可归。" - actionText: controller.busy ? "正在准备…" : "开始记录"; actionEnabled: !controller.busy - onActionTriggered: controller.restoreWorkspace() + spacing: 12 + KosEmptyState { + width: parent.width + symbol: ""; title: controller.error ? "笔记空间未能打开" : "让想法有处可归" + description: controller.error || "写下想法,让灵感与日常记录有处可归。" + actionText: controller.busy ? "正在准备…" : controller.error ? "重试打开" : "开始记录" + actionEnabled: !controller.busy + onActionTriggered: controller.restoreWorkspace() + } + Button { + anchors.horizontalCenter: parent.horizontalCenter + text: "从完整备份恢复"; visible: controller.recoveryAvailable + enabled: !controller.busy + onClicked: libraryActions.restoreBackup() + } } KosSidebar { id: sidebar diff --git a/qml/NoteReadingView.qml b/qml/NoteReadingView.qml index a769551..375ecc2 100644 --- a/qml/NoteReadingView.qml +++ b/qml/NoteReadingView.qml @@ -8,6 +8,9 @@ ScrollView { required property string source required property string noteTitle required property string documentPath + // The controller applies the same manifest and containment policy as export + // and the editor. Without a resolver, image resources remain disabled. + property var assetResolver: null property string fontFamily: "sans-serif" property int titleSize: 24 property string modified: "" @@ -17,15 +20,12 @@ ScrollView { clip: true contentWidth: availableWidth function localImage(value) { - let path = value.trim().replace(/^<|>$/g, "") - // Only filesystem resources. Encoded schemes and network shares are rejected too. - try { path = decodeURIComponent(path) } catch (e) { return "" } - if (!path || /[\u0000-\u001f]/.test(path) || /^[/\\]{2}/.test(path)) return "" - if (/^[a-z][a-z0-9+.-]*:/i.test(path)) return "" - if (!path.startsWith("/")) path = documentPath.slice(0, documentPath.lastIndexOf("/") + 1) + path - const parts = [] - path.split("/").forEach(function(part) { if (part === "..") parts.pop(); else if (part && part !== ".") parts.push(encodeURIComponent(part)) }) - return "file:///" + parts.join("/") + const path = value.trim().replace(/^<|>$/g, "") + if (!path || !documentPath || typeof assetResolver !== "function") return "" + try { + const resolved = assetResolver(path) + return resolved ? resolved.toString() : "" + } catch (e) { return "" } } readonly property var blocks: { let markdown = source.replace(/^\uFEFF/, "") diff --git a/qml/NotesFilePicker.qml b/qml/NotesFilePicker.qml new file mode 100644 index 0000000..250dc8e --- /dev/null +++ b/qml/NotesFilePicker.qml @@ -0,0 +1,199 @@ +import QtQuick +import QtQuick.Window +import QtCore +import QtQuick.Dialogs as SystemDialogs +import Kos.Ui + +// Keep selection separate from storage: a system dialog's returned URL is the +// exact path granted by the portal, including its final file name. +QtObject { + id: root + property bool systemFileDialogs: false + property Item sourceItem: null + property string mode: "openFile" // openFile, openFiles, folder, saveFile + property string title: "" + property url currentFolder: toUrl(StandardPaths.writableLocation(StandardPaths.HomeLocation)) + property url selectedFile + property url selectedFolder + property var selectedFiles: [] + property var nameFilters: ["*"] + property string defaultSuffix: "" + property string acceptText: mode === "saveFile" ? qsTr("Save") : qsTr("Open") + property string rejectText: qsTr("Cancel") + property string locationPlaceholder: qsTr("Folder location") + property string fileNamePlaceholder: qsTr("File name") + property string unavailableText: qsTr("Folder unavailable") + property string emptyText: qsTr("No matching files") + property string overwriteTitle: qsTr("Replace this file?") + property string overwriteText: qsTr("A file with this name already exists.") + property string overwriteAcceptText: qsTr("Replace") + readonly property bool visible: _pending || _finishing || (!!_backend && _backend.visible) + signal accepted() + signal rejected() + signal failed(string message) + + property var _backend: null + property bool _pending: false + property bool _finishing: false + property string _activeMode: "" + property string _activeSuffix: "" + property bool _activeSystem: false + Component.onDestruction: { + _pending = false + const dialog = _backend + _backend = null + if (dialog) { dialog.close(); dialog.destroy() } + } + // Components are replaceable by the contract test; only one is instantiated. + property Component _fileComponent: Component { SystemDialogs.FileDialog {} } + property Component _folderComponent: Component { SystemDialogs.FolderDialog {} } + property Component _localComponent: Component { LiquidFilePicker {} } + property Connections _backendSignals: Connections { + target: root._backend + ignoreUnknownSignals: true + function onAccepted() { root._complete(root._backend, true) } + function onRejected() { root._complete(root._backend, false) } + function onVisibleChanged() { + if (root._backend && !root._backend.visible) { + const dialog = root._backend + Qt.callLater(function() { root._hidden(dialog) }) + } + } + function onClosed() { root._hidden(root._backend) } + } + + function toUrl(path) { + if (String(path).startsWith("file:")) return path + return "file://" + String(path).split("/").map(encodeURIComponent).join("/") + } + function _localUrl(value) { + const text = String(value) + return (/^file:\/\/(?:localhost)?\//i.test(text) || /^file:\/(?!\/)/i.test(text)) + && !/[?#]/.test(text) + } + function _parentUrl(value) { + const text = String(value) + return text.slice(0, text.lastIndexOf("/") + 1) + } + function _suffix() { return defaultSuffix.replace(/^\./, "") } + function _prefill() { + let value = String(selectedFile) + const suffix = _suffix() + if (value && suffix && !value.toLowerCase().endsWith("." + suffix.toLowerCase())) + value += "." + encodeURIComponent(suffix) + return value + } + function _systemFilters() { + // LiquidFilePicker takes a union of glob patterns. Qt takes named + // alternatives; keep the union together, including *.md and *.MD. + const patterns = Array.from(nameFilters || []) + return patterns.length ? [qsTr("Files") + " (" + patterns.join(" ") + ")"] : [] + } + function open() { + if (visible) return + if (["openFile", "openFiles", "folder", "saveFile"].indexOf(mode) < 0) { + failed(qsTr("Unsupported file selection mode")); rejected(); return + } + _activeMode = mode + _activeSuffix = _suffix() + _activeSystem = systemFileDialogs + _pending = true + let component, properties + if (systemFileDialogs) { + properties = {title: title, parentWindow: sourceItem ? sourceItem.Window.window : null, + currentFolder: currentFolder, acceptLabel: acceptText, rejectLabel: rejectText, + modality: Qt.WindowModal} + if (mode === "folder") { + component = _folderComponent + } else { + component = _fileComponent + properties.fileMode = mode === "openFiles" ? SystemDialogs.FileDialog.OpenFiles + : mode === "saveFile" ? SystemDialogs.FileDialog.SaveFile : SystemDialogs.FileDialog.OpenFile + properties.nameFilters = _systemFilters() + // Qt can append this suffix after the portal has granted the + // original URL. Supply it only in the proposed name instead. + properties.defaultSuffix = "" + if (mode === "saveFile" && String(selectedFile)) properties.selectedFile = _prefill() + } + } else { + component = _localComponent + properties = {sourceItem: sourceItem, mode: mode, title: title, currentFolder: currentFolder, + selectedFile: selectedFile, nameFilters: nameFilters, defaultSuffix: _suffix(), + acceptText: acceptText, rejectText: rejectText, locationPlaceholder: locationPlaceholder, + fileNamePlaceholder: fileNamePlaceholder, unavailableText: unavailableText, emptyText: emptyText, + overwriteTitle: overwriteTitle, overwriteText: overwriteText, overwriteAcceptText: overwriteAcceptText} + } + // Recreate native helpers per request: some portal implementations retain + // the previous save selection and otherwise reuse the old proposed name. + _backend = component ? component.createObject(sourceItem || root, properties) : null + if (!_backend) { _fail(qsTr("无法打开文件选择器,请重试。")); return } + const dialog = _backend + dialog.open() + // A dialog that opened successfully can be dismissed before the next + // event-loop turn; its later hiding is cancellation, not a show failure. + if (!dialog.visible) { + Qt.callLater(function() { + if (root._backend === dialog && root._pending && !dialog.visible) + root._fail(qsTr("无法打开文件选择器,请检查系统文件对话框服务。")) + }) + } + } + function close() { reject() } + function reject() { + if (!_pending) return + const dialog = _backend + if (dialog) dialog.reject() + // Closing a backend must complete the request even if it only hides. + if (_pending) _complete(dialog, false) + } + function _fail(message) { + _finishing = true + _pending = false + failed(message) + rejected() + _finishing = false + const dialog = _backend + if (dialog && dialog.visible) dialog.reject() + if (dialog === _backend && dialog && !dialog.visible) _retire(dialog) + } + function _complete(dialog, accept) { + if (dialog !== _backend || !_pending) return + if (accept) { + let values = _activeMode === "folder" ? [dialog.selectedFolder] + : _activeMode === "openFiles" ? Array.from(dialog.selectedFiles || []) : [dialog.selectedFile] + if (!values.length || values.some(value => !_localUrl(value))) { + _fail(qsTr("请选择本地文件或文件夹。")); return + } + if (_activeSystem && _activeMode === "saveFile" && _activeSuffix + && !String(values[0]).toLowerCase().endsWith("." + encodeURIComponent(_activeSuffix).toLowerCase())) { + _fail(qsTr("文件名需要以 .%1 结尾,请重新选择保存位置。").arg(_activeSuffix)); return + } + if (_activeMode === "folder") { + selectedFolder = values[0] + currentFolder = values[0] + } else { + selectedFile = values[0] + selectedFiles = values.map(value => String(value)) + currentFolder = _activeSystem ? _parentUrl(values[0]) : dialog.currentFolder + } + } else if (!_activeSystem && dialog && _localUrl(dialog.currentFolder)) { + currentFolder = dialog.currentFolder + } + _finishing = true + _pending = false + if (accept) accepted() + else rejected() + _finishing = false + if (dialog && !dialog.visible) _retire(dialog) + } + function _hidden(dialog) { + if (!dialog || dialog !== _backend || dialog.visible) return + if (_pending) _complete(dialog, false) + else _retire(dialog) + } + function _retire(dialog) { + if (dialog !== _backend) return + _backend = null + dialog.destroy() + } +} diff --git a/qml/SourceEditor.qml b/qml/SourceEditor.qml index 2aca4bc..fdeb5a6 100644 --- a/qml/SourceEditor.qml +++ b/qml/SourceEditor.qml @@ -28,7 +28,7 @@ Item { function toggleOutline(anchor) { tools.toggleOutline(anchor) } function correctSpelling(word) { web.replaceMisspelledWord(word); editorFacade.forceActiveFocus() } function exportBaseName() { return String(root.controller.noteTitle || "笔记").replace(/[\\/:*?"<>|\u0000-\u001f]/g, "-").replace(/[. ]+$/g, "").slice(0,100) || "笔记" } - function chooseExport(format) { if (!webReady || session.sourceMode || session.composing || exporting) return; exportDialog.format = format; exportDialog.selectedFile = String(exportDialog.currentFolder) + "/" + encodeURIComponent(root.exportBaseName() + "." + format); exportDialog.open() } + function chooseExport(format) { if (!webReady || session.sourceMode || session.composing || exporting) return; exportDialog.format = format; exportDialog.selectedFile = String(exportDialog.currentFolder).replace(/\/$/, "") + "/" + encodeURIComponent(root.exportBaseName() + "." + format); exportDialog.open() } function beginExport(format, target, printing) { if (!webReady || session.sourceMode || session.composing || exporting) return const path = printing ? controller.printPreviewPath() : controller.outputPath(target, format) @@ -170,7 +170,9 @@ Item { anchors.fill: parent source: root; session: root.session; controller: root.controller; sceneItem: root.sceneItem } - LiquidFilePicker { + NotesFilePicker { + systemFileDialogs: root.controller.systemFileDialogs + onFailed: function(message) { root.notify(message) } sourceItem: root.sceneItem; acceptText: "选择"; rejectText: "取消" locationPlaceholder: "输入文件夹路径"; fileNamePlaceholder: "文件名"; emptyText: "此文件夹中没有可选文件" overwriteTitle: "替换这个文件?"; overwriteText: "目标位置已有同名文件,替换后将覆盖原内容。"; overwriteAcceptText: "替换"; unavailableText: "文件夹不存在或无法访问"; @@ -182,7 +184,9 @@ Item { onAccepted: root.controller.importAttachment(request, generation, selectedFile) onRejected: bridge.attachmentResult(request, generation, {ok: false, error: "cancelled"}) } - LiquidFilePicker { + NotesFilePicker { + systemFileDialogs: root.controller.systemFileDialogs + onFailed: function(message) { root.notify(message) } sourceItem: root.sceneItem; acceptText: "导出"; rejectText: "取消" locationPlaceholder: "输入文件夹路径"; fileNamePlaceholder: "文件名"; emptyText: "此文件夹中没有可选文件" overwriteTitle: "替换这个文件?"; overwriteText: "目标位置已有同名文件,替换后将覆盖原内容。"; overwriteAcceptText: "替换"; unavailableText: "文件夹不存在或无法访问"; diff --git a/src/EditorExport.cpp b/src/EditorExport.cpp index fe79e92..9b9657d 100644 --- a/src/EditorExport.cpp +++ b/src/EditorExport.cpp @@ -1,4 +1,5 @@ #include "NotesController.h" +#include "storage/ManagedAssets.h" #include #include #include @@ -14,7 +15,10 @@ void NotesController::setEditorExporting(bool value) { if (m_exporting != value) QString NotesController::outputPath(const QUrl &target, const QString &extension) const { if (!target.isLocalFile() || (extension != "html" && extension != "pdf")) return {}; QString file = target.toLocalFile(); - if (!file.endsWith('.' + extension, Qt::CaseInsensitive)) file += '.' + extension; + if (!file.endsWith('.' + extension, Qt::CaseInsensitive)) { + if (systemFileDialogs()) return {}; // A portal grant covers the exact chosen name. + file += '.' + extension; + } if (QFileInfo(file).isSymLink()) return {}; return file; } @@ -65,17 +69,20 @@ QString NotesController::exportDocument(const QString &body, const QString &titl QString html = body; const QRegularExpression images("]*\\bsrc=\"([^\"]+)\"[^>]*>", QRegularExpression::CaseInsensitiveOption); auto matches = images.globalMatch(body); - const QString root = QFileInfo(assetRoot()).canonicalFilePath(); + const ManagedAssets::Context assets{assetRoot(), documentUrl(), assetManifest(), managedAssets()}; while (matches.hasNext()) { const auto match = matches.next(); const QString encoded = match.captured(1); const QUrl url(QString(encoded).replace("&", "&")); - if (url.scheme() == "data") continue; - const QString file = url.isLocalFile() ? QFileInfo(url.toLocalFile()).canonicalFilePath() : url.scheme() == "nkasset" ? QFileInfo(url.path()).canonicalFilePath() : QString(); - if (root.isEmpty() || file.isEmpty() || !file.startsWith(root + '/') || QFileInfo(file).size() > 32 * 1024 * 1024) { return {}; } - const QByteArray bytes = read(file); - if (bytes.isEmpty()) { return {}; } - const QString data = "data:" + QMimeDatabase().mimeTypeForData(bytes).name() + ";base64," + QString::fromLatin1(bytes.toBase64()); + if (url.scheme() == "data") { + if (!url.toString().startsWith("data:image/", Qt::CaseInsensitive)) return {}; + continue; + } + QByteArray bytes; + if (ManagedAssets::resolvePath(assets, url, &bytes).isEmpty()) return {}; + const QString mime = QMimeDatabase().mimeTypeForData(bytes).name(); + if (!mime.startsWith("image/")) return {}; + const QString data = "data:" + mime + ";base64," + QString::fromLatin1(bytes.toBase64()); QString image = match.captured(); image.replace("src=\"" + encoded + "\"", "src=\"" + data + "\""); html.replace(match.captured(), image); } diff --git a/src/NotesController.cpp b/src/NotesController.cpp index ee7aa93..5164c2b 100644 --- a/src/NotesController.cpp +++ b/src/NotesController.cpp @@ -1,4 +1,5 @@ #include "NotesController.h" +#include "storage/ManagedAssets.h" #include "EditorSession.h" #include "storage/NotesRepository.h" #include @@ -29,14 +30,14 @@ NotesController::NotesController(EditorSession *session, NotesRepository *reposi m_textHistoryReplay=false; ++m_revision; m_status = m_conflict ? "冲突 · 本地输入仍保留" : "已修改"; - if (!m_session->composing() && !m_conflict) m_autosave.start(); + if (!m_session->composing()) m_autosave.start(); emit changed(); }); connect(session, &EditorSession::stateChanged, this, [this] { emit changed(); if (m_session->composing()) m_autosave.stop(); - else if (dirty() && !m_saving && !m_conflict) { - if (m_transition) save(); else m_autosave.start(); + else if (dirty() && !m_saving) { + if (m_transition) advance(); else m_autosave.start(); } else if (m_transition && !m_session->composing()) advance(); }); if (repository) { @@ -121,7 +122,12 @@ void NotesController::createFolder(const QString &path, const QString &icon) { } void NotesController::save() { if (!dirty() || m_saving || m_path.isEmpty() || m_session->composing() || m_loading) return; - if (m_conflict) { m_error = "文件已在外部改变。本地输入仍保留;请另存副本,或明确放弃本地输入后重新读取。"; emit changed(); return; } + if (m_conflict) { + m_autosave.stop(); + send("checkpoint", {{"path", m_path}, {"text", m_session->source()}, {"baseHash", m_base}}); + m_error = "文件已在外部改变。本地输入仍保留,正在保存恢复草稿;请另存副本,或明确放弃本地输入后重新读取。"; + emit changed(); return; + } m_autosave.stop(); m_error.clear(); m_status = "保存中…"; m_saving = send("save", {{"path", m_path}, {"text", m_session->source()}, {"baseHash", m_base}, {"sourceNewlines", m_session->sourceEdited()}}); emit changed(); } @@ -158,7 +164,7 @@ void NotesController::recoverDraft(const QString &path, const QString &text, con void NotesController::saveCopy(const QString &target) { if (m_session->composing() || busy() || m_path.isEmpty() || m_saving) return; m_loading = true; - send("saveAs", {{"path", target}, {"text", m_session->source()}, {"newIdentity", true}}); emit changed(); + send("saveAs", {{"path", target}, {"sourcePath", m_path}, {"text", m_session->source()}, {"newIdentity", true}}); emit changed(); } void NotesController::copyRecovery(const QString &text) { if (busy()) return; @@ -202,17 +208,20 @@ void NotesController::openLink(const QString &href) { return; } } - if (!resolved.isLocalFile()) return; - const QString file = QFileInfo(resolved.toLocalFile()).canonicalFilePath(); + if (!resolved.isLocalFile() && resolved.scheme() != "nkasset") return; + const QUrl target = externalDocument() + ? ManagedAssets::resolveExternalLink({assetRoot(), documentUrl(), {}, false}, url) + : resolveAsset(url); + const QString file = target.toLocalFile(); const QString root = QFileInfo(assetRoot()).canonicalFilePath(); - if (file.isEmpty() || root.isEmpty() || !file.startsWith(root + '/')) { + if (file.isEmpty()) { m_error = "链接目标不存在或不在当前笔记库中"; emit changed(); return; } if (QFileInfo(file).suffix().compare("md", Qt::CaseInsensitive) == 0) { if (busy()) return; const QString relative = QDir(root).relativeFilePath(file), fragment = resolved.fragment(); drain([this, relative, fragment] { m_loading = true; send("read", {{"path", relative}, {"fragment", fragment}}); emit changed(); }); - } else QDesktopServices::openUrl(QUrl::fromLocalFile(file)); + } else QDesktopServices::openUrl(target); } QString NotesController::positionKey() const { return "positions/" + QString::fromLatin1(QCryptographicHash::hash((m_library + '/' + (m_selectedId.isEmpty() || m_duplicateIds.contains(m_selectedId) ? m_selectedId + "/" + m_path : m_selectedId)).toUtf8(), QCryptographicHash::Sha256).toHex()); @@ -285,6 +294,9 @@ void NotesController::report(const QVariantMap &r) { {"attachmentChanged", "附件回收数据发生变化,已阻止恢复"}, {"databaseError", "内部笔记数据库无法读取或保存"}, {"databaseVersion", "笔记数据库版本较新,请升级应用"}, + {"databaseCorrupt", "笔记数据库未通过完整性检查,请从完整备份恢复"}, + {"recoveryPointerInvalid", "笔记空间的恢复位置记录无效,原目录仍保留"}, + {"recoveryActivationUncertain", "恢复位置已写入,但磁盘同步未确认,请关闭应用后重新打开"}, {"invalidName", "名称无效,请使用普通名称,不要输入路径"}, {"invalidBackup", "这个文件不是可用的笔记备份,或备份版本不受支持"}, {"backupFailed", "备份或恢复未能完成,原有数据仍保留"}, @@ -302,10 +314,19 @@ void NotesController::receive(quint64 id, const QString &op, const QVariantMap & if (!m_pending.contains(id)) return; const Pending p = m_pending.take(id); if (p.op != op) return; - if (op != "selectLibrary" && (r.value("library").toString() != m_library || r.value("generation").toULongLong() != m_generation || p.generation != m_generation)) return; + const bool recoveredLibrary = op == "restoreBackup" && r.value("ok").toBool() + && p.library == m_library && p.generation == m_generation + && ((!m_library.isEmpty() && r.value("library").toString() == m_library) + || (m_recoveryAvailable && m_library.isEmpty() && r.value("recoveredInitialization").toBool())); + if (op != "selectLibrary" && !recoveredLibrary && (r.value("library").toString() != m_library || r.value("generation").toULongLong() != m_generation || p.generation != m_generation)) return; + if (op == "checkpoint" && (p.epoch != m_epoch || p.path != m_path || p.revision != m_revision)) return; if (op == "search" && p.args.value("serial").toULongLong() != m_searchSerial) return; const bool documentOp = op == "read" || op == "save" || op == "saveAs" || op == "create" || op == "trash" || op == "rename"; if (documentOp && !p.args.value("recoveryCopy").toBool() && p.epoch != m_epoch) return; + if (r.value("ok").toBool()) { + if (r.contains("managedAssets")) m_managedAssets = r.value("managedAssets").toBool(); + if (r.contains("assetManifest")) m_assetManifest = r.value("assetManifest").toMap(); + } if(r.contains("undoState"))m_undoState=r.value("undoState").toMap(); if(r.value("historyRecorded").toBool() || op=="undo" || op=="redo")m_organizationHistory=true; if(op=="undo" || op=="redo" || op=="editFolder" || op=="createFolder" || op=="moveFolder" || op=="purgeFolder")m_organizing=false; @@ -314,6 +335,7 @@ void NotesController::receive(quint64 id, const QString &op, const QVariantMap & if(op == "importPreview" || op == "importNotes")m_operationProgress.clear(); if ((documentOp && !p.args.value("recoveryCopy").toBool()) || op == "selectLibrary") m_loading = false; if (!r.value("ok").toBool()) { + if (op == "selectLibrary" && m_library.isEmpty()) m_recoveryAvailable = r.value("recoveryAvailable").toBool(); if (op == "createFolder") { m_status = r.value("error") == "alreadyExists" ? QStringLiteral("这个位置已有同名文件夹(可能在最近删除中),请修改名称或父级文件夹。") : organizerError(r.value("error").toString()); emit changed(); emit folderCreationFailed(m_status); return; @@ -340,10 +362,18 @@ void NotesController::receive(quint64 id, const QString &op, const QVariantMap & if(op=="importNotes") { refresh();inspectImports();message+="。已提交的内容仍保留,可从全部笔记查看;再次导入时跳过重复内容继续。"; } else if(op=="importPreview")message+="。当前笔记未改变,请检查所选文件或文件夹。"; else if(op=="exportNotes")message+="。原笔记仍保留,请检查保存位置。"; - else message+="。当前笔记空间保持不变,请检查备份文件或保存位置后重试。"; + else if (r.value("activationUncertain").toBool()) { + m_recoveryAvailable=false; + message="恢复切换已写入,但磁盘同步未能确认。原目录和恢复目录均保留;请关闭应用后重新打开确认当前空间。"; + } else message+="。当前笔记空间保持不变,请检查备份文件或保存位置后重试。"; m_status="操作未完成";m_transition=false;m_afterSave={};emit changed();emit libraryOperationFailed(message);return; } - report(r); if (m_restoreWorkspace) { m_restoreWorkspace = false; m_error.prepend("上次工作区无法恢复,请重新选择笔记库。"); } m_transition = false; m_afterSave = {}; emit changed(); return; + auto failure = r; + if (op == "save" && p.revision != m_revision && failure.contains("recoveryDurable")) failure.insert("recoveryDurable", false); + report(failure); + if (op == "save" && p.revision != m_revision && !m_session->composing()) m_autosave.start(); + if (m_restoreWorkspace) { m_restoreWorkspace = false; m_error.prepend("笔记空间未能打开,请重试打开或从完整备份恢复。"); } + m_transition = false; m_afterSave = {}; emit changed(); return; } if(op=="undo" || op=="redo") { QString target; @@ -379,6 +409,13 @@ void NotesController::receive(quint64 id, const QString &op, const QVariantMap & if(op == "backup" || op == "restoreBackup") { m_backupResult=r;m_error.clear(); if(op=="restoreBackup") { + if (recoveredLibrary) { + m_library = r.value("library").toString(); m_generation = r.value("generation").toULongLong(); + setPreference("lastLibrary", r.value("entryRoot", m_library)); + } + m_recoveryAvailable=false;m_recovery.clear();m_attachments.clear();m_batchReport.clear();m_undoState.clear(); + m_tagPlan.clear();m_smartFolders.clear();m_linkCandidates.clear();m_importHistory.clear(); + setPreference("lastNote", QVariantMap{}); ++m_epoch;m_path.clear();m_selectedId.clear();m_base.clear();m_revision=m_savedRevision=0; m_history.clear();m_historyIndex=-1;m_historyTarget=-1;m_conflict=false;m_readOnly=false; m_query.clear();m_filter="all";++m_searchSerial;m_importPlan.clear();m_importReport.clear(); @@ -389,7 +426,12 @@ void NotesController::receive(quint64 id, const QString &op, const QVariantMap & emit changed();emit backupFinished();return; } if (op == "imports") { m_importHistory = r.value("items").toList(); emit changed(); return; } - if (op == "exportNotes") { m_status = QString("已导出 %1 篇笔记").arg(r.value("count").toInt()); emit changed();emit notesExported(QUrl::fromLocalFile(r.value("directory").toString()),r.value("count").toInt());return; } + if (op == "exportNotes") { + const auto warnings = r.value("warnings").toList(); + m_status = QString("已导出 %1 篇笔记").arg(r.value("count").toInt()); + if (!warnings.isEmpty()) m_status += " · 部分链接需检查"; + emit changed(); emit notesExported(QUrl::fromLocalFile(r.value("directory").toString()), r.value("count").toInt(), warnings); return; + } if(QStringList{"knowledge","history","historyVersion","resolveWiki","createWiki"}.contains(op) && (p.epoch!=m_epoch||p.path!=m_path))return; if(op=="templates"||op=="templateSave"||op=="templateDelete"){m_templates=r.value("templates").toMap();emit changed();return;} if(op=="fromTemplate"||op=="dailyNote"){m_query.clear();m_filter="all";++m_searchSerial;load(r.value("path").toString());refresh();return;} @@ -449,6 +491,7 @@ void NotesController::receive(quint64 id, const QString &op, const QVariantMap & return; } if (op == "importAttachment") { + emit changed(); // Publish the owned-asset context before the editor requests it. emit attachmentReady(p.args.value("request").toString(), p.args.value("webEpoch").toInt(), r); if (p.epoch == m_epoch) m_status = "附件已导入笔记库"; emit changed(); return; @@ -470,7 +513,9 @@ void NotesController::receive(quint64 id, const QString &op, const QVariantMap & m_applying = true; m_session->loadPrototype({}); m_applying = false; applyListing(r); m_status = r.value("initializationNotice").toString(); if (m_status.isEmpty()) m_status = "请选择笔记"; - setPreference("lastLibrary", m_library); + m_recoveryAvailable = false; + m_managedAssets = r.value("managedAssets").toBool(); m_assetManifest = r.value("assetManifest").toMap(); + setPreference("lastLibrary", r.value("entryRoot", m_library)); // Startup needs draft locks and recovery metadata, not every retained // version's full body. Opening recovery explicitly loads those previews. send("recovery", {{"includeCommitText", false}}); refresh(); @@ -508,7 +553,7 @@ void NotesController::receive(quint64 id, const QString &op, const QVariantMap & QSettings().setValue(positionKey(), position); recordActiveDocument(); m_savedRevision = p.revision; // Never setPlainText here: preserve IME, cursor and undo. - m_status = dirty() ? "已修改 · 正在保存后续输入" : "已保存"; m_error.clear(); + m_status = dirty() ? "已修改 · 正在保存后续输入" : r.value("recoveryCleanupFailed").toBool() ? "已保存 · 旧恢复草稿未能清理,可在恢复中心检查" : "已保存"; m_error.clear(); if (dirty()) { if (!m_session->composing()) save(); } else { refresh(); send("recovery", {{"includeCommitText", m_recoveryVisible}}); advance(); } } else if (op == "read" || op == "create") { // A clean reload can race with typing while the response is in flight. @@ -518,6 +563,7 @@ void NotesController::receive(quint64 id, const QString &op, const QVariantMap & m_selectedId = r.value("id").toString(); m_favorite = r.value("favorite").toBool(); for (const auto &v : m_notes) if (v.toMap().value("path").toString() == m_path) m_selectedId = v.toMap().value("id").toString(); m_readOnly = r.value("readOnly").toBool(); m_conflict = false; m_error.clear(); + emit changed(); // Set resource scope before publishing the new document. m_applying = true; m_session->loadPrototype(p.args.contains("recoverText") ? p.args.value("recoverText").toString() : r.value("text").toString()); m_applying = false; m_revision = m_savedRevision = 0; if (p.args.contains("recoverText")) { ++m_revision; m_conflict = p.args.value("recoverBase").toString() != m_base; } @@ -567,6 +613,7 @@ void NotesController::receive(quint64 id, const QString &op, const QVariantMap & // The original recovery remains available. Explicit Save Copy resolves this // buffer by explicitly opening the new identity; original draft is retained. m_path = r.value("path").toString(); m_base = r.value("baseHash").toString(); ++m_epoch; + emit changed(); // Publish the copied document's resource scope first. m_applying = true; m_session->loadPrototype(r.value("text").toString()); m_applying = false; m_revision = m_savedRevision = 0; m_conflict = false; m_readOnly = false; m_error.clear(); applyListing(r.value("listing").toMap()); @@ -585,13 +632,13 @@ void NotesController::receive(quint64 id, const QString &op, const QVariantMap & if (m_recoveryVisible && !p.args.value("includeCommitText", true).toBool()) { inspectRecovery(); return; } m_recovery = r; QVariantList drafts; - for (const auto &v : r.value("drafts").toList()) { auto d = v.toMap(); d.insert("durable", true); drafts.append(d); } + for (const auto &v : r.value("drafts").toList()) { auto d = v.toMap(); d.insert("durable", d.value("recoveryDurable", true)); drafts.append(d); } for (const auto &v : r.value("memoryDrafts").toList()) { auto d = v.toMap(); bool found = false; for (auto &stored : drafts) { auto existing = stored.toMap(); if (existing.value("path") != d.value("path")) continue; - d.insert("durable", existing.value("text") == d.value("text") && existing.value("baseHash") == d.value("baseHash")); + d.insert("durable", existing.value("durable").toBool() && existing.value("text") == d.value("text") && existing.value("baseHash") == d.value("baseHash")); d.insert("matchesBase", existing.value("matchesBase")); stored = d; found = true; break; } if (!found) { d.insert("durable", false); drafts.append(d); } @@ -607,7 +654,11 @@ void NotesController::receive(quint64 id, const QString &op, const QVariantMap & } } } - else if (op == "checkpoint") { if (m_conflict) m_error = "外部版本已改变;本地恢复草稿已保存。请另存副本,或在恢复中心保留双方版本。"; } + else if (op == "checkpoint") { + if (m_conflict) m_error = r.value("recoveryDurable").toBool() + ? "外部版本已改变;本地恢复草稿已保存。请另存副本,或在恢复中心保留双方版本。" + : "本地输入仍在内存中,恢复草稿尚未落盘。请保持窗口打开并另存或重试。"; + } else if (op == "createFolder") { const auto path=p.args.value("path").toString();m_status="文件夹已创建";selectCategory("folder:"+path);emit folderCreated(path); } else if (op == "favorite" || op == "restore" || op == "discardDraft") { if (op == "favorite" && p.path == m_path) m_favorite = p.args.value("favorite").toBool(); refresh(); inspectRecovery(); } emit changed(); diff --git a/src/NotesController.h b/src/NotesController.h index 10c0606..00c11f6 100644 --- a/src/NotesController.h +++ b/src/NotesController.h @@ -30,6 +30,8 @@ class NotesController final : public QObject { Q_PROPERTY(QVariantList importHistory MEMBER m_importHistory NOTIFY changed) Q_PROPERTY(bool externalDocument READ externalDocument NOTIFY changed) Q_PROPERTY(QString assetRoot READ assetRoot NOTIFY changed) + Q_PROPERTY(bool systemFileDialogs READ systemFileDialogs CONSTANT) + Q_PROPERTY(bool recoveryAvailable MEMBER m_recoveryAvailable NOTIFY changed) Q_PROPERTY(QUrl documentUrl READ documentUrl NOTIFY changed) Q_PROPERTY(QString legacyLibrary READ legacyLibrary NOTIFY changed) Q_PROPERTY(QString library MEMBER m_library NOTIFY changed) @@ -90,6 +92,10 @@ class NotesController final : public QObject { QString noteTitle() const; QString assetRoot() const; QUrl documentUrl() const; + bool systemFileDialogs() const; + bool managedAssets() const { return m_managedAssets && !externalDocument(); } + QVariantMap assetManifest() const { return managedAssets() ? m_assetManifest : QVariantMap(); } + Q_INVOKABLE QUrl resolveAsset(const QUrl &source) const; QString legacyLibrary() const; Q_INVOKABLE void openNote(const QString &path, int match = -1); Q_INVOKABLE void createNote(); @@ -177,7 +183,7 @@ class NotesController final : public QObject { void importPreviewReady(); void importFinished(); void backupFinished(); - void notesExported(const QUrl &directory, int count); + void notesExported(const QUrl &directory, int count, const QVariantList &warnings); void libraryOperationFailed(const QString &message); void missingLinkRequested(); void tagRenameReady(); @@ -229,6 +235,8 @@ class NotesController final : public QObject { quint64 m_next = 0, m_generation = 0, m_epoch = 0, m_revision = 0, m_savedRevision = 0, m_searchSerial = 0; quint64 m_saving = 0; bool m_restoreWorkspace = false, m_exporting = false, m_organizing = false; + bool m_recoveryAvailable = false, m_managedAssets = false; + QVariantMap m_assetManifest; bool m_favorite = false, m_recoveryVisible = false; bool m_loading = false, m_transition = false, m_applying = false, m_readOnly = false, m_conflict = false; QString m_highlightQuery; diff --git a/src/NotesLibrary.cpp b/src/NotesLibrary.cpp index 96e114d..0a4ac02 100644 --- a/src/NotesLibrary.cpp +++ b/src/NotesLibrary.cpp @@ -1,6 +1,7 @@ #include "NotesController.h" #include "EditorSession.h" #include "storage/MarkdownFile.h" +#include "storage/ManagedAssets.h" #include "storage/NotesRepository.h" #include #include @@ -11,6 +12,10 @@ QString NotesController::assetRoot() const { QUrl NotesController::documentUrl() const { return QUrl::fromLocalFile(externalDocument() ? m_path.mid(9) : m_library + '/' + m_path); } +QUrl NotesController::resolveAsset(const QUrl &source) const { + return ManagedAssets::resolveUrl({assetRoot(), documentUrl(), assetManifest(), managedAssets()}, source); +} +bool NotesController::systemFileDialogs() const { return QFileInfo::exists("/.flatpak-info"); } QString NotesController::legacyLibrary() const { return preference("legacyLibrary").toString(); } void NotesController::previewImport(const QList &sources,const QString &folder,bool keepStructure) { if (busy() || sources.isEmpty()) return; diff --git a/src/main.cpp b/src/main.cpp index 461a71a..78b0e63 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -21,42 +22,73 @@ #include "EditorRenderLifecycle.h" #include "NotesController.h" #include "storage/NotesRepository.h" -class EditorRequests final : public QWebEngineUrlRequestInterceptor { +#include "storage/ManagedAssets.h" + +// Interception runs off the UI thread. Publish one coherent, implicitly shared +// repository snapshot for both interception and serving; never query SQL here. +class AssetContext { public: - void setLibrary(const QString &library) { + void publish(const ManagedAssets::Context &context) { QWriteLocker lock(&m_lock); - m_library = QFileInfo(library).canonicalFilePath(); + m_context = context; + } + ManagedAssets::Context snapshot() const { + QReadLocker lock(&m_lock); + return m_context; } +private: + mutable QReadWriteLock m_lock; + ManagedAssets::Context m_context; +}; +class EditorRequests final : public QWebEngineUrlRequestInterceptor { +public: + explicit EditorRequests(const AssetContext &context) : m_context(context) {} void interceptRequest(QWebEngineUrlRequestInfo &request) override { const QUrl url = request.requestUrl(); bool allowed = url.scheme() == "qrc" && (url.path().startsWith("/editor/") || url.path() == "/qtwebchannel/qwebchannel.js"); if (url.scheme() == "data" && (request.resourceType() == QWebEngineUrlRequestInfo::ResourceTypeImage || request.resourceType() == QWebEngineUrlRequestInfo::ResourceTypeFontResource)) allowed = true; if ((url.isLocalFile() || url.scheme() == "nkasset") && request.resourceType() == QWebEngineUrlRequestInfo::ResourceTypeImage) { - const QString path = QFileInfo(url.scheme() == "nkasset" ? url.path() : url.toLocalFile()).canonicalFilePath(); - // Only images in the current library are exposed to the document. - QReadLocker lock(&m_lock); - const QString root = m_library; - allowed = !root.isEmpty() && path.startsWith(root + '/'); + const auto context = m_context.snapshot(); + const QString identity = context.managed ? ManagedAssets::resolveIdentity(context, url) : QString(); + const QString path = context.managed + ? (identity.isEmpty() ? QString() : QDir(context.root).filePath(identity)) + : ManagedAssets::resolvePath(context, url); + allowed = !path.isEmpty(); + if (allowed && url.isLocalFile()) { + // Chromium must never open a historical absolute file URL. The + // scheme handler checks current cache bytes against the manifest. + QUrl controlled; + controlled.setScheme("nkasset"); + controlled.setPath(path); + controlled.setQuery(url.query(QUrl::FullyEncoded), QUrl::StrictMode); + controlled.setFragment(url.fragment(QUrl::FullyEncoded), QUrl::StrictMode); + request.redirect(controlled); + return; + } } request.block(!allowed); } private: - QReadWriteLock m_lock; - QString m_library; + const AssetContext &m_context; }; class EditorAssets final : public QWebEngineUrlSchemeHandler { public: - QString library; + explicit EditorAssets(const AssetContext &context) : m_context(context) {} void requestStarted(QWebEngineUrlRequestJob *job) override { - const QString root = QFileInfo(library).canonicalFilePath(); - const QString path = QFileInfo(job->requestUrl().path()).canonicalFilePath(); - if (job->requestMethod() != "GET" || root.isEmpty() || !path.startsWith(root + '/') || QFileInfo(path).size() > 32 * 1024 * 1024) { job->fail(QWebEngineUrlRequestJob::RequestDenied); return; } - auto *file = new QFile(path, job); - if (!file->open(QIODevice::ReadOnly)) { job->fail(QWebEngineUrlRequestJob::UrlNotFound); return; } - const QByteArray mime = QMimeDatabase().mimeTypeForFile(path, QMimeDatabase::MatchContent).name().toUtf8(); + QByteArray bytes; + if (job->requestMethod() != "GET" || ManagedAssets::resolvePath(m_context.snapshot(), job->requestUrl(), &bytes).isEmpty()) { + job->fail(QWebEngineUrlRequestJob::RequestDenied); + return; + } + const QByteArray mime = QMimeDatabase().mimeTypeForData(bytes).name().toUtf8(); if (!mime.startsWith("image/")) { job->fail(QWebEngineUrlRequestJob::RequestDenied); return; } - job->reply(mime, file); + auto *buffer = new QBuffer(job); + buffer->setData(bytes); + buffer->open(QIODevice::ReadOnly); + job->reply(mime, buffer); } +private: + const AssetContext &m_context; }; int main(int argc, char **argv) { QWebEngineUrlScheme assetScheme("nkasset"); @@ -107,11 +139,17 @@ int main(int argc, char **argv) { QMetaObject::invokeMethod(&controller, [&controller, file] { controller.openExternal(file); }, Qt::QueuedConnection); } }); - EditorRequests requests; - EditorAssets assets; + AssetContext assetContext; + EditorRequests requests(assetContext); + EditorAssets assets(assetContext); QQuickWebEngineProfile::defaultProfile()->installUrlSchemeHandler("nkasset", &assets); - QObject::connect(&controller, &NotesController::changed, &assets, [&] { assets.library = controller.assetRoot(); }); - QObject::connect(&controller, &NotesController::changed, &requests, [&] { requests.setLibrary(controller.assetRoot()); }); + const auto publishAssets = [&] { + assetContext.publish({controller.assetRoot(), controller.documentUrl(), controller.assetManifest(), controller.managedAssets()}); + }; + // The controller emits changed after accepting the manifest and before + // loading document content or delivering an attachment result to WebChannel. + QObject::connect(&controller, &NotesController::changed, &assets, publishAssets); + publishAssets(); QQuickWebEngineProfile::defaultProfile()->setUrlRequestInterceptor(&requests); QQuickWebEngineProfile::defaultProfile()->setSpellCheckEnabled(true); QQuickWebEngineProfile::defaultProfile()->setSpellCheckLanguages({"en-US"}); diff --git a/src/storage/ManagedAssets.h b/src/storage/ManagedAssets.h new file mode 100644 index 0000000..618f44a --- /dev/null +++ b/src/storage/ManagedAssets.h @@ -0,0 +1,239 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#ifdef Q_OS_UNIX +#include +#include +#include +#endif + +// The repository supplies a snapshot of validated database identities. Resolving +// an identity never inspects its former location or opens the library database. +namespace ManagedAssets { +inline constexpr qint64 MaxBytes = 32 * 1024 * 1024; +struct Context { + QString root; + QUrl documentUrl; // Current file URL, or a library-relative virtual note path. + QVariantMap manifest; // assets/. -> lowercase SHA-256 of the BLOB. + bool managed = true; +}; + +inline QString digestForPath(const QString &path) { + static const QRegularExpression pattern(QStringLiteral("\\Aassets/([a-f0-9]{64})\\.[a-z0-9]{1,8}\\z")); + return pattern.match(path).captured(1); +} + +namespace detail { +inline bool safeCharacters(const QString &path) { + for (const QChar c : path) { + if (c == '\\' || c.category() == QChar::Other_Control || c.category() == QChar::Other_Format) + return false; + } + return !path.isEmpty(); +} +inline bool safeUrl(const QUrl &url) { + if (!url.isValid() || !url.authority().isEmpty()) return false; + if (!url.scheme().isEmpty() && url.scheme() != "file" && url.scheme() != "nkasset") return false; + // Decode once: escaped percent/hash characters are ordinary filename bytes. + // Encoded separators and traversal components never grant another directory. + static const QRegularExpression encoded(QStringLiteral("%(?:2e|2f|5c|0[0-9a-f]|1[0-9a-f]|7f)"), QRegularExpression::CaseInsensitiveOption); + if (url.path(QUrl::FullyEncoded).contains(encoded)) return false; + const QString path = url.path(QUrl::FullyDecoded); + return safeCharacters(path) && !path.startsWith("//") + && (url.scheme().isEmpty() || path.startsWith('/')); +} +inline bool plainComponents(const QString &path) { + if (!safeCharacters(path)) return false; + for (const auto &part : path.split('/')) + if (part.isEmpty() || part == "." || part == "..") return false; + return true; +} +inline QString absolutePath(const QString &path) { + if (!path.startsWith('/') || !safeCharacters(path)) return {}; + if (path == "/") return path; + return plainComponents(path.mid(1)) ? path : QString(); +} +inline QString rootPath(QString root) { + while (root.size() > 1 && root.endsWith('/')) root.chop(1); + return absolutePath(root); +} +inline QString relativeToRoot(const QString &root, const QString &path) { + const QString prefix = root == "/" ? root : root + '/'; + return !root.isEmpty() && path.startsWith(prefix) ? path.mid(prefix.size()) : QString(); +} +inline QString documentPath(const Context &context, const QString &root) { + if (!safeUrl(context.documentUrl)) return {}; + const QString document = context.documentUrl.path(QUrl::FullyDecoded); + if (!document.startsWith('/')) return plainComponents(document) ? document : QString(); + if (absolutePath(document).isEmpty()) return {}; + return relativeToRoot(root, document); +} +inline QString scopedRelativePath(const Context &context, const QUrl &url, const QString &root) { + const QString path = url.path(QUrl::FullyDecoded); + if (path.startsWith('/')) { + if (absolutePath(path).isEmpty()) return {}; + return relativeToRoot(root, path); + } + const QString document = documentPath(context, root); + if (document.isEmpty()) return {}; + QStringList parts = document.split('/'); + parts.removeLast(); + bool leading = true; + for (const auto &part : path.split('/')) { + if (part.isEmpty()) return {}; + if (part == "." || part == "..") { + // Ordinary leading ../ is necessary for notes in nested folders. + // It may never climb out of this library or follow a child and backtrack. + if (!leading) return {}; + if (part == "..") { + if (parts.isEmpty()) return {}; + parts.removeLast(); + } + } else { + leading = false; + parts.append(part); + } + } + return parts.join('/'); +} +inline QString ownedIdentity(const Context &context, const QString &path) { + const QString digest = digestForPath(path); + const auto found = context.manifest.constFind(path); + if (digest.isEmpty() || found == context.manifest.cend() + || found->metaType().id() != QMetaType::QString || found->toString() != digest) return {}; + return path; +} +inline QString checkedEntry(const QString &root, const QString &relative) { + if (!plainComponents(relative)) return {}; + const QString path = (root == "/" ? root : root + '/') + relative; + QString component; + const auto parts = path.mid(1).split('/'); + for (qsizetype i = 0; i < parts.size(); ++i) { + component += '/' + parts[i]; + const QFileInfo info(component); + if (info.isSymLink() || (i + 1 < parts.size() ? !info.isDir() : !(info.isFile() || info.isDir()))) return {}; + } + const QString canonicalRoot = QFileInfo(root).canonicalFilePath(); + const QString canonicalFile = QFileInfo(path).canonicalFilePath(); + if (canonicalRoot != root || canonicalFile != path || relativeToRoot(canonicalRoot, canonicalFile).isEmpty()) return {}; + return canonicalFile; +} +inline QString checkedFile(const QString &root, const QString &relative) { + const QString path = checkedEntry(root, relative); + if (path.isEmpty()) return {}; + const QFileInfo file(path); + return file.isFile() && file.size() > 0 && file.size() <= MaxBytes ? path : QString(); +} +inline bool readFile(const QString &path, QByteArray &bytes) { + QFile file; +#ifdef Q_OS_UNIX + // Walk from / using descriptors so a concurrent symlink substitution cannot + // make the checked cache path read a different directory (or a FIFO/device). + const auto parts = path.mid(1).split('/'); +#if defined(Q_OS_LINUX) && defined(O_PATH) + constexpr int directoryFlags = O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC; +#else + constexpr int directoryFlags = O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC; +#endif + int directory = ::open("/", directoryFlags); + if (directory < 0) return false; + for (qsizetype i = 0; i + 1 < parts.size(); ++i) { + const int next = ::openat(directory, QFile::encodeName(parts[i]).constData(), directoryFlags); + ::close(directory); + if (next < 0) return false; + directory = next; + } + const int descriptor = ::openat(directory, QFile::encodeName(parts.last()).constData(), O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC); + ::close(directory); + if (descriptor < 0) return false; + struct stat status {}; + if (::fstat(descriptor, &status) != 0 || !S_ISREG(status.st_mode) || status.st_size <= 0 || status.st_size > MaxBytes + || !file.open(descriptor, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) { + ::close(descriptor); + return false; + } +#else + file.setFileName(path); + if (!file.open(QIODevice::ReadOnly)) return false; +#endif + bytes = file.read(MaxBytes + 1); + return file.error() == QFileDevice::NoError && file.atEnd() && !bytes.isEmpty() && bytes.size() <= MaxBytes; +} +} // namespace detail + +// Pure identity lookup for rewriting/export from database BLOBs. Absolute URLs +// contribute only their exact assets/. suffix (even if the old +// library is now nested under the new root); their old paths are never inspected. +inline QString resolveIdentity(const Context &context, const QUrl &url) { + if (!context.managed || !detail::safeUrl(url)) return {}; + const QString root = detail::rootPath(context.root); + if (root.isEmpty()) return {}; + const QString path = url.path(QUrl::FullyDecoded); + const QString relative = detail::scopedRelativePath(context, url, root); + const QString owned = detail::ownedIdentity(context, relative); + if (!owned.isEmpty()) return owned; + // Historical versions retain their original text after a note moves. A + // leading relative prefix identifies an owned asset; it never traverses disk. + if (url.isRelative() && !path.startsWith('/') && !detail::documentPath(context, root).isEmpty()) { + QString identity = path; + while (identity.startsWith("../") || identity.startsWith("./")) + identity.remove(0, identity.startsWith("../") ? 3 : 2); + const auto historical = detail::ownedIdentity(context, identity); + if (!historical.isEmpty()) return historical; + } + if ((url.scheme() != "file" && url.scheme() != "nkasset") || detail::absolutePath(path).isEmpty()) return {}; + const auto parts = path.split('/'); + if (parts.size() < 3 || parts[parts.size() - 2] != "assets") return {}; + return detail::ownedIdentity(context, "assets/" + parts.last()); +} + +// Access always verifies current cache bytes against the database-owned digest. +// Pass contents when serving/exporting so the exact verified bytes are consumed, +// rather than reopening a filename that may have changed since validation. +inline QString resolvePath(const Context &context, const QUrl &url, QByteArray *contents = nullptr) { + if (contents) contents->clear(); + if (!detail::safeUrl(url)) return {}; + const QString root = detail::rootPath(context.root); + if (root.isEmpty()) return {}; + const QString relative = context.managed ? resolveIdentity(context, url) : detail::scopedRelativePath(context, url, root); + if (relative.isEmpty()) return {}; + const QString path = detail::checkedFile(root, relative); + if (path.isEmpty()) return {}; + QByteArray bytes; + if (!detail::readFile(path, bytes)) return {}; + if (context.managed && QString::fromLatin1(QCryptographicHash::hash(bytes, QCryptographicHash::Sha256).toHex()) != context.manifest.value(relative).toString()) return {}; + if (contents) *contents = bytes; + return path; +} +inline QUrl resolveUrl(const Context &context, const QUrl &url) { + const QString path = resolvePath(context, url); + if (path.isEmpty()) return {}; + QUrl result = QUrl::fromLocalFile(path); + result.setQuery(url.query(QUrl::FullyEncoded), QUrl::StrictMode); + result.setFragment(url.fragment(QUrl::FullyEncoded), QUrl::StrictMode); + return result; +} +// Ordinary external links may target directories, empty files or large files. +// Validate their scope without reading them or applying image-serving limits. +inline QUrl resolveExternalLink(const Context &context, const QUrl &url) { + if (context.managed || !detail::safeUrl(url)) return {}; + const QString root = detail::rootPath(context.root); + if (root.isEmpty()) return {}; + QUrl target = url; + QString path = target.path(QUrl::FullyDecoded); + while (path.size() > 1 && path.endsWith('/')) path.chop(1); + target.setPath(path); + const QString relative = detail::scopedRelativePath(context, target, root); + const QString file = detail::checkedEntry(root, relative); + if (file.isEmpty()) return {}; + QUrl result = QUrl::fromLocalFile(file); + result.setQuery(url.query(QUrl::FullyEncoded), QUrl::StrictMode); + result.setFragment(url.fragment(QUrl::FullyEncoded), QUrl::StrictMode); + return result; +} +} // namespace ManagedAssets diff --git a/src/storage/ManagedLibrary.cpp b/src/storage/ManagedLibrary.cpp new file mode 100644 index 0000000..654f443 --- /dev/null +++ b/src/storage/ManagedLibrary.cpp @@ -0,0 +1,176 @@ +#include "ManagedLibrary.h" +#include "ManagedStore.h" +#include "RepositoryTestHooks.h" +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef Q_OS_UNIX +#include +#include +#include +#endif +#ifdef Q_OS_LINUX +#include +#include +#endif + +namespace { +void require(bool condition, const QString &code, const QString &detail = {}) { + if (!condition) throw ManagedStoreError{code, detail}; +} +bool present(const QString &path) { const QFileInfo info(path); return info.exists() || info.isSymLink(); } +void checkedDirectory(const QString &path) { + require(QFileInfo(path).isDir() && !QFileInfo(path).isSymLink() + && QFileInfo(path).canonicalFilePath() == path, "unsafePath", path); +} +bool validId(const QString &id) { + static const QRegularExpression pattern("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"); + return pattern.match(id).hasMatch(); +} +void stage(const QString &name) { + if (RepositoryTestHooks::managedRecoveryStage) RepositoryTestHooks::managedRecoveryStage(name); +} +void failAt(const QString &name) { + require(!RepositoryTestHooks::managedFailure || !RepositoryTestHooks::managedFailure(name), "writeFailed", name); +} +void syncPath(const QString &path, bool directory) { +#ifdef Q_OS_UNIX + int fd = ::open(QFile::encodeName(path).constData(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW | (directory ? O_DIRECTORY : 0)); + require(fd >= 0, "writeFailed", path); + int result; + do { result = ::fsync(fd); } while (result < 0 && errno == EINTR); + ::close(fd); + require(result == 0, "writeFailed", path); +#else + Q_UNUSED(directory); + throw ManagedStoreError{"writeFailed", "Durable recovery publication is unavailable on this platform: " + path}; +#endif +} +} + +struct ManagedLibrary::Lease { + std::unique_ptr instance; + std::unique_ptr legacy; +}; +std::shared_ptr ManagedLibrary::lockDirectory(const QString &directory) { + checkedDirectory(directory); + auto lease = std::make_shared(); +#ifdef Q_OS_LINUX + // Never remove or replace this inode, including after a crashed sandbox. + const auto path = directory + "/managed.instance.lock"; + int fd = ::open(QFile::encodeName(path).constData(), O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, 0600); + require(fd >= 0, "metadataUnavailable", path); + lease->instance = std::make_unique(); + if (!lease->instance->open(fd, QIODevice::ReadWrite, QFileDevice::AutoCloseHandle)) { + ::close(fd); throw ManagedStoreError{"metadataUnavailable", path}; + } + struct stat identity{}; + require(::fstat(fd,&identity)==0 && S_ISREG(identity.st_mode), "unsafePath", path); + int result; + do { result = ::flock(fd, LOCK_EX | LOCK_NB); } while (result < 0 && errno == EINTR); + require(result == 0, "libraryLocked", directory); + if (!QFileInfo::exists("/.flatpak-info")) +#endif + { + const auto path = directory + "/managed.lock"; + require(!QFileInfo(path).isSymLink(), "unsafePath", path); + lease->legacy = std::make_unique(path); + lease->legacy->setStaleLockTime(0); + require(lease->legacy->tryLock(), "libraryLocked", directory); + } + return lease; +} +ManagedLibrary::ManagedLibrary(const QString &directory) { + require(!directory.isEmpty(), "invalidLibrary"); + entry = QDir(directory).absolutePath(); + // Reject linked ancestors before mkdir can write through one. + auto ancestor = entry; + while (!QFileInfo::exists(ancestor) && !QFileInfo(ancestor).isSymLink()) { + const auto parent = QFileInfo(ancestor).absolutePath(); + require(parent != ancestor, "unsafePath", entry); ancestor = parent; + } + checkedDirectory(ancestor); + require(QDir().mkpath(entry), "metadataUnavailable", entry); + checkedDirectory(entry); + entryLease = lockDirectory(entry); + active = entry; + const auto pointer = entry + "/active-library.json"; + if (!present(pointer)) return; + try { + const QFileInfo info(pointer); + require(info.isFile() && !info.isSymLink() && info.size() > 0 && info.size() <= 4096, "recoveryPointerInvalid"); + QFile file(pointer); + require(file.open(QIODevice::ReadOnly), "recoveryPointerInvalid"); + const auto bytes = file.readAll(); + require(file.error() == QFileDevice::NoError, "recoveryPointerInvalid"); + QJsonParseError error; + const auto document = QJsonDocument::fromJson(bytes, &error); + const auto record = document.object(); + const auto id = record.value("recoveryId").toString(); + const auto name = record.value("directory").toString(); + require(error.error == QJsonParseError::NoError && document.isObject() && record.size() == 3 + && bytes == QJsonDocument(record).toJson(QJsonDocument::Compact) + && record.value("version").isDouble() && record.value("version").toDouble() == 1 + && validId(id) && name == QFileInfo(entry).fileName() + "-restored-" + id, "recoveryPointerInvalid"); + active = QFileInfo(entry).absolutePath() + '/' + name; + checkedDirectory(active); + require(!present(active + "/active-library.json"), "recoveryPointerInvalid"); + require(QFileInfo(active + "/notes.sqlite3").isFile() && !QFileInfo(active + "/notes.sqlite3").isSymLink(), "recoveryPointerInvalid"); + } catch (const ManagedStoreError &) { + throw ManagedStoreError{"recoveryPointerInvalid", pointer}; + } + // Do not turn an active-directory lock failure into an invalid-pointer error. + activeLease = lockDirectory(active); +} +ManagedLibrary::~ManagedLibrary() = default; +QString ManagedLibrary::entryRoot() const { return entry; } +QString ManagedLibrary::activeRoot() const { return active; } +bool ManagedLibrary::activationUncertain() const { return uncertain; } +QString ManagedLibrary::createReplacement(const QString &id) const { + require(validId(id) && !uncertain, "recoveryPointerInvalid"); + const auto parent = QFileInfo(entry).absolutePath(); + checkedDirectory(parent); + const auto name = QFileInfo(entry).fileName() + "-restored-" + id; + failAt("createReplacement"); + require(QDir(parent).mkdir(name), "writeFailed", parent + '/' + name); + const auto result = parent + '/' + name; + require(QFile::setPermissions(result, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner), "writeFailed", result); + return result; +} +void ManagedLibrary::syncFile(const QString &path) { syncPath(path, false); } +void ManagedLibrary::syncDirectory(const QString &path) { syncPath(path, true); } +void ManagedLibrary::publish(const QString &directory, const QString &id, std::shared_ptr lease) { + require(!uncertain && validId(id) && lease, "recoveryPointerInvalid"); + require(directory == QFileInfo(entry).absolutePath() + '/' + QFileInfo(entry).fileName() + "-restored-" + id, "recoveryPointerInvalid"); + checkedDirectory(directory); + require(!present(directory + "/active-library.json"), "recoveryPointerInvalid"); + const auto pointer = entry + "/active-library.json"; + require(!QFileInfo(pointer).isSymLink(), "recoveryPointerInvalid"); + const auto bytes = QJsonDocument(QJsonObject{{"version", 1}, {"recoveryId", id}, {"directory", QFileInfo(directory).fileName()}}).toJson(QJsonDocument::Compact); + QSaveFile file(pointer); file.setDirectWriteFallback(false); + failAt("pointerWrite"); + require(file.open(QIODevice::WriteOnly) && file.write(bytes) == bytes.size() && file.flush(), "writeFailed", pointer); +#ifdef Q_OS_UNIX + int rc; + do { rc = ::fsync(file.handle()); } while (rc < 0 && errno == EINTR); + require(rc == 0, "writeFailed", pointer); +#endif + stage("beforePointerCommit"); failAt("pointerCommit"); + pendingLease = std::move(lease); + // QSaveFile can report an error while finalizing its atomic rename. Treat + // any commit error conservatively too; reopening resolves the disk pointer. + uncertain = true; + require(file.commit(), "writeFailed", pointer); + // Any failure past this boundary retains both leases and prohibits writes. + stage("pointerRenamed"); failAt("pointerDirectorySync"); + syncDirectory(entry); + active = directory; + activeLease = std::move(pendingLease); + uncertain = false; + stage("pointerDurable"); +} diff --git a/src/storage/ManagedLibrary.h b/src/storage/ManagedLibrary.h new file mode 100644 index 0000000..bf967cb --- /dev/null +++ b/src/storage/ManagedLibrary.h @@ -0,0 +1,24 @@ +#pragma once +#include +#include + +// The configured entry directory never changes. Recovery publishes a sibling +// directory through a small, strictly validated pointer while retaining its lock. +class ManagedLibrary { +public: + struct Lease; + explicit ManagedLibrary(const QString &entryDirectory); + ~ManagedLibrary(); + QString entryRoot() const; + QString activeRoot() const; + static std::shared_ptr lockDirectory(const QString &directory); + QString createReplacement(const QString &recoveryId) const; + void publish(const QString &directory, const QString &recoveryId, std::shared_ptr lease); + bool activationUncertain() const; + static void syncFile(const QString &path); + static void syncDirectory(const QString &path); +private: + QString entry, active; + std::shared_ptr entryLease, activeLease, pendingLease; + bool uncertain = false; +}; diff --git a/src/storage/ManagedStore.cpp b/src/storage/ManagedStore.cpp index 17edbed..a859b16 100644 --- a/src/storage/ManagedStore.cpp +++ b/src/storage/ManagedStore.cpp @@ -1,4 +1,7 @@ #include "ManagedStore.h" +#include "ManagedLibrary.h" +#include "ManagedAssets.h" +#include "RepositoryTestHooks.h" #include "MarkdownFile.h" #include "KnowledgeIndex.h" #include @@ -15,19 +18,29 @@ #include #include #include -#include #include #include #include #include #include -#ifdef Q_OS_LINUX -#include -#include -#endif +#include namespace { -struct StoreError { QString code, detail; }; +using StoreError = ManagedStoreError; +using Database = std::unique_ptr; +Database openDatabase(const QString &path, int flags); +void copyDatabase(sqlite3 *destination, sqlite3 *source); +void streamCopy(const QString &source, const QString &destination); +void failAt(const QString &stage) { + if (RepositoryTestHooks::managedFailure && RepositoryTestHooks::managedFailure(stage)) + throw StoreError{"writeFailed", stage}; +} +void recoveryStage(const QString &stage) { + if (RepositoryTestHooks::managedRecoveryStage) RepositoryTestHooks::managedRecoveryStage(stage); +} +void externalStage(const QString &stage) { + if (RepositoryTestHooks::externalSaveStage) RepositoryTestHooks::externalSaveStage(stage); +} QString uid() { return QUuid::createUuid().toString(QUuid::WithoutBraces); } QString utc() { return QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs); } QString hashText(const QString &text) { return MarkdownFile::digest(text.toUtf8()); } @@ -37,9 +50,33 @@ QString parentFolder(const QString &path) { const auto folder = QFileInfo(path). QString relativeManagedPath(const QString &source, const QString &target) { return QDir("/" + parentFolder(source)).relativeFilePath("/" + target); } +QString relativeManagedUrl(const QString &source, const QString &target, const QUrl &original) { + QString result=QString::fromUtf8(QUrl::toPercentEncoding(relativeManagedPath(source,target),"/")); + if(original.hasQuery())result+='?'+original.query(QUrl::FullyEncoded); + if(original.hasFragment())result+='#'+original.fragment(QUrl::FullyEncoded); + return result; +} +// Export supports the same Markdown spans as LinkPaths. Hide complete raw HTML +// opening tags while rewriting those spans, then restore the tags byte-for-byte. +// HTML resources remain unsupported and are explicitly reported to the caller. +bool rewriteExportLinks(QString &text, const std::function &map) { + static const QRegularExpression htmlTag(R"rx(<(?:a|img|source)\b(?:[^<>"']|"[^"]*"|'[^']*')*>)rx",QRegularExpression::CaseInsensitiveOption); + QString prefix;do {prefix="NKNOTES_HTML_"+uid()+'_';}while(text.contains(prefix)); + QList> hidden; + QList matches; + auto found=htmlTag.globalMatch(LinkPaths::masked(text));while(found.hasNext())matches.append(found.next()); + for(auto it=matches.crbegin();it!=matches.crend();++it) { + const auto marker=prefix+QString::number(hidden.size())+'_'; + hidden.append({marker,text.mid(it->capturedStart(),it->capturedLength())}); + text.replace(it->capturedStart(),it->capturedLength(),marker); + } + const bool supported=LinkPaths::rewrite(text,map); + for(const auto &entry:hidden)text.replace(entry.first,entry.second); + return supported&&hidden.isEmpty(); +} bool validPath(const QString &path, bool empty = false) { if (path.isEmpty()) return empty; - if (QDir::isAbsolutePath(path) || path.contains('\\') || path.contains(QChar::Null)) return false; + if (QDir::isAbsolutePath(path) || path.startsWith("external:") || path.contains('\\') || path.contains(QChar::Null)) return false; for (const auto &part : path.split('/')) if (part.isEmpty() || part == "." || part == ".." || part == ".nextnotes" || part == ".git") return false; return true; } @@ -71,6 +108,7 @@ struct ManagedStore::Impl { struct Command { QString label; QList changes; QString primaryId; }; QList undoStack, redoStack; QString initializationNotice; + int cacheFailures = 0; void initializeFolderMetadata() { QSet columns; for (const auto &v : query("PRAGMA table_info(folders)")) @@ -94,12 +132,88 @@ struct ManagedStore::Impl { std::shared_ptr cancelled; bool stopping() const { return cancelled && cancelled->load(); } void notify(const QString &phase, int done, int total = 0) { if (progress) progress({{"phase",phase},{"completed",done},{"total",total}}); } - QString root; + QString root, configuredRoot; + std::unique_ptr library; + QVariantMap openError; Knowledge::Index index; QHash external; - std::unique_ptr lock; - std::unique_ptr instanceLock; + // Contains only the newest request for each path, including failed commits. + QHash memoryDrafts; + QVariantMap assetManifest; + bool manifestDirty = true; ~Impl() { if (db) sqlite3_close(db); } + void initializeDatabase(); + void preflight(); + void validateDatabase(bool backup, bool legacy = false); + void prepareBackup(const QString &source, const QString &snapshot); + QVariantMap recoverInitialization(const QVariantMap &a); + QVariantMap checkpoint(const QVariantMap &a); + QVariantMap discardDraft(const QString &path); + QVariantMap saveExternal(const QVariantMap &a); + QVariantList recoveryDrafts(); + bool hasPendingMemoryDrafts() const { + for (const auto &draft : memoryDrafts) if (!draft.value("recoveryDurable").toBool()) return true; + return false; + } + QString checkedCachePath(const QString &relative) const { + require(validPath(relative) && relative.startsWith("assets/"), "unsafePath", relative); + QString current = root; + require(QFileInfo(root).canonicalFilePath() == root && !QFileInfo(root).isSymLink(), "unsafePath", root); + const auto parts = relative.split('/'); + for (qsizetype i = 0; i < parts.size(); ++i) { + current += '/' + parts[i]; + const QFileInfo info(current); + require(!info.isSymLink() && (!info.exists() || (info.canonicalFilePath() == current + && (i + 1 < parts.size() ? info.isDir() : info.isFile()))), "unsafePath", current); + } + return current; + } + const QVariantMap &managedManifest() { + if (manifestDirty) { + QVariantMap next; + for (const auto &v : query("SELECT path,data FROM assets WHERE deleted=''")) { + const auto row=v.toMap();const auto path=row.value("path").toString(),digest=ManagedAssets::digestForPath(path); + require(!digest.isEmpty() && row.value("data").metaType().id()==QMetaType::QByteArray + && row.value("data").toByteArray().size()<=ManagedAssets::MaxBytes + && MarkdownFile::digest(row.value("data").toByteArray())==digest,"databaseCorrupt","Invalid asset identity"); + next.insert(path,digest); + } + assetManifest = std::move(next); manifestDirty = false; + } + return assetManifest; + } + QString attachmentIdentity(const QString &source, QUrl url) { + if(!validPath(source))return {}; + url.setQuery(QString());url.setFragment(QString()); + return ManagedAssets::resolveIdentity({root,QUrl::fromLocalFile(root+'/'+source),managedManifest(),true},url); + } + QStringList attachmentReferenceTexts() { + QStringList texts; + // Old history/drafts do not record their original document base. Digest + // references deliberately over-protect; they never dereference a path. + for(const auto &v:query("SELECT text FROM notes UNION ALL SELECT text FROM versions UNION ALL SELECT text FROM drafts")) + texts.append(v.toMap().value("text").toString()); + const auto templates=settings("templates");for(const auto &value:templates)texts.append(value.toString()); + for(const auto &draft:memoryDrafts)texts.append(draft.value("text").toString()); + for(const auto *stack:{&undoStack,&redoStack})for(const auto &command:*stack)for(const auto &change:command.changes) { + texts.append(change.before.value("text").toString());texts.append(change.after.value("text").toString()); + } + for(auto &text:texts)if(text.contains('%'))text+='\n'+QUrl::fromPercentEncoding(text.toUtf8()); + return texts; + } + int attachmentReferences(const QString &path,const QStringList &texts) const { + const auto digest=ManagedAssets::digestForPath(path);int count=0; + for(const auto &text:texts)if(text.contains(path)||(!digest.isEmpty()&&text.contains(digest)))++count; + return count; + } + void addContext(QVariantMap &result) { + result.insert("cacheFailures", cacheFailures); + result.insert("assetManifest", managedManifest()); + result.insert("managedAssets", !result.value("path").toString().startsWith("external:")); + if (!result.value("managedAssets").toBool()) result.insert("assetManifest", QVariantMap()); + result.insert("library", root); + result.insert("entryRoot", library ? library->entryRoot() : configuredRoot); + } QVariantList query(const QString &sql, const QVariantList &args = {}) { sqlite3_stmt *raw = nullptr; const auto statement = sql.toUtf8(); @@ -220,9 +334,11 @@ struct ManagedStore::Impl { for(const auto &v:query("SELECT * FROM notes")) { auto n=v.toMap(); const auto old=n["path"].toString(),next=moved.value(old,old);QString text=n["text"].toString(); const bool rewritable=LinkPaths::rewrite(text,[&](const QString &target,bool wiki){if(wiki){auto paths=index.resolve(old,target,true);if(paths.size()!=1)return target;const auto destination=moved.value(paths[0],paths[0]);if(next==old&&destination==paths[0])return target;return relativeManagedPath(next,destination);} - const auto resolved=LinkPaths::resolve(old,target);if(resolved.isEmpty())return target; + const QUrl url(target);const auto identity=attachmentIdentity(old,url); + if(identity.isEmpty()&&url.path(QUrl::FullyDecoded).split('/').contains("assets"))return target; + const auto resolved=identity.isEmpty()?LinkPaths::resolve(old,target):identity;if(resolved.isEmpty())return target; const auto destination=moved.value(resolved,resolved);if(next==old&&destination==resolved)return target; - QUrl url(target);const auto fragment=url.fragment(QUrl::FullyEncoded),queryString=url.query(QUrl::FullyEncoded);QString result=relativeManagedPath(next,destination);result=QString::fromUtf8(QUrl::toPercentEncoding(result,"/"));if(!queryString.isEmpty())result+='?'+queryString;if(!fragment.isEmpty())result+='#'+fragment;return result;}); + return relativeManagedUrl(next,destination,url);}); require(rewritable,"unsupportedLinks",old); if(text!=n["text"].toString())updateText(n,text); } @@ -245,23 +361,33 @@ struct ManagedStore::Impl { QString asset(const QByteArray &bytes,const QString &name) { QString suffix=QFileInfo(name).suffix().toLower();if(!QRegularExpression("^[a-z0-9]{1,8}$").match(suffix).hasMatch())suffix="bin"; const auto path="assets/"+MarkdownFile::digest(bytes)+'.'+suffix; - query("INSERT OR IGNORE INTO assets(path,data,name,deleted) VALUES(?,?,?,'')",{path,bytes,name}); + query("INSERT INTO assets(path,data,name,deleted) VALUES(?,?,?,'') ON CONFLICT(path) DO UPDATE SET deleted=''",{path,bytes,name}); + manifestDirty=true; // Materialized attachment cache is disposable; the BLOB is authoritative. - if(!QFileInfo::exists(root+'/'+path))writeBytes(root+'/'+path,bytes);return path; + const auto target=checkedCachePath(path); + if(!QFileInfo::exists(target))writeBytes(target,bytes);return path; + } + QString rebaseAttachments(QString text,const QString &source,const QString &destination) { + if(!validPath(source))return text; + rewriteExportLinks(text,[&](const QString &target,bool wiki){ + if(wiki)return target;const QUrl url(target);const auto owned=attachmentIdentity(source,url); + return owned.isEmpty()?target:relativeManagedUrl(destination,owned,url); + }); + return text; } QString rebaseText(QString text,const QString &source,const QString &destination) { refreshIndex(); require(LinkPaths::rewrite(text,[&](const QString &target,bool wiki){ QString resolved; if(wiki) { const auto choices=index.resolve(source,target,true);if(choices.size()!=1)return target;resolved=choices[0]; } - else resolved=LinkPaths::resolve(source,target); + else { + const QUrl url(target);resolved=attachmentIdentity(source,url); + if(resolved.isEmpty()&&url.path(QUrl::FullyDecoded).split('/').contains("assets"))return target; + if(resolved.isEmpty())resolved=LinkPaths::resolve(source,target); + } if(resolved.isEmpty())return target; - auto next=relativeManagedPath(destination,resolved); - if(wiki)return next; - const QUrl url(target);next=QString::fromUtf8(QUrl::toPercentEncoding(next,"/")); - if(!url.query().isEmpty())next+='?'+url.query(QUrl::FullyEncoded); - if(!url.fragment().isEmpty())next+='#'+url.fragment(QUrl::FullyEncoded); - return next; + if(wiki)return relativeManagedPath(destination,resolved); + return relativeManagedUrl(destination,resolved,QUrl(target)); }),"unsupportedLinks"); return text; } @@ -273,44 +399,187 @@ struct ManagedStore::Impl { QVariantMap restoreBackup(const QVariantMap &a); }; -ManagedStore::ManagedStore(const QString &directory) : d(std::make_unique()) { - d->root=QDir(directory).absolutePath(); - require(QDir().mkpath(d->root),"metadataUnavailable",d->root); -#ifdef Q_OS_LINUX - { - // Flatpak may reuse PID 2 for each launch. A persistent PID lock then - // mistakes a crashed predecessor for this live process. flock follows - // the open file description, survives namespace boundaries and is - // released by the kernel even on SIGKILL. Never unlink this lock inode. - d->instanceLock = std::make_unique(d->root + "/managed.instance.lock"); - require(d->instanceLock->open(QIODevice::ReadWrite), "metadataUnavailable"); - int result; - do { result = flock(d->instanceLock->handle(), LOCK_EX | LOCK_NB); } while (result < 0 && errno == EINTR); - require(result == 0, "libraryLocked"); - } - // Keep the legacy native lock as well for compatibility with older builds. - if (!QFileInfo::exists("/.flatpak-info")) -#endif - { - d->lock=std::make_unique(d->root+"/managed.lock");d->lock->setStaleLockTime(0);require(d->lock->tryLock(),"libraryLocked"); - } - require(sqlite3_open_v2(QFile::encodeName(d->root+"/notes.sqlite3").constData(),&d->db,SQLITE_OPEN_READWRITE|SQLITE_OPEN_CREATE|SQLITE_OPEN_FULLMUTEX,nullptr)==SQLITE_OK,"databaseError"); - sqlite3_busy_timeout(d->db,5000); - d->query("PRAGMA journal_mode=WAL");d->query("PRAGMA synchronous=FULL");d->query("PRAGMA foreign_keys=ON"); - require(d->one("PRAGMA user_version").value("user_version").toInt()<=1,"databaseVersion","This database was created by a newer version."); - d->query("CREATE TABLE IF NOT EXISTS notes(id TEXT PRIMARY KEY,path TEXT NOT NULL UNIQUE,text TEXT NOT NULL,hash TEXT NOT NULL,created TEXT NOT NULL,modified TEXT NOT NULL,pinned INTEGER NOT NULL DEFAULT 0,favorite INTEGER NOT NULL DEFAULT 0,deleted TEXT NOT NULL DEFAULT '',trash_token TEXT NOT NULL DEFAULT '',source TEXT NOT NULL DEFAULT '',source_hash TEXT NOT NULL DEFAULT '',import_batch TEXT NOT NULL DEFAULT '')"); - d->query("CREATE TABLE IF NOT EXISTS folders(id TEXT PRIMARY KEY,path TEXT NOT NULL UNIQUE,deleted TEXT NOT NULL DEFAULT '',trash_token TEXT NOT NULL DEFAULT '',icon TEXT NOT NULL DEFAULT 'folder')"); - d->initializeFolderMetadata(); - d->query("CREATE TABLE IF NOT EXISTS drafts(path TEXT PRIMARY KEY,text TEXT NOT NULL,base_hash TEXT NOT NULL,time TEXT NOT NULL)"); - d->query("CREATE TABLE IF NOT EXISTS versions(note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,hash TEXT NOT NULL,text TEXT NOT NULL,time TEXT NOT NULL,PRIMARY KEY(note_id,hash))"); - d->query("CREATE TABLE IF NOT EXISTS assets(path TEXT PRIMARY KEY,data BLOB NOT NULL,name TEXT NOT NULL,deleted TEXT NOT NULL DEFAULT '')"); - d->query("CREATE TABLE IF NOT EXISTS imports(id TEXT PRIMARY KEY,name TEXT NOT NULL,time TEXT NOT NULL,report BLOB NOT NULL)"); - d->query("CREATE TABLE IF NOT EXISTS settings(kind TEXT NOT NULL,name TEXT NOT NULL,value BLOB NOT NULL,PRIMARY KEY(kind,name))"); - d->query("CREATE INDEX IF NOT EXISTS notes_source ON notes(source,source_hash)");d->query("CREATE INDEX IF NOT EXISTS notes_state ON notes(deleted,modified)");d->query("PRAGMA user_version=1"); - d->initializeFolders(); - d->initializeUndo(); - for(const auto &v:d->query("SELECT path FROM assets WHERE deleted=''")){const auto path=v.toMap()["path"].toString();if(!QFileInfo::exists(d->root+'/'+path))writeBytes(d->root+'/'+path,d->one("SELECT data FROM assets WHERE path=?",{path})["data"].toByteArray());} +ManagedStore::ManagedStore(const QString &directory, bool retainInitializationFailure) : d(std::make_unique()) { + d->configuredRoot = QDir(directory).absolutePath(); + d->root = d->configuredRoot; + try { + d->library = std::make_unique(directory); + d->root = d->library->activeRoot(); + d->preflight(); + d->initializeDatabase(); + } catch (const StoreError &error) { + if (!retainInitializationFailure) throw; + if (d->db) { sqlite3_close(d->db); d->db = nullptr; } + d->openError = {{"ok", false}, {"error", error.code}, {"detail", error.detail}, + {"entryRoot", d->configuredRoot}, {"failedDirectory", d->root}, + {"recoveryAvailable", bool(d->library) && error.code != "libraryLocked"}}; + } +} +void ManagedStore::Impl::initializeDatabase() { + require(sqlite3_open_v2(QFile::encodeName(root+"/notes.sqlite3").constData(),&db,SQLITE_OPEN_READWRITE|SQLITE_OPEN_CREATE|SQLITE_OPEN_FULLMUTEX,nullptr)==SQLITE_OK,"databaseError"); + sqlite3_busy_timeout(db,5000); + query("PRAGMA trusted_schema=OFF"); + query("PRAGMA journal_mode=WAL");query("PRAGMA synchronous=FULL");query("PRAGMA foreign_keys=ON"); + require(one("PRAGMA user_version").value("user_version").toInt()<=1,"databaseVersion","This database was created by a newer version."); + query("CREATE TABLE IF NOT EXISTS notes(id TEXT PRIMARY KEY,path TEXT NOT NULL UNIQUE,text TEXT NOT NULL,hash TEXT NOT NULL,created TEXT NOT NULL,modified TEXT NOT NULL,pinned INTEGER NOT NULL DEFAULT 0,favorite INTEGER NOT NULL DEFAULT 0,deleted TEXT NOT NULL DEFAULT '',trash_token TEXT NOT NULL DEFAULT '',source TEXT NOT NULL DEFAULT '',source_hash TEXT NOT NULL DEFAULT '',import_batch TEXT NOT NULL DEFAULT '')"); + query("CREATE TABLE IF NOT EXISTS folders(id TEXT PRIMARY KEY,path TEXT NOT NULL UNIQUE,deleted TEXT NOT NULL DEFAULT '',trash_token TEXT NOT NULL DEFAULT '',icon TEXT NOT NULL DEFAULT 'folder')"); + initializeFolderMetadata(); + query("CREATE TABLE IF NOT EXISTS drafts(path TEXT PRIMARY KEY,text TEXT NOT NULL,base_hash TEXT NOT NULL,time TEXT NOT NULL)"); + query("CREATE TABLE IF NOT EXISTS versions(note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,hash TEXT NOT NULL,text TEXT NOT NULL,time TEXT NOT NULL,PRIMARY KEY(note_id,hash))"); + query("CREATE TABLE IF NOT EXISTS assets(path TEXT PRIMARY KEY,data BLOB NOT NULL,name TEXT NOT NULL,deleted TEXT NOT NULL DEFAULT '')"); + query("CREATE TABLE IF NOT EXISTS imports(id TEXT PRIMARY KEY,name TEXT NOT NULL,time TEXT NOT NULL,report BLOB NOT NULL)"); + query("CREATE TABLE IF NOT EXISTS settings(kind TEXT NOT NULL,name TEXT NOT NULL,value BLOB NOT NULL,PRIMARY KEY(kind,name))"); + query("CREATE INDEX IF NOT EXISTS notes_source ON notes(source,source_hash)");query("CREATE INDEX IF NOT EXISTS notes_state ON notes(deleted,modified)");query("PRAGMA user_version=1"); + query("PRAGMA application_id=1313558065"); + initializeFolders(); + initializeUndo(); + cacheFailures=0; + for(const auto &v:query("SELECT path,data FROM assets WHERE deleted=''")) { + const auto row=v.toMap(); + try { + const auto target=checkedCachePath(row["path"].toString()); + if(!QFileInfo::exists(target)) { failAt("cacheWrite"); writeBytes(target,row["data"].toByteArray()); } + } catch(const StoreError &) { ++cacheFailures; } + } + if(cacheFailures>0) { + if(!initializationNotice.isEmpty())initializationNotice+='\n'; + initializationNotice+="部分附件缓存未能生成,原件仍保留在数据库中。请检查磁盘空间与权限后重新打开。"; + } +} +void ManagedStore::Impl::validateDatabase(bool backup, bool legacy) { + query("PRAGMA trusted_schema=OFF"); + const auto version = one("PRAGMA user_version").value("user_version").toInt(); + require(version <= 1, backup ? "invalidBackup" : "databaseVersion", "This database was created by a newer version."); + require(version == 1 || (legacy && version == 0), backup ? "invalidBackup" : "databaseCorrupt"); + const auto application = one("PRAGMA application_id").value("application_id").toInt(); + require(application == 1313558065 || (!backup && application == 0), "databaseCorrupt", "Unrecognized database application ID"); + const auto check = query("PRAGMA quick_check"); + require(check.size() == 1 && check.first().toMap().value("quick_check").toString() == "ok", "databaseCorrupt"); + require(query("PRAGMA foreign_key_check").isEmpty(), "databaseCorrupt"); + const QMap required{ + {"notes", QString("id,path,text,hash,created,modified,pinned,favorite,deleted,trash_token,source,source_hash,import_batch").split(',')}, + {"folders", QString("id,path,deleted,trash_token").split(',')}, + {"drafts", QString("path,text,base_hash,time").split(',')}, + {"versions", QString("note_id,hash,text,time").split(',')}, + {"assets", QString("path,data,name,deleted").split(',')}, + {"imports", QString("id,name,time,report").split(',')}, + {"settings", QString("kind,name,value").split(',')} + }; + QSet tables; + for (const auto &v : query("SELECT name,type,tbl_name,sql FROM sqlite_master WHERE name NOT GLOB 'sqlite_*'")) { + const auto row = v.toMap(); const auto name = row.value("name").toString(); + if (row.value("type") == "index") { + require(required.contains(row.value("tbl_name").toString()), "databaseCorrupt"); continue; + } + require(row.value("type") == "table" && required.contains(name) + && QRegularExpression("^CREATE\\s+TABLE\\s", QRegularExpression::CaseInsensitiveOption).match(row.value("sql").toString()).hasMatch(), "databaseCorrupt"); + tables.insert(name); + } + // Only the documented version-zero, folder-only legacy store is partial. + const bool partialLegacy = legacy && version == 0 && (tables.isEmpty() || tables == QSet{"folders"}); + require(partialLegacy || tables.size() == required.size(), "databaseCorrupt", "Missing managed tables"); + const QMap primaryKeys{{"notes",{"id"}},{"folders",{"id"}},{"drafts",{"path"}}, + {"versions",{"note_id","hash"}},{"assets",{"path"}},{"imports",{"id"}},{"settings",{"kind","name"}}}; + for (const auto &table : tables) { + QSet columns; + QMap keyColumns; + const auto optional = table == "folders" ? QStringList{"icon", "sort_order", "created"} : QStringList(); + for (const auto &v : query("PRAGMA table_xinfo(" + table + ')')) { + const auto column = v.toMap(); const auto name = column.value("name").toString(); + require(column.value("hidden").toInt() == 0 && (required.value(table).contains(name) || optional.contains(name)), "databaseCorrupt", table); + columns.insert(name); + if(column.value("pk").toInt()>0)keyColumns.insert(column.value("pk").toInt(),name); + } + require(keyColumns.values()==primaryKeys.value(table),"databaseCorrupt","Invalid primary key: "+table); + for (const auto &column : required.value(table)) require(columns.contains(column), "databaseCorrupt", table + '.' + column); + query("SELECT " + required.value(table).join(',') + " FROM " + table + " LIMIT 0"); + if(table=="notes" || table=="folders") { + bool uniquePath=false; + for(const auto &v:query("PRAGMA index_list("+table+')')) { + const auto index=v.toMap();if(!index.value("unique").toBool() || index.value("partial").toBool())continue; + const auto fields=query("SELECT name FROM pragma_index_info(?)",{index.value("name")}); + if(fields.size()==1 && fields.first().toMap().value("name")=="path")uniquePath=true; + } + require(uniquePath,"databaseCorrupt","Missing unique path: "+table); + } + if(table=="versions") { + query("SELECT rowid FROM versions LIMIT 0"); + const auto keys=query("PRAGMA foreign_key_list(versions)"); + require(keys.size()==1 && keys.first().toMap().value("table")=="notes" && keys.first().toMap().value("from")=="note_id" + && keys.first().toMap().value("to")=="id" && keys.first().toMap().value("on_delete")=="CASCADE","databaseCorrupt","Invalid version ownership"); + } + } + for (const auto &table : {QString("notes"), QString("folders"), QString("drafts")}) { + if (!tables.contains(table)) continue; + for (const auto &v : query("SELECT path FROM " + table)) { + const auto path = v.toMap().value("path").toString(); + bool valid = validPath(path); + if (table == "drafts" && path.startsWith("external:")) + valid = QDir::isAbsolutePath(path.mid(9)) && !path.contains(QChar::Null) && !path.contains('\n') && !path.contains('\r'); + require(valid, "databaseCorrupt", "Invalid stored path"); + } + } + if (tables.contains("assets")) { + static const QRegularExpression identity("^assets/([0-9a-f]{64})\\.[a-z0-9]{1,8}$"); + for (const auto &v : query("SELECT path,data FROM assets")) { + const auto row = v.toMap(); const auto match = identity.match(row.value("path").toString()); + require(match.hasMatch() && row.value("data").metaType().id() == QMetaType::QByteArray + && row.value("data").toByteArray().size() <= 32 * 1024 * 1024 + && match.captured(1) == MarkdownFile::digest(row.value("data").toByteArray()), "databaseCorrupt", "Invalid asset identity"); + } + } } +void ManagedStore::Impl::preflight() { + const auto file = root + "/notes.sqlite3"; + if (!QFileInfo::exists(file) && !QFileInfo(file).isSymLink()) { + require(!QFileInfo::exists(file + "-wal") && !QFileInfo::exists(file + "-shm") + && !QFileInfo(file + "-wal").isSymLink() && !QFileInfo(file + "-shm").isSymLink(), "databaseCorrupt", "Database is missing but sidecars remain"); + return; + } + // Even SQLITE_OPEN_READONLY can create/write shared-memory sidecars for a + // WAL database. Copy under our retained lease, and probe only the private + // snapshot. The original database, WAL and SHM are never opened by SQLite. + QTemporaryDir temporary; require(temporary.isValid(), "metadataUnavailable"); + const auto snapshot = temporary.filePath("notes.sqlite3"); + for (const auto &suffix : {QString(), QString("-wal"), QString("-shm"), QString("-journal")}) { + const auto source = file + suffix; const QFileInfo info(source); + require(!info.isSymLink() && (!info.exists() || info.isFile()), "unsafePath", source); + if (info.exists() && suffix != "-shm") streamCopy(source, snapshot + suffix); + } + try { + Impl inspection; + inspection.db = openDatabase(snapshot, SQLITE_OPEN_READWRITE).release(); + inspection.query("PRAGMA query_only=ON"); + inspection.validateDatabase(false, true); + inspection.query("PRAGMA query_only=OFF"); + // Exercise legacy migrations before allowing them to touch the original. + if (inspection.one("PRAGMA user_version").value("user_version").toInt() == 1) { + inspection.initializeFolderMetadata(); inspection.initializeFolders(); + inspection.listing(); + } + if (!inspection.one("SELECT name FROM sqlite_master WHERE type='table' AND name='assets'").isEmpty()) + for (const auto &asset : inspection.query("SELECT path FROM assets")) checkedCachePath(asset.toMap().value("path").toString()); + } catch (const StoreError &error) { + if (error.code == "databaseVersion" || error.code == "unsafePath") throw; + throw StoreError{"databaseCorrupt", error.detail}; + } +} +void ManagedStore::Impl::prepareBackup(const QString &source, const QString &snapshot) { + require(QFileInfo(source).isFile() && !QFileInfo(source).isSymLink(), "invalidBackup"); + streamCopy(source, snapshot); + try { + Impl inspection; + inspection.db = openDatabase(snapshot, SQLITE_OPEN_READWRITE).release(); + inspection.validateDatabase(true); + inspection.initializeFolderMetadata(); inspection.initializeFolders(); + inspection.listing(); + inspection.query("PRAGMA journal_mode=DELETE"); + } catch (const StoreError &error) { + throw StoreError{"invalidBackup", error.detail.isEmpty() ? QStringLiteral("备份内容或结构无法读取") : error.detail}; + } + recoveryStage("snapshotValidated"); +} + void ManagedStore::Impl::initializeFolders() { const auto initialized = settings("initialization"); if (initialized.value("defaultFolderCreated").toBool() && initialized.value("quickNotesFolderCreated").toBool()) return; @@ -466,11 +735,38 @@ QVariantMap ManagedStore::Impl::applyHistory(bool redo) { } ManagedStore::~ManagedStore() = default; QString ManagedStore::directory() const { return d->root; } +QString ManagedStore::entryRoot() const { return d->library ? d->library->entryRoot() : d->configuredRoot; } +bool ManagedStore::hasPendingMemoryDrafts() const { return d->hasPendingMemoryDrafts(); } +QVariantMap ManagedStore::initializationError() const { return d->openError; } QVariantMap ManagedStore::dispatch(const QString &op,const QVariantMap &a, std::function progress, std::shared_ptr cancelled) { d->progress = std::move(progress); d->cancelled = std::move(cancelled); + if (!d->openError.isEmpty()) { + if (op == "restoreBackup" && d->openError.value("recoveryAvailable").toBool()) { + try { return d->recoverInitialization(a); } + catch (const StoreError &error) { + const bool uncertain = d->library && d->library->activationUncertain(); + if (uncertain) d->openError = {{"ok",false},{"error","recoveryActivationUncertain"},{"recoveryAvailable",false},{"activationUncertain",true}}; + return {{"ok",false},{"error",uncertain ? QString("recoveryActivationUncertain") : error.code}, + {"detail",error.detail},{"activationUncertain",uncertain},{"entryRoot",entryRoot()}, + {"preservedDirectory",d->root},{"recoveryAvailable",!uncertain}}; + } + } + return d->openError; + } const bool outer=sqlite3_get_autocommit(d->db); try { - const bool ownTransaction = outer && op != "backup" && op != "restoreBackup" && op != "importNotes"; + if (op == "restoreBackup" || op == "importNotes" || op == "rename" || op == "renameFolder" || op == "moveFolder" || op == "editFolder" + || op == "trash" || op == "trashFolder" || op == "purgeFolder" || op == "undo" || op == "redo" + || (op == "batch" && QStringList{"move","trash","purgeNotes"}.contains(a.value("action").toString()))) + require(!hasPendingMemoryDrafts(), "pendingDrafts", "Persist or discard in-memory drafts before replacing or reorganizing the library."); + // These operations own independent draft transactions and must never be + // rolled back with the later external-file operation. + if (op == "checkpoint" || op == "discardDraft" || (op == "save" && a.value("path").toString().startsWith("external:"))) { + auto result = op == "checkpoint" ? d->checkpoint(a) : op == "discardDraft" ? d->discardDraft(a.value("path").toString()) : d->saveExternal(a); + result.insert("undoState", d->historyState()); + result.insert("entryRoot", entryRoot()); return result; + } + const bool ownTransaction = outer && op != "backup" && op != "restoreBackup" && op != "importNotes" && op != "recovery"; if(ownTransaction)d->query("BEGIN IMMEDIATE"); const auto label=d->commandLabel(op,a); if(!label.isEmpty())d->beginCapture(); @@ -497,8 +793,105 @@ QVariantMap ManagedStore::dispatch(const QString &op,const QVariantMap &a, std:: } else if(op=="clearRedo") d->redoStack.clear(); else if(op=="importNotes" || op=="restoreBackup" || op=="purgeFolder" || (op=="trashBatch"&&a.value("action")=="purge") || (op=="batch" && a.value("action")=="purgeNotes" && result.value("succeeded").toInt()>0)) {d->undoStack.clear();d->redoStack.clear();} - result.insert("undoState",d->historyState());return result; - }catch(const StoreError &e){if(outer)sqlite3_exec(d->db,"ROLLBACK",nullptr,nullptr,nullptr);d->index=Knowledge::Index();return {{"ok",false},{"error",e.code},{"detail",e.detail}};} + if (result.value("ok").toBool() && (op == "save" || op == "trash")) d->memoryDrafts.remove(a.value("path").toString()); + result.insert("undoState",d->historyState()); + if (result.value("ok").toBool() && (op=="list" || op=="search" || op=="selectLibrary" || op=="reindex" + || op=="read" || op=="restoreBackup" || op=="importNotes" || op=="importAttachment" + || op=="quarantineAttachment" || op=="restoreAttachment" || op=="restore" || op=="restoreFolder" || op=="create" || op=="saveAs" + || op=="fromTemplate" || op=="dailyNote" || op=="createWiki")) d->addContext(result); + return result; + }catch(const StoreError &e){if(outer)sqlite3_exec(d->db,"ROLLBACK",nullptr,nullptr,nullptr);d->index=Knowledge::Index();d->manifestDirty=true;return {{"ok",false},{"error",e.code},{"detail",e.detail}};} +} + +QVariantMap ManagedStore::Impl::checkpoint(const QVariantMap &a) { + const auto path = a.value("path").toString(); + require(path.startsWith("external:") ? QDir::isAbsolutePath(path.mid(9)) && !path.contains(QChar::Null) && !path.contains('\n') && !path.contains('\r') : validPath(path), "invalidName"); + QVariantMap draft{{"path",path},{"text",a.value("text").toString()},{"baseHash",a.value("baseHash").toString()}, + {"updatedUtc",utc()},{"recoveryDurable",false}}; + memoryDrafts.insert(path,draft); + try { + failAt("draftBegin"); query("BEGIN IMMEDIATE"); + query("INSERT INTO drafts(path,text,base_hash,time) VALUES(?,?,?,?) ON CONFLICT(path) DO UPDATE SET text=excluded.text,base_hash=excluded.base_hash,time=excluded.time", + {path,draft.value("text"),draft.value("baseHash"),draft.value("updatedUtc")}); + failAt("draftCommit"); query("COMMIT"); + // Once committed, the database is authoritative. Keeping a duplicate + // memory entry would become stale after note relocation or deletion. + memoryDrafts.remove(path); + } catch (const StoreError &error) { + sqlite3_exec(db,"ROLLBACK",nullptr,nullptr,nullptr); + return {{"ok",false},{"error","recoveryWriteFailed"},{"detail",error.detail},{"draftInMemory",true},{"recoveryDurable",false}}; + } + return {{"ok",true},{"recoveryDurable",true},{"draftInMemory",false}}; +} +QVariantMap ManagedStore::Impl::discardDraft(const QString &path) { + try { + query("BEGIN IMMEDIATE"); failAt("draftCleanup"); + query("DELETE FROM drafts WHERE path=?",{path}); + failAt("draftCleanupCommit"); query("COMMIT"); + memoryDrafts.remove(path); + return ok(); + } catch (const StoreError &error) { + sqlite3_exec(db,"ROLLBACK",nullptr,nullptr,nullptr); + return {{"ok",false},{"error","discardFailed"},{"detail",error.detail},{"draftRetained",true}}; + } +} +QVariantMap ManagedStore::Impl::saveExternal(const QVariantMap &a) { + auto draftResult = checkpoint(a); + if (!draftResult.value("ok").toBool()) return draftResult; + externalStage("draftCommitted"); + const auto path=a.value("path").toString(), file=path.mid(9), text=a.value("text").toString(), base=a.value("baseHash").toString(); + try { + require(external.contains(path) && external.value(path).hash == base,"unknownBase"); + auto document=external.value(path); + require(document.readOnlyReason.isEmpty(),"readOnly",document.readOnlyReason); + require(MarkdownFile::formatError(text).isEmpty(),"unsupportedFormat"); + require(QFileInfo::exists(file),"notFound",file); + const auto current=MarkdownFile::decode(bytesAt(file)); + if (current.hash != base) return {{"ok",false},{"error","conflict"},{"diskHash",current.hash},{"diskText",current.text},{"draftInMemory",false},{"recoveryDurable",true}}; + if(a.value("sourceNewlines").toBool())document.trailingNewline=text.endsWith('\n'); + const auto bytes=MarkdownFile::encode(document,text); + failAt("externalWrite"); + QSaveFile output(file); output.setDirectWriteFallback(false); + require(output.open(QIODevice::WriteOnly) && output.write(bytes)==bytes.size() && output.commit(),"writeFailed",output.errorString()); + external.insert(path,MarkdownFile::decode(bytes)); + // The disk baseline changes before cleanup. Cleanup failure must never + // tell the editor that its successful write failed or kept the old hash. + externalStage("externalWritten"); externalStage("beforeDraftCleanup"); + const auto cleanup=discardDraft(path); + QVariantMap result{{"ok",true},{"path",path},{"baseHash",MarkdownFile::digest(bytes)},{"recoveryDurable",true}}; + if(!cleanup.value("ok").toBool()) { + result.insert("draftRetained",true); result.insert("recoveryCleanupFailed",true); + result.insert("warning","recoveryCleanupFailed"); result.insert("detail",cleanup.value("detail")); + } + return result; + } catch(const StoreError &error) { + return {{"ok",false},{"error",error.code},{"detail",error.detail},{"draftInMemory",false},{"recoveryDurable",true}}; + } +} +QVariantList ManagedStore::Impl::recoveryDrafts() { + QMap merged; + for(const auto &v:query("SELECT * FROM drafts")) { + const auto row=v.toMap(); + merged.insert(row.value("path").toString(),{{"path",row.value("path")},{"text",row.value("text")}, + {"baseHash",row.value("base_hash")},{"updatedUtc",row.value("time")},{"recoveryDurable",true}}); + } + for(auto it=memoryDrafts.cbegin();it!=memoryDrafts.cend();++it)merged.insert(it.key(),it.value()); + QVariantList result; + for(auto draft:merged) { + const auto path=draft.value("path").toString(); + draft.insert("title",MarkdownFile::title(draft.value("text").toString(),"未命名笔记")); + if(path.startsWith("external:")) { + // Backup-owned paths are data, never an instruction to read a file. + const bool known=external.contains(path); + draft.insert("baseUnverified",!known); + draft.insert("matchesBase",known && external.value(path).hash==draft.value("baseHash").toString()); + } else { + const auto stored=one("SELECT hash FROM notes WHERE path=?",{path}); + draft.insert("matchesBase",!stored.isEmpty() && stored.value("hash")==draft.value("baseHash")); + } + result.append(draft); + } + return result; } QVariantMap ManagedStore::Impl::run(const QString &op,const QVariantMap &a) { @@ -514,25 +907,20 @@ QVariantMap ManagedStore::Impl::run(const QString &op,const QVariantMap &a) { // External documents are deliberately absent from notes, folders and the index. if(path.startsWith("external:")) { const auto file=path.mid(9); - if(op=="read") {auto d=MarkdownFile::decode(bytesAt(file));external.insert(path,d);return {{"ok",true},{"path",path},{"id",path},{"text",d.text},{"baseHash",d.hash},{"readOnly",!d.readOnlyReason.isEmpty()},{"readOnlyReason",d.readOnlyReason}};} - if(op=="save") { - require(external.contains(path),"unknownBase");auto d=external.value(path); - require(d.readOnlyReason.isEmpty(),"readOnly");require(MarkdownFile::digest(bytesAt(file))==a.value("baseHash").toString(),"conflict"); - const auto bytes=MarkdownFile::encode(d,a.value("text").toString());writeBytes(file,bytes);external.insert(path,MarkdownFile::decode(bytes));query("DELETE FROM drafts WHERE path=?",{path});return {{"ok",true},{"baseHash",MarkdownFile::digest(bytes)}}; - } + if(op=="read") {require(QDir::isAbsolutePath(file)&&!file.contains(QChar::Null),"unsafePath");auto d=MarkdownFile::decode(bytesAt(file));external.insert(path,d);return {{"ok",true},{"path",path},{"id",path},{"text",d.text},{"baseHash",d.hash},{"readOnly",!d.readOnlyReason.isEmpty()},{"readOnlyReason",d.readOnlyReason}};} if(op!="checkpoint"&&op!="discardDraft"&&op!="saveAs")return {{"ok",false},{"error","externalDocument"}}; } if(op=="read")return document(note(path)); if(op=="create"||op=="saveAs") { - auto result=insert(path,a.value("text").toString());result.insert("listing",listing());return result; + auto text=a.value("text").toString();const auto source=a.value("sourcePath").toString(); + if(op=="saveAs")text=rebaseAttachments(text,source.isEmpty()?QStringLiteral("recovery.md"):source,path); + auto result=insert(path,text);result.insert("listing",listing());return result; } if(op=="save") { auto n=note(path);require(n["hash"].toString()==a.value("baseHash").toString(),"conflict"); require(MarkdownFile::formatError(a.value("text").toString()).isEmpty(),"unsupportedFormat"); updateText(n,a.value("text").toString());query("DELETE FROM drafts WHERE path=?",{path});auto result=document(note(path));result.insert("listing",listing());return result; } - if(op=="checkpoint") {query("INSERT INTO drafts(path,text,base_hash,time) VALUES(?,?,?,?) ON CONFLICT(path) DO UPDATE SET text=excluded.text,base_hash=excluded.base_hash,time=excluded.time",{path,a.value("text"),a.value("baseHash"),utc()});return {{"ok",true},{"recoveryDurable",true}};} - if(op=="discardDraft"){query("DELETE FROM drafts WHERE path=?",{path});return ok();} if (op == "createFolder") { const auto icon = a.value("icon", QStringLiteral("folder")).toString(); require(validPath(path), "invalidName"); @@ -614,12 +1002,14 @@ QVariantMap ManagedStore::Impl::run(const QString &op,const QVariantMap &a) { for(const auto &v:query("SELECT * FROM notes WHERE deleted<>''")){auto n=v.toMap();if(!one("SELECT id FROM folders WHERE trash_token=?",{n["trash_token"]}).isEmpty())continue;trash.append(QVariantMap{{"kind","note"},{"token",n["trash_token"]},{"source",n["path"]},{"deletedUtc",n["deleted"]},{"fingerprint",n["hash"]},{"title",MarkdownFile::title(n["text"].toString(),QFileInfo(n["path"].toString()).completeBaseName())}});} QSet seen; for(const auto &v:query("SELECT * FROM folders WHERE deleted<>'' ORDER BY length(path)")){auto n=v.toMap();const auto token=n["trash_token"].toString();if(seen.contains(token))continue;seen.insert(token);trash.append(QVariantMap{{"kind","folder"},{"token",token},{"source",n["path"]},{"deletedUtc",n["deleted"]},{"fingerprint",token}});} - for(const auto &v:query("SELECT * FROM drafts")){auto n=v.toMap();auto stored=one("SELECT hash FROM notes WHERE path=?",{n["path"]});drafts.append(QVariantMap{{"title",MarkdownFile::title(n["text"].toString(),"未命名笔记")},{"path",n["path"]},{"text",n["text"]},{"baseHash",n["base_hash"]},{"updatedUtc",n["time"]},{"matchesBase",stored["hash"]==n["base_hash"]}});} - return {{"ok",true},{"trashItems",trash},{"drafts",drafts},{"commits",QVariantList()},{"memoryDrafts",QVariantList()}}; + drafts=recoveryDrafts(); + QVariantList memory; + for(const auto &draft:memoryDrafts)memory.append(draft); + return {{"ok",true},{"trashItems",trash},{"drafts",drafts},{"commits",QVariantList()},{"memoryDrafts",memory}}; } if(op=="restore"||op=="restoreFolder") { const auto token=a.value("token").toString(),target=a.value("target").toString();auto n=one("SELECT * FROM notes WHERE trash_token=? AND deleted<>''",{token}); - if(!target.isEmpty()){require(!n.isEmpty(),"notFound");return insert(target,n["text"].toString());} + if(!target.isEmpty()){require(!n.isEmpty(),"notFound");return insert(target,rebaseAttachments(n["text"].toString(),n["path"].toString(),target));} require(!n.isEmpty()||!one("SELECT id FROM folders WHERE trash_token=?",{token}).isEmpty(),"notFound"); const auto rows=query("SELECT path FROM notes WHERE trash_token=?",{token}); query("UPDATE folders SET deleted='',trash_token='' WHERE trash_token=?",{token}); @@ -699,8 +1089,8 @@ QVariantMap ManagedStore::Impl::run(const QString &op,const QVariantMap &a) { const auto resolved=index.resolve(path,target,wiki);const QUrl url(target); const bool remote=!wiki&&!url.isRelative()&&!url.isLocalFile(); const bool anchor=!wiki&&target.startsWith('#'); - const auto local=LinkPaths::resolve(path,target); - const bool attachment=!wiki&&!local.isEmpty()&&!one("SELECT path FROM assets WHERE path=? AND deleted=''",{local}).isEmpty(); + const auto local=wiki?QString():attachmentIdentity(path,url); + const bool attachment=!local.isEmpty(); l.insert("resolved",resolved.size()==1||remote||anchor||attachment); if(resolved.size()==1) { const auto &linked=index.documents[resolved[0]]; @@ -717,21 +1107,23 @@ QVariantMap ManagedStore::Impl::run(const QString &op,const QVariantMap &a) { } if(op=="importAttachment") { note(path);QByteArray bytes;QString name=a.value("name").toString();const QUrl source(a.value("source").toString());if(source.isLocalFile()){name=QFileInfo(source.toLocalFile()).fileName();bytes=bytesAt(source.toLocalFile(),32*1024*1024);}else{auto decoded=QByteArray::fromBase64Encoding(a.value("data").toByteArray(),QByteArray::AbortOnBase64DecodingErrors);require(bool(decoded),"invalidAttachment");bytes=decoded.decoded;} - require(!bytes.isEmpty()&&bytes.size()<=32*1024*1024,"attachmentTooLarge");auto rel=asset(bytes,name);return {{"ok",true},{"path",rel},{"name",name},{"url",QUrl::fromLocalFile(root+'/'+rel).toString()},{"mime",QMimeDatabase().mimeTypeForData(bytes).name()},{"image",QMimeDatabase().mimeTypeForData(bytes).name().startsWith("image/")}}; + require(!bytes.isEmpty()&&bytes.size()<=32*1024*1024,"attachmentTooLarge");auto rel=asset(bytes,name);return {{"ok",true},{"path",rel},{"name",name},{"url",QString::fromUtf8(QUrl::toPercentEncoding(relativeManagedPath(path,rel),"/"))},{"mime",QMimeDatabase().mimeTypeForData(bytes).name()},{"image",QMimeDatabase().mimeTypeForData(bytes).name().startsWith("image/")}}; } if(op=="attachments") { QVariantList items,removed; - const auto notes=query("SELECT text FROM notes"); + const auto texts=attachmentReferenceTexts(); for(const auto &v:query("SELECT path,name,length(data) AS size,deleted FROM assets")) { - auto n=v.toMap();const auto p=n["path"].toString();int references=0; - for(const auto &doc:notes)if(doc.toMap()["text"].toString().contains(p))++references; + auto n=v.toMap();const auto p=n["path"].toString();const int references=attachmentReferences(p,texts); n.insert("references",references);n.insert("bytes",n["size"]);n.insert("relative",p);n.insert("token",p);n.insert("removedUtc",n["deleted"]); if(n["deleted"].toString().isEmpty())items.append(n);else removed.append(n); } return {{"ok",true},{"items",items},{"removed",removed}}; } if(op=="quarantineAttachment"||op=="restoreAttachment") { - const auto rel=op=="quarantineAttachment"?a.value("relative",a.value("path")).toString():a.value("token").toString();require(!one("SELECT path FROM assets WHERE path=?",{rel}).isEmpty(),"notFound");if(op=="quarantineAttachment")for(const auto &v:query("SELECT text FROM notes"))require(!v.toMap()["text"].toString().contains(rel),"attachmentInUse");query("UPDATE assets SET deleted=? WHERE path=?",{op=="quarantineAttachment"?utc():QString(),rel});return ok(); + const auto rel=op=="quarantineAttachment"?a.value("relative",a.value("path")).toString():a.value("token").toString(); + require(!one("SELECT path FROM assets WHERE path=?",{rel}).isEmpty(),"notFound"); + if(op=="quarantineAttachment")require(attachmentReferences(rel,attachmentReferenceTexts())==0,"attachmentInUse"); + query("UPDATE assets SET deleted=? WHERE path=?",{op=="quarantineAttachment"?utc():QString(),rel});manifestDirty=true;return ok(); } if(op=="recoverMove")return ok(); throw StoreError{"unknownOperation",op}; @@ -837,22 +1229,46 @@ QVariantMap ManagedStore::Impl::importItems(const QVariantMap &a) { } QVariantMap ManagedStore::Impl::exportItems(const QVariantMap &a) { const QUrl url(a.value("destination").toString());const auto directory=url.isLocalFile()?url.toLocalFile():a.value("destination").toString();require(QFileInfo(directory).isDir(),"writeFailed"); - const auto chosen=a.value("paths").toStringList();const auto scope=a.value("folder").toString();QVariantList exported; + const auto chosen=a.value("paths").toStringList();const auto scope=a.value("folder").toString();QVariantList exported,warnings; auto top=QDir(directory).filePath("笔记导出-"+QDateTime::currentDateTime().toString("yyyyMMdd-HHmmss")+'-'+uid().left(6));require(!QFileInfo::exists(top)&&QDir().mkpath(top),"writeFailed"); - QSet assets;const auto allAssets=query("SELECT path,data FROM assets"); + // Identity lookup is independent of the old absolute path and the disposable + // cache. Only the current database's validated BLOBs become exported files. + const auto manifest=managedManifest();QSet assets; if(chosen.isEmpty())for(const auto &v:query("SELECT path FROM folders WHERE deleted=''")){const auto p=v.toMap()["path"].toString();if(scope.isEmpty()||p==scope||p.startsWith(scope+'/')){require(validPath(p),"unsafePath");require(QDir().mkpath(top+'/'+p),"writeFailed",p);}} for(const auto &v:query("SELECT * FROM notes WHERE deleted=''")) { auto n=v.toMap();const auto path=n["path"].toString();if(!chosen.isEmpty()&&!chosen.contains(path))continue;if(!scope.isEmpty()&&!path.startsWith(scope+'/'))continue; require(validPath(path),"unsafePath");auto text=n["text"].toString(); - LinkPaths::rewrite(text,[&](const QString &target,bool wiki){if(wiki)return target;QUrl url(target);QString resolved=url.isLocalFile()?QDir(root).relativeFilePath(url.toLocalFile()):LinkPaths::resolve(path,target);if(resolved.startsWith("assets/")){assets.insert(resolved);auto rel=relativeManagedPath(path,resolved);QString result=QString::fromUtf8(QUrl::toPercentEncoding(rel,"/"));if(!url.fragment().isEmpty())result+='#'+url.fragment(QUrl::FullyEncoded);return result;}return target;}); + const ManagedAssets::Context context{root,QUrl::fromLocalFile(root+'/'+path),manifest,true}; + QSet warned; + const bool supported=rewriteExportLinks(text,[&](const QString &target,bool wiki){ + if(wiki)return target; + const QUrl original(target);auto identityUrl=original;identityUrl.setQuery(QString());identityUrl.setFragment(QString()); + const auto identity=ManagedAssets::resolveIdentity(context,identityUrl); + if(!identity.isEmpty()) {assets.insert(identity);return relativeManagedUrl(path,identity,original);} + // Unknown local resources remain visible in the Markdown. Never try + // their former filesystem location, including for a missing digest. + const auto resolved=LinkPaths::resolve(path,target); + const bool local=!original.path().isEmpty() && (original.isRelative()||original.isLocalFile()||original.scheme()=="nkasset"); + if(local && !warned.contains(target) && (resolved.isEmpty()||one("SELECT id FROM notes WHERE path=? AND deleted=''",{resolved}).isEmpty())) { + warned.insert(target);warnings.append(QVariantMap{{"path",path},{"target",target},{"code","missingAttachment"}, + {"message","附件未包含在当前笔记库中,链接已原样保留:"+target}}); + } + return target; + }); + if(!supported)warnings.append(QVariantMap{{"path",path},{"code","unsupportedHtml"}, + {"message","HTML 链接或图片已原样保留;这类关联资源未自动导出。"}}); writeBytes(top+'/'+path,text.toUtf8());exported.append(path); } - for(const auto &v:allAssets){auto n=v.toMap();if(assets.contains(n["path"].toString()))writeBytes(top+'/'+n["path"].toString(),n["data"].toByteArray());} - return {{"ok",true},{"directory",top},{"count",exported.size()}}; + for(const auto &path:assets) { + const auto stored=one("SELECT data FROM assets WHERE path=? AND deleted=''",{path}); + require(!stored.isEmpty() && stored.value("data").metaType().id()==QMetaType::QByteArray + && MarkdownFile::digest(stored.value("data").toByteArray())==manifest.value(path).toString(),"databaseCorrupt","Invalid asset identity"); + writeBytes(top+'/'+path,stored.value("data").toByteArray()); + } + return {{"ok",true},{"directory",top},{"count",exported.size()},{"warnings",warnings}}; } namespace { -using Database = std::unique_ptr; Database openDatabase(const QString &path, int flags) { sqlite3 *raw = nullptr; const int rc = sqlite3_open_v2(QFile::encodeName(path).constData(), &raw, flags, nullptr); @@ -903,48 +1319,80 @@ QVariantMap ManagedStore::Impl::backup(const QVariantMap &a) { } QVariantMap ManagedStore::Impl::restoreBackup(const QVariantMap &a) { const auto source = QUrl(a.value("source").toString()).toLocalFile(); - require(QFileInfo(source).isFile() && !QFileInfo(source).isSymLink(), "invalidBackup"); QTemporaryDir temporary; require(temporary.isValid(), "writeFailed"); const auto snapshot = temporary.filePath("restore.sqlite3"); - streamCopy(source,snapshot); - auto incoming = openDatabase(snapshot, SQLITE_OPEN_READWRITE); - // Validate a private copy, including every schema column used by this app. - Impl inspection; inspection.db = incoming.release(); - try { - require(inspection.one("PRAGMA application_id").value("application_id").toInt()==1313558065 && - inspection.one("PRAGMA user_version").value("user_version").toInt()==1, "invalidBackup"); - require(inspection.one("PRAGMA quick_check").value("quick_check").toString()=="ok" && inspection.query("PRAGMA foreign_key_check").isEmpty(),"invalidBackup"); - const QStringList columns{ - "notes:id,path,text,hash,created,modified,pinned,favorite,deleted,trash_token,source,source_hash,import_batch", - "folders:id,path,deleted,trash_token", "drafts:path,text,base_hash,time", "versions:note_id,hash,text,time", - "assets:path,data,name,deleted", "imports:id,name,time,report", "settings:kind,name,value"}; - for(const auto &spec:columns)inspection.query("SELECT "+spec.section(':',1)+" FROM "+spec.section(':',0,0)+" LIMIT 0"); - for(const auto &v:inspection.query("SELECT name,type FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'")) { - const auto row=v.toMap(); - require(row["type"]=="table"||row["type"]=="index","invalidBackup"); - } - for(const auto &v:inspection.query("SELECT path FROM assets")) { - const auto path=v.toMap()["path"].toString(); - require(validPath(path)&&path.startsWith("assets/")&&!QFileInfo(root+'/'+path).isSymLink(),"invalidBackup"); + prepareBackup(source,snapshot); + auto incoming = openDatabase(snapshot, SQLITE_OPEN_READONLY); + // Prepare metadata and check every cache destination before the restore + // commit; no post-commit read can misreport a completed replacement. + Impl incomingInspection;incomingInspection.db=openDatabase(snapshot,SQLITE_OPEN_READONLY).release(); + QVariantMap incomingManifest; + const auto incomingAssets=incomingInspection.query("SELECT path,data,deleted FROM assets"); + for(const auto &v:incomingAssets) { + const auto asset=v.toMap();checkedCachePath(asset.value("path").toString()); + if(asset.value("deleted").toString().isEmpty())incomingManifest.insert(asset.value("path").toString(),MarkdownFile::digest(asset.value("data").toByteArray())); } - } catch (const StoreError &) { throw StoreError{"invalidBackup", "备份内容或结构无法读取"}; } - // Upgrade the private snapshot before replacing the active library. - inspection.initializeFolderMetadata(); - inspection.initializeFolders(); // Keep an independent, user-accessible rollback copy before replacing anything. const auto safetyFolder=QDir::cleanPath(QDir(root).absoluteFilePath("../Backups")); require(QDir().mkpath(safetyFolder),"writeFailed"); const auto safety=QDir(safetyFolder).filePath("恢复前-"+QDateTime::currentDateTime().toString("yyyyMMdd-HHmmss")+'-'+uid().left(6)+".nkbackup"); backup({{"destination",QUrl::fromLocalFile(safety).toString()}}); - copyDatabase(db,inspection.db); - initializationNotice = inspection.initializationNotice; - index=Knowledge::Index();external.clear(); - int cacheFailures=0; + copyDatabase(db,incoming.get()); + initializationNotice.clear(); + index=Knowledge::Index();external.clear();memoryDrafts.clear();assetManifest=std::move(incomingManifest);manifestDirty=false; + cacheFailures=0; // Cache refresh failures do not undo or misreport a committed database restore. - for(const auto &v:query("SELECT path FROM assets WHERE deleted=''")) { - const auto asset=one("SELECT path,data FROM assets WHERE path=?",{v.toMap()["path"]}); - try { writeBytes(root+'/'+asset["path"].toString(),asset["data"].toByteArray()); } + for(const auto &v:incomingAssets) { + const auto asset=v.toMap();if(!asset.value("deleted").toString().isEmpty())continue; + try { writeBytes(checkedCachePath(asset["path"].toString()),asset["data"].toByteArray()); } catch(const StoreError &) { ++cacheFailures; } } - return {{"ok",true},{"safetyCopy",safety},{"cacheFailures",cacheFailures},{"initializationNotice",initializationNotice}}; + return {{"ok",true},{"safetyCopy",safety},{"cacheFailures",cacheFailures},{"initializationNotice",initializationNotice},{"restored",true}}; +} +QVariantMap ManagedStore::Impl::recoverInitialization(const QVariantMap &a) { + require(library && !library->activationUncertain() && !db, "recoveryUnavailable"); + require(!hasPendingMemoryDrafts(), "pendingDrafts"); + QTemporaryDir temporary; require(temporary.isValid(), "writeFailed"); + const auto snapshot=temporary.filePath("validated.sqlite3"); + prepareBackup(QUrl(a.value("source").toString()).toLocalFile(),snapshot); + const auto preserved=root, recoveryId=uid(); + const auto replacement=library->createReplacement(recoveryId); + const auto lease=ManagedLibrary::lockDirectory(replacement); + failAt("replacementDatabase"); streamCopy(snapshot,replacement+"/notes.sqlite3"); + Impl prepared; prepared.root=replacement; prepared.configuredRoot=configuredRoot; + prepared.initializeDatabase(); + require(prepared.cacheFailures==0,"writeFailed","Replacement attachment cache is incomplete"); + auto listing=prepared.listing(); + prepared.addContext(listing); + // Complete WAL checkpoint and close all preparation connections before + // syncing the replacement; no connection ever touches the failed database. + const auto checkpoint=prepared.one("PRAGMA wal_checkpoint(TRUNCATE)"); + require(checkpoint.value("busy").toInt()==0,"writeFailed","Replacement checkpoint failed"); + require(prepared.one("PRAGMA journal_mode=DELETE").value("journal_mode").toString()=="delete","writeFailed"); + require(sqlite3_close(prepared.db)==SQLITE_OK,"writeFailed"); prepared.db=nullptr; + QStringList directories{replacement}; + QDirIterator files(replacement,QDir::Files|QDir::Dirs|QDir::NoDotAndDotDot|QDir::Hidden,QDirIterator::Subdirectories); + while(files.hasNext()) { + files.next(); const auto info=files.fileInfo(); + require(!info.isSymLink(),"unsafePath",info.absoluteFilePath()); + if(info.isDir())directories.append(info.absoluteFilePath());else ManagedLibrary::syncFile(info.absoluteFilePath()); + } + for(auto it=directories.crbegin();it!=directories.crend();++it)ManagedLibrary::syncDirectory(*it); + ManagedLibrary::syncDirectory(QFileInfo(replacement).absolutePath()); + // Reopen and complete the first listing before publication. DELETE mode + // avoids creating a new unsynced WAL after the durable preparation step. + prepared.db=openDatabase(replacement+"/notes.sqlite3",SQLITE_OPEN_READWRITE).release(); + prepared.query("PRAGMA trusted_schema=OFF"); prepared.query("PRAGMA synchronous=FULL"); prepared.query("PRAGMA foreign_keys=ON"); + prepared.initializeUndo(); listing=prepared.listing(); prepared.addContext(listing); + recoveryStage("replacementReady"); + library->publish(replacement,recoveryId,lease); + // Pointer publication is the sole commit point. Only infallible state moves + // follow it; errors while syncing its directory keep this context blocked. + db=prepared.db; prepared.db=nullptr; root=replacement; + initializationNotice=prepared.initializationNotice; index=std::move(prepared.index); + assetManifest=std::move(prepared.assetManifest); manifestDirty=false; + external.clear();memoryDrafts.clear();undoStack.clear();redoStack.clear();openError.clear(); + listing.insert("ok",true);listing.insert("restored",true);listing.insert("recoveredInitialization",true); + listing.insert("entryRoot",library->entryRoot());listing.insert("library",root);listing.insert("preservedDirectory",preserved); + listing.insert("undoState",historyState()); return listing; } diff --git a/src/storage/ManagedStore.h b/src/storage/ManagedStore.h index 121554c..2bbc6c9 100644 --- a/src/storage/ManagedStore.h +++ b/src/storage/ManagedStore.h @@ -5,12 +5,18 @@ #include #include +struct ManagedStoreError { QString code, detail; }; + class ManagedStore { public: - explicit ManagedStore(const QString &directory); + // Repository workers retain a failed open (and its locks) for restoreBackup. + explicit ManagedStore(const QString &directory, bool retainInitializationFailure = false); ~ManagedStore(); QVariantMap dispatch(const QString &operation, const QVariantMap &arguments, std::function progress = {}, std::shared_ptr cancelled = {}); QString directory() const; + QString entryRoot() const; + bool hasPendingMemoryDrafts() const; + QVariantMap initializationError() const; private: struct Impl; std::unique_ptr d; diff --git a/src/storage/NotesRepository.cpp b/src/storage/NotesRepository.cpp index b813444..1404208 100644 --- a/src/storage/NotesRepository.cpp +++ b/src/storage/NotesRepository.cpp @@ -71,6 +71,8 @@ class RepositoryWorker final : public QObject { currentProgress = {}; currentCancellation.reset(); result.insert("library", root); result.insert("generation", generation); + if (managed) result.insert("entryRoot", managed->entryRoot()); + else if (failedManaged) result.insert("entryRoot", failedManaged->entryRoot()); emit finished(id, op, result); } signals: @@ -79,6 +81,7 @@ class RepositoryWorker final : public QObject { void changed(const QVariantMap &); private: std::unique_ptr managed; + std::unique_ptr failedManaged; std::function currentProgress; std::shared_ptr currentCancellation; bool editingTags = false; @@ -231,21 +234,47 @@ QVariantMap RepositoryWorker::dispatch(const QString &op, const QVariantMap &a) { QString error; if (op == "selectLibrary" && a.value("managed").toBool()) { + if ((managed && managed->hasPendingMemoryDrafts()) || !memoryDrafts.isEmpty()) + return failure("pendingDrafts", "Persist or discard in-memory drafts before switching libraries."); + const auto selected=QDir(a.value("path").toString()).absolutePath(); + if (managed && (selected==managed->entryRoot() || selected==managed->directory())) + return managed->dispatch("list",{},currentProgress,currentCancellation); + // Retrying a failed selection releases only that failed context's leases. + failedManaged.reset(); try { - auto next = std::make_unique(a.value("path").toString()); + auto next = std::make_unique(a.value("path").toString(),true); auto result = next->dispatch("list", {}); - if (!result.value("ok").toBool()) return result; + if (!result.value("ok").toBool()) { + // A failed attempt to switch never replaces an already open + // library or arms a recovery operation against that library. + if (!root.isEmpty()) result.insert("recoveryAvailable",false); + else failedManaged=std::move(next); + return result; + } managed = std::move(next); root = managed->directory(); ++generation; delete watcher; watcher = nullptr; delete debounce; debounce = nullptr; lock.reset(); + opened.clear(); fingerprints.clear(); metadata={}; return result; - } catch (...) { return failure("databaseError", "无法打开内部笔记存储;未修改原笔记目录。"); } + } catch (const ManagedStoreError &error) { + auto result=failure(error.code,error.detail);result.insert("recoveryAvailable",false);return result; + } } - if (managed) { - if (a.contains("generation") && a.value("generation").toULongLong() != generation) return failure("staleGeneration"); - return managed->dispatch(op, a, currentProgress, currentCancellation); + if (a.contains("generation") && op != "selectLibrary" && a.value("generation").toULongLong() != generation) return failure("staleGeneration"); + if (op == "restoreBackup" && failedManaged && !managed) { + auto result=failedManaged->dispatch(op,a,currentProgress,currentCancellation); + if(result.value("ok").toBool()) { + managed=std::move(failedManaged); root=managed->directory(); ++generation; + delete watcher; watcher=nullptr; delete debounce; debounce=nullptr; lock.reset(); + } + return result; + } + if (managed && op != "selectLibrary") { + auto result=managed->dispatch(op,a,currentProgress,currentCancellation); + if(op=="restoreBackup" && result.value("ok").toBool()) {root=managed->directory();++generation;} + return result; } if (op == "selectLibrary") { - if (!memoryDrafts.isEmpty()) return failure("pendingDrafts", "Save or explicitly discard in-memory drafts before switching libraries."); + if (!memoryDrafts.isEmpty() || (managed && managed->hasPendingMemoryDrafts())) return failure("pendingDrafts", "Save or explicitly discard in-memory drafts before switching libraries."); QFileInfo selected(a.value("path").toString()); if (!selected.isDir() || selected.isSymLink()) return failure("invalidLibrary"); QString selectedRoot = selected.canonicalFilePath(); @@ -293,6 +322,7 @@ QVariantMap RepositoryWorker::dispatch(const QString &op, const QVariantMap &a) } delete oldWatcher; delete oldDebounce; lock = std::move(newLock); + managed.reset(); failedManaged.reset(); ++generation; return prepared; } diff --git a/src/storage/RepositoryTestHooks.h b/src/storage/RepositoryTestHooks.h index c836413..ef3d772 100644 --- a/src/storage/RepositoryTestHooks.h +++ b/src/storage/RepositoryTestHooks.h @@ -1,6 +1,12 @@ #pragma once #include +#include // Deterministic fault injection; configure only while the repository worker is idle. namespace RepositoryTestHooks { inline std::function beforeScan; +// Stage callbacks may terminate a test child to exercise crash boundaries. +inline std::function managedRecoveryStage; +inline std::function externalSaveStage; +// Return true to fail the named write/commit boundary; no environment backdoor. +inline std::function managedFailure; } diff --git a/tests/ManagedAssetsTest.cpp b/tests/ManagedAssetsTest.cpp new file mode 100644 index 0000000..7492470 --- /dev/null +++ b/tests/ManagedAssetsTest.cpp @@ -0,0 +1,308 @@ +#include +#include +#include +#include +#include +#include "ManagedAssets.h" + +class ManagedAssetsTest : public QObject { + Q_OBJECT + static bool write(const QString &path, const QByteArray &bytes) { + if (!QDir().mkpath(QFileInfo(path).absolutePath())) return false; + QFile file(path); + return file.open(QIODevice::WriteOnly) && file.write(bytes) == bytes.size(); + } + static QString digest(const QByteArray &bytes) { + return QString::fromLatin1(QCryptographicHash::hash(bytes, QCryptographicHash::Sha256).toHex()); + } + static ManagedAssets::Context context(const QString &root, const QString &document, const QByteArray &bytes) { + const QString hash = digest(bytes); + return {root, QUrl::fromLocalFile(root + '/' + document), {{"assets/" + hash + ".png", hash}}, true}; + } + static QUrl schemeUrl(const QString &path) { + QUrl url; + url.setScheme("nkasset"); + url.setPath(path); + return url; + } +private slots: + void relativeUrlsAndVirtualDocuments() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QByteArray bytes("database image bytes"); + const auto assets = context(directory.path(), "Projects/Nested/note.md", bytes); + const QString identity = assets.manifest.firstKey(); + const QUrl relative("../../" + identity); + // Identity resolution also works without a materialized library or note. + QCOMPARE(ManagedAssets::resolveIdentity(assets, relative), identity); + QVERIFY(ManagedAssets::resolvePath(assets, relative).isEmpty()); + QVERIFY(write(directory.filePath(identity), bytes)); + QCOMPARE(ManagedAssets::resolveUrl(assets, relative), QUrl::fromLocalFile(directory.filePath(identity))); + QCOMPARE(ManagedAssets::resolvePath(assets, schemeUrl(directory.filePath(identity))), directory.filePath(identity)); + QCOMPARE(ManagedAssets::resolveIdentity(assets, QUrl::fromLocalFile(directory.filePath(identity))), identity); + auto virtualDocument = assets; + virtualDocument.documentUrl = QUrl("Projects/Nested/note.md"); + QCOMPARE(ManagedAssets::resolveIdentity(virtualDocument, relative), identity); + virtualDocument.documentUrl = QUrl("note.md"); + QCOMPARE(ManagedAssets::resolveIdentity(virtualDocument, QUrl(identity)), identity); + QCOMPARE(ManagedAssets::resolveIdentity(virtualDocument, QUrl("./" + identity)), identity); + virtualDocument.root = directory.filePath("not-materialized"); + QCOMPARE(ManagedAssets::resolveIdentity(virtualDocument, QUrl(identity)), identity); + QVERIFY(ManagedAssets::resolvePath(virtualDocument, QUrl(identity)).isEmpty()); + } + void oldAbsoluteUrlsUseOnlyTheCurrentCache() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QByteArray bytes("authoritative current database bytes"); + const auto assets = context(directory.filePath("Restored Library"), "folder/note.md", bytes); + const QString identity = assets.manifest.firstKey(); + const QString former = directory.filePath("Old Library/" + identity); + QVERIFY(write(assets.root + '/' + identity, bytes)); + QVERIFY(write(former, "untrusted bytes at the original location")); + for (const QUrl &url : {QUrl::fromLocalFile(former), schemeUrl(former)}) { + QByteArray actual; + QCOMPARE(ManagedAssets::resolveIdentity(assets, url), identity); + QCOMPARE(ManagedAssets::resolvePath(assets, url, &actual), assets.root + '/' + identity); + QCOMPARE(actual, bytes); + } + QVERIFY(QFile::remove(former)); + QCOMPARE(ManagedAssets::resolvePath(assets, QUrl::fromLocalFile(former)), assets.root + '/' + identity); + // Even a symlink at the historical location is irrelevant: it is never opened. + QVERIFY(QFile::link(assets.root + '/' + identity, former)); + QCOMPARE(ManagedAssets::resolvePath(assets, QUrl::fromLocalFile(former)), assets.root + '/' + identity); + QVERIFY(QFile::remove(assets.root + '/' + identity)); + QVERIFY(ManagedAssets::resolvePath(assets, QUrl::fromLocalFile(former)).isEmpty()); + } + void historicalRelativeUrlsKeepTheirOwnedIdentity() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QByteArray bytes("historical image bytes"); + auto assets = context(directory.path(), "note.md", bytes); + const QString identity = assets.manifest.firstKey(); + QVERIFY(write(directory.filePath(identity), bytes)); + for (const QString &prefix : {QString("../"), QString("../../../"), QString("./../../")}) { + const QUrl url(prefix + identity); + QCOMPARE(ManagedAssets::resolveIdentity(assets, url), identity); + QCOMPARE(ManagedAssets::resolvePath(assets, url), directory.filePath(identity)); + } + assets.documentUrl = QUrl(); + QVERIFY(ManagedAssets::resolveIdentity(assets, QUrl("../" + identity)).isEmpty()); + assets.documentUrl = QUrl("note.md"); + assets.manifest.clear(); + QVERIFY(ManagedAssets::resolveIdentity(assets, QUrl("../" + identity)).isEmpty()); + } + void escapedFilenamesAndUrlSuffixesArePreserved() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QByteArray bytes("image bytes"); + const auto assets = context(directory.filePath("恢复 空间 #%?:"), "文件夹/笔记 #%.md", bytes); + const QString identity = assets.manifest.firstKey(); + QVERIFY(write(assets.root + '/' + identity, bytes)); + QUrl former = QUrl::fromLocalFile(directory.filePath("原空间 #%?:/" + identity)); + former.setQuery("version=%23%25", QUrl::StrictMode); + former.setFragment("section%20one", QUrl::StrictMode); + const QUrl resolved = ManagedAssets::resolveUrl(assets, former); + QCOMPARE(resolved.toLocalFile(), assets.root + '/' + identity); + QCOMPARE(resolved.query(QUrl::FullyEncoded), former.query(QUrl::FullyEncoded)); + QCOMPARE(resolved.fragment(QUrl::FullyEncoded), former.fragment(QUrl::FullyEncoded)); + QCOMPARE(ManagedAssets::resolveIdentity(assets, QUrl("../" + identity + "?v=1#part")), identity); + auto external = assets; + external.managed = false; + const QString filename = "图片 #%?:%2f.png"; + QVERIFY(write(assets.root + "/文件夹/" + filename, bytes)); + const QUrl relative(QString::fromUtf8(QUrl::toPercentEncoding(filename)) + "?v=%23#part%25"); + const QUrl result = ManagedAssets::resolveUrl(external, relative); + QCOMPARE(result.toLocalFile(), assets.root + "/文件夹/" + filename); + QCOMPARE(result.query(QUrl::FullyEncoded), relative.query(QUrl::FullyEncoded)); + QCOMPARE(result.fragment(QUrl::FullyEncoded), relative.fragment(QUrl::FullyEncoded)); + } + void databaseOwnershipAndExactContentIdentityAreRequired() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QByteArray bytes("owned bytes"); + auto assets = context(directory.path(), "note.md", bytes); + const QString identity = assets.manifest.firstKey(); + QVERIFY(write(directory.filePath(identity), bytes)); + auto unknown = assets; + unknown.manifest.clear(); + QVERIFY(ManagedAssets::resolveIdentity(unknown, QUrl(identity)).isEmpty()); + QVERIFY(ManagedAssets::resolvePath(unknown, QUrl::fromLocalFile(directory.filePath(identity))).isEmpty()); + QVERIFY(ManagedAssets::resolveIdentity(assets, QUrl(identity.left(identity.size() - 3) + "jpg")).isEmpty()); + assets.manifest[identity] = digest("another BLOB"); + QVERIFY(ManagedAssets::resolveIdentity(assets, QUrl(identity)).isEmpty()); + assets.manifest = {{"assets/" + digest(bytes).left(32) + ".png", digest(bytes)}}; + QVERIFY(ManagedAssets::resolveIdentity(assets, QUrl(assets.manifest.firstKey())).isEmpty()); + assets.manifest = {{identity, digest(bytes).toUtf8()}}; + QVERIFY(ManagedAssets::resolveIdentity(assets, QUrl(identity)).isEmpty()); + assets.manifest = {{"assets/../" + identity, digest(bytes)}}; + QVERIFY(ManagedAssets::resolveIdentity(assets, QUrl("assets/../" + identity)).isEmpty()); + assets.manifest = {{identity, digest(bytes)}}; + // Old libraries may lie beneath the restored root; only the root cache + // is exposed even when the original absolute path is itself contained. + const QUrl nestedOld = QUrl::fromLocalFile(directory.filePath("nested/" + identity)); + QCOMPARE(ManagedAssets::resolveIdentity(assets, nestedOld), identity); + QCOMPARE(ManagedAssets::resolvePath(assets, nestedOld), directory.filePath(identity)); + QVERIFY(ManagedAssets::resolveIdentity(assets, QUrl("nested/" + identity)).isEmpty()); + } + void cacheBytesAreVerifiedOnEveryAccess() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QByteArray bytes("verified payload"); + const auto assets = context(directory.path(), "note.md", bytes); + const QString identity = assets.manifest.firstKey(); + const QString file = directory.filePath(identity); + QVERIFY(write(file, bytes)); + QByteArray actual; + QCOMPARE(ManagedAssets::resolvePath(assets, QUrl(identity), &actual), file); + QCOMPARE(actual, bytes); + QVERIFY(write(file, QByteArray(bytes.size(), 'x'))); + QCOMPARE(ManagedAssets::resolveIdentity(assets, QUrl(identity)), identity); + QVERIFY(ManagedAssets::resolvePath(assets, QUrl(identity), &actual).isEmpty()); + QVERIFY(actual.isEmpty()); + QVERIFY(write(file, bytes)); + QCOMPARE(ManagedAssets::resolvePath(assets, QUrl(identity)), file); + QFile oversized(file); + QVERIFY(oversized.open(QIODevice::WriteOnly)); + QVERIFY(oversized.resize(ManagedAssets::MaxBytes + 1)); + oversized.close(); + QVERIFY(ManagedAssets::resolvePath(assets, QUrl(identity)).isEmpty()); + QVERIFY(write(file, {})); + QVERIFY(ManagedAssets::resolvePath(assets, QUrl(identity)).isEmpty()); + } + void malformedAndEscapingUrlsAreRejected() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QByteArray bytes("owned bytes"); + const auto assets = context(directory.path(), "folder/note.md", bytes); + const QString identity = assets.manifest.firstKey(); + QVERIFY(write(directory.filePath(identity), bytes)); + const QString old = "/old/Library/" + identity; + const QStringList rejected{ + "https://example.org/" + identity, + "file://server" + old, + "file://localhost" + old, + "file://user:password@server" + old, + "nkasset://server" + old, + "//server" + old, + "file:" + identity, + "nkasset:" + identity, + "../../Library/" + identity, + "../nested/../" + identity, + "../" + identity + "/../other.png", + "file:///old/../Library/" + identity, + "nkasset:/old/./Library/" + identity, + "file:///old//Library/" + identity, + "..%2f" + identity, + "%2e%2e/%2e%2e/" + identity, + "%252e%252e/" + identity, + "../assets%2f" + identity.mid(7), + "file:///old%2fLibrary/" + identity, + "file:///old%5cLibrary/" + identity, + "file:///old%00/Library/" + identity, + "file:///old%0a/Library/" + identity, + "file:///old%7f/Library/" + identity, + "file:///old\\Library/" + identity, + "file:////server/Library/" + identity, + "file:///old/Library/" + identity + "/", + "/old/Library/" + identity, + "../assets/" + digest(bytes).toUpper() + ".png" + }; + for (const QString &input : rejected) { + const QUrl url(input); + QVERIFY2(ManagedAssets::resolveIdentity(assets, url).isEmpty(), qPrintable(input)); + QVERIFY2(ManagedAssets::resolvePath(assets, url).isEmpty(), qPrintable(input)); + } + } + void symlinkRootsDirectoriesAndFilesAreRejected() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QByteArray bytes("owned bytes"); + const auto assets = context(directory.filePath("Library"), "note.md", bytes); + const QString identity = assets.manifest.firstKey(); + QVERIFY(write(assets.root + '/' + identity, bytes)); + const QString alias = directory.filePath("alias"); + QVERIFY(QFile::link(assets.root, alias)); + auto aliasContext = assets; + aliasContext.root = alias; + aliasContext.documentUrl = QUrl::fromLocalFile(alias + "/note.md"); + QVERIFY(ManagedAssets::resolvePath(aliasContext, QUrl(identity)).isEmpty()); + // A symlink above the root is rejected as well. + QVERIFY(QDir().mkpath(directory.filePath("parent/NestedLibrary"))); + auto nested = context(directory.filePath("parent/NestedLibrary"), "note.md", bytes); + QVERIFY(write(nested.root + '/' + identity, bytes)); + QVERIFY(QFile::link(directory.filePath("parent"), directory.filePath("parentAlias"))); + nested.root = directory.filePath("parentAlias/NestedLibrary"); + nested.documentUrl = QUrl("note.md"); + QVERIFY(ManagedAssets::resolvePath(nested, QUrl(identity)).isEmpty()); + QVERIFY(QDir().rename(assets.root + "/assets", directory.filePath("outside-assets"))); + QVERIFY(QFile::link(directory.filePath("outside-assets"), assets.root + "/assets")); + QVERIFY(ManagedAssets::resolvePath(assets, QUrl(identity)).isEmpty()); + QVERIFY(QFile::remove(assets.root + "/assets")); + QVERIFY(QDir().mkdir(assets.root + "/assets")); + QVERIFY(QFile::link(directory.filePath("outside-assets/" + identity.mid(7)), assets.root + '/' + identity)); + QVERIFY(ManagedAssets::resolvePath(assets, QUrl(identity)).isEmpty()); + } + void searchableAncestorsDoNotRequireDirectoryListingPermission() { +#if defined(Q_OS_LINUX) && defined(O_PATH) + if (::geteuid() == 0) QSKIP("Directory permissions require an unprivileged test user"); + QTemporaryDir fixture;QVERIFY(fixture.isValid()); + const QString ancestor=fixture.filePath("searchable"); + const auto assets=ManagedAssets::Context{ancestor+"/External",QUrl("note.md"),{},false}; + QVERIFY(write(assets.root+"/image.png","readable bytes")); + const auto restore=qScopeGuard([&]{QFile::setPermissions(ancestor,QFileDevice::ReadOwner|QFileDevice::WriteOwner|QFileDevice::ExeOwner);}); + QVERIFY(QFile::setPermissions(ancestor,QFileDevice::ExeOwner)); + QByteArray bytes; + QCOMPARE(ManagedAssets::resolvePath(assets,QUrl("image.png"),&bytes),assets.root+"/image.png"); + QCOMPARE(bytes,QByteArray("readable bytes")); +#else + QSKIP("Linux O_PATH traversal regression"); +#endif + } + void externalNavigationAllowsDirectoriesEmptyFilesAndLargeFiles() { + QTemporaryDir fixture;QVERIFY(fixture.isValid()); + const auto assets=ManagedAssets::Context{fixture.filePath("External"),QUrl("note.md"),{},false}; + QVERIFY(QDir().mkpath(assets.root+"/documents")); + QVERIFY(write(assets.root+"/empty.txt",{})); + QFile video(assets.root+"/recording.mp4");QVERIFY(video.open(QIODevice::WriteOnly)); + QVERIFY(video.resize(ManagedAssets::MaxBytes+1));video.close(); + QCOMPARE(ManagedAssets::resolveExternalLink(assets,QUrl("documents/")).toLocalFile(),assets.root+"/documents"); + QCOMPARE(ManagedAssets::resolveExternalLink(assets,QUrl("empty.txt")).toLocalFile(),assets.root+"/empty.txt"); + const auto link=ManagedAssets::resolveExternalLink(assets,QUrl("recording.mp4?download=1#t=5")); + QCOMPARE(link.toLocalFile(),video.fileName());QCOMPARE(link.query(),QString("download=1"));QCOMPARE(link.fragment(),QString("t=5")); + QVERIFY(ManagedAssets::resolvePath(assets,QUrl("recording.mp4")).isEmpty()); + QVERIFY(write(fixture.filePath("outside.txt"),"outside")); + QVERIFY(ManagedAssets::resolveExternalLink(assets,QUrl("../outside.txt")).isEmpty()); + QVERIFY(QFile::link(fixture.filePath("outside.txt"),assets.root+"/escape.txt")); + QVERIFY(ManagedAssets::resolveExternalLink(assets,QUrl("escape.txt")).isEmpty()); + auto managed=assets;managed.managed=true; + QVERIFY(ManagedAssets::resolveExternalLink(managed,QUrl("empty.txt")).isEmpty()); + } + void externalDocumentsUseOnlyTheirCurrentScope() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QByteArray bytes("external image bytes"); + auto assets = context(directory.filePath("External"), "note.md", bytes); + assets.managed = false; + const QString identity = assets.manifest.firstKey(); + const QString ownedLookingFile = assets.root + '/' + identity; + QVERIFY(write(ownedLookingFile, bytes)); + QVERIFY(write(assets.root + "/photo.png", bytes)); + QVERIFY(ManagedAssets::resolveIdentity(assets, QUrl(identity)).isEmpty()); + QCOMPARE(ManagedAssets::resolvePath(assets, QUrl("photo.png")), assets.root + "/photo.png"); + QCOMPARE(ManagedAssets::resolvePath(assets, QUrl(identity)), ownedLookingFile); + QCOMPARE(ManagedAssets::resolvePath(assets, schemeUrl(ownedLookingFile)), ownedLookingFile); + const QString outside = directory.filePath("Former/" + identity); + QVERIFY(write(outside, bytes)); + QVERIFY(ManagedAssets::resolvePath(assets, QUrl::fromLocalFile(outside)).isEmpty()); + QVERIFY(ManagedAssets::resolvePath(assets, schemeUrl(outside)).isEmpty()); + QVERIFY(ManagedAssets::resolvePath(assets, QUrl("../Former/" + identity)).isEmpty()); + QVERIFY(write(assets.root + "-other/photo.png", bytes)); + QVERIFY(ManagedAssets::resolvePath(assets, QUrl::fromLocalFile(assets.root + "-other/photo.png")).isEmpty()); + QVERIFY(QFile::link(outside, assets.root + "/escape.png")); + QVERIFY(ManagedAssets::resolvePath(assets, QUrl("escape.png")).isEmpty()); + QCOMPARE(ManagedAssets::resolvePath(assets, QUrl("photo.png#fragment")), assets.root + "/photo.png"); + QVERIFY(ManagedAssets::resolvePath(assets, QUrl("file://server" + ownedLookingFile)).isEmpty()); + } +}; +QTEST_GUILESS_MAIN(ManagedAssetsTest) +#include "ManagedAssetsTest.moc" diff --git a/tests/ManagedPortabilityTest.cpp b/tests/ManagedPortabilityTest.cpp new file mode 100644 index 0000000..ff157eb --- /dev/null +++ b/tests/ManagedPortabilityTest.cpp @@ -0,0 +1,210 @@ +#include +#include +#include +#include +#include +#include "ManagedStore.h" +#include "ManagedAssets.h" +#include "MarkdownFile.h" +#include "RepositoryTestHooks.h" + +class ManagedPortabilityTest : public QObject { + Q_OBJECT + static QString url(const QString &path) { return QUrl::fromLocalFile(path).toString(QUrl::FullyEncoded); } + static QByteArray read(const QString &path) { + QFile file(path);return file.open(QIODevice::ReadOnly)?file.readAll():QByteArray(); + } + static bool write(const QString &path,const QByteArray &bytes) { + QFile file(path);return file.open(QIODevice::WriteOnly)&&file.write(bytes)==bytes.size(); + } + static QVariantMap create(ManagedStore &store,const QString &path,const QString &text) { + return store.dispatch("create",{{"path",path},{"text",text}}); + } + static QVariantMap save(ManagedStore &store,const QString &path,const QString &text) { + const auto document=store.dispatch("read",{{"path",path}}); + return store.dispatch("save",{{"path",path},{"text",text},{"baseHash",document.value("baseHash")}}); + } + static QString attachment(ManagedStore &store,const QByteArray &bytes) { + const auto result=store.dispatch("importAttachment",{{"path","seed.md"},{"name","sample.bin"},{"data",bytes.toBase64()}}); + return result.value("ok").toBool()?result.value("path").toString():QString(); + } + static QVariantMap referenceItem(ManagedStore &store,const QString &asset) { + for(const auto &v:store.dispatch("attachments",{}).value("items").toList()) { + const auto row=v.toMap();if(row.value("relative").toString()==asset)return row; + } + return {}; + } + static QString image(const QString &target) { return "![fixture]("+target+")\n"; } +private slots: + void cleanup() { RepositoryTestHooks::managedFailure={}; } + void backupAcrossRootsPreservesEveryReferenceAndExportsDatabaseBytes() { + QTemporaryDir fixture;QVERIFY(fixture.isValid()); + const auto rootA=fixture.filePath("库 A 空格"),rootB=fixture.filePath("库 B 新位置"),backup=fixture.filePath("portable.nkbackup"); + const QString current="深层/二层/current.md",historical="history.md",draftPath="draft.md",deletedPath="deleted.md"; + QMap assets;QMap bytes; + QString currentText,currentHash,historyText,historyHash,draftText,draftHash,templateText,deletedText,deletedHash; + { + ManagedStore store(rootA);QVERIFY(create(store,"seed.md","seed").value("ok").toBool()); + for(const auto &kind:{QString("current"),QString("relative"),QString("history"),QString("draft"),QString("template"),QString("deleted")}) { + bytes.insert(kind,kind.toUtf8()+QByteArray::fromHex("000102ff80")+" synthetic bytes\n"); + assets.insert(kind,attachment(store,bytes.value(kind)));QVERIFY(!assets.value(kind).isEmpty()); + } + currentText="# Portable\n\n"+image(url(rootA+'/'+assets.value("current"))+"?download=a%20b#page%202") + +image("../../"+assets.value("relative")+"?raw=1#pixel"); + auto document=create(store,current,currentText);QVERIFY(document.value("ok").toBool());currentHash=document.value("baseHash").toString(); + historyText=image(url(rootA+'/'+assets.value("history")));document=create(store,historical,historyText); + QVERIFY(document.value("ok").toBool());historyHash=document.value("baseHash").toString();QVERIFY(save(store,historical,"history retained only\n").value("ok").toBool()); + document=create(store,draftPath,"disk baseline\n");QVERIFY(document.value("ok").toBool());draftHash=document.value("baseHash").toString(); + draftText=image(url(rootA+'/'+assets.value("draft")))+"unsaved\n"; + QVERIFY(store.dispatch("checkpoint",{{"path",draftPath},{"text",draftText},{"baseHash",draftHash}}).value("ok").toBool()); + templateText="# {{title}}\n"+image(url(rootA+'/'+assets.value("template"))); + QVERIFY(store.dispatch("templateSave",{{"name","Legacy absolute"},{"text",templateText}}).value("ok").toBool()); + deletedText=image(url(rootA+'/'+assets.value("deleted")));document=create(store,deletedPath,deletedText); + QVERIFY(document.value("ok").toBool());deletedHash=document.value("baseHash").toString(); + QVERIFY(store.dispatch("trash",{{"path",deletedPath},{"baseHash",deletedHash}}).value("ok").toBool()); + QVERIFY(store.dispatch("backup",{{"destination",url(backup)}}).value("ok").toBool()); + } + QVERIFY(QDir().rename(rootA,rootA+" unavailable"));QVERIFY(!QFileInfo::exists(rootA)); + { + ManagedStore store(rootB);const auto restored=store.dispatch("restoreBackup",{{"source",url(backup)}}); + QVERIFY2(restored.value("ok").toBool(),qPrintable(restored.value("detail").toString())); + const auto manifest=restored.value("assetManifest").toMap();QCOMPARE(manifest.size(),assets.size()); + const auto document=store.dispatch("read",{{"path",current}}); + QCOMPARE(document.value("text").toString(),currentText);QCOMPARE(document.value("baseHash").toString(),currentHash); + QCOMPARE(store.dispatch("historyVersion",{{"path",historical},{"hash",historyHash}}).value("text").toString(),historyText); + const auto recovery=store.dispatch("recovery",{});const auto drafts=recovery.value("drafts").toList();QCOMPARE(drafts.size(),1); + QCOMPARE(drafts.first().toMap().value("text").toString(),draftText);QCOMPARE(drafts.first().toMap().value("baseHash").toString(),draftHash); + QCOMPARE(store.dispatch("templates",{}).value("templates").toMap().value("Legacy absolute").toString(),templateText); + for(const auto &asset:assets) { + QVERIFY(referenceItem(store,asset).value("references").toInt()>0); + QCOMPARE(store.dispatch("quarantineAttachment",{{"relative",asset}}).value("error").toString(),QString("attachmentInUse")); + } + const auto oldIdentity=ManagedAssets::resolveIdentity({rootB,QUrl::fromLocalFile(rootB+'/'+current),manifest,true},QUrl(url(rootA+'/'+assets.value("current"))+"?download=a%20b#page%202")); + QCOMPARE(oldIdentity,assets.value("current")); + // Missing or changed caches must not affect an export from the BLOBs. + QVERIFY(QFile::remove(rootB+'/'+assets.value("current")));QVERIFY(write(rootB+'/'+assets.value("relative"),"cache must not be exported")); + auto exported=store.dispatch("exportNotes",{{"destination",url(fixture.path())},{"paths",QStringList{current}}}); + QVERIFY(exported.value("ok").toBool());QVERIFY(exported.value("warnings").toList().isEmpty()); + const auto directory=exported.value("directory").toString();const auto markdown=QString::fromUtf8(read(directory+'/'+current)); + QCOMPARE(markdown,QString("# Portable\n\n")+image("../../"+assets.value("current")+"?download=a%20b#page%202")+image("../../"+assets.value("relative")+"?raw=1#pixel")); + QCOMPARE(read(directory+'/'+assets.value("current")),bytes.value("current"));QCOMPARE(read(directory+'/'+assets.value("relative")),bytes.value("relative")); + QCOMPARE(store.dispatch("read",{{"path",current}}).value("baseHash").toString(),currentHash); + auto copied=store.dispatch("saveAs",{{"path","copies/deep/current.md"},{"sourcePath",current},{"text",currentText}}); + QVERIFY(copied.value("ok").toBool());QVERIFY(!copied.value("text").toString().contains(rootA));QVERIFY(copied.value("text").toString().contains("../../"+assets.value("current"))); + auto fromTemplate=store.dispatch("fromTemplate",{{"name","Legacy absolute"},{"title","Created"},{"folder","copies/deep"}}); + QVERIFY(fromTemplate.value("ok").toBool());QVERIFY(fromTemplate.value("text").toString().contains("../../"+assets.value("template"))); + const auto trash=recovery.value("trashItems").toList();QCOMPARE(trash.size(),1); + QVERIFY(store.dispatch("restore",{{"token",trash.first().toMap().value("token")}}).value("ok").toBool()); + const auto deleted=store.dispatch("read",{{"path",deletedPath}});QCOMPARE(deleted.value("text").toString(),deletedText);QCOMPARE(deleted.value("baseHash").toString(),deletedHash); + // Bring historical/draft text into independent notes without changing + // the original history, draft baseline, or legacy template bytes. + QVERIFY(store.dispatch("saveAs",{{"path","history-copy.md"},{"sourcePath",historical},{"text",historyText}}).value("ok").toBool()); + QVERIFY(store.dispatch("saveAs",{{"path","draft-copy.md"},{"sourcePath",draftPath},{"text",draftText}}).value("ok").toBool()); + exported=store.dispatch("exportNotes",{{"destination",url(fixture.path())}});QVERIFY(exported.value("ok").toBool()); + for(auto it=assets.cbegin();it!=assets.cend();++it)QCOMPARE(read(exported.value("directory").toString()+'/'+it.value()),bytes.value(it.key())); + } + ManagedStore reopened(rootB); + QCOMPARE(reopened.dispatch("read",{{"path",current}}).value("baseHash").toString(),currentHash); + QCOMPARE(reopened.dispatch("historyVersion",{{"path",historical},{"hash",historyHash}}).value("text").toString(),historyText); + QCOMPARE(reopened.dispatch("recovery",{}).value("drafts").toList().first().toMap().value("text").toString(),draftText); + } + void copyMoveHistoryAndTemplatesKeepOwnedRelativeAttachments() { + QTemporaryDir fixture;ManagedStore store(fixture.path());QVERIFY(create(store,"seed.md","seed").value("ok").toBool()); + const auto asset=attachment(store,"owned synthetic bytes");QVERIFY(!asset.isEmpty()); + const QString source="one/two/source.md",suffix="?download=a%20b#part%202",text="# Relative\n"+image("../../"+asset+suffix); + const auto original=create(store,source,text);QVERIFY(original.value("ok").toBool());const auto oldHash=original.value("baseHash"); + auto copy=store.dispatch("saveAs",{{"sourcePath",source},{"path","copy.md"},{"text",text}}); + QVERIFY(copy.value("ok").toBool());QCOMPARE(copy.value("text").toString(),QString("# Relative\n")+image(asset+suffix)); + auto nested=store.dispatch("saveAs",{{"sourcePath","copy.md"},{"path","three/four/five/copy.md"},{"text",copy.value("text")}}); + QVERIFY(nested.value("ok").toBool());QVERIFY(nested.value("text").toString().contains("../../../"+asset+suffix)); + auto savedTemplate=store.dispatch("templateSave",{{"path",source},{"name","Portable"},{"text",text}}); + QVERIFY(savedTemplate.value("ok").toBool());QCOMPARE(savedTemplate.value("templates").toMap().value("Portable").toString(),QString("# Relative\n")+image(asset+suffix)); + auto generated=store.dispatch("fromTemplate",{{"name","Portable"},{"folder","three/four/five"}}); + QVERIFY(generated.value("ok").toBool());QVERIFY(generated.value("text").toString().contains("../../../"+asset+suffix)); + QVERIFY(store.dispatch("rename",{{"path",source},{"target","moved.md"}}).value("ok").toBool()); + QCOMPARE(store.dispatch("read",{{"path","moved.md"}}).value("text").toString(),QString("# Relative\n")+image(asset+suffix)); + const auto historical=store.dispatch("historyVersion",{{"path","moved.md"},{"hash",oldHash}}); + QVERIFY(historical.value("ok").toBool());QCOMPARE(historical.value("text").toString(),text);QCOMPARE(historical.value("hash"),oldHash); + const auto manifest=store.dispatch("list",{}).value("assetManifest").toMap(); + QCOMPARE(ManagedAssets::resolveIdentity({fixture.path(),QUrl::fromLocalFile(fixture.filePath("moved.md")),manifest,true},QUrl("../../"+asset+suffix)),asset); + auto historyCopy=store.dispatch("saveAs",{{"path","recovered.md"},{"sourcePath","moved.md"},{"text",historical.value("text")}}); + QVERIFY(historyCopy.value("ok").toBool());QCOMPARE(historyCopy.value("text").toString(),QString("# Relative\n")+image(asset+suffix)); + auto recoveryCopy=store.dispatch("saveAs",{{"path","recovery-copy.md"},{"text",historical.value("text")},{"recoveryCopy",true}}); + QVERIFY(recoveryCopy.value("ok").toBool());QCOMPARE(recoveryCopy.value("text").toString(),historyCopy.value("text").toString()); + const auto draftBase=store.dispatch("read",{{"path","moved.md"}}).value("baseHash"); + QVERIFY(store.dispatch("checkpoint",{{"path","moved.md"},{"text",text+"draft"},{"baseHash",draftBase}}).value("ok").toBool()); + QVERIFY(store.dispatch("rename",{{"path","moved.md"},{"target","deeper/again/moved.md"}}).value("ok").toBool()); + const auto draft=store.dispatch("recovery",{}).value("drafts").toList().first().toMap(); + QCOMPARE(draft.value("path").toString(),QString("deeper/again/moved.md"));QCOMPARE(draft.value("text").toString(),text+"draft");QCOMPARE(draft.value("baseHash"),draftBase); + QCOMPARE(store.dispatch("quarantineAttachment",{{"relative",asset}}).value("error").toString(),QString("attachmentInUse")); + } + void historyDraftTemplateAndMemoryReferencesAlonePreventQuarantine() { + QTemporaryDir fixture;QMap assets; + { + ManagedStore store(fixture.path());QVERIFY(create(store,"seed.md","seed").value("ok").toBool()); + for(const auto &kind:{QString("history"),QString("draft"),QString("template"),QString("deleted"),QString("unused")}) { + assets.insert(kind,attachment(store,kind.toUtf8()));QVERIFY(!assets.value(kind).isEmpty()); + } + const auto historical=create(store,"nested/history.md",image("../"+assets.value("history")));QVERIFY(historical.value("ok").toBool()); + QVERIFY(save(store,"nested/history.md","no current attachment\n").value("ok").toBool()); + QVERIFY(store.dispatch("rename",{{"path","nested/history.md"},{"target","history.md"}}).value("ok").toBool()); + const auto draft=create(store,"draft.md","baseline");QVERIFY(draft.value("ok").toBool()); + QVERIFY(store.dispatch("checkpoint",{{"path","draft.md"},{"text",image("../../../"+assets.value("draft"))},{"baseHash",draft.value("baseHash")}}).value("ok").toBool()); + QVERIFY(store.dispatch("templateSave",{{"name","Only template"},{"text",image(url(fixture.path()+" old/"+assets.value("template")))}}).value("ok").toBool()); + const auto deleted=create(store,"deleted.md",image(assets.value("deleted")));QVERIFY(deleted.value("ok").toBool()); + QVERIFY(store.dispatch("trash",{{"path","deleted.md"},{"baseHash",deleted.value("baseHash")}}).value("ok").toBool()); + } + ManagedStore reopened(fixture.path()); + for(const auto &kind:{QString("history"),QString("draft"),QString("template"),QString("deleted")}) { + QVERIFY(referenceItem(reopened,assets.value(kind)).value("references").toInt()>0); + QCOMPARE(reopened.dispatch("quarantineAttachment",{{"relative",assets.value(kind)}}).value("error").toString(),QString("attachmentInUse")); + } + QVERIFY(reopened.dispatch("quarantineAttachment",{{"relative",assets.value("unused")}}).value("ok").toBool()); + QVERIFY(reopened.dispatch("discardDraft",{{"path","draft.md"}}).value("ok").toBool()); + QVERIFY(reopened.dispatch("quarantineAttachment",{{"relative",assets.value("draft")}}).value("ok").toBool()); + QVERIFY(reopened.dispatch("templateDelete",{{"name","Only template"}}).value("ok").toBool()); + QVERIFY(reopened.dispatch("quarantineAttachment",{{"relative",assets.value("template")}}).value("ok").toBool()); + QVERIFY(reopened.dispatch("restoreAttachment",{{"token",assets.value("unused")}}).value("ok").toBool()); + RepositoryTestHooks::managedFailure=[](const QString &stage){return stage=="draftCommit";}; + const auto failed=reopened.dispatch("checkpoint",{{"path","draft.md"},{"text",image(assets.value("unused"))},{"baseHash","baseline"}}); + QVERIFY(!failed.value("ok").toBool());QVERIFY(failed.value("draftInMemory").toBool()); + QCOMPARE(reopened.dispatch("quarantineAttachment",{{"relative",assets.value("unused")}}).value("error").toString(),QString("attachmentInUse")); + RepositoryTestHooks::managedFailure={};QVERIFY(reopened.dispatch("discardDraft",{{"path","draft.md"}}).value("ok").toBool()); + QVERIFY(reopened.dispatch("quarantineAttachment",{{"relative",assets.value("unused")}}).value("ok").toBool()); + } + void rejectedTraversalIsNotRewrittenIntoAnOwnedAttachment() { + QTemporaryDir fixture;ManagedStore store(fixture.filePath("Library"));QVERIFY(create(store,"seed.md","seed").value("ok").toBool()); + const auto asset=attachment(store,"owned bytes");QVERIFY(!asset.isEmpty()); + const auto text=image("child/../../"+asset);QVERIFY(create(store,"nested/source.md",text).value("ok").toBool()); + const auto copied=store.dispatch("saveAs",{{"path","copy.md"},{"sourcePath","nested/source.md"},{"text",text}}); + QVERIFY(copied.value("ok").toBool());QCOMPARE(copied.value("text").toString(),text); + const auto saved=store.dispatch("templateSave",{{"name","Unresolved"},{"path","nested/source.md"},{"text",text}}); + QVERIFY(saved.value("ok").toBool());QCOMPARE(saved.value("templates").toMap().value("Unresolved").toString(),text); + const auto generated=store.dispatch("fromTemplate",{{"name","Unresolved"},{"folder","deeper/nested"}}); + QVERIFY(generated.value("ok").toBool());QCOMPARE(generated.value("text").toString(),text); + const auto exported=store.dispatch("exportNotes",{{"destination",url(fixture.path())},{"paths",QStringList{"copy.md"}}}); + QVERIFY(exported.value("ok").toBool());QCOMPARE(exported.value("warnings").toList().size(),1); + QCOMPARE(exported.value("warnings").toList().first().toMap().value("code").toString(),QString("missingAttachment")); + QVERIFY(!QFileInfo::exists(exported.value("directory").toString()+'/'+asset)); + } + void mixedHtmlAndUnknownLinksArePreservedWithExplicitExportWarnings() { + QTemporaryDir fixture;const auto root=fixture.filePath("Library");ManagedStore store(root); + QVERIFY(create(store,"seed.md","seed").value("ok").toBool());const QByteArray bytes("export BLOB bytes");const auto asset=attachment(store,bytes);QVERIFY(!asset.isEmpty()); + const auto old=url(fixture.filePath("unavailable old/"+asset)),missing="assets/"+QString(64,'a')+".bin"; + const auto html="[ignored](unknown.bin)"; + const auto text=html+"\n"+image(old+"?raw=1#part")+image(missing)+"[remote](https://example.com/file.bin)\n" + +"[reference][r]\n\n[r]: <"+old+"?q=a%20b#ref> \"title\"\n"; + QVERIFY(create(store,"mixed.md",text).value("ok").toBool());QVERIFY(QFile::remove(root+'/'+asset)); + const auto result=store.dispatch("exportNotes",{{"destination",url(fixture.path())},{"paths",QStringList{"mixed.md"}}}); + QVERIFY(result.value("ok").toBool());const auto markdown=QString::fromUtf8(read(result.value("directory").toString()+"/mixed.md")); + QVERIFY(markdown.contains(html));QVERIFY(markdown.contains(image(asset+"?raw=1#part")));QVERIFY(markdown.contains(image(missing))); + QVERIFY(markdown.contains("[r]: <"+asset+"?q=a%20b#ref> \"title\"")); + QCOMPARE(read(result.value("directory").toString()+'/'+asset),bytes); + QSet codes;for(const auto &v:result.value("warnings").toList())codes.insert(v.toMap().value("code").toString()); + QCOMPARE(codes,(QSet{"missingAttachment","unsupportedHtml"}));QCOMPARE(result.value("warnings").toList().size(),2); + QVERIFY(!QFileInfo::exists(result.value("directory").toString()+'/'+missing)); + QCOMPARE(store.dispatch("read",{{"path","mixed.md"}}).value("text").toString(),text); + } +}; +QTEST_GUILESS_MAIN(ManagedPortabilityTest) +#include "ManagedPortabilityTest.moc" diff --git a/tests/ManagedRecoveryTest.cpp b/tests/ManagedRecoveryTest.cpp new file mode 100644 index 0000000..3569df9 --- /dev/null +++ b/tests/ManagedRecoveryTest.cpp @@ -0,0 +1,413 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "ManagedStore.h" +#include "MarkdownFile.h" +#include "NotesRepository.h" +#include "RepositoryTestHooks.h" +#ifdef Q_OS_UNIX +#include +#endif + +class ManagedRecoveryTest : public QObject { + Q_OBJECT + static bool write(const QString &path, const QByteArray &bytes) { + QFile file(path); return file.open(QIODevice::WriteOnly) && file.write(bytes)==bytes.size(); + } + static QByteArray read(const QString &path) { + QFile file(path); if(!file.open(QIODevice::ReadOnly))return {}; return file.readAll(); + } + static QString url(const QString &path) { return QUrl::fromLocalFile(path).toString(); } + static QVariantMap call(NotesRepository &repository,const QString &operation,const QVariantMap &args={}) { + QSignalSpy spy(&repository,&NotesRepository::finished); const auto id=repository.request(operation,args); + if(spy.isEmpty()&&!spy.wait(15000))return {{"ok",false},{"error","testTimeout"}}; + for(const auto &signal:spy)if(signal[0].toULongLong()==id)return signal[2].toMap(); + return {{"ok",false},{"error","missingRequest"}}; + } + static bool sql(const QString &path,const QByteArray &statement) { + sqlite3 *raw=nullptr; + const int opened=sqlite3_open(QFile::encodeName(path).constData(),&raw); + const int result=opened==SQLITE_OK?sqlite3_exec(raw,statement.constData(),nullptr,nullptr,nullptr):opened; + sqlite3_close(raw);return result==SQLITE_OK; + } + static bool makeBackup(const QString &parent,const QString &destination) { + ManagedStore store(parent+"/source"); + const auto created=store.dispatch("create",{{"path","Projects/saved.md"},{"text","# Saved\n\nSynthetic backup text.\n"}}); + if(!created.value("ok").toBool())return false; + if(!store.dispatch("importAttachment",{{"path","Projects/saved.md"},{"name","fixture.txt"},{"data",QByteArray("synthetic attachment").toBase64()}}).value("ok").toBool())return false; + return store.dispatch("backup",{{"destination",url(destination)}}).value("ok").toBool(); + } + static QMap corrupt(const QString &directory) { + QDir().mkpath(directory); + QMap files{{"notes.sqlite3",QByteArray("not a SQLite database")+QByteArray(1,'\0')+"with original bytes"}, + {"notes.sqlite3-wal",QByteArray("original WAL")+QByteArray(1,'\0')+"tail"}, {"notes.sqlite3-shm",QByteArray("original SHM")+QByteArray(1,'\0')+"tail"}}; + for(auto it=files.cbegin();it!=files.cend();++it)if(!write(directory+'/'+it.key(),it.value()))return {}; + return files; + } + static bool unchanged(const QString &directory,const QMap &files) { + for(auto it=files.cbegin();it!=files.cend();++it)if(read(directory+'/'+it.key())!=it.value())return false; + return true; + } + static quint64 inode(const QString &path) { +#ifdef Q_OS_UNIX + struct stat status{};return ::stat(QFile::encodeName(path).constData(),&status)==0?status.st_ino:0; +#else + Q_UNUSED(path);return 0; +#endif + } +private slots: + void cleanup() { + RepositoryTestHooks::managedFailure={}; + RepositoryTestHooks::managedRecoveryStage={}; + RepositoryTestHooks::externalSaveStage={}; + } + void corruptInitializationRestoresToSiblingAndReopens() { + QTemporaryDir fixture;QVERIFY(fixture.isValid()); + const auto backup=fixture.filePath("saved.nkbackup"), entry=fixture.filePath("Library"); + QVERIFY(makeBackup(fixture.path(),backup)); const auto original=corrupt(entry);QVERIFY(!original.isEmpty()); + QString replacement;quint64 lockInode=0; + { + NotesRepository repository; + const auto failed=call(repository,"selectLibrary",{{"path",entry},{"managed",true}}); + QCOMPARE(failed.value("error").toString(),QString("databaseCorrupt"));QVERIFY(failed.value("recoveryAvailable").toBool()); + QCOMPARE(failed.value("entryRoot").toString(),entry);QVERIFY(unchanged(entry,original)); + lockInode=inode(entry+"/managed.instance.lock"); + const auto restored=call(repository,"restoreBackup",{{"source",url(backup)},{"generation",failed.value("generation")}}); + QVERIFY2(restored.value("ok").toBool(),qPrintable(restored.value("detail").toString())); + QVERIFY(restored.value("recoveredInitialization").toBool());QCOMPARE(restored.value("entryRoot").toString(),entry); + QCOMPARE(restored.value("preservedDirectory").toString(),entry);QVERIFY(!restored.contains("safetyCopy")); + QVERIFY(restored.value("generation").toULongLong()>failed.value("generation").toULongLong()); + replacement=restored.value("library").toString();QVERIFY(replacement!=entry); + QCOMPARE(QFileInfo(replacement).absolutePath(),fixture.path()); + QCOMPARE(call(repository,"read",{{"path","Projects/saved.md"}}).value("text").toString(),QString("# Saved\n\nSynthetic backup text.\n")); + QVERIFY(unchanged(entry,original));QCOMPARE(inode(entry+"/managed.instance.lock"),lockInode); + const auto manifest=restored.value("assetManifest").toMap();QCOMPARE(manifest.size(),1); + QCOMPARE(read(replacement+'/'+manifest.firstKey()),QByteArray("synthetic attachment")); + } + NotesRepository reopened; + const auto selected=call(reopened,"selectLibrary",{{"path",entry},{"managed",true}}); + QVERIFY(selected.value("ok").toBool());QCOMPARE(selected.value("library").toString(),replacement); + QCOMPARE(selected.value("entryRoot").toString(),entry);QVERIFY(unchanged(entry,original));QCOMPARE(inode(entry+"/managed.instance.lock"),lockInode); + } + void repeatedRecoveryRetainsBothPreviousDirectories() { + QTemporaryDir fixture;const auto backup=fixture.filePath("saved.nkbackup"),entry=fixture.filePath("Library");QVERIFY(makeBackup(fixture.path(),backup)); + const auto original=corrupt(entry);QString first; + {ManagedStore failed(entry,true);const auto recovered=failed.dispatch("restoreBackup",{{"source",url(backup)}});QVERIFY(recovered.value("ok").toBool());first=failed.directory();} + const auto failedReplacement=corrupt(first);const auto pointer=read(entry+"/active-library.json"); + { + ManagedStore failed(entry,true);QCOMPARE(failed.directory(),first);QVERIFY(failed.initializationError().value("recoveryAvailable").toBool()); + const auto rejected=failed.dispatch("restoreBackup",{{"source",url(fixture.filePath("missing.nkbackup"))}});QVERIFY(!rejected.value("ok").toBool());QCOMPARE(read(entry+"/active-library.json"),pointer); + const auto recovered=failed.dispatch("restoreBackup",{{"source",url(backup)}});QVERIFY(recovered.value("ok").toBool()); + QCOMPARE(recovered.value("preservedDirectory").toString(),first);QVERIFY(failed.directory()!=first);QCOMPARE(failed.entryRoot(),entry); + } + QVERIFY(unchanged(entry,original));QVERIFY(unchanged(first,failedReplacement));ManagedStore reopened(entry); + QVERIFY(reopened.dispatch("read",{{"path","Projects/saved.md"}}).value("ok").toBool()); + } + void invalidBackupDoesNotPublishOrChangeFailedFiles_data() { + QTest::addColumn("mutation"); + QTest::newRow("application")<("mutation");QTest::addColumn("error"); + QTest::newRow("new-version")< original{{"notes.sqlite3",read(entry+"/notes.sqlite3")},{"notes.sqlite3-wal","retained old WAL"},{"notes.sqlite3-shm","retained old SHM"}}; + QVERIFY(write(entry+"/notes.sqlite3-wal",original.value("notes.sqlite3-wal")));QVERIFY(write(entry+"/notes.sqlite3-shm",original.value("notes.sqlite3-shm"))); + ManagedStore store(entry,true);QCOMPARE(store.initializationError().value("error").toString(),error);QVERIFY(unchanged(entry,original)); + } + void blockedLegacyCacheIsRejectedBeforeOriginalDatabaseChanges() { + QTemporaryDir fixture;const auto backup=fixture.filePath("saved.nkbackup"),entry=fixture.filePath("Library"); + QVERIFY(makeBackup(fixture.path(),backup));QVERIFY(QDir().mkpath(entry)); + QVERIFY(QFile::copy(backup,entry+"/notes.sqlite3"));QVERIFY(sql(entry+"/notes.sqlite3","PRAGMA application_id=0")); + QVERIFY(write(entry+"/assets","occupied cache directory")); + QMap original{{"notes.sqlite3",read(entry+"/notes.sqlite3")},{"notes.sqlite3-wal",QByteArray()},{"notes.sqlite3-shm","untouched SHM"}}; + QVERIFY(write(entry+"/notes.sqlite3-wal",original.value("notes.sqlite3-wal"))); + QVERIFY(write(entry+"/notes.sqlite3-shm",original.value("notes.sqlite3-shm"))); + ManagedStore store(entry,true); + QCOMPARE(store.initializationError().value("error").toString(),QString("unsafePath")); + QVERIFY(unchanged(entry,original)); + for(auto it=original.cbegin();it!=original.cend();++it)QVERIFY(QFileInfo::exists(entry+'/'+it.key())); + QCOMPARE(read(entry+"/assets"),QByteArray("occupied cache directory")); + } + void cacheWriteFailureLeavesLibraryUsableAndWarns() { + QTemporaryDir fixture;const auto entry=fixture.filePath("Library");QString asset; + { + ManagedStore store(entry);QVERIFY(store.dispatch("create",{{"path","note.md"},{"text","kept"}}).value("ok").toBool()); + const auto result=store.dispatch("importAttachment",{{"path","note.md"},{"name","fixture.bin"},{"data",QByteArray("kept asset").toBase64()}}); + QVERIFY(result.value("ok").toBool());asset=result.value("path").toString(); + } + QVERIFY(QFile::remove(entry+'/'+asset)); + RepositoryTestHooks::managedFailure=[](const QString &stage){return stage=="cacheWrite";}; + { + ManagedStore reopened(entry,true);QVERIFY(reopened.initializationError().isEmpty()); + const auto listing=reopened.dispatch("list",{});QVERIFY(listing.value("ok").toBool()); + QCOMPARE(listing.value("cacheFailures").toInt(),1);QVERIFY(listing.value("initializationNotice").toString().contains("附件缓存")); + QCOMPARE(reopened.dispatch("read",{{"path","note.md"}}).value("text").toString(),QString("kept")); + QVERIFY(reopened.dispatch("create",{{"path","new.md"},{"text","still writable"}}).value("ok").toBool()); + } + RepositoryTestHooks::managedFailure={}; + ManagedStore repaired(entry);QCOMPARE(repaired.dispatch("list",{}).value("cacheFailures").toInt(),0); + QCOMPARE(read(entry+'/'+asset),QByteArray("kept asset")); + } + void invalidPointerNeverFallsBackToCreatingDatabase_data() { + QTest::addColumn("kind"); + for(const auto &kind:{"traversal","absolute","wrong-id","extra-key","symlink","chain","missing-db","duplicate-key"})QTest::newRow(kind)<("stage");QTest::addColumn("published"); + QTest::newRow("validated")< #include #include +#include +#include #include #include "EditorSession.h" #include "NotesController.h" @@ -235,6 +237,46 @@ private slots: QVERIFY(exported.open(QIODevice::ReadOnly)); QCOMPARE(exported.readAll(), bytes); QVERIFY(f.controller.outputPath(QUrl("https://example.com/export"), "html").isEmpty()); } + void restoredManagedImagesExportFromTheCurrentOwnedCache() { + Fixture f; + QTemporaryDir library, output; + QVERIFY(library.isValid()); QVERIFY(output.isValid()); + const QByteArray png = QByteArray::fromBase64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jfN8AAAAASUVORK5CYII="); + const QString hash = QString::fromLatin1(QCryptographicHash::hash(png, QCryptographicHash::Sha256).toHex()); + const QString identity = "assets/" + hash + ".png"; + QVERIFY(QDir(library.path()).mkdir("assets")); + QFile image(library.filePath(identity)); QVERIFY(image.open(QIODevice::WriteOnly)); QCOMPARE(image.write(png), png.size()); image.close(); + f.controller.openNote("nested/note.md"); + f.answer("read", {{"path", "nested/note.md"}, {"id", "B"}, {"text", "image"}, {"baseHash", "base"}, + {"managedAssets", true}, {"assetManifest", QVariantMap{{identity, hash}}}}); + f.controller.setProperty("library", library.path()); + const QUrl old = QUrl::fromLocalFile("/missing/former library/" + identity); + QCOMPARE(f.controller.resolveAsset(old).toLocalFile(), image.fileName()); + f.controller.refresh(); const auto stale = f.take("search"); QVERIFY(!stale.isEmpty()); + f.controller.setQuery("newer query"); + f.controller.receive(stale[0].toULongLong(), "search", {{"ok", true}, {"library", library.path()}, {"generation", 1}, + {"managedAssets", true}, {"assetManifest", QVariantMap{}}}); + QCOMPARE(f.controller.resolveAsset(old).toLocalFile(), image.fileName()); + const QString target = output.filePath("restored.html"); + QVERIFY(f.controller.exportHtml(QUrl::fromLocalFile(target), "", "Restored")); + QFile exported(target); QVERIFY(exported.open(QIODevice::ReadOnly)); const QByteArray bytes = exported.readAll(); exported.close(); + QCOMPARE(bytes.count("src=\"data:image/png;base64,"), 2); + QVERIFY(image.open(QIODevice::WriteOnly)); image.write("changed bytes"); image.close(); + QVERIFY(f.controller.resolveAsset(old).isEmpty()); + QVERIFY(!f.controller.exportHtml(QUrl::fromLocalFile(target), "", "Changed")); + QVERIFY(exported.open(QIODevice::ReadOnly)); QCOMPARE(exported.readAll(), bytes); + } + void exportWarningsReachTheResultDialog() { + Fixture f; + QSignalSpy exported(&f.controller, &NotesController::notesExported); + f.controller.exportNotes(QUrl::fromLocalFile("/output")); + const QVariantList warnings{QVariantMap{{"path", "a.md"}, {"message", "附件未找到"}}}; + f.answer("exportNotes", {{"directory", "/output/Notes"}, {"count", 1}, {"warnings", warnings}}); + QCOMPARE(exported.size(), 1); + QCOMPARE(exported.first()[0].toUrl(), QUrl::fromLocalFile("/output/Notes")); + QCOMPARE(exported.first()[2].toList(), warnings); + QVERIFY(f.controller.property("status").toString().contains("部分链接需检查")); + } void webCloseWaitsForLatestRendererSnapshot() { Fixture f; f.session.enableWeb(); f.session.webConnected(); f.session.loadPrototype("原文"); const int epoch = f.session.webDocument().value("epoch").toInt(); @@ -259,6 +301,9 @@ private slots: void copyImmediatelyBecomesLastDocument() { Fixture f; QSignalSpy loaded(&f.controller, &NotesController::documentLoaded); f.controller.saveCopy("copy.md"); + QVariantMap copyArgs; + for (const auto &request : f.requests) if (request[1] == "saveAs") copyArgs = request[2].toMap(); + QCOMPARE(copyArgs.value("sourcePath").toString(), QString("a.md")); f.answer("saveAs", {{"path", "copy.md"}, {"text", "copy"}, {"baseHash", "copyhash"}, {"listing", QVariantMap{{"notes", QVariantList{QVariantMap{{"path", "copy.md"}, {"id", "COPY"}}}}}}}); QCOMPARE(f.controller.preference("lastNote").toMap().value("id").toString(), QString("COPY")); QCOMPARE(loaded.size(), 1); @@ -297,7 +342,7 @@ private slots: QVERIFY(controller.readOnly()); QCOMPARE(session.source(), QString("磁盘")); QSettings().clear(); } - void missingRestoredLibraryReturnsToPicker() { + void failedManagedStartupCanRetry() { QTextDocument doc; EditorSession session; session.attachDocument(&doc); NotesController controller(&session, nullptr); controller.setPreference("lastLibrary", "/missing/previous-library"); @@ -307,19 +352,74 @@ private slots: controller.receive(request[0].toULongLong(), "selectLibrary", {{"ok", false}, {"error", "invalidLibrary"}}); QVERIFY(controller.property("library").toString().isEmpty()); QVERIFY(!controller.busy()); - QVERIFY(controller.property("error").toString().contains("重新选择")); - controller.selectLibrary(QUrl::fromLocalFile("/valid")); + QVERIFY(controller.property("error").toString().contains("重试打开")); + controller.restoreWorkspace(); QCOMPARE(requests.size(), 1); QSettings().clear(); } - void failedSwitchStillAllowsSaveAndClose() { + void failedImportStillAllowsSaveAndClose() { Fixture f; f.controller.selectLibrary(QUrl::fromLocalFile("/missing")); - f.answer("selectLibrary", {{"ok", false}, {"error", "invalidLibrary"}}); + f.answer("importPreview", {{"ok", false}, {"error", "invalidImport"}}); QCOMPARE(f.controller.property("library").toString(), QString("/virtual/library")); f.type("继续"); f.controller.save(); f.answer("save", {{"baseHash", "new"}}); QSignalSpy closed(&f.controller, &NotesController::closeReady); f.controller.requestClose(); QCOMPARE(closed.size(), 1); } + void failedManagedStartupRestoresToNewRoot() { + QTextDocument document; EditorSession session; session.attachDocument(&document); + NotesController controller(&session, nullptr); + controller.setPreference("managedStorage", "/broken/library"); + QSignalSpy requests(&controller, &NotesController::requestSent); + controller.restoreWorkspace(); + auto request = requests.takeFirst(); + controller.receive(request[0].toULongLong(), "selectLibrary", {{"ok", false}, {"error", "databaseError"}, {"recoveryAvailable", true}, {"library", ""}, {"generation", 0}}); + QVERIFY(controller.property("recoveryAvailable").toBool()); QVERIFY(!controller.busy()); + controller.restoreBackup(QUrl::fromLocalFile("/backup.nkbackup")); + request = requests.takeFirst(); QCOMPARE(request[1].toString(), QString("restoreBackup")); + controller.receive(request[0].toULongLong(), "restoreBackup", {{"ok", true}, {"recoveredInitialization", true}, {"library", "/broken/library-restored"}, {"entryRoot", "/broken/library"}, {"preservedDirectory", "/broken/library"}, {"generation", 1}, {"managedAssets", true}}); + QCOMPARE(controller.property("library").toString(), QString("/broken/library-restored")); + QCOMPARE(controller.preference("lastLibrary").toString(), QString("/broken/library")); + QVERIFY(!controller.property("recoveryAvailable").toBool()); QVERIFY(!controller.busy()); + QVERIFY(!requests.isEmpty()); + for (const auto &r : requests) { + QCOMPARE(r[2].toMap().value("generation").toInt(), 1); + QCOMPARE(r[2].toMap().value("library").toString(), QString("/broken/library-restored")); + } + QSettings().clear(); + } + void conflictCheckpointsNewestInputWithoutAllowingClose() { + Fixture f; + f.type(" first"); f.controller.save(); + f.answer("save", {{"ok", false}, {"error", "conflict"}, {"recoveryDurable", true}}); + f.type(" second"); + QTRY_COMPARE_WITH_TIMEOUT(f.count("checkpoint"), 1, 2000); + const auto earlier = f.take("checkpoint"); + QCOMPARE(earlier[2].toMap().value("text").toString(), f.session.source()); + f.type(" newest"); + f.controller.receive(earlier[0].toULongLong(), "checkpoint", {{"ok", true}, {"library", "/virtual/library"}, {"generation", 1}, {"recoveryDurable", true}}); + QVERIFY(!f.controller.property("error").toString().contains("本地恢复草稿已保存")); + QTRY_COMPARE_WITH_TIMEOUT(f.count("checkpoint"), 1, 2000); + f.answer("checkpoint", {{"recoveryDurable", true}}); + QVERIFY(f.controller.property("error").toString().contains("本地恢复草稿已保存")); + QVERIFY(f.controller.dirty()); QCOMPARE(f.count("save"), 0); + QSignalSpy closed(&f.controller, &NotesController::closeReady); + f.controller.requestClose(); QCOMPARE(closed.size(), 0); + } + void nondurableCheckpointKeepsExplicitWarning() { + Fixture f; f.type(" local"); f.controller.save(); + f.answer("save", {{"ok", false}, {"error", "conflict"}, {"recoveryDurable", true}}); + f.controller.save(); + f.answer("checkpoint", {{"ok", false}, {"error", "recoveryWriteFailed"}, {"recoveryDurable", false}}); + QVERIFY(f.controller.property("error").toString().contains("未能写入磁盘")); + QVERIFY(f.controller.dirty()); + } + void recoveryListDoesNotUpgradeMemoryDraftDurability() { + Fixture f; f.controller.inspectRecovery(); + const QVariantMap draft{{"path", "a.md"}, {"text", "only in memory"}, {"baseHash", "base"}, {"recoveryDurable", false}}; + f.answer("recovery", {{"drafts", QVariantList{draft}}, {"memoryDrafts", QVariantList{draft}}}); + const auto drafts = f.controller.property("recovery").toMap().value("drafts").toList(); + QCOMPARE(drafts.size(), 1); QVERIFY(!drafts.first().toMap().value("durable").toBool()); + } void automaticSaveDebounce() { Fixture f; f.type("中文自动保存"); QCOMPARE(f.count("save"), 0); @@ -382,14 +482,16 @@ private slots: f.controller.receive(99999, "read", {{"ok", true}, {"text", "恶意陈旧"}}); QCOMPARE(f.session.source(), QString("原文")); } - void oldLibraryResponseIgnored() { + void preRestoreResponseIgnored() { Fixture f; - f.controller.refresh(); const auto old = f.take("search"); - f.controller.selectLibrary(QUrl::fromLocalFile("/virtual/other")); - const auto selection = f.take("selectLibrary"); - f.controller.receive(selection[0].toULongLong(), "selectLibrary", {{"ok", true}, {"library", "/virtual/other"}, {"generation", 2}, {"notes", QVariantList{}}}); + f.controller.refresh(); const auto old = f.take("search"); QVERIFY(!old.isEmpty()); + f.controller.restoreBackup(QUrl::fromLocalFile("/backup.nkbackup")); + const auto restoration = f.take("restoreBackup"); QVERIFY(!restoration.isEmpty()); + f.controller.receive(restoration[0].toULongLong(), "restoreBackup", {{"ok", true}, {"library", "/virtual/library"}, {"generation", 2}, {"restored", true}}); + const auto fresh = f.take("search"); QVERIFY(!fresh.isEmpty()); + f.controller.receive(fresh[0].toULongLong(), "search", {{"ok", true}, {"library", "/virtual/library"}, {"generation", 2}, {"notes", QVariantList{}}}); f.controller.receive(old[0].toULongLong(), "search", {{"ok", true}, {"library", "/virtual/library"}, {"generation", 1}, {"notes", QVariantList{QVariantMap{{"title", "旧库"}}}}}); - QCOMPARE(f.controller.property("library").toString(), QString("/virtual/other")); + QCOMPARE(f.controller.property("library").toString(), QString("/virtual/library")); QVERIFY(f.controller.property("notes").toList().isEmpty()); } void conflictAndReadFailureKeepBuffer() { diff --git a/tests/chrome/tst_filepicker.qml b/tests/chrome/tst_filepicker.qml new file mode 100644 index 0000000..04594c2 --- /dev/null +++ b/tests/chrome/tst_filepicker.qml @@ -0,0 +1,279 @@ +import QtQuick +import QtQuick.Window +import QtQuick.Dialogs as SystemDialogs +import QtTest +import NextNotes.ChromeTests + +TestCase { + id: testCase + name: "NotesFilePicker" + when: windowShown + visible: true + width: 800; height: 700 + Item { id: scene; anchors.fill: parent } + Component { + id: pickerComponent + NotesFilePicker { sourceItem: scene } + } + Component { + id: dialogComponent + QtObject { + property bool visible: false + property var parentWindow: null + property int modality: Qt.WindowModal + property string title: "" + property string acceptLabel: "" + property string rejectLabel: "" + property url currentFolder + property url selectedFile + property url selectedFolder + property var selectedFiles: [] + property var nameFilters: [] + property int fileMode: -1 + property string defaultSuffix: "" + property Item sourceItem: null + property string mode: "" + property string acceptText: "" + property string rejectText: "" + property string locationPlaceholder: "" + property string fileNamePlaceholder: "" + property string unavailableText: "" + property string emptyText: "" + property string overwriteTitle: "" + property string overwriteText: "" + property string overwriteAcceptText: "" + signal accepted() + signal rejected() + signal closed() + function open() { visible = true } + function reject() { visible = false; rejected() } + function close() { reject() } + function chooseFiles(values) { + selectedFiles = values; selectedFile = values[0] + visible = false; accepted() + } + function chooseFolder(value) { + selectedFolder = value; visible = false; accepted() + } + } + } + Component { + id: unavailableComponent + QtObject { + property bool visible: false + property var parentWindow: null + property int modality: Qt.WindowModal + property string title: "" + property string acceptLabel: "" + property string rejectLabel: "" + property url currentFolder + property var nameFilters: [] + property int fileMode: -1 + property string defaultSuffix: "" + function open() {} + function close() {} + function reject() {} + } + } + SignalSpy { id: acceptedSpy; signalName: "accepted" } + SignalSpy { id: rejectedSpy; signalName: "rejected" } + SignalSpy { id: failedSpy; signalName: "failed" } + property var picker: null + function compareUrl(actual, expected) { + // QUrl displays Unicode and spaces decoded, but keeps reserved #/% + // escaped. Compare normalized URLs without decoding path delimiters. + compare(String(Qt.resolvedUrl(actual)), String(Qt.resolvedUrl(expected))) + } + function makePicker(properties) { + const options = Object.assign({systemFileDialogs: true, _fileComponent: dialogComponent, + _folderComponent: dialogComponent, _localComponent: dialogComponent, + currentFolder: "file:///home/example"}, properties || {}) + picker = createTemporaryObject(pickerComponent, scene, options) + verify(picker !== null) + acceptedSpy.target = picker; rejectedSpy.target = picker; failedSpy.target = picker + return picker + } + function cleanup() { + if (picker) picker.close() + picker = null + acceptedSpy.target = null; rejectedSpy.target = null; failedSpy.target = null + acceptedSpy.clear(); rejectedSpy.clear(); failedSpy.clear() + } + function test_system_mode_data() { + return [ + {tag: "single", mode: "openFile", fileMode: SystemDialogs.FileDialog.OpenFile}, + {tag: "multiple", mode: "openFiles", fileMode: SystemDialogs.FileDialog.OpenFiles}, + {tag: "save", mode: "saveFile", fileMode: SystemDialogs.FileDialog.SaveFile} + ] + } + function test_system_mode(data) { + makePicker({mode: data.mode, title: "选择笔记", acceptText: "选择", rejectText: "取消", + nameFilters: ["*.md", "*.MD"]}) + picker.open() + verify(picker.visible) + const dialog = picker._backend + compare(dialog.fileMode, data.fileMode) + compare(dialog.parentWindow, scene.Window.window) + compare(dialog.modality, Qt.WindowModal) + compare(dialog.title, "选择笔记") + compare(dialog.acceptLabel, "选择"); compare(dialog.rejectLabel, "取消") + compare(dialog.nameFilters.length, 1) + verify(dialog.nameFilters[0].endsWith("(*.md *.MD)")) + picker.open() + compare(picker._backend, dialog) + const first = "file:///run/user/1000/doc/abcd/%E7%AC%94%E8%AE%B0%20%23%25.md" + const second = "file:///run/user/1000/doc/efgh/B.MD" + let activeDuringAccept = false + picker.accepted.connect(function() { activeDuringAccept = picker.visible }) + dialog.chooseFiles(data.mode === "openFiles" ? [first, second] : [first]) + compare(acceptedSpy.count, 1); compare(rejectedSpy.count, 0) + verify(activeDuringAccept) + compareUrl(picker.selectedFile, first) + verify(String(picker.selectedFile).endsWith("%23%25.md")) + compare(picker.selectedFiles.length, data.mode === "openFiles" ? 2 : 1) + compareUrl(picker.selectedFiles[0], first) + verify(String(picker.selectedFiles[0]).endsWith("%23%25.md")) + if (data.mode === "openFiles") compareUrl(picker.selectedFiles[1], second) + compareUrl(picker.currentFolder, "file:///run/user/1000/doc/abcd/") + verify(!picker.visible) + } + function test_directory_uses_folder_result() { + makePicker({mode: "folder", _fileComponent: null}) + picker.open() + verify(picker.visible) + const directory = "file:///run/user/1000/doc/abcd/%E6%96%87%E4%BB%B6%E5%A4%B9%20%23%25" + picker._backend.chooseFolder(directory) + compare(acceptedSpy.count, 1) + compareUrl(picker.selectedFolder, directory) + compareUrl(picker.currentFolder, directory) + verify(String(picker.selectedFolder).endsWith("%23%25")) + compare(picker.selectedFiles.length, 0) + } + function test_prefill_and_repeated_save_keep_exact_grant() { + makePicker({mode: "saveFile", defaultSuffix: "pdf", selectedFile: "file:///home/example/first"}) + picker.open() + compare(String(picker._backend.selectedFile), "file:///home/example/first.pdf") + compare(picker._backend.defaultSuffix, "") + const first = "file:///run/user/1000/doc/abcd/first.pdf" + picker._backend.chooseFiles([first]) + compare(String(picker.selectedFile), first) + picker.selectedFile = "file:///run/user/1000/doc/abcd/second.pdf" + picker.open() + compare(String(picker._backend.selectedFile), "file:///run/user/1000/doc/abcd/second.pdf") + picker._backend.chooseFiles(["file:///run/user/1000/doc/efgh/second.PDF"]) + compare(String(picker.selectedFile), "file:///run/user/1000/doc/efgh/second.PDF") + compare(acceptedSpy.count, 2); compare(failedSpy.count, 0) + } + function test_save_never_appends_to_returned_url_data() { + return [{tag: "missing", name: "report"}, {tag: "different", name: "report.txt"}] + } + function test_save_never_appends_to_returned_url(data) { + makePicker({mode: "saveFile", defaultSuffix: "nkbackup", selectedFile: "file:///home/example/proposed.nkbackup"}) + picker.open() + picker._backend.chooseFiles(["file:///run/user/1000/doc/abcd/" + data.name]) + compare(acceptedSpy.count, 0); compare(rejectedSpy.count, 1); compare(failedSpy.count, 1) + verify(failedSpy.signalArguments[0][0].indexOf(".nkbackup") >= 0) + compare(String(picker.selectedFile), "file:///home/example/proposed.nkbackup") + verify(!picker.visible) + } + function test_cancel_does_not_publish_transient_selection() { + makePicker({selectedFile: "file:///home/example/previous.md", selectedFiles: ["file:///home/example/previous.md"]}) + picker.open() + picker._backend.selectedFile = "file:///run/user/1000/doc/abcd/transient.md" + picker._backend.selectedFiles = ["file:///run/user/1000/doc/abcd/transient.md"] + picker.close(); picker.close() + compare(rejectedSpy.count, 1); compare(acceptedSpy.count, 0); compare(failedSpy.count, 0) + compare(String(picker.selectedFile), "file:///home/example/previous.md") + compare(picker.selectedFiles[0], "file:///home/example/previous.md") + verify(!picker.visible) + } + function test_hidden_without_result_rejects_once() { + makePicker() + picker.open() + picker._backend.visible = false + tryCompare(rejectedSpy, "count", 1) + compare(acceptedSpy.count, 0) + compare(failedSpy.count, 0) + verify(!picker.visible) + } + function test_retired_backend_cannot_complete_next_request() { + makePicker() + picker.open() + const retired = picker._backend + picker.reject() + compare(rejectedSpy.count, 1) + picker.open() + const active = picker._backend + verify(active !== retired) + // destroy() is deferred. Old backend signals emitted before deletion + // must not be attributed to the new, already-open request. + retired.selectedFile = "file:///run/user/1000/doc/old/stale.md" + retired.accepted() + retired.rejected() + retired.closed() + compare(acceptedSpy.count, 0) + compare(rejectedSpy.count, 1) + compare(failedSpy.count, 0) + verify(picker.visible) + // Let the old dialog's already-queued hidden callback run as well. + wait(0) + compare(picker._backend, active) + compare(acceptedSpy.count, 0) + compare(rejectedSpy.count, 1) + verify(picker.visible) + active.chooseFiles(["file:///run/user/1000/doc/new/current.md"]) + compare(acceptedSpy.count, 1) + compare(rejectedSpy.count, 1) + compare(failedSpy.count, 0) + compareUrl(picker.selectedFile, "file:///run/user/1000/doc/new/current.md") + } + function test_visible_request_keeps_its_mode_and_suffix() { + makePicker({mode: "saveFile", defaultSuffix: "pdf", selectedFile: "file:///home/example/note.pdf"}) + picker.open() + picker.mode = "folder"; picker.defaultSuffix = "html" + picker._backend.chooseFiles(["file:///run/user/1000/doc/abcd/note.pdf"]) + compare(acceptedSpy.count, 1); compare(failedSpy.count, 0) + compare(String(picker.selectedFile), "file:///run/user/1000/doc/abcd/note.pdf") + } + function test_invalid_multi_selection_is_rejected_as_a_whole() { + makePicker({mode: "openFiles"}) + picker.open() + picker._backend.chooseFiles(["file:///run/user/1000/doc/abcd/a.md", "https://example.invalid/b.md"]) + compare(acceptedSpy.count, 0); compare(rejectedSpy.count, 1); compare(failedSpy.count, 1) + compare(picker.selectedFiles.length, 0) + } + function test_open_failure_resets_popup_state() { + makePicker({_fileComponent: unavailableComponent}) + picker.open() + tryCompare(failedSpy, "count", 1) + compare(rejectedSpy.count, 1); compare(acceptedSpy.count, 0) + verify(!picker.visible) + } + function test_local_backend_and_exit_transition() { + makePicker({systemFileDialogs: false, mode: "saveFile", defaultSuffix: "html", + selectedFile: "file:///home/example/note.html", overwriteTitle: "替换", overwriteText: "同名文件"}) + picker.open() + const dialog = picker._backend + compare(dialog.mode, "saveFile"); compare(dialog.defaultSuffix, "html") + compare(dialog.nameFilters[0], "*"); compare(dialog.sourceItem, scene) + compare(dialog.overwriteTitle, "替换"); compare(dialog.overwriteText, "同名文件") + dialog.selectedFile = "file:///home/example/note.html" + // LiquidDialog emits acceptance before its exit transition is finished. + dialog.accepted() + compare(acceptedSpy.count, 1) + verify(picker.visible) + dialog.visible = false; dialog.closed() + verify(!picker.visible) + compare(rejectedSpy.count, 0) + } + function test_real_local_component_opens_and_cancels() { + picker = createTemporaryObject(pickerComponent, scene, {systemFileDialogs: false}) + verify(picker !== null) + rejectedSpy.target = picker + picker.open() + verify(picker.visible) + picker.reject() + tryCompare(picker, "visible", false) + compare(rejectedSpy.count, 1) + } +}