use crate::{
common::sanitize_url,
limits::VALIDATION_LIMITS,
traits::{HasPath, Validatable},
APP_PATH, PUBLIC_PATH,
};
use serde::{Deserialize, Serialize};
use url::Url;
#[cfg(target_arch = "wasm32")]
use crate::traits::Json;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::prelude::*;
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
#[derive(Deserialize, Serialize, Debug, Clone)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct PubkyAppUser {
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub name: String,
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub bio: Option<String>,
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub image: Option<String>,
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub links: Option<Vec<PubkyAppUserLink>>,
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub status: Option<String>,
}
impl Default for PubkyAppUser {
fn default() -> Self {
PubkyAppUser {
name: "anonymous".to_string(),
bio: None,
image: None,
links: None,
status: None,
}
.sanitize()
}
}
#[cfg(target_arch = "wasm32")]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
impl PubkyAppUser {
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn name(&self) -> String {
self.name.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn bio(&self) -> Option<String> {
self.bio.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn image(&self) -> Option<String> {
self.image.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn links(&self) -> Option<Vec<PubkyAppUserLink>> {
self.links.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn status(&self) -> Option<String> {
self.status.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = fromJson))]
pub fn from_json(js_value: &JsValue) -> Result<Self, String> {
Self::import_json(js_value)
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = toJson))]
pub fn to_json(&self) -> Result<JsValue, String> {
self.export_json()
}
}
#[cfg(target_arch = "wasm32")]
impl Json for PubkyAppUser {}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
#[derive(Serialize, Deserialize, Default, Clone, Debug)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct PubkyAppUserLink {
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub title: String,
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub url: String,
}
#[cfg(target_arch = "wasm32")]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
impl PubkyAppUserLink {
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn title(&self) -> String {
self.title.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn url(&self) -> String {
self.url.clone()
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
impl PubkyAppUser {
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(constructor))]
pub fn new(
name: String,
bio: Option<String>,
image: Option<String>,
links: Option<Vec<PubkyAppUserLink>>,
status: Option<String>,
) -> Self {
Self {
name,
bio,
image,
links,
status,
}
.sanitize()
}
}
impl HasPath for PubkyAppUser {
const PATH_SEGMENT: &'static str = "profile.json";
fn create_path() -> String {
[PUBLIC_PATH, APP_PATH, Self::PATH_SEGMENT].concat()
}
}
impl Validatable for PubkyAppUser {
fn sanitize(self) -> Self {
let mut name = self.name.trim().to_string();
if name == *"[DELETED]" {
name = "anonymous".to_string(); }
let bio = self.bio.map(|b| b.trim().to_string());
let image = self.image.map(|i| sanitize_url(&i));
let status = self.status.map(|s| s.trim().to_string());
let links = self
.links
.map(|links_vec| links_vec.into_iter().map(|link| link.sanitize()).collect());
PubkyAppUser {
name,
bio,
image,
links,
status,
}
}
fn validate(&self, _id: Option<&str>) -> Result<(), String> {
let name_length = self.name.chars().count();
if !(VALIDATION_LIMITS.user_name_min_length..=VALIDATION_LIMITS.user_name_max_length)
.contains(&name_length)
{
return Err("Validation Error: Invalid name length".into());
}
if let Some(bio) = &self.bio {
if bio.chars().count() > VALIDATION_LIMITS.user_bio_max_length {
return Err("Validation Error: Bio exceeds maximum length".into());
}
}
if let Some(image) = &self.image {
if image.is_empty() {
return Err("Validation Error: Image URI cannot be empty".into());
}
if image.chars().count() > VALIDATION_LIMITS.user_image_url_max_length {
return Err("Validation Error: Image URI exceeds maximum length".into());
}
Url::parse(image)
.map_err(|_| "Validation Error: Invalid image URI format".to_string())?;
}
if let Some(links) = &self.links {
if links.len() > VALIDATION_LIMITS.user_links_max_count {
return Err("Validation Error: Too many links".into());
}
for link in links {
link.validate(None)?;
}
}
if let Some(status) = &self.status {
if status.chars().count() > VALIDATION_LIMITS.user_status_max_length {
return Err("Validation Error: Status exceeds maximum length".into());
}
}
Ok(())
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
impl PubkyAppUserLink {
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(constructor))]
pub fn new(title: String, url: String) -> Self {
Self { title, url }.sanitize()
}
}
impl Validatable for PubkyAppUserLink {
fn sanitize(self) -> Self {
let title = self.title.trim().to_string();
let url = sanitize_url(&self.url);
PubkyAppUserLink { title, url }
}
fn validate(&self, _id: Option<&str>) -> Result<(), String> {
if self.title.trim().is_empty() {
return Err("Validation Error: Link title cannot be empty".into());
}
if self.title.chars().count() > VALIDATION_LIMITS.user_link_title_max_length {
return Err("Validation Error: Link title exceeds maximum length".into());
}
if self.url.trim().is_empty() {
return Err("Validation Error: Link URL cannot be empty".into());
}
if self.url.chars().count() > VALIDATION_LIMITS.user_link_url_max_length {
return Err("Validation Error: Link URL exceeds maximum length".into());
}
Url::parse(&self.url).map_err(|_| "Validation Error: Invalid URL format".to_string())?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::Validatable;
use crate::{APP_PATH, PUBLIC_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 path = PubkyAppUser::create_path();
assert_eq!(path, format!("{}{}profile.json", PUBLIC_PATH, 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: " https://example.com ".to_string(),
},
]),
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(), 2); assert_eq!(links[0].title, "GitHub");
assert_eq!(links[0].url, "https://github.com/alice"); assert_eq!(links[1].title, "Website");
assert_eq!(links[1].url, "https://example.com/"); }
#[test]
fn test_validate() {
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(None);
assert!(result.is_ok());
}
#[test]
fn test_validate_invalid_name() {
let user = PubkyAppUser::new(
"Al".to_string(), None,
None,
None,
None,
);
let result = user.validate(None);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().to_string(),
"Validation Error: Invalid name length"
);
let long_name = "a".repeat(VALIDATION_LIMITS.user_name_max_length + 1);
let user = PubkyAppUser::new(long_name.clone(), None, None, None, None);
assert_eq!(user.name.len(), VALIDATION_LIMITS.user_name_max_length + 1);
let result = user.validate(None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("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 = <PubkyAppUser as Validatable>::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 result = <PubkyAppUser as Validatable>::try_from(blob, "");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid URL format"));
}
#[test]
fn test_validate_invalid_image_url() {
let user_json = r#"
{
"name": "Alice",
"image": "invalid_image_url"
}
"#;
let blob = user_json.as_bytes();
let result = <PubkyAppUser as Validatable>::try_from(blob, "");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid image URI format"));
}
#[test]
fn test_sanitize_preserves_invalid_urls() {
let user = PubkyAppUser {
name: "Alice".to_string(),
bio: None,
image: Some(" invalid_image_url ".to_string()),
links: Some(vec![PubkyAppUserLink {
title: "Test".to_string(),
url: " invalid_link_url ".to_string(),
}]),
status: None,
};
let sanitized = user.sanitize();
assert_eq!(sanitized.image.as_deref(), Some("invalid_image_url"));
let links = sanitized.links.as_ref().unwrap();
assert_eq!(links.len(), 1);
assert_eq!(links[0].url, "invalid_link_url");
let result = sanitized.validate(None);
assert!(result.is_err());
}
#[test]
fn test_sanitize_preserves_length() {
let long_bio = "a".repeat(VALIDATION_LIMITS.user_bio_max_length + 10);
let long_status = "b".repeat(VALIDATION_LIMITS.user_status_max_length + 10);
let long_image = format!(
"https://example.com/{}.png",
"a".repeat(VALIDATION_LIMITS.user_image_url_max_length - 30)
);
let user = PubkyAppUser::new(
"Alice".to_string(),
Some(long_bio.clone()),
Some(long_image.clone()),
None,
Some(long_status.clone()),
);
assert_eq!(user.bio.as_deref(), Some(long_bio.as_str()));
assert_eq!(user.status.as_deref(), Some(long_status.as_str()));
assert_eq!(user.image.as_deref(), Some(long_image.as_str()));
}
#[test]
fn test_validate_field_length_errors() {
let test_cases = vec![
(
PubkyAppUser::new(
"Alice".to_string(),
Some("a".repeat(VALIDATION_LIMITS.user_bio_max_length + 1)),
None,
None,
None,
),
"bio",
),
(
PubkyAppUser::new(
"Alice".to_string(),
None,
None,
None,
Some("a".repeat(VALIDATION_LIMITS.user_status_max_length + 1)),
),
"status",
),
(
PubkyAppUser::new(
"Alice".to_string(),
None,
Some(format!(
"https://example.com/{}.png",
"a".repeat(VALIDATION_LIMITS.user_image_url_max_length - 20)
)),
None,
None,
),
"image",
),
];
for (user, field_name) in test_cases {
let result = user.validate(None);
assert!(
result.is_err(),
"Should reject {} that exceeds maximum length",
field_name
);
assert!(result.unwrap_err().contains("exceeds maximum length"));
}
}
#[test]
fn test_validate_too_many_links() {
let mut links = Vec::new();
for i in 0..VALIDATION_LIMITS.user_links_max_count + 1 {
links.push(PubkyAppUserLink {
title: format!("Link {}", i),
url: format!("https://example.com/{}", i),
});
}
let user = PubkyAppUser::new("Alice".to_string(), None, None, Some(links), None);
assert_eq!(
user.links.as_ref().unwrap().len(),
VALIDATION_LIMITS.user_links_max_count + 1
);
let result = user.validate(None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Too many links"));
}
#[test]
fn test_validate_link_length_errors() {
let long_title = "a".repeat(VALIDATION_LIMITS.user_link_title_max_length + 1);
let link = PubkyAppUserLink {
title: long_title.clone(),
url: "https://example.com".to_string(),
};
let sanitized = link.sanitize();
assert_eq!(
sanitized.title.len(),
VALIDATION_LIMITS.user_link_title_max_length + 1
);
let result = sanitized.validate(None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("exceeds maximum length"));
let very_long_path = "a".repeat(VALIDATION_LIMITS.user_link_url_max_length);
let very_long_url = format!("https://example.com/{}", very_long_path);
let link2 = PubkyAppUserLink {
title: "Test".to_string(),
url: very_long_url,
};
let sanitized2 = link2.sanitize();
if sanitized2.url.chars().count() > VALIDATION_LIMITS.user_link_url_max_length {
let result = sanitized2.validate(None);
assert!(
result.is_err(),
"Expected validation error for URL length {}, max is {}",
sanitized2.url.chars().count(),
VALIDATION_LIMITS.user_link_url_max_length
);
assert!(result.unwrap_err().contains("exceeds maximum length"));
} else {
let extremely_long_path = "a".repeat(VALIDATION_LIMITS.user_link_url_max_length + 50);
let extremely_long_url = format!("https://example.com/{}", extremely_long_path);
let link3 = PubkyAppUserLink {
title: "Test".to_string(),
url: extremely_long_url,
};
let sanitized3 = link3.sanitize();
let result = sanitized3.validate(None);
assert!(
result.is_err(),
"Expected validation error for URL length {}, max is {}",
sanitized3.url.chars().count(),
VALIDATION_LIMITS.user_link_url_max_length
);
assert!(result.unwrap_err().contains("exceeds maximum length"));
}
}
#[test]
fn test_unicode_character_counting() {
let emoji_name = "Hi👋🏻Bob"; let user = PubkyAppUser::new(emoji_name.to_string(), None, None, None, None);
assert!(
user.validate(None).is_ok(),
"Should accept emoji in name (counts chars, not bytes)"
);
let unicode_bio = "你好世界 🌍 مرحبا"; let user_with_bio = PubkyAppUser::new(
"Alice".to_string(),
Some(unicode_bio.to_string()),
None,
None,
None,
);
assert!(
user_with_bio.validate(None).is_ok(),
"Should accept multi-script Unicode in bio"
);
let max_emoji_name: String = "🔥".repeat(VALIDATION_LIMITS.user_name_max_length);
assert_eq!(
max_emoji_name.chars().count(),
VALIDATION_LIMITS.user_name_max_length
);
let user_max_emoji = PubkyAppUser::new(max_emoji_name, None, None, None, None);
assert!(
user_max_emoji.validate(None).is_ok(),
"Should accept {} emoji characters as name",
VALIDATION_LIMITS.user_name_max_length
);
}
}