1#![allow(clippy::unused_unit, deprecated)]
12
13use dioxus::prelude::*;
14
15#[cfg(feature = "server")]
16use crate::models::mcp_token::McpToken;
17use crate::models::mcp_token::{CreateTokenResponse, McpTokenSummary, NoteGrant, TokenScope};
18
19#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
24#[serde(rename_all = "lowercase")]
25pub enum TokenLifetime {
26 Days1,
28 Days7,
30 Days30,
32 Days90,
34 Never,
36}
37
38impl TokenLifetime {
39 #[cfg(feature = "server")]
41 fn expires_at(self) -> Option<chrono::DateTime<chrono::Utc>> {
42 let now = chrono::Utc::now();
43 match self {
44 TokenLifetime::Days1 => Some(now + chrono::Duration::days(1)),
45 TokenLifetime::Days7 => Some(now + chrono::Duration::days(7)),
46 TokenLifetime::Days30 => Some(now + chrono::Duration::days(30)),
47 TokenLifetime::Days90 => Some(now + chrono::Duration::days(90)),
48 TokenLifetime::Never => None,
49 }
50 }
51}
52
53#[server(CreateMcpToken, "/api")]
58pub async fn create_mcp_token(
59 name: String,
60 scope: TokenScope,
61 lifetime: TokenLifetime,
62 notes: Option<crate::models::mcp_token::NoteGrant>,
63) -> Result<CreateTokenResponse, ServerFnError> {
64 #[cfg(feature = "server")]
65 {
66 use crate::api::auth::get_current_admin_user;
67 use crate::api::error::AppError;
68 use crate::db::pool::get_conn;
69 use crate::mcp::auth::{hash_token, TOKEN_PREFIX};
70 use crate::mcp::crypto::encrypt_token;
71
72 let admin = get_current_admin_user().await?;
73
74 let name = name.trim().to_string();
76 crate::models::mcp_token::validate_token_name(&name)
77 .map_err(|message| AppError::BadRequest(message.to_string()))?;
78
79 if crate::mcp::crypto::mcp_enc_key().is_none() {
81 return Err(AppError::Internal("MCP_TOKEN_ENC_KEY 未设置").into());
82 }
83
84 let mut bytes = [0u8; 32];
86 rand::TryRng::try_fill_bytes(&mut rand::rngs::SysRng, &mut bytes)
87 .map_err(|_| AppError::Internal("令牌随机数生成失败"))?;
88 let plaintext = format!("{TOKEN_PREFIX}{}", hex::encode(bytes));
89 let hash = hash_token(&plaintext);
90 let enc = encrypt_token(&plaintext).ok_or(AppError::Internal("MCP 令牌加密失败"))?;
91 let id = uuid::Uuid::new_v4();
92 let expires_at = lifetime.expires_at();
93 let scope_str = scope.as_str();
94
95 let client = get_conn().await.map_err(AppError::db_conn)?;
96
97 let notes = validate_note_grant(&client, admin.id, notes.unwrap_or_default()).await?;
98
99 let row = client
100 .query_one(
101 "INSERT INTO mcp_tokens \
102 (id, user_id, name, scope, token_enc, token_hash, expires_at, notes_read, notes_write, notebook_ids) \
103 VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, $10) \
104 RETURNING id::text, user_id, name, scope, created_at, expires_at, \
105 last_used_at, revoked_at, notes_read, notes_write, notebook_ids",
106 &[&id, &admin.id, &name, &scope_str, &enc, &hash, &expires_at, ¬es.read, ¬es.write, ¬es.notebook_ids],
107 )
108 .await
109 .map_err(AppError::query)?;
110
111 let token = row_to_mcp_token_meta(&row);
112 Ok(CreateTokenResponse {
113 summary: token.into(),
114 plaintext,
115 })
116 }
117 #[cfg(not(feature = "server"))]
118 unreachable!()
119}
120
121#[server(UpdateMcpTokenPermissions, "/api")]
123pub async fn update_mcp_token_permissions(
124 id: String,
125 scope: TokenScope,
126 notes: NoteGrant,
127) -> Result<(), ServerFnError> {
128 let admin = crate::api::auth::get_current_admin_user().await?;
129 let client = crate::db::pool::get_conn()
130 .await
131 .map_err(crate::api::error::AppError::db_conn)?;
132 save_token_permissions(&client, admin.id, &id, scope, notes).await?;
133 Ok(())
134}
135
136#[cfg(feature = "server")]
137async fn validate_note_grant(
138 client: &tokio_postgres::Client,
139 owner_id: i32,
140 mut notes: NoteGrant,
141) -> Result<NoteGrant, crate::api::error::AppError> {
142 use crate::api::error::AppError;
143 notes.read |= notes.write;
144 if !notes.read {
145 notes.notebook_ids = None;
146 }
147 if let Some(ids) = notes.notebook_ids.as_mut() {
148 ids.sort_unstable();
149 ids.dedup();
150 let owned: i64 = client
151 .query_one(
152 "SELECT COUNT(*) FROM notebooks WHERE owner_id=$1 AND id=ANY($2)",
153 &[&owner_id, &ids.as_slice()],
154 )
155 .await
156 .map_err(AppError::query)?
157 .get(0);
158 if ids.is_empty() || owned != ids.len() as i64 {
159 return Err(AppError::BadRequest("请选择自己拥有的笔记本".into()));
160 }
161 }
162 Ok(notes)
163}
164
165#[cfg(feature = "server")]
166async fn save_token_permissions(
167 client: &tokio_postgres::Client,
168 owner_id: i32,
169 id: &str,
170 scope: TokenScope,
171 notes: NoteGrant,
172) -> Result<(), crate::api::error::AppError> {
173 use crate::api::error::AppError;
174 let invalid_token = || AppError::BadRequest("令牌不存在或已失效,请刷新列表".into());
175 let id = uuid::Uuid::parse_str(id).map_err(|_| invalid_token())?;
176 let notes = validate_note_grant(client, owner_id, notes).await?;
177 let changed = client
178 .execute(
179 "UPDATE mcp_tokens SET scope=$3, notes_read=$4, notes_write=$5, notebook_ids=$6 \
180 WHERE id=$1 AND user_id=$2 AND revoked_at IS NULL \
181 AND (expires_at IS NULL OR expires_at>NOW())",
182 &[
183 &id,
184 &owner_id,
185 &scope.as_str(),
186 ¬es.read,
187 ¬es.write,
188 ¬es.notebook_ids,
189 ],
190 )
191 .await
192 .map_err(AppError::query)?;
193 if changed == 0 {
194 return Err(invalid_token());
195 }
196 Ok(())
197}
198
199#[cfg(all(test, feature = "server"))]
200mod permission_tests {
201 use super::*;
202
203 #[tokio::test]
204 #[ignore = "requires YGGDRASIL_TEST_DATABASE_URL; uses connection-local temporary tables"]
205 async fn edit_permissions_preserves_token_and_enforces_ownership_and_validity() {
206 let url = std::env::var("YGGDRASIL_TEST_DATABASE_URL").unwrap();
207 let (client, connection) = tokio_postgres::connect(&url, tokio_postgres::NoTls)
208 .await
209 .unwrap();
210 let connection = tokio::spawn(connection);
211 client.batch_execute(
212 "CREATE TEMP TABLE notebooks (id INT PRIMARY KEY, owner_id INT NOT NULL);
213 INSERT INTO notebooks VALUES (10,1),(20,2);
214 CREATE TEMP TABLE mcp_tokens (
215 id UUID PRIMARY KEY, user_id INT NOT NULL, scope TEXT NOT NULL,
216 notes_read BOOLEAN DEFAULT false, notes_write BOOLEAN DEFAULT false,
217 notebook_ids INT[], revoked_at TIMESTAMPTZ, expires_at TIMESTAMPTZ,
218 token_hash TEXT DEFAULT 'original-hash', token_enc TEXT DEFAULT 'original-ciphertext'
219 )"
220 ).await.unwrap();
221 let id = uuid::Uuid::new_v4();
222 client
223 .execute(
224 "INSERT INTO mcp_tokens(id,user_id,scope) VALUES($1,1,'admin')",
225 &[&id],
226 )
227 .await
228 .unwrap();
229 let grant = NoteGrant {
230 read: false,
231 write: true,
232 notebook_ids: Some(vec![10, 10]),
233 };
234 save_token_permissions(&client, 1, &id.to_string(), TokenScope::Read, grant)
235 .await
236 .unwrap();
237 let row = client
238 .query_one("SELECT * FROM mcp_tokens WHERE id=$1", &[&id])
239 .await
240 .unwrap();
241 assert!(row.get::<_, bool>("notes_read"));
242 assert!(row.get::<_, bool>("notes_write"));
243 assert_eq!(row.get::<_, Vec<i32>>("notebook_ids"), vec![10]);
244 assert_eq!(row.get::<_, String>("scope"), "read");
245 assert_eq!(row.get::<_, String>("token_hash"), "original-hash");
246 assert_eq!(row.get::<_, String>("token_enc"), "original-ciphertext");
247 assert!(row
248 .get::<_, Option<chrono::DateTime<chrono::Utc>>>("expires_at")
249 .is_none());
250
251 for ids in [vec![], vec![20], vec![10, 20], vec![999]] {
252 assert!(save_token_permissions(
253 &client,
254 1,
255 &id.to_string(),
256 TokenScope::Admin,
257 NoteGrant {
258 read: true,
259 write: false,
260 notebook_ids: Some(ids)
261 }
262 )
263 .await
264 .is_err());
265 }
266 for (owner, target) in [
267 (2, id.to_string()),
268 (1, uuid::Uuid::new_v4().to_string()),
269 (1, "invalid".into()),
270 ] {
271 assert!(save_token_permissions(
272 &client,
273 owner,
274 &target,
275 TokenScope::Admin,
276 NoteGrant::default()
277 )
278 .await
279 .is_err());
280 }
281 let row = client
282 .query_one(
283 "SELECT scope,notes_write,notebook_ids FROM mcp_tokens WHERE id=$1",
284 &[&id],
285 )
286 .await
287 .unwrap();
288 assert_eq!(row.get::<_, String>(0), "read");
289 assert!(row.get::<_, bool>(1));
290 assert_eq!(row.get::<_, Vec<i32>>(2), vec![10]);
291
292 save_token_permissions(
294 &client,
295 1,
296 &id.to_string(),
297 TokenScope::Write,
298 NoteGrant {
299 read: true,
300 write: false,
301 notebook_ids: None,
302 },
303 )
304 .await
305 .unwrap();
306 save_token_permissions(
307 &client,
308 1,
309 &id.to_string(),
310 TokenScope::Write,
311 NoteGrant {
312 read: false,
313 write: false,
314 notebook_ids: Some(vec![999]),
315 },
316 )
317 .await
318 .unwrap();
319 let row = client
320 .query_one(
321 "SELECT notes_read,notes_write,notebook_ids FROM mcp_tokens WHERE id=$1",
322 &[&id],
323 )
324 .await
325 .unwrap();
326 assert!(!row.get::<_, bool>(0));
327 assert!(!row.get::<_, bool>(1));
328 assert!(row.get::<_, Option<Vec<i32>>>(2).is_none());
329 for sql in [
330 "UPDATE mcp_tokens SET expires_at=NOW()-INTERVAL '1 second' WHERE id=$1",
331 "UPDATE mcp_tokens SET expires_at=NULL,revoked_at=NOW() WHERE id=$1",
332 ] {
333 client.execute(sql, &[&id]).await.unwrap();
334 assert!(save_token_permissions(
335 &client,
336 1,
337 &id.to_string(),
338 TokenScope::Admin,
339 NoteGrant::default()
340 )
341 .await
342 .is_err());
343 let scope: String = client
344 .query_one("SELECT scope FROM mcp_tokens WHERE id=$1", &[&id])
345 .await
346 .unwrap()
347 .get(0);
348 assert_eq!(scope, "write");
349 }
350 drop(client);
351 connection.await.unwrap().unwrap();
352 }
353}
354
355#[server(ListMcpTokens, "/api")]
359pub async fn list_mcp_tokens() -> Result<Vec<McpTokenSummary>, ServerFnError> {
360 #[cfg(feature = "server")]
361 {
362 use crate::api::auth::get_current_admin_user;
363 use crate::api::error::AppError;
364 use crate::db::pool::get_conn;
365
366 let admin = get_current_admin_user().await?;
367 let client = get_conn().await.map_err(AppError::db_conn)?;
368
369 let rows = client
370 .query(
371 "SELECT id::text, user_id, name, scope, created_at, expires_at, \
372 last_used_at, revoked_at, notes_read, notes_write, notebook_ids \
373 FROM mcp_tokens \
374 WHERE user_id = $1 \
375 ORDER BY created_at DESC",
376 &[&admin.id],
377 )
378 .await
379 .map_err(AppError::query)?;
380
381 Ok(rows
382 .iter()
383 .map(row_to_mcp_token_meta)
384 .map(McpTokenSummary::from)
385 .collect())
386 }
387 #[cfg(not(feature = "server"))]
388 unreachable!()
389}
390
391#[server(RevealMcpToken, "/api")]
396pub async fn reveal_mcp_token(id: String) -> Result<Option<String>, ServerFnError> {
397 #[cfg(feature = "server")]
398 {
399 use crate::api::auth::get_current_admin_user;
400 use crate::api::error::AppError;
401 use crate::db::pool::get_conn;
402 use crate::mcp::crypto::decrypt_token;
403
404 let admin = get_current_admin_user().await?;
405 let client = get_conn().await.map_err(AppError::db_conn)?;
406
407 let id = match uuid::Uuid::parse_str(&id) {
409 Ok(u) => u,
410 Err(_) => return Ok(None),
411 };
412
413 let row = client
415 .query_opt(
416 "SELECT token_enc FROM mcp_tokens WHERE id = $1::uuid AND user_id = $2",
417 &[&id, &admin.id],
418 )
419 .await
420 .map_err(AppError::query)?;
421
422 Ok(row
425 .map(|r| r.get::<_, String>("token_enc"))
426 .and_then(|enc| decrypt_token(&enc)))
427 }
428 #[cfg(not(feature = "server"))]
429 unreachable!()
430}
431
432#[server(RevokeMcpToken, "/api")]
436pub async fn revoke_mcp_token(id: String) -> Result<(), ServerFnError> {
437 #[cfg(feature = "server")]
438 {
439 use crate::api::auth::get_current_admin_user;
440 use crate::api::error::AppError;
441 use crate::db::pool::get_conn;
442
443 let admin = get_current_admin_user().await?;
444 let client = get_conn().await.map_err(AppError::db_conn)?;
445
446 let id = match uuid::Uuid::parse_str(&id) {
448 Ok(u) => u,
449 Err(_) => return Ok(()),
450 };
451
452 client
453 .execute(
454 "UPDATE mcp_tokens SET revoked_at = NOW() \
455 WHERE id = $1::uuid AND user_id = $2 AND revoked_at IS NULL",
456 &[&id, &admin.id],
457 )
458 .await
459 .map_err(AppError::query)?;
460
461 Ok(())
462 }
463 #[cfg(not(feature = "server"))]
464 unreachable!()
465}
466
467#[cfg(feature = "server")]
472fn row_to_mcp_token_meta(row: &tokio_postgres::Row) -> McpToken {
473 let scope_str: String = row.get("scope");
474 let scope = TokenScope::from_db(&scope_str).unwrap_or_else(|| {
475 tracing::warn!(scope = %scope_str, "mcp_tokens.scope 非法值,兜底为 read");
476 TokenScope::Read
477 });
478 McpToken {
479 id: row.get("id"),
480 user_id: row.get("user_id"),
481 name: row.get("name"),
482 scope,
483 notes: crate::models::mcp_token::NoteGrant {
484 read: row.get("notes_read"),
485 write: row.get("notes_write"),
486 notebook_ids: row.get("notebook_ids"),
487 },
488 token_enc: String::new(),
489 token_hash: String::new(),
490 created_at: row.get("created_at"),
491 expires_at: row.get("expires_at"),
492 last_used_at: row.get("last_used_at"),
493 revoked_at: row.get("revoked_at"),
494 }
495}
496
497#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
503pub struct McpConfigSnippet {
504 pub title: String,
506 pub content: String,
508 pub content_html: String,
510}
511
512#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
518pub struct McpClientConfigs {
519 pub snippets: Vec<McpConfigSnippet>,
521}
522
523#[server(GetMcpClientConfigs, "/api")]
528pub async fn get_mcp_client_configs(token: String) -> Result<McpClientConfigs, ServerFnError> {
529 #[cfg(feature = "server")]
530 {
531 use crate::api::auth::get_current_admin_user;
532 use crate::highlight::server::highlight_code;
533
534 let _admin = get_current_admin_user().await?;
535 let c = crate::mcp::config::generate_client_configs(
536 &crate::mcp::config::base_url_from_env(),
537 &token,
538 );
539 let entries: [(&str, String, &str); 8] = [
541 (
542 "Oh-My-Pi(项目根 .mcp.json / ~/.omp/agent/mcp.json 或 ~/.mcp.json)",
543 c.omp_json,
544 "json",
545 ),
546 (
547 "Codex(追加到 ~/.codex/config.toml 或项目根 .codex/config.toml)",
548 c.codex_toml,
549 "toml",
550 ),
551 (
552 "OpenCode(~/.config/opencode/opencode.json 或项目根 opencode.json)",
553 c.opencode_json,
554 "json",
555 ),
556 (
557 "Claude Code(.mcp.json / ~/.claude.json)",
558 c.claude_code_json,
559 "json",
560 ),
561 ("Cursor(~/.cursor/mcp.json)", c.cursor_json, "json"),
562 ("Cline(cline_mcp_settings.json)", c.cline_json, "json"),
563 ("通用(单 server entry)", c.generic_json, "json"),
564 ("Claude Code CLI", c.claude_cli, "bash"),
565 ];
566 let snippets = entries
567 .into_iter()
568 .map(|(title, content, lang)| McpConfigSnippet {
569 title: title.to_string(),
570 content_html: highlight_code(&content, Some(lang)),
571 content,
572 })
573 .collect();
574 Ok(McpClientConfigs { snippets })
575 }
576 #[cfg(not(feature = "server"))]
577 unreachable!()
578}
579
580#[cfg(all(test, feature = "server"))]
581mod tests {
582 use super::*;
583
584 #[test]
585 fn lifetime_expires_at_days() {
586 let now = chrono::Utc::now();
587 let d1 = TokenLifetime::Days1.expires_at().unwrap();
588 let d7 = TokenLifetime::Days7.expires_at().unwrap();
589 assert!(d1 > now);
590 assert!(d7 > d1);
591 let delta = (d7 - d1).num_seconds() as f64 / 86400.0;
593 assert!((5.9..6.1).contains(&delta));
594 }
595
596 #[test]
597 fn lifetime_never_is_none() {
598 assert!(TokenLifetime::Never.expires_at().is_none());
599 }
600
601 #[test]
602 fn lifetime_serde_roundtrip() {
603 let json = serde_json::to_string(&TokenLifetime::Days30).unwrap();
604 assert_eq!(json, "\"days30\"");
605 let back: TokenLifetime = serde_json::from_str(&json).unwrap();
606 assert_eq!(back, TokenLifetime::Days30);
607 }
608}