fix: open local uploaded resources
This commit is contained in:
@@ -7,10 +7,12 @@ use axum::extract::{Path, Query, State};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, Uri};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use base64::Engine;
|
||||
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;
|
||||
|
||||
const ONLYOFFICE_PROBE_PATH: &str = "/web-apps/apps/api/documents/api.js";
|
||||
@@ -729,6 +731,9 @@ pub async fn proxy(
|
||||
.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(),
|
||||
@@ -789,6 +794,173 @@ pub async fn proxy(
|
||||
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))
|
||||
}
|
||||
|
||||
pub async fn callback(
|
||||
State(state): State<AppState>,
|
||||
uri: Uri,
|
||||
@@ -1222,6 +1394,47 @@ mod tests {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user