fix: 修复 Rust OnlyOffice 附件打开链路
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::transport::convex::{execute_convex_mutation_by_name, execute_convex_query_by_name};
|
||||
use axum::extract::{Multipart, Query, State};
|
||||
use axum::http::{header, HeaderMap};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Extension, Json};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
static UPLOAD_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MediaSignQuery {
|
||||
asset_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileTreeUploadTargetPreflightPayload {
|
||||
workspace_id: Option<String>,
|
||||
target_document_id: Option<String>,
|
||||
target_row_id: Option<String>,
|
||||
focused_row_id: Option<String>,
|
||||
active_document_id: Option<String>,
|
||||
rows: Option<Vec<FileTreeUploadTargetRow>>,
|
||||
document_workspaces: Option<Vec<FileTreeDocumentWorkspace>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[allow(dead_code)]
|
||||
pub struct FileTreeUploadTargetRow {
|
||||
row_id: Option<String>,
|
||||
row_kind: Option<String>,
|
||||
document_id: Option<String>,
|
||||
asset_id: Option<String>,
|
||||
asset_document_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileTreeDocumentWorkspace {
|
||||
document_id: Option<String>,
|
||||
workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileTreeUploadTargetPlan {
|
||||
workspace_id: String,
|
||||
target_document_id: String,
|
||||
target_mindmap_id: Option<String>,
|
||||
target_sub_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct UploadFile {
|
||||
name: String,
|
||||
content_type: String,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
async fn current_user_id(state: &AppState, context: &RequestContext) -> String {
|
||||
let actor_id = context.auth.actor_id.trim();
|
||||
if !actor_id.is_empty() && actor_id != "anonymous" {
|
||||
return actor_id.to_string();
|
||||
}
|
||||
if let Ok(user) = execute_convex_query_by_name(
|
||||
state.config(),
|
||||
context,
|
||||
"users:currentUser",
|
||||
json!({}),
|
||||
context.workspace.workspace_id.as_deref(),
|
||||
"media_current_user",
|
||||
)
|
||||
.await
|
||||
{
|
||||
for key in ["_id", "id"] {
|
||||
if let Some(user_id) = user
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return user_id.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
state.config().dev_user_id.clone()
|
||||
}
|
||||
|
||||
fn now_millis() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
}
|
||||
|
||||
fn new_asset_id() -> String {
|
||||
format!(
|
||||
"asset_{}_{}",
|
||||
now_millis(),
|
||||
UPLOAD_COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||
)
|
||||
}
|
||||
|
||||
fn asset_type(mime: &str) -> &'static str {
|
||||
if mime.starts_with("image/") {
|
||||
"image"
|
||||
} else if mime.starts_with("video/") {
|
||||
"video"
|
||||
} else if mime.starts_with("audio/") {
|
||||
"audio"
|
||||
} else {
|
||||
"file"
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_upload_multipart(
|
||||
mut multipart: Multipart,
|
||||
) -> Result<(UploadFile, String, String, Option<String>), WebError> {
|
||||
let mut file: Option<UploadFile> = None;
|
||||
let mut workspace_id = String::new();
|
||||
let mut document_id = String::new();
|
||||
let mut mindmap_id: Option<String> = None;
|
||||
|
||||
while let Some(field) = multipart.next_field().await.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"media_upload_bad_multipart",
|
||||
format!("上传表单解析失败: {error}"),
|
||||
)
|
||||
})? {
|
||||
let name = field.name().unwrap_or_default().to_string();
|
||||
if name == "file" {
|
||||
let file_name = field
|
||||
.file_name()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("附件")
|
||||
.to_string();
|
||||
let content_type = field
|
||||
.content_type()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let bytes = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"media_upload_file_read_failed",
|
||||
format!("读取上传文件失败: {error}"),
|
||||
)
|
||||
})?
|
||||
.to_vec();
|
||||
file = Some(UploadFile {
|
||||
name: file_name,
|
||||
content_type,
|
||||
bytes,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let value = field.text().await.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"media_upload_field_read_failed",
|
||||
format!("读取上传字段失败: {error}"),
|
||||
)
|
||||
})?;
|
||||
match name.as_str() {
|
||||
"workspaceId" => workspace_id = value.trim().to_string(),
|
||||
"documentId" => document_id = value.trim().to_string(),
|
||||
"mindmapId" => {
|
||||
let trimmed = value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
mindmap_id = Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let file =
|
||||
file.ok_or_else(|| WebError::bad_request_code("media_upload_file_missing", "缺少 file"))?;
|
||||
if file.bytes.is_empty() || workspace_id.is_empty() || document_id.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"media_upload_required_missing",
|
||||
"缺少必要参数",
|
||||
));
|
||||
}
|
||||
Ok((file, workspace_id, document_id, mindmap_id))
|
||||
}
|
||||
|
||||
fn absolute_origin(headers: &HeaderMap) -> String {
|
||||
let proto = headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("http");
|
||||
let host = headers
|
||||
.get(header::HOST)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("127.0.0.1:3000");
|
||||
format!("{proto}://{host}")
|
||||
}
|
||||
|
||||
fn proxied_file_url(headers: &HeaderMap, raw: &str) -> String {
|
||||
let encoded = URL_SAFE_NO_PAD.encode(raw.as_bytes());
|
||||
format!(
|
||||
"{}/api/onlyoffice/proxy?u={encoded}",
|
||||
absolute_origin(headers)
|
||||
)
|
||||
}
|
||||
|
||||
fn trim_string(value: Option<&String>) -> Option<String> {
|
||||
value
|
||||
.map(String::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn workspace_for_document(
|
||||
document_id: &str,
|
||||
fallback_workspace_id: Option<&str>,
|
||||
document_workspaces: &[FileTreeDocumentWorkspace],
|
||||
) -> Option<String> {
|
||||
for item in document_workspaces {
|
||||
if trim_string(item.document_id.as_ref()).as_deref() == Some(document_id) {
|
||||
if let Some(workspace_id) = trim_string(item.workspace_id.as_ref()) {
|
||||
return Some(workspace_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
fallback_workspace_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn document_for_target_row(
|
||||
target_row_id: Option<&str>,
|
||||
rows: &[FileTreeUploadTargetRow],
|
||||
) -> Option<String> {
|
||||
let row_id = target_row_id?.trim();
|
||||
if row_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
for row in rows {
|
||||
if trim_string(row.row_id.as_ref()).as_deref() != Some(row_id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(document_id) = trim_string(row.document_id.as_ref()) {
|
||||
return Some(document_id);
|
||||
}
|
||||
if let Some(document_id) = trim_string(row.asset_document_id.as_ref()) {
|
||||
return Some(document_id);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn upload(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Response, WebError> {
|
||||
let (file, workspace_id, document_id, mindmap_id) = read_upload_multipart(multipart).await?;
|
||||
let user_id = current_user_id(&state, &context).await;
|
||||
let upload_url = execute_convex_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"mediaAssets:generateUploadUrl",
|
||||
json!({ "userId": user_id }),
|
||||
Some(&workspace_id),
|
||||
None,
|
||||
"media_upload_generate_url",
|
||||
)
|
||||
.await?;
|
||||
let upload_url = upload_url.as_str().ok_or_else(|| {
|
||||
WebError::bad_gateway_code("media_upload_bad_upload_url", "Convex 未返回上传 URL")
|
||||
})?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let upload_response = client
|
||||
.post(upload_url)
|
||||
.header(header::CONTENT_TYPE, file.content_type.as_str())
|
||||
.body(file.bytes.clone())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"media_upload_storage_failed",
|
||||
format!("上传到 Convex Files 失败: {error}"),
|
||||
)
|
||||
})?;
|
||||
let upload_status = upload_response.status();
|
||||
let upload_json: Value = upload_response.json().await.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"media_upload_storage_bad_response",
|
||||
format!("Convex Files 响应解析失败: {error}"),
|
||||
)
|
||||
.with_header("x-upstream-status", upload_status.as_u16().to_string())
|
||||
})?;
|
||||
if !upload_status.is_success() {
|
||||
return Err(WebError::bad_gateway_code(
|
||||
"media_upload_storage_status",
|
||||
format!("上传到 Convex Files 失败: {upload_json}"),
|
||||
)
|
||||
.with_header("x-upstream-status", upload_status.as_u16().to_string()));
|
||||
}
|
||||
let storage_id = upload_json
|
||||
.get("storageId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_gateway_code(
|
||||
"media_upload_storage_id_missing",
|
||||
"Convex Files 缺少 storageId",
|
||||
)
|
||||
})?;
|
||||
|
||||
let target_sub_path = mindmap_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| format!("mindmaps/{value}"));
|
||||
let id = new_asset_id();
|
||||
let kind = asset_type(&file.content_type);
|
||||
let created = execute_convex_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"mediaAssets:createWithStorage",
|
||||
json!({
|
||||
"userId": user_id,
|
||||
"storageId": storage_id,
|
||||
"targetSubPath": target_sub_path,
|
||||
"asset": {
|
||||
"id": id,
|
||||
"workspace_id": workspace_id,
|
||||
"document_id": document_id,
|
||||
"asset_type": kind,
|
||||
"file_name": file.name,
|
||||
"file_size": file.bytes.len(),
|
||||
"mime_type": file.content_type,
|
||||
}
|
||||
}),
|
||||
Some(&workspace_id),
|
||||
None,
|
||||
"media_upload_create_asset",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let asset_id = created
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
Ok(Json(json!({
|
||||
"asset": created,
|
||||
"mindmapUrl": format!("asset:{asset_id}"),
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub async fn filetree_upload_target_preflight(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(payload): Json<FileTreeUploadTargetPreflightPayload>,
|
||||
) -> Result<Response, WebError> {
|
||||
let rows = payload.rows.unwrap_or_default();
|
||||
let document_workspaces = payload.document_workspaces.unwrap_or_default();
|
||||
let fallback_workspace_id = trim_string(payload.workspace_id.as_ref());
|
||||
let target_row_document_id = document_for_target_row(
|
||||
trim_string(payload.target_row_id.as_ref()).as_deref(),
|
||||
&rows,
|
||||
);
|
||||
let document_id = trim_string(payload.target_document_id.as_ref())
|
||||
.or(target_row_document_id)
|
||||
.or_else(|| {
|
||||
trim_string(payload.focused_row_id.as_ref())
|
||||
.and_then(|row_id| document_for_target_row(Some(&row_id), &rows))
|
||||
})
|
||||
.or_else(|| trim_string(payload.active_document_id.as_ref()))
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"filetree_upload_target_document_missing",
|
||||
"请选择一个目标页面后再上传文件",
|
||||
)
|
||||
})?;
|
||||
let workspace_id = workspace_for_document(
|
||||
&document_id,
|
||||
fallback_workspace_id
|
||||
.as_deref()
|
||||
.or(context.workspace.workspace_id.as_deref()),
|
||||
&document_workspaces,
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"filetree_upload_target_workspace_missing",
|
||||
"缺少 workspaceId",
|
||||
)
|
||||
})?;
|
||||
let plan = FileTreeUploadTargetPlan {
|
||||
workspace_id,
|
||||
target_document_id: document_id,
|
||||
target_mindmap_id: None,
|
||||
target_sub_path: None,
|
||||
};
|
||||
Ok(Json(json!({
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"plan": plan,
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub async fn sign(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<MediaSignQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> 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("media_sign_asset_missing", "缺少 assetId"))?;
|
||||
let user_id = current_user_id(&state, &context).await;
|
||||
let asset = execute_convex_query_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"mediaAssets:getById",
|
||||
json!({ "userId": user_id, "id": asset_id }),
|
||||
None,
|
||||
"media_sign_get_asset",
|
||||
)
|
||||
.await?;
|
||||
if asset.is_null() {
|
||||
return Err(WebError::new(
|
||||
axum::http::StatusCode::NOT_FOUND,
|
||||
"media_asset_not_found",
|
||||
"资源不存在",
|
||||
));
|
||||
}
|
||||
let refreshed = execute_convex_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"mediaAssets:refreshUrl",
|
||||
json!({ "userId": user_id, "id": asset_id }),
|
||||
None,
|
||||
None,
|
||||
"media_sign_refresh_url",
|
||||
)
|
||||
.await?;
|
||||
let signed_url = refreshed
|
||||
.get("signedUrl")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| WebError::bad_gateway_code("media_sign_url_missing", "生成签名链接失败"))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"signedUrl": proxied_file_url(&headers, signed_url),
|
||||
"asset": {
|
||||
"id": asset.get("id").cloned().unwrap_or(Value::Null),
|
||||
"document_id": asset.get("document_id").cloned().unwrap_or(Value::Null),
|
||||
"workspace_id": asset.get("workspace_id").cloned().unwrap_or(Value::Null),
|
||||
"file_name": asset.get("file_name").cloned().unwrap_or(Value::Null),
|
||||
"mime_type": asset.get("mime_type").cloned().unwrap_or(Value::Null),
|
||||
"file_size": asset.get("file_size").cloned().unwrap_or(Value::Null),
|
||||
"storage_id": asset.get("storage_id").cloned().unwrap_or(Value::Null),
|
||||
"updated_at": asset.get("updated_at").cloned().unwrap_or(Value::Null),
|
||||
}
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
@@ -10,7 +10,9 @@ mod kernel;
|
||||
mod local_folder_source;
|
||||
mod local_folder_events;
|
||||
mod local_markdown_parser;
|
||||
mod media;
|
||||
mod mindmap_shell;
|
||||
mod onlyoffice;
|
||||
mod query_support;
|
||||
mod search;
|
||||
mod session;
|
||||
@@ -22,7 +24,7 @@ mod web_shell;
|
||||
mod ws;
|
||||
|
||||
use crate::app::AppState;
|
||||
use axum::routing::{get, post};
|
||||
use axum::routing::{any, get, post};
|
||||
use axum::Router;
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
@@ -69,6 +71,19 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/auth/mnote-web-token", get(session::session))
|
||||
.route("/api/auth/session/refresh", post(session::refresh_session))
|
||||
.route("/api/ai-agent/run", post(compat::next_ai_agent_run))
|
||||
.route("/onlyoffice", get(onlyoffice::page))
|
||||
.route("/onlyoffice-server/{*path}", any(onlyoffice::server_proxy))
|
||||
.route("/cache/{*path}", any(onlyoffice::cache_proxy))
|
||||
.route("/api/onlyoffice/sign", post(onlyoffice::sign))
|
||||
.route("/api/onlyoffice/proxy", get(onlyoffice::proxy))
|
||||
.route("/api/onlyoffice/callback", post(onlyoffice::callback))
|
||||
.route("/api/onlyoffice/forcesave", post(onlyoffice::forcesave))
|
||||
.route("/api/media/upload", post(media::upload))
|
||||
.route("/api/media/sign", get(media::sign))
|
||||
.route(
|
||||
"/api/tree/filetree/upload-target-preflight",
|
||||
post(media::filetree_upload_target_preflight),
|
||||
)
|
||||
.route("/api/documents/meta", get(documents::meta))
|
||||
.route("/api/documents/content", get(documents::content))
|
||||
.route("/api/documents/page", get(web_shell::documents_page_compat))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user