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() {
|
2026-04-19 21:03:25 +08:00
|
|
|
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() {
|
2026-04-19 21:03:25 +08:00
|
|
|
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())?;
|
|
|
|
|
|
2026-04-19 21:03:25 +08:00
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 21:03:25 +08:00
|
|
|
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(),
|
2026-04-19 21:03:25 +08:00
|
|
|
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}");
|
2026-04-19 21:03:25 +08:00
|
|
|
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(),
|
2026-04-19 21:03:25 +08:00
|
|
|
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");
|
2026-04-19 21:03:25 +08:00
|
|
|
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,
|
2026-04-19 21:03:25 +08:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
}
|