From ec2bf0c54245cd84f492847d2a1e070919b14a53 Mon Sep 17 00:00:00 2001 From: bryan newbold Date: Sun, 19 Feb 2023 17:01:07 -0800 Subject: more refactoring of common code and APIs --- adenosine-cli/src/bin/adenosine.rs | 4 +- adenosine-pds/Cargo.toml | 32 +- adenosine-pds/src/bin/adenosine-pds.rs | 6 +- adenosine-pds/src/bsky.rs | 773 ------------------------------- adenosine-pds/src/db.rs | 15 +- adenosine-pds/src/db_bsky.rs | 778 ++++++++++++++++++++++++++++++++ adenosine-pds/src/lib.rs | 134 ++---- adenosine-pds/src/models.rs | 246 ---------- adenosine-pds/src/web.rs | 11 +- adenosine-pds/tests/bigger.car | Bin 60050 -> 0 bytes adenosine-pds/tests/example_repo.car | Bin 7390 -> 0 bytes adenosine-pds/tests/test_mst_interop.rs | 152 ------- adenosine-pds/tests/test_repro_mst.rs | 26 -- adenosine/Cargo.toml | 37 +- adenosine/src/app_bsky/mod.rs | 165 +++++++ adenosine/src/auth.rs | 37 ++ adenosine/src/com_atproto/mod.rs | 29 ++ adenosine/src/com_atproto/repo/mod.rs | 53 +++ adenosine/src/did.rs | 368 --------------- adenosine/src/ipld.rs | 47 ++ adenosine/src/lib.rs | 231 +--------- adenosine/src/plc.rs | 368 +++++++++++++++ adenosine/src/xrpc.rs | 186 ++++++++ adenosine/tests/bigger.car | Bin 0 -> 60050 bytes adenosine/tests/example_repo.car | Bin 0 -> 7390 bytes adenosine/tests/test_mst_interop.rs | 152 +++++++ adenosine/tests/test_repro_mst.rs | 26 ++ 27 files changed, 1944 insertions(+), 1932 deletions(-) delete mode 100644 adenosine-pds/src/bsky.rs create mode 100644 adenosine-pds/src/db_bsky.rs delete mode 100644 adenosine-pds/src/models.rs delete mode 100644 adenosine-pds/tests/bigger.car delete mode 100644 adenosine-pds/tests/example_repo.car delete mode 100644 adenosine-pds/tests/test_mst_interop.rs delete mode 100644 adenosine-pds/tests/test_repro_mst.rs create mode 100644 adenosine/src/app_bsky/mod.rs create mode 100644 adenosine/src/auth.rs create mode 100644 adenosine/src/com_atproto/mod.rs create mode 100644 adenosine/src/com_atproto/repo/mod.rs delete mode 100644 adenosine/src/did.rs create mode 100644 adenosine/src/ipld.rs create mode 100644 adenosine/src/plc.rs create mode 100644 adenosine/src/xrpc.rs create mode 100644 adenosine/tests/bigger.car create mode 100644 adenosine/tests/example_repo.car create mode 100644 adenosine/tests/test_mst_interop.rs create mode 100644 adenosine/tests/test_repro_mst.rs diff --git a/adenosine-cli/src/bin/adenosine.rs b/adenosine-cli/src/bin/adenosine.rs index 4164181..e35d634 100644 --- a/adenosine-cli/src/bin/adenosine.rs +++ b/adenosine-cli/src/bin/adenosine.rs @@ -1,5 +1,7 @@ +use adenosine::auth::parse_did_from_jwt; +use adenosine::created_at_now; use adenosine::identifiers::*; -use adenosine::*; +use adenosine::xrpc::{XrpcClient, XrpcMethod}; use adenosine_cli::*; use anyhow::anyhow; use serde_json::{json, Value}; diff --git a/adenosine-pds/Cargo.toml b/adenosine-pds/Cargo.toml index 41cfaae..f9dba1a 100644 --- a/adenosine-pds/Cargo.toml +++ b/adenosine-pds/Cargo.toml @@ -13,32 +13,24 @@ readme = "README.md" repository = "https://gitlab.com/bnewbold/adenosine" [dependencies] +adenosine = { version = "0.2.0", path = "../adenosine" } anyhow = "1" -structopt = "0.3" -serde = "1" -serde_json = "1" +askama = { version = "0.11", features = ["serde-json"] } +bcrypt = "0.13" +data-encoding = "2" +dotenvy = "0.15" +#ipfs-sqlite-block-store = "0.13" +lazy_static = "1" +libipld = { version = "0.14", features = ["dag-cbor", "derive"] } log = "0.4" pretty_env_logger = "0.4" -libipld = "0.14" -ipfs-sqlite-block-store = "0.13" +rouille = "3" rusqlite = { version = "0.26", features = ["bundled"] } rusqlite_migration = "1" -# NOTE: lexicon validation not implemented yet -#jsonschema = "*" -#schemafy = "*" -rouille = "3" -# NOTE: vendored for now -#iroh-car = "*" -adenosine = { version = "0.2.0", path = "../adenosine" } +serde = "1" +serde_json = "1" +structopt = "0.3" tokio = { version = "1", features = ["full"] } -futures = "0.3" -lazy_static = "1" -bcrypt = "0.13" -data-encoding = "2" -# TODO: replace this with data-encoding or similar; this is only needed for ucan_p256 stuff -async-trait = "0.1" -dotenvy = "0.15" -askama = { version = "0.11", features = ["serde-json"] } [package.metadata.deb] maintainer = "Bryan Newbold " diff --git a/adenosine-pds/src/bin/adenosine-pds.rs b/adenosine-pds/src/bin/adenosine-pds.rs index c16e7e0..c004098 100644 --- a/adenosine-pds/src/bin/adenosine-pds.rs +++ b/adenosine-pds/src/bin/adenosine-pds.rs @@ -1,5 +1,7 @@ +use adenosine::com_atproto; +use adenosine::crypto::KeyPair; use adenosine::mst; -use adenosine_pds::models::AccountRequest; +use adenosine::repo::RepoStore; use adenosine_pds::*; use anyhow::Result; use serde_json::json; @@ -208,7 +210,7 @@ fn main() -> Result<()> { public_url, did_plc, } => { - let req = AccountRequest { + let req = com_atproto::AccountRequest { email, handle: handle.clone(), password, diff --git a/adenosine-pds/src/bsky.rs b/adenosine-pds/src/bsky.rs deleted file mode 100644 index caa16f6..0000000 --- a/adenosine-pds/src/bsky.rs +++ /dev/null @@ -1,773 +0,0 @@ -use crate::models::*; -/// Helper functions for doing database and repo operations relating to bluesky endpoints and -/// records -use crate::{ - ipld_into_json_value, json_value_into_ipld, AtpDatabase, AtpService, Did, Result, Tid, - XrpcError, -}; -use adenosine::identifiers::{AtUri, DidOrHost, Nsid}; -use adenosine::repo::Mutation; -use anyhow::anyhow; -use libipld::Cid; -use rusqlite::params; -use serde_json::json; -use std::str::FromStr; - -/// Handles updating the database with creation, update, deletion of arbitrary records -pub fn bsky_mutate_db(db: &mut AtpDatabase, did: &Did, mutations: Vec) -> Result<()> { - // TODO: this function could probably be refactored - let bsky_post: Nsid = Nsid::from_str("app.bsky.feed.post").unwrap(); - let bsky_repost: Nsid = Nsid::from_str("app.bsky.feed.repost").unwrap(); - let bsky_like: Nsid = Nsid::from_str("app.bsky.feed.like").unwrap(); - let bsky_follow: Nsid = Nsid::from_str("app.bsky.graph.follow").unwrap(); - for m in mutations.into_iter() { - match m { - Mutation::Create(ref_type, tid, val) | Mutation::Update(ref_type, tid, val) - if ref_type == bsky_post => - { - db.bsky_upsert_post(did, &tid, Some(val))? - } - Mutation::Delete(ref_type, tid) if ref_type == bsky_post => { - db.bsky_upsert_post(did, &tid, None)? - } - Mutation::Create(ref_type, tid, val) | Mutation::Update(ref_type, tid, val) - if ref_type == bsky_repost => - { - db.bsky_upsert_ref("repost", did, &tid, Some(val))? - } - Mutation::Delete(ref_type, tid) if ref_type == bsky_repost => { - db.bsky_upsert_ref("repost", did, &tid, None)? - } - Mutation::Create(ref_type, tid, val) | Mutation::Update(ref_type, tid, val) - if ref_type == bsky_like => - { - db.bsky_upsert_ref("like", did, &tid, Some(val))? - } - Mutation::Delete(ref_type, tid) if ref_type == bsky_like => { - db.bsky_upsert_ref("like", did, &tid, None)? - } - Mutation::Create(ref_type, tid, val) | Mutation::Update(ref_type, tid, val) - if ref_type == bsky_follow => - { - db.bsky_upsert_follow(did, &tid, Some(val))? - } - Mutation::Delete(ref_type, tid) if ref_type == bsky_follow => { - db.bsky_upsert_follow(did, &tid, None)? - } - _ => (), - } - } - Ok(()) -} - -// TODO: should probably return Result>? -pub fn bsky_get_profile(srv: &mut AtpService, did: &Did) -> Result { - // first get the profile record - let mut profile_cid: Option = None; - let commit_cid = match srv.repo.lookup_commit(did)? { - Some(cid) => cid, - None => Err(anyhow!("repository not found: {}", did))?, - }; - let last_commit = srv.repo.get_commit(&commit_cid)?; - let full_map = srv.repo.mst_to_map(&last_commit.mst_cid)?; - let prefix = "/app.bsky.actor.profile/"; - for (mst_key, cid) in full_map.iter() { - if mst_key.starts_with(prefix) { - profile_cid = Some(*cid); - } - } - let (display_name, description): (Option, Option) = - if let Some(cid) = profile_cid { - let record: ProfileRecord = - serde_json::from_value(ipld_into_json_value(srv.repo.get_ipld(&cid)?))?; - (Some(record.displayName), record.description) - } else { - (None, None) - }; - let mut stmt = srv - .atp_db - .conn - .prepare_cached("SELECT handle FROM account WHERE did = $1")?; - let handle: String = stmt.query_row(params!(did.to_string()), |row| row.get(0))?; - let mut stmt = srv - .atp_db - .conn - .prepare_cached("SELECT COUNT(*) FROM bsky_post WHERE did = $1")?; - let post_count: u64 = stmt.query_row(params!(did.to_string()), |row| row.get(0))?; - let mut stmt = srv - .atp_db - .conn - .prepare_cached("SELECT COUNT(*) FROM bsky_follow WHERE did = $1")?; - let follows_count: u64 = stmt.query_row(params!(did.to_string()), |row| row.get(0))?; - let mut stmt = srv - .atp_db - .conn - .prepare_cached("SELECT COUNT(*) FROM bsky_follow WHERE subject_did = $1")?; - let followers_count: u64 = stmt.query_row(params!(did.to_string()), |row| row.get(0))?; - let decl = DeclRef { - actorType: "app.bsky.system.actorUser".to_string(), - cid: "bafyreid27zk7lbis4zw5fz4podbvbs4fc5ivwji3dmrwa6zggnj4bnd57u".to_string(), - }; - Ok(Profile { - did: did.to_string(), - handle, - creator: did.to_string(), - displayName: display_name, - description, - declaration: decl, - followersCount: followers_count, - followsCount: follows_count, - postsCount: post_count, - membersCount: 0, - myState: json!({}), - }) -} - -pub fn bsky_update_profile(srv: &mut AtpService, did: &Did, profile: ProfileRecord) -> Result<()> { - // get the profile record - let mut profile_tid: Option = None; - let commit_cid = match srv.repo.lookup_commit(did)? { - Some(cid) => cid, - None => Err(anyhow!("repository not found: {}", did))?, - }; - let last_commit = srv.repo.get_commit(&commit_cid)?; - let full_map = srv.repo.mst_to_map(&last_commit.mst_cid)?; - let prefix = "/app.bsky.actor.profile/"; - for (mst_key, _cid) in full_map.iter() { - if mst_key.starts_with(prefix) { - profile_tid = Some(Tid::from_str(mst_key.split('/').nth(2).unwrap())?); - } - } - let profile_tid: Tid = profile_tid.unwrap_or(srv.tid_gen.next_tid()); - let mutations: Vec = vec![Mutation::Update( - Nsid::from_str("app.bsky.actor.profile")?, - profile_tid, - json_value_into_ipld(serde_json::to_value(profile)?), - )]; - let keypair = srv.pds_keypair.clone(); - srv.repo.mutate_repo(did, &mutations, &keypair)?; - Ok(()) -} - -struct FeedRow { - pub item_did: Did, - pub item_handle: String, - pub item_post_tid: Tid, - pub item_post_cid: Cid, - pub indexed_at: String, -} - -fn feed_row(row: &rusqlite::Row) -> Result { - let item_did: String = row.get(0)?; - let item_did = Did::from_str(&item_did)?; - let item_handle = row.get(1)?; - let item_post_tid: String = row.get(2)?; - let item_post_tid = Tid::from_str(&item_post_tid)?; - let cid_string: String = row.get(3)?; - let item_post_cid = Cid::from_str(&cid_string)?; - let indexed_at: String = row.get(4)?; - Ok(FeedRow { - item_did, - item_handle, - item_post_tid, - item_post_cid, - indexed_at, - }) -} - -fn feed_row_to_item(srv: &mut AtpService, row: FeedRow) -> Result { - let record_ipld = srv.repo.get_ipld(&row.item_post_cid)?; - let uri = format!( - "at://{}/{}/{}", - row.item_did, "app.bsky.feed.post", row.item_post_tid - ); - - let mut stmt = srv.atp_db.conn.prepare_cached( - "SELECT COUNT(*) FROM bsky_ref WHERE ref_type = 'like' AND subject_uri = $1", - )?; - let like_count: u64 = stmt.query_row(params!(uri), |row| row.get(0))?; - - let mut stmt = srv.atp_db.conn.prepare_cached( - "SELECT COUNT(*) FROM bsky_ref WHERE ref_type = 'repost' AND subject_uri = $1", - )?; - let repost_count: u64 = stmt.query_row(params!(uri), |row| row.get(0))?; - - let mut stmt = srv - .atp_db - .conn - .prepare_cached("SELECT COUNT(*) FROM bsky_post WHERE reply_to_parent_uri = $1")?; - let reply_count: u64 = stmt.query_row(params!(uri), |row| row.get(0))?; - - let feed_item = FeedItem { - uri, - cid: row.item_post_cid.to_string(), - author: User { - did: row.item_did.to_string(), - handle: row.item_handle, - displayName: None, // TODO: fetch from profile (or cache) - }, - repostedBy: None, - record: ipld_into_json_value(record_ipld), - embed: None, - replyCount: reply_count, - repostCount: repost_count, - upvoteCount: like_count, - downvoteCount: 0, - indexedAt: row.indexed_at, - myState: None, - }; - Ok(feed_item) -} - -pub fn bsky_get_timeline(srv: &mut AtpService, did: &Did) -> Result { - let mut feed: Vec = vec![]; - // TODO: also handle reposts - let rows = { - let mut stmt = srv.atp_db - .conn - .prepare_cached("SELECT account.did, account.handle, bsky_post.tid, bsky_post.cid, bsky_post.indexed_at FROM bsky_post LEFT JOIN account ON bsky_post.did = account.did LEFT JOIN bsky_follow ON bsky_post.did = bsky_follow.subject_did WHERE bsky_follow.did = ?1 AND account.did IS NOT NULL ORDER BY bsky_post.tid DESC LIMIT 20")?; - let mut sql_rows = stmt.query(params!(did.to_string()))?; - let mut rows: Vec = vec![]; - while let Some(sql_row) = sql_rows.next()? { - let row = feed_row(sql_row)?; - rows.push(row); - } - rows - }; - for row in rows { - feed.push(feed_row_to_item(srv, row)?); - } - Ok(GenericFeed { feed }) -} - -pub fn bsky_get_author_feed(srv: &mut AtpService, did: &Did) -> Result { - let mut feed: Vec = vec![]; - // TODO: also handle reposts - let rows = { - let mut stmt = srv.atp_db - .conn - .prepare_cached("SELECT account.did, account.handle, bsky_post.tid, bsky_post.cid, bsky_post.indexed_at FROM bsky_post LEFT JOIN account ON bsky_post.did = account.did WHERE bsky_post.did = ?1 ORDER BY bsky_post.tid DESC LIMIT 20")?; - let mut sql_rows = stmt.query(params!(did.to_string()))?; - let mut rows: Vec = vec![]; - while let Some(sql_row) = sql_rows.next()? { - let row = feed_row(sql_row)?; - rows.push(row); - } - rows - }; - for row in rows { - feed.push(feed_row_to_item(srv, row)?); - } - Ok(GenericFeed { feed }) -} - -// TODO: this is a partial implementation -// TODO: should maybe have this take a did and tid instead of a aturi? -pub fn bsky_get_thread( - srv: &mut AtpService, - uri: &AtUri, - _depth: Option, -) -> Result { - // parse the URI - let did = match uri.repository { - DidOrHost::Did(ref did_type, ref did_body) => { - Did::from_str(&format!("did:{}:{}", did_type, did_body))? - } - _ => Err(anyhow!("expected a DID, not handle, in uri: {}", uri))?, - }; - if uri.collection != Some("app.bsky.feed.post".to_string()) { - Err(anyhow!("expected a post collection in uri: {}", uri))?; - }; - let tid = match uri.record { - Some(ref tid) => Tid::from_str(tid)?, - _ => Err(anyhow!("expected a record in uri: {}", uri))?, - }; - - // post itself, as a FeedItem - let post_items = { - let mut stmt = srv.atp_db - .conn - .prepare_cached("SELECT account.did, account.handle, bsky_post.tid, bsky_post.cid, bsky_post.indexed_at FROM bsky_post LEFT JOIN account ON bsky_post.did = account.did WHERE bsky_post.did = ?1 AND bsky_post.tid = ?2")?; - let mut sql_rows = stmt.query(params!(did.to_string(), tid.to_string()))?; - let mut rows: Vec = vec![]; - while let Some(sql_row) = sql_rows.next()? { - let row = feed_row(sql_row)?; - rows.push(row); - } - rows - }; - if post_items.is_empty() { - Err(XrpcError::NotFound("post not found".to_string()))?; - }; - let post_item = feed_row_to_item(srv, post_items.into_iter().next().unwrap())?; - - // TODO: any parent - let parent = None; - - // any children - let mut children = vec![]; - let rows = { - let mut stmt = srv.atp_db - .conn - .prepare_cached("SELECT account.did, account.handle, bsky_post.tid, bsky_post.cid, bsky_post.indexed_at FROM bsky_post LEFT JOIN account ON bsky_post.did = account.did WHERE bsky_post.reply_to_parent_uri = ?1 ORDER BY bsky_post.tid DESC LIMIT 20")?; - let mut sql_rows = stmt.query(params!(uri.to_string()))?; - let mut rows: Vec = vec![]; - while let Some(sql_row) = sql_rows.next()? { - let row = feed_row(sql_row)?; - rows.push(row); - } - rows - }; - for row in rows { - let item = feed_row_to_item(srv, row)?; - children.push(ThreadItem { - uri: item.uri, - cid: item.cid, - author: item.author, - record: item.record, - embed: item.embed, - // don't want a loop here - parent: None, - replyCount: item.replyCount, - // only going to depth of one here - replies: None, - upvoteCount: item.upvoteCount, - downvoteCount: 0, - repostCount: item.repostCount, - indexedAt: item.indexedAt, - myState: None, - }); - } - - let post = ThreadItem { - uri: post_item.uri, - cid: post_item.cid, - author: post_item.author, - record: post_item.record, - embed: post_item.embed, - parent, - replyCount: post_item.replyCount, - replies: Some(children), - upvoteCount: post_item.upvoteCount, - downvoteCount: 0, - repostCount: post_item.repostCount, - indexedAt: post_item.indexedAt, - myState: None, - }; - Ok(PostThread { thread: post }) -} - -#[test] -fn test_bsky_profile() { - use crate::{create_account, created_at_now}; - use libipld::ipld; - - let post_nsid = Nsid::from_str("app.bsky.feed.post").unwrap(); - let follow_nsid = Nsid::from_str("app.bsky.graph.follow").unwrap(); - - let mut srv = AtpService::new_ephemeral().unwrap(); - let req = AccountRequest { - email: "test@bogus.com".to_string(), - handle: "handle.test".to_string(), - password: "bogus".to_string(), - inviteCode: None, - recoveryKey: None, - }; - let session = create_account(&mut srv, &req, true).unwrap(); - let did = Did::from_str(&session.did).unwrap(); - let profile = bsky_get_profile(&mut srv, &did).unwrap(); - assert_eq!(profile.did, session.did); - assert_eq!(profile.handle, req.handle); - assert_eq!(profile.displayName, None); - assert_eq!(profile.description, None); - assert_eq!(profile.followersCount, 0); - assert_eq!(profile.followsCount, 0); - assert_eq!(profile.postsCount, 0); - - let record = ProfileRecord { - displayName: "Test Name".to_string(), - description: Some("short description".to_string()), - }; - bsky_update_profile(&mut srv, &did, record.clone()).unwrap(); - let profile = bsky_get_profile(&mut srv, &did).unwrap(); - assert_eq!(profile.displayName, Some(record.displayName)); - assert_eq!(profile.description, record.description); - - let record = ProfileRecord { - displayName: "New Test Name".to_string(), - description: Some("longer description".to_string()), - }; - bsky_update_profile(&mut srv, &did, record.clone()).unwrap(); - let profile = bsky_get_profile(&mut srv, &did).unwrap(); - assert_eq!(profile.displayName, Some(record.displayName)); - assert_eq!(profile.description, record.description); - - let mutations = vec![ - Mutation::Create( - follow_nsid.clone(), - srv.tid_gen.next_tid(), - ipld!({"subject": {"did": session.did}, "createdAt": created_at_now()}), - ), - Mutation::Create( - follow_nsid.clone(), - srv.tid_gen.next_tid(), - ipld!({"subject": {"did": "did:web:external.domain"}, "createdAt": created_at_now()}), - ), - Mutation::Create( - post_nsid.clone(), - srv.tid_gen.next_tid(), - ipld!({"text": "first post"}), - ), - Mutation::Create( - post_nsid.clone(), - srv.tid_gen.next_tid(), - ipld!({"text": "second post"}), - ), - Mutation::Create( - post_nsid.clone(), - srv.tid_gen.next_tid(), - ipld!({"text": "third post"}), - ), - ]; - srv.repo - .mutate_repo(&did, &mutations, &srv.pds_keypair) - .unwrap(); - bsky_mutate_db(&mut srv.atp_db, &did, mutations).unwrap(); - - let profile = bsky_get_profile(&mut srv, &did).unwrap(); - assert_eq!(profile.followersCount, 1); - assert_eq!(profile.followsCount, 2); - assert_eq!(profile.postsCount, 3); -} - -#[test] -fn test_bsky_feeds() { - // TODO: test that displayName comes through in feeds and timelines (it does not currently) - use crate::{create_account, created_at_now}; - use libipld::ipld; - - let post_nsid = Nsid::from_str("app.bsky.feed.post").unwrap(); - let like_nsid = Nsid::from_str("app.bsky.feed.like").unwrap(); - let repost_nsid = Nsid::from_str("app.bsky.feed.repost").unwrap(); - let follow_nsid = Nsid::from_str("app.bsky.graph.follow").unwrap(); - - let mut srv = AtpService::new_ephemeral().unwrap(); - let alice_did = { - let req = AccountRequest { - email: "alice@bogus.com".to_string(), - handle: "alice.test".to_string(), - password: "bogus".to_string(), - inviteCode: None, - recoveryKey: None, - }; - let session = create_account(&mut srv, &req, true).unwrap(); - Did::from_str(&session.did).unwrap() - }; - let bob_did = { - let req = AccountRequest { - email: "bob@bogus.com".to_string(), - handle: "bob.test".to_string(), - password: "bogus".to_string(), - inviteCode: None, - recoveryKey: None, - }; - let session = create_account(&mut srv, &req, true).unwrap(); - Did::from_str(&session.did).unwrap() - }; - let carol_did = { - let req = AccountRequest { - email: "carol@bogus.com".to_string(), - handle: "carol.test".to_string(), - password: "bogus".to_string(), - inviteCode: None, - recoveryKey: None, - }; - let session = create_account(&mut srv, &req, true).unwrap(); - Did::from_str(&session.did).unwrap() - }; - - // all feeds and timelines should be empty - let alice_feed = bsky_get_author_feed(&mut srv, &alice_did).unwrap(); - let alice_timeline = bsky_get_timeline(&mut srv, &alice_did).unwrap(); - assert!(alice_feed.feed.is_empty()); - assert!(alice_timeline.feed.is_empty()); - let bob_feed = bsky_get_author_feed(&mut srv, &bob_did).unwrap(); - let bob_timeline = bsky_get_timeline(&mut srv, &bob_did).unwrap(); - assert!(bob_feed.feed.is_empty()); - assert!(bob_timeline.feed.is_empty()); - let carol_feed = bsky_get_author_feed(&mut srv, &carol_did).unwrap(); - let carol_timeline = bsky_get_timeline(&mut srv, &carol_did).unwrap(); - assert!(carol_feed.feed.is_empty()); - assert!(carol_timeline.feed.is_empty()); - - // alice does some posts - let alice_post1_tid = srv.tid_gen.next_tid(); - let alice_post2_tid = srv.tid_gen.next_tid(); - let alice_post3_tid = srv.tid_gen.next_tid(); - assert!(alice_post1_tid < alice_post2_tid && alice_post2_tid < alice_post3_tid); - let mutations = vec![ - Mutation::Create( - post_nsid.clone(), - alice_post1_tid.clone(), - ipld!({"text": "alice first post"}), - ), - Mutation::Create( - post_nsid.clone(), - alice_post2_tid.clone(), - ipld!({"text": "alice second post"}), - ), - Mutation::Create( - post_nsid.clone(), - alice_post3_tid.clone(), - ipld!({"text": "alice third post"}), - ), - ]; - srv.repo - .mutate_repo(&alice_did, &mutations, &srv.pds_keypair) - .unwrap(); - bsky_mutate_db(&mut srv.atp_db, &alice_did, mutations).unwrap(); - - // bob follows alice, likes first post, reposts second, replies third - let alice_post3_uri = format!( - "at://{}/{}/{}", - alice_did.to_string(), - post_nsid.to_string(), - alice_post3_tid.to_string() - ); - let mutations = vec![ - Mutation::Create( - follow_nsid.clone(), - srv.tid_gen.next_tid(), - ipld!({"subject": {"did": alice_did.to_string()}, "createdAt": created_at_now()}), - ), - Mutation::Create( - like_nsid.clone(), - srv.tid_gen.next_tid(), - ipld!({"subject": {"uri": format!("at://{}/{}/{}", alice_did.to_string(), post_nsid.to_string(), alice_post1_tid.to_string())}, "createdAt": created_at_now()}), - ), - Mutation::Create( - repost_nsid.clone(), - srv.tid_gen.next_tid(), - ipld!({"subject": {"uri": format!("at://{}/{}/{}", alice_did.to_string(), post_nsid.to_string(), alice_post2_tid.to_string())}, "createdAt": created_at_now()}), - ), - Mutation::Create( - post_nsid.clone(), - srv.tid_gen.next_tid(), - ipld!({"text": "bob comment on alice post3", "reply": {"parent": {"uri": alice_post3_uri.clone()}, "root": {"uri": alice_post3_uri.clone()}}}), - ), - ]; - srv.repo - .mutate_repo(&bob_did, &mutations, &srv.pds_keypair) - .unwrap(); - bsky_mutate_db(&mut srv.atp_db, &bob_did, mutations).unwrap(); - - // carol follows bob - let mutations = vec![Mutation::Create( - follow_nsid.clone(), - srv.tid_gen.next_tid(), - ipld!({"subject": {"did": bob_did.to_string()}, "createdAt": created_at_now()}), - )]; - srv.repo - .mutate_repo(&bob_did, &mutations, &srv.pds_keypair) - .unwrap(); - bsky_mutate_db(&mut srv.atp_db, &carol_did, mutations).unwrap(); - - // test alice profile: counts should be updated - let alice_profile = bsky_get_profile(&mut srv, &alice_did).unwrap(); - assert_eq!(alice_profile.followersCount, 1); - assert_eq!(alice_profile.followsCount, 0); - assert_eq!(alice_profile.postsCount, 3); - - // test alice timeline: still empty (?) - let alice_timeline = bsky_get_timeline(&mut srv, &alice_did).unwrap(); - println!("{:?}", alice_timeline); - assert!(alice_timeline.feed.is_empty()); - - // test alice feed: should have 3 posts, with correct counts - let alice_feed = bsky_get_author_feed(&mut srv, &alice_did).unwrap(); - assert_eq!(alice_feed.feed.len(), 3); - - assert_eq!( - alice_feed.feed[2].uri, - format!( - "at://{}/{}/{}", - alice_did.to_string(), - post_nsid.to_string(), - alice_post1_tid.to_string() - ) - ); - // TODO: CID - assert_eq!(alice_feed.feed[2].author.did, alice_did.to_string()); - assert_eq!(alice_feed.feed[2].author.handle, "alice.test"); - assert_eq!(alice_feed.feed[2].repostedBy, None); - assert_eq!( - alice_feed.feed[2].record["text"].as_str().unwrap(), - "alice first post" - ); - assert_eq!(alice_feed.feed[2].embed, None); - assert_eq!(alice_feed.feed[2].replyCount, 0); - assert_eq!(alice_feed.feed[2].repostCount, 0); - assert_eq!(alice_feed.feed[2].upvoteCount, 1); - assert_eq!(alice_feed.feed[2].downvoteCount, 0); - - assert_eq!(alice_feed.feed[1].author.did, alice_did.to_string()); - assert_eq!(alice_feed.feed[1].replyCount, 0); - assert_eq!(alice_feed.feed[1].repostCount, 1); - assert_eq!(alice_feed.feed[1].upvoteCount, 0); - - assert_eq!(alice_feed.feed[0].author.did, alice_did.to_string()); - assert_eq!(alice_feed.feed[0].replyCount, 1); - assert_eq!(alice_feed.feed[0].repostCount, 0); - assert_eq!(alice_feed.feed[0].upvoteCount, 0); - - // test bob timeline: should include alice posts - let bob_timeline = bsky_get_timeline(&mut srv, &bob_did).unwrap(); - println!("BOB TIMELINE ======"); - for item in bob_timeline.feed.iter() { - println!("{:?}", item); - } - assert_eq!(bob_timeline.feed.len(), 3); - assert_eq!( - bob_timeline.feed[2].uri, - format!( - "at://{}/{}/{}", - alice_did.to_string(), - post_nsid.to_string(), - alice_post1_tid.to_string() - ) - ); - // TODO: CID - assert_eq!(bob_timeline.feed[2].author.did, alice_did.to_string()); - assert_eq!(bob_timeline.feed[2].author.handle, "alice.test"); - - // test bob feed: should include repost and reply - let bob_feed = bsky_get_author_feed(&mut srv, &bob_did).unwrap(); - assert_eq!(bob_feed.feed.len(), 1); - // TODO: handle reposts - /* - assert_eq!(bob_feed.feed.len(), 2); - assert_eq!(bob_feed.feed[1].uri, format!("at://{}/{}/{}", alice_did.to_string(), post_nsid.to_string(), alice_post1_tid.to_string())); - // TODO: CID - assert_eq!(bob_feed.feed[1].author.did, alice_did.to_string()); - assert_eq!(bob_feed.feed[1].author.handle, "alice.test"); - assert_eq!(bob_feed.feed[1].repostedBy.as_ref().unwrap().did, bob_did.to_string()); - assert_eq!(bob_feed.feed[1].repostedBy.as_ref().unwrap().handle, "bob.test"); - // TODO: "is a repost" (check record?) - */ - - assert_eq!(bob_feed.feed[0].author.did, bob_did.to_string()); - assert_eq!(bob_feed.feed[0].author.handle, "bob.test"); - - // test carol timeline: should include bob's repost and reply - let carol_timeline = bsky_get_timeline(&mut srv, &carol_did).unwrap(); - // TODO: handle re-posts (+1 here) - assert_eq!(carol_timeline.feed.len(), 1); - // TODO: details - - // test carol feed: still empty - let carol_feed = bsky_get_author_feed(&mut srv, &carol_did).unwrap(); - assert!(carol_feed.feed.is_empty()); -} - -#[test] -fn test_bsky_thread() { - use crate::create_account; - use libipld::ipld; - - let post_nsid = Nsid::from_str("app.bsky.feed.post").unwrap(); - - let mut srv = AtpService::new_ephemeral().unwrap(); - let alice_did = { - let req = AccountRequest { - email: "alice@bogus.com".to_string(), - handle: "alice.test".to_string(), - password: "bogus".to_string(), - inviteCode: None, - recoveryKey: None, - }; - let session = create_account(&mut srv, &req, true).unwrap(); - Did::from_str(&session.did).unwrap() - }; - let bob_did = { - let req = AccountRequest { - email: "bob@bogus.com".to_string(), - handle: "bob.test".to_string(), - password: "bogus".to_string(), - inviteCode: None, - recoveryKey: None, - }; - let session = create_account(&mut srv, &req, true).unwrap(); - Did::from_str(&session.did).unwrap() - }; - - // alice does a post - let alice_post1_tid = srv.tid_gen.next_tid(); - let mutations = vec![Mutation::Create( - post_nsid.clone(), - alice_post1_tid.clone(), - ipld!({"text": "alice first post"}), - )]; - srv.repo - .mutate_repo(&alice_did, &mutations, &srv.pds_keypair) - .unwrap(); - bsky_mutate_db(&mut srv.atp_db, &alice_did, mutations).unwrap(); - let alice_post1_uri = format!( - "at://{}/{}/{}", - alice_did.to_string(), - post_nsid.to_string(), - alice_post1_tid.to_string() - ); - - // bob likes and replies first post - let bob_post1_tid = srv.tid_gen.next_tid(); - let mutations = vec![Mutation::Create( - post_nsid.clone(), - bob_post1_tid.clone(), - ipld!({"text": "bob comment on alice post1", "reply": {"parent": {"uri": alice_post1_uri.clone()}, "root": {"uri": alice_post1_uri.clone()}}}), - )]; - srv.repo - .mutate_repo(&bob_did, &mutations, &srv.pds_keypair) - .unwrap(); - bsky_mutate_db(&mut srv.atp_db, &bob_did, mutations).unwrap(); - let bob_post1_uri = format!( - "at://{}/{}/{}", - bob_did.to_string(), - post_nsid.to_string(), - bob_post1_tid.to_string() - ); - - // alice replies to bob reply - let alice_post2_tid = srv.tid_gen.next_tid(); - let mutations = vec![Mutation::Create( - post_nsid.clone(), - alice_post2_tid.clone(), - ipld!({"text": "alice second post, replying to bob comment", "reply": {"parent": {"uri": bob_post1_uri.clone()}, "root": {"uri": alice_post1_uri.clone()}}}), - )]; - srv.repo - .mutate_repo(&alice_did, &mutations, &srv.pds_keypair) - .unwrap(); - bsky_mutate_db(&mut srv.atp_db, &alice_did, mutations).unwrap(); - let _alice_post2_uri = format!( - "at://{}/{}/{}", - alice_did.to_string(), - post_nsid.to_string(), - alice_post2_tid.to_string() - ); - - // get thread from bob's post - // TODO: should have both parent and children - let post = bsky_get_thread(&mut srv, &AtUri::from_str(&bob_post1_uri).unwrap(), None) - .unwrap() - .thread; - assert_eq!(post.author.did, bob_did.to_string()); - assert_eq!(post.author.handle, "bob.test".to_string()); - assert_eq!(post.embed, None); - assert_eq!(post.replyCount, 1); - assert_eq!(post.repostCount, 0); - assert_eq!(post.upvoteCount, 0); - assert_eq!(post.replies.as_ref().unwrap().len(), 1); - - let post_replies = post.replies.unwrap(); - assert_eq!(post_replies[0].author.did, alice_did.to_string()); - // TODO: root URI, etc -} diff --git a/adenosine-pds/src/db.rs b/adenosine-pds/src/db.rs index b694bb5..4cbece6 100644 --- a/adenosine-pds/src/db.rs +++ b/adenosine-pds/src/db.rs @@ -1,6 +1,7 @@ -use crate::models::{FollowRecord, Post, RefRecord}; /// ATP database (as distinct from blockstore) -use crate::{created_at_now, ipld_into_json_value, AtpSession, Did, KeyPair, Tid}; +use crate::{created_at_now, ipld_into_json_value, Did, KeyPair, Tid}; +use adenosine::app_bsky; +use adenosine::com_atproto; use anyhow::{anyhow, Result}; use lazy_static::lazy_static; use libipld::cbor::DagCborCodec; @@ -105,7 +106,7 @@ impl AtpDatabase { handle: &str, password: &str, keypair: &KeyPair, - ) -> Result { + ) -> Result { let mut stmt = self .conn .prepare_cached("SELECT did, password_bcrypt FROM account WHERE handle = ?1")?; @@ -120,7 +121,7 @@ impl AtpDatabase { .conn .prepare_cached("INSERT INTO session (did, jwt) VALUES (?1, ?2)")?; stmt.execute(params!(did.to_string(), jwt))?; - Ok(AtpSession { + Ok(com_atproto::Session { did: did.to_string(), name: handle.to_string(), accessJwt: jwt.to_string(), @@ -192,7 +193,7 @@ impl AtpDatabase { // need to re-compute the CID from DagCbor re-encoding, I guess. bleh. let block = Block::::encode(DagCborCodec, Code::Sha2_256, &val)?; let cid = *block.cid(); - let post: Post = serde_json::from_value(ipld_into_json_value(val))?; + let post: app_bsky::Post = serde_json::from_value(ipld_into_json_value(val))?; let (reply_to_parent_uri, reply_to_root_uri) = match post.reply { Some(ref reply) => (Some(reply.parent.uri.clone()), Some(reply.root.uri.clone())), None => (None, None), @@ -226,7 +227,7 @@ impl AtpDatabase { val: Option, ) -> Result<()> { if let Some(val) = val { - let ref_obj: RefRecord = serde_json::from_value(ipld_into_json_value(val))?; + let ref_obj: app_bsky::RefRecord = serde_json::from_value(ipld_into_json_value(val))?; let mut stmt = self .conn .prepare_cached("INSERT INTO bsky_ref (ref_type, did, tid, subject_uri, subject_cid, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)")?; @@ -253,7 +254,7 @@ impl AtpDatabase { pub fn bsky_upsert_follow(&mut self, did: &Did, tid: &Tid, val: Option) -> Result<()> { if let Some(val) = val { - let follow: FollowRecord = serde_json::from_value(ipld_into_json_value(val))?; + let follow: app_bsky::FollowRecord = serde_json::from_value(ipld_into_json_value(val))?; let mut stmt = self .conn .prepare_cached("INSERT INTO bsky_follow (did, tid, subject_did, created_at) VALUES (?1, ?2, ?3, ?4)")?; diff --git a/adenosine-pds/src/db_bsky.rs b/adenosine-pds/src/db_bsky.rs new file mode 100644 index 0000000..ee8e0f3 --- /dev/null +++ b/adenosine-pds/src/db_bsky.rs @@ -0,0 +1,778 @@ +/// Helper functions for doing database and repo operations relating to bluesky endpoints and +/// records +use crate::{AtpDatabase, AtpService, Result, XrpcError}; +use adenosine::app_bsky; +use adenosine::identifiers::{AtUri, Did, DidOrHost, Nsid, Tid}; +use adenosine::ipld::{ipld_into_json_value, json_value_into_ipld}; +use adenosine::repo::Mutation; +use anyhow::anyhow; +use libipld::Cid; +use rusqlite::params; +use serde_json::json; +use std::str::FromStr; + +/// Handles updating the database with creation, update, deletion of arbitrary records +pub fn bsky_mutate_db(db: &mut AtpDatabase, did: &Did, mutations: Vec) -> Result<()> { + // TODO: this function could probably be refactored + let bsky_post: Nsid = Nsid::from_str("app.bsky.feed.post").unwrap(); + let bsky_repost: Nsid = Nsid::from_str("app.bsky.feed.repost").unwrap(); + let bsky_like: Nsid = Nsid::from_str("app.bsky.feed.like").unwrap(); + let bsky_follow: Nsid = Nsid::from_str("app.bsky.graph.follow").unwrap(); + for m in mutations.into_iter() { + match m { + Mutation::Create(ref_type, tid, val) | Mutation::Update(ref_type, tid, val) + if ref_type == bsky_post => + { + db.bsky_upsert_post(did, &tid, Some(val))? + } + Mutation::Delete(ref_type, tid) if ref_type == bsky_post => { + db.bsky_upsert_post(did, &tid, None)? + } + Mutation::Create(ref_type, tid, val) | Mutation::Update(ref_type, tid, val) + if ref_type == bsky_repost => + { + db.bsky_upsert_ref("repost", did, &tid, Some(val))? + } + Mutation::Delete(ref_type, tid) if ref_type == bsky_repost => { + db.bsky_upsert_ref("repost", did, &tid, None)? + } + Mutation::Create(ref_type, tid, val) | Mutation::Update(ref_type, tid, val) + if ref_type == bsky_like => + { + db.bsky_upsert_ref("like", did, &tid, Some(val))? + } + Mutation::Delete(ref_type, tid) if ref_type == bsky_like => { + db.bsky_upsert_ref("like", did, &tid, None)? + } + Mutation::Create(ref_type, tid, val) | Mutation::Update(ref_type, tid, val) + if ref_type == bsky_follow => + { + db.bsky_upsert_follow(did, &tid, Some(val))? + } + Mutation::Delete(ref_type, tid) if ref_type == bsky_follow => { + db.bsky_upsert_follow(did, &tid, None)? + } + _ => (), + } + } + Ok(()) +} + +// TODO: should probably return Result>? +pub fn bsky_get_profile(srv: &mut AtpService, did: &Did) -> Result { + // first get the profile record + let mut profile_cid: Option = None; + let commit_cid = match srv.repo.lookup_commit(did)? { + Some(cid) => cid, + None => Err(anyhow!("repository not found: {}", did))?, + }; + let last_commit = srv.repo.get_commit(&commit_cid)?; + let full_map = srv.repo.mst_to_map(&last_commit.mst_cid)?; + let prefix = "/app.bsky.actor.profile/"; + for (mst_key, cid) in full_map.iter() { + if mst_key.starts_with(prefix) { + profile_cid = Some(*cid); + } + } + let (display_name, description): (Option, Option) = + if let Some(cid) = profile_cid { + let record: app_bsky::ProfileRecord = + serde_json::from_value(ipld_into_json_value(srv.repo.get_ipld(&cid)?))?; + (Some(record.displayName), record.description) + } else { + (None, None) + }; + let mut stmt = srv + .atp_db + .conn + .prepare_cached("SELECT handle FROM account WHERE did = $1")?; + let handle: String = stmt.query_row(params!(did.to_string()), |row| row.get(0))?; + let mut stmt = srv + .atp_db + .conn + .prepare_cached("SELECT COUNT(*) FROM bsky_post WHERE did = $1")?; + let post_count: u64 = stmt.query_row(params!(did.to_string()), |row| row.get(0))?; + let mut stmt = srv + .atp_db + .conn + .prepare_cached("SELECT COUNT(*) FROM bsky_follow WHERE did = $1")?; + let follows_count: u64 = stmt.query_row(params!(did.to_string()), |row| row.get(0))?; + let mut stmt = srv + .atp_db + .conn + .prepare_cached("SELECT COUNT(*) FROM bsky_follow WHERE subject_did = $1")?; + let followers_count: u64 = stmt.query_row(params!(did.to_string()), |row| row.get(0))?; + let decl = app_bsky::DeclRef { + actorType: "app.bsky.system.actorUser".to_string(), + cid: "bafyreid27zk7lbis4zw5fz4podbvbs4fc5ivwji3dmrwa6zggnj4bnd57u".to_string(), + }; + Ok(app_bsky::Profile { + did: did.to_string(), + handle, + creator: did.to_string(), + displayName: display_name, + description, + declaration: decl, + followersCount: followers_count, + followsCount: follows_count, + postsCount: post_count, + membersCount: 0, + myState: json!({}), + }) +} + +pub fn bsky_update_profile( + srv: &mut AtpService, + did: &Did, + profile: app_bsky::ProfileRecord, +) -> Result<()> { + // get the profile record + let mut profile_tid: Option = None; + let commit_cid = match srv.repo.lookup_commit(did)? { + Some(cid) => cid, + None => Err(anyhow!("repository not found: {}", did))?, + }; + let last_commit = srv.repo.get_commit(&commit_cid)?; + let full_map = srv.repo.mst_to_map(&last_commit.mst_cid)?; + let prefix = "/app.bsky.actor.profile/"; + for (mst_key, _cid) in full_map.iter() { + if mst_key.starts_with(prefix) { + profile_tid = Some(Tid::from_str(mst_key.split('/').nth(2).unwrap())?); + } + } + let profile_tid: Tid = profile_tid.unwrap_or(srv.tid_gen.next_tid()); + let mutations: Vec = vec![Mutation::Update( + Nsid::from_str("app.bsky.actor.profile")?, + profile_tid, + json_value_into_ipld(serde_json::to_value(profile)?), + )]; + let keypair = srv.pds_keypair.clone(); + srv.repo.mutate_repo(did, &mutations, &keypair)?; + Ok(()) +} + +struct FeedRow { + pub item_did: Did, + pub item_handle: String, + pub item_post_tid: Tid, + pub item_post_cid: Cid, + pub indexed_at: String, +} + +fn feed_row(row: &rusqlite::Row) -> Result { + let item_did: String = row.get(0)?; + let item_did = Did::from_str(&item_did)?; + let item_handle = row.get(1)?; + let item_post_tid: String = row.get(2)?; + let item_post_tid = Tid::from_str(&item_post_tid)?; + let cid_string: String = row.get(3)?; + let item_post_cid = Cid::from_str(&cid_string)?; + let indexed_at: String = row.get(4)?; + Ok(FeedRow { + item_did, + item_handle, + item_post_tid, + item_post_cid, + indexed_at, + }) +} + +fn feed_row_to_item(srv: &mut AtpService, row: FeedRow) -> Result { + let record_ipld = srv.repo.get_ipld(&row.item_post_cid)?; + let uri = format!( + "at://{}/{}/{}", + row.item_did, "app.bsky.feed.post", row.item_post_tid + ); + + let mut stmt = srv.atp_db.conn.prepare_cached( + "SELECT COUNT(*) FROM bsky_ref WHERE ref_type = 'like' AND subject_uri = $1", + )?; + let like_count: u64 = stmt.query_row(params!(uri), |row| row.get(0))?; + + let mut stmt = srv.atp_db.conn.prepare_cached( + "SELECT COUNT(*) FROM bsky_ref WHERE ref_type = 'repost' AND subject_uri = $1", + )?; + let repost_count: u64 = stmt.query_row(params!(uri), |row| row.get(0))?; + + let mut stmt = srv + .atp_db + .conn + .prepare_cached("SELECT COUNT(*) FROM bsky_post WHERE reply_to_parent_uri = $1")?; + let reply_count: u64 = stmt.query_row(params!(uri), |row| row.get(0))?; + + let feed_item = app_bsky::FeedItem { + uri, + cid: row.item_post_cid.to_string(), + author: app_bsky::User { + did: row.item_did.to_string(), + handle: row.item_handle, + displayName: None, // TODO: fetch from profile (or cache) + }, + repostedBy: None, + record: ipld_into_json_value(record_ipld), + embed: None, + replyCount: reply_count, + repostCount: repost_count, + upvoteCount: like_count, + downvoteCount: 0, + indexedAt: row.indexed_at, + myState: None, + }; + Ok(feed_item) +} + +pub fn bsky_get_timeline(srv: &mut AtpService, did: &Did) -> Result { + let mut feed: Vec = vec![]; + // TODO: also handle reposts + let rows = { + let mut stmt = srv.atp_db + .conn + .prepare_cached("SELECT account.did, account.handle, bsky_post.tid, bsky_post.cid, bsky_post.indexed_at FROM bsky_post LEFT JOIN account ON bsky_post.did = account.did LEFT JOIN bsky_follow ON bsky_post.did = bsky_follow.subject_did WHERE bsky_follow.did = ?1 AND account.did IS NOT NULL ORDER BY bsky_post.tid DESC LIMIT 20")?; + let mut sql_rows = stmt.query(params!(did.to_string()))?; + let mut rows: Vec = vec![]; + while let Some(sql_row) = sql_rows.next()? { + let row = feed_row(sql_row)?; + rows.push(row); + } + rows + }; + for row in rows { + feed.push(feed_row_to_item(srv, row)?); + } + Ok(app_bsky::GenericFeed { feed }) +} + +pub fn bsky_get_author_feed(srv: &mut AtpService, did: &Did) -> Result { + let mut feed: Vec = vec![]; + // TODO: also handle reposts + let rows = { + let mut stmt = srv.atp_db + .conn + .prepare_cached("SELECT account.did, account.handle, bsky_post.tid, bsky_post.cid, bsky_post.indexed_at FROM bsky_post LEFT JOIN account ON bsky_post.did = account.did WHERE bsky_post.did = ?1 ORDER BY bsky_post.tid DESC LIMIT 20")?; + let mut sql_rows = stmt.query(params!(did.to_string()))?; + let mut rows: Vec = vec![]; + while let Some(sql_row) = sql_rows.next()? { + let row = feed_row(sql_row)?; + rows.push(row); + } + rows + }; + for row in rows { + feed.push(feed_row_to_item(srv, row)?); + } + Ok(app_bsky::GenericFeed { feed }) +} + +// TODO: this is a partial implementation +// TODO: should maybe have this take a did and tid instead of a aturi? +pub fn bsky_get_thread( + srv: &mut AtpService, + uri: &AtUri, + _depth: Option, +) -> Result { + // parse the URI + let did = match uri.repository { + DidOrHost::Did(ref did_type, ref did_body) => { + Did::from_str(&format!("did:{}:{}", did_type, did_body))? + } + _ => Err(anyhow!("expected a DID, not handle, in uri: {}", uri))?, + }; + if uri.collection != Some("app.bsky.feed.post".to_string()) { + Err(anyhow!("expected a post collection in uri: {}", uri))?; + }; + let tid = match uri.record { + Some(ref tid) => Tid::from_str(tid)?, + _ => Err(anyhow!("expected a record in uri: {}", uri))?, + }; + + // post itself, as a app_bsky::FeedItem + let post_items = { + let mut stmt = srv.atp_db + .conn + .prepare_cached("SELECT account.did, account.handle, bsky_post.tid, bsky_post.cid, bsky_post.indexed_at FROM bsky_post LEFT JOIN account ON bsky_post.did = account.did WHERE bsky_post.did = ?1 AND bsky_post.tid = ?2")?; + let mut sql_rows = stmt.query(params!(did.to_string(), tid.to_string()))?; + let mut rows: Vec = vec![]; + while let Some(sql_row) = sql_rows.next()? { + let row = feed_row(sql_row)?; + rows.push(row); + } + rows + }; + if post_items.is_empty() { + Err(XrpcError::NotFound("post not found".to_string()))?; + }; + let post_item = feed_row_to_item(srv, post_items.into_iter().next().unwrap())?; + + // TODO: any parent + let parent = None; + + // any children + let mut children = vec![]; + let rows = { + let mut stmt = srv.atp_db + .conn + .prepare_cached("SELECT account.did, account.handle, bsky_post.tid, bsky_post.cid, bsky_post.indexed_at FROM bsky_post LEFT JOIN account ON bsky_post.did = account.did WHERE bsky_post.reply_to_parent_uri = ?1 ORDER BY bsky_post.tid DESC LIMIT 20")?; + let mut sql_rows = stmt.query(params!(uri.to_string()))?; + let mut rows: Vec = vec![]; + while let Some(sql_row) = sql_rows.next()? { + let row = feed_row(sql_row)?; + rows.push(row); + } + rows + }; + for row in rows { + let item = feed_row_to_item(srv, row)?; + children.push(app_bsky::ThreadItem { + uri: item.uri, + cid: item.cid, + author: item.author, + record: item.record, + embed: item.embed, + // don't want a loop here + parent: None, + replyCount: item.replyCount, + // only going to depth of one here + replies: None, + upvoteCount: item.upvoteCount, + downvoteCount: 0, + repostCount: item.repostCount, + indexedAt: item.indexedAt, + myState: None, + }); + } + + let post = app_bsky::ThreadItem { + uri: post_item.uri, + cid: post_item.cid, + author: post_item.author, + record: post_item.record, + embed: post_item.embed, + parent, + replyCount: post_item.replyCount, + replies: Some(children), + upvoteCount: post_item.upvoteCount, + downvoteCount: 0, + repostCount: post_item.repostCount, + indexedAt: post_item.indexedAt, + myState: None, + }; + Ok(app_bsky::PostThread { thread: post }) +} + +#[test] +fn test_bsky_profile() { + use crate::{create_account, created_at_now}; + use adenosine::com_atproto; + use libipld::ipld; + + let post_nsid = Nsid::from_str("app.bsky.feed.post").unwrap(); + let follow_nsid = Nsid::from_str("app.bsky.graph.follow").unwrap(); + + let mut srv = AtpService::new_ephemeral().unwrap(); + let req = com_atproto::AccountRequest { + email: "test@bogus.com".to_string(), + handle: "handle.test".to_string(), + password: "bogus".to_string(), + inviteCode: None, + recoveryKey: None, + }; + let session = create_account(&mut srv, &req, true).unwrap(); + let did = Did::from_str(&session.did).unwrap(); + let profile = bsky_get_profile(&mut srv, &did).unwrap(); + assert_eq!(profile.did, session.did); + assert_eq!(profile.handle, req.handle); + assert_eq!(profile.displayName, None); + assert_eq!(profile.description, None); + assert_eq!(profile.followersCount, 0); + assert_eq!(profile.followsCount, 0); + assert_eq!(profile.postsCount, 0); + + let record = app_bsky::ProfileRecord { + displayName: "Test Name".to_string(), + description: Some("short description".to_string()), + }; + bsky_update_profile(&mut srv, &did, record.clone()).unwrap(); + let profile = bsky_get_profile(&mut srv, &did).unwrap(); + assert_eq!(profile.displayName, Some(record.displayName)); + assert_eq!(profile.description, record.description); + + let record = app_bsky::ProfileRecord { + displayName: "New Test Name".to_string(), + description: Some("longer description".to_string()), + }; + bsky_update_profile(&mut srv, &did, record.clone()).unwrap(); + let profile = bsky_get_profile(&mut srv, &did).unwrap(); + assert_eq!(profile.displayName, Some(record.displayName)); + assert_eq!(profile.description, record.description); + + let mutations = vec![ + Mutation::Create( + follow_nsid.clone(), + srv.tid_gen.next_tid(), + ipld!({"subject": {"did": session.did}, "createdAt": created_at_now()}), + ), + Mutation::Create( + follow_nsid.clone(), + srv.tid_gen.next_tid(), + ipld!({"subject": {"did": "did:web:external.domain"}, "createdAt": created_at_now()}), + ), + Mutation::Create( + post_nsid.clone(), + srv.tid_gen.next_tid(), + ipld!({"text": "first post"}), + ), + Mutation::Create( + post_nsid.clone(), + srv.tid_gen.next_tid(), + ipld!({"text": "second post"}), + ), + Mutation::Create( + post_nsid.clone(), + srv.tid_gen.next_tid(), + ipld!({"text": "third post"}), + ), + ]; + srv.repo + .mutate_repo(&did, &mutations, &srv.pds_keypair) + .unwrap(); + bsky_mutate_db(&mut srv.atp_db, &did, mutations).unwrap(); + + let profile = bsky_get_profile(&mut srv, &did).unwrap(); + assert_eq!(profile.followersCount, 1); + assert_eq!(profile.followsCount, 2); + assert_eq!(profile.postsCount, 3); +} + +#[test] +fn test_bsky_feeds() { + // TODO: test that displayName comes through in feeds and timelines (it does not currently) + use crate::{create_account, created_at_now}; + use adenosine::com_atproto; + use libipld::ipld; + + let post_nsid = Nsid::from_str("app.bsky.feed.post").unwrap(); + let like_nsid = Nsid::from_str("app.bsky.feed.like").unwrap(); + let repost_nsid = Nsid::from_str("app.bsky.feed.repost").unwrap(); + let follow_nsid = Nsid::from_str("app.bsky.graph.follow").unwrap(); + + let mut srv = AtpService::new_ephemeral().unwrap(); + let alice_did = { + let req = com_atproto::AccountRequest { + email: "alice@bogus.com".to_string(), + handle: "alice.test".to_string(), + password: "bogus".to_string(), + inviteCode: None, + recoveryKey: None, + }; + let session = create_account(&mut srv, &req, true).unwrap(); + Did::from_str(&session.did).unwrap() + }; + let bob_did = { + let req = com_atproto::AccountRequest { + email: "bob@bogus.com".to_string(), + handle: "bob.test".to_string(), + password: "bogus".to_string(), + inviteCode: None, + recoveryKey: None, + }; + let session = create_account(&mut srv, &req, true).unwrap(); + Did::from_str(&session.did).unwrap() + }; + let carol_did = { + let req = com_atproto::AccountRequest { + email: "carol@bogus.com".to_string(), + handle: "carol.test".to_string(), + password: "bogus".to_string(), + inviteCode: None, + recoveryKey: None, + }; + let session = create_account(&mut srv, &req, true).unwrap(); + Did::from_str(&session.did).unwrap() + }; + + // all feeds and timelines should be empty + let alice_feed = bsky_get_author_feed(&mut srv, &alice_did).unwrap(); + let alice_timeline = bsky_get_timeline(&mut srv, &alice_did).unwrap(); + assert!(alice_feed.feed.is_empty()); + assert!(alice_timeline.feed.is_empty()); + let bob_feed = bsky_get_author_feed(&mut srv, &bob_did).unwrap(); + let bob_timeline = bsky_get_timeline(&mut srv, &bob_did).unwrap(); + assert!(bob_feed.feed.is_empty()); + assert!(bob_timeline.feed.is_empty()); + let carol_feed = bsky_get_author_feed(&mut srv, &carol_did).unwrap(); + let carol_timeline = bsky_get_timeline(&mut srv, &carol_did).unwrap(); + assert!(carol_feed.feed.is_empty()); + assert!(carol_timeline.feed.is_empty()); + + // alice does some posts + let alice_post1_tid = srv.tid_gen.next_tid(); + let alice_post2_tid = srv.tid_gen.next_tid(); + let alice_post3_tid = srv.tid_gen.next_tid(); + assert!(alice_post1_tid < alice_post2_tid && alice_post2_tid < alice_post3_tid); + let mutations = vec![ + Mutation::Create( + post_nsid.clone(), + alice_post1_tid.clone(), + ipld!({"text": "alice first post"}), + ), + Mutation::Create( + post_nsid.clone(), + alice_post2_tid.clone(), + ipld!({"text": "alice second post"}), + ), + Mutation::Create( + post_nsid.clone(), + alice_post3_tid.clone(), + ipld!({"text": "alice third post"}), + ), + ]; + srv.repo + .mutate_repo(&alice_did, &mutations, &srv.pds_keypair) + .unwrap(); + bsky_mutate_db(&mut srv.atp_db, &alice_did, mutations).unwrap(); + + // bob follows alice, likes first post, reposts second, replies third + let alice_post3_uri = format!( + "at://{}/{}/{}", + alice_did.to_string(), + post_nsid.to_string(), + alice_post3_tid.to_string() + ); + let mutations = vec![ + Mutation::Create( + follow_nsid.clone(), + srv.tid_gen.next_tid(), + ipld!({"subject": {"did": alice_did.to_string()}, "createdAt": created_at_now()}), + ), + Mutation::Create( + like_nsid.clone(), + srv.tid_gen.next_tid(), + ipld!({"subject": {"uri": format!("at://{}/{}/{}", alice_did.to_string(), post_nsid.to_string(), alice_post1_tid.to_string())}, "createdAt": created_at_now()}), + ), + Mutation::Create( + repost_nsid.clone(), + srv.tid_gen.next_tid(), + ipld!({"subject": {"uri": format!("at://{}/{}/{}", alice_did.to_string(), post_nsid.to_string(), alice_post2_tid.to_string())}, "createdAt": created_at_now()}), + ), + Mutation::Create( + post_nsid.clone(), + srv.tid_gen.next_tid(), + ipld!({"text": "bob comment on alice post3", "reply": {"parent": {"uri": alice_post3_uri.clone()}, "root": {"uri": alice_post3_uri.clone()}}}), + ), + ]; + srv.repo + .mutate_repo(&bob_did, &mutations, &srv.pds_keypair) + .unwrap(); + bsky_mutate_db(&mut srv.atp_db, &bob_did, mutations).unwrap(); + + // carol follows bob + let mutations = vec![Mutation::Create( + follow_nsid.clone(), + srv.tid_gen.next_tid(), + ipld!({"subject": {"did": bob_did.to_string()}, "createdAt": created_at_now()}), + )]; + srv.repo + .mutate_repo(&bob_did, &mutations, &srv.pds_keypair) + .unwrap(); + bsky_mutate_db(&mut srv.atp_db, &carol_did, mutations).unwrap(); + + // test alice profile: counts should be updated + let alice_profile = bsky_get_profile(&mut srv, &alice_did).unwrap(); + assert_eq!(alice_profile.followersCount, 1); + assert_eq!(alice_profile.followsCount, 0); + assert_eq!(alice_profile.postsCount, 3); + + // test alice timeline: still empty (?) + let alice_timeline = bsky_get_timeline(&mut srv, &alice_did).unwrap(); + println!("{:?}", alice_timeline); + assert!(alice_timeline.feed.is_empty()); + + // test alice feed: should have 3 posts, with correct counts + let alice_feed = bsky_get_author_feed(&mut srv, &alice_did).unwrap(); + assert_eq!(alice_feed.feed.len(), 3); + + assert_eq!( + alice_feed.feed[2].uri, + format!( + "at://{}/{}/{}", + alice_did.to_string(), + post_nsid.to_string(), + alice_post1_tid.to_string() + ) + ); + // TODO: CID + assert_eq!(alice_feed.feed[2].author.did, alice_did.to_string()); + assert_eq!(alice_feed.feed[2].author.handle, "alice.test"); + assert_eq!(alice_feed.feed[2].repostedBy, None); + assert_eq!( + alice_feed.feed[2].record["text"].as_str().unwrap(), + "alice first post" + ); + assert_eq!(alice_feed.feed[2].embed, None); + assert_eq!(alice_feed.feed[2].replyCount, 0); + assert_eq!(alice_feed.feed[2].repostCount, 0); + assert_eq!(alice_feed.feed[2].upvoteCount, 1); + assert_eq!(alice_feed.feed[2].downvoteCount, 0); + + assert_eq!(alice_feed.feed[1].author.did, alice_did.to_string()); + assert_eq!(alice_feed.feed[1].replyCount, 0); + assert_eq!(alice_feed.feed[1].repostCount, 1); + assert_eq!(alice_feed.feed[1].upvoteCount, 0); + + assert_eq!(alice_feed.feed[0].author.did, alice_did.to_string()); + assert_eq!(alice_feed.feed[0].replyCount, 1); + assert_eq!(alice_feed.feed[0].repostCount, 0); + assert_eq!(alice_feed.feed[0].upvoteCount, 0); + + // test bob timeline: should include alice posts + let bob_timeline = bsky_get_timeline(&mut srv, &bob_did).unwrap(); + println!("BOB TIMELINE ======"); + for item in bob_timeline.feed.iter() { + println!("{:?}", item); + } + assert_eq!(bob_timeline.feed.len(), 3); + assert_eq!( + bob_timeline.feed[2].uri, + format!( + "at://{}/{}/{}", + alice_did.to_string(), + post_nsid.to_string(), + alice_post1_tid.to_string() + ) + ); + // TODO: CID + assert_eq!(bob_timeline.feed[2].author.did, alice_did.to_string()); + assert_eq!(bob_timeline.feed[2].author.handle, "alice.test"); + + // test bob feed: should include repost and reply + let bob_feed = bsky_get_author_feed(&mut srv, &bob_did).unwrap(); + assert_eq!(bob_feed.feed.len(), 1); + // TODO: handle reposts + /* + assert_eq!(bob_feed.feed.len(), 2); + assert_eq!(bob_feed.feed[1].uri, format!("at://{}/{}/{}", alice_did.to_string(), post_nsid.to_string(), alice_post1_tid.to_string())); + // TODO: CID + assert_eq!(bob_feed.feed[1].author.did, alice_did.to_string()); + assert_eq!(bob_feed.feed[1].author.handle, "alice.test"); + assert_eq!(bob_feed.feed[1].repostedBy.as_ref().unwrap().did, bob_did.to_string()); + assert_eq!(bob_feed.feed[1].repostedBy.as_ref().unwrap().handle, "bob.test"); + // TODO: "is a repost" (check record?) + */ + + assert_eq!(bob_feed.feed[0].author.did, bob_did.to_string()); + assert_eq!(bob_feed.feed[0].author.handle, "bob.test"); + + // test carol timeline: should include bob's repost and reply + let carol_timeline = bsky_get_timeline(&mut srv, &carol_did).unwrap(); + // TODO: handle re-posts (+1 here) + assert_eq!(carol_timeline.feed.len(), 1); + // TODO: details + + // test carol feed: still empty + let carol_feed = bsky_get_author_feed(&mut srv, &carol_did).unwrap(); + assert!(carol_feed.feed.is_empty()); +} + +#[test] +fn test_bsky_thread() { + use crate::create_account; + use adenosine::com_atproto; + use libipld::ipld; + + let post_nsid = Nsid::from_str("app.bsky.feed.post").unwrap(); + + let mut srv = AtpService::new_ephemeral().unwrap(); + let alice_did = { + let req = com_atproto::AccountRequest { + email: "alice@bogus.com".to_string(), + handle: "alice.test".to_string(), + password: "bogus".to_string(), + inviteCode: None, + recoveryKey: None, + }; + let session = create_account(&mut srv, &req, true).unwrap(); + Did::from_str(&session.did).unwrap() + }; + let bob_did = { + let req = com_atproto::AccountRequest { + email: "bob@bogus.com".to_string(), + handle: "bob.test".to_string(), + password: "bogus".to_string(), + inviteCode: None, + recoveryKey: None, + }; + let session = create_account(&mut srv, &req, true).unwrap(); + Did::from_str(&session.did).unwrap() + }; + + // alice does a post + let alice_post1_tid = srv.tid_gen.next_tid(); + let mutations = vec![Mutation::Create( + post_nsid.clone(), + alice_post1_tid.clone(), + ipld!({"text": "alice first post"}), + )]; + srv.repo + .mutate_repo(&alice_did, &mutations, &srv.pds_keypair) + .unwrap(); + bsky_mutate_db(&mut srv.atp_db, &alice_did, mutations).unwrap(); + let alice_post1_uri = format!( + "at://{}/{}/{}", + alice_did.to_string(), + post_nsid.to_string(), + alice_post1_tid.to_string() + ); + + // bob likes and replies first post + let bob_post1_tid = srv.tid_gen.next_tid(); + let mutations = vec![Mutation::Create( + post_nsid.clone(), + bob_post1_tid.clone(), + ipld!({"text": "bob comment on alice post1", "reply": {"parent": {"uri": alice_post1_uri.clone()}, "root": {"uri": alice_post1_uri.clone()}}}), + )]; + srv.repo + .mutate_repo(&bob_did, &mutations, &srv.pds_keypair) + .unwrap(); + bsky_mutate_db(&mut srv.atp_db, &bob_did, mutations).unwrap(); + let bob_post1_uri = format!( + "at://{}/{}/{}", + bob_did.to_string(), + post_nsid.to_string(), + bob_post1_tid.to_string() + ); + + // alice replies to bob reply + let alice_post2_tid = srv.tid_gen.next_tid(); + let mutations = vec![Mutation::Create( + post_nsid.clone(), + alice_post2_tid.clone(), + ipld!({"text": "alice second post, replying to bob comment", "reply": {"parent": {"uri": bob_post1_uri.clone()}, "root": {"uri": alice_post1_uri.clone()}}}), + )]; + srv.repo + .mutate_repo(&alice_did, &mutations, &srv.pds_keypair) + .unwrap(); + bsky_mutate_db(&mut srv.atp_db, &alice_did, mutations).unwrap(); + let _alice_post2_uri = format!( + "at://{}/{}/{}", + alice_did.to_string(), + post_nsid.to_string(), + alice_post2_tid.to_string() + ); + + // get thread from bob's post + // TODO: should have both parent and children + let post = bsky_get_thread(&mut srv, &AtUri::from_str(&bob_post1_uri).unwrap(), None) + .unwrap() + .thread; + assert_eq!(post.author.did, bob_did.to_string()); + assert_eq!(post.author.handle, "bob.test".to_string()); + assert_eq!(post.embed, None); + assert_eq!(post.replyCount, 1); + assert_eq!(post.repostCount, 0); + assert_eq!(post.upvoteCount, 0); + assert_eq!(post.replies.as_ref().unwrap().len(), 1); + + let post_replies = post.replies.unwrap(); + assert_eq!(post_replies[0].author.did, alice_did.to_string()); + // TODO: root URI, etc +} diff --git a/adenosine-pds/src/lib.rs b/adenosine-pds/src/lib.rs index 07905f9..cca9b0d 100644 --- a/adenosine-pds/src/lib.rs +++ b/adenosine-pds/src/lib.rs @@ -2,33 +2,51 @@ use adenosine::created_at_now; use adenosine::identifiers::{AtUri, Did, Nsid, Ticker, Tid}; use anyhow::{anyhow, Result}; use askama::Template; -use libipld::Cid; -use libipld::Ipld; use log::{debug, error, info, warn}; use rouille::{router, Request, Response}; use serde_json::{json, Value}; -use std::collections::BTreeMap; use std::fmt; use std::io::Read; use std::path::PathBuf; use std::str::FromStr; use std::sync::Mutex; -mod bsky; mod db; -pub mod models; +mod db_bsky; mod web; -pub use adenosine::crypto::{KeyPair, PubKey}; -pub use adenosine::did; -pub use adenosine::did::DidDocMeta; -pub use adenosine::repo::{Mutation, RepoCommit, RepoStore}; -pub use adenosine::ucan_p256::P256KeyMaterial; -use bsky::*; -pub use db::AtpDatabase; -pub use models::*; +use adenosine::app_bsky; +use adenosine::com_atproto; +use adenosine::crypto::KeyPair; +use adenosine::ipld::{ipld_into_json_value, json_value_into_ipld}; +use adenosine::plc; +use adenosine::plc::DidDocMeta; +use adenosine::repo::{Mutation, RepoStore}; +use db::AtpDatabase; +use db_bsky::*; use web::*; +#[derive(Debug)] +pub enum XrpcError { + BadRequest(String), + NotFound(String), + Forbidden(String), + MutexPoisoned, +} + +impl std::error::Error for XrpcError {} + +impl fmt::Display for XrpcError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BadRequest(msg) | Self::NotFound(msg) | Self::Forbidden(msg) => { + write!(f, "{}", msg) + } + Self::MutexPoisoned => write!(f, "service mutex poisoned"), + } + } +} + pub struct AtpService { pub repo: RepoStore, pub atp_db: AtpDatabase, @@ -58,27 +76,6 @@ impl Default for AtpServiceConfig { } } -#[derive(Debug)] -enum XrpcError { - BadRequest(String), - NotFound(String), - Forbidden(String), - MutexPoisoned, -} - -impl std::error::Error for XrpcError {} - -impl fmt::Display for XrpcError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::BadRequest(msg) | Self::NotFound(msg) | Self::Forbidden(msg) => { - write!(f, "{}", msg) - } - Self::MutexPoisoned => write!(f, "service mutex poisoned"), - } - } -} - /// Helper to take an XRPC result (always a JSON object), and transform it to a rouille response fn xrpc_wrap(resp: Result) -> Response { match resp { @@ -256,49 +253,6 @@ impl AtpService { } } -/// Intentionally serializing with this instead of DAG-JSON, because ATP schemas don't encode CID -/// links in any special way, they just pass the CID as a string. -fn ipld_into_json_value(val: Ipld) -> Value { - match val { - Ipld::Null => Value::Null, - Ipld::Bool(b) => Value::Bool(b), - Ipld::Integer(v) => json!(v), - Ipld::Float(v) => json!(v), - Ipld::String(s) => Value::String(s), - Ipld::Bytes(b) => Value::String(data_encoding::BASE64_NOPAD.encode(&b)), - Ipld::List(l) => Value::Array(l.into_iter().map(ipld_into_json_value).collect()), - Ipld::Map(m) => Value::Object(serde_json::Map::from_iter( - m.into_iter().map(|(k, v)| (k, ipld_into_json_value(v))), - )), - Ipld::Link(c) => Value::String(c.to_string()), - } -} - -/// Crude reverse generation -/// -/// Does not handle base64 to bytes, and the link generation is pretty simple (object elements with -/// key "car"). Numbers always come through as f64 (float). -fn json_value_into_ipld(val: Value) -> Ipld { - match val { - Value::Null => Ipld::Null, - Value::Bool(b) => Ipld::Bool(b), - Value::String(s) => Ipld::String(s), - // TODO: handle numbers better? - Value::Number(v) => Ipld::Float(v.as_f64().unwrap()), - Value::Array(l) => Ipld::List(l.into_iter().map(json_value_into_ipld).collect()), - Value::Object(m) => { - let map: BTreeMap = BTreeMap::from_iter(m.into_iter().map(|(k, v)| { - if k == "car" && v.is_string() { - (k, Ipld::Link(Cid::from_str(v.as_str().unwrap()).unwrap())) - } else { - (k, json_value_into_ipld(v)) - } - })); - Ipld::Map(map) - } - } -} - fn xrpc_required_param(request: &Request, key: &str) -> Result { Ok(request.get_param(key).ok_or(XrpcError::BadRequest(format!( "require '{}' query parameter", @@ -424,7 +378,7 @@ fn xrpc_get_handler( let mut srv = srv.lock().or(Err(XrpcError::MutexPoisoned))?; let did_doc = srv.atp_db.get_did_doc(&did)?; let collections: Vec = srv.repo.collections(&did)?; - let desc = RepoDescribe { + let desc = com_atproto::repo::Describe { name: did.to_string(), // TODO: handle? did: did.to_string(), didDoc: did_doc, @@ -506,9 +460,9 @@ fn xrpc_get_repo_handler(srv: &Mutex, request: &Request) -> Result Result { +) -> Result { // check if account already exists (fast path, also confirmed by database schema) if srv.atp_db.account_exists(&req.handle, &req.email)? { Err(XrpcError::BadRequest( @@ -520,7 +474,7 @@ pub fn create_account( let (did, did_doc) = if create_did_plc { // generate DID - let create_op = did::CreateOp::new( + let create_op = plc::CreateOp::new( req.handle.clone(), srv.config.public_url.clone(), &srv.pds_keypair, @@ -576,7 +530,7 @@ fn xrpc_post_handler( match method { "com.atproto.account.create" => { // validate account request - let req: AccountRequest = rouille::input::json_input(request) + let req: com_atproto::AccountRequest = rouille::input::json_input(request) .map_err(|e| XrpcError::BadRequest(format!("failed to parse JSON body: {}", e)))?; // TODO: validate handle, email, recoverykey let mut srv = srv.lock().unwrap(); @@ -602,7 +556,7 @@ fn xrpc_post_handler( Ok(json!(sess)) } "com.atproto.session.create" => { - let req: SessionRequest = rouille::input::json_input(request) + let req: com_atproto::SessionRequest = rouille::input::json_input(request) .map_err(|e| XrpcError::BadRequest(format!("failed to parse JSON body: {}", e)))?; let mut srv = srv.lock().unwrap(); let keypair = srv.pds_keypair.clone(); @@ -628,7 +582,7 @@ fn xrpc_post_handler( .resolve_did(&did)? .expect("DID matches to a handle"); - Ok(json!(AtpSession { + Ok(json!(com_atproto::Session { did: did.to_string(), name: handle, accessJwt: jwt.to_string(), @@ -653,7 +607,7 @@ fn xrpc_post_handler( Ok(json!({})) } "com.atproto.repo.batchWrite" => { - let batch: RepoBatchWriteBody = rouille::input::json_input(request)?; + let batch: com_atproto::repo::BatchWriteBody = rouille::input::json_input(request)?; // TODO: validate edits against schemas let did = Did::from_str(&batch.did)?; let mut srv = srv.lock().unwrap(); @@ -690,7 +644,7 @@ fn xrpc_post_handler( } "com.atproto.repo.createRecord" => { // TODO: validate edits against schemas - let create: RepoCreateRecord = rouille::input::json_input(request)?; + let create: com_atproto::repo::CreateRecord = rouille::input::json_input(request)?; let did = Did::from_str(&create.did)?; let collection = Nsid::from_str(&create.collection)?; let mut srv = srv.lock().unwrap(); @@ -707,7 +661,7 @@ fn xrpc_post_handler( } "com.atproto.repo.putRecord" => { // TODO: validate edits against schemas - let put: RepoPutRecord = rouille::input::json_input(request)?; + let put: com_atproto::repo::PutRecord = rouille::input::json_input(request)?; let did = Did::from_str(&put.did)?; let collection = Nsid::from_str(&put.collection)?; let tid = Tid::from_str(&put.rkey)?; @@ -725,7 +679,7 @@ fn xrpc_post_handler( Ok(json!({})) } "com.atproto.repo.deleteRecord" => { - let delete: RepoDeleteRecord = rouille::input::json_input(request)?; + let delete: com_atproto::repo::DeleteRecord = rouille::input::json_input(request)?; let did = Did::from_str(&delete.did)?; let collection = Nsid::from_str(&delete.collection)?; let tid = Tid::from_str(&delete.rkey)?; @@ -754,7 +708,7 @@ fn xrpc_post_handler( } // =========== app.bsky methods "app.bsky.actor.updateProfile" => { - let profile: ProfileRecord = rouille::input::json_input(request)?; + let profile: app_bsky::ProfileRecord = rouille::input::json_input(request)?; let mut srv = srv.lock().unwrap(); let auth_did = &xrpc_check_auth_header(&mut srv, request, None)?; bsky_update_profile(&mut srv, auth_did, profile)?; @@ -865,7 +819,7 @@ fn repo_view_handler(srv: &Mutex, did: &str, request: &Request) -> R let commit_cid = &srv.repo.lookup_commit(&did)?.unwrap(); let commit = srv.repo.get_commit(commit_cid)?; let collections: Vec = srv.repo.collections(&did)?; - let desc = RepoDescribe { + let desc = com_atproto::repo::Describe { name: did.to_string(), // TODO did: did.to_string(), didDoc: did_doc, diff --git a/adenosine-pds/src/models.rs b/adenosine-pds/src/models.rs deleted file mode 100644 index f827a7a..0000000 --- a/adenosine-pds/src/models.rs +++ /dev/null @@ -1,246 +0,0 @@ -use serde_json::Value; - -// =========== com.atproto types (manually entered) - -#[allow(non_snake_case)] -#[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)] -pub struct AccountRequest { - pub email: String, - pub handle: String, - pub password: String, - pub inviteCode: Option, - pub recoveryKey: Option, -} - -#[allow(non_snake_case)] -#[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)] -pub struct SessionRequest { - pub handle: String, - pub password: String, -} - -#[allow(non_snake_case)] -#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] -pub struct AtpSession { - pub did: String, - pub name: String, - pub accessJwt: String, - pub refreshJwt: String, -} - -#[allow(non_snake_case)] -#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] -pub struct RepoDescribe { - pub name: String, - pub did: String, - pub didDoc: serde_json::Value, - pub collections: Vec, - pub nameIsCorrect: bool, -} - -#[allow(non_snake_case)] -#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] -pub struct RepoCreateRecord { - pub did: String, - pub collection: String, - pub record: serde_json::Value, -} - -#[allow(non_snake_case)] -#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] -pub struct RepoPutRecord { - pub did: String, - pub collection: String, - pub rkey: String, - pub record: serde_json::Value, -} - -#[allow(non_snake_case)] -#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] -pub struct RepoDeleteRecord { - pub did: String, - pub collection: String, - pub rkey: String, -} - -#[allow(non_snake_case)] -#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] -pub struct RepoBatchWriteBody { - pub did: String, - pub writes: Vec, -} - -#[allow(non_snake_case)] -#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] -pub struct RepoBatchWrite { - #[serde(rename = "type")] - pub op_type: String, - pub collection: String, - pub rkey: Option, - pub value: serde_json::Value, -} - -// =========== app.bsky types (manually entered) - -#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] -pub struct Subject { - pub uri: String, - // TODO: CID is required - pub cid: Option, -} - -/// Generic over Re-post and Like -#[allow(non_snake_case)] -#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] -pub struct RefRecord { - pub subject: Subject, - pub createdAt: String, -} - -#[allow(non_snake_case)] -#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] -pub struct FollowSubject { - pub did: String, - // pub declarationCid: String, -} - -#[allow(non_snake_case)] -#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] -pub struct FollowRecord { - pub subject: FollowSubject, - pub createdAt: String, -} - -#[allow(non_snake_case)] -#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] -pub struct ProfileRecord { - pub displayName: String, - pub description: Option, -} - -// app.bsky.system.actorUser or app.bsky.system.actorScene -#[allow(non_snake_case)] -#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] -pub struct Declaration { - pub actorType: String, -} - -// actorType: app.bsky.system.actorUser -// cid: bafyreid27zk7lbis4zw5fz4podbvbs4fc5ivwji3dmrwa6zggnj4bnd57u -#[allow(non_snake_case)] -#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] -pub struct DeclRef { - pub actorType: String, - pub cid: String, -} - -#[allow(non_snake_case)] -#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] -pub struct Profile { - pub did: String, - pub declaration: DeclRef, - pub handle: String, - // for simple accounts, 'creator' is just the did - pub creator: String, - pub displayName: Option, - pub description: Option, - pub followersCount: u64, - pub followsCount: u64, - pub membersCount: u64, - pub postsCount: u64, - pub myState: serde_json::Value, -} - -#[allow(non_snake_case)] -#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] -pub struct GenericFeed { - pub feed: Vec, -} - -#[allow(non_snake_case)] -#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] -pub struct User { - pub did: String, - pub handle: String, - pub displayName: Option, -} - -#[allow(non_snake_case)] -#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] -pub struct FeedItem { - pub uri: String, - pub cid: String, - pub author: User, - pub repostedBy: Option, - pub record: Value, - //pub embed?: RecordEmbed | ExternalEmbed | UnknownEmbed, - pub embed: Option, - pub replyCount: u64, - pub repostCount: u64, - pub upvoteCount: u64, - pub downvoteCount: u64, - pub indexedAt: String, - pub myState: Option, -} - -#[allow(non_snake_case)] -#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] -pub struct Post { - pub text: String, - pub reply: Option, - pub createdAt: Option, -} - -#[allow(non_snake_case)] -#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] -pub struct PostReply { - pub parent: Subject, - pub root: Subject, -} - -#[allow(non_snake_case)] -#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] -pub struct PostThread { - pub thread: ThreadItem, -} - -// TODO: 'parent' and 'replies' should allow "NotFoundPost" for references that point to an unknown -// URI -#[allow(non_snake_case)] -#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] -pub struct ThreadItem { - pub uri: String, - pub cid: String, - pub author: User, - pub record: Value, - //pub embed?: RecordEmbed | ExternalEmbed | UnknownEmbed, - pub embed: Option, - pub parent: Option>, - pub replyCount: u64, - pub replies: Option>, - pub repostCount: u64, - pub upvoteCount: u64, - pub downvoteCount: u64, - pub indexedAt: String, - pub myState: Option, -} - -#[allow(non_snake_case)] -#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] -pub struct FollowTarget { - // TODO: nested follow list? - pub subject: Subject, - pub did: String, - pub handle: String, - pub displayName: Option, - pub createdAt: Option, - pub indexedAt: String, -} - -#[allow(non_snake_case)] -#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] -pub struct Follow { - // TODO: nested follow list? - pub subject: Subject, - pub follows: FollowTarget, -} diff --git a/adenosine-pds/src/web.rs b/adenosine-pds/src/web.rs index 0d80b4e..8f15a49 100644 --- a/adenosine-pds/src/web.rs +++ b/adenosine-pds/src/web.rs @@ -1,4 +1,5 @@ -use crate::models::*; +use adenosine::app_bsky; +use adenosine::com_atproto; use adenosine::identifiers::{Did, Nsid, Tid}; use adenosine::repo::RepoCommit; use askama::Template; @@ -28,8 +29,8 @@ pub struct AboutView { pub struct AccountView { pub domain: String, pub did: Did, - pub profile: Profile, - pub feed: Vec, + pub profile: app_bsky::Profile, + pub feed: Vec, } #[derive(Template)] @@ -39,7 +40,7 @@ pub struct ThreadView { pub did: Did, pub collection: Nsid, pub tid: Tid, - pub post: ThreadItem, + pub post: app_bsky::ThreadItem, } #[derive(Template)] @@ -48,7 +49,7 @@ pub struct RepoView { pub domain: String, pub did: Did, pub commit: RepoCommit, - pub describe: RepoDescribe, + pub describe: com_atproto::repo::Describe, } #[derive(Template)] diff --git a/adenosine-pds/tests/bigger.car b/adenosine-pds/tests/bigger.car deleted file mode 100644 index 7169013..0000000 Binary files a/adenosine-pds/tests/bigger.car and /dev/null differ diff --git a/adenosine-pds/tests/example_repo.car b/adenosine-pds/tests/example_repo.car deleted file mode 100644 index b2ae723..0000000 Binary files a/adenosine-pds/tests/example_repo.car and /dev/null differ diff --git a/adenosine-pds/tests/test_mst_interop.rs b/adenosine-pds/tests/test_mst_interop.rs deleted file mode 100644 index 8a5becc..0000000 --- a/adenosine-pds/tests/test_mst_interop.rs +++ /dev/null @@ -1,152 +0,0 @@ -use adenosine_pds::RepoStore; -use libipld::Cid; -use std::collections::BTreeMap; -use std::str::FromStr; - -#[test] -fn test_known_maps() { - let mut repo = RepoStore::open_ephemeral().unwrap(); - let cid1 = - Cid::from_str("bafyreie5cvv4h45feadgeuwhbcutmh6t2ceseocckahdoe6uat64zmz454").unwrap(); - - let empty_map: BTreeMap = Default::default(); - assert_eq!( - repo.mst_from_map(&empty_map).unwrap().to_string(), - "bafyreie5737gdxlw5i64vzichcalba3z2v5n6icifvx5xytvske7mr3hpm" - ); - - let mut trivial_map: BTreeMap = Default::default(); - trivial_map.insert("asdf".to_string(), cid1.clone()); - assert_eq!( - repo.mst_from_map(&trivial_map).unwrap().to_string(), - "bafyreidaftbr35xhh4lzmv5jcoeufqjh75ohzmz6u56v7n2ippbtxdgqqe" - ); - - let mut singlelayer2_map: BTreeMap = Default::default(); - singlelayer2_map.insert("com.example.record/9ba1c7247ede".to_string(), cid1.clone()); - assert_eq!( - repo.mst_from_map(&singlelayer2_map).unwrap().to_string(), - "bafyreid4g5smj6ukhrjasebt6myj7wmtm2eijouteoyueoqgoh6vm5jkae" - ); - - let mut simple_map: BTreeMap = Default::default(); - simple_map.insert("asdf".to_string(), cid1.clone()); - simple_map.insert("88bfafc7".to_string(), cid1.clone()); - simple_map.insert("2a92d355".to_string(), cid1.clone()); - simple_map.insert("app.bsky.feed.post/454397e440ec".to_string(), cid1.clone()); - simple_map.insert("app.bsky.feed.post/9adeb165882c".to_string(), cid1.clone()); - assert_eq!( - repo.mst_from_map(&simple_map).unwrap().to_string(), - "bafyreiecb33zh7r2sc3k2wthm6exwzfktof63kmajeildktqc25xj6qzx4" - ); -} - -// TODO: behavior of these wide-char keys is undefined behavior in string MST -#[ignore] -#[test] -fn test_tricky_map() { - let mut repo = RepoStore::open_ephemeral().unwrap(); - let cid1 = - Cid::from_str("bafyreie5cvv4h45feadgeuwhbcutmh6t2ceseocckahdoe6uat64zmz454").unwrap(); - - let mut tricky_map: BTreeMap = Default::default(); - tricky_map.insert("".to_string(), cid1.clone()); - tricky_map.insert("jalapeño".to_string(), cid1.clone()); - tricky_map.insert("coöperative".to_string(), cid1.clone()); - tricky_map.insert("coüperative".to_string(), cid1.clone()); - tricky_map.insert("abc\x00".to_string(), cid1.clone()); - assert_eq!( - repo.mst_from_map(&tricky_map).unwrap().to_string(), - "bafyreiecb33zh7r2sc3k2wthm6exwzfktof63kmajeildktqc25xj6qzx4" - ); -} - -#[test] -fn test_trims_top() { - // "trims top of tree on delete" - - use adenosine_pds::mst::print_mst_keys; - let mut repo = RepoStore::open_ephemeral().unwrap(); - let cid1 = - Cid::from_str("bafyreie5cvv4h45feadgeuwhbcutmh6t2ceseocckahdoe6uat64zmz454").unwrap(); - let l1root = "bafyreihuyj2vzb2vjw3yhxg6dy25achg5fmre6gg5m6fjtxn64bqju4dee"; - let l0root = "bafyreibmijjc63mekkjzl3v2pegngwke5u6cu66g75z6uw27v64bc6ahqi"; - - // NOTE: this test doesn't do much in this case of rust implementation - let mut trim_map: BTreeMap = Default::default(); - trim_map.insert("com.example.record/40c73105b48f".to_string(), cid1.clone()); // level 0 - trim_map.insert("com.example.record/e99bf3ced34b".to_string(), cid1.clone()); // level 0 - trim_map.insert("com.example.record/893e6c08b450".to_string(), cid1.clone()); // level 0 - trim_map.insert("com.example.record/9cd8b6c0cc02".to_string(), cid1.clone()); // level 0 - trim_map.insert("com.example.record/cbe72d33d12a".to_string(), cid1.clone()); // level 0 - trim_map.insert("com.example.record/a15e33ba0f6c".to_string(), cid1.clone()); // level 1 - let trim_before_cid = repo.mst_from_map(&trim_map).unwrap(); - print_mst_keys(&mut repo.db, &trim_before_cid).unwrap(); - assert_eq!(trim_before_cid.to_string(), l1root); - - // NOTE: if we did mutations in-place, this is where we would mutate - - trim_map.remove("com.example.record/a15e33ba0f6c"); - let trim_after_cid = repo.mst_from_map(&trim_map).unwrap(); - assert_eq!(trim_after_cid.to_string(), l0root); -} - -#[test] -fn test_insertion() { - // "handles insertion that splits two layers down" - - let mut repo = RepoStore::open_ephemeral().unwrap(); - let cid1 = - Cid::from_str("bafyreie5cvv4h45feadgeuwhbcutmh6t2ceseocckahdoe6uat64zmz454").unwrap(); - let l1root = "bafyreiagt55jzvkenoa4yik77dhomagq2uj26ix4cijj7kd2py2u3s43ve"; - let l2root = "bafyreiddrz7qbvfattp5dzzh4ldohsaobatsg7f5l6awxnmuydewq66qoa"; - - // TODO: actual mutation instead of rebuild from scratch - let mut insertion_map: BTreeMap = Default::default(); - insertion_map.insert("com.example.record/403e2aeebfdb".to_string(), cid1.clone()); // A; level 0 - insertion_map.insert("com.example.record/40c73105b48f".to_string(), cid1.clone()); // B; level 0 - insertion_map.insert("com.example.record/645787eb4316".to_string(), cid1.clone()); // C; level 0 - insertion_map.insert("com.example.record/7ca4e61d6fbc".to_string(), cid1.clone()); // D; level 1 - insertion_map.insert("com.example.record/893e6c08b450".to_string(), cid1.clone()); // E; level 0 - insertion_map.insert("com.example.record/9cd8b6c0cc02".to_string(), cid1.clone()); // G; level 0 - insertion_map.insert("com.example.record/cbe72d33d12a".to_string(), cid1.clone()); // H; level 0 - insertion_map.insert("com.example.record/dbea731be795".to_string(), cid1.clone()); // I; level 1 - insertion_map.insert("com.example.record/e2ef555433f2".to_string(), cid1.clone()); // J; level 0 - insertion_map.insert("com.example.record/e99bf3ced34b".to_string(), cid1.clone()); // K; level 0 - insertion_map.insert("com.example.record/f728ba61e4b6".to_string(), cid1.clone()); // L; level 0 - let insertion_before_cid = repo.mst_from_map(&insertion_map).unwrap(); - assert_eq!(insertion_before_cid.to_string(), l1root); - - insertion_map.insert("com.example.record/9ba1c7247ede".to_string(), cid1.clone()); - let insertion_after_cid = repo.mst_from_map(&insertion_map).unwrap(); - assert_eq!(insertion_after_cid.to_string(), l2root); -} - -#[test] -fn test_higher_layers() { - // "handles new layers that are two higher than existing" - - use adenosine_pds::mst::print_mst_keys; - let mut repo = RepoStore::open_ephemeral().unwrap(); - let cid1 = - Cid::from_str("bafyreie5cvv4h45feadgeuwhbcutmh6t2ceseocckahdoe6uat64zmz454").unwrap(); - let l0root = "bafyreicivoa3p3ttcebdn2zfkdzenkd2uk3gxxlaz43qvueeip6yysvq2m"; - let l2root = "bafyreidwoqm6xlewxzhrx6ytbyhsazctlv72txtmnd4au6t53z2vpzn7wa"; - let l2root2 = "bafyreiapru27ce4wdlylk5revtr3hewmxhmt3ek5f2ypioiivmdbv5igrm"; - - // TODO: actual mutation instead of rebuild from scratch - let mut higher_map: BTreeMap = Default::default(); - higher_map.insert("com.example.record/403e2aeebfdb".to_string(), cid1.clone()); // A; level 0 - higher_map.insert("com.example.record/cbe72d33d12a".to_string(), cid1.clone()); // C; level 0 - let higher_before_cid = repo.mst_from_map(&higher_map).unwrap(); - assert_eq!(higher_before_cid.to_string(), l0root); - - higher_map.insert("com.example.record/9ba1c7247ede".to_string(), cid1.clone()); // B; level 2 - let higher_after_cid = repo.mst_from_map(&higher_map).unwrap(); - print_mst_keys(&mut repo.db, &higher_after_cid).unwrap(); - assert_eq!(higher_after_cid.to_string(), l2root); - - higher_map.insert("com.example.record/fae7a851fbeb".to_string(), cid1.clone()); // D; level 1 - let higher_after_cid = repo.mst_from_map(&higher_map).unwrap(); - assert_eq!(higher_after_cid.to_string(), l2root2); -} diff --git a/adenosine-pds/tests/test_repro_mst.rs b/adenosine-pds/tests/test_repro_mst.rs deleted file mode 100644 index df88559..0000000 --- a/adenosine-pds/tests/test_repro_mst.rs +++ /dev/null @@ -1,26 +0,0 @@ -use adenosine_pds::RepoStore; -use std::path::PathBuf; -use std::str::FromStr; - -#[test] -fn test_repro_mst() { - let mut repo = RepoStore::open_ephemeral().unwrap(); - let cid = repo - .import_car_path( - &PathBuf::from_str("./tests/example_repo.car").unwrap(), - None, - ) - .unwrap(); - repo.verify_repo_mst(&cid).unwrap(); - let cid = repo - .import_car_path(&PathBuf::from_str("./tests/bigger.car").unwrap(), None) - .unwrap(); - repo.verify_repo_mst(&cid).unwrap(); - - // test round-tripping from export - let car_bytes = repo.export_car(&cid, None).unwrap(); - let mut other_repo = RepoStore::open_ephemeral().unwrap(); - let other_cid = other_repo.import_car_bytes(&car_bytes, None).unwrap(); - other_repo.verify_repo_mst(&cid).unwrap(); - assert_eq!(cid, other_cid); -} diff --git a/adenosine/Cargo.toml b/adenosine/Cargo.toml index 456b0fe..f4705ae 100644 --- a/adenosine/Cargo.toml +++ b/adenosine/Cargo.toml @@ -15,33 +15,32 @@ repository = "https://gitlab.com/bnewbold/adenosine" [dependencies] # NOTE: could try 'rustls-tls' feature instead of default native TLS? -reqwest = { version = "0.11", features = ["blocking", "json"] } -serde = { version = "1", features = ["serde_derive"] } -serde_json = "1" +anyhow = "1" +async-trait = "0.1" base64 = "0.13" -regex = "1" -lazy_static = "1" +bs58 = "0.4" data-encoding = "2" +futures = "0.3" +ipfs-sqlite-block-store = "0.13" +lazy_static = "1" +libipld = { version = "0.14", features = ["dag-cbor", "derive"] } +log = "0.4" +multibase = "0.9" rand = "0.8" +regex = "1" +reqwest = { version = "0.11", features = ["blocking", "json"] } +serde = { version = "1", features = ["serde_derive"] } +serde_json = "1" +sha256 = "1" time = { version = "0.3", features = ["formatting"] } +tokio = { version = "1", features = ["full"] } + +# crypto/auth stuff k256 = { version = "0.11", features = ["ecdsa"] } p256 = { version = "0.11", features = ["ecdsa"] } -bs58 = "0.4" -libipld = "0.14" -ipfs-sqlite-block-store = "0.13" -tokio = { version = "1", features = ["full"] } -futures = "0.3" -sha256 = "1" -multibase = "0.9" ucan = "0.7.0-alpha.1" -async-trait = "0.1" # for vendored iroh-car -thiserror = "1.0" integer-encoding = { version = "3", features = ["tokio_async"] } multihash = "0.16" - -# uncertain about these... -anyhow = "1" -log = "0.4" -env_logger = "0.7" +thiserror = "1.0" diff --git a/adenosine/src/app_bsky/mod.rs b/adenosine/src/app_bsky/mod.rs new file mode 100644 index 0000000..18a0449 --- /dev/null +++ b/adenosine/src/app_bsky/mod.rs @@ -0,0 +1,165 @@ +/// app.bsky types (manually entered) +use serde_json::Value; + +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct Subject { + pub uri: String, + // TODO: CID is required + pub cid: Option, +} + +/// Generic over Re-post and Like +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct RefRecord { + pub subject: Subject, + pub createdAt: String, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct FollowSubject { + pub did: String, + // pub declarationCid: String, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct FollowRecord { + pub subject: FollowSubject, + pub createdAt: String, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct ProfileRecord { + pub displayName: String, + pub description: Option, +} + +// app.bsky.system.actorUser or app.bsky.system.actorScene +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct Declaration { + pub actorType: String, +} + +// actorType: app.bsky.system.actorUser +// cid: bafyreid27zk7lbis4zw5fz4podbvbs4fc5ivwji3dmrwa6zggnj4bnd57u +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct DeclRef { + pub actorType: String, + pub cid: String, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct Profile { + pub did: String, + pub declaration: DeclRef, + pub handle: String, + // for simple accounts, 'creator' is just the did + pub creator: String, + pub displayName: Option, + pub description: Option, + pub followersCount: u64, + pub followsCount: u64, + pub membersCount: u64, + pub postsCount: u64, + pub myState: serde_json::Value, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct GenericFeed { + pub feed: Vec, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct User { + pub did: String, + pub handle: String, + pub displayName: Option, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct FeedItem { + pub uri: String, + pub cid: String, + pub author: User, + pub repostedBy: Option, + pub record: Value, + //pub embed?: RecordEmbed | ExternalEmbed | UnknownEmbed, + pub embed: Option, + pub replyCount: u64, + pub repostCount: u64, + pub upvoteCount: u64, + pub downvoteCount: u64, + pub indexedAt: String, + pub myState: Option, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct Post { + pub text: String, + pub reply: Option, + pub createdAt: Option, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct PostReply { + pub parent: Subject, + pub root: Subject, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct PostThread { + pub thread: ThreadItem, +} + +// TODO: 'parent' and 'replies' should allow "NotFoundPost" for references that point to an unknown +// URI +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct ThreadItem { + pub uri: String, + pub cid: String, + pub author: User, + pub record: Value, + //pub embed?: RecordEmbed | ExternalEmbed | UnknownEmbed, + pub embed: Option, + pub parent: Option>, + pub replyCount: u64, + pub replies: Option>, + pub repostCount: u64, + pub upvoteCount: u64, + pub downvoteCount: u64, + pub indexedAt: String, + pub myState: Option, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct FollowTarget { + // TODO: nested follow list? + pub subject: Subject, + pub did: String, + pub handle: String, + pub displayName: Option, + pub createdAt: Option, + pub indexedAt: String, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct Follow { + // TODO: nested follow list? + pub subject: Subject, + pub follows: FollowTarget, +} diff --git a/adenosine/src/auth.rs b/adenosine/src/auth.rs new file mode 100644 index 0000000..ff931ef --- /dev/null +++ b/adenosine/src/auth.rs @@ -0,0 +1,37 @@ +use anyhow::anyhow; +pub use anyhow::Result; +use serde_json::Value; + +/// Tries to parse a DID internal identifier from a JWT (as base64-encoded token) +pub fn parse_did_from_jwt(jwt: &str) -> Result { + let second_b64 = jwt.split('.').nth(1).ok_or(anyhow!("couldn't parse JWT"))?; + let second_json: Vec = base64::decode_config(second_b64, base64::URL_SAFE)?; + let obj: Value = serde_json::from_slice(&second_json)?; + // trying to also support pulling "aud" as DID; not sure this is actually correct use of + // UCAN/JWT semantics? + let did = obj["sub"] + .as_str() + .or(obj["aud"].as_str()) + .ok_or(anyhow!("couldn't find DID subject in JWT"))? + .to_string(); + if !did.starts_with("did:") { + return Err(anyhow!("couldn't find DID subject in JWT")); + } + Ok(did) +} + +#[test] +fn test_parse_jwt() { + assert!(parse_did_from_jwt(".").is_err()); + // JWT from atproto ("sub") + assert_eq!( + parse_did_from_jwt("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWQ6cGxjOmV4M3NpNTI3Y2QyYW9nYnZpZGtvb296YyIsImlhdCI6MTY2NjgyOTM5M30.UvZgTqvaJICONa1wIUT1bny7u3hqVAqWhWy3qeuyZrE").unwrap(), + "did:plc:ex3si527cd2aogbvidkooozc", + ); + // UCAN from adenosine-pds ("aud") + assert_eq!( + parse_did_from_jwt("eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsInVjdiI6IjAuOS4wLWNhbmFyeSJ9.eyJhdHQiOltdLCJhdWQiOiJkaWQ6cGxjOnM3b25ieWphN2MzeXJzZ3Zob2xrbHM1YiIsImV4cCI6MTY3NTM4Mzg2NywiZmN0IjpbXSwiaXNzIjoiZGlkOmtleTp6RG5hZWRHVGJkb0Frb1NlOG96a3k1WHAzMjZTVFpUSm50aDlHY2dxaTZQYjNzYjczIiwibm5jIjoiTnZURDhENWZjNXFpalIyMWJ1V2Z1ZE02dzlBM2drSy1ac3RtUW03b21pdyIsInByZiI6W119.QwZkb9R17tNhXnY_roqFYgdiIgUnSC18FYWQb3PcH6BU1R5l4W_T4XdACyczPGfM-jAnF2r2loBXDntYVS6N5A").unwrap(), + "did:plc:s7onbyja7c3yrsgvholkls5b", + ); + assert!(parse_did_from_jwt("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9").is_err()); +} diff --git a/adenosine/src/com_atproto/mod.rs b/adenosine/src/com_atproto/mod.rs new file mode 100644 index 0000000..8e2317a --- /dev/null +++ b/adenosine/src/com_atproto/mod.rs @@ -0,0 +1,29 @@ +// com.atproto types (manually entered) + +pub mod repo; + +#[allow(non_snake_case)] +#[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)] +pub struct AccountRequest { + pub email: String, + pub handle: String, + pub password: String, + pub inviteCode: Option, + pub recoveryKey: Option, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)] +pub struct SessionRequest { + pub handle: String, + pub password: String, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct Session { + pub did: String, + pub name: String, + pub accessJwt: String, + pub refreshJwt: String, +} diff --git a/adenosine/src/com_atproto/repo/mod.rs b/adenosine/src/com_atproto/repo/mod.rs new file mode 100644 index 0000000..aa66e98 --- /dev/null +++ b/adenosine/src/com_atproto/repo/mod.rs @@ -0,0 +1,53 @@ +/// com.atproto.repo types (manually entered) + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct Describe { + pub name: String, + pub did: String, + pub didDoc: serde_json::Value, + pub collections: Vec, + pub nameIsCorrect: bool, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct CreateRecord { + pub did: String, + pub collection: String, + pub record: serde_json::Value, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct PutRecord { + pub did: String, + pub collection: String, + pub rkey: String, + pub record: serde_json::Value, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct DeleteRecord { + pub did: String, + pub collection: String, + pub rkey: String, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct BatchWriteBody { + pub did: String, + pub writes: Vec, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct BatchWrite { + #[serde(rename = "type")] + pub op_type: String, + pub collection: String, + pub rkey: Option, + pub value: serde_json::Value, +} diff --git a/adenosine/src/did.rs b/adenosine/src/did.rs deleted file mode 100644 index c7d7d10..0000000 --- a/adenosine/src/did.rs +++ /dev/null @@ -1,368 +0,0 @@ -use crate::crypto::{KeyPair, PubKey}; -/// DID and 'did:plc' stuff -/// -/// This is currently a partial/skeleton implementation, which only generates local/testing did:plc -/// DIDs (and DID documents) using a single 'create' genesis block. Key rotation, etc, is not -/// supported. -use crate::identifiers::Did; -use anyhow::Result; -use libipld::cbor::DagCborCodec; -use libipld::multihash::Code; -use libipld::{Block, Cid, DagCbor, DefaultParams}; -use serde_json::json; -use std::str::FromStr; - -#[allow(non_snake_case)] -#[derive(Debug, DagCbor, PartialEq, Eq, Clone)] -pub struct CreateOp { - #[ipld(rename = "type")] - pub op_type: String, - pub signingKey: String, - pub recoveryKey: String, - pub username: String, - pub service: String, - pub prev: Option, - pub sig: String, -} - -#[allow(non_snake_case)] -#[derive(Debug, DagCbor, PartialEq, Eq, Clone)] -struct UnsignedCreateOp { - #[ipld(rename = "type")] - pub op_type: String, - pub signingKey: String, - pub recoveryKey: String, - pub username: String, - pub service: String, - pub prev: Option, -} - -impl UnsignedCreateOp { - fn into_signed(self, sig: String) -> CreateOp { - CreateOp { - op_type: self.op_type, - prev: self.prev, - sig, - signingKey: self.signingKey, - recoveryKey: self.recoveryKey, - username: self.username, - service: self.service, - } - } -} - -impl CreateOp { - pub fn new( - username: String, - atp_pds: String, - keypair: &KeyPair, - recovery_key: Option, - ) -> Self { - let signing_key = keypair.pubkey().to_did_key(); - let recovery_key = recovery_key.unwrap_or(signing_key.clone()); - let unsigned = UnsignedCreateOp { - op_type: "create".to_string(), - prev: None, - signingKey: signing_key, - recoveryKey: recovery_key, - username, - service: atp_pds, - }; - let block = Block::::encode(DagCborCodec, Code::Sha2_256, &unsigned) - .expect("encode DAG-CBOR"); - let sig = keypair.sign_bytes(block.data()); - unsigned.into_signed(sig) - } - - pub fn did_plc(&self) -> Did { - // dump DAG-CBOR - let block = Block::::encode(DagCborCodec, Code::Sha2_256, self) - .expect("encode DAG-CBOR"); - let bin = block.data(); - // hash SHA-256 - let digest_bytes: Vec = data_encoding::HEXLOWER - .decode(sha256::digest(bin).as_bytes()) - .expect("SHA-256 digest is always hex string"); - // encode base32 - let digest_b32 = data_encoding::BASE32_NOPAD - .encode(&digest_bytes) - .to_ascii_lowercase(); - // truncate - Did::from_str(&format!("did:plc:{}", &digest_b32[0..24])).unwrap() - } - - fn into_unsigned(self) -> UnsignedCreateOp { - UnsignedCreateOp { - op_type: self.op_type, - prev: self.prev, - signingKey: self.signingKey, - recoveryKey: self.recoveryKey, - username: self.username, - service: self.service, - } - } - - pub fn did_doc(&self) -> serde_json::Value { - let meta = DidDocMeta { - did: self.did_plc(), - // TODO - user_url: format!("https://{}", self.username), - service_url: self.service.clone(), - recovery_didkey: self.recoveryKey.clone(), - signing_didkey: self.signingKey.clone(), - }; - meta.did_doc() - } - - /// This method only makes sense on the "genesis" create object - pub fn verify_self(&self) -> Result<()> { - let key = PubKey::from_did_key(&self.signingKey)?; - let unsigned = { - let cpy = (*self).clone(); - cpy.into_unsigned() - }; - //println!("unsigned: {:?}", unsigned); - let block = Block::::encode(DagCborCodec, Code::Sha2_256, &unsigned) - .expect("encode DAG-CBOR"); - key.verify_bytes(block.data(), &self.sig) - } -} - -#[derive(Debug, PartialEq, Eq, Clone)] -pub struct DidDocMeta { - pub did: Did, - pub user_url: String, - pub service_url: String, - pub recovery_didkey: String, - pub signing_didkey: String, -} - -impl DidDocMeta { - pub fn did_doc(&self) -> serde_json::Value { - let key_type = "EcdsaSecp256r1VerificationKey2019"; - json!({ - "@context": [ - "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/suites/ecdsa-2019/v1" - ], - "id": self.did.to_string(), - "alsoKnownAs": [ self.user_url ], - "verificationMethod": [ - { - "id": format!("{}#signingKey)", self.did), - "type": key_type, - "controller": self.did.to_string(), - "publicKeyMultibase": self.signing_didkey - }, - { - "id": format!("{}#recoveryKey)", self.did), - "type": key_type, - "controller": self.did.to_string(), - "publicKeyMultibase": self.recovery_didkey - } - ], - "assertionMethod": [ format!("{}#signingKey)", self.did)], - "capabilityInvocation": [ format!("{}#signingKey)", self.did) ], - "capabilityDelegation": [ format!("{}#signingKey)", self.did) ], - "service": [ - { - "id": format!("{}#atpPds)", self.did), - "type": "AtpPersonalDataServer", - "serviceEndpoint": self.service_url - } - ] - }) - } -} - -#[test] -fn test_debug_did_signing() { - let op = UnsignedCreateOp { - op_type: "create".to_string(), - signingKey: "did:key:zDnaeSWVQyW8DSF6mDwT9j8YrzDWDs8h6PPjuTcipzG84iCBE".to_string(), - recoveryKey: "did:key:zDnaeSWVQyW8DSF6mDwT9j8YrzDWDs8h6PPjuTcipzG84iCBE".to_string(), - username: "carla.test".to_string(), - service: "http://localhost:2583".to_string(), - prev: None, - }; - let block = - Block::::encode(DagCborCodec, Code::Sha2_256, &op).expect("encode DAG-CBOR"); - let op_bytes = block.data(); - - let _key_bytes = vec![ - 4, 30, 224, 8, 198, 84, 108, 1, 58, 193, 91, 176, 212, 45, 4, 36, 28, 252, 242, 95, 20, 85, - 87, 246, 79, 134, 42, 113, 5, 216, 238, 235, 21, 146, 16, 88, 239, 217, 36, 252, 148, 197, - 203, 22, 29, 2, 52, 152, 77, 208, 21, 88, 2, 85, 219, 212, 148, 139, 104, 200, 15, 119, 46, - 178, 186, - ]; - - let pub_key = - PubKey::from_did_key("did:key:zDnaeSWVQyW8DSF6mDwT9j8YrzDWDs8h6PPjuTcipzG84iCBE").unwrap(); - //let keypair = KeyPair::from_bytes(&key_bytes).unwrap(); - //assert_eq!(keypair.to_bytes(), key_bytes); - - let encoded_bytes = vec![ - 166, 100, 112, 114, 101, 118, 246, 100, 116, 121, 112, 101, 102, 99, 114, 101, 97, 116, - 101, 103, 115, 101, 114, 118, 105, 99, 101, 117, 104, 116, 116, 112, 58, 47, 47, 108, 111, - 99, 97, 108, 104, 111, 115, 116, 58, 50, 53, 56, 51, 104, 117, 115, 101, 114, 110, 97, 109, - 101, 106, 99, 97, 114, 108, 97, 46, 116, 101, 115, 116, 106, 115, 105, 103, 110, 105, 110, - 103, 75, 101, 121, 120, 57, 100, 105, 100, 58, 107, 101, 121, 58, 122, 68, 110, 97, 101, - 83, 87, 86, 81, 121, 87, 56, 68, 83, 70, 54, 109, 68, 119, 84, 57, 106, 56, 89, 114, 122, - 68, 87, 68, 115, 56, 104, 54, 80, 80, 106, 117, 84, 99, 105, 112, 122, 71, 56, 52, 105, 67, - 66, 69, 107, 114, 101, 99, 111, 118, 101, 114, 121, 75, 101, 121, 120, 57, 100, 105, 100, - 58, 107, 101, 121, 58, 122, 68, 110, 97, 101, 83, 87, 86, 81, 121, 87, 56, 68, 83, 70, 54, - 109, 68, 119, 84, 57, 106, 56, 89, 114, 122, 68, 87, 68, 115, 56, 104, 54, 80, 80, 106, - 117, 84, 99, 105, 112, 122, 71, 56, 52, 105, 67, 66, 69, - ]; - assert_eq!(encoded_bytes, op_bytes); - - let _sig_bytes = vec![ - 131, 115, 47, 143, 89, 68, 79, 73, 121, 198, 70, 76, 91, 64, 171, 25, 18, 139, 244, 94, - 123, 224, 205, 32, 241, 174, 36, 120, 199, 206, 199, 202, 216, 154, 2, 10, 247, 101, 138, - 170, 85, 95, 142, 164, 50, 203, 92, 23, 247, 218, 231, 224, 78, 68, 55, 104, 243, 145, 243, - 4, 219, 102, 44, 227, - ]; - let sig_str = - "g3Mvj1lET0l5xkZMW0CrGRKL9F574M0g8a4keMfOx8rYmgIK92WKqlVfjqQyy1wX99rn4E5EN2jzkfME22Ys4w"; - - pub_key.verify_bytes(op_bytes, sig_str).unwrap(); - - let signed = op.into_signed(sig_str.to_string()); - signed.verify_self().unwrap(); -} - -/* ------------------------------------- -OP: -{ - type: 'create', - signingKey: 'did:key:zDnaesoxZb8mLjf16e4PWsNqLLj9uWM9TQ8nNwxqErDmKXLAN', - recoveryKey: 'did:key:zDnaesoxZb8mLjf16e4PWsNqLLj9uWM9TQ8nNwxqErDmKXLAN', - username: 'carla.test', - service: 'http://localhost:2583', - prev: null, - sig: 'VYGxmZs-D5830YdQSNrZpbxVyOPB4nCJtO-x0XElt35AE5wjvJFa2vJu8qjURG6TvEbMvfbekDo_eXEMhdPWdg' -} -ENCODED: -{"0":167,"1":99,"2":115,"3":105,"4":103,"5":120,"6":86,"7":86,"8":89,"9":71,"10":120,"11":109,"12":90,"13":115,"14":45,"15":68,"16":53,"17":56,"18":51,"19":48,"20":89,"21":100,"22":81,"23":83,"24":78,"25":114,"26":90,"27":112,"28":98,"29":120,"30":86,"31":121,"32":79,"33":80,"34":66,"35":52,"36":110,"37":67,"38":74,"39":116,"40":79,"41":45,"42":120,"43":48,"44":88,"45":69,"46":108,"47":116,"48":51,"49":53,"50":65,"51":69,"52":53,"53":119,"54":106,"55":118,"56":74,"57":70,"58":97,"59":50,"60":118,"61":74,"62":117,"63":56,"64":113,"65":106,"66":85,"67":82,"68":71,"69":54,"70":84,"71":118,"72":69,"73":98,"74":77,"75":118,"76":102,"77":98,"78":101,"79":107,"80":68,"81":111,"82":95,"83":101,"84":88,"85":69,"86":77,"87":104,"88":100,"89":80,"90":87,"91":100,"92":103,"93":100,"94":112,"95":114,"96":101,"97":118,"98":246,"99":100,"100":116,"101":121,"102":112,"103":101,"104":102,"105":99,"106":114,"107":101,"108":97,"109":116,"110":101,"111":103,"112":115,"113":101,"114":114,"115":118,"116":105,"117":99,"118":101,"119":117,"120":104,"121":116,"122":116,"123":112,"124":58,"125":47,"126":47,"127":108,"128":111,"129":99,"130":97,"131":108,"132":104,"133":111,"134":115,"135":116,"136":58,"137":50,"138":53,"139":56,"140":51,"141":104,"142":117,"143":115,"144":101,"145":114,"146":110,"147":97,"148":109,"149":101,"150":106,"151":99,"152":97,"153":114,"154":108,"155":97,"156":46,"157":116,"158":101,"159":115,"160":116,"161":106,"162":115,"163":105,"164":103,"165":110,"166":105,"167":110,"168":103,"169":75,"170":101,"171":121,"172":120,"173":57,"174":100,"175":105,"176":100,"177":58,"178":107,"179":101,"180":121,"181":58,"182":122,"183":68,"184":110,"185":97,"186":101,"187":115,"188":111,"189":120,"190":90,"191":98,"192":56,"193":109,"194":76,"195":106,"196":102,"197":49,"198":54,"199":101,"200":52,"201":80,"202":87,"203":115,"204":78,"205":113,"206":76,"207":76,"208":106,"209":57,"210":117,"211":87,"212":77,"213":57,"214":84,"215":81,"216":56,"217":110,"218":78,"219":119,"220":120,"221":113,"222":69,"223":114,"224":68,"225":109,"226":75,"227":88,"228":76,"229":65,"230":78,"231":107,"232":114,"233":101,"234":99,"235":111,"236":118,"237":101,"238":114,"239":121,"240":75,"241":101,"242":121,"243":120,"244":57,"245":100,"246":105,"247":100,"248":58,"249":107,"250":101,"251":121,"252":58,"253":122,"254":68,"255":110,"256":97,"257":101,"258":115,"259":111,"260":120,"261":90,"262":98,"263":56,"264":109,"265":76,"266":106,"267":102,"268":49,"269":54,"270":101,"271":52,"272":80,"273":87,"274":115,"275":78,"276":113,"277":76,"278":76,"279":106,"280":57,"281":117,"282":87,"283":77,"284":57,"285":84,"286":81,"287":56,"288":110,"289":78,"290":119,"291":120,"292":113,"293":69,"294":114,"295":68,"296":109,"297":75,"298":88,"299":76,"300":65,"301":78} -SHA256 base32: -cg2dfxdh5voabmdjzw2abw3sgvtjymknh2bmpvtwot7t2ih4v7za -did:plc:cg2dfxdh5voabmdjzw2abw3s ------------------------------------- - -*/ - -#[test] -fn test_debug_did_plc() { - let op = CreateOp { - op_type: "create".to_string(), - signingKey: "did:key:zDnaesoxZb8mLjf16e4PWsNqLLj9uWM9TQ8nNwxqErDmKXLAN".to_string(), - recoveryKey: "did:key:zDnaesoxZb8mLjf16e4PWsNqLLj9uWM9TQ8nNwxqErDmKXLAN".to_string(), - username: "carla.test".to_string(), - service: "http://localhost:2583".to_string(), - prev: None, - sig: - "VYGxmZs-D5830YdQSNrZpbxVyOPB4nCJtO-x0XElt35AE5wjvJFa2vJu8qjURG6TvEbMvfbekDo_eXEMhdPWdg" - .to_string(), - }; - op.verify_self().unwrap(); - let block = - Block::::encode(DagCborCodec, Code::Sha2_256, &op).expect("encode DAG-CBOR"); - let op_bytes = block.data(); - - let encoded_bytes = vec![ - 167, 99, 115, 105, 103, 120, 86, 86, 89, 71, 120, 109, 90, 115, 45, 68, 53, 56, 51, 48, 89, - 100, 81, 83, 78, 114, 90, 112, 98, 120, 86, 121, 79, 80, 66, 52, 110, 67, 74, 116, 79, 45, - 120, 48, 88, 69, 108, 116, 51, 53, 65, 69, 53, 119, 106, 118, 74, 70, 97, 50, 118, 74, 117, - 56, 113, 106, 85, 82, 71, 54, 84, 118, 69, 98, 77, 118, 102, 98, 101, 107, 68, 111, 95, - 101, 88, 69, 77, 104, 100, 80, 87, 100, 103, 100, 112, 114, 101, 118, 246, 100, 116, 121, - 112, 101, 102, 99, 114, 101, 97, 116, 101, 103, 115, 101, 114, 118, 105, 99, 101, 117, 104, - 116, 116, 112, 58, 47, 47, 108, 111, 99, 97, 108, 104, 111, 115, 116, 58, 50, 53, 56, 51, - 104, 117, 115, 101, 114, 110, 97, 109, 101, 106, 99, 97, 114, 108, 97, 46, 116, 101, 115, - 116, 106, 115, 105, 103, 110, 105, 110, 103, 75, 101, 121, 120, 57, 100, 105, 100, 58, 107, - 101, 121, 58, 122, 68, 110, 97, 101, 115, 111, 120, 90, 98, 56, 109, 76, 106, 102, 49, 54, - 101, 52, 80, 87, 115, 78, 113, 76, 76, 106, 57, 117, 87, 77, 57, 84, 81, 56, 110, 78, 119, - 120, 113, 69, 114, 68, 109, 75, 88, 76, 65, 78, 107, 114, 101, 99, 111, 118, 101, 114, 121, - 75, 101, 121, 120, 57, 100, 105, 100, 58, 107, 101, 121, 58, 122, 68, 110, 97, 101, 115, - 111, 120, 90, 98, 56, 109, 76, 106, 102, 49, 54, 101, 52, 80, 87, 115, 78, 113, 76, 76, - 106, 57, 117, 87, 77, 57, 84, 81, 56, 110, 78, 119, 120, 113, 69, 114, 68, 109, 75, 88, 76, - 65, 78, - ]; - assert_eq!(op_bytes, encoded_bytes); - - let sha256_str = "cg2dfxdh5voabmdjzw2abw3sgvtjymknh2bmpvtwot7t2ih4v7za"; - let _did_plc = "did:plc:cg2dfxdh5voabmdjzw2abw3s"; - - let digest_bytes: Vec = data_encoding::HEXLOWER - .decode(&sha256::digest(op_bytes).as_bytes()) - .expect("SHA-256 digest is always hex string"); - let digest_b32 = data_encoding::BASE32_NOPAD - .encode(&digest_bytes) - .to_ascii_lowercase(); - assert_eq!(digest_b32, sha256_str); -} - -#[test] -fn test_did_plc_examples() { - // https://atproto.com/specs/did-plc - let op = CreateOp { - op_type: "create".to_string(), - signingKey: "did:key:zDnaejYFhgFiVF89LhJ4UipACLKuqo6PteZf8eKDVKeExXUPk".to_string(), - recoveryKey: "did:key:zDnaeSezF2TgCD71b5DiiFyhHQwKAfsBVqTTHRMvP597Z5Ztn".to_string(), - username: "alice.example.com".to_string(), - service: "https://example.com".to_string(), - prev: None, - sig: - "vi6JAl5W4FfyViD5_BKL9p0rbI3MxTWuh0g_egTFAjtf7gwoSfSe1O3qMOEUPX6QH3H0Q9M4y7gOLGblWkEwfQ" - .to_string(), - }; - op.verify_self().unwrap(); - assert_eq!( - &op.did_plc().to_string(), - "did:plc:7iza6de2dwap2sbkpav7c6c6" - ); - - // interacting with PDS / PLC server - let op = CreateOp { - op_type: "create".to_string(), - signingKey: "did:key:zDnaekmbFffmpo7LZ4C7bEFjGKPk11N47kKN8j7jtAcGUabw3".to_string(), - recoveryKey: "did:key:zDnaekmbFffmpo7LZ4C7bEFjGKPk11N47kKN8j7jtAcGUabw3".to_string(), - username: "voltaire.test".to_string(), - service: "http://localhost:2583".to_string(), - prev: None, - sig: - "HNfQUg6SMnYKp1l3LtAIsoAblmi33mYiHE9JH1j7w3B-hd8xWpmCUBUoqKfQXmsAs0K1z8Izt19yYk6PqVFgyg" - .to_string(), - }; - op.verify_self().unwrap(); - assert_eq!( - &op.did_plc().to_string(), - "did:plc:bmrcg7zrxoiw2kiml3tkw2xv" - ); -} - -#[test] -fn test_self_verify() { - let keypair = KeyPair::new_random(); - let op = CreateOp::new( - "dummy-handle".to_string(), - "https://dummy.service".to_string(), - &keypair, - None, - ); - println!("{:?}", op); - op.verify_self().unwrap(); -} - -#[test] -fn test_known_key() { - let keypair = KeyPair::new_random(); - let op = CreateOp::new( - "dummy-handle".to_string(), - "https://dummy.service".to_string(), - &keypair, - None, - ); - println!("{:?}", op); - op.verify_self().unwrap(); -} diff --git a/adenosine/src/ipld.rs b/adenosine/src/ipld.rs new file mode 100644 index 0000000..65a56e4 --- /dev/null +++ b/adenosine/src/ipld.rs @@ -0,0 +1,47 @@ +use libipld::{Cid, Ipld}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::str::FromStr; + +/// Intentionally serializing with this instead of DAG-JSON, because ATP schemas don't encode CID +/// links in any special way, they just pass the CID as a string. +pub fn ipld_into_json_value(val: Ipld) -> Value { + match val { + Ipld::Null => Value::Null, + Ipld::Bool(b) => Value::Bool(b), + Ipld::Integer(v) => json!(v), + Ipld::Float(v) => json!(v), + Ipld::String(s) => Value::String(s), + Ipld::Bytes(b) => Value::String(data_encoding::BASE64_NOPAD.encode(&b)), + Ipld::List(l) => Value::Array(l.into_iter().map(ipld_into_json_value).collect()), + Ipld::Map(m) => Value::Object(serde_json::Map::from_iter( + m.into_iter().map(|(k, v)| (k, ipld_into_json_value(v))), + )), + Ipld::Link(c) => Value::String(c.to_string()), + } +} + +/// Crude reverse generation +/// +/// Does not handle base64 to bytes, and the link generation is pretty simple (object elements with +/// key "car"). Numbers always come through as f64 (float). +pub fn json_value_into_ipld(val: Value) -> Ipld { + match val { + Value::Null => Ipld::Null, + Value::Bool(b) => Ipld::Bool(b), + Value::String(s) => Ipld::String(s), + // TODO: handle numbers better? + Value::Number(v) => Ipld::Float(v.as_f64().unwrap()), + Value::Array(l) => Ipld::List(l.into_iter().map(json_value_into_ipld).collect()), + Value::Object(m) => { + let map: BTreeMap = BTreeMap::from_iter(m.into_iter().map(|(k, v)| { + if k == "car" && v.is_string() { + (k, Ipld::Link(Cid::from_str(v.as_str().unwrap()).unwrap())) + } else { + (k, json_value_into_ipld(v)) + } + })); + Ipld::Map(map) + } + } +} diff --git a/adenosine/src/lib.rs b/adenosine/src/lib.rs index dc4a1b9..49701a1 100644 --- a/adenosine/src/lib.rs +++ b/adenosine/src/lib.rs @@ -1,232 +1,17 @@ -use anyhow::anyhow; -pub use anyhow::Result; -use reqwest::header; -use serde_json::Value; -use std::collections::HashMap; -use std::str::FromStr; -use std::time::Duration; - +pub mod app_bsky; +pub mod auth; pub mod car; +pub mod com_atproto; pub mod crypto; -pub mod did; pub mod identifiers; +pub mod ipld; pub mod mst; +pub mod plc; pub mod repo; -pub mod ucan_p256; -pub mod vendored; -use identifiers::Nsid; - -static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); - -#[derive(Debug, PartialEq, Eq, Clone)] -pub enum XrpcMethod { - Get, - Post, -} - -impl FromStr for XrpcMethod { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result { - match s { - "get" => Ok(XrpcMethod::Get), - "post" => Ok(XrpcMethod::Post), - _ => Err(anyhow!("unknown method: {}", s)), - } - } -} - -pub struct XrpcClient { - http_client: reqwest::blocking::Client, - host: String, -} - -impl XrpcClient { - pub fn new(host: String, auth_token: Option) -> Result { - let mut headers = header::HeaderMap::new(); - if let Some(token) = &auth_token { - let mut auth_value = header::HeaderValue::from_str(&format!("Bearer {}", token))?; - auth_value.set_sensitive(true); - headers.insert(header::AUTHORIZATION, auth_value); - }; - - let http_client = reqwest::blocking::Client::builder() - .default_headers(headers) - .user_agent(APP_USER_AGENT) - .timeout(Duration::from_secs(30)) - //.danger_accept_invalid_certs(true) - .build() - .expect("ERROR :: Could not build reqwest client"); +pub mod xrpc; - Ok(XrpcClient { http_client, host }) - } - - pub fn get( - &self, - nsid: &Nsid, - params: Option>, - ) -> Result> { - log::debug!("XRPC GET endpoint={} params={:?}", nsid, params); - let params: HashMap = params.unwrap_or_default(); - let res = self - .http_client - .get(format!("{}/xrpc/{}", self.host, nsid)) - .query(¶ms) - .send()?; - // TODO: refactor this error handling stuff into single method - if res.status() == 400 { - let val: Value = res.json()?; - return Err(anyhow!( - "XRPC Bad Request (400): {}", - val["message"].as_str().unwrap_or("unknown") - )); - } else if res.status() == 500 { - let val: Value = res.json()?; - return Err(anyhow!( - "XRPC Internal Error (500): {}", - val["message"].as_str().unwrap_or("unknown") - )); - } - let res = res.error_for_status()?; - Ok(res.json()?) - } - - pub fn get_to_writer( - &self, - nsid: &Nsid, - params: Option>, - output: &mut W, - ) -> Result { - let params: HashMap = params.unwrap_or_default(); - let res = self - .http_client - .get(format!("{}/xrpc/{}", self.host, nsid)) - .query(¶ms) - .send()?; - if res.status() == 400 { - let val: Value = res.json()?; - return Err(anyhow!( - "XRPC Bad Request (400): {}", - val["message"].as_str().unwrap_or("unknown") - )); - } else if res.status() == 500 { - let val: Value = res.json()?; - return Err(anyhow!( - "XRPC Internal Error (500): {}", - val["message"].as_str().unwrap_or("unknown") - )); - } - let mut res = res.error_for_status()?; - Ok(res.copy_to(output)?) - } - - pub fn post( - &self, - nsid: &Nsid, - params: Option>, - body: Option, - ) -> Result> { - let params: HashMap = params.unwrap_or_default(); - log::debug!( - "XRPC POST endpoint={} params={:?} body={:?}", - nsid, - params, - body - ); - let mut req = self - .http_client - .post(format!("{}/xrpc/{}", self.host, nsid)) - .query(¶ms); - req = if let Some(b) = body { - req.json(&b) - } else { - req - }; - let res = req.send()?; - if res.status() == 400 { - let val: Value = res.json()?; - return Err(anyhow!( - "XRPC Bad Request (400): {}", - val["message"].as_str().unwrap_or("unknown") - )); - } else if res.status() == 500 { - let val: Value = res.json()?; - return Err(anyhow!( - "XRPC Internal Error (500): {}", - val["message"].as_str().unwrap_or("unknown") - )); - } - let res = res.error_for_status()?; - if res.content_length() == Some(0) { - Ok(None) - } else { - Ok(res.json()?) - } - } - - pub fn post_cbor_from_reader( - &self, - nsid: &Nsid, - params: Option>, - input: &mut R, - ) -> Result> { - let params: HashMap = params.unwrap_or_default(); - let mut buf: Vec = Vec::new(); - input.read_to_end(&mut buf)?; - let res = self - .http_client - .post(format!("{}/xrpc/{}", self.host, nsid)) - .query(¶ms) - .header(reqwest::header::CONTENT_TYPE, "application/cbor") - .body(buf) - .send()?; - if res.status() == 400 { - let val: Value = res.json()?; - return Err(anyhow!( - "XRPC Bad Request: {}", - val["message"].as_str().unwrap_or("unknown") - )); - } - let res = res.error_for_status()?; - Ok(res.json()?) - } - - // reqwest::blocking::Body -} - -/// Tries to parse a DID internal identifier from a JWT (as base64-encoded token) -pub fn parse_did_from_jwt(jwt: &str) -> Result { - let second_b64 = jwt.split('.').nth(1).ok_or(anyhow!("couldn't parse JWT"))?; - let second_json: Vec = base64::decode_config(second_b64, base64::URL_SAFE)?; - let obj: Value = serde_json::from_slice(&second_json)?; - // trying to also support pulling "aud" as DID; not sure this is actually correct use of - // UCAN/JWT semantics? - let did = obj["sub"] - .as_str() - .or(obj["aud"].as_str()) - .ok_or(anyhow!("couldn't find DID subject in JWT"))? - .to_string(); - if !did.starts_with("did:") { - return Err(anyhow!("couldn't find DID subject in JWT")); - } - Ok(did) -} - -#[test] -fn test_parse_jwt() { - assert!(parse_did_from_jwt(".").is_err()); - // JWT from atproto ("sub") - assert_eq!( - parse_did_from_jwt("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWQ6cGxjOmV4M3NpNTI3Y2QyYW9nYnZpZGtvb296YyIsImlhdCI6MTY2NjgyOTM5M30.UvZgTqvaJICONa1wIUT1bny7u3hqVAqWhWy3qeuyZrE").unwrap(), - "did:plc:ex3si527cd2aogbvidkooozc", - ); - // UCAN from adenosine-pds ("aud") - assert_eq!( - parse_did_from_jwt("eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsInVjdiI6IjAuOS4wLWNhbmFyeSJ9.eyJhdHQiOltdLCJhdWQiOiJkaWQ6cGxjOnM3b25ieWphN2MzeXJzZ3Zob2xrbHM1YiIsImV4cCI6MTY3NTM4Mzg2NywiZmN0IjpbXSwiaXNzIjoiZGlkOmtleTp6RG5hZWRHVGJkb0Frb1NlOG96a3k1WHAzMjZTVFpUSm50aDlHY2dxaTZQYjNzYjczIiwibm5jIjoiTnZURDhENWZjNXFpalIyMWJ1V2Z1ZE02dzlBM2drSy1ac3RtUW03b21pdyIsInByZiI6W119.QwZkb9R17tNhXnY_roqFYgdiIgUnSC18FYWQb3PcH6BU1R5l4W_T4XdACyczPGfM-jAnF2r2loBXDntYVS6N5A").unwrap(), - "did:plc:s7onbyja7c3yrsgvholkls5b", - ); - assert!(parse_did_from_jwt("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9").is_err()); -} +mod ucan_p256; +mod vendored; /// Helper to generate the current timestamp as right now, UTC, formatted as an RFC 3339 string. /// diff --git a/adenosine/src/plc.rs b/adenosine/src/plc.rs new file mode 100644 index 0000000..c7d7d10 --- /dev/null +++ b/adenosine/src/plc.rs @@ -0,0 +1,368 @@ +use crate::crypto::{KeyPair, PubKey}; +/// DID and 'did:plc' stuff +/// +/// This is currently a partial/skeleton implementation, which only generates local/testing did:plc +/// DIDs (and DID documents) using a single 'create' genesis block. Key rotation, etc, is not +/// supported. +use crate::identifiers::Did; +use anyhow::Result; +use libipld::cbor::DagCborCodec; +use libipld::multihash::Code; +use libipld::{Block, Cid, DagCbor, DefaultParams}; +use serde_json::json; +use std::str::FromStr; + +#[allow(non_snake_case)] +#[derive(Debug, DagCbor, PartialEq, Eq, Clone)] +pub struct CreateOp { + #[ipld(rename = "type")] + pub op_type: String, + pub signingKey: String, + pub recoveryKey: String, + pub username: String, + pub service: String, + pub prev: Option, + pub sig: String, +} + +#[allow(non_snake_case)] +#[derive(Debug, DagCbor, PartialEq, Eq, Clone)] +struct UnsignedCreateOp { + #[ipld(rename = "type")] + pub op_type: String, + pub signingKey: String, + pub recoveryKey: String, + pub username: String, + pub service: String, + pub prev: Option, +} + +impl UnsignedCreateOp { + fn into_signed(self, sig: String) -> CreateOp { + CreateOp { + op_type: self.op_type, + prev: self.prev, + sig, + signingKey: self.signingKey, + recoveryKey: self.recoveryKey, + username: self.username, + service: self.service, + } + } +} + +impl CreateOp { + pub fn new( + username: String, + atp_pds: String, + keypair: &KeyPair, + recovery_key: Option, + ) -> Self { + let signing_key = keypair.pubkey().to_did_key(); + let recovery_key = recovery_key.unwrap_or(signing_key.clone()); + let unsigned = UnsignedCreateOp { + op_type: "create".to_string(), + prev: None, + signingKey: signing_key, + recoveryKey: recovery_key, + username, + service: atp_pds, + }; + let block = Block::::encode(DagCborCodec, Code::Sha2_256, &unsigned) + .expect("encode DAG-CBOR"); + let sig = keypair.sign_bytes(block.data()); + unsigned.into_signed(sig) + } + + pub fn did_plc(&self) -> Did { + // dump DAG-CBOR + let block = Block::::encode(DagCborCodec, Code::Sha2_256, self) + .expect("encode DAG-CBOR"); + let bin = block.data(); + // hash SHA-256 + let digest_bytes: Vec = data_encoding::HEXLOWER + .decode(sha256::digest(bin).as_bytes()) + .expect("SHA-256 digest is always hex string"); + // encode base32 + let digest_b32 = data_encoding::BASE32_NOPAD + .encode(&digest_bytes) + .to_ascii_lowercase(); + // truncate + Did::from_str(&format!("did:plc:{}", &digest_b32[0..24])).unwrap() + } + + fn into_unsigned(self) -> UnsignedCreateOp { + UnsignedCreateOp { + op_type: self.op_type, + prev: self.prev, + signingKey: self.signingKey, + recoveryKey: self.recoveryKey, + username: self.username, + service: self.service, + } + } + + pub fn did_doc(&self) -> serde_json::Value { + let meta = DidDocMeta { + did: self.did_plc(), + // TODO + user_url: format!("https://{}", self.username), + service_url: self.service.clone(), + recovery_didkey: self.recoveryKey.clone(), + signing_didkey: self.signingKey.clone(), + }; + meta.did_doc() + } + + /// This method only makes sense on the "genesis" create object + pub fn verify_self(&self) -> Result<()> { + let key = PubKey::from_did_key(&self.signingKey)?; + let unsigned = { + let cpy = (*self).clone(); + cpy.into_unsigned() + }; + //println!("unsigned: {:?}", unsigned); + let block = Block::::encode(DagCborCodec, Code::Sha2_256, &unsigned) + .expect("encode DAG-CBOR"); + key.verify_bytes(block.data(), &self.sig) + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct DidDocMeta { + pub did: Did, + pub user_url: String, + pub service_url: String, + pub recovery_didkey: String, + pub signing_didkey: String, +} + +impl DidDocMeta { + pub fn did_doc(&self) -> serde_json::Value { + let key_type = "EcdsaSecp256r1VerificationKey2019"; + json!({ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/suites/ecdsa-2019/v1" + ], + "id": self.did.to_string(), + "alsoKnownAs": [ self.user_url ], + "verificationMethod": [ + { + "id": format!("{}#signingKey)", self.did), + "type": key_type, + "controller": self.did.to_string(), + "publicKeyMultibase": self.signing_didkey + }, + { + "id": format!("{}#recoveryKey)", self.did), + "type": key_type, + "controller": self.did.to_string(), + "publicKeyMultibase": self.recovery_didkey + } + ], + "assertionMethod": [ format!("{}#signingKey)", self.did)], + "capabilityInvocation": [ format!("{}#signingKey)", self.did) ], + "capabilityDelegation": [ format!("{}#signingKey)", self.did) ], + "service": [ + { + "id": format!("{}#atpPds)", self.did), + "type": "AtpPersonalDataServer", + "serviceEndpoint": self.service_url + } + ] + }) + } +} + +#[test] +fn test_debug_did_signing() { + let op = UnsignedCreateOp { + op_type: "create".to_string(), + signingKey: "did:key:zDnaeSWVQyW8DSF6mDwT9j8YrzDWDs8h6PPjuTcipzG84iCBE".to_string(), + recoveryKey: "did:key:zDnaeSWVQyW8DSF6mDwT9j8YrzDWDs8h6PPjuTcipzG84iCBE".to_string(), + username: "carla.test".to_string(), + service: "http://localhost:2583".to_string(), + prev: None, + }; + let block = + Block::::encode(DagCborCodec, Code::Sha2_256, &op).expect("encode DAG-CBOR"); + let op_bytes = block.data(); + + let _key_bytes = vec![ + 4, 30, 224, 8, 198, 84, 108, 1, 58, 193, 91, 176, 212, 45, 4, 36, 28, 252, 242, 95, 20, 85, + 87, 246, 79, 134, 42, 113, 5, 216, 238, 235, 21, 146, 16, 88, 239, 217, 36, 252, 148, 197, + 203, 22, 29, 2, 52, 152, 77, 208, 21, 88, 2, 85, 219, 212, 148, 139, 104, 200, 15, 119, 46, + 178, 186, + ]; + + let pub_key = + PubKey::from_did_key("did:key:zDnaeSWVQyW8DSF6mDwT9j8YrzDWDs8h6PPjuTcipzG84iCBE").unwrap(); + //let keypair = KeyPair::from_bytes(&key_bytes).unwrap(); + //assert_eq!(keypair.to_bytes(), key_bytes); + + let encoded_bytes = vec![ + 166, 100, 112, 114, 101, 118, 246, 100, 116, 121, 112, 101, 102, 99, 114, 101, 97, 116, + 101, 103, 115, 101, 114, 118, 105, 99, 101, 117, 104, 116, 116, 112, 58, 47, 47, 108, 111, + 99, 97, 108, 104, 111, 115, 116, 58, 50, 53, 56, 51, 104, 117, 115, 101, 114, 110, 97, 109, + 101, 106, 99, 97, 114, 108, 97, 46, 116, 101, 115, 116, 106, 115, 105, 103, 110, 105, 110, + 103, 75, 101, 121, 120, 57, 100, 105, 100, 58, 107, 101, 121, 58, 122, 68, 110, 97, 101, + 83, 87, 86, 81, 121, 87, 56, 68, 83, 70, 54, 109, 68, 119, 84, 57, 106, 56, 89, 114, 122, + 68, 87, 68, 115, 56, 104, 54, 80, 80, 106, 117, 84, 99, 105, 112, 122, 71, 56, 52, 105, 67, + 66, 69, 107, 114, 101, 99, 111, 118, 101, 114, 121, 75, 101, 121, 120, 57, 100, 105, 100, + 58, 107, 101, 121, 58, 122, 68, 110, 97, 101, 83, 87, 86, 81, 121, 87, 56, 68, 83, 70, 54, + 109, 68, 119, 84, 57, 106, 56, 89, 114, 122, 68, 87, 68, 115, 56, 104, 54, 80, 80, 106, + 117, 84, 99, 105, 112, 122, 71, 56, 52, 105, 67, 66, 69, + ]; + assert_eq!(encoded_bytes, op_bytes); + + let _sig_bytes = vec![ + 131, 115, 47, 143, 89, 68, 79, 73, 121, 198, 70, 76, 91, 64, 171, 25, 18, 139, 244, 94, + 123, 224, 205, 32, 241, 174, 36, 120, 199, 206, 199, 202, 216, 154, 2, 10, 247, 101, 138, + 170, 85, 95, 142, 164, 50, 203, 92, 23, 247, 218, 231, 224, 78, 68, 55, 104, 243, 145, 243, + 4, 219, 102, 44, 227, + ]; + let sig_str = + "g3Mvj1lET0l5xkZMW0CrGRKL9F574M0g8a4keMfOx8rYmgIK92WKqlVfjqQyy1wX99rn4E5EN2jzkfME22Ys4w"; + + pub_key.verify_bytes(op_bytes, sig_str).unwrap(); + + let signed = op.into_signed(sig_str.to_string()); + signed.verify_self().unwrap(); +} + +/* +------------------------------------ +OP: +{ + type: 'create', + signingKey: 'did:key:zDnaesoxZb8mLjf16e4PWsNqLLj9uWM9TQ8nNwxqErDmKXLAN', + recoveryKey: 'did:key:zDnaesoxZb8mLjf16e4PWsNqLLj9uWM9TQ8nNwxqErDmKXLAN', + username: 'carla.test', + service: 'http://localhost:2583', + prev: null, + sig: 'VYGxmZs-D5830YdQSNrZpbxVyOPB4nCJtO-x0XElt35AE5wjvJFa2vJu8qjURG6TvEbMvfbekDo_eXEMhdPWdg' +} +ENCODED: +{"0":167,"1":99,"2":115,"3":105,"4":103,"5":120,"6":86,"7":86,"8":89,"9":71,"10":120,"11":109,"12":90,"13":115,"14":45,"15":68,"16":53,"17":56,"18":51,"19":48,"20":89,"21":100,"22":81,"23":83,"24":78,"25":114,"26":90,"27":112,"28":98,"29":120,"30":86,"31":121,"32":79,"33":80,"34":66,"35":52,"36":110,"37":67,"38":74,"39":116,"40":79,"41":45,"42":120,"43":48,"44":88,"45":69,"46":108,"47":116,"48":51,"49":53,"50":65,"51":69,"52":53,"53":119,"54":106,"55":118,"56":74,"57":70,"58":97,"59":50,"60":118,"61":74,"62":117,"63":56,"64":113,"65":106,"66":85,"67":82,"68":71,"69":54,"70":84,"71":118,"72":69,"73":98,"74":77,"75":118,"76":102,"77":98,"78":101,"79":107,"80":68,"81":111,"82":95,"83":101,"84":88,"85":69,"86":77,"87":104,"88":100,"89":80,"90":87,"91":100,"92":103,"93":100,"94":112,"95":114,"96":101,"97":118,"98":246,"99":100,"100":116,"101":121,"102":112,"103":101,"104":102,"105":99,"106":114,"107":101,"108":97,"109":116,"110":101,"111":103,"112":115,"113":101,"114":114,"115":118,"116":105,"117":99,"118":101,"119":117,"120":104,"121":116,"122":116,"123":112,"124":58,"125":47,"126":47,"127":108,"128":111,"129":99,"130":97,"131":108,"132":104,"133":111,"134":115,"135":116,"136":58,"137":50,"138":53,"139":56,"140":51,"141":104,"142":117,"143":115,"144":101,"145":114,"146":110,"147":97,"148":109,"149":101,"150":106,"151":99,"152":97,"153":114,"154":108,"155":97,"156":46,"157":116,"158":101,"159":115,"160":116,"161":106,"162":115,"163":105,"164":103,"165":110,"166":105,"167":110,"168":103,"169":75,"170":101,"171":121,"172":120,"173":57,"174":100,"175":105,"176":100,"177":58,"178":107,"179":101,"180":121,"181":58,"182":122,"183":68,"184":110,"185":97,"186":101,"187":115,"188":111,"189":120,"190":90,"191":98,"192":56,"193":109,"194":76,"195":106,"196":102,"197":49,"198":54,"199":101,"200":52,"201":80,"202":87,"203":115,"204":78,"205":113,"206":76,"207":76,"208":106,"209":57,"210":117,"211":87,"212":77,"213":57,"214":84,"215":81,"216":56,"217":110,"218":78,"219":119,"220":120,"221":113,"222":69,"223":114,"224":68,"225":109,"226":75,"227":88,"228":76,"229":65,"230":78,"231":107,"232":114,"233":101,"234":99,"235":111,"236":118,"237":101,"238":114,"239":121,"240":75,"241":101,"242":121,"243":120,"244":57,"245":100,"246":105,"247":100,"248":58,"249":107,"250":101,"251":121,"252":58,"253":122,"254":68,"255":110,"256":97,"257":101,"258":115,"259":111,"260":120,"261":90,"262":98,"263":56,"264":109,"265":76,"266":106,"267":102,"268":49,"269":54,"270":101,"271":52,"272":80,"273":87,"274":115,"275":78,"276":113,"277":76,"278":76,"279":106,"280":57,"281":117,"282":87,"283":77,"284":57,"285":84,"286":81,"287":56,"288":110,"289":78,"290":119,"291":120,"292":113,"293":69,"294":114,"295":68,"296":109,"297":75,"298":88,"299":76,"300":65,"301":78} +SHA256 base32: +cg2dfxdh5voabmdjzw2abw3sgvtjymknh2bmpvtwot7t2ih4v7za +did:plc:cg2dfxdh5voabmdjzw2abw3s +------------------------------------ + +*/ + +#[test] +fn test_debug_did_plc() { + let op = CreateOp { + op_type: "create".to_string(), + signingKey: "did:key:zDnaesoxZb8mLjf16e4PWsNqLLj9uWM9TQ8nNwxqErDmKXLAN".to_string(), + recoveryKey: "did:key:zDnaesoxZb8mLjf16e4PWsNqLLj9uWM9TQ8nNwxqErDmKXLAN".to_string(), + username: "carla.test".to_string(), + service: "http://localhost:2583".to_string(), + prev: None, + sig: + "VYGxmZs-D5830YdQSNrZpbxVyOPB4nCJtO-x0XElt35AE5wjvJFa2vJu8qjURG6TvEbMvfbekDo_eXEMhdPWdg" + .to_string(), + }; + op.verify_self().unwrap(); + let block = + Block::::encode(DagCborCodec, Code::Sha2_256, &op).expect("encode DAG-CBOR"); + let op_bytes = block.data(); + + let encoded_bytes = vec![ + 167, 99, 115, 105, 103, 120, 86, 86, 89, 71, 120, 109, 90, 115, 45, 68, 53, 56, 51, 48, 89, + 100, 81, 83, 78, 114, 90, 112, 98, 120, 86, 121, 79, 80, 66, 52, 110, 67, 74, 116, 79, 45, + 120, 48, 88, 69, 108, 116, 51, 53, 65, 69, 53, 119, 106, 118, 74, 70, 97, 50, 118, 74, 117, + 56, 113, 106, 85, 82, 71, 54, 84, 118, 69, 98, 77, 118, 102, 98, 101, 107, 68, 111, 95, + 101, 88, 69, 77, 104, 100, 80, 87, 100, 103, 100, 112, 114, 101, 118, 246, 100, 116, 121, + 112, 101, 102, 99, 114, 101, 97, 116, 101, 103, 115, 101, 114, 118, 105, 99, 101, 117, 104, + 116, 116, 112, 58, 47, 47, 108, 111, 99, 97, 108, 104, 111, 115, 116, 58, 50, 53, 56, 51, + 104, 117, 115, 101, 114, 110, 97, 109, 101, 106, 99, 97, 114, 108, 97, 46, 116, 101, 115, + 116, 106, 115, 105, 103, 110, 105, 110, 103, 75, 101, 121, 120, 57, 100, 105, 100, 58, 107, + 101, 121, 58, 122, 68, 110, 97, 101, 115, 111, 120, 90, 98, 56, 109, 76, 106, 102, 49, 54, + 101, 52, 80, 87, 115, 78, 113, 76, 76, 106, 57, 117, 87, 77, 57, 84, 81, 56, 110, 78, 119, + 120, 113, 69, 114, 68, 109, 75, 88, 76, 65, 78, 107, 114, 101, 99, 111, 118, 101, 114, 121, + 75, 101, 121, 120, 57, 100, 105, 100, 58, 107, 101, 121, 58, 122, 68, 110, 97, 101, 115, + 111, 120, 90, 98, 56, 109, 76, 106, 102, 49, 54, 101, 52, 80, 87, 115, 78, 113, 76, 76, + 106, 57, 117, 87, 77, 57, 84, 81, 56, 110, 78, 119, 120, 113, 69, 114, 68, 109, 75, 88, 76, + 65, 78, + ]; + assert_eq!(op_bytes, encoded_bytes); + + let sha256_str = "cg2dfxdh5voabmdjzw2abw3sgvtjymknh2bmpvtwot7t2ih4v7za"; + let _did_plc = "did:plc:cg2dfxdh5voabmdjzw2abw3s"; + + let digest_bytes: Vec = data_encoding::HEXLOWER + .decode(&sha256::digest(op_bytes).as_bytes()) + .expect("SHA-256 digest is always hex string"); + let digest_b32 = data_encoding::BASE32_NOPAD + .encode(&digest_bytes) + .to_ascii_lowercase(); + assert_eq!(digest_b32, sha256_str); +} + +#[test] +fn test_did_plc_examples() { + // https://atproto.com/specs/did-plc + let op = CreateOp { + op_type: "create".to_string(), + signingKey: "did:key:zDnaejYFhgFiVF89LhJ4UipACLKuqo6PteZf8eKDVKeExXUPk".to_string(), + recoveryKey: "did:key:zDnaeSezF2TgCD71b5DiiFyhHQwKAfsBVqTTHRMvP597Z5Ztn".to_string(), + username: "alice.example.com".to_string(), + service: "https://example.com".to_string(), + prev: None, + sig: + "vi6JAl5W4FfyViD5_BKL9p0rbI3MxTWuh0g_egTFAjtf7gwoSfSe1O3qMOEUPX6QH3H0Q9M4y7gOLGblWkEwfQ" + .to_string(), + }; + op.verify_self().unwrap(); + assert_eq!( + &op.did_plc().to_string(), + "did:plc:7iza6de2dwap2sbkpav7c6c6" + ); + + // interacting with PDS / PLC server + let op = CreateOp { + op_type: "create".to_string(), + signingKey: "did:key:zDnaekmbFffmpo7LZ4C7bEFjGKPk11N47kKN8j7jtAcGUabw3".to_string(), + recoveryKey: "did:key:zDnaekmbFffmpo7LZ4C7bEFjGKPk11N47kKN8j7jtAcGUabw3".to_string(), + username: "voltaire.test".to_string(), + service: "http://localhost:2583".to_string(), + prev: None, + sig: + "HNfQUg6SMnYKp1l3LtAIsoAblmi33mYiHE9JH1j7w3B-hd8xWpmCUBUoqKfQXmsAs0K1z8Izt19yYk6PqVFgyg" + .to_string(), + }; + op.verify_self().unwrap(); + assert_eq!( + &op.did_plc().to_string(), + "did:plc:bmrcg7zrxoiw2kiml3tkw2xv" + ); +} + +#[test] +fn test_self_verify() { + let keypair = KeyPair::new_random(); + let op = CreateOp::new( + "dummy-handle".to_string(), + "https://dummy.service".to_string(), + &keypair, + None, + ); + println!("{:?}", op); + op.verify_self().unwrap(); +} + +#[test] +fn test_known_key() { + let keypair = KeyPair::new_random(); + let op = CreateOp::new( + "dummy-handle".to_string(), + "https://dummy.service".to_string(), + &keypair, + None, + ); + println!("{:?}", op); + op.verify_self().unwrap(); +} diff --git a/adenosine/src/xrpc.rs b/adenosine/src/xrpc.rs new file mode 100644 index 0000000..382c7fb --- /dev/null +++ b/adenosine/src/xrpc.rs @@ -0,0 +1,186 @@ +use crate::identifiers::Nsid; +use anyhow::anyhow; +pub use anyhow::Result; +use reqwest::header; +use serde_json::Value; +use std::collections::HashMap; +use std::str::FromStr; +use std::time::Duration; + +static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum XrpcMethod { + Get, + Post, +} + +impl FromStr for XrpcMethod { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s { + "get" => Ok(XrpcMethod::Get), + "post" => Ok(XrpcMethod::Post), + _ => Err(anyhow!("unknown method: {}", s)), + } + } +} + +pub struct XrpcClient { + http_client: reqwest::blocking::Client, + host: String, +} + +impl XrpcClient { + pub fn new(host: String, auth_token: Option) -> Result { + let mut headers = header::HeaderMap::new(); + if let Some(token) = &auth_token { + let mut auth_value = header::HeaderValue::from_str(&format!("Bearer {}", token))?; + auth_value.set_sensitive(true); + headers.insert(header::AUTHORIZATION, auth_value); + }; + + let http_client = reqwest::blocking::Client::builder() + .default_headers(headers) + .user_agent(APP_USER_AGENT) + .timeout(Duration::from_secs(30)) + //.danger_accept_invalid_certs(true) + .build() + .expect("ERROR :: Could not build reqwest client"); + + Ok(XrpcClient { http_client, host }) + } + + pub fn get( + &self, + nsid: &Nsid, + params: Option>, + ) -> Result> { + log::debug!("XRPC GET endpoint={} params={:?}", nsid, params); + let params: HashMap = params.unwrap_or_default(); + let res = self + .http_client + .get(format!("{}/xrpc/{}", self.host, nsid)) + .query(¶ms) + .send()?; + // TODO: refactor this error handling stuff into single method + if res.status() == 400 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Bad Request (400): {}", + val["message"].as_str().unwrap_or("unknown") + )); + } else if res.status() == 500 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Internal Error (500): {}", + val["message"].as_str().unwrap_or("unknown") + )); + } + let res = res.error_for_status()?; + Ok(res.json()?) + } + + pub fn get_to_writer( + &self, + nsid: &Nsid, + params: Option>, + output: &mut W, + ) -> Result { + let params: HashMap = params.unwrap_or_default(); + let res = self + .http_client + .get(format!("{}/xrpc/{}", self.host, nsid)) + .query(¶ms) + .send()?; + if res.status() == 400 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Bad Request (400): {}", + val["message"].as_str().unwrap_or("unknown") + )); + } else if res.status() == 500 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Internal Error (500): {}", + val["message"].as_str().unwrap_or("unknown") + )); + } + let mut res = res.error_for_status()?; + Ok(res.copy_to(output)?) + } + + pub fn post( + &self, + nsid: &Nsid, + params: Option>, + body: Option, + ) -> Result> { + let params: HashMap = params.unwrap_or_default(); + log::debug!( + "XRPC POST endpoint={} params={:?} body={:?}", + nsid, + params, + body + ); + let mut req = self + .http_client + .post(format!("{}/xrpc/{}", self.host, nsid)) + .query(¶ms); + req = if let Some(b) = body { + req.json(&b) + } else { + req + }; + let res = req.send()?; + if res.status() == 400 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Bad Request (400): {}", + val["message"].as_str().unwrap_or("unknown") + )); + } else if res.status() == 500 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Internal Error (500): {}", + val["message"].as_str().unwrap_or("unknown") + )); + } + let res = res.error_for_status()?; + if res.content_length() == Some(0) { + Ok(None) + } else { + Ok(res.json()?) + } + } + + pub fn post_cbor_from_reader( + &self, + nsid: &Nsid, + params: Option>, + input: &mut R, + ) -> Result> { + let params: HashMap = params.unwrap_or_default(); + let mut buf: Vec = Vec::new(); + input.read_to_end(&mut buf)?; + let res = self + .http_client + .post(format!("{}/xrpc/{}", self.host, nsid)) + .query(¶ms) + .header(reqwest::header::CONTENT_TYPE, "application/cbor") + .body(buf) + .send()?; + if res.status() == 400 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Bad Request: {}", + val["message"].as_str().unwrap_or("unknown") + )); + } + let res = res.error_for_status()?; + Ok(res.json()?) + } + + // reqwest::blocking::Body +} diff --git a/adenosine/tests/bigger.car b/adenosine/tests/bigger.car new file mode 100644 index 0000000..7169013 Binary files /dev/null and b/adenosine/tests/bigger.car differ diff --git a/adenosine/tests/example_repo.car b/adenosine/tests/example_repo.car new file mode 100644 index 0000000..b2ae723 Binary files /dev/null and b/adenosine/tests/example_repo.car differ diff --git a/adenosine/tests/test_mst_interop.rs b/adenosine/tests/test_mst_interop.rs new file mode 100644 index 0000000..ee45019 --- /dev/null +++ b/adenosine/tests/test_mst_interop.rs @@ -0,0 +1,152 @@ +use adenosine::repo::RepoStore; +use libipld::Cid; +use std::collections::BTreeMap; +use std::str::FromStr; + +#[test] +fn test_known_maps() { + let mut repo = RepoStore::open_ephemeral().unwrap(); + let cid1 = + Cid::from_str("bafyreie5cvv4h45feadgeuwhbcutmh6t2ceseocckahdoe6uat64zmz454").unwrap(); + + let empty_map: BTreeMap = Default::default(); + assert_eq!( + repo.mst_from_map(&empty_map).unwrap().to_string(), + "bafyreie5737gdxlw5i64vzichcalba3z2v5n6icifvx5xytvske7mr3hpm" + ); + + let mut trivial_map: BTreeMap = Default::default(); + trivial_map.insert("asdf".to_string(), cid1.clone()); + assert_eq!( + repo.mst_from_map(&trivial_map).unwrap().to_string(), + "bafyreidaftbr35xhh4lzmv5jcoeufqjh75ohzmz6u56v7n2ippbtxdgqqe" + ); + + let mut singlelayer2_map: BTreeMap = Default::default(); + singlelayer2_map.insert("com.example.record/9ba1c7247ede".to_string(), cid1.clone()); + assert_eq!( + repo.mst_from_map(&singlelayer2_map).unwrap().to_string(), + "bafyreid4g5smj6ukhrjasebt6myj7wmtm2eijouteoyueoqgoh6vm5jkae" + ); + + let mut simple_map: BTreeMap = Default::default(); + simple_map.insert("asdf".to_string(), cid1.clone()); + simple_map.insert("88bfafc7".to_string(), cid1.clone()); + simple_map.insert("2a92d355".to_string(), cid1.clone()); + simple_map.insert("app.bsky.feed.post/454397e440ec".to_string(), cid1.clone()); + simple_map.insert("app.bsky.feed.post/9adeb165882c".to_string(), cid1.clone()); + assert_eq!( + repo.mst_from_map(&simple_map).unwrap().to_string(), + "bafyreiecb33zh7r2sc3k2wthm6exwzfktof63kmajeildktqc25xj6qzx4" + ); +} + +// TODO: behavior of these wide-char keys is undefined behavior in string MST +#[ignore] +#[test] +fn test_tricky_map() { + let mut repo = RepoStore::open_ephemeral().unwrap(); + let cid1 = + Cid::from_str("bafyreie5cvv4h45feadgeuwhbcutmh6t2ceseocckahdoe6uat64zmz454").unwrap(); + + let mut tricky_map: BTreeMap = Default::default(); + tricky_map.insert("".to_string(), cid1.clone()); + tricky_map.insert("jalapeño".to_string(), cid1.clone()); + tricky_map.insert("coöperative".to_string(), cid1.clone()); + tricky_map.insert("coüperative".to_string(), cid1.clone()); + tricky_map.insert("abc\x00".to_string(), cid1.clone()); + assert_eq!( + repo.mst_from_map(&tricky_map).unwrap().to_string(), + "bafyreiecb33zh7r2sc3k2wthm6exwzfktof63kmajeildktqc25xj6qzx4" + ); +} + +#[test] +fn test_trims_top() { + // "trims top of tree on delete" + + use adenosine::mst::print_mst_keys; + let mut repo = RepoStore::open_ephemeral().unwrap(); + let cid1 = + Cid::from_str("bafyreie5cvv4h45feadgeuwhbcutmh6t2ceseocckahdoe6uat64zmz454").unwrap(); + let l1root = "bafyreihuyj2vzb2vjw3yhxg6dy25achg5fmre6gg5m6fjtxn64bqju4dee"; + let l0root = "bafyreibmijjc63mekkjzl3v2pegngwke5u6cu66g75z6uw27v64bc6ahqi"; + + // NOTE: this test doesn't do much in this case of rust implementation + let mut trim_map: BTreeMap = Default::default(); + trim_map.insert("com.example.record/40c73105b48f".to_string(), cid1.clone()); // level 0 + trim_map.insert("com.example.record/e99bf3ced34b".to_string(), cid1.clone()); // level 0 + trim_map.insert("com.example.record/893e6c08b450".to_string(), cid1.clone()); // level 0 + trim_map.insert("com.example.record/9cd8b6c0cc02".to_string(), cid1.clone()); // level 0 + trim_map.insert("com.example.record/cbe72d33d12a".to_string(), cid1.clone()); // level 0 + trim_map.insert("com.example.record/a15e33ba0f6c".to_string(), cid1.clone()); // level 1 + let trim_before_cid = repo.mst_from_map(&trim_map).unwrap(); + print_mst_keys(&mut repo.db, &trim_before_cid).unwrap(); + assert_eq!(trim_before_cid.to_string(), l1root); + + // NOTE: if we did mutations in-place, this is where we would mutate + + trim_map.remove("com.example.record/a15e33ba0f6c"); + let trim_after_cid = repo.mst_from_map(&trim_map).unwrap(); + assert_eq!(trim_after_cid.to_string(), l0root); +} + +#[test] +fn test_insertion() { + // "handles insertion that splits two layers down" + + let mut repo = RepoStore::open_ephemeral().unwrap(); + let cid1 = + Cid::from_str("bafyreie5cvv4h45feadgeuwhbcutmh6t2ceseocckahdoe6uat64zmz454").unwrap(); + let l1root = "bafyreiagt55jzvkenoa4yik77dhomagq2uj26ix4cijj7kd2py2u3s43ve"; + let l2root = "bafyreiddrz7qbvfattp5dzzh4ldohsaobatsg7f5l6awxnmuydewq66qoa"; + + // TODO: actual mutation instead of rebuild from scratch + let mut insertion_map: BTreeMap = Default::default(); + insertion_map.insert("com.example.record/403e2aeebfdb".to_string(), cid1.clone()); // A; level 0 + insertion_map.insert("com.example.record/40c73105b48f".to_string(), cid1.clone()); // B; level 0 + insertion_map.insert("com.example.record/645787eb4316".to_string(), cid1.clone()); // C; level 0 + insertion_map.insert("com.example.record/7ca4e61d6fbc".to_string(), cid1.clone()); // D; level 1 + insertion_map.insert("com.example.record/893e6c08b450".to_string(), cid1.clone()); // E; level 0 + insertion_map.insert("com.example.record/9cd8b6c0cc02".to_string(), cid1.clone()); // G; level 0 + insertion_map.insert("com.example.record/cbe72d33d12a".to_string(), cid1.clone()); // H; level 0 + insertion_map.insert("com.example.record/dbea731be795".to_string(), cid1.clone()); // I; level 1 + insertion_map.insert("com.example.record/e2ef555433f2".to_string(), cid1.clone()); // J; level 0 + insertion_map.insert("com.example.record/e99bf3ced34b".to_string(), cid1.clone()); // K; level 0 + insertion_map.insert("com.example.record/f728ba61e4b6".to_string(), cid1.clone()); // L; level 0 + let insertion_before_cid = repo.mst_from_map(&insertion_map).unwrap(); + assert_eq!(insertion_before_cid.to_string(), l1root); + + insertion_map.insert("com.example.record/9ba1c7247ede".to_string(), cid1.clone()); + let insertion_after_cid = repo.mst_from_map(&insertion_map).unwrap(); + assert_eq!(insertion_after_cid.to_string(), l2root); +} + +#[test] +fn test_higher_layers() { + // "handles new layers that are two higher than existing" + + use adenosine::mst::print_mst_keys; + let mut repo = RepoStore::open_ephemeral().unwrap(); + let cid1 = + Cid::from_str("bafyreie5cvv4h45feadgeuwhbcutmh6t2ceseocckahdoe6uat64zmz454").unwrap(); + let l0root = "bafyreicivoa3p3ttcebdn2zfkdzenkd2uk3gxxlaz43qvueeip6yysvq2m"; + let l2root = "bafyreidwoqm6xlewxzhrx6ytbyhsazctlv72txtmnd4au6t53z2vpzn7wa"; + let l2root2 = "bafyreiapru27ce4wdlylk5revtr3hewmxhmt3ek5f2ypioiivmdbv5igrm"; + + // TODO: actual mutation instead of rebuild from scratch + let mut higher_map: BTreeMap = Default::default(); + higher_map.insert("com.example.record/403e2aeebfdb".to_string(), cid1.clone()); // A; level 0 + higher_map.insert("com.example.record/cbe72d33d12a".to_string(), cid1.clone()); // C; level 0 + let higher_before_cid = repo.mst_from_map(&higher_map).unwrap(); + assert_eq!(higher_before_cid.to_string(), l0root); + + higher_map.insert("com.example.record/9ba1c7247ede".to_string(), cid1.clone()); // B; level 2 + let higher_after_cid = repo.mst_from_map(&higher_map).unwrap(); + print_mst_keys(&mut repo.db, &higher_after_cid).unwrap(); + assert_eq!(higher_after_cid.to_string(), l2root); + + higher_map.insert("com.example.record/fae7a851fbeb".to_string(), cid1.clone()); // D; level 1 + let higher_after_cid = repo.mst_from_map(&higher_map).unwrap(); + assert_eq!(higher_after_cid.to_string(), l2root2); +} diff --git a/adenosine/tests/test_repro_mst.rs b/adenosine/tests/test_repro_mst.rs new file mode 100644 index 0000000..692c41b --- /dev/null +++ b/adenosine/tests/test_repro_mst.rs @@ -0,0 +1,26 @@ +use adenosine::repo::RepoStore; +use std::path::PathBuf; +use std::str::FromStr; + +#[test] +fn test_repro_mst() { + let mut repo = RepoStore::open_ephemeral().unwrap(); + let cid = repo + .import_car_path( + &PathBuf::from_str("./tests/example_repo.car").unwrap(), + None, + ) + .unwrap(); + repo.verify_repo_mst(&cid).unwrap(); + let cid = repo + .import_car_path(&PathBuf::from_str("./tests/bigger.car").unwrap(), None) + .unwrap(); + repo.verify_repo_mst(&cid).unwrap(); + + // test round-tripping from export + let car_bytes = repo.export_car(&cid, None).unwrap(); + let mut other_repo = RepoStore::open_ephemeral().unwrap(); + let other_cid = other_repo.import_car_bytes(&car_bytes, None).unwrap(); + other_repo.verify_repo_mst(&cid).unwrap(); + assert_eq!(cid, other_cid); +} -- cgit v1.2.3