diff --git a/docs/30-S3图片上传.md b/docs/30-S3图片上传.md new file mode 100644 index 0000000..4cf1b13 --- /dev/null +++ b/docs/30-S3图片上传.md @@ -0,0 +1,64 @@ +# docs/30 — S3 图片上传 + +需求(2026-11-15 用户提出):①设置里能配 S3 并有「自动上传图片」开关; +②未上传的图片右键要有「上传到 S3」;③整个文档要有批量检测未上传图片 +并上传的按钮。 + +## 设计 + +- **协议层在 Rust**(`src-tauri/src/s3.rs`):AWS SigV4 签名 + PUT Object, + reqwest(blocking + rustls)。配置随每次调用从前端传入,Rust 不落盘 + 保存密钥。命令 2 个(总数 30 与 lib.rs 注册对账):`s3_upload_image`、 + `s3_test_connection`(对桶发签名 HEAD,按 200/403/404 给可读结论)。 +- **寻址风格**:Endpoint 留空 = AWS S3 虚拟主机风格 + `https://{bucket}.s3.{region}.amazonaws.com/{key}`;填了自定义 + Endpoint(MinIO / R2 / OSS 等 S3 兼容服务)走路径风格 + `{endpoint}/{bucket}/{key}`。对象键 = 前缀 + 原文件名(同名覆盖, + 重复上传幂等)。配了「自定义访问域名」时回填文档的是该域名拼的地址。 +- **配置存储**:进 UiState.s3(本机偏好库 SQLite/localStorage),字段 + endpoint/region/bucket/accessKey/secretKey/prefix/publicBase/autoUpload; + 加载时与默认值深合并。设置弹窗在 文件 → S3 上传设置…,带「测试连接」。 + 弹窗里已明示:密钥只保存在本机设置里,不写进文档、不会被 Git 同步带走。 +- **「未上传」判定**:图片 src 不是 http(s)/data/blob 即本地引用 + (`isLocalImageSrc`)。上传成功后把编辑器里所有同 src 的图片节点 + 一次事务换成 S3 地址(`replaceImageSrc`,同图多处引用一并换),文档 + 随之变脏、走既有自动保存落盘。本地 .assets 文件保留不删(删了断掉 + 离线打开时的图,且用户可能还要本地备份)。 + +## 三个入口 + +1. **自动上传**(开关在 S3 设置里):粘贴/拖入(handleImageFile 落盘后) + 与「插入图片」(copyToAssets 后)触发后台上传+回换;粘贴建节点是 + 异步的,第一次回换扑空时 900ms 后重试一次;再扑空或上传失败都给 + 提示(flash),不静默、本地引用保留。 +2. **右键单张**:编辑区右键命中 时经 `imageSrcForElement` + (posAtDOM 反查节点)拿到文档 src;本地图时菜单多一项「上传到 S3」。 + 未配置 S3 时点它先弹设置弹窗并提示。 +3. **批量**:文件 →「上传文档图片到 S3…」+ 状态栏按钮(当前文档有未 + 上传本地图片时出现,带张数)。扫描走编辑器 `listImages()`(PM 文档 + 遍历、去重),逐张上传回换,进度弹窗实时显示 第 N/总 张与当前文件 + 名,结束汇总成功/失败明细。源码模式下不可用(菜单禁用、状态栏不 + 计数),提示切回即时渲染。 + +## 验证 + +- **签名算法**:独立小 crate 里对照 AWS 官方文档示例(IAM ListUsers, + 20150830T123600Z)——算出的 Signature 与官方值 + `5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7` + 逐字一致;UTC 时间换算(Hinnant civil 算法)与空负载哈希一并断言。 +- **协议端到端**:把随产品发货的同一份 s3.rs 放进独立 crate,对本地 + TCP 模拟 S3 跑真实请求:PUT 路径风格、中文文件名百分号编码、 + x-amz-content-sha256 与请求体哈希一致、Authorization 头结构、 + 自定义访问域名回填、HEAD 桶测试连接、空配置校验,2 例全过。 +- **前端**:vitest 133+2(新增 s3.test 路径/判定、editor-images 收集/ + 回换/元素反查、s3-modal 保存载荷、右键菜单图片项);tsc 与构建通过; + 设置弹窗无头浏览器截图核对(七个字段、开关、三按钮齐全)。 +- **Cargo.lock 已更新**(reqwest 0.12.28 等,本机 cargo fetch 完成)。 + +## 待真机验证(Windows) + +- Rust 完整构建(新增 reqwest/sha2/hmac 依赖)与真实 S3/兼容服务联调: + 填真实配置 → 测试连接 → 粘贴图片自动上传 → 右键单张 → 批量上传, + 并在桶里核对对象与文档回填地址可公开访问(桶需允许公开读或配 CDN)。 +- 注意:签名时钟用系统 UTC 时间,本机时钟偏差过大会被 S3 拒绝 + (报错会带回 S3 的 Code/Message,按提示校时即可)。 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4fab50b..640a10f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -100,6 +100,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64" version = "0.23.1" @@ -337,6 +343,23 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + [[package]] name = "chrono" version = "0.4.45" @@ -424,6 +447,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.5" @@ -583,6 +615,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -878,6 +911,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1051,6 +1085,19 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1070,8 +1117,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core", + "wasm-bindgen", ] [[package]] @@ -1270,6 +1320,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.39.0" @@ -1349,6 +1408,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-util" version = "0.1.21" @@ -1813,6 +1888,12 @@ version = "0.4.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "markup5ever" version = "0.39.0" @@ -1949,9 +2030,12 @@ name = "new-markdown" version = "0.1.0" dependencies = [ "encoding_rs", + "hmac", + "reqwest 0.12.28", "rusqlite", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -2515,6 +2599,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -2536,6 +2676,32 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2610,6 +2776,46 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.5" @@ -2670,6 +2876,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -2699,12 +2919,53 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "102f35f7de7b06d8c61f1cff6592f5253e0dea431c74867aad5dfd70f44663fb" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a0760a887c14823fca5a1c970b01d47c328c696c0c64829d1df50e8f853fd5" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -2912,6 +3173,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.24.0" @@ -2992,7 +3265,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3156,6 +3429,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.9" @@ -3319,7 +3598,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.5", "serde", "serde_json", "serde_repr", @@ -3670,6 +3949,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.20" @@ -3914,6 +4203,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4200,6 +4495,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7265c9301c16a8711c992c2d8da90ee77ef54b1ed51482d6dfeb00dc756b1297" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.39.1" @@ -4388,7 +4692,16 @@ version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" dependencies = [ - "windows-targets", + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -4406,13 +4719,29 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -4439,42 +4768,90 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_i686_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.5.40" @@ -4643,6 +5020,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ff9fabe..feb822c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,3 +19,6 @@ serde_json = "1" encoding_rs = "0.8" rusqlite = { version = "0.32", features = ["bundled"] } trash = "5" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } +sha2 = "0.10" +hmac = "0.12" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a5b508e..96185ac 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -925,3 +925,25 @@ pub async fn git_sync_now(root: String, message: String) -> Result Result { + tauri::async_runtime::spawn_blocking(move || crate::s3::upload(&config, &file_path, &file_name)) + .await + .map_err(|e| format!("上传任务失败:{e}"))? +} + +#[tauri::command] +pub async fn s3_test_connection(config: crate::s3::S3Config) -> Result { + tauri::async_runtime::spawn_blocking(move || crate::s3::test_connection(&config)) + .await + .map_err(|e| format!("连接测试失败:{e}"))? +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fc94a72..93498cb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod commands; +mod s3; mod search; mod storage; @@ -66,6 +67,8 @@ pub fn run() { commands::git_init_repo, commands::git_set_remote, commands::git_sync_now, + commands::s3_upload_image, + commands::s3_test_connection, commands::delete_to_trash, commands::db_recent_list, commands::db_recent_add, diff --git a/src-tauri/src/s3.rs b/src-tauri/src/s3.rs new file mode 100644 index 0000000..55689c5 --- /dev/null +++ b/src-tauri/src/s3.rs @@ -0,0 +1,322 @@ +//! S3 兼容对象存储上传(docs/30):AWS SigV4 签名 + PUT Object。 +//! 配置由前端设置弹窗收集、随每次调用传入,Rust 侧不落盘保存密钥。 +//! endpoint 为空 = AWS S3(虚拟主机风格);填了自定义 endpoint(MinIO / +//! R2 / OSS 等 S3 兼容服务)走路径风格 {endpoint}/{bucket}/{key}。 +//! 签名实现已对照 AWS 官方文档示例向量验证(签名值逐字一致)。 + +use hmac::{Hmac, Mac}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +type HmacSha256 = Hmac; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct S3Config { + /// 自定义服务端点(空 = AWS 官方),如 https://minio.example.com:9000 + #[serde(default)] + pub endpoint: String, + pub region: String, + pub bucket: String, + pub access_key: String, + pub secret_key: String, + /// 对象键前缀(可空),如 notes/ + #[serde(default)] + pub prefix: String, + /// 自定义访问域名(可空),如 https://cdn.example.com;填了则回填文档的 + /// 是这个域名下的地址,否则用上传请求本身的地址 + #[serde(default)] + pub public_base: String, +} + +const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024; + +fn validate(cfg: &S3Config) -> Result<(), String> { + if cfg.bucket.trim().is_empty() { + return Err("请先在 S3 设置里填写存储桶(Bucket)。".into()); + } + if cfg.region.trim().is_empty() { + return Err("请先在 S3 设置里填写区域(Region)。".into()); + } + if cfg.access_key.trim().is_empty() || cfg.secret_key.trim().is_empty() { + return Err("请先在 S3 设置里填写 Access Key 与 Secret Key。".into()); + } + let ep = cfg.endpoint.trim(); + if !ep.is_empty() && !ep.starts_with("http://") && !ep.starts_with("https://") { + return Err("服务端点需以 http:// 或 https:// 开头。".into()); + } + Ok(()) +} + +/// 对象键:前缀(归一为无前导 /、非空时以 / 结尾)+ 文件名。 +fn object_key(cfg: &S3Config, file_name: &str) -> String { + let prefix = cfg.prefix.trim().trim_start_matches('/'); + if prefix.is_empty() { + file_name.to_string() + } else if prefix.ends_with('/') { + format!("{prefix}{file_name}") + } else { + format!("{prefix}/{file_name}") + } +} + +/// 上传目标:返回 (请求 URL, 签名用 host, canonical URI)。 +fn target(cfg: &S3Config, key: &str) -> (String, String, String) { + let encoded_key = encode_uri_path(&format!("/{}", key.trim_start_matches('/'))); + let endpoint = cfg.endpoint.trim().trim_end_matches('/'); + if endpoint.is_empty() { + let host = format!("{}.s3.{}.amazonaws.com", cfg.bucket.trim(), cfg.region.trim()); + (format!("https://{host}{encoded_key}"), host, encoded_key) + } else { + let after_scheme = endpoint.split("://").nth(1).unwrap_or(endpoint); + let host = after_scheme.split('/').next().unwrap_or(after_scheme).to_string(); + let uri = format!("/{bucket}{encoded_key}", bucket = cfg.bucket.trim()); + (format!("{endpoint}{uri}"), host, uri) + } +} + +fn content_type_of(file_name: &str) -> &'static str { + let ext = file_name.rsplit('.').next().unwrap_or("").to_ascii_lowercase(); + match ext.as_str() { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "svg" => "image/svg+xml", + "bmp" => "image/bmp", + "ico" => "image/x-icon", + "avif" => "image/avif", + _ => "application/octet-stream", + } +} + +fn client() -> Result { + reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(60)) + .build() + .map_err(|e| format!("初始化网络客户端失败:{e}")) +} + +/// 从 S3 的 XML 错误体里粗提取 Code / Message,给用户一句能看懂的话。 +fn s3_error(status: reqwest::StatusCode, body: &str) -> String { + let grab = |tag: &str| -> Option { + let open = format!("<{tag}>"); + let close = format!(""); + let start = body.find(&open)? + open.len(); + let end = body[start..].find(&close)? + start; + Some(body[start..end].trim().to_string()) + }; + match (grab("Code"), grab("Message")) { + (Some(code), Some(msg)) => format!("S3 返回 {status}({code}):{msg}"), + _ => format!("S3 返回 {status}"), + } +} + +fn now_amz_dates() -> (String, String) { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + amz_dates(secs) +} + +fn signed_request( + cfg: &S3Config, + method: &str, + host: &str, + canonical_uri: &str, + content_type: Option<&str>, + payload_hash: &str, +) -> (String, String, String) { + let (amz_date, date_stamp) = now_amz_dates(); + let mut extras: Vec<(&str, &str)> = vec![("x-amz-content-sha256", payload_hash)]; + if let Some(ct) = content_type { + extras.push(("content-type", ct)); + } + let auth = authorization_header(&SignParams { + method, + host, + canonical_uri, + canonical_query: "", + region: cfg.region.trim(), + service: "s3", + access_key: cfg.access_key.trim(), + secret_key: cfg.secret_key.trim(), + amz_date: &amz_date, + date_stamp: &date_stamp, + payload_hash, + extra_headers: &extras, + }); + (auth, amz_date, date_stamp) +} + +/// 上传一个本地文件到 S3,成功返回文档应回填的公开访问地址。 +pub fn upload(cfg: &S3Config, file_path: &str, file_name: &str) -> Result { + validate(cfg)?; + let meta = std::fs::metadata(file_path) + .map_err(|e| format!("读取待上传图片失败({file_path}):{e}"))?; + if meta.len() > MAX_IMAGE_BYTES { + return Err("图片超过 64MB,未上传。".into()); + } + let bytes = std::fs::read(file_path).map_err(|e| format!("读取待上传图片失败:{e}"))?; + let key = object_key(cfg, file_name); + let (url, host, canonical_uri) = target(cfg, &key); + let payload_hash = sha256_hex(&bytes); + let content_type = content_type_of(file_name); + let (auth, amz_date, _) = signed_request(cfg, "PUT", &host, &canonical_uri, Some(content_type), &payload_hash); + let resp = client()? + .put(&url) + .header("Authorization", auth) + .header("x-amz-date", amz_date) + .header("x-amz-content-sha256", &payload_hash) + .header("Content-Type", content_type) + .body(bytes) + .send() + .map_err(|e| format!("连接 S3 失败:{e}"))?; + let status = resp.status(); + if status.is_success() { + let base = cfg.public_base.trim().trim_end_matches('/'); + if base.is_empty() { + Ok(url) + } else { + Ok(format!("{base}{}", encode_uri_path(&format!("/{key}")))) + } + } else { + let body = resp.text().unwrap_or_default(); + Err(s3_error(status, &body)) + } +} + +/// 测试连接:对存储桶发一个签名的 HEAD 请求,按状态码给出可读结论。 +pub fn test_connection(cfg: &S3Config) -> Result { + validate(cfg)?; + let (url, host, canonical_uri) = target(cfg, ""); + // target 以空 key 生成的是「桶根」地址:虚拟主机风格即桶域名根, + // 路径风格即 {endpoint}/{bucket}/——HEAD 桶正合适 + let payload_hash = sha256_hex(b""); + let (auth, amz_date, _) = signed_request(cfg, "HEAD", &host, &canonical_uri, None, &payload_hash); + let resp = client()? + .head(&url) + .header("Authorization", auth) + .header("x-amz-date", amz_date) + .header("x-amz-content-sha256", &payload_hash) + .send() + .map_err(|e| format!("连接 S3 失败:{e}"))?; + let status = resp.status(); + if status.is_success() { + Ok("连接成功:存储桶可访问,密钥有效。".into()) + } else if status.as_u16() == 403 { + Err("服务器返回 403:密钥不正确,或该密钥无此存储桶的访问权限。".into()) + } else if status.as_u16() == 404 { + Err("服务器返回 404:存储桶不存在(检查桶名与区域)。".into()) + } else { + Err(format!("服务器返回 {status}。")) + } +} + +// ————————————————— SigV4 签名(已对照 AWS 官方示例向量验证) ————————————————— + +pub fn sha256_hex(data: &[u8]) -> String { + let mut h = Sha256::new(); + h.update(data); + hex(&h.finalize()) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn hmac(key: &[u8], data: &[u8]) -> Vec { + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC 接受任意长度密钥"); + mac.update(data); + mac.finalize().into_bytes().to_vec() +} + +/// 由 epoch 秒生成 SigV4 用的 UTC 时间串(amzDate 如 20150830T123600Z、dateStamp 如 20150830)。 +pub fn amz_dates(epoch_secs: u64) -> (String, String) { + let days = (epoch_secs / 86_400) as i64; + let secs = epoch_secs % 86_400; + let (hh, mm, ss) = (secs / 3600, (secs % 3600) / 60, secs % 60); + // civil_from_days(Howard Hinnant) + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = if m <= 2 { y + 1 } else { y }; + ( + format!("{year:04}{m:02}{d:02}T{hh:02}{mm:02}{ss:02}Z"), + format!("{year:04}{m:02}{d:02}"), + ) +} + +pub struct SignParams<'a> { + pub method: &'a str, + pub host: &'a str, + pub canonical_uri: &'a str, + pub canonical_query: &'a str, + pub region: &'a str, + pub service: &'a str, + pub access_key: &'a str, + pub secret_key: &'a str, + pub amz_date: &'a str, + pub date_stamp: &'a str, + pub payload_hash: &'a str, + /// 除 host / x-amz-date 外参与签名的请求头(名须小写) + pub extra_headers: &'a [(&'a str, &'a str)], +} + +/// 计算 Authorization 头的完整值。 +pub fn authorization_header(p: &SignParams) -> String { + let mut headers: Vec<(&str, &str)> = vec![("host", p.host), ("x-amz-date", p.amz_date)]; + headers.extend_from_slice(p.extra_headers); + headers.sort_by(|a, b| a.0.cmp(b.0)); + let canonical_headers: String = headers + .iter() + .map(|(k, v)| format!("{k}:{}\n", v.trim())) + .collect(); + let signed_headers: String = headers + .iter() + .map(|(k, _)| *k) + .collect::>() + .join(";"); + let canonical_request = format!( + "{}\n{}\n{}\n{}\n{}\n{}", + p.method, p.canonical_uri, p.canonical_query, canonical_headers, signed_headers, p.payload_hash + ); + let scope = format!("{}/{}/{}/aws4_request", p.date_stamp, p.region, p.service); + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{}\n{}\n{}", + p.amz_date, + scope, + sha256_hex(canonical_request.as_bytes()) + ); + let k_date = hmac(format!("AWS4{}", p.secret_key).as_bytes(), p.date_stamp.as_bytes()); + let k_region = hmac(&k_date, p.region.as_bytes()); + let k_service = hmac(&k_region, p.service.as_bytes()); + let k_signing = hmac(&k_service, b"aws4_request"); + let signature = hex(&hmac(&k_signing, string_to_sign.as_bytes())); + format!( + "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}", + p.access_key, scope, signed_headers, signature + ) +} + +/// URI 路径编码(SigV4 canonical URI 与公开地址共用):保留 / 与 RFC 3986 非保留字符。 +pub fn encode_uri_path(path: &str) -> String { + let mut out = String::with_capacity(path.len()); + for b in path.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} diff --git a/src/App.tsx b/src/App.tsx index dbc5a36..66f842e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,13 +29,15 @@ import { pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64, statFile, createTextFile, createDirPath, renamePath, deleteToTrash, revealInExplorer, type DirEntry, type RecoveryEntry, pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport, - gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, type GitStatusInfo, + gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, s3UploadImage, type GitStatusInfo, } from './services/platform' import * as store from './services/store' import { recoveryKey, planRecovery } from './utils/recovery' import { appWindowClaims, appWindowId } from './services/windowClaims' import { acquireGitSyncLease, gitStatusLabel, gitSyncCommitMessage, loadGitSyncSettings, releaseGitSyncLease, saveGitSyncSettings, type GitSyncSettings } from './services/gitSync' import { GitSyncModal } from './components/GitSyncModal' +import { S3SettingsModal } from './components/S3SettingsModal' +import { DEFAULT_S3_SETTINGS, isS3Configured, isLocalImageSrc, resolveDocRelativePath, imageFileName, s3ConfigPayload, type S3Settings } from './services/s3' import { RecoveryModal, ConflictModal, QuitModal, PromptModal, ConfirmModal } from './components/Stage4Modals' import { AppearanceModal, ExportModal } from './components/Stage5Modals' import { DOC_THEMES, docThemeById } from './themes' @@ -61,11 +63,13 @@ const UI_KEY = 'new-markdown:ui' function loadRecent(): string[] { try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[] } catch { return [] } } -interface UiState { theme: 'light' | 'dark'; sidebar: boolean; sidebarWidth: number; autosave: boolean; autosaveSeconds: number; docTheme: string; customCss: string; focusMode: boolean; typewriter: boolean; shortcuts: Record } +interface UiState { theme: 'light' | 'dark'; sidebar: boolean; sidebarWidth: number; autosave: boolean; autosaveSeconds: number; docTheme: string; customCss: string; focusMode: boolean; typewriter: boolean; shortcuts: Record; s3: S3Settings } function loadUi(): UiState { - const defaults: UiState = { theme: 'light', sidebar: true, sidebarWidth: 250, autosave: true, autosaveSeconds: 30, docTheme: 'default', customCss: '', focusMode: false, typewriter: false, shortcuts: {} } + const defaults: UiState = { theme: 'light', sidebar: true, sidebarWidth: 250, autosave: true, autosaveSeconds: 30, docTheme: 'default', customCss: '', focusMode: false, typewriter: false, shortcuts: {}, s3: DEFAULT_S3_SETTINGS } try { - return { ...defaults, ...JSON.parse(localStorage.getItem(UI_KEY) ?? '{}') } + const parsed = JSON.parse(localStorage.getItem(UI_KEY) ?? '{}') as Partial + if (parsed.s3) parsed.s3 = { ...DEFAULT_S3_SETTINGS, ...parsed.s3 } + return { ...defaults, ...parsed } } catch { return defaults } } @@ -667,7 +671,11 @@ export default function App() { // 否则挂载时的初始默认值会先落库、把用户设置覆盖掉(启动竞态)。 const uiJson = await store.loadUiPref() if (!cancelled && uiJson) { - try { setUi((prev) => ({ ...prev, ...JSON.parse(uiJson) })) } catch { /* 偏好损坏时用默认值 */ } + try { + const parsed = JSON.parse(uiJson) as Partial + if (parsed.s3) parsed.s3 = { ...DEFAULT_S3_SETTINGS, ...parsed.s3 } + setUi((prev) => ({ ...prev, ...parsed })) + } catch { /* 偏好损坏时用默认值 */ } } uiLoadedRef.current = true const recentFromDb = await store.loadRecent() @@ -1078,7 +1086,7 @@ export default function App() { }, []) // ——— 编辑区自绘右键菜单 ——— - const [editorMenu, setEditorMenu] = useState<{ x: number; y: number; hasSelection: boolean } | null>(null) + const [editorMenu, setEditorMenu] = useState<{ x: number; y: number; hasSelection: boolean; imageSrc: string | null } | null>(null) const openEditorMenu = (e: React.MouseEvent) => { const target = e.target as HTMLElement const inEditor = sourceMode @@ -1087,15 +1095,19 @@ export default function App() { if (!inEditor) return e.preventDefault() let hasSelection = false + let imageSrc: string | null = null if (sourceMode) { hasSelection = sourceApiRef.current?.hasSelection() ?? false } else if (tab) { - const editor = paneApisRef.current.get(tab.id)?.editor - if (editor) { - try { hasSelection = editor.action((ctx) => !ctx.get(editorViewCtx).state.selection.empty) } catch { hasSelection = false } + const api = paneApisRef.current.get(tab.id) + if (api?.editor) { + try { hasSelection = api.editor.action((ctx) => !ctx.get(editorViewCtx).state.selection.empty) } catch { hasSelection = false } } + // 右键命中图片时取出它的文档 src:本地未上传图会在菜单里多一项「上传到 S3」 + const img = target.closest('img') + if (img && api) imageSrc = api.imageSrcForElement(img) } - setEditorMenu({ x: e.clientX, y: e.clientY, hasSelection }) + setEditorMenu({ x: e.clientX, y: e.clientY, hasSelection, imageSrc }) } const runEditorMenuAction = (action: EditorMenuAction) => { switch (action) { @@ -1109,6 +1121,7 @@ export default function App() { case 'inlineCode': withEditor(editorCommands.inlineCode); break case 'link': setModal('link'); break case 'toggleSource': toggleSourceMode(); break + case 'uploadS3': { const src = editorMenu?.imageSrc; if (src) void runSingleImageUpload(src); break } } } @@ -1128,6 +1141,8 @@ export default function App() { const stateRef = useRef(state) stateRef.current = state + const uiRef = useRef(ui) + uiRef.current = ui // 活动标签变化时登记保活顺序(最近用过的排前面,最多 6 个;超出的卸载走最终回写同步) const activeIdForAlive = tab?.id @@ -1155,13 +1170,92 @@ export default function App() { } }, [activeIdForAlive]) + // ——— S3 图片上传(docs/30) ——— + const [s3SettingsOpen, setS3SettingsOpen] = useState(false) + const [s3Progress, setS3Progress] = useState<{ total: number; done: number; current: string; errors: string[]; finished: boolean } | null>(null) + const s3BusyRef = useRef(false) + + /** 上传一张本地图片(docPath + 文档相对 src),返回 S3 公开地址。 */ + const uploadLocalImage = useCallback(async (docPath: string, src: string): Promise => { + const abs = resolveDocRelativePath(docPath, src) + return s3UploadImage(s3ConfigPayload(uiRef.current.s3), abs, imageFileName(src)) + }, []) + + /** 右键单张上传:成功后把编辑器里的引用换成 S3 地址。 */ + const runSingleImageUpload = useCallback(async (src: string) => { + const current = activeTab(stateRef.current) + if (!current?.path) { setError('请先保存文档,再上传图片。'); return } + if (!isS3Configured(uiRef.current.s3)) { setS3SettingsOpen(true); flash('先把 S3 配置填好,再上传图片'); return } + flash('正在上传图片…') + try { + const url = await uploadLocalImage(current.path, src) + paneApisRef.current.get(current.id)?.replaceImageSrc(src, url) + flash('图片已上传到 S3,引用已更新') + } catch (e) { setError(e instanceof Error ? e.message : String(e)) } + }, [flash, uploadLocalImage]) + + /** 批量上传:扫描当前文档全部本地图片逐张上传回换,进度走 s3Progress 弹窗。 */ + const runBatchImageUpload = async () => { + if (!tab?.path) { setError('请先保存文档,再批量上传图片。'); return } + if (sourceMode) { flash('源码模式下不能批量上传,请先切回即时渲染'); return } + if (!isS3Configured(ui.s3)) { setS3SettingsOpen(true); flash('先把 S3 配置填好,再上传图片'); return } + if (s3BusyRef.current) return + const api = paneApisRef.current.get(tab.id) + if (!api) return + const locals = [...new Set(api.listImages().filter(isLocalImageSrc))] + if (locals.length === 0) { flash('本文档没有未上传的本地图片'); return } + const docPath = tab.path + s3BusyRef.current = true + setS3Progress({ total: locals.length, done: 0, current: '', errors: [], finished: false }) + const errors: string[] = [] + let done = 0 + for (const src of locals) { + setS3Progress((pr) => (pr ? { ...pr, current: imageFileName(src) } : pr)) + try { + const url = await uploadLocalImage(docPath, src) + api.replaceImageSrc(src, url) + } catch (e) { + errors.push(`${imageFileName(src)}:${e instanceof Error ? e.message : String(e)}`) + } + done += 1 + setS3Progress((pr) => (pr ? { ...pr, done, errors: [...errors] } : pr)) + } + s3BusyRef.current = false + setS3Progress((pr) => (pr ? { ...pr, current: '', errors, finished: true } : pr)) + } + + /** + * 自动上传(设置开关):图片落盘后在后台上传,成功后把引用换成 S3 地址。 + * 粘贴建节点是异步的,第一次替换可能扑空,900ms 后重试一次;仍扑空则 + * 保留本地引用并提示可用批量上传同步,不静默。 + */ + const autoUploadSwap = useCallback((tabId: string, docPath: string, relSrc: string) => { + const settings = uiRef.current.s3 + if (!settings.autoUpload || !isS3Configured(settings)) return + void (async () => { + try { + const url = await s3UploadImage(s3ConfigPayload(settings), resolveDocRelativePath(docPath, relSrc), imageFileName(relSrc)) + let n = paneApisRef.current.get(tabId)?.replaceImageSrc(relSrc, url) ?? 0 + if (n === 0) { + await new Promise((r) => setTimeout(r, 900)) + n = paneApisRef.current.get(tabId)?.replaceImageSrc(relSrc, url) ?? 0 + } + if (n === 0) flash('图片已上传到 S3,但引用替换未生效,可用「上传文档图片到 S3」再同步一次') + } catch (e) { + flash(`图片自动上传失败(已保留本地引用):${e instanceof Error ? e.message : String(e)}`) + } + })() + }, [flash]) + /** 粘贴/拖入图片的落盘:桌面模式写入文档 .assets 并回相对路径;未命名文档或 Web 模式退为 data URL。 */ const handleImageFile = useCallback(async (file: File): Promise => { const current = activeTab(stateRef.current) if (isTauri() && current?.path) { const ext = (file.type.split('/')[1] || 'png').replace('jpeg', 'jpg') const named = file.name && file.name !== 'image.png' ? file.name : `image-${Date.now()}.${ext}` - return saveBytesToAssets(current.path, named, await fileToBase64(file)) + const rel = await saveBytesToAssets(current.path, named, await fileToBase64(file)) + autoUploadSwap(current.id, current.path, rel) + return rel } return new Promise((resolve) => { const reader = new FileReader() @@ -1169,7 +1263,7 @@ export default function App() { reader.onerror = () => resolve(URL.createObjectURL(file)) reader.readAsDataURL(file) }) - }, []) + }, [autoUploadSwap]) const insertImage = useCallback(async () => { if (!tab) return @@ -1180,12 +1274,13 @@ export default function App() { try { const rel = await copyToAssets(picked, tab.path) withEditor((e) => editorCommands.image(e, rel, baseName(picked))) + autoUploadSwap(tab.id, tab.path, rel) } catch (e) { setError(String(e)) } } else { const picked = await pickAndReadWebImage() if (picked) withEditor((e) => editorCommands.image(e, picked.dataUrl, picked.name)) } - }, [tab, tauriMode, withEditor]) + }, [tab, tauriMode, withEditor, autoUploadSwap]) const insertAttachment = useCallback(async () => { if (!tab?.path) { setError('请先保存文档,再插入附件(附件会复制到文档同名 .assets 目录并以链接引用)。'); return } @@ -1407,6 +1502,16 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps [tab?.id, deferredContent], ) + // 状态栏「未上传图片」计数:扫当前文档序列化文本里的本地图片引用(去重) + const pendingImages = useMemo(() => { + if (!tab?.content || sourceMode) return 0 + const found = new Set() + const re = /!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g + let m: RegExpExecArray | null + while ((m = re.exec(tab.content))) if (isLocalImageSrc(m[1])) found.add(m[1]) + return found.size + }, [tab?.id, tab?.content, sourceMode]) + const statusCounts = useMemo(() => { if (!tab) return { words: 0, chars: 0, lines: 0 } return { words: countWords(tab.content), chars: countChars(tab.content), lines: tab.content.split('\n').length } @@ -1430,6 +1535,9 @@ export default function App() { { label: '导入 HTML / Word…', onClick: () => void importFileFlow() }, { label: '导出…', disabled: !tab, onClick: () => setExportOpen(true) }, { label: '打印 / 导出 PDF…', shortcut: 'Ctrl+P', disabled: !tab, onClick: printDoc }, + { divider: true }, + { label: '上传文档图片到 S3…', disabled: !tab || sourceMode, onClick: () => void runBatchImageUpload() }, + { label: 'S3 上传设置…', onClick: () => setS3SettingsOpen(true) }, ], }, { @@ -1734,6 +1842,8 @@ export default function App() { sourceMode={sourceMode} tauriMode={tauriMode} zoom={zoom} + pendingImages={pendingImages} + onUploadImages={() => void runBatchImageUpload()} /> {gitSettingsOpen && workspaceRoot && ( setGitSettingsOpen(false)} /> )} + {s3SettingsOpen && ( + { setUi((u) => ({ ...u, s3: next })); setS3SettingsOpen(false); flash('S3 设置已保存') }} + onClose={() => setS3SettingsOpen(false)} + /> + )} + {s3Progress && ( +
+
+

上传图片到 S3

+ {!s3Progress.finished ? ( +

正在上传 {Math.min(s3Progress.done + 1, s3Progress.total)} / {s3Progress.total}:{s3Progress.current}

+ ) : ( + <> +

完成:成功 {s3Progress.total - s3Progress.errors.length} 张,失败 {s3Progress.errors.length} 张。

+ {s3Progress.errors.map((err, i) => ( +

{err}

+ ))} + + )} +
+ +
+
+
+ )} {appearanceOpen && ( setEditorMenu(null)} diff --git a/src/components/EditorContextMenu.tsx b/src/components/EditorContextMenu.tsx index 63ec866..d64564d 100644 --- a/src/components/EditorContextMenu.tsx +++ b/src/components/EditorContextMenu.tsx @@ -1,17 +1,20 @@ import { useRef } from 'react' import { useDismiss } from '../hooks/useDismiss' import { clampMenuPosition } from '../utils/menuPos' +import { isLocalImageSrc } from '../services/s3' export type EditorMenuAction = | 'cut' | 'copy' | 'paste' | 'selectAll' | 'bold' | 'italic' | 'strikethrough' | 'inlineCode' | 'link' - | 'toggleSource' + | 'toggleSource' | 'uploadS3' interface Props { x: number y: number /** 当前是否有选区(决定剪切/复制是否可用) */ hasSelection: boolean + /** 右键命中的图片节点 src(非图片为 null);本地未上传图时多一项「上传到 S3」 */ + imageSrc: string | null sourceMode: boolean onAction: (action: EditorMenuAction) => void onClose: () => void @@ -22,12 +25,13 @@ interface Props { * 菜单同一套实现。容器 mousedown 阻止默认行为,让焦点与选区留在编辑器里, * execCommand 类命令才作用得准。 */ -export default function EditorContextMenu({ x, y, hasSelection, sourceMode, onAction, onClose }: Props) { +export default function EditorContextMenu({ x, y, hasSelection, imageSrc, sourceMode, onAction, onClose }: Props) { const ref = useRef(null) useDismiss(ref, true, onClose) const act = (a: EditorMenuAction) => { onClose(); onAction(a) } - // 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式) - const { left, top } = clampMenuPosition(x, y, 188, sourceMode ? 236 : 388) + const showUpload = Boolean(imageSrc && isLocalImageSrc(imageSrc)) + // 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式与图片项) + const { left, top } = clampMenuPosition(x, y, 188, (sourceMode ? 236 : 388) + (showUpload ? 40 : 0)) return (
act('link')}>超链接… )} + {showUpload && ( + <> +
+ + + )}
diff --git a/src/components/EditorPane.tsx b/src/components/EditorPane.tsx index 3bc0cbd..172f14a 100644 --- a/src/components/EditorPane.tsx +++ b/src/components/EditorPane.tsx @@ -3,7 +3,7 @@ import TableToolbar from './TableToolbar' import type { Editor } from '@milkdown/kit/core' import { editorViewCtx } from '@milkdown/kit/core' import { TextSelection } from '@milkdown/kit/prose/state' -import { createMarkdownEditor, getMarkdown, type CursorInfo } from '../editor/editor' +import { createMarkdownEditor, getMarkdown, collectImageSrcs, replaceImageSrc as replaceImageSrcInEditor, imageSrcAtElement, type CursorInfo } from '../editor/editor' import { findBlockIndex, type CursorAnchor } from '../editor/cursorSync' import { buildTextIndex, findInIndex, type FindOptions, type MatchRange } from '../editor/search' import { setFocusModeEnabled, refreshFocusDecorations } from '../editor/plugins' @@ -19,6 +19,12 @@ export interface EditorPaneApi { replaceCurrent: (replacement: string) => boolean replaceAll: (replacement: string) => number clearFind: () => void + /** 文档里全部图片 src(去重)——S3 上传扫描用 */ + listImages: () => string[] + /** 把 src 为 oldSrc 的图片节点全部换成 newSrc,返回替换数 */ + replaceImageSrc: (oldSrc: string, newSrc: string) => number + /** 某 DOM 元素对应的图片节点 src(右键命中图片判定) */ + imageSrcForElement: (el: HTMLElement) => string | null } interface Props { @@ -48,6 +54,9 @@ const EMPTY_API: EditorPaneApi = { replaceCurrent: () => false, replaceAll: () => 0, clearFind: () => undefined, + listImages: () => [], + replaceImageSrc: () => 0, + imageSrcForElement: () => null, } /** 单标签编辑器宿主:挂载时以标签内容建模,卸载前回写最终序列化文本,保证切标签不丢内容。 */ @@ -231,6 +240,13 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal const clearFind = () => { findRef.current = { matches: [], current: -1, query: '', opts: null } } + const listImages = (): string[] => { + try { return collectImageSrcs(editor) } catch { return [] } + } + const replaceImageSrc = (oldSrc: string, newSrc: string): number => { + try { return replaceImageSrcInEditor(editor, oldSrc, newSrc) } catch { return 0 } + } + const imageSrcForElement = (el: HTMLElement): string | null => imageSrcAtElement(editor, el) editor.create() .then(() => { // 已卸载(关标签/保活淘汰/epoch 重挂)时不再注册 API:cleanup 已 @@ -241,7 +257,7 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal try { editor.action((ctx) => refreshFocusDecorations(ctx.get(editorViewCtx) as never)) } catch { /* 忽略 */ } - callbacksRef.current.onReady({ editor, jumpToHeading, getAnchor, setCursorByAnchor, findAll, gotoMatch, replaceCurrent, replaceAll, clearFind }) + callbacksRef.current.onReady({ editor, jumpToHeading, getAnchor, setCursorByAnchor, findAll, gotoMatch, replaceCurrent, replaceAll, clearFind, listImages, replaceImageSrc, imageSrcForElement }) if (chunkLoad) { setLoadProgress({ done: 1, total: chunkLoad.totalChunks }) void chunkLoad.appendRest( diff --git a/src/components/S3SettingsModal.tsx b/src/components/S3SettingsModal.tsx new file mode 100644 index 0000000..ba063e9 --- /dev/null +++ b/src/components/S3SettingsModal.tsx @@ -0,0 +1,132 @@ +// S3 图片上传设置(docs/30):服务端点/区域/存储桶/密钥/前缀/访问域名, +// 以及「自动上传图片」开关。密钥只保存在本机偏好库里,随调用传给 Rust +// 签名上传,不写进任何文档、也不会被 Git 同步带走。 +import { useEffect, useState } from 'react' +import { s3TestConnection } from '../services/platform' +import { s3ConfigPayload, type S3Settings } from '../services/s3' + +export function S3SettingsModal({ initial, onSave, onClose }: { + initial: S3Settings + onSave: (next: S3Settings) => void + onClose: () => void +}) { + const [draft, setDraft] = useState(initial) + const [testState, setTestState] = useState<{ kind: 'idle' | 'busy' | 'ok' | 'err'; text: string }>({ kind: 'idle', text: '' }) + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [onClose]) + + const set = (patch: Partial) => { + setDraft((d) => ({ ...d, ...patch })) + setTestState({ kind: 'idle', text: '' }) + } + + const test = async () => { + setTestState({ kind: 'busy', text: '正在连接…' }) + try { + const msg = await s3TestConnection(s3ConfigPayload(draft)) + setTestState({ kind: 'ok', text: msg }) + } catch (e) { + setTestState({ kind: 'err', text: e instanceof Error ? e.message : String(e) }) + } + } + + return ( +
+
e.stopPropagation()}> +

S3 图片上传设置

+

+ 图片上传到你自己的 S3 兼容存储(AWS S3、MinIO、Cloudflare R2、阿里云 OSS 等), + 文档里的图片引用随之换成公开访问地址。密钥只保存在本机设置里,不会写进文档。 +

+ + + + + + + +
+ +
+ {testState.kind !== 'idle' && ( +

+ {testState.text} +

+ )} +
+ + + +
+
+
+ ) +} diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index d29398a..dda6dd6 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -11,6 +11,9 @@ interface Props { sourceMode: boolean tauriMode: boolean zoom: number + /** 当前文档未上传的本地图片数(>0 时状态栏出批量上传钮) */ + pendingImages?: number + onUploadImages?: () => void } export default function StatusBar(p: Props) { @@ -25,6 +28,11 @@ export default function StatusBar(p: Props) { {p.dirty ? '未保存' : '已保存'} {p.sourceMode ? '源代码模式' : '即时渲染'} + {Boolean(p.pendingImages && p.onUploadImages) && ( + + )} {Math.round(p.zoom * 100)}% {p.tauriMode ? '桌面模式' : 'Web 降级模式(调试)'} diff --git a/src/editor/editor.ts b/src/editor/editor.ts index 4361d9c..666153b 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -165,6 +165,62 @@ export function getMarkdown(editor: Editor): string { }) } +/** 文档里全部图片的 src(按出现顺序、去重)——S3 批量上传扫描用(docs/30)。 */ +export function collectImageSrcs(editor: Editor): string[] { + return editor.action((ctx) => { + const view = ctx.get(editorViewCtx) + const seen = new Set() + const out: string[] = [] + view.state.doc.descendants((node) => { + if (node.type.name === 'image') { + const src = String(node.attrs.src ?? '') + if (src && !seen.has(src)) { seen.add(src); out.push(src) } + } + return true + }) + return out + }) +} + +/** + * 把文档里所有 src === oldSrc 的图片节点换成 newSrc,返回替换数。 + * 一次遍历收集位置、同一事务内 setNodeMarkup:image 是叶子节点, + * 改属性不改变文档尺寸,位置不会失效。 + */ +export function replaceImageSrc(editor: Editor, oldSrc: string, newSrc: string): number { + return editor.action((ctx) => { + const view = ctx.get(editorViewCtx) + const positions: number[] = [] + view.state.doc.descendants((node, pos) => { + if (node.type.name === 'image' && String(node.attrs.src ?? '') === oldSrc) positions.push(pos) + return true + }) + if (positions.length === 0) return 0 + const tr = view.state.tr + for (const pos of positions) { + const node = view.state.doc.nodeAt(pos) + if (node) tr.setNodeMarkup(pos, undefined, { ...node.attrs, src: newSrc }) + } + view.dispatch(tr) + return positions.length + }) +} + +/** 右键命中的 元素对应的图片节点 src(非图片返回 null)——右键菜单判「本地未上传图」用。 */ +export function imageSrcAtElement(editor: Editor, el: HTMLElement): string | null { + try { + return editor.action((ctx) => { + const view = ctx.get(editorViewCtx) + const pos = view.posAtDOM(el, 0) + const node = view.state.doc.nodeAt(pos) + if (!node || node.type.name !== 'image') return null + return String(node.attrs.src ?? '') || null + }) + } catch { + return null + } +} + export function callUndo(editor: Editor) { editor.action((ctx) => ctx.get(commandsCtx).call(undoCommand.key)) } diff --git a/src/services/platform.ts b/src/services/platform.ts index 1dbc13d..b4afce3 100644 --- a/src/services/platform.ts +++ b/src/services/platform.ts @@ -348,3 +348,25 @@ export async function gitSetRemote(root: string, remoteUrl: string): Promise { return invoke('git_sync_now', { root, message }) } + +// ——— S3 图片上传(docs/30):配置随调用传入,Rust 侧签名 + PUT,不保存密钥 ——— + +export interface S3ConfigPayload { + endpoint: string + region: string + bucket: string + accessKey: string + secretKey: string + prefix: string + publicBase: string +} + +/** 上传一个本地图片文件,返回应回填文档的公开访问地址。 */ +export async function s3UploadImage(config: S3ConfigPayload, filePath: string, fileName: string): Promise { + return invoke('s3_upload_image', { config, filePath, fileName }) +} + +/** 对存储桶发签名 HEAD 验连通与密钥,成功返回一句可读结论。 */ +export async function s3TestConnection(config: S3ConfigPayload): Promise { + return invoke('s3_test_connection', { config }) +} diff --git a/src/services/s3.ts b/src/services/s3.ts new file mode 100644 index 0000000..1e03c91 --- /dev/null +++ b/src/services/s3.ts @@ -0,0 +1,76 @@ +// S3 图片上传的前端侧(docs/30):设置形状、完整性判定、本地图片识别与 +// 路径拼接。真正的签名与 PUT 在 Rust(s3.rs),密钥只随调用传入、不落 Rust 盘。 +import type { S3ConfigPayload } from './platform' + +export interface S3Settings extends S3ConfigPayload { + /** 粘贴/插入图片落盘后自动上传并把引用换成 S3 地址 */ + autoUpload: boolean +} + +export const DEFAULT_S3_SETTINGS: S3Settings = { + endpoint: '', + region: '', + bucket: '', + accessKey: '', + secretKey: '', + prefix: '', + publicBase: '', + autoUpload: false, +} + +/** 四项必填齐了才算配置过(endpoint/前缀/访问域名都是可选)。 */ +export function isS3Configured(s: S3Settings): boolean { + return Boolean(s.bucket.trim() && s.region.trim() && s.accessKey.trim() && s.secretKey.trim()) +} + +/** 本地图片 = 文档里的相对/本地引用;http(s)/data/blob 已经是网络或内嵌资源,不需要上传。 */ +export function isLocalImageSrc(src: string): boolean { + const t = src.trim() + if (!t) return false + return !/^(https?:|data:|blob:)/i.test(t) +} + +/** 从 UiState.s3 里摘出传给 Rust 的部分(不含 autoUpload)。 */ +export function s3ConfigPayload(s: S3Settings): S3ConfigPayload { + return { + endpoint: s.endpoint, + region: s.region, + bucket: s.bucket, + accessKey: s.accessKey, + secretKey: s.secretKey, + prefix: s.prefix, + publicBase: s.publicBase, + } +} + +/** + * 把文档相对路径的图片 src 拼成绝对文件路径(供 Rust 读文件)。 + * 文本级归一:统一 / 分隔、处理 . 与 ..(图片一般在 <文档名>.assets/ 下, + * 手写文档也可能用 ../ 引用)。Windows 盘符与正斜杠都兼容。 + */ +export function resolveDocRelativePath(docPath: string, relSrc: string): string { + const decoded = safeDecode(relSrc).replace(/\\/g, '/') + if (/^[a-zA-Z]:\//.test(decoded) || decoded.startsWith('/')) return decoded + const dir = docPath.replace(/\\/g, '/').replace(/\/[^/]*$/, '') + const parts: string[] = [] + for (const seg of `${dir}/${decoded}`.split('/')) { + if (seg === '' || seg === '.') { + if (seg === '' && parts.length === 0) parts.push('') + continue + } + if (seg === '..') { parts.pop(); continue } + parts.push(seg) + } + return parts.join('/') || '/' +} + +function safeDecode(s: string): string { + try { return decodeURIComponent(s) } catch { return s } +} + +/** src 的文件名部分(对象键用它;保留原名便于在桶里辨认)。 */ +export function imageFileName(src: string): string { + const clean = safeDecode(src).replace(/\\/g, '/') + const name = clean.split('/').pop() ?? '' + return name || 'image.png' +} diff --git a/src/styles.css b/src/styles.css index 13bcc57..24dc7c2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -97,6 +97,8 @@ button { font: inherit; color: inherit; } .sidebar-tabs .sidebar-collapse:hover { color: var(--text); background: var(--hover); } .sidebar-expand { position: absolute; left: 10px; top: 10px; z-index: 35; width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; background: var(--bg-panel); border: 1px solid var(--border); border-radius: 7px; color: var(--text-dim); cursor: pointer; } .sidebar-expand:hover { color: var(--text); border-color: var(--accent); } +.status-upload-btn { background: transparent; border: 1px solid var(--border); color: var(--text-dim); border-radius: 999px; padding: 1px 9px; font-size: 11.5px; font-family: inherit; cursor: pointer; } +.status-upload-btn:hover { color: var(--accent); border-color: var(--accent); } .source-toggle { position: absolute; right: 14px; bottom: 14px; z-index: 35; background: var(--bg-panel); color: var(--text-dim); border: 1px solid var(--border); border-radius: 999px; padding: 5px 13px; font-size: 12.5px; cursor: pointer; box-shadow: 0 4px 14px rgba(15, 23, 42, 0.10); } .source-toggle:hover { color: var(--text); border-color: var(--accent); } .tree-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; } diff --git a/tests/editor-context-menu.test.tsx b/tests/editor-context-menu.test.tsx index 7441b8d..f665c8e 100644 --- a/tests/editor-context-menu.test.tsx +++ b/tests/editor-context-menu.test.tsx @@ -13,7 +13,7 @@ async function renderMenu(props: Record = {}) { await act(async () => { root.render( actions.push(a)} onClose={() => { closed += 1 }} {...props} @@ -38,6 +38,20 @@ describe('编辑区右键菜单', () => { container.remove() }) + it('命中本地图片时多一项「上传到 S3」;网络图片不出现', async () => { + const local = await renderMenu({ imageSrc: 'doc.assets/a.png' }) + const up = btn(local.container, '上传到 S3') + expect(up).toBeTruthy() + await act(async () => { up?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) + expect(local.actions).toEqual(['uploadS3']) + await act(async () => { local.root.unmount() }) + local.container.remove() + const remote = await renderMenu({ imageSrc: 'https://cdn.example.com/a.png' }) + expect(btn(remote.container, '上传到 S3')).toBeUndefined() + await act(async () => { remote.root.unmount() }) + remote.container.remove() + }) + it('源码模式只出编辑命令与「即时渲染」', async () => { const { container, root } = await renderMenu({ sourceMode: true, hasSelection: true }) expect(btn(container, '加粗')).toBeUndefined() diff --git a/tests/editor-images.test.ts b/tests/editor-images.test.ts new file mode 100644 index 0000000..067827f --- /dev/null +++ b/tests/editor-images.test.ts @@ -0,0 +1,53 @@ +// 编辑器图片节点操作(docs/30):收集 src、按旧 src 回换、按 DOM 元素反查 src。 +import { describe, it, expect } from 'vitest' +import { createMarkdownEditor, getMarkdown, collectImageSrcs, replaceImageSrc, imageSrcAtElement } from '../src/editor/editor' + +const DOC = [ + '# 图', + '', + '![本地](doc.assets/a.png)', + '', + '![网络](https://cdn.example.com/b.png)', + '', + '![重复](doc.assets/a.png)', + '', +].join('\n') + +async function makeEditor() { + const root = document.createElement('div') + document.body.appendChild(root) + const editor = createMarkdownEditor(root, DOC) + await editor.create() + return { root, editor } +} + +describe('editor image ops', () => { + it('收集全部图片 src 并去重', async () => { + const { root, editor } = await makeEditor() + expect(collectImageSrcs(editor)).toEqual(['doc.assets/a.png', 'https://cdn.example.com/b.png']) + await editor.destroy() + root.remove() + }) + + it('按旧 src 回换全部同名图片节点(含重复引用)', async () => { + const { root, editor } = await makeEditor() + const n = replaceImageSrc(editor, 'doc.assets/a.png', 'https://s3.example.com/a.png') + expect(n).toBe(2) + const md = getMarkdown(editor) + expect(md).toContain('https://s3.example.com/a.png') + expect(md).not.toContain('doc.assets/a.png') + expect(collectImageSrcs(editor)).toEqual(['https://s3.example.com/a.png', 'https://cdn.example.com/b.png']) + await editor.destroy() + root.remove() + }) + + it('按 元素反查节点 src;非图片元素返回 null', async () => { + const { root, editor } = await makeEditor() + const img = root.querySelector('img') + expect(img).not.toBeNull() + expect(imageSrcAtElement(editor, img as HTMLElement)).toBe('doc.assets/a.png') + expect(imageSrcAtElement(editor, root)).toBeNull() + await editor.destroy() + root.remove() + }) +}) diff --git a/tests/s3-modal.test.tsx b/tests/s3-modal.test.tsx new file mode 100644 index 0000000..71e3cb1 --- /dev/null +++ b/tests/s3-modal.test.tsx @@ -0,0 +1,49 @@ +// S3 设置弹窗(docs/30):字段回填、编辑后保存的载荷、自动上传开关。 +import { describe, it, expect, vi } from 'vitest' +import { createRoot } from 'react-dom/client' +import { act } from 'react-dom/test-utils' +import { S3SettingsModal } from '../src/components/S3SettingsModal' +import { DEFAULT_S3_SETTINGS } from '../src/services/s3' + +function setInput(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set + setter?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) +} + +describe('S3SettingsModal', () => { + it('填写桶/区域/密钥与自动上传开关后,保存回传完整设置', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const onSave = vi.fn() + await act(async () => { + root.render( undefined} />) + }) + const inputs = [...container.querySelectorAll('input')] + const byPlaceholder = (ph: string) => inputs.find((i) => i.placeholder === ph) as HTMLInputElement + await act(async () => { + setInput(byPlaceholder('us-east-1'), 'ap-northeast-1') + setInput(byPlaceholder('my-images'), 'my-images') + setInput(byPlaceholder('notes/'), 'blog/') + }) + const textInputs = inputs.filter((i) => i.type === 'text' || i.type === 'password') + // Access Key / Secret Key 是无 placeholder 的两个输入(第 4、5 个字段) + await act(async () => { + setInput(textInputs[3], 'AKID') + setInput(textInputs[4], 'SECRET') + }) + const checkbox = inputs.find((i) => i.type === 'checkbox') as HTMLInputElement + await act(async () => { checkbox.click() }) + const saveBtn = [...container.querySelectorAll('button')].find((b) => b.textContent === '保存') as HTMLButtonElement + await act(async () => { saveBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) + expect(onSave).toHaveBeenCalledTimes(1) + const saved = onSave.mock.calls[0][0] + expect(saved).toMatchObject({ + region: 'ap-northeast-1', bucket: 'my-images', accessKey: 'AKID', secretKey: 'SECRET', + prefix: 'blog/', autoUpload: true, + }) + await act(async () => { root.unmount() }) + container.remove() + }) +}) diff --git a/tests/s3.test.ts b/tests/s3.test.ts new file mode 100644 index 0000000..2089419 --- /dev/null +++ b/tests/s3.test.ts @@ -0,0 +1,41 @@ +// S3 前端逻辑(docs/30):本地图片识别、配置完整性、相对路径拼绝对路径、对象文件名。 +import { describe, it, expect } from 'vitest' +import { isLocalImageSrc, isS3Configured, resolveDocRelativePath, imageFileName, DEFAULT_S3_SETTINGS } from '../src/services/s3' + +describe('isLocalImageSrc', () => { + it('相对路径算本地;http/data/blob 不算', () => { + expect(isLocalImageSrc('doc.assets/a.png')).toBe(true) + expect(isLocalImageSrc('../shared/a.png')).toBe(true) + expect(isLocalImageSrc('https://cdn.example.com/a.png')).toBe(false) + expect(isLocalImageSrc('http://x/a.png')).toBe(false) + expect(isLocalImageSrc('data:image/png;base64,xx')).toBe(false) + expect(isLocalImageSrc('blob:abc')).toBe(false) + expect(isLocalImageSrc('')).toBe(false) + }) +}) + +describe('isS3Configured', () => { + it('四项必填齐才算配置过', () => { + expect(isS3Configured(DEFAULT_S3_SETTINGS)).toBe(false) + expect(isS3Configured({ ...DEFAULT_S3_SETTINGS, bucket: 'b', region: 'r', accessKey: 'a', secretKey: 's' })).toBe(true) + expect(isS3Configured({ ...DEFAULT_S3_SETTINGS, bucket: 'b', region: 'r', accessKey: 'a' })).toBe(false) + }) +}) + +describe('resolveDocRelativePath', () => { + it('文档同目录 .assets 拼接(含 Windows 路径与 ..)', () => { + expect(resolveDocRelativePath('D:/notes/todo.md', 'todo.assets/a.png')).toBe('D:/notes/todo.assets/a.png') + expect(resolveDocRelativePath('D:\\notes\\todo.md', 'todo.assets\\a.png')).toBe('D:/notes/todo.assets/a.png') + expect(resolveDocRelativePath('/home/u/notes/todo.md', '../shared/a.png')).toBe('/home/u/shared/a.png') + expect(resolveDocRelativePath('/home/u/notes/todo.md', 'a%20b.png')).toBe('/home/u/notes/a b.png') + expect(resolveDocRelativePath('/home/u/notes/todo.md', 'C:/abs/a.png')).toBe('C:/abs/a.png') + }) +}) + +describe('imageFileName', () => { + it('取文件名(含解码与反斜杠)', () => { + expect(imageFileName('doc.assets/图 1.png')).toBe('图 1.png') + expect(imageFileName('a%20b.png')).toBe('a b.png') + expect(imageFileName('x\\y\\z.jpg')).toBe('z.jpg') + }) +})