收口本地工作区清理与资源投影
清理历史 Electron、Graphify、沙箱和截图等仓库跟踪残留,补充 CodeGraph 与 Convex active deploy source 协作说明。 新增 tree-first 下一阶段设计稿和 2026-05-20 清理总结,记录本地工作区、路径身份和 Zed/Lapce/VSCode 参考收口方向。 扩展 Rust Web 本地文件夹、DocumentBuffer、mindmap 资源、tree runtime 和页面聚合链路,并补充 task455 local-folder mindmap clean smoke。 验证:git diff --check 通过;pnpm store status --store-dir .pnpm-store 通过;npm ls --depth=0 --json 通过;find -L node_modules 未发现断链。cargo test -p mnote-web 当前 418 passed / 35 failed。
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::context::{stable_actor_id, RequestContext};
|
||||
use crate::error::WebError;
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_read_access, is_local_access_policy_admin_context,
|
||||
load_local_folder_page_tree_snapshot, local_access_policy_path_display,
|
||||
create_default_local_workspace_for_actor, ensure_local_workspace_read_access,
|
||||
is_local_access_policy_admin_context, load_local_folder_page_tree_snapshot,
|
||||
load_local_trash_entries, local_access_policy_path_display,
|
||||
};
|
||||
use crate::routes::snapshot_support::load_sidebar_dataset;
|
||||
use crate::routes::web_shell::{
|
||||
@@ -22,10 +23,12 @@ use axum::extract::ws::{Message as AxumWsMessage, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode, Uri};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use leptos::prelude::InnerHtmlAttribute;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
||||
|
||||
@@ -36,6 +39,8 @@ const COOKIE_CONVEX_AUTH_JWT: &str = "__convexAuthJWT";
|
||||
const COOKIE_CONVEX_AUTH_REFRESH_TOKEN: &str = "__convexAuthRefreshToken";
|
||||
const COOKIE_MNOTE_WEB_CONVEX_TOKEN: &str = "mnote_web_convex_token";
|
||||
const COOKIE_MNOTE_WEB_DEV_SESSION: &str = "mnote_web_dev_session";
|
||||
const COOKIE_MNOTE_ACTOR_ID: &str = "mnote_actor_id";
|
||||
const COOKIE_MNOTE_ACTOR_TYPE: &str = "mnote_actor_type";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -305,24 +310,58 @@ pub async fn root_entry(
|
||||
Some(root_uri.to_string()),
|
||||
)
|
||||
} else if should_render_local_first_landing {
|
||||
let workspace_id = "local-first-entry".to_string();
|
||||
let payload = create_default_local_workspace_for_actor(
|
||||
&context.auth.actor_id,
|
||||
&context.auth.actor_type,
|
||||
)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let root_uri = payload
|
||||
.get("workspace")
|
||||
.and_then(|workspace| workspace.get("rootUri"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WebError::internal("默认本地工作区初始化未返回 rootUri").with_context(&context)
|
||||
})?
|
||||
.to_string();
|
||||
let snapshot = load_local_folder_page_tree_snapshot(&root_uri)?;
|
||||
let workspace_id = snapshot
|
||||
.dataset
|
||||
.get("workspace")
|
||||
.and_then(|workspace| workspace.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("local-folder")
|
||||
.to_string();
|
||||
let workspace_projection = build_workspace_shell_projection(
|
||||
&json!({
|
||||
"workspaces": [{ "id": workspace_id, "name": "我的空间" }],
|
||||
"documents": [],
|
||||
}),
|
||||
&snapshot.dataset,
|
||||
&workspace_id,
|
||||
None,
|
||||
requested_page_id.as_deref(),
|
||||
"我的空间",
|
||||
);
|
||||
let selected_active_page_id = choose_root_entry_active_page_id(
|
||||
requested_page_id.clone(),
|
||||
recent_page_id.clone(),
|
||||
workspace_projection.active_page_id.as_deref(),
|
||||
workspace_projection
|
||||
.my_page_items
|
||||
.first()
|
||||
.map(|item| item.id.as_str()),
|
||||
);
|
||||
let sidebar_tree_html =
|
||||
render_local_sidebar_tree_html(&root_uri, selected_active_page_id.as_deref())?;
|
||||
let file_tree_html =
|
||||
render_local_file_tree_html(&root_uri, selected_active_page_id.as_deref())?;
|
||||
(
|
||||
workspace_id,
|
||||
workspace_projection,
|
||||
String::new(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
sidebar_tree_html,
|
||||
file_tree_html,
|
||||
selected_active_page_id,
|
||||
Some("local_folder".to_string()),
|
||||
Some(root_uri),
|
||||
)
|
||||
} else {
|
||||
let workspace_id =
|
||||
@@ -448,6 +487,7 @@ pub async fn root_entry(
|
||||
workspace_sidebar_html={workspace_sidebar_html.clone()}
|
||||
page_subtree_json={page_subtree_json}
|
||||
show_admin_access_policy={show_admin_access_policy}
|
||||
enable_tree_live={active_source_kind.as_deref() != Some("local_folder")}
|
||||
/>
|
||||
});
|
||||
let body_extra = format!(
|
||||
@@ -475,7 +515,7 @@ pub async fn root_entry(
|
||||
<title>{}</title>
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-actor-id="{}">
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-actor-id="{}" data-mnote-source-kind="{}" data-mnote-root-uri="{}">
|
||||
{}
|
||||
{}
|
||||
</body>
|
||||
@@ -483,6 +523,8 @@ pub async fn root_entry(
|
||||
escape_html(&html_title),
|
||||
crate::ssr::MNOTE_CSS,
|
||||
escape_html(context.auth.actor_id.as_str()),
|
||||
escape_html(active_source_kind.as_deref().unwrap_or("convex_workspace")),
|
||||
escape_html(active_root_uri.as_deref().unwrap_or("")),
|
||||
content,
|
||||
body_extra
|
||||
))
|
||||
@@ -506,6 +548,81 @@ pub async fn trash_entry(
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let source_kind = query
|
||||
.source_kind
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
if source_kind == Some("local_folder") {
|
||||
let root_uri = query
|
||||
.root_uri
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
})?;
|
||||
ensure_local_workspace_read_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let workspace_id =
|
||||
crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?;
|
||||
let sidebar_tree_html = render_local_sidebar_tree_html(root_uri, None).unwrap_or_default();
|
||||
let file_tree_html = render_local_file_tree_html(root_uri, None).unwrap_or_default();
|
||||
let workspace_projection = build_workspace_shell_projection(
|
||||
&json!({
|
||||
"active_workspace_id": workspace_id,
|
||||
"workspaces": [{ "id": workspace_id, "name": "我的空间" }],
|
||||
"documents": [],
|
||||
}),
|
||||
&workspace_id,
|
||||
None,
|
||||
"我的空间",
|
||||
);
|
||||
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
|
||||
&workspace_projection,
|
||||
Some(sidebar_tree_html.as_str()),
|
||||
Some(file_tree_html.as_str()),
|
||||
);
|
||||
let trash_workbench_html = render_local_trash_workbench_html(
|
||||
&workspace_id,
|
||||
root_uri,
|
||||
&load_local_trash_entries(root_uri)?,
|
||||
);
|
||||
let content = crate::ssr::render_view(leptos::view! {
|
||||
<crate::ssr::pages::layout::PageLayout
|
||||
current_nav="trash"
|
||||
sidebar_tree_html={sidebar_tree_html.clone()}
|
||||
workspace_name={"我的空间".to_string()}
|
||||
workspace_sidebar_html={workspace_sidebar_html.clone()}
|
||||
topbar_title={"垃圾箱".to_string()}
|
||||
enable_tree_live={false}
|
||||
>
|
||||
<div inner_html={trash_workbench_html}></div>
|
||||
</crate::ssr::pages::layout::PageLayout>
|
||||
});
|
||||
|
||||
let mut response = Html(format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>本地文件夹垃圾箱</title>
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-source-kind="local_folder" data-mnote-root-uri="{}">
|
||||
{}
|
||||
</body>
|
||||
</html>"#,
|
||||
crate::ssr::MNOTE_CSS,
|
||||
escape_html(root_uri),
|
||||
content,
|
||||
))
|
||||
.into_response();
|
||||
stamp_gateway_headers(response.headers_mut(), false);
|
||||
context.apply_response_headers(response.headers_mut());
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
|
||||
let workspace_id =
|
||||
resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
|
||||
@@ -815,6 +932,202 @@ fn render_trash_workbench_html(workspace_id: &str, dataset: &Value) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn render_local_trash_workbench_html(
|
||||
workspace_id: &str,
|
||||
root_uri: &str,
|
||||
entries: &BTreeMap<String, crate::routes::local_folder_source::LocalTrashEntry>,
|
||||
) -> String {
|
||||
let mut document_rows = Vec::new();
|
||||
let mut resource_rows = Vec::new();
|
||||
for (entry_id, entry) in entries {
|
||||
let deleted_at = entry.deleted_at_ms.to_string();
|
||||
let original = escape_html(&entry.original_relative_path);
|
||||
let trash_path = escape_html(&entry.trash_relative_path);
|
||||
let kind = escape_html(&entry.resource_kind);
|
||||
let row = format!(
|
||||
r#"<article class="mnote-trash-row" data-trash-row="local" data-trash-entry-id="{entry_id}" data-resource-kind="{kind}">
|
||||
<div class="mnote-trash-row-main">
|
||||
<span class="mnote-trash-kind">{kind}</span>
|
||||
<span class="mnote-trash-title">{original}</span>
|
||||
<span class="mnote-trash-meta">回收站:{trash_path} · {deleted_at}</span>
|
||||
</div>
|
||||
<div class="mnote-trash-actions">
|
||||
<button type="button" data-trash-action="local-restore" data-trash-entry-id="{entry_id}" data-resource-kind="{kind}" data-document-id="{command_id}">恢复</button>
|
||||
<button type="button" data-trash-action="local-purge" data-trash-entry-id="{entry_id}" data-resource-kind="{kind}" data-document-id="{command_id}">彻底删除</button>
|
||||
</div>
|
||||
</article>"#,
|
||||
entry_id = escape_html(entry_id),
|
||||
kind = kind,
|
||||
original = original,
|
||||
trash_path = trash_path,
|
||||
deleted_at = escape_html(&deleted_at),
|
||||
command_id = escape_html(local_trash_command_id(entry_id, entry).as_str()),
|
||||
);
|
||||
if entry.resource_kind == "markdown" || entry.resource_kind == "markdown_bundle" {
|
||||
document_rows.push(row);
|
||||
} else {
|
||||
resource_rows.push(row);
|
||||
}
|
||||
}
|
||||
let document_body = if document_rows.is_empty() {
|
||||
r#"<div class="mnote-trash-empty">暂无已删除页面</div>"#.to_string()
|
||||
} else {
|
||||
document_rows.join("")
|
||||
};
|
||||
let resource_body = if resource_rows.is_empty() {
|
||||
r#"<div class="mnote-trash-empty">暂无已删除资源</div>"#.to_string()
|
||||
} else {
|
||||
resource_rows.join("")
|
||||
};
|
||||
format!(
|
||||
r#"<section class="mnote-trash-workbench" data-testid="mnote-trash-workbench" data-trash-source-kind="local_folder" data-workspace-id="{workspace_id}" data-root-uri="{root_uri}" data-trash-fetch-path="/trash">
|
||||
<header class="mnote-trash-header">
|
||||
<h1>本地文件夹垃圾箱</h1>
|
||||
<p>本地删除项保存在当前目录的 <code>.mnote/trash</code> 与 <code>.mnote/trash-index.json</code> 中。</p>
|
||||
<p class="mnote-trash-status" data-trash-status role="status" aria-live="polite"></p>
|
||||
</header>
|
||||
<section class="mnote-trash-section" data-testid="mnote-trash-documents">
|
||||
<div class="mnote-trash-section-title">
|
||||
<h2>页面 <span data-trash-document-count>{document_count}</span></h2>
|
||||
<button type="button" data-trash-action="local-empty-documents"{document_empty_disabled}>清空页面垃圾箱</button>
|
||||
</div>
|
||||
{document_body}
|
||||
</section>
|
||||
<section class="mnote-trash-section" data-testid="mnote-trash-resources">
|
||||
<div class="mnote-trash-section-title">
|
||||
<h2>资源 <span data-trash-resource-count>{resource_count}</span></h2>
|
||||
<button type="button" data-trash-action="local-empty-resources"{resource_empty_disabled}>清空资源垃圾箱</button>
|
||||
</div>
|
||||
{resource_body}
|
||||
</section>
|
||||
</section>
|
||||
<script>
|
||||
(function() {{
|
||||
var root = document.querySelector('[data-testid="mnote-trash-workbench"]');
|
||||
if (!root) return;
|
||||
var workspaceId = root.getAttribute('data-workspace-id') || '';
|
||||
var rootUri = root.getAttribute('data-root-uri') || '';
|
||||
function setStatus(message, failed) {{
|
||||
var status = root.querySelector('[data-trash-status]');
|
||||
if (!status) return;
|
||||
status.textContent = message || '';
|
||||
status.setAttribute('data-type', failed ? 'error' : 'success');
|
||||
}}
|
||||
function readJson(response) {{
|
||||
return response.json().catch(function() {{ return null; }}).then(function(payload) {{
|
||||
if (!response.ok) throw new Error((payload && payload.message) || 'trash_request_failed_' + response.status);
|
||||
return payload;
|
||||
}});
|
||||
}}
|
||||
function postJson(url, body) {{
|
||||
return fetch(url, {{
|
||||
method: 'POST',
|
||||
headers: {{ 'content-type': 'application/json' }},
|
||||
body: JSON.stringify(body || {{}})
|
||||
}}).then(readJson);
|
||||
}}
|
||||
function refresh() {{
|
||||
var url = new URL('/trash', window.location.origin);
|
||||
url.searchParams.set('sourceKind', 'local_folder');
|
||||
url.searchParams.set('rootUri', rootUri);
|
||||
return fetch(url.toString(), {{ headers: {{ 'x-mnote-trash-live-refresh': '1' }} }})
|
||||
.then(function(response) {{ return response.text().then(function(html) {{ return {{ response: response, html: html }}; }}); }})
|
||||
.then(function(result) {{
|
||||
if (!result.response.ok) throw new Error('trash_live_refresh_failed_' + result.response.status);
|
||||
var parsed = new DOMParser().parseFromString(result.html, 'text/html');
|
||||
var nextRoot = parsed.querySelector('[data-testid="mnote-trash-workbench"]');
|
||||
if (!nextRoot) throw new Error('trash_live_refresh_missing_workbench');
|
||||
root.innerHTML = nextRoot.innerHTML;
|
||||
return true;
|
||||
}})
|
||||
.catch(function(error) {{
|
||||
setStatus(error && error.message ? error.message : String(error), true);
|
||||
return false;
|
||||
}});
|
||||
}}
|
||||
root.addEventListener('click', function(event) {{
|
||||
var button = event.target && event.target.closest ? event.target.closest('[data-trash-action]') : null;
|
||||
if (!button || button.disabled) return;
|
||||
var action = button.getAttribute('data-trash-action');
|
||||
var entryId = button.getAttribute('data-trash-entry-id') || '';
|
||||
var documentId = button.getAttribute('data-document-id') || entryId;
|
||||
var kind = button.getAttribute('data-resource-kind') || '';
|
||||
if (action === 'local-restore' || action === 'local-purge') {{
|
||||
if (action === 'local-purge' && !window.confirm('彻底删除后无法恢复,确定继续吗?')) return;
|
||||
button.disabled = true;
|
||||
postJson('/api/tree/commands', {{
|
||||
action: action === 'local-restore' ? 'restore' : 'purge',
|
||||
sourceKind: 'local_folder',
|
||||
rootUri: rootUri,
|
||||
workspaceId: workspaceId,
|
||||
documentId: entryId
|
||||
}}).then(function() {{
|
||||
return refresh();
|
||||
}}).then(function() {{
|
||||
setStatus(action === 'local-restore' ? '已恢复项目' : '已彻底删除项目', false);
|
||||
}}).catch(function(error) {{
|
||||
button.disabled = false;
|
||||
setStatus(error && error.message ? error.message : String(error), true);
|
||||
}});
|
||||
}}
|
||||
if (action === 'local-empty-documents' || action === 'local-empty-resources') {{
|
||||
var selector = action === 'local-empty-documents'
|
||||
? '[data-trash-row="local"][data-resource-kind="markdown"], [data-trash-row="local"][data-resource-kind="markdown_bundle"]'
|
||||
: '[data-trash-row="local"]:not([data-resource-kind="markdown"]):not([data-resource-kind="markdown_bundle"])';
|
||||
var rows = Array.prototype.slice.call(root.querySelectorAll(selector));
|
||||
if (rows.length === 0) return;
|
||||
if (!window.confirm('清空后无法恢复,确定继续吗?')) return;
|
||||
button.disabled = true;
|
||||
Promise.all(rows.map(function(row) {{
|
||||
var entryId = row.getAttribute('data-trash-entry-id') || '';
|
||||
return postJson('/api/tree/commands', {{
|
||||
action: 'purge',
|
||||
sourceKind: 'local_folder',
|
||||
rootUri: rootUri,
|
||||
workspaceId: workspaceId,
|
||||
documentId: entryId
|
||||
}});
|
||||
}})).then(function() {{
|
||||
return refresh();
|
||||
}}).then(function() {{
|
||||
setStatus(action === 'local-empty-documents' ? '已清空页面垃圾箱' : '已清空资源垃圾箱', false);
|
||||
}}).catch(function(error) {{
|
||||
button.disabled = false;
|
||||
setStatus(error && error.message ? error.message : String(error), true);
|
||||
}});
|
||||
}}
|
||||
}});
|
||||
}})();
|
||||
</script>"#,
|
||||
workspace_id = escape_html(workspace_id),
|
||||
root_uri = escape_html(root_uri),
|
||||
document_count = document_rows.len(),
|
||||
resource_count = resource_rows.len(),
|
||||
document_empty_disabled = if document_rows.is_empty() {
|
||||
" disabled"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
resource_empty_disabled = if resource_rows.is_empty() {
|
||||
" disabled"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
document_body = document_body,
|
||||
resource_body = resource_body,
|
||||
)
|
||||
}
|
||||
|
||||
fn local_trash_command_id(
|
||||
entry_id: &str,
|
||||
entry: &crate::routes::local_folder_source::LocalTrashEntry,
|
||||
) -> String {
|
||||
if entry.resource_kind == "markdown" || entry.resource_kind == "markdown_bundle" {
|
||||
return entry.document_id.clone();
|
||||
}
|
||||
entry_id.to_string()
|
||||
}
|
||||
|
||||
fn json_array<'a>(dataset: &'a Value, key: &str) -> &'a [Value] {
|
||||
dataset
|
||||
.get(key)
|
||||
@@ -1334,6 +1647,10 @@ fn build_auth_proxy_response(
|
||||
COOKIE_CONVEX_AUTH_REFRESH_TOKEN,
|
||||
tokens.get("refreshToken"),
|
||||
);
|
||||
if let Some(actor_id) = resolve_mnote_actor_id(&value, tokens, context) {
|
||||
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_ID, &actor_id);
|
||||
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_TYPE, "user");
|
||||
}
|
||||
expire_cookie(response.headers_mut(), COOKIE_MNOTE_WEB_DEV_SESSION);
|
||||
}
|
||||
}
|
||||
@@ -1342,6 +1659,61 @@ fn build_auth_proxy_response(
|
||||
response
|
||||
}
|
||||
|
||||
fn resolve_mnote_actor_id(
|
||||
value: &serde_json::Value,
|
||||
tokens: &serde_json::Value,
|
||||
context: &RequestContext,
|
||||
) -> Option<String> {
|
||||
[
|
||||
"/userId",
|
||||
"/user/id",
|
||||
"/user/_id",
|
||||
"/user/subject",
|
||||
"/profile/userId",
|
||||
"/profile/id",
|
||||
]
|
||||
.iter()
|
||||
.find_map(|pointer| {
|
||||
value
|
||||
.pointer(pointer)
|
||||
.and_then(Value::as_str)
|
||||
.and_then(stable_actor_id)
|
||||
})
|
||||
.or_else(|| {
|
||||
tokens
|
||||
.get("token")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(extract_actor_id_from_jwt)
|
||||
})
|
||||
.or_else(|| {
|
||||
let actor_id = stable_actor_id(&context.auth.actor_id)?;
|
||||
if actor_id == "anonymous" {
|
||||
None
|
||||
} else {
|
||||
Some(actor_id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_actor_id_from_jwt(token: &str) -> Option<String> {
|
||||
let payload_segment = token.split('.').nth(1)?;
|
||||
let decoded = URL_SAFE_NO_PAD.decode(payload_segment.as_bytes()).ok()?;
|
||||
let payload: Value = serde_json::from_slice(&decoded).ok()?;
|
||||
["sub", "userId", "id", "_id"].iter().find_map(|key| {
|
||||
payload
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.and_then(stable_actor_id)
|
||||
})
|
||||
}
|
||||
|
||||
fn set_literal_cookie(headers: &mut axum::http::HeaderMap, name: &'static str, value: &str) {
|
||||
let cookie = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax");
|
||||
if let Ok(value) = HeaderValue::from_str(&cookie) {
|
||||
headers.append(header::SET_COOKIE, value);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_auth_cookie_from_value(
|
||||
headers: &mut axum::http::HeaderMap,
|
||||
name: &'static str,
|
||||
@@ -1360,6 +1732,8 @@ fn clear_auth_cookies(headers: &mut axum::http::HeaderMap) {
|
||||
expire_cookie(headers, COOKIE_CONVEX_AUTH_JWT);
|
||||
expire_cookie(headers, COOKIE_CONVEX_AUTH_REFRESH_TOKEN);
|
||||
expire_cookie(headers, COOKIE_MNOTE_WEB_DEV_SESSION);
|
||||
expire_cookie(headers, COOKIE_MNOTE_ACTOR_ID);
|
||||
expire_cookie(headers, COOKIE_MNOTE_ACTOR_TYPE);
|
||||
}
|
||||
|
||||
fn expire_cookie(headers: &mut axum::http::HeaderMap, name: &'static str) {
|
||||
@@ -1456,9 +1830,20 @@ mod tests {
|
||||
use axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode};
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use axum::routing::{get, post};
|
||||
use base64::Engine;
|
||||
use tokio::net::TcpListener;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn temp_root(name: &str) -> std::path::PathBuf {
|
||||
let stamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("{name}-{stamp}"));
|
||||
std::fs::create_dir_all(&path).expect("temp root");
|
||||
path
|
||||
}
|
||||
|
||||
fn app() -> axum::Router {
|
||||
app_with_legacy_next_base_url("http://127.0.0.1:3100".into())
|
||||
}
|
||||
@@ -1526,6 +1911,7 @@ mod tests {
|
||||
axum::Json(serde_json::json!({
|
||||
"status": "success",
|
||||
"value": {
|
||||
"userId": "user_demo",
|
||||
"tokens": {
|
||||
"token": "jwt-demo",
|
||||
"refreshToken": "refresh-demo"
|
||||
@@ -1542,6 +1928,43 @@ mod tests {
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
async fn spawn_convex_auth_upstream_with_token(token: String) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("convex auth listener");
|
||||
let addr = listener.local_addr().expect("convex auth addr");
|
||||
let app = axum::Router::new().route(
|
||||
"/api/action",
|
||||
post(move || {
|
||||
let token = token.clone();
|
||||
async move {
|
||||
axum::Json(serde_json::json!({
|
||||
"status": "success",
|
||||
"value": {
|
||||
"tokens": {
|
||||
"token": token,
|
||||
"refreshToken": "refresh-demo"
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}),
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("convex auth server");
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
fn unsigned_jwt_with_subject(subject: &str) -> String {
|
||||
let payload = serde_json::json!({ "sub": subject });
|
||||
let encoded_payload =
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes());
|
||||
format!("header.{encoded_payload}.signature")
|
||||
}
|
||||
|
||||
async fn spawn_legacy_auth_upstream() -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
@@ -1745,6 +2168,66 @@ mod tests {
|
||||
assert!(!html.contains("window.location.reload"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trash_entry_renders_local_folder_trash_workbench() {
|
||||
let root = temp_root("mnote-local-folder-trash-entry");
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_real",
|
||||
&format!("file://{}", root.display()),
|
||||
)
|
||||
.expect("init local workspace");
|
||||
std::fs::create_dir_all(root.join(".mnote")).expect("create metadata dir");
|
||||
std::fs::create_dir_all(root.join(".mnote").join("trash")).expect("create trash dir");
|
||||
std::fs::write(
|
||||
root.join(".mnote").join("trash-index.json"),
|
||||
serde_json::json!({
|
||||
"entries": {
|
||||
"local-md:Deleted~2FDeleted.md": {
|
||||
"documentId": "local-md:Deleted~2FDeleted.md",
|
||||
"resourceKind": "markdown_bundle",
|
||||
"resourceScope": "local_folder",
|
||||
"originalRelativePath": "Deleted",
|
||||
"trashRelativePath": ".mnote/trash/Deleted",
|
||||
"deletedAtMs": 1770000000000u64
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write trash index");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let uri = format!(
|
||||
"/trash?sourceKind=local_folder&rootUri={}",
|
||||
root_uri.replace(':', "%3A").replace('/', "%2F")
|
||||
);
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(uri)
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-trash-source-kind="local_folder""#));
|
||||
assert!(html.contains("Deleted"));
|
||||
assert!(html.contains(r#"data-trash-action="local-restore""#));
|
||||
assert!(html.contains(r#"data-trash-action="local-purge""#));
|
||||
assert!(html.contains(r#"data-trash-action="local-empty-documents""#));
|
||||
assert!(html.contains(r#"data-trash-action="local-empty-resources" disabled"#));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_entry_active_selection_prefers_page_id_over_recent_projection_and_first_page() {
|
||||
let selected = super::choose_root_entry_active_page_id(
|
||||
@@ -1860,6 +2343,11 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_renders_local_first_landing_without_convex() {
|
||||
let _guard = crate::test_support::hermes_env_lock()
|
||||
.lock()
|
||||
.expect("env lock");
|
||||
let base = temp_root("mnote-root-local-first-landing");
|
||||
std::env::set_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR", &base);
|
||||
let response = app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -1871,15 +2359,19 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
std::env::remove_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-testid="mnote-create-default-local-workspace""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
|
||||
assert!(html.contains("初始化的新页面"));
|
||||
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
|
||||
assert!(!html.contains(r#"data-testid="mnote-create-default-local-workspace""#));
|
||||
assert!(!html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
|
||||
assert!(!html.contains("workspaces:ensureDefaultWorkspace"));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2060,6 +2552,52 @@ mod tests {
|
||||
assert!(html.contains(r#"data-mnote-shell="workspace""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_initializes_default_local_workspace_page() {
|
||||
let _guard = crate::test_support::hermes_env_lock()
|
||||
.lock()
|
||||
.expect("env lock");
|
||||
let base = temp_root("mnote-root-default-local-workspace");
|
||||
std::env::set_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR", &base);
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
std::env::remove_var("MNOTE_LOCAL_WORKSPACE_BASE_DIR");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
let expected_root = base
|
||||
.join("users")
|
||||
.join("user_real")
|
||||
.join("workspaces")
|
||||
.join("my-space");
|
||||
assert!(expected_root
|
||||
.join("初始化的新页面")
|
||||
.join("初始化的新页面.md")
|
||||
.exists());
|
||||
assert!(html.contains("初始化的新页面"));
|
||||
assert!(html.contains("local_folder"));
|
||||
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
|
||||
assert!(html.contains(r#"data-mnote-root-uri="file://"#));
|
||||
assert!(!html.contains(r#"data-testid="mnote-workspace-empty-state""#));
|
||||
assert!(!html.contains("当前还没有可显示的本地工作区"));
|
||||
assert!(!html.contains(r#"data-testid="mnote-empty-create-page""#));
|
||||
assert!(html.contains(r#""transport":"disabled""#));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_entry_returns_gateway_fallback_shell_when_compat_disabled() {
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
@@ -2150,6 +2688,12 @@ mod tests {
|
||||
assert!(values
|
||||
.iter()
|
||||
.any(|value| value.contains("__convexAuthRefreshToken=refresh-demo")));
|
||||
assert!(values
|
||||
.iter()
|
||||
.any(|value| value.contains("mnote_actor_id=user_demo")));
|
||||
assert!(values
|
||||
.iter()
|
||||
.any(|value| value.contains("mnote_actor_type=user")));
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
@@ -2158,6 +2702,42 @@ mod tests {
|
||||
assert_eq!(payload["tokens"]["refreshToken"], "dummy");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_api_normalizes_session_subject_before_setting_actor_cookie() {
|
||||
let token = unsigned_jwt_with_subject("user_stable|session_rotating");
|
||||
let convex_url = spawn_convex_auth_upstream_with_token(token).await;
|
||||
let response = app_with_config_and_convex_url(
|
||||
"http://127.0.0.1:3100".into(),
|
||||
false,
|
||||
Some(convex_url),
|
||||
)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"action":"auth:signIn","args":{"provider":"password","params":{"email":"mnote.e2e@example.com","password":"MnoteE2E123!","flow":"signIn"}}}"#,
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let cookies = response.headers().get_all(header::SET_COOKIE);
|
||||
let values = cookies
|
||||
.iter()
|
||||
.map(|value| value.to_str().unwrap_or_default())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(values
|
||||
.iter()
|
||||
.any(|value| value.contains("mnote_actor_id=user_stable")));
|
||||
assert!(!values
|
||||
.iter()
|
||||
.any(|value| value.contains("mnote_actor_id=user_stable|session_rotating")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_entry_uses_mnote_web_login_ui_when_compat_enabled() {
|
||||
let legacy_base_url = spawn_legacy_auth_upstream().await;
|
||||
|
||||
Reference in New Issue
Block a user