1- //! 笔记图片命令(v0.10.1)——显示解析 + 本地导入。
1+ //! 笔记图片命令(v0.10.1)——显示解析 + 本地导入;v0.15 扩展剪贴板/外链落盘 。
22//!
33//! @ai-context: 笔记 Markdown 内嵌"data_dir 相对引用"(session-images/{sid}/...、
44//! notes-images/{nid}/...、产物裸 full/thumbs/...)。WebView 无法
55//! 直接读本地文件,resolve 在服务端校验后返回可 convert 的绝对路径;
66//! 本地图片导入必须复制进 data_dir(assetProtocol scope=$APPDATA/**)。
7+ //! @ai-context: v0.15 图片落盘三入口(REQ:用户插入的图片必须留副本)——① 文件
8+ //! 选择(历史行为,扩展名白名单);② 剪贴板 base64(import_note_image_b64,
9+ //! 字节嗅探定格式);③ 外链 URL(import_note_image_url,ureq 下载限流)。
10+ //! 三者共用 save_image_bytes(大小上限 + 唯一命名 + 落盘),删除笔记时
11+ //! 顺带清理 notes-images/{nid}/ 防孤立残留。
712
13+ use std:: io:: Read ;
814use std:: path:: { Path , PathBuf } ;
15+ use std:: time:: Duration ;
916
17+ use base64:: engine:: general_purpose:: STANDARD as BASE64 ;
18+ use base64:: Engine as _;
1019use tauri:: State ;
1120
1221use crate :: commands:: AppState ;
@@ -16,6 +25,10 @@ use crate::types::Note;
1625const IMAGE_EXT_WHITELIST : & [ & str ] = & [ "png" , "jpg" , "jpeg" , "webp" , "gif" ] ;
1726/// 导入大小上限(10MB——关键帧/截图场景足够)。
1827const IMAGE_MAX_BYTES : u64 = 10 * 1024 * 1024 ;
28+ /// 外链下载超时(秒)。
29+ const URL_FETCH_TIMEOUT_SECS : u64 = 10 ;
30+ /// 外链下载跟随重定向上限。
31+ const URL_FETCH_MAX_REDIRECTS : u32 = 3 ;
1932
2033/// 解析笔记图片引用 → 可 convert 的本地绝对路径(纯函数,可单测)。
2134///
@@ -90,7 +103,82 @@ pub async fn import_note_image(
90103 import_image_file ( & source_path, & state. data_dir , note_id, IMAGE_MAX_BYTES )
91104}
92105
93- /// 复制图片进笔记图片目录并返回相对引用(纯逻辑,命令层复用——可单测)。
106+ /// 剪贴板图片导入笔记(v0.15:base64 IPC——同 capture_fragment 先例)。
107+ ///
108+ /// @ai-context: 编辑态粘贴图片入口:前端把 ClipboardEvent 的 image blob 转 base64
109+ /// 上送,本命令字节嗅探定格式(不信任 MIME——浏览器可能给错 type),
110+ /// 解码验证 + 大小上限后落盘。失败即 Err——前端提示不落脏内容。
111+ #[ tauri:: command]
112+ pub async fn import_note_image_b64 (
113+ state : State < ' _ , AppState > ,
114+ note_id : i64 ,
115+ image_b64 : String ,
116+ ) -> Result < String , String > {
117+ if note_id <= 0 {
118+ return Err ( "无效的笔记 id" . to_string ( ) ) ;
119+ }
120+ if state. db . get_note ( note_id) . map_err ( |e| e. to_string ( ) ) ?. is_none ( ) {
121+ return Err ( "笔记不存在" . to_string ( ) ) ;
122+ }
123+ let bytes = BASE64
124+ . decode ( image_b64. trim ( ) )
125+ . map_err ( |_| "图片数据解码失败(非法 base64)" . to_string ( ) ) ?;
126+ import_image_bytes ( & bytes, & state. data_dir , note_id, IMAGE_MAX_BYTES )
127+ }
128+
129+ /// 外链图片下载导入笔记(v0.15:🌐 链接图自动复制——防源站删除导致资源丢失)。
130+ ///
131+ /// @ai-context: 下载经 ureq(TLS 全栈已有依赖),超时 + 限流 10MB + 跟随重定向;
132+ /// 内容类型双保险(Content-Type 前缀 image/ 或字节嗅探命中白名单)。
133+ /// 失败返回 Err——前端降级插入原 URL 并提示(能力降级不失效)。
134+ #[ tauri:: command]
135+ pub async fn import_note_image_url (
136+ state : State < ' _ , AppState > ,
137+ note_id : i64 ,
138+ url : String ,
139+ ) -> Result < String , String > {
140+ if note_id <= 0 {
141+ return Err ( "无效的笔记 id" . to_string ( ) ) ;
142+ }
143+ if state. db . get_note ( note_id) . map_err ( |e| e. to_string ( ) ) ?. is_none ( ) {
144+ return Err ( "笔记不存在" . to_string ( ) ) ;
145+ }
146+ let bytes = download_image_limited ( & url, IMAGE_MAX_BYTES ) ?;
147+ import_image_bytes ( & bytes, & state. data_dir , note_id, IMAGE_MAX_BYTES )
148+ }
149+
150+ /// 下载并限流读取图片字节(纯逻辑:非 http/https 直接拒绝——可单测)。
151+ ///
152+ /// @ai-context: ureq 2 的 read_to_string 无长度上限(恶意站点可无限流),
153+ /// 经 into_reader().take(max+1) 截断读取——超限即 Err 不落盘。
154+ fn download_image_limited ( url : & str , max_bytes : u64 ) -> Result < Vec < u8 > , String > {
155+ if !( url. starts_with ( "http://" ) || url. starts_with ( "https://" ) ) {
156+ return Err ( "仅支持 http/https 链接" . to_string ( ) ) ;
157+ }
158+ let resp = ureq:: AgentBuilder :: new ( )
159+ . timeout ( Duration :: from_secs ( URL_FETCH_TIMEOUT_SECS ) )
160+ . redirects ( URL_FETCH_MAX_REDIRECTS )
161+ . build ( )
162+ . get ( url)
163+ . call ( )
164+ . map_err ( |e| format ! ( "图片下载失败: {e}" ) ) ?;
165+ if !resp. content_type ( ) . starts_with ( "image/" ) {
166+ return Err ( format ! ( "目标不是图片资源(Content-Type: {})" , resp. content_type( ) ) ) ;
167+ }
168+ let mut reader = resp. into_reader ( ) ;
169+ let mut buf = Vec :: with_capacity ( 64 * 1024 ) ;
170+ reader
171+ . by_ref ( )
172+ . take ( max_bytes + 1 )
173+ . read_to_end ( & mut buf)
174+ . map_err ( |e| format ! ( "图片读取失败: {e}" ) ) ?;
175+ if buf. len ( ) as u64 > max_bytes {
176+ return Err ( format ! ( "图片超过大小上限({} 字节)" , max_bytes) ) ;
177+ }
178+ Ok ( buf)
179+ }
180+
181+ /// 复制图片进笔记图片目录并返回相对引用(文件入口;扩展名白名单——v0.10.1 契约)。
94182fn import_image_file (
95183 source_path : & str ,
96184 data_dir : & Path ,
@@ -109,9 +197,43 @@ fn import_image_file(
109197 if !IMAGE_EXT_WHITELIST . contains ( & ext. as_str ( ) ) {
110198 return Err ( format ! ( "不支持的图片格式: {}(支持 png/jpg/jpeg/webp/gif)" , ext) ) ;
111199 }
112- let len = std:: fs:: metadata ( src) . map_err ( |e| e. to_string ( ) ) ?. len ( ) ;
113- if len > max_bytes {
114- return Err ( format ! ( "图片超过大小上限({} 字节)" , len) ) ;
200+ let bytes = std:: fs:: read ( src) . map_err ( |e| e. to_string ( ) ) ?;
201+ write_image_bytes ( data_dir, note_id, & bytes, & ext, max_bytes)
202+ }
203+
204+ /// 字节入口(剪贴板/外链共用):字节嗅探定格式 + 校验 + 落盘。
205+ ///
206+ /// @ai-context: 与文件入口不同——机器来源的数据不信任扩展名/MIME,用
207+ /// image::guess_format 嗅探魔数(不完整解码,快且拒垃圾数据)。
208+ fn import_image_bytes (
209+ bytes : & [ u8 ] ,
210+ data_dir : & Path ,
211+ note_id : i64 ,
212+ max_bytes : u64 ,
213+ ) -> Result < String , String > {
214+ let ext = match image:: guess_format ( bytes) {
215+ Ok ( fmt) => match fmt {
216+ image:: ImageFormat :: Png => "png" ,
217+ image:: ImageFormat :: Jpeg => "jpg" ,
218+ image:: ImageFormat :: Gif => "gif" ,
219+ image:: ImageFormat :: WebP => "webp" ,
220+ _ => return Err ( "不支持的图片格式(支持 png/jpg/jpeg/webp/gif)" . to_string ( ) ) ,
221+ } ,
222+ Err ( _) => return Err ( "图片数据损坏或不是图片(嗅探失败)" . to_string ( ) ) ,
223+ } ;
224+ write_image_bytes ( data_dir, note_id, bytes, ext, max_bytes)
225+ }
226+
227+ /// 落盘核心(纯逻辑,可单测):大小上限 → 建目录 → 唯一命名 → 写文件。
228+ fn write_image_bytes (
229+ data_dir : & Path ,
230+ note_id : i64 ,
231+ bytes : & [ u8 ] ,
232+ ext : & str ,
233+ max_bytes : u64 ,
234+ ) -> Result < String , String > {
235+ if bytes. len ( ) as u64 > max_bytes {
236+ return Err ( format ! ( "图片超过大小上限({} 字节)" , max_bytes) ) ;
115237 }
116238 let dir = data_dir. join ( "notes-images" ) . join ( note_id. to_string ( ) ) ;
117239 std:: fs:: create_dir_all ( & dir) . map_err ( |e| e. to_string ( ) ) ?;
@@ -122,7 +244,7 @@ fn import_image_file(
122244 seq += 1 ;
123245 name = format ! ( "{}_{}.{}" , ts, seq, ext) ;
124246 }
125- std:: fs:: copy ( src , dir. join ( & name) ) . map_err ( |e| e. to_string ( ) ) ?;
247+ std:: fs:: write ( dir. join ( & name) , bytes ) . map_err ( |e| e. to_string ( ) ) ?;
126248 Ok ( format ! ( "notes-images/{}/{}" , note_id, name) )
127249}
128250
0 commit comments