Files
mnote/rust/crates/adapter-onlyoffice/src/lib.rs
T

641 lines
21 KiB
Rust
Raw Normal View History

2026-04-15 20:01:12 +08:00
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<Sha256>;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OnlyOfficeAssetLocator {
pub asset_id: String,
pub workspace_id: Option<String>,
pub document_id: Option<String>,
}
#[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<String>,
pub document_id: Option<String>,
pub user_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OnlyOfficeSignTokens {
pub token: Option<String>,
pub document_token: Option<String>,
pub editor_config_token: Option<String>,
}
#[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<String>,
pub supabase_url: Option<String>,
pub supabase_internal_url: Option<String>,
pub onlyoffice_storage_host_override: Option<String>,
pub convex_origin: Option<String>,
pub supabase_anon_key: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OnlyOfficeProxyPreparation {
pub target_url: String,
pub forward_headers: Vec<OnlyOfficeHeader>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OnlyOfficeCallbackPreparationInput {
pub asset_id: String,
pub document_id: Option<String>,
pub workspace_id: Option<String>,
pub user_id: Option<String>,
pub session_id: Option<String>,
pub status: i64,
pub url: Option<String>,
pub key: Option<String>,
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<String>,
pub idempotency_key: Option<String>,
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<String>,
}
#[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<OnlyOfficeHeader>,
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<String>,
pub document_id: Option<String>,
pub user_id: Option<String>,
pub session_id: Option<String>,
}
#[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<OnlyOfficeSignTokens, String> {
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<OnlyOfficeProxyPreparation, String> {
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::<String, BTreeSet<String>>::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();
2026-04-15 20:01:12 +08:00
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();
2026-04-15 20:01:12 +08:00
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)
{
2026-04-15 20:01:12 +08:00
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);
2026-04-15 20:01:12 +08:00
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<OnlyOfficeCallbackPreparation, String> {
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())),
2026-04-15 20:01:12 +08:00
});
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<Vec<OnlyOfficeOutboundRequest>, 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<String, String> {
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())?;
2026-04-15 20:01:12 +08:00
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<String, String> {
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::<u16>)
.collect::<Result<Vec<_>, _>>();
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(),
2026-04-15 20:01:12 +08:00
));
}
Some((value.to_string(), String::new()))
}
fn try_parse_origin_url(raw: Option<&str>) -> Option<Url> {
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/"));
2026-04-15 20:01:12 +08:00
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(),
),
2026-04-15 20:01:12 +08:00
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");
}
}