fix local office resource editing
- 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
This commit is contained in:
@@ -1,19 +1,30 @@
|
||||
use crate::app::AppConfig;
|
||||
use crate::app::AppState;
|
||||
use crate::error::WebError;
|
||||
use adapter_onlyoffice::{prepare_proxy_request, sign_config, OnlyOfficeProxyPreparationInput};
|
||||
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, Uri};
|
||||
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";
|
||||
@@ -136,6 +147,9 @@ pub struct OnlyOfficeCallbackQuery {
|
||||
asset_id: Option<String>,
|
||||
#[serde(rename = "userId")]
|
||||
user_id: Option<String>,
|
||||
#[serde(rename = "rootUri")]
|
||||
root_uri: Option<String>,
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -429,6 +443,13 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
|
||||
}}
|
||||
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";
|
||||
@@ -465,19 +486,44 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
|
||||
return value;
|
||||
}}
|
||||
}}
|
||||
function buildCallbackUrl(assetId, userId) {{
|
||||
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) {{
|
||||
function loadScript(url, timeoutMs) {{
|
||||
return new Promise((resolve, reject) => {{
|
||||
const script = document.createElement("script");
|
||||
script.src = url;
|
||||
script.onload = resolve;
|
||||
script.onerror = () => reject(new Error("无法加载 ONLYOFFICE API: " + 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";
|
||||
@@ -539,10 +585,15 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
|
||||
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) {{
|
||||
if (initial.assetId && !isLocalFolderAsset()) {{
|
||||
try {{
|
||||
const response = await fetch("/api/media/sign?assetId=" + encodeURIComponent(initial.assetId));
|
||||
const payload = await response.json().catch(() => null);
|
||||
@@ -554,91 +605,115 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
|
||||
// ignore
|
||||
}}
|
||||
}}
|
||||
const docKey = initial.assetId
|
||||
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 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");
|
||||
await waitForDocEditorReady(120000);
|
||||
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 config = {{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
documentType: docTypeFromExt(initial.fileType),
|
||||
document: {{
|
||||
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,
|
||||
title: initial.fileName,
|
||||
url: fileState.resolvedFileUrl,
|
||||
key: fileState.docKey,
|
||||
permissions: {{
|
||||
edit: resolvedMode !== "view",
|
||||
download: true,
|
||||
print: true,
|
||||
copy: true
|
||||
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);
|
||||
}}
|
||||
}},
|
||||
editorConfig: {{
|
||||
mode: resolvedMode,
|
||||
lang: "zh-CN",
|
||||
callbackUrl: buildCallbackUrl(initial.assetId, userId),
|
||||
customization: {{
|
||||
feedback: {{ visible: 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);
|
||||
}}, 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));
|
||||
boot().catch((error) => {{
|
||||
showError(error && error.message ? error.message : error);
|
||||
}});
|
||||
</script>
|
||||
</body>
|
||||
</html>"#,
|
||||
@@ -972,6 +1047,131 @@ fn proxy_local_folder_file_open(
|
||||
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,
|
||||
@@ -988,6 +1188,19 @@ pub async fn callback(
|
||||
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",
|
||||
@@ -1308,14 +1521,101 @@ async fn proxy_onlyoffice_path(
|
||||
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,
|
||||
request: Request<Body>,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1332,13 +1632,43 @@ pub async fn cache_proxy(
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn stable_doc_key(asset_id: &str, storage_id: &str, file_url: &str, file_name: &str) -> String {
|
||||
if !asset_id.trim().is_empty() {
|
||||
let raw = if !asset_id.trim().is_empty() {
|
||||
if storage_id.trim().is_empty() {
|
||||
return asset_id.trim().to_string();
|
||||
asset_id.trim().to_string()
|
||||
} else {
|
||||
format!("{}_{}", asset_id.trim(), js_hash_abs(storage_id))
|
||||
}
|
||||
return 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));
|
||||
}
|
||||
js_hash_abs(&format!("{file_url}-{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)]
|
||||
@@ -1374,6 +1704,24 @@ mod tests {
|
||||
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(
|
||||
@@ -1533,6 +1881,8 @@ mod tests {
|
||||
Query(OnlyOfficeCallbackQuery {
|
||||
asset_id: Some("asset_1".into()),
|
||||
user_id: None,
|
||||
root_uri: None,
|
||||
path: None,
|
||||
}),
|
||||
Json(json!({
|
||||
"status": 2,
|
||||
@@ -1550,6 +1900,85 @@ mod tests {
|
||||
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;
|
||||
@@ -1561,6 +1990,8 @@ mod tests {
|
||||
Query(OnlyOfficeCallbackQuery {
|
||||
asset_id: Some("asset_1".into()),
|
||||
user_id: Some("user_1".into()),
|
||||
root_uri: None,
|
||||
path: None,
|
||||
}),
|
||||
Json(json!({
|
||||
"status": 2,
|
||||
@@ -1635,4 +2066,50 @@ mod tests {
|
||||
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 }"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user