收口 MNote P0 P1 P2 审查尾项

- 归档 OnlyOffice live bridge、Page AI、mindmap、design governance 与相关 bug 条目
- 补齐 MinerU OCR 后端 runtime 合同与 smoke/test 基线
- 收口 ChatOnly/Doubao、ObjectIdentity、Page Aggregate compat 与 runtime owner 文档口径

验证:
- cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1
- cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_bridge -- --test-threads=1
- git diff --check
- git diff --cached --check
- codegraph index . --force && codegraph status .
- codegraph sync . && codegraph status .
This commit is contained in:
lix-2026
2026-06-01 09:29:12 +08:00
parent 49a0545148
commit 1882db7681
143 changed files with 29810 additions and 3228 deletions
+416 -10
View File
@@ -1,3 +1,4 @@
use super::onlyoffice_bridge;
use crate::app::AppConfig;
use crate::app::AppState;
use crate::error::WebError;
@@ -147,6 +148,9 @@ pub struct OnlyOfficeCallbackQuery {
asset_id: Option<String>,
#[serde(rename = "userId")]
user_id: Option<String>,
#[serde(rename = "sessionId")]
session_id: Option<String>,
token: Option<String>,
#[serde(rename = "rootUri")]
root_uri: Option<String>,
path: Option<String>,
@@ -506,10 +510,12 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
return null;
}}
}}
function buildCallbackUrl(assetId, userId, localFile) {{
function buildCallbackUrl(assetId, userId, localFile, bridgeSessionId, bridgeToken) {{
const callback = new URL("/api/onlyoffice/callback", callbackOrigin || location.origin);
if (assetId) callback.searchParams.set("assetId", assetId);
if (userId) callback.searchParams.set("userId", userId);
if (bridgeSessionId) callback.searchParams.set("sessionId", bridgeSessionId);
if (bridgeToken) callback.searchParams.set("token", bridgeToken);
if (localFile && localFile.rootUri && localFile.path) {{
callback.searchParams.set("rootUri", localFile.rootUri);
callback.searchParams.set("path", localFile.path);
@@ -598,6 +604,11 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
if (initial.fileUrl && initial.fileUrl.indexOf("/api/local-folder/files/open") !== -1) return true;
return false;
}}
function editModeLocationHref() {{
const next = new URL(location.href);
next.searchParams.set("mode", "edit");
return next.toString();
}}
async function resolveAssetUrlAndKey() {{
let effectiveFileUrl = initial.fileUrl;
let storageId = "";
@@ -639,6 +650,18 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
const localFile = localFolderOpenParams(fileState.effectiveFileUrl || initial.fileUrl);
const displayUserId = String(userId || initial.userId || "mnote-local-user").trim() || "mnote-local-user";
const displayUserName = displayUserId === "mnote-local-user" ? "MNote" : displayUserId;
const bridgeSessionSalt = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Date.now().toString(36) + "-" + Math.random().toString(36).slice(2));
const bridgeSessionId = "mnote-oo-" + fileState.docKey + "-" + bridgeSessionSalt;
const bridgeToken = (crypto && crypto.randomUUID) ? crypto.randomUUID() : ("mnote-oo-token-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2));
const bridgePluginConfigUrl = new URL("/api/onlyoffice/bridge/plugin/config", location.origin);
bridgePluginConfigUrl.searchParams.set("sessionId", bridgeSessionId);
bridgePluginConfigUrl.searchParams.set("token", bridgeToken);
bridgePluginConfigUrl.searchParams.set("apiBase", location.origin);
bridgePluginConfigUrl.searchParams.set("documentId", initial.documentId || "");
bridgePluginConfigUrl.searchParams.set("assetId", initial.assetId || "");
bridgePluginConfigUrl.searchParams.set("fileType", initial.fileType || "");
bridgePluginConfigUrl.searchParams.set("docKey", fileState.docKey);
bridgePluginConfigUrl.searchParams.set("pageOrigin", location.origin);
const config = {{
width: "100%",
height: "100%",
@@ -658,11 +681,15 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
editorConfig: {{
mode: resolvedMode,
lang: "zh-CN",
callbackUrl: buildCallbackUrl(initial.assetId, userId, localFile),
callbackUrl: buildCallbackUrl(initial.assetId, userId, localFile, bridgeSessionId, bridgeToken),
user: {{
id: displayUserId,
name: displayUserName
}},
plugins: {{
autostart: [MNOTE_AGENT_PLUGIN_GUID],
pluginsData: [bridgePluginConfigUrl.toString()]
}},
customization: {{
feedback: {{ visible: false }},
anonymous: {{ request: false, label: "Guest" }},
@@ -673,6 +700,14 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
events: {{
onDocumentReady: () => {{ window.__MNOTE_ONLYOFFICE_READY__ = true; }},
onAppReady: () => {{ window.__MNOTE_ONLYOFFICE_READY__ = true; }},
onRequestEditRights: () => {{
const editHref = editModeLocationHref();
window.__MNOTE_ONLYOFFICE_REQUEST_EDIT_RIGHTS__ = {{
requested: true,
targetUrl: editHref
}};
window.location.replace(editHref);
}},
onError: (event) => showError(JSON.stringify(event))
}}
}};
@@ -690,7 +725,52 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
resolvedMode,
assetId: initial.assetId,
documentId: initial.documentId,
docKey: fileState.docKey
docKey: fileState.docKey,
bridgeSessionId,
bridgeToken,
bridgePluginConfigUrl: bridgePluginConfigUrl.toString()
}};
try {{
if (window.parent && window.parent !== window) {{
window.parent.postMessage({{
type: "mnote:onlyoffice-bridge-ready",
bridgeSessionId,
documentId: initial.documentId,
assetId: initial.assetId,
fileType: initial.fileType
}}, location.origin);
}}
}} catch (_) {{}}
window.__MNOTE_ONLYOFFICE_BRIDGE__ = {{
sessionId: bridgeSessionId,
run: async function(action, payload, timeoutMs) {{
const enqueue = await fetch("/api/onlyoffice/bridge/commands", {{
method: "POST",
headers: {{ "content-type": "application/json" }},
body: JSON.stringify({{
sessionId: bridgeSessionId,
token: bridgeToken,
action,
payload: payload || {{}}
}})
}});
const enqueued = await enqueue.json().catch(() => null);
if (!enqueue.ok) throw new Error(enqueued && enqueued.message || "ONLYOFFICE bridge command enqueue failed");
const commandId = enqueued && enqueued.command && enqueued.command.id;
if (!commandId) throw new Error("ONLYOFFICE bridge command id missing");
const resultUrl = new URL("/api/onlyoffice/bridge/results", location.origin);
resultUrl.searchParams.set("sessionId", bridgeSessionId);
resultUrl.searchParams.set("token", bridgeToken);
resultUrl.searchParams.set("commandId", commandId);
resultUrl.searchParams.set("timeoutMs", String(timeoutMs || 25000));
const resultResponse = await fetch(resultUrl.toString());
if (resultResponse.status === 204) throw new Error("ONLYOFFICE bridge command timed out");
const result = await resultResponse.json().catch(() => null);
if (!resultResponse.ok || !result || result.ok === false) {{
throw new Error(result && result.error || result && result.message || "ONLYOFFICE bridge command failed");
}}
return result.result;
}}
}};
const readyDeadline = Date.now() + 120000;
const timer = window.setInterval(() => {{
@@ -1070,12 +1150,15 @@ fn onlyoffice_callback_success(extra: Value) -> Response {
}
fn onlyoffice_callback_failure(error: WebError) -> Response {
Json(json!({
(
error.status(),
Json(json!({
"error": 1,
"code": error.code(),
"message": error.message(),
}))
.into_response()
})),
)
.into_response()
}
async fn download_onlyoffice_callback_body(download_url: &str) -> Result<Bytes, WebError> {
@@ -1135,6 +1218,53 @@ async fn local_folder_onlyoffice_callback(
)
})?;
let target = resolve_onlyoffice_local_file_path(root_uri, relative_path)?;
let session_id = query
.session_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"onlyoffice_local_callback_session_required",
"OnlyOffice 本地保存缺少 bridge sessionId",
)
})?;
let token = query
.token
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"onlyoffice_local_callback_token_required",
"OnlyOffice 本地保存缺少 bridge token",
)
})?;
if !onlyoffice_bridge::session_token_matches(session_id, token) {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"onlyoffice_local_callback_token_invalid",
"OnlyOffice 本地保存 bridge token 无效",
));
}
let session = onlyoffice_bridge::session_info(session_id).ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"onlyoffice_local_callback_session_unregistered",
"OnlyOffice 本地保存 bridge session 未注册",
)
})?;
if let Some(session_asset_id) = session.asset_id.as_deref() {
if !session_asset_id.trim().is_empty() && session_asset_id.trim() != asset_id {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"onlyoffice_local_callback_asset_mismatch",
"OnlyOffice 本地保存 session 与资源不匹配",
));
}
}
let onlyoffice_internal_url = resolve_onlyoffice_internal_url().await;
let prepared = prepare_callback(OnlyOfficeCallbackPreparationInput {
asset_id: asset_id.to_string(),
@@ -1840,6 +1970,22 @@ mod tests {
));
assert!(html.contains("if (proxyOrigin && isLocalFolderFileOpen)"));
assert!(html.contains("if (proxyOrigin && (isLocal || url.searchParams.has(\"token\")))"));
assert!(html.contains(
"const MNOTE_AGENT_PLUGIN_GUID = \"asc.{05F87DDF-7B42-4C6F-9F2B-9C77A8D5F4E2}\";"
));
assert!(html.contains(
"const bridgePluginConfigUrl = new URL(\"/api/onlyoffice/bridge/plugin/config\", location.origin);"
));
assert!(
html.contains("bridgePluginConfigUrl.searchParams.set(\"apiBase\", location.origin);")
);
assert!(html.contains("const bridgeSessionSalt ="));
assert!(html.contains(
"const bridgeSessionId = \"mnote-oo-\" + fileState.docKey + \"-\" + bridgeSessionSalt;"
));
assert!(html.contains("/api/onlyoffice/bridge/plugin/config"));
assert!(html.contains("pluginsData: [bridgePluginConfigUrl.toString()]"));
assert!(html.contains("window.__MNOTE_ONLYOFFICE_BRIDGE__"));
}
fn test_state(legacy_next_base_url: Option<String>) -> AppState {
@@ -1897,6 +2043,8 @@ mod tests {
Query(OnlyOfficeCallbackQuery {
asset_id: Some("asset_1".into()),
user_id: None,
session_id: None,
token: None,
root_uri: None,
path: None,
}),
@@ -1917,9 +2065,9 @@ mod tests {
}
#[tokio::test]
async fn onlyoffice_local_callback_writes_status_two_body_to_original_file() {
async fn onlyoffice_local_callback_rejects_unauthenticated_local_write() {
let root = std::env::temp_dir().join(format!(
"mnote-onlyoffice-local-callback-{}",
"mnote-onlyoffice-local-callback-unauth-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
@@ -1938,6 +2086,74 @@ mod tests {
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
session_id: None,
token: None,
root_uri: Some(format!("file://{}", root.display())),
path: Some("Page/report.docx".into()),
}),
Json(json!({
"status": 2,
"key": "doc_key",
"url": download_url
})),
)
.await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["error"], 1);
assert_eq!(
payload["code"],
"onlyoffice_local_callback_session_required"
);
assert_eq!(fs::read(&target).expect("read target"), b"old");
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn onlyoffice_local_callback_writes_status_two_body_to_original_file() {
let root = std::env::temp_dir().join(format!(
"mnote-onlyoffice-local-callback-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("Page")).expect("create page");
let target = root.join("Page").join("report.docx");
fs::write(&target, b"old").expect("write old docx");
let (download_url, _captured) = spawn_legacy_json_server("new docx bytes").await;
let session_id = format!("mnote-oo-local-callback-{}", std::process::id());
let token = format!("token-{session_id}");
let registered =
onlyoffice_bridge::register_session(Json(onlyoffice_bridge::BridgeSessionPayload {
session_id: session_id.clone(),
token: Some(token.clone()),
editor_type: Some("word".into()),
document_id: Some("local-md:Page".into()),
asset_id: Some("local:asset:Page/report.docx".into()),
file_type: Some("docx".into()),
doc_key: None,
page_origin: None,
}))
.await;
assert_eq!(registered.status(), StatusCode::OK);
let response = callback(
State(test_state(None)),
format!(
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
session_id,
token,
root.display(),
)
.parse::<Uri>()
.expect("uri"),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
session_id: Some(session_id),
token: Some(token),
root_uri: Some(format!("file://{}", root.display())),
path: Some("Page/report.docx".into()),
}),
@@ -1958,6 +2174,147 @@ mod tests {
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn onlyoffice_local_callback_writes_status_six_body_to_original_file() {
let root = std::env::temp_dir().join(format!(
"mnote-onlyoffice-local-callback-status-six-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("Page")).expect("create page");
let target = root.join("Page").join("report.docx");
fs::write(&target, b"old").expect("write old docx");
let (download_url, _captured) = spawn_legacy_json_server("status six bytes").await;
let session_id = format!("mnote-oo-local-callback-six-{}", std::process::id());
let token = format!("token-{session_id}");
let registered =
onlyoffice_bridge::register_session(Json(onlyoffice_bridge::BridgeSessionPayload {
session_id: session_id.clone(),
token: Some(token.clone()),
editor_type: Some("word".into()),
document_id: Some("local-md:Page".into()),
asset_id: Some("local:asset:Page/report.docx".into()),
file_type: Some("docx".into()),
doc_key: None,
page_origin: None,
}))
.await;
assert_eq!(registered.status(), StatusCode::OK);
let response = callback(
State(test_state(None)),
format!(
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
session_id,
token,
root.display(),
)
.parse::<Uri>()
.expect("uri"),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
session_id: Some(session_id),
token: Some(token),
root_uri: Some(format!("file://{}", root.display())),
path: Some("Page/report.docx".into()),
}),
Json(json!({
"status": 6,
"key": "doc_key",
"url": download_url
})),
)
.await;
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["error"], 0);
assert_eq!(fs::read(&target).expect("read target"), b"status six bytes");
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn onlyoffice_local_callback_rejects_root_escape_path() {
let root = std::env::temp_dir().join(format!(
"mnote-onlyoffice-local-callback-root-{}",
std::process::id()
));
let outside = std::env::temp_dir().join(format!(
"mnote-onlyoffice-local-callback-outside-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&outside);
fs::create_dir_all(root.join("Page")).expect("create page");
fs::create_dir_all(&outside).expect("create outside");
fs::write(outside.join("report.docx"), b"outside").expect("write outside");
let (download_url, _captured) = spawn_legacy_json_server("should not write").await;
let session_id = format!("mnote-oo-local-callback-escape-{}", std::process::id());
let token = format!("token-{session_id}");
let registered =
onlyoffice_bridge::register_session(Json(onlyoffice_bridge::BridgeSessionPayload {
session_id: session_id.clone(),
token: Some(token.clone()),
editor_type: Some("word".into()),
document_id: Some("local-md:Page".into()),
asset_id: Some("local:asset:Page/report.docx".into()),
file_type: Some("docx".into()),
doc_key: None,
page_origin: None,
}))
.await;
assert_eq!(registered.status(), StatusCode::OK);
let response = callback(
State(test_state(None)),
format!(
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=..%2F{}%2Freport.docx",
session_id,
token,
root.display(),
outside.file_name().and_then(|value| value.to_str()).unwrap_or_default(),
)
.parse::<Uri>()
.expect("uri"),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
session_id: Some(session_id),
token: Some(token),
root_uri: Some(format!("file://{}", root.display())),
path: Some(format!(
"../{}/report.docx",
outside
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default()
)),
}),
Json(json!({
"status": 2,
"key": "doc_key",
"url": download_url
})),
)
.await;
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(payload["error"], 1);
assert_eq!(payload["code"], "onlyoffice_local_file_root_escape");
assert_eq!(
fs::read(outside.join("report.docx")).expect("read outside"),
b"outside"
);
let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&outside);
}
#[tokio::test]
async fn onlyoffice_local_callback_ignores_non_write_status() {
let root = std::env::temp_dir().join(format!(
@@ -1968,17 +2325,36 @@ mod tests {
fs::create_dir_all(root.join("Page")).expect("create page");
let target = root.join("Page").join("report.docx");
fs::write(&target, b"old").expect("write old docx");
let session_id = format!("mnote-oo-local-callback-ignore-{}", std::process::id());
let token = format!("token-{session_id}");
let registered =
onlyoffice_bridge::register_session(Json(onlyoffice_bridge::BridgeSessionPayload {
session_id: session_id.clone(),
token: Some(token.clone()),
editor_type: Some("word".into()),
document_id: Some("local-md:Page".into()),
asset_id: Some("local:asset:Page/report.docx".into()),
file_type: Some("docx".into()),
doc_key: None,
page_origin: None,
}))
.await;
assert_eq!(registered.status(), StatusCode::OK);
let response = callback(
State(test_state(None)),
format!(
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
root.display()
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
session_id,
token,
root.display(),
)
.parse::<Uri>()
.expect("uri"),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
session_id: Some(session_id),
token: Some(token),
root_uri: Some(format!("file://{}", root.display())),
path: Some("Page/report.docx".into()),
}),
@@ -2006,6 +2382,8 @@ mod tests {
Query(OnlyOfficeCallbackQuery {
asset_id: Some("asset_1".into()),
user_id: Some("user_1".into()),
session_id: None,
token: None,
root_uri: None,
path: None,
}),
@@ -2128,4 +2506,32 @@ mod tests {
assert!(html.contains("anonymous: { request: false, label: \"Guest\" }"));
assert!(html.contains("features: { featuresTips: false }"));
}
#[tokio::test]
async fn onlyoffice_page_reinitializes_edit_url_on_request_edit_rights() {
let response = page(Query(OnlyOfficePageQuery {
file_url: Some(
"http://localhost:3000/api/local-folder/files/open?rootUri=file:///tmp&path=Page/report.docx"
.into(),
),
file_name: Some("report.docx".into()),
file_type: Some("docx".into()),
asset_id: Some("local-file:Page/report.docx".into()),
document_id: Some("local-md:Page".into()),
user_id: None,
mode: Some("view".into()),
}))
.await
.expect("onlyoffice page");
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
assert!(html.contains("function editModeLocationHref()"));
assert!(html.contains("next.searchParams.set(\"mode\", \"edit\");"));
assert!(html.contains("onRequestEditRights: () =>"));
assert!(html.contains("window.__MNOTE_ONLYOFFICE_REQUEST_EDIT_RIGHTS__"));
assert!(html.contains("window.location.replace(editHref);"));
}
}