diff --git a/rust/crates/mnote-web/src/routes/local_folder_source.rs b/rust/crates/mnote-web/src/routes/local_folder_source.rs index 0f38c537..62cad6fb 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -42,6 +42,7 @@ struct LocalFolderEntry { struct LocalFolderMetadata { page_options: BTreeMap, trash_entries: BTreeMap, + uploaded_assets: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -156,6 +157,19 @@ pub(crate) struct LocalTrashEntry { pub(crate) purged_at: Option, } +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LocalUploadedAssetEntry { + #[serde(default, alias = "documentId")] + document_id: String, + #[serde(default, alias = "relativePath")] + relative_path: String, + #[serde(default, alias = "fileName")] + file_name: String, + #[serde(default, alias = "createdAtMs")] + created_at_ms: u128, +} + #[derive(Debug, Clone)] struct LocalFolderRow { node_id: String, @@ -2523,6 +2537,21 @@ pub(crate) fn write_local_markdown_asset( let root_relative_path = normalize_relative_path(&canonical_root, &target)?; let markdown_relative_path = normalize_markdown_relative_asset_path(markdown_dir, &target)?; let asset_type = local_upload_asset_type(kind, &file.content_type); + let mut uploaded_assets = metadata.uploaded_assets; + uploaded_assets.insert( + root_relative_path.clone(), + LocalUploadedAssetEntry { + document_id: document_id.to_string(), + relative_path: root_relative_path.clone(), + file_name: target + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(&sanitized_name) + .to_string(), + created_at_ms: now_ms(), + }, + ); + write_uploaded_asset_index_metadata(&canonical_root, &uploaded_assets)?; Ok(json!({ "id": format!("local:asset:{root_relative_path}"), "asset_type": asset_type, @@ -4506,6 +4535,9 @@ fn load_local_folder_metadata(root: &Path) -> Result Result Ok(entries) } +fn load_uploaded_asset_index_map( + path: &Path, +) -> Result, WebError> { + if !path.exists() { + return Ok(BTreeMap::new()); + } + let value = read_metadata_json(path)?; + let source = value.get("entries").unwrap_or(&value); + let entries: BTreeMap = serde_json::from_value(source.clone()) + .map_err(|error| metadata_invalid(path, format!("上传资源索引损坏: {error}")))?; + Ok(entries) +} + fn metadata_invalid(path: &Path, message: impl Into) -> WebError { WebError::bad_request_code( "local_metadata_invalid", @@ -4641,6 +4686,25 @@ fn write_page_options_metadata( write_json_atomic(&path, &value) } +fn write_uploaded_asset_index_metadata( + root: &Path, + entries: &BTreeMap, +) -> Result<(), WebError> { + let mnote_dir = root.join(".mnote"); + fs::create_dir_all(&mnote_dir).map_err(|error| { + WebError::bad_request_code( + "local_metadata_write_failed", + format!("无法创建本地元数据目录 {}: {error}", mnote_dir.display()), + ) + })?; + let path = mnote_dir.join("uploaded-assets.json"); + let value = json!({ + "version": 1, + "entries": entries, + }); + write_json_atomic(&path, &value) +} + #[allow(dead_code)] fn write_json_atomic(path: &Path, value: &Value) -> Result<(), WebError> { let tmp_path = path.with_extension("json.tmp"); @@ -4684,8 +4748,11 @@ fn scan_directory( } else { 0 }; + let uploaded_asset = metadata.uploaded_assets.get(&entry.relative_path); let row_kind = if entry.is_dir { "folder".to_string() + } else if uploaded_asset.is_some() { + "asset".to_string() } else if is_markdown_file(&entry.file_name) { "markdown".to_string() } else { @@ -4706,7 +4773,9 @@ fn scan_directory( child_count, expandable: entry.is_dir && child_count > 0, expanded_by_default: depth < 1 && entry.is_dir && entry_count <= 80, - document_id: if is_markdown_file(&entry.file_name) { + document_id: if let Some(asset) = uploaded_asset { + Some(asset.document_id.clone()) + } else if is_markdown_file(&entry.file_name) { Some(local_markdown_path_page_id(&entry.relative_path)) } else { None @@ -4968,6 +5037,29 @@ fn scan_markdown_page_tree( } continue; } + let uploaded_asset = metadata.uploaded_assets.get(&entry.relative_path); + if let Some(asset) = uploaded_asset { + directory_rows.push(LocalFolderRow { + node_id: local_node_id(&entry.relative_path), + row_id: format!("local:asset:{}", entry.relative_path), + parent_node_id: parent_node_id.clone(), + title: entry.file_name.clone(), + depth, + position: position as u32, + row_kind: "asset".to_string(), + icon_hint: icon_hint_for_entry(&entry), + relative_path: entry.relative_path.clone(), + source_uri: file_uri_for_path(&entry.path), + child_count: 0, + expandable: false, + expanded_by_default: false, + document_id: Some(asset.document_id.clone()), + capabilities: local_entry_capabilities(&entry), + workspace_id: workspace_id.to_string(), + root_source_uri: root_source_uri.to_string(), + }); + continue; + } if is_markdown_file(&entry.file_name) { let markdown = fs::read_to_string(&entry.path).unwrap_or_default(); let parsed = parse_markdown_page(&markdown, &entry.file_name); @@ -8284,6 +8376,49 @@ fn main() {} .expect("read copied asset"), b"new-image" ); + let markdown_asset = write_local_markdown_asset( + &root_uri, + "local-md:docs~2FREADME~2FREADME.md", + "attachment", + LocalUploadFile { + name: "notes.md".to_string(), + content_type: "text/markdown".to_string(), + bytes: b"# Uploaded notes\n".to_vec(), + }, + ) + .expect("upload markdown asset"); + assert_eq!(markdown_asset["sourcePath"], "notes.md"); + let uploaded_asset_index = + std::fs::read_to_string(root.join(".mnote").join("uploaded-assets.json")) + .expect("uploaded asset index"); + assert!(uploaded_asset_index.contains("docs/README/notes.md")); + + let snapshot = load_local_folder_file_tree_snapshot(&root_uri).expect("file tree"); + let items = snapshot.projection["items"].as_array().expect("items"); + let notes_row = items + .iter() + .find(|item| item["title"].as_str() == Some("notes.md")) + .expect("uploaded markdown asset row"); + assert_eq!(notes_row["rowKind"].as_str(), Some("asset")); + assert_eq!(notes_row["iconHint"].as_str(), Some("markdown")); + assert_eq!( + notes_row["resourceMeta"]["assetId"].as_str(), + Some("local-file:docs/README/notes.md") + ); + let page_tree = load_local_folder_page_tree_snapshot(&root_uri).expect("page tree"); + let page_items = page_tree.projection["items"] + .as_array() + .expect("page items"); + let page_notes_row = page_items + .iter() + .find(|item| item["title"].as_str() == Some("notes.md")) + .expect("uploaded markdown asset page row"); + assert_eq!(page_notes_row["rowKind"].as_str(), Some("asset")); + assert_eq!(page_notes_row["iconHint"].as_str(), Some("markdown")); + assert_eq!( + page_notes_row["resourceMeta"]["assetId"].as_str(), + Some("local-file:docs/README/notes.md") + ); let _ = std::fs::remove_dir_all(&root); } diff --git a/rust/crates/mnote-web/src/routes/onlyoffice.rs b/rust/crates/mnote-web/src/routes/onlyoffice.rs index 2aa9d92d..a2ae1d88 100644 --- a/rust/crates/mnote-web/src/routes/onlyoffice.rs +++ b/rust/crates/mnote-web/src/routes/onlyoffice.rs @@ -7,10 +7,12 @@ use axum::extract::{Path, Query, State}; use axum::http::{header, HeaderMap, HeaderValue, Method, Request, Uri}; use axum::response::{Html, IntoResponse, Response}; use axum::Json; +use base64::Engine; use serde::Deserialize; use serde_json::{json, Value}; use std::env; use std::fs; +use std::path::{Path as FsPath, PathBuf}; use std::time::Duration; const ONLYOFFICE_PROBE_PATH: &str = "/web-apps/apps/api/documents/api.js"; @@ -729,6 +731,9 @@ pub async fn proxy( .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| WebError::bad_request_code("onlyoffice_proxy_url_missing", "缺少 u"))?; + if let Some(response) = proxy_local_folder_file_open(encoded_url, &method)? { + return Ok(response); + } let prepared = prepare_proxy_request(OnlyOfficeProxyPreparationInput { encoded_url: encoded_url.to_string(), method: method.as_str().to_string(), @@ -789,6 +794,173 @@ pub async fn proxy( Ok(response) } +fn decode_onlyoffice_proxy_url(encoded_url: &str) -> Option { + [ + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + &base64::engine::general_purpose::URL_SAFE, + &base64::engine::general_purpose::STANDARD, + ] + .into_iter() + .find_map(|engine| { + engine + .decode(encoded_url) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + }) +} + +fn is_local_mnote_proxy_host(host: &str) -> bool { + matches!(host, "localhost" | "127.0.0.1" | "host.docker.internal") +} + +fn parse_local_file_root_uri(root_uri: &str) -> Result { + let trimmed = root_uri.trim(); + let Some(path) = trimmed.strip_prefix("file://") else { + return Err(WebError::bad_request_code( + "onlyoffice_local_file_root_invalid", + "本地文件 rootUri 必须使用 file://", + )); + }; + if path.trim().is_empty() { + return Err(WebError::bad_request_code( + "onlyoffice_local_file_root_invalid", + "本地文件 rootUri 不能为空", + )); + } + Ok(PathBuf::from(path)) +} + +fn resolve_onlyoffice_local_file_path( + root_uri: &str, + relative_path: &str, +) -> Result { + let root = parse_local_file_root_uri(root_uri)?; + let canonical_root = root.canonicalize().map_err(|error| { + WebError::bad_request_code( + "onlyoffice_local_file_root_unavailable", + format!("无法访问本地文件夹: {error}"), + ) + })?; + let requested = FsPath::new(relative_path); + if requested.is_absolute() + || requested + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(WebError::bad_request_code( + "onlyoffice_local_file_root_escape", + "本地文件路径不能越过 root", + )); + } + let target = canonical_root + .join(requested) + .canonicalize() + .map_err(|error| { + WebError::bad_request_code( + "onlyoffice_local_file_not_found", + format!("找不到本地文件: {error}"), + ) + })?; + if !target.starts_with(&canonical_root) || !target.is_file() { + return Err(WebError::bad_request_code( + "onlyoffice_local_file_root_escape", + "本地文件路径不能越过 root", + )); + } + Ok(target) +} + +fn onlyoffice_content_type_for_path(path: &FsPath) -> HeaderValue { + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + HeaderValue::from_static(match extension.as_str() { + "doc" => "application/msword", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "ppt" => "application/vnd.ms-powerpoint", + "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "xls" => "application/vnd.ms-excel", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "odt" => "application/vnd.oasis.opendocument.text", + "odp" => "application/vnd.oasis.opendocument.presentation", + "ods" => "application/vnd.oasis.opendocument.spreadsheet", + "csv" => "text/csv; charset=utf-8", + "md" | "markdown" => "text/markdown; charset=utf-8", + "txt" | "log" => "text/plain; charset=utf-8", + "pdf" => "application/pdf", + _ => "application/octet-stream", + }) +} + +fn proxy_local_folder_file_open( + encoded_url: &str, + method: &Method, +) -> Result, WebError> { + if *method != Method::GET && *method != Method::HEAD { + return Ok(None); + } + let Some(raw_url) = decode_onlyoffice_proxy_url(encoded_url) else { + return Ok(None); + }; + let Ok(url) = reqwest::Url::parse(&raw_url) else { + return Ok(None); + }; + let Some(host) = url.host_str() else { + return Ok(None); + }; + if !is_local_mnote_proxy_host(host) || url.path() != "/api/local-folder/files/open" { + return Ok(None); + } + let mut root_uri = String::new(); + let mut relative_path = String::new(); + for (key, value) in url.query_pairs() { + match key.as_ref() { + "rootUri" => root_uri = value.into_owned(), + "path" => relative_path = value.into_owned(), + _ => {} + } + } + if root_uri.trim().is_empty() || relative_path.trim().is_empty() { + return Err(WebError::bad_request_code( + "onlyoffice_local_file_query_missing", + "本地文件代理缺少 rootUri 或 path", + )); + } + let target = resolve_onlyoffice_local_file_path(&root_uri, &relative_path)?; + let metadata = fs::metadata(&target).map_err(|error| { + WebError::bad_request_code( + "onlyoffice_local_file_metadata_failed", + format!("无法读取本地文件元数据: {error}"), + ) + })?; + let mut response = if *method == Method::HEAD { + Response::new(Body::empty()) + } else { + let bytes = fs::read(&target).map_err(|error| { + WebError::bad_request_code( + "onlyoffice_local_file_read_failed", + format!("无法读取本地文件: {error}"), + ) + })?; + Response::new(Body::from(bytes)) + }; + response.headers_mut().insert( + header::CONTENT_TYPE, + onlyoffice_content_type_for_path(&target), + ); + response.headers_mut().insert( + header::CONTENT_LENGTH, + HeaderValue::from_str(&metadata.len().to_string()) + .unwrap_or_else(|_| HeaderValue::from_static("0")), + ); + response + .headers_mut() + .insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes")); + Ok(Some(response)) +} + pub async fn callback( State(state): State, uri: Uri, @@ -1222,6 +1394,47 @@ mod tests { assert!(html.contains("documentId=doc_1")); } + #[tokio::test] + async fn onlyoffice_proxy_serves_local_folder_file_open_url() { + let root = std::env::temp_dir().join(format!( + "mnote-onlyoffice-local-proxy-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("Page")).expect("create page"); + fs::write(root.join("Page").join("report.docx"), b"docx").expect("write docx"); + let local_url = format!( + "http://localhost:3000/api/local-folder/files/open?rootUri=file://{}&path=Page/report.docx", + root.display() + ); + let encoded = base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + local_url.as_bytes(), + ); + + let response = proxy( + Query(OnlyOfficeProxyQuery { u: Some(encoded) }), + HeaderMap::new(), + Method::GET, + ) + .await + .expect("proxy local file"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document") + ); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + assert_eq!(&body[..], b"docx"); + + let _ = fs::remove_dir_all(&root); + } + #[test] fn onlyoffice_internal_candidates_keep_default_first_after_env() { let candidates = onlyoffice_internal_candidates(); diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 8805ca9b..87fbd91c 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -2137,7 +2137,7 @@ const SIDEBAR_TREE_JS: &str = r##" function localFilePathFromAssetId(assetId) { var value = String(assetId || '').trim(); - return value.indexOf('local-file:') === 0 ? value.slice('local-file:'.length) : ''; + return value.indexOf('local-file:') === 0 ? value.slice('local-file:'.length) : value.indexOf('local:asset:') === 0 ? value.slice('local:asset:'.length) : ''; } function buildLocalFileOpenUrl(relativePath, download) { @@ -2350,6 +2350,16 @@ const SIDEBAR_TREE_JS: &str = r##" return String(asset && (asset.asset_type || asset.assetType || asset.mime_type || '') || '').trim(); } + function fileTreeIconKindForFileName(fileName) { + var name = String(fileName || '').trim().toLowerCase(); + var ext = name.indexOf('.') >= 0 ? name.split('.').pop() : ''; + if (['doc', 'docx', 'odt', 'rtf', 'ppt', 'pptx', 'odp', 'xls', 'xlsx', 'ods', 'csv'].indexOf(ext) >= 0) return 'office'; + if (ext === 'md' || ext === 'markdown') return 'markdown'; + if (ext === 'pdf') return 'pdf'; + if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'].indexOf(ext) >= 0) return 'image'; + return ''; + } + function isLocalUploadedAsset(asset) { var id = String(asset && asset.id || '').trim(); return String(asset && asset.sourceKind || '').trim() === 'local_folder' || id.indexOf('local:asset:') === 0; @@ -2688,7 +2698,7 @@ const SIDEBAR_TREE_JS: &str = r##" }; li.setAttribute('data-node-id', 'asset:' + assetId); var title = uploadedAssetTitle(asset); - var iconKind = uploadedAssetType(asset) || 'file'; + var iconKind = fileTreeIconKindForFileName(title) || uploadedAssetType(asset) || 'file'; if (objectKind === 'mindmap') iconKind = 'mindmap'; li.innerHTML = '