diff --git a/Cargo.lock b/Cargo.lock index dd20eb64..822c1da1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2328,6 +2328,8 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "base32", + "blake3", "chrono", "const_format", "criterion", diff --git a/Cargo.toml b/Cargo.toml index 832d5909..d6c3a969 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,8 @@ chrono = "0.4.38" pkarr = { version = "2.2.0", features = ["async"], default-features = false } pubky = { path = "pubky/pubky" } reqwest = "0.12.7" +base32 = "0.5.1" +blake3 = "1.5.4" [dev-dependencies] anyhow = "1.0.86" diff --git a/benches/watcher.rs b/benches/watcher.rs index 106162d1..c8df6a7d 100644 --- a/benches/watcher.rs +++ b/benches/watcher.rs @@ -2,7 +2,7 @@ use criterion::{criterion_group, criterion_main, Criterion}; use pkarr::{mainline::Testnet, Keypair}; use pubky::PubkyClient; use pubky_homeserver::Homeserver; -use pubky_nexus::models::homeserver::{HomeserverUser, UserLink}; +use pubky_nexus::models::pubky_app::{PubkyAppUser, UserLink}; use pubky_nexus::EventProcessor; use setup::run_setup; use std::time::Duration; @@ -32,7 +32,7 @@ async fn create_homeserver_with_events() -> (Testnet, String) { .await .unwrap(); - let user = HomeserverUser { + let user = PubkyAppUser { bio: Some("This is an example bio".to_string()), image: Some("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjiO4O+w8ABL0CPPcYQa4AAAAASUVORK5CYII=".to_string()), links: Some(vec![UserLink { diff --git a/examples/populate_homeserver.rs b/examples/populate_homeserver.rs index 24d9ca1b..e2084b8a 100644 --- a/examples/populate_homeserver.rs +++ b/examples/populate_homeserver.rs @@ -3,7 +3,7 @@ use log::info; use pkarr::{mainline::Testnet, Keypair, PublicKey}; use pubky::PubkyClient; use pubky_nexus::{ - models::homeserver::{HomeserverUser, UserLink}, + models::pubky_app::{PubkyAppUser, UserLink}, setup, Config, }; @@ -36,7 +36,7 @@ async fn main() -> Result<()> { client.signup(&keypair, &homeserver).await?; // Create a new profile - let user = HomeserverUser { + let user = PubkyAppUser { bio: Some("This is an example bio".to_string()), image: Some("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjiO4O+w8ABL0CPPcYQa4AAAAASUVORK5CYII=".to_string()), links: Some(vec![UserLink { diff --git a/src/events/mod.rs b/src/events/mod.rs index e643830a..7d4a146a 100644 --- a/src/events/mod.rs +++ b/src/events/mod.rs @@ -1,5 +1,5 @@ use crate::models::{ - homeserver::HomeserverUser, + pubky_app::PubkyAppUser, traits::Collection, user::{UserCounts, UserDetails}, }; @@ -125,7 +125,7 @@ impl Event { debug!("Processing User resource at {}", self.uri.path); // Serialize and validate - let user = HomeserverUser::try_from(&blob).await?; + let user = PubkyAppUser::try_from(&blob).await?; // Create UserDetails object let user_details = match self.get_user_id() { diff --git a/src/models/homeserver/mod.rs b/src/models/homeserver/mod.rs deleted file mode 100644 index e153e1db..00000000 --- a/src/models/homeserver/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -/// Raw schemas stored on homeserver. -pub mod user; - -pub use user::{HomeserverUser, UserLink}; diff --git a/src/models/mod.rs b/src/models/mod.rs index a778420c..555a2680 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,6 +1,6 @@ -pub mod homeserver; pub mod info; pub mod post; +pub mod pubky_app; pub mod tag; pub mod traits; pub mod user; diff --git a/src/models/post/details.rs b/src/models/post/details.rs index 68127f11..14b8fd75 100644 --- a/src/models/post/details.rs +++ b/src/models/post/details.rs @@ -1,24 +1,11 @@ +use super::PostStream; use crate::db::connectors::neo4j::get_neo4j_graph; +use crate::models::pubky_app::PostKind; use crate::{queries, RedisOps}; use neo4rs::Node; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use super::PostStream; - -/// Represents the type of pubky-app posted data -/// Used primarily to best display the content in UI -#[derive(Serialize, Deserialize, ToSchema, Default)] -pub enum PostKind { - #[default] - Short, - Long, - Image, - Video, - Link, - File, -} - /// Represents post data with content, bio, image, links, and status. #[derive(Serialize, Deserialize, ToSchema, Default)] pub struct PostDetails { diff --git a/src/models/post/mod.rs b/src/models/post/mod.rs index 0fe81be8..b438fd79 100644 --- a/src/models/post/mod.rs +++ b/src/models/post/mod.rs @@ -8,7 +8,7 @@ mod view; pub use bookmark::Bookmark; pub use counts::PostCounts; -pub use details::{PostDetails, PostKind}; +pub use details::PostDetails; pub use relationships::PostRelationships; pub use stream::{PostStream, PostStreamReach, PostStreamSorting}; pub use thread::PostThread; diff --git a/src/models/pubky_app/bookmark.rs b/src/models/pubky_app/bookmark.rs new file mode 100644 index 00000000..dd4b3470 --- /dev/null +++ b/src/models/pubky_app/bookmark.rs @@ -0,0 +1,33 @@ +use super::traits::GenerateId; +use serde::{Deserialize, Serialize}; + +/// Represents raw homeserver bookmark with id +/// URI: /pub/pubky.app/bookmarks/:bookmark_id +/// +/// Example URI: +/// +/// `/pub/pubky.app/bookmarks/kx8uzgiq5f75bqofp51nq8r11r` +/// +#[derive(Serialize, Deserialize, Default)] +pub struct PubkyAppBookmark { + pub uri: String, + pub created_at: i64, +} + +impl GenerateId for PubkyAppBookmark { + /// Bookmark ID is created based on the hash of the URI bookmarked + fn get_id_data(&self) -> String { + self.uri.clone() + } +} + +#[test] +fn test_create_bookmark_id() { + let bookmark = PubkyAppBookmark { + uri: "user_id/pub/pubky.app/posts/post_id".to_string(), + created_at: 1627849723, + }; + + let bookmark_id = bookmark.create_id(); + println!("Generated Bookmark ID: {}", bookmark_id); +} diff --git a/src/models/pubky_app/follow.rs b/src/models/pubky_app/follow.rs new file mode 100644 index 00000000..3b7ec88d --- /dev/null +++ b/src/models/pubky_app/follow.rs @@ -0,0 +1,13 @@ +use serde::{Deserialize, Serialize}; + +/// Represents raw homeserver follow object with timestamp +/// URI: /pub/pubky.app/follows/:user_id +/// +/// Example URI: +/// +/// `/pub/pubky.app/follows/pxnu33x7jtpx9ar1ytsi4yxbp6a5o36gwhffs8zoxmbuptici1jy`` +/// +#[derive(Serialize, Deserialize, Default)] +pub struct PubkyAppFollow { + pub created_at: i64, +} diff --git a/src/models/pubky_app/mod.rs b/src/models/pubky_app/mod.rs new file mode 100644 index 00000000..c653fa09 --- /dev/null +++ b/src/models/pubky_app/mod.rs @@ -0,0 +1,13 @@ +/// Raw Pubky.App schemas as stored on homeserver. +mod bookmark; +mod follow; +mod post; +mod tag; +pub mod traits; +mod user; + +pub use bookmark::PubkyAppBookmark; +pub use follow::PubkyAppFollow; +pub use post::{PostKind, PubkyAppPost}; +pub use tag::PubkyAppTag; +pub use user::{PubkyAppUser, UserLink}; diff --git a/src/models/pubky_app/post.rs b/src/models/pubky_app/post.rs new file mode 100644 index 00000000..290dcf1f --- /dev/null +++ b/src/models/pubky_app/post.rs @@ -0,0 +1,32 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Represents the type of pubky-app posted data +/// Used primarily to best display the content in UI +#[derive(Serialize, Deserialize, ToSchema, Default)] +pub enum PostKind { + #[default] + Short, + Long, + Image, + Video, + Link, + File, +} + +/// Used primarily to best display the content in UI +#[derive(Serialize, Deserialize, Default)] +pub struct PostEmbed { + pub r#type: String, //e.g., "post", we have to define a type for this. + pub uri: String, +} + +/// Represents raw post in homeserver with content and kind +/// URI: /pub/pubky.app/posts/:post_id +/// Where post_id is CrockfordBase32 encoding of timestamp +#[derive(Serialize, Deserialize, Default)] +pub struct PubkyAppPost { + pub content: String, + pub kind: PostKind, + pub embed: PostEmbed, +} diff --git a/src/models/pubky_app/tag.rs b/src/models/pubky_app/tag.rs new file mode 100644 index 00000000..676d6d38 --- /dev/null +++ b/src/models/pubky_app/tag.rs @@ -0,0 +1,71 @@ +use super::traits::GenerateId; +use base32::{encode, Alphabet}; +use blake3::Hasher; +use serde::{Deserialize, Serialize}; + +/// Represents raw homeserver tag with id +/// URI: /pub/pubky.app/tags/:tag_id +/// +/// Example URI: +/// +/// `/pub/pubky.app/tags/xsmykwj3jdzdwbox6bu5yjowzw` +/// +/// Where tag_id is z-base32(Sha256("{uri_tagged}:{")))[:8] +#[derive(Serialize, Deserialize, Default)] +pub struct PubkyAppTag { + pub uri: String, + pub label: String, + pub created_at: i64, +} + +impl GenerateId for PubkyAppTag { + /// Tag ID is created based on the hash of the URI tagged and the label used + fn get_id_data(&self) -> String { + format!("{}:{}", self.uri, self.label) + } +} + +impl PubkyAppTag { + /// Creates a unique identifier (tag ID) for the `PubkyAppTag` instance. + /// + /// The tag ID is generated by: + /// 1. Concatenating the `uri` and `label` fields of the `PubkyAppTag` with a colon (`:`) separator. + /// 2. Hashing the concatenated string using the `blake3` hashing algorithm. + /// 3. Taking the first half of the bytes from the resulting `blake3` hash. + /// 4. Encoding those bytes using the Z-base32 alphabet (Base32 variant). + /// + /// The resulting Base32-encoded string is returned as the tag ID. + /// + /// # Returns + /// - A `String` representing the Base32-encoded tag ID derived from the `blake3` hash of the concatenated `uri` and `label`. + pub fn create_id(&self) -> String { + // Concatenate the URI and label with a colon in between + let data = format!("{}:{}", self.uri, self.label); + + // Create a Blake3 hash of the concatenated string + let mut hasher = Hasher::new(); + hasher.update(data.as_bytes()); + let blake3_hash = hasher.finalize(); + + // Get the first half of the hash bytes + let half_hash_length = blake3_hash.as_bytes().len() / 2; + let half_hash = &blake3_hash.as_bytes()[..half_hash_length]; + + // Encode the first half of the hash in Base32 using the Z-base32 alphabet + + // Return the Base32 encoded string as the tag ID + encode(Alphabet::Z, half_hash) + } +} + +#[test] +fn testcreate_id() { + let tag = PubkyAppTag { + uri: "user_id/pub/pubky.app/posts/post_id".to_string(), + created_at: 1627849723, + label: "cool".to_string(), + }; + + let tag_id = tag.create_id(); + println!("Generated Tag ID: {}", tag_id); +} diff --git a/src/models/pubky_app/traits.rs b/src/models/pubky_app/traits.rs new file mode 100644 index 00000000..a5d60d36 --- /dev/null +++ b/src/models/pubky_app/traits.rs @@ -0,0 +1,35 @@ +use base32::{encode, Alphabet}; +use blake3::Hasher; + +/// Trait for generating an ID based on the struct's data. +pub trait GenerateId { + fn get_id_data(&self) -> String; + + /// Creates a unique identifier for bookmarks and tag homeserver paths instance. + /// + /// The ID is generated by: + /// 1. Concatenating the `uri` and `label` fields of the `PubkyAppTag` with a colon (`:`) separator. + /// 2. Hashing the concatenated string using the `blake3` hashing algorithm. + /// 3. Taking the first half of the bytes from the resulting `blake3` hash. + /// 4. Encoding those bytes using the Z-base32 alphabet (Base32 variant). + /// + /// The resulting Base32-encoded string is returned as the tag ID. + /// + /// # Returns + /// - A `String` representing the Base32-encoded tag ID derived from the `blake3` hash of the concatenated `uri` and `label`. + fn create_id(&self) -> String { + let data = self.get_id_data(); + + // Create a Blake3 hash of the input data + let mut hasher = Hasher::new(); + hasher.update(data.as_bytes()); + let blake3_hash = hasher.finalize(); + + // Get the first half of the hash bytes + let half_hash_length = blake3_hash.as_bytes().len() / 2; + let half_hash = &blake3_hash.as_bytes()[..half_hash_length]; + + // Encode the first half of the hash in Base32 using the Z-base32 alphabet + encode(Alphabet::Z, half_hash) + } +} diff --git a/src/models/homeserver/user.rs b/src/models/pubky_app/user.rs similarity index 91% rename from src/models/homeserver/user.rs rename to src/models/pubky_app/user.rs index 0c437a4a..23f27f14 100644 --- a/src/models/homeserver/user.rs +++ b/src/models/pubky_app/user.rs @@ -3,8 +3,9 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; /// Profile schema +/// URI: /pub/pubky.app/profile.json #[derive(Deserialize, Serialize, Debug)] -pub struct HomeserverUser { +pub struct PubkyAppUser { pub name: String, pub bio: Option, pub image: Option, @@ -19,7 +20,7 @@ pub struct UserLink { pub url: String, } -impl HomeserverUser { +impl PubkyAppUser { pub async fn try_from(blob: &Bytes) -> Result> { let user: Self = serde_json::from_slice(blob)?; user.validate().await?; diff --git a/src/models/user/details.rs b/src/models/user/details.rs index dc9ca94f..5b0dc7fb 100644 --- a/src/models/user/details.rs +++ b/src/models/user/details.rs @@ -1,6 +1,6 @@ use super::UserSearch; use crate::db::graph::exec::exec_single_row; -use crate::models::homeserver::{HomeserverUser, UserLink}; +use crate::models::pubky_app::{PubkyAppUser, UserLink}; use crate::models::traits::Collection; use crate::{queries, RedisOps}; use axum::async_trait; @@ -67,7 +67,7 @@ impl UserDetails { pub async fn from_homeserver( user_id: &str, - homeserver_user: HomeserverUser, + homeserver_user: PubkyAppUser, ) -> Result> { // Validate user_id is a valid pkarr public key PublicKey::try_from(user_id)?; diff --git a/src/routes/v0/post/details.rs b/src/routes/v0/post/details.rs index 90a05f2f..c39da17d 100644 --- a/src/routes/v0/post/details.rs +++ b/src/routes/v0/post/details.rs @@ -1,4 +1,5 @@ -use crate::models::post::{PostDetails, PostKind}; +use crate::models::post::PostDetails; +use crate::models::pubky_app::PostKind; use crate::routes::v0::endpoints::POST_DETAILS_ROUTE; use crate::{Error, Result}; use axum::extract::Path; diff --git a/src/routes/v0/user/details.rs b/src/routes/v0/user/details.rs index 7f801418..d0978473 100644 --- a/src/routes/v0/user/details.rs +++ b/src/routes/v0/user/details.rs @@ -1,4 +1,4 @@ -use crate::models::homeserver::UserLink; +use crate::models::pubky_app::UserLink; use crate::models::user::UserDetails; use crate::routes::v0::endpoints::USER_DETAILS_ROUTE; use crate::{Error, Result}; diff --git a/tests/watcher/user.rs b/tests/watcher/user.rs index b014949f..cb5b21f1 100644 --- a/tests/watcher/user.rs +++ b/tests/watcher/user.rs @@ -5,7 +5,7 @@ use pubky::PubkyClient; use pubky_homeserver::Homeserver; use pubky_nexus::{ models::{ - homeserver::{HomeserverUser, UserLink}, + pubky_app::{PubkyAppUser, UserLink}, user::UserView, }, setup, Config, EventProcessor, @@ -34,7 +34,7 @@ async fn test_homeserver_user() -> Result<()> { .unwrap(); // Create a user sticking to the homeserver schema for pubky-app profiles - let user = HomeserverUser { + let user = PubkyAppUser { bio: Some("This is an example bio".to_string()), image: Some("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjiO4O+w8ABL0CPPcYQa4AAAAASUVORK5CYII=".to_string()), links: Some(vec![UserLink {