feat: 完成 rust cutover phase 8 收口
This commit is contained in:
@@ -0,0 +1,615 @@
|
||||
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();
|
||||
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<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())),
|
||||
});
|
||||
|
||||
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())?;
|
||||
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(),
|
||||
));
|
||||
}
|
||||
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/"));
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user