use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use hmac::{Hmac, Mac}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sha2::Sha256; use std::collections::{BTreeMap, BTreeSet}; use url::Url; type HmacSha256 = Hmac; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OnlyOfficeAssetLocator { pub asset_id: String, pub workspace_id: Option, pub document_id: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OnlyOfficeSessionRef { pub session_id: String, pub asset_id: String, pub workspace_id: Option, pub document_id: Option, pub user_id: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OnlyOfficeSignTokens { pub token: Option, pub document_token: Option, pub editor_config_token: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OnlyOfficeHeader { pub name: String, pub value: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OnlyOfficeProxyPreparationInput { pub encoded_url: String, pub method: String, pub range: Option, pub supabase_url: Option, pub supabase_internal_url: Option, pub onlyoffice_storage_host_override: Option, pub convex_origin: Option, pub supabase_anon_key: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OnlyOfficeProxyPreparation { pub target_url: String, pub forward_headers: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OnlyOfficeCallbackPreparationInput { pub asset_id: String, pub document_id: Option, pub workspace_id: Option, pub user_id: Option, pub session_id: Option, pub status: i64, pub url: Option, pub key: Option, pub onlyoffice_internal_url: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OnlyOfficeCallbackPreparation { pub should_write: bool, pub download_url: Option, pub idempotency_key: Option, pub locator: OnlyOfficeAssetLocator, pub session: OnlyOfficeSessionRef, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OnlyOfficeForcesavePreparationInput { pub asset_id: String, pub key: String, pub onlyoffice_internal_url: String, pub secret: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OnlyOfficeOutboundRequest { pub via: String, pub url: String, pub method: String, pub headers: Vec, pub body_json: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OnlyOfficeSessionResolveInput { pub asset_id: String, pub workspace_id: Option, pub document_id: Option, pub user_id: Option, pub session_id: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OnlyOfficeSessionResolveResult { pub locator: OnlyOfficeAssetLocator, pub session: OnlyOfficeSessionRef, } pub fn adapter_boundary_statement() -> &'static str { "adapter-onlyoffice 只定义资产定位、会话定位、签名/代理/callback/forcesave 边界,不把 OnlyOffice 变成主事实层。" } pub fn normalize_secret(raw: &str) -> String { let trimmed = raw.trim(); if trimmed.is_empty() { return String::new(); } if ((trimmed.starts_with('"') && trimmed.ends_with('"')) || (trimmed.starts_with('\'') && trimmed.ends_with('\''))) && trimmed.len() >= 2 { return trimmed[1..trimmed.len() - 1].trim().to_string(); } trimmed.to_string() } pub fn sign_config(config: &Value, secret: &str) -> Result { let normalized_secret = normalize_secret(secret); if normalized_secret.is_empty() { return Ok(OnlyOfficeSignTokens { token: None, document_token: None, editor_config_token: None, }); } let token = sign_hs256(config, &normalized_secret)?; let document_token = config .get("document") .map(|value| sign_hs256(value, &normalized_secret)) .transpose()?; let editor_config_token = config .get("editorConfig") .map(|value| sign_hs256(value, &normalized_secret)) .transpose()?; Ok(OnlyOfficeSignTokens { token: Some(token), document_token, editor_config_token, }) } pub fn resolve_session(input: OnlyOfficeSessionResolveInput) -> OnlyOfficeSessionResolveResult { let session_id = input .session_id .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| format!("onlyoffice:{}", input.asset_id.trim())); let locator = OnlyOfficeAssetLocator { asset_id: input.asset_id.trim().to_string(), workspace_id: input.workspace_id.filter(|value| !value.trim().is_empty()), document_id: input.document_id.filter(|value| !value.trim().is_empty()), }; let session = OnlyOfficeSessionRef { session_id, asset_id: locator.asset_id.clone(), workspace_id: locator.workspace_id.clone(), document_id: locator.document_id.clone(), user_id: input.user_id.filter(|value| !value.trim().is_empty()), }; OnlyOfficeSessionResolveResult { locator, session } } pub fn prepare_proxy_request( input: OnlyOfficeProxyPreparationInput, ) -> Result { let decoded = decode_base64url_to_utf8(&input.encoded_url)?; let mut target = Url::parse(&decoded).map_err(|_| "u 不是有效的 URL".to_string())?; if target.scheme() != "http" && target.scheme() != "https" { return Err("仅支持 http/https URL".into()); } let supa = try_parse_origin_host(input.supabase_url.as_deref()); let supa_internal_origin = try_parse_origin_url(input.supabase_internal_url.as_deref()); let storage_override = try_parse_origin_host(input.onlyoffice_storage_host_override.as_deref()); let convex_origin = try_parse_origin_url(input.convex_origin.as_deref()); let is_supabase_path = target.path().starts_with("/storage/v1/") || target.path().starts_with("/auth/v1/") || target.path().starts_with("/rest/v1/") || target.path().starts_with("/functions/v1/") || target.path().starts_with("/realtime/v1/"); if is_supabase_path { if let Some(origin) = supa_internal_origin.as_ref() { target.set_scheme(origin.scheme()).ok(); target.set_host(origin.host_str()).ok(); target.set_port(origin.port()).ok(); } else if let Some(origin) = try_parse_origin_url(input.supabase_url.as_deref()) { target.set_scheme(origin.scheme()).ok(); target.set_host(origin.host_str()).ok(); target.set_port(origin.port()).ok(); } } let mut allowed_hostnames = BTreeSet::new(); let mut allowed_ports_by_hostname = BTreeMap::>::new(); let mut add_allowed = |hostname: &str, port: &str| { if hostname.trim().is_empty() { return; } allowed_hostnames.insert(hostname.to_string()); if !port.trim().is_empty() { allowed_ports_by_hostname .entry(hostname.to_string()) .or_default() .insert(port.to_string()); } }; if let Some((hostname, port)) = supa.as_ref() { add_allowed(hostname, port); if is_local_hostname(hostname) { add_allowed("127.0.0.1", port); add_allowed("localhost", port); add_allowed("host.docker.internal", port); } } if let Some(origin) = supa_internal_origin.as_ref() { let port = origin .port() .map(|value| value.to_string()) .unwrap_or_default(); if let Some(hostname) = origin.host_str() { add_allowed(hostname, &port); if is_local_hostname(hostname) { add_allowed("127.0.0.1", &port); add_allowed("localhost", &port); add_allowed("host.docker.internal", &port); } } } if let Some((hostname, port)) = storage_override.as_ref() { add_allowed(hostname, port); } if let Some(origin) = convex_origin.as_ref() { let port = origin .port() .map(|value| value.to_string()) .unwrap_or_default(); if let Some(hostname) = origin.host_str() { add_allowed(hostname, &port); if is_local_hostname(hostname) { add_allowed("127.0.0.1", &port); add_allowed("localhost", &port); add_allowed("host.docker.internal", &port); } } } let hostname = target .host_str() .ok_or_else(|| "目标 URL 缺少 hostname".to_string())?; if is_private_ipv4(hostname) && !is_local_hostname(hostname) && !allowed_hostnames.contains(hostname) { return Err("禁止访问内网/私有地址".into()); } if !allowed_hostnames.is_empty() && !allowed_hostnames.contains(hostname) { return Err("禁止代理到非允许的主机".into()); } if let Some(allowed_ports) = allowed_ports_by_hostname.get(hostname) { if !allowed_ports.is_empty() { let port = target .port_or_known_default() .ok_or_else(|| "目标 URL 缺少端口信息".to_string())? .to_string(); if !allowed_ports.contains(&port) { return Err("禁止代理到该端口".into()); } } } let mut forward_headers = Vec::new(); if let Some(range) = input.range.filter(|value| !value.trim().is_empty()) { forward_headers.push(OnlyOfficeHeader { name: "range".into(), value: range, }); } if let Some(anon_key) = input .supabase_anon_key .filter(|value| !value.trim().is_empty()) { let matches_public = supa .as_ref() .map(|(value, _)| value == hostname) .unwrap_or(false); let matches_internal = supa_internal_origin .as_ref() .and_then(|value| value.host_str()) .map(|value| value == hostname) .unwrap_or(false); if matches_public || matches_internal { forward_headers.push(OnlyOfficeHeader { name: "apikey".into(), value: anon_key, }); } } Ok(OnlyOfficeProxyPreparation { target_url: target.to_string(), forward_headers, }) } pub fn prepare_callback( input: OnlyOfficeCallbackPreparationInput, ) -> Result { let session_result = resolve_session(OnlyOfficeSessionResolveInput { asset_id: input.asset_id.clone(), workspace_id: input.workspace_id.clone(), document_id: input.document_id.clone(), user_id: input.user_id.clone(), session_id: input .session_id .clone() .or_else(|| Some("onlyoffice-callback".into())), }); if input.status != 2 && input.status != 6 { return Ok(OnlyOfficeCallbackPreparation { should_write: false, download_url: None, idempotency_key: input.key.filter(|value| !value.trim().is_empty()), locator: session_result.locator, session: session_result.session, }); } let raw_url = input .url .filter(|value| !value.trim().is_empty()) .ok_or_else(|| "OnlyOffice callback 缺少下载地址".to_string())?; Ok(OnlyOfficeCallbackPreparation { should_write: true, download_url: Some(rewrite_onlyoffice_download_url( &raw_url, &input.onlyoffice_internal_url, )), idempotency_key: input.key.filter(|value| !value.trim().is_empty()), locator: session_result.locator, session: session_result.session, }) } pub fn prepare_forcesave( input: OnlyOfficeForcesavePreparationInput, ) -> Result, String> { let base = input.onlyoffice_internal_url.trim().trim_end_matches('/'); if base.is_empty() { return Err("缺少 onlyofficeInternalUrl".into()); } if input.asset_id.trim().is_empty() { return Err("缺少 assetId".into()); } if input.key.trim().is_empty() { return Err("缺少 key".into()); } let payload = json!({ "c": "forcesave", "key": input.key, "userdata": format!("asset:{}", input.asset_id.trim()), }); let raw_body_json = serde_json::to_string(&payload).map_err(|error| error.to_string())?; let normalized_secret = normalize_secret(input.secret.as_deref().unwrap_or("")); let mut requests = Vec::new(); if !normalized_secret.is_empty() { let token = sign_hs256(&payload, &normalized_secret)?; requests.push(OnlyOfficeOutboundRequest { via: "command".into(), url: format!("{base}/command"), method: "POST".into(), headers: vec![OnlyOfficeHeader { name: "content-type".into(), value: "application/json".into(), }], body_json: serde_json::to_string(&json!({ "token": token })) .map_err(|error| error.to_string())?, }); requests.push(OnlyOfficeOutboundRequest { via: "forcesave".into(), url: format!("{base}/forcesave"), method: "POST".into(), headers: vec![OnlyOfficeHeader { name: "content-type".into(), value: "application/json".into(), }], body_json: raw_body_json, }); return Ok(requests); } requests.push(OnlyOfficeOutboundRequest { via: "forcesave".into(), url: format!("{base}/forcesave"), method: "POST".into(), headers: vec![OnlyOfficeHeader { name: "content-type".into(), value: "application/json".into(), }], body_json: raw_body_json.clone(), }); requests.push(OnlyOfficeOutboundRequest { via: "command_no_token".into(), url: format!("{base}/command"), method: "POST".into(), headers: vec![OnlyOfficeHeader { name: "content-type".into(), value: "application/json".into(), }], body_json: raw_body_json, }); Ok(requests) } pub fn rewrite_onlyoffice_download_url(raw: &str, onlyoffice_internal_url: &str) -> String { let base = onlyoffice_internal_url.trim().trim_end_matches('/'); if base.is_empty() { return raw.to_string(); } let Ok(url) = Url::parse(raw) else { return raw.to_string(); }; let prefix = "/onlyoffice-server"; if !url.path().starts_with(prefix) { return raw.to_string(); } let next_path = url.path()[prefix.len()..].trim_start_matches('/'); if next_path.is_empty() { return raw.to_string(); } let mut rewritten = format!("{base}/{next_path}"); if let Some(query) = url.query() { rewritten.push('?'); rewritten.push_str(query); } rewritten } fn sign_hs256(payload: &Value, secret: &str) -> Result { let header = json!({ "alg": "HS256", "typ": "JWT" }); let header_part = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes()); let payload_part = URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes()); let signing_input = format!("{header_part}.{payload_part}"); let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|error| error.to_string())?; mac.update(signing_input.as_bytes()); let signature = mac.finalize().into_bytes(); let signature_part = URL_SAFE_NO_PAD.encode(signature); Ok(format!("{signing_input}.{signature_part}")) } fn decode_base64url_to_utf8(input: &str) -> Result { let bytes = URL_SAFE_NO_PAD .decode(input.trim()) .map_err(|_| "u 不是有效的 base64url URL".to_string())?; String::from_utf8(bytes).map_err(|_| "u 不是有效的 base64url URL".to_string()) } fn is_local_hostname(hostname: &str) -> bool { matches!(hostname, "127.0.0.1" | "localhost" | "host.docker.internal") } fn is_private_ipv4(hostname: &str) -> bool { let parts = hostname .split('.') .map(str::parse::) .collect::, _>>(); let Ok(parts) = parts else { return false; }; if parts.len() != 4 || parts.iter().any(|value| *value > 255) { return false; } let a = parts[0]; let b = parts[1]; a == 10 || a == 127 || (a == 169 && b == 254) || (a == 172 && (16..=31).contains(&b)) || (a == 192 && b == 168) || a == 0 } fn try_parse_origin_host(raw: Option<&str>) -> Option<(String, String)> { let value = raw?.trim(); if value.is_empty() { return None; } if value.starts_with("http://") || value.starts_with("https://") { let url = Url::parse(value).ok()?; return Some(( url.host_str()?.to_string(), url.port() .map(|value| value.to_string()) .unwrap_or_default(), )); } Some((value.to_string(), String::new())) } fn try_parse_origin_url(raw: Option<&str>) -> Option { let value = raw?.trim(); if value.is_empty() { return None; } Url::parse(value) .or_else(|_| Url::parse(&format!("http://{value}"))) .ok() } #[cfg(test)] mod tests { use super::*; #[test] fn boundary_statement_mentions_non_source_of_truth() { assert!(adapter_boundary_statement().contains("不把 OnlyOffice 变成主事实层")); } #[test] fn normalize_secret_strips_quotes() { assert_eq!(normalize_secret(" \"abc\" "), "abc"); assert_eq!(normalize_secret("'xyz'"), "xyz"); } #[test] fn sign_config_returns_document_and_editor_tokens() { let tokens = sign_config( &json!({ "document": {"title": "A"}, "editorConfig": {"mode": "edit"}, }), "secret", ) .expect("sign should succeed"); assert!(tokens.token.is_some()); assert!(tokens.document_token.is_some()); assert!(tokens.editor_config_token.is_some()); } #[test] fn proxy_request_rewrites_supabase_path_to_internal_origin() { let encoded_url = URL_SAFE_NO_PAD.encode( b"https://public.example.com/storage/v1/object/sign/documents/a.docx?token=abc", ); let result = prepare_proxy_request(OnlyOfficeProxyPreparationInput { encoded_url, method: "GET".into(), range: Some("bytes=0-99".into()), supabase_url: Some("https://public.example.com".into()), supabase_internal_url: Some("http://127.0.0.1:18000".into()), onlyoffice_storage_host_override: None, convex_origin: Some("http://127.0.0.1:3210".into()), supabase_anon_key: Some("anon".into()), }) .expect("proxy should prepare"); assert!(result .target_url .starts_with("http://127.0.0.1:18000/storage/v1/")); assert_eq!(result.forward_headers.len(), 2); } #[test] fn callback_preparation_rewrites_onlyoffice_server_path() { let result = prepare_callback(OnlyOfficeCallbackPreparationInput { asset_id: "asset_1".into(), document_id: Some("doc_1".into()), workspace_id: Some("ws_1".into()), user_id: Some("user_1".into()), session_id: None, status: 6, url: Some( "http://app.example.com/onlyoffice-server/cache/files/out.docx?token=1".into(), ), key: Some("doc_key_1".into()), onlyoffice_internal_url: "http://127.0.0.1:8082".into(), }) .expect("callback should prepare"); assert!(result.should_write); assert_eq!( result.download_url.as_deref(), Some("http://127.0.0.1:8082/cache/files/out.docx?token=1") ); assert_eq!(result.session.session_id, "onlyoffice-callback"); } #[test] fn forcesave_preparation_prefers_command_when_secret_present() { let result = prepare_forcesave(OnlyOfficeForcesavePreparationInput { asset_id: "asset_1".into(), key: "doc_key_1".into(), onlyoffice_internal_url: "http://127.0.0.1:8082".into(), secret: Some("secret".into()), }) .expect("forcesave should prepare"); assert_eq!(result[0].via, "command"); assert_eq!(result[1].via, "forcesave"); } }