Skip to content

Commit 7b769ef

Browse files
committed
feat(notes): 图片落盘三入口与删除清理(v0.15 ④)
1 parent 8789edb commit 7b769ef

4 files changed

Lines changed: 199 additions & 9 deletions

File tree

‎app/src-tauri/src/commands.rs‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -434,13 +434,24 @@ pub async fn update_note(
434434
Ok(true)
435435
}
436436

437-
/// 删除笔记(REQ-004)。
437+
/// 删除笔记(REQ-004;v0.15 顺带清理笔记图片目录——防孤立残留)。
438438
#[tauri::command]
439439
pub async fn delete_note(state: State<'_, AppState>, id: i64) -> Result<bool, String> {
440440
if id <= 0 {
441441
return Err("无效的笔记 id".to_string());
442442
}
443-
state.db.delete_note(id).map_err(|e| e.to_string())
443+
let deleted = state.db.delete_note(id).map_err(|e| e.to_string())?;
444+
if deleted {
445+
// v0.15:notes-images/{nid}/ 只属于该笔记——删除笔记即清空(尽力而为:
446+
// 失败不阻断(用户重试删除无意义时也允许残留,垃圾回收后续单独任务)
447+
let img_dir = state.data_dir.join("notes-images").join(id.to_string());
448+
if let Err(e) = std::fs::remove_dir_all(&img_dir) {
449+
if e.kind() != std::io::ErrorKind::NotFound {
450+
eprintln!("[notes] 清理笔记图片目录失败({img_dir:?}): {e}");
451+
}
452+
}
453+
}
454+
Ok(deleted)
444455
}
445456

446457
/// 搜索笔记(REQ-004;关键词截断——TD-005;v0.10.0 支持按标签过滤)。

‎app/src-tauri/src/commands_note_images.rs‎

Lines changed: 128 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
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;
814
use std::path::{Path, PathBuf};
15+
use std::time::Duration;
916

17+
use base64::engine::general_purpose::STANDARD as BASE64;
18+
use base64::Engine as _;
1019
use tauri::State;
1120

1221
use crate::commands::AppState;
@@ -16,6 +25,10 @@ use crate::types::Note;
1625
const IMAGE_EXT_WHITELIST: &[&str] = &["png", "jpg", "jpeg", "webp", "gif"];
1726
/// 导入大小上限(10MB——关键帧/截图场景足够)。
1827
const 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 契约)。
94182
fn 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

‎app/src-tauri/src/commands_note_images_tests.rs‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,20 @@
22
33
use std::fs;
44

5-
use crate::commands_note_images::{import_image_file, resolve_note_image_path};
5+
use base64::engine::general_purpose::STANDARD as BASE64;
6+
use base64::Engine as _;
7+
8+
use crate::commands_note_images::{
9+
download_image_limited, import_image_bytes, import_image_file, resolve_note_image_path,
10+
write_image_bytes,
11+
};
612
use crate::db::Db;
713
use crate::types::{NewNote, Note};
814

15+
/// 1×1 透明 PNG(合法魔数 + 数据——字节嗅探用例用)。
16+
const TINY_PNG_B64: &str =
17+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
18+
919
/// 构造带指定来源会话的笔记(内存库;会话外键需先存在)。
1020
fn make_note(session_id: Option<i64>) -> Note {
1121
let db = Db::open(":memory:").expect("in-memory db");
@@ -131,3 +141,47 @@ fn import_rejects_bad_source() {
131141
fs::write(&big, vec![0u8; 200]).unwrap();
132142
assert!(import_image_file(big.to_str().unwrap(), dir.path(), 9, 100).is_err());
133143
}
144+
145+
#[test]
146+
fn import_bytes_sniffs_png_and_writes() {
147+
// Arrange:合法 1×1 PNG 字节(模拟剪贴板/下载来源)
148+
let png = BASE64.decode(TINY_PNG_B64).unwrap();
149+
let dir = tempfile::tempdir().unwrap();
150+
// Act
151+
let rel = import_image_bytes(&png, dir.path(), 9, 1024 * 1024).unwrap();
152+
// Assert:嗅探定格式 → .png 落盘且字节一致
153+
assert!(rel.starts_with("notes-images/9/") && rel.ends_with(".png"));
154+
let abs = dir.path().join(rel.replace('/', std::path::MAIN_SEPARATOR_STR));
155+
assert_eq!(fs::read(&abs).unwrap(), png);
156+
}
157+
158+
#[test]
159+
fn import_bytes_rejects_garbage_and_oversize() {
160+
// Arrange
161+
let dir = tempfile::tempdir().unwrap();
162+
let png = BASE64.decode(TINY_PNG_B64).unwrap();
163+
// Act & Assert:垃圾数据嗅探失败;合法图超过 max_bytes 拒绝
164+
assert!(import_image_bytes(b"fake-png-bytes", dir.path(), 9, 1024).is_err());
165+
assert!(import_image_bytes(&png, dir.path(), 9, 10).is_err());
166+
}
167+
168+
#[test]
169+
fn write_bytes_unique_name_on_conflict() {
170+
// Arrange
171+
let dir = tempfile::tempdir().unwrap();
172+
// Act:同秒重名 → 序列后缀(文件入口同契约)
173+
let rel1 = write_image_bytes(dir.path(), 9, b"a", "png", 1024).unwrap();
174+
let rel2 = write_image_bytes(dir.path(), 9, b"a", "png", 1024).unwrap();
175+
// Assert
176+
assert_ne!(rel1, rel2);
177+
assert!(rel2.contains('_'));
178+
}
179+
180+
#[test]
181+
fn download_rejects_non_http_scheme() {
182+
// Arrange & Act:非 http/https 直接拒绝(不触网——纯前缀校验路径)
183+
// Assert
184+
assert!(download_image_limited("ftp://host/a.png", 1024).is_err());
185+
assert!(download_image_limited("file:///C:/x/a.png", 1024).is_err());
186+
assert!(download_image_limited("C:/x/a.png", 1024).is_err());
187+
}

‎app/src-tauri/src/lib.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,8 +662,11 @@ pub fn run() {
662662
commands_images::save_user_screenshot,
663663
commands_images::session_images_base_url,
664664
// v0.10.1:笔记图片——Markdown 引用解析 / 本地导入 / data_dir 基准
665+
// v0.15:剪贴板 base64 导入 + 外链 URL 下载导入(图片落盘三入口)
665666
commands_note_images::resolve_note_image,
666667
commands_note_images::import_note_image,
668+
commands_note_images::import_note_image_b64,
669+
commands_note_images::import_note_image_url,
667670
commands_note_images::app_data_dir,
668671
// 结构图(REQ-182/183/184,v0.7.7:非线性结构图像捕获持久化 + 图库)
669672
commands_structures::capture_session_structures,

0 commit comments

Comments
 (0)