feat(control-plane): add libSQL Turso backend
This commit is contained in:
@@ -0,0 +1,600 @@
|
||||
use crate::app::AppState;
|
||||
use crate::error::WebError;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use control_plane::{
|
||||
AppendAiRuntimeEventInput, CreatePasswordIdentityInput, DirectoryGrantInput,
|
||||
UpsertAiRuntimeRunInput, UpsertUserInput, UpsertWorkspaceInput,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DevSeedRequest {
|
||||
seeds: Vec<DevSeedOperation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
enum DevSeedOperation {
|
||||
SetupWorkspace {
|
||||
user_id: String,
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
#[serde(default)]
|
||||
username: Option<String>,
|
||||
#[serde(default)]
|
||||
display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
role: Option<String>,
|
||||
#[serde(default)]
|
||||
password: Option<String>,
|
||||
workspace_id: String,
|
||||
workspace_name: String,
|
||||
root_uri: String,
|
||||
root_path: String,
|
||||
#[serde(default)]
|
||||
source_kind: Option<String>,
|
||||
#[serde(default)]
|
||||
permission: Option<String>,
|
||||
#[serde(default)]
|
||||
capabilities: Vec<String>,
|
||||
#[serde(default)]
|
||||
grant_source: Option<String>,
|
||||
#[serde(default)]
|
||||
grant_created_by: Option<String>,
|
||||
},
|
||||
SeedAiRuntime {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
user_id: String,
|
||||
#[serde(default)]
|
||||
workspace_id: Option<String>,
|
||||
#[serde(default)]
|
||||
document_id: Option<String>,
|
||||
session_id: String,
|
||||
run_id: String,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
profile: String,
|
||||
acp_runtime: String,
|
||||
#[serde(default)]
|
||||
trace_id: Option<String>,
|
||||
status: String,
|
||||
#[serde(default = "empty_json_object")]
|
||||
runtime_json: Value,
|
||||
#[serde(default = "empty_json_object")]
|
||||
payload_json: Value,
|
||||
#[serde(default)]
|
||||
events: Vec<DevSeedRuntimeEvent>,
|
||||
},
|
||||
ClearRuntimeEvents {
|
||||
user_id: String,
|
||||
run_id: String,
|
||||
},
|
||||
GetAiRuntimeRun {
|
||||
user_id: String,
|
||||
run_id: String,
|
||||
},
|
||||
ListAiRuntimeRuns {
|
||||
user_id: String,
|
||||
#[serde(default)]
|
||||
workspace_id: Option<String>,
|
||||
#[serde(default)]
|
||||
document_id: Option<String>,
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
#[serde(default = "default_dev_seed_limit")]
|
||||
limit: usize,
|
||||
},
|
||||
ListAiRuntimeEvents {
|
||||
user_id: String,
|
||||
run_id: String,
|
||||
#[serde(default = "default_dev_seed_limit")]
|
||||
limit: usize,
|
||||
#[serde(default)]
|
||||
event_type: Option<String>,
|
||||
},
|
||||
CountAiRuntimeEvents {
|
||||
user_id: String,
|
||||
run_id: String,
|
||||
#[serde(default)]
|
||||
event_type: Option<String>,
|
||||
},
|
||||
FindExternalConversationBinding {
|
||||
user_id: String,
|
||||
#[serde(default)]
|
||||
workspace_id: Option<String>,
|
||||
mnote_session_id: String,
|
||||
provider: String,
|
||||
},
|
||||
ListExternalConversationBindings {
|
||||
user_id: String,
|
||||
#[serde(default)]
|
||||
workspace_id: Option<String>,
|
||||
mnote_session_id: String,
|
||||
#[serde(default = "default_dev_seed_limit")]
|
||||
limit: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DevSeedRuntimeEvent {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
event_type: String,
|
||||
#[serde(default = "empty_json_object")]
|
||||
payload_json: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DevSeedResponse {
|
||||
ok: bool,
|
||||
results: Vec<Value>,
|
||||
}
|
||||
|
||||
fn empty_json_object() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
fn default_dev_seed_limit() -> usize {
|
||||
200
|
||||
}
|
||||
|
||||
pub async fn seed(
|
||||
State(state): State<AppState>,
|
||||
Json(request): Json<DevSeedRequest>,
|
||||
) -> Result<Json<DevSeedResponse>, WebError> {
|
||||
if !state.config().allow_dev_fixtures {
|
||||
return Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"dev_seed_disabled",
|
||||
"dev seed API 默认关闭,需要 MNOTE_WEB_ALLOW_DEV_FIXTURES=1",
|
||||
));
|
||||
}
|
||||
|
||||
let mut results = Vec::with_capacity(request.seeds.len());
|
||||
for operation in request.seeds {
|
||||
results.push(apply_seed_operation(&state, operation)?);
|
||||
}
|
||||
Ok(Json(DevSeedResponse { ok: true, results }))
|
||||
}
|
||||
|
||||
fn apply_seed_operation(
|
||||
state: &AppState,
|
||||
operation: DevSeedOperation,
|
||||
) -> Result<Value, WebError> {
|
||||
match operation {
|
||||
DevSeedOperation::SetupWorkspace {
|
||||
user_id,
|
||||
email,
|
||||
username,
|
||||
display_name,
|
||||
role,
|
||||
password,
|
||||
workspace_id,
|
||||
workspace_name,
|
||||
root_uri,
|
||||
root_path,
|
||||
source_kind,
|
||||
permission,
|
||||
capabilities,
|
||||
grant_source,
|
||||
grant_created_by,
|
||||
} => {
|
||||
let username = username.unwrap_or_else(|| user_id.clone());
|
||||
let display_name = display_name.unwrap_or_else(|| username.clone());
|
||||
let email = email.or_else(|| Some(format!("{username}@example.com")));
|
||||
let user = state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some(user_id.clone()),
|
||||
email: email.clone(),
|
||||
username: username.clone(),
|
||||
display_name: display_name.clone(),
|
||||
role,
|
||||
password_hash: None,
|
||||
})
|
||||
.map_err(dev_seed_error)?;
|
||||
if let Some(password) = password.filter(|value| !value.is_empty()) {
|
||||
state
|
||||
.control_plane()
|
||||
.create_password_identity(CreatePasswordIdentityInput {
|
||||
user_id: user.id.clone(),
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
})
|
||||
.map_err(dev_seed_error)?;
|
||||
}
|
||||
let workspace = state
|
||||
.control_plane()
|
||||
.upsert_workspace(UpsertWorkspaceInput {
|
||||
id: Some(workspace_id),
|
||||
owner_user_id: user.id.clone(),
|
||||
name: workspace_name,
|
||||
kind: Some("personal".to_string()),
|
||||
root_uri: root_uri.clone(),
|
||||
root_path: root_path.clone(),
|
||||
source_kind,
|
||||
})
|
||||
.map_err(dev_seed_error)?;
|
||||
let grant = state
|
||||
.control_plane()
|
||||
.grant_directory_access(DirectoryGrantInput {
|
||||
user_id: user.id.clone(),
|
||||
workspace_id: Some(workspace.id.clone()),
|
||||
root_uri,
|
||||
root_path,
|
||||
permission: permission.unwrap_or_else(|| "write".to_string()),
|
||||
recursive: true,
|
||||
capabilities: if capabilities.is_empty() {
|
||||
vec!["ai".to_string()]
|
||||
} else {
|
||||
capabilities
|
||||
},
|
||||
source: grant_source.unwrap_or_else(|| "dev_seed".to_string()),
|
||||
created_by: grant_created_by.or_else(|| Some(user.id.clone())),
|
||||
})
|
||||
.map_err(dev_seed_error)?;
|
||||
Ok(json!({
|
||||
"kind": "setupWorkspace",
|
||||
"user": user,
|
||||
"workspace": workspace,
|
||||
"grant": grant,
|
||||
}))
|
||||
}
|
||||
DevSeedOperation::SeedAiRuntime {
|
||||
id,
|
||||
user_id,
|
||||
workspace_id,
|
||||
document_id,
|
||||
session_id,
|
||||
run_id,
|
||||
title,
|
||||
profile,
|
||||
acp_runtime,
|
||||
trace_id,
|
||||
status,
|
||||
runtime_json,
|
||||
payload_json,
|
||||
events,
|
||||
} => {
|
||||
let run = state
|
||||
.control_plane()
|
||||
.upsert_ai_runtime_run(UpsertAiRuntimeRunInput {
|
||||
id,
|
||||
user_id: user_id.clone(),
|
||||
workspace_id: workspace_id.clone(),
|
||||
document_id: document_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
run_id: run_id.clone(),
|
||||
title,
|
||||
profile: profile.clone(),
|
||||
acp_runtime: acp_runtime.clone(),
|
||||
trace_id,
|
||||
status,
|
||||
runtime_json: runtime_json.to_string(),
|
||||
payload_json: payload_json.to_string(),
|
||||
})
|
||||
.map_err(dev_seed_error)?;
|
||||
let mut event_records = Vec::with_capacity(events.len());
|
||||
for event in events {
|
||||
event_records.push(
|
||||
state
|
||||
.control_plane()
|
||||
.append_ai_runtime_event(AppendAiRuntimeEventInput {
|
||||
id: event.id,
|
||||
user_id: user_id.clone(),
|
||||
workspace_id: workspace_id.clone(),
|
||||
document_id: document_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
run_id: run_id.clone(),
|
||||
profile: profile.clone(),
|
||||
acp_runtime: acp_runtime.clone(),
|
||||
event_type: event.event_type,
|
||||
payload_json: event.payload_json.to_string(),
|
||||
})
|
||||
.map_err(dev_seed_error)?,
|
||||
);
|
||||
}
|
||||
Ok(json!({
|
||||
"kind": "seedAiRuntime",
|
||||
"run": run,
|
||||
"events": event_records,
|
||||
}))
|
||||
}
|
||||
DevSeedOperation::ClearRuntimeEvents { user_id, run_id } => {
|
||||
let deleted = state
|
||||
.control_plane()
|
||||
.delete_ai_runtime_events_for_run(&user_id, &run_id)
|
||||
.map_err(dev_seed_error)?;
|
||||
Ok(json!({
|
||||
"kind": "clearRuntimeEvents",
|
||||
"deleted": deleted,
|
||||
}))
|
||||
}
|
||||
DevSeedOperation::GetAiRuntimeRun { user_id, run_id } => {
|
||||
let run = state
|
||||
.control_plane()
|
||||
.find_ai_runtime_run(&user_id, &run_id)
|
||||
.map_err(dev_seed_error)?;
|
||||
Ok(json!({
|
||||
"kind": "getAiRuntimeRun",
|
||||
"run": run,
|
||||
}))
|
||||
}
|
||||
DevSeedOperation::ListAiRuntimeRuns {
|
||||
user_id,
|
||||
workspace_id,
|
||||
document_id,
|
||||
session_id,
|
||||
limit,
|
||||
} => {
|
||||
let runs = state
|
||||
.control_plane()
|
||||
.list_ai_runtime_runs(
|
||||
&user_id,
|
||||
workspace_id.as_deref(),
|
||||
document_id.as_deref(),
|
||||
session_id.as_deref(),
|
||||
limit.clamp(1, 1_000),
|
||||
)
|
||||
.map_err(dev_seed_error)?;
|
||||
Ok(json!({
|
||||
"kind": "listAiRuntimeRuns",
|
||||
"runs": runs,
|
||||
}))
|
||||
}
|
||||
DevSeedOperation::ListAiRuntimeEvents {
|
||||
user_id,
|
||||
run_id,
|
||||
limit,
|
||||
event_type,
|
||||
} => {
|
||||
let events = state
|
||||
.control_plane()
|
||||
.list_ai_runtime_events(&user_id, &run_id, limit.clamp(1, 10_000))
|
||||
.map_err(dev_seed_error)?;
|
||||
let events: Vec<_> = events
|
||||
.into_iter()
|
||||
.filter(|event| {
|
||||
event_type
|
||||
.as_deref()
|
||||
.map(|expected| event.event_type == expected)
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"kind": "listAiRuntimeEvents",
|
||||
"events": events,
|
||||
}))
|
||||
}
|
||||
DevSeedOperation::CountAiRuntimeEvents {
|
||||
user_id,
|
||||
run_id,
|
||||
event_type,
|
||||
} => {
|
||||
let events = state
|
||||
.control_plane()
|
||||
.list_ai_runtime_events(&user_id, &run_id, 10_000)
|
||||
.map_err(dev_seed_error)?;
|
||||
let count = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event_type
|
||||
.as_deref()
|
||||
.map(|expected| event.event_type == expected)
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.count();
|
||||
Ok(json!({
|
||||
"kind": "countAiRuntimeEvents",
|
||||
"count": count,
|
||||
}))
|
||||
}
|
||||
DevSeedOperation::FindExternalConversationBinding {
|
||||
user_id,
|
||||
workspace_id,
|
||||
mnote_session_id,
|
||||
provider,
|
||||
} => {
|
||||
let binding = state
|
||||
.control_plane()
|
||||
.find_ai_external_conversation_binding(
|
||||
&user_id,
|
||||
workspace_id.as_deref(),
|
||||
&mnote_session_id,
|
||||
&provider,
|
||||
)
|
||||
.map_err(dev_seed_error)?;
|
||||
Ok(json!({
|
||||
"kind": "findExternalConversationBinding",
|
||||
"binding": binding,
|
||||
}))
|
||||
}
|
||||
DevSeedOperation::ListExternalConversationBindings {
|
||||
user_id,
|
||||
workspace_id,
|
||||
mnote_session_id,
|
||||
limit,
|
||||
} => {
|
||||
let bindings = state
|
||||
.control_plane()
|
||||
.list_ai_external_conversation_bindings(
|
||||
&user_id,
|
||||
workspace_id.as_deref(),
|
||||
&mnote_session_id,
|
||||
limit.clamp(1, 1_000),
|
||||
)
|
||||
.map_err(dev_seed_error)?;
|
||||
Ok(json!({
|
||||
"kind": "listExternalConversationBindings",
|
||||
"bindings": bindings,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dev_seed_error(error: control_plane::ControlPlaneError) -> WebError {
|
||||
match error {
|
||||
control_plane::ControlPlaneError::InvalidInput(message) => {
|
||||
WebError::bad_request_code("dev_seed_invalid", message)
|
||||
}
|
||||
control_plane::ControlPlaneError::NotFound(message) => {
|
||||
WebError::new(StatusCode::NOT_FOUND, "dev_seed_not_found", message)
|
||||
}
|
||||
control_plane::ControlPlaneError::Conflict(message) => {
|
||||
WebError::bad_request_code("dev_seed_conflict", message)
|
||||
}
|
||||
other => WebError::internal(format!("dev seed failed: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::AppConfig;
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::routing::post;
|
||||
use axum::Router;
|
||||
use serde_json::{json, Value};
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn test_router(allow_dev_fixtures: bool) -> Router {
|
||||
Router::new()
|
||||
.route("/api/dev/seed", post(super::seed))
|
||||
.with_state(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: None,
|
||||
enable_legacy_next_compat: false,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures,
|
||||
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 send_seed(router: Router, body: Value) -> (StatusCode, Value) {
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/dev/seed")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.expect("valid request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body bytes");
|
||||
let body_str = String::from_utf8_lossy(&body);
|
||||
let payload: Value = serde_json::from_slice(&body)
|
||||
.unwrap_or_else(|e| panic!("send_seed: status={status} body={body_str} error={e}"));
|
||||
(status, payload)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dev_seed_returns_forbidden_when_disabled() {
|
||||
let (status, payload) = send_seed(test_router(false), json!({ "seeds": [] })).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(payload["code"], "dev_seed_disabled");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dev_seed_setup_workspace_writes_to_control_plane() {
|
||||
let router = test_router(true);
|
||||
|
||||
// Step 1: setupWorkspace → 创建用户 + workspace + directory grant
|
||||
let (status, payload) = send_seed(
|
||||
router.clone(),
|
||||
json!({
|
||||
"seeds": [{
|
||||
"kind": "setupWorkspace",
|
||||
"user_id": "dev-seed-test-user",
|
||||
"workspace_id": "dev-seed-test-ws",
|
||||
"workspace_name": "测试开发空间",
|
||||
"root_uri": "file:///tmp/dev-seed-test",
|
||||
"root_path": "/tmp/dev-seed-test"
|
||||
}]
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "setupWorkspace 应成功: {payload}");
|
||||
assert!(payload["ok"].as_bool().unwrap_or(false));
|
||||
assert_eq!(payload["results"][0]["kind"], "setupWorkspace");
|
||||
assert_eq!(payload["results"][0]["user"]["id"], "dev-seed-test-user");
|
||||
assert_eq!(payload["results"][0]["workspace"]["id"], "dev-seed-test-ws");
|
||||
assert!(payload["results"][0]["grant"].is_object());
|
||||
|
||||
// Step 2: seedAiRuntime → 创建 AI runtime run + event
|
||||
let (status, payload) = send_seed(
|
||||
router.clone(),
|
||||
json!({
|
||||
"seeds": [{
|
||||
"kind": "seedAiRuntime",
|
||||
"user_id": "dev-seed-test-user",
|
||||
"session_id": "test-session-1",
|
||||
"run_id": "test-run-1",
|
||||
"profile": "test-profile",
|
||||
"acp_runtime": "reasonix",
|
||||
"status": "running",
|
||||
"events": [{
|
||||
"eventType": "message",
|
||||
"payloadJson": { "text": "hello" }
|
||||
}]
|
||||
}]
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "seedAiRuntime 应成功: {payload}");
|
||||
assert!(payload["ok"].as_bool().unwrap_or(false));
|
||||
assert_eq!(payload["results"][0]["kind"], "seedAiRuntime");
|
||||
assert_eq!(payload["results"][0]["run"]["run_id"], "test-run-1");
|
||||
assert_eq!(payload["results"][0]["run"]["profile"], "test-profile");
|
||||
assert_eq!(payload["results"][0]["run"]["acp_runtime"], "reasonix");
|
||||
assert_eq!(payload["results"][0]["run"]["status"], "running");
|
||||
assert_eq!(
|
||||
payload["results"][0]["events"].as_array().unwrap().len(),
|
||||
1
|
||||
);
|
||||
|
||||
// Step 3: getAiRuntimeRun → 回读验证
|
||||
let (status, payload) = send_seed(
|
||||
router,
|
||||
json!({
|
||||
"seeds": [{
|
||||
"kind": "getAiRuntimeRun",
|
||||
"user_id": "dev-seed-test-user",
|
||||
"run_id": "test-run-1"
|
||||
}]
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "getAiRuntimeRun 应成功: {payload}");
|
||||
assert!(payload["results"][0]["run"].is_object());
|
||||
assert_eq!(payload["results"][0]["run"]["run_id"], "test-run-1");
|
||||
assert_eq!(payload["results"][0]["run"]["profile"], "test-profile");
|
||||
assert_eq!(payload["results"][0]["run"]["acp_runtime"], "reasonix");
|
||||
assert_eq!(payload["results"][0]["run"]["status"], "running");
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::provider_identity_sync::sync_provider_identities;
|
||||
use crate::routes::local_folder_source::{
|
||||
control_plane_db_path_display, create_default_local_workspace_for_actor,
|
||||
control_plane_status_display, create_default_local_workspace_for_actor,
|
||||
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
|
||||
load_local_folder_file_tree_children_snapshot, load_local_folder_file_tree_snapshot,
|
||||
load_local_folder_page_tree_scope_snapshot, load_local_folder_page_tree_snapshot,
|
||||
@@ -198,7 +198,7 @@ pub async fn admin_access_policy_entry(
|
||||
.with_context(&context));
|
||||
}
|
||||
let workspace_name = default_workspace_name_for_context(&state, &context);
|
||||
let share_grants_path = control_plane_db_path_display();
|
||||
let share_grants_path = control_plane_status_display();
|
||||
let content = crate::ssr::render_view(leptos::view! {
|
||||
<crate::ssr::pages::admin::AdminAccessPolicyPanel workspace_name={workspace_name} share_grants_path={share_grants_path} is_admin=true />
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ use axum::extract::{Extension, Multipart, Path as AxumPath, Query, State};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::Json;
|
||||
use bridge_runtime::project_legacy_content_to_block_document;
|
||||
use control_plane::AppendAuditInput;
|
||||
use control_plane::{AppendAuditInput, ResolvedAccess};
|
||||
use control_plane::{
|
||||
CreateShareLinkInput, DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord,
|
||||
OutboxEventInput, ShareLinkRecord,
|
||||
@@ -657,10 +657,24 @@ pub(crate) fn ensure_local_workspace_access_with_state(
|
||||
));
|
||||
}
|
||||
let actor_id = context.auth.actor_id.trim();
|
||||
let canonical_root_uri = file_uri_for_path(&canonical_root);
|
||||
let sqlite_access = state
|
||||
.control_plane()
|
||||
.resolve_access(actor_id, &file_uri_for_path(&canonical_root))
|
||||
.map_err(|error| WebError::internal(format!("SQLite 控制面授权查询失败: {error}")))?;
|
||||
.resolve_access(actor_id, &canonical_root_uri)
|
||||
.unwrap_or_else(|error| {
|
||||
tracing::warn!(
|
||||
%actor_id,
|
||||
%error,
|
||||
root_uri = %canonical_root_uri,
|
||||
"控制面授权查询失败,回退到本地文件系统权限检查"
|
||||
);
|
||||
ResolvedAccess {
|
||||
user_id: actor_id.to_string(),
|
||||
root_uri: canonical_root_uri.clone(),
|
||||
permission: String::new(),
|
||||
grant_ids: Vec::new(),
|
||||
}
|
||||
});
|
||||
if local_access_permission_allows(&sqlite_access.permission, mode) {
|
||||
return Ok(canonical_root);
|
||||
}
|
||||
@@ -766,18 +780,78 @@ fn local_access_policy_path() -> PathBuf {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn control_plane_db_path_display() -> String {
|
||||
std::env::var("MNOTE_CONTROL_PLANE_DB_PATH")
|
||||
pub(crate) fn control_plane_status_display() -> String {
|
||||
let backend = std::env::var("MNOTE_CONTROL_PLANE_BACKEND")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
default_local_workspace_base_dir()
|
||||
.join("control-plane")
|
||||
.join("control-plane.db")
|
||||
.display()
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_else(|| "sqlite".to_string());
|
||||
match backend.as_str() {
|
||||
"sqlite" => {
|
||||
let db_path = std::env::var("MNOTE_CONTROL_PLANE_DB_PATH")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
default_local_workspace_base_dir()
|
||||
.join("control-plane")
|
||||
.join("control-plane.db")
|
||||
.display()
|
||||
.to_string()
|
||||
});
|
||||
format!("backend=sqlite; db={db_path}")
|
||||
}
|
||||
"libsql-local" | "turso-local" | "turso" => {
|
||||
let db_path = std::env::var("MNOTE_TURSO_LOCAL_PATH")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
default_local_workspace_base_dir()
|
||||
.join("control-plane")
|
||||
.join("control-plane-libsql.db")
|
||||
.display()
|
||||
.to_string()
|
||||
});
|
||||
format!("backend={backend}; local={db_path}")
|
||||
}
|
||||
"turso-local-replica" | "turso-remote-replica" => {
|
||||
let db_path = std::env::var("MNOTE_TURSO_LOCAL_REPLICA_PATH")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
default_local_workspace_base_dir()
|
||||
.join("control-plane")
|
||||
.join("control-plane-replica.db")
|
||||
.display()
|
||||
.to_string()
|
||||
});
|
||||
format!("backend={backend}; replica={db_path}; remote=env:MNOTE_TURSO_DATABASE_URL")
|
||||
}
|
||||
"turso-remote" => "backend=turso-remote; remote=env:MNOTE_TURSO_DATABASE_URL".to_string(),
|
||||
"turso-synced" => {
|
||||
let db_path = std::env::var("MNOTE_TURSO_SYNCED_PATH")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("MNOTE_TURSO_LOCAL_REPLICA_PATH")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
default_local_workspace_base_dir()
|
||||
.join("control-plane")
|
||||
.join("control-plane-synced.db")
|
||||
.display()
|
||||
.to_string()
|
||||
});
|
||||
format!("backend=turso-synced; local={db_path}; remote=env:MNOTE_TURSO_DATABASE_URL")
|
||||
}
|
||||
other => format!("backend={other}; unsupported"),
|
||||
}
|
||||
}
|
||||
|
||||
fn local_share_grants_path() -> PathBuf {
|
||||
@@ -3304,7 +3378,7 @@ fn save_local_markdown_page_inner(
|
||||
fn refresh_local_search_index_best_effort(root: &Path, root_uri: &str, workspace_id: &str) {
|
||||
let control_plane = open_control_plane_store();
|
||||
let _ = local_search_index::refresh_local_search_index_for_change_with_store(
|
||||
&control_plane,
|
||||
control_plane.as_ref(),
|
||||
root,
|
||||
root_uri,
|
||||
workspace_id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod bridge;
|
||||
pub(crate) mod command_support;
|
||||
mod compat;
|
||||
mod dev_seed;
|
||||
pub(crate) mod dev_hot;
|
||||
mod documents;
|
||||
mod editor;
|
||||
@@ -45,10 +46,11 @@ mod ws;
|
||||
|
||||
pub(crate) use gateway::current_actor_id;
|
||||
pub(crate) use local_folder_source::{
|
||||
decode_local_id_segment, ensure_local_path_read_access, ensure_local_workspace_access,
|
||||
ensure_local_workspace_read_access_with_state, ensure_local_workspace_write_access_with_state,
|
||||
local_markdown_conflict_detection_key, local_workspace_id_from_root_uri,
|
||||
update_local_markdown_title, write_local_markdown_page_body,
|
||||
control_plane_status_display, decode_local_id_segment, ensure_local_path_read_access,
|
||||
ensure_local_workspace_access, ensure_local_workspace_read_access_with_state,
|
||||
ensure_local_workspace_write_access_with_state, local_markdown_conflict_detection_key,
|
||||
local_workspace_id_from_root_uri, update_local_markdown_title,
|
||||
write_local_markdown_page_body,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use local_search_index::write_local_index_settings;
|
||||
@@ -87,6 +89,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/evidence/search", post(evidence::search))
|
||||
.route("/api/evidence/read", post(evidence::read))
|
||||
.route("/api/evidence/open", post(evidence::open))
|
||||
.route("/api/dev/seed", post(dev_seed::seed))
|
||||
.route("/api/knowledge-rag/status", get(knowledge_rag::status))
|
||||
.route(
|
||||
"/api/knowledge-rag/pipeline-events",
|
||||
|
||||
Reference in New Issue
Block a user