//! sh.weaver.domain.* endpoint handlers use std::collections::{HashMap, HashSet}; use axum::{Json, extract::State}; use jacquard::IntoStatic; use jacquard::cowstr::ToCowStr; use jacquard::types::string::{AtUri, Cid, Did, Uri}; use jacquard::types::value::Data; use jacquard_axum::ExtractXrpc; use serde::{Deserialize, Serialize}; use weaver_api::sh_weaver::domain::{ DocumentView, PublicationView, generate_document::{GenerateDocumentOutput, GenerateDocumentRequest}, resolve_by_domain::{ResolveByDomainOutput, ResolveByDomainRequest}, resolve_document::{ResolveDocumentOutput, ResolveDocumentRequest}, }; use weaver_api::sh_weaver::notebook::{BookEntryView, EntryView}; use weaver_api::site_standard::document::Document; use crate::clickhouse::{DocumentRow, ProfileRow, PublicationRow}; use crate::endpoints::actor::resolve_actor; use crate::endpoints::notebook::{ build_entry_view_with_authors, hydrate_authors, parse_record_json, }; use crate::endpoints::repo::XrpcErrorResponse; use crate::server::AppState; /// Handle sh.weaver.domain.resolveByDomain /// /// Resolves a publication by its custom domain. pub async fn resolve_by_domain( State(state): State, ExtractXrpc(args): ExtractXrpc, ) -> Result>, XrpcErrorResponse> { let domain = args.domain.as_ref(); let custom_domain = state .clickhouse .get_publication_by_domain(domain) .await .map_err(|e| { tracing::error!("Failed to lookup domain: {}", e); XrpcErrorResponse::internal_error("Database query failed") })? .ok_or_else(|| XrpcErrorResponse::not_found("Domain not found"))?; // Fetch full publication record let pub_row = state .clickhouse .get_publication( &custom_domain.publication_did, &custom_domain.publication_rkey, ) .await .map_err(|e| { tracing::error!("Failed to get publication: {}", e); XrpcErrorResponse::internal_error("Database query failed") })? .ok_or_else(|| XrpcErrorResponse::not_found("Publication not found"))?; let publication = build_publication_view(&pub_row)?; Ok(Json( ResolveByDomainOutput { publication, extra_data: None, } .into_static(), )) } /// Handle sh.weaver.domain.resolveDocument /// /// Resolves a document by path within a publication. pub async fn resolve_document( State(state): State, ExtractXrpc(args): ExtractXrpc, ) -> Result>, XrpcErrorResponse> { // Parse publication URI let pub_uri = &args.publication; let pub_authority = pub_uri.authority(); let pub_rkey = pub_uri .rkey() .ok_or_else(|| XrpcErrorResponse::invalid_request("Publication URI must include rkey"))?; // Resolve authority to DID let pub_did = crate::endpoints::actor::resolve_actor(&state, pub_authority).await?; let pub_did_str = pub_did.as_str(); let pub_rkey_str = pub_rkey.as_ref(); // Verify publication exists let _pub_row = state .clickhouse .get_publication(pub_did_str, pub_rkey_str) .await .map_err(|e| { tracing::error!("Failed to get publication: {}", e); XrpcErrorResponse::internal_error("Database query failed") })? .ok_or_else(|| XrpcErrorResponse::not_found("Publication not found"))?; // Resolve document by path let path = args.path.as_ref(); let doc_row = state .clickhouse .resolve_document_by_path(pub_did_str, pub_rkey_str, path) .await .map_err(|e| { tracing::error!("Failed to resolve document: {}", e); XrpcErrorResponse::internal_error("Database query failed") })? .ok_or_else(|| XrpcErrorResponse::not_found("Document not found"))?; let document = build_document_view(&doc_row)?; Ok(Json( ResolveDocumentOutput { document, extra_data: None, } .into_static(), )) } /// Build a PublicationView from a PublicationRow. fn build_publication_view( row: &PublicationRow, ) -> Result, XrpcErrorResponse> { let uri_str = format!("at://{}/site.standard.publication/{}", row.did, row.rkey); let uri = AtUri::new(&uri_str).map_err(|e| { tracing::error!("Invalid publication URI: {}", e); XrpcErrorResponse::internal_error("Invalid URI") })?; let cid = Cid::new(row.cid.as_bytes()).map_err(|e| { tracing::error!("Invalid publication CID: {}", e); XrpcErrorResponse::internal_error("Invalid CID") })?; let did = Did::new(&row.did).map_err(|e| { tracing::error!("Invalid publication DID: {}", e); XrpcErrorResponse::internal_error("Invalid DID") })?; let record = parse_record_json(&row.record)?; let notebook_uri = if row.notebook_uri.is_empty() { None } else { AtUri::new(&row.notebook_uri).ok() }; Ok(PublicationView::new() .uri(uri.into_static()) .cid(cid.into_static()) .did(did.into_static()) .rkey(row.rkey.to_cowstr().into_static()) .name(row.name.to_cowstr().into_static()) .domain(row.domain.to_cowstr().into_static()) .record(record) .indexed_at(row.indexed_at.fixed_offset()) .maybe_notebook_uri(notebook_uri.map(|u| u.into_static())) .build()) } /// Build a DocumentView from a DocumentRow. fn build_document_view(row: &DocumentRow) -> Result, XrpcErrorResponse> { let uri_str = format!("at://{}/site.standard.document/{}", row.did, row.rkey); let uri = AtUri::new(&uri_str).map_err(|e| { tracing::error!("Invalid document URI: {}", e); XrpcErrorResponse::internal_error("Invalid URI") })?; let cid = Cid::new(row.cid.as_bytes()).map_err(|e| { tracing::error!("Invalid document CID: {}", e); XrpcErrorResponse::internal_error("Invalid CID") })?; let did = Did::new(&row.did).map_err(|e| { tracing::error!("Invalid document DID: {}", e); XrpcErrorResponse::internal_error("Invalid DID") })?; let record = parse_record_json(&row.record)?; let entry_uri = if row.entry_uri.is_empty() { None } else { AtUri::new(&row.entry_uri).ok() }; let entry_index = if row.entry_index >= 0 { Some(row.entry_index) } else { None }; Ok(DocumentView::new() .uri(uri.into_static()) .cid(cid.into_static()) .did(did.into_static()) .rkey(row.rkey.to_cowstr().into_static()) .title(row.title.to_cowstr().into_static()) .path(row.path.to_cowstr().into_static()) .record(record) .indexed_at(row.indexed_at.fixed_offset()) .maybe_entry_uri(entry_uri.map(|u| u.into_static())) .maybe_entry_index(entry_index) .build()) } /// Handle sh.weaver.domain.generateDocument /// /// Generates a site.standard.document record from a weaver entry. /// Returns a ready-to-write record with fully hydrated BookEntryView in content. pub async fn generate_document( State(state): State, ExtractXrpc(args): ExtractXrpc, ) -> Result>, XrpcErrorResponse> { // Parse entry URI let entry_uri = &args.entry; let entry_authority = entry_uri.authority(); let entry_rkey = entry_uri .rkey() .ok_or_else(|| XrpcErrorResponse::invalid_request("Entry URI must include rkey"))?; // Resolve entry authority to DID let entry_did = resolve_actor(&state, entry_authority).await?; let entry_did_str = entry_did.as_str(); let entry_rkey_str = entry_rkey.as_ref(); // Parse publication URI let pub_uri = &args.publication; let pub_authority = pub_uri.authority(); let pub_rkey = pub_uri .rkey() .ok_or_else(|| XrpcErrorResponse::invalid_request("Publication URI must include rkey"))?; // Resolve publication authority to DID let pub_did = resolve_actor(&state, pub_authority).await?; let pub_did_str = pub_did.as_str(); let pub_rkey_str = pub_rkey.as_ref(); // Verify publication exists and get notebook info let pub_row = state .clickhouse .get_publication(pub_did_str, pub_rkey_str) .await .map_err(|e| { tracing::error!("Failed to get publication: {}", e); XrpcErrorResponse::internal_error("Database query failed") })? .ok_or_else(|| publication_not_found("Publication not found"))?; // Check that publication is linked to a notebook if pub_row.notebook_uri.is_empty() { return Err(notebook_not_linked( "Publication is not linked to a notebook", )); } // Get evidence-based contributors for this entry (same as all other entry endpoints). let entry_contributors = state .clickhouse .get_entry_contributors(entry_did_str, entry_rkey_str) .await .map_err(|e| { tracing::error!("Failed to get entry contributors: {}", e); XrpcErrorResponse::internal_error("Database query failed") })?; // Get entry - either from inline record or from index let (entry_view, entry_record) = if let Some(ref inline_record) = args.entry_record { // Use inline record directly - build an EntryView from the Data build_entry_view_from_data( &entry_did, entry_rkey_str, inline_record.clone(), &entry_contributors, &state, ) .await? } else { // Fetch entry from index let entry_row = state .clickhouse .get_entry_exact(entry_did_str, entry_rkey_str) .await .map_err(|e| { tracing::error!("Failed to get entry: {}", e); XrpcErrorResponse::internal_error("Database query failed") })? .ok_or_else(|| entry_not_found("Entry not found"))?; // Merge evidence-based contributors with record's authorDids let mut all_author_dids: HashSet = entry_contributors.iter().cloned().collect(); for did in &entry_row.author_dids { all_author_dids.insert(did.clone()); } let merged_authors: Vec = all_author_dids.into_iter().collect(); let author_dids_vec: Vec<&str> = merged_authors.iter().map(|s| s.as_str()).collect(); let profiles = state .clickhouse .get_profiles_batch(&author_dids_vec) .await .map_err(|e| { tracing::error!("Failed to fetch profiles: {}", e); XrpcErrorResponse::internal_error("Database query failed") })?; let profile_map: HashMap<&str, &ProfileRow> = profiles.iter().map(|p| (p.did.as_str(), p)).collect(); // Use merged authors (contributors + explicit) let entry_view = build_entry_view_with_authors(&entry_row, &merged_authors, &profile_map)?; let entry_record = parse_record_json(&entry_row.record)?; (entry_view, entry_record) }; // Extract title and path from entry (before entry_view is consumed). let title = entry_view .title .as_ref() .map(|t| t.as_ref().to_string()) .unwrap_or_else(|| "Untitled".to_string()); let entry_path = entry_view.path.as_ref().map(|p| p.to_string()); // Try to extract description from the entry record (extract content summary) let description = extract_description_from_entry(&entry_record); // Get the entry index within the notebook. let entry_index = get_entry_index_in_notebook(&state, &pub_row.notebook_uri, entry_did_str, entry_rkey_str) .await .unwrap_or_else(|| { tracing::warn!( entry_did = %entry_did_str, entry_rkey = %entry_rkey_str, notebook_uri = %pub_row.notebook_uri, "Could not determine entry index, defaulting to 0" ); 0 }); // Build BookEntryView (without prev/next for now - caller can add if needed) let book_entry = BookEntryView::new() .entry(entry_view) .index(entry_index) .build(); // Serialize BookEntryView to JSON string, then parse as Data let content_json = serde_json::to_string(&book_entry).map_err(|e| { tracing::error!("Failed to serialize BookEntryView: {}", e); XrpcErrorResponse::internal_error("Failed to serialize content") })?; let content_data: Data<'_> = serde_json::from_str(&content_json).map_err(|e| { tracing::error!("Failed to parse content as Data: {}", e); XrpcErrorResponse::internal_error("Failed to convert to Data") })?; let content_data = content_data.into_static(); // Use provided path, or fall back to entry's path. let path = args .path .clone() .or_else(|| entry_path.map(|p| p.into())) .unwrap_or_else(|| "untitled".into()); // Build the site.standard.document record let document = Document::new() .site(Uri::new(args.publication.as_str()).map_err(|e| { tracing::error!("Invalid publication URI: {}", e); XrpcErrorResponse::internal_error("Invalid publication URI") })?) .title(title) .published_at(chrono::Utc::now().fixed_offset()) .path(Some(path)) .content(content_data) .maybe_description(description.map(|d| d.into())) .build(); Ok(Json( GenerateDocumentOutput { record: document.into_static(), extra_data: None, } .into_static(), )) } /// Build an EntryView from inline Data (when entry_record is provided). async fn build_entry_view_from_data( entry_did: &Did<'_>, entry_rkey: &str, entry_record: Data<'_>, entry_contributors: &[smol_str::SmolStr], state: &AppState, ) -> Result<(EntryView<'static>, Data<'static>), XrpcErrorResponse> { // Merge evidence-based contributors with record's authorDids (dedupe). let mut all_author_dids: HashSet = entry_contributors .iter() .map(|s| s.to_string()) .collect(); // Add authorDids from record if present, otherwise add entry owner. if let Some(record_authors) = extract_author_dids(&entry_record) { for did in record_authors { all_author_dids.insert(did); } } // Always include entry owner as fallback. all_author_dids.insert(entry_did.as_str().to_string()); // Fetch profiles for all authors. let author_dids_ref: Vec<&str> = all_author_dids.iter().map(|s| s.as_str()).collect(); let profiles = state .clickhouse .get_profiles_batch(&author_dids_ref) .await .map_err(|e| { tracing::error!("Failed to fetch profiles: {}", e); XrpcErrorResponse::internal_error("Database query failed") })?; let profile_map: HashMap<&str, &ProfileRow> = profiles.iter().map(|p| (p.did.as_str(), p)).collect(); // Use merged set: evidence-based contributors + explicit authors from record. let merged_authors: Vec = all_author_dids .iter() .map(|s| smol_str::SmolStr::new(s)) .collect(); let authors = hydrate_authors(&merged_authors, &profile_map)?; // Extract title and path from record using pattern matching on Data let (title, path) = extract_title_and_path(&entry_record); // Build URI let uri_str = format!( "at://{}/sh.weaver.notebook.entry/{}", entry_did.as_str(), entry_rkey ); let uri = AtUri::new(&uri_str).map_err(|e| { tracing::error!("Invalid entry URI: {}", e); XrpcErrorResponse::internal_error("Invalid entry URI") })?; // Use a placeholder CID since we're building from inline data. // This is a valid CIDv1 with identity codec (bafkrei prefix) and 32 'a' chars. let placeholder_cid = Cid::str("bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); // Build entry view let mut builder = EntryView::new() .uri(uri.into_static()) .cid(placeholder_cid.into_static()) .authors(authors) .record(entry_record.clone().into_static()) .indexed_at(chrono::Utc::now().fixed_offset()); if let Some(t) = title { builder = builder.title(jacquard::CowStr::from(t)); } if let Some(p) = path { builder = builder.path(jacquard::CowStr::from(p)); } Ok((builder.build(), entry_record.into_static())) } /// Extract title and path from a Data record using pattern matching. fn extract_title_and_path(entry_record: &Data<'_>) -> (Option, Option) { use jacquard::types::value::Object; if let Data::Object(Object(map)) = entry_record { let title = map.get("title").and_then(|v| { if let Data::String(s) = v { Some(s.as_ref().to_string()) } else { None } }); let path = map.get("path").and_then(|v| { if let Data::String(s) = v { Some(s.as_ref().to_string()) } else { None } }); (title, path) } else { (None, None) } } /// Extract authorDids from a Data record using pattern matching. fn extract_author_dids(entry_record: &Data<'_>) -> Option> { use jacquard::types::value::Object; if let Data::Object(Object(map)) = entry_record { map.get("authorDids").and_then(|v| { if let Data::Array(arr) = v { let dids: Vec = arr .iter() .filter_map(|item| { if let Data::String(s) = item { Some(s.as_ref().to_string()) } else { None } }) .collect(); if dids.is_empty() { None } else { Some(dids) } } else { None } }) } else { None } } /// Extract a description from an entry record (first ~300 chars of content). fn extract_description_from_entry(entry_record: &Data<'_>) -> Option { use jacquard::types::value::Object; if let Data::Object(Object(map)) = entry_record { map.get("content").and_then(|v| { if let Data::String(s) = v { let content = s.as_ref(); // Take first 300 chars, break at word boundary let trimmed: String = content.chars().take(300).collect(); if content.len() > 300 { // Find last space to break at word boundary if let Some(last_space) = trimmed.rfind(' ') { Some(format!("{}...", &trimmed[..last_space])) } else { Some(format!("{}...", trimmed)) } } else { Some(trimmed) } } else { None } }) } else { None } } /// Get the index of an entry within a notebook. async fn get_entry_index_in_notebook( state: &AppState, notebook_uri: &str, entry_did: &str, entry_rkey: &str, ) -> Option { // Parse notebook URI to get DID and rkey let notebook_at_uri = AtUri::new(notebook_uri).ok()?; let notebook_did = notebook_at_uri.authority(); let notebook_rkey = notebook_at_uri.rkey()?; // Resolve notebook DID if it's a handle let notebook_did_resolved = resolve_actor(state, notebook_did).await.ok()?; // Get entry index from ClickHouse let index = state .clickhouse .get_entry_index_in_notebook( notebook_did_resolved.as_str(), notebook_rkey.as_ref(), entry_did, entry_rkey, ) .await .ok() .flatten(); index.map(|i| i as i64) } // === Custom error constructors for generateDocument === fn publication_not_found(message: impl Into) -> XrpcErrorResponse { XrpcErrorResponse { status: axum::http::StatusCode::NOT_FOUND, error: "PublicationNotFound".to_string(), message: Some(message.into()), } } fn entry_not_found(message: impl Into) -> XrpcErrorResponse { XrpcErrorResponse { status: axum::http::StatusCode::NOT_FOUND, error: "EntryNotFound".to_string(), message: Some(message.into()), } } fn notebook_not_linked(message: impl Into) -> XrpcErrorResponse { XrpcErrorResponse { status: axum::http::StatusCode::BAD_REQUEST, error: "NotebookNotLinked".to_string(), message: Some(message.into()), } } // === Internal Caddy Verification Endpoint === #[derive(Debug, Deserialize)] pub struct VerifyDomainQuery { pub domain: String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct VerifyDomainResponse { pub valid: bool, pub publication_uri: Option, } /// Internal endpoint for Caddy on-demand TLS verification. pub async fn verify_domain( State(state): State, axum::extract::Query(params): axum::extract::Query, ) -> Result, XrpcErrorResponse> { let domain = ¶ms.domain; tracing::info!(%domain, "Verifying custom domain for TLS"); let row = state .clickhouse .get_publication_by_domain(domain) .await .map_err(|e| { tracing::error!(%domain, error = %e, "Database error"); XrpcErrorResponse::internal_error("Database query failed") })?; match row { Some(r) => { let uri = format!( "at://{}/site.standard.publication/{}", r.publication_did, r.publication_rkey ); tracing::info!(%domain, %uri, "Domain verified"); Ok(Json(VerifyDomainResponse { valid: true, publication_uri: Some(uri), })) } None => { tracing::info!(%domain, "Domain not found"); Ok(Json(VerifyDomainResponse { valid: false, publication_uri: None, })) } } }