diff --git a/Cargo.lock b/Cargo.lock index fedb48d..045f57a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,12 +33,6 @@ dependencies = [ "constant_time_eq", ] -[[package]] -name = "bytes" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" - [[package]] name = "cc" version = "1.2.1" @@ -71,12 +65,6 @@ dependencies = [ "syn", ] -[[package]] -name = "equivalent" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" - [[package]] name = "form_urlencoded" version = "1.2.1" @@ -86,12 +74,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "hashbrown" -version = "0.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" - [[package]] name = "icu_collections" version = "1.5.0" @@ -231,17 +213,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "indexmap" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" -dependencies = [ - "equivalent", - "hashbrown", - "serde", -] - [[package]] name = "itoa" version = "1.0.14" @@ -281,11 +252,9 @@ version = "0.2.0" dependencies = [ "base32", "blake3", - "bytes", "serde", "serde_json", "url", - "utoipa", ] [[package]] @@ -414,29 +383,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "utoipa" -version = "5.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "514a48569e4e21c86d0b84b5612b5e73c0b2cf09db63260134ba426d4e8ea714" -dependencies = [ - "indexmap", - "serde", - "serde_json", - "utoipa-gen", -] - -[[package]] -name = "utoipa-gen" -version = "5.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5629efe65599d0ccd5d493688cbf6e03aa7c1da07fe59ff97cf5977ed0637f66" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "write16" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index c0c478e..b070918 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,10 +9,8 @@ license = "MIT" documentation = "https://github.com/pubky/pubky-app-specs" [dependencies] -bytes = "^1.7.0" serde = { version = "1.0.215", features = ["derive"] } serde_json = "1.0.133" -utoipa = "5.2.0" url = "2.5.4" base32 = "0.5.1" blake3 = "1.5.4" diff --git a/src/bookmark.rs b/src/bookmark.rs index 65ec5b4..bf53b2c 100644 --- a/src/bookmark.rs +++ b/src/bookmark.rs @@ -52,7 +52,6 @@ impl Validatable for PubkyAppBookmark { mod tests { use super::*; use crate::traits::Validatable; - use bytes::Bytes; #[test] fn test_create_bookmark_id() { @@ -107,7 +106,7 @@ mod tests { let bookmark = PubkyAppBookmark::new(uri.clone()); let id = bookmark.create_id(); - let blob = Bytes::from(bookmark_json); + let blob = bookmark_json.as_bytes(); let bookmark_parsed = ::try_from(&blob, &id).unwrap(); assert_eq!(bookmark_parsed.uri, uri); diff --git a/src/file.rs b/src/file.rs index e6389ac..1d1ada0 100644 --- a/src/file.rs +++ b/src/file.rs @@ -52,7 +52,6 @@ impl Validatable for PubkyAppFile { mod tests { use super::*; use crate::traits::Validatable; - use bytes::Bytes; #[test] fn test_new() { @@ -136,7 +135,7 @@ mod tests { ); let id = file.create_id(); - let blob = Bytes::from(file_json); + let blob = file_json.as_bytes(); let file_parsed = ::try_from(&blob, &id).unwrap(); assert_eq!(file_parsed.name, "example.png"); diff --git a/src/follow.rs b/src/follow.rs index 768b7e7..0f0ef07 100644 --- a/src/follow.rs +++ b/src/follow.rs @@ -1,21 +1,86 @@ -use crate::traits::Validatable; +use crate::{ + common::timestamp, + traits::{HasPubkyIdPath, Validatable}, + APP_PATH, +}; use serde::{Deserialize, Serialize}; /// Represents raw homeserver follow object with timestamp +/// +/// On follow objects, the main data is encoded in the path +/// /// URI: /pub/pubky.app/follows/:user_id /// /// Example URI: /// -/// `/pub/pubky.app/follows/pxnu33x7jtpx9ar1ytsi4yxbp6a5o36gwhffs8zoxmbuptici1jy`` +/// `/pub/pubky.app/follows/pxnu33x7jtpx9ar1ytsi4yxbp6a5o36gwhffs8zoxmbuptici1jy` /// -#[derive(Serialize, Deserialize, Default)] +#[derive(Serialize, Deserialize, Default, Debug)] pub struct PubkyAppFollow { created_at: i64, } +impl PubkyAppFollow { + /// Creates a new `PubkyAppFollow` instance. + pub fn new() -> Self { + let created_at = timestamp(); + Self { created_at } + } +} + impl Validatable for PubkyAppFollow { fn validate(&self, _id: &str) -> Result<(), String> { - // TODO: additional follow validation? E.g, validate `created_at` ? + // TODO: additional follow validation? E.g., validate `created_at`? Ok(()) } } + +impl HasPubkyIdPath for PubkyAppFollow { + fn create_path(&self, pubky_id: &str) -> String { + format!("{}follows/{}", APP_PATH, pubky_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::Validatable; + + #[test] + fn test_new() { + let follow = PubkyAppFollow::new(); + // Check that created_at is recent + let now = timestamp(); + assert!(follow.created_at <= now && follow.created_at >= now - 1_000_000); + // within 1 second + } + + #[test] + fn test_create_path_with_id() { + let mute = PubkyAppFollow::new(); + let path = mute.create_path("user_id123"); + assert_eq!(path, "/pub/pubky.app/follows/user_id123"); + } + + #[test] + fn test_validate() { + let follow = PubkyAppFollow::new(); + let result = follow.validate("some_user_id"); + assert!(result.is_ok()); + } + + #[test] + fn test_try_from_valid() { + let follow_json = r#" + { + "created_at": 1627849723 + } + "#; + + let blob = follow_json.as_bytes(); + let follow_parsed = + ::try_from(&blob, "some_user_id").unwrap(); + + assert_eq!(follow_parsed.created_at, 1627849723); + } +} diff --git a/src/mute.rs b/src/mute.rs index a2f995a..b0e740b 100644 --- a/src/mute.rs +++ b/src/mute.rs @@ -1,4 +1,8 @@ -use crate::traits::Validatable; +use crate::{ + common::timestamp, + traits::{HasPubkyIdPath, Validatable}, + APP_PATH, +}; use serde::{Deserialize, Serialize}; /// Represents raw homeserver Mute object with timestamp @@ -6,16 +10,74 @@ use serde::{Deserialize, Serialize}; /// /// Example URI: /// -/// `/pub/pubky.app/mutes/pxnu33x7jtpx9ar1ytsi4yxbp6a5o36gwhffs8zoxmbuptici1jy`` +/// `/pub/pubky.app/mutes/pxnu33x7jtpx9ar1ytsi4yxbp6a5o36gwhffs8zoxmbuptici1jy` /// -#[derive(Serialize, Deserialize, Default)] +#[derive(Serialize, Deserialize, Default, Debug)] pub struct PubkyAppMute { created_at: i64, } +impl PubkyAppMute { + /// Creates a new `PubkyAppMute` instance. + pub fn new() -> Self { + let created_at = timestamp(); + Self { created_at } + } +} + impl Validatable for PubkyAppMute { fn validate(&self, _id: &str) -> Result<(), String> { - // TODO: additional Mute validation? E.g, validate `created_at` ? + // TODO: additional Mute validation? E.g., validate `created_at` ? Ok(()) } } + +impl HasPubkyIdPath for PubkyAppMute { + fn create_path(&self, pubky_id: &str) -> String { + format!("{}mutes/{}", APP_PATH, pubky_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::timestamp; + use crate::traits::Validatable; + + #[test] + fn test_new() { + let mute = PubkyAppMute::new(); + // Check that created_at is recent + let now = timestamp(); + assert!(mute.created_at <= now && mute.created_at >= now - 1_000_000); + // within 1 second + } + + #[test] + fn test_create_path_with_id() { + let mute = PubkyAppMute::new(); + let path = mute.create_path("user_id123"); + assert_eq!(path, "/pub/pubky.app/mutes/user_id123"); + } + + #[test] + fn test_validate() { + let mute = PubkyAppMute::new(); + let result = mute.validate("some_user_id"); + assert!(result.is_ok()); + } + + #[test] + fn test_try_from_valid() { + let mute_json = r#" + { + "created_at": 1627849723 + } + "#; + + let blob = mute_json.as_bytes(); + let mute_parsed = ::try_from(&blob, "some_user_id").unwrap(); + + assert_eq!(mute_parsed.created_at, 1627849723); + } +} diff --git a/src/post.rs b/src/post.rs index 37bc26c..89d3db1 100644 --- a/src/post.rs +++ b/src/post.rs @@ -5,7 +5,6 @@ use crate::{ use serde::{Deserialize, Serialize}; use std::fmt; use url::Url; -use utoipa::ToSchema; // Validation const MAX_SHORT_CONTENT_LENGTH: usize = 1000; @@ -13,7 +12,7 @@ const MAX_LONG_CONTENT_LENGTH: usize = 50000; /// Represents the type of pubky-app posted data /// Used primarily to best display the content in UI -#[derive(Serialize, Deserialize, ToSchema, Default, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] #[serde(rename_all = "lowercase")] pub enum PubkyAppPostKind { #[default] @@ -173,7 +172,6 @@ impl Validatable for PubkyAppPost { mod tests { use super::*; use crate::traits::Validatable; - use bytes::Bytes; #[test] fn test_create_id() { @@ -297,7 +295,7 @@ mod tests { ) .create_id(); - let blob = Bytes::from(post_json); + let blob = post_json.as_bytes(); let post = ::try_from(&blob, &id).unwrap(); assert_eq!(post.content, "Hello World!"); @@ -320,7 +318,7 @@ mod tests { let id = PubkyAppPost::new(content.clone(), PubkyAppPostKind::Short, None, None, None) .create_id(); - let blob = Bytes::from(post_json); + let blob = post_json.as_bytes(); let post = ::try_from(&blob, &id).unwrap(); assert_eq!(post.content, "empty"); // After sanitization diff --git a/src/tag.rs b/src/tag.rs index 4aaac7e..15481e3 100644 --- a/src/tag.rs +++ b/src/tag.rs @@ -100,7 +100,6 @@ impl Validatable for PubkyAppTag { mod tests { use super::*; use crate::{traits::Validatable, APP_PATH}; - use bytes::Bytes; #[test] fn test_create_id() { @@ -221,7 +220,7 @@ mod tests { ) .create_id(); - let blob = Bytes::from(tag_json); + let blob = tag_json.as_bytes(); let tag = ::try_from(&blob, &id).unwrap(); assert_eq!(tag.uri, "pubky://user_pubky_id/pub/pubky.app/profile.json"); assert_eq!(tag.label, "cool tag"); // After sanitization @@ -238,7 +237,7 @@ mod tests { "#; let id = "B55PGPFV1E5E0HQ2PB76EQGXPR"; - let blob = Bytes::from(tag_json); + let blob = tag_json.as_bytes(); let result = ::try_from(&blob, &id); assert!(result.is_err()); assert_eq!( diff --git a/src/traits.rs b/src/traits.rs index fe27958..8c09983 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -1,7 +1,6 @@ use crate::common::timestamp; use base32::{decode, encode, Alphabet}; use blake3::Hasher; -use bytes::Bytes; use serde::de::DeserializeOwned; pub trait TimestampId { @@ -104,7 +103,7 @@ pub trait HashId { } pub trait Validatable: Sized + DeserializeOwned { - fn try_from(blob: &Bytes, id: &str) -> Result { + fn try_from(blob: &[u8], id: &str) -> Result { let mut instance: Self = serde_json::from_slice(blob).map_err(|e| e.to_string())?; instance = instance.sanitize(); instance.validate(id)?; @@ -121,3 +120,7 @@ pub trait Validatable: Sized + DeserializeOwned { pub trait HasPath { fn create_path(&self) -> String; } + +pub trait HasPubkyIdPath { + fn create_path(&self, pubky_id: &str) -> String; +} diff --git a/src/user.rs b/src/user.rs index 317e291..ccbb54e 100644 --- a/src/user.rs +++ b/src/user.rs @@ -1,9 +1,11 @@ -use crate::traits::Validatable; +use crate::{ + traits::{HasPath, Validatable}, + APP_PATH, +}; use serde::{Deserialize, Serialize}; use url::Url; -use utoipa::ToSchema; -// Validation +// Validation constants const MIN_USERNAME_LENGTH: usize = 3; const MAX_USERNAME_LENGTH: usize = 50; const MAX_BIO_LENGTH: usize = 160; @@ -15,7 +17,7 @@ const MAX_STATUS_LENGTH: usize = 50; /// Profile schema /// URI: /pub/pubky.app/profile.json -#[derive(Deserialize, Serialize, Debug, Default)] +#[derive(Deserialize, Serialize, Debug, Default, Clone)] pub struct PubkyAppUser { name: String, bio: Option, @@ -25,12 +27,38 @@ pub struct PubkyAppUser { } /// Represents a user's single link with a title and URL. -#[derive(Serialize, Deserialize, ToSchema, Default, Clone, Debug)] +#[derive(Serialize, Deserialize, Default, Clone, Debug)] pub struct PubkyAppUserLink { title: String, url: String, } +impl PubkyAppUser { + /// Creates a new `PubkyAppUser` instance and sanitizes it. + pub fn new( + name: String, + bio: Option, + image: Option, + links: Option>, + status: Option, + ) -> Self { + Self { + name, + bio, + image, + links, + status, + } + .sanitize() + } +} + +impl HasPath for PubkyAppUser { + fn create_path(&self) -> String { + format!("{}profile.json", APP_PATH) + } +} + impl Validatable for PubkyAppUser { fn sanitize(self) -> Self { // Sanitize name @@ -177,3 +205,168 @@ impl Validatable for PubkyAppUserLink { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::Validatable; + use crate::APP_PATH; + + #[test] + fn test_new() { + let user = PubkyAppUser::new( + "Alice".to_string(), + Some("Maximalist".to_string()), + Some("https://example.com/image.png".to_string()), + Some(vec![ + PubkyAppUserLink { + title: "GitHub".to_string(), + url: "https://github.com/alice".to_string(), + }, + PubkyAppUserLink { + title: "Website".to_string(), + url: "https://alice.dev".to_string(), + }, + ]), + Some("Exploring the decentralized web.".to_string()), + ); + + assert_eq!(user.name, "Alice"); + assert_eq!(user.bio.as_deref(), Some("Maximalist")); + assert_eq!(user.image.as_deref(), Some("https://example.com/image.png")); + assert_eq!( + user.status.as_deref(), + Some("Exploring the decentralized web.") + ); + assert!(user.links.is_some()); + assert_eq!(user.links.as_ref().unwrap().len(), 2); + } + + #[test] + fn test_create_path() { + let user = PubkyAppUser::default(); + let path = user.create_path(); + assert_eq!(path, format!("{}profile.json", APP_PATH)); + } + + #[test] + fn test_sanitize() { + let user = PubkyAppUser::new( + " Alice ".to_string(), + Some(" Maximalist and developer. ".to_string()), + Some("https://example.com/image.png".to_string()), + Some(vec![ + PubkyAppUserLink { + title: " GitHub ".to_string(), + url: " https://github.com/alice ".to_string(), + }, + PubkyAppUserLink { + title: "Website".to_string(), + url: "invalid_url".to_string(), // Invalid URL + }, + ]), + Some(" Exploring the decentralized web. ".to_string()), + ); + + assert_eq!(user.name, "Alice"); + assert_eq!(user.bio.as_deref(), Some("Maximalist and developer.")); + assert_eq!(user.image.as_deref(), Some("https://example.com/image.png")); + assert_eq!( + user.status.as_deref(), + Some("Exploring the decentralized web.") + ); + assert!(user.links.is_some()); + let links = user.links.unwrap(); + assert_eq!(links.len(), 1); // Invalid URL link should be filtered out + assert_eq!(links[0].title, "GitHub"); + assert_eq!(links[0].url, "https://github.com/alice"); + } + + #[test] + fn test_validate_valid() { + let user = PubkyAppUser::new( + "Alice".to_string(), + Some("Maximalist".to_string()), + Some("https://example.com/image.png".to_string()), + None, + Some("Exploring the decentralized web.".to_string()), + ); + + let result = user.validate(""); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_invalid_name() { + let user = PubkyAppUser::new( + "Al".to_string(), // Too short + None, + None, + None, + None, + ); + + let result = user.validate(""); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().to_string(), + "Validation Error: Invalid name length" + ); + } + + #[test] + fn test_try_from_valid() { + let user_json = r#" + { + "name": "Alice", + "bio": "Maximalist", + "image": "https://example.com/image.png", + "links": [ + { + "title": "GitHub", + "url": "https://github.com/alice" + }, + { + "title": "Website", + "url": "https://alice.dev" + } + ], + "status": "Exploring the decentralized web." + } + "#; + + let blob = user_json.as_bytes(); + let user = ::try_from(&blob, "").unwrap(); + + assert_eq!(user.name, "Alice"); + assert_eq!(user.bio.as_deref(), Some("Maximalist")); + assert_eq!(user.image.as_deref(), Some("https://example.com/image.png")); + assert_eq!( + user.status.as_deref(), + Some("Exploring the decentralized web.") + ); + assert!(user.links.is_some()); + assert_eq!(user.links.as_ref().unwrap().len(), 2); + } + + #[test] + fn test_try_from_invalid_link() { + let user_json = r#" + { + "name": "Alice", + "links": [ + { + "title": "GitHub", + "url": "invalid_url" + } + ] + } + "#; + + let blob = user_json.as_bytes(); + let user = ::try_from(&blob, "").unwrap(); + + // Since the link URL is invalid, it should be filtered out + assert!(user.links.is_none() || user.links.as_ref().unwrap().is_empty()); + } +}