atproto blogging
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 = ¶ms.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}