chore: align mvp design governance

- 统一 local-first MVP 后阶段架构口径,补充 process 执行总序和 Reasonix 协作记录

- 归档已完成的 design checklist,标注参考型 process,更新 AGENTS/REASONIX/架构文档

- 补充文件树/主编辑器下载与上下文菜单相关实现、bug 记录和 smoke 脚本

验证:git diff --check;codegraph sync .;cargo test -p mnote-web;node --check scripts/task476-filetree-editor-context-menu-download-smoke.js
This commit is contained in:
lix-2026
2026-05-21 09:04:13 +08:00
parent fd94792ea6
commit 1956a8a21a
69 changed files with 3390 additions and 111 deletions
@@ -204,6 +204,7 @@ struct LocalAssetUploadFields {
file: LocalUploadFile,
root_uri: String,
document_id: String,
target_relative_path: Option<String>,
kind: String,
}
@@ -2314,12 +2315,21 @@ pub async fn upload_local_markdown_asset(
let fields = read_local_asset_upload_multipart(multipart).await?;
ensure_local_workspace_access(&context, &fields.root_uri)
.map_err(|error| error.with_context(&context))?;
let asset = write_local_markdown_asset(
&fields.root_uri,
&fields.document_id,
&fields.kind,
fields.file,
)?;
let asset = if fields.target_relative_path.is_some() || fields.document_id.trim().is_empty() {
write_local_folder_file_upload(
&fields.root_uri,
fields.target_relative_path.as_deref().unwrap_or(""),
&fields.kind,
fields.file,
)?
} else {
write_local_markdown_asset(
&fields.root_uri,
&fields.document_id,
&fields.kind,
fields.file,
)?
};
Ok((StatusCode::OK, Json(json!({ "ok": true, "asset": asset }))))
}
@@ -2329,7 +2339,33 @@ pub async fn open_local_file(
) -> Result<(StatusCode, HeaderMap, Vec<u8>), WebError> {
ensure_local_workspace_read_access(&context, &query.root_uri)
.map_err(|error| error.with_context(&context))?;
let target = resolve_local_file_open_path(&query.root_uri, &query.path)?;
let target = resolve_local_open_path(&query.root_uri, &query.path)?;
let mut headers = HeaderMap::new();
if target.is_dir() {
if !query.download.unwrap_or(false) {
return Err(WebError::bad_request_code(
"local_file_open_is_directory",
"不能直接读取本地目录",
)
.with_context(&context));
}
let bytes = build_local_directory_tar_archive(&target).map_err(|error| {
WebError::bad_request_code(
"local_directory_download_failed",
format!("无法打包本地目录 {}: {error}", target.display()),
)
.with_context(&context)
})?;
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/x-tar"),
);
headers.insert(
header::CONTENT_DISPOSITION,
content_disposition_attachment_for_filename(&local_directory_tar_filename(&target)),
);
return Ok((StatusCode::OK, headers, bytes));
}
let bytes = fs::read(&target).map_err(|error| {
WebError::bad_request_code(
"local_file_open_read_failed",
@@ -2337,12 +2373,11 @@ pub async fn open_local_file(
)
.with_context(&context)
})?;
let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_TYPE, content_type_for_path(&target));
if query.download.unwrap_or(false) {
headers.insert(
header::CONTENT_DISPOSITION,
HeaderValue::from_static("attachment"),
content_disposition_attachment_for_path(&target),
);
}
Ok((StatusCode::OK, headers, bytes))
@@ -2485,7 +2520,7 @@ pub async fn write_local_resource(
))
}
fn resolve_local_file_open_path(root_uri: &str, relative_path: &str) -> Result<PathBuf, WebError> {
fn resolve_local_open_path(root_uri: &str, relative_path: &str) -> Result<PathBuf, WebError> {
let root_path = parse_file_root_uri(root_uri)?;
let canonical_root = root_path.canonicalize().map_err(|error| {
WebError::bad_request_code(
@@ -2519,6 +2554,11 @@ fn resolve_local_file_open_path(root_uri: &str, relative_path: &str) -> Result<P
"本地文件路径不能越过 root",
));
}
Ok(target)
}
fn resolve_local_file_open_path(root_uri: &str, relative_path: &str) -> Result<PathBuf, WebError> {
let target = resolve_local_open_path(root_uri, relative_path)?;
if target.is_dir() {
return Err(WebError::bad_request_code(
"local_file_open_is_directory",
@@ -2534,6 +2574,7 @@ async fn read_local_asset_upload_multipart(
let mut file: Option<LocalUploadFile> = None;
let mut root_uri = String::new();
let mut document_id = String::new();
let mut target_relative_path: Option<String> = None;
let mut kind = String::new();
while let Some(field) = multipart.next_field().await.map_err(|error| {
@@ -2583,6 +2624,9 @@ async fn read_local_asset_upload_multipart(
match name.as_str() {
"rootUri" => root_uri = value.trim().to_string(),
"documentId" => document_id = value.trim().to_string(),
"targetRelativePath" | "targetDirectoryPath" => {
target_relative_path = Some(value.trim().to_string())
}
"kind" => kind = value.trim().to_string(),
_ => {}
}
@@ -2591,7 +2635,10 @@ async fn read_local_asset_upload_multipart(
let file = file.ok_or_else(|| {
WebError::bad_request_code("local_asset_upload_file_missing", "缺少 file")
})?;
if file.bytes.is_empty() || root_uri.is_empty() || document_id.is_empty() {
if file.bytes.is_empty()
|| root_uri.is_empty()
|| (document_id.is_empty() && target_relative_path.is_none())
{
return Err(WebError::bad_request_code(
"local_asset_upload_required_missing",
"缺少必要参数",
@@ -2601,10 +2648,85 @@ async fn read_local_asset_upload_multipart(
file,
root_uri,
document_id,
target_relative_path,
kind,
})
}
pub(crate) fn write_local_folder_file_upload(
root_uri: &str,
target_relative_path: &str,
kind: &str,
file: LocalUploadFile,
) -> Result<Value, WebError> {
let root_path = parse_file_root_uri(root_uri)?;
let canonical_root = root_path.canonicalize().map_err(|error| {
WebError::bad_request_code(
"local_folder_unavailable",
format!("无法访问本地文件夹: {error}"),
)
})?;
if !canonical_root.is_dir() {
return Err(WebError::bad_request_code(
"local_folder_not_directory",
"本地 rootUri 必须指向目录",
));
}
let target_relative_path = target_relative_path.trim();
let requested = if target_relative_path.is_empty() {
Path::new(".")
} else {
Path::new(target_relative_path)
};
if requested.is_absolute()
|| requested
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return Err(WebError::bad_request_code(
"local_folder_upload_root_escape",
"上传目标目录不能越过 root",
));
}
let target_dir = canonical_root
.join(requested)
.canonicalize()
.map_err(|error| {
WebError::bad_request_code(
"local_folder_upload_target_missing",
format!("找不到上传目标目录: {error}"),
)
})?;
if !target_dir.starts_with(&canonical_root) || !target_dir.is_dir() {
return Err(WebError::bad_request_code(
"local_folder_upload_target_invalid",
"上传目标必须是本地 root 内的目录",
));
}
let sanitized_name = sanitize_file_name(&file.name, "附件");
let target = next_available_raw_path(&target_dir, &sanitized_name);
fs::write(&target, &file.bytes).map_err(|error| {
WebError::bad_request_code(
"local_folder_upload_write_failed",
format!("无法写入本地文件 {}: {error}", target.display()),
)
})?;
let root_relative_path = normalize_relative_path(&canonical_root, &target)?;
let asset_type = local_upload_asset_type(kind, &file.content_type);
Ok(json!({
"id": format!("local-file:{root_relative_path}"),
"asset_type": asset_type,
"file_name": target.file_name().and_then(|value| value.to_str()).unwrap_or(&sanitized_name),
"mime_type": file.content_type,
"file_size": file.bytes.len(),
"file_url": root_relative_path,
"sourcePath": root_relative_path,
"sourceKind": "local_folder",
"rootUri": file_uri_for_path(&canonical_root),
"rootRelativePath": root_relative_path,
}))
}
pub(crate) fn write_local_markdown_asset(
root_uri: &str,
document_id: &str,
@@ -6532,6 +6654,217 @@ fn content_type_for_path(path: &Path) -> HeaderValue {
HeaderValue::from_static(content_type)
}
fn percent_encode_content_disposition_filename(value: &str) -> String {
let mut encoded = String::new();
for byte in value.as_bytes() {
match *byte {
b'A'..=b'Z'
| b'a'..=b'z'
| b'0'..=b'9'
| b'!'
| b'#'
| b'$'
| b'&'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~' => encoded.push(*byte as char),
_ => encoded.push_str(&format!("%{byte:02X}")),
}
}
encoded
}
fn content_disposition_attachment_for_path(path: &Path) -> HeaderValue {
let filename = path
.file_name()
.and_then(|value| value.to_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("download");
content_disposition_attachment_for_filename(filename)
}
fn content_disposition_attachment_for_filename(filename: &str) -> HeaderValue {
let filename = filename.trim();
let filename = if filename.is_empty() {
"download"
} else {
filename
};
let ascii_fallback = filename
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_' | ' ') {
ch
} else {
'_'
}
})
.collect::<String>();
let ascii_fallback = if ascii_fallback.trim().is_empty() {
"download".to_string()
} else {
ascii_fallback
};
let quoted = ascii_fallback.replace('\\', "\\\\").replace('"', "\\\"");
let encoded = percent_encode_content_disposition_filename(filename);
HeaderValue::from_str(&format!(
"attachment; filename=\"{quoted}\"; filename*=UTF-8''{encoded}"
))
.unwrap_or_else(|_| HeaderValue::from_static("attachment"))
}
fn local_directory_tar_filename(path: &Path) -> String {
let name = path
.file_name()
.and_then(|value| value.to_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("folder");
format!("{name}.tar")
}
fn tar_octal(value: u64, width: usize) -> Vec<u8> {
let mut encoded = format!("{value:o}").into_bytes();
let max_digits = width.saturating_sub(1);
if encoded.len() > max_digits {
encoded = vec![b'7'; max_digits];
}
let mut out = vec![b'0'; max_digits.saturating_sub(encoded.len())];
out.extend(encoded);
out.push(0);
out
}
fn split_tar_path(path: &str) -> Result<(&str, &str), std::io::Error> {
let bytes = path.as_bytes();
if bytes.len() <= 100 {
return Ok(("", path));
}
let mut best: Option<usize> = None;
for (index, ch) in path.char_indices() {
if ch == '/' {
let prefix_len = path[..index].as_bytes().len();
let name_len = path[index + 1..].as_bytes().len();
if prefix_len <= 155 && name_len <= 100 {
best = Some(index);
}
}
}
if let Some(index) = best {
return Ok((&path[..index], &path[index + 1..]));
}
Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"tar entry path is too long",
))
}
fn append_tar_header(
out: &mut Vec<u8>,
path: &str,
size: u64,
typeflag: u8,
mode: u64,
mtime: u64,
) -> Result<(), std::io::Error> {
let normalized = path.trim_start_matches('/').replace('\\', "/");
let (prefix, name) = split_tar_path(&normalized)?;
let mut header = [0u8; 512];
header[0..name.as_bytes().len()].copy_from_slice(name.as_bytes());
let mode = tar_octal(mode, 8);
header[100..108].copy_from_slice(&mode);
header[108..116].copy_from_slice(&tar_octal(0, 8));
header[116..124].copy_from_slice(&tar_octal(0, 8));
header[124..136].copy_from_slice(&tar_octal(size, 12));
header[136..148].copy_from_slice(&tar_octal(mtime, 12));
header[148..156].fill(b' ');
header[156] = typeflag;
header[257..263].copy_from_slice(b"ustar\0");
header[263..265].copy_from_slice(b"00");
if !prefix.is_empty() {
header[345..345 + prefix.as_bytes().len()].copy_from_slice(prefix.as_bytes());
}
let checksum = header.iter().map(|byte| u32::from(*byte)).sum::<u32>();
let checksum_bytes = format!("{checksum:06o}\0 ").into_bytes();
header[148..156].copy_from_slice(&checksum_bytes);
out.extend_from_slice(&header);
Ok(())
}
fn append_tar_padding(out: &mut Vec<u8>, size: usize) {
let remainder = size % 512;
if remainder > 0 {
out.extend(std::iter::repeat(0).take(512 - remainder));
}
}
fn append_local_directory_tar_entries(
out: &mut Vec<u8>,
root: &Path,
current: &Path,
base_name: &str,
) -> Result<(), std::io::Error> {
let metadata = fs::symlink_metadata(current)?;
if metadata.file_type().is_symlink() {
return Ok(());
}
let relative = current.strip_prefix(root).unwrap_or(current);
let mut entry_name = if relative.as_os_str().is_empty() {
base_name.to_string()
} else {
format!(
"{base_name}/{}",
relative.to_string_lossy().replace('\\', "/")
)
};
let mtime = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_secs())
.unwrap_or(0);
if metadata.is_dir() {
if !entry_name.ends_with('/') {
entry_name.push('/');
}
append_tar_header(out, &entry_name, 0, b'5', 0o755, mtime)?;
let mut children = fs::read_dir(current)?.collect::<Result<Vec<_>, _>>()?;
children.sort_by_key(|entry| entry.file_name());
for child in children {
append_local_directory_tar_entries(out, root, &child.path(), base_name)?;
}
return Ok(());
}
if metadata.is_file() {
append_tar_header(out, &entry_name, metadata.len(), b'0', 0o644, mtime)?;
let bytes = fs::read(current)?;
out.extend_from_slice(&bytes);
append_tar_padding(out, bytes.len());
}
Ok(())
}
fn build_local_directory_tar_archive(directory: &Path) -> Result<Vec<u8>, std::io::Error> {
let canonical = directory.canonicalize()?;
let base_name = canonical
.file_name()
.and_then(|value| value.to_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("folder")
.to_string();
let mut out = Vec::new();
append_local_directory_tar_entries(&mut out, &canonical, &canonical, &base_name)?;
out.extend_from_slice(&[0u8; 1024]);
Ok(out)
}
fn editor_block_table_to_markdown(block: &Value) -> String {
let table = block
.get("props")
@@ -8873,6 +9206,90 @@ fn main() {}
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn local_file_open_download_sets_content_disposition_filename() {
let root = temp_root("mnote-local-file-open-download-filename");
init_workspace(&root);
std::fs::write(root.join("报告 2026.docx"), b"docx").expect("write docx");
let root_uri = format!("file://{}", root.display());
let mut headers = HeaderMap::new();
headers.insert("x-mnote-actor-id", "user_test".parse().unwrap());
headers.insert("x-mnote-actor-type", "user".parse().unwrap());
let context = RequestContext::from_http_parts(
&Method::GET,
&"/api/local-folder/files/open".parse().expect("uri"),
&headers,
);
let (_, response_headers, _) = open_local_file(
Extension(context),
Query(LocalFileOpenQuery {
root_uri,
path: "报告 2026.docx".into(),
download: Some(true),
}),
)
.await
.expect("download local file");
let disposition = response_headers
.get(axum::http::header::CONTENT_DISPOSITION)
.and_then(|value| value.to_str().ok())
.expect("content disposition");
assert!(disposition.starts_with("attachment;"));
assert!(disposition.contains("filename=\"__ 2026.docx\""));
assert!(
disposition.contains("filename*=UTF-8''%E6%8A%A5%E5%91%8A%202026.docx"),
"{disposition}"
);
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn local_folder_open_downloads_directory_as_tar() {
let root = temp_root("mnote-local-folder-open-download-directory");
init_workspace(&root);
let reports = root.join("Reports");
std::fs::create_dir_all(&reports).expect("create reports");
std::fs::write(reports.join("summary.txt"), b"summary").expect("write summary");
let root_uri = format!("file://{}", root.display());
let mut headers = HeaderMap::new();
headers.insert("x-mnote-actor-id", "user_test".parse().unwrap());
headers.insert("x-mnote-actor-type", "user".parse().unwrap());
let context = RequestContext::from_http_parts(
&Method::GET,
&"/api/local-folder/files/open".parse().expect("uri"),
&headers,
);
let (_, response_headers, bytes) = open_local_file(
Extension(context),
Query(LocalFileOpenQuery {
root_uri,
path: "Reports".into(),
download: Some(true),
}),
)
.await
.expect("download local directory");
assert_eq!(
response_headers
.get(axum::http::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok()),
Some("application/x-tar")
);
let disposition = response_headers
.get(axum::http::header::CONTENT_DISPOSITION)
.and_then(|value| value.to_str().ok())
.expect("content disposition");
assert!(disposition.contains("filename=\"Reports.tar\""));
assert!(bytes.windows(5).any(|window| window == b"ustar"));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_file_tree_classifies_mindmap_and_office_assets() {
let root = temp_root("mnote-local-filetree-resource-kinds");