use crate::app::AppConfig; use crate::app::AppState; use crate::error::WebError; use adapter_onlyoffice::{prepare_proxy_request, sign_config, OnlyOfficeProxyPreparationInput}; use axum::body::{Body, Bytes}; use axum::extract::{Path, Query, State}; use axum::http::{header, HeaderMap, HeaderValue, Method, Request, Uri}; use axum::response::{Html, IntoResponse, Response}; use axum::Json; use serde::Deserialize; use serde_json::{json, Value}; use std::env; use std::fs; use std::time::Duration; 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, } #[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 } 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 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 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), ); 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"))?; 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: proxy_origin_env("CONVEX_SELF_HOSTED_URL") .or_else(|| proxy_origin_env("NEXT_PUBLIC_CONVEX_URL")), 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) } 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" ); 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) } pub async fn server_proxy( State(_state): State, Path(path): Path, uri: Uri, method: Method, headers: HeaderMap, request: Request, ) -> Result { 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 { if !asset_id.trim().is_empty() { if storage_id.trim().is_empty() { return asset_id.trim().to_string(); } return format!("{}_{}", asset_id.trim(), js_hash_abs(storage_id)); } js_hash_abs(&format!("{file_url}-{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 onlyoffice_internal_candidates_keep_default_first_after_env() { let candidates = onlyoffice_internal_candidates(); assert!(candidates.contains(&DEFAULT_ONLYOFFICE_INTERNAL_URL.to_string())); } 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, }), 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_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()), }), 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")); } }