use crate::app::AppConfig;
use crate::app::AppState;
use crate::error::WebError;
use adapter_onlyoffice::{
prepare_callback, prepare_proxy_request, sign_config, OnlyOfficeCallbackPreparationInput,
OnlyOfficeProxyPreparationInput,
};
use axum::body::{Body, Bytes};
use axum::extract::{Path, Query, State};
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use base64::Engine;
use futures_util::{SinkExt, StreamExt};
use hyper::upgrade::Upgraded;
use hyper_util::rt::TokioIo;
use serde::Deserialize;
use serde_json::{json, Value};
use std::env;
use std::fs;
use std::path::{Path as FsPath, PathBuf};
use std::time::Duration;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::handshake::derive_accept_key;
use tokio_tungstenite::tungstenite::protocol::Role;
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
use tokio_tungstenite::WebSocketStream;
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,
#[serde(rename = "rootUri")]
root_uri: Option,
path: 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
}
fn onlyoffice_document_url_base_js() -> String {
env_or_dotenv("ONLYOFFICE_DOCUMENT_URL_BASE")
.or_else(|| env_or_dotenv("MNOTE_WEB_ONLYOFFICE_INTERNAL_BASE_URL"))
.and_then(|value| normalize_http_origin(&value))
.unwrap_or_else(|| "http://host.docker.internal:3000".into())
}
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 encode_query_component(value: &str) -> String {
let mut encoded = String::new();
for byte in value.as_bytes() {
let ch = *byte as char;
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '~') {
encoded.push(ch);
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
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 document_url_base = onlyoffice_document_url_base_js();
let html = format!(
r#"
{title}
"#,
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),
document_url_base = json_string(&document_url_base),
);
Ok(Html(html).into_response())
}
pub async fn object_shell(
Path((document_id, asset_id)): Path<(String, String)>,
Query(query): Query,
) -> Result {
let document_id = document_id.trim();
let asset_id = asset_id.trim();
if document_id.is_empty() || asset_id.is_empty() {
return Err(WebError::bad_request_code(
"onlyoffice_resource_identity_required",
"缺少有效 documentId 或 assetId",
));
}
let file_name = query.file_name.unwrap_or_else(|| "附件".into());
let file_type = query.file_type.unwrap_or_else(|| "docx".into());
let mode = query.mode.unwrap_or_else(|| "edit".into());
let user_id = query.user_id.unwrap_or_default();
let file_url = query.file_url.unwrap_or_default();
let onlyoffice_url = {
let mut params = Vec::new();
params.push(("fileUrl", file_url.as_str()));
params.push(("fileName", file_name.as_str()));
params.push(("fileType", file_type.as_str()));
params.push(("assetId", asset_id));
params.push(("documentId", document_id));
params.push(("userId", user_id.as_str()));
params.push(("mode", mode.as_str()));
let query = params
.into_iter()
.map(|(key, value)| format!("{key}={}", encode_query_component(value)))
.collect::>()
.join("&");
format!("/onlyoffice?{query}")
};
let object_identity = format!("resource:onlyoffice:{document_id}:{asset_id}");
let html = format!(
r#"
{title}
"#,
title = escape_html(&file_name),
object_identity = escape_html(&object_identity),
document_id = escape_html(document_id),
asset_id = escape_html(asset_id),
onlyoffice_url = escape_html(&onlyoffice_url),
);
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"))?;
if let Some(response) = proxy_local_folder_file_open(encoded_url, &method)? {
return Ok(response);
}
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)
}
fn decode_onlyoffice_proxy_url(encoded_url: &str) -> Option {
[
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
&base64::engine::general_purpose::URL_SAFE,
&base64::engine::general_purpose::STANDARD,
]
.into_iter()
.find_map(|engine| {
engine
.decode(encoded_url)
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok())
})
}
fn is_local_mnote_proxy_host(host: &str) -> bool {
matches!(host, "localhost" | "127.0.0.1" | "host.docker.internal")
}
fn parse_local_file_root_uri(root_uri: &str) -> Result {
let trimmed = root_uri.trim();
let Some(path) = trimmed.strip_prefix("file://") else {
return Err(WebError::bad_request_code(
"onlyoffice_local_file_root_invalid",
"本地文件 rootUri 必须使用 file://",
));
};
if path.trim().is_empty() {
return Err(WebError::bad_request_code(
"onlyoffice_local_file_root_invalid",
"本地文件 rootUri 不能为空",
));
}
Ok(PathBuf::from(path))
}
fn resolve_onlyoffice_local_file_path(
root_uri: &str,
relative_path: &str,
) -> Result {
let root = parse_local_file_root_uri(root_uri)?;
let canonical_root = root.canonicalize().map_err(|error| {
WebError::bad_request_code(
"onlyoffice_local_file_root_unavailable",
format!("无法访问本地文件夹: {error}"),
)
})?;
let requested = FsPath::new(relative_path);
if requested.is_absolute()
|| requested
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return Err(WebError::bad_request_code(
"onlyoffice_local_file_root_escape",
"本地文件路径不能越过 root",
));
}
let target = canonical_root
.join(requested)
.canonicalize()
.map_err(|error| {
WebError::bad_request_code(
"onlyoffice_local_file_not_found",
format!("找不到本地文件: {error}"),
)
})?;
if !target.starts_with(&canonical_root) || !target.is_file() {
return Err(WebError::bad_request_code(
"onlyoffice_local_file_root_escape",
"本地文件路径不能越过 root",
));
}
Ok(target)
}
fn onlyoffice_content_type_for_path(path: &FsPath) -> HeaderValue {
let extension = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or("")
.to_ascii_lowercase();
HeaderValue::from_static(match extension.as_str() {
"doc" => "application/msword",
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"ppt" => "application/vnd.ms-powerpoint",
"pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"xls" => "application/vnd.ms-excel",
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"odt" => "application/vnd.oasis.opendocument.text",
"odp" => "application/vnd.oasis.opendocument.presentation",
"ods" => "application/vnd.oasis.opendocument.spreadsheet",
"csv" => "text/csv; charset=utf-8",
"md" | "markdown" => "text/markdown; charset=utf-8",
"txt" | "log" => "text/plain; charset=utf-8",
"pdf" => "application/pdf",
_ => "application/octet-stream",
})
}
fn proxy_local_folder_file_open(
encoded_url: &str,
method: &Method,
) -> Result