use super::onlyoffice_bridge; use crate::app::AppConfig; use crate::app::AppState; use crate::error::WebError; use adapter_onlyoffice::{ prepare_callback, prepare_proxy_request, sign_config, OnlyOfficeCallbackPreparationInput, OnlyOfficeProxyPreparationInput, }; use axum::body::{Body, Bytes}; use axum::extract::{Path, Query, State}; use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri}; use axum::response::{Html, IntoResponse, Response}; use axum::Json; use base64::Engine; use futures_util::{SinkExt, StreamExt}; use hyper::upgrade::Upgraded; use hyper_util::rt::TokioIo; 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; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::handshake::derive_accept_key; use tokio_tungstenite::tungstenite::protocol::Role; use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; use tokio_tungstenite::WebSocketStream; const ONLYOFFICE_PROBE_PATH: &str = "/web-apps/apps/api/documents/api.js"; const DEFAULT_ONLYOFFICE_INTERNAL_URL: &str = "http://127.0.0.1:8082"; const ONLYOFFICE_RUNTIME_REWRITE_SNIPPET: &str = r#""#; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OnlyOfficePageQuery { file_url: Option, file_name: Option, file_type: Option, asset_id: Option, document_id: Option, user_id: Option, mode: Option, } #[derive(Debug, Deserialize)] pub struct OnlyOfficeProxyQuery { u: Option, } #[derive(Debug, Deserialize)] pub struct OnlyOfficeCallbackQuery { #[serde(rename = "assetId")] asset_id: Option, #[serde(rename = "userId")] user_id: Option, #[serde(rename = "sessionId")] session_id: Option, token: Option, #[serde(rename = "rootUri")] root_uri: Option, path: Option, } #[derive(Debug, Deserialize)] pub struct OnlyOfficeForcesaveQuery { #[serde(rename = "assetId")] asset_id: Option, key: Option, } #[derive(Debug, Deserialize)] pub struct OnlyOfficeSignPayload { config: Option, } fn env_or_dotenv(key: &str) -> Option { if let Ok(value) = env::var(key) { let trimmed = value.trim().trim_matches('"').to_string(); if !trimmed.is_empty() { return Some(trimmed); } } let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../..") .join(".env.all"); let content = fs::read_to_string(root).ok()?; for line in content.lines() { let line = line.trim_end_matches('\r'); if line.starts_with('#') || line.trim().is_empty() { continue; } let Some((k, v)) = line.split_once('=') else { continue; }; if k.trim() != key { continue; } let trimmed = v.trim().trim_matches('"').to_string(); if !trimmed.is_empty() { return Some(trimmed); } } None } fn normalize_http_origin(raw: &str) -> Option { let value = raw.trim().trim_end_matches('/'); if value.is_empty() || value == "/onlyoffice-server" { return None; } let url = reqwest::Url::parse(value).ok()?; if url.scheme() != "http" && url.scheme() != "https" { return None; } Some(url.to_string().trim_end_matches('/').to_string()) } fn onlyoffice_internal_candidates() -> Vec { let mut candidates = Vec::new(); let mut push = |value: Option| { let Some(value) = value else { return; }; let Some(normalized) = normalize_http_origin(&value) else { return; }; if !candidates.contains(&normalized) { candidates.push(normalized); } }; push(env_or_dotenv("ONLYOFFICE_INTERNAL_URL")); if let Some(raw) = env_or_dotenv("ONLYOFFICE_INTERNAL_URL_CANDIDATES") { for value in raw.split(',') { push(Some(value.to_string())); } } push(Some(DEFAULT_ONLYOFFICE_INTERNAL_URL.into())); push(Some("http://127.0.0.1:8081".into())); push(Some("http://localhost:8082".into())); push(Some("http://localhost:8081".into())); if candidates.is_empty() { candidates.push(DEFAULT_ONLYOFFICE_INTERNAL_URL.into()); } candidates } fn onlyoffice_document_url_base_js() -> String { env_or_dotenv("ONLYOFFICE_DOCUMENT_URL_BASE") .or_else(|| env_or_dotenv("MNOTE_WEB_ONLYOFFICE_INTERNAL_BASE_URL")) .and_then(|value| normalize_http_origin(&value)) .unwrap_or_else(|| "http://host.docker.internal:3000".into()) } async fn resolve_onlyoffice_internal_url() -> String { let client = reqwest::Client::builder() .timeout(Duration::from_millis(2_500)) .build() .unwrap_or_else(|_| reqwest::Client::new()); let candidates = onlyoffice_internal_candidates(); for candidate in &candidates { let probe_url = format!("{candidate}{ONLYOFFICE_PROBE_PATH}"); if client .head(probe_url) .send() .await .map(|response| response.status().is_success()) .unwrap_or(false) { return candidate.clone(); } } candidates .first() .cloned() .unwrap_or_else(|| DEFAULT_ONLYOFFICE_INTERNAL_URL.into()) } fn escape_html(value: &str) -> String { value .replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) } fn json_string(value: &str) -> String { serde_json::to_string(value).unwrap_or_else(|_| "\"\"".into()) } fn encode_query_component(value: &str) -> String { let mut encoded = String::new(); for byte in value.as_bytes() { let ch = *byte as char; if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '~') { encoded.push(ch); } else { encoded.push_str(&format!("%{byte:02X}")); } } encoded } fn header_value(headers: &HeaderMap, name: &str) -> Option { headers .get(name) .and_then(|value| value.to_str().ok()) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn forwarded_public_origin(headers: &HeaderMap) -> (String, String, String) { let origin_like = header_value(headers, "origin") .or_else(|| header_value(headers, "referer")) .and_then(|value| reqwest::Url::parse(&value).ok()); let forwarded_host_raw = header_value(headers, "x-forwarded-host") .or_else(|| header_value(headers, header::HOST.as_str())) .or_else(|| { origin_like.as_ref().map(|url| match url.port() { Some(port) => format!("{}:{port}", url.host_str().unwrap_or_default()), None => url.host_str().unwrap_or_default().to_string(), }) }) .unwrap_or_else(|| "127.0.0.1:3000".into()); let forwarded_host = forwarded_host_raw .split(',') .next() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("127.0.0.1:3000") .to_string(); let forwarded_proto = header_value(headers, "x-forwarded-proto") .and_then(|value| { value .split(',') .next() .map(str::trim) .map(ToOwned::to_owned) }) .filter(|value| !value.is_empty()) .or_else(|| origin_like.as_ref().map(|url| url.scheme().to_string())) .unwrap_or_else(|| "http".into()); let forwarded_port = header_value(headers, "x-forwarded-port") .and_then(|value| { value .split(',') .next() .map(str::trim) .map(ToOwned::to_owned) }) .filter(|value| !value.is_empty()) .or_else(|| { forwarded_host .rsplit(':') .next() .filter(|value| value.chars().all(|ch| ch.is_ascii_digit())) .map(ToOwned::to_owned) }) .unwrap_or_else(|| { if forwarded_proto == "https" { "443".into() } else { "80".into() } }); (forwarded_host, forwarded_proto, forwarded_port) } pub async fn page(Query(query): Query) -> Result { let file_url = query.file_url.unwrap_or_default(); let file_name = query.file_name.unwrap_or_else(|| "附件".into()); let file_type = query.file_type.unwrap_or_else(|| "docx".into()); let asset_id = query.asset_id.unwrap_or_default(); let document_id = query.document_id.unwrap_or_default(); let user_id = query.user_id.unwrap_or_default(); let mode = match query.mode.as_deref() { Some("view") => "view", _ => "edit", }; let document_url_base = onlyoffice_document_url_base_js(); let html = format!( r#" {title}

ONLYOFFICE 加载失败

"#, title = escape_html(&file_name), file_url = json_string(&file_url), file_name = json_string(&file_name), file_type = json_string(&file_type), asset_id = json_string(&asset_id), document_id = json_string(&document_id), user_id = json_string(&user_id), mode = json_string(mode), document_url_base = json_string(&document_url_base), ); Ok(Html(html).into_response()) } pub async fn object_shell( Path((document_id, asset_id)): Path<(String, String)>, Query(query): Query, ) -> Result { let document_id = document_id.trim(); let asset_id = asset_id.trim(); if document_id.is_empty() || asset_id.is_empty() { return Err(WebError::bad_request_code( "onlyoffice_resource_identity_required", "缺少有效 documentId 或 assetId", )); } let file_name = query.file_name.unwrap_or_else(|| "附件".into()); let file_type = query.file_type.unwrap_or_else(|| "docx".into()); let mode = query.mode.unwrap_or_else(|| "edit".into()); let user_id = query.user_id.unwrap_or_default(); let file_url = query.file_url.unwrap_or_default(); let onlyoffice_url = { let mut params = Vec::new(); params.push(("fileUrl", file_url.as_str())); params.push(("fileName", file_name.as_str())); params.push(("fileType", file_type.as_str())); params.push(("assetId", asset_id)); params.push(("documentId", document_id)); params.push(("userId", user_id.as_str())); params.push(("mode", mode.as_str())); let query = params .into_iter() .map(|(key, value)| format!("{key}={}", encode_query_component(value))) .collect::>() .join("&"); format!("/onlyoffice?{query}") }; let object_identity = format!("resource:onlyoffice:{document_id}:{asset_id}"); let html = format!( r#" {title}
"#, title = escape_html(&file_name), object_identity = escape_html(&object_identity), document_id = escape_html(document_id), asset_id = escape_html(asset_id), onlyoffice_url = escape_html(&onlyoffice_url), ); Ok(Html(html).into_response()) } pub async fn sign(Json(payload): Json) -> Result { let config = payload.config.ok_or_else(|| { WebError::bad_request_code("onlyoffice_sign_config_missing", "缺少 config") })?; let secret = env_or_dotenv("ONLYOFFICE_JWT_SECRET").unwrap_or_default(); let tokens = sign_config(&config, &secret) .map_err(|error| WebError::internal(format!("OnlyOffice 签名失败: {error}")))?; Ok(Json(tokens).into_response()) } fn proxy_origin_env(key: &str) -> Option { env_or_dotenv(key).and_then(|value| normalize_http_origin(&value)) } pub async fn proxy( Query(query): Query, headers: HeaderMap, method: Method, ) -> Result { let encoded_url = query .u .as_deref() .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(), range: headers .get(header::RANGE) .and_then(|value| value.to_str().ok()) .map(ToOwned::to_owned), supabase_url: proxy_origin_env("NEXT_PUBLIC_SUPABASE_URL") .or_else(|| proxy_origin_env("SUPABASE_URL")), supabase_internal_url: proxy_origin_env("SUPABASE_INTERNAL_URL"), onlyoffice_storage_host_override: env_or_dotenv( "NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE", ), convex_origin: None, supabase_anon_key: env_or_dotenv("NEXT_PUBLIC_SUPABASE_ANON_KEY") .or_else(|| env_or_dotenv("SUPABASE_ANON_KEY")), }) .map_err(|error| WebError::bad_request_code("onlyoffice_proxy_prepare_failed", error))?; let client = reqwest::Client::new(); let mut request = client.request( reqwest::Method::from_bytes(method.as_str().as_bytes()).unwrap_or(reqwest::Method::GET), &prepared.target_url, ); for item in prepared.forward_headers { request = request.header(item.name, item.value); } let upstream = request.send().await.map_err(|error| { WebError::bad_gateway_code( "onlyoffice_proxy_fetch_failed", format!("回源下载失败: {error}"), ) })?; let status = upstream.status(); let mut response_headers = HeaderMap::new(); for (name, value) in upstream.headers() { if name == header::SET_COOKIE { continue; } response_headers.insert(name, value.clone()); } if method == Method::HEAD { let mut response = Response::new(Body::empty()); *response.status_mut() = status; *response.headers_mut() = response_headers; return Ok(response); } let bytes = upstream.bytes().await.map_err(|error| { WebError::bad_gateway_code( "onlyoffice_proxy_body_failed", format!("读取回源文件失败: {error}"), ) })?; let mut response = Response::new(Body::from(bytes)); *response.status_mut() = status; *response.headers_mut() = response_headers; 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)) } fn is_onlyoffice_local_asset_id(asset_id: &str) -> bool { let value = asset_id.trim(); value.starts_with("local:") || value.starts_with("local-file:") } fn onlyoffice_callback_success(extra: Value) -> Response { let mut payload = json!({ "error": 0 }); if let (Some(object), Some(extra_object)) = (payload.as_object_mut(), extra.as_object()) { for (key, value) in extra_object { object.insert(key.clone(), value.clone()); } } Json(payload).into_response() } fn onlyoffice_callback_failure(error: WebError) -> Response { ( error.status(), Json(json!({ "error": 1, "code": error.code(), "message": error.message(), })), ) .into_response() } async fn download_onlyoffice_callback_body(download_url: &str) -> Result { let client = reqwest::Client::builder() .timeout(Duration::from_secs(60)) .build() .map_err(|error| { WebError::internal(format!("OnlyOffice callback HTTP 客户端创建失败: {error}")) })?; let response = client.get(download_url).send().await.map_err(|error| { WebError::bad_gateway_code( "onlyoffice_local_callback_download_failed", format!("OnlyOffice 保存文件下载失败: {error}"), ) })?; let status = response.status(); if !status.is_success() { return Err(WebError::bad_gateway_code( "onlyoffice_local_callback_download_failed", format!("OnlyOffice 保存文件下载失败: HTTP {status}"), )); } response.bytes().await.map_err(|error| { WebError::bad_gateway_code( "onlyoffice_local_callback_body_failed", format!("OnlyOffice 保存文件读取失败: {error}"), ) }) } async fn local_folder_onlyoffice_callback( query: &OnlyOfficeCallbackQuery, body: &Value, status: i64, ) -> Result { let asset_id = query.asset_id.as_deref().unwrap_or("").trim(); let root_uri = query .root_uri .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code( "onlyoffice_local_callback_root_missing", "OnlyOffice 本地保存缺少 rootUri", ) })?; let relative_path = query .path .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code( "onlyoffice_local_callback_path_missing", "OnlyOffice 本地保存缺少 path", ) })?; 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(), document_id: None, workspace_id: None, user_id: query.user_id.clone(), session_id: None, status, url: body .get("url") .and_then(Value::as_str) .map(ToOwned::to_owned), key: body .get("key") .and_then(Value::as_str) .map(ToOwned::to_owned), onlyoffice_internal_url, }) .map_err(|error| WebError::bad_request_code("onlyoffice_local_callback_invalid", error))?; if !prepared.should_write { return Ok(onlyoffice_callback_success(json!({ "localWrite": false, "status": status, }))); } let download_url = prepared.download_url.as_deref().ok_or_else(|| { WebError::bad_request_code( "onlyoffice_local_callback_url_missing", "OnlyOffice 本地保存缺少下载地址", ) })?; let bytes = download_onlyoffice_callback_body(download_url).await?; fs::write(&target, &bytes).map_err(|error| { WebError::bad_request_code( "onlyoffice_local_callback_write_failed", format!("写回本地 Office 文件失败: {error}"), ) })?; Ok(onlyoffice_callback_success(json!({ "localWrite": true, "bytes": bytes.len(), }))) } pub async fn callback( State(state): State, uri: Uri, Query(query): Query, Json(body): Json, ) -> Response { let status = body .get("status") .and_then(Value::as_i64) .unwrap_or_default(); tracing::info!( asset_id = query.asset_id.as_deref().unwrap_or(""), user_id = query.user_id.as_deref().unwrap_or(""), status, "OnlyOffice callback received by mnote-web" ); let is_local_callback = query .asset_id .as_deref() .map(is_onlyoffice_local_asset_id) .unwrap_or(false) || query.root_uri.as_deref().is_some() || query.path.as_deref().is_some(); if is_local_callback { return match local_folder_onlyoffice_callback(&query, &body, status).await { Ok(response) => response, Err(error) => onlyoffice_callback_failure(error), }; } match proxy_legacy_onlyoffice_json( state.config(), "/api/onlyoffice/callback", uri.query(), Some(body), ) .await { Ok(response) => response, Err(error) if error.status() == axum::http::StatusCode::NOT_IMPLEMENTED => ( axum::http::StatusCode::NOT_IMPLEMENTED, Json(json!({ "error": 1, "degraded": true, "code": "onlyoffice_legacy_writeback_unavailable", "message": error.message(), })), ) .into_response(), Err(error) => ( axum::http::StatusCode::BAD_GATEWAY, Json(json!({ "error": 1, "degraded": true, "code": "onlyoffice_legacy_writeback_failed", "message": error.message(), })), ) .into_response(), } } pub async fn forcesave( State(state): State, uri: Uri, Query(query): Query, ) -> Result { let asset_id = query .asset_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("onlyoffice_forcesave_asset_missing", "缺少 assetId") })?; let key = query .key .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("onlyoffice_forcesave_key_missing", "缺少 key") })?; let response = proxy_legacy_onlyoffice_json( state.config(), "/api/onlyoffice/forcesave", uri.query(), None, ) .await .map_err(|error| { if error.status() == axum::http::StatusCode::NOT_IMPLEMENTED { return WebError::new( axum::http::StatusCode::NOT_IMPLEMENTED, "onlyoffice_legacy_writeback_unavailable", format!( "OnlyOffice forcesave 未配置 legacy Next 写回链: assetId={asset_id}, key={key}" ), ); } error })?; Ok(response) } fn legacy_onlyoffice_writeback_base(config: &AppConfig) -> Option { config .legacy_next_base_url .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| value.trim_end_matches('/').to_string()) } async fn proxy_legacy_onlyoffice_json( config: &AppConfig, path: &str, query: Option<&str>, body: Option, ) -> Result { let base = legacy_onlyoffice_writeback_base(config).ok_or_else(|| { WebError::new( axum::http::StatusCode::NOT_IMPLEMENTED, "onlyoffice_legacy_writeback_unavailable", "OnlyOffice Rust route 暂未直接写回,且未配置 legacy Next 写回链", ) })?; let target = append_path_and_query(&base, path, query); let client = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .build() .map_err(|error| { WebError::internal(format!( "OnlyOffice legacy proxy HTTP 客户端创建失败: {error}" )) })?; let mut request = client .post(target) .header(header::CONTENT_TYPE, "application/json"); if let Some(body) = body { request = request.json(&body); } else { request = request.body("{}"); } let upstream = request.send().await.map_err(|error| { WebError::bad_gateway_code( "onlyoffice_legacy_writeback_failed", format!("OnlyOffice legacy 写回请求失败: {error}"), ) })?; let status = upstream.status(); let content_type = upstream .headers() .get(header::CONTENT_TYPE) .cloned() .unwrap_or_else(|| HeaderValue::from_static("application/json")); let bytes = upstream.bytes().await.map_err(|error| { WebError::bad_gateway_code( "onlyoffice_legacy_writeback_failed", format!("OnlyOffice legacy 写回响应读取失败: {error}"), ) })?; let mut response = Response::new(Body::from(bytes)); *response.status_mut() = status; response .headers_mut() .insert(header::CONTENT_TYPE, content_type); Ok(response) } fn append_path_and_query(base: &str, path: &str, query: Option<&str>) -> String { let mut target = format!( "{}/{}", base.trim_end_matches('/'), path.trim_start_matches('/') ); if let Some(query) = query.filter(|value| !value.is_empty()) { target.push('?'); target.push_str(query); } target } fn request_body_bytes( request: Request, ) -> impl std::future::Future> { async move { axum::body::to_bytes(request.into_body(), 32 * 1024 * 1024) .await .map_err(|error| { WebError::bad_request_code( "onlyoffice_server_body_failed", format!("读取请求体失败: {error}"), ) }) } } fn inject_onlyoffice_html_fixups(body: &str) -> String { if body.contains("window.__MNOTE_ONLYOFFICE_XHR_REWRITE__") { return body.to_string(); } body.replacen( "", &format!("\n{}\n", ONLYOFFICE_RUNTIME_REWRITE_SNIPPET), 1, ) } fn strip_hop_by_hop_headers(headers: &mut HeaderMap) { for name in [ header::CONNECTION, header::CONTENT_ENCODING, header::CONTENT_LENGTH, header::HeaderName::from_static("keep-alive"), header::HeaderName::from_static("proxy-authenticate"), header::HeaderName::from_static("proxy-authorization"), header::TE, header::TRAILER, header::TRANSFER_ENCODING, header::UPGRADE, ] { headers.remove(name); } } async fn proxy_onlyoffice_path( upstream_prefix: &str, upstream_path: &str, uri: Uri, method: Method, headers: HeaderMap, request: Request, ) -> Result { let base = resolve_onlyoffice_internal_url().await; let prefix = upstream_prefix.trim_matches('/'); let normalized_path = upstream_path.trim_start_matches('/'); let is_cache_path = prefix == "cache" || normalized_path.starts_with("cache/"); let target_base = if prefix.is_empty() { base.clone() } else { format!("{}/{}", base.trim_end_matches('/'), prefix) }; let target = append_path_and_query(&target_base, upstream_path, uri.query()); let target_origin = reqwest::Url::parse(&base).ok(); let body = request_body_bytes(request).await?; let client = reqwest::Client::new(); let mut builder = client.request( reqwest::Method::from_bytes(method.as_str().as_bytes()).unwrap_or(reqwest::Method::GET), target, ); for (name, value) in headers.iter() { if matches!( name.as_str(), "host" | "connection" | "upgrade" | "sec-websocket-key" | "sec-websocket-version" | "accept-encoding" ) { continue; } builder = builder.header(name, value); } // 说明:部分 ONLYOFFICE HTML 需要注入同源代理补丁。这里强制回源明文, // 避免 gzip 字节被当成 text/html 注入后在 iframe 中显示乱码。 builder = builder.header(header::ACCEPT_ENCODING, "identity"); if let Some(base_url) = target_origin.as_ref() { builder = builder.header(header::HOST, base_url.host_str().unwrap_or("127.0.0.1")); } let (forwarded_host, forwarded_proto, forwarded_port) = forwarded_public_origin(&headers); builder = builder .header("x-forwarded-host", forwarded_host) .header("x-forwarded-proto", forwarded_proto) .header("x-forwarded-port", forwarded_port) .header("x-forwarded-prefix", "/onlyoffice-server"); if method != Method::GET && method != Method::HEAD { builder = builder.body(body); } let upstream = builder.send().await.map_err(|error| { WebError::bad_gateway_code( "onlyoffice_server_proxy_failed", format!("OnlyOffice 代理失败: {error}"), ) })?; let status = upstream.status(); let upstream_headers = upstream.headers().clone(); let mut response_headers = HeaderMap::new(); for (name, value) in upstream_headers.iter() { if name == header::SET_COOKIE { continue; } response_headers.insert(name, value.clone()); } if method == Method::HEAD { if is_cache_path { let upstream_content_length = upstream_headers.get(header::CONTENT_LENGTH).cloned(); strip_hop_by_hop_headers(&mut response_headers); if let Some(content_length) = upstream_content_length { response_headers.insert(header::CONTENT_LENGTH, content_length); } } let mut response = Response::new(Body::empty()); *response.status_mut() = status; *response.headers_mut() = response_headers; return Ok(response); } let bytes = upstream.bytes().await.map_err(|error| { WebError::bad_gateway_code( "onlyoffice_server_proxy_body_failed", format!("读取 OnlyOffice 响应失败: {error}"), ) })?; let maybe_html = upstream_headers .get(header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .map(str::to_ascii_lowercase) .map(|value| value.contains("text/html")) .unwrap_or(false); let mut response = if maybe_html { let html = String::from_utf8_lossy(&bytes); let body = inject_onlyoffice_html_fixups(&html); strip_hop_by_hop_headers(&mut response_headers); response_headers.insert( header::CACHE_CONTROL, header::HeaderValue::from_static("no-store"), ); response_headers.insert( header::CONTENT_LENGTH, header::HeaderValue::from_str(&body.as_bytes().len().to_string()) .unwrap_or_else(|_| header::HeaderValue::from_static("0")), ); Response::new(Body::from(body)) } else { if is_cache_path { strip_hop_by_hop_headers(&mut response_headers); response_headers.insert( header::CONTENT_LENGTH, header::HeaderValue::from_str(&bytes.len().to_string()) .unwrap_or_else(|_| header::HeaderValue::from_static("0")), ); } Response::new(Body::from(bytes)) }; *response.status_mut() = status; *response.headers_mut() = response_headers; Ok(response) } fn onlyoffice_websocket_url(base: &str, upstream_path: &str, query: Option<&str>) -> String { let mut target = append_path_and_query(base, upstream_path, query); if let Some(rest) = target.strip_prefix("http://") { target = format!("ws://{rest}"); } else if let Some(rest) = target.strip_prefix("https://") { target = format!("wss://{rest}"); } target } async fn bridge_onlyoffice_websocket(upgraded: Upgraded, target: String) { let client_io = TokioIo::new(upgraded); let mut client_socket = WebSocketStream::from_raw_socket(client_io, Role::Server, None).await; let Ok((mut upstream_socket, _response)) = connect_async(&target).await else { let _ = client_socket.close(None).await; return; }; loop { tokio::select! { client_message = client_socket.next() => { let Some(Ok(message)) = client_message else { let _ = upstream_socket.send(TungsteniteMessage::Close(None)).await; break; }; let is_close = matches!(message, TungsteniteMessage::Close(_)); if upstream_socket.send(message).await.is_err() { break; } if is_close { break; } } upstream_message = upstream_socket.next() => { let Some(Ok(message)) = upstream_message else { let _ = client_socket.send(TungsteniteMessage::Close(None)).await; break; }; let is_close = matches!(message, TungsteniteMessage::Close(_)); if client_socket.send(message).await.is_err() { break; } if is_close { break; } } } } } pub async fn server_proxy( State(_state): State, Path(path): Path, uri: Uri, method: Method, headers: HeaderMap, mut request: Request, ) -> Result { let upgrade = headers .get(header::UPGRADE) .and_then(|value| value.to_str().ok()) .map(|value| value.eq_ignore_ascii_case("websocket")) .unwrap_or(false); if upgrade { let key = headers .get(header::SEC_WEBSOCKET_KEY) .and_then(|value| value.to_str().ok()) .ok_or_else(|| { WebError::bad_request_code( "onlyoffice_websocket_key_missing", "缺少 Sec-WebSocket-Key", ) })?; let target = onlyoffice_websocket_url(&resolve_onlyoffice_internal_url().await, &path, uri.query()); let upgraded = hyper::upgrade::on(&mut request); tokio::spawn(async move { if let Ok(upgraded) = upgraded.await { bridge_onlyoffice_websocket(upgraded, target).await; } }); let response = Response::builder() .status(StatusCode::SWITCHING_PROTOCOLS) .header(header::CONNECTION, "Upgrade") .header(header::UPGRADE, "websocket") .header( header::SEC_WEBSOCKET_ACCEPT, derive_accept_key(key.as_bytes()), ) .body(Body::empty()) .map_err(|error| { WebError::internal(format!("构造 OnlyOffice WebSocket 响应失败: {error}")) })?; return Ok(response); } proxy_onlyoffice_path("", &path, uri, method, headers, request).await } pub async fn cache_proxy( State(_state): State, Path(path): Path, uri: Uri, method: Method, headers: HeaderMap, request: Request, ) -> Result { proxy_onlyoffice_path("cache", &path, uri, method, headers, request).await } #[cfg(test)] pub fn stable_doc_key(asset_id: &str, storage_id: &str, file_url: &str, file_name: &str) -> String { let raw = if !asset_id.trim().is_empty() { if storage_id.trim().is_empty() { asset_id.trim().to_string() } else { format!("{}_{}", asset_id.trim(), js_hash_abs(storage_id)) } } else { js_hash_abs(&format!("{file_url}-{file_name}")) }; safe_onlyoffice_doc_key(&raw, file_name) } #[cfg(test)] fn safe_onlyoffice_doc_key(input: &str, file_name: &str) -> String { let raw = input.trim(); if raw.is_empty() { return format!("mnote_{}", js_hash_abs(file_name)); } let mut safe = String::new(); let mut previous_underscore = false; for ch in raw.chars() { let allowed = ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-'); let next = if allowed { ch } else { '_' }; if next == '_' { if previous_underscore { continue; } previous_underscore = true; } else { previous_underscore = false; } safe.push(next); } if !safe.is_empty() && safe == raw && safe.len() <= 96 { return safe; } format!("mnote_{}_{}", js_hash_abs(raw), js_hash_abs(file_name)) } #[cfg(test)] fn js_hash_abs(input: &str) -> String { let mut hash: i32 = 0; for unit in input.encode_utf16() { hash = hash .wrapping_shl(5) .wrapping_sub(hash) .wrapping_add(unit as i32); } hash.abs().to_string() } #[cfg(test)] mod tests { use super::*; use crate::app::{AppConfig, AppState}; use axum::extract::State; use axum::http::StatusCode; use serde_json::json; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use tokio::sync::oneshot; #[test] fn stable_doc_key_uses_onlyoffice_safe_characters() { let key = stable_doc_key("asset_1", "kg2abc:def", "", ""); assert!(key.len() <= 128); assert!(key .chars() .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-'))); assert!(key.starts_with("asset_1_")); } #[test] fn stable_doc_key_hashes_local_unicode_asset_ids() { let key = stable_doc_key( "local:asset:Alpha/重庆发展特殊化妆品可行性报告_政府汇报版.docx", "", "", "重庆发展特殊化妆品可行性报告_政府汇报版.docx", ); assert!(key.len() <= 128); assert!(key.starts_with("mnote_")); assert!(key .chars() .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-'))); assert!(!key.contains('/')); assert!(!key.contains(':')); assert!(!key.contains('重')); } #[tokio::test] async fn onlyoffice_object_shell_exposes_resource_identity() { let response = object_shell( Path(("doc_1".into(), "asset_docx".into())), Query(OnlyOfficePageQuery { file_url: Some("/api/media/sign?assetId=asset_docx".into()), file_name: Some("方案.docx".into()), file_type: Some("docx".into()), asset_id: None, document_id: None, user_id: Some("user_1".into()), mode: Some("edit".into()), }), ) .await .expect("object shell"); 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("data-mnote-object-editor=\"onlyoffice\"")); assert!( html.contains("data-mnote-object-identity=\"resource:onlyoffice:doc_1:asset_docx\"") ); assert!(html.contains("/onlyoffice?")); assert!(html.contains("assetId=asset_docx")); 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(); assert!(candidates.contains(&DEFAULT_ONLYOFFICE_INTERNAL_URL.to_string())); } #[tokio::test] async fn onlyoffice_page_exposes_documentserver_fetch_base_for_local_files() { 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("const documentUrlBase = \"http://host.docker.internal:3000\";")); assert!(html.contains("const proxyOrigin = documentUrlBase;")); assert!(html.contains("const callbackOrigin = documentUrlBase;")); assert!( !html.contains("const proxyOrigin = pageLocal ? documentUrlBase : location.origin;") ); assert!(html.contains("documentUrlBase,")); assert!(html.contains("resolvedFileUrl: fileState.resolvedFileUrl")); assert!(html.contains( r#"const isLocalFolderFileOpen = url.pathname === "/api/local-folder/files/open";"# )); 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) -> AppState { AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url, enable_legacy_next_compat: true, enable_debug_shell_routes: false, enable_editor_actor: true, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: None, convex_admin_key: None, allow_dev_fixtures: false, query_fixtures_json: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), }) } async fn spawn_legacy_json_server( response_body: &'static str, ) -> (String, oneshot::Receiver) { let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let addr = listener.local_addr().expect("addr"); let (tx, rx) = oneshot::channel(); tokio::spawn(async move { let (mut stream, _) = listener.accept().await.expect("accept"); let mut buffer = vec![0_u8; 8192]; let read = stream.read(&mut buffer).await.expect("read"); let request = String::from_utf8_lossy(&buffer[..read]).to_string(); let _ = tx.send(request); let response = format!( "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", response_body.len(), response_body ); stream.write_all(response.as_bytes()).await.expect("write"); }); (format!("http://{addr}"), rx) } #[tokio::test] async fn onlyoffice_callback_without_legacy_next_fails_explicitly() { let response = callback( State(test_state(None)), "/api/onlyoffice/callback?assetId=asset_1" .parse::() .expect("uri"), Query(OnlyOfficeCallbackQuery { asset_id: Some("asset_1".into()), user_id: None, session_id: None, token: None, root_uri: None, path: None, }), Json(json!({ "status": 2, "url": "http://127.0.0.1:8082/cache/files/out.docx" })), ) .await; assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); 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["degraded"], true); } #[tokio::test] async fn onlyoffice_local_callback_rejects_unauthenticated_local_write() { let root = std::env::temp_dir().join(format!( "mnote-onlyoffice-local-callback-unauth-{}", 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 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() ) .parse::() .expect("uri"), 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::() .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": 2, "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"new docx bytes"); 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::() .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::() .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!( "mnote-onlyoffice-local-callback-ignore-{}", 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 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&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx", session_id, token, root.display(), ) .parse::() .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": 1 })), ) .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"old"); let _ = fs::remove_dir_all(&root); } #[tokio::test] async fn onlyoffice_callback_proxies_to_legacy_next_writeback() { let (base_url, captured) = spawn_legacy_json_server(r#"{"error":0}"#).await; let response = callback( State(test_state(Some(base_url))), "/api/onlyoffice/callback?assetId=asset_1&userId=user_1" .parse::() .expect("uri"), Query(OnlyOfficeCallbackQuery { asset_id: Some("asset_1".into()), user_id: Some("user_1".into()), session_id: None, token: None, root_uri: None, path: None, }), Json(json!({ "status": 2, "key": "doc_key", "url": "http://127.0.0.1:8082/cache/files/out.docx" })), ) .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"); let request = captured.await.expect("captured"); assert_eq!(payload["error"], 0); assert!(request .starts_with("POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1")); assert!(request.contains(r#""status":2"#)); assert!(request.contains(r#""key":"doc_key""#)); } #[tokio::test] async fn onlyoffice_forcesave_without_legacy_next_is_not_noop_success() { let response = forcesave( State(test_state(None)), "/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key" .parse::() .expect("uri"), Query(OnlyOfficeForcesaveQuery { asset_id: Some("asset_1".into()), key: Some("doc_key".into()), }), ) .await .map(IntoResponse::into_response) .unwrap_or_else(IntoResponse::into_response); assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); 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["ok"], false); assert_eq!(payload["code"], "onlyoffice_legacy_writeback_unavailable"); assert_ne!(payload["via"], "mnote-web-rust-noop"); } #[tokio::test] async fn onlyoffice_forcesave_proxies_to_legacy_next_writeback() { let (base_url, captured) = spawn_legacy_json_server(r#"{"ok":true,"via":"forcesave","result":{"error":0}}"#).await; let response = forcesave( State(test_state(Some(base_url))), "/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key" .parse::() .expect("uri"), Query(OnlyOfficeForcesaveQuery { asset_id: Some("asset_1".into()), key: Some("doc_key".into()), }), ) .await .expect("response"); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); let request = captured.await.expect("captured"); assert_eq!(payload["ok"], true); assert_eq!(payload["via"], "forcesave"); assert!(request .starts_with("POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1")); } #[tokio::test] async fn onlyoffice_page_skips_media_sign_for_local_folder_asset() { 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"); // 1) Guard function is present assert!(html.contains("function isLocalFolderAsset()")); // 2) Condition uses guard to skip /api/media/sign assert!(html.contains("if (initial.assetId && !isLocalFolderAsset())")); // 3) local: prefix detection assert!(html.contains("initial.assetId.indexOf(\"local:\") === 0")); // 4) local-file: prefix detection assert!(html.contains("initial.assetId.indexOf(\"local-file:\") === 0")); // 5) fileUrl path detection for /api/local-folder/files/open assert!(html.contains( "initial.fileUrl && initial.fileUrl.indexOf(\"/api/local-folder/files/open\") !== -1" )); // 6) Non-local asset still fetches /api/media/sign (general code path preserved) assert!(html.contains("/api/media/sign?assetId=")); // 7) local rootUri/path are propagated into callback for save writeback assert!(html.contains("function localFolderOpenParams(raw)")); assert!(html.contains("callback.searchParams.set(\"rootUri\", localFile.rootUri);")); assert!(html.contains("callback.searchParams.set(\"path\", localFile.path);")); // 8) user name is explicit so OnlyOffice does not ask for collaboration name assert!(html.contains("user: {")); assert!(html.contains("name: displayUserName")); 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);")); } }