- add local-folder OnlyOffice sign/callback writeback and edit-tab handling - align main resource tabs, attachment edit menu, slash isolation, and filetree context behavior - record Sidex/Hermes gap reviews and Reasonix task checklists
2116 lines
78 KiB
Rust
2116 lines
78 KiB
Rust
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#"<script>
|
|
window.__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true;
|
|
(function () {
|
|
try {
|
|
var locationOrigin = location.origin.replace(/\/+$/, '');
|
|
var proxyPrefix = locationOrigin + '/onlyoffice-server';
|
|
var internal = {
|
|
'http://127.0.0.1:8081': true,
|
|
'http://localhost:8081': true,
|
|
'http://127.0.0.1:8082': true,
|
|
'http://localhost:8082': true,
|
|
'https://127.0.0.1:8081': true,
|
|
'https://localhost:8081': true,
|
|
'https://127.0.0.1:8082': true,
|
|
'https://localhost:8082': true
|
|
};
|
|
|
|
function rewrite(input) {
|
|
try {
|
|
var raw = String(input || '');
|
|
if (!raw) return input;
|
|
var abs = new URL(raw, location.origin);
|
|
var origin = abs.protocol + '//' + abs.host;
|
|
var isProxyPath = abs.pathname === '/onlyoffice-server' || abs.pathname.indexOf('/onlyoffice-server/') === 0;
|
|
var isCachePath = abs.pathname === '/cache' || abs.pathname.indexOf('/cache/') === 0;
|
|
var sameHostWrongPort = abs.hostname === location.hostname && abs.host !== location.host;
|
|
|
|
if ((isProxyPath || isCachePath) && sameHostWrongPort) {
|
|
abs.protocol = location.protocol;
|
|
abs.host = location.host;
|
|
return abs.toString();
|
|
}
|
|
|
|
if (location.protocol === 'https:' && abs.protocol === 'http:' && abs.hostname === location.hostname && (isProxyPath || isCachePath)) {
|
|
abs.protocol = 'https:';
|
|
abs.host = location.host;
|
|
return abs.toString();
|
|
}
|
|
|
|
if (!internal[origin]) return input;
|
|
if (isCachePath) return locationOrigin + abs.pathname + abs.search + abs.hash;
|
|
return proxyPrefix + abs.pathname + abs.search + abs.hash;
|
|
} catch (_error) {
|
|
return input;
|
|
}
|
|
}
|
|
|
|
try {
|
|
var origOpen = XMLHttpRequest.prototype.open;
|
|
XMLHttpRequest.prototype.open = function (method, url, async, user, password) {
|
|
return origOpen.call(this, method, rewrite(url), async, user, password);
|
|
};
|
|
} catch (_error) {}
|
|
|
|
try {
|
|
var origFetch = window.fetch;
|
|
if (typeof origFetch === 'function') {
|
|
window.fetch = function (input, init) {
|
|
try {
|
|
if (typeof input === 'string') return origFetch.call(this, rewrite(input), init);
|
|
if (input && typeof input === 'object' && typeof input.url === 'string') {
|
|
return origFetch.call(this, new Request(rewrite(input.url), input), init);
|
|
}
|
|
} catch (_error) {}
|
|
return origFetch.call(this, input, init);
|
|
};
|
|
}
|
|
} catch (_error) {}
|
|
|
|
try {
|
|
var origWinOpen = window.open;
|
|
if (typeof origWinOpen === 'function') {
|
|
window.open = function (url, target, features) {
|
|
try {
|
|
if (typeof url === 'string') url = rewrite(url);
|
|
} catch (_error) {}
|
|
return origWinOpen.call(this, url, target, features);
|
|
};
|
|
}
|
|
} catch (_error) {}
|
|
|
|
try {
|
|
var loc = window.location;
|
|
var origAssign = loc.assign && loc.assign.bind(loc);
|
|
if (origAssign) {
|
|
loc.assign = function (url) { return origAssign(rewrite(url)); };
|
|
}
|
|
var origReplace = loc.replace && loc.replace.bind(loc);
|
|
if (origReplace) {
|
|
loc.replace = function (url) { return origReplace(rewrite(url)); };
|
|
}
|
|
} catch (_error) {}
|
|
} catch (_error) {}
|
|
})();
|
|
</script>"#;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct OnlyOfficePageQuery {
|
|
file_url: Option<String>,
|
|
file_name: Option<String>,
|
|
file_type: Option<String>,
|
|
asset_id: Option<String>,
|
|
document_id: Option<String>,
|
|
user_id: Option<String>,
|
|
mode: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct OnlyOfficeProxyQuery {
|
|
u: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct OnlyOfficeCallbackQuery {
|
|
#[serde(rename = "assetId")]
|
|
asset_id: Option<String>,
|
|
#[serde(rename = "userId")]
|
|
user_id: Option<String>,
|
|
#[serde(rename = "rootUri")]
|
|
root_uri: Option<String>,
|
|
path: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct OnlyOfficeForcesaveQuery {
|
|
#[serde(rename = "assetId")]
|
|
asset_id: Option<String>,
|
|
key: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct OnlyOfficeSignPayload {
|
|
config: Option<Value>,
|
|
}
|
|
|
|
fn env_or_dotenv(key: &str) -> Option<String> {
|
|
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<String> {
|
|
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<String> {
|
|
let mut candidates = Vec::new();
|
|
let mut push = |value: Option<String>| {
|
|
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<String> {
|
|
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<OnlyOfficePageQuery>) -> Result<Response, WebError> {
|
|
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#"<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>{title}</title>
|
|
<style>
|
|
html, body, #onlyoffice-frame {{ width: 100%; height: 100%; margin: 0; }}
|
|
body {{ overflow: hidden; background: #f7f7f5; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
|
|
.mnote-onlyoffice-error {{ position: fixed; inset: 0; display: none; place-items: center; padding: 24px; color: #2f3437; background: #f7f7f5; }}
|
|
.mnote-onlyoffice-error[data-visible="true"] {{ display: grid; }}
|
|
.mnote-onlyoffice-error > div {{ max-width: 560px; border: 1px solid #e2e2df; background: #fff; padding: 18px 20px; border-radius: 6px; box-shadow: 0 8px 28px rgba(15, 15, 15, .08); }}
|
|
.mnote-onlyoffice-error h1 {{ margin: 0 0 8px; font-size: 16px; font-weight: 600; }}
|
|
.mnote-onlyoffice-error p {{ margin: 0; font-size: 13px; line-height: 1.6; color: #73726e; word-break: break-word; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="onlyoffice-frame"></div>
|
|
<div id="onlyoffice-error" class="mnote-onlyoffice-error"><div><h1>ONLYOFFICE 加载失败</h1><p id="onlyoffice-error-message"></p></div></div>
|
|
<script>
|
|
window.__MNOTE_ONLYOFFICE_READY__ = false;
|
|
window.__MNOTE_ONLYOFFICE_ERRLOG__ = [];
|
|
const initial = {{
|
|
fileUrl: {file_url},
|
|
fileName: {file_name},
|
|
fileType: {file_type},
|
|
assetId: {asset_id},
|
|
documentId: {document_id},
|
|
userId: {user_id},
|
|
mode: {mode}
|
|
}};
|
|
const MNOTE_AGENT_PLUGIN_GUID = "asc.{{05F87DDF-7B42-4C6F-9F2B-9C77A8D5F4E2}}";
|
|
const pageLocal = location.hostname === "127.0.0.1" || location.hostname === "localhost";
|
|
const documentUrlBase = {document_url_base};
|
|
const proxyOrigin = pageLocal ? documentUrlBase : location.origin;
|
|
const callbackOrigin = proxyOrigin;
|
|
|
|
function showError(message) {{
|
|
window.__MNOTE_ONLYOFFICE_ERRLOG__.push({{ kind: "error", message: String(message || "") }});
|
|
const wrap = document.getElementById("onlyoffice-error");
|
|
const text = document.getElementById("onlyoffice-error-message");
|
|
if (text) text.textContent = String(message || "未知错误");
|
|
if (wrap) wrap.setAttribute("data-visible", "true");
|
|
}}
|
|
window.addEventListener("error", (event) => {{
|
|
const name = event.error && event.error.name ? String(event.error.name) : "";
|
|
const message = event.message || (event.error && event.error.message) || "";
|
|
window.__MNOTE_ONLYOFFICE_ERRLOG__.push({{ kind: "error", name, message }});
|
|
if (name === "NotFoundError" || name === "SecurityError" || message.includes("ServiceWorker")) {{
|
|
event.preventDefault();
|
|
}}
|
|
}});
|
|
window.addEventListener("unhandledrejection", (event) => {{
|
|
const reason = event.reason || {{}};
|
|
const name = reason.name ? String(reason.name) : "";
|
|
const message = reason.message ? String(reason.message) : String(reason || "");
|
|
window.__MNOTE_ONLYOFFICE_ERRLOG__.push({{ kind: "rejection", name, message }});
|
|
if (name === "NotFoundError" || name === "SecurityError" || message.includes("ServiceWorker")) {{
|
|
event.preventDefault();
|
|
}}
|
|
}});
|
|
|
|
function hashOnlyOfficeKey(input) {{
|
|
let hash = 0;
|
|
const value = String(input || "");
|
|
for (let i = 0; i < value.length; i += 1) {{
|
|
hash = ((hash << 5) - hash + value.charCodeAt(i)) | 0;
|
|
}}
|
|
return String(Math.abs(hash));
|
|
}}
|
|
function safeOnlyOfficeDocKey(input) {{
|
|
const raw = String(input || "").trim();
|
|
if (!raw) return "mnote_" + hashOnlyOfficeKey(initial.fileName || "document");
|
|
const safe = raw.replace(/[^A-Za-z0-9_.=-]/g, "_").replace(/_+/g, "_");
|
|
if (safe && safe === raw && safe.length <= 96) return safe;
|
|
return "mnote_" + hashOnlyOfficeKey(raw) + "_" + hashOnlyOfficeKey(initial.fileName || "");
|
|
}}
|
|
function docTypeFromExt(ext) {{
|
|
const value = String(ext || "").toLowerCase();
|
|
if (["ppt", "pptx", "odp"].includes(value)) return "slide";
|
|
if (["xls", "xlsx", "ods", "csv"].includes(value)) return "cell";
|
|
if (value === "pdf") return "pdf";
|
|
return "word";
|
|
}}
|
|
function base64UrlEncodeUtf8(input) {{
|
|
const bytes = new TextEncoder().encode(String(input || ""));
|
|
let binary = "";
|
|
for (const b of bytes) binary += String.fromCharCode(b);
|
|
return btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
}}
|
|
function resolveDocumentUrl(raw) {{
|
|
const value = String(raw || "").trim();
|
|
if (!value) return "";
|
|
try {{
|
|
const url = new URL(value, location.origin);
|
|
const alreadyProxy = url.pathname.includes("/api/onlyoffice/proxy");
|
|
if (alreadyProxy && proxyOrigin) {{
|
|
const origin = new URL(proxyOrigin);
|
|
url.protocol = origin.protocol;
|
|
url.host = origin.host;
|
|
return url.toString();
|
|
}}
|
|
const isLocal = ["127.0.0.1", "localhost", "host.docker.internal"].includes(url.hostname);
|
|
if (proxyOrigin && (isLocal || url.searchParams.has("token"))) {{
|
|
const proxy = new URL("/api/onlyoffice/proxy", proxyOrigin);
|
|
proxy.searchParams.set("u", base64UrlEncodeUtf8(url.toString()));
|
|
return proxy.toString();
|
|
}}
|
|
return url.toString();
|
|
}} catch {{
|
|
return value;
|
|
}}
|
|
}}
|
|
function localFolderOpenParams(raw) {{
|
|
try {{
|
|
const url = new URL(String(raw || ""), location.origin);
|
|
if (url.pathname !== "/api/local-folder/files/open") return null;
|
|
const rootUri = String(url.searchParams.get("rootUri") || "").trim();
|
|
const path = String(url.searchParams.get("path") || "").trim();
|
|
if (!rootUri || !path) return null;
|
|
return {{ rootUri, path }};
|
|
}} catch {{
|
|
return null;
|
|
}}
|
|
}}
|
|
function buildCallbackUrl(assetId, userId, localFile) {{
|
|
const callback = new URL("/api/onlyoffice/callback", callbackOrigin || location.origin);
|
|
if (assetId) callback.searchParams.set("assetId", assetId);
|
|
if (userId) callback.searchParams.set("userId", userId);
|
|
if (localFile && localFile.rootUri && localFile.path) {{
|
|
callback.searchParams.set("rootUri", localFile.rootUri);
|
|
callback.searchParams.set("path", localFile.path);
|
|
}}
|
|
return callback.toString();
|
|
}}
|
|
function loadScript(url, timeoutMs) {{
|
|
return new Promise((resolve, reject) => {{
|
|
const script = document.createElement("script");
|
|
script.src = url;
|
|
const cleanup = () => {{ script.onload = null; script.onerror = null; }};
|
|
script.onload = () => {{ cleanup(); resolve(); }};
|
|
script.onerror = () => {{ cleanup(); reject(new Error("无法加载 ONLYOFFICE API: " + url)); }};
|
|
document.head.appendChild(script);
|
|
if (timeoutMs > 0) {{
|
|
const timer = window.setTimeout(() => {{
|
|
cleanup();
|
|
reject(new Error("ONLYOFFICE API 加载超时 (" + (timeoutMs / 1000) + "秒)"));
|
|
}}, timeoutMs);
|
|
resolve = ((orig) => (value) => {{ window.clearTimeout(timer); return orig(value); }})(resolve);
|
|
reject = ((orig) => (reason) => {{ window.clearTimeout(timer); return orig(reason); }})(reject);
|
|
}}
|
|
}});
|
|
}}
|
|
const MNOTE_ONLYOFFICE_FRAME_REV = "mnote-proxy-identity-20260511";
|
|
function withOnlyOfficeFrameRevision(raw) {{
|
|
try {{
|
|
const value = String(raw || "");
|
|
if (!value) return raw;
|
|
const url = new URL(value, location.origin);
|
|
if (url.pathname.includes("/web-apps/apps/documenteditor/main/index.html") && !url.searchParams.has("mnoteProxyRev")) {{
|
|
url.searchParams.set("mnoteProxyRev", MNOTE_ONLYOFFICE_FRAME_REV);
|
|
return url.toString();
|
|
}}
|
|
}} catch {{
|
|
// ignore
|
|
}}
|
|
return raw;
|
|
}}
|
|
function installOnlyOfficeFrameSrcPatch() {{
|
|
try {{
|
|
if (window.__MNOTE_ONLYOFFICE_FRAME_SRC_PATCHED__) return;
|
|
window.__MNOTE_ONLYOFFICE_FRAME_SRC_PATCHED__ = true;
|
|
const originalSetAttribute = Element.prototype.setAttribute;
|
|
Element.prototype.setAttribute = function (name, value) {{
|
|
if (this && this.tagName === "IFRAME" && String(name || "").toLowerCase() === "src") {{
|
|
value = withOnlyOfficeFrameRevision(value);
|
|
}}
|
|
return originalSetAttribute.call(this, name, value);
|
|
}};
|
|
const descriptor = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, "src");
|
|
if (descriptor && descriptor.get && descriptor.set) {{
|
|
Object.defineProperty(HTMLIFrameElement.prototype, "src", {{
|
|
configurable: true,
|
|
enumerable: descriptor.enumerable,
|
|
get: descriptor.get,
|
|
set(value) {{
|
|
return descriptor.set.call(this, withOnlyOfficeFrameRevision(value));
|
|
}}
|
|
}});
|
|
}}
|
|
}} catch {{
|
|
// ignore
|
|
}}
|
|
}}
|
|
async function waitForDocEditorReady(timeoutMs) {{
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {{
|
|
if (window.DocsAPI && window.DocsAPI.DocEditor) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
}}
|
|
throw new Error("ONLYOFFICE DocEditor 初始化超时");
|
|
}}
|
|
async function resolveWhoami() {{
|
|
if (initial.userId) return initial.userId;
|
|
try {{
|
|
const response = await fetch("/api/auth/whoami");
|
|
const payload = await response.json().catch(() => null);
|
|
return String(payload && (payload.userId || payload.id) || "").trim();
|
|
}} catch {{
|
|
return "";
|
|
}}
|
|
}}
|
|
function isLocalFolderAsset() {{
|
|
if (initial.assetId && (initial.assetId.indexOf("local:") === 0 || initial.assetId.indexOf("local-file:") === 0)) return true;
|
|
if (initial.fileUrl && initial.fileUrl.indexOf("/api/local-folder/files/open") !== -1) return true;
|
|
return false;
|
|
}}
|
|
async function resolveAssetUrlAndKey() {{
|
|
let effectiveFileUrl = initial.fileUrl;
|
|
let storageId = "";
|
|
if (initial.assetId && !isLocalFolderAsset()) {{
|
|
try {{
|
|
const response = await fetch("/api/media/sign?assetId=" + encodeURIComponent(initial.assetId));
|
|
const payload = await response.json().catch(() => null);
|
|
if (response.ok && payload) {{
|
|
effectiveFileUrl = String(payload.signedUrl || effectiveFileUrl || "").trim();
|
|
storageId = String(payload.asset && payload.asset.storage_id || "").trim();
|
|
}}
|
|
}} catch {{
|
|
// ignore
|
|
}}
|
|
}}
|
|
const rawDocKey = initial.assetId
|
|
? (storageId ? initial.assetId + "_" + hashOnlyOfficeKey(storageId) : initial.assetId)
|
|
: hashOnlyOfficeKey(String(effectiveFileUrl || "") + "-" + initial.fileName);
|
|
const docKey = safeOnlyOfficeDocKey(rawDocKey);
|
|
return {{ effectiveFileUrl, storageId, docKey, resolvedFileUrl: resolveDocumentUrl(effectiveFileUrl) }};
|
|
}}
|
|
async function boot() {{
|
|
const bootTimeoutMs = 60000;
|
|
const bootDeadline = Date.now() + bootTimeoutMs;
|
|
const bootHeartbeat = window.setInterval(() => {{
|
|
if (Date.now() > bootDeadline) {{
|
|
window.clearInterval(bootHeartbeat);
|
|
showError("ONLYOFFICE 页面初始化超时(" + (bootTimeoutMs / 1000) + "秒),请检查 DocumentServer 是否正常运行。");
|
|
}}
|
|
}}, 5000);
|
|
try {{
|
|
const userId = await resolveWhoami();
|
|
const fileState = await resolveAssetUrlAndKey();
|
|
if (!fileState.resolvedFileUrl) throw new Error("缺少 fileUrl 参数");
|
|
await loadScript("/onlyoffice-server/web-apps/apps/api/documents/api.js", 15000);
|
|
await waitForDocEditorReady(60000);
|
|
|
|
const resolvedMode = initial.mode === "view" ? "view" : "edit";
|
|
const localFile = localFolderOpenParams(fileState.effectiveFileUrl || initial.fileUrl);
|
|
const displayUserId = String(userId || initial.userId || "mnote-local-user").trim() || "mnote-local-user";
|
|
const displayUserName = displayUserId === "mnote-local-user" ? "MNote" : displayUserId;
|
|
const config = {{
|
|
width: "100%",
|
|
height: "100%",
|
|
documentType: docTypeFromExt(initial.fileType),
|
|
document: {{
|
|
fileType: initial.fileType,
|
|
title: initial.fileName,
|
|
url: fileState.resolvedFileUrl,
|
|
key: fileState.docKey,
|
|
permissions: {{
|
|
edit: resolvedMode !== "view",
|
|
download: true,
|
|
print: true,
|
|
copy: true
|
|
}}
|
|
}},
|
|
editorConfig: {{
|
|
mode: resolvedMode,
|
|
lang: "zh-CN",
|
|
callbackUrl: buildCallbackUrl(initial.assetId, userId, localFile),
|
|
user: {{
|
|
id: displayUserId,
|
|
name: displayUserName
|
|
}},
|
|
customization: {{
|
|
feedback: {{ visible: false }},
|
|
anonymous: {{ request: false, label: "Guest" }},
|
|
features: {{ featuresTips: false }},
|
|
forcesave: resolvedMode !== "view"
|
|
}}
|
|
}},
|
|
events: {{
|
|
onDocumentReady: () => {{ window.__MNOTE_ONLYOFFICE_READY__ = true; }},
|
|
onAppReady: () => {{ window.__MNOTE_ONLYOFFICE_READY__ = true; }},
|
|
onError: (event) => showError(JSON.stringify(event))
|
|
}}
|
|
}};
|
|
window.__MNOTE_ONLYOFFICE_DEBUG__ = {{
|
|
pageOrigin: location.origin,
|
|
baseUrl: "/onlyoffice-server",
|
|
documentUrlBase,
|
|
proxyOrigin,
|
|
callbackOrigin,
|
|
fileUrlInput: fileState.effectiveFileUrl,
|
|
resolvedFileUrl: fileState.resolvedFileUrl,
|
|
fileName: initial.fileName,
|
|
fileType: initial.fileType,
|
|
mode: initial.mode,
|
|
resolvedMode,
|
|
assetId: initial.assetId,
|
|
documentId: initial.documentId,
|
|
docKey: fileState.docKey
|
|
}};
|
|
const readyDeadline = Date.now() + 120000;
|
|
const timer = window.setInterval(() => {{
|
|
const root = document.getElementById("onlyoffice-frame");
|
|
const count = (root ? root.querySelectorAll("iframe,canvas").length : 0) + document.body.querySelectorAll("iframe,canvas").length;
|
|
if (count > 0) {{
|
|
window.__MNOTE_ONLYOFFICE_READY__ = true;
|
|
window.clearInterval(timer);
|
|
}} else if (Date.now() > readyDeadline) {{
|
|
window.clearInterval(timer);
|
|
}}
|
|
}}, 500);
|
|
const signResponse = await fetch("/api/onlyoffice/sign", {{
|
|
method: "POST",
|
|
headers: {{ "content-type": "application/json" }},
|
|
body: JSON.stringify({{ config }})
|
|
}});
|
|
const signPayload = await signResponse.json().catch(() => null);
|
|
if (!signResponse.ok) throw new Error(signPayload && signPayload.message || signPayload && signPayload.error || "OnlyOffice 签名失败");
|
|
if (signPayload.token) config.token = signPayload.token;
|
|
if (signPayload.documentToken) config.document.token = signPayload.documentToken;
|
|
if (signPayload.editorConfigToken) config.editorConfig.token = signPayload.editorConfigToken;
|
|
installOnlyOfficeFrameSrcPatch();
|
|
window.__MNOTE_ONLYOFFICE_EDITOR__ = new window.DocsAPI.DocEditor("onlyoffice-frame", config);
|
|
}} finally {{
|
|
window.clearInterval(bootHeartbeat);
|
|
}}
|
|
}}
|
|
boot().catch((error) => {{
|
|
showError(error && error.message ? error.message : error);
|
|
}});
|
|
</script>
|
|
</body>
|
|
</html>"#,
|
|
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<OnlyOfficePageQuery>,
|
|
) -> Result<Response, WebError> {
|
|
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::<Vec<_>>()
|
|
.join("&");
|
|
format!("/onlyoffice?{query}")
|
|
};
|
|
let object_identity = format!("resource:onlyoffice:{document_id}:{asset_id}");
|
|
let html = format!(
|
|
r#"<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>{title}</title>
|
|
<style>
|
|
html, body, .mnote-office-object-shell, iframe {{ width: 100%; height: 100%; margin: 0; border: 0; }}
|
|
body {{ overflow: hidden; background: #f7f7f5; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="mnote-office-object-shell" data-mnote-object-editor="onlyoffice" data-mnote-object-identity="{object_identity}" data-document-id="{document_id}" data-asset-id="{asset_id}">
|
|
<iframe src="{onlyoffice_url}" title="{title}" allow="clipboard-read; clipboard-write; fullscreen"></iframe>
|
|
</main>
|
|
</body>
|
|
</html>"#,
|
|
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<OnlyOfficeSignPayload>) -> Result<Response, WebError> {
|
|
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<String> {
|
|
env_or_dotenv(key).and_then(|value| normalize_http_origin(&value))
|
|
}
|
|
|
|
pub async fn proxy(
|
|
Query(query): Query<OnlyOfficeProxyQuery>,
|
|
headers: HeaderMap,
|
|
method: Method,
|
|
) -> Result<Response, WebError> {
|
|
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<String> {
|
|
[
|
|
&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<PathBuf, WebError> {
|
|
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<PathBuf, WebError> {
|
|
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<Option<Response>, WebError> {
|
|
if *method != Method::GET && *method != Method::HEAD {
|
|
return Ok(None);
|
|
}
|
|
let Some(raw_url) = decode_onlyoffice_proxy_url(encoded_url) else {
|
|
return Ok(None);
|
|
};
|
|
let Ok(url) = reqwest::Url::parse(&raw_url) else {
|
|
return Ok(None);
|
|
};
|
|
let Some(host) = url.host_str() else {
|
|
return Ok(None);
|
|
};
|
|
if !is_local_mnote_proxy_host(host) || url.path() != "/api/local-folder/files/open" {
|
|
return Ok(None);
|
|
}
|
|
let mut root_uri = String::new();
|
|
let mut relative_path = String::new();
|
|
for (key, value) in url.query_pairs() {
|
|
match key.as_ref() {
|
|
"rootUri" => root_uri = value.into_owned(),
|
|
"path" => relative_path = value.into_owned(),
|
|
_ => {}
|
|
}
|
|
}
|
|
if root_uri.trim().is_empty() || relative_path.trim().is_empty() {
|
|
return Err(WebError::bad_request_code(
|
|
"onlyoffice_local_file_query_missing",
|
|
"本地文件代理缺少 rootUri 或 path",
|
|
));
|
|
}
|
|
let target = resolve_onlyoffice_local_file_path(&root_uri, &relative_path)?;
|
|
let metadata = fs::metadata(&target).map_err(|error| {
|
|
WebError::bad_request_code(
|
|
"onlyoffice_local_file_metadata_failed",
|
|
format!("无法读取本地文件元数据: {error}"),
|
|
)
|
|
})?;
|
|
let mut response = if *method == Method::HEAD {
|
|
Response::new(Body::empty())
|
|
} else {
|
|
let bytes = fs::read(&target).map_err(|error| {
|
|
WebError::bad_request_code(
|
|
"onlyoffice_local_file_read_failed",
|
|
format!("无法读取本地文件: {error}"),
|
|
)
|
|
})?;
|
|
Response::new(Body::from(bytes))
|
|
};
|
|
response.headers_mut().insert(
|
|
header::CONTENT_TYPE,
|
|
onlyoffice_content_type_for_path(&target),
|
|
);
|
|
response.headers_mut().insert(
|
|
header::CONTENT_LENGTH,
|
|
HeaderValue::from_str(&metadata.len().to_string())
|
|
.unwrap_or_else(|_| HeaderValue::from_static("0")),
|
|
);
|
|
response
|
|
.headers_mut()
|
|
.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes"));
|
|
Ok(Some(response))
|
|
}
|
|
|
|
fn is_onlyoffice_local_asset_id(asset_id: &str) -> bool {
|
|
let value = asset_id.trim();
|
|
value.starts_with("local:") || value.starts_with("local-file:")
|
|
}
|
|
|
|
fn onlyoffice_callback_success(extra: Value) -> Response {
|
|
let mut payload = json!({ "error": 0 });
|
|
if let (Some(object), Some(extra_object)) = (payload.as_object_mut(), extra.as_object()) {
|
|
for (key, value) in extra_object {
|
|
object.insert(key.clone(), value.clone());
|
|
}
|
|
}
|
|
Json(payload).into_response()
|
|
}
|
|
|
|
fn onlyoffice_callback_failure(error: WebError) -> Response {
|
|
Json(json!({
|
|
"error": 1,
|
|
"code": error.code(),
|
|
"message": error.message(),
|
|
}))
|
|
.into_response()
|
|
}
|
|
|
|
async fn download_onlyoffice_callback_body(download_url: &str) -> Result<Bytes, WebError> {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(60))
|
|
.build()
|
|
.map_err(|error| {
|
|
WebError::internal(format!("OnlyOffice callback HTTP 客户端创建失败: {error}"))
|
|
})?;
|
|
let response = client.get(download_url).send().await.map_err(|error| {
|
|
WebError::bad_gateway_code(
|
|
"onlyoffice_local_callback_download_failed",
|
|
format!("OnlyOffice 保存文件下载失败: {error}"),
|
|
)
|
|
})?;
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
return Err(WebError::bad_gateway_code(
|
|
"onlyoffice_local_callback_download_failed",
|
|
format!("OnlyOffice 保存文件下载失败: HTTP {status}"),
|
|
));
|
|
}
|
|
response.bytes().await.map_err(|error| {
|
|
WebError::bad_gateway_code(
|
|
"onlyoffice_local_callback_body_failed",
|
|
format!("OnlyOffice 保存文件读取失败: {error}"),
|
|
)
|
|
})
|
|
}
|
|
|
|
async fn local_folder_onlyoffice_callback(
|
|
query: &OnlyOfficeCallbackQuery,
|
|
body: &Value,
|
|
status: i64,
|
|
) -> Result<Response, WebError> {
|
|
let asset_id = query.asset_id.as_deref().unwrap_or("").trim();
|
|
let root_uri = query
|
|
.root_uri
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.ok_or_else(|| {
|
|
WebError::bad_request_code(
|
|
"onlyoffice_local_callback_root_missing",
|
|
"OnlyOffice 本地保存缺少 rootUri",
|
|
)
|
|
})?;
|
|
let relative_path = query
|
|
.path
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.ok_or_else(|| {
|
|
WebError::bad_request_code(
|
|
"onlyoffice_local_callback_path_missing",
|
|
"OnlyOffice 本地保存缺少 path",
|
|
)
|
|
})?;
|
|
let target = resolve_onlyoffice_local_file_path(root_uri, relative_path)?;
|
|
let onlyoffice_internal_url = resolve_onlyoffice_internal_url().await;
|
|
let prepared = prepare_callback(OnlyOfficeCallbackPreparationInput {
|
|
asset_id: asset_id.to_string(),
|
|
document_id: None,
|
|
workspace_id: None,
|
|
user_id: query.user_id.clone(),
|
|
session_id: None,
|
|
status,
|
|
url: body
|
|
.get("url")
|
|
.and_then(Value::as_str)
|
|
.map(ToOwned::to_owned),
|
|
key: body
|
|
.get("key")
|
|
.and_then(Value::as_str)
|
|
.map(ToOwned::to_owned),
|
|
onlyoffice_internal_url,
|
|
})
|
|
.map_err(|error| WebError::bad_request_code("onlyoffice_local_callback_invalid", error))?;
|
|
if !prepared.should_write {
|
|
return Ok(onlyoffice_callback_success(json!({
|
|
"localWrite": false,
|
|
"status": status,
|
|
})));
|
|
}
|
|
let download_url = prepared.download_url.as_deref().ok_or_else(|| {
|
|
WebError::bad_request_code(
|
|
"onlyoffice_local_callback_url_missing",
|
|
"OnlyOffice 本地保存缺少下载地址",
|
|
)
|
|
})?;
|
|
let bytes = download_onlyoffice_callback_body(download_url).await?;
|
|
fs::write(&target, &bytes).map_err(|error| {
|
|
WebError::bad_request_code(
|
|
"onlyoffice_local_callback_write_failed",
|
|
format!("写回本地 Office 文件失败: {error}"),
|
|
)
|
|
})?;
|
|
Ok(onlyoffice_callback_success(json!({
|
|
"localWrite": true,
|
|
"bytes": bytes.len(),
|
|
})))
|
|
}
|
|
|
|
pub async fn callback(
|
|
State(state): State<AppState>,
|
|
uri: Uri,
|
|
Query(query): Query<OnlyOfficeCallbackQuery>,
|
|
Json(body): Json<Value>,
|
|
) -> Response {
|
|
let status = body
|
|
.get("status")
|
|
.and_then(Value::as_i64)
|
|
.unwrap_or_default();
|
|
tracing::info!(
|
|
asset_id = query.asset_id.as_deref().unwrap_or(""),
|
|
user_id = query.user_id.as_deref().unwrap_or(""),
|
|
status,
|
|
"OnlyOffice callback received by mnote-web"
|
|
);
|
|
let is_local_callback = query
|
|
.asset_id
|
|
.as_deref()
|
|
.map(is_onlyoffice_local_asset_id)
|
|
.unwrap_or(false)
|
|
|| query.root_uri.as_deref().is_some()
|
|
|| query.path.as_deref().is_some();
|
|
if is_local_callback {
|
|
return match local_folder_onlyoffice_callback(&query, &body, status).await {
|
|
Ok(response) => response,
|
|
Err(error) => onlyoffice_callback_failure(error),
|
|
};
|
|
}
|
|
match proxy_legacy_onlyoffice_json(
|
|
state.config(),
|
|
"/api/onlyoffice/callback",
|
|
uri.query(),
|
|
Some(body),
|
|
)
|
|
.await
|
|
{
|
|
Ok(response) => response,
|
|
Err(error) if error.status() == axum::http::StatusCode::NOT_IMPLEMENTED => (
|
|
axum::http::StatusCode::NOT_IMPLEMENTED,
|
|
Json(json!({
|
|
"error": 1,
|
|
"degraded": true,
|
|
"code": "onlyoffice_legacy_writeback_unavailable",
|
|
"message": error.message(),
|
|
})),
|
|
)
|
|
.into_response(),
|
|
Err(error) => (
|
|
axum::http::StatusCode::BAD_GATEWAY,
|
|
Json(json!({
|
|
"error": 1,
|
|
"degraded": true,
|
|
"code": "onlyoffice_legacy_writeback_failed",
|
|
"message": error.message(),
|
|
})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
pub async fn forcesave(
|
|
State(state): State<AppState>,
|
|
uri: Uri,
|
|
Query(query): Query<OnlyOfficeForcesaveQuery>,
|
|
) -> Result<Response, WebError> {
|
|
let asset_id = query
|
|
.asset_id
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.ok_or_else(|| {
|
|
WebError::bad_request_code("onlyoffice_forcesave_asset_missing", "缺少 assetId")
|
|
})?;
|
|
let key = query
|
|
.key
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.ok_or_else(|| {
|
|
WebError::bad_request_code("onlyoffice_forcesave_key_missing", "缺少 key")
|
|
})?;
|
|
let response = proxy_legacy_onlyoffice_json(
|
|
state.config(),
|
|
"/api/onlyoffice/forcesave",
|
|
uri.query(),
|
|
None,
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
if error.status() == axum::http::StatusCode::NOT_IMPLEMENTED {
|
|
return WebError::new(
|
|
axum::http::StatusCode::NOT_IMPLEMENTED,
|
|
"onlyoffice_legacy_writeback_unavailable",
|
|
format!(
|
|
"OnlyOffice forcesave 未配置 legacy Next 写回链: assetId={asset_id}, key={key}"
|
|
),
|
|
);
|
|
}
|
|
error
|
|
})?;
|
|
Ok(response)
|
|
}
|
|
|
|
fn legacy_onlyoffice_writeback_base(config: &AppConfig) -> Option<String> {
|
|
config
|
|
.legacy_next_base_url
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(|value| value.trim_end_matches('/').to_string())
|
|
}
|
|
|
|
async fn proxy_legacy_onlyoffice_json(
|
|
config: &AppConfig,
|
|
path: &str,
|
|
query: Option<&str>,
|
|
body: Option<Value>,
|
|
) -> Result<Response, WebError> {
|
|
let base = legacy_onlyoffice_writeback_base(config).ok_or_else(|| {
|
|
WebError::new(
|
|
axum::http::StatusCode::NOT_IMPLEMENTED,
|
|
"onlyoffice_legacy_writeback_unavailable",
|
|
"OnlyOffice Rust route 暂未直接写回,且未配置 legacy Next 写回链",
|
|
)
|
|
})?;
|
|
let target = append_path_and_query(&base, path, query);
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(30))
|
|
.build()
|
|
.map_err(|error| {
|
|
WebError::internal(format!(
|
|
"OnlyOffice legacy proxy HTTP 客户端创建失败: {error}"
|
|
))
|
|
})?;
|
|
let mut request = client
|
|
.post(target)
|
|
.header(header::CONTENT_TYPE, "application/json");
|
|
if let Some(body) = body {
|
|
request = request.json(&body);
|
|
} else {
|
|
request = request.body("{}");
|
|
}
|
|
let upstream = request.send().await.map_err(|error| {
|
|
WebError::bad_gateway_code(
|
|
"onlyoffice_legacy_writeback_failed",
|
|
format!("OnlyOffice legacy 写回请求失败: {error}"),
|
|
)
|
|
})?;
|
|
let status = upstream.status();
|
|
let content_type = upstream
|
|
.headers()
|
|
.get(header::CONTENT_TYPE)
|
|
.cloned()
|
|
.unwrap_or_else(|| HeaderValue::from_static("application/json"));
|
|
let bytes = upstream.bytes().await.map_err(|error| {
|
|
WebError::bad_gateway_code(
|
|
"onlyoffice_legacy_writeback_failed",
|
|
format!("OnlyOffice legacy 写回响应读取失败: {error}"),
|
|
)
|
|
})?;
|
|
let mut response = Response::new(Body::from(bytes));
|
|
*response.status_mut() = status;
|
|
response
|
|
.headers_mut()
|
|
.insert(header::CONTENT_TYPE, content_type);
|
|
Ok(response)
|
|
}
|
|
|
|
fn append_path_and_query(base: &str, path: &str, query: Option<&str>) -> String {
|
|
let mut target = format!(
|
|
"{}/{}",
|
|
base.trim_end_matches('/'),
|
|
path.trim_start_matches('/')
|
|
);
|
|
if let Some(query) = query.filter(|value| !value.is_empty()) {
|
|
target.push('?');
|
|
target.push_str(query);
|
|
}
|
|
target
|
|
}
|
|
|
|
fn request_body_bytes(
|
|
request: Request<Body>,
|
|
) -> impl std::future::Future<Output = Result<Bytes, WebError>> {
|
|
async move {
|
|
axum::body::to_bytes(request.into_body(), 32 * 1024 * 1024)
|
|
.await
|
|
.map_err(|error| {
|
|
WebError::bad_request_code(
|
|
"onlyoffice_server_body_failed",
|
|
format!("读取请求体失败: {error}"),
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
fn inject_onlyoffice_html_fixups(body: &str) -> String {
|
|
if body.contains("window.__MNOTE_ONLYOFFICE_XHR_REWRITE__") {
|
|
return body.to_string();
|
|
}
|
|
body.replacen(
|
|
"<head>",
|
|
&format!("<head>\n{}\n", ONLYOFFICE_RUNTIME_REWRITE_SNIPPET),
|
|
1,
|
|
)
|
|
}
|
|
|
|
fn strip_hop_by_hop_headers(headers: &mut HeaderMap) {
|
|
for name in [
|
|
header::CONNECTION,
|
|
header::CONTENT_ENCODING,
|
|
header::CONTENT_LENGTH,
|
|
header::HeaderName::from_static("keep-alive"),
|
|
header::HeaderName::from_static("proxy-authenticate"),
|
|
header::HeaderName::from_static("proxy-authorization"),
|
|
header::TE,
|
|
header::TRAILER,
|
|
header::TRANSFER_ENCODING,
|
|
header::UPGRADE,
|
|
] {
|
|
headers.remove(name);
|
|
}
|
|
}
|
|
|
|
async fn proxy_onlyoffice_path(
|
|
upstream_prefix: &str,
|
|
upstream_path: &str,
|
|
uri: Uri,
|
|
method: Method,
|
|
headers: HeaderMap,
|
|
request: Request<Body>,
|
|
) -> Result<Response, WebError> {
|
|
let base = resolve_onlyoffice_internal_url().await;
|
|
let prefix = upstream_prefix.trim_matches('/');
|
|
let normalized_path = upstream_path.trim_start_matches('/');
|
|
let is_cache_path = prefix == "cache" || normalized_path.starts_with("cache/");
|
|
let target_base = if prefix.is_empty() {
|
|
base.clone()
|
|
} else {
|
|
format!("{}/{}", base.trim_end_matches('/'), prefix)
|
|
};
|
|
let target = append_path_and_query(&target_base, upstream_path, uri.query());
|
|
let target_origin = reqwest::Url::parse(&base).ok();
|
|
let body = request_body_bytes(request).await?;
|
|
let client = reqwest::Client::new();
|
|
let mut builder = client.request(
|
|
reqwest::Method::from_bytes(method.as_str().as_bytes()).unwrap_or(reqwest::Method::GET),
|
|
target,
|
|
);
|
|
for (name, value) in headers.iter() {
|
|
if matches!(
|
|
name.as_str(),
|
|
"host"
|
|
| "connection"
|
|
| "upgrade"
|
|
| "sec-websocket-key"
|
|
| "sec-websocket-version"
|
|
| "accept-encoding"
|
|
) {
|
|
continue;
|
|
}
|
|
builder = builder.header(name, value);
|
|
}
|
|
// 说明:部分 ONLYOFFICE HTML 需要注入同源代理补丁。这里强制回源明文,
|
|
// 避免 gzip 字节被当成 text/html 注入后在 iframe 中显示乱码。
|
|
builder = builder.header(header::ACCEPT_ENCODING, "identity");
|
|
if let Some(base_url) = target_origin.as_ref() {
|
|
builder = builder.header(header::HOST, base_url.host_str().unwrap_or("127.0.0.1"));
|
|
}
|
|
let (forwarded_host, forwarded_proto, forwarded_port) = forwarded_public_origin(&headers);
|
|
builder = builder
|
|
.header("x-forwarded-host", forwarded_host)
|
|
.header("x-forwarded-proto", forwarded_proto)
|
|
.header("x-forwarded-port", forwarded_port)
|
|
.header("x-forwarded-prefix", "/onlyoffice-server");
|
|
if method != Method::GET && method != Method::HEAD {
|
|
builder = builder.body(body);
|
|
}
|
|
let upstream = builder.send().await.map_err(|error| {
|
|
WebError::bad_gateway_code(
|
|
"onlyoffice_server_proxy_failed",
|
|
format!("OnlyOffice 代理失败: {error}"),
|
|
)
|
|
})?;
|
|
let status = upstream.status();
|
|
let upstream_headers = upstream.headers().clone();
|
|
let mut response_headers = HeaderMap::new();
|
|
for (name, value) in upstream_headers.iter() {
|
|
if name == header::SET_COOKIE {
|
|
continue;
|
|
}
|
|
response_headers.insert(name, value.clone());
|
|
}
|
|
if method == Method::HEAD {
|
|
if is_cache_path {
|
|
let upstream_content_length = upstream_headers.get(header::CONTENT_LENGTH).cloned();
|
|
strip_hop_by_hop_headers(&mut response_headers);
|
|
if let Some(content_length) = upstream_content_length {
|
|
response_headers.insert(header::CONTENT_LENGTH, content_length);
|
|
}
|
|
}
|
|
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_server_proxy_body_failed",
|
|
format!("读取 OnlyOffice 响应失败: {error}"),
|
|
)
|
|
})?;
|
|
let maybe_html = upstream_headers
|
|
.get(header::CONTENT_TYPE)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(str::to_ascii_lowercase)
|
|
.map(|value| value.contains("text/html"))
|
|
.unwrap_or(false);
|
|
let mut response = if maybe_html {
|
|
let html = String::from_utf8_lossy(&bytes);
|
|
let body = inject_onlyoffice_html_fixups(&html);
|
|
strip_hop_by_hop_headers(&mut response_headers);
|
|
response_headers.insert(
|
|
header::CACHE_CONTROL,
|
|
header::HeaderValue::from_static("no-store"),
|
|
);
|
|
response_headers.insert(
|
|
header::CONTENT_LENGTH,
|
|
header::HeaderValue::from_str(&body.as_bytes().len().to_string())
|
|
.unwrap_or_else(|_| header::HeaderValue::from_static("0")),
|
|
);
|
|
Response::new(Body::from(body))
|
|
} else {
|
|
if is_cache_path {
|
|
strip_hop_by_hop_headers(&mut response_headers);
|
|
response_headers.insert(
|
|
header::CONTENT_LENGTH,
|
|
header::HeaderValue::from_str(&bytes.len().to_string())
|
|
.unwrap_or_else(|_| header::HeaderValue::from_static("0")),
|
|
);
|
|
}
|
|
Response::new(Body::from(bytes))
|
|
};
|
|
*response.status_mut() = status;
|
|
*response.headers_mut() = response_headers;
|
|
Ok(response)
|
|
}
|
|
|
|
fn onlyoffice_websocket_url(base: &str, upstream_path: &str, query: Option<&str>) -> String {
|
|
let mut target = append_path_and_query(base, upstream_path, query);
|
|
if let Some(rest) = target.strip_prefix("http://") {
|
|
target = format!("ws://{rest}");
|
|
} else if let Some(rest) = target.strip_prefix("https://") {
|
|
target = format!("wss://{rest}");
|
|
}
|
|
target
|
|
}
|
|
|
|
async fn bridge_onlyoffice_websocket(upgraded: Upgraded, target: String) {
|
|
let client_io = TokioIo::new(upgraded);
|
|
let mut client_socket = WebSocketStream::from_raw_socket(client_io, Role::Server, None).await;
|
|
let Ok((mut upstream_socket, _response)) = connect_async(&target).await else {
|
|
let _ = client_socket.close(None).await;
|
|
return;
|
|
};
|
|
|
|
loop {
|
|
tokio::select! {
|
|
client_message = client_socket.next() => {
|
|
let Some(Ok(message)) = client_message else {
|
|
let _ = upstream_socket.send(TungsteniteMessage::Close(None)).await;
|
|
break;
|
|
};
|
|
let is_close = matches!(message, TungsteniteMessage::Close(_));
|
|
if upstream_socket.send(message).await.is_err() {
|
|
break;
|
|
}
|
|
if is_close {
|
|
break;
|
|
}
|
|
}
|
|
upstream_message = upstream_socket.next() => {
|
|
let Some(Ok(message)) = upstream_message else {
|
|
let _ = client_socket.send(TungsteniteMessage::Close(None)).await;
|
|
break;
|
|
};
|
|
let is_close = matches!(message, TungsteniteMessage::Close(_));
|
|
if client_socket.send(message).await.is_err() {
|
|
break;
|
|
}
|
|
if is_close {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn server_proxy(
|
|
State(_state): State<AppState>,
|
|
Path(path): Path<String>,
|
|
uri: Uri,
|
|
method: Method,
|
|
headers: HeaderMap,
|
|
mut request: Request<Body>,
|
|
) -> Result<Response, WebError> {
|
|
let upgrade = headers
|
|
.get(header::UPGRADE)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(|value| value.eq_ignore_ascii_case("websocket"))
|
|
.unwrap_or(false);
|
|
if upgrade {
|
|
let key = headers
|
|
.get(header::SEC_WEBSOCKET_KEY)
|
|
.and_then(|value| value.to_str().ok())
|
|
.ok_or_else(|| {
|
|
WebError::bad_request_code(
|
|
"onlyoffice_websocket_key_missing",
|
|
"缺少 Sec-WebSocket-Key",
|
|
)
|
|
})?;
|
|
let target =
|
|
onlyoffice_websocket_url(&resolve_onlyoffice_internal_url().await, &path, uri.query());
|
|
let upgraded = hyper::upgrade::on(&mut request);
|
|
tokio::spawn(async move {
|
|
if let Ok(upgraded) = upgraded.await {
|
|
bridge_onlyoffice_websocket(upgraded, target).await;
|
|
}
|
|
});
|
|
let response = Response::builder()
|
|
.status(StatusCode::SWITCHING_PROTOCOLS)
|
|
.header(header::CONNECTION, "Upgrade")
|
|
.header(header::UPGRADE, "websocket")
|
|
.header(
|
|
header::SEC_WEBSOCKET_ACCEPT,
|
|
derive_accept_key(key.as_bytes()),
|
|
)
|
|
.body(Body::empty())
|
|
.map_err(|error| {
|
|
WebError::internal(format!("构造 OnlyOffice WebSocket 响应失败: {error}"))
|
|
})?;
|
|
return Ok(response);
|
|
}
|
|
proxy_onlyoffice_path("", &path, uri, method, headers, request).await
|
|
}
|
|
|
|
pub async fn cache_proxy(
|
|
State(_state): State<AppState>,
|
|
Path(path): Path<String>,
|
|
uri: Uri,
|
|
method: Method,
|
|
headers: HeaderMap,
|
|
request: Request<Body>,
|
|
) -> Result<Response, WebError> {
|
|
proxy_onlyoffice_path("cache", &path, uri, method, headers, request).await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn stable_doc_key(asset_id: &str, storage_id: &str, file_url: &str, file_name: &str) -> String {
|
|
let raw = if !asset_id.trim().is_empty() {
|
|
if storage_id.trim().is_empty() {
|
|
asset_id.trim().to_string()
|
|
} else {
|
|
format!("{}_{}", asset_id.trim(), js_hash_abs(storage_id))
|
|
}
|
|
} else {
|
|
js_hash_abs(&format!("{file_url}-{file_name}"))
|
|
};
|
|
safe_onlyoffice_doc_key(&raw, file_name)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn safe_onlyoffice_doc_key(input: &str, file_name: &str) -> String {
|
|
let raw = input.trim();
|
|
if raw.is_empty() {
|
|
return format!("mnote_{}", js_hash_abs(file_name));
|
|
}
|
|
let mut safe = String::new();
|
|
let mut previous_underscore = false;
|
|
for ch in raw.chars() {
|
|
let allowed = ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-');
|
|
let next = if allowed { ch } else { '_' };
|
|
if next == '_' {
|
|
if previous_underscore {
|
|
continue;
|
|
}
|
|
previous_underscore = true;
|
|
} else {
|
|
previous_underscore = false;
|
|
}
|
|
safe.push(next);
|
|
}
|
|
if !safe.is_empty() && safe == raw && safe.len() <= 96 {
|
|
return safe;
|
|
}
|
|
format!("mnote_{}_{}", js_hash_abs(raw), js_hash_abs(file_name))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn js_hash_abs(input: &str) -> String {
|
|
let mut hash: i32 = 0;
|
|
for unit in input.encode_utf16() {
|
|
hash = hash
|
|
.wrapping_shl(5)
|
|
.wrapping_sub(hash)
|
|
.wrapping_add(unit as i32);
|
|
}
|
|
hash.abs().to_string()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::app::{AppConfig, AppState};
|
|
use axum::extract::State;
|
|
use axum::http::StatusCode;
|
|
use serde_json::json;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::TcpListener;
|
|
use tokio::sync::oneshot;
|
|
|
|
#[test]
|
|
fn stable_doc_key_uses_onlyoffice_safe_characters() {
|
|
let key = stable_doc_key("asset_1", "kg2abc:def", "", "");
|
|
assert!(key.len() <= 128);
|
|
assert!(key
|
|
.chars()
|
|
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-')));
|
|
assert!(key.starts_with("asset_1_"));
|
|
}
|
|
|
|
#[test]
|
|
fn stable_doc_key_hashes_local_unicode_asset_ids() {
|
|
let key = stable_doc_key(
|
|
"local:asset:Alpha/重庆发展特殊化妆品可行性报告_政府汇报版.docx",
|
|
"",
|
|
"",
|
|
"重庆发展特殊化妆品可行性报告_政府汇报版.docx",
|
|
);
|
|
assert!(key.len() <= 128);
|
|
assert!(key.starts_with("mnote_"));
|
|
assert!(key
|
|
.chars()
|
|
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-')));
|
|
assert!(!key.contains('/'));
|
|
assert!(!key.contains(':'));
|
|
assert!(!key.contains('重'));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn onlyoffice_object_shell_exposes_resource_identity() {
|
|
let response = object_shell(
|
|
Path(("doc_1".into(), "asset_docx".into())),
|
|
Query(OnlyOfficePageQuery {
|
|
file_url: Some("/api/media/sign?assetId=asset_docx".into()),
|
|
file_name: Some("方案.docx".into()),
|
|
file_type: Some("docx".into()),
|
|
asset_id: None,
|
|
document_id: None,
|
|
user_id: Some("user_1".into()),
|
|
mode: Some("edit".into()),
|
|
}),
|
|
)
|
|
.await
|
|
.expect("object shell");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let html = String::from_utf8(body.to_vec()).expect("html");
|
|
assert!(html.contains("data-mnote-object-editor=\"onlyoffice\""));
|
|
assert!(
|
|
html.contains("data-mnote-object-identity=\"resource:onlyoffice:doc_1:asset_docx\"")
|
|
);
|
|
assert!(html.contains("/onlyoffice?"));
|
|
assert!(html.contains("assetId=asset_docx"));
|
|
assert!(html.contains("documentId=doc_1"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn onlyoffice_proxy_serves_local_folder_file_open_url() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"mnote-onlyoffice-local-proxy-{}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&root);
|
|
fs::create_dir_all(root.join("Page")).expect("create page");
|
|
fs::write(root.join("Page").join("report.docx"), b"docx").expect("write docx");
|
|
let local_url = format!(
|
|
"http://localhost:3000/api/local-folder/files/open?rootUri=file://{}&path=Page/report.docx",
|
|
root.display()
|
|
);
|
|
let encoded = base64::Engine::encode(
|
|
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
|
|
local_url.as_bytes(),
|
|
);
|
|
|
|
let response = proxy(
|
|
Query(OnlyOfficeProxyQuery { u: Some(encoded) }),
|
|
HeaderMap::new(),
|
|
Method::GET,
|
|
)
|
|
.await
|
|
.expect("proxy local file");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get(header::CONTENT_TYPE)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document")
|
|
);
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
assert_eq!(&body[..], b"docx");
|
|
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn onlyoffice_internal_candidates_keep_default_first_after_env() {
|
|
let candidates = onlyoffice_internal_candidates();
|
|
assert!(candidates.contains(&DEFAULT_ONLYOFFICE_INTERNAL_URL.to_string()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn onlyoffice_page_exposes_documentserver_fetch_base_for_local_files() {
|
|
let response = page(Query(OnlyOfficePageQuery {
|
|
file_url: Some(
|
|
"http://localhost:3000/api/local-folder/files/open?rootUri=file:///tmp&path=Page/report.docx"
|
|
.into(),
|
|
),
|
|
file_name: Some("report.docx".into()),
|
|
file_type: Some("docx".into()),
|
|
asset_id: Some("local-file:Page/report.docx".into()),
|
|
document_id: Some("local-md:Page".into()),
|
|
user_id: None,
|
|
mode: Some("view".into()),
|
|
}))
|
|
.await
|
|
.expect("onlyoffice page");
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let html = String::from_utf8(body.to_vec()).expect("html");
|
|
assert!(html.contains("const documentUrlBase = \"http://host.docker.internal:3000\";"));
|
|
assert!(html.contains("const proxyOrigin = pageLocal ? documentUrlBase : location.origin;"));
|
|
assert!(html.contains("documentUrlBase,"));
|
|
assert!(html.contains("resolvedFileUrl: fileState.resolvedFileUrl"));
|
|
}
|
|
|
|
fn test_state(legacy_next_base_url: Option<String>) -> AppState {
|
|
AppState::new(AppConfig {
|
|
service_name: "mnote-web".into(),
|
|
service_version: "0.1.0".into(),
|
|
bind_addr: "127.0.0.1:0".into(),
|
|
public_bind_addr: "127.0.0.1:3000".into(),
|
|
legacy_next_base_url,
|
|
enable_legacy_next_compat: true,
|
|
enable_debug_shell_routes: false,
|
|
enable_editor_actor: true,
|
|
hermes_base_path: "/api/hermes".into(),
|
|
compat_next_base_path: "/api/compat/next".into(),
|
|
convex_url: None,
|
|
convex_admin_key: None,
|
|
allow_dev_fixtures: false,
|
|
query_fixtures_json: None,
|
|
mutation_fixtures_json: None,
|
|
dev_user_id: "dev-user".into(),
|
|
dev_user_name: "开发用户".into(),
|
|
dev_user_email: "dev@mnote.local".into(),
|
|
})
|
|
}
|
|
|
|
async fn spawn_legacy_json_server(
|
|
response_body: &'static str,
|
|
) -> (String, oneshot::Receiver<String>) {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
|
let addr = listener.local_addr().expect("addr");
|
|
let (tx, rx) = oneshot::channel();
|
|
tokio::spawn(async move {
|
|
let (mut stream, _) = listener.accept().await.expect("accept");
|
|
let mut buffer = vec![0_u8; 8192];
|
|
let read = stream.read(&mut buffer).await.expect("read");
|
|
let request = String::from_utf8_lossy(&buffer[..read]).to_string();
|
|
let _ = tx.send(request);
|
|
let response = format!(
|
|
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
|
response_body.len(),
|
|
response_body
|
|
);
|
|
stream.write_all(response.as_bytes()).await.expect("write");
|
|
});
|
|
(format!("http://{addr}"), rx)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn onlyoffice_callback_without_legacy_next_fails_explicitly() {
|
|
let response = callback(
|
|
State(test_state(None)),
|
|
"/api/onlyoffice/callback?assetId=asset_1"
|
|
.parse::<Uri>()
|
|
.expect("uri"),
|
|
Query(OnlyOfficeCallbackQuery {
|
|
asset_id: Some("asset_1".into()),
|
|
user_id: None,
|
|
root_uri: None,
|
|
path: None,
|
|
}),
|
|
Json(json!({
|
|
"status": 2,
|
|
"url": "http://127.0.0.1:8082/cache/files/out.docx"
|
|
})),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(payload["error"], 1);
|
|
assert_eq!(payload["degraded"], true);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn onlyoffice_local_callback_writes_status_two_body_to_original_file() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"mnote-onlyoffice-local-callback-{}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&root);
|
|
fs::create_dir_all(root.join("Page")).expect("create page");
|
|
let target = root.join("Page").join("report.docx");
|
|
fs::write(&target, b"old").expect("write old docx");
|
|
let (download_url, _captured) = spawn_legacy_json_server("new docx bytes").await;
|
|
let response = callback(
|
|
State(test_state(None)),
|
|
format!(
|
|
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
|
|
root.display()
|
|
)
|
|
.parse::<Uri>()
|
|
.expect("uri"),
|
|
Query(OnlyOfficeCallbackQuery {
|
|
asset_id: Some("local:asset:Page/report.docx".into()),
|
|
user_id: None,
|
|
root_uri: Some(format!("file://{}", root.display())),
|
|
path: Some("Page/report.docx".into()),
|
|
}),
|
|
Json(json!({
|
|
"status": 2,
|
|
"key": "doc_key",
|
|
"url": download_url
|
|
})),
|
|
)
|
|
.await;
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
|
|
assert_eq!(payload["error"], 0);
|
|
assert_eq!(fs::read(&target).expect("read target"), b"new docx bytes");
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn onlyoffice_local_callback_ignores_non_write_status() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"mnote-onlyoffice-local-callback-ignore-{}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&root);
|
|
fs::create_dir_all(root.join("Page")).expect("create page");
|
|
let target = root.join("Page").join("report.docx");
|
|
fs::write(&target, b"old").expect("write old docx");
|
|
let response = callback(
|
|
State(test_state(None)),
|
|
format!(
|
|
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
|
|
root.display()
|
|
)
|
|
.parse::<Uri>()
|
|
.expect("uri"),
|
|
Query(OnlyOfficeCallbackQuery {
|
|
asset_id: Some("local:asset:Page/report.docx".into()),
|
|
user_id: None,
|
|
root_uri: Some(format!("file://{}", root.display())),
|
|
path: Some("Page/report.docx".into()),
|
|
}),
|
|
Json(json!({ "status": 1 })),
|
|
)
|
|
.await;
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
|
|
assert_eq!(payload["error"], 0);
|
|
assert_eq!(fs::read(&target).expect("read target"), b"old");
|
|
let _ = fs::remove_dir_all(&root);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn onlyoffice_callback_proxies_to_legacy_next_writeback() {
|
|
let (base_url, captured) = spawn_legacy_json_server(r#"{"error":0}"#).await;
|
|
let response = callback(
|
|
State(test_state(Some(base_url))),
|
|
"/api/onlyoffice/callback?assetId=asset_1&userId=user_1"
|
|
.parse::<Uri>()
|
|
.expect("uri"),
|
|
Query(OnlyOfficeCallbackQuery {
|
|
asset_id: Some("asset_1".into()),
|
|
user_id: Some("user_1".into()),
|
|
root_uri: None,
|
|
path: None,
|
|
}),
|
|
Json(json!({
|
|
"status": 2,
|
|
"key": "doc_key",
|
|
"url": "http://127.0.0.1:8082/cache/files/out.docx"
|
|
})),
|
|
)
|
|
.await;
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
let request = captured.await.expect("captured");
|
|
|
|
assert_eq!(payload["error"], 0);
|
|
assert!(request
|
|
.starts_with("POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1"));
|
|
assert!(request.contains(r#""status":2"#));
|
|
assert!(request.contains(r#""key":"doc_key""#));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn onlyoffice_forcesave_without_legacy_next_is_not_noop_success() {
|
|
let response = forcesave(
|
|
State(test_state(None)),
|
|
"/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key"
|
|
.parse::<Uri>()
|
|
.expect("uri"),
|
|
Query(OnlyOfficeForcesaveQuery {
|
|
asset_id: Some("asset_1".into()),
|
|
key: Some("doc_key".into()),
|
|
}),
|
|
)
|
|
.await
|
|
.map(IntoResponse::into_response)
|
|
.unwrap_or_else(IntoResponse::into_response);
|
|
|
|
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
assert_eq!(payload["ok"], false);
|
|
assert_eq!(payload["code"], "onlyoffice_legacy_writeback_unavailable");
|
|
assert_ne!(payload["via"], "mnote-web-rust-noop");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn onlyoffice_forcesave_proxies_to_legacy_next_writeback() {
|
|
let (base_url, captured) =
|
|
spawn_legacy_json_server(r#"{"ok":true,"via":"forcesave","result":{"error":0}}"#).await;
|
|
let response = forcesave(
|
|
State(test_state(Some(base_url))),
|
|
"/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key"
|
|
.parse::<Uri>()
|
|
.expect("uri"),
|
|
Query(OnlyOfficeForcesaveQuery {
|
|
asset_id: Some("asset_1".into()),
|
|
key: Some("doc_key".into()),
|
|
}),
|
|
)
|
|
.await
|
|
.expect("response");
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
|
let request = captured.await.expect("captured");
|
|
|
|
assert_eq!(payload["ok"], true);
|
|
assert_eq!(payload["via"], "forcesave");
|
|
assert!(request
|
|
.starts_with("POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn onlyoffice_page_skips_media_sign_for_local_folder_asset() {
|
|
let response = page(Query(OnlyOfficePageQuery {
|
|
file_url: Some(
|
|
"http://localhost:3000/api/local-folder/files/open?rootUri=file:///tmp&path=Page/report.docx"
|
|
.into(),
|
|
),
|
|
file_name: Some("report.docx".into()),
|
|
file_type: Some("docx".into()),
|
|
asset_id: Some("local-file:Page/report.docx".into()),
|
|
document_id: Some("local-md:Page".into()),
|
|
user_id: None,
|
|
mode: Some("view".into()),
|
|
}))
|
|
.await
|
|
.expect("onlyoffice page");
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body");
|
|
let html = String::from_utf8(body.to_vec()).expect("html");
|
|
|
|
// 1) Guard function is present
|
|
assert!(html.contains("function isLocalFolderAsset()"));
|
|
// 2) Condition uses guard to skip /api/media/sign
|
|
assert!(html.contains("if (initial.assetId && !isLocalFolderAsset())"));
|
|
// 3) local: prefix detection
|
|
assert!(html.contains("initial.assetId.indexOf(\"local:\") === 0"));
|
|
// 4) local-file: prefix detection
|
|
assert!(html.contains("initial.assetId.indexOf(\"local-file:\") === 0"));
|
|
// 5) fileUrl path detection for /api/local-folder/files/open
|
|
assert!(html.contains(
|
|
"initial.fileUrl && initial.fileUrl.indexOf(\"/api/local-folder/files/open\") !== -1"
|
|
));
|
|
// 6) Non-local asset still fetches /api/media/sign (general code path preserved)
|
|
assert!(html.contains("/api/media/sign?assetId="));
|
|
// 7) local rootUri/path are propagated into callback for save writeback
|
|
assert!(html.contains("function localFolderOpenParams(raw)"));
|
|
assert!(html.contains("callback.searchParams.set(\"rootUri\", localFile.rootUri);"));
|
|
assert!(html.contains("callback.searchParams.set(\"path\", localFile.path);"));
|
|
// 8) user name is explicit so OnlyOffice does not ask for collaboration name
|
|
assert!(html.contains("user: {"));
|
|
assert!(html.contains("name: displayUserName"));
|
|
assert!(html.contains("anonymous: { request: false, label: \"Guest\" }"));
|
|
assert!(html.contains("features: { featuresTips: false }"));
|
|
}
|
|
}
|