use axum::extract::{Path, Query}; use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; use axum::response::{Html, IntoResponse, Response}; use axum::Json; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::{HashMap, VecDeque}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; use tokio::time::{sleep, Duration, Instant}; use uuid::Uuid; pub const MNOTE_ONLYOFFICE_BRIDGE_GUID: &str = "asc.{05F87DDF-7B42-4C6F-9F2B-9C77A8D5F4E2}"; const MAX_LONG_POLL_MS: u64 = 25_000; const LONG_POLL_INTERVAL_MS: u64 = 250; static BRIDGE_COMMAND_COUNTER: AtomicU64 = AtomicU64::new(1); static BRIDGE_STATE: OnceLock>> = OnceLock::new(); #[derive(Debug, Default)] struct BridgeSessionState { queue: VecDeque, results: HashMap, token: Option, editor_type: Option, document_id: Option, asset_id: Option, file_type: Option, doc_key: Option, page_origin: Option, last_seen_millis: u128, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BridgeCommandWire { pub id: String, pub action: String, #[serde(default)] pub payload: Value, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BridgeResultWire { pub id: String, pub ok: bool, #[serde(default)] pub result: Value, #[serde(default)] pub error: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct BridgeSessionInfo { pub session_id: String, pub editor_type: Option, pub document_id: Option, pub asset_id: Option, pub file_type: Option, pub doc_key: Option, pub page_origin: Option, pub last_seen_millis: u128, pub pending_commands: usize, pub pending_results: usize, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BridgePluginConfigQuery { session_id: Option, api_base: Option, token: Option, document_id: Option, asset_id: Option, file_type: Option, doc_key: Option, page_origin: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BridgePluginIndexQuery { session_id: Option, api_base: Option, token: Option, document_id: Option, asset_id: Option, file_type: Option, doc_key: Option, page_origin: Option, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct BridgePluginIndexState { session_id: String, api_base: String, token: String, #[serde(default)] document_id: Option, #[serde(default)] asset_id: Option, #[serde(default)] file_type: Option, #[serde(default)] doc_key: Option, #[serde(default)] page_origin: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BridgeSessionPayload { pub session_id: String, #[serde(default)] pub token: Option, #[serde(default)] pub editor_type: Option, #[serde(default)] pub document_id: Option, #[serde(default)] pub asset_id: Option, #[serde(default)] pub file_type: Option, #[serde(default)] pub doc_key: Option, #[serde(default)] pub page_origin: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BridgeCloseSessionPayload { pub session_id: String, #[serde(default)] pub token: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BridgeEnqueuePayload { session_id: String, #[serde(default)] token: Option, action: String, #[serde(default)] payload: Value, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BridgeNextQuery { session_id: String, token: Option, timeout_ms: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BridgeResultQuery { session_id: String, token: Option, command_id: String, timeout_ms: Option, } pub async fn plugin_config(Query(query): Query) -> Response { let session_id = non_empty(query.session_id).unwrap_or_else(|| "mnote-onlyoffice".into()); let api_base = normalize_api_base(query.api_base.as_deref()); let token = non_empty(query.token).unwrap_or_else(new_bridge_token); let index_state = encode_index_state( &session_id, &api_base, &token, query.document_id.as_deref(), query.asset_id.as_deref(), query.file_type.as_deref(), query.doc_key.as_deref(), query.page_origin.as_deref(), ); let index_url = format!("{api_base}/api/onlyoffice/bridge/plugin/index/{index_state}"); let payload = json!({ "name": "MNote Bridge", "guid": MNOTE_ONLYOFFICE_BRIDGE_GUID, "mnoteBridgeToken": token, "version": "0.1.0", "variations": [{ "description": "MNote agent bridge for ONLYOFFICE documents, spreadsheets, and presentations.", "url": index_url, "EditorsSupport": ["word", "cell", "slide"], "isViewer": false, "isVisual": false, "isModal": false, "isInsideMode": false, "initDataType": "none", "initData": "", "buttons": [] }] }); json_response(payload) } pub async fn plugin_index(Query(query): Query) -> impl IntoResponse { let session_id = non_empty(query.session_id).unwrap_or_else(|| "mnote-onlyoffice".into()); let api_base = normalize_api_base(query.api_base.as_deref()); let token = non_empty(query.token).unwrap_or_else(new_bridge_token); render_plugin_index( &session_id, &api_base, &token, query.document_id.as_deref(), query.asset_id.as_deref(), query.file_type.as_deref(), query.doc_key.as_deref(), query.page_origin.as_deref(), ) } pub async fn plugin_index_with_state( Path(encoded_state): Path, Query(query): Query, ) -> impl IntoResponse { let state = decode_index_state(&encoded_state); let session_id = non_empty(query.session_id) .or_else(|| state.as_ref().map(|value| value.session_id.clone())) .unwrap_or_else(|| "mnote-onlyoffice".into()); let api_base = normalize_api_base(query.api_base.as_deref().or_else(|| { state .as_ref() .map(|value| value.api_base.as_str()) .filter(|value| !value.is_empty()) })); let document_id = query.document_id.as_deref().or_else(|| { state .as_ref() .and_then(|value| value.document_id.as_deref()) }); let asset_id = query .asset_id .as_deref() .or_else(|| state.as_ref().and_then(|value| value.asset_id.as_deref())); let file_type = query .file_type .as_deref() .or_else(|| state.as_ref().and_then(|value| value.file_type.as_deref())); let doc_key = query .doc_key .as_deref() .or_else(|| state.as_ref().and_then(|value| value.doc_key.as_deref())); let page_origin = query.page_origin.as_deref().or_else(|| { state .as_ref() .and_then(|value| value.page_origin.as_deref()) }); let token = non_empty(query.token) .or_else(|| { state .as_ref() .map(|value| value.token.as_str()) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) }) .unwrap_or_else(new_bridge_token); render_plugin_index( &session_id, &api_base, &token, document_id, asset_id, file_type, doc_key, page_origin, ) } fn render_plugin_index( session_id: &str, api_base: &str, token: &str, document_id: Option<&str>, asset_id: Option<&str>, file_type: Option<&str>, doc_key: Option<&str>, page_origin: Option<&str>, ) -> Html { let html = format!( r#" "#, session_id = json_string(session_id), api_base = json_string(api_base), token = json_string(token), document_id = json_string(document_id.unwrap_or("")), asset_id = json_string(asset_id.unwrap_or("")), file_type = json_string(file_type.unwrap_or("")), doc_key = json_string(doc_key.unwrap_or("")), page_origin = json_string(page_origin.unwrap_or("")), ); Html(html) } pub async fn register_session(Json(payload): Json) -> Response { let Some(session_id) = non_empty(Some(payload.session_id)) else { return bad_request("onlyoffice_bridge_session_required", "缺少 sessionId"); }; let Some(token) = non_empty(payload.token) else { return unauthorized("onlyoffice_bridge_token_required", "缺少 bridge token"); }; let mut state = bridge_state().lock().expect("bridge state lock poisoned"); let session = state.entry(session_id.clone()).or_default(); session.token = Some(token); session.last_seen_millis = now_millis(); session.editor_type = payload .editor_type .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); session.document_id = payload .document_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); session.asset_id = payload .asset_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); session.file_type = payload .file_type .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); session.doc_key = payload .doc_key .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); session.page_origin = payload .page_origin .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); json_response(json!({ "ok": true, "sessionId": session_id, "editorType": session.editor_type.clone().unwrap_or_default(), "documentId": session.document_id.clone().unwrap_or_default(), "assetId": session.asset_id.clone().unwrap_or_default(), "fileType": session.file_type.clone().unwrap_or_default(), "docKey": session.doc_key.clone().unwrap_or_default(), "pageOrigin": session.page_origin.clone().unwrap_or_default() })) } pub async fn list_sessions() -> Response { json_response(json!({ "ok": true, "sessions": session_infos() })) } pub async fn current_session() -> Response { let current = session_infos().into_iter().next(); json_response(json!({ "ok": true, "session": current })) } pub async fn close_session(Json(payload): Json) -> Response { let Some(session_id) = non_empty(Some(payload.session_id)) else { return bad_request("onlyoffice_bridge_session_required", "缺少 sessionId"); }; if let Err(response) = ensure_session_token(&session_id, payload.token.as_deref()) { return response; } let mut state = bridge_state().lock().expect("bridge state lock poisoned"); let closed = state.remove(&session_id).is_some(); json_response(json!({ "ok": true, "sessionId": session_id, "closed": closed })) } pub async fn capabilities() -> Response { json_response(capability_payload()) } pub fn capability_payload() -> Value { json!({ "ok": true, "schema": "mnote.onlyoffice.bridge_capabilities.v1", "guid": MNOTE_ONLYOFFICE_BRIDGE_GUID, "actions": [ "selection.get", "document.insert_text", "document.replace_selection", "document.insert_html", "document.export", "document.search_replace", "document.insert_table", "document.get_comments", "document.add_comment", "sheet.get_sheets", "sheet.add_sheet", "sheet.rename_sheet", "sheet.get_range", "sheet.get_range_values", "sheet.get_values", "sheet.set_value", "sheet.set_formula", "sheet.batch_set_values", "sheet.set_range_values", "sheet.format_range", "sheet.set_dimensions", "sheet.sort_range", "sheet.add_chart", "presentation.get_slides", "presentation.get_slide_texts", "presentation.get_shapes", "presentation.add_text_slide", "presentation.replace_text", "presentation.set_shape_text", "presentation.delete_slide", "presentation.add_table", "presentation.clear_slide", "presentation.add_shape" ], "editors": { "word": ["selection.get", "document.insert_text", "document.replace_selection", "document.insert_html", "document.export", "document.search_replace", "document.insert_table", "document.get_comments", "document.add_comment"], "cell": ["selection.get", "sheet.get_sheets", "sheet.add_sheet", "sheet.rename_sheet", "sheet.get_range", "sheet.get_range_values", "sheet.get_values", "sheet.set_value", "sheet.set_formula", "sheet.batch_set_values", "sheet.set_range_values", "sheet.format_range", "sheet.set_dimensions", "sheet.sort_range", "sheet.add_chart"], "slide": ["selection.get", "presentation.get_slides", "presentation.get_slide_texts", "presentation.get_shapes", "presentation.add_text_slide", "presentation.replace_text", "presentation.set_shape_text", "presentation.delete_slide", "presentation.add_table", "presentation.clear_slide", "presentation.add_shape"] } }) } pub async fn enqueue_command(Json(payload): Json) -> Response { let Some(session_id) = non_empty(Some(payload.session_id)) else { return bad_request("onlyoffice_bridge_session_required", "缺少 sessionId"); }; if let Err(response) = ensure_session_token(&session_id, payload.token.as_deref()) { return response; } let Some(action) = non_empty(Some(payload.action)) else { return bad_request("onlyoffice_bridge_action_required", "缺少 action"); }; let command = push_command(&session_id, action, payload.payload); json_response(json!({ "ok": true, "sessionId": session_id, "command": command })) } pub async fn next_command(Query(query): Query) -> Response { let Some(session_id) = non_empty(Some(query.session_id)) else { return bad_request("onlyoffice_bridge_session_required", "缺少 sessionId"); }; if let Err(response) = ensure_session_token(&session_id, query.token.as_deref()) { return response; } let timeout = query .timeout_ms .unwrap_or(MAX_LONG_POLL_MS) .min(MAX_LONG_POLL_MS); let deadline = Instant::now() + Duration::from_millis(timeout); loop { if let Some(command) = pop_command(&session_id) { return json_response(json!(command)); } if Instant::now() >= deadline { return StatusCode::NO_CONTENT.into_response(); } sleep(Duration::from_millis(LONG_POLL_INTERVAL_MS)).await; } } pub async fn post_result(Json(payload): Json) -> Response { let Some(session_id) = payload .get("sessionId") .and_then(Value::as_str) .map(str::to_string) .and_then(|value| non_empty(Some(value))) else { return bad_request("onlyoffice_bridge_session_required", "缺少 sessionId"); }; if let Err(response) = ensure_session_token(&session_id, payload.get("token").and_then(Value::as_str)) { return response; } let Ok(result) = serde_json::from_value::(payload) else { return bad_request("onlyoffice_bridge_result_invalid", "bridge result 格式无效"); }; let mut state = bridge_state().lock().expect("bridge state lock poisoned"); let session = state.entry(session_id.clone()).or_default(); session.results.insert(result.id.clone(), result.clone()); json_response(json!({ "ok": true, "sessionId": session_id, "result": result })) } pub async fn get_result(Query(query): Query) -> Response { let Some(session_id) = non_empty(Some(query.session_id)) else { return bad_request("onlyoffice_bridge_session_required", "缺少 sessionId"); }; let Some(command_id) = non_empty(Some(query.command_id)) else { return bad_request("onlyoffice_bridge_command_required", "缺少 commandId"); }; if let Err(response) = ensure_session_token(&session_id, query.token.as_deref()) { return response; } let timeout = query .timeout_ms .unwrap_or(MAX_LONG_POLL_MS) .min(MAX_LONG_POLL_MS); let deadline = Instant::now() + Duration::from_millis(timeout); loop { if let Some(result) = take_result(&session_id, &command_id) { return json_response(json!(result)); } if Instant::now() >= deadline { return StatusCode::NO_CONTENT.into_response(); } sleep(Duration::from_millis(LONG_POLL_INTERVAL_MS)).await; } } pub fn session_info(session_id: &str) -> Option { let session_id = session_id.trim(); if session_id.is_empty() { return None; } session_infos() .into_iter() .find(|session| session.session_id == session_id) } pub fn session_token_matches(session_id: &str, token: &str) -> bool { let session_id = session_id.trim(); let token = token.trim(); if session_id.is_empty() || token.is_empty() { return false; } let state = bridge_state().lock().expect("bridge state lock poisoned"); state .get(session_id) .and_then(|session| session.token.as_deref()) .is_some_and(|expected| expected == token) } pub fn session_infos() -> Vec { let state = bridge_state().lock().expect("bridge state lock poisoned"); let mut sessions = state .iter() .map(|(session_id, session)| (session_id.clone(), session.last_seen_millis)) .collect::>(); sessions.sort_by(|(_, left), (_, right)| right.cmp(left)); sessions .into_iter() .filter_map(|(session_id, _)| { let session = state.get(&session_id)?; Some(BridgeSessionInfo { session_id: session_id.clone(), editor_type: session.editor_type.clone(), document_id: session.document_id.clone(), asset_id: session.asset_id.clone(), file_type: session.file_type.clone(), doc_key: session.doc_key.clone(), page_origin: session.page_origin.clone(), last_seen_millis: session.last_seen_millis, pending_commands: session.queue.len(), pending_results: session.results.len(), }) }) .collect() } pub async fn run_bridge_command( session_id: &str, action: &str, payload: Value, timeout_ms: u64, ) -> Result { let session_id = session_id.trim(); if session_id.is_empty() { return Err(BridgeRunError::BadRequest( "缺少 ONLYOFFICE bridge sessionId".into(), )); } let action = action.trim(); if action.is_empty() { return Err(BridgeRunError::BadRequest( "缺少 ONLYOFFICE bridge action".into(), )); } let token = { let state = bridge_state().lock().expect("bridge state lock poisoned"); state .get(session_id) .and_then(|session| session.token.clone()) .ok_or_else(|| { BridgeRunError::BadRequest("ONLYOFFICE bridge session 未注册或缺少 token".into()) })? }; let command = push_command(session_id, action.to_string(), payload); let timeout = timeout_ms.min(MAX_LONG_POLL_MS); let deadline = Instant::now() + Duration::from_millis(timeout); loop { if ensure_session_token(session_id, Some(&token)).is_err() { return Err(BridgeRunError::BadRequest( "ONLYOFFICE bridge session token 已失效".into(), )); } if let Some(result) = take_result(session_id, &command.id) { return Ok(result); } if Instant::now() >= deadline { return Err(BridgeRunError::Timeout { session_id: session_id.to_string(), command_id: command.id, }); } sleep(Duration::from_millis(LONG_POLL_INTERVAL_MS)).await; } } #[derive(Debug, Clone)] pub enum BridgeRunError { BadRequest(String), Timeout { session_id: String, command_id: String, }, } fn bridge_state() -> &'static Mutex> { BRIDGE_STATE.get_or_init(|| Mutex::new(HashMap::new())) } fn push_command(session_id: &str, action: String, payload: Value) -> BridgeCommandWire { let command = BridgeCommandWire { id: format!( "oo_cmd_{}_{}", now_millis(), BRIDGE_COMMAND_COUNTER.fetch_add(1, Ordering::Relaxed) ), action, payload, }; let mut state = bridge_state().lock().expect("bridge state lock poisoned"); state .entry(session_id.to_string()) .or_default() .queue .push_back(command.clone()); command } fn pop_command(session_id: &str) -> Option { let mut state = bridge_state().lock().expect("bridge state lock poisoned"); let session = state.entry(session_id.to_string()).or_default(); session.last_seen_millis = now_millis(); session.queue.pop_front() } fn take_result(session_id: &str, command_id: &str) -> Option { let mut state = bridge_state().lock().expect("bridge state lock poisoned"); state .get_mut(session_id) .and_then(|session| session.results.remove(command_id)) } fn non_empty(value: Option) -> Option { value .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } fn normalize_api_base(value: Option<&str>) -> String { value .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("http://127.0.0.1:3000") .trim_end_matches('/') .to_string() } fn encode_index_state( session_id: &str, api_base: &str, token: &str, document_id: Option<&str>, asset_id: Option<&str>, file_type: Option<&str>, doc_key: Option<&str>, page_origin: Option<&str>, ) -> String { let payload = json!(BridgePluginIndexState { session_id: session_id.to_string(), api_base: api_base.to_string(), token: token.to_string(), document_id: document_id .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), asset_id: asset_id .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), file_type: file_type .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), doc_key: doc_key .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), page_origin: page_origin .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), }); URL_SAFE_NO_PAD.encode(payload.to_string()) } fn decode_index_state(encoded_state: &str) -> Option { let bytes = URL_SAFE_NO_PAD.decode(encoded_state.as_bytes()).ok()?; serde_json::from_slice(&bytes).ok() } fn json_response(payload: Value) -> Response { let mut headers = HeaderMap::new(); headers.insert( header::CONTENT_TYPE, HeaderValue::from_static("application/json; charset=utf-8"), ); (StatusCode::OK, headers, payload.to_string()).into_response() } fn unauthorized(code: &'static str, message: &'static str) -> Response { ( StatusCode::UNAUTHORIZED, Json(json!({ "ok": false, "code": code, "message": message })), ) .into_response() } fn bad_request(code: &'static str, message: &'static str) -> Response { ( StatusCode::BAD_REQUEST, Json(json!({ "ok": false, "code": code, "message": message })), ) .into_response() } fn new_bridge_token() -> String { Uuid::new_v4().to_string() } fn ensure_session_token(session_id: &str, token: Option<&str>) -> Result<(), Response> { let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else { return Err(unauthorized( "onlyoffice_bridge_token_required", "缺少 bridge token", )); }; let state = bridge_state().lock().expect("bridge state lock poisoned"); match state .get(session_id) .and_then(|session| session.token.as_deref()) { Some(expected) if expected == token => Ok(()), Some(_) => Err(unauthorized( "onlyoffice_bridge_token_invalid", "bridge token 无效", )), None => Err(unauthorized( "onlyoffice_bridge_session_unregistered", "bridge session 未注册", )), } } fn json_string(input: &str) -> String { serde_json::to_string(input).unwrap_or_else(|_| "\"\"".into()) } fn now_millis() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn bridge_plugin_config_exposes_dynamic_index_and_guid() { let response = plugin_config(Query(BridgePluginConfigQuery { session_id: Some("session-a".into()), api_base: Some("http://127.0.0.1:3000/".into()), token: Some("token-a".into()), document_id: Some("doc-a".into()), asset_id: Some("asset-a".into()), file_type: Some("xlsx".into()), doc_key: Some("doc-key-a".into()), page_origin: Some("http://127.0.0.1:3000".into()), })) .await; assert_eq!(response.status(), StatusCode::OK); 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["guid"], MNOTE_ONLYOFFICE_BRIDGE_GUID); assert_eq!(payload["version"], "0.1.0"); let url = payload["variations"][0]["url"].as_str().unwrap_or_default(); assert!(url.contains("/api/onlyoffice/bridge/plugin/index")); assert!(!url.contains('?')); let encoded_state = url.rsplit('/').next().unwrap_or_default(); let state = decode_index_state(encoded_state).expect("encoded state"); assert_eq!(state.session_id, "session-a"); assert_eq!(state.api_base, "http://127.0.0.1:3000"); assert_eq!(state.token, "token-a"); assert_eq!(state.document_id.as_deref(), Some("doc-a")); assert_eq!(state.asset_id.as_deref(), Some("asset-a")); assert_eq!(state.file_type.as_deref(), Some("xlsx")); assert_eq!(state.doc_key.as_deref(), Some("doc-key-a")); assert_eq!(state.page_origin.as_deref(), Some("http://127.0.0.1:3000")); } #[tokio::test] async fn bridge_plugin_index_accepts_path_state_with_onlyoffice_query_params() { let encoded_state = encode_index_state( "session-b", "http://127.0.0.1:3000", "token-b", Some("doc-b"), Some("asset-b"), Some("pptx"), Some("doc-key-b"), Some("http://127.0.0.1:3001"), ); let response = plugin_index_with_state( Path(encoded_state), Query(BridgePluginIndexQuery { session_id: None, api_base: None, token: None, document_id: None, asset_id: None, file_type: None, doc_key: None, page_origin: None, }), ) .await .into_response(); assert_eq!(response.status(), StatusCode::OK); 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(r#"var sessionId = "session-b";"#)); assert!(html.contains(r#"var apiBase = "http://127.0.0.1:3000";"#)); assert!(html.contains(r#"documentId: "doc-b""#)); assert!(html.contains(r#"assetId: "asset-b""#)); assert!(html.contains(r#"fileType: "pptx""#)); assert!(html.contains(r#"docKey: "doc-key-b""#)); assert!(html.contains(r#"pageOrigin: "http://127.0.0.1:3001""#)); } #[tokio::test] async fn bridge_plugin_index_exposes_second_batch_recipe_actions() { let response = plugin_index(Query(BridgePluginIndexQuery { session_id: Some("session-recipes".into()), api_base: Some("http://127.0.0.1:3000".into()), token: Some("token-recipes".into()), document_id: None, asset_id: None, file_type: None, doc_key: None, page_origin: None, })) .await .into_response(); assert_eq!(response.status(), StatusCode::OK); 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(r#"case "document.search_replace":"#)); assert!(html.contains(r#"case "document.insert_table":"#)); assert!(html.contains(r#"case "document.get_comments":"#)); assert!(html.contains(r#"case "document.add_comment":"#)); assert!(html.contains(r#"case "sheet.get_sheets":"#)); assert!(html.contains(r#"case "sheet.add_sheet":"#)); assert!(html.contains(r#"case "sheet.rename_sheet":"#)); assert!(html.contains(r#"case "sheet.get_range_values":"#)); assert!(html.contains(r#"case "sheet.set_range_values":"#)); assert!(html.contains(r#"case "sheet.format_range":"#)); assert!(html.contains(r#"case "sheet.set_dimensions":"#)); assert!(html.contains(r#"case "sheet.sort_range":"#)); assert!(html.contains(r#"case "sheet.add_chart":"#)); assert!(html.contains("range.SetSort")); assert!(html.contains("worksheet.AddChart")); assert!(html.contains(r#"case "presentation.get_slide_texts":"#)); assert!(html.contains(r#"case "presentation.get_shapes":"#)); assert!(html.contains(r#"case "presentation.replace_text":"#)); assert!(html.contains(r#"case "presentation.set_shape_text":"#)); assert!(html.contains(r#"case "presentation.delete_slide":"#)); assert!(html.contains(r#"case "presentation.add_table":"#)); assert!(html.contains(r#"case "presentation.clear_slide":"#)); assert!(html.contains(r#"case "presentation.add_shape":"#)); assert!(html.contains("slide.AddObject(table)")); assert!(html.contains("slide.RemoveAllObjects()")); assert!(html.contains("Api.CreateShape")); assert!(html.contains("function expectedEditorTypeForAction(action)")); assert!(html.contains(r#"if (name.indexOf("document.") === 0) return "word";"#)); assert!(html.contains(r#"if (name.indexOf("sheet.") === 0) return "cell";"#)); assert!(html.contains(r#"if (name.indexOf("presentation.") === 0) return "slide";"#)); assert!(html.contains("onlyoffice_editor_type_mismatch")); } #[tokio::test] async fn bridge_session_registration_lists_metadata_and_capabilities() { let session_id = format!("test-session-meta-{}", now_millis()); let token = format!("token-{session_id}"); let registered = register_session(Json(BridgeSessionPayload { session_id: session_id.clone(), token: Some(token), editor_type: Some("slide".into()), document_id: Some("doc-meta".into()), asset_id: Some("asset-meta".into()), file_type: Some("pptx".into()), doc_key: Some("doc-key-meta".into()), page_origin: Some("http://127.0.0.1:3000".into()), })) .await; assert_eq!(registered.status(), StatusCode::OK); let listed = list_sessions().await; assert_eq!(listed.status(), StatusCode::OK); let body = axum::body::to_bytes(listed.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); let session = payload["sessions"] .as_array() .expect("sessions") .iter() .find(|value| value["sessionId"] == session_id) .expect("registered session"); assert_eq!(session["editorType"], "slide"); assert_eq!(session["documentId"], "doc-meta"); assert_eq!(session["assetId"], "asset-meta"); assert_eq!(session["fileType"], "pptx"); assert_eq!(session["docKey"], "doc-key-meta"); assert_eq!(session["pageOrigin"], "http://127.0.0.1:3000"); let current = current_session().await; assert_eq!(current.status(), StatusCode::OK); let body = axum::body::to_bytes(current.into_body(), usize::MAX) .await .expect("current body"); let payload: Value = serde_json::from_slice(&body).expect("current json"); assert!(payload["session"].is_object() || payload["session"].is_null()); let closed = close_session(Json(BridgeCloseSessionPayload { session_id: session_id.clone(), token: Some(format!("token-{session_id}")), })) .await; assert_eq!(closed.status(), StatusCode::OK); assert!(session_info(&session_id).is_none()); let capabilities = capabilities().await; let body = axum::body::to_bytes(capabilities.into_body(), usize::MAX) .await .expect("capabilities body"); let payload: Value = serde_json::from_slice(&body).expect("capabilities json"); assert!(payload["actions"] .as_array() .expect("actions") .iter() .any(|action| action == "presentation.set_shape_text")); assert!(payload["actions"] .as_array() .expect("actions") .iter() .any(|action| action == "sheet.format_range")); assert!(payload["actions"] .as_array() .expect("actions") .iter() .any(|action| action == "presentation.delete_slide")); assert!(payload["actions"] .as_array() .expect("actions") .iter() .any(|action| action == "document.insert_table")); assert!(payload["actions"] .as_array() .expect("actions") .iter() .any(|action| action == "document.get_comments")); assert!(payload["actions"] .as_array() .expect("actions") .iter() .any(|action| action == "document.add_comment")); assert!(payload["actions"] .as_array() .expect("actions") .iter() .any(|action| action == "sheet.sort_range")); assert!(payload["actions"] .as_array() .expect("actions") .iter() .any(|action| action == "sheet.add_chart")); assert!(payload["actions"] .as_array() .expect("actions") .iter() .any(|action| action == "presentation.add_table")); assert!(payload["actions"] .as_array() .expect("actions") .iter() .any(|action| action == "presentation.clear_slide")); assert!(payload["actions"] .as_array() .expect("actions") .iter() .any(|action| action == "presentation.add_shape")); } #[tokio::test] async fn bridge_queue_round_trips_command_and_result() { let session_id = format!("test-session-{}", now_millis()); let token = format!("token-{session_id}"); let registered = register_session(Json(BridgeSessionPayload { session_id: session_id.clone(), token: Some(token.clone()), editor_type: Some("word".into()), document_id: None, asset_id: None, file_type: None, doc_key: None, page_origin: None, })) .await; assert_eq!(registered.status(), StatusCode::OK); let denied = enqueue_command(Json(BridgeEnqueuePayload { session_id: session_id.clone(), token: None, action: "selection.get".into(), payload: json!({"sample": true}), })) .await; assert_eq!(denied.status(), StatusCode::UNAUTHORIZED); let enqueue = enqueue_command(Json(BridgeEnqueuePayload { session_id: session_id.clone(), token: Some(token.clone()), action: "selection.get".into(), payload: json!({"sample": true}), })) .await; assert_eq!(enqueue.status(), StatusCode::OK); let body = axum::body::to_bytes(enqueue.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); let command_id = payload["command"]["id"].as_str().unwrap().to_string(); let next = next_command(Query(BridgeNextQuery { session_id: session_id.clone(), token: Some(token.clone()), timeout_ms: Some(1), })) .await; assert_eq!(next.status(), StatusCode::OK); let body = axum::body::to_bytes(next.into_body(), usize::MAX) .await .expect("body"); let command: BridgeCommandWire = serde_json::from_slice(&body).expect("command"); assert_eq!(command.id, command_id); assert_eq!(command.action, "selection.get"); let posted = post_result(Json(json!({ "sessionId": session_id, "token": token, "id": command_id, "ok": true, "result": {"text": "hello"} }))) .await; assert_eq!(posted.status(), StatusCode::OK); } }