at main 664 lines 23 kB view raw
1//! sh.weaver.domain.* endpoint handlers 2 3use std::collections::{HashMap, HashSet}; 4 5use axum::{Json, extract::State}; 6use jacquard::IntoStatic; 7use jacquard::cowstr::ToCowStr; 8use jacquard::types::string::{AtUri, Cid, Did, Uri}; 9use jacquard::types::value::Data; 10use jacquard_axum::ExtractXrpc; 11use serde::{Deserialize, Serialize}; 12use weaver_api::sh_weaver::domain::{ 13 DocumentView, PublicationView, 14 generate_document::{GenerateDocumentOutput, GenerateDocumentRequest}, 15 resolve_by_domain::{ResolveByDomainOutput, ResolveByDomainRequest}, 16 resolve_document::{ResolveDocumentOutput, ResolveDocumentRequest}, 17}; 18use weaver_api::sh_weaver::notebook::{BookEntryView, EntryView}; 19use weaver_api::site_standard::document::Document; 20 21use crate::clickhouse::{DocumentRow, ProfileRow, PublicationRow}; 22use crate::endpoints::actor::resolve_actor; 23use crate::endpoints::notebook::{ 24 build_entry_view_with_authors, hydrate_authors, parse_record_json, 25}; 26use crate::endpoints::repo::XrpcErrorResponse; 27use crate::server::AppState; 28 29/// Handle sh.weaver.domain.resolveByDomain 30/// 31/// Resolves a publication by its custom domain. 32pub async fn resolve_by_domain( 33 State(state): State<AppState>, 34 ExtractXrpc(args): ExtractXrpc<ResolveByDomainRequest>, 35) -> Result<Json<ResolveByDomainOutput<'static>>, XrpcErrorResponse> { 36 let domain = args.domain.as_ref(); 37 38 let custom_domain = state 39 .clickhouse 40 .get_publication_by_domain(domain) 41 .await 42 .map_err(|e| { 43 tracing::error!("Failed to lookup domain: {}", e); 44 XrpcErrorResponse::internal_error("Database query failed") 45 })? 46 .ok_or_else(|| XrpcErrorResponse::not_found("Domain not found"))?; 47 48 // Fetch full publication record 49 let pub_row = state 50 .clickhouse 51 .get_publication( 52 &custom_domain.publication_did, 53 &custom_domain.publication_rkey, 54 ) 55 .await 56 .map_err(|e| { 57 tracing::error!("Failed to get publication: {}", e); 58 XrpcErrorResponse::internal_error("Database query failed") 59 })? 60 .ok_or_else(|| XrpcErrorResponse::not_found("Publication not found"))?; 61 62 let publication = build_publication_view(&pub_row)?; 63 64 Ok(Json( 65 ResolveByDomainOutput { 66 publication, 67 extra_data: None, 68 } 69 .into_static(), 70 )) 71} 72 73/// Handle sh.weaver.domain.resolveDocument 74/// 75/// Resolves a document by path within a publication. 76pub async fn resolve_document( 77 State(state): State<AppState>, 78 ExtractXrpc(args): ExtractXrpc<ResolveDocumentRequest>, 79) -> Result<Json<ResolveDocumentOutput<'static>>, XrpcErrorResponse> { 80 // Parse publication URI 81 let pub_uri = &args.publication; 82 let pub_authority = pub_uri.authority(); 83 let pub_rkey = pub_uri 84 .rkey() 85 .ok_or_else(|| XrpcErrorResponse::invalid_request("Publication URI must include rkey"))?; 86 87 // Resolve authority to DID 88 let pub_did = crate::endpoints::actor::resolve_actor(&state, pub_authority).await?; 89 let pub_did_str = pub_did.as_str(); 90 let pub_rkey_str = pub_rkey.as_ref(); 91 92 // Verify publication exists 93 let _pub_row = state 94 .clickhouse 95 .get_publication(pub_did_str, pub_rkey_str) 96 .await 97 .map_err(|e| { 98 tracing::error!("Failed to get publication: {}", e); 99 XrpcErrorResponse::internal_error("Database query failed") 100 })? 101 .ok_or_else(|| XrpcErrorResponse::not_found("Publication not found"))?; 102 103 // Resolve document by path 104 let path = args.path.as_ref(); 105 let doc_row = state 106 .clickhouse 107 .resolve_document_by_path(pub_did_str, pub_rkey_str, path) 108 .await 109 .map_err(|e| { 110 tracing::error!("Failed to resolve document: {}", e); 111 XrpcErrorResponse::internal_error("Database query failed") 112 })? 113 .ok_or_else(|| XrpcErrorResponse::not_found("Document not found"))?; 114 115 let document = build_document_view(&doc_row)?; 116 117 Ok(Json( 118 ResolveDocumentOutput { 119 document, 120 extra_data: None, 121 } 122 .into_static(), 123 )) 124} 125 126/// Build a PublicationView from a PublicationRow. 127fn build_publication_view( 128 row: &PublicationRow, 129) -> Result<PublicationView<'static>, XrpcErrorResponse> { 130 let uri_str = format!("at://{}/site.standard.publication/{}", row.did, row.rkey); 131 let uri = AtUri::new(&uri_str).map_err(|e| { 132 tracing::error!("Invalid publication URI: {}", e); 133 XrpcErrorResponse::internal_error("Invalid URI") 134 })?; 135 136 let cid = Cid::new(row.cid.as_bytes()).map_err(|e| { 137 tracing::error!("Invalid publication CID: {}", e); 138 XrpcErrorResponse::internal_error("Invalid CID") 139 })?; 140 141 let did = Did::new(&row.did).map_err(|e| { 142 tracing::error!("Invalid publication DID: {}", e); 143 XrpcErrorResponse::internal_error("Invalid DID") 144 })?; 145 146 let record = parse_record_json(&row.record)?; 147 148 let notebook_uri = if row.notebook_uri.is_empty() { 149 None 150 } else { 151 AtUri::new(&row.notebook_uri).ok() 152 }; 153 154 Ok(PublicationView::new() 155 .uri(uri.into_static()) 156 .cid(cid.into_static()) 157 .did(did.into_static()) 158 .rkey(row.rkey.to_cowstr().into_static()) 159 .name(row.name.to_cowstr().into_static()) 160 .domain(row.domain.to_cowstr().into_static()) 161 .record(record) 162 .indexed_at(row.indexed_at.fixed_offset()) 163 .maybe_notebook_uri(notebook_uri.map(|u| u.into_static())) 164 .build()) 165} 166 167/// Build a DocumentView from a DocumentRow. 168fn build_document_view(row: &DocumentRow) -> Result<DocumentView<'static>, XrpcErrorResponse> { 169 let uri_str = format!("at://{}/site.standard.document/{}", row.did, row.rkey); 170 let uri = AtUri::new(&uri_str).map_err(|e| { 171 tracing::error!("Invalid document URI: {}", e); 172 XrpcErrorResponse::internal_error("Invalid URI") 173 })?; 174 175 let cid = Cid::new(row.cid.as_bytes()).map_err(|e| { 176 tracing::error!("Invalid document CID: {}", e); 177 XrpcErrorResponse::internal_error("Invalid CID") 178 })?; 179 180 let did = Did::new(&row.did).map_err(|e| { 181 tracing::error!("Invalid document DID: {}", e); 182 XrpcErrorResponse::internal_error("Invalid DID") 183 })?; 184 185 let record = parse_record_json(&row.record)?; 186 187 let entry_uri = if row.entry_uri.is_empty() { 188 None 189 } else { 190 AtUri::new(&row.entry_uri).ok() 191 }; 192 193 let entry_index = if row.entry_index >= 0 { 194 Some(row.entry_index) 195 } else { 196 None 197 }; 198 199 Ok(DocumentView::new() 200 .uri(uri.into_static()) 201 .cid(cid.into_static()) 202 .did(did.into_static()) 203 .rkey(row.rkey.to_cowstr().into_static()) 204 .title(row.title.to_cowstr().into_static()) 205 .path(row.path.to_cowstr().into_static()) 206 .record(record) 207 .indexed_at(row.indexed_at.fixed_offset()) 208 .maybe_entry_uri(entry_uri.map(|u| u.into_static())) 209 .maybe_entry_index(entry_index) 210 .build()) 211} 212 213/// Handle sh.weaver.domain.generateDocument 214/// 215/// Generates a site.standard.document record from a weaver entry. 216/// Returns a ready-to-write record with fully hydrated BookEntryView in content. 217pub async fn generate_document( 218 State(state): State<AppState>, 219 ExtractXrpc(args): ExtractXrpc<GenerateDocumentRequest>, 220) -> Result<Json<GenerateDocumentOutput<'static>>, XrpcErrorResponse> { 221 // Parse entry URI 222 let entry_uri = &args.entry; 223 let entry_authority = entry_uri.authority(); 224 let entry_rkey = entry_uri 225 .rkey() 226 .ok_or_else(|| XrpcErrorResponse::invalid_request("Entry URI must include rkey"))?; 227 228 // Resolve entry authority to DID 229 let entry_did = resolve_actor(&state, entry_authority).await?; 230 let entry_did_str = entry_did.as_str(); 231 let entry_rkey_str = entry_rkey.as_ref(); 232 233 // Parse publication URI 234 let pub_uri = &args.publication; 235 let pub_authority = pub_uri.authority(); 236 let pub_rkey = pub_uri 237 .rkey() 238 .ok_or_else(|| XrpcErrorResponse::invalid_request("Publication URI must include rkey"))?; 239 240 // Resolve publication authority to DID 241 let pub_did = resolve_actor(&state, pub_authority).await?; 242 let pub_did_str = pub_did.as_str(); 243 let pub_rkey_str = pub_rkey.as_ref(); 244 245 // Verify publication exists and get notebook info 246 let pub_row = state 247 .clickhouse 248 .get_publication(pub_did_str, pub_rkey_str) 249 .await 250 .map_err(|e| { 251 tracing::error!("Failed to get publication: {}", e); 252 XrpcErrorResponse::internal_error("Database query failed") 253 })? 254 .ok_or_else(|| publication_not_found("Publication not found"))?; 255 256 // Check that publication is linked to a notebook 257 if pub_row.notebook_uri.is_empty() { 258 return Err(notebook_not_linked( 259 "Publication is not linked to a notebook", 260 )); 261 } 262 263 // Get evidence-based contributors for this entry (same as all other entry endpoints). 264 let entry_contributors = state 265 .clickhouse 266 .get_entry_contributors(entry_did_str, entry_rkey_str) 267 .await 268 .map_err(|e| { 269 tracing::error!("Failed to get entry contributors: {}", e); 270 XrpcErrorResponse::internal_error("Database query failed") 271 })?; 272 273 // Get entry - either from inline record or from index 274 let (entry_view, entry_record) = if let Some(ref inline_record) = args.entry_record { 275 // Use inline record directly - build an EntryView from the Data 276 build_entry_view_from_data( 277 &entry_did, 278 entry_rkey_str, 279 inline_record.clone(), 280 &entry_contributors, 281 &state, 282 ) 283 .await? 284 } else { 285 // Fetch entry from index 286 let entry_row = state 287 .clickhouse 288 .get_entry_exact(entry_did_str, entry_rkey_str) 289 .await 290 .map_err(|e| { 291 tracing::error!("Failed to get entry: {}", e); 292 XrpcErrorResponse::internal_error("Database query failed") 293 })? 294 .ok_or_else(|| entry_not_found("Entry not found"))?; 295 296 // Merge evidence-based contributors with record's authorDids 297 let mut all_author_dids: HashSet<smol_str::SmolStr> = 298 entry_contributors.iter().cloned().collect(); 299 for did in &entry_row.author_dids { 300 all_author_dids.insert(did.clone()); 301 } 302 let merged_authors: Vec<smol_str::SmolStr> = all_author_dids.into_iter().collect(); 303 let author_dids_vec: Vec<&str> = merged_authors.iter().map(|s| s.as_str()).collect(); 304 305 let profiles = state 306 .clickhouse 307 .get_profiles_batch(&author_dids_vec) 308 .await 309 .map_err(|e| { 310 tracing::error!("Failed to fetch profiles: {}", e); 311 XrpcErrorResponse::internal_error("Database query failed") 312 })?; 313 let profile_map: HashMap<&str, &ProfileRow> = 314 profiles.iter().map(|p| (p.did.as_str(), p)).collect(); 315 316 // Use merged authors (contributors + explicit) 317 let entry_view = 318 build_entry_view_with_authors(&entry_row, &merged_authors, &profile_map)?; 319 let entry_record = parse_record_json(&entry_row.record)?; 320 (entry_view, entry_record) 321 }; 322 323 // Extract title and path from entry (before entry_view is consumed). 324 let title = entry_view 325 .title 326 .as_ref() 327 .map(|t| t.as_ref().to_string()) 328 .unwrap_or_else(|| "Untitled".to_string()); 329 330 let entry_path = entry_view.path.as_ref().map(|p| p.to_string()); 331 332 // Try to extract description from the entry record (extract content summary) 333 let description = extract_description_from_entry(&entry_record); 334 335 // Get the entry index within the notebook. 336 let entry_index = 337 get_entry_index_in_notebook(&state, &pub_row.notebook_uri, entry_did_str, entry_rkey_str) 338 .await 339 .unwrap_or_else(|| { 340 tracing::warn!( 341 entry_did = %entry_did_str, 342 entry_rkey = %entry_rkey_str, 343 notebook_uri = %pub_row.notebook_uri, 344 "Could not determine entry index, defaulting to 0" 345 ); 346 0 347 }); 348 349 // Build BookEntryView (without prev/next for now - caller can add if needed) 350 let book_entry = BookEntryView::new() 351 .entry(entry_view) 352 .index(entry_index) 353 .build(); 354 355 // Serialize BookEntryView to JSON string, then parse as Data 356 let content_json = serde_json::to_string(&book_entry).map_err(|e| { 357 tracing::error!("Failed to serialize BookEntryView: {}", e); 358 XrpcErrorResponse::internal_error("Failed to serialize content") 359 })?; 360 let content_data: Data<'_> = serde_json::from_str(&content_json).map_err(|e| { 361 tracing::error!("Failed to parse content as Data: {}", e); 362 XrpcErrorResponse::internal_error("Failed to convert to Data") 363 })?; 364 let content_data = content_data.into_static(); 365 366 // Use provided path, or fall back to entry's path. 367 let path = args 368 .path 369 .clone() 370 .or_else(|| entry_path.map(|p| p.into())) 371 .unwrap_or_else(|| "untitled".into()); 372 373 // Build the site.standard.document record 374 let document = Document::new() 375 .site(Uri::new(args.publication.as_str()).map_err(|e| { 376 tracing::error!("Invalid publication URI: {}", e); 377 XrpcErrorResponse::internal_error("Invalid publication URI") 378 })?) 379 .title(title) 380 .published_at(chrono::Utc::now().fixed_offset()) 381 .path(Some(path)) 382 .content(content_data) 383 .maybe_description(description.map(|d| d.into())) 384 .build(); 385 386 Ok(Json( 387 GenerateDocumentOutput { 388 record: document.into_static(), 389 extra_data: None, 390 } 391 .into_static(), 392 )) 393} 394 395/// Build an EntryView from inline Data (when entry_record is provided). 396async fn build_entry_view_from_data( 397 entry_did: &Did<'_>, 398 entry_rkey: &str, 399 entry_record: Data<'_>, 400 entry_contributors: &[smol_str::SmolStr], 401 state: &AppState, 402) -> Result<(EntryView<'static>, Data<'static>), XrpcErrorResponse> { 403 // Merge evidence-based contributors with record's authorDids (dedupe). 404 let mut all_author_dids: HashSet<String> = entry_contributors 405 .iter() 406 .map(|s| s.to_string()) 407 .collect(); 408 // Add authorDids from record if present, otherwise add entry owner. 409 if let Some(record_authors) = extract_author_dids(&entry_record) { 410 for did in record_authors { 411 all_author_dids.insert(did); 412 } 413 } 414 // Always include entry owner as fallback. 415 all_author_dids.insert(entry_did.as_str().to_string()); 416 417 // Fetch profiles for all authors. 418 let author_dids_ref: Vec<&str> = all_author_dids.iter().map(|s| s.as_str()).collect(); 419 let profiles = state 420 .clickhouse 421 .get_profiles_batch(&author_dids_ref) 422 .await 423 .map_err(|e| { 424 tracing::error!("Failed to fetch profiles: {}", e); 425 XrpcErrorResponse::internal_error("Database query failed") 426 })?; 427 428 let profile_map: HashMap<&str, &ProfileRow> = 429 profiles.iter().map(|p| (p.did.as_str(), p)).collect(); 430 431 // Use merged set: evidence-based contributors + explicit authors from record. 432 let merged_authors: Vec<smol_str::SmolStr> = all_author_dids 433 .iter() 434 .map(|s| smol_str::SmolStr::new(s)) 435 .collect(); 436 let authors = hydrate_authors(&merged_authors, &profile_map)?; 437 438 // Extract title and path from record using pattern matching on Data 439 let (title, path) = extract_title_and_path(&entry_record); 440 441 // Build URI 442 let uri_str = format!( 443 "at://{}/sh.weaver.notebook.entry/{}", 444 entry_did.as_str(), 445 entry_rkey 446 ); 447 let uri = AtUri::new(&uri_str).map_err(|e| { 448 tracing::error!("Invalid entry URI: {}", e); 449 XrpcErrorResponse::internal_error("Invalid entry URI") 450 })?; 451 452 // Use a placeholder CID since we're building from inline data. 453 // This is a valid CIDv1 with identity codec (bafkrei prefix) and 32 'a' chars. 454 let placeholder_cid = 455 Cid::str("bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); 456 457 // Build entry view 458 let mut builder = EntryView::new() 459 .uri(uri.into_static()) 460 .cid(placeholder_cid.into_static()) 461 .authors(authors) 462 .record(entry_record.clone().into_static()) 463 .indexed_at(chrono::Utc::now().fixed_offset()); 464 465 if let Some(t) = title { 466 builder = builder.title(jacquard::CowStr::from(t)); 467 } 468 if let Some(p) = path { 469 builder = builder.path(jacquard::CowStr::from(p)); 470 } 471 472 Ok((builder.build(), entry_record.into_static())) 473} 474 475/// Extract title and path from a Data record using pattern matching. 476fn extract_title_and_path(entry_record: &Data<'_>) -> (Option<String>, Option<String>) { 477 use jacquard::types::value::Object; 478 479 if let Data::Object(Object(map)) = entry_record { 480 let title = map.get("title").and_then(|v| { 481 if let Data::String(s) = v { 482 Some(s.as_ref().to_string()) 483 } else { 484 None 485 } 486 }); 487 let path = map.get("path").and_then(|v| { 488 if let Data::String(s) = v { 489 Some(s.as_ref().to_string()) 490 } else { 491 None 492 } 493 }); 494 (title, path) 495 } else { 496 (None, None) 497 } 498} 499 500/// Extract authorDids from a Data record using pattern matching. 501fn extract_author_dids(entry_record: &Data<'_>) -> Option<Vec<String>> { 502 use jacquard::types::value::Object; 503 504 if let Data::Object(Object(map)) = entry_record { 505 map.get("authorDids").and_then(|v| { 506 if let Data::Array(arr) = v { 507 let dids: Vec<String> = arr 508 .iter() 509 .filter_map(|item| { 510 if let Data::String(s) = item { 511 Some(s.as_ref().to_string()) 512 } else { 513 None 514 } 515 }) 516 .collect(); 517 if dids.is_empty() { None } else { Some(dids) } 518 } else { 519 None 520 } 521 }) 522 } else { 523 None 524 } 525} 526 527/// Extract a description from an entry record (first ~300 chars of content). 528fn extract_description_from_entry(entry_record: &Data<'_>) -> Option<String> { 529 use jacquard::types::value::Object; 530 531 if let Data::Object(Object(map)) = entry_record { 532 map.get("content").and_then(|v| { 533 if let Data::String(s) = v { 534 let content = s.as_ref(); 535 // Take first 300 chars, break at word boundary 536 let trimmed: String = content.chars().take(300).collect(); 537 if content.len() > 300 { 538 // Find last space to break at word boundary 539 if let Some(last_space) = trimmed.rfind(' ') { 540 Some(format!("{}...", &trimmed[..last_space])) 541 } else { 542 Some(format!("{}...", trimmed)) 543 } 544 } else { 545 Some(trimmed) 546 } 547 } else { 548 None 549 } 550 }) 551 } else { 552 None 553 } 554} 555 556/// Get the index of an entry within a notebook. 557async fn get_entry_index_in_notebook( 558 state: &AppState, 559 notebook_uri: &str, 560 entry_did: &str, 561 entry_rkey: &str, 562) -> Option<i64> { 563 // Parse notebook URI to get DID and rkey 564 let notebook_at_uri = AtUri::new(notebook_uri).ok()?; 565 let notebook_did = notebook_at_uri.authority(); 566 let notebook_rkey = notebook_at_uri.rkey()?; 567 568 // Resolve notebook DID if it's a handle 569 let notebook_did_resolved = resolve_actor(state, notebook_did).await.ok()?; 570 571 // Get entry index from ClickHouse 572 let index = state 573 .clickhouse 574 .get_entry_index_in_notebook( 575 notebook_did_resolved.as_str(), 576 notebook_rkey.as_ref(), 577 entry_did, 578 entry_rkey, 579 ) 580 .await 581 .ok() 582 .flatten(); 583 584 index.map(|i| i as i64) 585} 586 587// === Custom error constructors for generateDocument === 588 589fn publication_not_found(message: impl Into<String>) -> XrpcErrorResponse { 590 XrpcErrorResponse { 591 status: axum::http::StatusCode::NOT_FOUND, 592 error: "PublicationNotFound".to_string(), 593 message: Some(message.into()), 594 } 595} 596 597fn entry_not_found(message: impl Into<String>) -> XrpcErrorResponse { 598 XrpcErrorResponse { 599 status: axum::http::StatusCode::NOT_FOUND, 600 error: "EntryNotFound".to_string(), 601 message: Some(message.into()), 602 } 603} 604 605fn notebook_not_linked(message: impl Into<String>) -> XrpcErrorResponse { 606 XrpcErrorResponse { 607 status: axum::http::StatusCode::BAD_REQUEST, 608 error: "NotebookNotLinked".to_string(), 609 message: Some(message.into()), 610 } 611} 612 613// === Internal Caddy Verification Endpoint === 614 615#[derive(Debug, Deserialize)] 616pub struct VerifyDomainQuery { 617 pub domain: String, 618} 619 620#[derive(Debug, Serialize)] 621#[serde(rename_all = "camelCase")] 622pub struct VerifyDomainResponse { 623 pub valid: bool, 624 pub publication_uri: Option<String>, 625} 626 627/// Internal endpoint for Caddy on-demand TLS verification. 628pub async fn verify_domain( 629 State(state): State<AppState>, 630 axum::extract::Query(params): axum::extract::Query<VerifyDomainQuery>, 631) -> Result<Json<VerifyDomainResponse>, XrpcErrorResponse> { 632 let domain = &params.domain; 633 tracing::info!(%domain, "Verifying custom domain for TLS"); 634 635 let row = state 636 .clickhouse 637 .get_publication_by_domain(domain) 638 .await 639 .map_err(|e| { 640 tracing::error!(%domain, error = %e, "Database error"); 641 XrpcErrorResponse::internal_error("Database query failed") 642 })?; 643 644 match row { 645 Some(r) => { 646 let uri = format!( 647 "at://{}/site.standard.publication/{}", 648 r.publication_did, r.publication_rkey 649 ); 650 tracing::info!(%domain, %uri, "Domain verified"); 651 Ok(Json(VerifyDomainResponse { 652 valid: true, 653 publication_uri: Some(uri), 654 })) 655 } 656 None => { 657 tracing::info!(%domain, "Domain not found"); 658 Ok(Json(VerifyDomainResponse { 659 valid: false, 660 publication_uri: None, 661 })) 662 } 663 } 664}