1use anyhow::{anyhow, Result}; 2use serde::{de::DeserializeOwned, Deserialize, Serialize}; 3use tangled_config::session::Session; 4 5#[derive(Clone, Debug)] 6pub struct TangledClient { 7 base_url: String, 8} 9 10const REPO_CREATE: &str = "sh.tangled.repo.create"; 11 12impl Default for TangledClient { 13 fn default() -> Self { 14 Self::new("https://tngl.sh") 15 } 16} 17 18impl TangledClient { 19 pub fn new(base_url: impl Into<String>) -> Self { 20 Self { 21 base_url: base_url.into(), 22 } 23 } 24 25 fn xrpc_url(&self, method: &str) -> String { 26 format!("{}/xrpc/{}", self.base_url.trim_end_matches('/'), method) 27 } 28 29 async fn post_json<TReq: Serialize, TRes: DeserializeOwned>( 30 &self, 31 method: &str, 32 req: &TReq, 33 bearer: Option<&str>, 34 ) -> Result<TRes> { 35 let url = self.xrpc_url(method); 36 let client = reqwest::Client::new(); 37 let mut reqb = client 38 .post(url) 39 .header(reqwest::header::CONTENT_TYPE, "application/json"); 40 if let Some(token) = bearer { 41 reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token)); 42 } 43 let res = reqb.json(req).send().await?; 44 let status = res.status(); 45 if !status.is_success() { 46 let body = res.text().await.unwrap_or_default(); 47 return Err(anyhow!("{}: {}", status, body)); 48 } 49 Ok(res.json::<TRes>().await?) 50 } 51 52 async fn get_json<TRes: DeserializeOwned>( 53 &self, 54 method: &str, 55 params: &[(&str, String)], 56 bearer: Option<&str>, 57 ) -> Result<TRes> { 58 let url = self.xrpc_url(method); 59 let client = reqwest::Client::new(); 60 let mut reqb = client 61 .get(&url) 62 .query(&params) 63 .header(reqwest::header::ACCEPT, "application/json"); 64 if let Some(token) = bearer { 65 reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token)); 66 } 67 let res = reqb.send().await?; 68 let status = res.status(); 69 let body = res.text().await.unwrap_or_default(); 70 if !status.is_success() { 71 return Err(anyhow!("GET {} -> {}: {}", url, status, body)); 72 } 73 serde_json::from_str::<TRes>(&body).map_err(|e| { 74 let snippet = body.chars().take(300).collect::<String>(); 75 anyhow!( 76 "error decoding response from {}: {}\nBody (first 300 chars): {}", 77 url, 78 e, 79 snippet 80 ) 81 }) 82 } 83 84 pub async fn login_with_password( 85 &self, 86 handle: &str, 87 password: &str, 88 _pds: &str, 89 ) -> Result<Session> { 90 #[derive(Serialize)] 91 struct Req<'a> { 92 #[serde(rename = "identifier")] 93 identifier: &'a str, 94 #[serde(rename = "password")] 95 password: &'a str, 96 } 97 #[derive(Deserialize)] 98 struct Res { 99 #[serde(rename = "accessJwt")] 100 access_jwt: String, 101 #[serde(rename = "refreshJwt")] 102 refresh_jwt: String, 103 did: String, 104 handle: String, 105 } 106 let body = Req { 107 identifier: handle, 108 password, 109 }; 110 let res: Res = self 111 .post_json("com.atproto.server.createSession", &body, None) 112 .await?; 113 Ok(Session { 114 access_jwt: res.access_jwt, 115 refresh_jwt: res.refresh_jwt, 116 did: res.did, 117 handle: res.handle, 118 ..Default::default() 119 }) 120 } 121 122 pub async fn list_repos( 123 &self, 124 user: Option<&str>, 125 knot: Option<&str>, 126 starred: bool, 127 bearer: Option<&str>, 128 ) -> Result<Vec<Repository>> { 129 // NOTE: Repo listing is done via the user's PDS using com.atproto.repo.listRecords 130 // for the collection "sh.tangled.repo". This does not go through the Tangled API base. 131 // Here, `self.base_url` must be the PDS base (e.g., https://bsky.social). 132 // Resolve handle to DID if needed 133 let did = match user { 134 Some(u) if u.starts_with("did:") => u.to_string(), 135 Some(handle) => { 136 #[derive(Deserialize)] 137 struct Res { 138 did: String, 139 } 140 let params = [("handle", handle.to_string())]; 141 let res: Res = self 142 .get_json("com.atproto.identity.resolveHandle", &params, bearer) 143 .await?; 144 res.did 145 } 146 None => { 147 return Err(anyhow!( 148 "missing user for list_repos; provide handle or DID" 149 )); 150 } 151 }; 152 153 #[derive(Deserialize)] 154 struct RecordItem { 155 uri: String, 156 value: Repository, 157 } 158 #[derive(Deserialize)] 159 struct ListRes { 160 #[serde(default)] 161 records: Vec<RecordItem>, 162 } 163 164 let params = vec![ 165 ("repo", did), 166 ("collection", "sh.tangled.repo".to_string()), 167 ("limit", "100".to_string()), 168 ]; 169 170 let res: ListRes = self 171 .get_json("com.atproto.repo.listRecords", &params, bearer) 172 .await?; 173 let mut repos: Vec<Repository> = res 174 .records 175 .into_iter() 176 .map(|r| { 177 let mut val = r.value; 178 if val.rkey.is_none() { 179 if let Some(k) = Self::uri_rkey(&r.uri) { 180 val.rkey = Some(k); 181 } 182 } 183 if val.did.is_none() { 184 if let Some(d) = Self::uri_did(&r.uri) { 185 val.did = Some(d); 186 } 187 } 188 val 189 }) 190 .collect(); 191 // Apply optional filters client-side 192 if let Some(k) = knot { 193 repos.retain(|r| r.knot.as_deref().unwrap_or("") == k); 194 } 195 if starred { 196 // TODO: implement starred filtering when API is available. For now, no-op. 197 } 198 Ok(repos) 199 } 200 201 pub async fn create_repo(&self, opts: CreateRepoOptions<'_>) -> Result<()> { 202 // 1) Create the sh.tangled.repo record on the user's PDS 203 #[derive(Serialize)] 204 struct Record<'a> { 205 name: &'a str, 206 knot: &'a str, 207 #[serde(skip_serializing_if = "Option::is_none")] 208 description: Option<&'a str>, 209 #[serde(rename = "createdAt")] 210 created_at: String, 211 } 212 #[derive(Serialize)] 213 struct CreateRecordReq<'a> { 214 repo: &'a str, 215 collection: &'a str, 216 validate: bool, 217 record: Record<'a>, 218 } 219 #[derive(Deserialize)] 220 struct CreateRecordRes { 221 uri: String, 222 } 223 224 let now = chrono::Utc::now().to_rfc3339(); 225 let rec = Record { 226 name: opts.name, 227 knot: opts.knot, 228 description: opts.description, 229 created_at: now, 230 }; 231 let create_req = CreateRecordReq { 232 repo: opts.did, 233 collection: "sh.tangled.repo", 234 validate: true, 235 record: rec, 236 }; 237 238 let pds_client = TangledClient::new(opts.pds_base); 239 let created: CreateRecordRes = pds_client 240 .post_json( 241 "com.atproto.repo.createRecord", 242 &create_req, 243 Some(opts.access_jwt), 244 ) 245 .await?; 246 247 // Extract rkey from at-uri: at://did/collection/rkey 248 let rkey = created 249 .uri 250 .rsplit('/') 251 .next() 252 .ok_or_else(|| anyhow!("failed to parse rkey from uri"))?; 253 254 // 2) Obtain a service auth token for the Tangled server (aud = did:web:<host>) 255 let host = self 256 .base_url 257 .trim_end_matches('/') 258 .strip_prefix("https://") 259 .or_else(|| self.base_url.trim_end_matches('/').strip_prefix("http://")) 260 .ok_or_else(|| anyhow!("invalid base_url"))?; 261 let audience = format!("did:web:{}", host); 262 263 #[derive(Deserialize)] 264 struct GetSARes { 265 token: String, 266 } 267 let params = [ 268 ("aud", audience), 269 ("exp", (chrono::Utc::now().timestamp() + 600).to_string()), 270 ]; 271 let sa: GetSARes = pds_client 272 .get_json( 273 "com.atproto.server.getServiceAuth", 274 &params, 275 Some(opts.access_jwt), 276 ) 277 .await?; 278 279 // 3) Call sh.tangled.repo.create with the rkey 280 #[derive(Serialize)] 281 struct CreateRepoReq<'a> { 282 rkey: &'a str, 283 #[serde(skip_serializing_if = "Option::is_none")] 284 #[serde(rename = "defaultBranch")] 285 default_branch: Option<&'a str>, 286 #[serde(skip_serializing_if = "Option::is_none")] 287 source: Option<&'a str>, 288 } 289 let req = CreateRepoReq { 290 rkey, 291 default_branch: opts.default_branch, 292 source: opts.source, 293 }; 294 // No output expected on success 295 let _: serde_json::Value = self.post_json(REPO_CREATE, &req, Some(&sa.token)).await?; 296 Ok(()) 297 } 298 299 pub async fn get_repo_info( 300 &self, 301 owner: &str, 302 name: &str, 303 bearer: Option<&str>, 304 ) -> Result<RepoRecord> { 305 let did = if owner.starts_with("did:") { 306 owner.to_string() 307 } else { 308 #[derive(Deserialize)] 309 struct Res { 310 did: String, 311 } 312 let params = [("handle", owner.to_string())]; 313 let res: Res = self 314 .get_json("com.atproto.identity.resolveHandle", &params, bearer) 315 .await?; 316 res.did 317 }; 318 319 #[derive(Deserialize)] 320 struct RecordItem { 321 uri: String, 322 value: Repository, 323 } 324 #[derive(Deserialize)] 325 struct ListRes { 326 #[serde(default)] 327 records: Vec<RecordItem>, 328 } 329 let params = vec![ 330 ("repo", did.clone()), 331 ("collection", "sh.tangled.repo".to_string()), 332 ("limit", "100".to_string()), 333 ]; 334 let res: ListRes = self 335 .get_json("com.atproto.repo.listRecords", &params, bearer) 336 .await?; 337 for item in res.records { 338 if item.value.name == name { 339 let rkey = 340 Self::uri_rkey(&item.uri).ok_or_else(|| anyhow!("missing rkey in uri"))?; 341 let knot = item.value.knot.unwrap_or_default(); 342 return Ok(RepoRecord { 343 did: did.clone(), 344 name: name.to_string(), 345 rkey, 346 knot, 347 description: item.value.description, 348 }); 349 } 350 } 351 Err(anyhow!("repo not found for owner/name")) 352 } 353 354 pub async fn delete_repo( 355 &self, 356 did: &str, 357 name: &str, 358 pds_base: &str, 359 access_jwt: &str, 360 ) -> Result<()> { 361 let pds_client = TangledClient::new(pds_base); 362 let info = pds_client 363 .get_repo_info(did, name, Some(access_jwt)) 364 .await?; 365 366 #[derive(Serialize)] 367 struct DeleteRecordReq<'a> { 368 repo: &'a str, 369 collection: &'a str, 370 rkey: &'a str, 371 } 372 let del = DeleteRecordReq { 373 repo: did, 374 collection: "sh.tangled.repo", 375 rkey: &info.rkey, 376 }; 377 let _: serde_json::Value = pds_client 378 .post_json("com.atproto.repo.deleteRecord", &del, Some(access_jwt)) 379 .await?; 380 381 let host = self 382 .base_url 383 .trim_end_matches('/') 384 .strip_prefix("https://") 385 .or_else(|| self.base_url.trim_end_matches('/').strip_prefix("http://")) 386 .ok_or_else(|| anyhow!("invalid base_url"))?; 387 let audience = format!("did:web:{}", host); 388 #[derive(Deserialize)] 389 struct GetSARes { 390 token: String, 391 } 392 let params = [ 393 ("aud", audience), 394 ("exp", (chrono::Utc::now().timestamp() + 600).to_string()), 395 ]; 396 let sa: GetSARes = pds_client 397 .get_json( 398 "com.atproto.server.getServiceAuth", 399 &params, 400 Some(access_jwt), 401 ) 402 .await?; 403 404 #[derive(Serialize)] 405 struct DeleteReq<'a> { 406 did: &'a str, 407 name: &'a str, 408 rkey: &'a str, 409 } 410 let body = DeleteReq { 411 did, 412 name, 413 rkey: &info.rkey, 414 }; 415 let _: serde_json::Value = self 416 .post_json("sh.tangled.repo.delete", &body, Some(&sa.token)) 417 .await?; 418 Ok(()) 419 } 420 421 pub async fn update_repo_knot( 422 &self, 423 did: &str, 424 rkey: &str, 425 new_knot: &str, 426 pds_base: &str, 427 access_jwt: &str, 428 ) -> Result<()> { 429 let pds_client = TangledClient::new(pds_base); 430 #[derive(Deserialize, Serialize, Clone)] 431 struct Rec { 432 name: String, 433 knot: String, 434 #[serde(skip_serializing_if = "Option::is_none")] 435 description: Option<String>, 436 #[serde(rename = "createdAt")] 437 created_at: String, 438 } 439 #[derive(Deserialize)] 440 struct GetRes { 441 value: Rec, 442 } 443 let params = [ 444 ("repo", did.to_string()), 445 ("collection", "sh.tangled.repo".to_string()), 446 ("rkey", rkey.to_string()), 447 ]; 448 let got: GetRes = pds_client 449 .get_json("com.atproto.repo.getRecord", &params, Some(access_jwt)) 450 .await?; 451 let mut rec = got.value; 452 rec.knot = new_knot.to_string(); 453 #[derive(Serialize)] 454 struct PutReq<'a> { 455 repo: &'a str, 456 collection: &'a str, 457 rkey: &'a str, 458 validate: bool, 459 record: Rec, 460 } 461 let req = PutReq { 462 repo: did, 463 collection: "sh.tangled.repo", 464 rkey, 465 validate: true, 466 record: rec, 467 }; 468 let _: serde_json::Value = pds_client 469 .post_json("com.atproto.repo.putRecord", &req, Some(access_jwt)) 470 .await?; 471 Ok(()) 472 } 473 474 pub async fn get_default_branch( 475 &self, 476 knot_host: &str, 477 did: &str, 478 name: &str, 479 ) -> Result<DefaultBranch> { 480 #[derive(Deserialize)] 481 struct Res { 482 name: String, 483 hash: String, 484 #[serde(rename = "shortHash")] 485 short_hash: Option<String>, 486 when: String, 487 message: Option<String>, 488 } 489 let knot_client = TangledClient::new(knot_host); 490 let repo_param = format!("{}/{}", did, name); 491 let params = [("repo", repo_param)]; 492 let res: Res = knot_client 493 .get_json("sh.tangled.repo.getDefaultBranch", &params, None) 494 .await?; 495 Ok(DefaultBranch { 496 name: res.name, 497 hash: res.hash, 498 short_hash: res.short_hash, 499 when: res.when, 500 message: res.message, 501 }) 502 } 503 504 pub async fn get_languages(&self, knot_host: &str, did: &str, name: &str) -> Result<Languages> { 505 let knot_client = TangledClient::new(knot_host); 506 let repo_param = format!("{}/{}", did, name); 507 let params = [("repo", repo_param)]; 508 let res: serde_json::Value = knot_client 509 .get_json("sh.tangled.repo.languages", &params, None) 510 .await?; 511 let langs = res 512 .get("languages") 513 .cloned() 514 .unwrap_or(serde_json::json!([])); 515 let languages: Vec<Language> = serde_json::from_value(langs)?; 516 let total_size = res.get("totalSize").and_then(|v| v.as_u64()); 517 let total_files = res.get("totalFiles").and_then(|v| v.as_u64()); 518 Ok(Languages { 519 languages, 520 total_size, 521 total_files, 522 }) 523 } 524 525 pub async fn star_repo( 526 &self, 527 pds_base: &str, 528 access_jwt: &str, 529 subject_at_uri: &str, 530 user_did: &str, 531 ) -> Result<String> { 532 #[derive(Serialize)] 533 struct Rec<'a> { 534 subject: &'a str, 535 #[serde(rename = "createdAt")] 536 created_at: String, 537 } 538 #[derive(Serialize)] 539 struct Req<'a> { 540 repo: &'a str, 541 collection: &'a str, 542 validate: bool, 543 record: Rec<'a>, 544 } 545 #[derive(Deserialize)] 546 struct Res { 547 uri: String, 548 } 549 let now = chrono::Utc::now().to_rfc3339(); 550 let rec = Rec { 551 subject: subject_at_uri, 552 created_at: now, 553 }; 554 let req = Req { 555 repo: user_did, 556 collection: "sh.tangled.feed.star", 557 validate: true, 558 record: rec, 559 }; 560 let pds_client = TangledClient::new(pds_base); 561 let res: Res = pds_client 562 .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 563 .await?; 564 let rkey = Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in star uri"))?; 565 Ok(rkey) 566 } 567 568 pub async fn unstar_repo( 569 &self, 570 pds_base: &str, 571 access_jwt: &str, 572 subject_at_uri: &str, 573 user_did: &str, 574 ) -> Result<()> { 575 #[derive(Deserialize)] 576 struct Item { 577 uri: String, 578 value: StarRecord, 579 } 580 #[derive(Deserialize)] 581 struct ListRes { 582 #[serde(default)] 583 records: Vec<Item>, 584 } 585 let pds_client = TangledClient::new(pds_base); 586 let params = vec![ 587 ("repo", user_did.to_string()), 588 ("collection", "sh.tangled.feed.star".to_string()), 589 ("limit", "100".to_string()), 590 ]; 591 let res: ListRes = pds_client 592 .get_json("com.atproto.repo.listRecords", &params, Some(access_jwt)) 593 .await?; 594 let mut rkey = None; 595 for item in res.records { 596 if item.value.subject == subject_at_uri { 597 rkey = Self::uri_rkey(&item.uri); 598 if rkey.is_some() { 599 break; 600 } 601 } 602 } 603 let rkey = rkey.ok_or_else(|| anyhow!("star record not found"))?; 604 #[derive(Serialize)] 605 struct Del<'a> { 606 repo: &'a str, 607 collection: &'a str, 608 rkey: &'a str, 609 } 610 let del = Del { 611 repo: user_did, 612 collection: "sh.tangled.feed.star", 613 rkey: &rkey, 614 }; 615 let _: serde_json::Value = pds_client 616 .post_json("com.atproto.repo.deleteRecord", &del, Some(access_jwt)) 617 .await?; 618 Ok(()) 619 } 620 621 fn uri_rkey(uri: &str) -> Option<String> { 622 uri.rsplit('/').next().map(|s| s.to_string()) 623 } 624 fn uri_did(uri: &str) -> Option<String> { 625 let parts: Vec<&str> = uri.split('/').collect(); 626 if parts.len() >= 3 { 627 Some(parts[2].to_string()) 628 } else { 629 None 630 } 631 } 632} 633 634#[derive(Debug, Clone, Serialize, Deserialize, Default)] 635pub struct Repository { 636 pub did: Option<String>, 637 pub rkey: Option<String>, 638 pub name: String, 639 pub knot: Option<String>, 640 pub description: Option<String>, 641 #[serde(default)] 642 pub private: bool, 643} 644 645#[derive(Debug, Clone)] 646pub struct RepoRecord { 647 pub did: String, 648 pub name: String, 649 pub rkey: String, 650 pub knot: String, 651 pub description: Option<String>, 652} 653 654#[derive(Debug, Clone, Serialize, Deserialize)] 655pub struct DefaultBranch { 656 pub name: String, 657 pub hash: String, 658 #[serde(skip_serializing_if = "Option::is_none")] 659 pub short_hash: Option<String>, 660 pub when: String, 661 #[serde(skip_serializing_if = "Option::is_none")] 662 pub message: Option<String>, 663} 664 665#[derive(Debug, Clone, Serialize, Deserialize)] 666pub struct Language { 667 pub name: String, 668 pub size: u64, 669 pub percentage: u64, 670} 671 672#[derive(Debug, Clone, Serialize, Deserialize)] 673pub struct Languages { 674 pub languages: Vec<Language>, 675 #[serde(skip_serializing_if = "Option::is_none")] 676 pub total_size: Option<u64>, 677 #[serde(skip_serializing_if = "Option::is_none")] 678 pub total_files: Option<u64>, 679} 680 681#[derive(Debug, Clone, Serialize, Deserialize)] 682pub struct StarRecord { 683 pub subject: String, 684 #[serde(rename = "createdAt")] 685 pub created_at: String, 686} 687 688#[derive(Debug, Clone)] 689pub struct CreateRepoOptions<'a> { 690 pub did: &'a str, 691 pub name: &'a str, 692 pub knot: &'a str, 693 pub description: Option<&'a str>, 694 pub default_branch: Option<&'a str>, 695 pub source: Option<&'a str>, 696 pub pds_base: &'a str, 697 pub access_jwt: &'a str, 698}