social_helpers.rs (15643B)
1 #[cfg(not(feature = "std"))] 2 use alloc::{format, string::String, vec::Vec}; 3 4 use radroots_events::social::{ 5 RadrootsCalendarParticipant, RadrootsSocialFarmAnchor, RadrootsSocialLocation, 6 RadrootsSocialMediaDimensions, RadrootsSocialMediaThumbnail, 7 }; 8 9 use crate::error::{EventEncodeError, EventParseError}; 10 use crate::field_helpers::{push_tag, push_tag_values, validate_non_empty_field}; 11 12 pub(crate) fn validate_http_url(value: &str, field: &'static str) -> Result<(), EventEncodeError> { 13 if value.starts_with("https://") || value.starts_with("http://") { 14 validate_non_empty_field(value, field) 15 } else { 16 Err(EventEncodeError::InvalidField(field)) 17 } 18 } 19 20 pub(crate) fn validate_date(value: &str, field: &'static str) -> Result<(), EventEncodeError> { 21 if is_date(value) { 22 Ok(()) 23 } else { 24 Err(EventEncodeError::InvalidField(field)) 25 } 26 } 27 28 pub(crate) fn validate_date_tag(value: &str, tag: &'static str) -> Result<(), EventParseError> { 29 if is_date(value) { 30 Ok(()) 31 } else { 32 Err(EventParseError::InvalidTag(tag)) 33 } 34 } 35 36 pub(crate) fn is_date(value: &str) -> bool { 37 let bytes = value.as_bytes(); 38 bytes.len() == 10 39 && bytes[0].is_ascii_digit() 40 && bytes[1].is_ascii_digit() 41 && bytes[2].is_ascii_digit() 42 && bytes[3].is_ascii_digit() 43 && bytes[4] == b'-' 44 && bytes[5].is_ascii_digit() 45 && bytes[6].is_ascii_digit() 46 && bytes[7] == b'-' 47 && bytes[8].is_ascii_digit() 48 && bytes[9].is_ascii_digit() 49 } 50 51 pub(crate) fn validate_end_after_start( 52 start: u64, 53 end: Option<u64>, 54 field: &'static str, 55 ) -> Result<(), EventEncodeError> { 56 if end.is_some_and(|end| end < start) { 57 Err(EventEncodeError::InvalidField(field)) 58 } else { 59 Ok(()) 60 } 61 } 62 63 pub(crate) fn validate_date_end_after_start( 64 start: &str, 65 end: Option<&str>, 66 field: &'static str, 67 ) -> Result<(), EventEncodeError> { 68 if end.is_some_and(|end| end < start) { 69 Err(EventEncodeError::InvalidField(field)) 70 } else { 71 Ok(()) 72 } 73 } 74 75 pub(crate) fn push_location_tags(tags: &mut Vec<Vec<String>>, location: &RadrootsSocialLocation) { 76 if let Some(name) = location 77 .name 78 .as_deref() 79 .filter(|value| !value.trim().is_empty()) 80 { 81 push_tag(tags, "location", name); 82 } 83 if let Some(geohash) = location 84 .geohash 85 .as_deref() 86 .filter(|value| !value.trim().is_empty()) 87 { 88 push_tag(tags, "g", geohash); 89 } 90 } 91 92 pub(crate) fn location_from_tags(tags: &[Vec<String>]) -> Option<RadrootsSocialLocation> { 93 let name = first_tag_value(tags, "location"); 94 let geohash = first_tag_value(tags, "g"); 95 if name.is_none() && geohash.is_none() { 96 None 97 } else { 98 Some(RadrootsSocialLocation { name, geohash }) 99 } 100 } 101 102 pub(crate) fn push_farm_anchor(tags: &mut Vec<Vec<String>>, farm: &RadrootsSocialFarmAnchor) { 103 if farm.farm.pubkey.trim().is_empty() || farm.farm.d_tag.trim().is_empty() { 104 return; 105 } 106 let address = format!("30340:{}:{}", farm.farm.pubkey, farm.farm.d_tag); 107 push_tag(tags, "a", address); 108 } 109 110 pub(crate) fn participants_from_tags( 111 tags: &[Vec<String>], 112 ) -> Option<Vec<RadrootsCalendarParticipant>> { 113 let participants = tags 114 .iter() 115 .filter(|tag| tag.first().map(|value| value.as_str()) == Some("p")) 116 .filter_map(|tag| { 117 let pubkey = tag.get(1)?.clone(); 118 if pubkey.trim().is_empty() { 119 return None; 120 } 121 Some(RadrootsCalendarParticipant { 122 pubkey, 123 relay: tag.get(2).filter(|value| !value.trim().is_empty()).cloned(), 124 role: tag.get(3).filter(|value| !value.trim().is_empty()).cloned(), 125 }) 126 }) 127 .collect::<Vec<_>>(); 128 if participants.is_empty() { 129 None 130 } else { 131 Some(participants) 132 } 133 } 134 135 pub(crate) fn push_participants( 136 tags: &mut Vec<Vec<String>>, 137 participants: Option<&Vec<RadrootsCalendarParticipant>>, 138 ) { 139 let Some(participants) = participants else { 140 return; 141 }; 142 for participant in participants { 143 if participant.pubkey.trim().is_empty() { 144 continue; 145 } 146 let mut tag = vec!["p".to_string(), participant.pubkey.clone()]; 147 if let Some(relay) = participant.relay.as_ref() { 148 tag.push(relay.clone()); 149 } 150 if let Some(role) = participant.role.as_ref() { 151 if participant.relay.is_none() { 152 tag.push(String::new()); 153 } 154 tag.push(role.clone()); 155 } 156 tags.push(tag); 157 } 158 } 159 160 pub(crate) fn first_tag_value(tags: &[Vec<String>], key: &str) -> Option<String> { 161 tags.iter() 162 .find(|tag| tag.first().map(|value| value.as_str()) == Some(key)) 163 .and_then(|tag| tag.get(1)) 164 .filter(|value| !value.trim().is_empty()) 165 .cloned() 166 } 167 168 pub(crate) fn dimensions_tag(dimensions: &RadrootsSocialMediaDimensions) -> String { 169 format!("{}x{}", dimensions.width, dimensions.height) 170 } 171 172 pub(crate) fn parse_dimensions_tag( 173 value: &str, 174 tag: &'static str, 175 ) -> Result<RadrootsSocialMediaDimensions, EventParseError> { 176 let Some((width, height)) = value.split_once('x') else { 177 return Err(EventParseError::InvalidTag(tag)); 178 }; 179 let width = width 180 .parse::<u32>() 181 .map_err(|err| EventParseError::InvalidNumber(tag, err))?; 182 let height = height 183 .parse::<u32>() 184 .map_err(|err| EventParseError::InvalidNumber(tag, err))?; 185 if width == 0 || height == 0 { 186 return Err(EventParseError::InvalidTag(tag)); 187 } 188 Ok(RadrootsSocialMediaDimensions { width, height }) 189 } 190 191 pub(crate) fn push_thumbnail( 192 tags: &mut Vec<Vec<String>>, 193 thumbnail: &RadrootsSocialMediaThumbnail, 194 ) { 195 if thumbnail.url.trim().is_empty() { 196 return; 197 } 198 if let Some(dimensions) = thumbnail.dimensions.as_ref() { 199 push_tag_values( 200 tags, 201 "thumb", 202 [thumbnail.url.clone(), dimensions_tag(dimensions)], 203 ); 204 } else { 205 push_tag(tags, "thumb", thumbnail.url.clone()); 206 } 207 } 208 209 #[cfg(test)] 210 mod tests { 211 use super::*; 212 213 #[test] 214 fn validates_dates_and_ordered_time_ranges() { 215 assert!(is_date("2026-06-20")); 216 assert!(!is_date("2026-6-20")); 217 for invalid in [ 218 "x026-06-20", 219 "2x26-06-20", 220 "20x6-06-20", 221 "202x-06-20", 222 "2026/06-20", 223 "2026-x6-20", 224 "2026-0x-20", 225 "2026-06/20", 226 "2026-06-x0", 227 "2026-06-2x", 228 ] { 229 assert!(!is_date(invalid)); 230 } 231 assert!(validate_http_url("https://example.test/file", "url").is_ok()); 232 assert!(validate_http_url("http://example.test/file", "url").is_ok()); 233 assert!(matches!( 234 validate_http_url("ftp://example.test/file", "url"), 235 Err(EventEncodeError::InvalidField("url")) 236 )); 237 assert!(validate_date("2026-06-20", "date").is_ok()); 238 assert!(matches!( 239 validate_date("bad", "date"), 240 Err(EventEncodeError::InvalidField("date")) 241 )); 242 assert!(validate_date_tag("2026-06-20", "start").is_ok()); 243 assert!(validate_end_after_start(10, Some(10), "end").is_ok()); 244 assert!(validate_end_after_start(10, None, "end").is_ok()); 245 assert!(matches!( 246 validate_end_after_start(10, Some(9), "end"), 247 Err(EventEncodeError::InvalidField("end")) 248 )); 249 assert!(validate_date_end_after_start("2026-06-20", None, "end").is_ok()); 250 assert!(validate_date_end_after_start("2026-06-20", Some("2026-06-20"), "end").is_ok()); 251 assert!(matches!( 252 validate_date_end_after_start("2026-06-20", Some("2026-06-19"), "end"), 253 Err(EventEncodeError::InvalidField("end")) 254 )); 255 assert!(matches!( 256 validate_date_tag("bad", "start"), 257 Err(EventParseError::InvalidTag("start")) 258 )); 259 } 260 261 #[test] 262 fn encodes_and_decodes_location_participant_and_dimensions_tags() { 263 let mut tags = Vec::new(); 264 push_location_tags( 265 &mut tags, 266 &RadrootsSocialLocation { 267 name: Some("Pack shed".to_string()), 268 geohash: Some("c23nb62w20st".to_string()), 269 }, 270 ); 271 push_participants( 272 &mut tags, 273 Some(&vec![RadrootsCalendarParticipant { 274 pubkey: "crew_pubkey".to_string(), 275 relay: None, 276 role: Some("participant".to_string()), 277 }]), 278 ); 279 280 let location = location_from_tags(&tags).expect("location"); 281 assert_eq!(location.name.as_deref(), Some("Pack shed")); 282 assert_eq!(location.geohash.as_deref(), Some("c23nb62w20st")); 283 let named_location = 284 location_from_tags(&[vec!["location".to_string(), "Farm gate".to_string()]]) 285 .expect("named location"); 286 assert_eq!(named_location.name.as_deref(), Some("Farm gate")); 287 assert_eq!(named_location.geohash, None); 288 let geohash_location = 289 location_from_tags(&[vec!["g".to_string(), "c23nb62w20st".to_string()]]) 290 .expect("geohash location"); 291 assert_eq!(geohash_location.name, None); 292 assert_eq!(geohash_location.geohash.as_deref(), Some("c23nb62w20st")); 293 let participants = participants_from_tags(&tags).expect("participants"); 294 assert_eq!(participants[0].pubkey, "crew_pubkey"); 295 assert_eq!(participants[0].role.as_deref(), Some("participant")); 296 297 let mut empty_tags = Vec::new(); 298 push_location_tags( 299 &mut empty_tags, 300 &RadrootsSocialLocation { 301 name: Some(" ".to_string()), 302 geohash: Some(" ".to_string()), 303 }, 304 ); 305 assert!(empty_tags.is_empty()); 306 assert_eq!(location_from_tags(&empty_tags), None); 307 assert_eq!( 308 first_tag_value(&[vec!["location".to_string()]], "location"), 309 None 310 ); 311 assert_eq!( 312 first_tag_value(&[vec!["location".to_string(), " ".to_string()]], "location"), 313 None 314 ); 315 316 let mut anchor_tags = Vec::new(); 317 push_farm_anchor( 318 &mut anchor_tags, 319 &RadrootsSocialFarmAnchor { 320 farm: radroots_events::farm::RadrootsFarmRef { 321 pubkey: " ".to_string(), 322 d_tag: "farm-d-tag".to_string(), 323 }, 324 relays: None, 325 }, 326 ); 327 push_farm_anchor( 328 &mut anchor_tags, 329 &RadrootsSocialFarmAnchor { 330 farm: radroots_events::farm::RadrootsFarmRef { 331 pubkey: "farm_pubkey".to_string(), 332 d_tag: " ".to_string(), 333 }, 334 relays: None, 335 }, 336 ); 337 push_farm_anchor( 338 &mut anchor_tags, 339 &RadrootsSocialFarmAnchor { 340 farm: radroots_events::farm::RadrootsFarmRef { 341 pubkey: "farm_pubkey".to_string(), 342 d_tag: "farm-d-tag".to_string(), 343 }, 344 relays: None, 345 }, 346 ); 347 assert_eq!( 348 anchor_tags, 349 vec![vec![ 350 "a".to_string(), 351 "30340:farm_pubkey:farm-d-tag".to_string() 352 ]] 353 ); 354 355 assert_eq!(participants_from_tags(&[]), None); 356 let participants = participants_from_tags(&[ 357 vec!["p".to_string()], 358 vec!["p".to_string(), " ".to_string()], 359 vec![ 360 "p".to_string(), 361 "crew_pubkey".to_string(), 362 "wss://relay.example.test".to_string(), 363 "host".to_string(), 364 ], 365 ]) 366 .expect("participants"); 367 assert_eq!(participants.len(), 1); 368 assert_eq!( 369 participants[0].relay.as_deref(), 370 Some("wss://relay.example.test") 371 ); 372 assert_eq!(participants[0].role.as_deref(), Some("host")); 373 374 let mut participant_tags = Vec::new(); 375 push_participants(&mut participant_tags, None); 376 push_participants( 377 &mut participant_tags, 378 Some(&vec![ 379 RadrootsCalendarParticipant { 380 pubkey: " ".to_string(), 381 relay: None, 382 role: None, 383 }, 384 RadrootsCalendarParticipant { 385 pubkey: "crew_pubkey".to_string(), 386 relay: Some("wss://relay.example.test".to_string()), 387 role: Some("host".to_string()), 388 }, 389 RadrootsCalendarParticipant { 390 pubkey: "relay_only_pubkey".to_string(), 391 relay: Some("wss://relay.example.test".to_string()), 392 role: None, 393 }, 394 ]), 395 ); 396 assert_eq!( 397 participant_tags, 398 vec![ 399 vec![ 400 "p".to_string(), 401 "crew_pubkey".to_string(), 402 "wss://relay.example.test".to_string(), 403 "host".to_string() 404 ], 405 vec![ 406 "p".to_string(), 407 "relay_only_pubkey".to_string(), 408 "wss://relay.example.test".to_string() 409 ] 410 ] 411 ); 412 413 let dimensions = parse_dimensions_tag("1200x800", "dim").unwrap(); 414 assert_eq!(dimensions_tag(&dimensions), "1200x800"); 415 assert!(matches!( 416 parse_dimensions_tag("0x800", "dim"), 417 Err(EventParseError::InvalidTag("dim")) 418 )); 419 assert!(matches!( 420 parse_dimensions_tag("1200x0", "dim"), 421 Err(EventParseError::InvalidTag("dim")) 422 )); 423 assert!(matches!( 424 parse_dimensions_tag("badx800", "dim"), 425 Err(EventParseError::InvalidNumber("dim", _)) 426 )); 427 assert!(matches!( 428 parse_dimensions_tag("1200xbad", "dim"), 429 Err(EventParseError::InvalidNumber("dim", _)) 430 )); 431 432 let mut thumbnail_tags = Vec::new(); 433 push_thumbnail( 434 &mut thumbnail_tags, 435 &RadrootsSocialMediaThumbnail { 436 url: " ".to_string(), 437 dimensions: None, 438 }, 439 ); 440 push_thumbnail( 441 &mut thumbnail_tags, 442 &RadrootsSocialMediaThumbnail { 443 url: "https://media.example.test/thumb.jpg".to_string(), 444 dimensions: None, 445 }, 446 ); 447 push_thumbnail( 448 &mut thumbnail_tags, 449 &RadrootsSocialMediaThumbnail { 450 url: "https://media.example.test/thumb-large.jpg".to_string(), 451 dimensions: Some(RadrootsSocialMediaDimensions { 452 width: 320, 453 height: 240, 454 }), 455 }, 456 ); 457 assert_eq!( 458 thumbnail_tags, 459 vec![ 460 vec![ 461 "thumb".to_string(), 462 "https://media.example.test/thumb.jpg".to_string() 463 ], 464 vec![ 465 "thumb".to_string(), 466 "https://media.example.test/thumb-large.jpg".to_string(), 467 "320x240".to_string() 468 ], 469 ] 470 ); 471 } 472 }